Skip to main content

protocol_droid/domain/registry/
primitive.rs

1//! The primitives a registry holds: tools, resources, resource templates, and
2//! prompts.
3//!
4//! # These are the specification's shapes, read from the vendored schemas
5//!
6//! Every field below is named by a `schema.json` in
7//! `crates/protocol-droid-schema/spec/`, and the doc comment on each cites the
8//! revision that introduced it. The rule this module follows is the project's
9//! one: never remember a wire shape, read it. The two revisions that bracket
10//! the range are `2024-11-05` and `2026-07-28`, and the field sets differ —
11//! `2024-11-05`'s `Tool` is `{name, description, inputSchema}` and
12//! `2026-07-28`'s adds `title`, `outputSchema`, `annotations`, `icons` and
13//! `_meta` — so every field a later revision added is an `Option` here and the
14//! oldest revision's three are the only non-optional ones.
15//!
16//! # Where a schema is stored, and where it is lowered
17//!
18//! [`Tool::input_schema`] is a [`JsonObject`], which holds the *original text*
19//! rather than a re-serialised tree. That is what makes a per-revision schema
20//! dialect swap a re-parse of known bytes rather than a lossy round trip, and
21//! it is why the store keeps one copy per tool rather than one copy per
22//! (tool, revision) pair: the dialect lowering is T08's codec's job, it is
23//! deterministic given the bytes, and caching it per revision would mean a
24//! cache to invalidate on every publish. The schema is validated once, here,
25//! at the boundary — nothing downstream re-parses it just to check it is an
26//! object.
27//!
28//! # What is *not* here
29//!
30//! No handler, no I/O, and no session. A tool that a server can call is
31//! described by the [`Tool`] value; *calling* it is a handler the application
32//! layer owns, and binding a name to that handler is the registry's problem
33//! only in the sense that a missing name must be an error rather than a call
34//! into nothing. The visibility rule in [`super::visibility`] is the only thing
35//! in `domain/registry` that consults a session, and it does so through values
36//! it is handed rather than by reaching for one.
37
38use core::fmt;
39use std::sync::Arc;
40
41use crate::domain::Feature;
42use crate::domain::ir::FeatureSet;
43use crate::domain::ir::{Annotations, Extensions, JsonObject, ResourceIcon};
44use crate::domain::registry::auth::{Scope, ScopeSet};
45
46/// Which list a primitive belongs to.
47///
48/// The four lists are separate on the wire (`tools/list`, `resources/list`,
49/// `resources/templates/list`, `prompts/list`) and they are notified on
50/// separately, so the distinction is load-bearing rather than cosmetic: a tool
51/// being added must not make a session's prompt list appear to have changed.
52///
53/// `Ord` and `PartialOrd` are derived from the same declaration order, and
54/// `PrimitiveKind::ORDERING_AGREES` is the test that says so. The
55/// declaration order is a display order chosen once — tools, then resources,
56/// then templates, then prompts, matching the order the specification
57/// introduces them — and it is not the order any peer observes, because
58/// `Snapshot` keeps each list in registration order and this only orders the
59/// _kinds_ when a change set is rendered.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub enum PrimitiveKind {
62    /// `tools/list`, `tools/call`.
63    Tool,
64    /// `resources/list`, `resources/read`.
65    Resource,
66    /// `resources/templates/list`.
67    ResourceTemplate,
68    /// `prompts/list`, `prompts/get`.
69    Prompt,
70}
71
72impl PrimitiveKind {
73    /// Every kind, in declaration order.
74    pub const ALL: [Self; 4] = [
75        Self::Tool,
76        Self::Resource,
77        Self::ResourceTemplate,
78        Self::Prompt,
79    ];
80
81    /// The name used in a log line and in a change set.
82    #[must_use]
83    pub const fn as_str(self) -> &'static str {
84        match self {
85            Self::Tool => "tool",
86            Self::Resource => "resource",
87            Self::ResourceTemplate => "resource_template",
88            Self::Prompt => "prompt",
89        }
90    }
91
92    /// The feature a session needs before it may list this kind.
93    ///
94    /// So the listing methods' gates are written once. A table here rather than
95    /// at each call site is the point: `resources/list` and
96    /// `resources/templates/list` are separate methods in the spec, and a
97    /// second place naming which requires which feature is a second place to
98    /// forget it.
99    ///
100    /// `ResourceTemplate` maps to [`Feature::Resources`], not to a feature of
101    /// its own, because the specification's `resources` capability covers
102    /// templates too. A peer that can list resources can list their templates;
103    /// a separate feature would gate the two independently and let a session
104    /// hold the odd combination the spec has no way to express.
105    #[must_use]
106    pub const fn feature(self) -> Feature {
107        match self {
108            Self::Tool => Feature::Tools,
109            Self::Resource | Self::ResourceTemplate => Feature::Resources,
110            Self::Prompt => Feature::Prompts,
111        }
112    }
113}
114
115impl fmt::Display for PrimitiveKind {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.write_str(self.as_str())
118    }
119}
120
121/// A primitive's name, or a resource's URI.
122///
123/// One newtype for all four kinds rather than a `ToolId`, a `ResourceId` and so
124/// on, because they are looked up in one namespace-shaped map per kind and
125/// nothing in the registry ever compares a tool's name against a prompt's. Four
126/// newtypes would mean four identical `HashMap` key types and a conversion at
127/// every boundary, and would buy nothing: a `ToolId` cannot be passed where a
128/// prompt is expected, but neither can an `Arc<str>` that was obtained from a
129/// different table.
130///
131/// `Arc<str>` and deliberately not `Copy`, for the same reason
132/// [`RequestId`](crate::domain::RequestId) is: duplicating an identifier is
133/// meaningless, and a clone that shares the same bytes is the point.
134#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
135pub struct PrimitiveId(Arc<str>);
136
137impl PrimitiveId {
138    /// The identifier's text.
139    #[must_use]
140    pub fn as_str(&self) -> &str {
141        &self.0
142    }
143
144    /// The identifier's bytes, shared with every clone.
145    #[must_use]
146    pub fn into_arc(self) -> Arc<str> {
147        self.0
148    }
149}
150
151impl From<&str> for PrimitiveId {
152    fn from(text: &str) -> Self {
153        Self(Arc::from(text))
154    }
155}
156
157impl From<String> for PrimitiveId {
158    fn from(text: String) -> Self {
159        Self(Arc::from(text))
160    }
161}
162
163impl From<Arc<str>> for PrimitiveId {
164    fn from(text: Arc<str>) -> Self {
165        Self(text)
166    }
167}
168
169impl fmt::Display for PrimitiveId {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.write_str(&self.0)
172    }
173}
174
175/// What the registry must know about a primitive to decide who may see it.
176///
177/// Split from the primitive itself so the visibility predicate reads two small
178/// values instead of a variant, and so a caller that already has the metadata
179/// does not have to hold the whole tool to ask whether it is visible.
180///
181/// Deliberately not `Default`: a default would have to name one of the four
182/// kinds, and a metadata value that silently claims to be a tool is worse than
183/// one that does not exist.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub struct PrimitiveMeta {
186    /// Which list it belongs to.
187    kind: PrimitiveKind,
188    /// What a session must be able to do for this primitive to be expressible.
189    requires: FeatureSet,
190    /// What a principal must hold for this primitive to be visible.
191    scopes: ScopeSet,
192}
193
194impl PrimitiveMeta {
195    /// Metadata for a primitive that needs nothing: every session with any
196    /// principal sees it.
197    #[must_use]
198    pub const fn new(kind: PrimitiveKind) -> Self {
199        Self {
200            kind,
201            requires: FeatureSet::empty(),
202            scopes: ScopeSet::empty(),
203        }
204    }
205
206    /// The features a session must have.
207    #[must_use]
208    pub const fn requires(mut self, features: FeatureSet) -> Self {
209        self.requires = features;
210        self
211    }
212
213    /// The scopes a principal must hold.
214    #[must_use]
215    pub const fn scopes(mut self, scopes: ScopeSet) -> Self {
216        self.scopes = scopes;
217        self
218    }
219
220    /// Which list it belongs to.
221    #[must_use]
222    pub const fn kind(self) -> PrimitiveKind {
223        self.kind
224    }
225
226    /// What a session must be able to do.
227    #[must_use]
228    pub const fn required_features(self) -> FeatureSet {
229        self.requires
230    }
231
232    /// What a principal must hold.
233    #[must_use]
234    pub const fn required_scopes(self) -> ScopeSet {
235        self.scopes
236    }
237}
238
239/// A tool a client can call.
240///
241/// `name` and `inputSchema` are required by every revision including
242/// `2026-07-28`; everything else was added later and is `None` for a tool
243/// authored against `2024-11-05`.
244#[derive(Debug, Clone, PartialEq)]
245pub struct Tool {
246    /// `name`. Required by every revision.
247    pub name: PrimitiveId,
248    /// `title`. `2026-07-28`.
249    pub title: Option<String>,
250    /// `description`. Every revision.
251    pub description: Option<String>,
252    /// `inputSchema`. Required by every revision, and a JSON Schema object.
253    ///
254    /// Stored as the original text, validated once. See the module docs.
255    pub input_schema: JsonObject,
256    /// `outputSchema`. `2025-06-18`.
257    pub output_schema: Option<JsonObject>,
258    /// `annotations`. Typed in `2025-03-26`; untyped in nothing — the field
259    /// itself first appears there.
260    pub annotations: Option<ToolAnnotations>,
261    /// `icons`. `2026-07-28`.
262    pub icons: Option<Vec<ResourceIcon>>,
263    /// `_meta` and any field a later revision adds.
264    pub extensions: Extensions,
265}
266
267impl Tool {
268    /// A tool with a name and its input schema, and nothing else.
269    ///
270    /// # Errors
271    ///
272    /// Returns the [`JsonObject`](crate::domain::ir::JsonObjectError)
273    /// from parsing `input_schema`, which is the only fallible step: the schema
274    /// is validated here, once, so nothing downstream has to.
275    pub fn new(
276        name: impl Into<PrimitiveId>,
277        input_schema: &str,
278    ) -> Result<Self, crate::domain::ir::JsonObjectError> {
279        Ok(Self {
280            name: name.into(),
281            title: None,
282            description: None,
283            input_schema: JsonObject::new(input_schema)?,
284            output_schema: None,
285            annotations: None,
286            icons: None,
287            extensions: Extensions::empty(),
288        })
289    }
290
291    /// A human-readable description.
292    #[must_use]
293    pub fn described(mut self, description: impl Into<String>) -> Self {
294        self.description = Some(description.into());
295        self
296    }
297
298    /// An output schema, which makes the feature `ToolStructuredOutput`
299    /// (`2025-06-18`) a requirement of this tool.
300    ///
301    /// # Errors
302    ///
303    /// Propagates the parse failure for `schema`.
304    pub fn with_output_schema(
305        mut self,
306        schema: &str,
307    ) -> Result<Self, crate::domain::ir::JsonObjectError> {
308        self.output_schema = Some(JsonObject::new(schema)?);
309        Ok(self)
310    }
311}
312
313/// A tool's hints.
314///
315/// Every field here is a **hint**, not a guarantee: the schema says so
316/// directly, and says a client must never make a tool-use decision from an
317/// untrusted server's hints. Modelled because a registry that drops them loses
318/// data the specification defines.
319///
320/// Carrying hints does **not** raise a primitive's required features, and that
321/// is deliberate — see [`Primitive::meta`]. A peer on an older revision ignores
322/// hints it does not know and calls the tool all the same, so gating
323/// visibility on them would hide a working tool for no gain.
324#[derive(Debug, Clone, PartialEq, Eq, Default)]
325pub struct ToolAnnotations {
326    /// `title`.
327    pub title: Option<String>,
328    /// `readOnlyHint`. Absent means unknown, not false.
329    pub read_only_hint: Option<bool>,
330    /// `destructiveHint`.
331    pub destructive_hint: Option<bool>,
332    /// `idempotentHint`.
333    pub idempotent_hint: Option<bool>,
334    /// `openWorldHint`.
335    pub open_world_hint: Option<bool>,
336}
337
338/// A resource the server can read.
339///
340/// `name` and `uri` are required by every revision. A resource is keyed in the
341/// registry by its **uri**, not its name: `resources/read` takes a uri, and two
342/// resources may share a name.
343#[derive(Debug, Clone, PartialEq)]
344pub struct Resource {
345    /// `uri`. Required. Also the registry's key for this primitive.
346    pub uri: PrimitiveId,
347    /// `name`. Required.
348    pub name: String,
349    /// `title`. `2026-07-28`.
350    pub title: Option<String>,
351    /// `description`. Every revision.
352    pub description: Option<String>,
353    /// `mimeType`. Every revision.
354    pub mime_type: Option<String>,
355    /// `size`, in bytes, when known. `2026-07-28`.
356    pub size: Option<u64>,
357    /// `annotations`. Every revision, and the same shape as a content block's.
358    pub annotations: Option<Annotations>,
359    /// `icons`. `2026-07-28`.
360    pub icons: Option<Vec<ResourceIcon>>,
361    /// `_meta` and any field a later revision adds.
362    pub extensions: Extensions,
363}
364
365impl Resource {
366    /// A resource with a uri and a name, and nothing else.
367    #[must_use]
368    pub fn new(uri: impl Into<PrimitiveId>, name: impl Into<String>) -> Self {
369        Self {
370            uri: uri.into(),
371            name: name.into(),
372            title: None,
373            description: None,
374            mime_type: None,
375            size: None,
376            annotations: None,
377            icons: None,
378            extensions: Extensions::empty(),
379        }
380    }
381}
382
383/// A pattern that produces resources.
384///
385/// Separate from [`Resource`] because the two are listed separately
386/// (`resources/templates/list` against `resources/list`) and because a
387/// template's `uriTemplate` is not a uri: matching it against a request is
388/// template work, and storing one under the other's key would make a template
389/// look like a resource that happens to exist.
390#[derive(Debug, Clone, PartialEq)]
391pub struct ResourceTemplate {
392    /// `uriTemplate`. Required. The registry's key for this primitive.
393    pub uri_template: PrimitiveId,
394    /// `name`. Required.
395    pub name: String,
396    /// `title`. `2026-07-28`.
397    pub title: Option<String>,
398    /// `description`. Every revision.
399    pub description: Option<String>,
400    /// `mimeType`, for when every matching resource shares one.
401    pub mime_type: Option<String>,
402    /// `annotations`.
403    pub annotations: Option<Annotations>,
404    /// `icons`. `2026-07-28`.
405    pub icons: Option<Vec<ResourceIcon>>,
406    /// `_meta` and any field a later revision adds.
407    pub extensions: Extensions,
408}
409
410impl ResourceTemplate {
411    /// A template with a uri template and a name.
412    #[must_use]
413    pub fn new(uri_template: impl Into<PrimitiveId>, name: impl Into<String>) -> Self {
414        Self {
415            uri_template: uri_template.into(),
416            name: name.into(),
417            title: None,
418            description: None,
419            mime_type: None,
420            annotations: None,
421            icons: None,
422            extensions: Extensions::empty(),
423        }
424    }
425}
426
427/// An argument a prompt accepts.
428#[derive(Debug, Clone, PartialEq, Eq)]
429pub struct PromptArgument {
430    /// `name`.
431    pub name: String,
432    /// `description`.
433    pub description: Option<String>,
434    /// `title`. `2026-07-28`.
435    pub title: Option<String>,
436    /// `required`.
437    pub required: bool,
438}
439
440/// A prompt or prompt template the server offers.
441///
442/// Only `name` is required, and that is true in every revision — a prompt with
443/// no arguments and no description is a legal document.
444#[derive(Debug, Clone, PartialEq)]
445pub struct Prompt {
446    /// `name`. Required. The registry's key for this primitive.
447    pub name: PrimitiveId,
448    /// `title`. `2026-07-28`.
449    pub title: Option<String>,
450    /// `description`. Every revision.
451    pub description: Option<String>,
452    /// `arguments`. Every revision.
453    pub arguments: Vec<PromptArgument>,
454    /// `icons`. `2026-07-28`.
455    pub icons: Option<Vec<ResourceIcon>>,
456    /// `_meta` and any field a later revision adds.
457    pub extensions: Extensions,
458}
459
460impl Prompt {
461    /// A prompt with a name and no arguments.
462    #[must_use]
463    pub fn new(name: impl Into<PrimitiveId>) -> Self {
464        Self {
465            name: name.into(),
466            title: None,
467            description: None,
468            arguments: Vec::new(),
469            icons: None,
470            extensions: Extensions::empty(),
471        }
472    }
473
474    /// An argument this prompt accepts.
475    #[must_use]
476    pub fn with_argument(mut self, name: impl Into<String>, required: bool) -> Self {
477        self.arguments.push(PromptArgument {
478            name: name.into(),
479            description: None,
480            title: None,
481            required,
482        });
483        self
484    }
485}
486
487/// Any primitive, tagged with its kind.
488///
489/// The four payloads stay separate types rather than collapsing into one
490/// struct with fourteen `Option` fields, because the fields that matter are not
491/// shared: `Tool` has an input schema and no uri, `Resource` has a uri and no
492/// input schema, and a single flat struct would let a resource be built with
493/// an input schema and no way to say whether that was a mistake.
494#[derive(Debug, Clone, PartialEq)]
495pub enum Primitive {
496    /// A tool.
497    Tool(Tool),
498    /// A resource.
499    Resource(Resource),
500    /// A resource template.
501    ResourceTemplate(ResourceTemplate),
502    /// A prompt.
503    Prompt(Prompt),
504}
505
506impl Primitive {
507    /// Which list this belongs to.
508    #[must_use]
509    pub const fn kind(&self) -> PrimitiveKind {
510        match self {
511            Self::Tool(_) => PrimitiveKind::Tool,
512            Self::Resource(_) => PrimitiveKind::Resource,
513            Self::ResourceTemplate(_) => PrimitiveKind::ResourceTemplate,
514            Self::Prompt(_) => PrimitiveKind::Prompt,
515        }
516    }
517
518    /// The key this primitive is filed under.
519    #[must_use]
520    pub const fn id(&self) -> &PrimitiveId {
521        match self {
522            Self::Tool(tool) => &tool.name,
523            Self::Resource(resource) => &resource.uri,
524            Self::ResourceTemplate(template) => &template.uri_template,
525            Self::Prompt(prompt) => &prompt.name,
526        }
527    }
528
529    /// The metadata a visibility decision is made from.
530    ///
531    /// Derived from the payload rather than stored beside it, so the two
532    /// cannot disagree — a separate `meta` field would be a second source of
533    /// truth for a thing the payload already determines.
534    ///
535    /// # What derives a requirement, and what does not
536    ///
537    /// A requirement is raised when a session **cannot correctly use the
538    /// primitive** without the feature. That is a narrower test than "the
539    /// primitive has a field the revision does not define", and the difference
540    /// decides whether a feature is a gate or a filter.
541    ///
542    /// A tool's `outputSchema` is a gate. A tool that declares structured
543    /// output answers with a `structuredContent` member, and a session that
544    /// cannot represent it does not get a text fallback from the registry — it
545    /// gets a result whose shape it will misread. This is the same
546    /// `StructuredContentToText` decision the lowering path already made, and
547    /// the registry has to agree with it or the two disagree about the same
548    /// tool on the same session.
549    ///
550    /// Annotations, icons, `title` and `size` are **not** gates. An older peer
551    /// ignores a hint it does not recognise and the tool still works, which is
552    /// the same reason [`Annotations`] does not raise
553    /// `required_features` on a content block. Requiring a feature for
554    /// decoration would hide a perfectly callable tool from every session on an
555    /// older revision, and the hiding is silent — the peer simply never sees
556    /// the tool.
557    #[must_use]
558    pub fn meta(&self) -> PrimitiveMeta {
559        let mut meta = PrimitiveMeta::new(self.kind());
560        if let Self::Tool(tool) = self
561            && tool.output_schema.is_some()
562        {
563            meta = meta.requires(FeatureSet::empty().with(Feature::ToolStructuredOutput));
564        }
565        meta
566    }
567}
568
569/// A primitive as registered: the payload, plus the policy a visibility
570/// decision is made from.
571///
572/// # Why the two are one value
573///
574/// [`PrimitiveMeta`] is *derived* from the payload — that is what stops a tool
575/// carrying an `outputSchema` from being filed as needing nothing — but two of
576/// the three inputs to a visibility decision are not derivable. A tool's
577/// required scopes are a statement by whoever registered it, not a fact about
578/// its schema, and a resource's required features depend on the revision it is
579/// for.
580///
581/// So the snapshot stores a `Registered`, not a `Primitive`. One value means
582/// the listing and the lookup cannot return a payload with one policy and a
583/// neighbour with another, and a `PrimitiveMeta` is never handed to
584/// [`is_visible`](super::is_visible) that was not stored beside the payload it
585/// describes.
586///
587/// The derived half and the declared half are merged once, at construction.
588/// From then on there is a single set of requirements per primitive, and
589/// `requires` adds to the derived set rather than replacing it.
590#[derive(Debug, Clone, PartialEq)]
591pub struct Registered {
592    primitive: Primitive,
593    policy: PrimitiveMeta,
594}
595
596impl Registered {
597    /// Registers `primitive` with no requirements beyond the derived ones.
598    #[must_use]
599    pub fn new(primitive: Primitive) -> Self {
600        let policy = primitive.meta();
601        Self { primitive, policy }
602    }
603
604    /// The payload.
605    #[must_use]
606    pub const fn primitive(&self) -> &Primitive {
607        &self.primitive
608    }
609
610    /// The key this is filed under.
611    #[must_use]
612    pub const fn id(&self) -> &PrimitiveId {
613        self.primitive.id()
614    }
615
616    /// Which list this belongs to.
617    #[must_use]
618    pub const fn kind(&self) -> PrimitiveKind {
619        self.primitive.kind()
620    }
621
622    /// The requirements a visibility decision is made from.
623    #[must_use]
624    pub const fn meta(&self) -> &PrimitiveMeta {
625        &self.policy
626    }
627
628    /// Adds to the required features rather than replacing them.
629    #[must_use]
630    pub const fn requiring(mut self, features: FeatureSet) -> Self {
631        self.policy = self
632            .policy
633            .requires(self.policy.required_features().union(features));
634        self
635    }
636
637    /// Adds to the required scopes rather than replacing them.
638    #[must_use]
639    pub const fn requiring_scope(mut self, scope: Scope) -> Self {
640        self.policy = self
641            .policy
642            .scopes(self.policy.required_scopes().with(scope));
643        self
644    }
645}
646
647impl From<Primitive> for Registered {
648    fn from(primitive: Primitive) -> Self {
649        Self::new(primitive)
650    }
651}
652
653impl From<Tool> for Primitive {
654    fn from(tool: Tool) -> Self {
655        Self::Tool(tool)
656    }
657}
658impl From<Resource> for Primitive {
659    fn from(resource: Resource) -> Self {
660        Self::Resource(resource)
661    }
662}
663
664impl From<ResourceTemplate> for Primitive {
665    fn from(template: ResourceTemplate) -> Self {
666        Self::ResourceTemplate(template)
667    }
668}
669
670impl From<Prompt> for Primitive {
671    fn from(prompt: Prompt) -> Self {
672        Self::Prompt(prompt)
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use core::cmp::Ordering;
679    use std::sync::Arc;
680
681    use super::{
682        Primitive, PrimitiveId, PrimitiveKind, PrimitiveMeta, Prompt, Resource, ResourceTemplate,
683        Tool,
684    };
685    use crate::domain::Feature;
686    use crate::domain::ir::FeatureSet;
687    use crate::domain::ir::JsonObjectError;
688    use crate::domain::registry::auth::{Scope, ScopeSet};
689
690    /// The identifier a primitive is filed under is the field its own list
691    /// method looks it up by: a resource by uri, everything else by name. Two
692    /// resources sharing a name is legal, so keying resources by name would
693    /// silently drop one of them.
694    #[test]
695    fn the_key_is_the_field_the_list_method_uses() -> Result<(), JsonObjectError> {
696        let cases = [
697            (
698                Primitive::from(Tool::new("t", "{}")?),
699                PrimitiveKind::Tool,
700                "t",
701            ),
702            (
703                Primitive::from(Resource::new("file:///a", "a")),
704                PrimitiveKind::Resource,
705                "file:///a",
706            ),
707            (
708                Primitive::from(ResourceTemplate::new("file:///{id}", "by-id")),
709                PrimitiveKind::ResourceTemplate,
710                "file:///{id}",
711            ),
712            (
713                Primitive::from(Prompt::new("greet")),
714                PrimitiveKind::Prompt,
715                "greet",
716            ),
717        ];
718        for (primitive, kind, key) in cases {
719            assert_eq!(primitive.kind(), kind);
720            assert_eq!(primitive.id().as_str(), key);
721        }
722        Ok(())
723    }
724
725    /// `Ord` and `PartialOrd` derived from one declaration, which the 1.98
726    /// derive fast path assumes. A test inside a single module cannot catch a
727    /// disagreement between the two derives, because both come from the same
728    /// source; what it can catch is a later edit that derives one and hand-writes
729    /// the other.
730    #[test]
731    fn ordering_agrees_in_both_directions() {
732        for a in PrimitiveKind::ALL {
733            for b in PrimitiveKind::ALL {
734                let partial = a.partial_cmp(&b);
735                assert_eq!(partial, Some(a.cmp(&b)), "{a} against {b}");
736                assert_eq!(a < b, partial == Some(Ordering::Less), "{a} against {b}");
737                assert_eq!(a > b, partial == Some(Ordering::Greater), "{a} against {b}");
738            }
739        }
740    }
741
742    /// The declaration order is the order the specification introduces the
743    /// lists, and it is the order a change set renders its kinds in. Pinning it
744    /// means a reordering that changes a notification's rendered form shows up
745    /// as a test failure rather than as a diff in a log.
746    #[test]
747    fn the_kind_order_is_the_specification_order() {
748        assert_eq!(
749            PrimitiveKind::ALL,
750            [
751                PrimitiveKind::Tool,
752                PrimitiveKind::Resource,
753                PrimitiveKind::ResourceTemplate,
754                PrimitiveKind::Prompt,
755            ]
756        );
757    }
758
759    /// A tool's metadata is derived from its payload. A tool carrying an output
760    /// schema needs `ToolStructuredOutput`, and one that does not must not
761    /// claim to — the gate reads this, so getting it wrong hides or exposes a
762    /// tool on a session that cannot express it.
763    #[test]
764    fn metadata_follows_the_payload() -> Result<(), JsonObjectError> {
765        let plain = Tool::new("t", "{}")?;
766        assert!(Primitive::from(plain).meta().required_features().is_empty());
767
768        let structured = Tool::new("t", "{}")?.with_output_schema(r#"{"type":"object"}"#)?;
769        let required = Primitive::from(structured).meta().required_features();
770        assert!(required.contains(Feature::ToolStructuredOutput));
771        Ok(())
772    }
773
774    #[test]
775    fn scopes_are_carried_by_the_metadata_not_the_payload() {
776        let meta = PrimitiveMeta::new(PrimitiveKind::Tool)
777            .scopes(ScopeSet::empty().with(Scope::Write))
778            .requires(FeatureSet::empty().with(Feature::Tools));
779        assert!(meta.required_scopes().contains(Scope::Write));
780        assert!(meta.required_features().contains(Feature::Tools));
781        assert_eq!(meta.kind(), PrimitiveKind::Tool);
782    }
783
784    /// Metadata that states no rule requires nothing, so the primitive is
785    /// visible to everyone — the one "allow" case, expressed as an empty
786    /// subset rather than as a branch.
787    #[test]
788    fn metadata_without_a_rule_requires_nothing() {
789        let meta = PrimitiveMeta::new(PrimitiveKind::Resource);
790        assert!(meta.required_features().is_empty());
791        assert!(meta.required_scopes().holds(ScopeSet::empty()));
792    }
793
794    /// An `Arc<str>` identifier shares its bytes across clones, which is why
795    /// the type is not `Copy`.
796    #[test]
797    fn identifiers_share_their_bytes() {
798        let id = PrimitiveId::from("tools/call");
799        let clone = id.clone();
800        assert_eq!(id, clone);
801        assert!(Arc::ptr_eq(&id.clone().into_arc(), &clone.into_arc()));
802        assert_eq!(id.to_string(), "tools/call");
803    }
804
805    /// The tool constructors are the only fallible thing here, and they fail on
806    /// exactly the input a schema check should reject.
807    #[test]
808    fn a_non_object_schema_is_refused_at_the_boundary() {
809        assert!(Tool::new("t", "[]").is_err());
810        assert!(Tool::new("t", "{oops").is_err());
811        assert!(
812            Tool::new("t", "{}")
813                .and_then(|t| t.with_output_schema("[]"))
814                .is_err()
815        );
816    }
817}