Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/scripts/http-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/app/.well-known/openai-apps-challenge/route.ts
Original file line number Diff line number Diff line change
@@ -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' },
})
)
18 changes: 12 additions & 6 deletions apps/sim/lib/api/mcp/host-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/api/mcp/host-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
}

/**
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/lib/api/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
annotations: {
title: 'Search operations',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
Expand All @@ -133,6 +134,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
annotations: {
title: 'Describe operation',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
Expand All @@ -155,6 +157,7 @@ export function createSimMcpServer(context: Omit<McpDispatchContext, 'signal'>):
annotations: {
title: 'Read from Sim',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
Expand Down
10 changes: 10 additions & 0 deletions apps/sim/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
waleedlatif1 marked this conversation as resolved.
expect(proxy(mcpRequest('/.well-known/openai-apps-challenge/other', 'GET')).status).toBe(404)
}
)
})

describe('proxy matcher', () => {
Expand Down
7 changes: 4 additions & 3 deletions apps/sim/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
94 changes: 94 additions & 0 deletions apps/sim/scripts/test-mcp-host-e2e.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
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
Loading