Skip to main content

majordomus_cli/mcp/
protocol.rs

1//! JSON-RPC 2.0 and the MCP methods this server answers, over a [`Surface`]. Transport
2//! agnostic: a message in, zero or one message out. The subset spoken is the server side
3//! of the protocol: `initialize`, `ping`, `resources/list`, `resources/read`,
4//! `resources/templates/list`, `tools/list`, `tools/call`. Prompts are not advertised.
5//! Nothing here decides what a tool may change: a tool is a capability, its effect is the
6//! capability's, and its annotations are the capability's classified hints.
7
8use std::sync::Arc;
9
10use serde::ser::SerializeMap;
11use serde::Serialize;
12use serde_json::value::RawValue;
13use serde_json::{json, Value};
14
15use crate::peers::ClientInfo;
16
17/// One outgoing message. Most are built values; the two listings are prepared once and
18/// carried as raw JSON, so answering `tools/list` or `resources/list` copies bytes and
19/// builds nothing. A transport serialises a reply with `serde_json::to_string`; a caller
20/// that needs the data as a value takes [`Reply::into_value`].
21#[derive(Debug)]
22pub enum Reply {
23    /// A built message.
24    Value(Value),
25    /// A response whose result is prepared JSON.
26    Prepared {
27        /// The request id.
28        id: Value,
29        /// The serialised result object.
30        result: Arc<Box<RawValue>>,
31    },
32    /// The responses of a batch.
33    Batch(Vec<Reply>),
34}
35
36impl Reply {
37    /// The reply as a value (a prepared result is parsed).
38    pub fn into_value(self) -> Value {
39        match self {
40            Reply::Value(v) => v,
41            Reply::Prepared { id, result } => json!({
42                "jsonrpc": "2.0",
43                "id": id,
44                "result": serde_json::from_str::<Value>(result.get()).unwrap_or(Value::Null),
45            }),
46            Reply::Batch(items) => Value::Array(items.into_iter().map(Reply::into_value).collect()),
47        }
48    }
49}
50
51impl Serialize for Reply {
52    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
53        match self {
54            Reply::Value(v) => v.serialize(serializer),
55            Reply::Prepared { id, result } => {
56                let mut m = serializer.serialize_map(Some(3))?;
57                m.serialize_entry("jsonrpc", "2.0")?;
58                m.serialize_entry("id", id)?;
59                m.serialize_entry("result", &**result)?;
60                m.end()
61            }
62            Reply::Batch(items) => items.serialize(serializer),
63        }
64    }
65}
66
67use super::surface::{Surface, SurfaceError, ToolOutcome};
68
69/// Protocol versions this server accepts from a client, newest first. A client asking for
70/// another gets the first one.
71pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
72
73/// The `serverInfo.name` this server announces.
74pub const SERVER_NAME: &str = "majordomus";
75
76/// The `serverInfo.title` this server announces.
77pub const SERVER_TITLE: &str = "Majordomus";
78
79/// Every method a request may name. A request naming any other is answered
80/// `method not found` before it is looked at, so this list is what the server serves and
81/// not a description of it; `mcp.projection` renders it.
82pub const METHODS: &[&str] = &[
83    "initialize",
84    "ping",
85    "resources/list",
86    "resources/read",
87    "resources/templates/list",
88    "tools/call",
89    "tools/list",
90];
91
92const PARSE_ERROR: i64 = -32700;
93const INVALID_REQUEST: i64 = -32600;
94const METHOD_NOT_FOUND: i64 = -32601;
95const INVALID_PARAMS: i64 = -32602;
96const INTERNAL_ERROR: i64 = -32603;
97/// MCP's code for a resource that does not exist.
98const RESOURCE_NOT_FOUND: i64 = -32002;
99
100#[derive(Debug)]
101/// The protocol state over a surface: whether the client has initialised, who it said it
102/// was, the version to announce, and the HTTP endpoint of the shared server this session
103/// belongs to, when there is one.
104pub struct Server {
105    surface: Surface,
106    version: &'static str,
107    initialized: bool,
108    client: Option<ClientInfo>,
109    endpoint: Option<String>,
110    remote: bool,
111}
112
113impl Server {
114    /// A server over a surface, announcing `version` as its own.
115    pub fn new(surface: Surface, version: &'static str) -> Self {
116        Server {
117            surface,
118            version,
119            initialized: false,
120            client: None,
121            endpoint: None,
122            remote: false,
123        }
124    }
125
126    /// Whether the message about to be handled came from another host. A transport that
127    /// knows the caller's address says so before every message, because the next message
128    /// of one session may come from somewhere else: a session id is a name, not a proof.
129    /// A caller beyond loopback reads; a tool that changes something is refused to it
130    /// unless its input authenticates it (ADR 0126).
131    pub(crate) fn set_remote(&mut self, remote: bool) {
132        self.remote = remote;
133    }
134
135    /// The base URL of the shared server (`http://127.0.0.1:8741`) the `initialize`
136    /// instructions name, so that a client learns where Swagger UI and the peers are.
137    pub fn with_endpoint(mut self, url: Option<String>) -> Self {
138        self.endpoint = url;
139        self
140    }
141
142    /// The surface being served.
143    pub fn surface(&self) -> &Surface {
144        &self.surface
145    }
146
147    /// Has the client completed `initialize`?
148    pub fn is_initialized(&self) -> bool {
149        self.initialized
150    }
151
152    /// What the client said in `initialize`, when it has.
153    pub fn client(&self) -> Option<&ClientInfo> {
154        self.client.as_ref()
155    }
156
157    /// Resume a session another process was serving: the client already initialised
158    /// there and will not do so again. The peer is identified on the board as it was.
159    pub fn resume(&mut self, client: ClientInfo) {
160        if let Some(peer) = self.surface.peer() {
161            self.surface.context().peers.identify(peer, client.clone());
162        }
163        self.client = Some(client);
164        self.initialized = true;
165    }
166
167    /// The response to a parse failure of an incoming line.
168    pub fn parse_error(reason: &str) -> Value {
169        error(Value::Null, PARSE_ERROR, &format!("parse error: {reason}"))
170    }
171
172    /// Handle one decoded message. A notification yields `None`; a batch yields a batch of
173    /// the responses its requests produced, or `None` when it held only notifications.
174    pub fn handle(&mut self, message: Value) -> Option<Reply> {
175        if let Some(peer) = self.surface.peer() {
176            self.surface.context().peers.touch(peer);
177            // ... and the same message is the heartbeat of whatever episode this connection
178            // holds. A client that is working never has to send anything it would not
179            // otherwise send in order to keep its episode alive, which is the difference
180            // between a lifecycle and a thing clients have to remember to do (ADR 0103).
181            self.surface.context().episodes.touch(peer);
182        }
183        match message {
184            Value::Array(batch) => {
185                if batch.is_empty() {
186                    return Some(Reply::Value(error(
187                        Value::Null,
188                        INVALID_REQUEST,
189                        "empty batch",
190                    )));
191                }
192                let responses: Vec<Reply> = batch
193                    .into_iter()
194                    .filter_map(|m| self.handle_one(m))
195                    .collect();
196                (!responses.is_empty()).then_some(Reply::Batch(responses))
197            }
198            other => self.handle_one(other),
199        }
200    }
201
202    fn handle_one(&mut self, message: Value) -> Option<Reply> {
203        let Value::Object(msg) = message else {
204            return Some(Reply::Value(error(
205                Value::Null,
206                INVALID_REQUEST,
207                "a message is a JSON object",
208            )));
209        };
210        let id = msg.get("id").cloned();
211        let Some(method) = msg.get("method").and_then(Value::as_str) else {
212            // A response or a malformed message; a server never answers a response.
213            return id
214                .filter(|i| !i.is_null())
215                .map(|i| Reply::Value(error(i, INVALID_REQUEST, "missing method")));
216        };
217        let params = msg.get("params").cloned().unwrap_or(Value::Null);
218        let Some(id) = id.filter(|i| !i.is_null()) else {
219            self.notification(method, &params);
220            return None;
221        };
222        tracing::debug!(operation = method, "request");
223        // the two listings are prepared once and copied, never rebuilt
224        match method {
225            "tools/list" => {
226                return Some(Reply::Prepared {
227                    id,
228                    result: self.surface.tools_result(),
229                })
230            }
231            "resources/list" => {
232                return Some(Reply::Prepared {
233                    id,
234                    result: self.surface.resources_result(),
235                })
236            }
237            _ => {}
238        }
239        Some(Reply::Value(match self.request(method, &params) {
240            Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
241            Err((code, message)) => error(id, code, &message),
242        }))
243    }
244
245    fn notification(&mut self, method: &str, _params: &Value) {
246        match method {
247            "notifications/initialized" => self.initialized = true,
248            other => tracing::debug!(operation = other, "notification ignored"),
249        }
250    }
251
252    fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
253        if !METHODS.contains(&method) {
254            return Err((METHOD_NOT_FOUND, format!("method not found: {method}")));
255        }
256        match method {
257            "initialize" => Ok(self.initialize(params)),
258            "ping" => Ok(json!({})),
259            "resources/list" => Ok(
260                json!({ "resources": Value::Array(self.surface.resources_json().as_ref().clone()) }),
261            ),
262            "resources/templates/list" => Ok(json!({ "resourceTemplates": [] })),
263            "resources/read" => {
264                let uri = params
265                    .get("uri")
266                    .and_then(Value::as_str)
267                    .ok_or_else(|| (INVALID_PARAMS, "params.uri is required".to_string()))?;
268                match self.surface.read(uri) {
269                    Ok(c) => Ok(
270                        json!({ "contents": [{ "uri": c.uri, "mimeType": c.media_type, "text": c.text }] }),
271                    ),
272                    Err(SurfaceError::UnknownResource(u)) => {
273                        Err((RESOURCE_NOT_FOUND, format!("resource not found: {u}")))
274                    }
275                    Err(SurfaceError::Internal(e)) => Err((INTERNAL_ERROR, e)),
276                    Err(e) => Err((INVALID_PARAMS, e.to_string())),
277                }
278            }
279            "tools/list" => {
280                Ok(json!({ "tools": Value::Array(self.surface.tools_json().as_ref().clone()) }))
281            }
282            "tools/call" => {
283                let name = params
284                    .get("name")
285                    .and_then(Value::as_str)
286                    .ok_or_else(|| (INVALID_PARAMS, "params.name is required".to_string()))?;
287                let args = params
288                    .get("arguments")
289                    .cloned()
290                    .unwrap_or_else(|| json!({}));
291                if !args.is_object() {
292                    return Err((
293                        INVALID_PARAMS,
294                        "params.arguments must be an object".to_string(),
295                    ));
296                }
297                match self.surface.call_from(name, &args, self.remote) {
298                    Ok(ToolOutcome::Ok(value)) => {
299                        // compact: a client that wants the data reads structuredContent
300                        let text = value.to_string();
301                        Ok(
302                            json!({ "content": [{ "type": "text", "text": text }], "structuredContent": value, "isError": false }),
303                        )
304                    }
305                    // the category travels beside the text, so a client branches on a word
306                    // and never parses a sentence; no structuredContent, because the
307                    // tool's output schema describes a success
308                    Ok(ToolOutcome::Refused { code, reason }) => Ok(json!({
309                        "content": [{ "type": "text", "text": reason }],
310                        "isError": true,
311                        "_meta": { "majordomus": { "error": { "code": code } } }
312                    })),
313                    Err(SurfaceError::Internal(e)) => Err((INTERNAL_ERROR, e)),
314                    Err(e) => Err((INVALID_PARAMS, e.to_string())),
315                }
316            }
317            other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
318        }
319    }
320
321    fn initialize(&mut self, params: &Value) -> Value {
322        let asked = params
323            .get("protocolVersion")
324            .and_then(Value::as_str)
325            .unwrap_or_default();
326        let version = PROTOCOL_VERSIONS
327            .iter()
328            .find(|v| **v == asked)
329            .copied()
330            .unwrap_or(PROTOCOL_VERSIONS[0]);
331        let client = ClientInfo::from_initialize(params);
332        if let Some(peer) = self.surface.peer() {
333            self.surface.context().peers.identify(peer, client.clone());
334            tracing::info!(peer = %peer, client = %client.name, version = %client.version, "peer initialized");
335        }
336        self.client = Some(client);
337        json!({
338            "protocolVersion": version,
339            "capabilities": {
340                "resources": { "subscribe": false, "listChanged": false },
341                "tools": { "listChanged": false }
342            },
343            "serverInfo": { "name": SERVER_NAME, "title": SERVER_TITLE, "version": self.version },
344            "instructions": self.instructions(),
345        })
346    }
347
348    /// The text a client reads after `initialize`: what is served, what of it writes, where
349    /// the shared server is, and who else is attached.
350    pub fn instructions(&self) -> String {
351        let index = self.surface.index();
352        let registry = self.surface.registry();
353        let summary = registry.summary();
354        let mut text = format!(
355            "{} This repository: {} object(s), {} capabilities ({} tools, {} resources), index state {}. Resources are majordomus://<kind>/<identity>; majordomus://repository carries the diagnostics; majordomus_capabilities lists every capability with its projections. {}",
356            crate::about::SUMMARY,
357            index.objects.len(),
358            summary.total,
359            summary.mcp_tools,
360            summary.mcp_resources,
361            match index.state { crate::index::State::Ok => "ok", crate::index::State::Degraded => "degraded" },
362            effects_sentence(&registry),
363        );
364        if let Some(url) = &self.endpoint {
365            text.push_str(&format!(
366                // "of this checkout", not "for this repository": a linked worktree is a
367                // checkout with a server of its own, and on 2026-09-11 this repository had
368                // seven of them. The sentence a client reads on every initialize was the
369                // one that told nine agents they had seen each other (ADR 0044).
370                " This session belongs to the shared server of this checkout at {url}: its home page at {url}/ lists every surface, Swagger UI is {url}/swagger, OpenAPI {url}/openapi.json, MCP over HTTP {url}/mcp."
371            ));
372        }
373        if let Some(peer) = self.surface.peer() {
374            let board = &self.surface.context().peers;
375            text.push_str(&format!(
376                // Only this checkout's board is named here: the gathered one costs a lease
377                // read and a probe per registered checkout, and `initialize` is on the path
378                // of every client's first millisecond. The words point at the capability
379                // that pays for the wide answer rather than paying for it here.
380                " You are peer {peer} on this checkout's board; attached here: {}. majordomus_peers lists every worker of this repository — this board and the board of every other checkout of it, each peer stamped with the worktree it is working in; call majordomus_announce with your intent and the paths you expect to touch so that the other clients (Claude, Codex, Gemini, ...) can avoid colliding with you.",
381                board.summary()
382            ));
383            // The nudge that matters is delivered here, on every initialize, which is also
384            // every reconnect. A session whose transport was re-established keeps its work
385            // and loses its place on the board, and nothing else in the protocol will ever
386            // mention it again: this repository spent three hours with a session invisible
387            // to eight others for exactly that reason. So the instructions say what is
388            // true of THIS caller rather than what is true in general.
389            let peers = board.list();
390            if !peers
391                .iter()
392                .any(|p| Some(&p.id) == self.surface.peer() && p.announcement.is_some())
393            {
394                text.push_str(" You have not announced anything. If you have worked in this repository before in another session, the board does not know it: announce now, before you start, and again if this connection is ever re-established — your peer id is a position on this board, handed out again on a reconnect, and never your identity.");
395            }
396            let silent = peers
397                .iter()
398                .filter(|p| {
399                    p.attached && p.announcement.is_none() && Some(&p.id) != self.surface.peer()
400                })
401                .count();
402            if silent > 0 {
403                text.push_str(&format!(
404                    " {silent} attached peer(s) have announced nothing, so the board understates who is here."
405                ));
406            }
407            let departed = peers.iter().filter(|p| !p.attached).count();
408            if departed > 0 {
409                text.push_str(&format!(
410                    " {departed} peer(s) on the board have gone but their announcements stand: what they claimed is still claimed until somebody says otherwise."
411                ));
412            }
413        }
414        text
415    }
416}
417
418/// What the `initialize` instructions say about writing, read off the registry instead of
419/// stated by hand.
420///
421/// The sentence used to be the constant "Nothing here writes to the repository." It was true
422/// when it was written and false from [ADR 0040] onwards: `plan.transition` stamps a field
423/// into a tracked issue record and appends a ledger event, and it is exposed as an MCP tool.
424/// An agent that reads its instructions and believes them would then call a writer thinking
425/// it was reading — the one thing the instructions exist to prevent. So the sentence is
426/// derived: every capability whose MCP exposure is a tool and whose effect is
427/// [`Effect::RepositoryMutation`] is named, in the registry's own order, and only a registry
428/// that holds no such tool gets the old sentence back.
429///
430/// The wording itself is [`crate::about::writes`], the one sentence the tool uses about its
431/// own writers; this function only decides which tools it names.
432///
433/// The assertion is `the_instructions_never_claim_read_only_while_a_writer_is_exposed`.
434///
435/// [ADR 0040]: ../../../.ai/repo/adrs/0040-development-semantics-are-capabilities-of-one-runtime.md
436/// [`Effect::RepositoryMutation`]: crate::capability::model::Effect::RepositoryMutation
437pub(crate) fn effects_sentence(registry: &crate::capability::CapabilityRegistry) -> String {
438    let writers: Vec<&str> = registry
439        .iter()
440        .filter(|c| c.execution.effect == crate::capability::model::Effect::RepositoryMutation)
441        .filter_map(|c| c.exposure.mcp.as_ref()?.tool.as_deref())
442        .collect();
443    crate::about::writes(&writers)
444}
445
446pub(crate) fn resource_json(r: &super::surface::Resource) -> Value {
447    let mut v = json!({ "uri": r.uri, "name": r.name, "mimeType": r.media_type, "_meta": { "majordomus": r.meta } });
448    if let Some(t) = &r.title {
449        v["title"] = Value::String(t.clone());
450    }
451    if let Some(d) = &r.description {
452        v["description"] = Value::String(d.clone());
453    }
454    v
455}
456
457pub(crate) fn tool_json(t: &super::surface::Tool) -> Value {
458    json!({
459        "name": t.name, "title": t.title, "description": t.description,
460        "inputSchema": t.input_schema,
461        "outputSchema": t.output_schema,
462        "_meta": { "majordomus": { "id": t.id, "effect": t.effect } },
463        "annotations": {
464            "readOnlyHint": t.hints.read_only, "destructiveHint": t.hints.destructive,
465            "idempotentHint": t.hints.idempotent, "openWorldHint": t.hints.open_world
466        }
467    })
468}
469
470fn error(id: Value, code: i64, message: &str) -> Value {
471    // the one place a JSON-RPC error is made: it may echo a tool name or an argument
472    let message = crate::redaction::redact_secrets(message).text;
473    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    /// The `initialize` instructions are the one thing every client reads before it does
481    /// anything, and for most of this server's life they ended with the constant "Nothing
482    /// here writes to the repository." That sentence stopped being true when the first
483    /// mutating capability was exposed as a tool, and nothing said so: a constant cannot go
484    /// stale loudly. So the sentence is derived, and this is the assertion that it stays
485    /// derived — if a tool that writes the repository is exposed and the instructions still
486    /// promise a read-only surface, this fails.
487    #[test]
488    fn the_instructions_never_claim_read_only_while_a_writer_is_exposed() {
489        let registry = crate::capability::CapabilityRegistry::builder()
490            .with_builtin(crate::capability::builtin::all())
491            .build()
492            .unwrap();
493        let writers: Vec<&str> = registry
494            .iter()
495            .filter(|c| c.execution.effect == crate::capability::model::Effect::RepositoryMutation)
496            .filter_map(|c| c.exposure.mcp.as_ref()?.tool.as_deref())
497            .collect();
498        let sentence = effects_sentence(&registry);
499
500        if writers.is_empty() {
501            assert_eq!(sentence, "Nothing here writes to the repository.");
502        } else {
503            assert!(
504                !sentence.contains("Nothing here writes"),
505                "{} mutating tool(s) are exposed ({}) and the instructions still tell every \
506                 client the surface writes nothing: {sentence}",
507                writers.len(),
508                writers.join(", ")
509            );
510            for w in &writers {
511                assert!(
512                    sentence.contains(w),
513                    "the instructions do not name the writing tool {w}: {sentence}"
514                );
515            }
516        }
517    }
518
519    /// A tool's annotations are what a client reads to decide whether to ask a person
520    /// first, and for most of this server's life three of the four were constants:
521    /// never destructive, always idempotent. The first tool that unlinked files was
522    /// announced as both. They are now the capability's classified hints, and this is the
523    /// assertion that they stay so: every tool of the real registry is rendered, and each
524    /// annotation is compared with the policy of the capability behind it.
525    #[test]
526    fn a_tools_annotations_are_its_capabilitys_hints_and_a_writer_is_never_announced_as_safe() {
527        let registry = crate::capability::CapabilityRegistry::builder()
528            .with_builtin(crate::capability::builtin::all())
529            .build()
530            .unwrap();
531        let mut writers = 0;
532        for c in registry.iter() {
533            let Some(tool) = super::super::surface::Tool::of(c) else {
534                continue;
535            };
536            let v = tool_json(&tool);
537            let a = &v["annotations"];
538            let h = c.execution.hints();
539            assert_eq!(a["readOnlyHint"], h.read_only, "{}", tool.name);
540            assert_eq!(a["destructiveHint"], h.destructive, "{}", tool.name);
541            assert_eq!(a["idempotentHint"], h.idempotent, "{}", tool.name);
542            assert_eq!(a["openWorldHint"], h.open_world, "{}", tool.name);
543            assert_eq!(
544                v["_meta"]["majordomus"]["effect"],
545                serde_json::to_value(c.execution.effect).unwrap(),
546                "{}",
547                tool.name
548            );
549            if c.execution.effect == crate::capability::model::Effect::RepositoryMutation {
550                writers += 1;
551                assert_eq!(
552                    a["readOnlyHint"], false,
553                    "{} writes the repository",
554                    tool.name
555                );
556                assert_eq!(
557                    a["destructiveHint"], true,
558                    "{} writes the repository",
559                    tool.name
560                );
561                assert_eq!(
562                    a["idempotentHint"], false,
563                    "{} writes the repository",
564                    tool.name
565                );
566            }
567            if a["readOnlyHint"] == true {
568                assert_eq!(c.execution.effect, crate::capability::model::Effect::Read);
569            }
570        }
571        assert!(
572            writers > 0,
573            "the registry exposes no writing tool, so this proved nothing about one"
574        );
575    }
576
577    /// The sentence over a registry this repository does not happen to hold, so that the
578    /// assertion above cannot pass merely because the real registry has a writer today: a
579    /// registry with nothing in it writes nothing, and says so.
580    #[test]
581    fn a_registry_with_no_writer_still_says_nothing_writes() {
582        let empty = crate::capability::CapabilityRegistry::builder()
583            .build()
584            .unwrap();
585        assert_eq!(
586            effects_sentence(&empty),
587            "Nothing here writes to the repository."
588        );
589    }
590}