diff --git a/internal/app/mcp_integration_test.go b/internal/app/mcp_integration_test.go index 5942ed1..5796a4b 100644 --- a/internal/app/mcp_integration_test.go +++ b/internal/app/mcp_integration_test.go @@ -31,9 +31,13 @@ package app_test import ( + "bytes" "context" "encoding/json" "fmt" + "io" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -41,6 +45,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" tcpg "github.com/testcontainers/testcontainers-go/modules/postgres" @@ -50,6 +55,7 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/mcp" "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" ) @@ -89,20 +95,24 @@ type mcpIntegrationSuite struct { userID uuid.UUID // non-admin user for visibility test // Primary project (owned by adminID, internal visibility) - projectID uuid.UUID - projectSlug string + projectID uuid.UUID + projectSlug string + + // HTTP server for K2 transport tests + srvURL string // base URL of httptest.Server // Services - userSvc user.Service - projectSvc project.ProjectService - issueSvc project.IssueService - auditRec audit.Recorder + userSvc user.Service + projectSvc project.ProjectService + issueSvc project.IssueService + auditRec audit.Recorder + mcpTokenSvc mcp.TokenService // MCP tokens - adminTokenID uuid.UUID - adminPlaintext string // full-scope admin token - userTokenID uuid.UUID - userPlaintext string // restricted-scope token + adminTokenID uuid.UUID + adminPlaintext string // full-scope admin token + userTokenID uuid.UUID + userPlaintext string // restricted-scope token } func newMCPIntegrationSuite(t *testing.T) *mcpIntegrationSuite { @@ -188,27 +198,51 @@ func newMCPIntegrationSuite(t *testing.T) *mcpIntegrationSuite { // Suppress unused warnings for services used only by future batches _ = log _ = requirementSvc - _ = chi.NewRouter() + + // --- HTTP server for K2 transport tests --- + callerResolver := mcp.NewCallerResolver(userSvc) + mcpServer := mcp.NewMCPServer(mcp.ServerDeps{ + Caller: callerResolver, + Projects: projectSvc, + Reqs: requirementSvc, + Issues: issueSvc, + // Workspaces/Templates/Messages/Conversations left nil — K2/K3 tests don't call those tools. + }) + + rateLimiter := mcp.NewRateLimiter(mcp.RateLimitConfig{ + PerTokenPerMinute: 100, + GlobalPerMinute: 1000, + }, nil) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + user.NewHandler(userSvc).Mount(r) + mcp.MountTransports(r, mcpServer, mcpTokenSvc, rateLimiter) + + httpSrv := httptest.NewServer(r) return &mcpIntegrationSuite{ cleanup: func() { + httpSrv.Close() pool.Close() _ = container.Terminate(ctx) }, - ctx: ctx, - pool: pool, - adminID: adminUser.ID, - userID: normalUser.ID, - projectID: p.ID, - projectSlug: p.Slug, - userSvc: userSvc, - projectSvc: projectSvc, - issueSvc: issueSvc, - auditRec: auditRec, - adminTokenID: adminRes.ID, - adminPlaintext: adminRes.Plaintext, - userTokenID: userRes.ID, - userPlaintext: userRes.Plaintext, + ctx: ctx, + pool: pool, + adminID: adminUser.ID, + userID: normalUser.ID, + projectID: p.ID, + projectSlug: p.Slug, + srvURL: httpSrv.URL, + userSvc: userSvc, + projectSvc: projectSvc, + issueSvc: issueSvc, + auditRec: auditRec, + mcpTokenSvc: mcpTokenSvc, + adminTokenID: adminRes.ID, + adminPlaintext: adminRes.Plaintext, + userTokenID: userRes.ID, + userPlaintext: userRes.Plaintext, } } @@ -348,23 +382,192 @@ func (s *mcpIntegrationSuite) queryAuditMetadata(t *testing.T, action string) ma } // --------------------------------------------------------------------------- -// K2 stubs +// K2: Transport + Auth integration (4 scenarios) // --------------------------------------------------------------------------- +// bearerRoundTripper injects an Authorization: Bearer header into every request. +type bearerRoundTripper struct { + inner http.RoundTripper + token string +} + +func (rt *bearerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("Authorization", "Bearer "+rt.token) + return rt.inner.RoundTrip(req) +} + +// newSDKClient creates an MCP SDK client connected via StreamableHTTP transport +// with Bearer token authentication. +func newSDKClient(t *testing.T, baseURL, token string) (*mcpsdk.Client, *mcpsdk.ClientSession) { + t.Helper() + ctx := context.Background() + httpClient := &http.Client{Transport: &bearerRoundTripper{inner: http.DefaultTransport, token: token}} + transport := &mcpsdk.StreamableClientTransport{ + Endpoint: baseURL + "/mcp", + HTTPClient: httpClient, + DisableStandaloneSSE: true, // simpler for tests; don't need server-initiated messages + } + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "k2-test", Version: "0.0.1"}, nil) + session, err := client.Connect(ctx, transport, nil) + require.NoError(t, err, "StreamableHTTP client.Connect failed") + return client, session +} + +// newSDKClientSSE creates an MCP SDK client connected via legacy SSE transport +// with Bearer token authentication. +func newSDKClientSSE(t *testing.T, baseURL, token string) (*mcpsdk.Client, *mcpsdk.ClientSession) { + t.Helper() + ctx := context.Background() + httpClient := &http.Client{Transport: &bearerRoundTripper{inner: http.DefaultTransport, token: token}} + transport := &mcpsdk.SSEClientTransport{ + Endpoint: baseURL + "/mcp/sse", + HTTPClient: httpClient, + } + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "k2-sse-test", Version: "0.0.1"}, nil) + session, err := client.Connect(ctx, transport, nil) + require.NoError(t, err, "SSE client.Connect failed") + return client, session +} + +// testStreamableHTTPFullFlow connects an MCP SDK client via StreamableClientTransport +// to the /mcp endpoint, lists tools (asserts >= 17), and calls list_issues. func (s *mcpIntegrationSuite) testStreamableHTTPFullFlow(t *testing.T) { - t.Skip("filled in K2") + ctx := context.Background() + + client, session := newSDKClient(t, s.srvURL, s.adminPlaintext) + defer session.Close() + _ = client + + // List tools — assert >= 17 tools registered + toolsResult, err := session.ListTools(ctx, &mcpsdk.ListToolsParams{}) + require.NoError(t, err, "ListTools failed") + assert.Len(t, toolsResult.Tools, 17, "expected at least 17 tools registered") + + // Call list_issues — verify response structure + callResult, err := session.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "list_issues", + Arguments: map[string]any{"project_slug": s.projectSlug}, + }) + require.NoError(t, err, "CallTool list_issues failed") + assert.False(t, callResult.IsError, "list_issues should succeed for admin") + assert.NotEmpty(t, callResult.Content, "list_issues should return content") } +// testLegacySSEFullFlow connects an MCP SDK client via SSEClientTransport +// to the /mcp/sse endpoint and runs the same basic flow. func (s *mcpIntegrationSuite) testLegacySSEFullFlow(t *testing.T) { - t.Skip("filled in K2") + ctx := context.Background() + + client, session := newSDKClientSSE(t, s.srvURL, s.adminPlaintext) + defer session.Close() + _ = client + + // List tools — assert some tools registered + toolsResult, err := session.ListTools(ctx, &mcpsdk.ListToolsParams{}) + require.NoError(t, err, "ListTools via SSE failed") + assert.NotEmpty(t, toolsResult.Tools, "expected tools via SSE") + + // Call list_projects — simpler tool that only needs Caller.Resolve + callResult, err := session.CallTool(ctx, &mcpsdk.CallToolParams{ + Name: "list_projects", + Arguments: map[string]any{}, + }) + require.NoError(t, err, "CallTool list_projects via SSE failed") + assert.False(t, callResult.IsError, "list_projects should succeed") } +// testBearerVsQueryToken verifies both Authorization: Bearer header and ?token= +// query parameter successfully authenticate. func (s *mcpIntegrationSuite) testBearerVsQueryToken(t *testing.T) { - t.Skip("filled in K2") + ctx := context.Background() + + // --- Bearer auth via SDK client --- + _, session := newSDKClient(t, s.srvURL, s.adminPlaintext) + defer session.Close() + + toolsResult, err := session.ListTools(ctx, &mcpsdk.ListToolsParams{}) + require.NoError(t, err, "ListTools with Bearer auth failed") + assert.NotEmpty(t, toolsResult.Tools, "expected tools with Bearer auth") + + // --- Query param auth via raw HTTP POST --- + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-03-26", + "clientInfo": map[string]string{"name": "k2-query-test", "version": "0.0.1"}, + "capabilities": map[string]any{}, + }, + }) + req, err := http.NewRequest(http.MethodPost, s.srvURL+"/mcp?token="+s.adminPlaintext, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode, "query param auth should return 200") + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var initResp struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(respBody, &initResp)) + assert.NotEmpty(t, initResp.Result.ProtocolVersion, "initialize via ?token= should return protocol version") } +// testMCPSDKVersionNegotiation sends an initialize request with an unknown +// protocolVersion and asserts the response is not a 5xx (graceful negotiation). func (s *mcpIntegrationSuite) testMCPSDKVersionNegotiation(t *testing.T) { - t.Skip("filled in K2") + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 42, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "9999-99-99", + "clientInfo": map[string]string{"name": "k2-version-test", "version": "0.0.1"}, + "capabilities": map[string]any{}, + }, + }) + req, err := http.NewRequest(http.MethodPost, s.srvURL+"/mcp?token="+s.adminPlaintext, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // Server should not return 5xx; it responds with its negotiated version + assert.Less(t, resp.StatusCode, 500, "unknown protocol version should not cause 5xx") + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var initResp struct { + Result struct { + ProtocolVersion string `json:"protocolVersion"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(respBody, &initResp)) + + // Server responds with its own supported version (or an error, but not 5xx) + if initResp.Error != nil { + t.Logf("server returned JSON-RPC error: code=%d msg=%s", initResp.Error.Code, initResp.Error.Message) + } else { + assert.NotEmpty(t, initResp.Result.ProtocolVersion, "server should respond with a protocol version") + } } // ---------------------------------------------------------------------------