UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

235 lines (234 loc) • 16.3 kB
/** * How a deployment DECLARES an external tool server and what an elicitation round-trip looks like: * `McpServerSpec` (stdio / HTTP / in-process MCP), `A2aServerSpec` (the agent-to-agent peer) and the * elicit pair (`McpElicitRequest` / `McpElicitResponse` / `OnElicit`). Named `mcp-server-spec.ts`, not * `mcp.ts`: `src/core/mcp.ts` is the CLIENT, and two files a letter apart would be a worse hazard than * a longer name. Layer 0 vocabulary; `types.ts` re-exports every name below. */ 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 `<name>__<tool>` 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). Overrides any * same-named static `headers` entry. 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. */ 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; } /** * 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). */ 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). Overrides any same-named * static entry in {@link headers}. 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). */ 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>;