Agents get GET /openapi.json and POST /mcp. Tool names match search/get/stats/audit paths so PicoClaw does not need shebangs.
186 lines
4.5 KiB
Go
186 lines
4.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type rpcReq struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type rpcErr struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (s *Server) handleOpenAPI(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, OpenAPI())
|
|
}
|
|
|
|
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST JSON-RPC"})
|
|
return
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "read body"})
|
|
return
|
|
}
|
|
var req rpcReq
|
|
if err := json.Unmarshal(raw, &req); err != nil {
|
|
writeJSON(w, http.StatusOK, rpcResult(nil, nil, &rpcErr{-32700, "parse error"}))
|
|
return
|
|
}
|
|
result, rpcErrv, callErr := s.mcpDispatch(r, req)
|
|
if callErr != nil {
|
|
writeJSON(w, http.StatusOK, rpcResult(req.ID, nil, &rpcErr{-32603, callErr.Error()}))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rpcResult(req.ID, result, rpcErrv))
|
|
}
|
|
|
|
func (s *Server) mcpDispatch(r *http.Request, req rpcReq) (any, *rpcErr, error) {
|
|
switch req.Method {
|
|
case "initialize":
|
|
return map[string]any{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": map[string]any{"tools": map[string]any{}},
|
|
"serverInfo": map[string]any{"name": "2dph", "version": "1"},
|
|
}, nil, nil
|
|
case "notifications/initialized", "notifications/cancelled":
|
|
return map[string]any{}, nil, nil
|
|
case "tools/list":
|
|
return map[string]any{"tools": MCPTools()}, nil, nil
|
|
case "tools/call":
|
|
out, err := s.mcpCall(r, req.Params)
|
|
return out, nil, err
|
|
case "ping":
|
|
return map[string]any{}, nil, nil
|
|
default:
|
|
return nil, &rpcErr{-32601, "method not found"}, nil
|
|
}
|
|
}
|
|
|
|
func (s *Server) mcpCall(r *http.Request, params json.RawMessage) (any, error) {
|
|
var p struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(params, &p); err != nil {
|
|
return nil, fmt.Errorf("params")
|
|
}
|
|
if p.Arguments == nil {
|
|
p.Arguments = map[string]any{}
|
|
}
|
|
var (
|
|
body []byte
|
|
err error
|
|
)
|
|
switch p.Name {
|
|
case "search":
|
|
q := strings.TrimSpace(fmt.Sprint(p.Arguments["q"]))
|
|
if q == "" || q == "<nil>" {
|
|
return mcpText(`{"error":"q required"}`, true), nil
|
|
}
|
|
limit := 10
|
|
if raw, ok := p.Arguments["n"]; ok {
|
|
switch n := raw.(type) {
|
|
case float64:
|
|
limit = int(n)
|
|
case string:
|
|
if v, e := strconv.Atoi(n); e == nil {
|
|
limit = v
|
|
}
|
|
}
|
|
}
|
|
if limit < 1 || limit > 100 {
|
|
return mcpText(`{"error":"n must be int 1..100"}`, true), nil
|
|
}
|
|
if !s.tryAcquire(r) {
|
|
return nil, fmt.Errorf("cancelled")
|
|
}
|
|
defer s.release()
|
|
body, err = s.api.Search(r.Context(), q, limit)
|
|
case "get":
|
|
id := strings.TrimSpace(fmt.Sprint(p.Arguments["id"]))
|
|
if id == "" || id == "<nil>" {
|
|
return mcpText(`{"error":"id required"}`, true), nil
|
|
}
|
|
full := false
|
|
switch v := p.Arguments["body"].(type) {
|
|
case bool:
|
|
full = v
|
|
case string:
|
|
full = v == "1" || v == "true"
|
|
}
|
|
if !s.tryAcquire(r) {
|
|
return nil, fmt.Errorf("cancelled")
|
|
}
|
|
defer s.release()
|
|
body, err = s.api.Get(r.Context(), id, full)
|
|
case "stats":
|
|
if !s.tryAcquire(r) {
|
|
return nil, fmt.Errorf("cancelled")
|
|
}
|
|
defer s.release()
|
|
body, err = s.api.Stats(r.Context())
|
|
case "audit":
|
|
if !s.tryAcquire(r) {
|
|
return nil, fmt.Errorf("cancelled")
|
|
}
|
|
defer s.release()
|
|
body, err = s.api.Audit(r.Context())
|
|
case "ingest":
|
|
if !s.tryAcquire(r) {
|
|
return nil, fmt.Errorf("cancelled")
|
|
}
|
|
defer s.release()
|
|
body, err = s.api.Ingest(r.Context())
|
|
default:
|
|
return nil, fmt.Errorf("unknown tool %s", p.Name)
|
|
}
|
|
if err != nil {
|
|
return mcpText(err.Error(), true), nil
|
|
}
|
|
return mcpText(string(body), false), nil
|
|
}
|
|
|
|
func mcpText(text string, isError bool) map[string]any {
|
|
return map[string]any{
|
|
"content": []any{map[string]any{"type": "text", "text": text}},
|
|
"isError": isError,
|
|
}
|
|
}
|
|
|
|
type rpcResp struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Result any `json:"result,omitempty"`
|
|
Error *rpcErr `json:"error,omitempty"`
|
|
}
|
|
|
|
func rpcResult(id json.RawMessage, result any, err *rpcErr) rpcResp {
|
|
out := rpcResp{JSONRPC: "2.0", ID: id}
|
|
if len(id) == 0 {
|
|
out.ID = []byte("null")
|
|
}
|
|
if err != nil {
|
|
out.Error = err
|
|
return out
|
|
}
|
|
if result == nil {
|
|
result = map[string]any{}
|
|
}
|
|
out.Result = result
|
|
return out
|
|
}
|