lib/mcp-proxy.ts
Rama main · réplica de lectura del repositorio original.
import { emptyDiagnostics } from "./diagnostics";
import { assertSafeUpstreamUrl, NetworkPolicyError, readBoundedResponseText } from "./runtime/network-policy";
import type { GeneratedMcpServer, GenerationDiagnostics, McpOperationFilter, McpTool, OpenApiEndpoint, RuntimePermissionPolicy, SpecPreview } from "./types";
const MAX_MCP_TOOLS = 100;
const MAX_MCP_RESPONSE_BYTES = 1_000_000;
const MCP_TIMEOUT_MS = 15_000;
type JsonRpcPayload = {
jsonrpc?: string;
id?: string | number | null;
result?: unknown;
error?: {
code?: number;
message?: string;
data?: unknown;
};
};
type RemoteMcpTool = {
name?: unknown;
description?: unknown;
inputSchema?: unknown;
input_schema?: unknown;
annotations?: unknown;
};
type RemoteMcpImport = {
endpointUrl: string;
tools: McpTool[];
endpointMap: OpenApiEndpoint[];
diagnostics: GenerationDiagnostics;
generated: GeneratedMcpServer;
};
export type RemoteMcpCredential = {
scheme: "bearer" | "api_key_header" | "api_key_query" | "oauth2";
secret: string;
injectionName?: string | null;
};
type RemoteMcpRpcOptions = {
credential?: RemoteMcpCredential | null;
sessionId?: string | null;
};
function jsonRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function safeToolName(value: string, index: number) {
const cleaned = value
.trim()
.replace(/[^a-zA-Z0-9_.-]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 80);
return cleaned || `mcp_tool_${index + 1}`;
}
function titleFromUrl(url: URL) {
return `${url.hostname.replace(/^www\./, "")} MCP`;
}
function localMcpProxyFixtureEnabled() {
return process.env.ASTRAIL_ENABLE_LOCAL_MCP_PROXY_FIXTURES === "1";
}
function isLocalMcpProxyFixture(endpoint: URL) {
const hostname = endpoint.hostname.toLowerCase();
return localMcpProxyFixtureEnabled()
&& endpoint.protocol === "http:"
&& (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]");
}
async function assertSafeMcpEndpoint(endpoint: URL, credential?: RemoteMcpCredential | null) {
if (isLocalMcpProxyFixture(endpoint)) return;
if (credential && endpoint.protocol !== "https:") {
throw new NetworkPolicyError("Authenticated remote MCP calls require HTTPS.", "upstream_tls_required");
}
await assertSafeUpstreamUrl(endpoint);
}
function inferOperationKind(tool: McpTool): McpOperationFilter {
const annotations = jsonRecord(tool.annotations);
if (annotations.destructiveHint === true) return "destructive";
if (annotations.readOnlyHint === true) return "read";
if (/delete|remove|destroy|purge|erase|void|refund/i.test(tool.name)) return "destructive";
if (/^(get|list|search|read|fetch|describe|find|lookup)/i.test(tool.name)) return "read";
return "write";
}
function normalizeInputSchema(tool: RemoteMcpTool) {
const schema = tool.inputSchema ?? tool.input_schema;
return schema && typeof schema === "object" && !Array.isArray(schema)
? schema as Record<string, unknown>
: { type: "object", properties: {} };
}
function normalizeAnnotations(tool: RemoteMcpTool) {
return tool.annotations && typeof tool.annotations === "object" && !Array.isArray(tool.annotations)
? tool.annotations as McpTool["annotations"]
: undefined;
}
function normalizeRemoteTools(rawTools: unknown) {
const tools = Array.isArray(rawTools) ? rawTools : [];
const seen = new Set<string>();
return tools.slice(0, MAX_MCP_TOOLS).map((raw, index) => {
const record = jsonRecord(raw) as RemoteMcpTool;
const upstreamName = typeof record.name === "string" && record.name.trim()
? record.name.trim()
: `mcp_tool_${index + 1}`;
const baseName = safeToolName(upstreamName, index);
let name = baseName;
let suffix = 2;
while (seen.has(name)) {
name = `${baseName.slice(0, 74)}_${suffix}`;
suffix += 1;
}
seen.add(name);
const tool: McpTool = {
name,
description: typeof record.description === "string" && record.description.trim()
? record.description
: `Proxy ${name} through the upstream MCP server.`,
input_schema: normalizeInputSchema(record),
annotations: normalizeAnnotations(record),
metadata: { upstream_tool_name: upstreamName },
method: "MCP_PROXY",
path: name,
};
const operationKind = inferOperationKind(tool);
tool.policy = operationKind === "read" ? "allow" : operationKind === "destructive" ? "block" : "approval";
return tool;
});
}
function extractJsonFromSse(text: string) {
const dataLines = text
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.filter((line) => line && line !== "[DONE]");
for (const line of dataLines) {
try {
return JSON.parse(line) as unknown;
} catch {
// Keep scanning. Some SSE streams include comments or non-JSON events.
}
}
return null;
}
function parseJsonRpcPayload(text: string): JsonRpcPayload {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
parsed = extractJsonFromSse(text);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Remote MCP endpoint did not return a JSON-RPC object.");
}
return parsed as JsonRpcPayload;
}
function remoteMcpRequest(endpointUrl: string, options: RemoteMcpRpcOptions) {
const endpoint = new URL(endpointUrl);
if (options.credential && endpoint.protocol !== "https:" && !isLocalMcpProxyFixture(endpoint)) {
throw new NetworkPolicyError("Authenticated remote MCP calls require HTTPS.", "upstream_tls_required");
}
const headers: Record<string, string> = {
accept: "application/json, text/event-stream",
"content-type": "application/json",
"mcp-protocol-version": "2024-11-05",
"x-astrail-upstream": "mcp-proxy",
};
if (options.sessionId) headers["mcp-session-id"] = options.sessionId;
const credential = options.credential;
if (credential?.scheme === "bearer" || credential?.scheme === "oauth2") {
headers.authorization = `Bearer ${credential.secret}`;
} else if (credential?.scheme === "api_key_header") {
const headerName = credential.injectionName?.trim() || "x-api-key";
if (!/^[a-z0-9-]+$/i.test(headerName)) throw new Error("MCP credential header name is invalid.");
headers[headerName] = credential.secret;
} else if (credential?.scheme === "api_key_query") {
endpoint.searchParams.set(credential.injectionName?.trim() || "api_key", credential.secret);
}
return { endpoint, headers };
}
async function callRemoteMcpRpc(endpointUrl: string, method: string, params: Record<string, unknown> = {}, options: RemoteMcpRpcOptions = {}) {
const request = remoteMcpRequest(endpointUrl, options);
const response = await fetch(request.endpoint, {
method: "POST",
headers: request.headers,
body: JSON.stringify({
jsonrpc: "2.0",
id: `astrail_${Date.now().toString(36)}`,
method,
params,
}),
redirect: "manual",
signal: AbortSignal.timeout(MCP_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Remote MCP endpoint returned HTTP ${response.status}.`);
}
const payload = parseJsonRpcPayload(await readBoundedResponseText(response, MAX_MCP_RESPONSE_BYTES, "Remote MCP response"));
if (payload.error) {
throw new Error(payload.error.message || `Remote MCP JSON-RPC error ${payload.error.code ?? "unknown"}.`);
}
if (payload.result === undefined) {
throw new Error("Remote MCP endpoint returned an empty JSON-RPC result.");
}
return { result: payload.result, sessionId: response.headers.get("mcp-session-id") ?? options.sessionId ?? null };
}
async function notifyRemoteMcpInitialized(endpointUrl: string, options: RemoteMcpRpcOptions = {}) {
const request = remoteMcpRequest(endpointUrl, options);
const response = await fetch(request.endpoint, {
method: "POST",
headers: request.headers,
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }),
redirect: "manual",
signal: AbortSignal.timeout(MCP_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`Remote MCP initialized notification returned HTTP ${response.status}.`);
}
function toolsFromListResult(result: unknown) {
const record = jsonRecord(result);
return Array.isArray(record.tools) ? record.tools : [];
}
async function listRemoteMcpTools(endpointUrl: string, credential?: RemoteMcpCredential | null) {
let sessionId: string | null = null;
try {
const initialized = await callRemoteMcpRpc(endpointUrl, "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: {
name: "Astrail MCP Importer",
version: "1.0.0",
},
}, { credential });
sessionId = initialized.sessionId;
await notifyRemoteMcpInitialized(endpointUrl, { credential, sessionId });
} catch {
// Some stateless HTTP MCP servers allow tools/list without initialize.
}
const listed = await callRemoteMcpRpc(endpointUrl, "tools/list", {}, { credential, sessionId });
return normalizeRemoteTools(toolsFromListResult(listed.result));
}
export async function inspectRemoteMcpServer(sourceUrl: string, credential?: RemoteMcpCredential | null) {
const endpoint = new URL(sourceUrl);
await assertSafeMcpEndpoint(endpoint, credential);
const tools = await listRemoteMcpTools(endpoint.toString(), credential);
if (tools.length === 0) {
throw new Error("Remote MCP endpoint returned no tools.");
}
return { endpoint, tools };
}
export async function previewRemoteMcpServer(sourceUrl: string, credential?: RemoteMcpCredential | null): Promise<SpecPreview> {
const { endpoint, tools } = await inspectRemoteMcpServer(sourceUrl, credential);
const allOperations: Array<{ name: McpOperationFilter; count: number }> = [
{ name: "read", count: tools.filter((tool) => inferOperationKind(tool) === "read").length },
{ name: "write", count: tools.filter((tool) => inferOperationKind(tool) === "write").length },
{ name: "destructive", count: tools.filter((tool) => inferOperationKind(tool) === "destructive").length },
];
const operations = allOperations.filter((item) => item.count > 0);
return {
source_url: endpoint.toString(),
spec_size_bytes: 0,
endpoint_count: tools.length,
endpoint_limit: tools.length,
groups: [{ name: "MCP tools", count: tools.length }],
resources: [{ name: "mcp", count: tools.length }],
operations,
recommended_mode: "dynamic",
client_presets: ["default", "claude", "claude-code", "cursor", "openai"],
is_large: tools.length > 30,
warning: tools.length >= MAX_MCP_TOOLS
? `Imported the first ${MAX_MCP_TOOLS} tools from this MCP endpoint.`
: null,
diagnostics: [
`Remote MCP endpoint: ${endpoint.toString()}`,
`Tools found: ${tools.length}.`,
"Transport: streamable HTTP JSON-RPC.",
],
};
}
export async function buildRemoteMcpImport(sourceUrl: string, runtimePolicy?: RuntimePermissionPolicy | null, credential?: RemoteMcpCredential | null): Promise<RemoteMcpImport> {
const { endpoint, tools } = await inspectRemoteMcpServer(sourceUrl, credential);
const diagnostics = emptyDiagnostics(endpoint.toString());
diagnostics.discovered_url = endpoint.toString();
diagnostics.discovery_method = "mcp_tools_list";
diagnostics.endpoint_count = tools.length;
diagnostics.selected_group = "MCP tools";
diagnostics.tools_generated = tools.length;
diagnostics.timestamps.completed_at = new Date().toISOString();
diagnostics.raw = [
`Remote MCP endpoint: ${endpoint.toString()}`,
`Tools imported: ${tools.length}.`,
`Runtime policy: ${runtimePolicy ? "enabled" : "open"}.`,
];
diagnostics.trace.push(
{
label: "Remote MCP endpoint inspected",
status: "passed",
detail: endpoint.toString(),
},
{
label: `${tools.length} MCP tools imported`,
status: "passed",
}
);
const endpointMap = tools.map((tool): OpenApiEndpoint => ({
method: "MCP_PROXY",
path: typeof tool.metadata?.upstream_tool_name === "string" ? tool.metadata.upstream_tool_name : tool.name,
base_url: endpoint.toString(),
runtime_kind: "mcp_proxy",
tool_name: tool.name,
operation_id: tool.name,
summary: tool.description,
description: tool.description,
tags: ["mcp"],
parameters: [],
input_schema: tool.input_schema ?? { type: "object", properties: {} },
requires_auth: false,
visibility: "private",
resource: "mcp",
operation_kind: inferOperationKind(tool),
policy: tool.policy,
}));
return {
endpointUrl: endpoint.toString(),
tools,
endpointMap,
diagnostics,
generated: {
name: titleFromUrl(endpoint),
description: `Astrail proxy for ${endpoint.toString()}. Tools execute through the upstream MCP server while Astrail handles hosted access, billing, logs, and runtime policy.`,
tools,
generated_code: [
`// Astrail MCP proxy for ${endpoint.toString()}`,
"// This server fronts an existing streamable-HTTP MCP endpoint.",
"// Live calls are forwarded by Astrail's hosted runtime; no upstream code is executed inside the dashboard.",
].join("\n"),
},
};
}
export async function callRemoteMcpTool(endpointUrl: string, toolName: string, args: Record<string, unknown>, credential?: RemoteMcpCredential | null) {
const endpoint = new URL(endpointUrl);
await assertSafeMcpEndpoint(endpoint, credential);
let sessionId: string | null = null;
try {
const initialized = await callRemoteMcpRpc(endpoint.toString(), "initialize", {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "Astrail MCP Proxy", version: "1.0.0" },
}, { credential });
sessionId = initialized.sessionId;
await notifyRemoteMcpInitialized(endpoint.toString(), { credential, sessionId });
} catch {
// Stateless servers may reject initialization and still accept tools/call.
}
const called = await callRemoteMcpRpc(endpoint.toString(), "tools/call", {
name: toolName,
arguments: args,
}, { credential, sessionId });
return called.result;
}