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 catalog and canned results:
7//! `initialize`, `tools/list`, `tools/call`, `resources/list`,
8//! `resources/read`, `resources/templates/list`, `prompts/list`,
9//! `prompts/get`, `ping`, plus the `notifications/initialized` notification.
10//!
11//! This is a MOCK: no real tools run. `tools/call` returns deterministic
12//! canned content so agents can be exercised against a predictable (or, with
13//! a configured error tool, hostile) MCP server.
14//!
15//! Round 52 (#79) — Srikanth on 0.3.198 asked whether the mock can "respond
16//! with more tools, resources, prompts so that applications are chatty (sees
17//! more client-server traffic)". The default catalog now ships a fuller set
18//! of tools plus non-empty resources and prompts, and `resources/read` /
19//! `prompts/get` are implemented, so a client that lists then reads/gets/calls
20//! generates substantially more request/response traffic to capture.
21//!
22//! Mounted by `mockforge serve --mcp-mock`.
23
24use axum::{
25    extract::State, http::StatusCode, response::IntoResponse, response::Response, routing::post,
26    Json, Router,
27};
28use serde_json::{json, Value};
29
30/// Protocol version this mock advertises. Matches the MCP spec revision the
31/// reference clients negotiate; clients that send a different version still
32/// work because we echo a fixed supported version.
33const PROTOCOL_VERSION: &str = "2024-11-05";
34
35/// A single mock tool in the catalog.
36#[derive(Clone, Debug)]
37pub struct McpTool {
38    /// Tool name as advertised in `tools/list` and matched by `tools/call`.
39    pub name: String,
40    /// Human-readable description shown to the agent.
41    pub description: String,
42    /// JSON Schema for the tool's input (returned verbatim in `tools/list`).
43    pub input_schema: Value,
44    /// Canned text returned by `tools/call`. When `is_error` is true the call
45    /// result is flagged `isError: true` so agents can be tested against a
46    /// failing tool.
47    pub canned_result: String,
48    /// Whether `tools/call` should flag the result as an error (`isError`).
49    pub is_error: bool,
50}
51
52/// A single mock resource in the catalog.
53#[derive(Clone, Debug)]
54pub struct McpResource {
55    /// Resource URI advertised in `resources/list` and matched by
56    /// `resources/read`.
57    pub uri: String,
58    /// Human-readable resource name.
59    pub name: String,
60    /// Human-readable description.
61    pub description: String,
62    /// MIME type advertised for the resource contents.
63    pub mime_type: String,
64    /// Canned text returned by `resources/read`.
65    pub text: String,
66}
67
68/// A single argument declared by a mock prompt.
69#[derive(Clone, Debug)]
70pub struct McpPromptArg {
71    /// Argument name.
72    pub name: String,
73    /// Human-readable description.
74    pub description: String,
75    /// Whether the client must supply this argument.
76    pub required: bool,
77}
78
79/// A single mock prompt in the catalog.
80#[derive(Clone, Debug)]
81pub struct McpPrompt {
82    /// Prompt name advertised in `prompts/list` and matched by `prompts/get`.
83    pub name: String,
84    /// Human-readable description.
85    pub description: String,
86    /// Declared arguments (surfaced in `prompts/list`).
87    pub arguments: Vec<McpPromptArg>,
88    /// Canned user-message text returned by `prompts/get`.
89    pub text: String,
90}
91
92/// Runtime configuration for the mock MCP server.
93#[derive(Clone, Debug)]
94pub struct McpMockConfig {
95    /// Server name returned in `initialize` -> `serverInfo.name`.
96    pub server_name: String,
97    /// Server version returned in `initialize` -> `serverInfo.version`.
98    pub server_version: String,
99    /// Tool catalog returned by `tools/list` and dispatched by `tools/call`.
100    pub tools: Vec<McpTool>,
101    /// Resource catalog returned by `resources/list` and read by
102    /// `resources/read`.
103    pub resources: Vec<McpResource>,
104    /// Prompt catalog returned by `prompts/list` and fetched by `prompts/get`.
105    pub prompts: Vec<McpPrompt>,
106}
107
108impl Default for McpMockConfig {
109    fn default() -> Self {
110        Self {
111            server_name: "mockforge-mcp".to_string(),
112            server_version: env!("CARGO_PKG_VERSION").to_string(),
113            tools: default_tools(),
114            resources: default_resources(),
115            prompts: default_prompts(),
116        }
117    }
118}
119
120/// Default tool catalog. Deliberately broad (round 52 #79) so `tools/list`
121/// and subsequent `tools/call`s produce meaningful client-server traffic.
122fn default_tools() -> Vec<McpTool> {
123    let obj = |props: Value, required: Value| {
124        json!({
125            "type": "object", "properties": props, "required": required,
126        })
127    };
128    vec![
129        McpTool {
130            name: "echo".to_string(),
131            description: "Echo back the provided text.".to_string(),
132            input_schema: obj(json!({ "text": { "type": "string" } }), json!(["text"])),
133            canned_result: "echo".to_string(),
134            is_error: false,
135        },
136        McpTool {
137            name: "get_status".to_string(),
138            description: "Return a canned service status payload.".to_string(),
139            input_schema: json!({ "type": "object", "properties": {} }),
140            canned_result: "{\"status\":\"ok\",\"source\":\"mockforge-mcp\"}".to_string(),
141            is_error: false,
142        },
143        McpTool {
144            name: "get_time".to_string(),
145            description: "Return a canned current timestamp (UTC, ISO-8601).".to_string(),
146            input_schema: json!({ "type": "object", "properties": {} }),
147            canned_result: "{\"now\":\"2026-01-01T00:00:00Z\",\"tz\":\"UTC\"}".to_string(),
148            is_error: false,
149        },
150        McpTool {
151            name: "add".to_string(),
152            description: "Add two numbers and return their sum.".to_string(),
153            input_schema: obj(
154                json!({ "a": { "type": "number" }, "b": { "type": "number" } }),
155                json!(["a", "b"]),
156            ),
157            canned_result: "{\"sum\":42}".to_string(),
158            is_error: false,
159        },
160        McpTool {
161            name: "search_documents".to_string(),
162            description: "Search a canned document index and return matching hits.".to_string(),
163            input_schema: obj(
164                json!({
165                    "query": { "type": "string" },
166                    "limit": { "type": "integer", "minimum": 1, "maximum": 50 },
167                }),
168                json!(["query"]),
169            ),
170            canned_result: "{\"hits\":[{\"id\":\"doc-1\",\"title\":\"Getting Started\",\"score\":0.98},{\"id\":\"doc-2\",\"title\":\"API Reference\",\"score\":0.71}]}".to_string(),
171            is_error: false,
172        },
173        McpTool {
174            name: "get_weather".to_string(),
175            description: "Return a canned weather report for a location.".to_string(),
176            input_schema: obj(
177                json!({ "location": { "type": "string" } }),
178                json!(["location"]),
179            ),
180            canned_result: "{\"location\":\"Sample City\",\"tempC\":21,\"conditions\":\"Partly cloudy\"}".to_string(),
181            is_error: false,
182        },
183        McpTool {
184            name: "create_ticket".to_string(),
185            description: "Create a canned support ticket and return its id.".to_string(),
186            input_schema: obj(
187                json!({
188                    "title": { "type": "string" },
189                    "priority": { "type": "string", "enum": ["low", "medium", "high"] },
190                }),
191                json!(["title"]),
192            ),
193            canned_result: "{\"ticketId\":\"TKT-1001\",\"state\":\"open\"}".to_string(),
194            is_error: false,
195        },
196        McpTool {
197            name: "fail_tool".to_string(),
198            description: "Always returns an error result (for testing hostile tools).".to_string(),
199            input_schema: json!({ "type": "object", "properties": {} }),
200            canned_result: "simulated tool failure".to_string(),
201            is_error: true,
202        },
203    ]
204}
205
206/// Default resource catalog (round 52 #79). Non-empty so `resources/list`
207/// and `resources/read` exercise the resource half of the protocol.
208fn default_resources() -> Vec<McpResource> {
209    vec![
210        McpResource {
211            uri: "file:///readme.md".to_string(),
212            name: "README".to_string(),
213            description: "Project readme served by the mock.".to_string(),
214            mime_type: "text/markdown".to_string(),
215            text: "# MockForge MCP Mock\n\nCanned readme resource.\n".to_string(),
216        },
217        McpResource {
218            uri: "config://app/settings.json".to_string(),
219            name: "App settings".to_string(),
220            description: "Canned application settings document.".to_string(),
221            mime_type: "application/json".to_string(),
222            text: "{\"featureFlags\":{\"beta\":true},\"maxItems\":100}".to_string(),
223        },
224        McpResource {
225            uri: "db://users/schema".to_string(),
226            name: "Users schema".to_string(),
227            description: "Canned users table schema.".to_string(),
228            mime_type: "application/json".to_string(),
229            text: "{\"table\":\"users\",\"columns\":[\"id\",\"email\",\"created_at\"]}".to_string(),
230        },
231        McpResource {
232            uri: "log://app/latest".to_string(),
233            name: "Latest app log".to_string(),
234            description: "Canned tail of the application log.".to_string(),
235            mime_type: "text/plain".to_string(),
236            text: "2026-01-01T00:00:00Z INFO booted\n2026-01-01T00:00:01Z INFO ready\n".to_string(),
237        },
238    ]
239}
240
241/// Default prompt catalog (round 52 #79). Non-empty so `prompts/list` and
242/// `prompts/get` exercise the prompt half of the protocol.
243fn default_prompts() -> Vec<McpPrompt> {
244    vec![
245        McpPrompt {
246            name: "summarize".to_string(),
247            description: "Summarize the provided text.".to_string(),
248            arguments: vec![McpPromptArg {
249                name: "text".to_string(),
250                description: "The text to summarize.".to_string(),
251                required: true,
252            }],
253            text: "Please summarize the following text in three sentences.".to_string(),
254        },
255        McpPrompt {
256            name: "code_review".to_string(),
257            description: "Review a code snippet for bugs and style.".to_string(),
258            arguments: vec![
259                McpPromptArg {
260                    name: "language".to_string(),
261                    description: "Programming language of the snippet.".to_string(),
262                    required: false,
263                },
264                McpPromptArg {
265                    name: "code".to_string(),
266                    description: "The code to review.".to_string(),
267                    required: true,
268                },
269            ],
270            text: "Review the following code and list bugs, risks, and style issues.".to_string(),
271        },
272        McpPrompt {
273            name: "translate".to_string(),
274            description: "Translate text into a target language.".to_string(),
275            arguments: vec![
276                McpPromptArg {
277                    name: "target_language".to_string(),
278                    description: "Language to translate into.".to_string(),
279                    required: true,
280                },
281                McpPromptArg {
282                    name: "text".to_string(),
283                    description: "The text to translate.".to_string(),
284                    required: true,
285                },
286            ],
287            text: "Translate the following text into the requested target language.".to_string(),
288        },
289    ]
290}
291
292/// Build the axum router exposing the mock MCP JSON-RPC endpoint.
293pub fn router(config: McpMockConfig) -> Router {
294    Router::new().route("/mcp", post(handle_rpc)).with_state(config)
295}
296
297/// JSON-RPC error codes (subset of the spec) we may return.
298const METHOD_NOT_FOUND: i64 = -32601;
299const INVALID_REQUEST: i64 = -32600;
300
301async fn handle_rpc(State(config): State<McpMockConfig>, Json(req): Json<Value>) -> Response {
302    // Notifications have no `id`; per JSON-RPC the server must not reply with a
303    // result. MCP sends `notifications/initialized` after `initialize`.
304    let id = req.get("id").cloned();
305    let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
306    let params = req.get("params").cloned().unwrap_or(Value::Null);
307
308    if id.is_none() {
309        // Notification: acknowledge with 202 and no body.
310        return StatusCode::ACCEPTED.into_response();
311    }
312    let id = id.unwrap();
313
314    if req.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") {
315        return Json(error_response(id, INVALID_REQUEST, "jsonrpc must be \"2.0\""))
316            .into_response();
317    }
318
319    // Fallible lookups: an unknown uri / prompt name is INVALID_PARAMS, not a
320    // successful empty result, so a client can tell "no such resource" apart
321    // from "resource has no contents".
322    match method {
323        "resources/read" => {
324            return Json(match resources_read(&config, &params) {
325                Ok(r) => result_response(id, r),
326                Err((code, msg)) => error_response(id, code, &msg),
327            })
328            .into_response();
329        }
330        "prompts/get" => {
331            return Json(match prompts_get(&config, &params) {
332                Ok(r) => result_response(id, r),
333                Err((code, msg)) => error_response(id, code, &msg),
334            })
335            .into_response();
336        }
337        _ => {}
338    }
339
340    let result = match method {
341        "initialize" => Some(json!({
342            "protocolVersion": PROTOCOL_VERSION,
343            "capabilities": {
344                "tools": { "listChanged": false },
345                "resources": { "listChanged": false },
346                "prompts": { "listChanged": false },
347            },
348            "serverInfo": { "name": config.server_name, "version": config.server_version },
349        })),
350        "tools/list" => Some(json!({
351            "tools": config.tools.iter().map(|t| json!({
352                "name": t.name,
353                "description": t.description,
354                "inputSchema": t.input_schema,
355            })).collect::<Vec<_>>(),
356        })),
357        "tools/call" => Some(tools_call(&config, &params)),
358        "resources/list" => Some(json!({
359            "resources": config.resources.iter().map(|r| json!({
360                "uri": r.uri,
361                "name": r.name,
362                "description": r.description,
363                "mimeType": r.mime_type,
364            })).collect::<Vec<_>>(),
365        })),
366        "resources/templates/list" => Some(json!({ "resourceTemplates": [] })),
367        "prompts/list" => Some(json!({
368            "prompts": config.prompts.iter().map(|p| json!({
369                "name": p.name,
370                "description": p.description,
371                "arguments": p.arguments.iter().map(|a| json!({
372                    "name": a.name,
373                    "description": a.description,
374                    "required": a.required,
375                })).collect::<Vec<_>>(),
376            })).collect::<Vec<_>>(),
377        })),
378        "ping" => Some(json!({})),
379        _ => None,
380    };
381
382    match result {
383        Some(r) => Json(result_response(id, r)).into_response(),
384        None => Json(error_response(id, METHOD_NOT_FOUND, &format!("method not found: {method}")))
385            .into_response(),
386    }
387}
388
389fn tools_call(config: &McpMockConfig, params: &Value) -> Value {
390    let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
391    let args = params.get("arguments").cloned().unwrap_or(Value::Null);
392
393    let Some(tool) = config.tools.iter().find(|t| t.name == name) else {
394        return json!({
395            "content": [{ "type": "text", "text": format!("unknown tool: {name}") }],
396            "isError": true,
397        });
398    };
399
400    // The `echo` tool reflects its `text` argument so agents can verify the
401    // round-trip; everything else returns its canned result verbatim.
402    let text = if tool.name == "echo" {
403        args.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string()
404    } else {
405        tool.canned_result.clone()
406    };
407
408    json!({
409        "content": [{ "type": "text", "text": text }],
410        "isError": tool.is_error,
411    })
412}
413
414/// JSON-RPC invalid-params error code, returned for an unknown resource uri
415/// or prompt name.
416const INVALID_PARAMS: i64 = -32602;
417
418/// Handle `resources/read`: look the requested `uri` up in the catalog and
419/// return its canned contents, or an INVALID_PARAMS error if unknown.
420fn resources_read(config: &McpMockConfig, params: &Value) -> Result<Value, (i64, String)> {
421    let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
422    let Some(resource) = config.resources.iter().find(|r| r.uri == uri) else {
423        return Err((INVALID_PARAMS, format!("unknown resource: {uri}")));
424    };
425    Ok(json!({
426        "contents": [{
427            "uri": resource.uri,
428            "mimeType": resource.mime_type,
429            "text": resource.text,
430        }],
431    }))
432}
433
434/// Handle `prompts/get`: look the requested prompt `name` up in the catalog
435/// and return a canned single-message conversation, or an INVALID_PARAMS
436/// error if unknown.
437fn prompts_get(config: &McpMockConfig, params: &Value) -> Result<Value, (i64, String)> {
438    let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
439    let Some(prompt) = config.prompts.iter().find(|p| p.name == name) else {
440        return Err((INVALID_PARAMS, format!("unknown prompt: {name}")));
441    };
442    Ok(json!({
443        "description": prompt.description,
444        "messages": [{
445            "role": "user",
446            "content": { "type": "text", "text": prompt.text },
447        }],
448    }))
449}
450
451fn result_response(id: Value, result: Value) -> Value {
452    json!({ "jsonrpc": "2.0", "id": id, "result": result })
453}
454
455fn error_response(id: Value, code: i64, message: &str) -> Value {
456    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    async fn call(body: Value) -> Value {
464        let resp = handle_rpc(State(McpMockConfig::default()), Json(body)).await;
465        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
466        if bytes.is_empty() {
467            Value::Null
468        } else {
469            serde_json::from_slice(&bytes).unwrap()
470        }
471    }
472
473    #[tokio::test]
474    async fn initialize_returns_server_info_and_capabilities() {
475        let v = call(json!({"jsonrpc":"2.0","id":1,"method":"initialize"})).await;
476        assert_eq!(v["jsonrpc"], "2.0");
477        assert_eq!(v["id"], 1);
478        assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
479        assert_eq!(v["result"]["serverInfo"]["name"], "mockforge-mcp");
480        assert!(v["result"]["capabilities"]["tools"].is_object());
481    }
482
483    #[tokio::test]
484    async fn tools_list_returns_catalog() {
485        let v = call(json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})).await;
486        let tools = v["result"]["tools"].as_array().unwrap();
487        // Round 52 (#79) — the default catalog is deliberately broad so the
488        // list response is chatty; keep echo/get_status and add several more.
489        assert!(tools.len() >= 6, "expected a rich default tool catalog");
490        assert!(tools.iter().any(|t| t["name"] == "echo"));
491        assert!(tools.iter().any(|t| t["name"] == "search_documents"));
492        assert!(tools[0]["inputSchema"].is_object());
493    }
494
495    #[tokio::test]
496    async fn tools_call_echo_reflects_argument() {
497        let v = call(json!({
498            "jsonrpc":"2.0","id":3,"method":"tools/call",
499            "params": { "name": "echo", "arguments": { "text": "hello mcp" } }
500        }))
501        .await;
502        assert_eq!(v["result"]["content"][0]["text"], "hello mcp");
503        assert_eq!(v["result"]["isError"], false);
504    }
505
506    #[tokio::test]
507    async fn tools_call_unknown_tool_is_error() {
508        let v = call(json!({
509            "jsonrpc":"2.0","id":4,"method":"tools/call",
510            "params": { "name": "nope", "arguments": {} }
511        }))
512        .await;
513        assert_eq!(v["result"]["isError"], true);
514    }
515
516    #[tokio::test]
517    async fn unknown_method_returns_jsonrpc_error() {
518        let v = call(json!({"jsonrpc":"2.0","id":5,"method":"does/not/exist"})).await;
519        assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
520    }
521
522    #[tokio::test]
523    async fn notification_without_id_returns_no_body() {
524        // notifications/initialized has no `id`; must not produce a JSON-RPC reply.
525        let v = call(json!({"jsonrpc":"2.0","method":"notifications/initialized"})).await;
526        assert_eq!(v, Value::Null);
527    }
528
529    #[tokio::test]
530    async fn resources_and_prompts_list_are_populated() {
531        // Round 52 (#79) — the mock now ships non-empty resource and prompt
532        // catalogs so the resource/prompt halves of the protocol produce
533        // real client-server traffic.
534        let r = call(json!({"jsonrpc":"2.0","id":6,"method":"resources/list"})).await;
535        let resources = r["result"]["resources"].as_array().unwrap();
536        assert!(!resources.is_empty());
537        assert!(resources.iter().all(|x| x["uri"].is_string() && x["mimeType"].is_string()));
538
539        let p = call(json!({"jsonrpc":"2.0","id":7,"method":"prompts/list"})).await;
540        let prompts = p["result"]["prompts"].as_array().unwrap();
541        assert!(!prompts.is_empty());
542        assert!(prompts.iter().any(|x| x["name"] == "summarize"));
543    }
544
545    #[tokio::test]
546    async fn resources_read_returns_canned_contents() {
547        let v = call(json!({
548            "jsonrpc":"2.0","id":8,"method":"resources/read",
549            "params": { "uri": "file:///readme.md" }
550        }))
551        .await;
552        let contents = v["result"]["contents"].as_array().unwrap();
553        assert_eq!(contents.len(), 1);
554        assert_eq!(contents[0]["uri"], "file:///readme.md");
555        assert!(contents[0]["text"].as_str().unwrap().contains("MockForge"));
556    }
557
558    #[tokio::test]
559    async fn resources_read_unknown_uri_is_invalid_params() {
560        let v = call(json!({
561            "jsonrpc":"2.0","id":9,"method":"resources/read",
562            "params": { "uri": "file:///nope" }
563        }))
564        .await;
565        assert_eq!(v["error"]["code"], INVALID_PARAMS);
566    }
567
568    #[tokio::test]
569    async fn prompts_get_returns_message() {
570        let v = call(json!({
571            "jsonrpc":"2.0","id":10,"method":"prompts/get",
572            "params": { "name": "summarize" }
573        }))
574        .await;
575        let messages = v["result"]["messages"].as_array().unwrap();
576        assert_eq!(messages.len(), 1);
577        assert_eq!(messages[0]["role"], "user");
578        assert_eq!(messages[0]["content"]["type"], "text");
579    }
580
581    #[tokio::test]
582    async fn prompts_get_unknown_name_is_invalid_params() {
583        let v = call(json!({
584            "jsonrpc":"2.0","id":11,"method":"prompts/get",
585            "params": { "name": "nope" }
586        }))
587        .await;
588        assert_eq!(v["error"]["code"], INVALID_PARAMS);
589    }
590}