UNPKG

mcp-handler

Version:

Framework-agnostic HTTP adapter for Model Context Protocol servers

832 lines (814 loc) • 27.7 kB
'use strict'; require('./chunk-Y2F7D3TJ.js'); var server = require('@modelcontextprotocol/server'); // src/lib/log-helper.ts function createEvent(event) { return { ...event, timestamp: Date.now() }; } // src/webmcp/script-handler.ts function createWebMcpScriptHandler(options) { const { endpoint, tools, credentials = "same-origin", cacheControl = "public, max-age=300" } = options; if (typeof endpoint !== "string" || endpoint.length === 0) { throw new Error("createWebMcpScriptHandler: `endpoint` is required"); } if (!Array.isArray(tools) || tools.some((name2) => typeof name2 !== "string" || name2.length === 0)) { throw new Error( "createWebMcpScriptHandler: `tools` must be an array of tool names \u2014 only allowlisted tools are exposed to the web" ); } const script = buildScript({ endpoint, tools, credentials }); const headers = { "content-type": "text/javascript; charset=utf-8", "cache-control": cacheControl }; return function webMcpScriptHandler(req) { if (req.method === "HEAD") { return new Response(null, { status: 200, headers }); } if (req.method !== "GET") { return new Response("Method not allowed", { status: 405, headers: { allow: "GET, HEAD" } }); } return new Response(script, { status: 200, headers }); }; } function buildScript(config) { const configJson = JSON.stringify(config).replace(/</g, "\\u003c"); return `(() => { "use strict"; const config = ${configJson}; const provider = [ typeof document !== "undefined" && document.modelContext, typeof navigator !== "undefined" && navigator.modelContext, ].find((candidate) => candidate && typeof candidate.registerTool === "function"); if (!provider || typeof provider.registerTool !== "function") { return; } let protocolVersion = null; let requestId = 0; async function rpc(method, params, isNotification, signal) { const body = { jsonrpc: "2.0", method }; if (params !== undefined) body.params = params; if (!isNotification) body.id = ++requestId; const headers = { "content-type": "application/json", accept: "application/json, text/event-stream", }; if (protocolVersion) headers["mcp-protocol-version"] = protocolVersion; const res = await fetch(config.endpoint, { method: "POST", credentials: config.credentials, headers, body: JSON.stringify(body), signal, }); if (isNotification) return null; if (!res.ok) { throw new Error("MCP request failed: HTTP " + res.status); } const contentType = res.headers.get("content-type") || ""; if (contentType.includes("text/event-stream")) { return parseSse(await res.text(), body.id); } return unwrap(await res.json(), body.id); } function parseSse(text, id) { for (const event of text.split(/\\r?\\n\\r?\\n/)) { const data = event .split(/\\r?\\n/) .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).replace(/^ /, "")) .join("\\n"); if (!data) continue; let message; try { message = JSON.parse(data); } catch { continue; } if ( message && message.id === id && ("result" in message || "error" in message) ) { return unwrap(message, id); } } throw new Error("No MCP response found in event stream"); } function unwrap(message, id) { if (message && message.error) { throw new Error( message.error.message || "MCP error " + message.error.code, ); } if (!message || message.id !== id) { throw new Error("Unexpected MCP response"); } return message.result; } (async () => { const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "mcp-handler/webmcp", version: "1.0.0" }, }); protocolVersion = (init && init.protocolVersion) || "2025-06-18"; await rpc("notifications/initialized", undefined, true).catch(() => null); let cursor; do { const listed = await rpc("tools/list", cursor ? { cursor } : {}); for (const tool of (listed && listed.tools) || []) { if (!config.tools.includes(tool.name)) continue; try { await provider.registerTool({ name: tool.name, title: tool.title || tool.annotations?.title || tool.name, description: tool.description || tool.title || tool.annotations?.title || tool.name, inputSchema: tool.inputSchema, annotations: { readOnlyHint: tool.annotations?.readOnlyHint === true }, execute: (args, options) => rpc("tools/call", { name: tool.name, arguments: args || {} }, false, options?.signal), }); } catch (error) { console.warn("[mcp-handler/webmcp] failed to register tool " + tool.name + ":", error); } } cursor = listed && listed.nextCursor; } while (cursor); })().catch((error) => { console.warn("[mcp-handler/webmcp] failed to register tools:", error); }); })(); `; } // src/handler/mcp-api-handler.ts function initializeMcpApiHandler(initializeServer, options = {}) { const { serverInfo = { name: "mcp-typescript server on vercel", version: "0.1.0" }, verboseLogs = false, onEvent, maxSubscriptions, experimental_webMcp, ...mcpServerOptions } = options; if (experimental_webMcp) { createWebMcpScriptHandler({ ...experimental_webMcp, endpoint: "/" }); } const emitError = (error) => { if (verboseLogs) { console.error("MCP handler error:", error); } onEvent?.( createEvent({ type: "ERROR", error, source: "request", severity: "error" }) ); }; const sdkHandler = server.createMcpHandler( async () => { const server$1 = new server.McpServer(serverInfo, mcpServerOptions); await initializeServer(server$1); return server$1; }, { legacy: "stateless", onerror: emitError, maxSubscriptions } ); return async function mcpApiHandler(req) { if (experimental_webMcp && (req.method === "GET" || req.method === "HEAD")) { const scriptUrl = new URL(req.url); if (scriptUrl.searchParams.has("webmcp-script")) { scriptUrl.searchParams.delete("webmcp-script"); return createWebMcpScriptHandler({ ...experimental_webMcp, endpoint: scriptUrl.toString() })(req); } } let method; let parsedBody; const started = Date.now(); if (req.method === "POST" && (req.headers.get("content-type") || "").includes("application/json")) { try { parsedBody = await req.clone().json(); if (typeof parsedBody === "object" && parsedBody !== null && "method" in parsedBody) { method = String(parsedBody.method); onEvent?.( createEvent({ type: "REQUEST_RECEIVED", method, parameters: redactEventSecret(parsedBody), status: "success" }) ); } } catch { } } try { const response = await sdkHandler.fetch(req, { authInfo: req.auth, parsedBody }); if (method) { onEvent?.( createEvent({ type: "REQUEST_COMPLETED", method, duration: Date.now() - started, status: response.ok ? "success" : "error" }) ); } return response; } catch (error) { emitError(error instanceof Error ? error : new Error(String(error))); throw error; } }; } function redactEventSecret(body) { const request = body; if (typeof request.method !== "string" || !request.method.startsWith("events/") || !request.params || typeof request.params !== "object") return body; const params = request.params; if (!params.delivery || typeof params.delivery !== "object" || !("secret" in params.delivery)) return body; return { ...body, params: { ...params, delivery: { ...params.delivery, secret: "[REDACTED]" } } }; } // src/handler/index.ts function createMcpRouteHandler(initializeServer, options) { return initializeMcpApiHandler(initializeServer, options); } var object = { type: "object", additionalProperties: true }; var name = { type: "string", minLength: 1 }; var integer = { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER }; var listParams = server.fromJsonSchema({ type: "object", properties: { cursor: { type: "string" } } }); var subscribeParams = server.fromJsonSchema({ type: "object", required: ["name", "arguments", "delivery"], properties: { name, arguments: object, delivery: { type: "object", required: ["mode", "url", "secret"], properties: { mode: name, url: name, secret: name } }, cursor: { type: ["string", "null"] }, maxAgeMs: integer, ttlMs: { ...integer, type: ["integer", "null"] } } }); var unsubscribeParams = server.fromJsonSchema({ type: "object", required: ["name", "arguments", "delivery"], properties: { name, arguments: object, delivery: { type: "object", required: ["url"], properties: { url: name } } } }); var webhookFailures = [ "connection_refused", "timeout", "tls_error", "http_4xx", "http_5xx", "challenge_failed" ]; var subscriptionState = server.fromJsonSchema({ type: "object", required: ["cursor", "truncated"], additionalProperties: false, properties: { cursor: { type: ["string", "null"] }, truncated: { type: "boolean" }, deliveryStatus: { type: "object", required: ["active", "lastError"], additionalProperties: false, properties: { active: { type: "boolean" }, lastDeliveryAt: { type: "string", format: "date-time" }, lastError: { enum: [...webhookFailures, null] }, failedSince: { type: "string", format: "date-time" }, throttled: { type: "boolean" }, retryAfterMs: { type: "integer", minimum: 0 } } } } }); // src/events/json.ts function canonical(value, ancestors = /* @__PURE__ */ new Set()) { if (value === null) return "null"; if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value); if (typeof value !== "object" || ancestors.has(value) || !Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Invalid JSON value"); ancestors.add(value); try { if (Array.isArray(value)) return `[${Array.from(value, (item) => canonical(item, ancestors)).join( "," )}]`; return `{${Object.keys(value).sort().map( (key) => `${JSON.stringify(key)}:${canonical( value[key], ancestors )}` ).join(",")}}`; } finally { ancestors.delete(value); } } // src/events/register.ts var registered = /* @__PURE__ */ new WeakSet(); function experimental_registerMcpEvents(server$1, options) { if (registered.has(server$1)) throw new Error("MCP Events already registered"); const minMs = options.ttl?.minMs ?? 6e4; const defaultMs = options.ttl?.defaultMs ?? 36e5; const maxMs = options.ttl?.maxMs ?? 864e5; if (![minMs, defaultMs, maxMs].every( (value) => Number.isSafeInteger(value) && value > 0 ) || minMs > defaultMs || defaultMs > maxMs || maxMs > 864e13 - Date.now()) throw new Error( "MCP Events TTLs must satisfy 0 < minMs <= defaultMs <= maxMs" ); registered.add(server$1); server$1.server.registerCapabilities({ events: {} }); async function context(ctx) { const authInfo = ctx.http?.authInfo; if (!authInfo || authInfo.expiresAt !== void 0 && authInfo.expiresAt <= Date.now() / 1e3) { throw new server.ProtocolError(-32012, "Authentication required"); } const principal = await options.getPrincipal(authInfo); if (typeof principal !== "string" || !principal.trim()) { throw new server.ProtocolError(-32012, "Authenticated principal required"); } return { principal, authInfo, signal: ctx.mcpReq.signal }; } async function catalog(ctx) { const events = typeof options.events === "function" ? await options.events(ctx) : options.events; const names = /* @__PURE__ */ new Set(); for (const event of events) { if (!event.name || names.has(event.name)) throw new Error("Invalid event catalog"); names.add(event.name); } const visible = []; for (const event of events) { ctx.signal.throwIfAborted(); if (event.authorizeDiscovery === void 0 || await event.authorizeDiscovery(ctx) === true) { visible.push(event); } } ctx.signal.throwIfAborted(); return visible; } server$1.server.setRequestHandler( "events/list", { params: listParams }, async (params, ctx) => safe(async () => { const auth = await context(ctx); if (params.cursor !== void 0) throw new server.ProtocolError(-32602, "Invalid catalog cursor"); const events = await catalog(auth); return { events: events.map((event) => ({ name: event.name, ...event.description !== void 0 ? { description: event.description } : {}, delivery: ["webhook"], inputSchema: event.inputSchema["~standard"].jsonSchema.input({ target: "draft-2020-12" }), payloadSchema: event.payloadSchema["~standard"].jsonSchema.output({ target: "draft-2020-12" }) })) }; }) ); server$1.server.setRequestHandler( "events/subscribe", { params: subscribeParams }, async (params, ctx) => safe(async () => { const auth = await context(ctx); if (params.delivery.mode !== "webhook") { throw new server.ProtocolError(-32014, "Unsupported delivery mode", { feature: "deliveryMode" }); } validateUrl(params.delivery.url); validateSecret(params.delivery.secret); const event = (await catalog(auth)).find( (candidate) => candidate.name === params.name ); if (!event) throw new server.ProtocolError(-32011, "Event not found", { kind: "event" }); const checked = await event.inputSchema["~standard"].validate( params.arguments ); if (checked.issues || canonical(checked.value) !== canonical(params.arguments)) { throw new server.ProtocolError( -32602, "Arguments must match the event schema without transformation" ); } if (await event.authorize(params.arguments, { principal: auth.principal, signal: auth.signal }) !== true) { throw new server.ProtocolError(-32012, "Event arguments are not authorized"); } const key = await subscriptionKey( auth.principal, params.name, params.arguments, params.delivery.url ); auth.signal.throwIfAborted(); const operationId = await options.subscriptions.prepare(key, auth); if (typeof operationId !== "string" || !operationId.trim()) { throw new Error("Invalid subscription operation"); } auth.signal.throwIfAborted(); const verification = await options.delivery.verifyEndpoint( { ...key, secret: params.delivery.secret }, auth ); if (verification.verified !== true) { const reason = webhookFailures.includes(verification.reason) ? verification.reason : "challenge_failed"; throw new server.ProtocolError(-32015, "Callback verification failed", { reason }); } auth.signal.throwIfAborted(); const ttl = params.ttlMs === null && options.ttl?.allowNoExpiry ? null : Math.max(minMs, Math.min(params.ttlMs ?? defaultMs, maxMs)); const refreshBefore = ttl === null ? null : new Date(Date.now() + ttl).toISOString(); const state = await options.subscriptions.upsert( { ...key, secret: params.delivery.secret, refreshBefore, cursor: params.cursor ?? null, ...params.maxAgeMs !== void 0 ? { maxAgeMs: params.maxAgeMs } : {} }, auth, operationId ); const result = await subscriptionState["~standard"].validate(state); if (result.issues) throw new Error("Invalid subscription state"); return { id: key.id, refreshBefore, ...result.value }; }) ); server$1.server.setRequestHandler( "events/unsubscribe", { params: unsubscribeParams }, async (params, ctx) => safe(async () => { const auth = await context(ctx); validateUrl(params.delivery.url); const key = await subscriptionKey( auth.principal, params.name, params.arguments, params.delivery.url ); auth.signal.throwIfAborted(); if (!await options.subscriptions.remove(key, auth)) { throw new server.ProtocolError(-32011, "Subscription not found", { kind: "subscription" }); } return {}; }) ); } function validateUrl(value) { try { const url = new URL(value); if (!value.startsWith("https://") || url.protocol !== "https:" || url.username || url.password || url.hash) throw new Error(); } catch { throw new server.ProtocolError( -32602, "Callback must be an HTTPS URL without credentials or a fragment" ); } } function validateSecret(value) { try { if (!/^whsec_[A-Za-z0-9+/]+={0,2}$/.test(value)) throw new Error(); const encoded = value.slice(6); const decoded = atob(encoded); if (decoded.length < 24 || decoded.length > 64 || btoa(decoded).replace(/=+$/, "") !== encoded.replace(/=+$/, "")) throw new Error(); } catch { throw new server.ProtocolError(-32602, "Invalid Standard Webhooks secret"); } } async function subscriptionKey(principal, name2, args, url) { const bytes = new TextEncoder().encode( canonical([principal, url, name2, args]) ); const hash = await crypto.subtle.digest("SHA-256", bytes); const id = "sub_" + Array.from( new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0") ).join(""); return { id, principal, name: name2, arguments: args, url }; } async function safe(run) { try { return await run(); } catch (error) { if (error instanceof server.ProtocolError && [-32602, -32011, -32012, -32013, -32014, -32015].includes(error.code)) { const messages2 = { [-32602]: "Invalid event parameters", [-32011]: "Event or subscription not found", [-32012]: "Forbidden", [-32013]: "Resource exhausted", [-32014]: "Unsupported event option", [-32015]: "Callback verification failed" }; const data = error.data; const detail = error.code === -32015 && webhookFailures.some((reason) => reason === data?.reason) ? { reason: data.reason } : error.code === -32011 && (data?.kind === "event" || data?.kind === "subscription") ? { kind: data.kind } : error.code === -32014 && data?.feature === "deliveryMode" ? { feature: "deliveryMode" } : void 0; throw new server.ProtocolError(error.code, messages2[error.code], detail); } throw new server.ProtocolError(-32603, "MCP Events operation failed"); } } // src/events/validate-delivery.ts var messages = { invalid_subscription: "A valid stored subscription is required", event_mismatch: "Event definition does not match the subscription", expired: "Subscription has expired", invalid_arguments: "Stored arguments must match the event schema unchanged", invalid_payload: "Payload must be JSON matching the event schema unchanged", forbidden: "Subscription principal is no longer authorized" }; var ExperimentalMcpEventDeliveryError = class extends Error { constructor(code) { super(messages[code]); this.code = code; this.name = "ExperimentalMcpEventDeliveryError"; } }; async function experimental_validateMcpEventDelivery({ event, subscription, payload, signal = new AbortController().signal }) { signal.throwIfAborted(); if (!subscription || typeof subscription.principal !== "string" || !subscription.principal.trim() || typeof subscription.name !== "string" || !subscription.name || !subscription.arguments || typeof subscription.arguments !== "object" || Array.isArray(subscription.arguments)) throw new ExperimentalMcpEventDeliveryError("invalid_subscription"); if (event.name !== subscription.name) throw new ExperimentalMcpEventDeliveryError("event_mismatch"); const expiry = subscription.refreshBefore === null ? null : typeof subscription.refreshBefore === "string" ? Date.parse(subscription.refreshBefore) : NaN; if (expiry !== null && !Number.isFinite(expiry)) throw new ExperimentalMcpEventDeliveryError("invalid_subscription"); function checkLifetime() { signal.throwIfAborted(); if (expiry !== null && expiry <= Date.now()) throw new ExperimentalMcpEventDeliveryError("expired"); } checkLifetime(); await validateUnchanged( event.inputSchema, subscription.arguments, "invalid_arguments" ); checkLifetime(); await validateUnchanged(event.payloadSchema, payload, "invalid_payload"); checkLifetime(); const authorized = await event.authorize(subscription.arguments, { principal: subscription.principal, signal }); checkLifetime(); if (authorized !== true) throw new ExperimentalMcpEventDeliveryError("forbidden"); } async function validateUnchanged(schema, value, code) { function json(input) { try { return canonical(input); } catch { throw new ExperimentalMcpEventDeliveryError(code); } } const before = json(value); const result = await schema["~standard"].validate(value); if (result.issues || json(result.value) !== before || json(value) !== before) throw new ExperimentalMcpEventDeliveryError(code); } // src/lib/url.ts function getPublicOrigin(req) { const forwardedHost = req.headers.get("x-forwarded-host"); const forwardedProto = req.headers.get("x-forwarded-proto"); if (forwardedHost) { const host = forwardedHost.split(",")[0].trim(); const proto = forwardedProto?.split(",")[0].trim() || "https"; return `${proto}://${host}`; } const forwarded = req.headers.get("forwarded"); if (forwarded) { const parsed = parseForwardedHeader(forwarded); if (parsed.host) { const proto = parsed.proto || "https"; return `${proto}://${parsed.host}`; } } return new URL(req.url).origin; } function getPublicUrl(req) { const url = new URL(req.url); const publicOrigin = getPublicOrigin(req); const result = new URL(url.pathname + url.search + url.hash, publicOrigin); return result; } function parseForwardedHeader(forwarded) { const result = {}; const firstElement = forwarded.split(",")[0]; const pairs = firstElement.split(";"); for (const pair of pairs) { const [key, value] = pair.split("=").map((s) => s.trim().toLowerCase()); if (key === "host" && value) { result.host = value.replace(/^"|"$/g, ""); } else if (key === "proto" && value) { result.proto = value.replace(/^"|"$/g, ""); } } return result; } // src/auth/auth-wrapper.ts function withMcpAuth(handler, verifyToken, { required = false, resourceMetadataPath = "/.well-known/oauth-protected-resource", requiredScopes, resourceUrl } = {}) { return async (req) => { const origin = resourceUrl ?? getPublicOrigin(req); const resourceMetadataUrl = `${origin}${resourceMetadataPath}`; const challengeOptions = { requiredScopes, resourceMetadataUrl }; const authHeader = req.headers.get("Authorization"); const [type, token] = authHeader?.split(" ") || []; const bearerToken = type?.toLowerCase() === "bearer" ? token : void 0; let authInfo; try { authInfo = await verifyToken(req, bearerToken); } catch (error) { console.error("Unexpected error authenticating bearer token:", error); return server.bearerAuthChallengeResponse( new server.OAuthError(server.OAuthErrorCode.InvalidToken, "Invalid token"), challengeOptions ); } try { if (required && !authInfo) { throw new server.OAuthError( server.OAuthErrorCode.InvalidToken, "No authorization provided" ); } if (!authInfo) { return handler(req); } if (requiredScopes?.length) { const hasAllScopes = requiredScopes.every( (scope) => authInfo.scopes.includes(scope) ); if (!hasAllScopes) { throw new server.OAuthError( server.OAuthErrorCode.InsufficientScope, "Insufficient scope" ); } } if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1e3) { throw new server.OAuthError(server.OAuthErrorCode.InvalidToken, "Token has expired"); } req.auth = authInfo; return handler(req); } catch (error) { if (!server.OAuthError.isInstance(error)) { console.error("Unexpected error authenticating bearer token:", error); } return server.bearerAuthChallengeResponse(error, challengeOptions); } }; } // src/auth/auth-metadata.ts var corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "*", "Access-Control-Max-Age": "86400" }; function protectedResourceHandler({ authServerUrls, resourceUrl: explicitResourceUrl }) { return (req) => { let resource; if (explicitResourceUrl) { resource = explicitResourceUrl; } else { const publicUrl = getPublicUrl(req); publicUrl.pathname = publicUrl.pathname.replace(/^\/\.well-known\/[^\/]+/, ""); resource = publicUrl.pathname === "/" ? publicUrl.toString().replace(/\/$/, "") : publicUrl.toString(); } const metadata = generateProtectedResourceMetadata({ authServerUrls, resourceUrl: resource }); return new Response(JSON.stringify(metadata), { headers: { ...corsHeaders, "Cache-Control": "max-age=3600", "Content-Type": "application/json" } }); }; } function generateProtectedResourceMetadata({ authServerUrls, resourceUrl, additionalMetadata }) { return Object.assign( { resource: resourceUrl, authorization_servers: authServerUrls }, additionalMetadata ); } function metadataCorsOptionsRequestHandler() { return () => { return new Response(null, { status: 200, headers: corsHeaders }); }; } exports.ExperimentalMcpEventDeliveryError = ExperimentalMcpEventDeliveryError; exports.createMcpHandler = createMcpRouteHandler; exports.experimental_registerMcpEvents = experimental_registerMcpEvents; exports.experimental_validateMcpEventDelivery = experimental_validateMcpEventDelivery; exports.experimental_withMcpAuth = withMcpAuth; exports.generateProtectedResourceMetadata = generateProtectedResourceMetadata; exports.getPublicOrigin = getPublicOrigin; exports.getPublicUrl = getPublicUrl; exports.metadataCorsOptionsRequestHandler = metadataCorsOptionsRequestHandler; exports.protectedResourceHandler = protectedResourceHandler; exports.withMcpAuth = withMcpAuth; //# sourceMappingURL=out.js.map //# sourceMappingURL=index.js.map