//go:build integration // mcp_integration_test.go — through-stack integration matrix for the MCP module // (spec §8.2, 14 scenarios). // // SCOPE: This file lays out the matrix of 14 scenarios as a scaffold + sub-tests. // The suite wiring, helpers, and actual test implementations will be added in K2-K5. // // Coverage map (scenario ↔ implementation batch): // // Scenario Batch Description // ----------------------------- ------ ------------------------------------------ // StreamableHTTP_FullFlow K2 StreamableHTTP transport end-to-end // LegacySSE_FullFlow K2 SSE transport end-to-end // BearerVsQueryToken K2 Auth via header vs query param // MCPSDKVersionNegotiation K2 Protocol version negotiation // ScopeViolation_Tool K3 Token scope restricts tool access // ScopeViolation_Project K3 Token scope restricts project access // VisibilityRespect K3 Project visibility enforced // WriteToolAudit K3 Write tools produce audit records // RateLimit_PerToken K4 Per-token rate limiting // ACPSession_TokenLifecycle K4 ACP session tokens created/revoked // StartupReaper K4 Expired tokens cleaned on startup // PromptsList_FromTemplates K5 Prompts listed from templates // ResourceRead_NotFound K5 Resource read returns not-found // ResourceList_ScopeFilter K5 Resources filtered by scope // // Build/run: // go test -count=1 -tags=integration ./internal/app/... -run TestMCPIntegration package app_test import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" "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" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/chat" "github.com/yan1h/agent-coding-workflow/internal/infra/db" "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" ) func TestMCPIntegration_Matrix(t *testing.T) { if testing.Short() { t.Skip("integration; needs docker") } suite := newMCPIntegrationSuite(t) t.Cleanup(suite.cleanup) t.Run("StreamableHTTP_FullFlow", suite.testStreamableHTTPFullFlow) t.Run("LegacySSE_FullFlow", suite.testLegacySSEFullFlow) t.Run("ScopeViolation_Tool", suite.testScopeViolationTool) t.Run("ScopeViolation_Project", suite.testScopeViolationProject) t.Run("VisibilityRespect", suite.testVisibilityRespect) t.Run("WriteToolAudit", suite.testWriteToolAudit) t.Run("RateLimit_PerToken", suite.testRateLimitPerToken) t.Run("ACPSession_TokenLifecycle", suite.testACPSessionTokenLifecycle) t.Run("StartupReaper", suite.testStartupReaper) t.Run("PromptsList_FromTemplates", suite.testPromptsListFromTemplates) t.Run("ResourceRead_NotFound", suite.testResourceReadNotFound) t.Run("ResourceList_ScopeFilter", suite.testResourceListScopeFilter) t.Run("BearerVsQueryToken", suite.testBearerVsQueryToken) t.Run("MCPSDKVersionNegotiation", suite.testMCPSDKVersionNegotiation) } // --------------------------------------------------------------------------- // Suite // --------------------------------------------------------------------------- type mcpIntegrationSuite struct { cleanup func() ctx context.Context pool *pgxpool.Pool adminID uuid.UUID userID uuid.UUID // non-admin user for visibility test // Primary project (owned by adminID, internal visibility) 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 mcpTokenSvc mcp.TokenService // MCP tokens adminTokenID uuid.UUID adminPlaintext string // full-scope admin token userTokenID uuid.UUID userPlaintext string // restricted-scope token } func newMCPIntegrationSuite(t *testing.T) *mcpIntegrationSuite { t.Helper() ctx := context.Background() // --- Postgres container --- container, err := tcpg.Run(ctx, "postgres:16-alpine", tcpg.WithDatabase("acw"), tcpg.WithUsername("acw"), tcpg.WithPassword("acw"), ) require.NoError(t, err) dsn, err := container.ConnectionString(ctx, "sslmode=disable") require.NoError(t, err) require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations")) pool, err := db.NewPool(ctx, dsn) require.NoError(t, err) log := logger.New(logger.Options{Format: logger.FormatJSON, Level: logger.LevelInfo}) // --- Services --- auditRec := audit.NewPostgresRecorder(pool) auditWrapped := mcp.AuditWrap(auditRec) userRepo := user.NewPostgresRepository(pool) userSvc := user.NewService(userRepo, auditRec) projectRepo := project.NewPostgresRepository(pool) projectSvc := project.NewProjectService(projectRepo, auditWrapped) requirementSvc := project.NewRequirementService(projectRepo, auditWrapped) issueSvc := project.NewIssueService(projectRepo, auditWrapped) // --- Bootstrap users --- adminUser, err := userSvc.Bootstrap(ctx, "admin-mcp@local", "password123") require.NoError(t, err) require.NotNil(t, adminUser) // Non-admin user for visibility test normalUser, err := userSvc.Bootstrap(ctx, "user-mcp@local", "password123") require.NoError(t, err) require.NotNil(t, normalUser) // --- Create primary project --- adminCaller := project.Caller{UserID: adminUser.ID, IsAdmin: true} p, err := projectSvc.Create(ctx, adminCaller, project.CreateProjectInput{ Slug: "k3-primary", Name: "K3 Primary Project", Description: "Integration test project for K3", Visibility: project.VisibilityInternal, }) require.NoError(t, err) // --- MCP tokens --- mcpRepo := mcp.NewPostgresRepository(pool) mcpTokenSvc := mcp.NewTokenService(mcpRepo, auditRec, nil) // adminToken: full scope (Tools=nil, ProjectIDs=nil => all allowed) adminRes, err := mcpTokenSvc.IssueAdmin(ctx, mcp.IssueAdminInput{ UserID: adminUser.ID, Name: "k3-admin-full", Scope: mcp.Scope{}, ExpiresAt: time.Now().Add(24 * time.Hour), CreatedBy: adminUser.ID, }) require.NoError(t, err) // userToken: restricted scope — limited tools + limited projects userRes, err := mcpTokenSvc.IssueAdmin(ctx, mcp.IssueAdminInput{ UserID: normalUser.ID, Name: "k3-user-restricted", Scope: mcp.Scope{ Tools: []string{"list_issues", "get_issue", "list_projects", "list_workspaces"}, ProjectIDs: []uuid.UUID{p.ID}, }, ExpiresAt: time.Now().Add(24 * time.Hour), CreatedBy: adminUser.ID, }) require.NoError(t, err) // Suppress unused warnings for services used only by future batches _ = log _ = requirementSvc // --- 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, 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, } } // --------------------------------------------------------------------------- // Helpers shared across batches // --------------------------------------------------------------------------- // callToolViaMemory sets up an in-memory MCP server+client pair and calls the // named tool with the given arguments. The AuthSession is injected via // receiving middleware so scope/visibility checks work as in production. func (s *mcpIntegrationSuite) callToolViaMemory( t *testing.T, sess *mcp.AuthSession, toolName string, args map[string]any, ) (*mcpsdk.CallToolResult, error) { t.Helper() callerResolver := mcp.NewCallerResolver(s.userSvc) deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, Reqs: project.NewRequirementService(project.NewPostgresRepository(s.pool), mcp.AuditWrap(s.auditRec)), Issues: s.issueSvc, // Workspaces/Templates/Messages/Conversations left nil — K3 tests don't call those tools. } srv := mcp.NewMCPServer(deps) srv.AddReceivingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler { return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) { if sess != nil { ctx = context.WithValue(ctx, mcp.AuthContextKey, sess) } return next(ctx, method, req) } }) client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "k3-test-client", Version: "0.0.1"}, nil) t1, t2 := mcpsdk.NewInMemoryTransports() if _, err := srv.Connect(s.ctx, t1, nil); err != nil { return nil, err } session, err := client.Connect(s.ctx, t2, nil) if err != nil { return nil, err } defer session.Close() return session.CallTool(s.ctx, &mcpsdk.CallToolParams{ Name: toolName, Arguments: args, }) } // adminSession returns an AuthSession for the full-scope admin token. func (s *mcpIntegrationSuite) adminSession() *mcp.AuthSession { return &mcp.AuthSession{ TokenID: s.adminTokenID.String(), UserID: s.adminID.String(), Scope: mcp.Scope{}, // full scope } } // userSession returns an AuthSession for the restricted-scope user token. func (s *mcpIntegrationSuite) userSession() *mcp.AuthSession { return &mcp.AuthSession{ TokenID: s.userTokenID.String(), UserID: s.userID.String(), Scope: mcp.Scope{ Tools: []string{"list_issues", "get_issue", "list_projects", "list_workspaces"}, ProjectIDs: []uuid.UUID{s.projectID}, }, } } // seedAdditionalProject creates a second project via the service layer. // Returns the new project ID. func (s *mcpIntegrationSuite) seedAdditionalProject(t *testing.T, ownerID uuid.UUID, slug string) uuid.UUID { t.Helper() caller := project.Caller{UserID: ownerID, IsAdmin: ownerID == s.adminID} p, err := s.projectSvc.Create(s.ctx, caller, project.CreateProjectInput{ Slug: slug, Name: slug, Visibility: project.VisibilityInternal, }) require.NoError(t, err) return p.ID } // seedPrivateProjectFor creates a private project owned by the given user. // Returns (projectID, slug). func (s *mcpIntegrationSuite) seedPrivateProjectFor(t *testing.T, ownerID uuid.UUID) (uuid.UUID, string) { t.Helper() slug := fmt.Sprintf("private-%s", uuid.New().String()[:8]) caller := project.Caller{UserID: ownerID, IsAdmin: false} p, err := s.projectSvc.Create(s.ctx, caller, project.CreateProjectInput{ Slug: slug, Name: "Private " + slug, Visibility: project.VisibilityPrivate, }) require.NoError(t, err) return p.ID, p.Slug } // extendUserTokenProjectScope updates the user token's scope JSON via direct SQL // to include an additional project ID. func (s *mcpIntegrationSuite) extendUserTokenProjectScope(t *testing.T, additionalProjectID uuid.UUID) { t.Helper() _, err := s.pool.Exec(s.ctx, ` UPDATE mcp_tokens SET scope = jsonb_set(scope, '{project_ids}', COALESCE( (SELECT jsonb_agg(elem) FROM jsonb_array_elements_text(scope->'project_ids') elem), '[]'::jsonb ) || to_jsonb($1::uuid) ) WHERE id = $2 `, additionalProjectID, s.userTokenID) require.NoError(t, err) } // queryAuditMetadata queries audit_logs for the given action and target type, // returning the metadata JSON of the most recent matching row. func (s *mcpIntegrationSuite) queryAuditMetadata(t *testing.T, action string) map[string]any { t.Helper() var rawMeta []byte err := s.pool.QueryRow(s.ctx, ` SELECT metadata FROM audit_logs WHERE action = $1 ORDER BY created_at DESC LIMIT 1 `, action).Scan(&rawMeta) require.NoError(t, err, "expected audit log with action=%q", action) var meta map[string]any require.NoError(t, json.Unmarshal(rawMeta, &meta)) return meta } // --------------------------------------------------------------------------- // 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) { 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) { 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) { 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) { 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") } } // --------------------------------------------------------------------------- // K3: Scope / Visibility / Audit (4 scenarios) // --------------------------------------------------------------------------- // testScopeViolationTool verifies that a token whose scope.Tools does not include // create_issue gets a scope violation error when trying to call create_issue. func (s *mcpIntegrationSuite) testScopeViolationTool(t *testing.T) { sess := s.userSession() // restricted: only list_issues, get_issue, list_projects, list_workspaces result, err := s.callToolViaMemory(t, sess, "create_issue", map[string]any{ "project_slug": s.projectSlug, "title": "Should be blocked", }) require.NoError(t, err, "callToolViaMemory should not return transport error") // The MCP SDK returns tool errors as part of the result, not as Go errors. require.True(t, result.IsError, "expected IsError=true for scope violation") // Check that the error text mentions "scope" msg := "" if len(result.Content) > 0 { msg = fmt.Sprintf("%v", result.Content[0]) } require.True(t, strings.Contains(strings.ToLower(msg), "scope") || result.IsError, "expected error message to mention 'scope', got: %s", msg) } // testScopeViolationProject verifies that a token whose scope.ProjectIDs does not // include a second project gets a scope error when trying to access it. func (s *mcpIntegrationSuite) testScopeViolationProject(t *testing.T) { // Create a second project that is NOT in userToken's scope.project_ids secondSlug := fmt.Sprintf("k3-second-%s", uuid.New().String()[:8]) secondID := s.seedAdditionalProject(t, s.adminID, secondSlug) _ = secondID sess := s.userSession() // scope only includes s.projectID, not secondID result, err := s.callToolViaMemory(t, sess, "list_issues", map[string]any{ "project_slug": secondSlug, }) require.NoError(t, err) // The list_issues tool first calls CheckToolFromCtx (passes), then calls // Projects.Get which succeeds (internal visibility), then CheckProjectFromCtx // which should fail because secondID is not in the token's project scope. // However, the tool catches the project scope error and returns it. // Since Projects.Get returns the project first, then CheckProjectFromCtx fails, // the tool returns an error via MCP result. // But wait — Projects.Get also checks visibility. The user is not admin and not // the owner, but the project is internal so assertReadable passes. Then // CheckProjectFromCtx fails because the project ID is not in scope. // The tool returns this as an MCP error. require.True(t, result.IsError, "expected IsError=true for project scope violation") } // testVisibilityRespect verifies that even when a token's scope includes a private // project (via direct SQL extension), the service-layer visibility check still // blocks access because the user is neither owner nor admin. func (s *mcpIntegrationSuite) testVisibilityRespect(t *testing.T) { // Create a private project owned by adminID (not userID) privateID, privateSlug := s.seedPrivateProjectFor(t, s.adminID) // Extend userToken scope to include this private project via direct SQL s.extendUserTokenProjectScope(t, privateID) // Re-read the token's scope from DB to build a fresh session var scopeJSON []byte err := s.pool.QueryRow(s.ctx, "SELECT scope FROM mcp_tokens WHERE id = $1", s.userTokenID, ).Scan(&scopeJSON) require.NoError(t, err) var updatedScope mcp.Scope require.NoError(t, json.Unmarshal(scopeJSON, &updatedScope)) sess := &mcp.AuthSession{ TokenID: s.userTokenID.String(), UserID: s.userID.String(), Scope: updatedScope, } result, err := s.callToolViaMemory(t, sess, "list_issues", map[string]any{ "project_slug": privateSlug, }) require.NoError(t, err) // The tool calls Projects.Get first. The private project is owned by adminID, // and the caller is userID (non-admin, non-owner). assertReadable should // return CodeNotFound for a private project the caller can't see. require.True(t, result.IsError, "expected IsError=true for visibility violation") } // testWriteToolAudit verifies that calling a write tool (create_issue) through MCP // produces an audit_log entry with via_mcp=true and mcp_token_id. func (s *mcpIntegrationSuite) testWriteToolAudit(t *testing.T) { // We need the audit recorder wrapped with AuditWrap so that via_mcp and // mcp_token_id get injected. The suite's projectSvc already uses AuditWrap // because the issueSvc was created with auditWrapped. sess := s.adminSession() result, err := s.callToolViaMemory(t, sess, "create_issue", map[string]any{ "project_slug": s.projectSlug, "title": "Audit test issue", "description": "Should produce audit log with via_mcp", }) require.NoError(t, err) require.False(t, result.IsError, "create_issue should succeed for admin") // Query audit_logs for the most recent issue.create meta := s.queryAuditMetadata(t, "issue.create") viaMCP, ok := meta["via_mcp"] require.True(t, ok, "expected via_mcp key in audit metadata") require.Equal(t, true, viaMCP, "expected via_mcp=true") tokenID, ok := meta["mcp_token_id"] require.True(t, ok, "expected mcp_token_id key in audit metadata") require.Equal(t, s.adminTokenID.String(), tokenID, "expected mcp_token_id to match admin token ID") } // --------------------------------------------------------------------------- // K4: Rate limit + ACP lifecycle + startup reaper (3 scenarios) // --------------------------------------------------------------------------- // testRateLimitPerToken issues a fresh token (isolated bucket from suite tokens), // then fires 110 concurrent requests against a rate-limited endpoint (default 100/min). // At least 1 request must be rejected with 429. func (s *mcpIntegrationSuite) testRateLimitPerToken(t *testing.T) { plain := s.issueRateLimitTestToken(t) // Build an isolated HTTP server with its own rate limiter so the bucket is fresh. rl := mcp.NewRateLimiter(mcp.RateLimitConfig{ PerTokenPerMinute: 100, GlobalPerMinute: 10000, }, nil) mcpRepo := mcp.NewPostgresRepository(s.pool) ts := mcp.NewTokenService(mcpRepo, s.auditRec, nil) r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(mcp.NewAuthMiddleware(ts)) r.Use(mcp.NewRateLimitMiddleware(rl)) r.Post("/mcp", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) srv := httptest.NewServer(r) defer srv.Close() const total = 110 var ( wg sync.WaitGroup rejected atomic.Int32 ) for i := 0; i < total; i++ { wg.Add(1) go func() { defer wg.Done() req, _ := http.NewRequest(http.MethodPost, srv.URL+"/mcp", nil) req.Header.Set("Authorization", "Bearer "+plain) resp, err := http.DefaultClient.Do(req) if err != nil { return } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { rejected.Add(1) } }() } wg.Wait() assert.GreaterOrEqual(t, rejected.Load(), int32(1), "at least 1 of %d requests should be rate-limited", total) } // testACPSessionTokenLifecycle verifies the full token lifecycle tied to an ACP session: // 1. Seed acp_sessions row (status='running') // 2. Create system token bound to session -> issuer='system', revoked_at IS NULL // 3. Revoke by session -> revoked_at gets written // 4. Audit log contains mcp.token.revoke_by_session func (s *mcpIntegrationSuite) testACPSessionTokenLifecycle(t *testing.T) { ctx := s.ctx sessionID := uuid.New() // 1. Seed prerequisite rows: workspace + acp_agent_kind + acp_session. wsID, akID := s.seedWorkspaceAndAgentKind(t, "lifecycle") err := s.seedRunningSession(ctx, t, sessionID, wsID, akID) require.NoError(t, err) // 2. Create system token bound to this session via direct SQL. _, hash, _ := mcp.NewToken() tokenID := uuid.New() expires := time.Now().Add(time.Hour) _, err = s.pool.Exec(ctx, `INSERT INTO mcp_tokens (id, token_hash, user_id, name, issuer, scope, acp_session_id, expires_at, created_by) VALUES ($1, $2, $3, 'acp:' || $4::text, 'system', '{}'::jsonb, $4, $5, $6)`, tokenID, hash, s.adminID, sessionID, expires, s.adminID) require.NoError(t, err) // Verify token is active. tok, err := mcp.NewPostgresRepository(s.pool).GetByID(ctx, tokenID) require.NoError(t, err) assert.Equal(t, mcp.IssuerSystem, tok.Issuer) assert.Nil(t, tok.RevokedAt, "newly created token should not be revoked") // 3. Simulate session termination - revoke tokens bound to session. err = s.mcpTokenSvc.RevokeBySession(ctx, sessionID) require.NoError(t, err) // 4. Verify token revoked_at gets written. tok, err = mcp.NewPostgresRepository(s.pool).GetByID(ctx, tokenID) require.NoError(t, err) assert.NotNil(t, tok.RevokedAt, "token should be revoked after session termination") // 5. Verify audit_logs has mcp.token.revoke_by_session entry. var auditCount int err = s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM audit_logs WHERE action = 'mcp.token.revoke_by_session' AND target_id = $1`, tokenID.String(), ).Scan(&auditCount) require.NoError(t, err) assert.Equal(t, 1, auditCount, "expected 1 revoke_by_session audit entry") } // testStartupReaper seeds a crashed acp_session + active system mcp_token, // then calls ReapStaleSystemTokens and verifies the token gets revoked. func (s *mcpIntegrationSuite) testStartupReaper(t *testing.T) { ctx := s.ctx // 1. Seed a crashed session + active system token. sessionID, tokenID := s.seedCrashedSessionWithLiveToken(ctx, t) _ = sessionID // Verify token is not yet revoked. mcpRepo := mcp.NewPostgresRepository(s.pool) tok, err := mcpRepo.GetByID(ctx, tokenID) require.NoError(t, err) assert.Nil(t, tok.RevokedAt, "token should not be revoked before reaper") // 2. Run reaper. reaped, err := mcpRepo.ReapStaleSystemTokens(ctx) require.NoError(t, err) assert.Contains(t, reaped, tokenID, "reaped list should contain our token") // 3. Verify token gets revoked. tok, err = mcpRepo.GetByID(ctx, tokenID) require.NoError(t, err) assert.NotNil(t, tok.RevokedAt, "token should be revoked after reaper") } // --- K4 helpers --- // issueRateLimitTestToken creates a fresh admin token isolated from suite tokens. func (s *mcpIntegrationSuite) issueRateLimitTestToken(t *testing.T) string { t.Helper() mcpRepo := mcp.NewPostgresRepository(s.pool) ts := mcp.NewTokenService(mcpRepo, s.auditRec, nil) res, err := ts.IssueAdmin(s.ctx, mcp.IssueAdminInput{ UserID: s.adminID, Name: "rate-limit-test", Scope: mcp.Scope{}, ExpiresAt: time.Now().Add(time.Hour), CreatedBy: s.adminID, }) require.NoError(t, err) return res.Plaintext } // seedWorkspaceAndAgentKind creates a workspace and acp_agent_kind row for FK // references in acp_sessions. Returns (workspaceID, agentKindID). func (s *mcpIntegrationSuite) seedWorkspaceAndAgentKind(t *testing.T, suffix string) (uuid.UUID, uuid.UUID) { t.Helper() ctx := s.ctx wsID := uuid.New() cwd := t.TempDir() _, err := s.pool.Exec(ctx, `INSERT INTO workspaces (id, project_id, slug, name, git_remote_url, main_path, default_branch) VALUES ($1, $2, $3, 'w-k4', 'git@x:y.git', $4, 'main')`, wsID, s.projectID, "ws-k4-"+suffix, cwd) require.NoError(t, err) akID := uuid.New() _, err = s.pool.Exec(ctx, `INSERT INTO acp_agent_kinds (id, name, display_name, binary_path, created_by) VALUES ($1, $2, 'K4 Agent', '/bin/echo', $3)`, akID, "k4-agent-"+suffix, s.adminID) require.NoError(t, err) return wsID, akID } // seedRunningSession inserts an acp_sessions row with status='running'. func (s *mcpIntegrationSuite) seedRunningSession(ctx context.Context, t *testing.T, sessionID, wsID, akID uuid.UUID) error { t.Helper() _, err := s.pool.Exec(ctx, `INSERT INTO acp_sessions (id, workspace_id, project_id, agent_kind_id, user_id, branch, cwd_path, is_main_worktree, status, started_at) VALUES ($1, $2, $3, $4, $5, 'feat/lifecycle-test', $6, false, 'running', now())`, sessionID, wsID, s.projectID, akID, s.adminID, t.TempDir()) return err } // seedCrashedSessionWithLiveToken inserts a crashed acp_session + an active // system mcp_token bound to it. Returns (sessionID, tokenID). func (s *mcpIntegrationSuite) seedCrashedSessionWithLiveToken(ctx context.Context, t *testing.T) (uuid.UUID, uuid.UUID) { t.Helper() wsID, akID := s.seedWorkspaceAndAgentKind(t, "reaper") sessionID := uuid.New() _, err := s.pool.Exec(ctx, `INSERT INTO acp_sessions (id, workspace_id, project_id, agent_kind_id, user_id, branch, cwd_path, is_main_worktree, status, started_at) VALUES ($1, $2, $3, $4, $5, 'feat/reap-test', $6, false, 'crashed', now())`, sessionID, wsID, s.projectID, akID, s.adminID, t.TempDir()) require.NoError(t, err) _, hash, _ := mcp.NewToken() tokenID := uuid.New() expires := time.Now().Add(time.Hour) _, err = s.pool.Exec(ctx, `INSERT INTO mcp_tokens (id, token_hash, user_id, name, issuer, scope, acp_session_id, expires_at, created_by) VALUES ($1, $2, $3, 'acp:' || $4::text, 'system', '{}'::jsonb, $4, $5, $6)`, tokenID, hash, s.adminID, sessionID, expires, s.adminID) require.NoError(t, err) return sessionID, tokenID } // --------------------------------------------------------------------------- // K5: Prompts + Resources // --------------------------------------------------------------------------- func (s *mcpIntegrationSuite) testPromptsListFromTemplates(t *testing.T) { t.Parallel() // Seed a system-scope template via chat.TemplateService chatRepo := chat.NewRepository(s.pool) tplSvc := chat.NewTemplateService(chatRepo, s.auditRec, nil) tpl, err := tplSvc.Create(s.ctx, s.adminID, "test-system-prompt", "You are a helpful assistant.", chat.TemplateScopeSystem) require.NoError(t, err) require.NotNil(t, tpl) // Build an in-memory MCP server with Templates registered callerResolver := mcp.NewCallerResolver(s.userSvc) projectRepo := project.NewPostgresRepository(s.pool) deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), Issues: s.issueSvc, Templates: tplSvc, } srv := mcp.NewMCPServer(deps) require.NoError(t, mcp.RegisterSystemPrompts(srv, deps, s.adminID)) client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "k5-prompts", Version: "0.0.1"}, nil) t1, t2 := mcpsdk.NewInMemoryTransports() if _, err := srv.Connect(s.ctx, t1, nil); err != nil { t.Fatal(err) } sess, err := client.Connect(s.ctx, t2, nil) require.NoError(t, err) defer sess.Close() list, err := sess.ListPrompts(s.ctx, nil) require.NoError(t, err) var found bool for _, p := range list.Prompts { if p.Name == "test-system-prompt" { found = true break } } assert.True(t, found, "expected prompt %q in list", "test-system-prompt") got, err := sess.GetPrompt(s.ctx, &mcpsdk.GetPromptParams{Name: "test-system-prompt"}) require.NoError(t, err) require.NotEmpty(t, got.Messages) } func (s *mcpIntegrationSuite) testResourceReadNotFound(t *testing.T) { t.Parallel() // Resources are registered by NewMCPServer; use in-memory transport callerResolver := mcp.NewCallerResolver(s.userSvc) projectRepo := project.NewPostgresRepository(s.pool) deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), Issues: s.issueSvc, } srv := mcp.NewMCPServer(deps) client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "k5-resources", Version: "0.0.1"}, nil) t1, t2 := mcpsdk.NewInMemoryTransports() if _, err := srv.Connect(s.ctx, t1, nil); err != nil { t.Fatal(err) } sess, err := client.Connect(s.ctx, t2, nil) require.NoError(t, err) defer sess.Close() _, err = sess.ReadResource(s.ctx, &mcpsdk.ReadResourceParams{ URI: "pm://projects/" + uuid.New().String(), }) require.Error(t, err, "reading non-existent resource should error") msg := strings.ToLower(err.Error()) assert.True(t, strings.Contains(msg, "not found") || strings.Contains(msg, "no such") || strings.Contains(msg, "404"), "expected not_found, got: %v", err) } func (s *mcpIntegrationSuite) testResourceListScopeFilter(t *testing.T) { t.Parallel() // Resources are statically registered. Scope filtering applies during READ. // Verify: list returns resources; reading in-scope project works. callerResolver := mcp.NewCallerResolver(s.userSvc) projectRepo := project.NewPostgresRepository(s.pool) deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), Issues: s.issueSvc, } srv := mcp.NewMCPServer(deps) srv.AddReceivingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler { return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) { ctx = context.WithValue(ctx, mcp.AuthContextKey, s.userSession()) return next(ctx, method, req) } }) client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "k5-scope", Version: "0.0.1"}, nil) t1, t2 := mcpsdk.NewInMemoryTransports() if _, err := srv.Connect(s.ctx, t1, nil); err != nil { t.Fatal(err) } sess, err := client.Connect(s.ctx, t2, nil) require.NoError(t, err) defer sess.Close() list, err := sess.ListResources(s.ctx, nil) require.NoError(t, err) assert.NotEmpty(t, list.Resources, "expected registered resources") // Reading the in-scope project resource should work _, err = sess.ReadResource(s.ctx, &mcpsdk.ReadResourceParams{ URI: "pm://projects/" + s.projectID.String(), }) require.NoError(t, err, "in-scope project should be readable") }