use anyhow::Result;
use serde::Serialize;
use serde_json::{json, Value};
use std::error::Error as StdError;
use std::fmt;
use std::io::{self, BufRead, Write};
use std::time::Instant;
pub const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
#[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],
}
#[derive(Debug)]
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 output_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 list_resources_page(&self, cursor: Option<&str>) -> Result<(Vec<Value>, Option<String>)> {
paginate_values(self.list_resources()?, cursor, 100)
}
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<()> {
let mut lifecycle = McpLifecycle::default();
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, &mut lifecycle) {
writeln!(writer, "{}", serde_json::to_string(&response)?)?;
writer.flush()?;
}
}
Ok(())
}
#[derive(Default)]
enum McpLifecycle {
#[default]
PendingInitialize,
AwaitingInitialized,
Ready,
}
#[derive(Debug)]
struct InvalidParams(String);
impl fmt::Display for InvalidParams {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl StdError for InvalidParams {}
fn invalid_params(message: impl Into<String>) -> anyhow::Error {
anyhow::Error::new(InvalidParams(message.into()))
}
fn handle_request<B: McpBackend>(
backend: &B,
request: Value,
lifecycle: &mut McpLifecycle,
) -> 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 == "notifications/initialized" {
if matches!(lifecycle, McpLifecycle::AwaitingInitialized) {
*lifecycle = McpLifecycle::Ready;
}
return None;
}
if id.is_null() && method.starts_with("notifications/") {
return None;
}
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
if method == "initialize" {
*lifecycle = McpLifecycle::AwaitingInitialized;
return Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": initialize_result(¶ms)
}));
}
if !matches!(lifecycle, McpLifecycle::Ready) {
return Some(jsonrpc_error(id, -32002, "server is not initialized"));
}
if !matches!(
method,
"tools/list"
| "tools/call"
| "resources/list"
| "resources/templates/list"
| "resources/read"
| "prompts/list"
| "prompts/get"
) {
return Some(jsonrpc_error(
id,
-32601,
&format!("method not found: {method}"),
));
}
let result = match method {
"tools/list" => Ok(json!({ "tools": tool_definitions() })),
"tools/call" => call_tool(backend, ¶ms),
"resources/list" => {
let cursor = params.get("cursor").and_then(Value::as_str);
backend
.list_resources_page(cursor)
.map(|(resources, next_cursor)| {
let mut value = json!({ "resources": resources });
if let Some(next_cursor) = next_cursor {
value["nextCursor"] = json!(next_cursor);
}
value
})
}
"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),
_ => unreachable!("known MCP method was checked above"),
};
Some(match result {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(error) if error.downcast_ref::<InvalidParams>().is_some() => {
jsonrpc_error(id, -32602, &error.to_string())
}
Err(error) => jsonrpc_error(id, -32603, &error.to_string()),
})
}
fn initialize_result(params: &Value) -> Value {
let _requested_protocol_version = params.get("protocolVersion").and_then(Value::as_str);
json!({
"protocolVersion": MCP_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(|| invalid_params("tools/call missing params.name"))?;
let arguments = params.get("arguments").unwrap_or(&Value::Null);
if !arguments.is_null() && !arguments.is_object() {
return Err(invalid_params(
"tools/call params.arguments must be an object",
));
}
if !is_known_tool(name) {
return Err(invalid_params(format!("unknown tool: {name}")));
}
let started = Instant::now();
match backend.call_tool(name, arguments) {
Ok(value) => Ok(tool_result(
value,
false,
started.elapsed().as_millis() as u64,
)),
Err(error) => Ok(tool_result(
json!({ "error": { "message": error.to_string() } }),
true,
started.elapsed().as_millis() as u64,
)),
}
}
fn is_known_tool(name: &str) -> bool {
matches!(
name,
"sdd_trace_list"
| "sdd_trace_show"
| "sdd_trace_summary"
| "sdd_artifact_status"
| "sdd_artifact_history"
| "sdd_context_build"
| "sdd_context_bundle"
| "sdd_clients_doctor"
| "sdd_project_status"
| "sdd_search"
| "sdd_runtime_adapters"
| "sdd_optimize_status"
| "sdd_context_handoff"
| "sdd_readiness_summary"
| "sdd_capabilities_status"
| "sdd_agents_manifest"
)
}
fn read_resource<B: McpBackend>(backend: &B, params: &Value) -> Result<Value> {
let uri = params
.get("uri")
.and_then(Value::as_str)
.ok_or_else(|| invalid_params("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(|| invalid_params("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, duration_ms: u64) -> Value {
let scalar_text = value.as_str().map(str::to_string);
let structured_content = if value.is_object() {
value
} else {
json!({ "value": value })
};
let text = scalar_text.unwrap_or_else(|| {
serde_json::to_string_pretty(&structured_content)
.unwrap_or_else(|_| structured_content.to_string())
});
let payload_bytes = serde_json::to_vec(&structured_content)
.map(|bytes| bytes.len() as u64)
.unwrap_or_default();
let cache = cache_outcome(&structured_content);
let mut content = vec![json!({ "type": "text", "text": text })];
if let Some(resources) = structured_content
.get("resources")
.and_then(Value::as_array)
{
content.extend(resources.iter().filter_map(resource_link_content));
}
json!({
"content": content,
"structuredContent": structured_content,
"isError": is_error,
"_meta": {
"telemetry": {
"duration_ms": duration_ms,
"cache": cache,
"payload_bytes": payload_bytes
}
}
})
}
fn cache_outcome(value: &Value) -> &'static str {
let source = value
.pointer("/data/source")
.or_else(|| value.get("source"))
.and_then(Value::as_str);
if source == Some("direct-fallback") {
return "fallback";
}
if value
.pointer("/data/cache/rebuilt")
.or_else(|| value.pointer("/cache/rebuilt"))
.and_then(Value::as_bool)
== Some(true)
{
return "rebuilt";
}
if source == Some("cache") {
"hit"
} else {
"not-applicable"
}
}
fn resource_link_content(value: &Value) -> Option<Value> {
let uri = value.get("uri")?.as_str()?;
let name = value.get("name").and_then(Value::as_str).unwrap_or(uri);
let mut link = json!({ "type": "resource_link", "uri": uri, "name": name });
for field in ["title", "description", "mimeType", "annotations"] {
if let Some(value) = value.get(field) {
link[field] = value.clone();
}
}
Some(link)
}
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),
("limit", false),
("cursor", 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_artifact_history",
"Read paginated artifact revision history",
object_schema(&[
("orchestration", true),
("stage", false),
("limit", false),
("cursor", 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), ("cursor", 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_readiness_summary",
"Aggregate MCP doctor, health, auto/workflow status, cache freshness, capabilities and quality report into ready/warn/blocked",
object_schema(&[("orchestration", false), ("workflow", 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(), field_schema(name));
if *is_required {
required.push(*name);
}
}
properties.insert(
"profile".to_string(),
json!({
"type": "string",
"enum": ["compact", "standard", "full"],
"description": "Optional response profile; omitted preserves the legacy payload for one minor release."
}),
);
let mut schema = json!({
"type": "object",
"properties": properties,
});
if !required.is_empty() {
schema["required"] = json!(required);
}
schema
}
fn field_schema(name: &str) -> Value {
match name {
"limit" => json!({ "type": "integer", "minimum": 1, "maximum": 50 }),
"cursor" => json!({ "type": "string", "minLength": 1 }),
"workflow" => json!({
"type": "string",
"minLength": 1,
"maxLength": 128,
"pattern": "^[A-Za-z0-9][A-Za-z0-9_-]*$"
}),
_ => json!({ "type": "string" }),
}
}
fn output_schema(name: &str) -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"description": format!("Versioned structured output contract for `{name}`."),
"oneOf": [legacy_output_schema(name), profiled_output_schema(), tool_error_schema()]
})
}
fn legacy_output_schema(name: &str) -> Value {
let required: &[&str] = match name {
"sdd_trace_list" => &["events"],
"sdd_trace_show" => &["run_id", "events", "children"],
"sdd_trace_summary" => &["total_events", "roots"],
"sdd_artifact_status" => &["path", "artifacts"],
"sdd_artifact_history" => &["history"],
"sdd_context_build" => &["content"],
"sdd_context_bundle" => &["context_pack", "mcp_policy"],
"sdd_clients_doctor" => &["status", "covered"],
"sdd_project_status" => &["status", "cache", "orchestrations"],
"sdd_search" => &["query", "source", "results"],
"sdd_runtime_adapters" => &["adapters"],
"sdd_optimize_status" => &["enabled", "codegraph"],
"sdd_context_handoff" => &["handoff", "rendered"],
"sdd_readiness_summary" => &["status", "verdict", "components"],
"sdd_capabilities_status" => &["catalog", "doctor"],
"sdd_agents_manifest" => &["manifest"],
_ => &[],
};
let properties = required
.iter()
.map(|field| ((*field).to_string(), output_field_schema(field)))
.collect::<serde_json::Map<_, _>>();
json!({
"title": format!("{name} legacy response"),
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": true
})
}
fn output_field_schema(field: &str) -> Value {
match field {
"events" | "children" | "roots" | "orchestrations" | "results" | "adapters" | "history" => {
json!({ "type": "array" })
}
"artifacts" | "context_pack" | "mcp_policy" | "cache" | "components" | "catalog"
| "doctor" | "manifest" | "codegraph" | "handoff" | "covered" => {
json!({ "type": "object" })
}
"enabled" => json!({ "type": "boolean" }),
"total_events" => json!({ "type": "integer", "minimum": 0 }),
_ => json!({ "type": "string" }),
}
}
fn profiled_output_schema() -> Value {
json!({
"title": "Profiled response envelope",
"type": "object",
"properties": {
"schema_version": { "const": MCP_PROTOCOL_VERSION },
"profile": { "type": "string", "enum": ["compact", "standard", "full"] },
"truncated": { "type": "boolean" },
"next_cursor": { "type": ["string", "null"] },
"data": { "type": "object" },
"resources": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": { "const": "resource_link" },
"uri": { "type": "string" },
"name": { "type": "string" },
"mimeType": { "type": "string" },
"annotations": { "type": "object" }
},
"required": ["type", "uri", "name"],
"additionalProperties": true
}
}
},
"required": ["schema_version", "profile", "truncated", "next_cursor", "data", "resources"],
"additionalProperties": false
})
}
fn tool_error_schema() -> Value {
json!({
"title": "Tool execution error",
"type": "object",
"properties": {
"error": {
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"],
"additionalProperties": true
}
},
"required": ["error"],
"additionalProperties": false
})
}
fn tool(name: &'static str, description: &'static str, input_schema: Value) -> McpToolDefinition {
McpToolDefinition {
name,
title: name,
description,
input_schema,
output_schema: output_schema(name),
annotations: json!({ "readOnlyHint": true }),
}
}
fn paginate_values(
values: Vec<Value>,
cursor: Option<&str>,
page_size: usize,
) -> Result<(Vec<Value>, Option<String>)> {
let offset = cursor
.map(str::parse::<usize>)
.transpose()
.map_err(|_| invalid_params("invalid cursor"))?
.unwrap_or_default();
if offset > values.len() {
return Err(invalid_params("cursor is out of range"));
}
let end = offset.saturating_add(page_size).min(values.len());
let next_cursor = (end < values.len()).then(|| end.to_string());
Ok((values[offset..end].to_vec(), next_cursor))
}
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://artifact/{orchestration}/{artifact}/history",
"sdd_artifact_history",
"SDD artifact history",
"Read committed revisions for a local SDD artifact",
"application/json",
),
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",
),
resource_template(
"sdd://auto/status",
"sdd_auto_status",
"SDD autonomous engine status",
"Read the autonomous engine status including demand queue and engine states",
"application/json",
),
resource_template(
"sdd://workflow/{id}/status",
"sdd_workflow_status",
"SDD workflow run status",
"Read a persisted workflow run report by workflow ID",
"application/json",
),
resource_template(
"sdd://quality/{slug}",
"sdd_quality_report",
"SDD quality report",
"Read the quality evaluation report for an orchestration",
"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":0,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"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":0,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"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();
let rmcp = adapters
.iter()
.find(|adapter| adapter.id == "rmcp")
.unwrap();
assert_eq!(rmcp.fallback, "manual-jsonrpc-stdio");
assert!(adapters.iter().any(|adapter| adapter.id == "rig"));
let input = br#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"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_readiness_summary"));
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"));
assert!(resource_names.contains(&"sdd_auto_status"));
assert!(resource_names.contains(&"sdd_workflow_status"));
assert!(resource_names.contains(&"sdd_quality_report"));
let search = mcp_tool_definitions()
.into_iter()
.find(|tool| tool.name == "sdd_search")
.unwrap();
let serialized = serde_json::to_value(search).unwrap();
assert_eq!(
serialized["inputSchema"]["properties"]["limit"]["type"],
"integer"
);
assert_eq!(
serialized["inputSchema"]["properties"]["cursor"]["type"],
"string"
);
assert_eq!(
serialized["inputSchema"]["properties"]["profile"]["enum"][0],
"compact"
);
assert_eq!(serialized["outputSchema"]["type"], "object");
assert_eq!(
serialized["outputSchema"]["oneOf"][1]["properties"]["schema_version"]["const"],
MCP_PROTOCOL_VERSION
);
assert!(serialized["outputSchema"]["oneOf"][0]["required"]
.as_array()
.unwrap()
.contains(&json!("results")));
let readiness = mcp_tool_definitions()
.into_iter()
.find(|tool| tool.name == "sdd_readiness_summary")
.unwrap();
assert!(readiness.output_schema["oneOf"][0]["required"]
.as_array()
.unwrap()
.contains(&json!("verdict")));
assert_eq!(
readiness.input_schema["properties"]["workflow"]["pattern"],
"^[A-Za-z0-9][A-Za-z0-9_-]*$"
);
let clients = mcp_tool_definitions()
.into_iter()
.find(|tool| tool.name == "sdd_clients_doctor")
.unwrap();
assert_eq!(
clients.output_schema["oneOf"][0]["properties"]["covered"]["type"],
"object"
);
}
#[test]
fn negotiates_supported_protocol_and_requires_initialized_notification() {
let input = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2099-01-01"}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}
"#;
let mut output = Vec::new();
serve_lines(&input[..], &mut output, &DummyBackend).unwrap();
let responses = String::from_utf8(output)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(responses[0]["result"]["protocolVersion"], "2025-11-25");
assert_eq!(responses[1]["error"]["code"], -32002);
assert!(responses[2]["result"]["tools"].is_array());
}
#[test]
fn tool_results_preserve_text_and_expose_structured_content_with_safe_telemetry() {
let input = br#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sdd_trace_list","arguments":{}}}
"#;
let mut output = Vec::new();
serve_lines(&input[..], &mut output, &DummyBackend).unwrap();
let response: Value =
serde_json::from_str(String::from_utf8(output).unwrap().lines().last().unwrap())
.unwrap();
let result = &response["result"];
assert_eq!(result["content"][0]["type"], "text");
assert!(result["structuredContent"].is_object());
assert!(result["_meta"]["telemetry"]["duration_ms"].is_u64());
assert!(result["_meta"]["telemetry"]["payload_bytes"].is_u64());
assert!(result["_meta"]["telemetry"]["cache"].is_string());
assert!(result["_meta"]["telemetry"].get("arguments").is_none());
let plain = tool_result(json!("plain text"), false, 0);
assert_eq!(plain["content"][0]["text"], "plain text");
assert_eq!(plain["structuredContent"]["value"], "plain text");
let profiled = tool_result(
json!({
"schema_version": "2025-11-25",
"profile": "compact",
"truncated": false,
"next_cursor": null,
"data": { "content": "x".repeat(10_000) },
"resources": [],
}),
false,
0,
);
let serialized_text: Value =
serde_json::from_str(profiled["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(serialized_text, profiled["structuredContent"]);
}
#[test]
fn maps_invalid_params_and_tool_execution_failures_separately() {
struct FailingBackend;
impl McpBackend for FailingBackend {
fn call_tool(&self, _name: &str, _arguments: &Value) -> Result<Value> {
Err(anyhow::anyhow!("backend validation failed"))
}
fn list_resources(&self) -> Result<Vec<Value>> {
Ok(vec![])
}
fn read_resource(&self, _uri: &str) -> Result<McpResource> {
unreachable!()
}
fn get_prompt(&self, _name: &str, _arguments: &Value) -> Result<Value> {
unreachable!()
}
}
let input = br#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}
{"jsonrpc":"2.0","id":2,"method":"resources/list","params":{"cursor":"not-a-cursor"}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"sdd_trace_list","arguments":{}}}
{"jsonrpc":"2.0","id":4,"method":"missing/method","params":{}}
"#;
let mut output = Vec::new();
serve_lines(&input[..], &mut output, &FailingBackend).unwrap();
let responses = String::from_utf8(output)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(responses[1]["error"]["code"], -32602);
assert_eq!(responses[2]["error"]["code"], -32602);
assert_eq!(responses[3]["result"]["isError"], true);
assert!(responses[3]["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("backend validation failed"));
assert_eq!(responses[4]["error"]["code"], -32601);
}
#[test]
fn resources_list_supports_cursor_pagination() {
struct ManyResourcesBackend;
impl McpBackend for ManyResourcesBackend {
fn call_tool(&self, _name: &str, _arguments: &Value) -> Result<Value> {
Ok(json!({}))
}
fn list_resources(&self) -> Result<Vec<Value>> {
Ok((0..101)
.map(|index| json!({"uri": format!("sdd://item/{index}"), "name": index.to_string()}))
.collect())
}
fn read_resource(&self, _uri: &str) -> Result<McpResource> {
unreachable!()
}
fn get_prompt(&self, _name: &str, _arguments: &Value) -> Result<Value> {
unreachable!()
}
}
let input = br#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":1,"method":"resources/list","params":{}}
{"jsonrpc":"2.0","id":2,"method":"resources/list","params":{"cursor":"100"}}
"#;
let mut output = Vec::new();
serve_lines(&input[..], &mut output, &ManyResourcesBackend).unwrap();
let responses = String::from_utf8(output)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(
responses[1]["result"]["resources"]
.as_array()
.unwrap()
.len(),
100
);
assert_eq!(responses[1]["result"]["nextCursor"], "100");
assert_eq!(
responses[2]["result"]["resources"]
.as_array()
.unwrap()
.len(),
1
);
assert!(responses[2]["result"].get("nextCursor").is_none());
}
#[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["protocolVersion"], MCP_PROTOCOL_VERSION);
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());
}
}