Skip to main content

scc_cli/
mcp.rs

1//! MCP server (docs/API_AND_INTEGRATIONS.md §2, EPIC-080).
2//!
3//! Exposes exactly ten intent-level tools to agents — never analyzer-level
4//! graph operations:
5//!   system_overview, system_atlas, task_context, component_context,
6//!   flow_context, impact_context, verify_context, system_context,
7//!   surface_map, structural_source
8//!
9//! Transport: stdio, newline-delimited JSON-RPC 2.0 (MCP stdio framing).
10//! Repository read-only by default (docs/SECURITY.md §10).
11
12use std::io::{BufRead, Write};
13use std::path::Path;
14
15const PROTOCOL_VERSION: &str = "2025-06-18";
16/// The newest MCP protocol revision this server can speak. The client
17/// requests a version in `initialize.params.protocolVersion`; we negotiate
18/// down to the newest revision we support that is <= the client's, so both
19/// the 2025-06-18 and 2025-11-25 protocol generations work (fixwave Item
20/// 14 — OMP offers 2025-11-25, other clients 2025-06-18).
21const MAX_PROTOCOL_VERSION: &str = "2025-11-25";
22const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-11-25"];
23
24/// Negotiate the protocol version: prefer the client's requested revision
25/// when we support it; otherwise fall back to the newest supported revision
26/// that is not newer than the client's request; as a last resort use our
27/// oldest supported revision (a client that predates both gets the oldest
28/// we speak).
29// trace:v1 id=impl.crates-scc-cli-src-mcp.negotiate-protocol work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
30fn negotiate_protocol(requested: Option<&str>) -> &'static str {
31    let Some(req) = requested else {
32        return PROTOCOL_VERSION;
33    };
34    if SUPPORTED_PROTOCOL_VERSIONS.contains(&req) {
35        return SUPPORTED_PROTOCOL_VERSIONS
36            .iter()
37            .find(|v| **v == req)
38            .copied()
39            .unwrap_or(PROTOCOL_VERSION);
40    }
41    // Unsupported request: if it is newer than everything we support,
42    // answer with our newest; if it is older, answer with our oldest.
43    if req > MAX_PROTOCOL_VERSION {
44        return MAX_PROTOCOL_VERSION;
45    }
46    PROTOCOL_VERSION
47}
48
49// trace:v1 id=impl.crates-scc-cli-src-mcp.Tool work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
50struct Tool {
51    name: &'static str,
52    description: &'static str,
53    input_schema: serde_json::Value,
54}
55
56/// MCP tool annotations for a read-only deterministic context tool.
57/// All ten tools are non-destructive and idempotent for identical model
58/// state and input; only `task_context` takes open-world input.
59// trace:v1 id=impl.crates-scc-cli-src-mcp.tool-annotations work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
60fn tool_annotations(name: &str) -> serde_json::Value {
61    serde_json::json!({
62        "readOnlyHint": true,
63        "destructiveHint": false,
64        "idempotentHint": true,
65        "openWorldHint": name == "task_context",
66    })
67}
68
69// trace:v1 id=impl.scc.mcp work=WORK-SCC-001 satisfies=REQ-SCC-API
70// trace:v1 id=impl.crates-scc-cli-src-mcp.tools work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
71fn tools() -> Vec<Tool> {
72    vec![
73        Tool {
74            name: "system_overview",
75            description: "Compact system overview: purpose, components, boundaries, stores, external systems, flows, invariants, freshness.",
76            input_schema: serde_json::json!({"type": "object", "properties": {}}),
77        },
78        Tool {
79            name: "system_atlas",
80            description: "Full System Atlas: complete architecture for session startup (purpose, components, flows, ownership, contracts, invariants, failure paths, deployment, trust boundaries). The primary agent startup tool.",
81            input_schema: serde_json::json!({
82                "type": "object",
83                "properties": {
84                    "token_budget": {"type": "integer", "description": "Optional token budget (default context.atlas_tokens)"},
85                    "scope": {"type": "string", "description": "production (default: fixture/test/benchmark evidence labels components but never feeds architecture sections) or full (every role feeds architecture)"}
86                }
87            }),
88        },
89        Tool {
90            name: "task_context",
91            description: "Task-specific system context pack for a coding goal. Primary agent operation.",
92            input_schema: serde_json::json!({
93                "type": "object",
94                "required": ["goal"],
95                "properties": {
96                    "goal": {"type": "string", "description": "The task/goal in natural language"},
97                    "files": {"type": "array", "items": {"type": "string"}, "description": "Explicit file paths"},
98                    "symbols": {"type": "array", "items": {"type": "string"}, "description": "Explicit symbol names"},
99                    "token_budget": {"type": "integer", "minimum": 512, "description": "Hard token budget (default 8000)"}
100                }
101            }),
102        },
103        Tool {
104            name: "component_context",
105            description: "Component detail: responsibility, implementation, dependencies, ownership, flows, contracts, tests, evidence.",
106            input_schema: serde_json::json!({
107                "type": "object",
108                "required": ["component"],
109                "properties": {
110                    "component": {"type": "string", "description": "Component id or name"}
111                }
112            }),
113        },
114        Tool {
115            name: "flow_context",
116            description: "Flow detail: trigger, steps, branches, data, failures, retries, evidence.",
117            input_schema: serde_json::json!({
118                "type": "object",
119                "required": ["flow"],
120                "properties": {
121                    "flow": {"type": "string", "description": "Flow id or name"}
122                }
123            }),
124        },
125        Tool {
126            name: "impact_context",
127            description: "Impact of a change: affected components, flows, consumers, contracts, data, invariants, tests, risk.",
128            input_schema: serde_json::json!({
129                "type": "object",
130                "properties": {
131                    "files": {"type": "array", "items": {"type": "string"}},
132                    "symbols": {"type": "array", "items": {"type": "string"}},
133                    "diff": {"type": "string", "description": "Git base revision for diff (e.g. origin/main)"}
134                }
135            }),
136        },
137        Tool {
138            name: "verify_context",
139            description: "Verification report: freshness, stale facts, conflicts, low-confidence dependencies, drift, missing evidence.",
140            input_schema: serde_json::json!({"type": "object", "properties": {}}),
141        },
142        Tool {
143            name: "system_context",
144            description: "Session-startup artifact: the System Atlas fused with the System Surface Map (the actual callable API layer), model coverage and honest omissions in one deterministic pack. The primary agent startup tool.",
145            input_schema: serde_json::json!({
146                "type": "object",
147                "properties": {"token_budget": {"type": "integer", "description": "Optional token budget. Default is the production adaptive startup total; the atlas:surface split is chosen from repository complexity."}}
148            }),
149        },
150        Tool {
151            name: "surface_map",
152            description: "The System Surface Map: the repository's actual callable API layer, ranked by global importance — or, with a goal, personalized to that task (task PPR re-ranking).",
153            input_schema: serde_json::json!({
154                "type": "object",
155                "properties": {
156                    "goal": {"type": "string", "description": "Task goal to personalize the map"},
157                    "token_budget": {"type": "integer", "description": "Optional token budget (default context.surface_tokens, 7000)"}
158                }
159            }),
160        },
161        Tool {
162            name: "structural_source",
163            description: "Structural Source representation of files: exact declaration headers plus per-symbol call/write evidence (deep) or signatures and imports (fallback). Pass files or a goal (a goal selects the task-matched files via the PPR->Surface pipeline).",
164            input_schema: serde_json::json!({
165                "type": "object",
166                "properties": {
167                    "files": {"type": "array", "items": {"type": "string"}, "description": "Repository-relative file paths or scc:// content handles"},
168                    "goal": {"type": "string", "description": "Task goal; resolves to the task-matched files"},
169                    "token_budget": {"type": "integer", "description": "Optional token budget (default context.structural_source, 6000)"}
170                }
171            }),
172        },
173    ]
174}
175
176
177// trace:v1 id=impl.crates-scc-cli-src-mcp.send work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
178fn send(msg: &serde_json::Value) {
179    let mut line = serde_json::to_string(msg).unwrap_or_default();
180    line.push('\n');
181    let stdout = std::io::stdout();
182    let mut lock = stdout.lock();
183    let _ = lock.write_all(line.as_bytes());
184    let _ = lock.flush();
185}
186
187// trace:v1 id=impl.crates-scc-cli-src-mcp.reply work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
188fn reply(id: &serde_json::Value, result: serde_json::Value) {
189    send(&serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result}));
190}
191
192// trace:v1 id=impl.crates-scc-cli-src-mcp.error work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
193fn error(id: &serde_json::Value, code: i64, message: &str) {
194    send(&serde_json::json!({
195        "jsonrpc": "2.0",
196        "id": id,
197        "error": {"code": code, "message": message}
198    }));
199}
200
201/// Run the MCP server over stdin/stdout for `root`.
202// trace:v1 id=impl.crates-scc-cli-src-mcp.serve-stdio work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
203pub fn serve_stdio(root: &Path) -> crate::Result<()> {
204    let stdin = std::io::stdin();
205    let mut line = String::new();
206    let mut lock = stdin.lock();
207    loop {
208        line.clear();
209        let n = lock.read_line(&mut line)?;
210        if n == 0 {
211            break; // EOF
212        }
213        let line = line.trim();
214        if line.is_empty() {
215            continue;
216        }
217        let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) else {
218            continue;
219        };
220        let Some(method) = msg.get("method").and_then(|m| m.as_str()) else {
221            continue; // response or notification
222        };
223        let id = msg.get("id").cloned().unwrap_or(serde_json::Value::Null);
224        let params = msg.get("params").cloned().unwrap_or(serde_json::json!({}));
225
226        match method {
227            "initialize" => {
228                let requested = params
229                    .get("protocolVersion")
230                    .and_then(|v| v.as_str());
231                reply(
232                    &id,
233                    serde_json::json!({
234                        "protocolVersion": negotiate_protocol(requested),
235                        "capabilities": {"tools": {"listChanged": false}},
236                        "serverInfo": {"name": "scc", "version": env!("CARGO_PKG_VERSION")}
237                    }),
238                );
239            }
240            "notifications/initialized" | "notifications/cancelled" => {}
241            "ping" => reply(&id, serde_json::json!({})),
242            "tools/list" => {
243                let list: Vec<serde_json::Value> = tools()
244                    .iter()
245                    .map(|t| {
246                        serde_json::json!({
247                            "name": t.name,
248                            "description": t.description,
249                            "inputSchema": t.input_schema,
250                            "annotations": tool_annotations(t.name),
251                        })
252                    })
253                    .collect();
254                reply(&id, serde_json::json!({"tools": list}));
255            }
256            "tools/call" => {
257                let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
258                let args = params.get("arguments").cloned().unwrap_or(serde_json::json!({}));
259                match call_tool(root, name, &args) {
260                    Ok(text) => reply(
261                        &id,
262                        serde_json::json!({"content": [{"type": "text", "text": text}]}),
263                    ),
264                    Err(e) => reply(
265                        &id,
266                        serde_json::json!({
267                            "content": [{"type": "text", "text": format!("error: {e}")}],
268                            "isError": true
269                        }),
270                    ),
271                }
272            }
273            other => error(&id, -32601, &format!("method not found: {other}")),
274        }
275    }
276    Ok(())
277}
278
279// trace:exempt reason=internal-detail
280// trace:v1 id=impl.crates-scc-cli-src-mcp.call-tool work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
281fn call_tool(root: &Path, name: &str, args: &serde_json::Value) -> crate::Result<String> {
282    // Curated MCP surface stays (10 semantic tools); DERIVATION routes into
283    // the operation registry — same engine, same semantics as every other
284    // transport. Text extraction below (content/text fields) is rendering.
285    let store = crate::open_store(root)?;
286    if !store.snapshot_status()?.is_some() {
287        return Ok("# NOT INDEXED\nRun `scc index` before asking for system context.".to_string());
288    }
289
290    let str_arg = |k: &str| -> String {
291        args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string()
292    };
293    let arr_arg = |k: &str| -> Vec<String> {
294        args.get(k)
295            .and_then(|v| v.as_array())
296            .map(|a| {
297                a.iter()
298                    .filter_map(|x| x.as_str().map(|s| s.to_string()))
299                    .collect()
300            })
301            .unwrap_or_default()
302    };
303    let invoke = |op: &str, input: serde_json::Value| -> crate::Result<serde_json::Value> {
304        scc_engine::invoke(root, op, input).map_err(|e| crate::CliError::Other(e.to_string()))
305    };
306    let pack_text = |v: &serde_json::Value| -> String {
307        v.get("content").and_then(|c| c.as_str()).unwrap_or("").to_string()
308    };
309
310
311    match name {
312        "system_overview" => Ok(pack_text(&invoke("context.overview", serde_json::json!({}))?)),
313        "system_atlas" => {
314            let budget = args.get("token_budget").and_then(|b| b.as_u64()).map(|b| b as usize);
315            let full = args.get("scope").and_then(|s| s.as_str()).map(|s| s == "full").unwrap_or(false);
316            Ok(pack_text(&invoke("context.atlas", serde_json::json!({"budget": budget, "full": full}))?))
317        }
318        "task_context" => {
319            let goal = str_arg("goal");
320            if goal.is_empty() {
321                return Ok("task_context requires a `goal` string.".to_string());
322            }
323            let budget = args
324                .get("token_budget")
325                .and_then(|v| v.as_u64())
326                .map(|b| b as usize);
327            // Transport parity: THE one complete task artifact via invoke.
328            let out = invoke("context.task", serde_json::json!({
329                "goal": goal, "files": arr_arg("files"),
330                "symbols": arr_arg("symbols"), "budget": budget, "hook": false,
331            }))?;
332            let pack = out.get("pack").cloned().unwrap_or(serde_json::json!({}));
333            let delta = out.get("delta").and_then(|d| d.as_str()).unwrap_or("");
334            let content = pack_text(&pack);
335            if delta.is_empty() {
336                Ok(content)
337            } else {
338                Ok(format!("{content}\n{delta}"))
339            }
340        }
341        "component_context" => {
342            let id = str_arg("component");
343            if id.is_empty() {
344                return Ok("component_context requires a `component` id or name.".to_string());
345            }
346            Ok(pack_text(&invoke("context.component", serde_json::json!({"id": id}))?))
347        }
348        "flow_context" => {
349            let id = str_arg("flow");
350            if id.is_empty() {
351                return Ok("flow_context requires a `flow` id or name.".to_string());
352            }
353            Ok(pack_text(&invoke("context.flow", serde_json::json!({"id": id}))?))
354        }
355        "impact_context" => {
356            let diff = str_arg("diff");
357            Ok(pack_text(&invoke("context.impact", serde_json::json!({
358                "files": arr_arg("files"), "symbols": arr_arg("symbols"),
359                "diff": if diff.is_empty() { serde_json::Value::Null } else { serde_json::json!(diff) },
360            }))?))
361        }
362        "verify_context" => Ok(pack_text(&invoke("context.verify", serde_json::json!({}))?)),
363        "system_context" => {
364            let budget_tokens = args
365                .get("token_budget")
366                .and_then(|b| b.as_u64())
367                .map(|b| b as usize);
368            // Transport parity via the registry: engine startup derives +
369            // records the ledger (same as CLI `context startup`).
370            let out = invoke("context.startup", serde_json::json!({"budget": budget_tokens}))?;
371            Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
372        }
373        "surface_map" => {
374            let goal = str_arg("goal");
375            let tokens = args
376                .get("token_budget")
377                .and_then(|v| v.as_u64())
378                .map(|b| b as usize);
379            // Registry derivation (lexical scorer; same as inference-disabled
380            // CLI — remote-model wiring stays transport-side via engine API).
381            let out = invoke("surface.build", serde_json::json!({
382                "task": if goal.is_empty() { serde_json::Value::Null } else { serde_json::json!(goal) },
383                "budget": tokens, "explain": false,
384            }))?;
385            Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
386        }
387        "structural_source" => {
388            let goal = str_arg("goal");
389            let budget = args
390                .get("token_budget")
391                .and_then(|v| v.as_u64())
392                .map(|b| b as usize);
393            let files = arr_arg("files");
394            let task = if goal.is_empty() { None } else { Some(goal.as_str()) };
395            // Registry derivation: the engine owns the structural
396            // build; this transport renders its `text` field.
397            let task_s: Option<String> = task.map(|s: &str| s.to_string());
398            let out = invoke("context.structural", serde_json::json!({
399                "files": files, "task": task_s, "budget": budget,
400            }))?;
401            Ok(out.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string())
402        }
403        other => Err(crate::CliError::Other(format!("unknown tool: {other}"))),
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412// trace:exempt reason=internal-detail
413// trace:v1 id=impl.crates-scc-cli-src-mcp.tool-schemas-are-valid-json-schema work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
414    fn tool_schemas_are_valid_json_schema() {
415        for t in tools() {
416            assert_eq!(t.input_schema["type"], "object");
417            assert!(t.input_schema.get("properties").is_some());
418        }
419        assert_eq!(tools().len(), 10, "the ten semantic tools only");
420        // truthful annotations: read-only, non-destructive, idempotent;
421        // only task_context takes open-world input.
422        for t in tools() {
423            let a = tool_annotations(t.name);
424            assert_eq!(a["readOnlyHint"], true, "{}", t.name);
425            assert_eq!(a["destructiveHint"], false, "{}", t.name);
426            assert_eq!(a["idempotentHint"], true, "{}", t.name);
427            assert_eq!(
428                a["openWorldHint"],
429                t.name == "task_context",
430                "{}",
431                t.name
432            );
433        }
434    }
435
436    #[test]
437// trace:v1 id=impl.crates-scc-cli-src-mcp.negotiate-protocol-echoes-supported work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
438    fn negotiate_protocol_echoes_supported() {
439        // A client requesting a version we support gets it back verbatim.
440        assert_eq!(negotiate_protocol(Some("2025-06-18")), "2025-06-18");
441        assert_eq!(negotiate_protocol(Some("2025-11-25")), "2025-11-25");
442    }
443
444    #[test]
445// trace:v1 id=impl.crates-scc-cli-src-mcp.negotiate-protocol-newer-falls-back work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
446    fn negotiate_protocol_newer_falls_back_to_max() {
447        // A client requesting a NEWER protocol than we support gets our
448        // newest supported revision (2025-11-25).
449        assert_eq!(negotiate_protocol(Some("2026-01-01")), "2025-11-25");
450    }
451
452    #[test]
453// trace:v1 id=impl.crates-scc-cli-src-mcp.negotiate-protocol-absent-defaults work=WORK-wave-15-2-heterogeneous-hierarchy-edges-semantic-scoring-explain-rank-caching
454    fn negotiate_protocol_absent_defaults() {
455        // No requested version -> our baseline.
456        assert_eq!(negotiate_protocol(None), "2025-06-18");
457    }
458}