use axum::{
extract::State, http::StatusCode, response::IntoResponse, response::Response, routing::post,
Json, Router,
};
use serde_json::{json, Value};
const PROTOCOL_VERSION: &str = "2024-11-05";
#[derive(Clone, Debug)]
pub struct McpTool {
pub name: String,
pub description: String,
pub input_schema: Value,
pub canned_result: String,
pub is_error: bool,
}
#[derive(Clone, Debug)]
pub struct McpMockConfig {
pub server_name: String,
pub server_version: String,
pub tools: Vec<McpTool>,
}
impl Default for McpMockConfig {
fn default() -> Self {
Self {
server_name: "mockforge-mcp".to_string(),
server_version: env!("CARGO_PKG_VERSION").to_string(),
tools: vec![
McpTool {
name: "echo".to_string(),
description: "Echo back the provided text.".to_string(),
input_schema: json!({
"type": "object",
"properties": { "text": { "type": "string" } },
"required": ["text"],
}),
canned_result: "echo".to_string(),
is_error: false,
},
McpTool {
name: "get_status".to_string(),
description: "Return a canned status payload.".to_string(),
input_schema: json!({ "type": "object", "properties": {} }),
canned_result: "{\"status\":\"ok\",\"source\":\"mockforge-mcp\"}".to_string(),
is_error: false,
},
],
}
}
}
pub fn router(config: McpMockConfig) -> Router {
Router::new().route("/mcp", post(handle_rpc)).with_state(config)
}
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_REQUEST: i64 = -32600;
async fn handle_rpc(State(config): State<McpMockConfig>, Json(req): Json<Value>) -> Response {
let id = req.get("id").cloned();
let method = req.get("method").and_then(|m| m.as_str()).unwrap_or("");
let params = req.get("params").cloned().unwrap_or(Value::Null);
if id.is_none() {
return StatusCode::ACCEPTED.into_response();
}
let id = id.unwrap();
if req.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") {
return Json(error_response(id, INVALID_REQUEST, "jsonrpc must be \"2.0\""))
.into_response();
}
let result = match method {
"initialize" => Some(json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {
"tools": { "listChanged": false },
"resources": { "listChanged": false },
"prompts": { "listChanged": false },
},
"serverInfo": { "name": config.server_name, "version": config.server_version },
})),
"tools/list" => Some(json!({
"tools": config.tools.iter().map(|t| json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})).collect::<Vec<_>>(),
})),
"tools/call" => Some(tools_call(&config, ¶ms)),
"resources/list" => Some(json!({ "resources": [] })),
"prompts/list" => Some(json!({ "prompts": [] })),
"ping" => Some(json!({})),
_ => None,
};
match result {
Some(r) => Json(result_response(id, r)).into_response(),
None => Json(error_response(id, METHOD_NOT_FOUND, &format!("method not found: {method}")))
.into_response(),
}
}
fn tools_call(config: &McpMockConfig, params: &Value) -> Value {
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
let Some(tool) = config.tools.iter().find(|t| t.name == name) else {
return json!({
"content": [{ "type": "text", "text": format!("unknown tool: {name}") }],
"isError": true,
});
};
let text = if tool.name == "echo" {
args.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string()
} else {
tool.canned_result.clone()
};
json!({
"content": [{ "type": "text", "text": text }],
"isError": tool.is_error,
})
}
fn result_response(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
fn error_response(id: Value, code: i64, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
#[cfg(test)]
mod tests {
use super::*;
async fn call(body: Value) -> Value {
let resp = handle_rpc(State(McpMockConfig::default()), Json(body)).await;
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
}
}
#[tokio::test]
async fn initialize_returns_server_info_and_capabilities() {
let v = call(json!({"jsonrpc":"2.0","id":1,"method":"initialize"})).await;
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 1);
assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(v["result"]["serverInfo"]["name"], "mockforge-mcp");
assert!(v["result"]["capabilities"]["tools"].is_object());
}
#[tokio::test]
async fn tools_list_returns_catalog() {
let v = call(json!({"jsonrpc":"2.0","id":2,"method":"tools/list"})).await;
let tools = v["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2);
assert!(tools.iter().any(|t| t["name"] == "echo"));
assert!(tools[0]["inputSchema"].is_object());
}
#[tokio::test]
async fn tools_call_echo_reflects_argument() {
let v = call(json!({
"jsonrpc":"2.0","id":3,"method":"tools/call",
"params": { "name": "echo", "arguments": { "text": "hello mcp" } }
}))
.await;
assert_eq!(v["result"]["content"][0]["text"], "hello mcp");
assert_eq!(v["result"]["isError"], false);
}
#[tokio::test]
async fn tools_call_unknown_tool_is_error() {
let v = call(json!({
"jsonrpc":"2.0","id":4,"method":"tools/call",
"params": { "name": "nope", "arguments": {} }
}))
.await;
assert_eq!(v["result"]["isError"], true);
}
#[tokio::test]
async fn unknown_method_returns_jsonrpc_error() {
let v = call(json!({"jsonrpc":"2.0","id":5,"method":"does/not/exist"})).await;
assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
}
#[tokio::test]
async fn notification_without_id_returns_no_body() {
let v = call(json!({"jsonrpc":"2.0","method":"notifications/initialized"})).await;
assert_eq!(v, Value::Null);
}
#[tokio::test]
async fn resources_and_prompts_list_are_empty() {
let r = call(json!({"jsonrpc":"2.0","id":6,"method":"resources/list"})).await;
assert!(r["result"]["resources"].as_array().unwrap().is_empty());
let p = call(json!({"jsonrpc":"2.0","id":7,"method":"prompts/list"})).await;
assert!(p["result"]["prompts"].as_array().unwrap().is_empty());
}
}