From 71826152d521b0972ff14e1886b6fdcefda0c2ff Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 10 Sep 2026 16:15:01 -0500 Subject: [PATCH 1/3] fix: forward MCP passthrough account context --- api/mcp.ts | 20 +++++++++++++- packages/mcp/tests/api-handler.test.ts | 37 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/api/mcp.ts b/api/mcp.ts index 32ff675d..c0b3b73b 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, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id', ); } @@ -477,10 +479,26 @@ export default async function handler( return; } + const requestedAccountId = + resolvedAuth.scheme === 'Token' + ? getHeaderValue(req.headers['x-account-id']) + : undefined; + 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; + } + const configuredApiToken = process.env.T49_API_TOKEN?.trim(); const configuredClientSecret = process.env.T49_MCP_CLIENT_SECRET?.trim(); let resolvedTerminal49Auth: ResolvedTerminal49Auth = { apiToken: callerToken, + accountId: requestedAccountId, authSource: resolvedAuth.source ?? 'authorization', }; diff --git a/packages/mcp/tests/api-handler.test.ts b/packages/mcp/tests/api-handler.test.ts index e2f0428b..889592b6 100644 --- a/packages/mcp/tests/api-handler.test.ts +++ b/packages/mcp/tests/api-handler.test.ts @@ -192,6 +192,43 @@ describe('api/mcp handler lifecycle', () => { expect(mockState.serverCreateArgs[0]?.apiToken).toBe('token-scheme-value'); }); + it('forwards validated account context for a caller-owned credential', 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, + }, + }); + const res = new MockResponse(); + + await handler(req as any, res as any); + + expect(mockState.serverCreateArgs[0]).toMatchObject({ + apiToken: 'token-scheme-value', + accountId, + }); + }); + + 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({ From 891d04fc0098d93e92fa2601676f2f90592d064e Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Thu, 10 Sep 2026 16:19:27 -0500 Subject: [PATCH 2/3] fix: preserve passthrough bearer type --- api/mcp.ts | 30 ++++++++++++++++++++++++-- packages/mcp/tests/api-handler.test.ts | 22 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/api/mcp.ts b/api/mcp.ts index c0b3b73b..0ebab4c6 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -49,7 +49,7 @@ function setCorsHeaders(res: ResponseLike): void { res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader( 'Access-Control-Allow-Headers', - 'Content-Type, Authorization, X-Account-ID, 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', ); } @@ -483,6 +483,20 @@ export default async function handler( 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', @@ -493,11 +507,23 @@ export default async function handler( }); 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: requestedAccountId, authSource: resolvedAuth.source ?? 'authorization', }; diff --git a/packages/mcp/tests/api-handler.test.ts b/packages/mcp/tests/api-handler.test.ts index 889592b6..6394fd80 100644 --- a/packages/mcp/tests/api-handler.test.ts +++ b/packages/mcp/tests/api-handler.test.ts @@ -192,7 +192,7 @@ describe('api/mcp handler lifecycle', () => { expect(mockState.serverCreateArgs[0]?.apiToken).toBe('token-scheme-value'); }); - it('forwards validated account context for a caller-owned credential', async () => { + 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({ @@ -200,6 +200,7 @@ describe('api/mcp handler lifecycle', () => { host: 'localhost', authorization: 'Token token-scheme-value', 'x-account-id': accountId, + 'x-t49-credential-type': 'bearer', }, }); const res = new MockResponse(); @@ -207,11 +208,28 @@ describe('api/mcp handler lifecycle', () => { await handler(req as any, res as any); expect(mockState.serverCreateArgs[0]).toMatchObject({ - apiToken: 'token-scheme-value', + 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({ From a4b5f94dfb2d2e1768dbe17b18dddeaa4872e6fa Mon Sep 17 00:00:00 2001 From: Alex Garcia Date: Fri, 11 Sep 2026 15:09:19 -0500 Subject: [PATCH 3/3] fix: scope MCP account context to bearer passthrough --- api/mcp.ts | 2 +- packages/mcp/tests/api-handler.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/api/mcp.ts b/api/mcp.ts index 0ebab4c6..68bedd1f 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -524,7 +524,7 @@ export default async function handler( let resolvedTerminal49Auth: ResolvedTerminal49Auth = { apiToken: credentialType === 'bearer' ? `Bearer ${callerToken}` : callerToken, - accountId: requestedAccountId, + 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 6394fd80..8e314c4f 100644 --- a/packages/mcp/tests/api-handler.test.ts +++ b/packages/mcp/tests/api-handler.test.ts @@ -192,6 +192,25 @@ 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';