UNPKG

mcpay

Version:

SDK and CLI for MCPay functionality - MCP servers with payment capabilities

489 lines 26.9 kB
function jsonResponse(obj, status = 200) { return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" }, }); } async function wrapUpstreamResponse(upstream) { // Clone headers to avoid immutable header guards on upstream responses const headers = new Headers(upstream.headers); // If the runtime already decompressed the body, avoid advertising compression again headers.delete("content-encoding"); headers.delete("content-length"); headers.delete("transfer-encoding"); // Read the body content to avoid "Response body object should not be disturbed or locked" error // This ensures the body is fully consumed before creating a new Response const body = await upstream.arrayBuffer(); return new Response(body, { status: upstream.status, statusText: upstream.statusText, headers, }); } export function withProxy(targetUrl, hooks) { return async (req) => { console.log(`[${new Date().toISOString()}] Target URL: ${targetUrl}`); // Preserve original body for non-JSON requests if (!req.headers.get("content-type")?.includes("json")) { const upstream = await fetch(targetUrl, { method: req.method, headers: req.headers, body: req.body, duplex: 'half' }); return await wrapUpstreamResponse(upstream); } // Parse JSON while preserving original body stream let originalRpc = null; let originalBodyText = null; try { // Clone request to preserve original body for forwarding const clonedReq = req.clone(); originalBodyText = await clonedReq.text(); // Parse JSON for hook processing originalRpc = JSON.parse(originalBodyText); } catch (err) { // If JSON parsing fails, forward original body const upstream = await fetch(targetUrl, { method: req.method, headers: req.headers, body: req.body, duplex: 'half' }); return await wrapUpstreamResponse(upstream); } if (!originalRpc || Array.isArray(originalRpc)) { // Forward original body for non-object JSON const upstream = await fetch(targetUrl, { method: req.method, headers: req.headers, body: originalBodyText, duplex: 'half' }); return await wrapUpstreamResponse(upstream); } // Generalized MCP routing const method = String(originalRpc["method"] || ""); const isNotification = !("id" in originalRpc); const url = new URL(req.url); const extra = { requestId: crypto.randomUUID(), sessionId: originalRpc?.params?._meta?.sessionId, originalUrl: req.url, targetUrl, inboundHeaders: new Headers(req.headers), serverId: url.searchParams.get("id"), }; // Notifications: allow hooks to observe, then forward if (isNotification) { const notification = originalRpc; for (const h of hooks) { if (h.processNotification) { try { await h.processNotification(notification); } catch (e) { if (h.processNotificationError) { await h.processNotificationError({ code: 0, message: e?.message || "notification_error" }, notification); } } } } const forwardHeaders = new Headers(req.headers); forwardHeaders.delete("content-length"); forwardHeaders.delete("host"); forwardHeaders.delete("connection"); forwardHeaders.delete("transfer-encoding"); forwardHeaders.delete("content-encoding"); forwardHeaders.set("content-type", "application/json"); // Forward original body text instead of reconstructing JSON const upstream = await fetch(targetUrl, { method: req.method, headers: forwardHeaders, body: originalBodyText }); return await wrapUpstreamResponse(upstream); } // Helper for non-tool methods const handleGeneric = async (currentReq, runRequest, runResponse, runError, methodName) => { const originalRpcLocal = originalRpc; for (const h of hooks) { const r = await (runRequest(h, currentReq) || Promise.resolve(null)); if (!r) continue; if (r.resultType === "continue") { currentReq = r.request; continue; } if (r.resultType === "continueAsync") { const id = originalRpcLocal?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: r.response }; return jsonResponse(envelope, 200); } if (r.resultType === "respond") { const id = originalRpcLocal?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: r.response }; return jsonResponse(envelope, 200); } } const forwardHeaders = new Headers(req.headers); for (const h of hooks) { if (h.prepareUpstreamHeaders) { try { await h.prepareUpstreamHeaders(forwardHeaders, req, extra); } catch { } } } forwardHeaders.delete("content-length"); forwardHeaders.delete("host"); forwardHeaders.delete("connection"); forwardHeaders.delete("transfer-encoding"); forwardHeaders.delete("content-encoding"); forwardHeaders.set("content-type", "application/json"); let upstream; try { // Check if hooks modified the request - if so, reconstruct body const hasModifications = currentReq !== originalRpcLocal; if (hasModifications) { // Hooks modified the request, reconstruct body upstream = await fetch(targetUrl, { method: req.method, headers: forwardHeaders, body: JSON.stringify({ ...originalRpcLocal, method: methodName, params: currentReq?.params ?? originalRpcLocal["params"] }), }); } else { // No modifications, forward original body upstream = await fetch(targetUrl, { method: req.method, headers: forwardHeaders, body: originalBodyText, }); } } catch (e) { for (const h of hooks.slice().reverse()) { const rr = await (runError(h, { code: 0, message: e?.message || "upstream_error" }, currentReq) || Promise.resolve(null)); if (rr && rr.resultType === "respond") { const id = originalRpcLocal?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: rr.response }; return jsonResponse(envelope, 200); } } return jsonResponse({ jsonrpc: "2.0", id: originalRpcLocal?.id ?? 0, error: { code: -32000, message: "Upstream fetch failed" } }, 502); } const contentType = upstream.headers.get("content-type") || ""; const isJson = contentType.includes("application/json"); const isStreaming = contentType.includes("text/event-stream"); // Clone response before consuming body to avoid "Response body object should not be disturbed or locked" error const clonedUpstream = upstream.clone(); let data; if (isStreaming) { let text = null; try { text = await upstream.text(); } catch { return await wrapUpstreamResponse(clonedUpstream); } try { const dataLines = text.split('\n').filter(line => line.startsWith('data: ')).map(line => line.substring(6)); if (dataLines.length === 0) { data = JSON.parse(text); } else { const lastMessage = dataLines[dataLines.length - 1]; data = JSON.parse(lastMessage); } } catch { return await wrapUpstreamResponse(clonedUpstream); } } else if (isJson) { try { data = await upstream.json(); } catch { return await wrapUpstreamResponse(clonedUpstream); } } else { return await wrapUpstreamResponse(upstream); } const maybeRpc = data; if (maybeRpc && typeof maybeRpc === "object" && "jsonrpc" in maybeRpc) { if ("error" in maybeRpc) { for (const h of hooks.slice().reverse()) { const rr = await (runError(h, { code: maybeRpc.error?.code ?? -32000, message: maybeRpc.error?.message ?? "error" }, currentReq) || Promise.resolve(null)); if (rr && rr.resultType === "respond") { const id = originalRpcLocal?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: rr.response }; return jsonResponse(envelope, 200); } } return await wrapUpstreamResponse(clonedUpstream); } if ("result" in maybeRpc) { let currentRes = (maybeRpc["result"] ?? null); for (const h of hooks.slice().reverse()) { const r = await (runResponse(h, currentRes, currentReq) || Promise.resolve(null)); if (r && r.resultType === "continue") { currentRes = r.response; continue; } } const id = originalRpcLocal?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: currentRes }; const headers = new Headers(upstream.headers); headers.delete("content-encoding"); headers.delete("content-length"); headers.delete("transfer-encoding"); headers.set("content-type", "application/json"); return new Response(JSON.stringify(envelope), { status: upstream.status, statusText: upstream.statusText, headers }); } } return await wrapUpstreamResponse(clonedUpstream); }; switch (method) { case "tools/call": { // Specialized tools/call handler with retry support let currentReq = originalRpc; let attempts = 0; const maxRetries = 1; // Track hook response retries separately to prevent infinite loops // This counter is independent of attempts since hook responses don't consume upstream requests let hookResponseRetries = 0; const maxHookResponseRetries = 10; // Prevent infinite loops from hook responses while (true) { // Call request hooks on each iteration (including retries) // This allows hooks to intercept retries before the fetch happens let hookResponse = null; for (const h of hooks) { if (!h.processCallToolRequest) continue; const r = await h.processCallToolRequest(currentReq, extra); if (r.resultType === "continue") { currentReq = r.request; continue; } if (r.resultType === "continueAsync") { const id = originalRpc?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: r.response }; return jsonResponse(envelope, 200); } if (r.resultType === "respond") { hookResponse = r.response; break; // Stop processing request hooks, but we'll process response hooks below } } // If a hook returned a response, process it through response hooks before returning if (hookResponse) { let currentRes = hookResponse; let requestedRetry = null; for (const h of hooks.slice().reverse()) { if (!h.processCallToolResult) continue; const r = await h.processCallToolResult(currentRes, currentReq, extra); if (r.resultType === "continue") { currentRes = r.response; continue; } if (r?.resultType === "retry") { // If a response hook requests retry, track it and check retry limit requestedRetry = { request: r.request }; break; } if (r?.resultType === "abort") { const rr = r; return jsonResponse({ error: rr.reason, body: rr.body }, 400); } } // If retry was requested, check retry limit before continuing // Note: We don't increment attempts here because no upstream request was made. // The attempts counter should only track actual upstream requests, not hook responses. // We use a separate counter for hook response retries to prevent infinite loops. if (requestedRetry) { if (hookResponseRetries < maxHookResponseRetries) { hookResponseRetries++; currentReq = requestedRetry.request; continue; // Retry the request (without incrementing attempts) } // Max hook response retries exceeded, return the current response (fall through) } // Return the response (either no retry requested, or retry limit exceeded) const id = originalRpc?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: currentRes }; return jsonResponse(envelope, 200); } const forwardHeaders = new Headers(req.headers); for (const h of hooks) { if (h.prepareUpstreamHeaders) { try { await h.prepareUpstreamHeaders(forwardHeaders, req, extra); } catch { } } } forwardHeaders.delete("content-length"); forwardHeaders.delete("host"); forwardHeaders.delete("connection"); forwardHeaders.delete("transfer-encoding"); forwardHeaders.delete("content-encoding"); forwardHeaders.set("content-type", "application/json"); // Check if hooks modified the request - if so, reconstruct body const hasModifications = currentReq !== originalRpc; let upstream; if (hasModifications) { // Hooks modified the request, reconstruct body upstream = await fetch(targetUrl, { method: req.method, headers: forwardHeaders, body: JSON.stringify({ ...originalRpc, method: "tools/call", params: currentReq.params ?? originalRpc["params"] }), }); } else { // No modifications, forward original body upstream = await fetch(targetUrl, { method: req.method, headers: forwardHeaders, body: originalBodyText, }); } const contentType = upstream.headers.get("content-type") || ""; const isJson = contentType.includes("application/json"); const isStreaming = contentType.includes("text/event-stream"); // Clone response before consuming body to avoid "Response body object should not be disturbed or locked" error const clonedUpstream = upstream.clone(); let data; if (isStreaming) { let text = null; try { text = await upstream.text(); } catch { return await wrapUpstreamResponse(clonedUpstream); } try { const dataLines = text.split('\n').filter(line => line.startsWith('data: ')).map(line => line.substring(6)); if (dataLines.length === 0) { data = JSON.parse(text); } else { const lastMessage = dataLines[dataLines.length - 1]; data = JSON.parse(lastMessage); } } catch { return await wrapUpstreamResponse(clonedUpstream); } } else if (isJson) { try { data = await upstream.json(); } catch { return await wrapUpstreamResponse(clonedUpstream); } } else { return await wrapUpstreamResponse(upstream); } const maybeRpc = data; if (maybeRpc && typeof maybeRpc === "object" && "jsonrpc" in maybeRpc && "result" in maybeRpc) { let currentRes = (maybeRpc["result"] ?? null); let requestedRetry = null; for (const h of hooks.slice().reverse()) { if (!h.processCallToolResult) continue; const r = await h.processCallToolResult(currentRes, currentReq, extra); if (r.resultType === "continue") { currentRes = r.response; continue; } if (r?.resultType === "retry") { requestedRetry = { request: r.request }; break; } if (r?.resultType === "abort") { const rr = r; return jsonResponse({ error: rr.reason, body: rr.body }, 400); } } if (requestedRetry && attempts < maxRetries) { attempts++; currentReq = requestedRetry.request; continue; } const id = originalRpc?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: currentRes }; const headers = new Headers(upstream.headers); headers.delete("content-encoding"); headers.delete("content-length"); headers.delete("transfer-encoding"); headers.set("content-type", "application/json"); return new Response(JSON.stringify(envelope), { status: upstream.status, statusText: upstream.statusText, headers }); } // If upstream returns bare result-like, wrap it if (maybeRpc && typeof maybeRpc === "object" && !("jsonrpc" in maybeRpc) && ("content" in maybeRpc || "isError" in maybeRpc)) { let currentRes = maybeRpc; let requestedRetry = null; for (const h of hooks.slice().reverse()) { if (!h.processCallToolResult) continue; const r = await h.processCallToolResult(currentRes, currentReq, extra); if (r.resultType === "continue") { currentRes = r.response; continue; } if (r?.resultType === "retry") { requestedRetry = { request: r.request }; break; } if (r?.resultType === "abort") { const rr = r; return jsonResponse({ error: rr.reason, body: rr.body }, 400); } } if (requestedRetry && attempts < maxRetries) { attempts++; currentReq = requestedRetry.request; continue; } const id = originalRpc?.id ?? 0; const envelope = { jsonrpc: "2.0", id, result: currentRes }; const headers = new Headers(upstream.headers); headers.delete("content-encoding"); headers.delete("content-length"); headers.delete("transfer-encoding"); headers.set("content-type", "application/json"); return new Response(JSON.stringify(envelope), { status: upstream.status, statusText: upstream.statusText, headers }); } // Fallback return await wrapUpstreamResponse(clonedUpstream); } } case "initialize": return handleGeneric(originalRpc, (h, reqObj) => h.processInitializeRequest ? h.processInitializeRequest(reqObj, extra) : null, (h, res, reqObj) => h.processInitializeResult ? h.processInitializeResult(res, reqObj, extra) : null, (h, err, reqObj) => h.processInitializeError ? h.processInitializeError(err, reqObj, extra) : null, "initialize"); case "tools/list": return handleGeneric(originalRpc, (h, reqObj) => h.processListToolsRequest ? h.processListToolsRequest(reqObj, extra) : null, (h, res, reqObj) => h.processListToolsResult ? h.processListToolsResult(res, reqObj, extra) : null, (h, err, reqObj) => h.processListToolsError ? h.processListToolsError(err, reqObj, extra) : null, "tools/list"); case "prompts/list": return handleGeneric(originalRpc, (h, reqObj) => h.processListPromptsRequest ? h.processListPromptsRequest(reqObj, extra) : null, (h, res, reqObj) => h.processListPromptsResult ? h.processListPromptsResult(res, reqObj, extra) : null, (h, err, reqObj) => h.processListPromptsError ? h.processListPromptsError(err, reqObj, extra) : null, "prompts/list"); case "resources/list": return handleGeneric(originalRpc, (h, reqObj) => h.processListResourcesRequest ? h.processListResourcesRequest(reqObj, extra) : null, (h, res, reqObj) => h.processListResourcesResult ? h.processListResourcesResult(res, reqObj, extra) : null, (h, err, reqObj) => h.processListResourcesError ? h.processListResourcesError(err, reqObj, extra) : null, "resources/list"); case "resources/templates/list": return handleGeneric(originalRpc, (h, reqObj) => h.processListResourceTemplatesRequest ? h.processListResourceTemplatesRequest(reqObj, extra) : null, (h, res, reqObj) => h.processListResourceTemplatesResult ? h.processListResourceTemplatesResult(res, reqObj, extra) : null, (h, err, reqObj) => h.processListResourceTemplatesError ? h.processListResourceTemplatesError(err, reqObj, extra) : null, "resources/templates/list"); case "resources/read": return handleGeneric(originalRpc, (h, reqObj) => h.processReadResourceRequest ? h.processReadResourceRequest(reqObj, extra) : null, (h, res, reqObj) => h.processReadResourceResult ? h.processReadResourceResult(res, reqObj, extra) : null, (h, err, reqObj) => h.processReadResourceError ? h.processReadResourceError(err, reqObj, extra) : null, "resources/read"); default: return handleGeneric(originalRpc, (h, reqObj) => h.processOtherRequest ? h.processOtherRequest(reqObj, extra) : null, (h, res, reqObj) => h.processOtherResult ? h.processOtherResult(res, reqObj, extra) : null, (h, err, reqObj) => h.processOtherError ? h.processOtherError(err, reqObj, extra) : null, method); } }; } //# sourceMappingURL=index.js.map