mcpay
Version:
SDK and CLI for MCPay functionality - MCP servers with payment capabilities
489 lines • 26.9 kB
JavaScript
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