From 95bbfe6f1c5a64bb8f7e8700a35e2c35badb10ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 08:04:53 +0000 Subject: [PATCH 1/2] Improve MCP HTTP compatibility for common clients Co-authored-by: G9Pedro --- .../mcp-server/src/mcp-http-server.test.ts | 132 ++++++++++++++++++ packages/mcp-server/src/mcp-http-server.ts | 86 +++++++++++- 2 files changed, 215 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/src/mcp-http-server.test.ts b/packages/mcp-server/src/mcp-http-server.test.ts index 1ca3f40..2a4a1f8 100644 --- a/packages/mcp-server/src/mcp-http-server.test.ts +++ b/packages/mcp-server/src/mcp-http-server.test.ts @@ -96,6 +96,49 @@ describe('mcp streamable http server', () => { } }); + it('accepts wildcard Accept and handles stateless follow-up requests', async () => { + const handle = await startWorkgraphMcpHttpServer({ + workspacePath, + defaultActor: 'system', + host: '127.0.0.1', + port: 0, + }); + + try { + const initializeResponse = await fetch(handle.url, { + method: 'POST', + headers: { + accept: '*/*', + 'content-type': 'application/json', + }, + body: JSON.stringify(createInitializeRequest(1)), + }); + expect(initializeResponse.status).toBe(200); + const initializePayload = parseMcpHttpResponse(await initializeResponse.text()); + expect(initializePayload?.result?.serverInfo?.name).toBeTruthy(); + expect(initializeResponse.headers.get('mcp-session-id')).toBeTruthy(); + + const toolsResponse = await fetch(handle.url, { + method: 'POST', + headers: { + accept: '*/*', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }), + }); + expect(toolsResponse.status).toBe(200); + const toolsPayload = parseMcpHttpResponse(await toolsResponse.text()); + expect(Array.isArray(toolsPayload?.result?.tools)).toBe(true); + } finally { + await handle.close(); + } + }); + it('enforces strict credential identity for MCP write tools', async () => { const init = workspace.initWorkspace(workspacePath, { createReadme: false, createBases: false }); const registration = agent.registerAgent(workspacePath, 'mcp-admin', { @@ -181,6 +224,65 @@ describe('mcp streamable http server', () => { await handle.close(); } }); + + it('accepts x-api-key header for MCP auth', async () => { + const init = workspace.initWorkspace(workspacePath, { createReadme: false, createBases: false }); + const registration = agent.registerAgent(workspacePath, 'mcp-admin', { + token: init.bootstrapTrustToken, + capabilities: ['mcp:write', 'thread:claim', 'thread:done', 'dispatch:run', 'agent:approve-registration'], + }); + expect(registration.apiKey).toBeDefined(); + policy.upsertParty(workspacePath, 'mcp-admin', { + roles: ['admin'], + capabilities: ['mcp:write', 'thread:claim', 'thread:done', 'dispatch:run', 'agent:approve-registration'], + }, { + actor: 'mcp-admin', + skipAuthorization: true, + }); + thread.createThread(workspacePath, 'x-api-key thread', 'x-api-key auth', 'seed'); + + const serverConfigPath = path.join(workspacePath, '.workgraph', 'server.json'); + const serverConfig = JSON.parse(fs.readFileSync(serverConfigPath, 'utf-8')) as Record; + serverConfig.auth = { + mode: 'strict', + allowUnauthenticatedFallback: false, + }; + fs.writeFileSync(serverConfigPath, `${JSON.stringify(serverConfig, null, 2)}\n`, 'utf-8'); + + const handle = await startWorkgraphMcpHttpServer({ + workspacePath, + defaultActor: 'system', + host: '127.0.0.1', + port: 0, + bearerToken: 'gateway-token', + }); + const client = new Client({ + name: 'workgraph-mcp-http-x-api-key-client', + version: '1.0.0', + }); + const transport = new StreamableHTTPClientTransport(new URL(handle.url), { + requestInit: { + headers: { + 'x-api-key': registration.apiKey, + }, + }, + }); + + await client.connect(transport); + try { + const claim = await client.callTool({ + name: 'workgraph_thread_claim', + arguments: { + threadPath: 'threads/x-api-key-thread.md', + actor: 'mcp-admin', + }, + }); + expect(isToolError(claim)).toBe(false); + } finally { + await client.close(); + await handle.close(); + } + }); }); function extractStructured(result: unknown): T { @@ -195,3 +297,33 @@ function isToolError(result: unknown): boolean { if (!('isError' in result)) return false; return (result as { isError?: boolean }).isError === true; } + +function createInitializeRequest(id: number): Record { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { + name: 'workgraph-http-compat-client', + version: '1.0.0', + }, + }, + }; +} + +function parseMcpHttpResponse(body: string): any { + const trimmed = body.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + return JSON.parse(trimmed); + } + for (const line of body.split('\n')) { + if (!line.startsWith('data:')) continue; + const payload = line.slice('data:'.length).trim(); + if (!payload) continue; + return JSON.parse(payload); + } + throw new Error(`Unable to parse MCP HTTP response body: ${body}`); +} diff --git a/packages/mcp-server/src/mcp-http-server.ts b/packages/mcp-server/src/mcp-http-server.ts index e3a384e..5371ed5 100644 --- a/packages/mcp-server/src/mcp-http-server.ts +++ b/packages/mcp-server/src/mcp-http-server.ts @@ -6,6 +6,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { auth as kernelAuth } from '@versatly/workgraph-kernel'; import { createWorkgraphMcpServer } from './mcp-server.js'; +const MCP_ACCEPT_FALLBACK = 'application/json, text/event-stream'; + export interface WorkgraphMcpHttpServerOptions { workspacePath: string; defaultActor?: string; @@ -77,7 +79,9 @@ export async function startWorkgraphMcpHttpServer( }); app.post(endpointPath, async (req: any, res: any) => { + normalizeAcceptHeader(req); const sessionId = readSessionId(req.headers['mcp-session-id']); + let ephemeralBinding: SessionBinding | undefined; try { const requestAuthContext = buildRequestAuthContext(req, 'mcp'); let binding: SessionBinding | undefined; @@ -111,6 +115,20 @@ export async function startWorkgraphMcpHttpServer( } }; await server.connect(transport); + } else if (!sessionId) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + const server = createWorkgraphMcpServer({ + workspacePath: options.workspacePath, + defaultActor: options.defaultActor, + readOnly: options.readOnly, + name: options.name, + version: options.version, + }); + binding = { transport, server, authContext: requestAuthContext }; + ephemeralBinding = binding; + await server.connect(transport); } else { res.status(400).json({ jsonrpc: '2.0', @@ -140,10 +158,15 @@ export async function startWorkgraphMcpHttpServer( id: null, }); } + } finally { + if (ephemeralBinding) { + await ephemeralBinding.server.close(); + } } }); app.get(endpointPath, async (req: any, res: any) => { + normalizeAcceptHeader(req); const sessionId = readSessionId(req.headers['mcp-session-id']); if (!sessionId || !sessions[sessionId]) { res.status(400).send('Invalid or missing MCP session ID.'); @@ -264,7 +287,10 @@ function createBearerAuthMiddleware( ): WorkgraphMcpBearerAuthMiddleware { const authToken = readString(rawToken); return (req: any, res: any, next: () => void) => { - const providedToken = readBearerToken(req.headers.authorization); + const providedToken = readCredentialToken( + req.headers.authorization, + req.headers['x-api-key'], + ); if (!authToken) return next(); if (!providedToken) { res.status(401).json({ @@ -292,18 +318,72 @@ function createBearerAuthMiddleware( } function readBearerToken(headerValue: unknown): string | undefined { - const authorization = readString(headerValue); + const authorization = readHeaderString(headerValue); if (!authorization || !authorization.startsWith('Bearer ')) { return undefined; } return readString(authorization.slice('Bearer '.length)); } +function readCredentialToken( + authorizationHeaderValue: unknown, + apiKeyHeaderValue: unknown, +): string | undefined { + return readBearerToken(authorizationHeaderValue) ?? readApiKeyToken(apiKeyHeaderValue); +} + +function readApiKeyToken(headerValue: unknown): string | undefined { + return readHeaderString(headerValue); +} + +function readHeaderString(headerValue: unknown): string | undefined { + if (Array.isArray(headerValue)) { + return readString(headerValue[0]); + } + return readString(headerValue); +} + +function normalizeAcceptHeader(req: { + headers?: Record; + rawHeaders?: unknown; +} | undefined): void { + const headers = req?.headers; + if (!headers) return; + const accept = readHeaderString(headers.accept); + if (!accept || hasWildcardAccept(accept)) { + headers.accept = MCP_ACCEPT_FALLBACK; + const rawHeaders = req?.rawHeaders; + if (Array.isArray(rawHeaders)) { + let hasAccept = false; + for (let index = 0; index < rawHeaders.length - 1; index += 2) { + const headerName = String(rawHeaders[index] ?? '').toLowerCase(); + if (headerName !== 'accept') continue; + rawHeaders[index + 1] = MCP_ACCEPT_FALLBACK; + hasAccept = true; + } + if (!hasAccept) { + rawHeaders.push('accept', MCP_ACCEPT_FALLBACK); + } + } + } +} + +function hasWildcardAccept(value: string): boolean { + const mediaTypes = value + .split(',') + .map((entry) => entry.split(';')[0]?.trim().toLowerCase()) + .filter((entry): entry is string => !!entry); + return mediaTypes.includes('*/*'); +} + function buildRequestAuthContext( req: any, source: 'mcp' | 'rest', ): kernelAuth.WorkgraphAuthContext { - const credentialToken = readBearerToken(req?.headers?.authorization); + const credentialToken = readCredentialToken( + req?.headers?.authorization, + req?.headers?.['x-api-key'], + ); return { ...(credentialToken ? { credentialToken } : {}), source, From cd63b15699c42c69049db70351136582deb593c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 08:05:14 +0000 Subject: [PATCH 2/2] Fix npm test vitest invocation in test runner Co-authored-by: G9Pedro --- scripts/run-tests.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index dac6fa4..31a8d92 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -172,7 +172,11 @@ function cleanup() { function spawnVitest(args) { const npmExecPath = process.env.npm_execpath; if (npmExecPath) { - return spawn(process.execPath, [npmExecPath, ...args], { + const userAgent = String(process.env.npm_config_user_agent ?? '').toLowerCase(); + const npmExecArgs = isNpmLikeInvoker(userAgent) + ? [npmExecPath, 'exec', '--', ...args] + : [npmExecPath, ...args]; + return spawn(process.execPath, npmExecArgs, { cwd: REPO_ROOT, env: process.env, stdio: ['inherit', 'pipe', 'pipe'], @@ -300,3 +304,7 @@ function escapeForCmd(value) { if (!/[ \t"]/u.test(value)) return value; return `"${value.replaceAll('"', '\\"')}"`; } + +function isNpmLikeInvoker(userAgent) { + return userAgent.startsWith('npm/'); +}