import type { MCPToolDefinition, MCPToolResult, ProxyMetadata } from '../types/mcp-tool.js';
import type { Config } from '../config/schema.js';
import { applyAuth } from './auth.js';
import { log } from '../utils/logger.js';
import { randomUUID } from 'crypto';
/**
* Request context for system variable substitution
*/
export interface RequestContext {
/** Client IP from X-Forwarded-For or direct connection */
clientIp?: string;
}
/**
* Substitute system variables in a string value
* Supported variables:
* - {{TIMESTAMP}} - Unix timestamp in milliseconds
* - {{REQUEST_ID}} - Unique UUID for this request
* - {{ISO_DATE}} - ISO 8601 formatted date
* - {{USER_IP}} - Client IP address (from context)
*/
function substituteSystemVariables(value: string, context: RequestContext): string {
const requestId = randomUUID();
const now = new Date();
return value
.replace(/\{\{TIMESTAMP\}\}/g, String(now.getTime()))
.replace(/\{\{REQUEST_ID\}\}/g, requestId)
.replace(/\{\{ISO_DATE\}\}/g, now.toISOString())
.replace(/\{\{USER_IP\}\}/g, context.clientIp ?? '');
}
/**
* Apply system variable substitution to all header values
*/
function substituteHeaderVariables(
headers: Record<string, string>,
context: RequestContext
): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, value] of Object.entries(headers)) {
result[name] = substituteSystemVariables(value, context);
}
return result;
}
/**
* Tool call arguments (input from MCP client)
*/
export type ToolArgs = Record<string, unknown>;
/**
* Build the full URL for the request
*/
function buildUrl(
baseUrl: string,
path: string,
pathParams: Record<string, string>,
queryParams: Record<string, string | string[]>
): string {
// Replace path parameters
let resolvedPath = path;
for (const [name, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(`{${name}}`, encodeURIComponent(value));
}
// Warn if any path params were not replaced (defensive check)
if (resolvedPath.includes('{') && resolvedPath.includes('}')) {
const unresolvedParams = resolvedPath.match(/\{[^}]+\}/g);
log.warn('Unresolved path parameters in URL', { path: resolvedPath, params: unresolvedParams });
}
// Ensure baseUrl ends with / for proper path joining
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
// Remove leading / from path since we'll append to base
const normalizedPath = resolvedPath.startsWith('/') ? resolvedPath.slice(1) : resolvedPath;
// Build URL by joining base and path
const url = new URL(normalizedPath, normalizedBase);
// Add query parameters (handling arrays with explode=true style)
for (const [name, value] of Object.entries(queryParams)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
// Arrays: add each value separately (OpenAPI explode=true default)
for (const item of value) {
if (item !== undefined && item !== null && item !== '') {
url.searchParams.append(name, item);
}
}
} else if (value !== '') {
url.searchParams.set(name, value);
}
}
return url.toString();
}
/**
* Safely convert a value to string
*/
function stringifyValue(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value.toString();
}
// At this point, value can only be symbol, bigint, or function - all have toString
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return String(value);
}
/**
* Extract path parameters from tool args
*/
function extractPathParams(args: ToolArgs, paramNames: string[]): Record<string, string> {
const params: Record<string, string> = {};
for (const name of paramNames) {
const value = args[name];
if (value !== undefined && value !== null) {
params[name] = stringifyValue(value);
}
}
return params;
}
/**
* Extract query parameters from tool args
* Arrays are preserved for proper OpenAPI serialization
*/
function extractQueryParams(args: ToolArgs, paramNames: string[]): Record<string, string | string[]> {
const params: Record<string, string | string[]> = {};
for (const name of paramNames) {
const value = args[name];
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
// Preserve arrays for proper serialization in buildUrl
params[name] = value.map((v) => stringifyValue(v));
} else {
params[name] = stringifyValue(value);
}
}
}
return params;
}
/**
* Extract headers from tool args
*/
function extractHeaders(args: ToolArgs, paramNames: string[]): Record<string, string> {
const headers: Record<string, string> = {};
for (const name of paramNames) {
const value = args[name];
if (value !== undefined && value !== null) {
headers[name] = stringifyValue(value);
}
}
return headers;
}
/**
* Build request body from tool args
*/
function buildRequestBody(args: ToolArgs, proxy: ProxyMetadata): string | undefined {
if (proxy.bodyParams.length === 0) {
return undefined;
}
// If there's a single "body" param, use it directly
if (proxy.bodyParams.length === 1 && proxy.bodyParams[0] === 'body') {
const body = args['body'];
return body !== undefined ? JSON.stringify(body) : undefined;
}
// Otherwise, extract body params and create object
const body: Record<string, unknown> = {};
const nonBodyParams = new Set([
...proxy.pathParams,
...proxy.queryParams,
...proxy.headerParams,
]);
for (const name of proxy.bodyParams) {
if (!nonBodyParams.has(name) && name in args) {
body[name] = args[name];
}
}
return Object.keys(body).length > 0 ? JSON.stringify(body) : undefined;
}
/**
* Format response as MCP tool result
*/
function formatResponse(
_status: number,
headers: Headers,
body: string
): MCPToolResult {
const contentType = headers.get('content-type') ?? 'text/plain';
// Try to parse as JSON and pretty print
if (contentType.includes('application/json')) {
try {
const parsed: unknown = JSON.parse(body);
return {
content: [
{
type: 'text',
text: JSON.stringify(parsed, null, 2),
},
],
};
} catch {
// Fall through to raw text
}
}
return {
content: [
{
type: 'text',
text: body,
},
],
};
}
/**
* Format error as MCP tool result
*/
function formatError(message: string, details?: string): MCPToolResult {
const text = details ? `Error: ${message}\n\nDetails: ${details}` : `Error: ${message}`;
return {
content: [{ type: 'text', text }],
isError: true,
};
}
/**
* Execute a tool call by making an HTTP request to the upstream API
*/
export async function executeTool(
tool: MCPToolDefinition,
args: ToolArgs,
config: Config,
context: RequestContext = {}
): Promise<MCPToolResult> {
const { _proxy: proxy } = tool;
try {
// Extract parameters
const pathParams = extractPathParams(args, proxy.pathParams);
const queryParams = extractQueryParams(args, proxy.queryParams);
const headers = extractHeaders(args, proxy.headerParams);
// Apply auth
const authResult = applyAuth(headers, queryParams, config.auth);
// Build URL
const url = buildUrl(
config.upstream.baseUrl,
proxy.path,
pathParams,
authResult.queryParams
);
// Build request options
const requestHeaders: Record<string, string> = {
...authResult.headers,
};
// Add content-type for requests with body
const body = buildRequestBody(args, proxy);
if (body && proxy.contentType) {
requestHeaders['Content-Type'] = proxy.contentType;
}
// Add any static headers from config
if (config.upstream.headers) {
Object.assign(requestHeaders, config.upstream.headers);
}
// Substitute system variables in headers ({{TIMESTAMP}}, {{REQUEST_ID}}, etc.)
const finalHeaders = substituteHeaderVariables(requestHeaders, context);
log.debug('Executing tool request', {
tool: tool.name,
method: proxy.method,
url,
});
// Make the request
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.upstream.timeout);
try {
const response = await fetch(url, {
method: proxy.method,
headers: finalHeaders,
body,
signal: controller.signal,
});
const responseBody = await response.text();
log.debug('Tool request completed', {
tool: tool.name,
status: response.status,
size: responseBody.length,
});
if (!response.ok) {
return formatError(
`HTTP ${response.status} ${response.statusText}`,
responseBody
);
}
return formatResponse(response.status, response.headers, responseBody);
} finally {
clearTimeout(timeout);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (error instanceof Error && error.name === 'AbortError') {
return formatError('Request timed out', `Timeout: ${config.upstream.timeout}ms`);
}
log.error('Tool execution failed', { tool: tool.name, error: message });
return formatError('Request failed', message);
}
}
/**
* Create an executor function for a specific tool
*/
export function createToolExecutor(tool: MCPToolDefinition, config: Config) {
return (args: ToolArgs) => executeTool(tool, args, config);
}