git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit3004151df50f91b000e0166f7361af1781b18d46
parente278275215
authorLucas Galante <[email protected]>
date2026-07-16 14:05
feat: tools-only MCP server module so apps expose actions to AI agents

initialize / tools/list / tools/call / ping over Streamable HTTP
(JSON-RPC 2.0 in POST bodies), hand-rolled on a raw TcpListener with no
new dependencies. Stateless: no sessions, no SSE stream. Apps declare
McpTools and pass the engine's calloop sender plus a wrapper into their
Message type; each call executes on the app's event loop and replies
over a carried mpsc channel — the same bridge pattern as cce-designer's
HTTP automation API, which is the reference consumer.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/lib.rs |   1 +
 src/mcp.rs | 309 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 310 insertions(+)

diff --git a/src/lib.rs b/src/lib.rs
index 1e44d3a..faf9e02 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -13,6 +13,7 @@ pub mod scene;
 pub mod process;
 pub mod file_dialog;
 pub mod ipc;
+pub mod mcp;
 pub mod vk;
 
 pub mod colors {
diff --git a/src/mcp.rs b/src/mcp.rs
new file mode 100644
index 0000000..c1f5293
--- /dev/null
+++ b/src/mcp.rs
@@ -0,0 +1,309 @@
+//! Minimal MCP (Model Context Protocol) server support for cce-ui apps.
+//!
+//! Implements the tools-only subset of the spec — `initialize`, `tools/list`,
+//! `tools/call`, `ping` — over the Streamable HTTP transport (JSON-RPC 2.0 in
+//! HTTP POST bodies), so any MCP client can inspect and drive a running app,
+//! e.g. `claude mcp add --transport http <name> http://127.0.0.1:<port>/mcp`.
+//! The server is stateless: no sessions, no SSE stream, no server-initiated
+//! messages (a GET gets 405).
+//!
+//! An app declares its [`McpTool`]s and calls [`start_mcp_server`] with the
+//! engine's calloop sender plus a constructor wrapping [`McpToolCall`] into
+//! its `Application::Message`; each `tools/call` is executed on the app's
+//! event loop and answered over the carried mpsc channel — the same bridge
+//! pattern as cce-designer's HTTP automation API.
+
+use std::io::{BufRead, BufReader, Read, Write};
+use std::net::{TcpListener, TcpStream};
+use std::sync::{mpsc, Arc};
+use std::time::Duration;
+
+use serde_json::{json, Value};
+
+/// Protocol revisions this server accepts; the newest is offered when the
+/// client requests anything else. The tools-only subset is identical across
+/// all of them.
+const PROTOCOL_VERSIONS: [&str; 3] = ["2025-06-18", "2025-03-26", "2024-11-05"];
+
+/// How long a `tools/call` waits on the app's event loop before failing.
+const CALL_TIMEOUT: Duration = Duration::from_secs(30);
+
+/// A tool the app exposes over MCP.
+#[derive(Debug, Clone)]
+pub struct McpTool {
+    pub name: String,
+    pub description: String,
+    /// JSON Schema for the tool's arguments (the spec's `inputSchema`).
+    pub input_schema: Value,
+}
+
+/// A `tools/call` in flight: delivered to the app's event loop wrapped in its
+/// `Application::Message`; the handler sends the outcome back over `reply`.
+/// An `Ok` value becomes the result's text content (strings verbatim, other
+/// JSON pretty-printed); an `Err` becomes an `isError` tool result.
+#[derive(Debug, Clone)]
+pub struct McpToolCall {
+    pub name: String,
+    pub arguments: Value,
+    pub reply: mpsc::Sender<Result<Value, String>>,
+}
+
+/// Spawn the MCP server on `127.0.0.1:port` (one thread per connection,
+/// mirroring the raw-HTTP style of cce-designer's api.rs). `wrap` lifts a
+/// tool call into the app's message type for delivery over `sender`.
+pub fn start_mcp_server<M, F>(
+    server_name: &str,
+    port: u16,
+    tools: Vec<McpTool>,
+    sender: calloop::channel::Sender<M>,
+    wrap: F,
+) where
+    M: Send + 'static,
+    F: Fn(McpToolCall) -> M + Send + Sync + 'static,
+{
+    let server_name = server_name.to_string();
+    std::thread::spawn(move || {
+        let listener = match TcpListener::bind(("127.0.0.1", port)) {
+            Ok(l) => l,
+            Err(e) => {
+                eprintln!("Failed to bind MCP server to port {port}: {e:?}");
+                return;
+            }
+        };
+        println!("MCP server '{server_name}' listening on http://127.0.0.1:{port}");
+
+        let shared = Arc::new((server_name, tools, sender, wrap));
+        for stream in listener.incoming() {
+            let stream = match stream {
+                Ok(s) => s,
+                Err(_) => continue,
+            };
+            let shared = Arc::clone(&shared);
+            std::thread::spawn(move || {
+                let (server_name, tools, sender, wrap) = &*shared;
+                handle_connection(stream, server_name, tools, &|name, arguments| {
+                    let (tx, rx) = mpsc::channel();
+                    let call = McpToolCall { name: name.to_string(), arguments, reply: tx };
+                    sender
+                        .send(wrap(call))
+                        .map_err(|_| "app event loop is gone".to_string())?;
+                    rx.recv_timeout(CALL_TIMEOUT)
+                        .map_err(|_| "timed out waiting for the app".to_string())?
+                });
+            });
+        }
+    });
+}
+
+fn handle_connection<F>(stream: TcpStream, server_name: &str, tools: &[McpTool], call_tool: &F)
+where
+    F: Fn(&str, Value) -> Result<Value, String>,
+{
+    let mut write_stream = match stream.try_clone() {
+        Ok(s) => s,
+        Err(_) => return,
+    };
+    let mut reader = BufReader::new(stream);
+    let mut request_line = String::new();
+    if reader.read_line(&mut request_line).is_err() {
+        return;
+    }
+
+    let mut content_length = 0usize;
+    loop {
+        let mut line = String::new();
+        if reader.read_line(&mut line).is_err() || line == "\r\n" || line == "\n" || line.is_empty()
+        {
+            break;
+        }
+        let lower = line.to_lowercase();
+        if let Some(rest) = lower.strip_prefix("content-length:") {
+            if let Ok(len) = rest.trim().parse::<usize>() {
+                content_length = len;
+            }
+        }
+    }
+
+    if !request_line.starts_with("POST ") {
+        // Stateless server: no SSE stream (GET) or session teardown (DELETE).
+        let _ = write_stream.write_all(
+            b"HTTP/1.1 405 Method Not Allowed\r\nAllow: POST\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
+        );
+        return;
+    }
+
+    let mut body = vec![0; content_length];
+    if reader.read_exact(&mut body).is_err() {
+        return;
+    }
+
+    let response = match serde_json::from_slice::<Value>(&body) {
+        Ok(req) => handle_jsonrpc(&req, server_name, tools, call_tool),
+        Err(_) => Some(error_response(Value::Null, -32700, "parse error")),
+    };
+    let http = match response {
+        Some(resp) => {
+            let body = resp.to_string();
+            format!(
+                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
+                body.len(),
+                body
+            )
+        }
+        // Notifications get no JSON-RPC response, just an HTTP ack.
+        None => "HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(),
+    };
+    let _ = write_stream.write_all(http.as_bytes());
+    let _ = write_stream.flush();
+}
+
+/// Dispatch one JSON-RPC message. Returns `None` for notifications (no id).
+fn handle_jsonrpc<F>(req: &Value, server_name: &str, tools: &[McpTool], call_tool: &F) -> Option<Value>
+where
+    F: Fn(&str, Value) -> Result<Value, String>,
+{
+    let method = req.get("method").and_then(Value::as_str).unwrap_or("");
+    let id = match req.get("id") {
+        Some(id) if !id.is_null() => id.clone(),
+        _ => return None,
+    };
+
+    let result = match method {
+        "initialize" => {
+            let requested = req
+                .pointer("/params/protocolVersion")
+                .and_then(Value::as_str)
+                .unwrap_or("");
+            let version = if PROTOCOL_VERSIONS.contains(&requested) {
+                requested
+            } else {
+                PROTOCOL_VERSIONS[0]
+            };
+            json!({
+                "protocolVersion": version,
+                "capabilities": { "tools": {} },
+                "serverInfo": { "name": server_name, "version": env!("CARGO_PKG_VERSION") },
+            })
+        }
+        "ping" => json!({}),
+        "tools/list" => json!({
+            "tools": tools.iter().map(|t| json!({
+                "name": t.name,
+                "description": t.description,
+                "inputSchema": t.input_schema,
+            })).collect::<Vec<_>>(),
+        }),
+        "tools/call" => {
+            let name = req
+                .pointer("/params/name")
+                .and_then(Value::as_str)
+                .unwrap_or("");
+            if !tools.iter().any(|t| t.name == name) {
+                return Some(error_response(id, -32602, &format!("unknown tool: {name}")));
+            }
+            let arguments = req
+                .pointer("/params/arguments")
+                .cloned()
+                .unwrap_or_else(|| json!({}));
+            match call_tool(name, arguments) {
+                Ok(value) => {
+                    let text = match value {
+                        Value::String(s) => s,
+                        other => serde_json::to_string_pretty(&other).unwrap_or_default(),
+                    };
+                    json!({ "content": [{ "type": "text", "text": text }], "isError": false })
+                }
+                Err(e) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
+            }
+        }
+        _ => return Some(error_response(id, -32601, &format!("method not found: {method}"))),
+    };
+    Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
+}
+
+fn error_response(id: Value, code: i64, message: &str) -> Value {
+    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn tools() -> Vec<McpTool> {
+        vec![McpTool {
+            name: "echo".to_string(),
+            description: "Echo the arguments back".to_string(),
+            input_schema: json!({ "type": "object" }),
+        }]
+    }
+
+    fn no_calls(_: &str, _: Value) -> Result<Value, String> {
+        panic!("no tool call expected");
+    }
+
+    #[test]
+    fn initialize_negotiates_protocol_version() {
+        let req = json!({
+            "jsonrpc": "2.0", "id": 1, "method": "initialize",
+            "params": { "protocolVersion": "2025-03-26" }
+        });
+        let resp = handle_jsonrpc(&req, "test", &tools(), &no_calls).unwrap();
+        assert_eq!(resp["result"]["protocolVersion"], "2025-03-26");
+        assert!(resp["result"]["capabilities"]["tools"].is_object());
+
+        // Unknown requested version falls back to our newest.
+        let req = json!({
+            "jsonrpc": "2.0", "id": 2, "method": "initialize",
+            "params": { "protocolVersion": "2099-01-01" }
+        });
+        let resp = handle_jsonrpc(&req, "test", &tools(), &no_calls).unwrap();
+        assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSIONS[0]);
+    }
+
+    #[test]
+    fn notifications_get_no_response() {
+        let req = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
+        assert!(handle_jsonrpc(&req, "test", &tools(), &no_calls).is_none());
+    }
+
+    #[test]
+    fn tools_list_reports_declared_tools() {
+        let req = json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/list" });
+        let resp = handle_jsonrpc(&req, "test", &tools(), &no_calls).unwrap();
+        assert_eq!(resp["result"]["tools"][0]["name"], "echo");
+        assert!(resp["result"]["tools"][0]["inputSchema"].is_object());
+    }
+
+    #[test]
+    fn tools_call_wraps_ok_and_err_results() {
+        let req = json!({
+            "jsonrpc": "2.0", "id": 4, "method": "tools/call",
+            "params": { "name": "echo", "arguments": { "x": 1 } }
+        });
+        let resp = handle_jsonrpc(&req, "test", &tools(), &|name, args| {
+            assert_eq!(name, "echo");
+            Ok(args)
+        })
+        .unwrap();
+        assert_eq!(resp["result"]["isError"], false);
+        assert!(resp["result"]["content"][0]["text"].as_str().unwrap().contains("\"x\": 1"));
+
+        let resp = handle_jsonrpc(&req, "test", &tools(), &|_, _| Err("boom".to_string())).unwrap();
+        assert_eq!(resp["result"]["isError"], true);
+        assert_eq!(resp["result"]["content"][0]["text"], "boom");
+    }
+
+    #[test]
+    fn unknown_tool_and_method_are_protocol_errors() {
+        let req = json!({
+            "jsonrpc": "2.0", "id": 5, "method": "tools/call",
+            "params": { "name": "nope" }
+        });
+        let resp = handle_jsonrpc(&req, "test", &tools(), &no_calls).unwrap();
+        assert_eq!(resp["error"]["code"], -32602);
+
+        let req = json!({ "jsonrpc": "2.0", "id": 6, "method": "resources/list" });
+        let resp = handle_jsonrpc(&req, "test", &tools(), &no_calls).unwrap();
+        assert_eq!(resp["error"]["code"], -32601);
+    }
+}