UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

126 lines (125 loc) • 9.52 kB
import { type McpFailureKind } from "./mcp-failure.js"; import type { McpServerSpec, OnElicit } from "./mcp-server-spec.js"; import type { WiringManifestMcpEntry } from "./wiring-manifest.js"; /** One server's probe reading, beside its manifest row. `connectMs` is wall time this probe measured * for THIS server's dial — the spawn/handshake, the initial `tools/list` walk and the intake, ending * at whichever verdict the server reached (connected, failed, or the clock below) — never a number the * server reported. `tools` are the namespaced (`mcp__<server>__<tool>`) names that WOULD mount, i.e. * exactly `McpServerStatus.toolNames` after the schema gate and the per-server allowlist, so * `tools.length` equals the row's `toolCount`; a server that did not connect carries `[]`. * `errorCode` is the row's failure class, repeated here so a caller reading only this array need not * join back. */ export interface McpProbeServerReading { name: string; connectMs: number; tools: string[]; errorCode?: McpFailureKind; } /** * What {@link probeMcpServers} answers. * * @contract mcp.probe.index_aligned — `entries` and `servers` always have the LENGTH of the `specs` * the caller passed and are ordered by it: `entries[i]` and `servers[i]` both describe `specs[i]`, for * every i, on every resolving call. A server that failed, that timed out, or that was never dialed * keeps its slot (the row carries its `status`/`errorCode`, the reading its measured `connectMs`), so a * consumer pairs its own declaration list with either array by index and never by name matching. */ export interface McpProbeResult { /** The rows, verbatim as a prepared leg would publish them — same projection, same failure * vocabulary, same `liveness` snapshot, same neutralized and bounded remote text. */ entries: WiringManifestMcpEntry[]; /** The probe-only half, index-aligned with `entries` (see the contract above). */ servers: McpProbeServerReading[]; } /** The probe's knobs. Every one of them is optional; a value outside its range is REFUSED, never * folded to the default (a bound the caller asked for and did not get is a bound nobody is keeping). */ export interface McpProbeOptions { /** The end-user identity a per-task principal header carries, exactly as `TaskSpec.principal` does * on a real leg — so a server that applies per-user rules answers the probe as it would answer the * task. */ principal?: string; /** An OVERALL ceiling on the VERDICT for each server, in whole milliseconds within `[1, MCP_ENV_MS_MAX]` (the * ceiling every env-supplied MCP bound keeps, because a larger delay is silently rewritten to 1 ms). The * caller's own choice, and it lives BESIDE the per-phase startup bounds the dial runs under — whichever * arrives first decides. When it elapses, THAT server's row reads `failed` with `errorCode: "timeout"` and * the walk moves on; nothing throws. It does NOT end the dial — nothing here can — so the transport stays * open until the dial lands on its own; see the residue note on {@link probeMcpServers}. * * Default: none. The probe has no clock of its own: it dials through the entrance a task does, so each * startup phase (the handshake, then the initial listing) carries that server's own bound from the one * reader ({@link import("./mcp.js").mcpStartupTimeoutMs}: its `startupTimeoutMs`, else env `MCP_TIMEOUT`, else * the SDK's 60 000 ms) — exactly what a task allows it. */ timeoutMs?: number; /** Abort the whole probe. The call rejects with the signal's own reason (a bare `abort()` gives an * `AbortError`) AFTER every server it had opened is closed — see the residue contract on * {@link probeMcpServers}. An already-aborted signal dials nothing. */ signal?: AbortSignal; /** The elicitation seat, for a caller that has a human behind it. Default: decline everything. */ onElicit?: OnElicit; /** Abort reading, stated once: the call rejects with the signal's own reason when that reason is an * Error (a bare `abort()` supplies a DOMException named `AbortError`) and with this module's own * `AbortError` otherwise — a face that throws a string is a face no `catch` can read. A condition * already held when the abort lands wins: the FIRST thing that ended the walk is what it rejects * with, so a materialization's refusal is never overwritten by the abort that followed it. */ /** How many servers this walk PURSUES A VERDICT for at once, ≥ 1. Default 1 — one at a time, in * declaration order. Raising it trades what isolation there is for wall time; the rows and the index * alignment are unaffected. * * Even at 1, `connectMs` is not a contention-free measurement: a settled dial's CLOSE is started and * not waited for, so the next dial begins while the previous transport is still shutting down. * * IT IS NOT A CEILING ON OPEN TRANSPORTS, and the difference is the whole point of `timeoutMs`: a * dial the clock abandoned keeps running (nothing here can cancel it) while the walk moves on, so * with a short `timeoutMs` against an unresponsive fleet the transports actually in flight can reach * one per DECLARATION, not `concurrency`. A caller probing a large fleet through a tight clock is * choosing that: the alternative — holding the slot until the abandoned dial lands — makes one stuck * server the walk's pace, which is the thing the clock exists to prevent. */ concurrency?: number; } /** * Connect to each declared MCP server, list its tools and close it again — no task, no run. * * The rows in `entries` are minted by the SAME projection a prepared leg uses, from the SAME * materialization, so they are the rows that leg would publish: `connected` with the mounted tool * count, `failed` with the connect-failure record as closed words (`errorCode` / `delivered` / * `httpStatus`) beside the neutralized, bounded remote text, `skipped` for a declaration this probe * produced no attempt record for. No word is added to the failure vocabulary * ({@link import("./mcp-failure.js").MCP_FAILURE_KINDS}) and none is spelled twice. * * NOTHING IS REGISTERED AND NOTHING IS MOUNTED: no task registry entry, no session, no checkpoint, no * store write, no `TaskEvent`. The materialized tools are dropped unread and never reach a harness, and * the result carries tool NAMES, so a caller holds nothing it could invoke. * * FAILURE IS PER SERVER, REFUSAL IS NOT. A server that cannot be dialed, that answers a status, or that * outlives `timeoutMs` takes its verdict in its own row and the walk continues. What DOES reject is the * declaration set the materialization itself refuses — two names that mount under one namespace prefix * are refused before any transport is touched, by that same function, so the probe answers a colliding * configuration exactly as a task would rather than showing two healthy-looking rows for a pair the * engine would never mount. * * @contract mcp.probe.zero_residue_bound — this call does not settle while a handle it opened is * undisposed: every dial's handle is disposed, a dial abandoned by the clock is disposed WHEN IT LANDS, * and all of them are awaited before the call resolves OR rejects. What that promises is that no * connection is left owned by nobody — NOT that every child process has already exited at the instant of * return, and NOT that the call itself is bounded. What bounds it, stated part by part rather than * summarised into a number that would be wrong: * · a dial has two REQUEST-BUDGET phases, each carrying the startup-phase bound ({@link * import("./mcp.js").mcpStartupTimeoutMs}: the server's own `startupTimeoutMs`, else env `MCP_TIMEOUT`, else the SDK's * 60 000 ms default): the handshake, one * request; and the initial tool listing, a PAGINATED WALK whose whole budget is that same number * (however many pages it takes — and no request at all when the peer advertises no tools). Two * budgets, so twice that, not once; * · the transport's own close ladder then reaps a stdio child that does not exit when its input closes: * at most 2 000 ms to SIGTERM, 2 000 ms more to SIGKILL; * · and ONE part is bounded by nothing here: a transport SEND carries no request deadline of its own * (the post-handshake `notifications/initialized`, an HTTP body read), so a peer that answers the * handshake and then stalls that send holds the dial — and therefore this call — open. The bound * belongs to the dial, which owns no cancellation; see `docs/KNOWN-LIMITS.md`. * So, PER DIAL and against a peer that either answers or fails its requests: that dial's residue is gone * within `max(timeoutMs, the handshake budget + the listing budget) + 4 000 ms`, and a server that exits * on its closed input (every well-behaved one) is gone at return. THE CALL is the walk over the * declarations, so its own duration is those per-dial times summed over `ceil(n / concurrency)` rounds — * this is not a whole-call number, and a caller budgeting a health check must do that arithmetic itself. * Against a peer that stalls a send, neither the dial nor this call has a deadline at all. */ export declare function probeMcpServers(specs: readonly McpServerSpec[], opts?: McpProbeOptions): Promise<McpProbeResult>;