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}