//! Minimaler MCP-stdio-Server (`app --mcp-panel`), den claude als Tool-Server //! startet. Er stellt genau ein Tool bereit: `write_panel(text)` schreibt den //! Text in die Panel-Datei aus `AI_CONTROL_PANEL` (dieselbe Env, die der //! Terminal-Prozess der PTY mitgibt und die claude an seine MCP-Kinder vererbt). //! Der bestehende Datei-Watcher im Terminal-Prozess zieht den Inhalt ins Panel. //! //! Protokoll: JSON-RPC 2.0, newline-getrennt (ein Objekt pro Zeile). use serde_json::{json, Value}; use std::io::{BufRead, Write}; pub fn run_mcp_panel() { let stdin = std::io::stdin(); let mut stdout = std::io::stdout(); let mut reader = stdin.lock(); let mut line = String::new(); loop { line.clear(); if reader.read_line(&mut line).unwrap_or(0) == 0 { break; // stdin geschlossen -> claude hat den Server beendet } let trimmed = line.trim(); if trimmed.is_empty() { continue; } let Ok(req) = serde_json::from_str::(trimmed) else { continue; }; let id = req.get("id").cloned(); let method = req.get("method").and_then(Value::as_str).unwrap_or(""); let result = handle(method, &req); // Nur Requests (mit id) bekommen eine Antwort; Notifications nicht. let (Some(id), Some(result)) = (id, result) else { continue; }; let resp = json!({ "jsonrpc": "2.0", "id": id, "result": result }); if writeln!(stdout, "{resp}").is_err() || stdout.flush().is_err() { break; } } } /// Liefert das `result` für einen Request, oder None für Notifications/Unbekanntes. fn handle(method: &str, req: &Value) -> Option { match method { "initialize" => { // Vom Client vorgeschlagene Protokollversion übernehmen. let protocol = req["params"]["protocolVersion"] .as_str() .unwrap_or("2024-11-05"); Some(json!({ "protocolVersion": protocol, "capabilities": { "tools": {} }, "serverInfo": { "name": "text-panel", "version": env!("CARGO_PKG_VERSION") }, })) } "tools/list" => Some(json!({ "tools": [ { "name": "write_panel", "description": "Legt einen längeren Entwurf (ADR, E-Mail, Dokument, Spezifikation, \ Commit-Message, Textbaustein) im ai-control-Panel neben dem Terminal \ ab, wo er mit der Maus selektierbar und über einen Button kopierbar \ ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \ aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \ IMMER `path` statt `text` übergeben — der Server liest die Datei \ selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.", "inputSchema": { "type": "object", "properties": { "text": { "type": "string", "description": "Vollständiger Entwurf als Markdown-Rohtext.", }, "path": { "type": "string", "description": "Statt `text`: Pfad einer vorhandenen Datei, deren Inhalt ins \ Panel geladen wird. Genau eines von beiden angeben.", } }, }, }, { "name": "write_commands", "description": "Listet Shell-Befehle, die der Nutzer ausführen soll, als kopierbare \ Kacheln im ai-control-Panel. IMMER nutzen, wenn ein Befehl für den \ Nutzer bestimmt ist — statt ihn als Codeblock in den Chat zu \ schreiben; im Chat nur kurz einordnen. Die Befehle werden an die \ Befehls-History der Session angehängt (flüchtig, startet mit \ jeder Session leer).", "inputSchema": { "type": "object", "properties": { "commands": { "type": "array", "description": "Befehle in Ausführungsreihenfolge.", "items": { "type": "object", "properties": { "cmd": { "type": "string", "description": "Der Befehl, exakt so ausführbar.", }, "note": { "type": "string", "description": "Optionale Kurznotiz, was der Befehl tut.", } }, "required": ["cmd"], }, } }, "required": ["commands"], }, }, { "name": "show_commands", "description": "Zeigt die Befehls-History im ai-control-Panel (Kachel-Ansicht mit \ allen bisher ausgegebenen Befehlen), ohne etwas anzuhängen. Nutzen, \ wenn der Nutzer die Befehlsliste sehen will („zeig die Befehle“, \ „zeige die Befehlsliste“).", "inputSchema": { "type": "object", "properties": {} }, }, { "name": "archive_panel", "description": "Archiviert den aktuell im Panel liegenden Entwurf dauerhaft als \ Markdown-Datei im Archiv-Home des Projekts. Auf Wunsch nutzen \ (Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \ `description` und `tags` mitgeben — einmalige Kuratierung im Moment \ des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \ konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \ nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \ das zurück.", "inputSchema": { "type": "object", "properties": { "dir": { "type": "string", "description": "Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \ künftige Archivierungen gemerkt.", }, "folder": { "type": "string", "description": "Optionaler Unterordner im Archiv-Home, relativ (z. B. \ `konzepte/panel`). Wird angelegt.", }, "description": { "type": "string", "description": "Einzeiler zum Inhalt fürs Frontmatter.", }, "tags": { "type": "array", "items": { "type": "string" }, "description": "Schlagwörter fürs Frontmatter (kurze Slugs).", } }, }, }, { "name": "show_archive", "description": "Zeigt die Archiv-Übersicht des Projekts als Wiki-Seite im Panel: \ Dokumente nach Ordnern gruppiert, mit Beschreibungen und \ klickbaren Schlagwort-Links. Mit `tag` stattdessen die Seite eines \ Schlagworts. Nutzen, wenn der Nutzer das Archiv sehen will \ („zeig das Archiv“).", "inputSchema": { "type": "object", "properties": { "tag": { "type": "string", "description": "Optional: Seite dieses Schlagworts statt der Übersicht.", } }, }, }, { "name": "search_archive", "description": "Volltext-Suche über das Panel-Archiv des Projekts (FTS5-Syntax: \ Wörter, \"Phrasen\", Präfix*). Die Treffer erscheinen als Kacheln \ im Panel; das Tool liefert sie zusätzlich mit Pfad und Snippet \ zurück. Nutzen, wenn der Nutzer im Archiv suchen will („such im \ Archiv nach …“).", "inputSchema": { "type": "object", "properties": { "query": { "type": "string", "description": "Suchanfrage (FTS5-Syntax).", }, "tag": { "type": "string", "description": "Optional: auf ein Schlagwort einengen.", } }, }, }, ], })), "tools/call" => Some(call_tool(req)), "ping" => Some(json!({})), _ => None, } } fn call_tool(req: &Value) -> Value { match req["params"]["name"].as_str().unwrap_or("") { "write_panel" => call_write(req), "write_commands" => call_write_commands(req), "show_commands" => call_show_commands(), "archive_panel" => call_archive(req), "search_archive" => call_search(req), "show_archive" => call_show_archive(req), other => err(format!("Unbekanntes Tool: {other}")), } } /// Erfolgs-Antwort eines Tool-Aufrufs (ein Text-Content). fn ok(text: String) -> Value { json!({ "content": [{ "type": "text", "text": text }] }) } /// Fehler-Antwort eines Tool-Aufrufs. fn err(text: String) -> Value { json!({ "content": [{ "type": "text", "text": text }], "isError": true }) } /// Pfad aus der PTY-Umgebung; fehlt die Variable, läuft das Terminal /// außerhalb von ai-control. fn env_path(var: &str) -> Result { std::env::var(var) .map_err(|_| err(format!("{var} nicht gesetzt (Terminal außerhalb ai-control)."))) } /// Schreibt Text in die Panel-Datei aus AI_CONTROL_PANEL. fn write_panel_text(text: &str) -> Result<(), Value> { let path = env_path("AI_CONTROL_PANEL")?; std::fs::write(&path, text).map_err(|e| err(format!("Panel-Datei nicht schreibbar: {e}"))) } fn call_write(req: &Value) -> Value { let args = &req["params"]["arguments"]; // `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne // dass das Modell den Inhalt Token für Token als `text` generieren muss. let (text, ok_msg) = match args["path"].as_str() { Some(src) => match std::fs::read_to_string(src) { Ok(content) => (content, "Datei ins Panel geladen."), Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")), }, None => ( args["text"].as_str().unwrap_or("").to_string(), "Entwurf ins Panel geschrieben.", ), }; match write_panel_text(&text) { Ok(()) => ok(ok_msg.into()), Err(e) => e, } } /// Merkt, ob dieser Server-Prozess (= eine claude-Session) schon einen /// Session-Marker in die History geschrieben hat. static SESSION_MARKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); fn call_write_commands(req: &Value) -> Value { let commands = req["params"]["arguments"]["commands"].clone(); let count = commands.as_array().map(Vec::len).unwrap_or(0); let path = match env_path("AI_CONTROL_COMMANDS") { Ok(path) => path, Err(e) => return e, }; let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); // Ein Record pro Aufruf; der erste Aufruf der Session bekommt einen // Marker-Record davor (Session-Trenner in der Kachel-Ansicht). let mut out = String::new(); if !SESSION_MARKED.swap(true, std::sync::atomic::Ordering::SeqCst) { out.push_str(&json!({ "ts": ts, "session": true }).to_string()); out.push('\n'); } out.push_str(&json!({ "ts": ts, "commands": commands }).to_string()); out.push('\n'); let res = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .and_then(|mut f| std::io::Write::write_all(&mut f, out.as_bytes())); match res { Ok(()) => ok(format!("{count} Befehl(e) als Kacheln im Panel abgelegt.")), Err(e) => err(format!("Command-History nicht schreibbar: {e}")), } } /// Öffnet die Kachel-Ansicht, ohne der History etwas hinzuzufügen: mtime der /// History-Datei anfassen genügt — der Watcher im Terminal-Prozess meldet sie /// als `commands-update`, das Panel schaltet auf „Befehle“ um. fn call_show_commands() -> Value { let path = match env_path("AI_CONTROL_COMMANDS") { Ok(path) => path, Err(e) => return e, }; let res = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .and_then(|f| f.set_modified(std::time::SystemTime::now())); match res { Ok(()) => ok("Befehls-History im Panel geöffnet.".into()), Err(e) => err(format!("Command-History nicht erreichbar: {e}")), } } fn call_archive(req: &Value) -> Value { let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); let args = &req["params"]["arguments"]; let dir = args["dir"].as_str(); let meta = crate::domain::archive::ArchiveMeta { folder: args["folder"].as_str().map(str::to_string), description: args["description"].as_str().map(str::to_string), tags: args["tags"] .as_array() .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) .unwrap_or_default(), }; match crate::domain::archive::archive_panel_content(&project, dir, &meta) { Ok(path) => ok(format!("Archiviert: {}", path.display())), Err(e) => err(format!( "Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen." )), } } /// Archiv-Übersicht bzw. Schlagwort-Seite generieren und in den Wiki-Puffer /// schreiben — der Watcher zieht sie als Wiki-Ansicht ins Panel. fn call_show_archive(req: &Value) -> Value { let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); let home = match crate::domain::archive::require_archive_home(&project) { Ok(home) => home, Err(e) => return err(e), }; let tag = req["params"]["arguments"]["tag"].as_str(); let page = match crate::domain::archive_index::archive_page(&home, tag) { Ok(page) => page, Err(e) => return err(e), }; let path = match env_path("AI_CONTROL_WIKI") { Ok(path) => path, Err(e) => return e, }; let json = match serde_json::to_string(&page) { Ok(json) => json, Err(e) => return err(e.to_string()), }; match std::fs::write(&path, json) { Ok(()) => ok(match tag { Some(t) => format!("Schlagwort-Seite #{t} im Panel."), None => "Archiv-Übersicht im Panel.".to_string(), }), Err(e) => err(format!("Wiki-Datei nicht schreibbar: {e}")), } } /// Volltext-Suche übers Archiv: Treffer als JSON in die Suchtreffer-Datei /// (der Watcher zieht sie als Kacheln ins Panel) und als Text zurück an claude. fn call_search(req: &Value) -> Value { let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default(); let home = match crate::domain::archive::require_archive_home(&project) { Ok(home) => home, Err(e) => return err(e), }; let args = &req["params"]["arguments"]; let query = args["query"].as_str().unwrap_or(""); let tag = args["tag"].as_str(); let hits = match crate::domain::archive_search::search(&home, query, tag, 20) { Ok(hits) => hits, Err(e) => return err(format!("Suche fehlgeschlagen: {e}")), }; let search_path = match env_path("AI_CONTROL_SEARCH") { Ok(path) => path, Err(e) => return e, }; let payload = json!({ "query": query, "tag": tag, "home": home.display().to_string(), "hits": hits, }); if let Err(e) = std::fs::write(&search_path, payload.to_string()) { return err(format!("Suchtreffer-Datei nicht schreibbar: {e}")); } let list: Vec = hits .iter() .map(|h| format!("- {} — {} ({})", h.relpath, h.title, h.snippet)) .collect(); ok(format!( "{} Treffer, als Kacheln im Panel. Archiv: {}\n{}", hits.len(), home.display(), list.join("\n") )) }