with-mcp.ts•19.2 kB
/// <reference types="@cloudflare/workers-types" />
/// <reference lib="esnext" />
interface McpConfig {
/** defaults to 2025-03-26 */
protocolVersion?: string;
/** GET endpoint that returns MCP-compatible 401 if authentication isn't valid.
*
* e.g. '/me'
*
* Will be used before responding with "initialize" and '.../list' methods
*/
authEndpoint?: string;
serverInfo?: {
name: string;
version: string;
};
promptOperationIds?: string[];
toolOperationIds?: string[];
resourceOperationIds?: string[];
}
interface OpenAPIOperation {
operationId: string;
summary?: string;
description?: string;
parameters?: Array<{
name: string;
in: string;
required?: boolean;
description?: string;
schema?: any;
}>;
requestBody?: {
content?: {
[mediaType: string]: {
schema?: any;
};
};
};
responses?: {
[statusCode: string]: {
description?: string;
content?: {
[mediaType: string]: {
schema?: any;
};
};
};
};
}
interface OpenAPISpec {
paths: {
[path: string]: {
[method: string]: OpenAPIOperation;
};
};
components?: {
schemas?: { [name: string]: any };
};
}
export function withMcp<TEnv = {}>(
handler: ExportedHandlerFetchHandler,
openapi: OpenAPISpec,
config: McpConfig
) {
// Extract operations by operationId
const allOperations = new Map<
string,
{ path: string; method: string; operation: OpenAPIOperation }
>();
for (const [path, methods] of Object.entries(openapi.paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (operation.operationId) {
allOperations.set(operation.operationId, { path, method, operation });
}
}
}
return async (
request: Request,
env: TEnv,
ctx: ExecutionContext
): Promise<Response> => {
const url = new URL(request.url);
// Handle MCP endpoint
if (url.pathname === "/mcp") {
// Handle preflight OPTIONS request
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, MCP-Protocol-Version",
"Access-Control-Max-Age": "86400",
},
});
}
if (request.method === "GET") {
return new Response("Only Streamable HTTP is supported", {
status: 405,
});
}
if (request.method === "POST") {
const response = await handleMcp(
request,
env,
ctx,
allOperations,
config,
handler
);
// Add CORS headers to the response
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, MCP-Protocol-Version",
};
// Clone the response to add headers
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: {
...Object.fromEntries(response.headers.entries()),
...corsHeaders,
},
});
}
}
// Pass through to original handler
return handler(request, env, ctx);
};
}
async function checkAuth(
config: McpConfig,
originalRequest: Request,
originalHandler: ExportedHandlerFetchHandler,
env: any,
ctx: any
): Promise<Response | null> {
if (!config.authEndpoint) {
return null; // No auth required
}
// Build auth request
const baseUrl = new URL(originalRequest.url).origin;
const authUrl = new URL(config.authEndpoint, baseUrl);
const origHeaders = Object.fromEntries(
originalRequest.headers
.entries()
.map(([key, val]) => [key.toLowerCase(), val])
);
const authRequest = new Request(authUrl.toString(), {
method: "GET",
headers: origHeaders,
}) as Request<unknown, IncomingRequestCfProperties<unknown>>;
const authResponse = await originalHandler(authRequest, env, ctx);
// If auth failed, return the auth response
if (authResponse.status === 401 || authResponse.status === 402) {
return authResponse;
}
return null; // Auth passed
}
async function handleMcp(
request: Request,
env: any,
ctx: any,
allOperations: Map<
string,
{ path: string; method: string; operation: OpenAPIOperation }
>,
config: McpConfig,
originalHandler: ExportedHandlerFetchHandler
): Promise<Response> {
try {
const message: any = await request.json();
// Handle initialize
if (message.method === "ping") {
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {},
}),
{ headers: { "Content-Type": "application/json" } }
);
}
if (message.method === "initialize") {
// Check auth if configured
const authResult = await checkAuth(
config,
request,
originalHandler,
env,
ctx
);
if (authResult) {
console.log("initialize", {
status: authResult.status,
authResult: await authResult.clone().text(),
});
return authResult;
}
const initializeResult = {
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: config.protocolVersion || "2025-03-26",
capabilities: {
...(config.promptOperationIds &&
config.promptOperationIds.length > 0 && { prompts: {} }),
...(config.resourceOperationIds &&
config.resourceOperationIds.length > 0 && { resources: {} }),
...(config.toolOperationIds &&
config.toolOperationIds.length > 0 && { tools: {} }),
},
serverInfo: config.serverInfo || {
name: "OpenAPI-MCP-Server",
version: "1.0.0",
},
},
};
console.log("initialize", { initializeResult });
return new Response(JSON.stringify(initializeResult), {
headers: { "Content-Type": "application/json" },
});
}
// Handle initialized notification
if (message.method === "notifications/initialized") {
console.log("notifications/initialized");
return new Response(null, { status: 202 });
}
// Handle prompts/list
if (message.method === "prompts/list") {
// Check auth if configured
const authResult = await checkAuth(
config,
request,
originalHandler,
env,
ctx
);
if (authResult) {
return authResult;
}
const prompts = (config.promptOperationIds || [])
.map((opId) => {
const op = allOperations.get(opId);
if (!op) return null;
return {
name: opId,
title: op.operation.summary || opId,
description: op.operation.description,
arguments: extractArguments(op.operation),
};
})
.filter(Boolean);
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: { prompts },
}),
{ headers: { "Content-Type": "application/json" } }
);
}
// Handle prompts/get
if (message.method === "prompts/get") {
const { name, arguments: args } = message.params;
const op = allOperations.get(name);
if (!op || !(config.promptOperationIds || []).includes(name)) {
return createError(message.id, -32602, `Unknown prompt: ${name}`);
}
// Execute the operation (which includes its own auth check)
const apiResponse = await executeOperation(
op,
args,
originalHandler,
request,
env,
ctx
);
// If 401 or 402, proxy the response as-is
if (apiResponse.status === 401 || apiResponse.status === 402) {
return apiResponse;
}
// Convert to prompt messages
const messages = await convertResponseToPromptMessages(apiResponse);
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
description: op.operation.description,
messages,
},
}),
{ headers: { "Content-Type": "application/json" } }
);
}
// Handle resources/list
if (message.method === "resources/list") {
// Check auth if configured
const authResult = await checkAuth(
config,
request,
originalHandler,
env,
ctx
);
if (authResult) {
return authResult;
}
const resources = (config.resourceOperationIds || [])
.map((opId) => {
const op = allOperations.get(opId);
if (!op) return null;
return {
uri: `resource://${opId}`,
name: opId,
title: op.operation.summary || opId,
description: op.operation.description,
mimeType: inferMimeType(op.operation),
};
})
.filter(Boolean);
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: { resources },
}),
{ headers: { "Content-Type": "application/json" } }
);
}
// Handle resources/read
if (message.method === "resources/read") {
const { uri } = message.params;
const opId = uri.replace("resource://", "");
const op = allOperations.get(opId);
if (!op || !(config.resourceOperationIds || []).includes(opId)) {
return createError(message.id, -32002, `Resource not found: ${uri}`);
}
// Execute the operation (which includes its own auth check)
const apiResponse = await executeOperation(
op,
{},
originalHandler,
request,
env,
ctx
);
// If 401 or 402, proxy the response as-is
if (apiResponse.status === 401 || apiResponse.status === 402) {
return apiResponse;
}
// Convert to resource content
const contents = await convertResponseToResourceContents(
apiResponse,
uri
);
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: { contents },
}),
{ headers: { "Content-Type": "application/json" } }
);
}
// Handle tools/list
if (message.method === "tools/list") {
// Check auth if configured
const authResult = await checkAuth(
config,
request,
originalHandler,
env,
ctx
);
if (authResult) {
console.log("tools/list", {
status: authResult.status,
authResult: await authResult.clone().text(),
});
return authResult;
}
const tools = (config.toolOperationIds || [])
.map((opId) => {
const op = allOperations.get(opId);
if (!op) return null;
return {
name: opId,
title: op.operation.summary || opId,
description: op.operation.description,
inputSchema: extractInputSchema(op.operation),
};
})
.filter(Boolean);
const toolsListResult = {
jsonrpc: "2.0",
id: message.id,
result: { tools },
};
console.log({ toolsListResult });
return new Response(JSON.stringify(toolsListResult), {
headers: { "Content-Type": "application/json" },
});
}
// Handle tools/call
if (message.method === "tools/call") {
const { name, arguments: args } = message.params;
const op = allOperations.get(name);
if (!op || !(config.toolOperationIds || []).includes(name)) {
return createError(message.id, -32602, `Unknown tool: ${name}`);
}
try {
// Execute the operation (which includes its own auth check)
const apiResponse = await executeOperation(
op,
args,
originalHandler,
request,
env,
ctx
);
// If 401 or 402, proxy the response as-is
if (apiResponse.status === 401 || apiResponse.status === 402) {
return apiResponse;
}
const content = await convertResponseToToolContent(apiResponse);
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
content,
isError: !apiResponse.ok,
},
}),
{ headers: { "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
content: [
{
type: "text",
text: `Error executing tool: ${error.message}`,
},
],
isError: true,
},
}),
{
headers: { "Content-Type": "application/json" },
}
);
}
}
return createError(
message.id,
-32601,
`Method not found: ${message.method}`
);
} catch (error) {
return createError(null, -32700, "Parse error");
}
}
function extractArguments(operation: OpenAPIOperation) {
const args = [];
// Extract from parameters
if (operation.parameters) {
for (const param of operation.parameters) {
args.push({
name: param.name,
description: param.description,
required: param.required || false,
});
}
}
// Extract from request body schema properties
if (
operation.requestBody?.content?.["application/json"]?.schema?.properties
) {
const props =
operation.requestBody.content["application/json"].schema.properties;
const required =
operation.requestBody.content["application/json"].schema.required || [];
for (const [name, schema] of Object.entries(props)) {
args.push({
name,
description: (schema as any).description,
required: required.includes(name),
});
}
}
return args;
}
function extractInputSchema(operation: OpenAPIOperation) {
// Start with basic object schema
const schema: any = {
type: "object",
properties: {},
required: [],
};
// Add parameters as properties
if (operation.parameters) {
for (const param of operation.parameters) {
schema.properties[param.name] = param.schema || { type: "string" };
if (param.required) {
schema.required.push(param.name);
}
}
}
// Merge request body schema
if (operation.requestBody?.content?.["application/json"]?.schema) {
const bodySchema = operation.requestBody.content["application/json"].schema;
if (bodySchema.properties) {
Object.assign(schema.properties, bodySchema.properties);
}
if (bodySchema.required) {
schema.required.push(...bodySchema.required);
}
}
return schema;
}
function inferMimeType(operation: OpenAPIOperation): string {
// Check response content types
const responses = operation.responses;
if (responses) {
for (const response of Object.values(responses)) {
if (response.content) {
const contentTypes = Object.keys(response.content);
if (contentTypes.length > 0) {
const preferred = ["text/plain", "text/markdown"];
const pref = contentTypes.find((x) => preferred.includes(x));
if (pref) {
return pref;
}
return contentTypes[0];
}
}
}
}
return "application/json";
}
async function executeOperation(
op: { path: string; method: string; operation: OpenAPIOperation },
args: any,
originalHandler: ExportedHandlerFetchHandler,
originalRequest: Request,
env: any,
ctx: any
): Promise<Response> {
// Build the API request URL
let url = op.path;
const queryParams = new URLSearchParams();
const bodyData: any = {};
// query params in the URL will be input into the API as well, regardless of whether or not they appear in the openapi
// needed for https://smithery.ai/docs/build/session-config#well-known-endpoint-approach
const originalRequestUrl = new URL(originalRequest.url);
originalRequestUrl.searchParams.forEach((value, key) => {
queryParams.set(key, value);
});
// Handle parameters
if (op.operation.parameters) {
for (const param of op.operation.parameters) {
const value = args[param.name];
if (value !== undefined) {
if (param.in === "path") {
url = url.replace(`{${param.name}}`, encodeURIComponent(value));
} else if (param.in === "query") {
queryParams.set(param.name, value);
}
// Note: header params would need special handling
}
}
}
// Add remaining args to body
Object.assign(bodyData, args);
// Build the final URL
const baseUrl = new URL(originalRequest.url).origin;
const finalUrl = new URL(url, baseUrl);
if (queryParams.toString()) {
finalUrl.search = queryParams.toString();
}
const origHeaders = Object.fromEntries(
originalRequest.headers
.entries()
.map(([key, val]) => [key.toLowerCase(), val])
);
const headers = {
...origHeaders,
accept: inferMimeType(op.operation),
"content-type": "application/json",
};
// Create the API request
const apiRequest = new Request(finalUrl.toString(), {
method: op.method.toUpperCase(),
headers,
...(op.method.toUpperCase() !== "GET" &&
Object.keys(bodyData).length > 0 && {
body: JSON.stringify(bodyData),
}),
}) as Request<unknown, IncomingRequestCfProperties<unknown>>;
return originalHandler(apiRequest, env, ctx);
}
async function convertResponseToPromptMessages(response: Response) {
const text = await response.text();
return [
{
role: "user" as const,
content: {
type: "text" as const,
text: response.ok
? text
: `Error: ${response.status} ${response.statusText}\n${text}`,
},
},
];
}
async function convertResponseToResourceContents(
response: Response,
uri: string
) {
const text = await response.text();
const contentType = response.headers.get("content-type") || "text/plain";
return [
{
uri,
mimeType: contentType,
text,
},
];
}
async function convertResponseToToolContent(response: Response) {
const text = await response.text();
return [{ type: "text" as const, text }];
}
function createError(id: any, code: number, message: string) {
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id,
error: { code, message },
}),
{
status: 200, // JSON-RPC errors use 200 status
headers: { "Content-Type": "application/json" },
}
);
}