fix(mcp): add shared operations and secure scene APIs

This commit is contained in:
Aymeric Rabot
2026-04-27 17:10:25 -04:00
parent 058ee747c9
commit 8dfd1a9429
81 changed files with 1332 additions and 1786 deletions
+36
View File
@@ -62,3 +62,39 @@ test('connectHttp close() stops the server', async () => {
}
expect(didConnect).toBe(false)
})
test('connectHttp requires auth when binding a non-loopback host', async () => {
await expect(connectHttp(server, 0, { host: '0.0.0.0' })).rejects.toThrow(
/requires PASCAL_MCP_HTTP_TOKEN/,
)
})
test('connectHttp rejects unauthenticated requests when a token is configured', async () => {
handle = await connectHttp(server, 0, { authToken: 'secret' })
const response = await fetch(`http://127.0.0.1:${handle.port}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
})
expect(response.status).toBe(401)
})
test('connectHttp handles allowed CORS preflight', async () => {
handle = await connectHttp(server, 0, {
authToken: 'secret',
allowedOrigins: ['https://app.example'],
})
const response = await fetch(`http://127.0.0.1:${handle.port}/mcp`, {
method: 'OPTIONS',
headers: {
origin: 'https://app.example',
'access-control-request-method': 'POST',
},
})
expect(response.status).toBe(204)
expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example')
})
+189 -6
View File
@@ -1,15 +1,38 @@
import { randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { randomUUID, timingSafeEqual } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_RATE_LIMIT_PER_MINUTE = 120
const WINDOW_MS = 60_000
const ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS'
const ALLOWED_HEADERS =
'authorization, content-type, mcp-session-id, mcp-protocol-version, x-pascal-mcp-token'
export type HttpTransportHandle = {
/** Host interface the server is listening on. */
host: string
/** Port the server is actually listening on (useful when caller passed 0). */
port: number
/** Gracefully close the HTTP server and the MCP transport. */
close(): Promise<void>
}
export type HttpTransportOptions = {
/**
* Network interface to bind. Defaults to loopback. Binding to a non-loopback
* interface requires an auth token.
*/
host?: string
/** Bearer token for HTTP MCP calls. Defaults to PASCAL_MCP_HTTP_TOKEN. */
authToken?: string
/** Exact CORS origins allowed to call this transport. Loopback origins are allowed. */
allowedOrigins?: string[]
/** Per-client request cap per minute. Set <= 0 to disable. */
rateLimitPerMinute?: number
}
/**
* Attach an `McpServer` to a Streamable HTTP transport bound to a local port.
*
@@ -17,17 +40,36 @@ export type HttpTransportHandle = {
* `IncomingMessage`/`ServerResponse` directly via `handleRequest(req, res)`.
* A new session ID is generated per connection (stateful mode).
*
* Listens on `0.0.0.0:<port>` (pass `0` for an ephemeral port in tests). The
* Listens on `127.0.0.1:<port>` (pass `0` for an ephemeral port in tests). The
* returned handle exposes the actual bound port and a `close()` that stops
* the underlying Node HTTP server.
* the underlying Node HTTP server. To bind a public interface, pass `host` and
* configure an auth token.
*/
export async function connectHttp(server: McpServer, port: number): Promise<HttpTransportHandle> {
export async function connectHttp(
server: McpServer,
port: number,
options: HttpTransportOptions = {},
): Promise<HttpTransportHandle> {
const host = options.host ?? DEFAULT_HOST
const authToken = options.authToken ?? process.env.PASCAL_MCP_HTTP_TOKEN
if (!isLoopbackHost(host) && !authToken) {
throw new Error(
'HTTP transport on a non-loopback host requires PASCAL_MCP_HTTP_TOKEN or authToken',
)
}
const guard = createHttpGuard({
authToken,
allowedOrigins: options.allowedOrigins ?? envAllowedOrigins(),
rateLimitPerMinute: options.rateLimitPerMinute ?? DEFAULT_RATE_LIMIT_PER_MINUTE,
})
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
})
await server.connect(transport)
const httpServer = createServer((req, res) => {
if (!guard(req, res)) return
transport.handleRequest(req, res).catch((err) => {
// Log to stderr; never touch stdout (stdio transport uses it).
console.error('[pascal-mcp] http transport error', err)
@@ -52,13 +94,14 @@ export async function connectHttp(server: McpServer, port: number): Promise<Http
}
httpServer.once('error', onError)
httpServer.once('listening', onListening)
httpServer.listen(port)
httpServer.listen(port, host)
})
const address = httpServer.address()
const boundPort = typeof address === 'object' && address !== null ? address.port : port
return {
host,
port: boundPort,
close: async () => {
await new Promise<void>((resolve, reject) => {
@@ -71,3 +114,143 @@ export async function connectHttp(server: McpServer, port: number): Promise<Http
},
}
}
function createHttpGuard(options: {
authToken?: string
allowedOrigins: string[]
rateLimitPerMinute: number
}): (req: IncomingMessage, res: ServerResponse) => boolean {
const buckets = new Map<string, { count: number; resetAt: number }>()
const allowedOrigins = new Set(
options.allowedOrigins
.map(normalizeOrigin)
.filter((origin): origin is string => origin !== null),
)
return (req, res) => {
const origin = req.headers.origin
if (origin && !isOriginAllowed(origin, req.headers.host, allowedOrigins)) {
sendJson(res, 403, { error: 'origin_not_allowed' })
return false
}
applyCors(req, res, allowedOrigins)
if (req.method === 'OPTIONS') {
res.writeHead(204).end()
return false
}
const pathname = req.url ? new URL(req.url, 'http://localhost').pathname : '/'
if (pathname !== '/mcp') {
sendJson(res, 404, { error: 'not_found' })
return false
}
if (options.authToken) {
const supplied = bearerToken(req) ?? headerValue(req.headers['x-pascal-mcp-token'])
if (!supplied || !safeEqual(supplied, options.authToken)) {
sendJson(res, 401, { error: 'unauthorized' })
return false
}
}
if (options.rateLimitPerMinute > 0) {
const now = Date.now()
const key = req.socket.remoteAddress ?? 'unknown'
const bucket = buckets.get(key)
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + WINDOW_MS })
} else {
bucket.count++
if (bucket.count > options.rateLimitPerMinute) {
res.setHeader('Retry-After', Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)))
sendJson(res, 429, { error: 'rate_limited' })
return false
}
}
}
return true
}
}
function applyCors(req: IncomingMessage, res: ServerResponse, allowedOrigins: Set<string>): void {
const origin = req.headers.origin
if (origin && isOriginAllowed(origin, req.headers.host, allowedOrigins)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
}
res.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS)
res.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS)
res.setHeader('X-Content-Type-Options', 'nosniff')
}
function isOriginAllowed(
origin: string,
requestHost: string | undefined,
allowedOrigins: Set<string>,
): boolean {
const normalized = normalizeOrigin(origin)
if (!normalized) return false
const parsed = new URL(normalized)
if (isLoopbackHost(parsed.hostname)) return true
if (requestHost && normalized === normalizeOrigin(`http://${requestHost}`)) return true
if (requestHost && normalized === normalizeOrigin(`https://${requestHost}`)) return true
return allowedOrigins.has(normalized)
}
function bearerToken(req: IncomingMessage): string | null {
const header = headerValue(req.headers.authorization)
if (!header) return null
const match = header.match(/^Bearer\s+(.+)$/i)
return match?.[1] ?? null
}
function headerValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) return value[0] ?? null
return value ?? null
}
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
if (!res.hasHeader('Content-Type')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
}
res.writeHead(status).end(JSON.stringify(payload))
}
function safeEqual(a: string, b: string): boolean {
const aBuffer = Buffer.from(a)
const bBuffer = Buffer.from(b)
if (aBuffer.length !== bBuffer.length) return false
return timingSafeEqual(aBuffer, bBuffer)
}
function envAllowedOrigins(): string[] {
return (process.env.PASCAL_MCP_HTTP_ORIGINS ?? '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean)
}
function normalizeOrigin(origin: string): string | null {
try {
const url = new URL(origin)
return `${url.protocol}//${url.host}`.toLowerCase()
} catch {
return null
}
}
function isLoopbackHost(host: string): boolean {
const h = stripPort(host).toLowerCase()
return h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '::1'
}
function stripPort(host: string): string {
if (host.startsWith('[')) {
const end = host.indexOf(']')
return end === -1 ? host : host.slice(1, end)
}
return host.split(':')[0] ?? host
}