Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions packages/mcp-server/src/mcp-http-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down Expand Up @@ -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<string, unknown>;
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<T>(result: unknown): T {
Expand All @@ -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<string, unknown> {
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}`);
}
86 changes: 83 additions & 3 deletions packages/mcp-server/src/mcp-http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<string, unknown>;
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,
Expand Down
10 changes: 9 additions & 1 deletion scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -300,3 +304,7 @@ function escapeForCmd(value) {
if (!/[ \t"]/u.test(value)) return value;
return `"${value.replaceAll('"', '\\"')}"`;
}

function isNpmLikeInvoker(userAgent) {
return userAgent.startsWith('npm/');
}
Loading