Skip to main content

everruns_core/
mcp_server.rs

1// MCP Server domain types
2//
3// Spec: specs/mcp.md (umbrella), specs/mcp-servers.md (detail)
4//
5// These types represent the MCP (Model Context Protocol) server configuration.
6// Used by both API and worker crates.
7//
8// Currently supports only HTTP (Streamable HTTP) transport.
9// MCP tool types follow the MCP specification for tool discovery and execution.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::{BTreeMap, HashMap};
15
16use crate::typed_id::McpServerId;
17
18#[cfg(feature = "openapi")]
19use utoipa::ToSchema;
20
21/// MCP Server transport type.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[cfg_attr(feature = "openapi", derive(ToSchema))]
24#[cfg_attr(feature = "openapi", schema(example = "http"))]
25#[serde(rename_all = "lowercase")]
26pub enum McpServerTransportType {
27    /// HTTP (Streamable HTTP) transport.
28    Http,
29    /// Local-process transport over stdio. Only usable by single-tenant
30    /// runtime/CLI hosts (e.g. the example coding CLI); the hosted product
31    /// rejects it during scoped-config validation (see specs/runtime-mcp.md).
32    Stdio,
33}
34
35impl McpServerTransportType {
36    /// Whether this transport spawns/contacts a local process rather than a
37    /// remote endpoint.
38    pub fn is_local(&self) -> bool {
39        matches!(self, McpServerTransportType::Stdio)
40    }
41}
42
43/// MCP server authentication mode.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
45#[cfg_attr(feature = "openapi", derive(ToSchema))]
46#[cfg_attr(feature = "openapi", schema(example = "api_key"))]
47#[serde(rename_all = "snake_case")]
48pub enum McpServerAuthMode {
49    /// No authentication required.
50    #[default]
51    None,
52    /// Organization-scoped API key stored on the MCP server config.
53    ApiKey,
54    /// User-scoped OAuth token resolved at runtime.
55    OAuth,
56}
57
58impl std::fmt::Display for McpServerAuthMode {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            McpServerAuthMode::None => write!(f, "none"),
62            McpServerAuthMode::ApiKey => write!(f, "api_key"),
63            McpServerAuthMode::OAuth => write!(f, "oauth"),
64        }
65    }
66}
67
68impl From<&str> for McpServerAuthMode {
69    fn from(s: &str) -> Self {
70        match s {
71            "api_key" => McpServerAuthMode::ApiKey,
72            "oauth" => McpServerAuthMode::OAuth,
73            _ => McpServerAuthMode::None,
74        }
75    }
76}
77
78impl McpServerAuthMode {
79    pub fn is_none(&self) -> bool {
80        matches!(self, McpServerAuthMode::None)
81    }
82}
83
84// ============================================================================
85// MCP protocol versions and per-server adoption policy
86// ============================================================================
87//
88// Everruns' MCP *client* speaks three protocol eras. They differ in how the
89// connection is established and what metadata travels with each request:
90//
91// - Legacy `2025-03-26` / current `2025-06-18`: *stateful*. The client must run
92//   the `initialize` handshake, may receive an `Mcp-Session-Id` it has to echo
93//   on every subsequent request, and sends `notifications/initialized`.
94// - RC `2026-07-28`: *stateless*. No handshake and no session id; protocol
95//   version + client info ride in `_meta` on every request, and routable
96//   headers (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`) let edge
97//   infrastructure route without parsing the body.
98//
99// See specs/mcp-servers.md (Multi-era protocol support) and the negotiation
100// engine in `everruns-mcp` (`protocol.rs`).
101
102/// Legacy MCP protocol version (stateful handshake).
103pub const MCP_PROTOCOL_VERSION_LEGACY: &str = "2025-03-26";
104/// Current stable MCP protocol version (stateful handshake).
105pub const MCP_PROTOCOL_VERSION_STABLE: &str = "2025-06-18";
106/// 2026 stateless release-candidate MCP protocol version.
107pub const MCP_PROTOCOL_VERSION_RC: &str = "2026-07-28";
108
109/// Per-server policy for which MCP protocol era the client uses.
110///
111/// `Auto` (the default) probes the server and adapts — it tries the stateless
112/// RC path first and transparently falls back to the stateful handshake when a
113/// server demands it, so a single configuration speaks to legacy, current, and
114/// RC servers without operator action. The pinned variants skip negotiation
115/// when an operator knows a server's era (or to work around a server that
116/// mis-signals it).
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
118#[cfg_attr(feature = "openapi", derive(ToSchema))]
119#[cfg_attr(feature = "openapi", schema(example = "auto"))]
120#[serde(rename_all = "snake_case")]
121pub enum McpProtocolMode {
122    /// Probe once, detect the server's era, adapt, and cache the verdict.
123    #[default]
124    Auto,
125    /// Pin to legacy `2025-03-26` stateful behavior (handshake + session id).
126    Legacy,
127    /// Pin to current `2025-06-18` stateful behavior (handshake + session id).
128    Stable,
129    /// Pin to the `2026-07-28` stateless release candidate (`_meta` per
130    /// request, routable headers, no handshake).
131    Rc,
132}
133
134impl McpProtocolMode {
135    /// Whether this is the default `Auto` policy. Used to keep the field out of
136    /// serialized config when it carries no information.
137    pub fn is_auto(&self) -> bool {
138        matches!(self, McpProtocolMode::Auto)
139    }
140
141    /// The protocol version string a *pinned* mode advertises. `Auto` returns
142    /// `None` because its version is decided by negotiation at runtime.
143    pub fn pinned_version(&self) -> Option<&'static str> {
144        match self {
145            McpProtocolMode::Auto => None,
146            McpProtocolMode::Legacy => Some(MCP_PROTOCOL_VERSION_LEGACY),
147            McpProtocolMode::Stable => Some(MCP_PROTOCOL_VERSION_STABLE),
148            McpProtocolMode::Rc => Some(MCP_PROTOCOL_VERSION_RC),
149        }
150    }
151
152    /// Whether a pinned mode requires the stateful `initialize` handshake.
153    /// `Auto` returns `None` (decided by negotiation).
154    pub fn pinned_stateful(&self) -> Option<bool> {
155        match self {
156            McpProtocolMode::Auto => None,
157            McpProtocolMode::Legacy | McpProtocolMode::Stable => Some(true),
158            McpProtocolMode::Rc => Some(false),
159        }
160    }
161}
162
163impl std::fmt::Display for McpProtocolMode {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            McpProtocolMode::Auto => write!(f, "auto"),
167            McpProtocolMode::Legacy => write!(f, "legacy"),
168            McpProtocolMode::Stable => write!(f, "stable"),
169            McpProtocolMode::Rc => write!(f, "rc"),
170        }
171    }
172}
173
174impl From<&str> for McpProtocolMode {
175    fn from(s: &str) -> Self {
176        match s {
177            "legacy" => McpProtocolMode::Legacy,
178            "stable" => McpProtocolMode::Stable,
179            "rc" => McpProtocolMode::Rc,
180            _ => McpProtocolMode::Auto,
181        }
182    }
183}
184
185/// Normalize a JSON-RPC error code across MCP eras.
186///
187/// The RC renumbered the legacy MCP-specific `-32002` ("invalid params"-class
188/// failure) onto the standard JSON-RPC `-32602` ("Invalid params"). Callers
189/// that branch on the code should normalize first so legacy and RC servers are
190/// handled identically.
191pub fn normalize_mcp_error_code(code: i64) -> i64 {
192    match code {
193        -32002 => -32602,
194        other => other,
195    }
196}
197
198impl std::fmt::Display for McpServerTransportType {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            McpServerTransportType::Http => write!(f, "http"),
202            McpServerTransportType::Stdio => write!(f, "stdio"),
203        }
204    }
205}
206
207impl From<&str> for McpServerTransportType {
208    fn from(s: &str) -> Self {
209        match s {
210            "stdio" => McpServerTransportType::Stdio,
211            // Default to HTTP for "http" and any unknown value.
212            _ => McpServerTransportType::Http,
213        }
214    }
215}
216
217/// MCP Server lifecycle status.
218/// - `active`: Server is available for use
219/// - `disabled`: Server is disabled and not used
220/// - `archived`: Server is hidden from listings and cannot be modified or assigned
221/// - `deleted`: Server is a tombstone kept only for historical references
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "openapi", derive(ToSchema))]
224#[cfg_attr(feature = "openapi", schema(example = "active"))]
225#[serde(rename_all = "lowercase")]
226pub enum McpServerStatus {
227    /// Server is available for use.
228    Active,
229    /// Server is disabled and not used.
230    Disabled,
231    /// Server is hidden from listings and cannot be modified or assigned.
232    Archived,
233    /// Server is deleted and should only survive as a tombstone for references.
234    Deleted,
235}
236
237impl std::fmt::Display for McpServerStatus {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        match self {
240            McpServerStatus::Active => write!(f, "active"),
241            McpServerStatus::Disabled => write!(f, "disabled"),
242            McpServerStatus::Archived => write!(f, "archived"),
243            McpServerStatus::Deleted => write!(f, "deleted"),
244        }
245    }
246}
247
248impl From<&str> for McpServerStatus {
249    fn from(s: &str) -> Self {
250        match s {
251            "disabled" => McpServerStatus::Disabled,
252            "archived" => McpServerStatus::Archived,
253            "deleted" => McpServerStatus::Deleted,
254            _ => McpServerStatus::Active,
255        }
256    }
257}
258
259/// MCP Server configuration.
260/// Represents a remote MCP server that can provide tools and resources.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[cfg_attr(feature = "openapi", derive(ToSchema))]
263pub struct McpServer {
264    /// Unique identifier for the MCP server.
265    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "mcp_01933b5a00007000800000000000001"))]
266    pub id: McpServerId,
267    /// Display name of the MCP server.
268    #[cfg_attr(feature = "openapi", schema(example = "atlassian-mcp-server"))]
269    pub name: String,
270    /// Human-readable description of the MCP server.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    #[cfg_attr(
273        feature = "openapi",
274        schema(example = "Atlassian MCP Server for Jira and Confluence")
275    )]
276    pub description: Option<String>,
277    /// URL of the MCP server endpoint.
278    #[cfg_attr(
279        feature = "openapi",
280        schema(example = "https://mcp.atlassian.com/v1/mcp")
281    )]
282    pub url: String,
283    /// Transport type (currently only HTTP supported).
284    pub transport_type: McpServerTransportType,
285    /// Current lifecycle status of the MCP server.
286    pub status: McpServerStatus,
287    /// Authentication mode for this MCP server.
288    #[serde(default)]
289    pub auth_mode: McpServerAuthMode,
290    /// Protocol-era adoption policy for the MCP client (`auto` negotiates).
291    #[serde(default, skip_serializing_if = "McpProtocolMode::is_auto")]
292    pub protocol_mode: McpProtocolMode,
293    /// Stable provider id used for user-scoped OAuth connections.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub oauth_provider_id: Option<String>,
296    /// Whether an API key has been configured.
297    pub api_key_set: bool,
298    /// Additional HTTP headers for authentication.
299    /// Keys are header names, values are header values.
300    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
301    pub headers: HashMap<String, String>,
302    /// Timestamp when the MCP server was created.
303    pub created_at: DateTime<Utc>,
304    /// Timestamp when the MCP server was last updated.
305    pub updated_at: DateTime<Utc>,
306    /// Timestamp when the MCP server was archived.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub archived_at: Option<DateTime<Utc>>,
309    /// Timestamp when the MCP server was deleted.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub deleted_at: Option<DateTime<Utc>>,
312}
313
314/// Session-, agent-, or harness-scoped remote MCP server configuration.
315///
316/// This intentionally mirrors the `mcpServers` object shape used by common MCP
317/// client config files while staying within Everruns' current remote-HTTP-only
318/// support.
319#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320#[cfg_attr(feature = "openapi", derive(ToSchema))]
321pub struct ScopedMcpServer {
322    /// MCP transport type. Only remote HTTP is supported today.
323    #[serde(
324        default = "default_scoped_transport_type",
325        rename = "type",
326        alias = "transport_type"
327    )]
328    pub transport_type: McpServerTransportType,
329    /// URL of the remote MCP server endpoint. Required for HTTP transport;
330    /// empty/ignored for stdio.
331    #[serde(default, skip_serializing_if = "String::is_empty")]
332    pub url: String,
333    /// Additional HTTP headers sent on MCP requests (HTTP transport only).
334    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
335    pub headers: HashMap<String, String>,
336    /// Executable to spawn for a stdio transport server.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub command: Option<String>,
339    /// Arguments passed to the stdio `command`.
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub args: Vec<String>,
342    /// Environment variables set for the stdio `command`.
343    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
344    pub env: HashMap<String, String>,
345    /// Authentication mode used when executing tools from this scoped server.
346    #[serde(default, skip_serializing_if = "McpServerAuthMode::is_none")]
347    pub auth_mode: McpServerAuthMode,
348    /// Protocol-era adoption policy for the MCP client (`auto` negotiates).
349    #[serde(default, skip_serializing_if = "McpProtocolMode::is_auto")]
350    pub protocol_mode: McpProtocolMode,
351    /// Provider id used to resolve a user-scoped bearer token.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub oauth_provider_id: Option<String>,
354    /// Whether to discover tool definitions live from this server.
355    #[serde(
356        default = "default_scoped_tool_discovery",
357        skip_serializing_if = "is_true"
358    )]
359    pub tool_discovery: bool,
360}
361
362impl Default for ScopedMcpServer {
363    fn default() -> Self {
364        Self {
365            transport_type: McpServerTransportType::Http,
366            url: String::new(),
367            headers: HashMap::new(),
368            auth_mode: McpServerAuthMode::None,
369            protocol_mode: McpProtocolMode::Auto,
370            oauth_provider_id: None,
371            tool_discovery: true,
372            command: None,
373            args: Vec::new(),
374            env: HashMap::new(),
375        }
376    }
377}
378
379pub type ScopedMcpServers = BTreeMap<String, ScopedMcpServer>;
380
381fn default_scoped_transport_type() -> McpServerTransportType {
382    McpServerTransportType::Http
383}
384
385fn default_scoped_tool_discovery() -> bool {
386    true
387}
388
389fn is_true(value: &bool) -> bool {
390    *value
391}
392
393pub fn scoped_mcp_servers_is_empty(servers: &ScopedMcpServers) -> bool {
394    servers.is_empty()
395}
396
397/// Merge scoped MCP servers by logical server name. Later layers override earlier ones.
398pub fn merge_scoped_mcp_servers(
399    base: &ScopedMcpServers,
400    overlay: &ScopedMcpServers,
401) -> ScopedMcpServers {
402    let mut merged = base.clone();
403    merged.extend(overlay.clone());
404    merged
405}
406
407// ============================================================================
408// MCP Tool Types (following MCP specification)
409// ============================================================================
410
411/// MCP Tool definition as returned by tools/list.
412/// Follows the MCP specification for tool discovery.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414#[cfg_attr(feature = "openapi", derive(ToSchema))]
415pub struct McpToolDefinition {
416    /// Unique name of the tool within the MCP server.
417    pub name: String,
418    /// Human-readable description of what the tool does.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    pub description: Option<String>,
421    /// JSON Schema describing the tool's input parameters.
422    #[serde(rename = "inputSchema")]
423    pub input_schema: Value,
424    /// MCP tool annotations (behavioral hints).
425    /// See: <https://spec.modelcontextprotocol.io>
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub annotations: Option<McpToolAnnotations>,
428}
429
430/// MCP tool annotations as defined by the MCP specification.
431/// All fields are optional booleans following the MCP convention.
432#[derive(Debug, Clone, Serialize, Deserialize, Default)]
433#[cfg_attr(feature = "openapi", derive(ToSchema))]
434pub struct McpToolAnnotations {
435    #[serde(
436        default,
437        skip_serializing_if = "Option::is_none",
438        rename = "readOnlyHint"
439    )]
440    pub read_only_hint: Option<bool>,
441    #[serde(
442        default,
443        skip_serializing_if = "Option::is_none",
444        rename = "destructiveHint"
445    )]
446    pub destructive_hint: Option<bool>,
447    #[serde(
448        default,
449        skip_serializing_if = "Option::is_none",
450        rename = "idempotentHint"
451    )]
452    pub idempotent_hint: Option<bool>,
453    #[serde(
454        default,
455        skip_serializing_if = "Option::is_none",
456        rename = "openWorldHint"
457    )]
458    pub open_world_hint: Option<bool>,
459}
460
461/// Request for MCP tools/list endpoint.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct McpToolsListRequest {
464    pub jsonrpc: String,
465    pub id: i64,
466    pub method: String,
467}
468
469impl Default for McpToolsListRequest {
470    fn default() -> Self {
471        Self {
472            jsonrpc: "2.0".to_string(),
473            id: 1,
474            method: "tools/list".to_string(),
475        }
476    }
477}
478
479/// Response from MCP tools/list endpoint.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct McpToolsListResponse {
482    pub jsonrpc: String,
483    pub id: i64,
484    #[serde(default)]
485    pub result: Option<McpToolsListResult>,
486    #[serde(default)]
487    pub error: Option<McpError>,
488}
489
490/// Result of tools/list containing the list of tools.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct McpToolsListResult {
493    pub tools: Vec<McpToolDefinition>,
494    #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
495    pub next_cursor: Option<String>,
496}
497
498/// MCP error response.
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct McpError {
501    pub code: i64,
502    pub message: String,
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub data: Option<Value>,
505}
506
507/// Request for MCP tools/call endpoint.
508#[derive(Debug, Clone, Serialize, Deserialize)]
509pub struct McpToolCallRequest {
510    pub jsonrpc: String,
511    pub id: i64,
512    pub method: String,
513    pub params: McpToolCallParams,
514}
515
516/// Parameters for tools/call request.
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct McpToolCallParams {
519    pub name: String,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub arguments: Option<Value>,
522}
523
524impl McpToolCallRequest {
525    pub fn new(id: i64, name: String, arguments: Option<Value>) -> Self {
526        Self {
527            jsonrpc: "2.0".to_string(),
528            id,
529            method: "tools/call".to_string(),
530            params: McpToolCallParams { name, arguments },
531        }
532    }
533}
534
535/// Response from MCP tools/call endpoint.
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct McpToolCallResponse {
538    pub jsonrpc: String,
539    pub id: i64,
540    #[serde(default)]
541    pub result: Option<McpToolCallResult>,
542    #[serde(default)]
543    pub error: Option<McpError>,
544}
545
546/// Result of tools/call containing content.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct McpToolCallResult {
549    pub content: Vec<McpContent>,
550    #[serde(rename = "isError", default)]
551    pub is_error: bool,
552}
553
554/// MCP content type (text, image, etc.).
555#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(tag = "type")]
557pub enum McpContent {
558    #[serde(rename = "text")]
559    Text { text: String },
560    #[serde(rename = "image")]
561    Image { data: String, mime_type: String },
562    #[serde(rename = "resource")]
563    Resource {
564        uri: String,
565        mime_type: Option<String>,
566        text: Option<String>,
567    },
568}
569
570/// Helper to generate prefixed tool name for MCP tools.
571/// Format: mcp_{server_name}__{tool_name} (double underscore separator)
572/// The double underscore allows unambiguous parsing when server names contain underscores.
573pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
574    format!(
575        "mcp_{}__{}",
576        sanitize_mcp_server_name(server_name),
577        tool_name
578    )
579}
580
581/// Sanitize an MCP server name into a stable tool-name prefix.
582pub fn sanitize_mcp_server_name(server_name: &str) -> String {
583    server_name
584        .to_lowercase()
585        .chars()
586        .map(|c| if c.is_alphanumeric() { c } else { '_' })
587        .collect::<String>()
588}
589
590/// Check if a tool name is an MCP tool (starts with "mcp_").
591pub fn is_mcp_tool(tool_name: &str) -> bool {
592    tool_name.starts_with("mcp_")
593}
594
595/// Parse MCP tool name to extract server name prefix and original tool name.
596/// Returns (server_name_prefix, original_tool_name) if valid MCP tool.
597/// Expected format: mcp_{server_name}__{tool_name} (double underscore separator)
598pub fn parse_mcp_tool_name(tool_name: &str) -> Option<(String, String)> {
599    if !tool_name.starts_with("mcp_") {
600        return None;
601    }
602    let rest = &tool_name[4..]; // Skip "mcp_"
603    // Find the double underscore separator between server name and tool name
604    if let Some(pos) = rest.find("__") {
605        let server_prefix = rest[..pos].to_string();
606        let original_name = rest[pos + 2..].to_string(); // Skip "__"
607        if !server_prefix.is_empty() && !original_name.is_empty() {
608            return Some((server_prefix, original_name));
609        }
610    }
611    None
612}
613
614/// Stable connection-provider id for an OAuth-enabled MCP server.
615pub fn mcp_oauth_provider_id_for_uuid(server_id: uuid::Uuid) -> String {
616    format!("mcp_oauth_{}", server_id)
617}
618
619/// Secret name for a session-scoped MCP OAuth token field.
620pub fn mcp_oauth_session_secret_name(server_id: uuid::Uuid, field: &str) -> String {
621    format!("mcp_oauth:{}:{}", server_id, field)
622}
623
624// ============================================================================
625// Structured execute errors (EVE-492)
626// ============================================================================
627
628/// Closed vocabulary of error codes for Everruns' own MCP `tools/call`
629/// execute path. Surfaces in [`McpExecuteError::code`] so LLM toolcallers
630/// can branch on a machine-readable value instead of regexing prose.
631///
632/// New variants are a spec change. SDKs should treat any value they don't
633/// recognise as `unknown` (forward-compat) — serde's `#[serde(other)]`
634/// catch-all enables that on the deserialize side.
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
636#[cfg_attr(feature = "openapi", derive(ToSchema))]
637#[serde(rename_all = "snake_case")]
638pub enum McpErrorCode {
639    /// Tool name doesn't match any registered tool.
640    ToolNotFound,
641    /// Tool timed out (server-imposed budget exceeded).
642    ToolTimeout,
643    /// Tool panicked or hit an unrecoverable internal error.
644    ToolPanicked,
645    /// Required argument missing or argument failed validation.
646    InvalidArguments,
647    /// Caller is authenticated but not authorized for the requested action
648    /// or org scope.
649    PermissionDenied,
650    /// Org/user quota or rate limit hit.
651    QuotaExceeded,
652    /// Outbound network call blocked by egress policy.
653    NetworkBlocked,
654    /// Upstream MCP server unreachable or returned an error we couldn't
655    /// classify.
656    McpServerUnreachable,
657    /// Catch-all for unclassified internal failures. Treat as transient
658    /// only if `retryable` is also true.
659    Internal,
660    /// Forward-compat sentinel — SDKs see this when the server returns a
661    /// code they don't know yet.
662    #[serde(other)]
663    Unknown,
664}
665
666impl McpErrorCode {
667    /// Stable wire string for this variant. Mirrors what `serde` emits so
668    /// non-Rust SDKs and tests can match on the same value.
669    pub fn as_str(&self) -> &'static str {
670        match self {
671            McpErrorCode::ToolNotFound => "tool_not_found",
672            McpErrorCode::ToolTimeout => "tool_timeout",
673            McpErrorCode::ToolPanicked => "tool_panicked",
674            McpErrorCode::InvalidArguments => "invalid_arguments",
675            McpErrorCode::PermissionDenied => "permission_denied",
676            McpErrorCode::QuotaExceeded => "quota_exceeded",
677            McpErrorCode::NetworkBlocked => "network_blocked",
678            McpErrorCode::McpServerUnreachable => "mcp_server_unreachable",
679            McpErrorCode::Internal => "internal",
680            McpErrorCode::Unknown => "unknown",
681        }
682    }
683
684    /// Default category for this code. Callers may override per-occurrence
685    /// when context narrows the classification (e.g. an `Internal` with a
686    /// known-transient root cause).
687    pub fn default_category(&self) -> McpErrorCategory {
688        match self {
689            McpErrorCode::ToolTimeout
690            | McpErrorCode::McpServerUnreachable
691            | McpErrorCode::QuotaExceeded => McpErrorCategory::Transient,
692            McpErrorCode::InvalidArguments => McpErrorCategory::Validation,
693            McpErrorCode::PermissionDenied => McpErrorCategory::Auth,
694            McpErrorCode::ToolNotFound
695            | McpErrorCode::ToolPanicked
696            | McpErrorCode::NetworkBlocked => McpErrorCategory::Permanent,
697            McpErrorCode::Internal | McpErrorCode::Unknown => McpErrorCategory::Permanent,
698        }
699    }
700
701    /// Default retryability for this code. Same override caveat as
702    /// `default_category`.
703    pub fn default_retryable(&self) -> bool {
704        matches!(
705            self,
706            McpErrorCode::ToolTimeout
707                | McpErrorCode::McpServerUnreachable
708                | McpErrorCode::QuotaExceeded
709        )
710    }
711}
712
713/// Broad-strokes routing hint sitting alongside the precise [`McpErrorCode`].
714/// The categories are stable enough that an LLM can pick a recovery
715/// strategy from this field alone (e.g. retry transients with backoff,
716/// surface validation errors to the user, escalate auth failures).
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
718#[cfg_attr(feature = "openapi", derive(ToSchema))]
719#[serde(rename_all = "snake_case")]
720pub enum McpErrorCategory {
721    /// Worth retrying — same call, possibly after `retry_after_seconds`.
722    Transient,
723    /// Repeating the same call will fail the same way.
724    Permanent,
725    /// Caller-side problem (bad arguments, schema mismatch).
726    Validation,
727    /// Authentication/authorization issue.
728    Auth,
729    /// Forward-compat sentinel.
730    #[serde(other)]
731    Unknown,
732}
733
734/// Typed structured-error envelope returned by Everruns' MCP `tools/call`
735/// execute path. Serialized into the MCP `structuredContent` field on
736/// error responses so the legacy `content[0].text` channel stays
737/// backward-compatible; new SDKs prefer the typed envelope.
738///
739/// See `specs/mcp.md` for the error contract.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741#[cfg_attr(feature = "openapi", derive(ToSchema))]
742pub struct McpExecuteError {
743    /// Machine-readable error code. Closed vocabulary; SDKs that see an
744    /// unrecognised value should map it to `unknown`.
745    pub code: McpErrorCode,
746    /// Human-readable error message. Mirrors the legacy
747    /// `content[0].text` string for backward compat.
748    pub message: String,
749    /// Broad-strokes recovery category.
750    pub category: McpErrorCategory,
751    /// `true` when the same call is worth retrying. Distinct from
752    /// `category == "transient"` because a server may know about a
753    /// non-transient retry path (e.g. a transient `Internal`).
754    pub retryable: bool,
755    /// Seconds the caller should wait before retrying. Set on
756    /// `tool_timeout`, `quota_exceeded`, and upstream-unreachable cases
757    /// when the server has a concrete back-off hint.
758    #[serde(skip_serializing_if = "Option::is_none")]
759    pub retry_after_seconds: Option<u32>,
760    /// Short, agent-readable recovery hint. Free-form; one or two sentences.
761    #[serde(skip_serializing_if = "Option::is_none")]
762    pub hint: Option<String>,
763    /// Chain of upstream error messages, oldest cause first. Useful for
764    /// debugging; SDKs should not treat this as machine-readable.
765    #[serde(default, skip_serializing_if = "Vec::is_empty")]
766    pub cause_chain: Vec<String>,
767}
768
769impl McpExecuteError {
770    /// Construct an error using the code's default category and
771    /// retryability. Callers can chain `.with_*` to override.
772    pub fn new(code: McpErrorCode, message: impl Into<String>) -> Self {
773        Self {
774            category: code.default_category(),
775            retryable: code.default_retryable(),
776            code,
777            message: message.into(),
778            retry_after_seconds: None,
779            hint: None,
780            cause_chain: Vec::new(),
781        }
782    }
783
784    pub fn with_category(mut self, category: McpErrorCategory) -> Self {
785        self.category = category;
786        self
787    }
788
789    pub fn with_retryable(mut self, retryable: bool) -> Self {
790        self.retryable = retryable;
791        self
792    }
793
794    pub fn with_retry_after_seconds(mut self, seconds: u32) -> Self {
795        self.retry_after_seconds = Some(seconds);
796        self
797    }
798
799    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
800        self.hint = Some(hint.into());
801        self
802    }
803
804    pub fn with_cause(mut self, cause: impl Into<String>) -> Self {
805        self.cause_chain.push(cause.into());
806        self
807    }
808}
809
810/// Classify a free-form error string raised by an internal MCP tool
811/// implementation into a structured envelope. The implementations
812/// currently return `Result<String, String>`; this is the boundary
813/// where we recover the structure from prose. Pattern matches are
814/// intentionally narrow (substrings, not regexes) so the classifier
815/// fails open to `Internal` rather than mis-categorising.
816///
817/// **Convention for new error messages**: prefer constructing the
818/// `McpExecuteError` directly (via a future `McpExecuteError`-typed
819/// `Result`) instead of relying on this classifier. The classifier
820/// exists to give the legacy `String` error path structure without
821/// rewriting every tool first.
822pub fn classify_mcp_execute_error(message: &str) -> McpExecuteError {
823    let lower = message.to_ascii_lowercase();
824    // Catalog-backed query/execute tools format their dispatch errors as
825    // `<kind>: <message>` (see `crates/server/src/api/mcp_endpoint/catalog.rs::format_dispatch_error`
826    // and the public contract in `specs/domains.md`). Map those prefixes
827    // first so the most common real-world MCP failures get a precise code
828    // rather than landing in the `Internal` catch-all.
829    let code = if lower.starts_with("bad_request:") || lower.starts_with("unprocessable:") {
830        McpErrorCode::InvalidArguments
831    } else if lower.starts_with("not_found:") {
832        McpErrorCode::ToolNotFound
833    } else if lower.starts_with("conflict:") {
834        // No dedicated `conflict` code today; surface as a validation
835        // failure since the caller's input is the proximate cause and
836        // a retry without changes won't succeed.
837        McpErrorCode::InvalidArguments
838    } else if lower.starts_with("forbidden:") {
839        McpErrorCode::PermissionDenied
840    } else if lower.starts_with("internal:") {
841        McpErrorCode::Internal
842    // Order matters: more specific patterns first.
843    } else if lower.contains("timed out") || lower.contains("timeout") {
844        McpErrorCode::ToolTimeout
845    } else if lower.starts_with("unknown tool") {
846        McpErrorCode::ToolNotFound
847    } else if lower.starts_with("missing required parameter") || lower.contains("invalid argument")
848    {
849        McpErrorCode::InvalidArguments
850    } else if lower.contains("permission denied")
851        || lower.contains("forbidden")
852        || lower.contains("not authorized")
853        || lower.contains("unauthorized")
854    {
855        McpErrorCode::PermissionDenied
856    } else if lower.contains("quota") || lower.contains("rate limit") {
857        McpErrorCode::QuotaExceeded
858    } else if lower.contains("network blocked") || lower.contains("egress") {
859        McpErrorCode::NetworkBlocked
860    } else if lower.contains("mcp server") && lower.contains("unreachable") {
861        McpErrorCode::McpServerUnreachable
862    } else if lower.contains("panicked") {
863        McpErrorCode::ToolPanicked
864    } else {
865        McpErrorCode::Internal
866    };
867    McpExecuteError::new(code, message)
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    #[test]
875    fn protocol_mode_defaults_to_auto() {
876        assert_eq!(McpProtocolMode::default(), McpProtocolMode::Auto);
877        assert!(McpProtocolMode::default().is_auto());
878    }
879
880    #[test]
881    fn protocol_mode_serde_round_trips_snake_case() {
882        for (mode, json) in [
883            (McpProtocolMode::Auto, "\"auto\""),
884            (McpProtocolMode::Legacy, "\"legacy\""),
885            (McpProtocolMode::Stable, "\"stable\""),
886            (McpProtocolMode::Rc, "\"rc\""),
887        ] {
888            assert_eq!(serde_json::to_string(&mode).unwrap(), json);
889            let back: McpProtocolMode = serde_json::from_str(json).unwrap();
890            assert_eq!(back, mode);
891        }
892    }
893
894    #[test]
895    fn protocol_mode_pinned_version_and_statefulness() {
896        assert_eq!(McpProtocolMode::Auto.pinned_version(), None);
897        assert_eq!(McpProtocolMode::Auto.pinned_stateful(), None);
898        assert_eq!(
899            McpProtocolMode::Legacy.pinned_version(),
900            Some(MCP_PROTOCOL_VERSION_LEGACY)
901        );
902        assert_eq!(McpProtocolMode::Legacy.pinned_stateful(), Some(true));
903        assert_eq!(
904            McpProtocolMode::Stable.pinned_version(),
905            Some(MCP_PROTOCOL_VERSION_STABLE)
906        );
907        assert_eq!(McpProtocolMode::Stable.pinned_stateful(), Some(true));
908        assert_eq!(
909            McpProtocolMode::Rc.pinned_version(),
910            Some(MCP_PROTOCOL_VERSION_RC)
911        );
912        assert_eq!(McpProtocolMode::Rc.pinned_stateful(), Some(false));
913    }
914
915    #[test]
916    fn scoped_mcp_server_omits_auto_protocol_mode_but_keeps_pinned() {
917        // Default (auto) is skipped on the wire so existing config is byte-identical.
918        let auto = ScopedMcpServer {
919            url: "https://example.com/mcp".to_string(),
920            ..Default::default()
921        };
922        let json = serde_json::to_value(&auto).unwrap();
923        assert!(
924            json.get("protocol_mode").is_none(),
925            "auto protocol_mode must not serialize: {json}"
926        );
927
928        // A pinned mode is preserved.
929        let pinned = ScopedMcpServer {
930            url: "https://example.com/mcp".to_string(),
931            protocol_mode: McpProtocolMode::Legacy,
932            ..Default::default()
933        };
934        let json = serde_json::to_value(&pinned).unwrap();
935        assert_eq!(
936            json.get("protocol_mode").and_then(|v| v.as_str()),
937            Some("legacy")
938        );
939    }
940
941    #[test]
942    fn scoped_mcp_server_parses_protocol_mode_from_mcp_json_shape() {
943        // `.mcp.json`-style config can pin an era; absence means auto.
944        let with_mode: ScopedMcpServer = serde_json::from_value(serde_json::json!({
945            "type": "http",
946            "url": "https://example.com/mcp",
947            "protocol_mode": "rc"
948        }))
949        .unwrap();
950        assert_eq!(with_mode.protocol_mode, McpProtocolMode::Rc);
951
952        let without_mode: ScopedMcpServer = serde_json::from_value(serde_json::json!({
953            "type": "http",
954            "url": "https://example.com/mcp"
955        }))
956        .unwrap();
957        assert_eq!(without_mode.protocol_mode, McpProtocolMode::Auto);
958    }
959
960    #[test]
961    fn merge_scoped_mcp_servers_lets_later_layer_override_protocol_mode() {
962        // Session can pin an era over a harness/agent default — last-wins layering.
963        let mut base = ScopedMcpServers::default();
964        base.insert(
965            "docs".to_string(),
966            ScopedMcpServer {
967                url: "https://example.com/mcp".to_string(),
968                protocol_mode: McpProtocolMode::Auto,
969                ..Default::default()
970            },
971        );
972        let mut overlay = ScopedMcpServers::default();
973        overlay.insert(
974            "docs".to_string(),
975            ScopedMcpServer {
976                url: "https://example.com/mcp".to_string(),
977                protocol_mode: McpProtocolMode::Legacy,
978                ..Default::default()
979            },
980        );
981        let merged = merge_scoped_mcp_servers(&base, &overlay);
982        assert_eq!(
983            merged.get("docs").unwrap().protocol_mode,
984            McpProtocolMode::Legacy
985        );
986    }
987
988    #[test]
989    fn normalize_mcp_error_code_maps_legacy_to_rc() {
990        // RC renumbered -32002 onto the standard -32602; everything else passes through.
991        assert_eq!(normalize_mcp_error_code(-32002), -32602);
992        assert_eq!(normalize_mcp_error_code(-32602), -32602);
993        assert_eq!(normalize_mcp_error_code(-32601), -32601);
994        assert_eq!(normalize_mcp_error_code(0), 0);
995    }
996
997    #[test]
998    fn test_mcp_tool_name_simple() {
999        // Simple server name without special characters
1000        assert_eq!(mcp_tool_name("github", "search"), "mcp_github__search");
1001    }
1002
1003    #[test]
1004    fn test_mcp_tool_name_with_underscores() {
1005        // Server name with underscores (e.g., microsoft_learn)
1006        assert_eq!(
1007            mcp_tool_name("microsoft_learn", "docs_search"),
1008            "mcp_microsoft_learn__docs_search"
1009        );
1010    }
1011
1012    #[test]
1013    fn test_mcp_tool_name_with_dashes() {
1014        // Server name with dashes gets converted to underscores
1015        assert_eq!(
1016            mcp_tool_name("microsoft-learn", "search"),
1017            "mcp_microsoft_learn__search"
1018        );
1019    }
1020
1021    #[test]
1022    fn test_mcp_tool_name_uppercase() {
1023        // Server name is lowercased
1024        assert_eq!(mcp_tool_name("GitHub", "search"), "mcp_github__search");
1025    }
1026
1027    #[test]
1028    fn test_mcp_tool_name_special_chars() {
1029        // Special characters are replaced with underscores
1030        assert_eq!(
1031            mcp_tool_name("my.server.name", "tool"),
1032            "mcp_my_server_name__tool"
1033        );
1034    }
1035
1036    #[test]
1037    fn test_is_mcp_tool() {
1038        assert!(is_mcp_tool("mcp_github__search"));
1039        assert!(is_mcp_tool("mcp_microsoft_learn__docs_search"));
1040        assert!(!is_mcp_tool("get_weather"));
1041        assert!(!is_mcp_tool("mcpsearch")); // Must have underscore after mcp
1042    }
1043
1044    #[test]
1045    fn test_parse_mcp_tool_name_simple() {
1046        let result = parse_mcp_tool_name("mcp_github__search");
1047        assert_eq!(result, Some(("github".to_string(), "search".to_string())));
1048    }
1049
1050    #[test]
1051    fn test_parse_mcp_tool_name_with_underscores() {
1052        // Server name with underscores should be parsed correctly
1053        let result = parse_mcp_tool_name("mcp_microsoft_learn__docs_search");
1054        assert_eq!(
1055            result,
1056            Some(("microsoft_learn".to_string(), "docs_search".to_string()))
1057        );
1058    }
1059
1060    #[test]
1061    fn test_parse_mcp_tool_name_complex() {
1062        // Multiple underscores in both server name and tool name
1063        let result = parse_mcp_tool_name("mcp_my_long_server_name__my_complex_tool");
1064        assert_eq!(
1065            result,
1066            Some((
1067                "my_long_server_name".to_string(),
1068                "my_complex_tool".to_string()
1069            ))
1070        );
1071    }
1072
1073    #[test]
1074    fn test_parse_mcp_tool_name_invalid_prefix() {
1075        // Not an MCP tool
1076        assert_eq!(parse_mcp_tool_name("get_weather"), None);
1077    }
1078
1079    #[test]
1080    fn test_parse_mcp_tool_name_no_separator() {
1081        // Missing double underscore separator
1082        assert_eq!(parse_mcp_tool_name("mcp_github_search"), None);
1083    }
1084
1085    #[test]
1086    fn test_parse_mcp_tool_name_empty_parts() {
1087        // Empty server name or tool name
1088        assert_eq!(parse_mcp_tool_name("mcp___search"), None);
1089        assert_eq!(parse_mcp_tool_name("mcp_github__"), None);
1090    }
1091
1092    #[test]
1093    fn test_roundtrip() {
1094        // Generate and parse should roundtrip
1095        let server = "microsoft_learn";
1096        let tool = "docs_search";
1097        let full_name = mcp_tool_name(server, tool);
1098        let parsed = parse_mcp_tool_name(&full_name);
1099        assert_eq!(
1100            parsed,
1101            Some(("microsoft_learn".to_string(), "docs_search".to_string()))
1102        );
1103    }
1104
1105    // ------------------------------------------------------------------
1106    // McpExecuteError / McpErrorCode (EVE-492)
1107    // ------------------------------------------------------------------
1108
1109    #[test]
1110    fn mcp_error_code_serializes_to_snake_case_wire_string() {
1111        assert_eq!(
1112            serde_json::to_string(&McpErrorCode::ToolTimeout).unwrap(),
1113            "\"tool_timeout\""
1114        );
1115        assert_eq!(
1116            serde_json::to_string(&McpErrorCode::McpServerUnreachable).unwrap(),
1117            "\"mcp_server_unreachable\""
1118        );
1119    }
1120
1121    #[test]
1122    fn mcp_error_code_as_str_matches_serde_wire() {
1123        for code in [
1124            McpErrorCode::ToolNotFound,
1125            McpErrorCode::ToolTimeout,
1126            McpErrorCode::ToolPanicked,
1127            McpErrorCode::InvalidArguments,
1128            McpErrorCode::PermissionDenied,
1129            McpErrorCode::QuotaExceeded,
1130            McpErrorCode::NetworkBlocked,
1131            McpErrorCode::McpServerUnreachable,
1132            McpErrorCode::Internal,
1133            McpErrorCode::Unknown,
1134        ] {
1135            let wire = serde_json::to_string(&code).unwrap();
1136            assert_eq!(
1137                wire,
1138                format!("\"{}\"", code.as_str()),
1139                "as_str() must match serde wire for {code:?}"
1140            );
1141        }
1142    }
1143
1144    #[test]
1145    fn mcp_error_code_unknown_variant_is_forward_compat_sentinel() {
1146        // SDKs that receive a code they don't recognise should land on
1147        // `Unknown`, not fail to deserialise.
1148        let code: McpErrorCode = serde_json::from_str("\"future_code_we_dont_know_yet\"").unwrap();
1149        assert_eq!(code, McpErrorCode::Unknown);
1150    }
1151
1152    #[test]
1153    fn classify_recognises_timeout_substrings() {
1154        let err = classify_mcp_execute_error("Tool timed out after 30000ms");
1155        assert_eq!(err.code, McpErrorCode::ToolTimeout);
1156        assert_eq!(err.category, McpErrorCategory::Transient);
1157        assert!(err.retryable);
1158
1159        let err = classify_mcp_execute_error("Command timed out after 5000ms");
1160        assert_eq!(err.code, McpErrorCode::ToolTimeout);
1161    }
1162
1163    #[test]
1164    fn classify_recognises_tool_not_found() {
1165        let err = classify_mcp_execute_error("Unknown tool: github.foo");
1166        assert_eq!(err.code, McpErrorCode::ToolNotFound);
1167        assert_eq!(err.category, McpErrorCategory::Permanent);
1168        assert!(!err.retryable);
1169    }
1170
1171    #[test]
1172    fn classify_recognises_invalid_arguments() {
1173        let err = classify_mcp_execute_error("Missing required parameter: query");
1174        assert_eq!(err.code, McpErrorCode::InvalidArguments);
1175        assert_eq!(err.category, McpErrorCategory::Validation);
1176        assert!(!err.retryable);
1177    }
1178
1179    #[test]
1180    fn classify_recognises_permission_denied() {
1181        for msg in [
1182            "permission denied for org",
1183            "Forbidden: org scope not allowed",
1184            "not authorized to call this tool",
1185            "Unauthorized request",
1186        ] {
1187            let err = classify_mcp_execute_error(msg);
1188            assert_eq!(
1189                err.code,
1190                McpErrorCode::PermissionDenied,
1191                "expected PermissionDenied for {msg:?}"
1192            );
1193            assert_eq!(err.category, McpErrorCategory::Auth);
1194        }
1195    }
1196
1197    #[test]
1198    fn classify_recognises_quota_and_rate_limit() {
1199        let err = classify_mcp_execute_error("Quota exceeded for org");
1200        assert_eq!(err.code, McpErrorCode::QuotaExceeded);
1201        assert!(err.retryable);
1202
1203        let err = classify_mcp_execute_error("Rate limit hit");
1204        assert_eq!(err.code, McpErrorCode::QuotaExceeded);
1205    }
1206
1207    #[test]
1208    fn classify_recognises_catalog_dispatch_prefixes() {
1209        // `crates/server/src/api/mcp_endpoint/catalog.rs::format_dispatch_error`
1210        // emits `<kind>: <message>` for inventory-backed query/execute
1211        // tools. These are the public MCP contract per specs/domains.md,
1212        // so the classifier must route them to precise codes rather than
1213        // the catch-all `Internal` bucket.
1214        for (prefix, expected) in [
1215            (
1216                "bad_request: name must be <=200 chars",
1217                McpErrorCode::InvalidArguments,
1218            ),
1219            (
1220                "unprocessable: cycle detected in capability graph",
1221                McpErrorCode::InvalidArguments,
1222            ),
1223            (
1224                "conflict: session is already paused",
1225                McpErrorCode::InvalidArguments,
1226            ),
1227            (
1228                "not_found: agent agent_xyz not in this org",
1229                McpErrorCode::ToolNotFound,
1230            ),
1231            (
1232                "forbidden: principal lacks SESSION_WRITE",
1233                McpErrorCode::PermissionDenied,
1234            ),
1235            (
1236                "internal: storage backend returned 503",
1237                McpErrorCode::Internal,
1238            ),
1239        ] {
1240            let err = classify_mcp_execute_error(prefix);
1241            assert_eq!(err.code, expected, "expected {expected:?} for {prefix:?}");
1242        }
1243    }
1244
1245    #[test]
1246    fn classify_falls_open_to_internal() {
1247        // No known pattern → Internal, not a wrong guess. Retryable
1248        // defaults to false so callers don't burn retries on unknown
1249        // permanent failures.
1250        let err = classify_mcp_execute_error("strange unanticipated message");
1251        assert_eq!(err.code, McpErrorCode::Internal);
1252        assert_eq!(err.category, McpErrorCategory::Permanent);
1253        assert!(!err.retryable);
1254    }
1255
1256    #[test]
1257    fn mcp_execute_error_skips_empty_optional_fields() {
1258        let err = McpExecuteError::new(McpErrorCode::ToolNotFound, "no such tool");
1259        let value = serde_json::to_value(&err).unwrap();
1260        // Required fields present.
1261        assert_eq!(value["code"], "tool_not_found");
1262        assert_eq!(value["message"], "no such tool");
1263        assert_eq!(value["category"], "permanent");
1264        assert_eq!(value["retryable"], false);
1265        // Optional fields omitted entirely from the wire when empty.
1266        assert!(value.get("retry_after_seconds").is_none());
1267        assert!(value.get("hint").is_none());
1268        assert!(value.get("cause_chain").is_none());
1269    }
1270
1271    #[test]
1272    fn mcp_execute_error_builders_chain() {
1273        let err = McpExecuteError::new(McpErrorCode::ToolTimeout, "tool timed out after 30000ms")
1274            .with_retry_after_seconds(10)
1275            .with_hint("Reduce input size before retrying.")
1276            .with_cause("downstream: upstream gateway timeout");
1277        let value = serde_json::to_value(&err).unwrap();
1278        assert_eq!(value["code"], "tool_timeout");
1279        assert_eq!(value["retry_after_seconds"], 10);
1280        assert_eq!(value["hint"], "Reduce input size before retrying.");
1281        assert_eq!(
1282            value["cause_chain"][0],
1283            "downstream: upstream gateway timeout"
1284        );
1285    }
1286}