agentplane/tools/mod.rs
1//! Calling tools on other people's servers.
2//!
3//! # The security decision this module exists to make
4//!
5//! MCP servers advertise their tools with *annotations*: `readOnlyHint`,
6//! `destructiveHint`, `idempotentHint`. They read like a safety contract. The
7//! specification is explicit that they are not:
8//!
9//! > Clients **MUST** consider tool annotations to be untrusted unless they come
10//! > from trusted servers.
11//!
12//! That warning lands harder here than in most runtimes, because of how the
13//! declarations compose. `readOnlyHint: true` would mean [`Effect::mutates`] is
14//! false; a non-mutating effect defaults to [`Recovery::Retry`]; and a retried
15//! call is a second real call. So a server that marks its own money-moving tool
16//! read-only could arrange for that tool to be **retried after a timeout** —
17//! choosing, from the far side of the trust boundary, the one condition under
18//! which this runtime performs something twice.
19//!
20//! So safety is not taken from the wire. It comes from a [`ToolCatalog`] the
21//! operator writes, and:
22//!
23//! * **A tool absent from the catalogue cannot be called at all.** Fail closed:
24//! an unknown tool is one nobody has reasoned about, and discovering tools at
25//! runtime is exactly how an agent acquires authority nobody granted.
26//! * **Advertised hints are recorded and compared, never obeyed.** A server
27//! claiming more safety than the operator granted is not a nuisance to
28//! normalise away — it is a signal, and it is reported.
29//!
30//! # What is *not* second-guessed
31//!
32//! The tool's output. It arrives as [`Tainted`](crate::core::Tainted) and
33//! untrusted, like every other effect result, because it is the outside world's
34//! data. Nothing in the catalogue can change that: the catalogue governs
35//! authority, not provenance.
36
37#[cfg(feature = "mcp")]
38mod mcp;
39/// Serving MCP: the other direction from the host side of this module, where a
40/// model's host calls in and a governed run happens.
41#[cfg(feature = "mcp-server")]
42pub mod serve;
43#[cfg(feature = "mcp-server-http")]
44pub mod serve_http;
45// Not gated on a transport: a typed tool is a tool this process implements, and
46// needs no wire at all.
47mod schema;
48mod typed;
49
50#[cfg(feature = "mcp")]
51pub use mcp::{
52 McpAccess, McpClient, McpDataSafety, McpPrompt, McpResource, McpTask, McpTaskCancel,
53 McpTaskPoll, McpTaskSnapshot, McpTaskState, McpTaskUpdate, TaskRetention,
54};
55/// The MCP SDK this host is built on, re-exported because it is public API:
56/// [`McpClient::new`] takes an rmcp `RunningService`, so a caller wiring a
57/// transport must name rmcp types at exactly the version this crate links —
58/// a direct dependency would have to be re-unified by hand on every upgrade.
59#[cfg(feature = "mcp")]
60pub use rmcp;
61pub use schema::{closed_schema, strict_schema};
62pub use typed::{Tool, ToolBox, ToolFailure};
63
64/// The MCP revision this plane serves and offers.
65///
66/// One definition because it is one fact. It is the set `server/discover`
67/// advertises, the ceiling `initialize` may negotiate to, the revision each
68/// request is validated against, the revision the client offers first, and the
69/// one the interop page publishes — and a revision that disagrees with the
70/// advertised set is drift a caller finds before we do.
71///
72/// **The extension decides it, not the date.** A governed suspension has no
73/// expression on this wire without the Tasks extension, so the dependency is on
74/// the extension and this revision is the one that carries it — which is why
75/// the published support policy is stated per extension.
76#[cfg(feature = "mcp")]
77pub const MCP_REVISION: rmcp::model::ProtocolVersion = rmcp::model::ProtocolVersion::V_2026_07_28;
78
79use std::collections::BTreeMap;
80use std::fmt::Debug;
81use std::sync::Arc;
82
83use async_trait::async_trait;
84use serde::{Deserialize, Serialize};
85use serde_json::Value;
86
87use crate::core::{
88 Disposition, Effect, EffectDescriptor, EffectError, ProtectedField, Recovery, RetryPolicy,
89 Sensitivity, Trust,
90};
91
92/// Which tool, on which server.
93///
94/// The server is part of the identity because two servers may both offer
95/// `transfer`, and they are not the same tool — the catalogue must be able to
96/// permit one and refuse the other.
97///
98/// The server name is the **operator's local name for a provider**, not
99/// anything the far side chose, and it is what [`ToolRouter`] dispatches on.
100#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
101pub struct ToolId {
102 pub server: String,
103 pub tool: String,
104}
105
106/// The scheme a manifest grant is written in.
107///
108/// Deliberately transport-neutral. Naming the transport — `mcp://server/tool`
109/// for a tool compiled into this binary, which never touches MCP — would assert
110/// a supply-chain fact the document cannot know, to readers who have no way to
111/// check it. A manifest is a **review artifact** and an Agent Card republishes
112/// its tool references, so a false statement in it is the defect this crate
113/// refuses everywhere else.
114///
115/// A reference names *which tool*. Which transport reaches its server is a
116/// **deployment** decision, made by [`ToolRouter`] — and keeping it there is
117/// also what lets one manifest run against an in-process double in a test and a
118/// real MCP server in production, which a transport-bearing reference forbids.
119pub const TOOL_SCHEME: &str = "tool://";
120
121/// The effect kind a call to an MCP tool is announced under.
122pub const TOOL_CALL_KIND: &str = "tool.call";
123
124/// The reserved server that names **agents on this plane** rather than a
125/// transport.
126///
127/// A grant spelled `tool://agent/<capability>` offers another agent's
128/// capability to a tool-calling model. Dispatch is `StepCtx::commission`, not
129/// a wire: the consultation is a journaled delegation effect, so it replays,
130/// the label travels, the sub-run's spend bills the run that asked, and the
131/// specialist's own manifest still governs everything it does. The server
132/// component is reserved — wiring a remote transport or a typed tool under
133/// this name is refused at build, because a name that could mean either "an
134/// agent here" or "somebody's server" would let a deployment change which one
135/// answers without changing any reviewed document.
136pub const AGENT_SERVER: &str = "agent";
137
138impl ToolId {
139 pub fn new(server: impl Into<String>, tool: impl Into<String>) -> Self {
140 Self {
141 server: server.into(),
142 tool: tool.into(),
143 }
144 }
145
146 /// The tool a `tool.call` effect named, read from its descriptor; `None`
147 /// for any other kind, or arguments that name no server and tool.
148 ///
149 /// The reader of the descriptor [`ToolCall`] writes, kept beside the
150 /// writer. The gate's own checks read the same two arguments with an
151 /// empty name for a missing one, so a malformed call is refused as an
152 /// ungranted tool rather than passed over.
153 #[must_use]
154 pub fn called(descriptor: &EffectDescriptor) -> Option<Self> {
155 if descriptor.kind != TOOL_CALL_KIND {
156 return None;
157 }
158 let text = |name: &str| descriptor.args.get(name).and_then(Value::as_str);
159 Some(Self::new(text("server")?, text("tool")?))
160 }
161
162 /// How a **manifest** names this tool: `tool://server/name`.
163 ///
164 /// The one spelling a grant is matched against. Built here rather than at
165 /// each call site because it was built at each call site, in two formats,
166 /// and a tool that resolved in the catalogue then failed the manifest gate
167 /// was the result.
168 #[must_use]
169 pub fn reference(&self) -> String {
170 format!("{TOOL_SCHEME}{}/{}", self.server, self.tool)
171 }
172
173 /// The source a call to this tool labels its result with.
174 ///
175 /// The name a field rule's `allowed_sources` matches to admit what this
176 /// tool returned. Ask for it rather than spelling it, so the rule and the
177 /// provenance cannot name one tool two ways.
178 #[must_use]
179 pub fn source(&self) -> crate::core::SourceId {
180 crate::core::SourceId::new(self.reference())
181 }
182
183 /// The inverse of [`reference`](Self::reference).
184 ///
185 /// `None` for anything that is not `tool://server/name`, and for
186 /// components no wire name could carry — see [`wire_name`](Self::wire_name)
187 /// for the charset. Refusing rather than guessing: a reference this cannot
188 /// parse is one no router can dial, and inventing an id from it would
189 /// grant a tool nobody wrote down.
190 #[must_use]
191 pub fn parse(reference: &str) -> Option<Self> {
192 let rest = reference.strip_prefix(TOOL_SCHEME)?;
193 let (server, tool) = rest.split_once('/')?;
194 (valid_component(server) && valid_component(tool) && !tool.contains('/'))
195 .then(|| Self::new(server, tool))
196 }
197
198 /// How a **model** names this tool: `server__tool`, dots rendered as `-`.
199 ///
200 /// Neither `tool://server/tool` nor `server/tool` is a legal function name
201 /// — providers restrict them to letters, digits, underscore and hyphen
202 /// (Gemini also permits dots, but a name must be legal everywhere) — so a
203 /// `:` or `/` is rejected before the model ever sees the tool. Two rules
204 /// make the rendering readable *and* injective:
205 ///
206 /// * **`.` becomes `-`.** Capabilities are conventionally dotted
207 /// (`blog.research`), and `-` is legal in every shipped provider's
208 /// charset — so `tool://agent/blog.research` reads as
209 /// `agent__blog-research` rather than an escape soup.
210 /// * **The separator is `__`, and it cannot occur elsewhere.** Components
211 /// are refused at declaration if they contain `-` (which would collide
212 /// with a rendered dot), contain `__`, or start or end with `_` (either
213 /// of which would let ordinary underscores run into the separator). The
214 /// one `__` in a wire name is therefore the boundary, and the mapping
215 /// back is exact.
216 ///
217 /// An earlier scheme escaped `_`→`_u` and `.`→`_d`, which was injective
218 /// and unreadable — `agent__blog_dresearch` — and its readability cost was
219 /// paid at exactly the wrong moment: by someone diffing a model's chosen
220 /// tool against an operator's grant. Refusing a few pathological names at
221 /// declaration ([`ToolCatalog::allow`] panics; [`ToolId::parse`] returns
222 /// `None`) is the better trade.
223 ///
224 /// This is the name [`ToolCatalog::resolve`] matches, because it is the
225 /// only one a model can actually emit.
226 #[must_use]
227 pub fn wire_name(&self) -> String {
228 format!(
229 "{}__{}",
230 wire_component(&self.server),
231 wire_component(&self.tool)
232 )
233 }
234}
235
236/// Render one component for the wire: dots become hyphens.
237///
238/// Injective because [`valid_component`] refuses a literal `-` on the way in —
239/// every hyphen a model sees denotes a dot, and every other byte is itself.
240fn wire_component(value: &str) -> String {
241 value.replace('.', "-")
242}
243
244/// Whether a server or tool name may appear in a wire name.
245///
246/// Letters, digits, `_` and `.` only; no `__`; no leading or trailing `_`; no
247/// `-`. Each refusal protects the wire rendering's injectivity or the
248/// providers' charsets — see [`ToolId::wire_name`] — and each is enforced
249/// where a tool is *declared*, never against a name a model emitted: a model's
250/// near-miss is a failed resolve, not a panic.
251fn valid_component(value: &str) -> bool {
252 !value.is_empty()
253 && value
254 .chars()
255 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
256 && !value.contains("__")
257 && !value.starts_with('_')
258 && !value.ends_with('_')
259}
260
261impl std::fmt::Display for ToolId {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 write!(f, "{}/{}", self.server, self.tool)
264 }
265}
266
267/// What the *operator* says about a tool.
268///
269/// Every field here decides something the runtime will do when a call goes
270/// wrong, which is why none of them may come from the server.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272pub struct ToolSafety {
273 /// Whether calling this changes the world.
274 ///
275 /// Defaults to `true`, and the default is the whole point: an operator who
276 /// has not thought about a tool gets the answer that makes the runtime
277 /// cautious rather than the one that makes it fast.
278 pub mutates: bool,
279 /// What to do when an attempt's outcome is unknown.
280 pub recovery: Recovery,
281 /// The highest sensitivity this tool may be *sent*.
282 pub max_sensitivity: Sensitivity,
283 /// The lowest sensitivity its results carry.
284 pub output_sensitivity: Sensitivity,
285 /// How many attempts, and how spaced.
286 pub retry: RetryPolicy,
287 /// Whether a call the tool ran and reported failed may be tried again
288 /// under [`retry`](Self::retry).
289 ///
290 /// `false` by default: a tool that ran and answered with an error has
291 /// given its answer, and asking again repeats the work for the same one.
292 /// A read-only tool whose failures are transient says so here.
293 #[serde(default)]
294 pub retry_landed: bool,
295 /// High-risk JSON arguments whose source constraints are stricter than
296 /// ordinary content fields.
297 #[serde(default, skip_serializing_if = "Vec::is_empty")]
298 pub protected_fields: Vec<ProtectedField>,
299}
300
301impl Default for ToolSafety {
302 fn default() -> Self {
303 Self {
304 mutates: true,
305 recovery: Recovery::RequiresOperator,
306 max_sensitivity: Sensitivity::Public,
307 output_sensitivity: Sensitivity::Public,
308 retry: RetryPolicy::never(),
309 retry_landed: false,
310 protected_fields: Vec::new(),
311 }
312 }
313}
314
315/// Protected fields in a canonical order.
316///
317/// Order carries no meaning here — the set is what matters — so anything that
318/// hashes or compares these must not be able to see it.
319pub(crate) fn sorted_fields(fields: &[ProtectedField]) -> Vec<ProtectedField> {
320 let mut out = fields.to_vec();
321 out.sort_by(|left, right| left.path().cmp(right.path()));
322 out
323}
324
325impl ToolSafety {
326 /// The safety a manifest grant declares.
327 ///
328 /// The fields a manifest does not carry — retry, recovery, output
329 /// sensitivity — take their cautious defaults, which is where a
330 /// hand-written safety starts too. One derivation, shared by the derived
331 /// catalogue and by a peer call held to its grant, so the two cannot read
332 /// one declaration differently.
333 #[cfg(feature = "manifest")]
334 #[must_use]
335 pub fn from_grant(grant: &crate::manifest::ToolGrant) -> Self {
336 let defaults = Self::default();
337 Self {
338 mutates: grant.mutates,
339 protected_fields: grant.protected_fields.clone(),
340 max_sensitivity: grant.max_sensitivity.unwrap_or(defaults.max_sensitivity),
341 ..defaults
342 }
343 }
344
345 /// A tool that only reads.
346 ///
347 /// Named for what the operator is asserting, not for what a server claimed.
348 #[must_use]
349 pub fn read_only() -> Self {
350 Self {
351 mutates: false,
352 recovery: Recovery::Retry,
353 ..Self::default()
354 }
355 }
356
357 #[must_use]
358 pub fn recovery(mut self, r: Recovery) -> Self {
359 self.recovery = r;
360 self
361 }
362
363 #[must_use]
364 pub const fn max_sensitivity(mut self, s: Sensitivity) -> Self {
365 self.max_sensitivity = s;
366 self
367 }
368
369 #[must_use]
370 pub const fn output_sensitivity(mut self, s: Sensitivity) -> Self {
371 self.output_sensitivity = s;
372 self
373 }
374
375 #[must_use]
376 pub fn retry(mut self, r: RetryPolicy) -> Self {
377 self.retry = r;
378 self
379 }
380
381 /// Let a failure the tool ran and reported be retried — see
382 /// [`retry_landed`](Self::retry_landed).
383 #[must_use]
384 pub const fn retry_landed(mut self, yes: bool) -> Self {
385 self.retry_landed = yes;
386 self
387 }
388
389 /// Add a field-level information-flow rule.
390 #[must_use]
391 pub fn protect(mut self, field: ProtectedField) -> Self {
392 assert!(
393 !self
394 .protected_fields
395 .iter()
396 .any(|existing| existing.path() == field.path()),
397 "a protected tool argument may be declared only once"
398 );
399 self.protected_fields.push(field);
400 self.protected_fields
401 .sort_by(|left, right| left.path().cmp(right.path()));
402 self
403 }
404}
405
406/// What a server said about its own tool.
407///
408/// Recorded so an operator can see it and so disagreements are visible. Never
409/// consulted when deciding what the runtime will do.
410#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
411pub struct Advertised {
412 pub read_only: Option<bool>,
413 pub destructive: Option<bool>,
414 pub idempotent: Option<bool>,
415}
416
417/// What a server says one of its tools is: a draft for a grant, and the
418/// reference a reviewed grant is compared with.
419///
420/// The description and schema are **model-steering text from the far side of
421/// a trust boundary** — a server can write instructions into them (tool
422/// poisoning) or change them after a person approved the tool (a rug pull). So
423/// nothing here reaches a model or decides anything. The grant does both: it
424/// carries the description and argument schema an operator reviewed, under the
425/// manifest digest, and [`drift`](Self::drift) names where the server has since
426/// moved away from it. [`digest`](Self::digest) pins what was reviewed for an
427/// embedder that keeps no grant file.
428#[derive(Debug, Clone, PartialEq)]
429pub struct Discovered {
430 /// Keyed by the name this plane knows the server by, never the server's own.
431 pub id: ToolId,
432 pub advertised: Advertised,
433 pub description: String,
434 pub input_schema: Value,
435 /// Over the tool's name, description, schema and annotations, canonically.
436 pub digest: crate::core::Digest,
437}
438
439impl Discovered {
440 /// Assemble one, computing its digest.
441 #[must_use]
442 pub fn new(
443 id: ToolId,
444 advertised: Advertised,
445 description: impl Into<String>,
446 input_schema: Value,
447 ) -> Self {
448 let description = description.into();
449 let digest =
450 crate::core::Digest::of(&crate::core::canon::value_bytes(&serde_json::json!({
451 "name": id.tool,
452 "description": description,
453 "input_schema": input_schema,
454 "annotations": advertised,
455 })));
456 Self {
457 id,
458 advertised,
459 description,
460 input_schema,
461 digest,
462 }
463 }
464}
465
466/// Where a server's tool has moved away from the grant an operator reviewed.
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub enum Drift {
469 /// The server now describes the tool differently.
470 Description,
471 /// The server now takes a different argument schema.
472 Arguments,
473}
474
475impl Advertised {
476 /// Whether the server claims more safety than the operator granted.
477 ///
478 /// Not an error — a server is entitled to describe itself, and an operator
479 /// is entitled to disagree. It is reported because the interesting case is
480 /// a server that *starts* claiming to be read-only after an update, which
481 /// is what a compromised or swapped-out server looks like from here.
482 #[must_use]
483 pub fn overclaims(&self, safety: &ToolSafety) -> bool {
484 // Every overclaim is a safety promise about a tool the operator declared
485 // *mutating*; a read-only grant has nothing to overclaim against.
486 if !safety.mutates {
487 return false;
488 }
489 // Read-only or "not destructive" on a mutating grant are the same signal
490 // — a server claiming more safety than it was given, which is what a
491 // swapped-out server looks like from here. `destructive` was recorded and
492 // never read, so the claim that most invites "this is safe to repeat"
493 // went unchecked.
494 self.read_only == Some(true)
495 || self.destructive == Some(false)
496 // Idempotent, but only where repeating is genuinely unsafe.
497 || (self.idempotent == Some(true)
498 && matches!(safety.recovery, Recovery::RequiresOperator))
499 }
500}
501
502/// Why a tool call could not be made, or did not work.
503#[derive(Debug, thiserror::Error)]
504pub enum ToolError {
505 /// The call never left. Safe to repeat.
506 #[error("could not reach '{tool}': {detail}")]
507 Unreachable { tool: ToolId, detail: String },
508
509 /// The server received the request and declined it without running the tool
510 /// — unknown method, invalid arguments. The request is intact.
511 #[error("'{tool}' refused the request: {detail}")]
512 Refused { tool: ToolId, detail: String },
513
514 /// It was sent and the outcome is unknown.
515 ///
516 /// The dangerous one, and the reason [`ToolSafety::recovery`] exists: a
517 /// timeout is not evidence that nothing happened.
518 #[error("'{tool}' did not answer in time: {detail}")]
519 TimedOut { tool: ToolId, detail: String },
520
521 /// The server ran the tool and the tool reported failure.
522 ///
523 /// Distinct from the two above because the call *landed*: repeating it would
524 /// be a second real invocation.
525 #[error("'{tool}' reported an error: {detail}")]
526 ToolFailed { tool: ToolId, detail: String },
527
528 /// The server answered with something that is not a tool result.
529 #[error("'{tool}' returned a malformed response: {detail}")]
530 Malformed { tool: ToolId, detail: String },
531}
532
533impl ToolError {
534 /// What this failure says about whether the call reached the world.
535 #[must_use]
536 pub const fn disposition(&self) -> Disposition {
537 match self {
538 Self::Unreachable { .. } | Self::Refused { .. } => Disposition::DidNotHappen,
539 Self::TimedOut { .. } => Disposition::InDoubt,
540 // The tool ran. Both of these mean the far side did work.
541 Self::ToolFailed { .. } | Self::Malformed { .. } => Disposition::Landed,
542 }
543 }
544}
545
546/// Where a [`ToolClient`] sends a call.
547///
548/// # Why a transport has to say this
549///
550/// [`Egress`](crate::core::Egress) does set membership on a **host**, and
551/// every other outbound path hands it one — a model driver parses its base
552/// URL, a peer client its card URL. A tool grant is `tool://server/name`,
553/// which names a catalogue entry and not a destination, so a client cannot be
554/// asked to parse a URL it may not have: it declares the answer, and the
555/// declaration is what the allowlist judges.
556///
557/// # What each answer means, and what it does not claim
558///
559/// [`Local`](Self::Local) says *this client opens no network connection from
560/// this plane*: tools compiled into the binary, an MCP server run as a child
561/// process over stdio, a test double. It is **not** a claim that the far side
562/// reaches nothing — a child process can open its own socket, and that is the
563/// same residual as a compromised allowlisted endpoint. What it claims is that
564/// no host of this plane's choosing is being contacted, which is the question
565/// an allowlist can answer.
566///
567/// [`Remote`](Self::Remote) names the host. Deny-by-default applies to it once
568/// an [`Egress`](crate::core::Egress) is wired.
569#[derive(Debug, Clone, PartialEq, Eq)]
570pub enum Destination {
571 /// Opens no network connection from this plane.
572 Local,
573 /// Connects to this host.
574 Remote(String),
575}
576
577impl Destination {
578 /// Name the host this client connects to.
579 pub fn remote(host: impl Into<String>) -> Self {
580 Self::Remote(host.into())
581 }
582
583 /// The host an allowlist judges, or `None` for a local transport.
584 #[must_use]
585 pub fn host(&self) -> Option<&str> {
586 match self {
587 Self::Local => None,
588 Self::Remote(host) => Some(host),
589 }
590 }
591}
592
593/// Transports a tool call. Implemented by the MCP adapter, or by anything else.
594#[async_trait]
595pub trait ToolClient: Send + Sync + Debug {
596 /// Invoke a tool and return its result.
597 ///
598 /// # Errors
599 ///
600 /// A [`ToolError`] whose variant states what is known about whether the call
601 /// reached the far side. Getting that wrong is how a payment happens twice,
602 /// so an implementation that cannot tell must say [`ToolError::TimedOut`].
603 /// `provenance` is what the callee may check: which run, which effect,
604 /// which agent, signed for exactly this call. A transport that has nowhere
605 /// to put it ignores it — that is a weaker deployment, not a broken one —
606 /// but it must never *invent* one, because a fabricated block is precisely
607 /// the compromised-intermediary case the signature exists to detect.
608 async fn call(
609 &self,
610 tool: &ToolId,
611 arguments: &Value,
612 provenance: Option<&crate::core::Provenance>,
613 ) -> Result<Value, ToolError>;
614
615 /// Where this client sends *this* call, for the plane's egress allowlist.
616 ///
617 /// Per tool rather than per client, because [`ToolRouter`] is itself a
618 /// `ToolClient` fanning out to one transport per server: a single answer
619 /// for the whole router would have to be the union of its routes, and a
620 /// union is exactly the wildcard `Egress` refuses to have.
621 ///
622 /// **No default, deliberately**, for the reason
623 /// [`JournalStore::is_shared`](crate::journal::JournalStore::is_shared)
624 /// has none: a default of [`Destination::Local`] would let a remote
625 /// transport answer *reaches nobody* by saying nothing, and the runtime
626 /// uses this answer to refuse a destination the deployment never granted —
627 /// and a control that fails open when an implementer forgets is not a
628 /// control.
629 ///
630 /// Answered cheaply — it is read before every dispatch, so a client that
631 /// resolved DNS here would put a lookup in the hot path.
632 fn destination(&self, tool: &ToolId) -> Destination;
633}
634
635/// The tools this plane may call, and what the operator says about each.
636#[derive(Debug, Default, Clone)]
637pub struct ToolCatalog {
638 entries: BTreeMap<ToolId, (ToolSafety, Advertised)>,
639 /// What a model is shown. Separate from safety because presentation is not
640 /// authority, and absent for catalogues used only by hand-written skills.
641 #[cfg(feature = "manifest")]
642 declarations: BTreeMap<ToolId, (String, Value)>,
643}
644
645impl ToolCatalog {
646 #[must_use]
647 pub fn new() -> Self {
648 Self::default()
649 }
650
651 /// Every tool a manifest declares, with the safety it declared.
652 ///
653 /// # Why this exists
654 ///
655 /// A manifest and a catalogue are two parties speaking: the agent's author
656 /// says what it needs, the operator says what it may have. That separation
657 /// is real in a deployment where those are different people — and pure tax
658 /// when they are the same one, which is most of the time. Stating `mutates`,
659 /// `max_sensitivity` and the protected fields **twice** is not two
660 /// decisions, it is one decision and a chance to disagree about it.
661 ///
662 /// So the common case is one declaration. An operator who wants to say
663 /// something different still can: `allow` after this replaces an entry, and
664 /// the manifest's own grant is re-checked at dispatch regardless — a
665 /// catalogue cannot widen what a declaration never asked for.
666 ///
667 /// The fields a manifest does not carry — retry, recovery, output
668 /// sensitivity — take their cautious defaults, which is the same posture a
669 /// hand-written `ToolSafety::default()` starts from.
670 #[cfg(feature = "manifest")]
671 #[must_use]
672 pub fn from_manifest(manifest: &crate::manifest::Manifest) -> Self {
673 let mut catalog = Self::new();
674 for grant in &manifest.spec.tools {
675 let Some(id) = ToolId::parse(&grant.reference) else {
676 // A reference the transport cannot name is refused by manifest
677 // validation, so reaching here means the two disagree about
678 // what a reference is — and inventing an id would grant a tool
679 // nobody wrote down.
680 continue;
681 };
682 catalog = catalog.allow(id.clone(), ToolSafety::from_grant(grant));
683 if grant.description.is_some() || grant.arguments.is_some() {
684 catalog = catalog.declare(
685 id,
686 grant.description.clone().unwrap_or_default(),
687 grant
688 .arguments
689 .clone()
690 .unwrap_or_else(|| serde_json::json!({ "type": "object" })),
691 );
692 }
693 }
694 catalog
695 }
696
697 /// Permit a tool, with the operator's declaration of what it does.
698 ///
699 /// # Panics
700 ///
701 /// If either component of the id could not appear in a wire name — a
702 /// literal `-`, a `__`, a leading or trailing `_`, or a byte outside
703 /// letters, digits, `_` and `.` — because a granted tool the model cannot
704 /// be offered under an unambiguous name is a wiring mistake, and this is
705 /// the one place every declaration passes. See [`ToolId::wire_name`].
706 #[must_use]
707 pub fn allow(mut self, id: ToolId, safety: ToolSafety) -> Self {
708 assert!(
709 valid_component(&id.server) && valid_component(&id.tool),
710 "tool id '{id}' cannot be rendered as a wire name: server and tool may \
711 hold letters, digits, `_` and `.`, with no `-`, no `__`, and no leading \
712 or trailing `_` — a name outside that set would collide with, or split \
713 on, the `__` separator and the `.`→`-` rendering"
714 );
715 self.entries.insert(id, (safety, Advertised::default()));
716 self
717 }
718
719 /// Attach the description and argument schema a model is shown.
720 ///
721 /// Crate-private because callers should either derive this from a
722 /// [`ToolBox`] or keep it in the digest-covered manifest. A third public
723 /// declaration path would recreate the drift this method removes.
724 #[cfg(feature = "manifest")]
725 pub(crate) fn declare(mut self, id: ToolId, description: String, arguments: Value) -> Self {
726 self.declarations.insert(id, (description, arguments));
727 self
728 }
729
730 /// Model-facing declaration, when this catalogue carries one.
731 #[cfg(feature = "manifest")]
732 pub(crate) fn declaration(&self, id: &ToolId) -> Option<(&str, &Value)> {
733 self.declarations
734 .get(id)
735 .map(|(description, arguments)| (description.as_str(), arguments))
736 }
737
738 /// Every entry, so several agents' catalogues can be combined.
739 ///
740 /// Owned rather than borrowed because a merge builds a new catalogue: two
741 /// agents on one plane may each declare tools, and the plane needs both.
742 #[must_use]
743 pub fn entries(self) -> Vec<(ToolId, ToolSafety)> {
744 self.entries
745 .into_iter()
746 .map(|(id, (safety, _))| (id, safety))
747 .collect()
748 }
749
750 /// Resolve a name a **model** chose to a tool the operator granted.
751 ///
752 /// This is the bridge between a completion and a dispatch, and it is where
753 /// most of the risk in tool-calling lives. A model emits a flat string it
754 /// generated; everything downstream treats the result as authority. So the
755 /// match is exact and total: the name must equal a granted tool's
756 /// `server/tool` rendering, byte for byte.
757 ///
758 /// **Nothing is resolved approximately.** No case folding, no trimming, no
759 /// nearest neighbour, no prefix. A model that writes `ledger/Transfer` or
760 /// `ledger/transfer ` gets a refusal, not the tool it nearly named — because
761 /// the whole point of a catalogue is that authority comes from the
762 /// operator's list rather than from a string a model produced, and a
763 /// resolver that helpfully corrects a near miss has handed the model the
764 /// power to reach a tool by describing it.
765 ///
766 /// The name compared is [`ToolId::wire_name`] — the only spelling a
767 /// provider permits a model to emit.
768 ///
769 /// `None` means refused. The caller must not fall back.
770 #[must_use]
771 pub fn resolve(&self, name: &str) -> Option<ToolId> {
772 self.entries
773 .keys()
774 .find(|id| id.wire_name() == name)
775 .cloned()
776 }
777
778 /// Find a tool by the spelling a **manifest** uses.
779 ///
780 /// The counterpart to [`resolve`](Self::resolve), which takes the spelling a
781 /// *model* uses. Two spellings because a manifest reference is a URI a human
782 /// reviews and a wire name is what a provider permits a model to emit — and
783 /// both are derived in one place each, because they were once derived in
784 /// three places in two formats.
785 #[must_use]
786 pub fn resolve_reference(&self, reference: &str) -> Option<ToolId> {
787 self.entries
788 .keys()
789 .find(|id| id.reference() == reference)
790 .cloned()
791 }
792
793 /// Every granted tool, for declaring to a model.
794 ///
795 /// Declare from here rather than from a hand-written list: offering a model
796 /// a tool the catalogue does not grant produces a call that is refused after
797 /// the tokens are paid for, and offering fewer than are granted hides
798 /// capability for no reason. One source, so the two cannot disagree.
799 pub fn granted(&self) -> impl Iterator<Item = &ToolId> {
800 self.entries.keys()
801 }
802
803 /// Record what a server advertised about a tool it offers.
804 ///
805 /// Changes nothing about how the tool is treated. It exists so the
806 /// disagreement is visible.
807 #[must_use]
808 pub fn observed(mut self, id: &ToolId, advertised: Advertised) -> Self {
809 if let Some(entry) = self.entries.get_mut(id) {
810 // Recorded and compared, never obeyed — and the "compared" half
811 // must reach somebody: an advertisement that outgrows its grant
812 // is the first observable move of a server going bad, and a
813 // discrepancy only visible to code that calls `overclaiming()` by
814 // hand is detection without delivery. A warning, not an error,
815 // because the grant is the ceiling either way; what the operator
816 // is being told is that the server now *wants* more than they
817 // gave it.
818 if advertised.overclaims(&entry.0) {
819 tracing::warn!(
820 tool = %id,
821 "this tool's server now advertises more safety than the \
822 operator granted; the grant still rules, but the \
823 advertisement changed"
824 );
825 }
826 entry.1 = advertised;
827 }
828 self
829 }
830
831 /// The operator's declaration, if this tool is permitted at all.
832 #[must_use]
833 pub fn safety(&self, id: &ToolId) -> Option<&ToolSafety> {
834 self.entries.get(id).map(|(s, _)| s)
835 }
836
837 #[must_use]
838 pub fn advertised(&self, id: &ToolId) -> Option<&Advertised> {
839 self.entries.get(id).map(|(_, a)| a)
840 }
841
842 /// Tools where the server claims more safety than the operator granted.
843 ///
844 /// Worth surfacing at startup: a server that begins advertising itself as
845 /// read-only after an upgrade is indistinguishable, from here, from one that
846 /// has been replaced.
847 pub fn overclaiming(&self) -> impl Iterator<Item = &ToolId> {
848 self.entries
849 .iter()
850 .filter(|(_, (safety, adv))| adv.overclaims(safety))
851 .map(|(id, _)| id)
852 }
853
854 #[must_use]
855 pub fn len(&self) -> usize {
856 self.entries.len()
857 }
858
859 #[must_use]
860 pub fn is_empty(&self) -> bool {
861 self.entries.is_empty()
862 }
863}
864
865/// Which client reaches which server.
866///
867/// # Why a plane needs one at all
868///
869/// A [`ToolId`] carries a server because two servers may both offer `transfer`
870/// and they are not the same tool. Nothing enforced that: a plane held exactly
871/// one [`ToolClient`] and handed it every id, so a deployment could grant
872/// `tool://ledger/read` and wire a client connected to a *different* server —
873/// which then answered, because a transport that never reads the server
874/// component cannot tell the difference. The realistic shape is two servers with
875/// a tool of the same name, where the wrong one runs and reports success.
876///
877/// One client per server, resolved by name, makes that unspellable. A server
878/// nobody registered is [`ToolError::Unreachable`] — the honest answer, since
879/// there is no transport that could carry the call.
880///
881/// It is also what lets a plane use more than one kind of tool at once: a
882/// [`ToolBox`] of typed in-process tools and an MCP server are two transports,
883/// and a plane routes to both by the server a reference names.
884#[derive(Debug, Default)]
885pub struct ToolRouter {
886 routes: BTreeMap<String, Arc<dyn ToolClient>>,
887}
888
889impl ToolRouter {
890 #[must_use]
891 pub fn new() -> Self {
892 Self::default()
893 }
894
895 /// Route one server's tools to one client.
896 ///
897 /// # Panics
898 ///
899 /// If the server is already routed. Silently replacing would make
900 /// registration order decide which transport carries a call, which is the
901 /// same defect this type exists to remove.
902 #[must_use]
903 pub fn server(mut self, name: impl Into<String>, client: Arc<dyn ToolClient>) -> Self {
904 let name = name.into();
905 assert!(
906 !self.routes.contains_key(&name),
907 "tool server '{name}' is routed twice — one of the two transports would \
908 silently never be called"
909 );
910 self.routes.insert(name, client);
911 self
912 }
913
914 /// Route every server the box implements to the box.
915 ///
916 /// A typed tool names its own server, so the box already knows which ones it
917 /// answers for; asking a caller to repeat them would be one decision written
918 /// twice.
919 #[must_use]
920 pub fn toolbox(self, tools: &Arc<ToolBox>) -> Self {
921 tools
922 .servers()
923 .map(ToOwned::to_owned)
924 .collect::<Vec<String>>()
925 .into_iter()
926 .fold(self, |router, name| {
927 router.server(name, Arc::clone(tools) as Arc<dyn ToolClient>)
928 })
929 }
930
931 /// The servers this router can reach.
932 pub fn servers(&self) -> impl Iterator<Item = &str> {
933 self.routes.keys().map(String::as_str)
934 }
935}
936
937impl ToolRouter {
938 /// The transport that would carry this call, if one is routed.
939 fn route(&self, tool: &ToolId) -> Option<&Arc<dyn ToolClient>> {
940 self.routes.get(&tool.server)
941 }
942}
943
944#[async_trait]
945impl ToolClient for ToolRouter {
946 async fn call(
947 &self,
948 tool: &ToolId,
949 arguments: &Value,
950 provenance: Option<&crate::core::Provenance>,
951 ) -> Result<Value, ToolError> {
952 let Some(client) = self.route(tool) else {
953 return Err(ToolError::Unreachable {
954 tool: tool.clone(),
955 detail: format!(
956 "no transport is wired for tool server '{}'; this plane routes {:?}",
957 tool.server,
958 self.routes.keys().collect::<Vec<_>>()
959 ),
960 });
961 };
962 client.call(tool, arguments, provenance).await
963 }
964
965 /// Whatever the routed transport says.
966 ///
967 /// An unrouted server answers [`Destination::Local`] rather than refusing:
968 /// there is no transport, so there is no destination to grant, and the call
969 /// is about to fail as [`ToolError::Unreachable`] with the honest reason.
970 /// Reporting an egress refusal for a server nobody wired would name the
971 /// wrong problem.
972 fn destination(&self, tool: &ToolId) -> Destination {
973 self.route(tool)
974 .map_or(Destination::Local, |client| client.destination(tool))
975 }
976}
977
978/// One call to one tool.
979///
980/// Built through [`ToolCatalog`], so a tool nobody declared cannot be
981/// constructed — the refusal happens before an effect exists, rather than inside
982/// one.
983#[derive(Debug)]
984pub struct ToolCall {
985 id: ToolId,
986 arguments: Value,
987 safety: ToolSafety,
988 client: std::sync::Arc<dyn ToolClient>,
989 /// Who is calling, sealed for this call. Set by the runtime via
990 /// [`Effect::attach`](crate::core::Effect::attach), because it contains the
991 /// effect key and an effect does not know its own.
992 provenance: Option<crate::core::Provenance>,
993 /// The model's id for the call this execution answers.
994 answering: Option<String>,
995}
996
997impl ToolCall {
998 /// Prepare a call, if the operator permits this tool.
999 ///
1000 /// # Prefer `StepCtx::call_tool`
1001 ///
1002 /// This takes whatever catalogue it is handed, and **nothing here binds that
1003 /// catalogue to the manifest governing the caller**. A hand-built one —
1004 /// which is the obvious thing to write, and what an older version of
1005 /// `examples/governed_transfer.rs` demonstrated — compiles, runs, and can be
1006 /// *laxer* than the declaration: a [`ToolSafety::read_only`] entry for a tool
1007 /// the manifest calls mutating exempts it from the whole-value taint gate and
1008 /// carries [`Recovery::Retry`], so a timed-out money-moving call is sent
1009 /// again. The plane's own catalogue is refused at build for exactly that
1010 /// divergence; a skill's is not checked by anything.
1011 ///
1012 /// [`StepCtx::call_tool`](crate::runtime::StepCtx::call_tool) dispatches
1013 /// over the plane's checked catalogue and makes the drift unrepresentable.
1014 /// Where a skill genuinely needs its own — a catalogue assembled before a
1015 /// runtime exists, or one for a test — build it with
1016 /// [`ToolCatalog::from_manifest`], which derives the reach from the
1017 /// declaration rather than restating it.
1018 ///
1019 /// [`ToolSafety::read_only`]: ToolSafety::read_only
1020 /// [`Recovery::Retry`]: crate::core::Recovery::Retry
1021 ///
1022 /// # Errors
1023 ///
1024 /// [`ToolError::Unreachable`] when the tool is not in the catalogue. It is
1025 /// deliberately not a separate "unknown tool" error at the call site: from
1026 /// the run's point of view an undeclared tool is one it cannot reach, and
1027 /// treating it as anything softer invites a fallback.
1028 pub fn prepare(
1029 catalog: &ToolCatalog,
1030 client: std::sync::Arc<dyn ToolClient>,
1031 id: ToolId,
1032 arguments: Value,
1033 egress: Option<&crate::core::Egress>,
1034 ) -> Result<Self, ToolError> {
1035 let Some(safety) = catalog.safety(&id) else {
1036 return Err(ToolError::Unreachable {
1037 detail: "this tool is not in the catalogue; a tool nobody declared is a \
1038 tool nobody has reasoned about"
1039 .into(),
1040 tool: id,
1041 });
1042 };
1043 // Refused **before the effect exists**, so nothing leaves, nothing is
1044 // journaled and nothing is metered — the same point in the sequence at
1045 // which a model driver refuses an ungranted base URL. A `Local`
1046 // transport has no host to grant and is not judged: the allowlist
1047 // decides where traffic may go, and this traffic goes nowhere of the
1048 // plane's choosing.
1049 if let Some(egress) = egress
1050 && let Some(host) = client.destination(&id).host().map(ToOwned::to_owned)
1051 && let Err(refused) = egress.permits(Some(&host))
1052 {
1053 return Err(ToolError::Unreachable {
1054 // Em dash, not a splice. `EgressError` owns the whole sentence
1055 // about the host — it is the only layer that knows one — and
1056 // this clause owns the server, which it does not. Joined with
1057 // `reaches {refused}` the two read as one broken sentence:
1058 // *…reaches 'evil.example' is not a granted destination*.
1059 detail: format!(
1060 "the transport for server '{}' was refused — {refused}",
1061 id.server
1062 ),
1063 tool: id,
1064 });
1065 }
1066 Ok(Self {
1067 safety: safety.clone(),
1068 id,
1069 arguments,
1070 client,
1071 provenance: None,
1072 answering: None,
1073 })
1074 }
1075
1076 /// Mark this execution as the answer to a model's call made to `offered`,
1077 /// so its span carries the provider's call id (`gen_ai.tool.call.id`).
1078 ///
1079 /// Telemetry only: the id is in neither the effect key nor the journal's
1080 /// descriptor. An id over 256 bytes is not recorded.
1081 ///
1082 /// # Errors
1083 ///
1084 /// [`ToolError::Unreachable`] when the call named another offer, or the
1085 /// offer stands for another tool: a span joining this execution to the
1086 /// call would say the model asked for what ran.
1087 pub fn answering(
1088 mut self,
1089 call: &crate::model::ToolCall,
1090 offered: &crate::model::ToolDeclaration,
1091 ) -> Result<Self, ToolError> {
1092 if call.name != offered.name || offered.tool.as_ref() != Some(&self.id) {
1093 return Err(ToolError::Unreachable {
1094 detail: format!(
1095 "the model's call '{}' named '{}', which is not the offer of this tool",
1096 call.id, call.name
1097 ),
1098 tool: self.id,
1099 });
1100 }
1101 self.answering = (call.id.len() <= MAX_CALL_ID).then(|| call.id.clone());
1102 Ok(self)
1103 }
1104}
1105
1106/// The longest provider call id a span records.
1107const MAX_CALL_ID: usize = 256;
1108
1109#[async_trait]
1110impl Effect for ToolCall {
1111 fn gen_ai_operation(&self) -> Option<&'static str> {
1112 Some(crate::runtime::telemetry::GEN_AI_EXECUTE_TOOL)
1113 }
1114
1115 /// No provider: a tool call names what ran, not who served it.
1116 fn gen_ai_request(&self) -> Option<crate::core::GenAiRequest> {
1117 Some(crate::core::GenAiRequest {
1118 provider: None,
1119 name: self.id.reference(),
1120 call_id: self.answering.clone(),
1121 })
1122 }
1123
1124 type Output = Value;
1125
1126 fn descriptor(&self) -> EffectDescriptor {
1127 // The server and tool are part of the key, so two servers offering the
1128 // same tool name are two different effects and cannot replay into each
1129 // other.
1130 EffectDescriptor::new(
1131 TOOL_CALL_KIND,
1132 serde_json::json!({
1133 "server": self.id.server,
1134 "tool": self.id.tool,
1135 "arguments": self.arguments,
1136 // Sorted here, not merely by the builder. `protect()` sorts,
1137 // but a `ToolSafety` built by struct literal or deserialised
1138 // from config never passes through it — and declaration order
1139 // would then change the **effect key**, so the same call
1140 // replayed against a differently-ordered catalogue would report
1141 // divergence. Canonicalising at the one place the value is
1142 // hashed makes the order unobservable.
1143 "protected_fields": sorted_fields(&self.safety.protected_fields),
1144 }),
1145 )
1146 }
1147
1148 fn mutates(&self) -> bool {
1149 self.safety.mutates
1150 }
1151
1152 fn recovery(&self) -> Recovery {
1153 self.safety.recovery.clone()
1154 }
1155
1156 fn retry(&self) -> RetryPolicy {
1157 self.safety.retry
1158 }
1159
1160 fn retries_landed(&self) -> bool {
1161 self.safety.retry_landed
1162 }
1163
1164 fn max_sensitivity(&self) -> Sensitivity {
1165 self.safety.max_sensitivity
1166 }
1167
1168 fn sink_arguments(&self) -> Option<&Value> {
1169 Some(&self.arguments)
1170 }
1171
1172 fn rebind(&mut self, arguments: Value) -> bool {
1173 self.arguments = arguments;
1174 true
1175 }
1176
1177 fn protected_fields(&self) -> &[ProtectedField] {
1178 &self.safety.protected_fields
1179 }
1180
1181 fn output_sensitivity(&self) -> Sensitivity {
1182 self.safety.output_sensitivity
1183 }
1184
1185 /// A tool result is the outside world's data.
1186 ///
1187 /// Stated rather than inherited, because this is the one effect where a
1188 /// reader is most likely to wonder whether the catalogue could relax it. It
1189 /// cannot: the catalogue governs authority, not provenance.
1190 fn trust(&self) -> Trust {
1191 Trust::Untrusted
1192 }
1193
1194 /// The tool's own reference, so a source rule can name *this* tool.
1195 ///
1196 /// `tool://server/name` — the spelling a manifest grants and a reviewer
1197 /// reads. Under the family-level default every granted tool answered as
1198 /// `effect:tool.call`, so a `ProtectedField::from_sources` rule could not
1199 /// distinguish the CRM lookup from the ticket search: "the recipient must
1200 /// come from the CRM" was unsatisfiable strictly, and satisfiable loosely
1201 /// by whichever tool an injected prompt reached first.
1202 fn source(&self) -> crate::core::SourceId {
1203 self.id.source()
1204 }
1205
1206 fn attach(&mut self, provenance: &crate::core::Provenance) {
1207 self.provenance = Some(provenance.clone());
1208 }
1209
1210 async fn perform(&self) -> Result<Value, EffectError> {
1211 self.client
1212 .call(&self.id, &self.arguments, self.provenance.as_ref())
1213 .await
1214 .map_err(|e| {
1215 let detail = e.to_string();
1216 // The disposition is what decides whether this is ever tried
1217 // again, so the mapping is explicit rather than a default arm.
1218 match e.disposition() {
1219 Disposition::DidNotHappen => EffectError::Rejected(detail),
1220 Disposition::InDoubt => EffectError::Interrupted {
1221 driver: self.id.to_string(),
1222 detail,
1223 },
1224 Disposition::Landed => EffectError::Performed(detail),
1225 }
1226 })
1227 }
1228}
1229
1230#[cfg(test)]
1231mod overclaim_tests {
1232 use super::*;
1233
1234 /// Each safety claim is checked on its own, so removing any one clause is a
1235 /// real regression rather than one masked by another firing on the same
1236 /// fixture. `destructive == Some(false)` on a mutating grant is the clause
1237 /// that was recorded and never read.
1238 #[test]
1239 fn a_server_claiming_more_safety_than_granted_is_flagged_per_clause() {
1240 let mutating = ToolSafety::default(); // mutates: true, Recovery::default
1241 let operator_needs_operator = ToolSafety::default().recovery(Recovery::RequiresOperator);
1242
1243 // read-only lie
1244 assert!(
1245 Advertised {
1246 read_only: Some(true),
1247 ..Advertised::default()
1248 }
1249 .overclaims(&mutating)
1250 );
1251
1252 // not-destructive lie — the clause under test
1253 assert!(
1254 Advertised {
1255 destructive: Some(false),
1256 ..Advertised::default()
1257 }
1258 .overclaims(&mutating)
1259 );
1260
1261 // idempotent lie, but only when repeating is unsafe
1262 assert!(
1263 Advertised {
1264 idempotent: Some(true),
1265 ..Advertised::default()
1266 }
1267 .overclaims(&operator_needs_operator)
1268 );
1269
1270 // Honest advertisements and honest silence do not flag.
1271 assert!(
1272 !Advertised {
1273 destructive: Some(true),
1274 read_only: Some(false),
1275 idempotent: Some(false),
1276 }
1277 .overclaims(&mutating)
1278 );
1279 assert!(!Advertised::default().overclaims(&mutating));
1280 assert!(
1281 !Advertised {
1282 read_only: Some(true),
1283 destructive: Some(false),
1284 idempotent: Some(true),
1285 }
1286 .overclaims(&ToolSafety::read_only())
1287 );
1288 }
1289}