mcp-handler
Version:
Framework-agnostic HTTP adapter for Model Context Protocol servers
820 lines (803 loc) • 27.2 kB
JavaScript
import './chunk-ZNCO4QRF.mjs';
import { fromJsonSchema, ProtocolError, bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, createMcpHandler, McpServer } from '@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 = createMcpHandler(
async () => {
const server = new McpServer(serverInfo, mcpServerOptions);
await initializeServer(server);
return server;
},
{
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 = fromJsonSchema({
type: "object",
properties: { cursor: { type: "string" } }
});
var subscribeParams = 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 = 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 = 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, options) {
if (registered.has(server))
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);
server.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 ProtocolError(-32012, "Authentication required");
}
const principal = await options.getPrincipal(authInfo);
if (typeof principal !== "string" || !principal.trim()) {
throw new 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.server.setRequestHandler(
"events/list",
{ params: listParams },
async (params, ctx) => safe(async () => {
const auth = await context(ctx);
if (params.cursor !== void 0)
throw new 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.server.setRequestHandler(
"events/subscribe",
{ params: subscribeParams },
async (params, ctx) => safe(async () => {
const auth = await context(ctx);
if (params.delivery.mode !== "webhook") {
throw new 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 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 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 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 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.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 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 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 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 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 ProtocolError(error.code, messages2[error.code], detail);
}
throw new 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 bearerAuthChallengeResponse(
new OAuthError(OAuthErrorCode.InvalidToken, "Invalid token"),
challengeOptions
);
}
try {
if (required && !authInfo) {
throw new OAuthError(
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 OAuthError(
OAuthErrorCode.InsufficientScope,
"Insufficient scope"
);
}
}
if (authInfo.expiresAt && authInfo.expiresAt < Date.now() / 1e3) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Token has expired");
}
req.auth = authInfo;
return handler(req);
} catch (error) {
if (!OAuthError.isInstance(error)) {
console.error("Unexpected error authenticating bearer token:", error);
}
return 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
});
};
}
export { ExperimentalMcpEventDeliveryError, createMcpRouteHandler as createMcpHandler, experimental_registerMcpEvents, experimental_validateMcpEventDelivery, withMcpAuth as experimental_withMcpAuth, generateProtectedResourceMetadata, getPublicOrigin, getPublicUrl, metadataCorsOptionsRequestHandler, protectedResourceHandler, withMcpAuth };
//# sourceMappingURL=out.js.map
//# sourceMappingURL=index.mjs.map