@agentauth/mcp
Version:
Universal payment-enabled MCP gateway for AI agents with native x402 protocol support.
470 lines (469 loc) • 20.8 kB
JavaScript
/*
* Copyright (c) 2025 AgentAuth
* SPDX-License-Identifier: MIT
*
* Transport connection logic adapted from mcp-remote
* https://www.npmjs.com/package/mcp-remote
*/
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { deriveAddress, signPayload } from '@agentauth/core';
import { PaymentHandler } from '../payments/paymentHandler.js';
const VERSION = '0.1.0';
const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport';
export const pid = process.pid;
export let DEBUG = false;
/**
* Gets current timestamp in ISO format
*/
export function timestamp() {
const now = new Date();
return now.toISOString();
}
/**
* Logs a message with timestamp and process ID
*/
export function log(str, ...rest) {
console.error(`[${timestamp()}] [${pid}] ${str}`, ...rest);
}
/**
* Logs debug messages when debug mode is enabled
*/
export function debugLog(str, ...rest) {
if (DEBUG) {
log(`[DEBUG] ${str}`, ...rest);
}
}
/**
* Enables or disables debug logging
*/
export function setDebug(val) {
DEBUG = val;
if (DEBUG) {
debugLog('Debug mode enabled.');
}
}
/**
* Validates that the server URL uses HTTPS or is allowed via exceptions
* @param serverUrl The URL to validate
* @param allowHttp Whether HTTP is explicitly allowed via --allow-http flag
* @throws Error if URL is not secure and not allowed
*/
export function validateServerUrlSecurity(serverUrl, allowHttp) {
const url = new URL(serverUrl);
const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:';
if (!(url.protocol === 'https:' || isLocalhost || allowHttp)) {
log('Error: Non-HTTPS URLs are only allowed for localhost or when --allow-http flag is provided');
process.exit(1);
}
}
/**
* Generates fresh AgentAuth headers for each request with current timestamp
* @param token The AgentAuth token to sign with
* @returns Headers object with address, signature, and base64-encoded payload
*/
export function generateFreshAuthHeaders(token) {
const agentauth_address = deriveAddress(token);
const payload = {
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload, token);
return {
'X-AgentAuth-Address': agentauth_address,
'X-AgentAuth-Signature': signature,
'X-AgentAuth-Payload': Buffer.from(JSON.stringify(payload)).toString('base64'),
};
}
// AgentAuth headers that should not be overridden by custom headers
const PROTECTED_AGENTAUTH_HEADERS = [
'x-agentauth-address',
'x-agentauth-signature',
'x-agentauth-payload'
];
/**
* Check if a header name conflicts with protected AgentAuth headers (case-insensitive)
*/
function hasAgentAuthConflict(headerName) {
const normalized = headerName.toLowerCase();
return PROTECTED_AGENTAUTH_HEADERS.some(protectedHeader => protectedHeader.toLowerCase() === normalized);
}
/**
* Merge custom headers with AgentAuth headers, with conflict detection and warnings
*/
function mergeHeaders(agentAuthHeaders, customHeaders) {
// Check for conflicts and warn
Object.keys(customHeaders).forEach(headerName => {
if (hasAgentAuthConflict(headerName)) {
console.warn(`⚠️ Warning: Custom header '${headerName}' is overriding AgentAuth authentication.`);
}
});
// Custom headers take precedence over AgentAuth headers
return {
...agentAuthHeaders,
...customHeaders
};
}
/**
* Wrapper class that adds fresh auth headers and custom headers to each request by intercepting fetch calls.
* Intercepts POST requests (MCP message sends) and injects fresh AgentAuth headers
* with current timestamps to ensure authentication doesn't expire.
*/
class AuthRefreshTransportWrapper {
wrappedTransport;
token;
baseCustomHeaders;
pendingPaymentHeaders = null;
originalFetch;
constructor(transport, token, customHeaders = {}) {
this.wrappedTransport = transport;
this.token = token;
this.baseCustomHeaders = customHeaders; // Only non-payment custom headers from CLI
this.pendingPaymentHeaders = null;
this.originalFetch = globalThis.fetch;
this.interceptFetch();
}
get sessionId() {
return this.wrappedTransport.sessionId;
}
set onclose(handler) {
this.wrappedTransport.onclose = handler;
}
set onerror(handler) {
this.wrappedTransport.onerror = handler;
}
set onmessage(handler) {
this.wrappedTransport.onmessage = handler;
}
async start() {
return this.wrappedTransport.start();
}
async send(message) {
debugLog('Sending message with fresh auth headers via fetch interception');
return this.wrappedTransport.send(message);
}
async close() {
// Restore original fetch
globalThis.fetch = this.originalFetch;
return this.wrappedTransport.close();
}
/**
* Set payment headers for the NEXT request only (stateless)
* Headers will be automatically cleared after the request is sent
*/
setPaymentHeadersForNextRequest(headers) {
this.pendingPaymentHeaders = headers;
debugLog('Set payment headers for next request only');
}
interceptFetch() {
const self = this;
globalThis.fetch = async function (input, init) {
// Only intercept POST requests (MCP message sending)
if (init?.method === 'POST') {
// Always generate fresh auth headers with new timestamp
const freshHeaders = generateFreshAuthHeaders(self.token);
// Build the final headers for this request
let finalHeaders = { ...freshHeaders, ...self.baseCustomHeaders };
// If there are pending payment headers, add them for THIS request only
if (self.pendingPaymentHeaders) {
finalHeaders = { ...finalHeaders, ...self.pendingPaymentHeaders };
debugLog('Adding one-time payment headers to this request');
// Clear payment headers immediately - they're only for this request
self.pendingPaymentHeaders = null;
}
// Create new headers object that includes all headers
const headers = new Headers(init.headers);
Object.entries(finalHeaders).forEach(([key, value]) => {
headers.set(key, value);
});
// Create new init with all headers
const newInit = {
...init,
headers
};
debugLog('Intercepted POST request, injected fresh auth headers');
return self.originalFetch(input, newInit);
}
// For non-POST requests, use original fetch
return self.originalFetch(input, init);
};
}
}
/**
* Creates a wallet-aware bidirectional proxy between two transports
* @param params The transport connections and optional wallet service
*/
export function mcpProxy({ transportToClient, transportToServer, walletService }) {
let transportToClientClosed = false;
let transportToServerClosed = false;
let paymentHandler;
let lastRequest;
// Initialize payment handler if wallet service is provided
if (walletService) {
paymentHandler = new PaymentHandler(walletService);
debugLog('Wallet service enabled - payment handling active (AgentPay v0.0.2)');
// Note: Removed cleanup since we're now STATELESS
}
transportToClient.onmessage = async (message) => {
debugLog('[Local→Remote]', 'method' in message ? message.method : ('id' in message ? message.id : 'no-id'));
// Store the request for potential retry
if ('method' in message) {
lastRequest = message;
}
if ('method' in message && message.method === 'initialize') {
const { clientInfo } = message.params;
if (clientInfo) {
clientInfo.name = `${clientInfo.name} (via agentauth-mcp ${VERSION})`;
}
}
// Check for payment approval in request (AgentPay v0.0.2)
if (paymentHandler && paymentHandler.hasPaymentAuthorization(message)) {
debugLog('🔄 Intercepted payment authorization request!');
const result = await paymentHandler.processPaymentAuthorization(message);
if (result.error) {
// Send error response directly to agent (STATELESS)
debugLog('Payment authorization failed:', result.error);
const errorResponse = {
jsonrpc: '2.0',
id: 'id' in message ? message.id : 'error',
error: {
code: -32001,
message: 'Payment authorization failed',
data: {
error_type: 'payment_authorization_failed',
message: result.error,
instructions: 'Please fix the issue and retry with the same parameters.'
}
}
};
transportToClient.send(errorResponse).catch(onClientError);
return;
}
if (result.headers) {
// Set payment headers for the NEXT request only (stateless)
debugLog('Setting AgentPay headers for next request only');
if ('setPaymentHeadersForNextRequest' in transportToServer && typeof transportToServer.setPaymentHeadersForNextRequest === 'function') {
transportToServer.setPaymentHeadersForNextRequest(result.headers);
debugLog('AgentPay headers will be added to next request only');
}
else {
debugLog('Warning: Transport does not support stateless header injection');
}
}
// Strip aa_* parameters before forwarding to server
// The payment info is now in the X-PAYMENT header, so remove proxy-specific params
if ('params' in message && message.params && typeof message.params === 'object') {
const params = message.params;
if (params.arguments && typeof params.arguments === 'object') {
// Create a clean copy without aa_* parameters
const cleanArguments = Object.fromEntries(Object.entries(params.arguments).filter(([key]) => !key.startsWith('aa_')));
message = {
...message,
params: {
...params,
arguments: cleanArguments
}
};
debugLog('Stripped aa_* parameters from request before forwarding to server');
}
}
}
transportToServer.send(message).catch(onServerError);
};
transportToServer.onmessage = async (message) => {
debugLog('[Remote→Local]', 'method' in message ? message.method : message.id);
debugLog('Raw message from server:', JSON.stringify(message, null, 2));
// Check for AgentPay v0.0.2 payment required response
if (paymentHandler && paymentHandler.isPaymentRequired(message)) {
try {
debugLog('🏦 Payment required detected (multi-protocol support)');
const enhancedMessage = await paymentHandler.processPaymentRequired(message, lastRequest);
debugLog('Enhanced payment request for agent consumption');
transportToClient.send(enhancedMessage).catch(onClientError);
return;
}
catch (error) {
log('Error processing payment requirement:', error instanceof Error ? error.message : 'Unknown error');
// Fall through to send original message
}
}
transportToClient.send(message).catch(onClientError);
};
transportToClient.onclose = () => {
if (transportToServerClosed)
return;
transportToClientClosed = true;
debugLog('Local transport closed, closing remote transport');
transportToServer.close().catch(onServerError);
};
transportToServer.onclose = () => {
if (transportToClientClosed)
return;
transportToServerClosed = true;
debugLog('Remote transport closed, closing local transport');
transportToClient.close().catch(onClientError);
};
transportToClient.onerror = onClientError;
transportToServer.onerror = onServerError;
function onClientError(error) {
log('Error from local client:', error.message);
}
function onServerError(error) {
log('Error from remote server:', error.message);
}
}
/**
* Wrapper class that only adds custom headers to requests (no AgentAuth)
*/
class CustomHeadersTransportWrapper {
wrappedTransport;
customHeaders;
originalFetch;
constructor(transport, customHeaders) {
this.wrappedTransport = transport;
this.customHeaders = customHeaders;
this.originalFetch = globalThis.fetch;
this.interceptFetch();
}
get sessionId() {
return this.wrappedTransport.sessionId;
}
set onclose(handler) {
this.wrappedTransport.onclose = handler;
}
set onerror(handler) {
this.wrappedTransport.onerror = handler;
}
set onmessage(handler) {
this.wrappedTransport.onmessage = handler;
}
async start() {
return this.wrappedTransport.start();
}
async send(message) {
debugLog('Sending message with custom headers via fetch interception');
return this.wrappedTransport.send(message);
}
async close() {
// Restore original fetch
globalThis.fetch = this.originalFetch;
return this.wrappedTransport.close();
}
interceptFetch() {
const self = this;
globalThis.fetch = async function (input, init) {
// Only intercept POST requests (MCP message sending)
if (init?.method === 'POST') {
// Create new headers object that includes custom headers
const headers = new Headers(init.headers);
Object.entries(self.customHeaders).forEach(([key, value]) => {
headers.set(key, value);
});
// Create new init with custom headers
const newInit = {
...init,
headers
};
debugLog('Intercepted POST request, injected custom headers');
return self.originalFetch(input, newInit);
}
// For non-POST requests, use original fetch
return self.originalFetch(input, init);
};
}
}
/**
* Creates and connects to a remote server with transport strategy fallback support.
* Uses the AuthRefreshTransportWrapper to provide fresh auth headers when token is provided.
* @param serverUrl The URL of the remote server
* @param strategy The transport strategy to use (http-first, sse-first, etc.)
* @param recursionReasons Set tracking fallback attempts to prevent infinite recursion
* @param token Optional AgentAuth token for authentication
* @param customHeaders Optional custom headers to include with requests
* @returns The connected transport, wrapped with auth refresh if token provided
*/
export async function connectToRemoteServer(serverUrl, strategy = 'http-first', recursionReasons = new Set(), token, customHeaders = {}) {
log(`Connecting to remote server: ${serverUrl} with strategy: ${strategy}`);
const url = new URL(serverUrl);
const requestInit = {};
const useSSE = strategy === 'sse-only' || (strategy === 'sse-first' && !recursionReasons.has(REASON_TRANSPORT_FALLBACK));
const useHTTP = strategy === 'http-only' || (strategy === 'http-first' && !recursionReasons.has(REASON_TRANSPORT_FALLBACK));
// Determine the transport to use based on the strategy
let transport;
if (useSSE) {
transport = new SSEClientTransport(url, { requestInit });
}
else if (useHTTP) {
transport = new StreamableHTTPClientTransport(url, { requestInit });
}
else {
// This case happens on the second leg of a fallback strategy
const fallbackStrategy = strategy === 'sse-first' ? 'http-only' : 'sse-only';
return connectToRemoteServer(serverUrl, fallbackStrategy, recursionReasons, token, customHeaders);
}
debugLog(`Attempting connection with ${transport.constructor.name}`);
try {
await transport.start();
// Additional probe for HTTP transport to verify endpoint supports Streamable HTTP
if (!useSSE) {
debugLog('Performing HTTP probe to confirm server supports Streamable HTTP...');
try {
const testTransport = new StreamableHTTPClientTransport(url, { requestInit });
const testClient = new Client({ name: 'agentauth-mcp-fallback-test', version: '0.0.0' }, { capabilities: {} });
await testClient.connect(testTransport);
await testTransport.close();
debugLog('HTTP probe succeeded; server supports Streamable HTTP.');
}
catch (probeError) {
debugLog(`HTTP probe failed with message: ${probeError.message}`);
const isProtocolLikeError = probeError instanceof Error &&
(probeError.message.includes('405') || probeError.message.includes('Method Not Allowed') ||
probeError.message.includes('404') || probeError.message.includes('Not Found') ||
probeError.message.includes('protocol error'));
const shouldAttemptFallback = (strategy === 'http-first' || strategy === 'sse-first') &&
!recursionReasons.has(REASON_TRANSPORT_FALLBACK);
if (shouldAttemptFallback && isProtocolLikeError) {
log(`Transport probe failed, attempting fallback...`);
recursionReasons.add(REASON_TRANSPORT_FALLBACK);
// opposite transport
const fallbackStrategy = 'sse-only';
return connectToRemoteServer(serverUrl, fallbackStrategy, recursionReasons, token, customHeaders);
}
// probe failed but no fallback; rethrow
throw probeError;
}
}
log(`Connected successfully using ${transport.constructor.name}.`);
// Determine which wrapper to use based on token and custom headers
const hasCustomHeaders = Object.keys(customHeaders).length > 0;
if (token && hasCustomHeaders) {
debugLog('Wrapping transport with auth refresh and custom headers capability');
return new AuthRefreshTransportWrapper(transport, token, customHeaders);
}
else if (token) {
debugLog('Wrapping transport with auth refresh capability');
return new AuthRefreshTransportWrapper(transport, token);
}
else if (hasCustomHeaders) {
debugLog('Wrapping transport with custom headers capability');
return new CustomHeadersTransportWrapper(transport, customHeaders);
}
return transport;
}
catch (error) {
debugLog(`Connection failed with ${transport.constructor.name}:`, error.message);
const shouldAttemptFallback = (strategy === 'http-first' || strategy === 'sse-first') &&
!recursionReasons.has(REASON_TRANSPORT_FALLBACK);
if (shouldAttemptFallback && error instanceof Error &&
(error.message.includes('405') || error.message.includes('Method Not Allowed') ||
error.message.includes('404') || error.message.includes('Not Found') ||
error.message.includes('protocol error'))) {
log(`Transport failed, attempting fallback...`);
recursionReasons.add(REASON_TRANSPORT_FALLBACK);
// The logic above will ensure the other transport is tried on the recursive call
return connectToRemoteServer(serverUrl, strategy, recursionReasons, token, customHeaders);
}
// If no fallback is possible or the error is not a fallback candidate, rethrow.
throw error;
}
}