You've already forked agentic-coding-workflow
test(mcp): K2 — transport + auth integration scenarios
- StreamableHTTP_FullFlow: initialize → tools/list → tools/call - LegacySSE_FullFlow: same flow over /mcp/sse - BearerVsQueryToken: header + ?token= both authenticate - MCPSDKVersionNegotiation: unknown version -> graceful response Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user