Skip to main content

logicaffeine_cli/mcp/
mod.rs

1//! `largo mcp` — the LOGOS toolchain served over the Model Context Protocol.
2//!
3//! MCP is JSON-RPC 2.0 over stdio, one newline-delimited JSON object per
4//! line. The entire protocol lives in the pure [`McpServer::handle_line`]
5//! transition — one input line in, zero or one output line back — so every
6//! conformance test drives it in-process; [`cmd_mcp`] is only the stdio
7//! shell around it. Stdout carries nothing but JSON-RPC; logs go to stderr.
8//!
9//! Register with an agent client as:
10//!
11//! ```text
12//! claude mcp add logos -- largo mcp
13//! ```
14
15mod protocol;
16mod resources;
17mod sandbox;
18mod tools;
19
20use serde_json::{json, Value};
21
22use protocol::{Incoming, INVALID_PARAMS, METHOD_NOT_FOUND, NOT_INITIALIZED};
23
24/// The MCP server state machine.
25pub struct McpServer {
26    initialized: bool,
27}
28
29impl McpServer {
30    /// A fresh, un-initialized server.
31    pub fn new() -> Self {
32        McpServer { initialized: false }
33    }
34
35    /// Handle one JSON-RPC line. Returns the response line, or `None` for
36    /// notifications (which never get responses).
37    pub fn handle_line(&mut self, line: &str) -> Option<String> {
38        let incoming = match protocol::parse_line(line) {
39            Ok(incoming) => incoming,
40            Err(response) => return Some(response),
41        };
42        let Incoming { id, method, params } = incoming;
43
44        // Notifications are consumed silently — including
45        // `notifications/initialized`, which carries no state we don't
46        // already have (the `initialize` request itself opens the session).
47        let id = id?;
48
49        let response = match method.as_str() {
50            "initialize" => {
51                self.initialized = true;
52                let requested = params.get("protocolVersion").and_then(Value::as_str);
53                protocol::ok(
54                    &id,
55                    json!({
56                        "protocolVersion": protocol::negotiate_version(requested),
57                        "capabilities": {
58                            "tools": {},
59                            "resources": {},
60                            "prompts": {},
61                        },
62                        "serverInfo": {
63                            "name": "logos",
64                            "version": env!("CARGO_PKG_VERSION"),
65                        },
66                    }),
67                )
68            }
69            "ping" => protocol::ok(&id, json!({})),
70            _ if !self.initialized => protocol::error(
71                &id,
72                NOT_INITIALIZED,
73                "server is not initialized: send initialize first",
74            ),
75            "tools/list" => protocol::ok(&id, tools::list()),
76            "tools/call" => tools::call(&id, &params),
77            "resources/list" => protocol::ok(&id, resources::list()),
78            "resources/read" => resources::read(&id, &params),
79            "prompts/list" => protocol::ok(&id, prompts_list()),
80            "prompts/get" => prompts_get(&id, &params),
81            other => protocol::error(&id, METHOD_NOT_FOUND, &format!("unknown method '{other}'")),
82        };
83        Some(response)
84    }
85}
86
87impl Default for McpServer {
88    fn default() -> Self {
89        Self::new()
90    }
91}
92
93/// The one prompt the server ships: how to write LOGOS.
94const WRITE_LOGOS_PROMPT: &str = "\
95You are writing LOGOS, the English programming language. Before writing any \
96code, read the resource logos://quickguide — it teaches the whole language \
97on one page. The essentials: programs live in `## Main` blocks; every \
98statement is an English sentence ending with a period; `Let x be 5.` \
99declares, `Set x to 6.` mutates (requires `Let mutable`), `Show x.` prints; \
100indices are 1-based; value comparison is `is equal to` / `equals`, never \
101bare `is`. Check your work with the logos_check tool (structured \
102diagnostics with teaching explanations), then execute with logos_run.";
103
104/// The `prompts/list` result.
105fn prompts_list() -> Value {
106    json!({"prompts": [{
107        "name": "write-logos",
108        "description": "Orient an agent that is about to write LOGOS code",
109        "arguments": [],
110    }]})
111}
112
113/// The `prompts/get` response line.
114fn prompts_get(id: &Value, params: &Value) -> String {
115    match params.get("name").and_then(Value::as_str) {
116        Some("write-logos") => protocol::ok(
117            id,
118            json!({
119                "description": "Orient an agent that is about to write LOGOS code",
120                "messages": [{
121                    "role": "user",
122                    "content": {"type": "text", "text": WRITE_LOGOS_PROMPT},
123                }],
124            }),
125        ),
126        Some(other) => protocol::error(id, INVALID_PARAMS, &format!("unknown prompt '{other}'")),
127        None => protocol::error(id, INVALID_PARAMS, "prompts/get needs a 'name'"),
128    }
129}
130
131/// Handle `largo mcp`: serve MCP over stdio until EOF.
132///
133/// With `--worker <engine>` (hidden), run the sandbox worker instead — the
134/// self-invocation target of the `logos_run` tool, never called by users.
135pub fn cmd_mcp(
136    worker: Option<String>,
137    worker_args: Vec<String>,
138) -> Result<(), Box<dyn std::error::Error>> {
139    if let Some(engine) = worker {
140        return sandbox::worker(&engine, &worker_args);
141    }
142
143    use std::io::{BufRead, Write};
144
145    let stdin = std::io::stdin();
146    let stdout = std::io::stdout();
147    let mut server = McpServer::new();
148
149    for line in stdin.lock().lines() {
150        let line = line?;
151        if line.trim().is_empty() {
152            continue;
153        }
154        if let Some(response) = server.handle_line(&line) {
155            let mut out = stdout.lock();
156            out.write_all(response.as_bytes())?;
157            out.write_all(b"\n")?;
158            out.flush()?;
159        }
160    }
161    Ok(())
162}