Skip to main content

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}