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}