Skip to content
Open
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
48 changes: 46 additions & 2 deletions api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}

Expand Down Expand Up @@ -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',
};

Expand Down
74 changes: 74 additions & 0 deletions packages/mcp/tests/api-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading