sdd-layer 0.13.1

Spec-Driven Development CLI and agent harness
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(&params)),
        "tools/list" => Ok(json!({ "tools": tool_definitions() })),
        "tools/call" => call_tool(backend, &params),
        "resources/list" => backend
            .list_resources()
            .map(|resources| json!({ "resources": resources })),
        "resources/templates/list" => Ok(json!({ "resourceTemplates": resource_templates() })),
        "resources/read" => read_resource(backend, &params),
        "prompts/list" => Ok(json!({ "prompts": prompt_definitions() })),
        "prompts/get" => get_prompt(backend, &params),
        _ => 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"));
    }
}