Skip to main content

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(&registry);
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(&registry());
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(&registry());
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}