@sema-agent/core
Version:
Stateless, task-oriented AI agent core
761 lines • 54.4 kB
TypeScript
import { type McpInstructionsEntry, type PromptSectionDisposer } from "./prompt-section-seat.js";
import { McpConnectionPool } from "./mcp-connection-pool.js";
import { type McpDelivered, type McpFailureKind, type McpServerLiveness } from "./mcp-failure.js";
import type { AgentTool } from "../internal/harness-types.js";
import type { ImageContent, TextContent } from "../internal/llm.js";
import { type McpImageResizer } from "./image-downsample.js";
import type { McpServerSpec, OnElicit, ToolEffect } from "./types.js";
import type { McpRevocationLedger } from "./mcp-server-spec.js";
import { type ReminderDisclosureCounts } from "./reminder-disclosure.js";
export { resolveMcpDeclaredResultSize } from "./mcp-tool-meta.js";
/**
* The safety axes (design/77 §4 irreversibility, design/70 egress) derived from one materialized MCP
* tool's server-advertised `annotations`. These ride alongside the {@link AgentTool} (which is vendored
* and carries no axis fields) so `prepare-task` can fold them into the SAME irreversibleTools/egressTools
* collection that `spec.tools` feeds — otherwise a destructive MCP tool would register no gate and a
* no-policy deployment would silently AUTO-ALLOW it (MAJOR-1). The `name` is the namespaced tool name
* (`<server>__<tool>`), matching the keys the gate looks up.
*
* Trust note (design F): a server's `annotations` are SERVER-controlled and "not guaranteed faithful" (MCP
* spec), so they may only ever TIGHTEN — `destructiveHint` adds an `ask`/suspend, `openWorldHint` adds an
* egress tighten — never LOWER. A server's `readOnlyHint` does NOT lower `effect` (that would let an adversarial
* server lie on a mutating tool to escape repeat-safety + path-confinement). The ONLY trusted way to LOWER an
* MCP tool below the fail-closed `write` default is the caller's {@link McpServerSpec.toolAxes} override
* (caller = trust root). The synthetic first-party resource tools are the one exception — core sets their
* `read` effect itself because it authored them.
*/
export interface McpToolAxis {
/** Namespaced tool name (`mcp__<server>__<tool>` — design/108 ① CC parity). */
name: string;
/** design/77 §4: `destructiveHint === true` → `"always"` (the gate always tightens allow→ask/suspend).
* `"never"` appears ONLY via an explicit caller override — enforcement ignores it (tighten-only),
* the ask's risk-axes report face consumes it (an explicit negative is a judgment, not silence). */
irreversibility?: "always" | "never";
/** design/70: a non-readOnly tool with `openWorldHint === true` → egress (external write — never
* auto-allowed). `false` appears ONLY via an explicit caller override — same posture as above:
* enforcement ignores it, the report face consumes it. */
egress?: boolean;
/**
* The repeat-safety / blast-radius class (NOT the data-trust axis — untrusted resource *content* is fenced
* separately by delimitUntrusted). `read`/`idempotent` here means "not write-capable", which keeps the tool
* off the LOUD ungated-write warning and out of path-confinement under a skill. Source is restricted by trust
* (design F): the SYNTHETIC first-party resource tools set `read` as a fact core controls, and the caller's
* {@link McpServerSpec.toolAxes} override may set any effect — a server's own `readOnlyHint` never sets it.
*/
effect?: ToolEffect;
}
/**
* REF-D17: one MCP tool dropped at intake (server/tool/reason) — the SINGLE shape {@link
* MaterializedMcp.droppedTools}, {@link McpRefreshResult.dropped}, and `turn-attachments.ts`'s
* disclosure rendering/batching all reference. MCP trust/disclosure is a security-relevant surface
* (the trust model was BREAKING-redone historically) — a drift between the connect-time and
* refresh-time shapes would make a real field change invisible at one of the two intake paths.
*/
export interface McpDroppedTool {
server: string;
tool: string;
reason: string;
}
/**
* The completeness verdict of a walked STANDARD MCP list method (`tools/list`, `resources/list` — see
* {@link walkMcpListPages}). ABSENT means the walk reached the end of pagination and the aggregated
* listing IS the server's full list. PRESENT means it is not, and the listing must not be presented —
* or DIFFED — as if it were: an entry missing from a truncated listing is unproven-absent, not
* withdrawn (that inference is exactly what made a paginating server's second-page tools read as
* `removed`).
* - `page_cap` — {@link MAX_MCP_LIST_PAGES} pages were walked with a cursor still pending;
* - `cursor_loop` — the server handed back a cursor already used in this walk (a paginator that does
* not advance); the walk stops rather than spinning to the cap re-appending the same page;
* - `cursor_invalid` — the server sent a `nextCursor` that is not a string (`null` excepted: that is
* JSON's absent optional). It cannot be followed and it is not proof of the end, so the walk stops
* and says so instead of certifying a short listing as complete;
* - `page_error` — a CONTINUATION page failed. The pages already retrieved are kept and the failure
* text carried. A FIRST-page failure is never this: it still throws (a caller's "no listing at all"
* must not degrade into "an empty listing");
* - `budget_exhausted` — CORE stopped the walk: the time this listing is allowed to spend
* ({@link walkMcpListPages}'s `budgetMs`) had too little left to fund another request
* ({@link MIN_MCP_PAGE_BUDGET_MS}), so none was sent. Distinct from `page_error` on purpose — the
* server neither failed nor was asked. Issuing that request anyway would hand the SDK a
* near-zero timeout, and its -32001 would arrive here wearing the server's failure clothes.
* `error` is SERVER-authored text — neutralize it before it rides any model/operator face.
*/
export interface McpListingIncomplete {
reason: "page_cap" | "cursor_loop" | "cursor_invalid" | "page_error" | "budget_exhausted";
/** Pages actually retrieved before the walk stopped (≥ 1 — a zero-page walk throws instead). */
pages: number;
error?: string;
/** The walk's total time budget in ms. Present ONLY on `budget_exhausted`, where it is the whole
* actionable content of the verdict (which bound to raise); every other reason leaves it absent
* rather than reporting a budget that had nothing to do with the stop. */
budgetMs?: number;
}
/** Tools materialized from one or more MCP servers, plus a disposer to disconnect them. */
export interface MaterializedMcp {
tools: AgentTool[];
/**
* Per-tool safety axes derived from each tool's MCP `annotations` (design/77 §4 / design/70). One entry
* per materialized tool that carries a tightening hint; `prepare-task` folds these into its
* irreversibleTools/egressTools sets so a destructive/open-world MCP tool REGISTERS the gate + tightens
* even on a no-policy deployment. Empty when no server advertised a relevant annotation.
*/
toolAxes: McpToolAxis[];
/**
* MCP server `instructions` (design/64 §17.3, CC `# MCP Server Instructions`): the optional guidance a
* connected server returns from `initialize`, after the host's disposition (the `prompt.section` station,
* applied once at the dial: a dropped entry is absent, a replaced one carries `author: "host"`). The
* Runner injects these into the (stable) system prompt. One entry per server whose entry survived.
*/
serverInstructions: McpInstructionsEntry[];
/**
* G1 通告层续批 (CC `mcp_instructions_delta` parity) — the run-scoped instruction-delta ref the run
* loop drains at turn boundaries (`spec.attachments.mcpInstructions` opt-in; `newTools` consumption
* discipline: drained only when the announcement survived the byte cap intact).
* - `pendingRemovals`: a server whose instructions are in the model's context and whose transport closed
* mid-task, or whose re-dial failed (the CC "servers have disconnected — their instructions above no
* longer apply" copy names only what the model holds — the lane's ledger, `instructionsTold`).
* `dispose()` disarms first: task teardown is not a disconnect.
* - `pendingAdds`: the CC add lane ("# MCP Server Instructions" reminder). A server's INITIAL
* instructions ride the stable system prompt instead (design/64 §17.3, deliberate CC deviation:
* prefix-cache-correct); this lane carries what comes later — a re-dial queues its new
* connection's text here, and a run adds the text of a server it is to teach and has not told.
* A materialization alone takes every entry of `serverInstructions` as taught; a run states its block.
*/
instructionsDelta: {
pendingAdds: McpInstructionsEntry[];
pendingRemovals: string[];
};
/**
* The OPERATOR warning lane for this materialization (the Runner forwards each entry to
* `onError(phase:"mcp")`). Empty when every server connected and listed completely. Populations,
* distinguishable by the `code` each Error carries — a consumer must NOT read mere presence as "this
* server was skipped":
* - `mcp.server_unavailable` — a server that failed to connect / list its tools and was SKIPPED
* (fail-open, design/29): the task proceeds with the healthy servers' tools, because a single bad
* server (missing stdio command, unreachable URL) must never brick every task in the scenario;
* - `mcp.listing_incomplete` — a server that DID connect but whose `tools/list` pagination walk did
* not reach the end ({@link McpListingIncomplete}): its tools past the stopping point are not
* mounted this task, which is a capability loss the operator must see rather than infer from a
* short tool list;
* - `prompt.section_answer_invalid` — the host's disposition of a CONNECTED server's instructions
* could not be read, so the entry was included as computed.
*/
warnings: Error[];
/**
* [cc216] Tools DROPPED at intake because the server advertised a schema no provider accepts
* ({@link mcpToolSchemaProblem}) — without the drop, one malformed tool schema riding the wire
* fails the WHOLE model request (provider 400), bricking the task for every healthy tool
* (CC 2.1.216 ships the same intake prune, `tengu_mcp_drop_invalid_tool_schemas`).
*
* HONEST REACH (pinned in mcp-dropped-tools.test.ts): listings arrive through the lenient reader
* (`listToolsLenient` — raw request, no SDK-client zod pass), so a string-root or otherwise odd
* schema DOES reach this gate and is judged per tool; healthy siblings survive. This per-tool gate
* is therefore the PRIMARY adjudicator for schema shape, not a fallback behind SDK validation.
*
* The drop is NEVER silent, two faces (CC parity — its `mcp_dropped_tools_delta` system reminder):
* - operator: prepare-task forwards each entry to `onError(phase:"mcp")` alongside `warnings`;
* - model: the run loop announces the entries ONCE at a turn boundary (`mcp_dropped_tools`
* attachment, `mcpInstructions` opt-in lane) and DRAINS this array on intact delivery — same
* survival-gated consumption discipline as `instructionsDelta`.
* `tool` is the server-advertised name, neutralized at intake (`inlineUntrusted`); `reason` is
* core-TEMPLATED but its structural-error paths may embed server-controlled property names, so
* the whole string is likewise neutralized + bounded at intake (single-line, ≤240 cps, fences
* defused) — the bound also keeps one rendered announce entry inside a single frame.
*/
droppedTools: McpDroppedTool[];
/**
* design/99 §E9 — per-server status, a PURE PROJECTION of THIS materialization (no new connection logic).
* One entry per declared server DIALED (none for one `dialable` kept from the dial). **Semantics = the moment of this task leg's materialize** — core never
* persists MCP connections, so it is "did this leg connect", NOT a live session-health signal between tasks.
* A consumer (service) surfacing it as session status must understand that. `toolNames` are the namespaced
* (`<server>__<tool>`) names exposed.
*
* READ THIS AS A SNAPSHOT, NOT A HEARTBEAT. `status` is the connect-time verdict and is never
* rewritten: a server that dies mid-task still reads `connected` here, because that IS what happened at
* materialize. The one live bit is {@link McpServerStatus.transportClosed} (set if and when core observes
* the transport go away — see its own note for the reach). For an authoritative answer about a server's
* health RIGHT NOW, call {@link refresh} — it is the only face that touches the connection.
*/
statuses: McpServerStatus[];
/**
* RefreshMcpTools (CC 2.1.218 parity, anchored to the CLI form) — re-read the tool list of one server (or
* every CONNECTED server when `server` is omitted) over the EXISTING connection. NEVER dials or
* re-dials: a dead/never-connected server reports `not_connected` (so does one whose connection closed while the walk was reading); a live listTools failure
* reports `failed` (the connection is left as-is). A refreshed entry carries the NEW intake
* result (tools/axes/dropped — the same schema-gate/namespacing/axis pipeline as connect) plus
* the added/removed namespaced tool names relative to the previous listing; the caller (the
* RefreshMcpTools tool mount in prepare-task) swaps them into the harness toolset.
*
* LEG-LOCAL fact (review, documented): refresh results do NOT ride a checkpoint — a durable
* resume re-materializes and takes a FRESH first listing, so refreshed tools may be absent on the
* resumed leg until RefreshMcpTools is called again (an honest not-found, never a silent
* misroute).
*/
refresh: (server?: string) => Promise<McpRefreshResult[]>;
/**
* RE-DIAL one named server — the action {@link refresh} deliberately is not. It closes
* whatever handle the server still has and dials it again through the SAME `connectServer` the
* materialization used, so the schema gate, the namespacing, the axis derivation and the metadata
* cache seeding cannot drift between a first connect and a re-dial: one function, not one
* convention.
*
* ONE SERVER, NAMED — CC 2.1.259's control-channel request carries a REQUIRED `serverName` and has
* no all-servers form, and an all-servers arm would close HEALTHY connections to re-dial them (a
* stdio server owning a device or a port is exactly the one that cannot be re-dialed while its
* predecessor still holds the device). CLOSE-THEN-DIAL, so a re-dial that FAILS leaves the server
* DISCONNECTED until another re-dial or the next leg's materialize, and calls in flight on the
* replaced connection fail with it — inherent to re-dialing, and CC's behavior too.
*
* NOT A MODEL FACE (CC has no such tool either): a verb that can leave a connection down is a host
* act, and its consumer entrance is the run handle's `reconnectMcpServer`. LEG-LOCAL like
* {@link refresh}: the result rides no checkpoint, and a durable resume takes a fresh listing.
*/
reconnect: (server: string) => Promise<McpReconnectResult>;
dispose: () => Promise<void>;
}
/** One per-server entry of {@link MaterializedMcp.refresh}. `added`/`removed` are namespaced
* (`mcp__<server>__<tool>`) names; `tools`/`axes`/`dropped` are present only on `"refreshed"`.
* The model-facing refresh receipt renders each `dropped` entry with ITS OWN `reason` (schema
* gate, same-listing name collision, …) — never a blanket label; a consumer matching that receipt
* text should key on the tool name, not on any fixed reason wording. Note the startup-time
* sibling: two DECLARED servers whose names normalize to one prefix refuse the whole mount at
* connect (`config.mcp_server_name_collision`, both original spellings named) — a deployment
* carrying such a pair learns at the first materialize, with the rename spelled out in the error. */
export interface McpRefreshResult {
server: string;
/** The server's namespaced-name prefix (`mcp__<normalized-server>__`) — the consumer's SPLICE
* DOMAIN for the swap (review F4: a prefix domain is self-healing and decoupled from the diff
* baseline, which advances even when a consumer skips a swap). Present on every entry. */
prefix: string;
/** `"revoked"` (design/338): the host ledger marks this server revoked — the refresh did NOT
* contact it (no tools/list round trip; the engine must not hand a severed server a request
* channel or splice its post-revocation text into the model catalog). */
status: "refreshed" | "not_connected" | "failed" | "revoked";
/** The same reading as {@link McpReconnectResult.toolCount}: the server's tools the producer holds after the call. */
toolCount: number;
added: string[];
/**
* Names that were mounted before this refresh and are no longer in the roster it returns — i.e. an
* assertion that the server withdrew them. It is therefore EMPTY whenever {@link listingIncomplete}
* is present, and empty as a FACT rather than a policy: an incomplete walk cannot prove absence, so
* it retains what it could not prove gone (see `listingIncomplete`) and nothing leaves. `added` is
* unaffected — a name that IS in the partial listing was really listed, whether or not the walk
* finished (#381).
*/
removed: string[];
tools?: AgentTool[];
axes?: McpToolAxis[];
dropped?: McpDroppedTool[];
/** On a `"refreshed"` entry whose connection carries instructions: that connection's entry as its dial disposed of it (`author: "host"` when replaced) —
* a refresh re-reads tools, never instructions; a run whose roster change first leaves something of the server teaches it. */
instructions?: McpInstructionsEntry;
/**
* Set on `not_connected` / `failed` / `revoked`; ALSO set on a `"refreshed"` entry whose listing walk
* did not finish (see {@link listingIncomplete}) — a refresh that produced usable tools is not a
* failure, so the status stays `refreshed` and the incompleteness rides here.
*/
error?: string;
/**
* Present when this entry's `tools/list` walk did NOT reach the end of pagination. `tools` is
* then a MERGE: everything the partial listing did return, plus the previously-mounted entries whose
* absence it cannot prove (so the consumer's replace-the-whole-prefix swap withdraws nothing, and
* `removed: []` describes a roster that really lost nothing). Absent on a complete walk and on every
* non-`refreshed` status.
*
* RESIDUAL, honest: the model-facing `RefreshMcpTools` receipt renders a refreshed entry's line from
* added/removed/excluded/dropped only, so this fact reaches the STRUCTURED face (`details.results`)
* and the operator, not the receipt text — rendering it there is a prepare-task change, filed rather
* than smuggled in here.
*/
listingIncomplete?: McpListingIncomplete;
}
/**
* The result of {@link MaterializedMcp.reconnect} — ONE server, because the face takes one.
*
* `outcome` is the whole top level, three words, so a consumer mapping it onto a three-state wire
* answer needs no table: `reconnected` is the only success, and everything that did not reconnect is
* either `refused` (the engine declined, or the dial/first listing failed — `reason` says which) or
* `not_declared` (no server of that name in this leg's spec). Sub-reasons ride `reason`; a fourth
* word would be a new state for every downstream to learn.
*
* @contract mcp.redial.roster_assertion — a re-dial result carries `tools` IF AND ONLY IF it asserts a
* roster for its `prefix` domain, and that one rule is what every consumer of it reads: the fresh intake
* on `reconnected`, the EMPTY roster on a dial that failed after the old connection was already closed
* (the server is down, so its tools are withdrawn rather than left mounted and unusable), and NOTHING at
* all on an arm that never touched the connection — an unknown name, a revoked server, a re-dial already
* in flight, a disposed materialization — whose mounted roster is left exactly as it was.
*/
export interface McpReconnectResult {
server: string;
/** The consumer's splice domain, same meaning and same mint as {@link McpRefreshResult.prefix}. */
prefix: string;
outcome: "reconnected" | "refused" | "not_declared";
/** Why, on every non-`reconnected` outcome (neutralized + bounded when it embeds remote text).
* Never the only machine-readable fact: see {@link status}. */
reason?: string;
/** How many of the server's tools the producer holds after the call — its roster: the run's (the valve and the deployment's
* overlay applied) for the run handle's `reconnectMcpServer`, this materialization's own handles for `MaterializedMcp.reconnect`
* (no run). A name no server is declared under holds nothing. Equal to `tools.length` whenever `tools` is present. */
toolCount: number;
/** The namespaced names that came onto / left that roster (a first dial adds its whole roster, a failed dial removes it). */
added: string[];
removed: string[];
/** See the type docstring: present iff this result asserts a roster for `prefix` — and then it is that roster's tools of the server. */
tools?: AgentTool[];
axes?: McpToolAxis[];
dropped?: McpDroppedTool[];
/** The OPERATOR lines this re-dial produced (an incomplete listing walk, a kept-but-defective tool
* schema, a spec naming tools a resources-only server cannot serve). Also appended to
* {@link MaterializedMcp.warnings}, which the runner drains ONCE at prepare — so the copy here is
* what the run handle's verb forwards to `onError(phase:"mcp")`. One production, two drains. */
warnings?: string[];
/** The NEW connect-time status record (also swapped into {@link MaterializedMcp.statuses}), present
* whenever the dial was attempted: a consumer keys its next action off `errorCode` / `httpStatus`
* (a 401 is a re-authorization door, a `spawn_failed` a missing binary), not off {@link reason}. */
status?: McpServerStatus;
/** The re-dial's own `tools/list` walk did not finish — {@link McpRefreshResult.listingIncomplete}
* minus the retention arm: a re-dial has no previous listing on this connection to retain from. */
listingIncomplete?: McpListingIncomplete;
}
/** design/99 §E9 — projected per-server MCP status (see {@link MaterializedMcp.statuses}). NOTE: `serverInfo`
* and `error` are SERVER-controlled strings (verbatim from the remote) — UNTRUSTED; a consumer rendering them
* into a TUI/log must treat them as such (a misbehaving server could embed ANSI/break-out sequences). `toolNames`
* are namespaced with a core-controlled `mcp__<server>__` prefix (design/108 ①). */
export interface McpServerStatus {
name: string;
/** The CONNECT-TIME verdict for this leg — "did this server connect and list its tools". Deliberately
* frozen: a later death does not rewrite it (see {@link transportClosed} and `MaterializedMcp.refresh`). */
status: "connected" | "failed";
serverInfo?: {
name: string;
version: string;
};
toolNames?: string[];
error?: string;
/** Present iff `status === "failed"` — the connect-time failure record, spread flat: {@link McpFailure.kind}
* as `errorCode`, {@link McpFailure.delivered} as `delivered`, and `httpStatus` when the kind is
* `http_status`. A consumer keys an action off THESE (a `http_status` 401 is a re-authorization door,
* a `spawn_failed` is a missing binary) instead of parsing the neutralized `error` text. */
errorCode?: McpFailureKind;
delivered?: McpDelivered;
httpStatus?: number;
/**
* Set to `true` once core OBSERVES this server's transport close during the task; absent
* otherwise (never `false`: absence means "no close was observed", which is not the same as "alive").
* It mirrors exactly the flag core's own fail-fast path keys on, so `transportClosed === true` also
* means every further call to this server's tools/resources is refused without dialing.
*
* REACH (honest, and the reason `refresh()` stays the authority): this needs a transport-level close
* NOTIFICATION. A stdio server has one — the child process exiting closes the pipe. A streamable-HTTP
* endpoint does NOT: the SDK's HTTP transport raises `onclose` only when the client itself closes it,
* so an endpoint that dies is discovered per-REQUEST (as a translated transport failure carrying the
* server name and the outcome) and this field stays absent.
*/
transportClosed?: boolean;
/**
* The engine's last liveness OBSERVATION about this server ({@link McpServerLiveness}) — a different
* question from all three of its neighbours, and the note each of them carries says so:
* · {@link status} is the CONNECT-TIME verdict of this leg, frozen at the dial — it is not this;
* · {@link transportClosed} is one EVENT (a close notification arrived), on the transports that have
* one — it is not this (this record also carries an `unreachable` that no close ever announced);
* · `McpReconnectResult.outcome` is one ACTION's judgment — it is not this (it says what a re-dial
* did, this says what the connection layer last learned).
* Written where the connection layer establishes or loses a connection (the dial, a re-dial, a close,
* a request that comes back with the transport gone), never on a timer and never by a probe: the
* engine adds no traffic of its own to answer it.
*
* ABSENT = no observation ({@link import("./mcp-failure.js").MCP_LIVENESS_STATES}): a declaration that could not be dialed
* (`invalid_config`) never touched a transport, and an older / foreign record predates the field. A
* consumer must read absence as indeterminate — never as healthy, never as off.
*/
liveness?: McpServerLiveness;
}
/**
* design/108 ① (CC 2.1.187 parity) — the GLOBAL `mcp__` marker prefix. CC names every MCP tool
* `mcp__<server>__<tool>` so a consumer (permission allowlist, bypass-exempt, the flywheel's whitelist) can
* identify an MCP-originated tool by prefix ALONE, without knowing the per-deployment server names. Exported so
* downstream consumers share one source (the durable-resume canonicalizer is retired — RB-476-A).
* The value is OWNED by the protocol table (protocol-table.ts) — this export is the public face.
*
* The type is the LITERAL `"mcp__"`, not `string`: a downstream literal-typed position (a template-literal
* type, a keyed lookup) compiles against the literal and stops compiling the moment this widens — and the
* export-surface snapshot is name → kind, so it cannot see a widening at all. Annotated rather than
* asserted, so a table-side change to the prefix surfaces as a compile error here instead of being
* silently re-narrowed by a cast.
*/
export declare const MCP_PREFIX: "mcp__";
/**
* Apply the token gate to a mapped MCP content array (CC `$Mp`/`BMp` shape): text blocks consume a
* cumulative char budget of `tokens×4`; past the budget they are sliced/dropped head-keep, and the CC
* guidance note is appended as a final text block. Non-text blocks (images) pass through untouched —
* same convention as the offload wrapper. Exported for tests.
*/
export declare function gateMcpOutput(content: Array<TextContent | ImageContent>, limitTokens?: number): Array<TextContent | ImageContent>;
export declare function structuredContentErrorLine(structuredContent: unknown, collectedText: string): string | undefined;
/** Head+tail bound for MCP error text (exported for tests). */
export declare function truncateMcpErrorText(s: string): string;
/**
* Default per-call MCP tool timeout. The SDK's own default (60s) silently kills any long-running MCP tool
* with a -32001 and offers the deployment no knob; a very large default (matching the behavior of the
* reference agent client) lets the task-level budget/abort govern instead, while `MCP_TOOL_TIMEOUT` (ms)
* remains available to tighten it per deployment.
*/
export declare const MCP_TOOL_TIMEOUT_DEFAULT_MS = 100000000;
/**
* The bounds EVERY `MCP_*` millisecond knob resolves into — CC 223-same (`MNs` @354067 and the idle leg
* `Pyd` @354076 both clamp into `[1000, 2147483647]`; the constants were already ported here, the clamp
* was not).
*
* The ceiling is not cosmetic: `setTimeout` stores its delay in a signed 32-bit field, so a larger value
* is silently rewritten to `1` — an operator WIDENING a bound past 24.85 days (the shape of "basically
* turn this watchdog off") gets the exact opposite: a bound that fires ~immediately on every call. The
* floor closes the other end of the same unit trap — a bare `30` is what someone thinking in seconds
* types, and a 30ms MCP bound is never anyone's intent.
*/
export declare const MCP_ENV_MS_MIN = 1000;
export declare const MCP_ENV_MS_MAX = 2147483647;
/** Test seam kept under the name its callers already use; the ledger it clears is the shared one
* ({@link __resetEnvKnobAnnouncements} — the MCP knobs are not a family of their own, they are
* process-level env knobs like the output-length pair). Never called by production code. */
export declare function __resetMcpEnvAnnouncements(): void;
/** Resolve the per-call tool timeout in ms (env `MCP_TOOL_TIMEOUT`, else the large default). Exported for tests. */
export declare function mcpToolTimeoutMs(): number;
/**
* Resolve the TOTAL wall-clock ceiling for one tool call in ms (env `MCP_TOOL_TIMEOUT_TOTAL`, else the
* per-call timeout). The SDK's `resetTimeoutOnProgress` only re-arms the SDK's OWN per-call timer
* (`mcpToolTimeoutMs()` above — a different mechanism from {@link armMcpIdleWatchdog}'s idle watchdog
* below, despite the name-adjacent terms; disambiguated here on purpose, see that function's docstring
* for why sema needs both). It enforces a total bound ONLY when `maxTotalTimeout` is provided (protocol.js
* `_resetTimeout`), so without this a
* misbehaving/stuck server emitting periodic progress notifications could extend a call forever. Defaulting
* the ceiling to the per-call timeout means progress can keep an active call alive only within the same
* window the deployment already accepted; the resulting failure is the same -32001 timeout frame. Env
* parsing follows the `MCP_TOOL_TIMEOUT` discipline (unset/unparseable/non-positive → default). Exported
* for tests.
*/
export declare function mcpToolTotalTimeoutMs(perCallMs: number): number;
/**
* RB-199 FO-1 (form-one audit, CC 2.1.220): a server that goes SILENT — alive, transport open, no error,
* no close, just never responds and never emits progress — has no bound tighter than the (deliberately
* enormous, ~27.8h-default) total ceiling above. CC bounds this with a SEPARATE idle watchdog distinct
* from its total ceiling (`j7u` @345484: stdio 30min / http-sse 5min).
*
* Narrower than the audit's original framing: a CLOSED transport is already handled correctly by the SDK
* itself — `Protocol._onclose` (shared/protocol.js) rejects every in-flight response handler immediately
* with `ConnectionClosed` the moment `onclose` fires, so this watchdog exists for the SILENT-but-open case
* only, not as a substitute for connection-close handling.
*/
export declare const MCP_IDLE_TIMEOUT_STDIO_DEFAULT_MS: number;
export declare const MCP_IDLE_TIMEOUT_HTTP_DEFAULT_MS: number;
/** Resolve the idle-watchdog bound in ms for a transport kind (env `MCP_IDLE_TIMEOUT_STDIO` /
* `MCP_IDLE_TIMEOUT_HTTP`, else the CC-matched default). Exported for tests. */
export declare function mcpIdleTimeoutMs(kind: "stdio" | "http"): number;
/**
* Startup-phase (connect + initial listTools) timeout override in ms; undefined keeps the SDK default. Startup
* deliberately stays bounded: a hung server at materialize time should fail open into a skipped-server warning
* quickly, not stall task preparation indefinitely. THE one reader of the knob: with a `server`, that
* declaration's own `startupTimeoutMs` wins over env `MCP_TIMEOUT` (typed data held to the env knob's range
* [{@link MCP_ENV_MS_MIN}, {@link MCP_ENV_MS_MAX}], but REFUSED outside it — `config.mcp_startup_timeout_invalid`
* — where a process string is clamped and announced); without one, the process-level bound alone. The env value
* is resolved either way, so a bad one is announced even when a declaration shadows it.
*/
export declare function mcpStartupTimeoutMs(server?: Pick<McpServerSpec, "name" | "startupTimeoutMs">): number | undefined;
/** The protocol-range condition name for a JSON-RPC error code, or `undefined` for every other code
* (see {@link MCP_SPEC_ERROR_CODE_NAMES}). */
export declare function describeMcpSpecErrorCode(code: unknown): string | undefined;
/**
* The SDK stamps `MCP error <code>: ` onto an McpError's message IN ITS CONSTRUCTOR, and it
* does so at BOTH ends of the wire: a server built on an MCP SDK throws an McpError, its already-stamped
* `.message` goes out as the JSON-RPC `error.message`, and the client constructs its own McpError over
* that string — so the failure reaches the model as `MCP error -32602: MCP error -32602: …`. Collapse a
* repeat of the SAME code (a leading stamp carrying a DIFFERENT code is server data, not a duplicate of
* ours, and is left alone). Exported for unit testing; not part of the package index.
*/
export declare function collapseMcpErrorPrefix(message: string): string;
export { normalizeNameSegment as normalizeMcpName, clampNameSegment } from "./protocol-naming.js";
export * from "./image-downsample.js";
/**
* design/116 W5-2 — compact, jq-friendly type signature for a `structuredContent` value, e.g.
* `{title: string, items: [{id: number}]}`. Port of CC 2.1.187 `inferCompactSchema` (services/mcp/client.ts,
* beside the structuredContent branch at client.ts:2676-2683): depth-2, first array element as the element
* type, ≤10 object entries then `, ...`.
*/
export declare function inferCompactSchema(value: unknown, depth?: number): string;
/** `kind` is the dial's ONE read of `spec.transport.kind` (taken by the caller beside the dial and threaded
* here, to the idle bucket and to the failure classifier alike): a nested `transport` object is the
* caller's, and a getter that answered differently to two readers would otherwise connect over one
* transport and be judged under another. An unknown kind takes the non-stdio branch, as it always did. */
/**
* The MCP HTTP transport's `fetch`: every request the transport makes — the POST, the SSE GET (initial and
* resumed), the DELETE, the auth flows — goes to the endpoint the declaration NAMED and follows no redirect.
* `fetch`'s default follows a 3xx silently and then reports only the last hop, which is the one shape under
* which the delivery verdict would lie (a connect refusal on the redirect target reading "never sent" while
* the declared endpoint had received the POST); a 3xx now surfaces as the SDK's HTTP-status error, and a
* body or a credential never travels to a host the declaration did not name. Exported for its pin only.
*/
export declare const declaredEndpointFetch: typeof fetch;
interface McpContentItem {
type: string;
text?: string;
data?: string;
mimeType?: string;
/** `resource_link` block: a pointer to a server resource. */
uri?: string;
name?: string;
description?: string;
/** `resource` block: an embedded resource (text or binary). */
resource?: {
uri?: string;
mimeType?: string;
text?: string;
blob?: string;
};
}
export declare function mapContent(content: Array<McpContentItem>, serverName?: string, imageResizer?: McpImageResizer): Promise<Array<TextContent | ImageContent>>;
/**
* Connect to each MCP server, list its tools, and wrap them as AgentTools.
* Tools are namespaced `mcp__<server>__<tool>` (CC parity) to avoid collisions. The model sees each
* tool's real JSON-Schema (`inputSchema`), which the agent loop validates natively.
*
* Fail-open (design/29): a server that fails to connect or list tools is SKIPPED — its error is
* returned in `warnings`, the healthy servers still materialize. A single misconfigured server
* (bad stdio command, unreachable URL) never bricks the whole task.
*
* Call `dispose()` when the task finishes — connections are task-scoped, never persisted.
*/
/**
* Invisible / format / private-use / unassigned characters stripped out of every model-facing string a
* server advertises — CC `MZg` :144247. CC spells this as the property class BELOW followed by five
* explicit ranges (zero-width U+200B–U+200F, bidi embedding U+202A–U+202E, bidi isolates U+2066–U+2069,
* the BOM, and the BMP private-use area); on this engine's Unicode data every one of those code points
* is already `Cf` or `Co`, so the class alone is byte-equivalent to CC's expression and the source stays
* free of literal invisible characters (a file carrying them is a grep blind spot, and this repo has
* paid for that once already). The equivalence is asserted by a pin, not assumed — a Unicode-data change
* that moved any of those ranges out of the class would open exactly the hole this strips.
*
* `\p{Cc}` is deliberately NOT here: newlines and tabs are legitimate description formatting.
*/
export declare const MCP_INVISIBLE_TEXT_RE: RegExp;
/** One tool's schema-normalization outcome (CC 220 `zyo` @336616-336654's return shape). */
export type McpSchemaNormalizeResult = {
outcome: "unchanged";
} | {
outcome: "normalized";
schema: Record<string, unknown>;
note: string;
combinators: string[];
} | {
outcome: "drop";
reason: string;
};
/**
* RB-199 FO-2 (form-one audit, CC 2.1.220 `zyo` @336616-336654): flatten a root-level `anyOf`/`oneOf`/
* `allOf` combinator into a plain `{type:"object", properties, required}` shape a provider's tool-schema
* validator accepts, INSTEAD of leaving `mcpToolSchemaProblem` below to drop the whole tool. Without this,
* a server advertising the common "zod union" combinator shape (structurally VALID JSON Schema — the
* existing gate's root-type/structural checks both pass it) gets forwarded as-is and a strict provider
* (Anthropic) 400s the ENTIRE model request at the wire — the exact failure `mcpToolSchemaProblem`'s own
* docstring promises to contain ("a single misconfigured server never bricks the whole task"). A schema
* with no top-level combinator key is passed through `{outcome:"unchanged"}` — the vast majority of tools
* never reach the union-handling code below at all (fail-open by construction, matching CC's own early exit).
* Properties are collected from the top-level `properties` FIRST (so a sibling `properties` block — legal
* alongside a combinator, and semantically load-bearing — is never shadowed by a same-named combinator-
* branch property), then from every combinator branch present, in declaration order; the FIRST property of
* a given name wins (matches CC's own `!(d in r)` guard) — differing branches disagreeing on the SAME
* property's shape is a real ambiguity CC accepts too, not a sema gap. `required` is unioned from the
* top-level `required` PLUS `allOf` branches ONLY (never `anyOf`/`oneOf` branches — those are alternatives;
* unioning their `required` would demand fields the caller may legitimately omit by picking a different
* branch, exactly the correctness bug CC's own asymmetry avoids). Any exception during the walk (a $ref
* cycle degenerate enough to defeat the local-only resolver, e.g.) is caught and mapped to `outcome:"drop"`
* — a normalizer crash is evidence the schema is pathological, not a reason to propagate an exception out
* of the per-tool intake loop into the per-SERVER catch (same isolation principle `mcpToolSchemaProblem`'s
* own docstring already states for structural validation).
*/
export declare function normalizeMcpToolSchema(schema: unknown): McpSchemaNormalizeResult;
/**
* [cc216] Deterministic intake gate for a server-advertised tool `inputSchema` — returns a
* CORE-authored reason string when the schema would be rejected at the provider wire, else
* `undefined` (tool is kept). Deliberately CONSERVATIVE (fail-open): only shapes that are
* *structurally* unusable are dropped — anything a provider might accept rides through. An ABSENT
* schema is fine (intake defaults it to `{type:"object"}` below). CC 2.1.216 gates the same prune
* per-server via remote config (its rollout mechanism, not the mechanism itself); sema's gate is
* the deterministic predicate alone. RB-199 FO-2: run {@link normalizeMcpToolSchema} on the schema
* FIRST — a root-level anyOf/oneOf/allOf reaching this gate unflattened is exactly the shape this
* gate's own docstring promise ("a single misconfigured server never bricks the whole task") existed
* to catch, and this gate alone does not catch it (no type key ⇒ passes the root-type check; the
* combinator's own structure is legal JSON Schema ⇒ passes validateJsonSchemaShape too).
*/
/**
* A schema defect the operator must HEAR about but that must not cost the tool its mount — returns a
* core-authored sentence, or `undefined` when there is nothing to say.
*
* Today that is exactly one thing: a TOP-LEVEL parameter name outside `[a-zA-Z0-9_.-]{1,64}`. A name
* like `"user name"` is rejected at the Anthropic wire, and because the tool table rides EVERY request
* it takes the whole request down with it — while the operator previously got nothing at all pointing
* at the cause (no warning, no drop record, just a task that stopped working). CC checks the identical
* names against the identical expression (`EX_`/`_xo` :325793/:325824, reported as `check:"propertyKey"`).
*
* WHY AN ADVISORY AND NOT A DROP, stated because the sibling structural gate above does drop: CC's own
* disposition here is keep-and-warn — its drop arm sits behind a rollout flag that ships OFF (`Xyd` /
* `Yyd` :354641/:354622, an empty remote-config list ⇒ false), so CC's shipped behavior is to keep the
* tool and warn "requests that include it may fail". The difference from the structural half is real
* and not a technicality: a schema with no object root is unusable everywhere, whereas this charset is
* ONE provider's rule, and this engine is bring-your-own-model. Dropping here would delete a working
* tool from a deployment whose provider accepts the name — a Chinese- or Japanese-named parameter is
* the ordinary case, not an adversarial one. Detection was the gap; removal was never the mandate.
*
* TOP-LEVEL only, like CC: a nested property name is not what the provider validates.
*/
export declare function mcpToolSchemaAdvisory(schema: unknown): string | undefined;
export declare function mcpToolSchemaProblem(schema: unknown): string | undefined;
export declare function materializeMcpTools(specs: McpServerSpec[], principal?: string, onElicit?: OnElicit, imageResizer?: McpImageResizer, // design/116 CONFIRM-1 seam: deployment-injected; default = auto-detected sharp
reminderDisclosure?: {
reminderMark?: string;
counts?: ReminderDisclosureCounts;
}, mcpRevocations?: McpRevocationLedger & {
onProbeFailure?: (error: unknown) => void;
}, poolOpts?: {
pool: McpConnectionPool;
ownsPool?: boolean;
}, promptSections?: PromptSectionDisposer, dialable?: (spec: McpServerSpec) => boolean): Promise<MaterializedMcp>;
/**
* Fold the caller's AUTHORITATIVE per-tool override (design F: caller = trust root) over the server-hint axis.
* Unlike server hints, the caller may RAISE or LOWER any axis: `effect` sets the repeat-safety class (lower to
* `read`/`idempotent` or raise to `write`); `egress: false` / `irreversibility: "never"` REMOVE a server-added
* tighten the caller knows is wrong. Returns `undefined` when nothing remains set.
*/
/** Exported for tests — the override algebra above is a contract face (negatives must survive). */
export declare function applyCallerAxisOverride(name: string, hint: McpToolAxis | undefined, override: {
effect?: ToolEffect;
egress?: boolean;
irreversibility?: "always" | "never";
} | undefined): McpToolAxis | undefined;
/** Read a first-page `-32602` message as either meaning (see {@link DIR_READ_NOT_A_DIRECTORY_RE}). Exported
* for the discrimination nails; the message is SERVER-authored and is only ever classified here, never
* trusted as instructions. */
export declare function classifyDirReadInvalidParams(message: string): "not_found" | "not_directory";
/**
* The content key {@link walkMcpListPages}'s cursor-loop arm uses to recognize an entry it already
* collected: the entry's own JSON, which is what the server sent and what a re-served page repeats
* verbatim. `undefined` for anything unserializable — an entry that cannot be keyed is never treated as
* a repeat, so the failure direction is "kept, and disclosed by the lane that judges it" rather than
* "silently gone".
*
* NOT a semantic identity: two spellings of the same entry (different key order, a description the
* server re-rendered) read as different entries here and both survive. That is the safe direction —
* this key only ever authorizes a DELETION, so it must be exact and never clever.
*
* Exported for the walker's own unit pins (same reason {@link walkMcpListPages} is): its bound is a
* behavior, and a pin that re-declared the bound locally would pass while the real one drifted.
*/
export declare function listEntryFingerprint(entry: unknown): string | undefined;
/**
* Walk a paginated MCP list method to the END (`{cursor?}` → `{items, nextCursor?}`), bounded.
*
* Every spec-standard MCP list method is paginated the same way: absence of `nextCursor` means "that
* was the last page". A single request therefore returns the WHOLE list only for servers that choose
* not to paginate — and a server may start paginating at any time, with no protocol change and no
* malice, the moment its list outgrows its own page size. Treating page one as the complete list is
* both a capability loss (the rest is never mounted) and, at any call site that DIFFS two listings, an
* active falsehood about the server's inventory.
*
* Contract:
* - page ONE is fetched with the caller's own historic parameters (`cursor === undefined`), so a
* non-paginating server's wire traffic is byte-identical to before;
* - a FIRST-page failure propagates unchanged — every caller's failure shape (skipped server,
* `failed` refresh, per-server attributed aggregate error) is built on that throw;
* - a CONTINUATION failure keeps the pages already retrieved and reports {@link McpListingIncomplete}
* (`page_error`) — fail-open WITH disclosure, the same law {@link readDirViaExtension} follows —
* except an ABORT, which still propagates (a cancelled walk is not a short listing);
* - a repeated cursor stops the walk (`cursor_loop`) instead of spinning to the cap, AND undoes the
* entries that page re-served (a paginator that does not advance is typically one answering with a
* page already collected, and keeping it is how one entry gets listed twice) — judged by
* `identityOf` where the caller can key its entries, whole-page otherwise; an unreadable cursor
* stops the walk as `cursor_invalid` rather than passing for the end of the listing;
* - {@link MAX_MCP_LIST_PAGES} pages with a cursor still pending stops it too (`page_cap`);
* - a remainder too short to fund a real request stops it as `budget_exhausted` — core's own doing,
* named as such (see {@link MIN_MCP_PAGE_BUDGET_MS}).
* SIZE, stated rather than assumed: a single page has never been bounded here (a server could always
* answer with an arbitrarily long list), and walking multiplies that existing exposure by at most the
* page cap — a bounded multiplier, not a new unbounded surface. Per-entry admission stays where it
* already is (the intake schema gate, which also drops a name a later page repeats).
*
* TIME, the reason `budgetMs` exists (confirmed): the page cap bounds REQUESTS, not
* latency. Giving every page its own full request timeout would let a server that stalls each page hold
* a connect (or a refresh) for up to twenty timeouts — twenty minutes at the SDK's 60s default, where
* an unpaginated listing cost one. So the WALK gets the budget a single listing used to have: page one
* is issued with the full amount (byte-identical to the pre-walk request) and each continuation gets
* only what is left. ACCEPTED COST, stated: a genuinely slow server whose pages together outlast that
* bound is truncated where a single-request listing would not have been — but that truncation is
* DISCLOSED, while the alternative is an undisclosed twenty-fold latency ceiling on the task's startup
* path. The bound is the caller's own configured request timeout, not a new knob, so a deployment that
* needs more already has the dial. `fetchPage` receives the remaining budget and MUST spend it as that
* request's timeout — the deadline is cooperative, since only the caller can pass a timeout to its own
* transport — and must REPORT that same number if the request then times out: a page cut off at 3ms
* whose failure text names the whole listing budget describes a wait that never happened.
*
* NOT merged with {@link readDirViaExtension}, which walks the same way but owns CC-anchored
* first-page `InvalidParams` discrimination (not-a-directory vs not-found) and its own three-state
* flags; folding it in here would put that CC semantics into the standard-method path where it has no
* meaning.
*
* Exported for the walker's own unit pins — its arms are failure/deadline states that no fixture server
* can drive deterministically. Every production consumer goes through the three call sites in this file.
*/
export declare function walkMcpListPages<T>(fetchPage: (cursor: string | undefined, remainingMs: number) => Promise<{
items: T[];
nextCursor?: string;
cursorInvalid?: boolean;
}>, opts: {
budgetMs: number;
onPage?: () => void;
signal?: AbortSignal;
/** A key for one entry's CONTENT — equal keys mean the same listing entry, served again.
* `undefined` for an entry the caller cannot key. Used ONLY by the cursor-loop arm, to tell a
* re-served page from a page that merely arrived with a broken cursor; absent ⇒ that arm cannot
* tell them apart and falls back to dropping the whole page (see it for the trade).
*
* CONTENT, not name: an entry's name is what the CALLERS' admission gates key on, and they judge
* each entry on its own (a tool whose schema they refuse is dropped, and a later entry of the same
* name is then free to mount). Keying this on the name would let this walk delete the second
* entry before any of that ran — the only admissible definition of a tool, removed on the theory
* that a name cannot appear twice, which is a rule the layer below owns and applies differently. */
fingerprintOf?: (item: T) => string | undefined;
}): Promise<{
items: T[];
incomplete?: McpListingIncomplete;
}>;
/**
* RB-408 G12 — `tools/call` result parse that accepts the MCP `2026-07-28` widening of
* `structuredContent` from "a JSON object" to ANY JSON value (the same revision that relaxes
* `inputSchema`/`outputSchema` to the full JSON Schema 2020-12 keyword set).
*
* SDK 1.30.0's `CallToolResultSchema` still types the field as a record, so a spec-legal array / string /
* number / boolean / null fails validation for the WHOLE result — and the failure takes the `content`
* blocks down with it: a server returning perfectly usable text plus a scalar `structuredContent`
* produced nothing but `Invalid tools/call result: expected record, received number`. That is a silent
* capability loss dressed as a protocol error, and it gets WORSE as servers adopt the revision.
*
* The widening is deliberately surgical: `structuredContent` is lifted out, the SDK's own strict schema
* runs over the remainder (so content-block validation, the `content` default and the loose passthrough
* of unknown top-level keys are byte-for-byte what they were), and the lifted value is re-attached
* VERBATIM — same reference, no rebuild. An object-valued `structuredContent` therefore behaves exactly
* as before, and a malformed `content` array is still rejected: this must not become a validation bypass.
* Same duck-typing posture as {@link LenientListToolsResultSchema} (no direct `zod` dependency — the SDK's
* `zod-compat` shim calls `.safeParse(data)` on any object lacking a Zod-4 `_zod` marker).
*
* RESIDUAL, SDK-side and deliberately not widened here: `Client.callTool` guards its `outputSchema`
* validation with a TRUTHINESS test on `result.structuredContent`, so a tool that declares an
* `outputSchema` and returns a falsy scalar (`0`, `""`, `false`, `null`) is still rejected by the SDK with
* "has an output schema but did not return structured content". That path is LOUD (never a silent drop),
* is bounded to outputSchema-declaring tools, and widening it means replacing `callTool` itself — which
* would mean re-implementing the two guards it exists for (the output validator and the
* `taskSupport:"required"` refusal, both keyed off SDK-private caches).
*/
export declare function parseCallToolResultLenient(data: unknown): {
success: true;
data: unknown;
} | {
success: false;
error: unknown;
};