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