majordomus_cli/capability/builtin/mcp.rs
1//! The `mcp` module: the MCP projection of this registry, described as one typed value.
2//!
3//! MCP is a projection and owns nothing: every tool is a capability, every resource a
4//! capability or an object of the layer. What a client can find out *about* the projection
5//! was nevertheless scattered — the protocol versions in `initialize`, the tools one page
6//! at a time in `tools/list`, the effect of each in an annotation, the client
7//! configurations in `share/providers.yaml`, whether each one starts this repository's
8//! server in a shell function. `mcp.projection` is the same facts as one answer, read by
9//! the Cockpit's MCP page, the HTTP route, the tool and `majordomus mcp --inspect` alike,
10//! so that none of them keeps an inventory of its own.
11//!
12//! Nothing here is a second list. The tools are the registry's, filtered by the exposure
13//! they declare; the effect and the hints are the capability's classification
14//! ([`ExecutionPolicy::hints`](crate::capability::ExecutionPolicy::hints)); the protocol
15//! facts are the constants the server answers `initialize` with; the clients are the
16//! providers the distribution declares.
17//!
18//! ```
19//! use majordomus_cli::capability::builtin::mcp;
20//! use majordomus_cli::capability::model::CapabilityKind;
21//!
22//! let m = mcp::module();
23//! let c = &m.capabilities[0].capability;
24//! assert_eq!(c.id.as_str(), "mcp.projection");
25//! // describing the projection reads the registry and changes nothing
26//! assert_eq!(c.kind, CapabilityKind::Query);
27//! assert_eq!(c.exposure.http.as_ref().unwrap().path, "/api/v1/mcp");
28//! ```
29
30use std::path::Path;
31
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34
35use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
36use crate::capability::handler::{CapabilityError, Context};
37use crate::capability::model::{
38 Effect, Exposure, Hints, McpExposure, McpResource, Provenance, Stability,
39};
40use crate::capability::module::ModuleDescriptor;
41use crate::capability::{CachePolicy, CapabilityRegistry};
42use crate::{capability, module};
43
44use super::get;
45
46/// The URI under which the description is read as a resource.
47pub const MCP_URI: &str = "majordomus://mcp";
48
49/// The launcher a client configuration names: the one command that finds or builds the
50/// executable and speaks MCP. Relative to a repository that carries it, and the name of the
51/// installed command where a repository does not.
52pub const LAUNCHER: &str = "majordomus-mcp";
53
54/// The input of `mcp.projection`: which tools to list. Everything else in the answer is
55/// about the projection as a whole and is always given.
56///
57/// ```
58/// use majordomus_cli::capability::builtin::mcp::McpProjectionInput;
59/// use majordomus_cli::capability::Effect;
60/// let all: McpProjectionInput = serde_json::from_str("{}").unwrap();
61/// assert!(all.effect.is_none());
62/// let writers: McpProjectionInput =
63/// serde_json::from_str(r#"{"effect":"repository_mutation"}"#).unwrap();
64/// assert_eq!(writers.effect, Some(Effect::RepositoryMutation));
65/// ```
66#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct McpProjectionInput {
69 /// List only the tools with this effect: `read`, `process_state` or
70 /// `repository_mutation`. Absent lists every tool.
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub effect: Option<Effect>,
73}
74
75impl BenchmarkCases for McpProjectionInput {
76 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
77 vec![
78 NamedCase::new("every-tool", McpProjectionInput { effect: None }),
79 NamedCase::new(
80 "writers",
81 McpProjectionInput {
82 effect: Some(Effect::RepositoryMutation),
83 },
84 ),
85 ]
86 }
87}
88
89/// Who answers `initialize`, as it answers.
90///
91/// ```
92/// use majordomus_cli::capability::builtin::mcp::McpServerIdentity;
93/// let s: McpServerIdentity = serde_json::from_value(serde_json::json!({
94/// "name": "majordomus", "title": "Majordomus", "version": "0.14.0",
95/// })).unwrap();
96/// // the version is the executable's: the protocol adapter has none of its own to drift
97/// assert_eq!((s.name.as_str(), s.version.as_str()), ("majordomus", "0.14.0"));
98/// ```
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
100pub struct McpServerIdentity {
101 /// The server name.
102 pub name: String,
103 /// The name a person reads.
104 pub title: String,
105 /// The version: this executable's, never one of the protocol adapter's own.
106 pub version: String,
107}
108
109/// One way a client reaches the server.
110///
111/// ```
112/// use majordomus_cli::capability::builtin::mcp::McpTransportEntry;
113/// let t: McpTransportEntry = serde_json::from_value(serde_json::json!({
114/// "id": "stdio", "reached_by": "majordomus mcp", "attached": 1,
115/// })).unwrap();
116/// // a count of sessions at the moment of asking, not a claim that a server is running
117/// assert_eq!((t.id.as_str(), t.attached), ("stdio", 1));
118/// ```
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120pub struct McpTransportEntry {
121 /// `stdio` or `http`.
122 pub id: String,
123 /// How it is reached: the command a client starts, or the path on the shared server.
124 pub reached_by: String,
125 /// Sessions attached to this process over it right now.
126 pub attached: usize,
127}
128
129/// What the server serves of the protocol, and what it does not.
130///
131/// ```
132/// use majordomus_cli::capability::builtin::mcp::McpServing;
133/// let s: McpServing = serde_json::from_value(serde_json::json!({
134/// "methods": ["initialize", "tools/call"], "tools": true, "resources": true,
135/// "prompts": false, "notifications": false,
136/// })).unwrap();
137/// // what is not served is stated, so a client does not learn it from a refusal
138/// assert!(s.tools && !s.prompts && !s.notifications);
139/// assert!(!s.methods.iter().any(|m| m.starts_with("prompts/")));
140/// ```
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142pub struct McpServing {
143 /// The methods a request may name. Anything else is answered `-32601`.
144 pub methods: Vec<String>,
145 /// Tools: every capability that declares one.
146 pub tools: bool,
147 /// Resources: every capability that declares one, and every object of the layer.
148 pub resources: bool,
149 /// Prompts. Not served: the repository's prompt assets are resources, because a prompt
150 /// served unrendered would read as finished.
151 pub prompts: bool,
152 /// Subscriptions and list-change notifications. Not served: this server sends nothing
153 /// unasked.
154 pub notifications: bool,
155}
156
157/// One tool, as the registry projects it.
158///
159/// ```
160/// use majordomus_cli::capability::builtin::mcp::McpToolEntry;
161/// use majordomus_cli::capability::Effect;
162/// let t: McpToolEntry = serde_json::from_value(serde_json::json!({
163/// "name": "majordomus_plan_transition", "capability": "plan.transition",
164/// "module": "plan", "title": "Move an issue", "effect": "repository_mutation",
165/// "hints": { "read_only": false, "destructive": true, "idempotent": false, "open_world": false },
166/// })).unwrap();
167/// assert_eq!(t.effect, Effect::RepositoryMutation);
168/// // the hints are the effect's classification, carried beside it rather than restated
169/// assert!(t.hints.destructive && !t.hints.read_only && !t.hints.idempotent);
170/// ```
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172pub struct McpToolEntry {
173 /// The name a client calls.
174 pub name: String,
175 /// The capability it projects; `capabilities.describe` answers its schemas.
176 pub capability: String,
177 /// The module that composes the capability.
178 pub module: String,
179 /// The title.
180 pub title: String,
181 /// What a call changes.
182 pub effect: Effect,
183 /// What a caller may assume before calling; the tool's annotations are this value.
184 pub hints: Hints,
185}
186
187/// How many tools have one effect.
188///
189/// ```
190/// use majordomus_cli::capability::builtin::mcp::McpEffectCount;
191/// use majordomus_cli::capability::Effect;
192/// let c: McpEffectCount =
193/// serde_json::from_value(serde_json::json!({ "effect": "read", "tools": 149 })).unwrap();
194/// assert_eq!((c.effect, c.tools), (Effect::Read, 149));
195/// ```
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct McpEffectCount {
198 /// The effect.
199 pub effect: Effect,
200 /// How many tools have it.
201 pub tools: usize,
202}
203
204/// The resources a client can read.
205///
206/// ```
207/// use majordomus_cli::capability::builtin::mcp::McpResourceSummary;
208/// let r: McpResourceSummary = serde_json::from_value(serde_json::json!({
209/// "builtin": 54, "objects": 1742, "template": "majordomus://<kind>/<identity>",
210/// })).unwrap();
211/// // the two counts together are the length of `resources/list`
212/// assert_eq!(r.builtin + r.objects, 1796);
213/// ```
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
215pub struct McpResourceSummary {
216 /// Resources a builtin capability answers.
217 pub builtin: usize,
218 /// Objects of this repository's layer, each one more resource.
219 pub objects: usize,
220 /// The URI shape of an object's resource.
221 pub template: String,
222}
223
224/// Where a client's project-scoped configuration stands in this repository.
225///
226/// ```
227/// use majordomus_cli::capability::builtin::mcp::McpClientStanding;
228/// // the words a caller reads, as they are written on every surface
229/// assert_eq!(serde_json::to_value(McpClientStanding::Wired).unwrap(), "wired");
230/// assert_eq!(serde_json::to_value(McpClientStanding::Foreign).unwrap(), "foreign");
231/// assert_eq!(serde_json::to_value(McpClientStanding::Absent).unwrap(), "absent");
232/// ```
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
234#[serde(rename_all = "snake_case")]
235pub enum McpClientStanding {
236 /// The file exists and names the launcher: this client starts this repository's server.
237 Wired,
238 /// The file exists and does not name the launcher: the client reads it and starts
239 /// something else, or nothing.
240 Foreign,
241 /// The file does not exist: this client starts no server here until it is written.
242 Absent,
243}
244
245/// One client the distribution declares a project-scoped configuration for.
246///
247/// ```
248/// use majordomus_cli::capability::builtin::mcp::{McpClient, McpClientStanding};
249/// let mut clients: Vec<McpClient> = serde_json::from_value(serde_json::json!([
250/// { "id": "gemini", "title": "Gemini CLI", "config": ".gemini/settings.json", "standing": "absent" },
251/// { "id": "claude-code", "title": "Claude Code", "config": ".mcp.json", "standing": "wired" },
252/// ])).unwrap();
253/// // ordered by the provider's id, whatever order the distribution declared them in
254/// majordomus_cli::order::canonical(&mut clients);
255/// assert_eq!(clients[0].id, "claude-code");
256/// assert_eq!(clients[0].standing, McpClientStanding::Wired);
257/// ```
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
259pub struct McpClient {
260 /// The provider id.
261 pub id: String,
262 /// The name a person knows it by.
263 pub title: String,
264 /// The file it reads, repository-relative.
265 pub config: String,
266 /// Where that file stands here.
267 pub standing: McpClientStanding,
268}
269
270/// A client is the provider it belongs to, and is ordered by that id: the order every other
271/// listing of providers reads in, whatever order the distribution declared them in.
272impl crate::order::Ordered for McpClient {
273 fn order_key(&self) -> crate::order::OrderKey<'_> {
274 crate::order::OrderKey::plain(&self.id, &self.id)
275 }
276}
277
278/// Something about the projection a person should act on.
279///
280/// ```
281/// use majordomus_cli::capability::builtin::mcp::McpFinding;
282/// let f: McpFinding = serde_json::from_value(serde_json::json!({
283/// "code": "mcp_client_config_foreign",
284/// "message": ".mcp.json exists and does not name majordomus-mcp",
285/// "remedy": "add a `majordomus` server to .mcp.json whose command is majordomus-mcp",
286/// })).unwrap();
287/// // a finding always says what to do, not only what is wrong
288/// assert!(!f.code.is_empty() && !f.remedy.is_empty());
289/// ```
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
291pub struct McpFinding {
292 /// The stable code.
293 pub code: String,
294 /// What is wrong.
295 pub message: String,
296 /// What to do about it.
297 pub remedy: String,
298}
299
300/// The MCP projection of this registry in this repository.
301///
302/// ```
303/// use majordomus_cli::capability::builtin::mcp::McpProjection;
304/// let p: McpProjection = serde_json::from_value(serde_json::json!({
305/// "server": { "name": "majordomus", "title": "Majordomus", "version": "0.14.0" },
306/// "protocol_versions": ["2025-06-18"],
307/// "transports": [{ "id": "stdio", "reached_by": "majordomus mcp", "attached": 0 }],
308/// "serving": { "methods": ["initialize"], "tools": true, "resources": true, "prompts": false, "notifications": false },
309/// "tool_count": 2,
310/// "tools": [],
311/// "effects": [{ "effect": "read", "tools": 1 }, { "effect": "repository_mutation", "tools": 1 }],
312/// "writers": ["majordomus_plan_transition"],
313/// "resources": { "builtin": 1, "objects": 0, "template": "majordomus://<kind>/<identity>" },
314/// "launcher_in_repository": false,
315/// "clients": [],
316/// "findings": [],
317/// })).unwrap();
318/// // the count is of every tool, whatever the `effect` filter narrowed `tools` to
319/// assert_eq!(p.tool_count, p.effects.iter().map(|e| e.tools).sum::<usize>());
320/// assert!(p.tools.is_empty() && p.writers.len() == 1);
321/// ```
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
323pub struct McpProjection {
324 /// Who answers `initialize`.
325 pub server: McpServerIdentity,
326 /// The protocol versions accepted, newest first.
327 pub protocol_versions: Vec<String>,
328 /// How a client reaches the server.
329 pub transports: Vec<McpTransportEntry>,
330 /// What of the protocol is served.
331 pub serving: McpServing,
332 /// How many tools the registry projects, whatever the filter asked for.
333 pub tool_count: usize,
334 /// The tools asked for, in registry order.
335 pub tools: Vec<McpToolEntry>,
336 /// The tools counted by effect, every effect present.
337 pub effects: Vec<McpEffectCount>,
338 /// The tools that write the repository, by name. The `initialize` instructions name the
339 /// same ones.
340 pub writers: Vec<String>,
341 /// The resources.
342 pub resources: McpResourceSummary,
343 /// Whether this repository carries the launcher at `bin/majordomus-mcp`. Where it does
344 /// not, the installed command of the same name is what a configuration names.
345 pub launcher_in_repository: bool,
346 /// The clients the distribution declares a configuration for, and where each stands.
347 pub clients: Vec<McpClient>,
348 /// What a person should act on. Empty is nothing to do.
349 pub findings: Vec<McpFinding>,
350}
351
352/// Every tool the registry projects, in registry order.
353///
354/// ```
355/// use majordomus_cli::capability::builtin::{self, mcp};
356/// use majordomus_cli::capability::{CapabilityRegistry, Effect};
357/// let registry = CapabilityRegistry::builder().with_builtin(builtin::all()).build().unwrap();
358/// let tools = mcp::tools(®istry);
359/// let transition = tools.iter().find(|t| t.name == "majordomus_plan_transition").unwrap();
360/// assert_eq!(transition.effect, Effect::RepositoryMutation);
361/// assert!(transition.hints.destructive && !transition.hints.read_only);
362/// ```
363pub fn tools(registry: &CapabilityRegistry) -> Vec<McpToolEntry> {
364 registry
365 .iter()
366 .filter_map(|c| {
367 let name = c.exposure.mcp.as_ref()?.tool.clone()?;
368 Some(McpToolEntry {
369 name,
370 capability: c.id.to_string(),
371 module: c.module.to_string(),
372 title: c.title.clone(),
373 effect: c.execution.effect,
374 hints: c.execution.hints(),
375 })
376 })
377 .collect()
378}
379
380/// Where a client configuration stands: read from the file, never from a list of who is
381/// expected to be wired.
382///
383/// ```
384/// use majordomus_cli::capability::builtin::mcp::{standing, McpClientStanding};
385/// let dir = std::env::temp_dir().join(format!("mj-mcp-standing-{}", std::process::id()));
386/// std::fs::create_dir_all(&dir).unwrap();
387/// assert_eq!(standing(&dir, ".mcp.json"), McpClientStanding::Absent);
388/// std::fs::write(dir.join(".mcp.json"), r#"{"mcpServers":{"other":{"command":"x"}}}"#).unwrap();
389/// assert_eq!(standing(&dir, ".mcp.json"), McpClientStanding::Foreign);
390/// std::fs::write(dir.join(".mcp.json"), r#"{"mcpServers":{"majordomus":{"command":"bin/majordomus-mcp"}}}"#).unwrap();
391/// assert_eq!(standing(&dir, ".mcp.json"), McpClientStanding::Wired);
392/// std::fs::remove_dir_all(&dir).unwrap();
393/// ```
394pub fn standing(root: &Path, config: &str) -> McpClientStanding {
395 match std::fs::read_to_string(root.join(config)) {
396 Err(_) => McpClientStanding::Absent,
397 Ok(text) if text.contains(LAUNCHER) => McpClientStanding::Wired,
398 Ok(_) => McpClientStanding::Foreign,
399 }
400}
401
402/// How many tools carry each effect, in the order the effects are declared; an effect no tool
403/// has is left out rather than counted as zero.
404fn effect_counts(all: &[McpToolEntry]) -> Vec<McpEffectCount> {
405 [
406 Effect::Read,
407 Effect::ProcessState,
408 Effect::RepositoryMutation,
409 ]
410 .into_iter()
411 .map(|effect| McpEffectCount {
412 effect,
413 tools: all.iter().filter(|t| t.effect == effect).count(),
414 })
415 .filter(|count| count.tools > 0)
416 .collect()
417}
418
419/// What the client configurations of this repository leave to be done: one finding for
420/// each configuration that exists and does not start this repository's server, and one
421/// when no declared client has a configuration at all.
422fn client_findings(clients: &[McpClient], launcher_in_repository: bool) -> Vec<McpFinding> {
423 let command = if launcher_in_repository {
424 "bin/majordomus-mcp"
425 } else {
426 LAUNCHER
427 };
428 let mut findings: Vec<McpFinding> = clients
429 .iter()
430 .filter(|c| c.standing == McpClientStanding::Foreign)
431 .map(|c| McpFinding {
432 code: "mcp_client_config_foreign".into(),
433 message: format!(
434 "{} exists and does not name {LAUNCHER}, so {} does not start this repository's server from it",
435 c.config, c.title
436 ),
437 remedy: format!(
438 "add a `majordomus` server to {} whose command is {command}",
439 c.config
440 ),
441 })
442 .collect();
443 if !clients.is_empty()
444 && clients
445 .iter()
446 .all(|c| c.standing == McpClientStanding::Absent)
447 {
448 findings.push(McpFinding {
449 code: "mcp_no_client_configured".into(),
450 message: "no declared client has a configuration in this repository, so none of them starts its server".into(),
451 remedy: format!(
452 "write one of {} naming the command {LAUNCHER}; docs/MCP.md shows each form",
453 clients
454 .iter()
455 .map(|c| c.config.as_str())
456 .collect::<Vec<_>>()
457 .join(", ")
458 ),
459 });
460 }
461 findings
462}
463
464fn projection(ctx: &Context, input: McpProjectionInput) -> Result<McpProjection, CapabilityError> {
465 Ok(describe(ctx, input))
466}
467
468/// The projection as the capability answers it, for a caller inside this program that
469/// already holds the context: `mcp --inspect` prints the same value the tool, the resource
470/// and the route are asked for, and reading it cannot fail, because nothing in it is
471/// fetched — the registry, the peer board and the layer's index are in the context, and a
472/// client configuration that cannot be read is one that is absent.
473///
474/// ```
475/// use majordomus_cli::capability::builtin::mcp::{describe, McpProjectionInput};
476/// use majordomus_cli::capability::{builtin, CapabilityRegistry, Context, Effect};
477/// use majordomus_cli::git::GitState;
478/// use majordomus_cli::index::{Index, RepositoryInfo, State};
479/// use std::sync::Arc;
480/// // an index with no objects and no declared provider: the registry alone answers
481/// let index = Index {
482/// repository: RepositoryInfo {
483/// root: "/tmp/doc".into(), layer_schema: "ai-repository/v1".into(),
484/// sections: Default::default(), git: GitState::Unavailable { reason: "doc".into() },
485/// discovery: "filesystem".into(), source_classes: vec![], kind_sources: vec![],
486/// scope_origin: majordomus_cli::scope::Origin::Distribution, scope_path: String::new(),
487/// observed: Default::default(),
488/// },
489/// objects: vec![], diagnostics: vec![], state: State::Ok, fingerprint: String::new(),
490/// scoped: Default::default(), distribution: None, providers: Default::default(),
491/// share: None,
492/// };
493/// let registry = CapabilityRegistry::builder().with_builtin(builtin::all()).with_index(&index).build().unwrap();
494/// let ctx = Context::new(Arc::new(index), Arc::new(registry));
495///
496/// let all = describe(&ctx, McpProjectionInput::default());
497/// assert_eq!(all.tools.len(), all.tool_count);
498/// assert_eq!(all.effects.iter().map(|e| e.tools).sum::<usize>(), all.tool_count);
499/// // no provider is declared, so there is no client and nothing to be done about one
500/// assert!(all.clients.is_empty() && all.findings.is_empty());
501///
502/// // one effect narrows the tools listed and nothing else
503/// let writers = describe(&ctx, McpProjectionInput { effect: Some(Effect::RepositoryMutation) });
504/// assert_eq!(writers.tool_count, all.tool_count);
505/// assert_eq!(writers.tools.len(), all.writers.len());
506/// assert!(writers.tools.iter().all(|t| all.writers.contains(&t.name)));
507/// ```
508pub fn describe(ctx: &Context, input: McpProjectionInput) -> McpProjection {
509 use crate::mcp::protocol;
510 use crate::peers::Transport;
511
512 let all = tools(&ctx.registry);
513 let effects = effect_counts(&all);
514 let writers = all
515 .iter()
516 .filter(|t| t.effect == Effect::RepositoryMutation)
517 .map(|t| t.name.clone())
518 .collect();
519
520 let peers = ctx.peers.list();
521 let attached = |t: Transport| {
522 peers
523 .iter()
524 .filter(|p| p.attached && p.transport == t)
525 .count()
526 };
527
528 let root = Path::new(&ctx.index.repository.root);
529 let launcher_in_repository = root.join("bin").join(LAUNCHER).is_file();
530 let mut clients: Vec<McpClient> = ctx
531 .index
532 .providers
533 .providers
534 .iter()
535 .filter_map(|d| {
536 let config = d.client_config.clone()?;
537 Some(McpClient {
538 id: d.id.clone(),
539 title: d.title.clone(),
540 standing: standing(root, &config),
541 config,
542 })
543 })
544 .collect();
545 crate::order::canonical(&mut clients);
546
547 let findings = client_findings(&clients, launcher_in_repository);
548
549 let builtin = ctx
550 .registry
551 .iter()
552 .filter(|c| matches!(c.provenance, Provenance::Builtin { .. }))
553 .filter(|c| {
554 c.exposure
555 .mcp
556 .as_ref()
557 .is_some_and(|m| m.resource.is_some())
558 })
559 .count();
560
561 McpProjection {
562 server: McpServerIdentity {
563 name: protocol::SERVER_NAME.into(),
564 title: protocol::SERVER_TITLE.into(),
565 version: crate::VERSION.into(),
566 },
567 protocol_versions: protocol::PROTOCOL_VERSIONS
568 .iter()
569 .map(|v| v.to_string())
570 .collect(),
571 transports: vec![
572 McpTransportEntry {
573 id: "stdio".into(),
574 reached_by: "majordomus mcp".into(),
575 attached: attached(Transport::Stdio),
576 },
577 McpTransportEntry {
578 id: "http".into(),
579 reached_by: format!("POST {} on the shared server", crate::http::mcp::PATH),
580 attached: attached(Transport::Http),
581 },
582 ],
583 serving: McpServing {
584 methods: protocol::METHODS.iter().map(|m| m.to_string()).collect(),
585 tools: true,
586 resources: true,
587 prompts: false,
588 notifications: false,
589 },
590 tool_count: all.len(),
591 tools: match input.effect {
592 Some(effect) => all.iter().filter(|t| t.effect == effect).cloned().collect(),
593 None => all.clone(),
594 },
595 effects,
596 writers,
597 resources: McpResourceSummary {
598 builtin,
599 objects: ctx.index.objects.len(),
600 template: crate::site::MCP_RESOURCE_TEMPLATE.into(),
601 },
602 launcher_in_repository,
603 clients,
604 findings,
605 }
606}
607
608/// The `mcp` module: the one capability that describes the MCP projection, composed into
609/// the application like every other module, so that describing the projection is itself a
610/// tool, a resource and a route of it.
611///
612/// ```
613/// use majordomus_cli::capability::builtin::mcp;
614/// let m = mcp::module();
615/// assert_eq!(m.id.as_str(), "mcp");
616/// let c = &m.capabilities[0].capability;
617/// assert_eq!(c.exposure.mcp.as_ref().unwrap().tool.as_deref(), Some("majordomus_mcp"));
618/// assert_eq!(c.exposure.mcp.as_ref().unwrap().resource.as_ref().unwrap().uri, mcp::MCP_URI);
619/// ```
620pub fn module() -> ModuleDescriptor {
621 module! {
622 id: "mcp",
623 title: "MCP projection",
624 description: "The MCP projection of this registry, described as one typed value: who answers `initialize` and with which protocol versions, the transports and how many sessions each holds, what of the protocol is served and what is not, every tool with the effect and the hints its annotations are derived from, the tools that write the repository, the resources, and where each declared client's configuration stands in this repository. Nothing in it is a list of its own: the tools are the registry's, the effect is the capability's classification, the clients are the distribution's providers.",
625 stability: Stability::Experimental,
626 capabilities: [
627 capability! {
628 id: "mcp.projection",
629 title: "The MCP projection of this registry",
630 description: "Who answers `initialize` and with which protocol versions; the transports and the sessions attached over each; the methods served, and that prompts and notifications are not; every tool (or only those with one effect) with its capability, effect and the hints its annotations are derived from; the tools that write the repository; the resources; and whether each client the distribution declares has a configuration here that starts this repository's server. Reads the registry and one file per declared client; writes nothing.",
631 input: McpProjectionInput,
632 output: McpProjection,
633 stability: Stability::Experimental,
634 exposure: Exposure {
635 mcp: Some(McpExposure {
636 tool: Some("majordomus_mcp".into()),
637 resource: Some(McpResource { uri: MCP_URI.into(), name: "mcp".into() }),
638 }),
639 http: get("/api/v1/mcp"),
640 cli: None,
641 },
642 tags: ["mcp", "introspection", "projections"],
643 // the sessions attached are read from the board at the moment of asking
644 cache: CachePolicy::Disabled,
645 handler: projection,
646 },
647 ],
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use crate::capability::builtin;
655
656 fn registry() -> CapabilityRegistry {
657 CapabilityRegistry::builder()
658 .with_builtin(builtin::all())
659 .build()
660 .unwrap()
661 }
662
663 /// The listing is the registry's and nothing else's: a capability with a tool exposure
664 /// is in it, one without is not, and the count is the registry's own count.
665 #[test]
666 fn the_tools_are_exactly_the_capabilities_that_declare_one() {
667 let r = registry();
668 let listed = tools(&r);
669 let declared: Vec<String> = r
670 .iter()
671 .filter_map(|c| c.exposure.mcp.as_ref()?.tool.clone())
672 .collect();
673 assert_eq!(
674 listed.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
675 declared
676 );
677 assert_eq!(listed.len(), r.summary().mcp_tools);
678 for t in &listed {
679 let c = r.by_mcp_tool(&t.name).unwrap();
680 assert_eq!(c.id.as_str(), t.capability);
681 assert_eq!(c.execution.effect, t.effect);
682 assert_eq!(c.execution.hints(), t.hints);
683 }
684 }
685
686 /// The description describes itself: it is a tool of the projection it reports, and a
687 /// read.
688 #[test]
689 fn the_description_is_one_of_the_tools_it_lists_and_is_a_read() {
690 let listed = tools(®istry());
691 let me = listed.iter().find(|t| t.name == "majordomus_mcp").unwrap();
692 assert_eq!(me.capability, "mcp.projection");
693 assert_eq!(me.effect, Effect::Read);
694 assert!(me.hints.read_only);
695 }
696
697 /// An effect is counted when a tool has it and left out when none does: a row saying
698 /// "0 tools" would be a row about nothing.
699 #[test]
700 fn an_effect_no_tool_has_is_not_counted() {
701 let all = tools(®istry());
702 let counted = effect_counts(&all);
703 assert_eq!(
704 counted.iter().map(|c| c.tools).sum::<usize>(),
705 all.len(),
706 "every tool has exactly one of the declared effects"
707 );
708 let reads: Vec<McpToolEntry> = all
709 .iter()
710 .filter(|t| t.effect == Effect::Read)
711 .cloned()
712 .collect();
713 let only = effect_counts(&reads);
714 assert_eq!(only.len(), 1);
715 assert_eq!(only[0].effect, Effect::Read);
716 assert_eq!(only[0].tools, reads.len());
717 assert!(effect_counts(&[]).is_empty());
718 }
719
720 /// The standing is what the file says: absent, there and naming something else, or
721 /// naming the launcher.
722 #[test]
723 fn a_configuration_stands_where_its_file_says() {
724 let dir = tempfile::tempdir().expect("a temp dir");
725 assert_eq!(standing(dir.path(), ".mcp.json"), McpClientStanding::Absent);
726 std::fs::write(
727 dir.path().join(".mcp.json"),
728 r#"{"mcpServers":{"other":{"command":"x"}}}"#,
729 )
730 .unwrap();
731 assert_eq!(
732 standing(dir.path(), ".mcp.json"),
733 McpClientStanding::Foreign
734 );
735 std::fs::write(
736 dir.path().join(".mcp.json"),
737 format!(r#"{{"mcpServers":{{"majordomus":{{"command":"bin/{LAUNCHER}"}}}}}}"#),
738 )
739 .unwrap();
740 assert_eq!(standing(dir.path(), ".mcp.json"), McpClientStanding::Wired);
741 }
742
743 fn client(config: &str, standing: McpClientStanding) -> McpClient {
744 McpClient {
745 id: config.trim_start_matches('.').into(),
746 title: format!("the client of {config}"),
747 config: config.into(),
748 standing,
749 }
750 }
751
752 /// A configuration that exists and starts something else is named, with the command
753 /// that would start this repository's server: the checkout's own launcher when the
754 /// repository carries one, the installed one otherwise.
755 #[test]
756 fn a_foreign_configuration_is_a_finding_that_names_the_command() {
757 let clients = [
758 client(".mcp.json", McpClientStanding::Foreign),
759 client(".codex/config.toml", McpClientStanding::Wired),
760 ];
761 let here = client_findings(&clients, true);
762 assert_eq!(here.len(), 1);
763 assert_eq!(here[0].code, "mcp_client_config_foreign");
764 assert!(here[0].message.contains(".mcp.json"));
765 assert!(here[0].message.contains("the client of .mcp.json"));
766 assert!(here[0]
767 .remedy
768 .ends_with("whose command is bin/majordomus-mcp"));
769
770 let installed = client_findings(&clients, false);
771 assert_eq!(installed.len(), 1);
772 assert!(installed[0]
773 .remedy
774 .ends_with(&format!("whose command is {LAUNCHER}")));
775 }
776
777 /// No configuration anywhere is one finding that lists where one could be written; a
778 /// wired client, or no declared client at all, is none.
779 #[test]
780 fn no_configuration_at_all_is_one_finding_and_a_wired_one_is_none() {
781 let absent = [
782 client(".mcp.json", McpClientStanding::Absent),
783 client(".gemini/settings.json", McpClientStanding::Absent),
784 ];
785 let findings = client_findings(&absent, false);
786 assert_eq!(findings.len(), 1);
787 assert_eq!(findings[0].code, "mcp_no_client_configured");
788 assert!(findings[0]
789 .remedy
790 .contains(".mcp.json, .gemini/settings.json"));
791
792 let wired = [
793 client(".mcp.json", McpClientStanding::Wired),
794 client(".gemini/settings.json", McpClientStanding::Absent),
795 ];
796 assert!(client_findings(&wired, true).is_empty());
797 assert!(client_findings(&[], true).is_empty());
798 }
799}