mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The tool gateway calls remote MCP servers over Streamable HTTP, and a server can answer `tools/call` with a `text/event-stream` body > - Such a server can send progress notifications before the response, and a slow tool can take more than 10 seconds > - The gateway aborts a remote MCP `tools/call` that uses the default deadline after 10 seconds, the general tool default > - When the call fails, the call evidence does not show how far the stream got, or whether the cause was the deadline or a broken transport > - This pull request gives remote MCP calls a default deadline of 30 seconds, the default response timeout of the remote HTTP client, and records the failure cause and the stream progress > - The benefit is that slow remote tools complete, and an operator can tell a deadline from a peer reset in the call evidence ## Linked Issues or Issue Description Refs #10147. That issue reports that a named MCP gateway aborts remote tools after 10 seconds and ignores the connection timeoutMs. This pull request raises the default deadline of a remote MCP `tools/call` to 30 seconds. It does not make the named gateway read the connection timeoutMs. Related: #11910 (open) keeps a connection callable after one timed-out tools/call. On current master a timeout and a JSON-RPC response error already keep the connection healthy, and this pull request keeps that policy. I searched pull requests and issues for "event stream tools/call", "SSE MCP tool call hang", "remote MCP timeout stream", "mcp text/event-stream" and "tool_timeout remote MCP". The only related open pull request is #11910. ## What Changed - `server/src/services/remote-http-fetch.ts`: the default response timeout is exported as `DEFAULT_REMOTE_HTTP_RESPONSE_TIMEOUT_MS` (30,000 ms, unchanged value). - `server/src/services/tool-gateway.ts`: - A remote MCP `tools/call` that uses the default deadline now gets `DEFAULT_REMOTE_HTTP_RESPONSE_TIMEOUT_MS` instead of the general tool default of 10 seconds. The Railway `run-command` budget and an explicit `timeoutMs` keep their values. - The call evidence records the response status, content type and request id before the body is read. A body that fails partway records the bytes received so far as its size. - A failed call records `failureKind` (`invocation_timeout`, `protocol_failure` or `transport_failure`), the deadline and whether it was aborted, the stream progress, and the error with its cause chain. Each string in the chain is redacted with `redactSensitiveText`, and a cyclic cause chain is cut with a "[Circular cause]" marker. - A deadline abort is a 504 `tool_timeout` and starts a fresh MCP session on the next call, as before. The connection health policy of master does not change. - A deliberate stop of a call (`elicitation_required`, `provider_interaction_required`, `oauth_insufficient_scope`, `oauth_refreshed_retry_required`) keeps its own reason when the deadline passes while it is recorded, as on master. An error that is already a gateway error also gets a failure kind: `tool_timeout` is `invocation_timeout`; a malformed, invalid or oversized response is `protocol_failure`; and `remote_http_*` (for example a DNS failure) and `mcp_remote_fetch_failed` are `transport_failure`. - A non-success response body is read as a stream, so a body that fails partway records the bytes that arrived. The reader stops at the size limit and cancels the body, as the `tools/call` reader does. #19785 makes the same reader change for the context path. - `server/src/services/mcp-http.ts`: `readMcpHttpResponse` takes an `onProgress` callback. It reports bytes, chunks and events received, whether the matching response arrived, and whether the stream ended. As on master, the read returns as soon as the response with the matching id arrives. - Tests: - `server/src/__tests__/mcp-http.test.ts`: a partial event followed by a peer error keeps the progress that was consumed, and releases the reader. - `server/src/__tests__/tool-gateway.test.ts`: six scenarios run against a remote server that streams one progress event first: slow success after 12 seconds, the default deadline, an explicit 1,500 ms deadline, a peer reset, a cyclic error cause, and a primitive error value. - `server/src/__tests__/tool-gateway.test.ts`: three regression tests: a tool question sent after the deadline passed keeps `elicitation_required`; a DNS failure records `transport_failure`; and a 503 body that fails partway records the bytes that arrived. All three fail on717e6c77cand pass on this head. - `server/src/__tests__/tool-gateway-service.test.ts`: when the call deadline passes during a forced Vercel token refresh, the call keeps the 409 `oauth_refreshed_retry_required` stop. `tool-gateway.ts` adds this reason to the deliberate stops, so the deadline does not change it to a 504 `tool_timeout`. ## Verification Head SHA: `25c6e703d1ded895989e9d2fcd6d6352a4f851f5`. It is the previous head `7836447f2` plus commit `25c6e703d`, which keeps the `oauth_refreshed_retry_required` reason when the call deadline passes during a token refresh. At this head, run from `server` with pnpm 9.15.4: `pnpm typecheck`: no errors; `mcp-http.test.ts`, `tool-gateway.test.ts` and `tool-gateway-service.test.ts`: 307 passed. Without the one-line change in `tool-gateway.ts`, the new test "keeps the explicit retry reason when the call deadline passes during the Vercel token refresh" fails: it receives 504 `tool_timeout`, not 409 `oauth_refreshed_retry_required`. Previous head: `7836447f24e2e6e9e0c402d4644d1f7f69b75cb8`. It is an ordinary merge of master `76994c50275dbc005cc248e11189a38baa21719f`, which includes #19877, into the previous head `00150e59c`. At this head, run from `server` with pnpm 9.15.4: `pnpm typecheck` and the test commands below. Results: typecheck: no errors; the 2 test files above: 116 passed. Previous head:00150e59c1. It merges master18b6b71d8. Run from `server`: ```sh pnpm exec vitest run src/__tests__/mcp-http.test.ts src/__tests__/tool-gateway.test.ts ``` - With this branch: 116 passed. With tool-gateway-service.test.ts and remote-mcp-socks-proxy.test.ts added: 315 passed in 4 files. - With `services/mcp-http.ts`, `services/remote-http-fetch.ts` and `services/tool-gateway.ts` from master and the test files from this branch, measured at717e6c77c: 7 failed, 106 passed. The failing tests are the six "records remote SSE ... without confusing a call deadline with a broken transport" scenarios and "retains consumed SSE progress when a partial event fails and releases the reader". - The existing master tests "keeps a remote MCP connection healthy and starts a fresh MCP session after a tool call timeout" and "... after a response body timeout" pass with this branch. - Typecheck: `pnpm --filter @paperclipai/server typecheck` reports 29 errors, all "Cannot find module @paperclipai/plugin-sdk" and the implicit any that follows, in the sandbox provider plugin packages of my local install. None is in a file that this pull request touches. - I ran the tests with an embedded PostgreSQL from the repository dependencies. The full upstream suite runs in CI. I ran only the test files named above. ## Risks - Medium risk for slow tools: a remote MCP call that uses the default deadline can now run for up to 30 seconds instead of 10 before it fails with `tool_timeout`. An explicit `timeoutMs` still applies. - The call evidence is larger: it can hold a redacted error chain and its stack traces. All strings go through `redactSensitiveText`. - No automatic replay: a timed-out `tools/call` is not sent again, because it can have changed data in the remote app. ## Model Used - Original change: the model that wrote it was not recorded. Port to current master, the adaptation to the connection health policy of master, and this description: Claude Opus 5.5 (claude-opus-5-5), with tool use and terminal code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no user-facing documentation change needed) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
431 lines
17 KiB
TypeScript
431 lines
17 KiB
TypeScript
import { request as httpRequest, type IncomingMessage } from "node:http";
|
|
import { request as httpsRequest } from "node:https";
|
|
import { connect as netConnect, isIP, type Socket } from "node:net";
|
|
import { Readable } from "node:stream";
|
|
import { connect as tlsConnect, type TLSSocket } from "node:tls";
|
|
import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
|
|
import { classifyRemoteConnectionError, markRemoteConnectionFailure, recordRemoteConnectionAttempts } from "./remote-connection-failure.js";
|
|
|
|
import {
|
|
isAlwaysDeniedLinkLocalIp,
|
|
isPrivateOrReservedIp,
|
|
normalizeIpAddress,
|
|
resolveApprovedRemoteHttpAddresses,
|
|
type RemoteHttpEndpointErrorFactory,
|
|
type RemoteHttpEndpointGuardOptions,
|
|
} from "./remote-http-endpoint-guard.js";
|
|
|
|
/** Statuses whose HTTP semantics forbid a response body. */
|
|
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
|
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
|
|
/**
|
|
* Stands in for undici's `headersTimeout`/`bodyTimeout`, which the platform
|
|
* `fetch` applied for free. Without it a remote server that accepts the
|
|
* connection and then stays silent would hold the request open indefinitely —
|
|
* the OAuth callers pass no `AbortSignal`, so nothing else would ever cut it
|
|
* loose (PAP-17110).
|
|
*
|
|
* Deliberately far tighter than undici's 300 s: everything that reaches this
|
|
* transport is metadata discovery, a token exchange, a DCR call or an MCP
|
|
* JSON-RPC round trip, none of which has any business taking minutes. Callers
|
|
* that own a longer budget — `tools/call`, which an operator can raise to 60 s —
|
|
* pass `responseTimeoutMs` so this default never truncates it.
|
|
*/
|
|
export const DEFAULT_REMOTE_HTTP_RESPONSE_TIMEOUT_MS = 30_000;
|
|
const DNS_RESOLUTION_ERROR_CODES = new Set(["ENODATA", "ENOTFOUND", "EAI_AGAIN"]);
|
|
|
|
/** How a verified socket is opened. Overridable so tests can simulate a rebind. */
|
|
export type RemoteHttpSocketFactory = (target: {
|
|
address: string;
|
|
port: number;
|
|
hostname: string;
|
|
useTls: boolean;
|
|
}) => Socket;
|
|
|
|
export type GuardedRemoteHttpFetchOptions = RemoteHttpEndpointGuardOptions & {
|
|
/** Builds the rejection thrown when the guard or the peer check fails. */
|
|
error: RemoteHttpEndpointErrorFactory;
|
|
/**
|
|
* Test seam: opens the raw TCP socket. Production leaves this unset so the
|
|
* socket is dialled at the approved address. A factory that connects
|
|
* somewhere else stands in for a rebind below the DNS layer, and must still
|
|
* be caught by the peer-address check.
|
|
*/
|
|
socketFactory?: RemoteHttpSocketFactory;
|
|
connectTimeoutMs?: number;
|
|
/** Deadline for response headers, and idle deadline between body chunks. */
|
|
responseTimeoutMs?: number;
|
|
/**
|
|
* Platform `fetch`, used only for IP literals, which cannot be rebound.
|
|
*/
|
|
unpinnedFetch?: typeof fetch;
|
|
};
|
|
|
|
/**
|
|
* Fetch a remote endpoint without reopening the DNS-rebinding window the guard
|
|
* just closed (PAP-17098).
|
|
*
|
|
* `assertPublicRemoteHttpEndpoint` used to hand its verdict to a bare global
|
|
* `fetch`, which resolved the hostname a second time. An attacker who controls
|
|
* the name server for the hostname could answer with a public address for the
|
|
* guard's lookup and a loopback, RFC 1918 or link-local address for the
|
|
* connection, reaching internal services and cloud metadata from the Paperclip
|
|
* server. This function instead:
|
|
*
|
|
* 1. resolves and validates the hostname exactly once, keeping the approved
|
|
* address set;
|
|
* 2. dials one of those approved addresses directly, so the socket layer never
|
|
* performs its own lookup;
|
|
* 3. preserves the original hostname for the `Host` header, for TLS SNI and for
|
|
* certificate identity checking, so pinning is invisible to the peer;
|
|
* 4. re-checks the address the socket actually connected to before a single
|
|
* request byte is written, which also covers a rebind below DNS; and
|
|
* 5. never follows redirects — it behaves as `redirect: "manual"` so the caller
|
|
* re-runs the whole guard against every `Location` it decides to follow.
|
|
*
|
|
* URLs that already carry an IP literal need no pinning and keep platform
|
|
* `fetch` semantics: no name resolution happens on
|
|
* either side of the guard, so there is no second answer to disagree with the
|
|
* first. `URL` has already normalised the literal (`0x7f.1`, `::ffff:7f00:1`)
|
|
* by the time the guard classifies it.
|
|
*/
|
|
export async function guardedRemoteHttpFetch(
|
|
url: string | URL,
|
|
init: RequestInit,
|
|
options: GuardedRemoteHttpFetchOptions,
|
|
): Promise<Response> {
|
|
const endpoint = url instanceof URL ? url : new URL(url);
|
|
const approved = await resolveApprovedRemoteHttpAddresses(endpoint, options, options.error);
|
|
const literalHost = isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) !== 0;
|
|
const platformFetch = options.unpinnedFetch ?? fetch;
|
|
if (literalHost) {
|
|
try {
|
|
return await platformFetch(endpoint.toString(), { ...init, redirect: "manual" });
|
|
} catch (error) {
|
|
if (isDnsResolutionError(error)) {
|
|
const failure = options.error(
|
|
"Remote MCP connection hostname could not be resolved",
|
|
"remote_http_dns_failed",
|
|
);
|
|
if (classifyRemoteConnectionError(error) === "dns_failure") markRemoteConnectionFailure(failure, "dns_failure");
|
|
throw failure;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
return pinnedRequest(endpoint, approved, init, options);
|
|
}
|
|
|
|
/** Node's platform fetch wraps DNS failures in TypeError.cause. */
|
|
function isDnsResolutionError(error: unknown): boolean {
|
|
const seen = new Set<unknown>();
|
|
let current = error;
|
|
while (typeof current === "object" && current !== null && !seen.has(current)) {
|
|
seen.add(current);
|
|
const record = current as { code?: unknown; cause?: unknown };
|
|
if (typeof record.code === "string" && DNS_RESOLUTION_ERROR_CODES.has(record.code)) return true;
|
|
current = record.cause;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function pinnedRequest(
|
|
endpoint: URL,
|
|
approved: string[],
|
|
init: RequestInit,
|
|
options: GuardedRemoteHttpFetchOptions,
|
|
): Promise<Response> {
|
|
const useTls = endpoint.protocol === "https:";
|
|
const hostname = endpoint.hostname.replace(/^\[|\]$/g, "");
|
|
const port = endpoint.port ? Number(endpoint.port) : useTls ? 443 : 80;
|
|
const approvedSet = new Set(approved.map(normalizeIpAddress));
|
|
const signal = init.signal ?? null;
|
|
|
|
signal?.throwIfAborted?.();
|
|
|
|
const socket = await dialApprovedAddress({ approved, approvedSet, port, hostname, useTls, signal, options });
|
|
|
|
try {
|
|
return await sendRequest({
|
|
endpoint,
|
|
hostname,
|
|
port,
|
|
useTls,
|
|
socket,
|
|
init,
|
|
signal,
|
|
responseTimeoutMs: options.responseTimeoutMs ?? DEFAULT_REMOTE_HTTP_RESPONSE_TIMEOUT_MS,
|
|
error: options.error,
|
|
});
|
|
} catch (error) {
|
|
socket.destroy();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** An approved address that could not be reached, so the next one may be tried. */
|
|
class UnreachableAddressError extends Error {
|
|
constructor(readonly reason: unknown) {
|
|
super("Remote MCP approved address was unreachable");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try the approved addresses in resolution order.
|
|
*
|
|
* `fetch` walks every A/AAAA record before giving up, so pinning to `approved[0]`
|
|
* alone would break a multi-homed host whose first record happens to be dead. A
|
|
* peer that fails the address check is a different matter — that is the rebinding
|
|
* defence firing, not a reachability problem — so it fails closed immediately
|
|
* rather than moving down the list.
|
|
*/
|
|
async function dialApprovedAddress(input: {
|
|
approved: string[];
|
|
approvedSet: Set<string>;
|
|
port: number;
|
|
hostname: string;
|
|
useTls: boolean;
|
|
signal: AbortSignal | null;
|
|
options: GuardedRemoteHttpFetchOptions;
|
|
}): Promise<Socket | TLSSocket> {
|
|
let lastReason: unknown;
|
|
const failedAttempts: unknown[] = [];
|
|
for (const address of input.approved) {
|
|
input.signal?.throwIfAborted?.();
|
|
try {
|
|
return await openVerifiedSocket({ ...input, address });
|
|
} catch (error) {
|
|
if (!(error instanceof UnreachableAddressError)) {
|
|
throw recordRemoteConnectionAttempts(error, [...failedAttempts, error]);
|
|
}
|
|
lastReason = error.reason;
|
|
failedAttempts.push(error.reason);
|
|
}
|
|
}
|
|
throw recordRemoteConnectionAttempts(lastReason
|
|
?? input.options.error("Remote MCP endpoint could not be reached", "remote_http_connect_failed"), failedAttempts);
|
|
}
|
|
|
|
/**
|
|
* Connect to `address`, confirm the peer really is that approved address, and
|
|
* only then complete the TLS handshake. Verifying before the handshake keeps
|
|
* even a ClientHello off an internal service.
|
|
*/
|
|
async function openVerifiedSocket(input: {
|
|
address: string;
|
|
approvedSet: Set<string>;
|
|
port: number;
|
|
hostname: string;
|
|
useTls: boolean;
|
|
signal: AbortSignal | null;
|
|
options: GuardedRemoteHttpFetchOptions;
|
|
}): Promise<Socket | TLSSocket> {
|
|
const { address, approvedSet, port, hostname, useTls, signal, options } = input;
|
|
const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
const factory = options.socketFactory
|
|
?? ((target) => netConnect({ host: target.address, port: target.port }));
|
|
|
|
const raw = factory({ address, port, hostname, useTls });
|
|
|
|
try {
|
|
await once(raw, "connect", { signal, timeoutMs: connectTimeoutMs, what: "connect to" });
|
|
} catch (error) {
|
|
// A connect timeout used to leave the half-open socket behind, because
|
|
// nothing above this point owns it yet.
|
|
raw.destroy();
|
|
if (signal?.aborted) throw error;
|
|
throw new UnreachableAddressError(error);
|
|
}
|
|
|
|
const peer = raw.remoteAddress ? normalizeIpAddress(raw.remoteAddress) : null;
|
|
const peerDenied = peer && (
|
|
isAlwaysDeniedLinkLocalIp(peer)
|
|
|| (!options.allowPrivateNetwork && isPrivateOrReservedIp(peer))
|
|
);
|
|
if (!peer || peerDenied || !approvedSet.has(peer)) {
|
|
raw.destroy();
|
|
throw options.error(
|
|
"Remote MCP connection resolved to an address that was not approved",
|
|
"remote_http_private_endpoint",
|
|
);
|
|
}
|
|
|
|
if (!useTls) return raw;
|
|
|
|
const secure = tlsConnect({
|
|
socket: raw,
|
|
// The certificate is checked against the hostname the operator configured,
|
|
// not the pinned address, and SNI carries that hostname too.
|
|
servername: isIP(hostname) === 0 ? hostname : undefined,
|
|
host: hostname,
|
|
});
|
|
try {
|
|
await once(secure, "secureConnect", { signal, timeoutMs: connectTimeoutMs, what: "negotiate TLS with" });
|
|
} catch (error) {
|
|
secure.destroy();
|
|
raw.destroy();
|
|
if (signal?.aborted) throw error;
|
|
throw new UnreachableAddressError(error);
|
|
}
|
|
return secure;
|
|
}
|
|
|
|
async function sendRequest(input: {
|
|
endpoint: URL;
|
|
hostname: string;
|
|
port: number;
|
|
useTls: boolean;
|
|
socket: Socket;
|
|
init: RequestInit;
|
|
signal: AbortSignal | null;
|
|
responseTimeoutMs: number;
|
|
error: RemoteHttpEndpointErrorFactory;
|
|
}): Promise<Response> {
|
|
const { endpoint, hostname, port, useTls, socket, init, signal, responseTimeoutMs, error } = input;
|
|
const headers = new Headers(init.headers);
|
|
const body = readRequestBody(init.body);
|
|
const method = (init.method ?? "GET").toUpperCase();
|
|
|
|
// Raw node:http requests do not add the default User-Agent that `fetch` and
|
|
// curl send. Some provider CDNs reject an absent User-Agent before the MCP or
|
|
// OAuth service sees the request (Coda returns 403 instead of its 401 OAuth
|
|
// challenge), so give every guarded request a stable, non-identifying client
|
|
// token while preserving an explicit caller value.
|
|
if (!headers.has("user-agent")) headers.set("user-agent", "Paperclip/1.0");
|
|
if (body !== undefined && !headers.has("content-length") && !headers.has("transfer-encoding")) {
|
|
headers.set("content-length", String(body.byteLength));
|
|
}
|
|
if (init.body instanceof URLSearchParams && !headers.has("content-type")) {
|
|
headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
|
|
}
|
|
// `URL.host` already drops the port when it is the protocol default, so the
|
|
// peer sees the same `Host` a plain `fetch` would have sent.
|
|
headers.set("host", endpoint.host);
|
|
|
|
const requestFn = useTls ? httpsRequest : httpRequest;
|
|
const message = await new Promise<IncomingMessage>((resolve, reject) => {
|
|
const req = requestFn({
|
|
method,
|
|
// `createConnection` returns the socket that was already verified, so no
|
|
// hostname reaches the socket layer and no second lookup can happen. It is
|
|
// only honoured while `agent` stays unset, so do not pass one.
|
|
createConnection: () => socket,
|
|
host: hostname,
|
|
port,
|
|
path: `${endpoint.pathname}${endpoint.search}`,
|
|
headers: Object.fromEntries(headers.entries()),
|
|
setHost: false,
|
|
...(signal ? { signal } : {}),
|
|
}, resolve);
|
|
req.on("error", reject);
|
|
// Headers deadline. `req.setTimeout` is socket-idle based, which a server
|
|
// that dribbles bytes could reset forever, so hold a hard timer instead.
|
|
const headersTimer = setTimeout(() => {
|
|
// Destroying the request tears the socket down too, so a silent peer costs
|
|
// neither a pending handler nor a leaked descriptor.
|
|
req.destroy(markRemoteConnectionFailure(error("Remote MCP endpoint did not respond in time", "remote_http_response_timeout"), "connection_timeout"));
|
|
}, responseTimeoutMs);
|
|
headersTimer.unref?.();
|
|
req.on("response", () => clearTimeout(headersTimer));
|
|
req.on("error", () => clearTimeout(headersTimer));
|
|
if (body !== undefined) req.write(body);
|
|
req.end();
|
|
});
|
|
|
|
// Body idle deadline, mirroring undici's `bodyTimeout`: a stalled stream is
|
|
// destroyed so `response.text()` rejects instead of hanging the caller.
|
|
message.setTimeout(responseTimeoutMs, () => {
|
|
message.destroy(markRemoteConnectionFailure(error("Remote MCP endpoint stalled mid-response", "remote_http_response_timeout"), "connection_timeout"));
|
|
});
|
|
// A finished response must not leave an armed socket timer behind, or a later
|
|
// reader of the same socket inherits a deadline it never asked for.
|
|
const disarm = () => message.setTimeout(0);
|
|
message.once("end", disarm);
|
|
message.once("close", disarm);
|
|
|
|
const responseHeaders = new Headers();
|
|
for (const [key, value] of Object.entries(message.headers)) {
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) responseHeaders.append(key, entry);
|
|
} else if (value !== undefined) {
|
|
responseHeaders.append(key, value);
|
|
}
|
|
}
|
|
|
|
const status = message.statusCode ?? 502;
|
|
const nullBody = NULL_BODY_STATUSES.has(status) || method === "HEAD";
|
|
if (nullBody) message.resume();
|
|
|
|
return new Response(
|
|
nullBody ? null : (Readable.toWeb(decodedBody(message, responseHeaders)) as unknown as ReadableStream<Uint8Array>),
|
|
{ status, statusText: message.statusMessage ?? "", headers: responseHeaders },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Match `fetch`'s content decoding. `node:http` hands back the raw bytes, so a
|
|
* server that compresses without being asked would otherwise turn into a JSON
|
|
* parse failure that looks like a broken MCP server.
|
|
*/
|
|
function decodedBody(message: IncomingMessage, headers: Headers): Readable {
|
|
const encoding = (message.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
const decoder = encoding === "gzip" || encoding === "x-gzip"
|
|
? createGunzip()
|
|
: encoding === "deflate"
|
|
? createInflate()
|
|
: encoding === "br"
|
|
? createBrotliDecompress()
|
|
: null;
|
|
if (!decoder) return message;
|
|
headers.delete("content-encoding");
|
|
headers.delete("content-length");
|
|
message.on("error", (error) => decoder.destroy(error));
|
|
return message.pipe(decoder);
|
|
}
|
|
|
|
function readRequestBody(body: RequestInit["body"]): Buffer | undefined {
|
|
if (body === undefined || body === null) return undefined;
|
|
if (typeof body === "string") return Buffer.from(body, "utf8");
|
|
if (body instanceof URLSearchParams) return Buffer.from(body.toString(), "utf8");
|
|
if (Buffer.isBuffer(body)) return body;
|
|
if (body instanceof ArrayBuffer) return Buffer.from(body);
|
|
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
|
|
throw new TypeError("Guarded remote HTTP requests only support string, URLSearchParams and buffer bodies");
|
|
}
|
|
|
|
function once(
|
|
emitter: Socket,
|
|
event: string,
|
|
input: { signal: AbortSignal | null; timeoutMs: number; what: string },
|
|
): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
settle(markRemoteConnectionFailure(new Error(`Timed out trying to ${input.what} the remote MCP endpoint`), "connection_timeout"));
|
|
}, input.timeoutMs);
|
|
timer.unref?.();
|
|
|
|
const onAbort = () => settle(input.signal?.reason ?? new Error("Remote MCP request was aborted"));
|
|
|
|
function settle(error?: Error) {
|
|
clearTimeout(timer);
|
|
emitter.off(event, onSuccess);
|
|
emitter.off("error", onError);
|
|
input.signal?.removeEventListener?.("abort", onAbort);
|
|
if (error) reject(error);
|
|
else resolve();
|
|
}
|
|
function onSuccess() {
|
|
settle();
|
|
}
|
|
function onError(error: Error) {
|
|
settle(error);
|
|
}
|
|
|
|
emitter.once(event, onSuccess);
|
|
emitter.once("error", onError);
|
|
input.signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
});
|
|
}
|