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:
2026-05-08 13:24:36 +08:00
parent 20b449bb37
commit e0e9ae7bda
+209 -6
View File
@@ -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"
)
@@ -92,11 +98,15 @@ type mcpIntegrationSuite struct {
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
@@ -188,10 +198,32 @@ 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)
},
@@ -201,10 +233,12 @@ func newMCPIntegrationSuite(t *testing.T) *mcpIntegrationSuite {
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,
@@ -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")
}
}
// ---------------------------------------------------------------------------