Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions apps/app/src/app/api/mcp-proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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)

Expand All @@ -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',
Expand All @@ -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')! } : {}),
},
})
}
Expand All @@ -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: {
Expand All @@ -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, {
Expand All @@ -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
Expand All @@ -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')! } : {}),
},
})
}
Expand All @@ -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',
},
})
Expand Down
10 changes: 10 additions & 0 deletions apps/app/src/components/custom-ui/tool-execution-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
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',
Expand All @@ -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 } : {}),
}
Expand Down
105 changes: 71 additions & 34 deletions apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -67,7 +71,7 @@ app.use("*", cors({
}
return "";
},
exposeHeaders: ["WWW-Authenticate"],
exposeHeaders: ["WWW-Authenticate", "X-MCPAY-X420"],
}))

// // 1. CORS for all routes (including preflight)
Expand Down Expand Up @@ -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<Response> => {
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) => {
Expand Down
32 changes: 20 additions & 12 deletions packages/js-sdk/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -37,6 +40,9 @@ program
.option('--svm <secretKey>', 'SVM secret key (base58/hex) (env: SVM_SECRET_KEY)')
.option('--evm-network <network>', 'EVM network (base-sepolia, base, avalanche-fuji, avalanche, iotex, sei, sei-testnet). Default: base-sepolia (env: EVM_NETWORK)')
.option('--svm-network <network>', 'SVM network (solana-devnet, solana). Default: solana-devnet (env: SVM_NETWORK)')
.option('--auth-mode <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;
Expand Down Expand Up @@ -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,
Expand Down
Loading