From 3cab27c9e118b6f74273a0a53be85d084ee73732 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 17 Aug 2026 08:33:11 +0000 Subject: [PATCH] refactor: centralize native MCP authentication --- .../handlers/mcp/__tests__/asana-auth.test.ts | 80 +---------- .../mcp/__tests__/deployment-mcp-auth.test.ts | 127 ++++++++++++++++++ .../mcp/__tests__/grafana-auth.test.ts | 84 +----------- .../mcp/__tests__/granola-auth.test.ts | 26 +--- .../mcp/__tests__/notion-auth.test.ts | 17 +-- .../mcp/__tests__/snowflake-auth.test.ts | 94 +------------ .../mcp/__tests__/vercel-auth.test.ts | 79 +---------- apps/api/src/handlers/mcp/asana/index.ts | 63 +-------- .../src/handlers/mcp/deployment-mcp-auth.ts | 49 +++++++ apps/api/src/handlers/mcp/grafana/index.ts | 63 +-------- apps/api/src/handlers/mcp/granola/index.ts | 55 +------- apps/api/src/handlers/mcp/notion/index.ts | 47 +------ apps/api/src/handlers/mcp/snowflake/index.ts | 66 +-------- apps/api/src/handlers/mcp/vercel/index.ts | 63 +-------- 14 files changed, 208 insertions(+), 705 deletions(-) create mode 100644 apps/api/src/handlers/mcp/__tests__/deployment-mcp-auth.test.ts create mode 100644 apps/api/src/handlers/mcp/deployment-mcp-auth.ts diff --git a/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts index fd76cb13e..935eea12f 100644 --- a/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/asana-auth.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import type { RunTokenContext } from '@roomote/types'; import type { Variables } from '../../../types'; @@ -119,31 +119,6 @@ describe('asana MCP auth and tool handling', () => { mockFindConnection.mockResolvedValue(mockConnectionRow()); }); - it('rejects when auth context is missing', async () => { - const response = await postMcp( - createApp(undefined), - createInitializeRequest(1), - ); - const body = (await response.json()) as JsonRpcErrorBody; - - expect(response.status).toBe(401); - expect(body.error.message).toContain('Unauthorized'); - }); - - it('accepts user auth tokens for control-plane Asana access', async () => { - const authToken: AuthTokenContext = { - userId: 'user-1', - tokenType: 'auth', - version: 1, - }; - - const response = await postMcp( - createApp(authToken), - createInitializeRequest(1), - ); - expect(response.status).toBe(200); - }); - it('initializes successfully for task run tokens', async () => { vi.stubGlobal( 'fetch', @@ -174,59 +149,6 @@ describe('asana MCP auth and tool handling', () => { }); }); - it('accepts a token minted for user A after the acting user switched to user B', async () => { - // Web steer / follow-up delivery mutate task_runs.actingUserId mid-run; - // the run-scoped token stays authorized (the token's userId is mint-time - // attribution and is never compared against the mutable acting user). - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: 'user-2', - }); - - const response = await postMcp( - createApp(createRunToken()), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - }); - - it('accepts a deployment-principal token after a human became the acting user', async () => { - // A human replying in the thread of an automation run switches the acting - // user from null to that human; the run-scoped null-principal token must - // keep working. - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: 'user-2', - }); - - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - }); - - it('allows deployment-principal tokens for deployment-principal task runs', async () => { - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: null, - }); - - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - result: { - serverInfo: { name: 'roomote-asana-mcp', version: '1.0.0' }, - }, - }); - }); - it('lists the Asana tools', async () => { const response = await postMcp(createApp(createRunToken()), { jsonrpc: '2.0', diff --git a/apps/api/src/handlers/mcp/__tests__/deployment-mcp-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/deployment-mcp-auth.test.ts new file mode 100644 index 000000000..473dae542 --- /dev/null +++ b/apps/api/src/handlers/mcp/__tests__/deployment-mcp-auth.test.ts @@ -0,0 +1,127 @@ +import type { + AuthTokenContext, + McpAccessTokenContext, + RunTokenContext, +} from '@roomote/types'; + +const { mockFindTaskRun, mockEq } = vi.hoisted(() => ({ + mockFindTaskRun: vi.fn(), + mockEq: vi.fn((column: unknown, value: unknown) => ({ column, value })), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + taskRuns: { findFirst: mockFindTaskRun }, + }, + }, + taskRuns: { id: 'taskRuns.id' }, + eq: mockEq, +})); + +import { resolveDeploymentMcpAuth } from '../deployment-mcp-auth'; + +const providers = [ + 'Asana', + 'Grafana', + 'Granola', + 'Notion', + 'Snowflake', + 'Vercel', +]; + +function createRunToken(overrides?: Partial): RunTokenContext { + return { + runId: 42, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + ...overrides, + }; +} + +describe.each(providers)('%s deployment-scoped MCP auth', (providerName) => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindTaskRun.mockResolvedValue({ id: 42 }); + }); + + it('rejects missing authentication', async () => { + await expect( + resolveDeploymentMcpAuth(undefined, providerName), + ).rejects.toMatchObject({ + httpStatus: 401, + message: 'Unauthorized: missing or invalid bearer token', + }); + }); + + it('accepts user auth tokens without validating a task run', async () => { + const authToken: AuthTokenContext = { + userId: 'user-1', + tokenType: 'auth', + version: 1, + }; + + await expect( + resolveDeploymentMcpAuth(authToken, providerName), + ).resolves.toEqual({ userId: 'user-1', tokenType: 'auth' }); + expect(mockFindTaskRun).not.toHaveBeenCalled(); + }); + + it('accepts run tokens when the target task run exists', async () => { + const runToken = createRunToken(); + + await expect( + resolveDeploymentMcpAuth(runToken, providerName), + ).resolves.toEqual({ + userId: 'user-1', + tokenType: 'run', + runId: 42, + }); + expect(mockFindTaskRun).toHaveBeenCalledWith({ + columns: { id: true }, + where: { column: 'taskRuns.id', value: 42 }, + }); + }); + + it('keeps deployment-principal run tokens independent of the acting user', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: 'user-2' }); + const runToken = createRunToken({ + userId: null, + principal: 'deployment', + }); + + await expect( + resolveDeploymentMcpAuth(runToken, providerName), + ).resolves.toEqual({ userId: null, tokenType: 'run', runId: 42 }); + }); + + it('rejects run tokens whose target task run no longer exists', async () => { + mockFindTaskRun.mockResolvedValue(undefined); + + await expect( + resolveDeploymentMcpAuth(createRunToken(), providerName), + ).rejects.toMatchObject({ + httpStatus: 404, + message: 'Task run not found for this MCP token', + }); + }); + + it('rejects MCP access tokens with the provider-specific public error', async () => { + const mcpToken: McpAccessTokenContext = { + userId: 'user-1', + tokenType: 'mcp', + version: 1, + resource: 'https://roomote.example/mcp', + scopes: ['mcp:access'], + }; + + await expect( + resolveDeploymentMcpAuth(mcpToken, providerName), + ).rejects.toMatchObject({ + httpStatus: 403, + message: `${providerName} MCP requires a user auth token or task run token for server-side credential access`, + }); + }); +}); diff --git a/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts index 68d4742c6..b3cb329c0 100644 --- a/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/grafana-auth.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import type { RunTokenContext } from '@roomote/types'; import type { Variables } from '../../../types'; @@ -40,10 +40,6 @@ vi.mock('@roomote/db/encryption', () => ({ import { db } from '@roomote/db/server'; import { grafanaMcp } from '../grafana'; -type JsonRpcErrorBody = { - error: { message: string }; -}; - function createInitializeRequest(id: number) { return { jsonrpc: '2.0', @@ -120,31 +116,6 @@ describe('grafana MCP auth and tool handling', () => { mockFindConnection.mockResolvedValue(mockConnectionRow()); }); - it('rejects when auth context is missing', async () => { - const response = await postMcp( - createApp(undefined), - createInitializeRequest(1), - ); - const body = (await response.json()) as JsonRpcErrorBody; - - expect(response.status).toBe(401); - expect(body.error.message).toContain('Unauthorized'); - }); - - it('accepts user auth tokens for control-plane Grafana access', async () => { - const authToken: AuthTokenContext = { - userId: 'user-1', - tokenType: 'auth', - version: 1, - }; - - const response = await postMcp( - createApp(authToken), - createInitializeRequest(1), - ); - expect(response.status).toBe(200); - }); - it('initializes successfully for task run tokens', async () => { const response = await postMcp( createApp(createRunToken()), @@ -160,59 +131,6 @@ describe('grafana MCP auth and tool handling', () => { }); }); - it('accepts a token minted for user A after the acting user switched to user B', async () => { - // Web steer / follow-up delivery mutate task_runs.actingUserId mid-run; - // the run-scoped token stays authorized (the token's userId is mint-time - // attribution and is never compared against the mutable acting user). - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: 'user-2', - }); - - const response = await postMcp( - createApp(createRunToken()), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - }); - - it('accepts a deployment-principal token after a human became the acting user', async () => { - // A human replying in the thread of an automation run switches the acting - // user from null to that human; the run-scoped null-principal token must - // keep working. - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: 'user-2', - }); - - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - }); - - it('allows deployment-principal tokens for deployment-principal task runs', async () => { - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: null, - }); - - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - result: { - serverInfo: { name: 'roomote-grafana-mcp', version: '1.0.0' }, - }, - }); - }); - it('lists the Grafana tools', async () => { const response = await postMcp(createApp(createRunToken()), { jsonrpc: '2.0', diff --git a/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts index 44632a4d7..d570d4750 100644 --- a/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import type { RunTokenContext } from '@roomote/types'; import type { Variables } from '../../../types'; @@ -125,30 +125,6 @@ describe('Granola MCP auth and tool handling', () => { vi.unstubAllGlobals(); }); - it('rejects requests without authentication', async () => { - const response = await postMcp( - createApp(undefined), - createInitializeRequest(1), - ); - const body = (await response.json()) as JsonRpcErrorBody; - - expect(response.status).toBe(401); - expect(body.error.message).toContain('missing or invalid bearer token'); - }); - - it('accepts user auth tokens for control-plane Granola access', async () => { - const authToken: AuthTokenContext = { - userId: 'user-1', - tokenType: 'auth', - version: 1, - }; - const response = await postMcp( - createApp(authToken), - createInitializeRequest(1), - ); - expect(response.status).toBe(200); - }); - it('initializes for a valid run token and deployment connection', async () => { const response = await postMcp( createApp(createRunToken()), diff --git a/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts index 845833521..ebc790c5d 100644 --- a/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import type { RunTokenContext } from '@roomote/types'; import type { Variables } from '../../../types'; @@ -111,21 +111,6 @@ describe('native Notion MCP', () => { }); }); - it('accepts user auth tokens for control-plane orchestration', async () => { - const authToken: AuthTokenContext = { - userId: 'user-1', - tokenType: 'auth', - version: 1, - }; - const response = await postMcp(createApp(authToken), { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - }); - - expect(response.status).toBe(200); - }); - it('exposes tools whose permissions are enforced by Notion capabilities', async () => { const response = await postMcp(createApp(createRunToken()), { jsonrpc: '2.0', diff --git a/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts index 36d7a3604..cc9a5eab2 100644 --- a/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/snowflake-auth.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import type { RunTokenContext } from '@roomote/types'; import type { Variables } from '../../../types'; @@ -75,10 +75,6 @@ vi.mock('snowflake-sdk', () => ({ import { db } from '@roomote/db/server'; import { snowflakeMcp } from '../snowflake'; -type JsonRpcErrorBody = { - error: { message: string }; -}; - function createInitializeRequest(id: number) { return { jsonrpc: '2.0', @@ -201,31 +197,6 @@ describe('snowflake MCP auth and tool handling', () => { }); }); - it('rejects when auth context is missing', async () => { - const response = await postMcp( - createApp(undefined), - createInitializeRequest(1), - ); - const body = (await response.json()) as JsonRpcErrorBody; - - expect(response.status).toBe(401); - expect(body.error.message).toContain('Unauthorized'); - }); - - it('accepts user auth tokens for control-plane Snowflake access', async () => { - const authToken: AuthTokenContext = { - userId: 'user-1', - tokenType: 'auth', - version: 1, - }; - - const response = await postMcp( - createApp(authToken), - createInitializeRequest(1), - ); - expect(response.status).toBe(200); - }); - it('initializes successfully for task run tokens', async () => { const response = await postMcp( createApp(createRunToken()), @@ -478,67 +449,4 @@ describe('snowflake MCP auth and tool handling', () => { }, }); }); - - it('validates task run tokens before serving Snowflake tools', async () => { - const response = await postMcp( - createApp(createRunToken()), - createInitializeRequest(3), - ); - - expect(response.status).toBe(200); - expect(mockFindTaskRun).toHaveBeenCalled(); - }); - - it('accepts a token minted for user A after the acting user switched to user B', async () => { - // Web steer / follow-up delivery mutate task_runs.actingUserId mid-run; - // the run-scoped token stays authorized (the token's userId is mint-time - // attribution and is never compared against the mutable acting user). - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: 'user-2', - }); - - const response = await postMcp( - createApp(createRunToken()), - createInitializeRequest(4), - ); - - expect(response.status).toBe(200); - }); - - it('accepts a deployment-principal token after a human became the acting user', async () => { - // A human replying in the thread of an automation run switches the acting - // user from null to that human; the run-scoped null-principal token must - // keep working. - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: 'user-2', - }); - - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(4), - ); - - expect(response.status).toBe(200); - }); - - it('allows deployment-principal tokens for deployment-principal task runs backed by a deployment-scoped connection', async () => { - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: null, - }); - - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(4), - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - result: { - serverInfo: { name: 'roomote-snowflake-mcp' }, - }, - }); - }); }); diff --git a/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts index b502993b9..9cf02ffee 100644 --- a/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/vercel-auth.test.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import type { RunTokenContext } from '@roomote/types'; import type { Variables } from '../../../types'; @@ -40,10 +40,6 @@ vi.mock('@roomote/db/encryption', () => ({ import { db } from '@roomote/db/server'; import { vercelMcp } from '../vercel'; -type JsonRpcErrorBody = { - error: { message: string }; -}; - function createInitializeRequest(id: number) { return { jsonrpc: '2.0', @@ -120,31 +116,6 @@ describe('vercel MCP auth and tool handling', () => { mockFindConnection.mockResolvedValue(mockConnectionRow()); }); - it('rejects when auth context is missing', async () => { - const response = await postMcp( - createApp(undefined), - createInitializeRequest(1), - ); - const body = (await response.json()) as JsonRpcErrorBody; - - expect(response.status).toBe(401); - expect(body.error.message).toContain('Unauthorized'); - }); - - it('accepts user auth tokens for control-plane Vercel access', async () => { - const authToken: AuthTokenContext = { - userId: 'user-1', - tokenType: 'auth', - version: 1, - }; - - const response = await postMcp( - createApp(authToken), - createInitializeRequest(1), - ); - expect(response.status).toBe(200); - }); - it('initializes successfully for task run tokens', async () => { const response = await postMcp( createApp(createRunToken()), @@ -160,54 +131,6 @@ describe('vercel MCP auth and tool handling', () => { }); }); - it('accepts a token minted for user A after the acting user switched to user B', async () => { - // Web steer / follow-up delivery mutate task_runs.actingUserId mid-run; - // the run-scoped token stays authorized (the token's userId is mint-time - // attribution and is never compared against the mutable acting user). - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: 'user-2', - }); - - const response = await postMcp( - createApp(createRunToken()), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - }); - - it('accepts a deployment-principal token after a human became the acting user', async () => { - // A human replying in the thread of an automation run switches the acting - // user from null to that human; the run-scoped null-principal token must - // keep working (default mock: actingUserId 'user-1'). - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - }); - - it('allows deployment-principal tokens for deployment-principal task runs', async () => { - mockFindTaskRun.mockResolvedValue({ - id: 42, - actingUserId: null, - }); - - const response = await postMcp( - createApp(createRunToken({ userId: null, principal: 'deployment' })), - createInitializeRequest(1), - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - result: { - serverInfo: { name: 'roomote-vercel-mcp', version: '1.0.0' }, - }, - }); - }); - it('lists the Vercel tools', async () => { const response = await postMcp(createApp(createRunToken()), { jsonrpc: '2.0', diff --git a/apps/api/src/handlers/mcp/asana/index.ts b/apps/api/src/handlers/mcp/asana/index.ts index 113720c2c..d817ed541 100644 --- a/apps/api/src/handlers/mcp/asana/index.ts +++ b/apps/api/src/handlers/mcp/asana/index.ts @@ -1,23 +1,13 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { - and, - db, - eq, - isNull, - mcpConnections, - taskRuns, -} from '@roomote/db/server'; +import { and, db, eq, isNull, mcpConnections } from '@roomote/db/server'; import { isMcpConnectionAsanaConfig } from '@roomote/types'; import type { Variables } from '../../../types'; -import { - isRunTokenContext, - McpProxyError, - type McpAuthContext, -} from '../proxy-utils'; +import { resolveDeploymentMcpAuth } from '../deployment-mcp-auth'; +import { McpProxyError } from '../proxy-utils'; import { registerAsanaTools } from './tools'; const ASANA_MCP_SERVER_INFO = { @@ -31,51 +21,6 @@ function createAsanaTransport() { }); } -async function resolveAsanaMcpAuth( - authContext: Variables['authContext'], -): Promise { - if (!authContext) { - throw new McpProxyError( - 401, - 'Unauthorized: missing or invalid bearer token', - ); - } - - if (isRunTokenContext(authContext)) { - const taskRun = await db.query.taskRuns.findFirst({ - columns: { id: true }, - where: eq(taskRuns.id, authContext.runId), - }); - - if (!taskRun) { - throw new McpProxyError(404, 'Task run not found for this MCP token'); - } - - // No principal equality check: the run-scoped token IS the authorization - // (only this run's sandbox holds it), and Asana credentials come from a - // deployment-scoped connection, so the token's userId plays no role in - // credential selection. The token's userId is mint-time attribution while - // task_runs.actingUserId is current-steering attribution — they - // legitimately diverge once a web steer or follow-up switches the acting - // user mid-run. - - return { - userId: authContext.userId, - tokenType: 'run', - runId: authContext.runId, - }; - } - - if (authContext.tokenType === 'auth') { - return { userId: authContext.userId, tokenType: 'auth' }; - } - - throw new McpProxyError( - 403, - 'Asana MCP requires a user auth token or task run token for server-side credential access', - ); -} - async function resolveAsanaConnection() { const connection = await db.query.mcpConnections.findFirst({ where: and( @@ -122,7 +67,7 @@ asanaMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { const transport = createAsanaTransport(); try { - await resolveAsanaMcpAuth(c.get('authContext')); + await resolveDeploymentMcpAuth(c.get('authContext'), 'Asana'); const connectionConfig = await resolveAsanaConnection(); const server = createAsanaMcpServer(connectionConfig); diff --git a/apps/api/src/handlers/mcp/deployment-mcp-auth.ts b/apps/api/src/handlers/mcp/deployment-mcp-auth.ts new file mode 100644 index 000000000..d9760fdb6 --- /dev/null +++ b/apps/api/src/handlers/mcp/deployment-mcp-auth.ts @@ -0,0 +1,49 @@ +import { db, eq, taskRuns } from '@roomote/db/server'; + +import type { Variables } from '../../types'; + +import { + isRunTokenContext, + McpProxyError, + type McpAuthContext, +} from './proxy-utils'; + +export async function resolveDeploymentMcpAuth( + authContext: Variables['authContext'], + providerName: string, +): Promise { + if (!authContext) { + throw new McpProxyError( + 401, + 'Unauthorized: missing or invalid bearer token', + ); + } + + if (isRunTokenContext(authContext)) { + const taskRun = await db.query.taskRuns.findFirst({ + columns: { id: true }, + where: eq(taskRuns.id, authContext.runId), + }); + + if (!taskRun) { + throw new McpProxyError(404, 'Task run not found for this MCP token'); + } + + // The run-scoped token is the authorization. Its user is mint-time + // attribution and may differ from the task's current acting user. + return { + userId: authContext.userId, + tokenType: 'run', + runId: authContext.runId, + }; + } + + if (authContext.tokenType === 'auth') { + return { userId: authContext.userId, tokenType: 'auth' }; + } + + throw new McpProxyError( + 403, + `${providerName} MCP requires a user auth token or task run token for server-side credential access`, + ); +} diff --git a/apps/api/src/handlers/mcp/grafana/index.ts b/apps/api/src/handlers/mcp/grafana/index.ts index 2cdc4bd03..5422ad7f1 100644 --- a/apps/api/src/handlers/mcp/grafana/index.ts +++ b/apps/api/src/handlers/mcp/grafana/index.ts @@ -1,23 +1,13 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { - and, - db, - eq, - isNull, - mcpConnections, - taskRuns, -} from '@roomote/db/server'; +import { and, db, eq, isNull, mcpConnections } from '@roomote/db/server'; import { isMcpConnectionGrafanaConfig } from '@roomote/types'; import type { Variables } from '../../../types'; -import { - isRunTokenContext, - McpProxyError, - type McpAuthContext, -} from '../proxy-utils'; +import { resolveDeploymentMcpAuth } from '../deployment-mcp-auth'; +import { McpProxyError } from '../proxy-utils'; import { registerGrafanaTools } from './tools'; const GRAFANA_MCP_SERVER_INFO = { @@ -31,51 +21,6 @@ function createGrafanaTransport() { }); } -async function resolveGrafanaMcpAuth( - authContext: Variables['authContext'], -): Promise { - if (!authContext) { - throw new McpProxyError( - 401, - 'Unauthorized: missing or invalid bearer token', - ); - } - - if (isRunTokenContext(authContext)) { - const taskRun = await db.query.taskRuns.findFirst({ - columns: { id: true }, - where: eq(taskRuns.id, authContext.runId), - }); - - if (!taskRun) { - throw new McpProxyError(404, 'Task run not found for this MCP token'); - } - - // No principal equality check: the run-scoped token IS the authorization - // (only this run's sandbox holds it), and Grafana credentials come from a - // deployment-scoped connection, so the token's userId plays no role in - // credential selection. The token's userId is mint-time attribution while - // task_runs.actingUserId is current-steering attribution — they - // legitimately diverge once a web steer or follow-up switches the acting - // user mid-run. - - return { - userId: authContext.userId, - tokenType: 'run', - runId: authContext.runId, - }; - } - - if (authContext.tokenType === 'auth') { - return { userId: authContext.userId, tokenType: 'auth' }; - } - - throw new McpProxyError( - 403, - 'Grafana MCP requires a user auth token or task run token for server-side credential access', - ); -} - async function resolveGrafanaConnection() { const connection = await db.query.mcpConnections.findFirst({ where: and( @@ -122,7 +67,7 @@ grafanaMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { const transport = createGrafanaTransport(); try { - await resolveGrafanaMcpAuth(c.get('authContext')); + await resolveDeploymentMcpAuth(c.get('authContext'), 'Grafana'); const connectionConfig = await resolveGrafanaConnection(); const server = createGrafanaMcpServer(connectionConfig); diff --git a/apps/api/src/handlers/mcp/granola/index.ts b/apps/api/src/handlers/mcp/granola/index.ts index d002ca29c..059b70b09 100644 --- a/apps/api/src/handlers/mcp/granola/index.ts +++ b/apps/api/src/handlers/mcp/granola/index.ts @@ -1,23 +1,13 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { - and, - db, - eq, - isNull, - mcpConnections, - taskRuns, -} from '@roomote/db/server'; +import { and, db, eq, isNull, mcpConnections } from '@roomote/db/server'; import { isMcpConnectionGranolaConfig } from '@roomote/types'; import type { Variables } from '../../../types'; -import { - isRunTokenContext, - McpProxyError, - type McpAuthContext, -} from '../proxy-utils'; +import { resolveDeploymentMcpAuth } from '../deployment-mcp-auth'; +import { McpProxyError } from '../proxy-utils'; import { registerGranolaTools } from './tools'; const GRANOLA_MCP_SERVER_INFO = { @@ -25,43 +15,6 @@ const GRANOLA_MCP_SERVER_INFO = { version: '1.0.0', } as const; -async function resolveGranolaMcpAuth( - authContext: Variables['authContext'], -): Promise { - if (!authContext) { - throw new McpProxyError( - 401, - 'Unauthorized: missing or invalid bearer token', - ); - } - - if (isRunTokenContext(authContext)) { - const taskRun = await db.query.taskRuns.findFirst({ - columns: { id: true }, - where: eq(taskRuns.id, authContext.runId), - }); - - if (!taskRun) { - throw new McpProxyError(404, 'Task run not found for this MCP token'); - } - - return { - userId: authContext.userId, - tokenType: 'run', - runId: authContext.runId, - }; - } - - if (authContext.tokenType === 'auth') { - return { userId: authContext.userId, tokenType: 'auth' }; - } - - throw new McpProxyError( - 403, - 'Granola MCP requires a user auth token or task run token for server-side credential access', - ); -} - async function resolveGranolaConnection() { const connection = await db.query.mcpConnections.findFirst({ where: and( @@ -109,7 +62,7 @@ granolaMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { }); try { - await resolveGranolaMcpAuth(c.get('authContext')); + await resolveDeploymentMcpAuth(c.get('authContext'), 'Granola'); const connectionConfig = await resolveGranolaConnection(); const server = createGranolaMcpServer(connectionConfig); diff --git a/apps/api/src/handlers/mcp/notion/index.ts b/apps/api/src/handlers/mcp/notion/index.ts index dd04470b6..5241c9fdf 100644 --- a/apps/api/src/handlers/mcp/notion/index.ts +++ b/apps/api/src/handlers/mcp/notion/index.ts @@ -8,17 +8,13 @@ import { eq, isNull, mcpConnections, - taskRuns, } from '@roomote/db/server'; import { isMcpConnectionNotionConfig } from '@roomote/types'; import type { Variables } from '../../../types'; -import { - isRunTokenContext, - McpProxyError, - type McpAuthContext, -} from '../proxy-utils'; +import { resolveDeploymentMcpAuth } from '../deployment-mcp-auth'; +import { McpProxyError } from '../proxy-utils'; import { registerNotionTools } from './tools'; const NOTION_MCP_SERVER_INFO = { @@ -26,43 +22,6 @@ const NOTION_MCP_SERVER_INFO = { version: '1.0.0', } as const; -async function resolveNotionMcpAuth( - authContext: Variables['authContext'], -): Promise { - if (!authContext) { - throw new McpProxyError( - 401, - 'Unauthorized: missing or invalid bearer token', - ); - } - - if (isRunTokenContext(authContext)) { - const taskRun = await db.query.taskRuns.findFirst({ - columns: { id: true }, - where: eq(taskRuns.id, authContext.runId), - }); - - if (!taskRun) { - throw new McpProxyError(404, 'Task run not found for this MCP token'); - } - - return { - userId: authContext.userId, - tokenType: 'run', - runId: authContext.runId, - }; - } - - if (authContext.tokenType === 'auth') { - return { userId: authContext.userId, tokenType: 'auth' }; - } - - throw new McpProxyError( - 403, - 'Notion MCP requires a user auth token or task run token for server-side credential access', - ); -} - async function resolveNotionConnection() { const [connection, enablement] = await Promise.all([ db.query.mcpConnections.findFirst({ @@ -119,7 +78,7 @@ notionMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { }); try { - await resolveNotionMcpAuth(c.get('authContext')); + await resolveDeploymentMcpAuth(c.get('authContext'), 'Notion'); const connection = await resolveNotionConnection(); const server = createNotionMcpServer(connection); diff --git a/apps/api/src/handlers/mcp/snowflake/index.ts b/apps/api/src/handlers/mcp/snowflake/index.ts index 51393e559..04b1834af 100644 --- a/apps/api/src/handlers/mcp/snowflake/index.ts +++ b/apps/api/src/handlers/mcp/snowflake/index.ts @@ -1,23 +1,13 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { - and, - db, - eq, - isNull, - mcpConnections, - taskRuns, -} from '@roomote/db/server'; +import { and, db, eq, isNull, mcpConnections } from '@roomote/db/server'; import { isMcpConnectionSnowflakeConfig } from '@roomote/types'; import type { Variables } from '../../../types'; -import { - isRunTokenContext, - McpProxyError, - type McpAuthContext, -} from '../proxy-utils'; +import { resolveDeploymentMcpAuth } from '../deployment-mcp-auth'; +import { McpProxyError, type McpAuthContext } from '../proxy-utils'; import { registerSnowflakeTools } from './tools'; const SNOWFLAKE_MCP_SERVER_INFO = { @@ -31,51 +21,6 @@ function createSnowflakeTransport() { }); } -async function resolveSnowflakeMcpAuth( - authContext: Variables['authContext'], -): Promise { - if (!authContext) { - throw new McpProxyError( - 401, - 'Unauthorized: missing or invalid bearer token', - ); - } - - if (isRunTokenContext(authContext)) { - const taskRun = await db.query.taskRuns.findFirst({ - columns: { id: true }, - where: eq(taskRuns.id, authContext.runId), - }); - - if (!taskRun) { - throw new McpProxyError(404, 'Task run not found for this MCP token'); - } - - // No principal equality check: the run-scoped token IS the authorization - // (only this run's sandbox holds it), and Snowflake credentials come from a - // deployment-scoped connection, so the token's userId plays no role in - // credential selection. The token's userId is mint-time attribution while - // task_runs.actingUserId is current-steering attribution — they - // legitimately diverge once a web steer or follow-up switches the acting - // user mid-run. - - return { - userId: authContext.userId, - tokenType: 'run', - runId: authContext.runId, - }; - } - - if (authContext.tokenType === 'auth') { - return { userId: authContext.userId, tokenType: 'auth' }; - } - - throw new McpProxyError( - 403, - 'Snowflake MCP requires a user auth token or task run token for server-side credential access', - ); -} - async function resolveSnowflakeConnection() { const connection = await db.query.mcpConnections.findFirst({ where: and( @@ -123,7 +68,10 @@ snowflakeMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { const transport = createSnowflakeTransport(); try { - const auth = await resolveSnowflakeMcpAuth(c.get('authContext')); + const auth = await resolveDeploymentMcpAuth( + c.get('authContext'), + 'Snowflake', + ); const connectionConfig = await resolveSnowflakeConnection(); const server = createSnowflakeMcpServer(auth, connectionConfig); diff --git a/apps/api/src/handlers/mcp/vercel/index.ts b/apps/api/src/handlers/mcp/vercel/index.ts index 6fd151bf1..b10c6797a 100644 --- a/apps/api/src/handlers/mcp/vercel/index.ts +++ b/apps/api/src/handlers/mcp/vercel/index.ts @@ -1,23 +1,13 @@ import { Hono } from 'hono'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; -import { - and, - db, - eq, - isNull, - mcpConnections, - taskRuns, -} from '@roomote/db/server'; +import { and, db, eq, isNull, mcpConnections } from '@roomote/db/server'; import { isMcpConnectionVercelConfig } from '@roomote/types'; import type { Variables } from '../../../types'; -import { - isRunTokenContext, - McpProxyError, - type McpAuthContext, -} from '../proxy-utils'; +import { resolveDeploymentMcpAuth } from '../deployment-mcp-auth'; +import { McpProxyError } from '../proxy-utils'; import { registerVercelTools } from './tools'; const VERCEL_MCP_SERVER_INFO = { @@ -31,51 +21,6 @@ function createVercelTransport() { }); } -async function resolveVercelMcpAuth( - authContext: Variables['authContext'], -): Promise { - if (!authContext) { - throw new McpProxyError( - 401, - 'Unauthorized: missing or invalid bearer token', - ); - } - - if (isRunTokenContext(authContext)) { - const taskRun = await db.query.taskRuns.findFirst({ - columns: { id: true }, - where: eq(taskRuns.id, authContext.runId), - }); - - if (!taskRun) { - throw new McpProxyError(404, 'Task run not found for this MCP token'); - } - - // No principal equality check: the run-scoped token IS the authorization - // (only this run's sandbox holds it), and Vercel credentials come from a - // deployment-scoped connection, so the token's userId plays no role in - // credential selection. The token's userId is mint-time attribution while - // task_runs.actingUserId is current-steering attribution — they - // legitimately diverge once a web steer or follow-up switches the acting - // user mid-run. - - return { - userId: authContext.userId, - tokenType: 'run', - runId: authContext.runId, - }; - } - - if (authContext.tokenType === 'auth') { - return { userId: authContext.userId, tokenType: 'auth' }; - } - - throw new McpProxyError( - 403, - 'Vercel MCP requires a user auth token or task run token for server-side credential access', - ); -} - async function resolveVercelConnection() { const connection = await db.query.mcpConnections.findFirst({ where: and( @@ -122,7 +67,7 @@ vercelMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { const transport = createVercelTransport(); try { - await resolveVercelMcpAuth(c.get('authContext')); + await resolveDeploymentMcpAuth(c.get('authContext'), 'Vercel'); const connectionConfig = await resolveVercelConnection(); const server = createVercelMcpServer(connectionConfig);