zuka
zuka/src/mcp/mod.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/mcp/mod.rs
RSmod.rs6.8 KBDownload
1// MCP — the agent-facing facade.
2//
3// JSON-RPC 2.0 over `POST /mcp`. Every tool calls the same core the REST handlers
4// call, so the two cannot drift about what a tree listing contains or who may read
5// it (SPEC §1.1). Authorization lives on `Identity`, never here.
6//
7// There is no tool that writes to a repository. An agent that wants to change code
8// runs `git`, which it already does well; what it needs help with is the forge
9// around git, and that is what these tools are.
10
11mod tools;
12
13use crate::brand;
14use crate::error::{Error, Result};
15use crate::http::response::{self, Body};
16use crate::http::{request, AppState};
17use hyper::body::Incoming;
18use hyper::{Method, Request, Response, StatusCode};
19use serde::Deserialize;
20use serde_json::{json, Value};
21use std::sync::Arc;
22
23/// Revision of the MCP spec this server implements.
24const PROTOCOL_VERSION: &str = "2025-06-18";
25
26/// JSON-RPC error codes. The first four are from the spec; the rest are ours.
27mod code {
28 pub const PARSE_ERROR: i32 = -32700;
29 pub const INVALID_REQUEST: i32 = -32600;
30 pub const METHOD_NOT_FOUND: i32 = -32601;
31 pub const INTERNAL_ERROR: i32 = -32603;
32}
33
34#[derive(Debug, Deserialize)]
35struct RpcRequest {
36 #[serde(default)]
37 jsonrpc: String,
38 /// Absent on a notification, which takes no response.
39 #[serde(default)]
40 id: Option<Value>,
41 method: String,
42 #[serde(default)]
43 params: Value,
44}
45
46pub async fn handle(
47 req: Request<Incoming>,
48 state: Arc<AppState>,
49) -> Result<(Response<Body>, Option<String>)> {
50 // The transport also defines a GET SSE channel for server-initiated messages.
51 // Nothing here pushes, so it is accepted and left open rather than advertised
52 // as unsupported, which some clients treat as a fatal handshake failure.
53 if req.method() == Method::GET {
54 return Ok((
55 response::text(StatusCode::METHOD_NOT_ALLOWED, "no server-initiated stream"),
56 None,
57 ));
58 }
59 if req.method() != Method::POST {
60 return Err(Error::MethodNotAllowed);
61 }
62
63 let identity = state.identify(req.headers(), req.method().as_str(), req.uri().path())?;
64 let account = identity.account.as_str().to_string();
65 state.check_rate(&format!("{}#{}", identity.account, identity.token_id))?;
66
67 let limit = state.config.max_body_bytes;
68 let bytes = request::read_body(req.into_body(), limit).await?;
69
70 let response: Option<Value> = match serde_json::from_slice::<RpcRequest>(&bytes) {
71 Err(e) => Some(error_response(
72 Value::Null,
73 code::PARSE_ERROR,
74 format!("could not parse request: {e}"),
75 )),
76 Ok(rpc) if rpc.jsonrpc != "2.0" => Some(error_response(
77 rpc.id.unwrap_or(Value::Null),
78 code::INVALID_REQUEST,
79 "jsonrpc must be \"2.0\"".into(),
80 )),
81 Ok(rpc) => dispatch(rpc, &state, &identity).await,
82 };
83
84 // A notification gets an accepted-and-silent 202, per JSON-RPC.
85 let http = match response {
86 None => response::text(StatusCode::ACCEPTED, ""),
87 Some(body) => response::json(StatusCode::OK, &body),
88 };
89 Ok((http, Some(account)))
90}
91
92/// Returns `None` for a notification.
93async fn dispatch(
94 rpc: RpcRequest,
95 state: &Arc<AppState>,
96 identity: &crate::account::Identity,
97) -> Option<Value> {
98 let Some(id) = rpc.id else {
99 // Notifications carry no reply. `notifications/initialized` is the one the
100 // handshake actually sends.
101 return None;
102 };
103
104 let outcome = match rpc.method.as_str() {
105 "initialize" => Ok(initialize()),
106 "ping" => Ok(json!({})),
107 "tools/list" => Ok(json!({ "tools": tools::catalogue() })),
108 "tools/call" => tools::call(rpc.params, state, identity).await,
109 other => {
110 return Some(error_response(
111 id,
112 code::METHOD_NOT_FOUND,
113 format!("unknown method {other:?}"),
114 ))
115 }
116 };
117
118 Some(match outcome {
119 Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
120 Err(e) => {
121 // A tool failure that is the caller's fault is reported as a tool result
122 // with `isError`, not as a protocol error: the model should see it and
123 // adapt, not have the call look broken.
124 if e.status() >= 500 {
125 if let Some(cause) = e.cause() {
126 eprintln!("[mcp] {} -> {cause:#}", rpc.method);
127 }
128 error_response(id, code::INTERNAL_ERROR, e.detail_for_user())
129 } else {
130 json!({
131 "jsonrpc": "2.0",
132 "id": id,
133 "result": {
134 "isError": true,
135 "content": [{ "type": "text", "text": e.detail_for_user() }],
136 }
137 })
138 }
139 }
140 })
141}
142
143fn initialize() -> Value {
144 json!({
145 "protocolVersion": PROTOCOL_VERSION,
146 "capabilities": { "tools": { "listChanged": false } },
147 "serverInfo": { "name": brand::NAME, "version": brand::VERSION },
148 "instructions": format!(
149 "{} hosts git repositories. Use these tools to create repositories, \
150 register SSH keys, inspect repository contents without cloning, and \
151 check CI results. To change code, clone the repository with git and \
152 push — there is no tool that writes to a repository, by design.",
153 brand::NAME
154 ),
155 })
156}
157
158fn error_response(id: Value, code: i32, message: String) -> Value {
159 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
160}
161
162/// Shared by tool argument parsing.
163pub(crate) fn invalid_params(message: impl Into<String>) -> Error {
164 Error::invalid("invalid-params", message)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn initialize_advertises_tools_and_identifies_the_server() {
173 let result = initialize();
174 assert_eq!(result["protocolVersion"], PROTOCOL_VERSION);
175 assert!(result["capabilities"]["tools"].is_object());
176 assert_eq!(result["serverInfo"]["name"], brand::NAME);
177 assert_eq!(result["serverInfo"]["version"], brand::VERSION);
178 }
179
180 #[test]
181 fn the_instructions_tell_an_agent_to_use_git_for_writes() {
182 let text = initialize()["instructions"].as_str().unwrap().to_string();
183 assert!(
184 text.contains("push") && text.contains("no tool that writes"),
185 "an agent must be told where writes go: {text}"
186 );
187 }
188
189 #[test]
190 fn an_error_response_is_shaped_as_json_rpc() {
191 let body = error_response(json!(1), code::METHOD_NOT_FOUND, "nope".into());
192 assert_eq!(body["jsonrpc"], "2.0");
193 assert_eq!(body["id"], 1);
194 assert_eq!(body["error"]["code"], code::METHOD_NOT_FOUND);
195 }
196}