1use axum::{
25 extract::State, http::StatusCode, response::IntoResponse, response::Response, routing::post,
26 Json, Router,
27};
28use serde_json::{json, Value};
29
30const PROTOCOL_VERSION: &str = "2024-11-05";
34
35#[derive(Clone, Debug)]
37pub struct McpTool {
38 pub name: String,
40 pub description: String,
42 pub input_schema: Value,
44 pub canned_result: String,
48 pub is_error: bool,
50}
51
52#[derive(Clone, Debug)]
54pub struct McpResource {
55 pub uri: String,
58 pub name: String,
60 pub description: String,
62 pub mime_type: String,
64 pub text: String,
66}
67
68#[derive(Clone, Debug)]
70pub struct McpPromptArg {
71 pub name: String,
73 pub description: String,
75 pub required: bool,
77}
78
79#[derive(Clone, Debug)]
81pub struct McpPrompt {
82 pub name: String,
84 pub description: String,
86 pub arguments: Vec<McpPromptArg>,
88 pub text: String,
90}
91
92#[derive(Clone, Debug)]
94pub struct McpMockConfig {
95 pub server_name: String,
97 pub server_version: String,
99 pub tools: Vec<McpTool>,
101 pub resources: Vec<McpResource>,
104 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
120fn 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
206fn 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
241fn 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
292pub fn router(config: McpMockConfig) -> Router {
294 Router::new().route("/mcp", post(handle_rpc)).with_state(config)
295}
296
297const 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 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 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 match method {
323 "resources/read" => {
324 return Json(match resources_read(&config, ¶ms) {
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, ¶ms) {
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, ¶ms)),
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 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
414const INVALID_PARAMS: i64 = -32602;
417
418fn 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
434fn 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 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 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 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}