//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" "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/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 stubs // --------------------------------------------------------------------------- func (s *mcpIntegrationSuite) testRateLimitPerToken(t *testing.T) { t.Skip("filled in K4") } func (s *mcpIntegrationSuite) testACPSessionTokenLifecycle(t *testing.T) { t.Skip("filled in K4") } func (s *mcpIntegrationSuite) testStartupReaper(t *testing.T) { t.Skip("filled in K4") } // --------------------------------------------------------------------------- // K5 stubs // --------------------------------------------------------------------------- func (s *mcpIntegrationSuite) testPromptsListFromTemplates(t *testing.T) { t.Skip("filled in K5") } func (s *mcpIntegrationSuite) testResourceReadNotFound(t *testing.T) { t.Skip("filled in K5") } func (s *mcpIntegrationSuite) testResourceListScopeFilter(t *testing.T) { t.Skip("filled in K5") }