1use std::io::{BufRead, Write};
13use std::path::Path;
14
15const PROTOCOL_VERSION: &str = "2025-06-18";
16const MAX_PROTOCOL_VERSION: &str = "2025-11-25";
22const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-11-25"];
23
24fn 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 if req > MAX_PROTOCOL_VERSION {
44 return MAX_PROTOCOL_VERSION;
45 }
46 PROTOCOL_VERSION
47}
48
49struct Tool {
51 name: &'static str,
52 description: &'static str,
53 input_schema: serde_json::Value,
54}
55
56fn 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
69fn 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
177fn 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
187fn reply(id: &serde_json::Value, result: serde_json::Value) {
189 send(&serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result}));
190}
191
192fn 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
201pub 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; }
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; };
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
279fn call_tool(root: &Path, name: &str, args: &serde_json::Value) -> crate::Result<String> {
282 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 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 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 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 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]
412fn 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 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]
437fn negotiate_protocol_echoes_supported() {
439 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]
445fn negotiate_protocol_newer_falls_back_to_max() {
447 assert_eq!(negotiate_protocol(Some("2026-01-01")), "2025-11-25");
450 }
451
452 #[test]
453fn negotiate_protocol_absent_defaults() {
455 assert_eq!(negotiate_protocol(None), "2025-06-18");
457 }
458}