diff --git a/api/mcp.ts b/api/mcp.ts index 32ff675d..68bedd1f 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -42,12 +42,14 @@ type ResponseLike = { on(event: 'close' | 'finish', listener: () => void): void; } & ServerResponse; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + function setCorsHeaders(res: ResponseLike): void { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader( 'Access-Control-Allow-Headers', - 'Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id', + 'Content-Type, Authorization, X-Account-ID, X-T49-Credential-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id', ); } @@ -477,10 +479,52 @@ export default async function handler( return; } + const requestedAccountId = + resolvedAuth.scheme === 'Token' + ? getHeaderValue(req.headers['x-account-id']) + : undefined; + const credentialType = + resolvedAuth.scheme === 'Token' + ? getHeaderValue(req.headers['x-t49-credential-type']) + : undefined; + if (credentialType && credentialType !== 'bearer') { + res.status(400).json({ + error: 'Bad Request', + message: 'X-T49-Credential-Type is invalid.', + }); + logLifecycle('mcp.request.complete', requestId, { + reason: 'invalid_credential_type', + }); + return; + } + if (requestedAccountId && !UUID.test(requestedAccountId)) { + res.status(400).json({ + error: 'Bad Request', + message: 'X-Account-ID must be a UUID.', + }); + logLifecycle('mcp.request.complete', requestId, { + reason: 'invalid_account_id', + }); + return; + } + if (credentialType === 'bearer' && !requestedAccountId) { + res.status(400).json({ + error: 'Bad Request', + message: + 'X-Account-ID is required for an account-scoped bearer credential.', + }); + logLifecycle('mcp.request.complete', requestId, { + reason: 'missing_account_id', + }); + return; + } + const configuredApiToken = process.env.T49_API_TOKEN?.trim(); const configuredClientSecret = process.env.T49_MCP_CLIENT_SECRET?.trim(); let resolvedTerminal49Auth: ResolvedTerminal49Auth = { - apiToken: callerToken, + apiToken: + credentialType === 'bearer' ? `Bearer ${callerToken}` : callerToken, + accountId: credentialType === 'bearer' ? requestedAccountId : undefined, authSource: resolvedAuth.source ?? 'authorization', }; diff --git a/packages/mcp/tests/api-handler.test.ts b/packages/mcp/tests/api-handler.test.ts index e2f0428b..8e314c4f 100644 --- a/packages/mcp/tests/api-handler.test.ts +++ b/packages/mcp/tests/api-handler.test.ts @@ -192,6 +192,80 @@ describe('api/mcp handler lifecycle', () => { expect(mockState.serverCreateArgs[0]?.apiToken).toBe('token-scheme-value'); }); + it('does not forward account context from an ordinary API key', async () => { + const { default: handler } = await import('../../../api/mcp.ts'); + const req = createRequest({ + headers: { + host: 'localhost', + authorization: 'Token token-scheme-value', + 'x-account-id': 'f5e2f70e-2de8-4456-8596-db40e617b808', + }, + }); + const res = new MockResponse(); + + await handler(req as any, res as any); + + expect(mockState.serverCreateArgs[0]).toMatchObject({ + apiToken: 'token-scheme-value', + accountId: undefined, + }); + }); + + it('forwards an account-scoped caller bearer without exposing it to OAuth resolution', async () => { + const { default: handler } = await import('../../../api/mcp.ts'); + const accountId = 'f5e2f70e-2de8-4456-8596-db40e617b808'; + const req = createRequest({ + headers: { + host: 'localhost', + authorization: 'Token token-scheme-value', + 'x-account-id': accountId, + 'x-t49-credential-type': 'bearer', + }, + }); + const res = new MockResponse(); + + await handler(req as any, res as any); + + expect(mockState.serverCreateArgs[0]).toMatchObject({ + apiToken: 'Bearer token-scheme-value', + accountId, + }); + }); + + it('requires account context for a caller bearer', async () => { + const { default: handler } = await import('../../../api/mcp.ts'); + const req = createRequest({ + headers: { + host: 'localhost', + authorization: 'Token token-scheme-value', + 'x-t49-credential-type': 'bearer', + }, + }); + const res = new MockResponse(); + + await handler(req as any, res as any); + + expect(res.statusCode).toBe(400); + expect(mockState.servers).toHaveLength(0); + }); + + it('rejects malformed account context before constructing a server', async () => { + const { default: handler } = await import('../../../api/mcp.ts'); + const req = createRequest({ + headers: { + host: 'localhost', + authorization: 'Token token-scheme-value', + 'x-account-id': 'not-an-account-id', + }, + }); + const res = new MockResponse(); + + await handler(req as any, res as any); + + expect(res.statusCode).toBe(400); + expect(mockState.servers).toHaveLength(0); + }); + it('allows requests without Host header when allow-list is not configured', async () => { const { default: handler } = await import('../../../api/mcp.ts'); const req = createRequest({