From 6b02786cafabdfb3af1bc3fb2ee213608218bc48 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 10:00:09 -0700 Subject: [PATCH 1/2] feat(mcp): support OAuth on workflow MCP servers --- .../api/mcp/serve/[serverId]/route.test.ts | 34 +++ .../api/mcp/serve/[serverId]/route.ts | 18 ++ .../app/api/auth/oauth2/authorize/route.ts | 2 +- .../api/mcp/serve/[serverId]/route.test.ts | 209 ++++++++++++++++++ .../sim/app/api/mcp/serve/[serverId]/route.ts | 110 ++++++++- .../create-workflow-mcp-server-modal.tsx | 2 +- .../workflow-mcp-servers.tsx | 6 +- apps/sim/lib/auth/oauth-resource.test.ts | 15 ++ apps/sim/lib/auth/oauth-resource.ts | 16 +- apps/sim/lib/mcp/oauth-metadata.ts | 29 +++ 10 files changed, 422 insertions(+), 19 deletions(-) create mode 100644 apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.test.ts create mode 100644 apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.ts create mode 100644 apps/sim/lib/mcp/oauth-metadata.ts diff --git a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.test.ts new file mode 100644 index 00000000000..ecde7d10a0d --- /dev/null +++ b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.test.ts @@ -0,0 +1,34 @@ +/** @vitest-environment node */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) + +import { GET } from '@/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route' + +afterAll(resetEnvFlagsMock) + +describe('workflow MCP protected-resource metadata', () => { + it('names the workflow MCP server URL as a Sim API resource', async () => { + setEnvFlags({ isAuthDisabled: false }) + const response = await GET(new NextRequest('https://sim.test/'), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + expect(await response.json()).toEqual({ + resource: 'https://sim.test/api/mcp/serve/server-1', + resource_name: 'Sim workflow MCP server', + authorization_servers: ['https://sim.test/api/auth'], + scopes_supported: ['api:read', 'api:write'], + bearer_methods_supported: ['header'], + }) + }) + + it('does not advertise disabled OAuth', async () => { + setEnvFlags({ isAuthDisabled: true }) + const response = await GET(new NextRequest('https://sim.test/'), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + expect(response.status).toBe(404) + }) +}) diff --git a/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.ts new file mode 100644 index 00000000000..7db3da478aa --- /dev/null +++ b/apps/sim/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { mcpServeRouteParamsSchema } from '@/lib/api/contracts/mcp' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { workflowMcpResourceMetadata } from '@/lib/mcp/oauth-metadata' + +/** + * RFC 9728 metadata for a workflow MCP server. Public protocol metadata, so it + * describes the endpoint without looking the server up. + */ +export const GET = withRouteHandler( + async (_request: NextRequest, context: { params: Promise<{ serverId: string }> }) => { + if (isAuthDisabled) return new NextResponse(null, { status: 404 }) + const parsed = mcpServeRouteParamsSchema.safeParse(await context.params) + if (!parsed.success) return new NextResponse(null, { status: 404 }) + return workflowMcpResourceMetadata(parsed.data.serverId) + } +) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 7402d1c03f9..2f3870d1ea9 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -135,7 +135,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return invalidRequest( resource.kind === 'search' ? searchScopeRequired - : 'The Sim MCP server requires the api:read or api:write scope.' + : 'This MCP server requires the api:read or api:write scope.' ) } if ( diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index be62590983a..27dcaf93ae3 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -23,6 +23,8 @@ const { mockGenerateInternalToken, mockResolveBillingAttribution, mockSerializeBillingAttributionHeader, + mockVerifyOAuthAccessToken, + MockInvalidOAuthAccessTokenError, fetchMock, } = vi.hoisted(() => ({ mockExecuteWorkflowService: vi.fn(), @@ -30,6 +32,12 @@ const { mockGenerateInternalToken: vi.fn(), mockResolveBillingAttribution: vi.fn(), mockSerializeBillingAttributionHeader: vi.fn(), + mockVerifyOAuthAccessToken: vi.fn(), + MockInvalidOAuthAccessTokenError: class extends Error { + constructor(readonly reason: string) { + super('Invalid access token') + } + }, fetchMock: vi.fn(), })) @@ -82,6 +90,41 @@ const PERSONAL_API_KEY_PRINCIPAL = { keyId: 'personal-key-1', } as const +const OAUTH_WRITE_PRINCIPAL = { + kind: 'oauth_access_token', + userId: 'user-1', + clientId: 'client-1', + tokenId: 'token-1', + scopes: ['api:write', 'offline_access'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), +} as const + +const PRIVATE_SERVER = { + id: 'server-1', + name: 'Private Server', + workspaceId: 'ws-1', + isPublic: false, + createdBy: 'owner-1', + workspaceAllowsPersonalApiKeys: true, +} + +const SERVER_RESOURCE = 'http://localhost:3000/api/mcp/serve/server-1' +const SERVER_RESOURCE_METADATA = + 'http://localhost:3000/.well-known/oauth-protected-resource/api/mcp/serve/server-1' + +function toolCallRequest(headers: Record) { + return new NextRequest(SERVER_RESOURCE, { + method: 'POST', + headers: { Accept: 'application/json', ...headers }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) +} + const WORKSPACE_API_KEY_PRINCIPAL = { kind: 'workspace_api_key', workspaceId: 'ws-1', @@ -94,6 +137,13 @@ vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockGenerateInternalToken, })) +vi.mock('@/lib/auth/oauth-access-token', () => ({ + InvalidOAuthAccessTokenError: MockInvalidOAuthAccessTokenError, + parseBearerToken: (headers: Headers) => + headers.get('authorization')?.replace(/^Bearer +/i, '') || null, + verifyOAuthAccessToken: mockVerifyOAuthAccessToken, +})) + vi.mock('@/lib/core/execution-limits', () => ({ getMaxExecutionTimeout: () => 60_000, })) @@ -154,6 +204,165 @@ describe('MCP Serve Route', () => { expect(response.status).toBe(401) }) + describe('OAuth access tokens', () => { + it('challenges an unauthenticated request with the server protected-resource metadata', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: false, + error: 'Unauthorized', + }) + + const response = await POST(toolCallRequest({}), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read api:write"` + ) + }) + + it('executes as the token user when the token is bound to this server', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([PRIVATE_SERVER]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) + mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL) + mockGetUserEntityPermissions.mockResolvedValueOnce('write') + mockExecuteWorkflowService.mockResolvedValueOnce({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + resolvedSecretTraceProvenance: createResolvedSecretTraceProvenance('user-1'), + }) + + const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + + expect(response.status).toBe(200) + expect(mockVerifyOAuthAccessToken).toHaveBeenCalledWith('sim_oat_valid', { + resource: SERVER_RESOURCE, + }) + expect(hybridAuthMockFns.mockCheckHybridAuth).not.toHaveBeenCalled() + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1') + expect(mockExecuteWorkflowService).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + principal: OAUTH_WRITE_PRINCIPAL, + useAuthenticatedUserAsActor: true, + }) + ) + }) + + it('answers a token bound elsewhere with invalid_token so the client re-authorizes', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) + mockVerifyOAuthAccessToken.mockRejectedValueOnce( + new MockInvalidOAuthAccessTokenError('wrong_resource') + ) + + const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_other' }), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toBe( + `Bearer error="invalid_token", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read api:write"` + ) + expect(mockExecuteWorkflowService).not.toHaveBeenCalled() + }) + + it('asks a read-only token to step up to api:write before calling a tool', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) + mockVerifyOAuthAccessToken.mockResolvedValueOnce({ + ...OAUTH_WRITE_PRINCIPAL, + scopes: ['api:read'], + }) + mockGetUserEntityPermissions.mockResolvedValueOnce('write') + + const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_read' }), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + + expect(response.status).toBe(403) + expect(response.headers.get('www-authenticate')).toBe( + `Bearer error="insufficient_scope", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:write"` + ) + expect(mockExecuteWorkflowService).not.toHaveBeenCalled() + }) + + it('lets a read-only token initialize the session', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) + mockVerifyOAuthAccessToken.mockResolvedValueOnce({ + ...OAUTH_WRITE_PRINCIPAL, + scopes: ['api:read'], + }) + mockGetUserEntityPermissions.mockResolvedValueOnce('read') + + const response = await POST( + new NextRequest(SERVER_RESOURCE, { + method: 'POST', + headers: { Authorization: 'Bearer sim_oat_read' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }), + { params: Promise.resolve({ serverId: 'server-1' }) } + ) + + expect(response.status).toBe(200) + }) + + it('refuses a token user who is no longer a workspace member', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) + mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL) + mockGetUserEntityPermissions.mockResolvedValueOnce(null) + + const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + + expect(response.status).toBe(403) + expect(mockExecuteWorkflowService).not.toHaveBeenCalled() + }) + + it('applies the workspace personal-key policy to OAuth tokens', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { ...PRIVATE_SERVER, workspaceAllowsPersonalApiKeys: false }, + ]) + mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL) + mockGetUserEntityPermissions.mockResolvedValueOnce('write') + + const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), { + params: Promise.resolve({ serverId: 'server-1' }), + }) + const body = await response.json() + + expect(response.status).toBe(403) + expect(body.error).toBe(PERSONAL_KEY_DENIED) + expect(mockExecuteWorkflowService).not.toHaveBeenCalled() + }) + + it('prefers an API key over a bearer token when both are sent', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) + hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({ + success: false, + error: 'Invalid API key', + }) + + const response = await POST( + toolCallRequest({ Authorization: 'Bearer sim_oat_valid', 'X-API-Key': 'bad-key' }), + { params: Promise.resolve({ serverId: 'server-1' }) } + ) + + expect(response.status).toBe(401) + expect(mockVerifyOAuthAccessToken).not.toHaveBeenCalled() + }) + }) + it('returns 401 on GET for private server when auth fails', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 007de1cf4c5..5f2625149bb 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -17,7 +17,7 @@ import { SUPPORTED_PROTOCOL_VERSIONS, type Tool, } from '@modelcontextprotocol/sdk/types.js' -import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' +import { isUserCredentialPrincipal, type WorkflowExecutionPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { workflow, @@ -38,7 +38,17 @@ import { mcpToolCallParamsSchema, } from '@/lib/api/contracts/mcp' import { PERSONAL_KEY_DENIED } from '@/lib/api-key/policy-messages' -import { AuthType, checkHybridAuth } from '@/lib/auth/hybrid' +import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid' +import { + InvalidOAuthAccessTokenError, + parseBearerToken, + verifyOAuthAccessToken, +} from '@/lib/auth/oauth-access-token' +import { + OAUTH_ACCESS_TOKEN_PREFIX, + OAUTH_API_WRITE_SCOPE, + oauthScopeSatisfies, +} from '@/lib/auth/oauth-provider' import { assertBillingAttributionSnapshot, type BillingAttributionSnapshot, @@ -62,6 +72,8 @@ import { MAX_MCP_TOOLS_PER_SERVER, MAX_MCP_WORKFLOW_RESPONSE_BYTES, } from '@/lib/mcp/constants' +import { withWorkflowMcpAuthChallenge } from '@/lib/mcp/oauth-metadata' +import { buildWorkflowMcpServerUrl } from '@/lib/mcp/urls' import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' import { executeWorkflowService } from '@/lib/workflows/executor/execute-service' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -448,6 +460,51 @@ async function resolveWorkflowMcpBillingAttribution( return attribution } +/** + * A 401 that points OAuth clients at this server's protected-resource metadata, + * so they can discover Sim's authorization server and start the flow. + */ +function unauthorizedResponse(serverId: string, invalidToken = false): NextResponse { + return withWorkflowMcpAuthChallenge( + NextResponse.json( + { error: invalidToken ? 'Invalid access token' : 'Unauthorized' }, + { + status: 401, + ...(invalidToken && { headers: { 'WWW-Authenticate': 'Bearer error="invalid_token"' } }), + } + ), + serverId + ) +} + +/** + * Authenticates a Sim OAuth access token bound to this server's URL; any other + * credential goes through hybrid auth. An API key wins when both are sent, as + * on the other MCP servers. + */ +async function authenticateMcpServeRequest( + request: NextRequest, + serverId: string +): Promise { + const bearer = parseBearerToken(request.headers) + if (!bearer?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) || request.headers.has('x-api-key')) { + return checkHybridAuth(request, { requireWorkflowId: false }) + } + try { + const principal = await verifyOAuthAccessToken(bearer, { + resource: buildWorkflowMcpServerUrl(serverId), + }) + return { success: true, userId: principal.userId, principal } + } catch (error) { + if (!(error instanceof InvalidOAuthAccessTokenError)) throw error + logger.warn('Invalid OAuth access token for workflow MCP server', { + serverId, + reason: error.reason, + }) + return 'invalid_token' + } +} + async function authorizeMcpServeRequest( request: NextRequest, server: WorkflowMcpServeServer, @@ -455,9 +512,10 @@ async function authorizeMcpServeRequest( ): Promise<{ response?: NextResponse; executeAuthContext?: ExecuteAuthContext }> { if (server.isPublic && !options.requireAuthForPublic) return {} - const auth = await checkHybridAuth(request, { requireWorkflowId: false }) + const auth = await authenticateMcpServeRequest(request, server.id) + if (auth === 'invalid_token') return { response: unauthorizedResponse(server.id, true) } if (!auth.success || !auth.userId) { - return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + return { response: unauthorizedResponse(server.id) } } if (!auth.principal) { throw new Error('Authenticated MCP request is missing its principal') @@ -480,11 +538,12 @@ async function authorizeMcpServeRequest( /** * The in-process execution service receives the resolved actor, not the - * caller's original API-key type, so enforce the workspace key policy at - * this authenticated MCP boundary. + * caller's original credential type, so enforce the workspace personal-key + * policy at this authenticated MCP boundary. OAuth tokens are the same + * authorization class as personal keys. */ - const isPersonalApiKey = auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' - if (isPersonalApiKey && !server.workspaceAllowsPersonalApiKeys) { + const isUserCredential = isUserCredentialPrincipal(auth.principal) + if (isUserCredential && !server.workspaceAllowsPersonalApiKeys) { return { response: NextResponse.json({ error: PERSONAL_KEY_DENIED }, { status: 403 }), } @@ -493,12 +552,42 @@ async function authorizeMcpServeRequest( return { executeAuthContext: { userId: auth.userId, - useAuthenticatedUserAsActor: isPersonalApiKey, + useAuthenticatedUserAsActor: isUserCredential, principal: auth.principal, }, } } +/** + * Calling a tool runs a workflow, so an OAuth token needs `api:write`. The + * `insufficient_scope` challenge lets the client step up to exactly that scope. + */ +function insufficientToolCallScopeResponse( + id: RequestId, + serverId: string, + executeAuthContext: ExecuteAuthContext | null +): NextResponse | null { + const principal = executeAuthContext?.principal + if (principal?.kind !== 'oauth_access_token') return null + if (oauthScopeSatisfies(principal.scopes, OAUTH_API_WRITE_SCOPE)) return null + return withWorkflowMcpAuthChallenge( + NextResponse.json( + createError( + id, + ErrorCode.InvalidRequest, + `Calling tools requires the ${OAUTH_API_WRITE_SCOPE} scope` + ), + { + status: 403, + headers: { + 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${OAUTH_API_WRITE_SCOPE}"`, + }, + } + ), + serverId + ) +} + function unsupportedSseGetResponse(): NextResponse { return NextResponse.json( { @@ -640,6 +729,9 @@ export const POST = withRouteHandler( return handleToolsList(id, serverId, rpcParams) case 'tools/call': { + const scopeResponse = insufficientToolCallScopeResponse(id, serverId, executeAuthContext) + if (scopeResponse) return scopeResponse + const paramsValidation = mcpToolCallParamsSchema.safeParse(rpcParams) if (!paramsValidation.success) { return NextResponse.json( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx index 92cb40126b6..7e8992c2866 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components/create-workflow-mcp-server-modal/create-workflow-mcp-server-modal.tsx @@ -119,7 +119,7 @@ export function CreateWorkflowMcpServerModal({ value={formData.isPublic ? 'public' : 'private'} onValueChange={(value) => setFormData({ ...formData, isPublic: value === 'public' })} > - API Key + Private Public {formData.isPublic && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx index c0bbd87f022..f712133d9b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/workflow-mcp-servers.tsx @@ -487,7 +487,7 @@ function ServerDetailView({ {server.name} Streamable-HTTP - {server.isPublic ? 'Public' : 'API Key'} + {server.isPublic ? 'Public' : 'Private'} @@ -855,13 +855,13 @@ function ServerDetailView({ value={editServerIsPublic ? 'public' : 'private'} onValueChange={(value) => setEditServerIsPublic(value === 'public')} > - API Key + Private Public

{editServerIsPublic ? 'Anyone with the URL can call this server without authentication' - : 'Requests must include your Sim API key in the X-API-Key header'} + : 'Clients sign in with OAuth, or send a Sim API key in the X-API-Key header'}

diff --git a/apps/sim/lib/auth/oauth-resource.test.ts b/apps/sim/lib/auth/oauth-resource.test.ts index 2ae67989972..55655c1a903 100644 --- a/apps/sim/lib/auth/oauth-resource.test.ts +++ b/apps/sim/lib/auth/oauth-resource.test.ts @@ -17,6 +17,7 @@ import { const resource = 'https://sim.example/api/mcp/search/organizations/org-one' const simMcpResource = 'https://sim.example/api/mcp' const otherResource = 'https://sim.example/api/mcp/search/organizations/org-two' +const workflowMcpResource = 'https://sim.example/api/mcp/serve/server-one' const scopes = ['search:read', 'offline_access'] describe('OAuth resource binding', () => { @@ -26,6 +27,13 @@ describe('OAuth resource binding', () => { expect(parseOAuthResource(null)).toBeNull() }) + it('accepts exact workflow MCP server endpoints as Sim API audiences', () => { + expect(parseOAuthResource(workflowMcpResource)).toEqual({ + kind: 'api', + url: workflowMcpResource, + }) + }) + it.each([ '', 'https://sim.example/api/mcp/search/workspace-one', @@ -44,6 +52,13 @@ describe('OAuth resource binding', () => { 'https://sim.example/api/mcp/', 'https://sim.example/api/mcp?workspaceId=ws-1', 'https://attacker.example/api/mcp', + 'https://sim.example/api/mcp/serve', + 'https://sim.example/api/mcp/serve/', + 'https://sim.example/api/mcp/serve/server-one/', + 'https://sim.example/api/mcp/serve/server-one?x=1', + 'https://sim.example/api/mcp/serve/a/../server-one', + 'https://sim.example/api/mcp/serve/server%2Done', + 'https://attacker.example/api/mcp/serve/server-one', ])('rejects noncanonical or unsupported resources: %s', (value) => { expect(() => parseOAuthResource(value)).toThrow(InvalidOAuthResourceError) }) diff --git a/apps/sim/lib/auth/oauth-resource.ts b/apps/sim/lib/auth/oauth-resource.ts index 3ba42fb3bc7..7d1c01f8a76 100644 --- a/apps/sim/lib/auth/oauth-resource.ts +++ b/apps/sim/lib/auth/oauth-resource.ts @@ -16,7 +16,8 @@ interface OAuthResourceIssuance { /** * An RFC 8707 audience this deployment issues tokens for. `api` is the Sim MCP - * server, which serves the Sim API; `search` is an organization's Search server. + * server, which serves the Sim API, or a workspace's workflow MCP server; + * `search` is an organization's Search server. */ export interface OAuthResource { kind: OAuthResourceKind @@ -25,6 +26,7 @@ export interface OAuthResource { const issuance = new AsyncLocalStorage() const SEARCH_RESOURCE_PATH = /^\/api\/mcp\/search\/organizations\/[A-Za-z0-9_-]{1,128}$/ +const WORKFLOW_MCP_RESOURCE_PATH = /^\/api\/mcp\/serve\/[A-Za-z0-9_-]{1,128}$/ export class InvalidOAuthResourceError extends Error { constructor() { @@ -33,7 +35,10 @@ export class InvalidOAuthResourceError extends Error { } } -/** Accepts only this deployment's canonical Sim MCP URL and its organization Search endpoints. */ +/** + * Accepts only this deployment's canonical Sim MCP URL, its organization Search + * endpoints, and its workflow MCP server endpoints. + */ export function parseOAuthResource(value: string | null): OAuthResource | null { if (value === null) return null if (value === getSimMcpUrl()) return { kind: 'api', url: value } @@ -46,12 +51,13 @@ export function parseOAuthResource(value: string | null): OAuthResource | null { url.username || url.password || url.search || - url.hash || - !SEARCH_RESOURCE_PATH.test(url.pathname) + url.hash ) { throw new InvalidOAuthResourceError() } - return { kind: 'search', url: value } + if (SEARCH_RESOURCE_PATH.test(url.pathname)) return { kind: 'search', url: value } + if (WORKFLOW_MCP_RESOURCE_PATH.test(url.pathname)) return { kind: 'api', url: value } + throw new InvalidOAuthResourceError() } /** diff --git a/apps/sim/lib/mcp/oauth-metadata.ts b/apps/sim/lib/mcp/oauth-metadata.ts new file mode 100644 index 00000000000..3fedbb44e70 --- /dev/null +++ b/apps/sim/lib/mcp/oauth-metadata.ts @@ -0,0 +1,29 @@ +import { + type OAuthProtectedResource, + protectedResourceMetadataResponse, + withOAuthResourceChallenge, +} from '@/lib/auth/oauth-protected-resource' +import { OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE } from '@/lib/auth/oauth-provider' +import { buildWorkflowMcpServerUrl } from '@/lib/mcp/urls' + +/** + * Listing tools needs `api:read`; calling one runs a workflow, so it needs + * `api:write`. `offline_access` is the authorization server's to grant. + */ +const WORKFLOW_MCP_SCOPES = [OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE] as const + +function workflowMcpResource(serverId: string): OAuthProtectedResource { + return { + resource: buildWorkflowMcpServerUrl(serverId), + name: 'Sim workflow MCP server', + scopes: WORKFLOW_MCP_SCOPES, + } +} + +export function workflowMcpResourceMetadata(serverId: string) { + return protectedResourceMetadataResponse(workflowMcpResource(serverId)) +} + +export function withWorkflowMcpAuthChallenge(response: T, serverId: string): T { + return withOAuthResourceChallenge(response, workflowMcpResource(serverId)) +} From 57f8bf4bc02dce22d6db0bc7ebd24e3b393cde88 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 10:07:32 -0700 Subject: [PATCH 2/2] fix(mcp): require api:read for OAuth tokens on workflow MCP servers --- .../api/mcp/serve/[serverId]/route.test.ts | 23 +++++++ .../sim/app/api/mcp/serve/[serverId]/route.ts | 61 ++++++++++++------- 2 files changed, 63 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 27dcaf93ae3..d72f720a0da 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -296,6 +296,29 @@ describe('MCP Serve Route', () => { expect(mockExecuteWorkflowService).not.toHaveBeenCalled() }) + it('refuses a token without api:read before serving tool metadata', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) + mockVerifyOAuthAccessToken.mockResolvedValueOnce({ + ...OAUTH_WRITE_PRINCIPAL, + scopes: ['offline_access'], + }) + + const response = await POST( + new NextRequest(SERVER_RESOURCE, { + method: 'POST', + headers: { Authorization: 'Bearer sim_oat_offline' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }), + }), + { params: Promise.resolve({ serverId: 'server-1' }) } + ) + + expect(response.status).toBe(403) + expect(response.headers.get('www-authenticate')).toBe( + `Bearer error="insufficient_scope", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read"` + ) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + it('lets a read-only token initialize the session', async () => { dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER]) mockVerifyOAuthAccessToken.mockResolvedValueOnce({ diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 5f2625149bb..5a736c11339 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -46,7 +46,9 @@ import { } from '@/lib/auth/oauth-access-token' import { OAUTH_ACCESS_TOKEN_PREFIX, + OAUTH_API_READ_SCOPE, OAUTH_API_WRITE_SCOPE, + type OAuthApiScope, oauthScopeSatisfies, } from '@/lib/auth/oauth-provider' import { @@ -477,15 +479,34 @@ function unauthorizedResponse(serverId: string, invalidToken = false): NextRespo ) } +/** + * A 403 whose `insufficient_scope` challenge lets an OAuth client step up to + * exactly the scope the request needed. + */ +function insufficientScopeResponse( + serverId: string, + scope: OAuthApiScope, + body: unknown +): NextResponse { + return withWorkflowMcpAuthChallenge( + NextResponse.json(body, { + status: 403, + headers: { 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${scope}"` }, + }), + serverId + ) +} + /** * Authenticates a Sim OAuth access token bound to this server's URL; any other * credential goes through hybrid auth. An API key wins when both are sent, as - * on the other MCP servers. + * on the other MCP servers. Every method reads the server's tools, so a token + * needs at least `api:read` (`api:write` implies it). */ async function authenticateMcpServeRequest( request: NextRequest, serverId: string -): Promise { +): Promise { const bearer = parseBearerToken(request.headers) if (!bearer?.startsWith(OAUTH_ACCESS_TOKEN_PREFIX) || request.headers.has('x-api-key')) { return checkHybridAuth(request, { requireWorkflowId: false }) @@ -494,6 +515,7 @@ async function authenticateMcpServeRequest( const principal = await verifyOAuthAccessToken(bearer, { resource: buildWorkflowMcpServerUrl(serverId), }) + if (!oauthScopeSatisfies(principal.scopes, OAUTH_API_READ_SCOPE)) return 'insufficient_scope' return { success: true, userId: principal.userId, principal } } catch (error) { if (!(error instanceof InvalidOAuthAccessTokenError)) throw error @@ -514,6 +536,13 @@ async function authorizeMcpServeRequest( const auth = await authenticateMcpServeRequest(request, server.id) if (auth === 'invalid_token') return { response: unauthorizedResponse(server.id, true) } + if (auth === 'insufficient_scope') { + return { + response: insufficientScopeResponse(server.id, OAUTH_API_READ_SCOPE, { + error: `This server requires the ${OAUTH_API_READ_SCOPE} scope`, + }), + } + } if (!auth.success || !auth.userId) { return { response: unauthorizedResponse(server.id) } } @@ -558,10 +587,7 @@ async function authorizeMcpServeRequest( } } -/** - * Calling a tool runs a workflow, so an OAuth token needs `api:write`. The - * `insufficient_scope` challenge lets the client step up to exactly that scope. - */ +/** Calling a tool runs a workflow, so an OAuth token needs `api:write`. */ function insufficientToolCallScopeResponse( id: RequestId, serverId: string, @@ -570,21 +596,14 @@ function insufficientToolCallScopeResponse( const principal = executeAuthContext?.principal if (principal?.kind !== 'oauth_access_token') return null if (oauthScopeSatisfies(principal.scopes, OAUTH_API_WRITE_SCOPE)) return null - return withWorkflowMcpAuthChallenge( - NextResponse.json( - createError( - id, - ErrorCode.InvalidRequest, - `Calling tools requires the ${OAUTH_API_WRITE_SCOPE} scope` - ), - { - status: 403, - headers: { - 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${OAUTH_API_WRITE_SCOPE}"`, - }, - } - ), - serverId + return insufficientScopeResponse( + serverId, + OAUTH_API_WRITE_SCOPE, + createError( + id, + ErrorCode.InvalidRequest, + `Calling tools requires the ${OAUTH_API_WRITE_SCOPE} scope` + ) ) }