oxicode_agent/mcp/error.rs
1//! Typed errors for MCP operations.
2//!
3//! Introduced for the consent gate (F-2, code audit 2026-07-25): new MCP
4//! code paths use a typed error enum instead of opaque `anyhow` strings, so
5//! callers can match on [`McpError::ConsentDenied`] without downcasting.
6//!
7//! Scope note: the rest of the `mcp` module still uses `anyhow::Result` for
8//! historical reasons (tracked as finding F-7). This module is the typed
9//! beachhead — new error-bearing code should extend it rather than reach for
10//! `anyhow::anyhow!`.
11
12use thiserror::Error;
13
14/// Errors raised by the MCP manager's consent-aware paths.
15#[derive(Debug, Error)]
16pub enum McpError {
17 /// Server spawn/connect denied because the server is not in the consent
18 /// allow-list (its stored [`crate::mcp::ConsentState`] is `Ask` for an
19 /// unknown server, or `Deny` after an explicit revocation).
20 ///
21 /// Actionable: the message names the `oxicode mcp trust <server>` command so
22 /// the user knows exactly how to proceed.
23 #[error(
24 "MCP server {server:?} is not trusted — run `oxicode mcp trust {server}` to allow, \
25 or remove the entry from mcp.json if you did not configure it"
26 )]
27 ConsentDenied {
28 /// The server name as it appears in `mcp.json` / `mcpServers`.
29 server: String,
30 },
31}
32
33#[cfg(test)]
34mod tests {
35 use super::*;
36
37 #[test]
38 fn consent_denied_display_names_server_and_command() {
39 let e = McpError::ConsentDenied {
40 server: "github".to_string(),
41 };
42 let msg = format!("{e}");
43 assert!(msg.contains("github"), "message must name the server");
44 assert!(
45 msg.contains("oxicode mcp trust github"),
46 "message must surface the exact remediation command, got: {msg}"
47 );
48 }
49
50 #[test]
51 fn consent_denied_is_send_and_sync() {
52 // Ensures the error can travel through anyhow::Result across async
53 // boundaries (tokio requires Send).
54 fn assert_send_sync<T: Send + Sync + 'static>() {}
55 assert_send_sync::<McpError>();
56 }
57}