1use axum::{
17 extract::State, http::StatusCode, response::IntoResponse, response::Response, routing::post,
18 Json, Router,
19};
20use serde_json::{json, Value};
21
22const PROTOCOL_VERSION: &str = "2024-11-05";
26
27#[derive(Clone, Debug)]
29pub struct McpTool {
30 pub name: String,
32 pub description: String,
34 pub input_schema: Value,
36 pub canned_result: String,
40 pub is_error: bool,
42}
43
44#[derive(Clone, Debug)]
46pub struct McpMockConfig {
47 pub server_name: String,
49 pub server_version: String,
51 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
84pub fn router(config: McpMockConfig) -> Router {
86 Router::new().route("/mcp", post(handle_rpc)).with_state(config)
87}
88
89const 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 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 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, ¶ms)),
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 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 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}