Skip to main content

mockforge_http/
mcp_mock.rs

1//! Mock MCP (Model Context Protocol) server (#913, #79 round 50 follow-up).
2//!
3//! Serves a JSON-RPC 2.0 endpoint at `POST /mcp` so an agent acting as an MCP
4//! client (the role Cursor / Claude Code / custom agents play when they call
5//! out to tool servers) can talk to MockForge as a fake MCP server. Answers
6//! the core MCP methods with a configurable tool catalog and canned results:
7//! `initialize`, `tools/list`, `tools/call`, `resources/list`, `prompts/list`,
8//! plus the `notifications/initialized` notification.
9//!
10//! This is a MOCK: no real tools run. `tools/call` returns deterministic
11//! canned content so agents can be exercised against a predictable (or, with
12//! a configured error tool, hostile) MCP server.
13//!
14//! Mounted by `mockforge serve --mcp-mock`.
15
16use axum::{
17    extract::State, http::StatusCode, response::IntoResponse, response::Response, routing::post,
18    Json, Router,
19};
20use serde_json::{json, Value};
21
22/// Protocol version this mock advertises. Matches the MCP spec revision the
23/// reference clients negotiate; clients that send a different version still
24/// work because we echo a fixed supported version.
25const PROTOCOL_VERSION: &str = "2024-11-05";
26
27/// A single mock tool in the catalog.
28#[derive(Clone, Debug)]
29pub struct McpTool {
30    /// Tool name as advertised in `tools/list` and matched by `tools/call`.
31    pub name: String,
32    /// Human-readable description shown to the agent.
33    pub description: String,
34    /// JSON Schema for the tool's input (returned verbatim in `tools/list`).
35    pub input_schema: Value,
36    /// Canned text returned by `tools/call`. When `is_error` is true the call
37    /// result is flagged `isError: true` so agents can be tested against a
38    /// failing tool.
39    pub canned_result: String,
40    /// Whether `tools/call` should flag the result as an error (`isError`).
41    pub is_error: bool,
42}
43
44/// Runtime configuration for the mock MCP server.
45#[derive(Clone, Debug)]
46pub struct McpMockConfig {
47    /// Server name returned in `initialize` -> `serverInfo.name`.
48    pub server_name: String,
49    /// Server version returned in `initialize` -> `serverInfo.version`.
50    pub server_version: String,
51    /// Tool catalog returned by `tools/list` and dispatched by `tools/call`.
52    pub tools: Vec<McpTool>,
53}
54
55impl Default for McpMockConfig {
56    fn default() -> Self {
57        Self {
58            server_name: "mockforge-mcp".to_string(),
59            server_version: env!("CARGO_PKG_VERSION").to_string(),
60            tools: vec![
61                McpTool {
62                    name: "echo".to_string(),
63                    description: "Echo back the provided text.".to_string(),
64                    input_schema: json!({
65                        "type": "object",
66                        "properties": { "text": { "type": "string" } },
67                        "required": ["text"],
68                    }),
69                    canned_result: "echo".to_string(),
70                    is_error: false,
71                },
72                McpTool {
73                    name: "get_status".to_string(),
74                    description: "Return a canned status payload.".to_string(),
75                    input_schema: json!({ "type": "object", "properties": {} }),
76                    canned_result: "{\"status\":\"ok\",\"source\":\"mockforge-mcp\"}".to_string(),
77                    is_error: false,
78                },
79            ],
80        }
81    }
82}
83
84/// Build the axum router exposing the mock MCP JSON-RPC endpoint.
85pub fn router(config: McpMockConfig) -> Router {
86    Router::new().route("/mcp", post(handle_rpc)).with_state(config)
87}
88
89/// JSON-RPC error codes (subset of the spec) we may return.
90const METHOD_NOT_FOUND: i64 = -32601;
91const INVALID_REQUEST: i64 = -32600;
92
93async fn handle_rpc(State(config): State<McpMockConfig>, Json(req): Json<Value>) -> Response {
94    // Notifications have no `id`; per JSON-RPC the server must not reply with a
95    // result. MCP sends `notifications/initialized` after `initialize`.
96    let id = req.get("id").cloned();
97    let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
98    let params = req.get("params").cloned().unwrap_or(Value::Null);
99
100    if id.is_none() {
101        // Notification: acknowledge with 202 and no body.
102        return StatusCode::ACCEPTED.into_response();
103    }
104    let id = id.unwrap();
105
106    if req.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") {
107        return Json(error_response(id, INVALID_REQUEST, "jsonrpc must be \"2.0\""))
108            .into_response();
109    }
110
111    let result = match method {
112        "initialize" => Some(json!({
113            "protocolVersion": PROTOCOL_VERSION,
114            "capabilities": {
115                "tools": { "listChanged": false },
116                "resources": { "listChanged": false },
117                "prompts": { "listChanged": false },
118            },
119            "serverInfo": { "name": config.server_name, "version": config.server_version },
120        })),
121        "tools/list" => Some(json!({
122            "tools": config.tools.iter().map(|t| json!({
123                "name": t.name,
124                "description": t.description,
125                "inputSchema": t.input_schema,
126            })).collect::<Vec<_>>(),
127        })),
128        "tools/call" => Some(tools_call(&config, &params)),
129        "resources/list" => Some(json!({ "resources": [] })),
130        "prompts/list" => Some(json!({ "prompts": [] })),
131        "ping" => Some(json!({})),
132        _ => None,
133    };
134
135    match result {
136        Some(r) => Json(result_response(id, r)).into_response(),
137        None => Json(error_response(id, METHOD_NOT_FOUND, &format!("method not found: {method}")))
138            .into_response(),
139    }
140}
141
142fn tools_call(config: &McpMockConfig, params: &Value) -> Value {
143    let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
144    let args = params.get("arguments").cloned().unwrap_or(Value::Null);
145
146    let Some(tool) = config.tools.iter().find(|t| t.name == name) else {
147        return json!({
148            "content": [{ "type": "text", "text": format!("unknown tool: {name}") }],
149            "isError": true,
150        });
151    };
152
153    // The `echo` tool reflects its `text` argument so agents can verify the
154    // round-trip; everything else returns its canned result verbatim.
155    let text = if tool.name == "echo" {
156        args.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string()
157    } else {
158        tool.canned_result.clone()
159    };
160
161    json!({
162        "content": [{ "type": "text", "text": text }],
163        "isError": tool.is_error,
164    })
165}
166
167fn result_response(id: Value, result: Value) -> Value {
168    json!({ "jsonrpc": "2.0", "id": id, "result": result })
169}
170
171fn error_response(id: Value, code: i64, message: &str) -> Value {
172    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    async fn call(body: Value) -> Value {
180        let resp = handle_rpc(State(McpMockConfig::default()), Json(body)).await;
181        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
182        if bytes.is_empty() {
183            Value::Null
184        } else {
185            serde_json::from_slice(&bytes).unwrap()
186        }
187    }
188
189    #[tokio::test]
190    async fn initialize_returns_server_info_and_capabilities() {
191        let v = call(json!({"jsonrpc":"2.0","id":1,"method":"initialize"})).await;
192        assert_eq!(v["jsonrpc"], "2.0");
193        assert_eq!(v["id"], 1);
194        assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
195        assert_eq!(v["result"]["serverInfo"]["name"], "mockforge-mcp");
196        assert!(v["result"]["capabilities"]["tools"].is_object());
197    }
198
199    #[tokio::test]
200    async fn tools_list_returns_catalog() {
201        let v = call(json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})).await;
202        let tools = v["result"]["tools"].as_array().unwrap();
203        assert_eq!(tools.len(), 2);
204        assert!(tools.iter().any(|t| t["name"] == "echo"));
205        assert!(tools[0]["inputSchema"].is_object());
206    }
207
208    #[tokio::test]
209    async fn tools_call_echo_reflects_argument() {
210        let v = call(json!({
211            "jsonrpc":"2.0","id":3,"method":"tools/call",
212            "params": { "name": "echo", "arguments": { "text": "hello mcp" } }
213        }))
214        .await;
215        assert_eq!(v["result"]["content"][0]["text"], "hello mcp");
216        assert_eq!(v["result"]["isError"], false);
217    }
218
219    #[tokio::test]
220    async fn tools_call_unknown_tool_is_error() {
221        let v = call(json!({
222            "jsonrpc":"2.0","id":4,"method":"tools/call",
223            "params": { "name": "nope", "arguments": {} }
224        }))
225        .await;
226        assert_eq!(v["result"]["isError"], true);
227    }
228
229    #[tokio::test]
230    async fn unknown_method_returns_jsonrpc_error() {
231        let v = call(json!({"jsonrpc":"2.0","id":5,"method":"does/not/exist"})).await;
232        assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
233    }
234
235    #[tokio::test]
236    async fn notification_without_id_returns_no_body() {
237        // notifications/initialized has no `id`; must not produce a JSON-RPC reply.
238        let v = call(json!({"jsonrpc":"2.0","method":"notifications/initialized"})).await;
239        assert_eq!(v, Value::Null);
240    }
241
242    #[tokio::test]
243    async fn resources_and_prompts_list_are_empty() {
244        let r = call(json!({"jsonrpc":"2.0","id":6,"method":"resources/list"})).await;
245        assert!(r["result"]["resources"].as_array().unwrap().is_empty());
246        let p = call(json!({"jsonrpc":"2.0","id":7,"method":"prompts/list"})).await;
247        assert!(p["result"]["prompts"].as_array().unwrap().is_empty());
248    }
249}