use anyhow::{anyhow, Result};
use serde::Serialize;
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
#[derive(Clone, Debug, Serialize)]
pub struct RuntimeAdapterDefinition {
pub id: &'static str,
pub kind: &'static str,
pub crate_name: &'static str,
pub feature: &'static str,
pub enabled: bool,
pub version_requirement: &'static str,
pub role: &'static str,
pub fallback: &'static str,
pub capabilities: &'static [&'static str],
}
pub struct McpResource {
pub uri: String,
pub mime_type: String,
pub text: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolDefinition {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub input_schema: Value,
pub annotations: Value,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct McpResourceTemplateDefinition {
pub uri_template: &'static str,
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub mime_type: &'static str,
}
#[derive(Clone, Debug, Serialize)]
pub struct McpPromptDefinition {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
pub arguments: Value,
}
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")
}
})
}
pub fn runtime_adapters() -> Vec<RuntimeAdapterDefinition> {
vec![
RuntimeAdapterDefinition {
id: "rmcp",
kind: "mcp-sdk",
crate_name: "rmcp",
feature: "mcp-rmcp",
enabled: cfg!(feature = "mcp-rmcp"),
version_requirement: "^1.7",
role: "Official Rust MCP SDK compatibility layer for typed server metadata.",
fallback: "manual-jsonrpc-stdio",
capabilities: &["tools", "resources", "prompts", "stdio"],
},
RuntimeAdapterDefinition {
id: "rig",
kind: "provider-tool-adapter",
crate_name: "rig-core",
feature: "rig-adapter",
enabled: cfg!(feature = "rig-adapter"),
version_requirement: "^0.38",
role: "Optional provider/tool abstraction for future direct model and tool execution.",
fallback: "provider-registry-and-local-cli-adapters",
capabilities: &["chat", "tools", "provider-routing"],
},
]
}
pub fn rmcp_server_info_json() -> Result<Option<Value>> {
#[cfg(feature = "mcp-rmcp")]
{
use rmcp::model::{Implementation, InitializeResult, ServerCapabilities};
let capabilities = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.build();
let server_info = InitializeResult::new(capabilities).with_server_info(
Implementation::new("sdd-layer", env!("CARGO_PKG_VERSION"))
.with_title("SDD Layer MCP")
.with_description(
"Read-only SDD artifacts, context packs, traces and client diagnostics.",
),
);
Ok(Some(serde_json::to_value(server_info)?))
}
#[cfg(not(feature = "mcp-rmcp"))]
{
Ok(None)
}
}
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> {
mcp_tool_definitions()
.into_iter()
.map(|tool| serde_json::to_value(tool).expect("MCP tool definition serializes"))
.collect()
}
pub fn mcp_tool_definitions() -> Vec<McpToolDefinition> {
vec![
tool(
"sdd_trace_list",
"List SDD trace events",
object_schema(&[("orchestration", false)]),
),
tool(
"sdd_trace_show",
"Show one trace tree by run_id",
object_schema(&[("run_id", true)]),
),
tool(
"sdd_trace_summary",
"Summarize SDD traces",
object_schema(&[("orchestration", false)]),
),
tool(
"sdd_artifact_status",
"Read local artifact store status",
object_schema(&[("orchestration", false)]),
),
tool(
"sdd_context_build",
"Build read-only SDD context from local artifacts",
object_schema(&[("orchestration", true), ("stage", true), ("task", false)]),
),
tool(
"sdd_context_bundle",
"Build the primary read-only SDD MCP context bundle with artifacts, handoff, traces, capabilities and CodeGraph guidance",
object_schema(&[
("orchestration", true),
("stage", true),
("task", false),
("query", false),
]),
),
tool(
"sdd_clients_doctor",
"Run clients doctor data in read-only mode",
object_schema(&[]),
),
tool(
"sdd_project_status",
"Read compact SDD MCP cache, client and CodeGraph status",
object_schema(&[]),
),
tool(
"sdd_search",
"Search SDD artifacts, traces and handoffs through one lightweight surface",
object_schema(&[("query", false), ("limit", false)]),
),
tool(
"sdd_runtime_adapters",
"Inspect optional MCP/provider runtime adapters compiled into this sdd binary",
object_schema(&[]),
),
tool(
"sdd_optimize_status",
"Inspect optimization wrapper status for CodeGraph, RTK and Caveman",
object_schema(&[]),
),
tool(
"sdd_context_handoff",
"Build read-only execution handoff from artifacts and optimization status",
object_schema(&[("orchestration", true), ("stage", true), ("task", false)]),
),
tool(
"sdd_capabilities_status",
"Inspect capability catalog and doctor status",
object_schema(&[]),
),
tool(
"sdd_agents_manifest",
"Read the canonical SDD agent manifest",
object_schema(&[("agent_id", false)]),
),
]
}
fn object_schema(fields: &[(&'static str, bool)]) -> Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for (name, is_required) in fields {
properties.insert((*name).to_string(), json!({ "type": "string" }));
if *is_required {
required.push(*name);
}
}
let mut schema = json!({
"type": "object",
"properties": properties,
});
if !required.is_empty() {
schema["required"] = json!(required);
}
schema
}
fn tool(name: &'static str, description: &'static str, input_schema: Value) -> McpToolDefinition {
McpToolDefinition {
name,
title: name,
description,
input_schema,
annotations: json!({ "readOnlyHint": true }),
}
}
fn resource_templates() -> Vec<Value> {
mcp_resource_templates()
.into_iter()
.map(|resource| serde_json::to_value(resource).expect("MCP resource template serializes"))
.collect()
}
pub fn mcp_resource_templates() -> Vec<McpResourceTemplateDefinition> {
vec![
resource_template(
"sdd://artifact/{orchestration}/{artifact}",
"sdd_artifact",
"SDD artifact",
"Read a local SDD artifact from docs/<slug>/",
"text/markdown",
),
resource_template(
"sdd://trace/{run_id}",
"sdd_trace",
"SDD trace tree",
"Read an SDD trace tree as JSON",
"application/json",
),
resource_template(
"sdd://context/{orchestration}/{stage}",
"sdd_context",
"SDD context pack",
"Read a generated context pack for one stage",
"text/markdown",
),
resource_template(
"sdd://context-bundle/{orchestration}/{stage}",
"sdd_context_bundle",
"SDD context bundle",
"Read the primary MCP context bundle for one stage",
"application/json",
),
resource_template(
"sdd://handoff/{orchestration}/{stage}",
"sdd_context_handoff",
"SDD context handoff",
"Read optimization-aware handoff for one stage",
"application/json",
),
resource_template(
"sdd://optimization/status",
"sdd_optimization_status",
"SDD optimization status",
"Read optimization wrapper status",
"application/json",
),
resource_template(
"sdd://capabilities/catalog",
"sdd_capabilities_catalog",
"SDD capability catalog",
"Read capability catalog and doctor status",
"application/json",
),
resource_template(
"sdd://agents/{agent_id}",
"sdd_agent_manifest",
"SDD agent manifest",
"Read canonical SDD agent manifests",
"application/json",
),
resource_template(
"sdd://runtime/adapters",
"sdd_runtime_adapters",
"SDD runtime adapters",
"Read optional MCP/provider adapter metadata for this sdd binary",
"application/json",
),
]
}
fn resource_template(
uri_template: &'static str,
name: &'static str,
title: &'static str,
description: &'static str,
mime_type: &'static str,
) -> McpResourceTemplateDefinition {
McpResourceTemplateDefinition {
uri_template,
name,
title,
description,
mime_type,
}
}
fn prompt_definitions() -> Vec<Value> {
mcp_prompt_definitions()
.into_iter()
.map(|prompt| serde_json::to_value(prompt).expect("MCP prompt definition serializes"))
.collect()
}
pub fn mcp_prompt_definitions() -> Vec<McpPromptDefinition> {
vec![
prompt(
"sdd_orchestration",
"Run SDD orchestration",
"Prompt for starting a full SDD orchestration",
json!([{ "name": "idea", "required": true }]),
),
prompt(
"sdd_stage_handoff",
"Prepare stage handoff",
"Prompt for handing off one SDD stage with trace context",
json!([
{ "name": "orchestration", "required": true },
{ "name": "stage", "required": true }
]),
),
prompt(
"sdd_trace_review",
"Review SDD trace",
"Prompt for reviewing an execution trace",
json!([{ "name": "run_id", "required": true }]),
),
]
}
fn prompt(
name: &'static str,
title: &'static str,
description: &'static str,
arguments: Value,
) -> McpPromptDefinition {
McpPromptDefinition {
name,
title,
description,
arguments,
}
}
#[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"));
}
#[test]
fn exposes_runtime_adapter_metadata() {
let adapters = runtime_adapters();
assert!(adapters.iter().any(|adapter| adapter.id == "rmcp"));
assert!(adapters.iter().any(|adapter| adapter.id == "rig"));
let input = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
"#;
let mut output = Vec::new();
serve_lines(&input[..], &mut output, &DummyBackend).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("sdd_runtime_adapters"));
}
#[test]
fn typed_registry_includes_optimized_mcp_surface() {
let tools = mcp_tool_definitions();
assert!(tools
.iter()
.all(|tool| tool.annotations["readOnlyHint"] == true));
let tool_names = tools.into_iter().map(|tool| tool.name).collect::<Vec<_>>();
assert!(tool_names.contains(&"sdd_optimize_status"));
assert!(tool_names.contains(&"sdd_context_handoff"));
assert!(tool_names.contains(&"sdd_context_bundle"));
assert!(tool_names.contains(&"sdd_capabilities_status"));
assert!(tool_names.contains(&"sdd_agents_manifest"));
assert!(tool_names.contains(&"sdd_project_status"));
assert!(tool_names.contains(&"sdd_search"));
let resource_names = mcp_resource_templates()
.into_iter()
.map(|resource| resource.name)
.collect::<Vec<_>>();
assert!(resource_names.contains(&"sdd_context_handoff"));
assert!(resource_names.contains(&"sdd_context_bundle"));
assert!(resource_names.contains(&"sdd_agent_manifest"));
}
#[cfg(feature = "mcp-rmcp")]
#[test]
fn builds_rmcp_server_info_when_feature_is_enabled() {
let info = rmcp_server_info_json().unwrap().unwrap();
assert_eq!(info["serverInfo"]["name"], "sdd-layer");
assert!(info["capabilities"]["tools"].is_object());
assert!(info["capabilities"]["resources"].is_object());
assert!(info["capabilities"]["prompts"].is_object());
}
}