@sema-agent/core
Version:
Stateless, task-oriented AI agent core
381 lines (380 loc) • 27.6 kB
TypeScript
import { type ReceiptFault } from "./mods/kernel/receive.js";
import type { ToolContentOrigin, ToolEffect } from "./tool-spec.js";
import type { ToolFamily, ToolPathTarget, ToolRenderHints, ToolRuleFace } from "./tool-face.js";
/** Definition of an MCP server to connect to for the duration of one task, then discard. */
export interface McpServerSpec {
/** Stable name; tool names are namespaced as `mcp__<server>__<tool>` (the protocol prefix, then this name as the peer segment) to avoid collisions. */
name: string;
/**
* Where this declaration CAME FROM, in the declaring side's own words (a shell's `plugin` / `user` /
* `project` / `local` config tiers, or anything else the deployment finds useful). Core reads
* nothing off it: it is echoed verbatim (single-line, length-bounded) onto the wiring manifest's
* per-server read face (`WiringManifest.mcp[].source`) so a consumer can group servers by their
* declaring tier without keeping a side table keyed on names. Optional; absent stays absent.
*/
source?: string;
transport: {
kind: "stdio";
command: string;
args?: string[];
env?: Record<string, string>;
} | {
kind: "http";
url: string;
/** Static headers sent on every request (e.g. an auth bearer for the MCP server itself). */
headers?: Record<string, string>;
/**
* Per-task **end-user principal** header (design/62). When set, the Runner injects the running task's
* authenticated {@link TaskSpec.principal} as this header (e.g. `"X-Agent-Principal"`) on every call to
* this server's tools, so the MCP server can enforce per-user RBAC. Absent {@link TaskSpec.principal}
* ⇒ the header is **not** sent (the MCP server must default to deny/public, never admin). A static `headers`
* entry under the same name in any case refuses this server at its dial (`config.protocol_header_collision`,
* header names being case-insensitive). The principal is Runner-held and the model/worker can neither read
* nor set it (it is not a tool argument) — see {@link TaskSpec.principal}.
*/
principalHeader?: string;
};
/** Optional allowlist of tool names to expose (others are dropped). ORTHOGONAL to
* {@link contentOrigin} and usefully paired with it (design/378 §2): the class declaration follows
* the server's roster, so a high-assurance deployment that wants a CLOSED tool set writes both —
* a tool a later refresh adds then lands outside this list and is simply not mounted. Existing
* semantics, no new mechanism; the refresh receipt still names every added tool either way. */
allowTools?: string[];
/**
* design/99 §E23 — opt in to INBOUND elicitation for THIS server: when `true` AND a {@link RunnerDeps.onElicit}
* seam is wired, core advertises the `elicitation` capability to this server and registers a handler, so the
* server may send an `elicitation/create` request to ask the END USER for input mid-tool-call. **Default OFF
* (fail-closed)**: never advertise unless explicitly wired, so a untrusted/over-eager server cannot pull a human
* into the loop. v1 is LIVE-ONLY — `onElicit` answers synchronously on the open connection (no durable
* suspend; a durable/cross-replica elicitation needs the deferred E22 longer-lived connection).
*/
elicitation?: boolean;
/**
* Caller-side per-tool safety-axis overrides, keyed by the server's REMOTE (un-namespaced) tool name. The
* CALLER is the trust root: unlike a server's self-declared annotations (which may only TIGHTEN), a caller
* override is AUTHORITATIVE and may LOWER an effect (vouch a tool is `read`/`idempotent`) as well as raise it
* (`egress` / irreversible). This is the ONLY trusted way to drop an MCP tool below the fail-closed `write`
* default (design F). Folds over the server hints in prepare-task (caller > server hint > fail-closed write).
*
* A DIFFERENT AXIS from {@link contentOrigin}: this one is about what a call DOES (repeat-safety,
* blast radius, reversibility — it feeds the approval gate); the content class is about what a call
* BRINGS BACK (memory-write governance, no gate/policy/roster effect). Neither implies the other —
* a read-only tool can return third-party text, and a deployment's own writer brings back nothing
* external — so vouching on one axis never quietly vouches on the other.
*
* THE SHAPE IS JUDGED, NOT TRUSTED: {@link readToolAxes} is the one reader of this declaration (an A2A peer's `toolAxes` is read by it too). The
* preparation door runs it on every entry, and a declaration it cannot read refuses the whole prepare
* (`config.invalid_argument`) — one bad tool refuses the entry, a bad key is never dropped and a bad word
* never defaults, because a caller's safety axis that silently reads as absent is a trust root that is not
* in force. The materialization entrance (`materializeMcpTools`, which the no-run probe dials through) asks
* it again per server: a declaration it cannot read fails THAT server's dial. A host that assembles this
* field from JSON (a console, a deployment intake) asks the same reader at its own door, so both sides
* answer one question one way.
*/
toolAxes?: Record<string, {
effect?: ToolEffect;
egress?: boolean;
irreversibility?: "always" | "never";
}>;
/**
* design/388 §1.1 / §2.7 — the OPTIONAL per-tool FACE overlay for this server's tools (keyed by the server's
* RAW tool name, like {@link allowTools} / {@link toolAxes}): a deployment lets an MCP tool enter a specialised
* approval card, be confirmed by path, carry a display name / summary, or narrow its rule vocabulary. TRUSTED
* (the deployment writes it), but validated against the MATERIALIZED schema at every listing (first mount and
* every refresh): a `pathTarget.param` that is not a top-level property, an `approvalCard` whose keys the schema
* lacks, a `ruleFace` naming a non-scalar key ⇒ the tool mounts WITHOUT the overlay and the leg announces
* `config.tool_face_invalid` once; a key naming a tool the listing does not carry announces
* `config.tool_face_undeclared` once. Applied whole or not at all.
*/
toolFaces?: Record<string, {
family?: ToolFamily;
pathTarget?: ToolPathTarget;
renderHints?: ToolRenderHints;
ruleFace?: ToolRuleFace;
}>;
/**
* design/378 — declare {@link ToolSpec.contentOrigin} on behalf of THIS SERVER'S ENTIRE TOOL SET
* (tools a mid-task refresh adds included), with the same authority and the same responsibility a
* directly-mounted host tool's own declaration carries.
*
* WHY THE SEAT EXISTS. Without it the class keys on the MOUNT SHAPE rather than on lineage: a tool
* the deployment wrote and runs itself is structurally `"external"` the moment it arrives over the
* MCP protocol namespace, so every call marks the session's memory externally exposed. The
* per-name channels cannot express the fact either — {@link import("./memory.js").MemorySpecInput.trustedTools}
* is a per-REQUEST allowlist keyed on the MINTED name (the host would have to predict the charset
* normalization) whose own definition is "the exception channel for UNDECLARED tools", and it says
* nothing about tools the server adds later.
*
* THIS IS A TRUST DECLARATION, not a routing hint. Use it only for servers inside the deployment's
* trust boundary — a process, socket or service the deployment itself runs. Declaring a THIRD-PARTY
* server means treating its output as content the deployment wrote: the class names are
* BOUNDARY-relative, never topological, so neither the transport kind nor the address is evidence
* of lineage (a stdio child can be an untrusted package; a loopback URL can be your own service) and
* core deliberately does not gate on either. The declaration covers THE PEER THIS ENTRY CONNECTS TO
* — authenticating that peer (socket permissions, credentials) is the host's mounting duty.
*
* SEMANTICS. `"local"` ⇒ invocations no longer mark this session's memory; `"execution"` ⇒ the same,
* except that {@link import("./memory.js").MemorySpecInput.execIsExternalContent} can still upgrade
* the class for a strict deployment (which is why this seat takes the three-value vocabulary and not
* a single "mine" flag — an execution-shaped tool mounted over MCP must stay inside that knob's
* reach); `"external"` is an explicit PIN, and pins are not no-ops — a declaration beats the
* `trustedTools` allowlist, so writing it forecloses the per-name exemption for this server's tools.
* ABSENT ⇒ the pre-378 behavior byte for byte: the protocol namespace classifies the tools
* `"external"` (fail-closed). A value outside the vocabulary is refused at the preparation door
* (`config.mcp_content_class`), never folded to a class.
*
* COVERAGE, stated honestly: the class rides this server's own mounted tools. The cross-server
* resource faces (ListMcpResourcesTool / ReadMcpResourceTool / ReadMcpResourceDirTool) aggregate
* over every connected server in one call, so they stay `"external"` and still mark — over-marking,
* the safe direction. A delegated child's pool is a separate static declaration surface
* ({@link ToolSpec.agentToolPool} entries carry their own `contentOrigin`): a deployment handing
* this server's tools to children mirrors the value there, and not mirroring it over-marks.
*
* TRUST SOURCE — a DEPLOYMENT-plane key. It redefines where the trust boundary runs, which puts it
* on the same authority plane as {@link RunnerDeps} wiring, not on the request plane. Core sees one
* `TaskSpec` and cannot tell a deployment-baseline entry from one a request supplied, so any
* assembly layer that accepts REQUEST-side MCP entries must reject or strip this key from them:
* "allowed to mount a server" is not "allowed to redefine the deployment's trust boundary", and a
* caller-supplied `"local"` would otherwise be self-authorization around the session mark. A
* single-tenant superuser surface (a host reading its own `--mcp-config` file) IS the deployment
* plane and needs no such gate.
*/
contentOrigin?: ToolContentOrigin;
/**
* The host's identity of THIS MOUNT of the server — a non-empty string (≤ 128 characters) the host
* minted from the declaration; the engine computes nothing from it and reads nothing off it. When present
* it rides the revocation probe ({@link McpRevocationLedger.isRevoked}) as the second argument, so the
* host can tell one mount generation of a server from a re-declared one under the same name. Absent ⇒
* the probe is asked with the name alone. Any other value refuses this server at its dial
* (`config.mcp_fingerprint_invalid`).
*/
fingerprint?: string;
/**
* THIS server's startup bound, in whole milliseconds: the handshake (connect + initialize) and the initial tool listing each carry it
* (the listing as one walk, however many pages). One knob, three seats, read by one function in this order — this member, else the
* process-level `MCP_TIMEOUT`, else the MCP SDK's own 60 000 ms — so a server that hangs at startup is cut on its own clock while its
* siblings keep the deployment's. It bounds only that startup phase: a call's own timeout (`MCP_TOOL_TIMEOUT`) is untouched.
*
* The range is the one every MCP millisecond bound keeps, 1 000 to 2 147 483 647 (a larger delay is silently rewritten to 1 ms by the
* timer, a smaller one is a unit slip). Unlike the environment seat — a string a process may clamp and announce — a declaration is typed
* data, so anything else (a string, a fraction, zero, a negative, NaN, `null`, a number outside the range) refuses THIS server at its
* dial (`config.mcp_startup_timeout_invalid`; siblings mount) rather than being clamped or read as the default.
*
* The bound a dial runs under (this member, else the environment seat) is part of a pooled connection's identity: mounts under one
* bound lease ONE connection, mounts under different bounds dial their own — so a mount is never held by another mount's clock.
* The no-run probe has no clock of its own: each startup phase carries this same bound (a caller's `timeoutMs` is an overall ceiling beside it).
*/
startupTimeoutMs?: number;
}
type ToolAxes = NonNullable<McpServerSpec["toolAxes"]>;
/**
* The surfaces that declare `toolAxes`, which {@link readToolAxes} names its reading by: an MCP server's keys are the server's
* remote TOOL names, an A2A peer's keys are the peer's remote SKILL ids.
*/
export type ToolAxesSurface = "mcp" | "a2a";
/**
* {@link readToolAxes}'s answer. `ok: true` carries the declaration as a PLAIN COPY the reader built from
* what it judged (never the caller's own object); `toolAxes` is absent when the entry declares nothing — no
* key at all, or only empty per-tool objects (which the engine reads the same as no declaration). `ok: false`
* carries one sentence naming the tool (or skill) and the member that did not fit, never a value.
*/
export type ToolAxesRead = {
ok: true;
toolAxes?: ToolAxes;
} | {
ok: false;
problem: string;
};
/**
* Where a declaration stops being one: the path from the declaration's root — a list index as a number, a member name as a string — and
* the clause that says what is there ("it contains itself; …"): a kind and a remedy, never a value the declarer wrote.
*/
export interface DeclarationFault {
readonly path: readonly (string | number)[];
readonly problem: string;
}
/** {@link receiveDeclaration}'s answer: the engine's own frozen copy, or where and why the declaration could not be received. */
export type DeclarationRead = {
readonly ok: true;
readonly value: unknown;
} | {
readonly ok: false;
readonly fault: DeclarationFault;
};
/**
* A receipt's refusal of a declaration (or of the list that carries declarations), said as a declaration refusal says it. The receipt's
* path does not say which kind of container each name came from, so a segment of digits alone is read as a list index.
*/
export declare function declarationFaultOf(fault: ReceiptFault): DeclarationFault;
/** What a declaration's own type fixes at one path, beyond being plain data: a string there, or a record of names to strings. */
export interface DeclarationPathRule {
readonly path: readonly string[];
readonly holds: "a string" | "names to strings";
/** The member must be there (a declaration with no value at the path is refused, as one with a value of another kind is). */
readonly required?: true;
}
/**
* Per surface, every path its declaration's type fixes beyond plain data — the declarer's `name` (a string, always there) and every record
* the type writes as `Record<string, string>`: an MCP server's `transport.env` (a stdio server's environment) and `transport.headers` (an
* HTTP server's headers); an A2A peer's `headers`. A path that is not its surface's (an A2A peer's `transport`) is not judged by another
* surface's row: it is a member the surface does not read.
*/
export declare const DECLARATION_PATH_RULES: Readonly<Record<ToolAxesSurface, readonly DeclarationPathRule[]>>;
/**
* The one reading of a declaration a caller hands the engine — an MCP server's or an A2A peer's entry (the snapshot), a `toolAxes`
* record ({@link readToolAxes}), a lock declaration: the engine's one receipt of a caller's object in its declaration mode, so the
* value is plain data held to the engine's one depth ({@link MODS_VALUE_DEPTH} levels below it) and every own member is read once, then, when the copy is a record, the declaration's own
* vocabulary over the copy (a list holds names; no member is named `__proto__`; each path `rules` names holds what its rule says — an
* entry's rules are its surface's {@link DECLARATION_PATH_RULES}). The copy is the answer — the caller's object is never handed on and
* never read again. A copy that is not a record is answered as it is, for the reader that expected a record to word.
*/
export declare function receiveDeclaration(declared: unknown, rules?: readonly DeclarationPathRule[]): DeclarationRead;
/**
* The one reader of a raw `toolAxes` declaration — {@link McpServerSpec.toolAxes} and {@link A2aServerSpec.toolAxes} are the same
* declaration, and the engine's preparation door, its materialization of an MCP server and of an A2A peer, and every host that
* assembles the field from JSON ask this same function. `at` names the surface the declaration sits on, because that decides what its
* keys are called in the sentence: `"mcp"` — the server's remote tools; `"a2a"` — the peer's remote skills. Absent ⇒ `{ ok: true }`
* (nothing declared). Anything else must be an object of per-tool objects whose members are exactly `effect` /
* `egress` / `irreversibility` with legal values; a declaration of any other shape, an empty tool name, an
* unknown member, a word outside the vocabulary or a non-boolean `egress` on ANY one tool refuses the whole
* declaration (`ok: false`) — the good tools are not kept and nothing is defaulted, since a caller axis is
* two-way (it can vouch a tool down as well as mark it up) and a silently lost half is a trust root that is
* not in force. The reader judges SHAPE only; whether the axes contradict each other (`egress` over a
* non-write effect) is the ingestion's own arithmetic. The declaration is read once, as plain data, by {@link receiveDeclaration} — a
* Proxy, a map, a class instance, a record built on a prototype or in another realm, a getter that throws, a structure that contains
* itself, a tool named `__proto__` are refusals that say where they sit (never the thrown text), not a throw — and judged on that copy.
* `at` is the caller's own word, not the declarer's: one outside {@link ToolAxesSurface} throws `config.invalid_argument` naming the
* surfaces, whatever is declared.
*/
export declare function readToolAxes(declared: unknown, at: ToolAxesSurface): ToolAxesRead;
/** {@link readToolAxes} for an MCP server's declaration — the entry a host that mounts MCP servers asks. */
export declare function readMcpToolAxes(declared: unknown): ToolAxesRead;
/**
* The word a sentence about one declaration of the surface `at` names the declarer by — `MCP server`, `A2A peer`. The same row the
* `toolAxes` refusal below reads, so every sentence the engine writes about a declaration it cannot read calls the declarer one thing.
*/
export declare function declarerWord(at: ToolAxesSurface): string;
/**
* The refusal an entry's unreadable `toolAxes` becomes (`config.invalid_argument`), ONE sentence for every surface that declares them:
* the subject is the surface's declarer word and `name` (already quoted and bounded by the caller: `MCP server "db"`, `A2A peer "agent"`),
* `problem` is {@link readToolAxes}'s own sentence.
*/
export declare function toolAxesRefusal(at: ToolAxesSurface, name: string, problem: string): Error & {
code: "config.invalid_argument";
};
/** The identity of one mount of a declared server, as the host minted it ({@link McpServerSpec.fingerprint}). */
export interface McpMountIdentity {
readonly fingerprint: string;
}
/** The host's MCP revocation ledger ({@link RunnerDeps.mcpRevocations}) — declared once, read by every MCP dispatch outlet. */
export interface McpRevocationLedger {
/** Synchronous. `mount` is passed exactly when the server's declaration carries a `fingerprint`. */
isRevoked(serverName: string, mount?: McpMountIdentity): boolean;
}
/**
* Definition of one A2A (agent-to-agent protocol) PEER to talk to for the duration of one task.
*
* The peer is a remote AGENT, not a data source: it acts on its own side. That single fact sets the
* defaults — every skill this peer advertises mounts with `egress: true` and `effect: "write"` (ruled
* 2026-08-03), so it registers the approval gate and is never auto-allowed on a no-policy deployment,
* and its replies reach the model inside an untrusted-data fence. {@link toolAxes} is the only trusted
* way down from those defaults (design F: the caller is the trust root).
*
* Skills mount as `a2a__<peer>__<skill>` (the protocol table's second namespace).
*/
export interface A2aServerSpec {
/** Stable local name for this peer; it becomes the namespace segment of every mounted tool name. */
name: string;
/** The peer's service URL. Doubles as the origin the well-known agent-card paths resolve against, and
* as the JSON-RPC endpoint when the card does not name a different one. */
url: string;
/** Explicit agent-card location, when the peer does not publish at a well-known path. Set ⇒ it is used
* ALONE (no well-known probing behind the operator's back). */
cardUrl?: string;
/** Static headers sent on every request to this peer (e.g. an auth bearer). They travel only to the
* declared origins (`url`, `cardUrl`): a card naming an endpoint on another origin refuses the peer
* (`config.protocol_endpoint_unbound`), and a redirect is not followed. */
headers?: Record<string, string>;
/**
* Per-task **end-user principal** header (design/62), same contract as the MCP side: when set, the
* Runner injects the running task's authenticated {@link TaskSpec.principal} under this header name on
* every call to this peer, so the peer can enforce per-user authorization. Absent principal ⇒ the
* header is not sent (the peer must then default to deny/public, never admin). A static entry in
* {@link headers} under the same name in any case refuses the peer (`config.protocol_header_collision`).
* The principal is Runner-held: the model can neither read nor set it.
*/
principalHeader?: string;
/** Optional allowlist of skill ids to expose (others are not mounted). */
allowSkills?: string[];
/**
* Caller-side per-skill safety-axis overrides, keyed by the peer's REMOTE skill id. The CALLER is the
* trust root, so this may LOWER the fail-closed default (`egress:true` + `effect:"write"`) as well as
* raise it — e.g. `{ egress: false, effect: "read" }` vouches that a skill only reads. Note that
* lowering `effect` WITHOUT clearing `egress` is a contradiction prepare-task refuses by design (an
* external write must be repeat-unsafe).
*
* THE SHAPE IS JUDGED, NOT TRUSTED, by the same reader as {@link McpServerSpec.toolAxes} ({@link readToolAxes} with `at: "a2a"`) and
* with the same disposition: a task's preparation door runs it on every peer of `TaskSpec.a2a`, and a declaration it cannot read
* refuses the whole prepare (`config.invalid_argument`, subject `A2A peer "<name>"`) before any peer is dialed — one bad skill refuses
* the task, a bad key is never dropped and a bad word never defaults. The materialization entrance (`materializeA2aTools`, the library
* entrance) asks it once per peer, when it reads that peer's declaration: there a declaration it cannot read fails THAT peer's dial
* (never dialed, a warning whose cause is the same refusal), its siblings unaffected; a refresh does not ask again, it dials the copy
* that was judged (a peer refused at the entrance is refused again by every refresh, from the stored refusal).
*/
toolAxes?: Record<string, {
effect?: ToolEffect;
egress?: boolean;
irreversibility?: "always" | "never";
}>;
}
/**
* design/99 §E23 — an INBOUND MCP elicitation request (a connected server asking the END USER for input
* mid-tool-call). Handed to the deployment's {@link RunnerDeps.onElicit} seam. **UNTRUSTED**: `message` /
* `requestedSchema` / `url` are SERVER-controlled — core never interprets `requestedSchema`; a consumer that
* shows `message` to a human MUST fence it (`delimitUntrusted`).
*/
export interface McpElicitRequest {
/** The {@link McpServerSpec.name} of the server that issued the request. */
server: string;
/** The server's human-facing prompt (UNTRUSTED — fence before display). */
message: string;
/** Form mode: the server's requested input schema (a flat object of scalars). OPAQUE to core. */
requestedSchema?: unknown;
/**
* URL mode: a server-controlled URL the user is asked to visit (UNTRUSTED — a phishing/social-engineering
* surface; a consumer MUST scrutinize/fence it before showing it). **Inactive**: core advertises FORM mode
* only, so the SDK rejects url-mode requests before this seam — the field is never populated today.
*
* Where url mode is heading (MCP revision `2026-07-28`), because the earlier note here ("reserved for a
* future url-mode opt-in") pointed at machinery that no longer exists: the revision REMOVES the
* `2025-11-25` url-mode correlator (`elicitationId`) and the `notifications/elicitation/complete`
* notification that went with it. A url interaction is instead carried by the revision's multi-round-trip
* request flow — the server answers with an input-required result, the client RETRIES the original
* request, and the server correlates across retries through its OWN opaque request state rather than a
* client-visible id. The one thing both shapes agree on is the server-supplied URL itself, which is what
* this field holds. So the field stays; anything that would have hung off the removed correlator must not
* be added here without re-reading the protocol revision core actually speaks.
*/
url?: string;
/** The running task's authenticated end-user (design/62), if any — for routing the prompt to the right human. */
principal?: string;
}
/** design/99 §E23 — the deployment's answer to an {@link McpElicitRequest} (returned to the MCP server). */
export interface McpElicitResponse {
action: "accept" | "decline" | "cancel";
/** Submitted values (accept only); core passes them through opaquely, never validated against the schema. */
content?: Record<string, string | number | boolean | string[]>;
}
/**
* design/99 §E23 — the LIVE-ONLY inbound-elicitation seam. A deployment provides it (e.g. the sema shell
* prompts the user at the terminal) and returns the answer SYNCHRONOUSLY; core replies to the server on the
* still-open connection. Absent ⇒ no server is advertised the elicitation capability (fail-closed). Not a
* durable suspend — a crash/replica-change loses an in-flight elicitation (acceptable for an interactive shell).
*
* 🔴 THROTTLING IS THE DEPLOYMENT'S JOB: once a server is opted in, it may issue elicitations at any frequency
* (one per tool call, or many) — core imposes NO rate/count limit (an elicitation is not a tool call and does
* not ride the policy gate). `onElicit` is the choke point: throttle, aggregate, or `decline`/`cancel` an
* over-eager or untrusted server here (the request's `AbortSignal` is forwarded so a deadline can cancel it).
*/
export type OnElicit = (req: McpElicitRequest, signal?: AbortSignal) => Promise<McpElicitResponse>;
export {};