// ---------------------------------------------------------------------------// MCP tool discovery — connect to an MCP server and list its tools// --------------------------------------------------------------------------- import { Duration, Effect, Option, Predicate } from "effect"; import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";import { McpToolDiscoveryError } from "./errors";import { httpStatusFromCause } from "./http-status";import { decodeListToolsPage, extractManifestFromListToolsResult, type McpToolManifest,} from "./manifest"; // Backstop for a server that returns a cycling / never-terminating cursor.// The spec puts no bound on page count; a compliant server terminates by// omitting `nextCursor`, so any real catalog fits well inside this.const MAX_LIST_TOOLS_PAGES = 100; // Every caller of `discoverTools` (resolveTools, detect, probeEndpoint,// checkHealth) is a probe: dial a server we don't control and list its// tools. Neither the MCP SDK's transport connect nor its `listTools` call// carries a deadline of its own against a server that never answers (a// closed/half-open loopback socket, a server wedged mid-handshake), so// without a bound here a single unresponsive endpoint hangs the calling// fiber forever. Mirrors the shape-probe fallback's default// (`probeMcpEndpointShape`'s `timeoutMs = 8_000`) at a slightly longer// bound since a real handshake + listTools round-trip is heavier than the// shape probe's single unauth POST.const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15); // ---------------------------------------------------------------------------// Public API// --------------------------------------------------------------------------- /** * List every tool from an open MCP connection, following `nextCursor` * pagination (spec: `tools/list` is a paginated operation — a single call * returns one page, not the catalog). */const listAllTools = ( connection: McpConnection,): Effect.Effect<McpToolManifest, McpToolDiscoveryError> => Effect.gen(function* () { const tools: unknown[] = []; let cursor: string | undefined = undefined; for (let page = 0; page < MAX_LIST_TOOLS_PAGES; page++) { const params: { cursor?: string } | undefined = cursor === undefined ? undefined : { cursor }; const listResult = yield* Effect.tryPromise({ try: () => connection.client.listTools(params), // A bearer accepted at the handshake can be rejected by the time the // listing runs (revocation landing mid-discovery). The fetch adapter's // reauthorization interception fires here exactly as during connect, // so keep the structural signals — the reauthorization tag and the // HTTP status — instead of collapsing every listing failure into a // generic discovery error; discarding them left the connection on a // sticky degraded verdict instead of reconnect-required. Statuses come // from `httpStatusFromCause` only: the connection-only numeric-code // decode must stay out, since JSON-RPC error codes are not HTTP // statuses. catch: (cause) => { if (hasNestedOAuthReauthorization(cause)) { return new McpToolDiscoveryError({ stage: "list_tools", message: "Failed listing MCP tools: MCP OAuth re-authorization required", reauthorizationRequired: true, }); } const httpStatus = httpStatusFromCause(cause); return new McpToolDiscoveryError({ stage: "list_tools", message: httpStatus === undefined ? "Failed listing MCP tools" : `Failed listing MCP tools (HTTP ${httpStatus})`, ...(httpStatus === undefined ? {} : { httpStatus }), }); }, }); const decoded = decodeListToolsPage(listResult); if (Option.isNone(decoded)) { return yield* new McpToolDiscoveryError({ stage: "list_tools", message: "MCP listTools response did not match the expected schema", }); } tools.push(...decoded.value.tools); const nextCursor = decoded.value.nextCursor; if (nextCursor == null || nextCursor === "") break; cursor = nextCursor; } return extractManifestFromListToolsResult( { tools }, { serverInfo: connection.client.getServerVersion?.(), instructions: connection.client.getInstructions?.(), }, ); }); /** * Connect to an MCP server and discover all available tools. * Returns the parsed manifest containing server metadata and tool entries. * * Bounded by `timeoutMs` (default `DEFAULT_DISCOVER_TIMEOUT`): every caller * is dialing a server we don't control, and neither the connect handshake * nor `listTools` has a deadline of its own, so an unresponsive endpoint * (dead loopback port, wedged mid-handshake) would otherwise hang the * calling fiber (and, transitively, the server-side request handling it) * forever. On timeout, any connection that DID get established is closed * before the timeout error is raised (`Effect.onExit` still fires for an * interrupted fiber). * * Interruption-safe: the connect phase cleans up after itself (the connector * aborts the handshake and closes the transport, killing any spawned stdio * child; #1631), and the mask below removes the window between the * connector succeeding and `onExit` attaching, where an interrupt would * leak the connection. The connector and listTools stay `restore`d so a 499 * or the timeout above can still cancel them promptly. */export const discoverTools = ( connector: McpConnector, timeoutMs: number = Duration.toMillis(DEFAULT_DISCOVER_TIMEOUT),): Effect.Effect<McpToolManifest, McpToolDiscoveryError> => Effect.uninterruptibleMask((restore) => Effect.gen(function* () { // Acquire connection const connection = yield* restore( connector.pipe( Effect.mapError((failure) => { // Preserve the handshake HTTP status (401/403 = auth wall) and a // connect-level timeout so the liveness health check can classify // structurally — dropping `failureKind: "timeout"` here is what // made a timed-out handshake read as a generic probe failure. const httpStatus = Predicate.isTagged(failure, "McpConnectionError") ? failure.httpStatus : undefined; const reauthorizationRequired = Predicate.isTagged( failure, "McpOAuthReauthorizationRequired", ); const timedOut = Predicate.isTagged(failure, "McpConnectionError") && failure.failureKind === "timeout"; return new McpToolDiscoveryError({ stage: "connect", message: `Failed connecting to MCP server: ${failure.message}`, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(reauthorizationRequired ? { reauthorizationRequired: true } : {}), ...(timedOut ? { timedOut } : {}), }); }), ), ); // The connection advertises the elicitation capability (connection.ts), // so a server may elicit mid-listTools — the Codex desktop plugins do // this for first-use approvals. Discovery has no user to route the // request to (unlike the invoke path's bridge in invoke.ts), and a // handler-less request would surface as a method-not-found error on the // server's side of an otherwise healthy sync. Decline explicitly: the // server completes the list with whatever it allows unapproved. connection.client.setRequestHandler("elicitation/create", () => Promise.resolve({ action: "decline" }), ); const manifest = yield* restore(listAllTools(connection)).pipe( Effect.onExit(() => closeConnection(connection)), ); return manifest; }), ).pipe( Effect.timeoutOrElse({ duration: Duration.millis(timeoutMs), orElse: () => Effect.fail( new McpToolDiscoveryError({ stage: "connect", message: `MCP discovery timed out after ${timeoutMs}ms`, timedOut: true, }), ), }), ); const closeConnection = (connection: { readonly close: () => Promise<void>;}): Effect.Effect<void, never> => Effect.ignore( Effect.tryPromise({ try: () => connection.close(), catch: () => new McpToolDiscoveryError({ stage: "list_tools", message: "Failed closing MCP connection", }), }), );
// ---------------------------------------------------------------------------
// MCP tool discovery — connect to an MCP server and list its tools
// ---------------------------------------------------------------------------
import { Duration, Effect, Option, Predicate } from "effect";
import { hasNestedOAuthReauthorization, type McpConnection, type McpConnector } from "./connection";
import { McpToolDiscoveryError } from "./errors";
import { httpStatusFromCause } from "./http-status";
import {
decodeListToolsPage,
extractManifestFromListToolsResult,
type McpToolManifest,
} from "./manifest";
// Backstop for a server that returns a cycling / never-terminating cursor.
// The spec puts no bound on page count; a compliant server terminates by
// omitting `nextCursor`, so any real catalog fits well inside this.
const MAX_LIST_TOOLS_PAGES = 100;
// Every caller of `discoverTools` (resolveTools, detect, probeEndpoint,
// checkHealth) is a probe: dial a server we don't control and list its
// tools. Neither the MCP SDK's transport connect nor its `listTools` call
// carries a deadline of its own against a server that never answers (a
// closed/half-open loopback socket, a server wedged mid-handshake), so
// without a bound here a single unresponsive endpoint hangs the calling
// fiber forever. Mirrors the shape-probe fallback's default
// (`probeMcpEndpointShape`'s `timeoutMs = 8_000`) at a slightly longer
// bound since a real handshake + listTools round-trip is heavier than the
// shape probe's single unauth POST.
const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(15);
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* List every tool from an open MCP connection, following `nextCursor`
* pagination (spec: `tools/list` is a paginated operation — a single call
* returns one page, not the catalog).
*/
const listAllTools = (
connection: McpConnection,
): Effect.Effect<McpToolManifest, McpToolDiscoveryError> =>
Effect.gen(function* () {
const tools: unknown[] = [];
let cursor: string | undefined = undefined;
for (let page = 0; page < MAX_LIST_TOOLS_PAGES; page++) {
const params: { cursor?: string } | undefined = cursor === undefined ? undefined : { cursor };
const listResult = yield* Effect.tryPromise({
try: () => connection.client.listTools(params),
// A bearer accepted at the handshake can be rejected by the time the
// listing runs (revocation landing mid-discovery). The fetch adapter's
// reauthorization interception fires here exactly as during connect,
// so keep the structural signals — the reauthorization tag and the
// HTTP status — instead of collapsing every listing failure into a
// generic discovery error; discarding them left the connection on a
// sticky degraded verdict instead of reconnect-required. Statuses come
// from `httpStatusFromCause` only: the connection-only numeric-code
// decode must stay out, since JSON-RPC error codes are not HTTP
// statuses.
catch: (cause) => {
if (hasNestedOAuthReauthorization(cause)) {
return new McpToolDiscoveryError({
stage: "list_tools",
message: "Failed listing MCP tools: MCP OAuth re-authorization required",
reauthorizationRequired: true,
});
}
const httpStatus = httpStatusFromCause(cause);
return new McpToolDiscoveryError({
stage: "list_tools",
message:
httpStatus === undefined
? "Failed listing MCP tools"
: `Failed listing MCP tools (HTTP ${httpStatus})`,
...(httpStatus === undefined ? {} : { httpStatus }),
});
},
});
const decoded = decodeListToolsPage(listResult);
if (Option.isNone(decoded)) {
return yield* new McpToolDiscoveryError({
stage: "list_tools",
message: "MCP listTools response did not match the expected schema",
});
}
tools.push(...decoded.value.tools);
const nextCursor = decoded.value.nextCursor;
if (nextCursor == null || nextCursor === "") break;
cursor = nextCursor;
}
return extractManifestFromListToolsResult(
{ tools },
{
serverInfo: connection.client.getServerVersion?.(),
instructions: connection.client.getInstructions?.(),
},
);
});
/**
* Connect to an MCP server and discover all available tools.
* Returns the parsed manifest containing server metadata and tool entries.
*
* Bounded by `timeoutMs` (default `DEFAULT_DISCOVER_TIMEOUT`): every caller
* is dialing a server we don't control, and neither the connect handshake
* nor `listTools` has a deadline of its own, so an unresponsive endpoint
* (dead loopback port, wedged mid-handshake) would otherwise hang the
* calling fiber (and, transitively, the server-side request handling it)
* forever. On timeout, any connection that DID get established is closed
* before the timeout error is raised (`Effect.onExit` still fires for an
* interrupted fiber).
*
* Interruption-safe: the connect phase cleans up after itself (the connector
* aborts the handshake and closes the transport, killing any spawned stdio
* child; #1631), and the mask below removes the window between the
* connector succeeding and `onExit` attaching, where an interrupt would
* leak the connection. The connector and listTools stay `restore`d so a 499
* or the timeout above can still cancel them promptly.
*/
export const discoverTools = (
connector: McpConnector,
timeoutMs: number = Duration.toMillis(DEFAULT_DISCOVER_TIMEOUT),
): Effect.Effect<McpToolManifest, McpToolDiscoveryError> =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
// Acquire connection
const connection = yield* restore(
connector.pipe(
Effect.mapError((failure) => {
// Preserve the handshake HTTP status (401/403 = auth wall) and a
// connect-level timeout so the liveness health check can classify
// structurally — dropping `failureKind: "timeout"` here is what
// made a timed-out handshake read as a generic probe failure.
const httpStatus = Predicate.isTagged(failure, "McpConnectionError")
? failure.httpStatus
: undefined;
const reauthorizationRequired = Predicate.isTagged(
failure,
"McpOAuthReauthorizationRequired",
);
const timedOut =
Predicate.isTagged(failure, "McpConnectionError") &&
failure.failureKind === "timeout";
return new McpToolDiscoveryError({
stage: "connect",
message: `Failed connecting to MCP server: ${failure.message}`,
...(httpStatus !== undefined ? { httpStatus } : {}),
...(reauthorizationRequired ? { reauthorizationRequired: true } : {}),
...(timedOut ? { timedOut } : {}),
});
}),
),
);
// The connection advertises the elicitation capability (connection.ts),
// so a server may elicit mid-listTools — the Codex desktop plugins do
// this for first-use approvals. Discovery has no user to route the
// request to (unlike the invoke path's bridge in invoke.ts), and a
// handler-less request would surface as a method-not-found error on the
// server's side of an otherwise healthy sync. Decline explicitly: the
// server completes the list with whatever it allows unapproved.
connection.client.setRequestHandler("elicitation/create", () =>
Promise.resolve({ action: "decline" }),
);
const manifest = yield* restore(listAllTools(connection)).pipe(
Effect.onExit(() => closeConnection(connection)),
);
return manifest;
}),
).pipe(
Effect.timeoutOrElse({
duration: Duration.millis(timeoutMs),
orElse: () =>
Effect.fail(
new McpToolDiscoveryError({
stage: "connect",
message: `MCP discovery timed out after ${timeoutMs}ms`,
timedOut: true,
}),
),
}),
);
const closeConnection = (connection: {
readonly close: () => Promise<void>;
}): Effect.Effect<void, never> =>
Effect.ignore(
Effect.tryPromise({
try: () => connection.close(),
catch: () =>
new McpToolDiscoveryError({
stage: "list_tools",
message: "Failed closing MCP connection",
}),
}),
);