use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
pub struct McpResource {
pub uri: String,
pub mime_type: String,
pub text: String,
}
pub trait McpBackend {
fn call_tool(&self, name: &str, arguments: &Value) -> Result<Value>;
fn list_resources(&self) -> Result<Vec<Value>>;
fn read_resource(&self, uri: &str) -> Result<McpResource>;
fn get_prompt(&self, name: &str, arguments: &Value) -> Result<Value>;
}
pub fn serve_stdio<B: McpBackend>(backend: &B) -> Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
serve_lines(stdin.lock(), &mut stdout, backend)
}
pub fn serve_lines<R: BufRead, W: Write, B: McpBackend>(
reader: R,
writer: &mut W,
backend: &B,
) -> Result<()> {
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let request: Value = match serde_json::from_str(trimmed) {
Ok(value) => value,
Err(error) => {
writeln!(
writer,
"{}",
jsonrpc_error(Value::Null, -32700, &error.to_string())
)?;
continue;
}
};
if let Some(response) = handle_request(backend, request) {
writeln!(writer, "{}", serde_json::to_string(&response)?)?;
writer.flush()?;
}
}
Ok(())
}
fn handle_request<B: McpBackend>(backend: &B, request: Value) -> Option<Value> {
let id = request.get("id").cloned().unwrap_or(Value::Null);
let method = request.get("method").and_then(Value::as_str).unwrap_or("");
if id.is_null() && method.starts_with("notifications/") {
return None;
}
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
let result = match method {
"initialize" => Ok(initialize_result(¶ms)),
"tools/list" => Ok(json!({ "tools": tool_definitions() })),
"tools/call" => call_tool(backend, ¶ms),
"resources/list" => backend
.list_resources()
.map(|resources| json!({ "resources": resources })),
"resources/templates/list" => Ok(json!({ "resourceTemplates": resource_templates() })),
"resources/read" => read_resource(backend, ¶ms),
"prompts/list" => Ok(json!({ "prompts": prompt_definitions() })),
"prompts/get" => get_prompt(backend, ¶ms),
_ => Err(anyhow!("method not found: {method}")),
};
Some(match result {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(error) => jsonrpc_error(id, -32603, &error.to_string()),
})
}
fn initialize_result(params: &Value) -> Value {
let protocol_version = params
.get("protocolVersion")
.and_then(Value::as_str)
.unwrap_or(MCP_PROTOCOL_VERSION);
json!({
"protocolVersion": protocol_version,
"capabilities": {
"tools": { "listChanged": false },
"resources": { "listChanged": false },
"prompts": { "listChanged": false }
},
"serverInfo": {
"name": "sdd-layer",
"version": env!("CARGO_PKG_VERSION")
}
})
}
fn call_tool<B: McpBackend>(backend: &B, params: &Value) -> Result<Value> {
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("tools/call missing params.name"))?;
let arguments = params.get("arguments").unwrap_or(&Value::Null);
let value = backend.call_tool(name, arguments)?;
Ok(tool_result(value, false))
}
fn read_resource<B: McpBackend>(backend: &B, params: &Value) -> Result<Value> {
let uri = params
.get("uri")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("resources/read missing params.uri"))?;
let resource = backend.read_resource(uri)?;
Ok(json!({
"contents": [{
"uri": resource.uri,
"mimeType": resource.mime_type,
"text": resource.text
}]
}))
}
fn get_prompt<B: McpBackend>(backend: &B, params: &Value) -> Result<Value> {
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("prompts/get missing params.name"))?;
let arguments = params.get("arguments").unwrap_or(&Value::Null);
backend.get_prompt(name, arguments)
}
fn tool_result(value: Value, is_error: bool) -> Value {
let text = if let Some(text) = value.as_str() {
text.to_string()
} else {
serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())
};
json!({
"content": [{ "type": "text", "text": text }],
"isError": is_error
})
}
fn jsonrpc_error(id: Value, code: i32, message: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": { "code": code, "message": message }
})
}
fn tool_definitions() -> Vec<Value> {
vec![
tool(
"sdd_trace_list",
"List SDD trace events",
json!({
"type": "object",
"properties": {
"orchestration": { "type": "string" }
}
}),
),
tool(
"sdd_trace_show",
"Show one trace tree by run_id",
json!({
"type": "object",
"properties": { "run_id": { "type": "string" } },
"required": ["run_id"]
}),
),
tool(
"sdd_trace_summary",
"Summarize SDD traces",
json!({
"type": "object",
"properties": { "orchestration": { "type": "string" } }
}),
),
tool(
"sdd_artifact_status",
"Read local artifact store status",
json!({
"type": "object",
"properties": { "orchestration": { "type": "string" } }
}),
),
tool(
"sdd_context_build",
"Build read-only SDD context from local artifacts",
json!({
"type": "object",
"properties": {
"orchestration": { "type": "string" },
"stage": { "type": "string" },
"task": { "type": "string" }
},
"required": ["orchestration", "stage"]
}),
),
tool(
"sdd_clients_doctor",
"Run clients doctor data in read-only mode",
json!({ "type": "object", "properties": {} }),
),
]
}
fn tool(name: &str, description: &str, input_schema: Value) -> Value {
json!({
"name": name,
"title": name,
"description": description,
"inputSchema": input_schema,
"annotations": { "readOnlyHint": true }
})
}
fn resource_templates() -> Vec<Value> {
vec![
json!({
"uriTemplate": "sdd://artifact/{orchestration}/{artifact}",
"name": "sdd_artifact",
"title": "SDD artifact",
"description": "Read a local SDD artifact from docs/<slug>/",
"mimeType": "text/markdown"
}),
json!({
"uriTemplate": "sdd://trace/{run_id}",
"name": "sdd_trace",
"title": "SDD trace tree",
"description": "Read an SDD trace tree as JSON",
"mimeType": "application/json"
}),
json!({
"uriTemplate": "sdd://context/{orchestration}/{stage}",
"name": "sdd_context",
"title": "SDD context pack",
"description": "Read a generated context pack for one stage",
"mimeType": "text/markdown"
}),
]
}
fn prompt_definitions() -> Vec<Value> {
vec![
json!({
"name": "sdd_orchestration",
"title": "Run SDD orchestration",
"description": "Prompt for starting a full SDD orchestration",
"arguments": [{ "name": "idea", "required": true }]
}),
json!({
"name": "sdd_stage_handoff",
"title": "Prepare stage handoff",
"description": "Prompt for handing off one SDD stage with trace context",
"arguments": [
{ "name": "orchestration", "required": true },
{ "name": "stage", "required": true }
]
}),
json!({
"name": "sdd_trace_review",
"title": "Review SDD trace",
"description": "Prompt for reviewing an execution trace",
"arguments": [{ "name": "run_id", "required": true }]
}),
]
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyBackend;
impl McpBackend for DummyBackend {
fn call_tool(&self, name: &str, arguments: &Value) -> Result<Value> {
Ok(json!({ "name": name, "arguments": arguments }))
}
fn list_resources(&self) -> Result<Vec<Value>> {
Ok(vec![json!({"uri": "sdd://trace/root", "name": "root"})])
}
fn read_resource(&self, uri: &str) -> Result<McpResource> {
Ok(McpResource {
uri: uri.to_string(),
mime_type: "text/plain".to_string(),
text: "ok".to_string(),
})
}
fn get_prompt(&self, name: &str, arguments: &Value) -> Result<Value> {
Ok(json!({
"description": name,
"messages": [{ "role": "user", "content": { "type": "text", "text": arguments.to_string() } }]
}))
}
}
#[test]
fn serves_jsonrpc_tools_over_lines() {
let input = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sdd_trace_list","arguments":{}}}
"#;
let mut output = Vec::new();
serve_lines(&input[..], &mut output, &DummyBackend).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("\"tools\""));
assert!(text.contains("sdd_trace_list"));
assert!(text.contains("\"isError\":false"));
}
#[test]
fn lists_prompts_and_reads_resources() {
let input = br#"{"jsonrpc":"2.0","id":1,"method":"prompts/list","params":{}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"sdd://trace/root"}}
"#;
let mut output = Vec::new();
serve_lines(&input[..], &mut output, &DummyBackend).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("sdd_orchestration"));
assert!(text.contains("sdd://trace/root"));
}
}