protocol_droid/application/server/builder.rs
1//! [`Server`] — the builder a user of this crate actually touches.
2//!
3//! # What it is
4//!
5//! A server is a [`RegistryStore`], a `Vec` of tool handlers, and the
6//! negotiation inputs a connection will need. It is a value, not a running
7//! process: nothing here binds a port or spawns a task. T13 and T14 supply the
8//! transports that take one of these and give it somewhere to run, and until
9//! then a test can drive it in-process.
10//!
11//! # Why a builder and not a `new`
12//!
13//! A server needs a name before it needs anything else, and most of what
14//! follows is optional. A `Server::new(name, tools, resources, prompts)` is
15//! four positional arguments in an order nobody can hold in their head, and it
16//! is wrong in five of the six shapes a caller wants. The builder makes the
17//! order free and names each thing at the call site.
18
19use std::num::NonZeroUsize;
20use std::sync::Arc;
21
22use crate::adapters::secondary::idempotency::InMemoryIdempotencyStore;
23use crate::application::idempotency::IdempotencyStore;
24use crate::application::mcp::input_required::StateKey;
25use crate::application::registry::store::RegistryStore;
26use crate::application::server::context::RequestContext;
27use crate::application::server::live::{Handlers, Live, ServerHandle, ToolEntry};
28use crate::domain::idempotency::IdempotencyError;
29use crate::domain::ir::{IrErrorCode, JsonObject};
30use crate::domain::registry::{
31 Primitive, PrimitiveId, PrimitiveKind, Prompt, Registered, Resource, ResourceTemplate, Tool,
32};
33
34/// A tool handler, and what it answers with.
35///
36/// The boxed form rather than a generic parameter, deliberately: a server holds
37/// a heterogeneous set of these behind one name, so there is nothing to
38/// monomorphise over. The alternative — a generic `Server<H>` — buys nothing
39/// here and makes the struct unnameable in a field.
40///
41/// The [`Send`] bound is load-bearing. A handler runs on the runtime, and a
42/// handler that is not `Send` cannot be sent to it.
43pub type ToolHandler = Arc<dyn Fn(RequestContext, JsonObject) -> BoxFuture + Send + Sync>;
44
45/// The future a [`ToolHandler`] returns.
46///
47/// Its own alias because spelling `Pin<Box<dyn Future<...> + Send>>` at every
48/// call site is how one of them ends up without the `Send`.
49pub type BoxFuture = std::pin::Pin<Box<dyn Future<Output = JsonObject> + Send>>;
50
51/// The future a [`ResourceHandler`] or [`PromptHandler`] returns.
52///
53/// Fallsible where [`BoxFuture`] is not, because a read is allowed to fail for
54/// a reason the peer should see as a refusal: a resource whose backing store
55/// is unreachable is not an empty resource.
56pub type BoxReadFuture =
57 std::pin::Pin<Box<dyn Future<Output = Result<JsonObject, DispatchError>> + Send>>;
58
59/// Serves one `resources/read`, given the URI the peer asked for.
60///
61/// Takes the URI rather than closing over it, because the URI is the request.
62/// A handler that closed over its own registration would have to be one per
63/// resource, and the point of the lookup table is that the dispatcher finds the
64/// right one from the request the peer actually sent.
65///
66/// Takes the URI by value rather than by reference. A reference would put a
67/// lifetime on the handler type, and a closure whose signature carries one
68/// cannot be inferred as higher-ranked — so the coercion to this type fails
69/// with a lifetime error aimed at the `#[server]` attribute. The dispatcher
70/// already owns the string it parsed out of the request, so this costs one
71/// clone and buys a handler type with no lifetimes in it at all.
72pub type ResourceHandler = Arc<dyn Fn(RequestContext, String) -> BoxReadFuture + Send + Sync>;
73
74/// Serves one `prompts/get`, given the `arguments` the peer sent.
75///
76/// Takes the whole argument object rather than a decoded one: prompt arguments
77/// are free-form by revision — a template like `{diff}` or `{focus}` is the
78/// specification's own idiom — so there is no fixed struct to decode into
79/// without turning a user's template into this crate's invention.
80/// Owned arguments, for the same reason as [`ResourceHandler`].
81pub type PromptHandler = Arc<dyn Fn(RequestContext, JsonObject) -> BoxReadFuture + Send + Sync>;
82
83/// A server, built.
84///
85/// # Examples
86///
87/// ```
88/// use std::sync::Arc;
89///
90/// use protocol_droid::application::server::Server;
91/// use protocol_droid::domain::ir::JsonObject;
92///
93/// let server = Server::builder("demo")
94/// .simple_tool(
95/// "echo",
96/// r#"{"type":"object","properties":{"text":{"type":"string"}}}"#,
97/// Arc::new(|_ctx, args| {
98/// Box::pin(async move {
99/// // A handler answers with the tool's result object, not with
100/// // a `Result`: MCP reports a tool failure as a successful
101/// // call carrying `isError: true`, so "the tool failed" is
102/// // something the handler *returns* rather than propagates.
103///
104/// // The result is built from a `serde_json::Value` so the
105/// // peer's text is escaped by the serialiser. Hand-writing
106/// // the document would make injection a matter of how many
107/// // quotes a caller remembered.
108/// let echoed = args.as_str().to_string();
109/// let result = JsonObject::from_value(serde_json::json!({
110/// "content": [{ "type": "text", "text": echoed }],
111/// }));
112/// result.unwrap_or_default()
113/// })
114/// }),
115/// )
116/// .expect("the schema above is valid")
117/// .build();
118///
119/// assert_eq!(server.tool_count(), 1);
120/// assert_eq!(server.name(), "demo");
121/// assert!(server.handler("echo").is_some());
122/// assert!(server.handler("missing").is_none());
123/// ```
124// Manual `Debug` rather than derived, for the reason `Dispatcher` has one: the
125// store is a `dyn` port with no readable state, so the derived form would
126// print `idempotency: <unknown>` on every line of a post-mortem and say
127// nothing. What a reader wants is the name, the tool count, and whether a
128// store was ever supplied — the third because a server still on the default
129// has an in-memory bound, and that is the question to ask first when a
130// recorded result has gone missing across a restart.
131pub struct Server {
132 name: String,
133 version: String,
134 /// The registry, the handler table, and who is told when they change —
135 /// shared with every [`ServerHandle`] so a change made while serving lands
136 /// in the same place requests read from.
137 live: Arc<Live>,
138 /// Deduplicates `tools/call` by request.
139 ///
140 /// A trait object rather than a concrete store because a caller serving
141 /// over HTTP wants a bound that outlives one process — a retried call may
142 /// arrive at a different replica — and a concrete `InMemoryIdempotencyStore`
143 /// in this field would be the kind of type that compiles everywhere and is
144 /// only correct on the one host. The default is the in-memory store, which
145 /// is correct for a single process and honest about being so.
146 idempotency: Arc<dyn IdempotencyStore<Result = JsonObject>>,
147 /// The type-keyed state handlers read through
148 /// [`RequestContext::state`](crate::application::server::context::RequestContext::state).
149 ///
150 /// `Arc` so a `Server` is cheap to clone into a `Dispatcher`, and
151 /// the dispatcher is what the transport's per-request handler
152 /// actually reaches into. An empty map is the default; a server
153 /// that never called [`ServerBuilder::with_state`] gets an empty
154 /// map and `state::<T>()` returns `None` for every `T`.
155 state: Arc<crate::application::server::context::StateMap>,
156 /// What SEP-2322 `requestState` is sealed under. See
157 /// [`Server::request_state_key`].
158 state_key: Option<StateKey>,
159}
160
161impl core::fmt::Debug for Server {
162 /// Prints the name, the version, and the tool count.
163 ///
164 /// Not the store, and the omission is deliberate: a built server always
165 /// has one, so printing that would say nothing. Whether a store was
166 /// *supplied* is a question about the builder, and the builder's own
167 /// `Debug` is where it is answerable.
168 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
169 f.debug_struct("Server")
170 .field("name", &self.name)
171 .field("version", &self.version)
172 .field("tool_count", &self.tool_count())
173 .finish_non_exhaustive()
174 }
175}
176
177impl Server {
178 /// Serves this server on stdin and stdout until the peer closes stdin.
179 ///
180 /// The shortest complete serving path in the crate, and the one the
181 /// documentation elsewhere assumes exists. The revision is settled from the
182 /// peer's own `initialize`, so this neither takes a version nor forces the
183 /// caller to pick one — which is the whole reason the crate exists.
184 ///
185 /// The caller does not have to know that a [`Dispatcher`] and a
186 /// [`PeerSession`] are involved, or that the session's profile is resolved
187 /// before the loop starts. Both stay reachable: this is a façade over the
188 /// types above, not a replacement for them.
189 ///
190 /// [`Dispatcher`]: crate::application::server::dispatch::Dispatcher
191 /// [`PeerSession`]: crate::application::server::context::PeerSession
192 ///
193 /// # Errors
194 ///
195 /// Propagates a [`ServeError`] for a read, write, or diagnostics failure.
196 /// A protocol error is not one of those: a peer whose frame this server
197 /// cannot serve is answered on the wire rather than raised here.
198 ///
199 /// [`ServeError`]: crate::adapters::primary::stdio::ServeError
200 ///
201 /// # Examples
202 ///
203 /// ```no_run
204 /// # use protocol_droid::application::server::Server;
205 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
206 /// let server = Server::builder("greeter").build();
207 /// let report = server.serve_stdio().await?;
208 /// eprintln!("{report:?}");
209 /// # Ok(())
210 /// # }
211 /// ```
212 #[cfg(feature = "stdio")]
213 pub async fn serve_stdio(
214 self,
215 ) -> Result<
216 crate::adapters::primary::stdio::ServeReport,
217 crate::adapters::primary::stdio::ServeError,
218 > {
219 let mut loop_ = crate::application::server::serve::StdioBuilder::new(self).build();
220 loop_.serve().await
221 }
222
223 /// Serves this server over Streamable HTTP, on loopback, until it stops.
224 ///
225 /// The same one-call shape as [`serve_stdio`](Self::serve_stdio), for the
226 /// same reason. The alternative — building an `McpRouter`, an
227 /// `axum::Router`, and a listener — moves the work onto the consumer
228 /// rather than removing it, and the pieces a consumer would have to reach
229 /// for are a `SessionStore` with no production `IdSource` in it and a
230 /// `RequestHandler` with no implementation for this `Dispatcher` anywhere
231 /// but the tests.
232 ///
233 /// Binds `127.0.0.1:3000` and serves plain HTTP. That is a development
234 /// shape, not a deployment one: a non-loopback address and TLS in front of
235 /// it are the caller's to arrange, and [`HttpBuilder`] is how.
236 ///
237 /// # Errors
238 ///
239 /// [`ServeError`] from building the route table, from binding, or from the
240 /// accept loop. A protocol error is not one of those: a peer whose request
241 /// this server cannot serve is answered on the wire rather than raised
242 /// here.
243 ///
244 /// # Examples
245 ///
246 /// ```no_run
247 /// # use protocol_droid::application::server::Server;
248 /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
249 /// let server = Server::builder("greeter").build();
250 /// let report = server.serve_http().await?;
251 /// eprintln!("listening on {}", report.addr);
252 /// # Ok(())
253 /// # }
254 /// ```
255 ///
256 /// [`HttpBuilder`]: crate::adapters::primary::transport::http::HttpBuilder
257 /// [`ServeError`]: crate::adapters::primary::transport::http::ServeError
258 #[cfg(feature = "http")]
259 pub async fn serve_http(
260 self,
261 ) -> Result<
262 crate::adapters::primary::transport::http::ServeReport,
263 crate::adapters::primary::transport::http::ServeError,
264 > {
265 crate::adapters::primary::transport::http::HttpBuilder::new(self)
266 .serve()
267 .await
268 }
269
270 /// Starts a server with a name and nothing else.
271 ///
272 /// Every method on the returned [`ServerBuilder`] is optional, so this
273 /// alone is a valid — if useless — server. That is the honest minimum: a
274 /// server with no tools is a real state, not a mistake to prevent at the
275 /// type level.
276 #[must_use]
277 pub fn builder(name: impl Into<String>) -> ServerBuilder {
278 ServerBuilder {
279 name: name.into(),
280 version: env!("CARGO_PKG_VERSION").to_string(),
281 registry: RegistryStore::new(),
282 tools: Vec::new(),
283 resource_reads: Vec::new(),
284 prompt_gets: Vec::new(),
285 idempotency: None,
286 state: Vec::new(),
287 state_key: None,
288 }
289 }
290
291 /// The name this server was built with.
292 #[must_use]
293 pub fn name(&self) -> &str {
294 &self.name
295 }
296
297 /// The version this server reports in `serverInfo`.
298 ///
299 /// Free-form on the wire — the schema types it as a plain `string` — so
300 /// nothing constrains the format, only that a peer can read it. It lives
301 /// beside [`name`](Self::name) because the two are the same fact: they are
302 /// the two fields of the `serverInfo` object, they are both about who this
303 /// server is rather than what it does, and splitting them would mean a
304 /// server whose `name` came from the builder and whose `version` came from
305 /// somewhere else.
306 #[must_use]
307 pub fn version(&self) -> &str {
308 &self.version
309 }
310
311 /// How many tools are registered, hidden or not.
312 #[must_use]
313 pub fn tool_count(&self) -> usize {
314 self.live.tool_count()
315 }
316
317 /// The key `requestState` is sealed under, if one was set.
318 ///
319 /// Set by [`ServerBuilder::request_state_key`], or filled from the OS
320 /// random source by a transport when the server is served without one. A
321 /// server with no key can still answer every request; it cannot hand out a
322 /// `requestState`, and a retry that carries one is refused as tampered,
323 /// because there is nothing to verify it against.
324 #[must_use]
325 pub const fn request_state_key(&self) -> Option<&StateKey> {
326 self.state_key.as_ref()
327 }
328
329 /// Installs `key` if the server has none, for a transport that is about to
330 /// serve it.
331 pub fn ensure_request_state_key(&mut self, key: impl FnOnce() -> Option<StateKey>) {
332 if self.state_key.is_none() {
333 self.state_key = key();
334 }
335 }
336
337 /// A handle that adds and removes tools, resources and prompts while this
338 /// server is serving, announcing each change to the sessions that
339 /// negotiated the matching `listChanged` capability.
340 #[must_use]
341 pub fn handle(&self) -> ServerHandle {
342 ServerHandle::new(Arc::clone(&self.live))
343 }
344
345 /// The store `tools/call` is deduplicated through.
346 ///
347 /// Exposed so a transport can answer "have you seen this id before?"
348 /// without running the tool, which is what a resuming peer wants: it has
349 /// dropped a connection and wants to know whether the call it never saw
350 /// answered actually happened.
351 #[must_use]
352 pub const fn idempotency(&self) -> &Arc<dyn IdempotencyStore<Result = JsonObject>> {
353 &self.idempotency
354 }
355
356 /// Registers a state value after the server is built.
357 ///
358 /// The builder API is `ServerBuilder::with_state`; this method
359 /// exists for callers that built a server through a path that did
360 /// not surface the builder (the `#[server]` macro's
361 /// `into_server` is the canonical one). Each call appends to
362 /// the existing map, so a server that already had state for one
363 /// type can grow state for a second.
364 ///
365 /// `Send + Sync + 'static` is what every other port in this crate
366 /// asks for; a pool of non-`Send` handles cannot live in this
367 /// map.
368 pub fn with_state<T: Send + Sync + 'static>(&mut self, value: T) {
369 Arc::make_mut(&mut self.state).insert(
370 std::any::TypeId::of::<T>(),
371 Arc::new(value) as Arc<dyn std::any::Any + Send + Sync>,
372 );
373 }
374
375 /// Adds a tool to a server that was already built.
376 ///
377 /// [`ServerBuilder::tool`] is the builder API and takes precedence. This
378 /// exists for the same reason [`with_state`](Self::with_state) does: a
379 /// caller who went through `#[server]`'s `into_server` never saw a
380 /// builder, and until this there was nothing they could do about it.
381 ///
382 /// It is not a convenience for schemas a Rust type can derive. `#[tool]`
383 /// builds `inputSchema` through `schemars` from the handler's parameters,
384 /// and `schemars` cannot express what MCP asks a tool to be able to say:
385 /// `$schema`, `$defs` with an `$anchor`, `allOf`/`anyOf`, or `if`/`then`/
386 /// `else`. Those are SEP-1613 and SEP-2106 — a tool whose schema another
387 /// toolchain generated and shipped as JSON has exactly one way to reach a
388 /// peer through this crate, and before this method it had none through
389 /// the front door.
390 ///
391 /// Registering and publishing together, as on the builder: a tool in
392 /// `tools/list` with no handler is one a client can see and not call, and
393 /// a handler with no entry is one that never runs. There is no state in
394 /// between for a caller to be wrong in.
395 ///
396 /// It announces nothing: `&mut self` means nothing can be connected yet.
397 /// To change a server that is already serving, use [`Self::handle`].
398 ///
399 /// Infallible, unlike [`Tool::new`]: a [`Tool`] has already had its
400 /// `inputSchema` parsed and validated, so there is no second parse here to
401 /// fail.
402 pub fn add_tool(&mut self, tool: Tool, handler: ToolHandler) {
403 let name = tool.name.clone();
404 let _published = self
405 .live
406 .add(Registered::from(Primitive::from(tool)), |table| {
407 table.tools.retain(|entry| entry.name != name);
408 table.tools.push(ToolEntry { name, handler });
409 });
410 }
411
412 /// The state map handlers read through
413 /// [`RequestContext::state`](crate::application::server::context::RequestContext::state).
414 ///
415 /// A transport hands this to every [`RequestContext`]
416 /// it builds. A handler that never calls `with_state` on the
417 /// builder gets an empty map here, and `state::<T>()` returns
418 /// `None` for every `T`.
419 #[must_use]
420 pub fn state(&self) -> &Arc<crate::application::server::context::StateMap> {
421 &self.state
422 }
423
424 /// The registry, for the transport that serves it.
425 ///
426 /// Takes `&self` and hands out an owned snapshot later, so a handler
427 /// publishing a new tool does not need the server borrowed mutably while
428 /// a request is in flight.
429 #[must_use]
430 pub fn registry(&self) -> &RegistryStore {
431 self.live.registry()
432 }
433
434 /// The handler for `name`, if there is one.
435 ///
436 /// Owned, because the table it came from can be replaced the moment this
437 /// returns: a handle may remove the tool while the caller still holds it.
438 #[must_use]
439 pub fn handler(&self, name: &str) -> Option<ToolHandler> {
440 self.live.tool(name)
441 }
442
443 /// The reader for `uri`, if one is registered.
444 ///
445 /// `None` for a resource that is listed but has no reader, which is a
446 /// state [`ServerBuilder::resource_with`] cannot produce — registration
447 /// takes both together — and which therefore means the URI is simply not
448 /// this server's.
449 #[must_use]
450 pub fn reader(&self, uri: &str) -> Option<ResourceHandler> {
451 self.live.reader(uri)
452 }
453
454 /// The getter for `name`, if one is registered.
455 ///
456 /// `None` under the same reasoning as [`reader`](Self::reader).
457 #[must_use]
458 pub fn getter(&self, name: &str) -> Option<PromptHandler> {
459 self.live.getter(name)
460 }
461
462 /// Finds `name` and runs it.
463 ///
464 /// The lookup and the call together rather than a `handler()` the caller
465 /// has to remember to use: a transport and an in-process test that each
466 /// wrote their own lookup would drift, and the drift would show up as a
467 /// tool that is in the registry and not callable.
468 ///
469 /// Visibility is decided by the **registry**, and only by the registry. It
470 /// used to be a second, cheaper check over a cached `required` set held by
471 /// each tool entry — which covered features and not scopes. So a
472 /// scope-restricted tool was absent from `tools/list` and still ran on
473 /// `tools/call`, which is the existence oracle
474 /// [`UnknownTool`](DispatchError::UnknownTool) exists to prevent.
475 ///
476 /// The cached set is gone rather than kept as a belt-and-braces second
477 /// check. Once the registry gate runs first, that check can only ever
478 /// agree with it, so it decides nothing — and a check that decides nothing
479 /// is a check that can drift into deciding the wrong thing without anyone
480 /// noticing. One predicate for the whole surface is the property worth
481 /// having.
482 ///
483 /// # Errors
484 ///
485 /// [`DispatchError::UnknownTool`] when no tool of that name is registered
486 /// **or** when this session may not see it — because it requires a
487 /// feature the profile lacks, or a scope the caller does not hold. The two
488 /// are deliberately the same answer: a different one for each would let a
489 /// caller read the existence of a tool it is not allowed to know about.
490 /// The server still logs which it was.
491 pub async fn call(
492 &self,
493 ctx: &RequestContext,
494 name: &str,
495 args: JsonObject,
496 ) -> Result<JsonObject, DispatchError> {
497 let handler = self.handler(name).ok_or(DispatchError::UnknownTool)?;
498
499 // The gate, once, for both halves of the surface. After this the tool
500 // is known to exist *and* to be visible to this session, so the
501 // handler is reached with no further checking to disagree with.
502 if self
503 .registry()
504 .get_visible(
505 ctx.peer().auth(),
506 &ctx.peer().profile(),
507 PrimitiveKind::Tool,
508 name,
509 )
510 .is_none()
511 {
512 return Err(DispatchError::UnknownTool);
513 }
514
515 Ok(handler(ctx.clone(), args).await)
516 }
517}
518
519/// Why a tool call did not run.
520#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
521pub enum DispatchError {
522 /// No tool of that name is registered.
523 ///
524 /// Deliberately the same answer for a tool that exists but is hidden from
525 /// this session: the difference would let a client probe for the existence
526 /// of a tool it may not see. The server still logs which it was.
527 #[error("no such tool")]
528 UnknownTool,
529
530 /// The tool is registered, but the session did not negotiate the features
531 /// it requires.
532 #[error("the session did not negotiate what this tool requires")]
533 NotCallable,
534
535 /// No method of that name exists.
536 ///
537 /// Refused on the method, not on a tool, because the caller named a method
538 /// and there is no tool to say anything about.
539 #[error("no such method")]
540 UnknownMethod,
541 /// No resource of that URI is registered, or it is listed without a
542 /// reader.
543 ///
544 /// Two causes share one answer because a peer cannot tell them apart: a
545 /// listing is a promise, so a resource that appears in `resources/list`
546 /// and then refuses to be read is the same failure as one that was never
547 /// there, and naming which of the two happened tells the peer nothing it
548 /// can act on.
549 #[error("no such resource")]
550 UnknownResource,
551 /// No prompt of that name is registered, or it is listed without a getter.
552 #[error("no such prompt")]
553 UnknownPrompt,
554 /// A registered reader or getter refused, naming why.
555 ///
556 /// Carries the handler's own message rather than a category, because the
557 /// peer is a client and the only thing it can do with a category is show it
558 /// to a user who would rather read what went wrong.
559 #[error("the handler refused: {0}")]
560 HandlerFailed(String),
561
562 /// `tools/call` params were not the shape a call has to be: not an object,
563 /// no string `name`, or an `arguments` that is not an object.
564 ///
565 /// A distinct variant from `UnknownTool` because a call with no `name` at
566 /// all has not failed to find a tool — it never named one, and answering
567 /// "no such tool" would send the caller looking for a tool that works.
568 #[error("the call parameters are not a tool call")]
569 MalformedParams,
570
571 /// `initialize` params were not the shape the handshake has to be.
572 ///
573 /// Separate from [`MalformedParams`](Self::MalformedParams) because the
574 /// two failures are not the same event: that one says a tool call named
575 /// nothing, and this one says the one request that establishes a session
576 /// arrived in a shape that would have to be guessed at. A peer sending it
577 /// has a bug worth naming, not a tool to go looking for.
578 ///
579 /// Covers a body that is not an object, a missing or non-string
580 /// `protocolVersion`, a string that is not a protocol version, a missing or
581 /// non-object `capabilities`, and a `clientInfo` with no `name`.
582 #[error("the initialize parameters are not a handshake")]
583 MalformedInitialize,
584
585 /// The session is on a revision this build has no wire types for.
586 ///
587 /// Distinct from a refused *method* ([`UnknownMethod`](Self::UnknownMethod))
588 /// because nothing about the request was wrong: the method exists, the
589 /// params parse, and the answer cannot be built because the revision the
590 /// session settled on is one [`negotiate_server`](crate::domain::negotiate_server) would have to counter-
591 /// offer. The session should never be in this state — it is reported
592 /// rather than papered over, because the alternative is emitting a result
593 /// whose shape the revision cannot express.
594 #[error("the session is on a revision this build cannot serve")]
595 UnnegotiableVersion,
596
597 /// A SEP-2575 request arrived without the `_meta` the schema requires.
598 ///
599 /// `2026-07-28` makes `RequestParams._meta` required on every request and
600 /// requires two keys in it — the revision and the client's capabilities —
601 /// because there is no handshake to carry them and no session to remember
602 /// them on. A request missing either is a well-formed message with a
603 /// missing argument, which is `-32602` and not a transport refusal.
604 ///
605 /// Its own variant rather than a reuse of
606 /// [`MalformedInitialize`](Self::MalformedInitialize) because the two are
607 /// different failures with different fixes: one says the handshake arrived
608 /// in a shape that would have to be guessed at, this says the shape is
609 /// right and a required key is not there.
610 #[error("the request `_meta` is missing a required key")]
611 MalformedRequestMeta,
612
613 /// A SEP-2575 request named a revision this build does not implement.
614 ///
615 /// Not [`MalformedParams`](Self::MalformedParams), and the difference is
616 /// the whole point of the code the specification assigns: `-32022` with
617 /// `data.supported` is what lets a client retry against a version this
618 /// server does speak, where `-32602` would send it looking for a bug in
619 /// its own request.
620 #[error("this server does not implement the requested protocol version")]
621 UnsupportedVersion {
622 /// The revision the client asked for, as it wrote it.
623 requested: String,
624 /// Every revision this build speaks, for the client to choose from.
625 supported: Vec<String>,
626 },
627
628 /// A second request arrived with an id this session is still working on.
629 ///
630 /// The JSON-RPC reading, and the reason it is an error rather than a
631 /// queue: the first request is a real, running, side-effecting call, and
632 /// the second carries no way to say what should happen to it. Running the
633 /// tool twice is the answer a retry gets by accident, and this is the
634 /// answer it gets on purpose. A client that wants the first result waits
635 /// for it and retries later — at which point the id is `Completed` and
636 /// comes straight back.
637 #[error("request {} is already in flight on this session", .0)]
638 DuplicateInFlight(String),
639
640 /// Serving the request needs client capabilities the request did not
641 /// declare (SEP-2575, `-32021`).
642 ///
643 /// Raised when a handler answers with an `InputRequiredResult` asking for
644 /// something the client said it cannot do: sending it anyway would ask a
645 /// client to sample or elicit after it declined to, and the specification
646 /// is explicit that a server "MUST NOT rely on capabilities the client has
647 /// not declared".
648 #[error("the request needs client capabilities it did not declare")]
649 MissingClientCapability {
650 /// The capability names, as `ClientCapabilities` keys.
651 required: Vec<&'static str>,
652 },
653
654 /// A SEP-2322 `requestState` did not verify.
655 ///
656 /// `-32602`, because the state is a request parameter and this one is not a
657 /// value this server issued. Refused before the handler runs, so no
658 /// handler can be made to act on a state a client wrote.
659 #[error("requestState failed its integrity check")]
660 TamperedRequestState,
661
662 /// The store holding recorded results could not be reached.
663 ///
664 /// Reported rather than swallowed because the alternative is the one thing
665 /// this whole path exists to prevent: a store that errors, a caller that
666 /// treats the error as "no claim", and a tool that runs a second time.
667 /// Failing the call is the only answer that does not charge a client twice
668 /// behind its back.
669 #[error("recorded results are unavailable: {0}")]
670 IdempotencyUnavailable(String),
671}
672
673impl DispatchError {
674 /// The JSON-RPC code this refusal is reported as.
675 ///
676 /// Lives here rather than in a transport adapter because it is a property
677 /// of the error, not of the way it is written down. It sat in the stdio
678 /// adapter until the HTTP router needed the same mapping, and the two
679 /// options at that point were both wrong: copy it and let the copies drift
680 /// apart, or reach into another adapter and break the layering.
681 ///
682 /// Every refusal gets an answer. The alternative is to drop it, which
683 /// leaves the peer waiting on a response that is never coming — and a
684 /// server that hangs is worse than one that says no. The codes are the
685 /// closest the standard offers: a method nobody serves is `-32601`, a call
686 /// that does not name something callable is `-32602`, and a session that
687 /// did not negotiate a feature — or one settled on a revision this build
688 /// cannot serve — is a server-side condition rather than either.
689 ///
690 /// A malformed `initialize` is `-32602` like any other malformed params:
691 /// the peer sent a request whose body it could not have meant, which is the
692 /// same condition whatever the method was.
693 ///
694 /// # Examples
695 ///
696 /// ```
697 /// # use protocol_droid::application::server::DispatchError;
698 /// # use protocol_droid::domain::ir::IrErrorCode;
699 /// assert_eq!(
700 /// DispatchError::UnknownMethod.jsonrpc_code(),
701 /// IrErrorCode::MethodNotFound,
702 /// );
703 /// assert_eq!(
704 /// DispatchError::MalformedParams.jsonrpc_code(),
705 /// IrErrorCode::InvalidParams,
706 /// );
707 /// ```
708 #[must_use]
709 pub const fn jsonrpc_code(&self) -> IrErrorCode {
710 match self {
711 Self::UnknownMethod => IrErrorCode::MethodNotFound,
712 // A URI or a prompt name that is not registered is the same
713 // failure as a tool name that is not, and answers the same way. The
714 // specification is explicit that an unknown tool is reported as an
715 // error rather than an empty result, and a client cannot act on a
716 // distinction between "no such thing" and "this thing is empty".
717 Self::MalformedParams
718 | Self::MalformedInitialize
719 | Self::UnknownTool
720 | Self::UnknownResource
721 | Self::UnknownPrompt
722 | Self::MalformedRequestMeta
723 | Self::TamperedRequestState => IrErrorCode::InvalidParams,
724 Self::MissingClientCapability { .. } => IrErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY,
725 // A request that names an id this session is already working on is
726 // a frame that violates JSON-RPC, so it takes the code for a frame
727 // the specification does not permit — not the code for a tool it
728 // cannot run, which is a different failure with a different fix.
729 Self::DuplicateInFlight(_) => IrErrorCode::InvalidRequest,
730 // A store that cannot be reached is this server's problem, whatever
731 // the peer sent, so it is a server-side code. Reporting it as the
732 // caller's fault would send a client looking for a bug in its own
733 // request when the recording of results is what failed.
734 Self::NotCallable | Self::UnnegotiableVersion | Self::IdempotencyUnavailable(_) => {
735 IrErrorCode::InternalError
736 }
737 // SEP-2575 assigns this its own code precisely so a client can
738 // distinguish "you asked for something real that I do not speak"
739 // from "your request was wrong", and act on the first by retrying.
740 Self::UnsupportedVersion { .. } => IrErrorCode::UNSUPPORTED_PROTOCOL_VERSION,
741 // A handler that refused is this server's problem whatever the peer
742 // sent. The peer asked for a resource or a prompt, named it
743 // correctly, and the answer is that the backing store is
744 // unreachable — reporting that as the caller's fault would send a
745 // client hunting in its own request for something it got right.
746 Self::HandlerFailed(_) => IrErrorCode::InternalError,
747 }
748 }
749
750 /// The `data` member this refusal's code requires, if it has one.
751 ///
752 /// `-32022` carries the revision the client asked for and every revision
753 /// this build speaks; `-32021` carries `requiredCapabilities` as a
754 /// `ClientCapabilities` object keyed by what is missing — not an array of
755 /// names, because the schema types it as the object a client declares.
756 /// Every other refusal carries none. One definition, so stdio and HTTP
757 /// cannot disagree about it.
758 #[must_use]
759 pub fn wire_data(&self) -> Option<JsonObject> {
760 let data = match self {
761 Self::UnsupportedVersion {
762 requested,
763 supported,
764 } => serde_json::json!({ "requested": requested, "supported": supported }),
765 Self::MissingClientCapability { required } => {
766 let capabilities: serde_json::Map<String, serde_json::Value> = required
767 .iter()
768 .map(|name| {
769 (
770 (*name).to_owned(),
771 serde_json::Value::Object(serde_json::Map::new()),
772 )
773 })
774 .collect();
775 serde_json::json!({ "requiredCapabilities": capabilities })
776 }
777 _ => return None,
778 };
779 JsonObject::from_value(data).ok()
780 }
781
782 /// The code and message pair a transport writes to the wire.
783 ///
784 /// The message is [`thiserror`]'s own for the variant, never the method
785 /// name the peer chose: a method name is a JSON string a peer controls, and
786 /// JSON permits a newline inside one, so echoing it into a log line would
787 /// let a peer close that line and open one of its own.
788 #[must_use]
789 pub fn as_wire(&self) -> (IrErrorCode, String) {
790 (self.jsonrpc_code(), self.to_string())
791 }
792}
793
794impl From<crate::application::server::result::OutcomeError> for DispatchError {
795 /// A reader or getter that could not produce a document failed.
796 ///
797 /// Every variant becomes [`DispatchError::HandlerFailed`] carrying the
798 /// outcome's own message. None of them is a bad request — the peer named a
799 /// resource or a prompt that exists and sent well-formed params — so
800 /// reporting any of them as `InvalidParams` would send a client looking in
801 /// its own request for something it got right.
802 fn from(error: crate::application::server::result::OutcomeError) -> Self {
803 Self::HandlerFailed(error.to_string())
804 }
805}
806
807impl From<IdempotencyError> for DispatchError {
808 /// Keeps a poisoned or absent store from reading as an absent claim.
809 fn from(error: IdempotencyError) -> Self {
810 Self::IdempotencyUnavailable(error.to_string())
811 }
812}
813
814/// The builder behind [`Server::builder`].
815pub struct ServerBuilder {
816 name: String,
817 version: String,
818 registry: RegistryStore,
819 tools: Vec<ToolEntry>,
820 /// Resources with their readers, in registration order.
821 ///
822 /// A `Vec` rather than a map because a server registers a handful of
823 /// resources and a linear scan over a few entries beats a hash of a
824 /// `PrimitiveId` that has to be computed first.
825 resource_reads: Vec<(PrimitiveId, ResourceHandler)>,
826 /// Prompts with their getters, in registration order.
827 prompt_gets: Vec<(PrimitiveId, PromptHandler)>,
828 idempotency: Option<Arc<dyn IdempotencyStore<Result = JsonObject>>>,
829 /// The state entries built up through `with_state` calls. Held as a
830 /// `Vec<(TypeId, Arc<dyn Any>)>` and re-keyed at build time because
831 /// the `TypeId` of `T` is only known to `with_state`, and pushing
832 /// into a `HashMap` keyed by a value the caller cannot see is the
833 /// wrong shape for a builder.
834 state: Vec<(std::any::TypeId, Arc<dyn std::any::Any + Send + Sync>)>,
835 state_key: Option<StateKey>,
836}
837
838impl core::fmt::Debug for ServerBuilder {
839 /// Prints the same three facts as [`Server`], plus whether a store was
840 /// supplied.
841 ///
842 /// The last is the one worth having here, and it is the question to ask
843 /// first when a recorded result has gone missing across a restart: a
844 /// server still on the default has an in-process bound, and no way to see
845 /// that from the built server it became.
846 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
847 f.debug_struct("ServerBuilder")
848 .field("name", &self.name)
849 .field("version", &self.version)
850 .field("tool_count", &self.tools.len())
851 .field("idempotency_supplied", &self.idempotency.is_some())
852 .finish_non_exhaustive()
853 }
854}
855
856impl ServerBuilder {
857 /// Sets the key SEP-2322 `requestState` is sealed under.
858 ///
859 /// Optional: a transport fills one from the OS random source when it is
860 /// absent. Set it explicitly when several replicas serve one endpoint —
861 /// they must share it, or a retry that lands on a different replica finds
862 /// its state refused.
863 #[must_use]
864 pub const fn request_state_key(mut self, key: StateKey) -> Self {
865 self.state_key = Some(key);
866 self
867 }
868
869 /// Sets the version this server reports in `serverInfo`.
870 ///
871 /// Defaults to the version of the crate that built it, which is right for
872 /// a server that *is* this crate — the examples, the conformance runner —
873 /// and wrong for one that merely links it. A library user shipping their
874 /// own server should say so here rather than let the dependency's version
875 /// stand in for theirs, which would have every downstream server claiming
876 /// to be whatever `protocol-droid` happened to be pinned at.
877 #[must_use]
878 pub fn version(mut self, version: impl Into<String>) -> Self {
879 self.version = version.into();
880 self
881 }
882
883 /// Replaces the store `tools/call` is deduplicated through.
884 ///
885 /// For a store that outlives the process — a shared cache, a table — or
886 /// for a test that wants to assert on what was recorded. A server that
887 /// keeps its own `IdSource` across restarts has a gap the in-memory
888 /// default cannot close: a call recorded before a restart is unknown
889 /// after it, and the retry runs the tool again.
890 #[must_use]
891 pub fn idempotency(mut self, store: Arc<dyn IdempotencyStore<Result = JsonObject>>) -> Self {
892 self.idempotency = Some(store);
893 self
894 }
895
896 /// Registers a value handlers can read through
897 /// [`RequestContext::state`](crate::application::server::context::RequestContext::state).
898 ///
899 /// `T` is keyed by `TypeId`, so two `with_state` calls with the same
900 /// `T` keep the later value (the registry is a `HashMap`, and the
901 /// later write is the one the lookup will see). The `Send + Sync`
902 /// bounds are the same every other port in this crate asks for; a
903 /// pool of non-`Send` handles cannot live in this map.
904 ///
905 /// The point of this method is the `#[tool]` problem: a macro that
906 /// generates module-scope items cannot see `self`, so the handler
907 /// it generates has no captured environment. Storing the state
908 /// here and reading it through the request context is the
909 /// standard answer, and the only one that keeps the macro
910 /// out of the receiver business.
911 #[must_use]
912 pub fn with_state<T: Send + Sync + 'static>(mut self, value: T) -> Self {
913 self.state.push((
914 std::any::TypeId::of::<T>(),
915 Arc::new(value) as Arc<dyn std::any::Any + Send + Sync>,
916 ));
917 self
918 }
919
920 /// Adds a tool, and publishes it to the registry in the same step.
921 ///
922 /// Takes a whole [`Tool`] rather than a name and a schema string, because
923 /// a schema string can only ever express the plainest tool there is. An
924 /// output schema, annotations and icons all change what the tool
925 /// _requires_ of a session, and a builder that cannot name them would
926 /// register a tool whose requirement nothing had computed.
927 ///
928 /// Registering and publishing together is the point: a tool in the list
929 /// but not in the registry is a tool a client can see and not call, and
930 /// one in neither is a handler that never runs. There is no state in
931 /// between for a caller to be wrong in.
932 #[must_use]
933 pub fn tool(self, tool: Tool, handler: ToolHandler) -> Self {
934 self.restricted_tool(Registered::from(Primitive::from(tool)), handler)
935 }
936
937 /// Adds a tool that carries its own requirements.
938 ///
939 /// [`tool`](Self::tool) takes a [`Tool`], and a `Tool` cannot say
940 /// "callers need the `files:read` scope" — that lives on [`Registered`],
941 /// which wraps a primitive with the policy it must satisfy. So without this
942 /// method the scope half of `is_visible` is reachable from no builder
943 /// call, `auth.rs` is dead code, and a server can be built that has no way
944 /// to express an authorisation rule at all. That is the same dead-gate
945 /// shape T11 found, one layer up.
946 ///
947 /// A `Registered` carrying something other than a tool is registered
948 /// under its own id and left out of the call table: it stays visible to
949 /// `tools/list` and the listing is the truth, so nothing is silently
950 /// dropped, but there is no handler to name for a `tools/call` of it.
951 #[must_use]
952 pub fn restricted_tool(mut self, registered: Registered, handler: ToolHandler) -> Self {
953 if let Primitive::Tool(tool) = registered.primitive() {
954 self.tools.push(ToolEntry {
955 name: tool.name.clone(),
956 handler,
957 });
958 }
959 self.registry.register(registered);
960 self
961 }
962
963 /// Adds a plain tool: a name, an input schema, and a handler.
964 ///
965 /// The common case, and the one a tool without an output schema or
966 /// annotations fits. It exists so the common case is one line rather than
967 /// a `Tool::new(...)?` at every call site, and it returns a `Result`
968 /// because the schema is the one fallible step.
969 /// # Errors
970 ///
971 /// The [`JsonObject`](crate::domain::ir::JsonObjectError) from parsing
972 /// `input_schema`. The schema is validated here, once, so a malformed one
973 /// fails at build time rather than on the first call.
974 pub fn simple_tool(
975 self,
976 name: impl Into<PrimitiveId>,
977 input_schema: &str,
978 handler: ToolHandler,
979 ) -> Result<Self, crate::domain::ir::JsonObjectError> {
980 Ok(self.tool(Tool::new(name, input_schema)?, handler))
981 }
982
983 /// Adds a resource.
984 ///
985 /// Adds a resource together with the reader that answers `resources/read`
986 /// for it.
987 ///
988 /// Both at once, and there is deliberately no `resource(resource)` beside
989 /// it. A resource that is listed by `resources/list` and then refused by
990 /// `resources/read` is a server telling a peer something false, and the
991 /// dispatcher has to answer `InvalidParams` for the read because it has no
992 /// other way to distinguish that from a URI the server never had. Making
993 /// the reader a separate opt-in would put that state one method call away,
994 /// and the failure would surface to a client rather than at the call site
995 /// that built it.
996 #[must_use]
997 pub fn resource_with(self, resource: Resource, reader: ResourceHandler) -> Self {
998 let mut builder = self;
999 builder.resource_reads.push((resource.uri.clone(), reader));
1000 builder
1001 .registry
1002 .register(Registered::from(Primitive::from(resource)));
1003 builder
1004 }
1005
1006 /// Adds a resource template.
1007 ///
1008 /// Templates are listed by their own method, and they are a distinct
1009 /// [`PrimitiveKind`] in the registry, so a template needs its own entry
1010 /// point rather than sharing `resource`'s.
1011 #[must_use]
1012 pub fn resource_template(self, template: ResourceTemplate) -> Self {
1013 self.registry
1014 .register(Registered::from(Primitive::from(template)));
1015 self
1016 }
1017
1018 /// Adds a prompt together with the getter that answers `prompts/get` for it.
1019 ///
1020 /// Required together for the reason [`resource_with`](Self::resource_with)
1021 /// takes both.
1022 #[must_use]
1023 pub fn prompt_with(self, prompt: Prompt, getter: PromptHandler) -> Self {
1024 let mut builder = self;
1025 builder.prompt_gets.push((prompt.name.clone(), getter));
1026 builder
1027 .registry
1028 .register(Registered::from(Primitive::from(prompt)));
1029 builder
1030 }
1031
1032 /// Finishes the server.
1033 #[must_use]
1034 pub fn build(self) -> Server {
1035 // Deduplicating by default rather than on request. A caller who has
1036 // never heard of idempotency is the caller most likely to be serving
1037 // a retriable HTTP transport, where a dropped connection turns one
1038 // call into two, and a store that has to be opted into is a store
1039 // nobody will think to opt into. The default is the bound that fails
1040 // safe: a server that forgets runs a tool once rather than twice.
1041 let idempotency = self.idempotency.unwrap_or_else(|| {
1042 // 4096 keys is generous for a single process and small enough to
1043 // bound memory. The capacity is a policy number, not a constant
1044 // with a meaning, so it is named rather than inlined.
1045 const DEFAULT_KEYS: NonZeroUsize = match NonZeroUsize::new(4096) {
1046 Some(value) => value,
1047 // Unreachable: 4096 is not zero. A `match` rather than an
1048 // `unwrap` because the crate denies that.
1049 None => NonZeroUsize::MIN,
1050 };
1051 Arc::new(InMemoryIdempotencyStore::with_capacity(DEFAULT_KEYS))
1052 });
1053 Server {
1054 name: self.name,
1055 version: self.version,
1056 live: Arc::new(Live::new(
1057 self.registry,
1058 Handlers {
1059 tools: self.tools,
1060 resources: self.resource_reads,
1061 prompts: self.prompt_gets,
1062 },
1063 )),
1064 idempotency,
1065 state: Arc::new(
1066 self.state
1067 .into_iter()
1068 .collect::<std::collections::HashMap<_, _>>(),
1069 ),
1070 state_key: self.state_key,
1071 }
1072 }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use std::sync::Arc;
1078
1079 use super::{DispatchError, Server};
1080 use crate::application::server::context::{PeerSession, RequestContext};
1081 use crate::application::session::SinkError;
1082 use crate::domain::feature::FeatureProfile;
1083 use crate::domain::ir::{JsonObject, JsonObjectError};
1084 use crate::domain::registry::PrimitiveKind;
1085 use crate::domain::session::{CancellationToken, SessionId};
1086 use crate::domain::{ProtocolVersion, RequestId, TransportKind};
1087
1088 /// What a test here can fail with.
1089 #[derive(Debug)]
1090 enum Failure {
1091 /// The test's own complaint.
1092 Message(String),
1093 /// A fixture was not a JSON object.
1094 Fixture(JsonObjectError),
1095 }
1096
1097 impl core::fmt::Display for Failure {
1098 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1099 match self {
1100 Self::Message(text) => f.write_str(text),
1101 Self::Fixture(error) => write!(f, "bad fixture: {error}"),
1102 }
1103 }
1104 }
1105
1106 impl std::error::Error for Failure {}
1107
1108 impl From<String> for Failure {
1109 fn from(text: String) -> Self {
1110 Self::Message(text)
1111 }
1112 }
1113
1114 impl From<&str> for Failure {
1115 fn from(text: &str) -> Self {
1116 Self::Message(text.to_string())
1117 }
1118 }
1119
1120 impl From<JsonObjectError> for Failure {
1121 fn from(error: JsonObjectError) -> Self {
1122 Self::Fixture(error)
1123 }
1124 }
1125
1126 type TestError = Failure;
1127
1128 /// A handler that answers with a fixed object, so a test can tell one tool
1129 /// from another without every case writing its own closure.
1130 fn answering(answer: &str) -> Result<super::ToolHandler, TestError> {
1131 let answer = JsonObject::new(answer)?;
1132 Ok(Arc::new(move |_ctx: RequestContext, _args: JsonObject| {
1133 let answer = answer.clone();
1134 Box::pin(async move { answer })
1135 }))
1136 }
1137
1138 /// A port pair that answers nothing and records nothing.
1139 ///
1140 /// The builder tests never elicit, sample, or emit progress — they are
1141 /// about registration and dispatch — so the ports exist only to satisfy
1142 /// `PeerSession`. A `RefCell` is safe here precisely because nothing
1143 /// touches them, and a real channel would be a second thing to get wrong
1144 /// in a test that is not about channels.
1145 struct Inert;
1146
1147 impl crate::application::session::FrameSink for Inert {
1148 fn send(&self, _frame: &str) -> impl Future<Output = Result<(), SinkError>> + Send {
1149 std::future::ready(Ok(()))
1150 }
1151 }
1152
1153 impl crate::application::server::context::CallbackExchange for Inert {
1154 fn exchange(
1155 &self,
1156 _method: &str,
1157 _params: &JsonObject,
1158 ) -> impl Future<Output = Result<JsonObject, crate::application::server::ExchangeError>> + Send
1159 {
1160 std::future::ready(Err(crate::application::server::ExchangeError::new(
1161 "inert in these tests",
1162 )))
1163 }
1164 }
1165
1166 fn peer_at(profile: FeatureProfile) -> PeerSession {
1167 PeerSession::new(
1168 ProtocolVersion::V2025_06_18,
1169 profile,
1170 SessionId::new(1),
1171 TransportKind::Stdio,
1172 Arc::new(Inert),
1173 Arc::new(Inert),
1174 )
1175 }
1176
1177 fn ctx_for(profile: FeatureProfile) -> RequestContext {
1178 RequestContext::for_request(
1179 &peer_at(profile),
1180 RequestId::number(1),
1181 CancellationToken::new(),
1182 None,
1183 )
1184 }
1185
1186 /// A tool added to the builder is in the registry in the same step.
1187 ///
1188 /// The builder's claim is that there is no state in between — no tool that
1189 /// is listable but not callable. A registry the builder has to be told
1190 /// about separately cannot make that claim, so this is the test of the
1191 /// claim and not a restatement of the code.
1192 #[test]
1193 fn a_registered_tool_is_in_the_registry_immediately() -> Result<(), TestError> {
1194 let server = Server::builder("demo")
1195 .simple_tool("echo", r#"{"type":"object"}"#, answering("{}")?)?
1196 .build();
1197
1198 assert_eq!(server.tool_count(), 1);
1199 assert_eq!(server.registry().snapshot().len(PrimitiveKind::Tool), 1);
1200 Ok(())
1201 }
1202
1203 /// A tool added after `into_server` lists *and* runs.
1204 ///
1205 /// Both halves, because the registry and the handler table are two places
1206 /// a method like this could write to only one of. A tool in `tools/list`
1207 /// with no handler is one a client can see and not call — the failure mode
1208 /// the builder's own doc calls out — and it would pass a listing-only test.
1209 #[test]
1210 fn a_tool_added_after_the_build_lists_and_runs() -> Result<(), TestError> {
1211 use crate::domain::registry::{Primitive, Tool};
1212
1213 let mut server = Server::builder("demo")
1214 .simple_tool("echo", r#"{"type":"object"}"#, answering("{}")?)?
1215 .build();
1216
1217 // The schema is the one thing `add_tool` exists for: `$defs`, an
1218 // `$anchor` and `if`/`then`/`else` are JSON Schema 2020-12 keywords no
1219 // Rust type derives, so this is the whole reason the method is here.
1220 let raw = Tool::new(
1221 "json_schema_2020_12",
1222 r#"{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","$defs":{"address":{"$anchor":"addressDef","type":"string"}},"if":{"properties":{"a":{"const":1}}},"then":{"required":["b"]}}"#,
1223 )?;
1224 server.add_tool(raw, answering(r#"{"ok":true}"#)?);
1225
1226 assert_eq!(server.tool_count(), 2);
1227 assert_eq!(server.registry().snapshot().len(PrimitiveKind::Tool), 2);
1228 assert!(server.handler("json_schema_2020_12").is_some());
1229
1230 // The listing, not just the count: the schema has to reach `tools/list`
1231 // with the keywords intact, since that is the wire the suite reads.
1232 let snapshot = server.registry().snapshot();
1233 let advertised = snapshot
1234 .list(PrimitiveKind::Tool)
1235 .iter()
1236 .find(|tool| tool.id().as_str() == "json_schema_2020_12")
1237 .ok_or("the added tool is not in the registry")?;
1238 let Primitive::Tool(advertised) = advertised.primitive() else {
1239 return Err("a tool listing held something that is not a tool".into());
1240 };
1241 let text = advertised.input_schema.as_str();
1242 for keyword in ["$schema", "$defs", "$anchor", "if", "then"] {
1243 if !text.contains(keyword) {
1244 return Err(format!("{keyword} did not reach the listing").into());
1245 }
1246 }
1247 Ok(())
1248 }
1249
1250 /// A malformed input schema fails at build time, not on the first call.
1251 #[test]
1252 fn a_malformed_input_schema_is_rejected_at_build_time() -> Result<(), TestError> {
1253 // The assertion is on the `Result` itself rather than on an error
1254 // message: the error's text is not the contract, the timing of the
1255 // failure is. A test that unwrapped and matched on a string would pass
1256 // against a builder that validated for the wrong reason.
1257 let handler = answering("{}")?;
1258 let built = Server::builder("demo").simple_tool("bad", "{not json", handler);
1259
1260 match built {
1261 Ok(_) => Err(Failure::from(
1262 "a malformed input schema was accepted at build time",
1263 )),
1264 Err(_) => Ok(()),
1265 }
1266 }
1267
1268 /// Two tools with one name is one tool, not two.
1269 ///
1270 /// T11's store treats a re-registration as a replacement, and the builder
1271 /// has to agree or the registry would list a name the dispatcher cannot
1272 /// resolve to a single handler.
1273 #[test]
1274 fn a_second_tool_of_the_same_name_replaces_the_first() -> Result<(), TestError> {
1275 let server = Server::builder("demo")
1276 .simple_tool(
1277 "echo",
1278 r#"{"type":"object"}"#,
1279 answering(r#"{"first":true}"#)?,
1280 )?
1281 .simple_tool(
1282 "echo",
1283 r#"{"type":"object"}"#,
1284 answering(r#"{"second":true}"#)?,
1285 )?
1286 .build();
1287
1288 assert_eq!(server.tool_count(), 2, "both handlers are kept");
1289 assert_eq!(
1290 server.registry().snapshot().len(PrimitiveKind::Tool),
1291 1,
1292 "but the registry holds one name"
1293 );
1294 Ok(())
1295 }
1296
1297 /// An unknown tool is refused without reaching any handler.
1298 #[tokio::test]
1299 async fn an_unknown_tool_is_refused() -> Result<(), TestError> {
1300 let server = Server::builder("demo")
1301 .simple_tool("echo", r#"{"type":"object"}"#, answering("{}")?)?
1302 .build();
1303 let ctx = ctx_for(FeatureProfile::all());
1304
1305 match server.call(&ctx, "nope", JsonObject::new("{}")?).await {
1306 Ok(answer) => Err(Failure::from(format!("expected a refusal, got {answer}"))),
1307 Err(DispatchError::UnknownTool) => Ok(()),
1308 Err(other) => Err(Failure::from(format!(
1309 "refused for the wrong reason: {other}"
1310 ))),
1311 }
1312 }
1313
1314 /// A registered tool whose required features the session lacks is refused
1315 /// by `call`, not run and refused afterwards.
1316 ///
1317 /// A tool with an output schema requires `ToolStructuredOutput`. A session
1318 /// that never negotiated it must not run the tool and then drop the part
1319 /// of the answer the client could not have used.
1320 #[tokio::test]
1321 async fn a_tool_needing_an_absent_feature_is_not_run() -> Result<(), TestError> {
1322 use crate::domain::registry::Tool;
1323
1324 // The full `Tool`, not `simple_tool`: an output schema is what raises
1325 // the requirement in the first place, and `simple_tool` could not
1326 // express one. A test written against it would be testing a gap.
1327 let structured = Tool::new("echo", r#"{"type":"object"}"#)?
1328 .with_output_schema(r#"{"type":"object"}"#)?;
1329
1330 let server = Server::builder("demo")
1331 .tool(structured, answering(r#"{"ran":true}"#)?)
1332 .build();
1333
1334 let ctx = ctx_for(FeatureProfile::empty());
1335 match server.call(&ctx, "echo", JsonObject::new("{}")?).await {
1336 Ok(answer) => Err(Failure::from(format!(
1337 "a tool the session cannot call returned {answer}"
1338 ))),
1339 // `UnknownTool`, not `NotCallable`, and the distinction is the
1340 // point. A tool this session may not see is a tool this session is
1341 // not told about: answering "it exists but you may not have it"
1342 // would confirm it exists, and a caller could then enumerate the
1343 // registry by watching which names come back refused. The same
1344 // answer as a tool that is not there is what makes the refusal
1345 // probe-proof.
1346 Err(DispatchError::UnknownTool) => Ok(()),
1347 Err(other) => Err(Failure::from(format!(
1348 "refused for the wrong reason: {other}"
1349 ))),
1350 }
1351 }
1352}