1use 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#[derive(Debug)]
22pub enum Reply {
23 Value(Value),
25 Prepared {
27 id: Value,
29 result: Arc<Box<RawValue>>,
31 },
32 Batch(Vec<Reply>),
34}
35
36impl Reply {
37 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
69pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
72
73pub const SERVER_NAME: &str = "majordomus";
75
76pub const SERVER_TITLE: &str = "Majordomus";
78
79pub 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;
97const RESOURCE_NOT_FOUND: i64 = -32002;
99
100#[derive(Debug)]
101pub 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 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 pub(crate) fn set_remote(&mut self, remote: bool) {
132 self.remote = remote;
133 }
134
135 pub fn with_endpoint(mut self, url: Option<String>) -> Self {
138 self.endpoint = url;
139 self
140 }
141
142 pub fn surface(&self) -> &Surface {
144 &self.surface
145 }
146
147 pub fn is_initialized(&self) -> bool {
149 self.initialized
150 }
151
152 pub fn client(&self) -> Option<&ClientInfo> {
154 self.client.as_ref()
155 }
156
157 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 pub fn parse_error(reason: &str) -> Value {
169 error(Value::Null, PARSE_ERROR, &format!("parse error: {reason}"))
170 }
171
172 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 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 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, ¶ms);
220 return None;
221 };
222 tracing::debug!(operation = method, "request");
223 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, ¶ms) {
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 let text = value.to_string();
301 Ok(
302 json!({ "content": [{ "type": "text", "text": text }], "structuredContent": value, "isError": false }),
303 )
304 }
305 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 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(®istry),
363 );
364 if let Some(url) = &self.endpoint {
365 text.push_str(&format!(
366 " 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 " 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 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
418pub(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 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 #[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(®istry);
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 #[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 #[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}