Skip to content

Commit 66e535d

Browse files
authored
fix(mcp): add directory verification compatibility (#8825)
* fix(mcp): add directory verification compatibility * test(mcp): verify ownership challenge over HTTP
1 parent ee5646c commit 66e535d

8 files changed

Lines changed: 140 additions & 10 deletions

File tree

‎.github/scripts/http-e2e.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,11 @@ case "$group" in
111111
done
112112
export NEXT_PUBLIC_FORCE_HOSTED=false
113113
export INTERNAL_API_SECRET=cli-http-ci-local-secret-at-least-32-characters
114+
export SIM_MCP_URL=http://mcp.sim.test/mcp
114115
start_app cli 3018 CLI
116+
MCP_HOST_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
117+
MCP_HOST_E2E_REPORT_PATH="$report_dir/mcp-host-e2e-report.json" \
118+
bun --no-env-file scripts/test-mcp-host-e2e.ts
115119
CLI_LATENCY_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
116120
CLI_LATENCY_E2E_DATABASE_URL="$DATABASE_URL" \
117121
CLI_LATENCY_E2E_RUNS=3 \
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2+
3+
/** Public ownership challenge issued for the OpenAI plugin submission. */
4+
export const GET = withRouteHandler(
5+
async () =>
6+
new Response('lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI', {
7+
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
8+
})
9+
)

‎apps/sim/lib/api/mcp/host-routing.test.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe('Sim MCP host routing', () => {
3030

3131
it.each([
3232
['/mcp', '/api/mcp'],
33+
['/.well-known/openai-apps-challenge', '/.well-known/openai-apps-challenge'],
3334
[
3435
'/.well-known/oauth-protected-resource/mcp',
3536
'/.well-known/oauth-protected-resource/api/mcp',
@@ -40,12 +41,17 @@ describe('Sim MCP host routing', () => {
4041
expect(resolveSimMcpHostPath('MCP.SIM.AI', pathname)).toBe(target)
4142
})
4243

43-
it.each(['/', '/login', '/workspace/ws-1', '/api/mcp', '/api/v2/workspaces', '/mcp/'])(
44-
'exposes nothing else: %s',
45-
(pathname) => {
46-
expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe('not_found')
47-
}
48-
)
44+
it.each([
45+
'/',
46+
'/login',
47+
'/workspace/ws-1',
48+
'/api/mcp',
49+
'/api/v2/workspaces',
50+
'/mcp/',
51+
'/.well-known/openai-apps-challenge/other',
52+
])('exposes nothing else: %s', (pathname) => {
53+
expect(resolveSimMcpHostPath('mcp.sim.ai', pathname)).toBe('not_found')
54+
})
4955

5056
it.each(['mcp.sim.ai:443', 'mcp.sim.ai.', 'MCP.SIM.AI.:443'])(
5157
'recognizes the host spelled %s',

‎apps/sim/lib/api/mcp/host-routing.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
33

44
const PROTECTED_RESOURCE_METADATA = '/.well-known/oauth-protected-resource'
55
const AUTHORIZATION_SERVER_METADATA = '/.well-known/oauth-authorization-server'
6+
const OPENAI_APPS_CHALLENGE = '/.well-known/openai-apps-challenge'
67

78
/**
89
* A `Host` header as a URL authority under `protocol`: lower-cased, without the
@@ -45,7 +46,9 @@ export function resolveSimMcpHostPath(
4546
if (pathname === mcp.pathname) return SIM_MCP_ROUTE_PATH
4647
if (pathname === `${PROTECTED_RESOURCE_METADATA}${mcp.pathname}`) return internalMetadataPath
4748
if (!dedicated) return null
48-
return pathname === AUTHORIZATION_SERVER_METADATA ? pathname : 'not_found'
49+
return pathname === AUTHORIZATION_SERVER_METADATA || pathname === OPENAI_APPS_CHALLENGE
50+
? pathname
51+
: 'not_found'
4952
}
5053

5154
/**

‎apps/sim/lib/api/mcp/server.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
116116
annotations: {
117117
title: 'Search operations',
118118
readOnlyHint: true,
119+
destructiveHint: false,
119120
idempotentHint: true,
120121
openWorldHint: false,
121122
},
@@ -133,6 +134,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
133134
annotations: {
134135
title: 'Describe operation',
135136
readOnlyHint: true,
137+
destructiveHint: false,
136138
idempotentHint: true,
137139
openWorldHint: false,
138140
},
@@ -155,6 +157,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
155157
annotations: {
156158
title: 'Read from Sim',
157159
readOnlyHint: true,
160+
destructiveHint: false,
158161
idempotentHint: true,
159162
openWorldHint: false,
160163
},

‎apps/sim/proxy.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,16 @@ describe('proxy on the dedicated MCP host', () => {
148148
it('serves nothing else on the MCP host', () => {
149149
expect(proxy(mcpRequest('/login', 'GET')).status).toBe(404)
150150
})
151+
152+
it.each(['', 'python-requests/2.32.3'])(
153+
'allows ownership verification with an automated user agent: %s',
154+
(userAgent) => {
155+
const request = mcpRequest('/.well-known/openai-apps-challenge', 'GET')
156+
if (userAgent) request.headers.set('user-agent', userAgent)
157+
expect(proxy(request).status).toBe(200)
158+
expect(proxy(mcpRequest('/.well-known/openai-apps-challenge/other', 'GET')).status).toBe(404)
159+
}
160+
)
151161
})
152162

153163
describe('proxy matcher', () => {

‎apps/sim/proxy.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,14 +301,15 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
301301
pathname.startsWith('/api/webhooks/tiktok') ||
302302
pathname.startsWith('/api/webhooks/agentmail')
303303
const isMcpEndpoint = pathname.startsWith('/api/mcp/')
304-
const isMcpOauthDiscoveryEndpoint =
304+
const isMcpDiscoveryEndpoint =
305305
pathname.startsWith('/.well-known/oauth-authorization-server') ||
306-
pathname.startsWith('/.well-known/oauth-protected-resource')
306+
pathname.startsWith('/.well-known/oauth-protected-resource') ||
307+
pathname === '/.well-known/openai-apps-challenge'
307308
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
308309

309310
// Block suspicious requests, but exempt machine-to-machine endpoints that may
310311
// legitimately omit User-Agent headers (webhooks and MCP protocol discovery/calls).
311-
if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpOauthDiscoveryEndpoint) {
312+
if (isSuspicious && !isWebhookEndpoint && !isMcpEndpoint && !isMcpDiscoveryEndpoint) {
312313
logger.warn('Blocked suspicious request', {
313314
userAgent,
314315
ip: getClientIp(request),
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import assert from 'node:assert/strict'
2+
import { mkdir, writeFile } from 'node:fs/promises'
3+
import { dirname } from 'node:path'
4+
import { createLogger } from '@sim/logger'
5+
import { getErrorMessage } from '@sim/utils/errors'
6+
7+
/** Exercises the public ownership challenge and host isolation through a running local app. */
8+
const logger = createLogger('McpHostE2E')
9+
const CHALLENGE_PATH = '/.well-known/openai-apps-challenge'
10+
const EXPECTED_CHALLENGE = 'lFJ1-XIWpHGRNcgzTPl2Y_yYCTWLvlIbAvNRD08EvLI'
11+
const MCP_HOST = 'mcp.sim.test'
12+
const startedAt = new Date().toISOString()
13+
14+
const configuredBaseUrl = process.env.MCP_HOST_E2E_BASE_URL
15+
const reportPath = process.env.MCP_HOST_E2E_REPORT_PATH
16+
assert(configuredBaseUrl, 'MCP_HOST_E2E_BASE_URL must be explicitly provided')
17+
assert(reportPath, 'MCP_HOST_E2E_REPORT_PATH must be explicitly provided')
18+
const baseUrl = new URL(configuredBaseUrl)
19+
assert(['localhost', '127.0.0.1', '[::1]'].includes(baseUrl.hostname), 'Use a loopback app')
20+
assert.equal(baseUrl.protocol, 'http:', 'Use a local HTTP app')
21+
assert.equal(baseUrl.pathname, '/', 'App URL must be an origin')
22+
assert(!baseUrl.username && !baseUrl.password, 'App URL must not contain credentials')
23+
assert.equal(process.env.SIM_MCP_URL, `http://${MCP_HOST}/mcp`, 'Configure the fixture MCP host')
24+
25+
interface CheckResult {
26+
name: string
27+
status: 'passed' | 'failed'
28+
durationMs: number
29+
error?: string
30+
}
31+
32+
const checks: CheckResult[] = []
33+
const requests: { path: string; userAgent: string; host: string; status: number }[] = []
34+
35+
async function request(path: string, userAgent: string, host = MCP_HOST) {
36+
// boundary-raw-fetch: exercise the real proxy and route over local HTTP.
37+
const response = await fetch(new URL(path, baseUrl), {
38+
headers: { Host: host, 'User-Agent': userAgent },
39+
redirect: 'error',
40+
signal: AbortSignal.timeout(120_000),
41+
})
42+
requests.push({ path, userAgent, host, status: response.status })
43+
const body = await response.text()
44+
return { response, body }
45+
}
46+
47+
async function check(name: string, run: () => Promise<void>) {
48+
const started = performance.now()
49+
try {
50+
await run()
51+
checks.push({ name, status: 'passed', durationMs: Math.round(performance.now() - started) })
52+
logger.info(`PASS ${name}`)
53+
} catch (error) {
54+
checks.push({
55+
name,
56+
status: 'failed',
57+
durationMs: Math.round(performance.now() - started),
58+
error: getErrorMessage(error),
59+
})
60+
logger.error(`FAIL ${name}`, { error: getErrorMessage(error) })
61+
}
62+
}
63+
64+
try {
65+
for (const userAgent of ['', 'python-requests/2.32.3']) {
66+
await check(`ownership challenge for ${userAgent || 'an empty User-Agent'}`, async () => {
67+
const { response, body } = await request(CHALLENGE_PATH, userAgent)
68+
assert.equal(response.status, 200)
69+
assert.equal(body, EXPECTED_CHALLENGE)
70+
assert.equal(response.headers.get('content-type'), 'text/plain; charset=utf-8')
71+
assert.equal(response.headers.get('cache-control'), 'no-store')
72+
})
73+
}
74+
75+
for (const path of [`${CHALLENGE_PATH}/extra`, '/login', '/api/health']) {
76+
await check(`dedicated MCP host rejects ${path}`, async () => {
77+
const { response } = await request(path, 'Mozilla/5.0')
78+
assert.equal(response.status, 404)
79+
})
80+
}
81+
82+
await check('application host still serves health checks', async () => {
83+
const { response } = await request('/api/health', 'Mozilla/5.0', baseUrl.host)
84+
assert.equal(response.status, 200)
85+
})
86+
} finally {
87+
await mkdir(dirname(reportPath), { recursive: true })
88+
await writeFile(
89+
reportPath,
90+
JSON.stringify({ startedAt, finishedAt: new Date().toISOString(), checks, requests }, null, 2)
91+
)
92+
}
93+
94+
if (checks.some((result) => result.status === 'failed')) process.exitCode = 1

0 commit comments

Comments
 (0)