Skip to main content

protocol_droid/application/server/
error.rs

1//! The two error types the server API returns.
2//!
3//! They are not variants of one error, because they mean opposite things about
4//! what the peer learns.
5//!
6//! [`GatedError`] is a refusal that never reaches the wire. The request was
7//! well formed and the server simply cannot serve it the way the handler
8//! imagined, so the failure stays on this side and the handler is told
9//! directly. Sending a JSON-RPC error there teaches the peer something false
10//! about the protocol.
11//!
12//! [`DispatchError`] is a failure while a method was being dispatched, and
13//! most of its variants do reach the peer as a JSON-RPC error — the peer named
14//! a tool that does not exist, or sent parameters the server could not read.
15//! The peer is owed an answer in those cases, and which answer is what the
16//! variants distinguish.
17
18use crate::domain::ProtocolVersion;
19use crate::domain::feature::Feature;
20use crate::domain::idempotency::IdempotencyError;
21use crate::domain::ir::{IrErrorCode, JsonObject};
22
23/// Why a feature-specific call was refused.
24///
25/// Every variant here is a refusal the peer never sees. The distinction from a
26/// JSON-RPC error matters: an error the peer sees is a protocol interaction,
27/// and this crate is careful about when it sends one. A handler that asks for
28/// a capability the session does not have is not a protocol error — the request
29/// was well formed and the server simply cannot serve it the way the handler
30/// imagined. Sending a JSON-RPC error there teaches the peer something false
31/// about the protocol, so the failure stays on this side of the wire and the
32/// handler is told directly.
33///
34/// That is the whole reason this type exists rather than reusing the
35/// lower-time `LowerError`: one happens during a translation the peer asked
36/// for, the other happens before anything is written at all.
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum GatedError {
39    /// The negotiated revision and the peer's declared capabilities do not
40    /// include the feature the call needs.
41    ///
42    /// The negotiated version is carried rather than left to the caller to
43    /// remember, because "elicitation is unavailable" is an answer nobody can
44    /// act on without knowing what *was* negotiated.
45    #[error("{feature} was not negotiated (session is {negotiated})")]
46    FeatureUnavailable {
47        /// The feature the call needed.
48        feature: Feature,
49        /// The revision this session actually settled on.
50        negotiated: ProtocolVersion,
51    },
52
53    /// A progress report was attempted on a request that was never given a
54    /// progress token.
55    ///
56    /// Separate from [`FeatureUnavailable`](Self::FeatureUnavailable) because
57    /// the fix is different: progress is available on every revision, and the
58    /// client is the one that declined to open a channel.
59    #[error("this request has no progress token; the client did not open one")]
60    NoProgressToken,
61
62    /// A `requestState` was to be sealed and the server holds no key.
63    ///
64    /// A server built in-process with no key, serving through no transport
65    /// that would have supplied one. Sealing under a placeholder would hand
66    /// out a state any client could forge, which is worse than refusing.
67    #[error("this server has no request-state key to seal with")]
68    NoStateKey,
69
70    /// The peer's answer to a server-initiated request did not arrive as JSON,
71    /// or the exchange did not complete.
72    ///
73    /// The gate is not implicated — the call was permitted, and the failure is
74    /// the peer's or the transport's. Kept here so a handler has one error
75    /// type to match rather than a gate error and a transport error.
76    #[error("the exchange did not complete: {reason}")]
77    MalformedAnswer {
78        /// What the transport reported.
79        ///
80        /// A `String` because the transport names its own failures and the
81        /// application layer has no business enumerating them. The rule is that
82        /// application code never names a _third-party_ error type; a
83        /// transport's own description is data the adapter chose to hand over.
84        reason: String,
85    },
86}
87
88impl GatedError {
89    /// The feature this refusal is about, when there is one.
90    ///
91    /// A progress-token refusal is about no feature, which is why this returns
92    /// an `Option` rather than inventing one — a caller logging the answer
93    /// wants to omit the field, not print a placeholder that reads as a claim.
94    #[must_use]
95    pub const fn feature(&self) -> Option<Feature> {
96        match self {
97            Self::FeatureUnavailable { feature, .. } => Some(*feature),
98            Self::NoProgressToken | Self::NoStateKey | Self::MalformedAnswer { .. } => None,
99        }
100    }
101
102    /// Whether nothing was written to the transport before this refusal.
103    ///
104    /// Always true, and that is the point: the gate runs before the exchange,
105    /// so a refused call cannot have left a partial frame behind. The
106    /// [`MalformedAnswer`](Self::MalformedAnswer) case is the exception in
107    /// spirit — the request _was_ written, because the gate had passed and the
108    /// failure came afterwards — so it reports false.
109    #[must_use]
110    pub const fn wrote_nothing(&self) -> bool {
111        match self {
112            Self::FeatureUnavailable { .. } | Self::NoProgressToken | Self::NoStateKey => true,
113            Self::MalformedAnswer { .. } => false,
114        }
115    }
116}
117
118/// Why a tool call did not run.
119#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
120pub enum DispatchError {
121    /// No tool of that name is registered.
122    ///
123    /// Deliberately the same answer for a tool that exists but is hidden from
124    /// this session: the difference would let a client probe for the existence
125    /// of a tool it may not see. The server still logs which it was.
126    #[error("no such tool")]
127    UnknownTool,
128
129    /// The tool is registered, but the session did not negotiate the features
130    /// it requires.
131    #[error("the session did not negotiate what this tool requires")]
132    NotCallable,
133
134    /// No method of that name exists.
135    ///
136    /// Refused on the method, not on a tool, because the caller named a method
137    /// and there is no tool to say anything about.
138    #[error("no such method")]
139    UnknownMethod,
140    /// No resource of that URI is registered, or it is listed without a
141    /// reader.
142    ///
143    /// Two causes share one answer because a peer cannot tell them apart: a
144    /// listing is a promise, so a resource that appears in `resources/list`
145    /// and then refuses to be read is the same failure as one that was never
146    /// there, and naming which of the two happened tells the peer nothing it
147    /// can act on.
148    #[error("no such resource")]
149    UnknownResource,
150    /// No prompt of that name is registered, or it is listed without a getter.
151    #[error("no such prompt")]
152    UnknownPrompt,
153    /// A registered reader or getter refused, naming why.
154    ///
155    /// Carries the handler's own message rather than a category, because the
156    /// peer is a client and the only thing it can do with a category is show it
157    /// to a user who would rather read what went wrong.
158    #[error("the handler refused: {0}")]
159    HandlerFailed(String),
160
161    /// `tools/call` params were not the shape a call has to be: not an object,
162    /// no string `name`, or an `arguments` that is not an object.
163    ///
164    /// A distinct variant from `UnknownTool` because a call with no `name` at
165    /// all has not failed to find a tool — it never named one, and answering
166    /// "no such tool" would send the caller looking for a tool that works.
167    #[error("the call parameters are not a tool call")]
168    MalformedParams,
169
170    /// `initialize` params were not the shape the handshake has to be.
171    ///
172    /// Separate from [`MalformedParams`](Self::MalformedParams) because the
173    /// two failures are not the same event: that one says a tool call named
174    /// nothing, and this one says the one request that establishes a session
175    /// arrived in a shape that would have to be guessed at. A peer sending it
176    /// has a bug worth naming, not a tool to go looking for.
177    ///
178    /// Covers a body that is not an object, a missing or non-string
179    /// `protocolVersion`, a string that is not a protocol version, a missing or
180    /// non-object `capabilities`, and a `clientInfo` with no `name`.
181    #[error("the initialize parameters are not a handshake")]
182    MalformedInitialize,
183
184    /// The session is on a revision this build has no wire types for.
185    ///
186    /// Distinct from a refused *method* ([`UnknownMethod`](Self::UnknownMethod))
187    /// because nothing about the request was wrong: the method exists, the
188    /// params parse, and the answer cannot be built because the revision the
189    /// session settled on is one [`negotiate_server`](crate::domain::negotiate_server) would have to counter-
190    /// offer. The session should never be in this state — it is reported
191    /// rather than papered over, because the alternative is emitting a result
192    /// whose shape the revision cannot express.
193    #[error("the session is on a revision this build cannot serve")]
194    UnnegotiableVersion,
195
196    /// A SEP-2575 request arrived without the `_meta` the schema requires.
197    ///
198    /// `2026-07-28` makes `RequestParams._meta` required on every request and
199    /// requires two keys in it — the revision and the client's capabilities —
200    /// because there is no handshake to carry them and no session to remember
201    /// them on. A request missing either is a well-formed message with a
202    /// missing argument, which is `-32602` and not a transport refusal.
203    ///
204    /// Its own variant rather than a reuse of
205    /// [`MalformedInitialize`](Self::MalformedInitialize) because the two are
206    /// different failures with different fixes: one says the handshake arrived
207    /// in a shape that would have to be guessed at, this says the shape is
208    /// right and a required key is not there.
209    #[error("the request `_meta` is missing a required key")]
210    MalformedRequestMeta,
211
212    /// A SEP-2575 request named a revision this build does not implement.
213    ///
214    /// Not [`MalformedParams`](Self::MalformedParams), and the difference is
215    /// the whole point of the code the specification assigns: `-32022` with
216    /// `data.supported` is what lets a client retry against a version this
217    /// server does speak, where `-32602` would send it looking for a bug in
218    /// its own request.
219    #[error("this server does not implement the requested protocol version")]
220    UnsupportedVersion {
221        /// The revision the client asked for, as it wrote it.
222        requested: String,
223        /// Every revision this build speaks, for the client to choose from.
224        supported: Vec<String>,
225    },
226
227    /// A second request arrived with an id this session is still working on.
228    ///
229    /// The JSON-RPC reading, and the reason it is an error rather than a
230    /// queue: the first request is a real, running, side-effecting call, and
231    /// the second carries no way to say what should happen to it. Running the
232    /// tool twice is the answer a retry gets by accident, and this is the
233    /// answer it gets on purpose. A client that wants the first result waits
234    /// for it and retries later — at which point the id is `Completed` and
235    /// comes straight back.
236    #[error("request {} is already in flight on this session", .0)]
237    DuplicateInFlight(String),
238
239    /// Serving the request needs client capabilities the request did not
240    /// declare (SEP-2575, `-32021`).
241    ///
242    /// Raised when a handler answers with an `InputRequiredResult` asking for
243    /// something the client said it cannot do: sending it anyway would ask a
244    /// client to sample or elicit after it declined to, and the specification
245    /// is explicit that a server "MUST NOT rely on capabilities the client has
246    /// not declared".
247    #[error("the request needs client capabilities it did not declare")]
248    MissingClientCapability {
249        /// The capability names, as `ClientCapabilities` keys.
250        required: Vec<&'static str>,
251    },
252
253    /// A SEP-2322 `requestState` did not verify.
254    ///
255    /// `-32602`, because the state is a request parameter and this one is not a
256    /// value this server issued. Refused before the handler runs, so no
257    /// handler can be made to act on a state a client wrote.
258    #[error("requestState failed its integrity check")]
259    TamperedRequestState,
260
261    /// The store holding recorded results could not be reached.
262    ///
263    /// Reported rather than swallowed because the alternative is the one thing
264    /// this whole path exists to prevent: a store that errors, a caller that
265    /// treats the error as "no claim", and a tool that runs a second time.
266    /// Failing the call is the only answer that does not charge a client twice
267    /// behind its back.
268    #[error("recorded results are unavailable: {0}")]
269    IdempotencyUnavailable(String),
270}
271
272impl DispatchError {
273    /// The JSON-RPC code this refusal is reported as.
274    ///
275    /// Lives here rather than in a transport adapter because it is a property
276    /// of the error, not of the way it is written down. It sat in the stdio
277    /// adapter until the HTTP router needed the same mapping, and the two
278    /// options at that point were both wrong: copy it and let the copies drift
279    /// apart, or reach into another adapter and break the layering.
280    ///
281    /// Every refusal gets an answer. The alternative is to drop it, which
282    /// leaves the peer waiting on a response that is never coming — and a
283    /// server that hangs is worse than one that says no. The codes are the
284    /// closest the standard offers: a method nobody serves is `-32601`, a call
285    /// that does not name something callable is `-32602`, and a session that
286    /// did not negotiate a feature — or one settled on a revision this build
287    /// cannot serve — is a server-side condition rather than either.
288    ///
289    /// A malformed `initialize` is `-32602` like any other malformed params:
290    /// the peer sent a request whose body it could not have meant, which is the
291    /// same condition whatever the method was.
292    ///
293    /// # Examples
294    ///
295    /// ```
296    /// # use protocol_droid::application::server::DispatchError;
297    /// # use protocol_droid::domain::ir::IrErrorCode;
298    /// assert_eq!(
299    ///     DispatchError::UnknownMethod.jsonrpc_code(),
300    ///     IrErrorCode::MethodNotFound,
301    /// );
302    /// assert_eq!(
303    ///     DispatchError::MalformedParams.jsonrpc_code(),
304    ///     IrErrorCode::InvalidParams,
305    /// );
306    /// ```
307    #[must_use]
308    pub const fn jsonrpc_code(&self) -> IrErrorCode {
309        match self {
310            Self::UnknownMethod => IrErrorCode::MethodNotFound,
311            // A URI or a prompt name that is not registered is the same
312            // failure as a tool name that is not, and answers the same way. The
313            // specification is explicit that an unknown tool is reported as an
314            // error rather than an empty result, and a client cannot act on a
315            // distinction between "no such thing" and "this thing is empty".
316            Self::MalformedParams
317            | Self::MalformedInitialize
318            | Self::UnknownTool
319            | Self::UnknownResource
320            | Self::UnknownPrompt
321            | Self::MalformedRequestMeta
322            | Self::TamperedRequestState => IrErrorCode::InvalidParams,
323            Self::MissingClientCapability { .. } => IrErrorCode::MISSING_REQUIRED_CLIENT_CAPABILITY,
324            // A request that names an id this session is already working on is
325            // a frame that violates JSON-RPC, so it takes the code for a frame
326            // the specification does not permit — not the code for a tool it
327            // cannot run, which is a different failure with a different fix.
328            Self::DuplicateInFlight(_) => IrErrorCode::InvalidRequest,
329            // A store that cannot be reached is this server's problem, whatever
330            // the peer sent, so it is a server-side code. Reporting it as the
331            // caller's fault would send a client looking for a bug in its own
332            // request when the recording of results is what failed.
333            Self::NotCallable | Self::UnnegotiableVersion | Self::IdempotencyUnavailable(_) => {
334                IrErrorCode::InternalError
335            }
336            // SEP-2575 assigns this its own code precisely so a client can
337            // distinguish "you asked for something real that I do not speak"
338            // from "your request was wrong", and act on the first by retrying.
339            Self::UnsupportedVersion { .. } => IrErrorCode::UNSUPPORTED_PROTOCOL_VERSION,
340            // A handler that refused is this server's problem whatever the peer
341            // sent. The peer asked for a resource or a prompt, named it
342            // correctly, and the answer is that the backing store is
343            // unreachable — reporting that as the caller's fault would send a
344            // client hunting in its own request for something it got right.
345            Self::HandlerFailed(_) => IrErrorCode::InternalError,
346        }
347    }
348
349    /// The `data` member this refusal's code requires, if it has one.
350    ///
351    /// `-32022` carries the revision the client asked for and every revision
352    /// this build speaks; `-32021` carries `requiredCapabilities` as a
353    /// `ClientCapabilities` object keyed by what is missing — not an array of
354    /// names, because the schema types it as the object a client declares.
355    /// Every other refusal carries none. One definition, so stdio and HTTP
356    /// cannot disagree about it.
357    #[must_use]
358    pub fn wire_data(&self) -> Option<JsonObject> {
359        let data = match self {
360            Self::UnsupportedVersion {
361                requested,
362                supported,
363            } => serde_json::json!({ "requested": requested, "supported": supported }),
364            Self::MissingClientCapability { required } => {
365                let capabilities: serde_json::Map<String, serde_json::Value> = required
366                    .iter()
367                    .map(|name| {
368                        (
369                            (*name).to_owned(),
370                            serde_json::Value::Object(serde_json::Map::new()),
371                        )
372                    })
373                    .collect();
374                serde_json::json!({ "requiredCapabilities": capabilities })
375            }
376            _ => return None,
377        };
378        JsonObject::from_value(data).ok()
379    }
380
381    /// The code and message pair a transport writes to the wire.
382    ///
383    /// The message is [`thiserror`]'s own for the variant, never the method
384    /// name the peer chose: a method name is a JSON string a peer controls, and
385    /// JSON permits a newline inside one, so echoing it into a log line would
386    /// let a peer close that line and open one of its own.
387    #[must_use]
388    pub fn as_wire(&self) -> (IrErrorCode, String) {
389        (self.jsonrpc_code(), self.to_string())
390    }
391}
392
393impl From<crate::application::server::result::OutcomeError> for DispatchError {
394    /// A reader or getter that could not produce a document failed.
395    ///
396    /// Every variant becomes [`DispatchError::HandlerFailed`] carrying the
397    /// outcome's own message. None of them is a bad request — the peer named a
398    /// resource or a prompt that exists and sent well-formed params — so
399    /// reporting any of them as `InvalidParams` would send a client looking in
400    /// its own request for something it got right.
401    fn from(error: crate::application::server::result::OutcomeError) -> Self {
402        Self::HandlerFailed(error.to_string())
403    }
404}
405
406impl From<IdempotencyError> for DispatchError {
407    /// Keeps a poisoned or absent store from reading as an absent claim.
408    fn from(error: IdempotencyError) -> Self {
409        Self::IdempotencyUnavailable(error.to_string())
410    }
411}