From c48255e0449837b1aaef7f7da1ed98a7db667e49 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 18:30:13 -0700 Subject: [PATCH 1/2] fix(mcp): add directory verification compatibility --- .../.well-known/openai-apps-challenge/route.ts | 9 +++++++++ apps/sim/lib/api/mcp/host-routing.test.ts | 18 ++++++++++++------ apps/sim/lib/api/mcp/host-routing.ts | 5 ++++- apps/sim/lib/api/mcp/server.ts | 3 +++ apps/sim/proxy.test.ts | 10 ++++++++++ apps/sim/proxy.ts | 7 ++++--- 6 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 apps/sim/app/.well-known/openai-apps-challenge/route.ts diff --git a/apps/sim/app/.well-known/openai-apps-challenge/route.ts b/apps/sim/app/.well-known/openai-apps-challenge/route.ts new file mode 100644 index 00000000000..ae1da9ea6fe --- /dev/null +++ b/apps/sim/app/.well-known/openai-apps-challenge/route.ts @@ -0,0 +1,9 @@ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** Public ownership challenge issued for the OpenAI plugin submission. */ +export const GET = withRouteHandler( + async () => + new Response('lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI', { + headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }, + }) +) diff --git a/apps/sim/lib/api/mcp/host-routing.test.ts b/apps/sim/lib/api/mcp/host-routing.test.ts index df8d0faa04d..021aba5ce99 100644 --- a/apps/sim/lib/api/mcp/host-routing.test.ts +++ b/apps/sim/lib/api/mcp/host-routing.test.ts @@ -30,6 +30,7 @@ describe('Sim MCP host routing', () => { it.each([ ['/mcp', '/api/mcp'], + ['/.well-known/openai-apps-challenge', '/.well-known/openai-apps-challenge'], [ '/.well-known/oauth-protected-resource/mcp', '/.well-known/oauth-protected-resource/api/mcp', @@ -40,12 +41,17 @@ describe('Sim MCP host routing', () => { expect(resolveSimMcpHostPath('MCP.SIM.AI', pathname)).toBe(target) }) - it.each(['/', '/login', '/workspace/ws-1', '/api/mcp', '/api/v2/workspaces', '/mcp/'])( - 'exposes nothing else: %s', - (pathname) => { - expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe('not_found') - } - ) + it.each([ + '/', + '/login', + '/workspace/ws-1', + '/api/mcp', + '/api/v2/workspaces', + '/mcp/', + '/.well-known/openai-apps-challenge/other', + ])('exposes nothing else: %s', (pathname) => { + expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe('not_found') + }) it.each(['mcp.sim.ai:443', 'mcp.sim.ai.', 'MCP.SIM.AI.:443'])( 'recognizes the host spelled %s', diff --git a/apps/sim/lib/api/mcp/host-routing.ts b/apps/sim/lib/api/mcp/host-routing.ts index 06579a60269..55d5597e827 100644 --- a/apps/sim/lib/api/mcp/host-routing.ts +++ b/apps/sim/lib/api/mcp/host-routing.ts @@ -3,6 +3,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' const PROTECTED_RESOURCE_METADATA = '/.well-known/oauth-protected-resource' const AUTHORIZATION_SERVER_METADATA = '/.well-known/oauth-authorization-server' +const OPENAI_APPS_CHALLENGE = '/.well-known/openai-apps-challenge' /** * A `Host` header as a URL authority under `protocol`: lower-cased, without the @@ -45,7 +46,9 @@ export function resolveSimMcpHostPath( if (pathname === mcp.pathname) return SIM_MCP_ROUTE_PATH if (pathname === `${PROTECTED_RESOURCE_METADATA}${mcp.pathname}`) return internalMetadataPath if (!dedicated) return null - return pathname === AUTHORIZATION_SERVER_METADATA ? pathname : 'not_found' + return pathname === AUTHORIZATION_SERVER_METADATA || pathname === OPENAI_APPS_CHALLENGE + ? pathname + : 'not_found' } /** diff --git a/apps/sim/lib/api/mcp/server.ts b/apps/sim/lib/api/mcp/server.ts index 4c2caa5976f..6b6dd88c7ce 100644 --- a/apps/sim/lib/api/mcp/server.ts +++ b/apps/sim/lib/api/mcp/server.ts @@ -116,6 +116,7 @@ export function createSimMcpServer(context: Omit): annotations: { title: 'Search operations', readOnlyHint: true, + destructiveHint: false, idempotentHint: true, openWorldHint: false, }, @@ -133,6 +134,7 @@ export function createSimMcpServer(context: Omit): annotations: { title: 'Describe operation', readOnlyHint: true, + destructiveHint: false, idempotentHint: true, openWorldHint: false, }, @@ -155,6 +157,7 @@ export function createSimMcpServer(context: Omit): annotations: { title: 'Read from Sim', readOnlyHint: true, + destructiveHint: false, idempotentHint: true, openWorldHint: false, }, diff --git a/apps/sim/proxy.test.ts b/apps/sim/proxy.test.ts index b36466e25e4..2d83f47c0b4 100644 --- a/apps/sim/proxy.test.ts +++ b/apps/sim/proxy.test.ts @@ -148,6 +148,16 @@ describe('proxy on the dedicated MCP host', () => { it('serves nothing else on the MCP host', () => { expect(proxy(mcpRequest('/login', 'GET')).status).toBe(404) }) + + it.each(['', 'python-requests/2.32.3'])( + 'allows ownership verification with an automated user agent: %s', + (userAgent) => { + const request = mcpRequest('/.well-known/openai-apps-challenge', 'GET') + if (userAgent) request.headers.set('user-agent', userAgent) + expect(proxy(request).status).toBe(200) + expect(proxy(mcpRequest('/.well-known/openai-apps-challenge/other', 'GET')).status).toBe(404) + } + ) }) describe('proxy matcher', () => { diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 94a11376773..326c86609d8 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -301,14 +301,15 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null { pathname.startsWith('/api/webhooks/tiktok') || pathname.startsWith('/api/webhooks/agentmail') const isMcpEndpoint = pathname.startsWith('/api/mcp/') - const isMcpOauthDiscoveryEndpoint = + const isMcpDiscoveryEndpoint = pathname.startsWith('/.well-known/oauth-authorization-server') || - pathname.startsWith('/.well-known/oauth-protected-resource') + pathname.startsWith('/.well-known/oauth-protected-resource') || + pathname === '/.well-known/openai-apps-challenge' const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent)) // Block suspicious requests, but exempt machine-to-machine endpoints that may // legitimately omit User-Agent headers (webhooks and MCP protocol discovery/calls). - if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpOauthDiscoveryEndpoint) { + if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpDiscoveryEndpoint) { logger.warn('Blocked suspicious request', { userAgent, ip: getClientIp(request), From 88d4fc78758ef959174634a4e02a270ecaf8255e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 18:46:06 -0700 Subject: [PATCH 2/2] test(mcp): verify ownership challenge over HTTP --- .github/scripts/http-e2e.sh | 4 ++ apps/sim/scripts/test-mcp-host-e2e.ts | 94 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 apps/sim/scripts/test-mcp-host-e2e.ts diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index 313122ee810..4a25c8fede0 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -111,7 +111,11 @@ case "$group" in done export NEXT_PUBLIC_FORCE_HOSTED=false export INTERNAL_API_SECRET=cli-http-ci-local-secret-at-least-32-characters + export SIM_MCP_URL=http://mcp.sim.test/mcp start_app cli 3018 CLI + MCP_HOST_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ + MCP_HOST_E2E_REPORT_PATH="$report_dir/mcp-host-e2e-report.json" \ + bun --no-env-file scripts/test-mcp-host-e2e.ts CLI_LATENCY_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ CLI_LATENCY_E2E_DATABASE_URL="$DATABASE_URL" \ CLI_LATENCY_E2E_RUNS=3 \ diff --git a/apps/sim/scripts/test-mcp-host-e2e.ts b/apps/sim/scripts/test-mcp-host-e2e.ts new file mode 100644 index 00000000000..03319f69154 --- /dev/null +++ b/apps/sim/scripts/test-mcp-host-e2e.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' + +/** Exercises the public ownership challenge and host isolation through a running local app. */ +const logger = createLogger('McpHostE2E') +const CHALLENGE_PATH = '/.well-known/openai-apps-challenge' +const EXPECTED_CHALLENGE = 'lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI' +const MCP_HOST = 'mcp.sim.test' +const startedAt = new Date().toISOString() + +const configuredBaseUrl = process.env.MCP_HOST_E2E_BASE_URL +const reportPath = process.env.MCP_HOST_E2E_REPORT_PATH +assert(configuredBaseUrl, 'MCP_HOST_E2E_BASE_URL must be explicitly provided') +assert(reportPath, 'MCP_HOST_E2E_REPORT_PATH must be explicitly provided') +const baseUrl = new URL(configuredBaseUrl) +assert(['localhost', '127.0.0.1', '[::1]'].includes(baseUrl.hostname), 'Use a loopback app') +assert.equal(baseUrl.protocol, 'http:', 'Use a local HTTP app') +assert.equal(baseUrl.pathname, '/', 'App URL must be an origin') +assert(!baseUrl.username && !baseUrl.password, 'App URL must not contain credentials') +assert.equal(process.env.SIM_MCP_URL, `http://${MCP_HOST}/mcp`, 'Configure the fixture MCP host') + +interface CheckResult { + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string +} + +const checks: CheckResult[] = [] +const requests: { path: string; userAgent: string; host: string; status: number }[] = [] + +async function request(path: string, userAgent: string, host = MCP_HOST) { + // boundary-raw-fetch: exercise the real proxy and route over local HTTP. + const response = await fetch(new URL(path, baseUrl), { + headers: { Host: host, 'User-Agent': userAgent }, + redirect: 'error', + signal: AbortSignal.timeout(120_000), + }) + requests.push({ path, userAgent, host, status: response.status }) + const body = await response.text() + return { response, body } +} + +async function check(name: string, run: () => Promise) { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) }) + logger.info(`PASS ${name}`) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Math.round(performance.now() - started), + error: getErrorMessage(error), + }) + logger.error(`FAIL ${name}`, { error: getErrorMessage(error) }) + } +} + +try { + for (const userAgent of ['', 'python-requests/2.32.3']) { + await check(`ownership challenge for ${userAgent || 'an empty User-Agent'}`, async () => { + const { response, body } = await request(CHALLENGE_PATH, userAgent) + assert.equal(response.status, 200) + assert.equal(body, EXPECTED_CHALLENGE) + assert.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8') + assert.equal(response.headers.get('cache-control'), 'no-store') + }) + } + + for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health']) { + await check(`dedicated MCP host rejects ${path}`, async () => { + const { response } = await request(path, 'Mozilla/5.0') + assert.equal(response.status, 404) + }) + } + + await check('application host still serves health checks', async () => { + const { response } = await request('/api/health', 'Mozilla/5.0', baseUrl.host) + assert.equal(response.status, 200) + }) +} finally { + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify({ startedAt, finishedAt: new Date().toISOString(), checks, requests }, null, 2) + ) +} + +if (checks.some((result) => result.status === 'failed')) process.exitCode = 1