Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
381 lines
14 KiB
TypeScript
381 lines
14 KiB
TypeScript
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
import {
|
|
StdioClientTransport,
|
|
type StdioServerParameters,
|
|
} from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
import type { CallToolResult, ListToolsResult } from "@modelcontextprotocol/sdk/types.js";
|
|
import { isLocalMcpHost } from "@rakazo/contracts";
|
|
import { combineSignals } from "./connector-safety.js";
|
|
import {
|
|
createSafeRemoteFetch,
|
|
type RemoteTransportDependencies,
|
|
type SafeRemoteFetch,
|
|
} from "./remote-mcp.js";
|
|
|
|
export type McpRemoteTransport = "streamable-http" | "sse";
|
|
|
|
export interface McpUrlPolicy {
|
|
/** Maximum URL length accepted before any network request. */
|
|
maxUrlLength?: number;
|
|
/** Permit plain HTTP only on the configured loopback resource's exact origin. */
|
|
allowHttpLocalhost?: boolean;
|
|
/** Permit configured credentials on an explicitly local HTTP endpoint. */
|
|
allowLocalHttpCredentials?: boolean;
|
|
/** Hosts allowed after redirects (redirects are rejected by default). */
|
|
allowedHosts?: readonly string[];
|
|
}
|
|
|
|
export interface McpHeaderPolicy {
|
|
/** Additional SDK request headers allowed beyond defaults and configured headers. */
|
|
allowedHeaders?: readonly string[];
|
|
headers?: Record<string, string>;
|
|
}
|
|
|
|
export interface McpRemoteOptions {
|
|
url: string | URL;
|
|
transport?: McpRemoteTransport;
|
|
urlPolicy?: McpUrlPolicy;
|
|
headerPolicy?: McpHeaderPolicy;
|
|
/** Opt in to the deprecated HTTP+SSE transport. */
|
|
allowLegacySse?: boolean;
|
|
/** Fall back to SSE only after a fresh Streamable HTTP client fails. */
|
|
fallbackToSse?: boolean;
|
|
/** Let the official SDK own token refresh, re-authentication, and request retry. */
|
|
authProvider?: OAuthClientProvider;
|
|
network?: RemoteTransportDependencies;
|
|
signal?: AbortSignal;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface McpStdioOptions {
|
|
command: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
env?: Record<string, string>;
|
|
/** Exact executable allowlist. Stdio is otherwise disabled. */
|
|
allowedCommands: readonly string[];
|
|
maxBufferSize?: number;
|
|
signal?: AbortSignal;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface McpClientOptions {
|
|
name?: string;
|
|
version?: string;
|
|
capabilities?: ConstructorParameters<typeof Client>[1];
|
|
}
|
|
|
|
const DEFAULT_HEADERS = ["accept", "content-type", "authorization", "user-agent"];
|
|
const RESOURCE_HEADERS = new Set(["mcp-session-id", "mcp-protocol-version", "last-event-id"]);
|
|
const DEFAULT_MAX_URL_LENGTH = 2_048;
|
|
|
|
function validateUrl(raw: string | URL, policy: McpUrlPolicy = {}): URL {
|
|
const url = new URL(raw.toString());
|
|
const max = policy.maxUrlLength ?? DEFAULT_MAX_URL_LENGTH;
|
|
if (url.toString().length > max) throw new Error(`MCP URL exceeds ${max} characters`);
|
|
if (url.username || url.password || url.hash)
|
|
throw new Error("MCP URL must not contain credentials or a fragment");
|
|
const local = isLocalMcpHost(url.hostname);
|
|
if (
|
|
url.protocol !== "https:" &&
|
|
!(url.protocol === "http:" && policy.allowHttpLocalhost === true && local)
|
|
) {
|
|
throw new Error("MCP remote URL must use HTTPS (HTTP is allowed only for localhost)");
|
|
}
|
|
if (policy.allowedHosts && !policy.allowedHosts.includes(url.hostname)) {
|
|
throw new Error(`MCP host is not in the allowlist: ${url.hostname}`);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
export function secureFetch(
|
|
resourceUrl: URL,
|
|
urlPolicy: McpUrlPolicy,
|
|
headerPolicy: McpHeaderPolicy = {},
|
|
network: RemoteTransportDependencies = {},
|
|
): SafeRemoteFetch {
|
|
const localOrigin =
|
|
urlPolicy.allowHttpLocalhost === true &&
|
|
resourceUrl.protocol === "http:" &&
|
|
isLocalMcpHost(resourceUrl.hostname)
|
|
? resourceUrl.origin
|
|
: undefined;
|
|
const allowed = new Set(
|
|
[
|
|
...DEFAULT_HEADERS,
|
|
...(headerPolicy.allowedHeaders ?? []),
|
|
...Object.keys(headerPolicy.headers ?? {}),
|
|
].map((header) => header.toLowerCase()),
|
|
);
|
|
const configured = Object.entries(headerPolicy.headers ?? {}).filter(([name]) =>
|
|
allowed.has(name.toLowerCase()),
|
|
);
|
|
const configuredValues = new Map(
|
|
configured.map(([name, value]) => [name.toLowerCase(), value] as const),
|
|
);
|
|
const configuredCredentialValues = new Set(configuredValues.values());
|
|
const configuredNames = new Set(configuredValues.keys());
|
|
const localCredentialHeaders = new Set([
|
|
...configuredNames,
|
|
"authorization",
|
|
"cookie",
|
|
"proxy-authorization",
|
|
]);
|
|
const safeRemoteFetch = createSafeRemoteFetch(network.fetch, network.resolveHostname);
|
|
const request = async (input: Request | URL | string, init?: RequestInit): Promise<Response> => {
|
|
const source = new Request(input, init);
|
|
// OAuth challenges and rediscovery can supply new URLs. Only the explicitly
|
|
// configured local resource origin (including port) may bypass remote policy.
|
|
const url = validateUrl(source.url, {
|
|
...urlPolicy,
|
|
allowHttpLocalhost: new URL(source.url).origin === localOrigin,
|
|
});
|
|
const headers = new Headers();
|
|
for (const [name, value] of source.headers) {
|
|
const normalized = name.toLowerCase();
|
|
if (RESOURCE_HEADERS.has(normalized)) {
|
|
if (url.origin !== resourceUrl.origin) continue;
|
|
} else if (!allowed.has(normalized)) continue;
|
|
if (url.origin !== resourceUrl.origin && configuredCredentialValues.has(value)) continue;
|
|
headers.set(name, value);
|
|
}
|
|
const localHttp = url.protocol === "http:" && isLocalMcpHost(url.hostname);
|
|
if (localHttp && urlPolicy.allowLocalHttpCredentials !== true) {
|
|
for (const name of [...headers.keys()]) {
|
|
if (localCredentialHeaders.has(name.toLowerCase())) headers.delete(name);
|
|
}
|
|
}
|
|
if (!localHttp && url.origin === resourceUrl.origin) {
|
|
for (const [name, value] of configured) headers.set(name, value);
|
|
}
|
|
// Buffer the body: a re-wrapped Request body is a stream without a replayable
|
|
// source, and undici fails the whole request when a server answers 401 early
|
|
// (the OAuth challenge) instead of draining it.
|
|
const body =
|
|
source.method === "GET" || source.method === "HEAD" ? undefined : await source.arrayBuffer();
|
|
const requestInit = {
|
|
method: source.method,
|
|
headers,
|
|
body,
|
|
redirect: "manual",
|
|
signal: source.signal,
|
|
} satisfies RequestInit;
|
|
const response = localHttp
|
|
? await (network.fetch ?? globalThis.fetch)(url, requestInit)
|
|
: await safeRemoteFetch(url, requestInit);
|
|
if (response.status >= 300 && response.status < 400) {
|
|
throw new Error("MCP redirects are not permitted; configure the final HTTPS URL explicitly");
|
|
}
|
|
return response;
|
|
};
|
|
const result = request as SafeRemoteFetch;
|
|
result.close = () => safeRemoteFetch.close();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Some providers (Brex) advertise OAuth discovery and registration hosts that
|
|
* are not publicly routable, while mirroring the same well-known documents on
|
|
* the MCP endpoint's own origin. When a request to another origin cannot
|
|
* connect, retry the same path on the endpoint origin before giving up.
|
|
*/
|
|
export function withEndpointOriginFallback(
|
|
endpointOrigin: string,
|
|
fetchImpl: typeof fetch = fetch,
|
|
): typeof fetch {
|
|
// Credentials must not be replayed to a different origin than the one the
|
|
// SDK addressed, so the retry drops auth/session headers.
|
|
const SENSITIVE_RETRY_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
|
|
const sanitizedInit = (init?: RequestInit): RequestInit | undefined => {
|
|
if (!init?.headers) return init;
|
|
const headers = new Headers(init.headers);
|
|
for (const name of [...headers.keys()]) {
|
|
if (SENSITIVE_RETRY_HEADERS.has(name.toLowerCase())) headers.delete(name);
|
|
}
|
|
return { ...init, headers };
|
|
};
|
|
return async (input: Request | URL | string, init?: RequestInit): Promise<Response> => {
|
|
if (input instanceof Request) return fetchImpl(input, init);
|
|
const url = new URL(String(input));
|
|
if (url.origin === endpointOrigin) return fetchImpl(input, init);
|
|
const method = (init?.method ?? "GET").toUpperCase();
|
|
if (method !== "GET" && method !== "HEAD") return fetchImpl(input, init);
|
|
try {
|
|
// Cap the first attempt: an unroutable host otherwise burns the full
|
|
// connect timeout before the fallback gets a chance.
|
|
return await fetchImpl(input, {
|
|
...init,
|
|
signal: combineSignals(init?.signal ?? undefined, AbortSignal.timeout(4_000)),
|
|
});
|
|
} catch {
|
|
init?.signal?.throwIfAborted();
|
|
return fetchImpl(new URL(url.pathname + url.search, endpointOrigin), sanitizedInit(init));
|
|
}
|
|
};
|
|
}
|
|
|
|
function stdioParams(options: McpStdioOptions): StdioServerParameters {
|
|
const command = options.command.trim();
|
|
if (!command || !options.allowedCommands.includes(command)) {
|
|
throw new Error("MCP stdio command is not in the configured allowlist");
|
|
}
|
|
const env: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(options.env ?? {})) {
|
|
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) env[key] = value;
|
|
}
|
|
return {
|
|
command,
|
|
args: options.args ?? [],
|
|
cwd: options.cwd,
|
|
env,
|
|
stderr: "pipe",
|
|
maxBufferSize: options.maxBufferSize,
|
|
};
|
|
}
|
|
|
|
/** A narrow seam around the official SDK, suitable for the agent tool layer. */
|
|
export class McpSession {
|
|
client: Client;
|
|
private readonly clientOptions: McpClientOptions;
|
|
private transport?: Transport;
|
|
private remoteFetch?: SafeRemoteFetch;
|
|
private connected = false;
|
|
private connecting?: Promise<void>;
|
|
|
|
constructor(options: McpClientOptions = {}) {
|
|
this.clientOptions = options;
|
|
this.client = this.newClient();
|
|
}
|
|
|
|
private newClient(): Client {
|
|
return new Client(
|
|
{ name: this.clientOptions.name ?? "rakazo", version: this.clientOptions.version ?? "0.1.0" },
|
|
this.clientOptions.capabilities,
|
|
);
|
|
}
|
|
|
|
async connectRemote(
|
|
options: McpRemoteOptions,
|
|
): Promise<{ transport: McpRemoteTransport; usedFallback: boolean }> {
|
|
if (this.connected || this.connecting)
|
|
throw new Error("MCP session is already connected or connecting");
|
|
const url = validateUrl(options.url, options.urlPolicy);
|
|
const safeFetch = secureFetch(
|
|
url,
|
|
options.urlPolicy ?? {},
|
|
options.headerPolicy,
|
|
options.network,
|
|
);
|
|
this.remoteFetch = safeFetch;
|
|
const fetch = withEndpointOriginFallback(url.origin, safeFetch);
|
|
const signal = combineSignals(options.signal, AbortSignal.timeout(options.timeoutMs ?? 15_000));
|
|
let usedFallback = false;
|
|
const connect = async (kind: McpRemoteTransport): Promise<void> => {
|
|
const requestInit: RequestInit = { headers: options.headerPolicy?.headers };
|
|
const transport =
|
|
kind === "streamable-http"
|
|
? new StreamableHTTPClientTransport(url, {
|
|
fetch,
|
|
requestInit,
|
|
authProvider: options.authProvider,
|
|
})
|
|
: new SSEClientTransport(url, {
|
|
fetch,
|
|
requestInit,
|
|
authProvider: options.authProvider,
|
|
eventSourceInit: { fetch: fetch as never },
|
|
});
|
|
this.transport = transport;
|
|
await this.client.connect(transport, { signal, timeout: options.timeoutMs ?? 15_000 });
|
|
this.connected = true;
|
|
};
|
|
this.connecting = (async () => {
|
|
try {
|
|
await connect(options.transport ?? "streamable-http");
|
|
} catch (error) {
|
|
const canFallback =
|
|
(options.fallbackToSse ?? true) &&
|
|
options.allowLegacySse &&
|
|
(options.transport ?? "streamable-http") === "streamable-http";
|
|
await this.client.close().catch(() => undefined);
|
|
this.connected = false;
|
|
this.transport = undefined;
|
|
if (!canFallback) {
|
|
await this.remoteFetch?.close().catch(() => undefined);
|
|
this.remoteFetch = undefined;
|
|
throw error;
|
|
}
|
|
usedFallback = true;
|
|
// The SDK's documented fallback uses a fresh Client after a failed
|
|
// Streamable HTTP handshake; a Client cannot be reconnected safely.
|
|
this.client = this.newClient();
|
|
try {
|
|
await connect("sse");
|
|
} catch (fallbackError) {
|
|
await this.close();
|
|
throw fallbackError;
|
|
}
|
|
} finally {
|
|
this.connecting = undefined;
|
|
}
|
|
})();
|
|
await this.connecting;
|
|
return {
|
|
transport: usedFallback ? "sse" : (options.transport ?? "streamable-http"),
|
|
usedFallback,
|
|
};
|
|
}
|
|
|
|
async connectStdio(options: McpStdioOptions): Promise<void> {
|
|
if (this.connected || this.connecting)
|
|
throw new Error("MCP session is already connected or connecting");
|
|
const transport = new StdioClientTransport(stdioParams(options));
|
|
this.transport = transport;
|
|
const signal = combineSignals(options.signal, AbortSignal.timeout(options.timeoutMs ?? 15_000));
|
|
this.connecting = this.client
|
|
.connect(transport, { signal, timeout: options.timeoutMs ?? 15_000 })
|
|
.then(() => {
|
|
this.connected = true;
|
|
})
|
|
.finally(() => {
|
|
this.connecting = undefined;
|
|
});
|
|
await this.connecting;
|
|
}
|
|
|
|
async listTools(options?: { signal?: AbortSignal }): Promise<ListToolsResult> {
|
|
this.assertConnected();
|
|
return this.client.listTools({}, { signal: options?.signal });
|
|
}
|
|
|
|
async callTool(
|
|
name: string,
|
|
args: Record<string, unknown> = {},
|
|
options?: { signal?: AbortSignal },
|
|
): Promise<CallToolResult> {
|
|
this.assertConnected();
|
|
return (await this.client.callTool({ name, arguments: args }, undefined, {
|
|
signal: options?.signal,
|
|
})) as CallToolResult;
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.connecting = undefined;
|
|
this.connected = false;
|
|
await this.client.close().catch(() => undefined);
|
|
await this.remoteFetch?.close().catch(() => undefined);
|
|
this.remoteFetch = undefined;
|
|
this.transport = undefined;
|
|
}
|
|
|
|
private assertConnected(): void {
|
|
if (!this.connected) throw new Error("MCP session is not connected");
|
|
}
|
|
}
|
|
|
|
export { validateUrl };
|