From 343a585a2942281baf867714ee93b1065db97336 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Oct 2025 19:50:55 +0000 Subject: [PATCH] feat: Add control plane headers for MCP proxy Adds headers to control MCP proxy behavior like auth mode, autopay, and error handling. Co-authored-by: microchipgnu --- apps/app/src/app/api/mcp-proxy/route.ts | 37 +++++- .../custom-ui/tool-execution-modal.tsx | 10 ++ apps/mcp/src/index.ts | 105 ++++++++++++------ packages/js-sdk/src/cli/index.ts | 32 ++++-- 4 files changed, 133 insertions(+), 51 deletions(-) diff --git a/apps/app/src/app/api/mcp-proxy/route.ts b/apps/app/src/app/api/mcp-proxy/route.ts index 65b0b2a4..d6fcec1e 100644 --- a/apps/app/src/app/api/mcp-proxy/route.ts +++ b/apps/app/src/app/api/mcp-proxy/route.ts @@ -44,6 +44,10 @@ export async function POST(request: Request) { const h = await headers() const url = new URL(request.url) const targetUrl = url.searchParams.get('target-url') + // Header-based control plane passthrough + const authMode = h.get('x-mcpay-auth-mode') || url.searchParams.get('auth-mode') || '' + const autoPay = h.get('x-mcpay-autopay') || url.searchParams.get('autopay') || '' + const errMode = h.get('x-mcpay-402-mode') || url.searchParams.get('402-mode') || '' const session = await serverAuth.getSession({ fetchOptions: { @@ -68,7 +72,11 @@ export async function POST(request: Request) { console.log('Request referer:', request.headers.get('referer')) // Use the local MCP server instead of external proxy - const mcpUrl = `${env.NEXT_PUBLIC_AUTH_URL}/mcp?target-url=${targetUrl}` + const params = new URLSearchParams({ 'target-url': targetUrl || '' }) + if (authMode) params.set('auth-mode', authMode) + if (autoPay) params.set('autopay', autoPay) + if (errMode) params.set('402-mode', errMode) + const mcpUrl = `${env.NEXT_PUBLIC_AUTH_URL}/mcp?${params.toString()}` console.log('mcpUrl', mcpUrl) @@ -82,6 +90,10 @@ export async function POST(request: Request) { 'X-Wallet-Type': h.get('x-wallet-type') || '', 'X-Wallet-Address': h.get('x-wallet-address') || '', 'X-Wallet-Provider': h.get('x-wallet-provider') || '', + // Control plane headers for MCP server + 'X-MCPAY-AUTH-MODE': authMode || '', + 'X-MCPAY-AUTOPAY': autoPay || '', + 'X-MCPAY-402-MODE': errMode || '', }, body: request.body, credentials: 'include', @@ -98,8 +110,10 @@ export async function POST(request: Request) { 'Content-Type': response.headers.get('Content-Type') || 'application/json', 'Access-Control-Allow-Origin': validOrigin || '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Wallet-Type, X-Wallet-Address, X-Wallet-Provider', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Wallet-Type, X-Wallet-Address, X-Wallet-Provider, X-MCPAY-AUTH-MODE, X-MCPAY-AUTOPAY, X-MCPAY-402-MODE', + 'Access-Control-Expose-Headers': 'X-MCPAY-X420, WWW-Authenticate', 'Access-Control-Allow-Credentials': validOrigin ? 'true' : 'false', + ...(response.headers.get('WWW-Authenticate') ? { 'WWW-Authenticate': response.headers.get('WWW-Authenticate')! } : {}), }, }) } @@ -108,6 +122,9 @@ export async function GET(request: Request) { const h = await headers() const url = new URL(request.url) const targetUrl = url.searchParams.get('target-url') + const authMode = h.get('x-mcpay-auth-mode') || url.searchParams.get('auth-mode') || '' + const autoPay = h.get('x-mcpay-autopay') || url.searchParams.get('autopay') || '' + const errMode = h.get('x-mcpay-402-mode') || url.searchParams.get('402-mode') || '' const session = await serverAuth.getSession({ fetchOptions: { @@ -128,7 +145,11 @@ export async function GET(request: Request) { // Use the local MCP server instead of external proxy - const mcpUrl = `${env.NEXT_PUBLIC_AUTH_URL}/mcp?target-url=${targetUrl}` + const params = new URLSearchParams({ 'target-url': targetUrl || '' }) + if (authMode) params.set('auth-mode', authMode) + if (autoPay) params.set('autopay', autoPay) + if (errMode) params.set('402-mode', errMode) + const mcpUrl = `${env.NEXT_PUBLIC_AUTH_URL}/mcp?${params.toString()}` // Forward the request to the local MCP server with session cookie const response = await fetch(mcpUrl, { @@ -139,6 +160,9 @@ export async function GET(request: Request) { 'X-Wallet-Type': h.get('x-wallet-type') || '', 'X-Wallet-Address': h.get('x-wallet-address') || '', 'X-Wallet-Provider': h.get('x-wallet-provider') || '', + 'X-MCPAY-AUTH-MODE': authMode || '', + 'X-MCPAY-AUTOPAY': autoPay || '', + 'X-MCPAY-402-MODE': errMode || '', }, credentials: 'include', // @ts-expect-error this is valid and needed @@ -154,8 +178,10 @@ export async function GET(request: Request) { 'Content-Type': response.headers.get('Content-Type') || 'application/json', 'Access-Control-Allow-Origin': validOrigin || '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Wallet-Type, X-Wallet-Address, X-Wallet-Provider', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Wallet-Type, X-Wallet-Address, X-Wallet-Provider, X-MCPAY-AUTH-MODE, X-MCPAY-AUTOPAY, X-MCPAY-402-MODE', + 'Access-Control-Expose-Headers': 'X-MCPAY-X420, WWW-Authenticate', 'Access-Control-Allow-Credentials': validOrigin ? 'true' : 'false', + ...(response.headers.get('WWW-Authenticate') ? { 'WWW-Authenticate': response.headers.get('WWW-Authenticate')! } : {}), }, }) } @@ -170,7 +196,8 @@ export async function OPTIONS(request: Request) { 'Content-Type': 'application/json, text/event-stream', 'Access-Control-Allow-Origin': validOrigin || '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Wallet-Type, X-Wallet-Address, X-Wallet-Provider', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Wallet-Type, X-Wallet-Address, X-Wallet-Provider, X-MCPAY-AUTH-MODE, X-MCPAY-AUTOPAY, X-MCPAY-402-MODE', + 'Access-Control-Expose-Headers': 'X-MCPAY-X420, WWW-Authenticate', 'Access-Control-Allow-Credentials': validOrigin ? 'true' : 'false', }, }) diff --git a/apps/app/src/components/custom-ui/tool-execution-modal.tsx b/apps/app/src/components/custom-ui/tool-execution-modal.tsx index 46fa6197..01e41947 100644 --- a/apps/app/src/components/custom-ui/tool-execution-modal.tsx +++ b/apps/app/src/components/custom-ui/tool-execution-modal.tsx @@ -520,6 +520,15 @@ export function ToolExecutionModal({ isOpen, onClose, tool, serverId, url }: Too } + // Control-plane hints for proxy behavior + const wantsExternalFlow = activeWallet?.walletType === 'external'; + const controlHeaders: Record = {}; + if (wantsExternalFlow) { + controlHeaders['X-MCPAY-AUTH-MODE'] = 'none'; + controlHeaders['X-MCPAY-AUTOPAY'] = 'off'; + controlHeaders['X-MCPAY-402-MODE'] = 'x420'; + } + const transport = new StreamableHTTPClientTransport(mcpUrl, { requestInit: { credentials: 'include', @@ -528,6 +537,7 @@ export function ToolExecutionModal({ isOpen, onClose, tool, serverId, url }: Too 'X-Wallet-Type': activeWallet?.walletType || 'unknown', 'X-Wallet-Address': walletAddress || '', 'X-Wallet-Provider': activeWallet?.provider || 'unknown', + ...controlHeaders, // Explicitly include cookies if available ...(document.cookie ? { 'Cookie': document.cookie } : {}), } diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 1345d17f..94ae4d8e 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -50,7 +50,11 @@ app.use("*", cors({ "x-api-key", "X-Wallet-Type", "X-Wallet-Address", - "X-Wallet-Provider" + "X-Wallet-Provider", + // Control plane headers + "X-MCPAY-AUTH-MODE", + "X-MCPAY-AUTOPAY", + "X-MCPAY-402-MODE" ], allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], credentials: true, @@ -67,7 +71,7 @@ app.use("*", cors({ } return ""; }, - exposeHeaders: ["WWW-Authenticate"], + exposeHeaders: ["WWW-Authenticate", "X-MCPAY-X420"], })) // // 1. CORS for all routes (including preflight) @@ -474,46 +478,79 @@ app.all("/mcp", async (c) => { return new Response("target-url missing", { status: 400 }); } - const withMcpProxy = (session: any) => withProxy(targetUrl, [ - new AnalyticsHook(analyticsSink, targetUrl), - new LoggingHook(), - new X402WalletHook(session), - new SecurityHook(), - ]); + // Read header-based controls + const inboundHeaders = original.headers; + const authModeHeader = (inboundHeaders.get("x-mcpay-auth-mode") || currentUrl.searchParams.get("auth-mode") || "").toLowerCase(); + const autoPayHeader = (inboundHeaders.get("x-mcpay-autopay") || currentUrl.searchParams.get("autopay") || "").toLowerCase(); + const errorModeHeader = (inboundHeaders.get("x-mcpay-402-mode") || currentUrl.searchParams.get("402-mode") || "").toLowerCase(); + + const isAutoPayDisabled = ["0", "false", "off", "disabled", "no"].includes(autoPayHeader); + const wantsX420 = errorModeHeader === "x420"; + + const buildProxy = (session: any) => { + const hooks = [ + new AnalyticsHook(analyticsSink, targetUrl), + new LoggingHook(), + ] as any[]; + if (!isAutoPayDisabled) { + hooks.push(new X402WalletHook(session)); + } + hooks.push(new SecurityHook()); + return withProxy(targetUrl, hooks as any); + }; + + // Helper: rewrite HTTP status to 420 when x402/error is present + const maybeRewriteTo420 = async (resp: Response): Promise => { + if (!wantsX420) return resp; + const ct = resp.headers.get("content-type") || ""; + // Only attempt for JSON bodies; passthrough for SSE/others + if (!ct.includes("application/json")) return resp; + let data: unknown; + try { + data = await resp.clone().json(); + } catch { + return resp; + } + const hasX402 = !!(data && typeof data === "object" && (data as any).result && (data as any).result._meta && ((data as any).result._meta as any)["x402/error"]); + if (!hasX402) return resp; + const headers = new Headers(resp.headers); + headers.set("x-mcpay-x420", "1"); + return new Response(JSON.stringify(data), { status: 420 as any, headers }); + }; // Extract API key from various sources const apiKeyFromQuery = currentUrl.searchParams.get("apiKey") || currentUrl.searchParams.get("api_key"); - // const authHeader = original.headers.get("authorization"); - // const apiKeyFromHeader = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null; const apiKeyFromXHeader = original.headers.get("x-api-key"); - - const apiKey = apiKeyFromQuery || apiKeyFromXHeader; //|| apiKeyFromHeader - - let session = null; - if (apiKey) { - session = await auth.api.getSession({ - headers: new Headers({ - 'x-api-key': apiKey, - }), - }); - } - - if (!session) { - session = await auth.api.getSession({ headers: original.headers }); + const apiKey = apiKeyFromQuery || apiKeyFromXHeader; + + // Decide auth mode: explicit header wins; default is "auto" (api-key if present else mcp-auth) + const authMode = authModeHeader === "api-key" || authModeHeader === "apikey" + ? "api-key" + : authModeHeader === "mcp-auth" || authModeHeader === "mcpauth" || authModeHeader === "session" + ? "mcp-auth" + : authModeHeader === "none" + ? "none" + : "auto"; + + if (authMode === "none") { + const proxy = buildProxy(null); + const resp = await proxy(original); + return maybeRewriteTo420(resp); } - if (session) { - console.log("[MCP] Authenticated session found, proxying with session:", session.session?.userId || session.session); - return withMcpProxy(session.session)(original); + if (authMode === "api-key" || (authMode === "auto" && apiKey)) { + const session = apiKey + ? await auth.api.getSession({ headers: new Headers({ 'x-api-key': apiKey }) }) + : null; + const proxy = buildProxy(session ? session.session : null); + const resp = await proxy(original); + return maybeRewriteTo420(resp); } - console.log("[MCP] No authenticated session, using withMcpAuth"); - const handler = withMcpAuth(auth, (req, session) => { - console.log("[MCP] withMcpAuth session:", session?.userId || session); - return withMcpProxy(session)(req); - }); - - return handler(original); + // mcp-auth (cookie/session) flow with optional login redirect + const handler = withMcpAuth(auth, (req, session) => buildProxy(session)(req)); + const resp = await handler(original); + return maybeRewriteTo420(resp); } const handler = (session: any) => createMcpHandler(async (server) => { diff --git a/packages/js-sdk/src/cli/index.ts b/packages/js-sdk/src/cli/index.ts index bc0355f9..0e2577d1 100644 --- a/packages/js-sdk/src/cli/index.ts +++ b/packages/js-sdk/src/cli/index.ts @@ -18,6 +18,9 @@ interface ServerOptions { svm?: string; evmNetwork?: string; svmNetwork?: string; + authMode?: string; + autopay?: boolean; // commander will set false if --no-autopay + x420?: boolean; } const program = new Command(); @@ -37,6 +40,9 @@ program .option('--svm ', 'SVM secret key (base58/hex) (env: SVM_SECRET_KEY)') .option('--evm-network ', 'EVM network (base-sepolia, base, avalanche-fuji, avalanche, iotex, sei, sei-testnet). Default: base-sepolia (env: EVM_NETWORK)') .option('--svm-network ', 'SVM network (solana-devnet, solana). Default: solana-devnet (env: SVM_NETWORK)') + .option('--auth-mode ', 'Auth mode for proxy: auto|api-key|mcp-auth|none (default: auto)') + .option('--no-autopay', 'Disable proxy-side auto-payment (skip X402 wallet hook)') + .option('--x420', 'Request HTTP 420 on x402 errors instead of 200 JSON') .action(async (options: ServerOptions) => { try { const apiKey = options.apiKey || process.env.API_KEY; @@ -91,18 +97,20 @@ program const serverConnections = serverUrls.map(url => { const isProxyUrl = url.includes('/v1/mcp') || url.includes('mcpay.tech') || url.includes('proxy'); - let transportOptions: any = undefined; - if (apiKey && isProxyUrl) { - // Only apply API key to proxy URLs - transportOptions = { - requestInit: { - credentials: 'include', - headers: new Headers({ - 'x-api-key': apiKey - }) - } - }; - } + const headers = new Headers(); + // Control-plane headers + if (options.authMode) headers.set('x-mcpay-auth-mode', String(options.authMode)); + if (options.autopay === false) headers.set('x-mcpay-autopay', 'off'); + if (options.x420) headers.set('x-mcpay-402-mode', 'x420'); + // API key only to proxy URLs + if (apiKey && isProxyUrl) headers.set('x-api-key', apiKey); + + const transportOptions: any = { + requestInit: { + credentials: 'include', + headers, + } + }; return { url,