@sema-agent/core
Version:
Stateless, task-oriented AI agent core
123 lines (122 loc) • 9.21 kB
TypeScript
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;
/** Per-server ceiling on the VERDICT, 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). 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: the engine's own startup-phase bound ({@link mcpStartupTimeoutMs}, env `MCP_TIMEOUT`) when a
* deployment set one; absent there, this module runs no clock and the answer for that server is
* whatever the dial's own request budgets produce — which bound the REQUESTS, not the dial. */
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
* mcpStartupTimeoutMs}, 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>;