Skip to content

Commit 88d4fc7

Browse files
committed
test(mcp): verify ownership challenge over HTTP
1 parent c48255e commit 88d4fc7

2 files changed

Lines changed: 98 additions & 0 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: 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)