509 lines · 20.6 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// MCP tool invocation — shared helper called from plugin.invokeTool.//// Responsible for://   1. Leasing remote connections from a per-plugin-instance pool when one is//      provided. MCP streamable-http is a series of HTTP requests keyed by the//      application-level `Mcp-Session-Id`, not a raw pooled socket, and servers//      legitimately retain state in that session. The pool keeps one idle//      connection per resolved identity and leases it exclusively per invoke;//      stdio and callers without a pool remain strictly per-call.//   2. Installing a per-invocation `elicitation/create` handler that bridges//      MCP's elicit capability into the host's elicit function threaded via//      `InvokeToolInput.elicit`.//   3. Calling `client.callTool({ name, arguments })`.// --------------------------------------------------------------------------- import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; import type { ClientContext, ProtocolError } from "@modelcontextprotocol/client"; // SDK error classes come through the lazy loader; by the time a tool call can// fail, the connect path has always loaded the module (see client-module.ts).import { mcpClientSdkIfLoaded } from "./client-module"; import {  ElicitationId,  FormElicitation,  UrlElicitation,  type Elicit,  type ElicitationRequest,} from "@executor-js/sdk/core"; import { McpConnectionError, McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";import type { McpConnection, McpConnector } from "./connection";import type { McpConnectionPool } from "./connection-pool";import {  httpRefusalMessageFromCause,  httpStatusFromCause,  insufficientScopeFromCause,} from "./http-status"; // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** * The MCP SDK's default request timer measures wall-clock time. An elicitation * is user work, so it must not consume the tool's active-work budget. The SDK * still gets a long timer as a transport-level backstop; this controller owns * the normal deadline and is paused while one or more elicitation handlers are * waiting for input. */export const MCP_ACTIVE_WORK_TIMEOUT_MS = 60_000;const MCP_SDK_TIMEOUT_BACKSTOP_MS = 2_147_483_647; export type ActiveWorkDeadline = {  readonly signal: AbortSignal;  readonly pause: () => void;  readonly resume: () => void;  readonly dispose: () => void;}; export const makeActiveWorkDeadline = (  timeoutMs: number = MCP_ACTIVE_WORK_TIMEOUT_MS,): ActiveWorkDeadline => {  const controller = new AbortController();  let remainingMs = timeoutMs;  let pendingElicitations = 0;  let startedAt: number | undefined;  let timer: ReturnType<typeof setTimeout> | undefined;   const stopTimer = (): void => {    if (timer === undefined || startedAt === undefined) return;    clearTimeout(timer);    timer = undefined;    remainingMs = Math.max(0, remainingMs - (Date.now() - startedAt));    startedAt = undefined;  };   const abortForTimeout = (): void => {    timer = undefined;    startedAt = undefined;    // oxlint-disable-next-line executor/no-error-constructor -- boundary: AbortSignal consumers need a stable timeout reason    controller.abort(new Error("MCP tool invocation exceeded its active-work deadline"));  };   const startTimer = (): void => {    if (controller.signal.aborted || pendingElicitations > 0) return;    if (remainingMs <= 0) {      abortForTimeout();      return;    }    startedAt = Date.now();    timer = setTimeout(() => {      remainingMs = 0;      abortForTimeout();    }, remainingMs);  };   startTimer();   return {    signal: controller.signal,    pause: () => {      pendingElicitations += 1;      if (pendingElicitations === 1) stopTimer();    },    resume: () => {      if (pendingElicitations === 0) return;      pendingElicitations -= 1;      if (pendingElicitations === 0) startTimer();    },    dispose: () => {      stopTimer();      // oxlint-disable-next-line executor/no-error-constructor -- boundary: disposing the scoped signal must interrupt SDK work      controller.abort(new Error("MCP tool invocation was disposed"));    },  };}; const abortOnSignals = (signals: readonly AbortSignal[]): Effect.Effect<never, Error> =>  Effect.callback<never, Error>((resume) => {    // oxlint-disable-next-line executor/no-error-constructor -- boundary: an aborted MCP handler must reject its JSON-RPC response    const abort = () => resume(Effect.fail(new Error("MCP elicitation was cancelled")));    if (signals.some((signal) => signal.aborted)) {      abort();      return;    }    for (const signal of signals) signal.addEventListener("abort", abort, { once: true });    return Effect.sync(() => {      for (const signal of signals) signal.removeEventListener("abort", abort);    });  }); const ArgsRecord = Schema.Record(Schema.String, Schema.Unknown);const decodeArgsRecord = Schema.decodeUnknownOption(ArgsRecord); const argsRecord = (value: unknown): Record<string, unknown> =>  Option.getOrElse(decodeArgsRecord(value), () => ({})); // The spec answers `tools/call` for a tool the server no longer advertises// with a protocol error (`-32602 Invalid params`, example message// "Unknown tool: …"); the reference TypeScript SDK server instead catches that// error and returns it as an execution-error envelope (`isError: true`, text// "Tool <name> not found"). Both shapes mean the same thing — the persisted// catalog drifted — so both are detected, anchored to the exact tool name to// keep a domain error that merely mentions "not found" from matching. A miss// is benign (the catalog still heals via TTL or explicit refresh).const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); export const isUnknownToolMessage = (message: string, toolName: string): boolean => {  const name = escapeRegExp(toolName);  return new RegExp(    `(?:unknown tool:?\\s*"?${name}"?|tool\\s+"?${name}"?\\s+(?:not found|is not available|does not exist))`,    "i",  ).test(message);}; /** The class name and stable code of an SDK rejection, for the defect log. *  Structural only: the message is deliberately not read here. */const summarizeSdkFailure = (cause: unknown): { name: string; code?: string | number } => {  // oxlint-disable-next-line executor/no-instanceof-error -- boundary: the MCP SDK rejects with Error subclasses whose constructor name is the only class discriminator for non-branded errors  const name = cause instanceof Error ? cause.constructor.name : typeof cause;  const code = Predicate.hasProperty(cause, "code") ? cause.code : undefined;  return typeof code === "string" || typeof code === "number" ? { name, code } : { name };}; /** A 4xx other than the auth walls, with a JSON body that names the problem, *  is the server refusing THIS call (a validation failure at the HTTP layer) *  — not a dead transport. 401/403 keep their auth classification; 5xx and *  bodyless 4xx stay opaque, since there is nothing the caller can act on. */const httpRefusal = (  status: number | undefined,  cause: unknown,): { readonly httpRefusal: { readonly status: number; readonly message: string } } | {} => {  if (status === undefined || status < 400 || status >= 500 || status === 401 || status === 403) {    return {};  }  const message = httpRefusalMessageFromCause(cause);  return message === undefined ? {} : { httpRefusal: { status, message } };}; const asProtocolError = (cause: unknown): ProtocolError | undefined => {  const sdk = mcpClientSdkIfLoaded();  if (sdk === undefined) return undefined;  // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass  return cause instanceof sdk.client.ProtocolError ? cause : undefined;}; const isUnknownToolCause = (cause: unknown, toolName: string): boolean => {  const sdk = mcpClientSdkIfLoaded();  const protocolError = asProtocolError(cause);  return (    sdk !== undefined &&    protocolError !== undefined &&    (protocolError.code === sdk.client.ProtocolErrorCode.InvalidParams ||      protocolError.code === sdk.client.ProtocolErrorCode.MethodNotFound) &&    // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: the narrowing above reaches the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides    isUnknownToolMessage(protocolError.message, toolName)  );}; // ---------------------------------------------------------------------------// Elicitation bridge — decode incoming MCP ElicitRequest, route through// the host's elicit function, marshal the response back to MCP shape.// --------------------------------------------------------------------------- const McpElicitParams = Schema.Union([  Schema.Struct({    mode: Schema.Literal("url"),    message: Schema.String,    url: Schema.String,    elicitationId: Schema.optional(Schema.String),    id: Schema.optional(Schema.String),    _meta: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),  }),  Schema.Struct({    mode: Schema.optional(Schema.Literal("form")),    message: Schema.String,    requestedSchema: Schema.Record(Schema.String, Schema.Unknown),    _meta: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),  }),]);type McpElicitParams = typeof McpElicitParams.Type; const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams);const decodeElicitContent = Schema.decodeUnknownSync(  Schema.Record(    Schema.String,    Schema.Union([      Schema.String,      Schema.Number,      Schema.Boolean,      Schema.mutable(Schema.Array(Schema.String)),    ]),  ),); /** The `_meta` keys that describe the TERMS of an approval, and nothing else. * *  `_meta` is an open, implementation-defined map: servers put progress *  tokens, internal ids, and their own opaque state in it. A host that renders *  all of it as "approval terms" both misleads (none of that is a term the *  user is agreeing to) and risks surfacing something private. So this *  projects the known consent vocabulary and drops the rest — an unknown *  server contributes nothing rather than noise. */export const APPROVAL_TERM_KEYS = ["persist", "origin", "connector_name", "connector_id"] as const; const isStringList = (value: unknown): value is readonly string[] =>  Array.isArray(value) && value.every((item) => typeof item === "string"); /** A term is a string, or a list of strings: Computer Use OFFERS *  `persist: ["session", "always"]` for the answer to pick from, where *  Chrome STATES `persist: "always"`. Either way it is a term of the grant. */const isApprovalTerm = (value: unknown): value is string | readonly string[] =>  typeof value === "string" || isStringList(value); export const approvalTerms = (meta: Record<string, unknown> | undefined) => {  if (meta === undefined) return {};  const terms = Object.fromEntries(    APPROVAL_TERM_KEYS.flatMap((key) => {      const value = meta[key];      return isApprovalTerm(value) ? [[key, value] as const] : [];    }),  );  return Object.keys(terms).length > 0 ? { meta: terms } : {};}; const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => {  const meta = approvalTerms(params._meta);  return params.mode === "url"    ? UrlElicitation.make({        message: params.message,        url: params.url,        elicitationId: ElicitationId.make(params.elicitationId ?? params.id ?? ""),        ...meta,      })    : FormElicitation.make({        message: params.message,        requestedSchema: params.requestedSchema,        ...meta,      });}; const installElicitationHandler = (  client: McpConnection["client"],  elicit: Elicit,  deadline: ActiveWorkDeadline,): void => {  client.setRequestHandler(    "elicitation/create",    async (request: { params: unknown }, ctx: ClientContext) => {      const params = decodeElicitParams(request.params);      const req = toElicitationRequest(params);      deadline.pause();      // Use runPromiseExit so we can inspect typed failures — `elicit`      // fails with `ElicitationDeclinedError` on decline/cancel, which      // we translate into the equivalent MCP elicit response instead of      // surfacing as a JSON-RPC error.      // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK request handlers are promise callbacks and must release the active-work lease      try {        const exit = await Effect.runPromiseExit(          Effect.raceFirst(elicit(req), abortOnSignals([ctx.mcpReq.signal, deadline.signal])),        );        if (Exit.isSuccess(exit)) {          const response = exit.value;          const persist = response.action === "accept" ? response.meta?.persist : undefined;          return {            action: response.action,            ...(response.action === "accept" && response.content              ? { content: decodeElicitContent(response.content) }              : {}),            ...(persist === undefined ? {} : { _meta: { persist } }),          };        }        const failure = exit.cause.reasons.find(Cause.isFailReason);        if (failure) {          const err = failure.error;          if (Predicate.isTagged(err, "ElicitationDeclinedError")) {            const action =              Predicate.hasProperty(err, "action") && err.action === "cancel"                ? "cancel"                : "decline";            return { action };          }        }        // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: MCP SDK async request handlers signal unexpected failures by rejecting        throw Cause.squash(exit.cause);      } finally {        deadline.resume();      }    },  );}; // ---------------------------------------------------------------------------// tools/list_changed bridge — while a connection is open (the call window),// listen for the spec's `notifications/tools/list_changed` and surface it to// the host so it can mark the persisted catalog stale. Registering the handler// is unconditional: it only fires if the server sends the notification, and a// server that never does costs nothing.// --------------------------------------------------------------------------- const installToolListChangedHandler = (  client: McpConnection["client"],  onToolListChanged: (() => void) | undefined,): void => {  if (!onToolListChanged) return;  client.setNotificationHandler("notifications/tools/list_changed", () => {    onToolListChanged();  });}; // ---------------------------------------------------------------------------// Single tool call — install handlers, callTool, return raw result// --------------------------------------------------------------------------- const useConnection = (  connection: McpConnection,  toolName: string,  args: Record<string, unknown>,  elicit: Elicit,  onToolListChanged: (() => void) | undefined,): Effect.Effect<unknown, McpInvocationError | McpOAuthReauthorizationRequired> =>  Effect.gen(function* () {    const deadline = yield* Effect.acquireRelease(      Effect.sync(() => makeActiveWorkDeadline()),      (activeWork) => Effect.sync(activeWork.dispose),    );    installElicitationHandler(connection.client, elicit, deadline);    installToolListChangedHandler(connection.client, onToolListChanged);    return yield* Effect.tryPromise({      try: () =>        connection.client.callTool(          { name: toolName, arguments: args },          { signal: deadline.signal, timeout: MCP_SDK_TIMEOUT_BACKSTOP_MS },        ),      catch: (cause) => {        if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) {          return new McpOAuthReauthorizationRequired({            message: "MCP OAuth re-authorization required",          });        }        // Raised by the fetch adapter when the 403 carried an RFC 6750        // insufficient_scope challenge (the authProvider path, where the SDK        // would otherwise consume the challenge before we could see it).        if (Predicate.isTagged(cause, "McpInsufficientScopeError")) {          return new McpInvocationError({            toolName,            message: `MCP tool call failed for ${toolName}`,            status: 403,            insufficientScope: true,            transportFailure: true,          });        }        const status = httpStatusFromCause(cause);        const protocolError = asProtocolError(cause);        const sdkFailure = summarizeSdkFailure(cause);        return new McpInvocationError({          toolName,          // The class and code ride in the message because the dispatch          // defect log renders only `Error#toString()`: without them the          // trace says which tool failed and nothing about how.          message: `MCP tool call failed for ${toolName} (${[            sdkFailure.name,            ...(sdkFailure.code === undefined ? [] : [String(sdkFailure.code)]),            ...(status === undefined ? [] : [`HTTP ${status}`]),          ].join(" ")})`,          sdkFailure,          ...(status === undefined ? {} : { status }),          ...(protocolError === undefined            ? { transportFailure: true }            : {                // A JSON-RPC error is the server's answer to this call, written                // for the caller (the same trust level as an `isError` result                // envelope), so its message may travel back to the sandbox.                // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: the narrowing above reaches the SDK's ProtocolError, whose message is the server's JSON-RPC error text                protocolError: { code: protocolError.code, message: protocolError.message },              }),          ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}),          ...(status === 403 && insufficientScopeFromCause(cause)            ? { insufficientScope: true }            : {}),          ...httpRefusal(status, cause),        });      },    }).pipe(      Effect.withSpan("plugin.mcp.client.call_tool", {        attributes: { "mcp.tool.name": toolName },      }),    );  }).pipe(Effect.scoped); // ---------------------------------------------------------------------------// Public API// --------------------------------------------------------------------------- export interface InvokeMcpToolInput {  readonly toolId: string;  /** The real MCP tool name advertised by the server. */  readonly toolName: string;  readonly args: unknown;  readonly transport: string;  /** Dials a fresh connection when no reusable remote session is available. */  readonly connector: McpConnector;  /** Optional per-plugin-instance pool and resolved remote identity key. Both   *  must be present to enable reuse; otherwise the connection is per-call. */  readonly connectionPool?: McpConnectionPool;  readonly connectionPoolKey?: string;  readonly elicit: Elicit;  /** Fired when the server sends `notifications/tools/list_changed` during   *  the call window. Synchronous and non-throwing by contract; the caller   *  uses it to mark the persisted catalog stale. */  readonly onToolListChanged?: () => void;} export const invokeMcpTool = (  input: InvokeMcpToolInput,): Effect.Effect<  unknown,  McpConnectionError | McpInvocationError | McpOAuthReauthorizationRequired> =>  Effect.gen(function* () {    const args = argsRecord(input.args);    const use = (connection: McpConnection) =>      useConnection(connection, input.toolName, args, input.elicit, input.onToolListChanged);     if (input.connectionPool && input.connectionPoolKey) {      return yield* input.connectionPool.withConnection(        input.connectionPoolKey,        input.connector.pipe(          Effect.withSpan("plugin.mcp.connection.acquire", {            attributes: { "plugin.mcp.transport": input.transport },          }),        ),        use,      );    }     const connection = yield* Effect.acquireRelease(      input.connector.pipe(        Effect.withSpan("plugin.mcp.connection.acquire", {          attributes: { "plugin.mcp.transport": input.transport },        }),      ),      (conn) =>        Effect.ignore(          Effect.tryPromise({            try: () => conn.close(),            catch: () =>              new McpConnectionError({                transport: input.transport,                message: "Failed to close MCP connection",              }),          }),        ),    );     return yield* use(connection);  }).pipe(

Lines 1–500 of 509.