diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..8b15903 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,98 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/mathiasbq/supervisor/internal/registry" +) + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Server is an HTTP handler implementing the MCP JSON-RPC protocol. +type Server struct { + reg *registry.Registry +} + +func NewServer(reg *registry.Registry) *Server { + return &Server{reg: reg} +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, nil, -32700, "parse error") + return + } + + var result any + var rpcErr *rpcError + + switch req.Method { + case "initialize": + result = map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": "supervisor", "version": "0.1.0"}, + } + + case "tools/list": + result = map[string]any{"tools": s.reg.Tools()} + + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + rpcErr = &rpcError{Code: -32602, Message: "invalid params"} + break + } + out, err := s.reg.Dispatch(context.Background(), p.Name, p.Arguments) + if err != nil { + rpcErr = &rpcError{Code: -32000, Message: err.Error()} + break + } + result = map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(out)}}, + } + + default: + rpcErr = &rpcError{Code: -32601, Message: "method not found: " + req.Method} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response{ + JSONRPC: "2.0", + ID: req.ID, + Result: result, + Error: rpcErr, + }) +} + +func writeError(w http.ResponseWriter, id any, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response{ + JSONRPC: "2.0", + ID: id, + Error: &rpcError{Code: code, Message: msg}, + }) +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 0000000..2a8c65c --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,78 @@ +package mcp_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mathiasbq/supervisor/internal/mcp" + "github.com/mathiasbq/supervisor/internal/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func jsonBody(t *testing.T, v any) *bytes.Buffer { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return bytes.NewBuffer(b) +} + +func TestMCPInitialize(t *testing.T) { + reg := registry.New() + srv := mcp.NewServer(reg) + + req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{}, + })) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + srv.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + result := resp["result"].(map[string]any) + assert.Equal(t, "2024-11-05", result["protocolVersion"]) +} + +func TestMCPToolsList(t *testing.T) { + reg := registry.New() + srv := mcp.NewServer(reg) + + req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": map[string]any{}, + })) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + result := resp["result"].(map[string]any) + assert.NotNil(t, result["tools"]) +} + +func TestMCPUnknownMethod(t *testing.T) { + reg := registry.New() + srv := mcp.NewServer(reg) + + req := httptest.NewRequest(http.MethodPost, "/mcp", jsonBody(t, map[string]any{ + "jsonrpc": "2.0", "id": 3, "method": "unknown/method", "params": map[string]any{}, + })) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + assert.NotNil(t, resp["error"]) +}