Skip to main content

majordomus_cli/mcp/
surface.rs

1//! The surface: the registry as resources and tools, independent of any protocol
2//! encoding. Every resource is a capability with an MCP resource exposure; every tool is
3//! a capability with an MCP tool exposure; every call goes through the registry to the
4//! one handler the capability has. Nothing is declared here.
5
6use std::sync::Arc;
7
8use serde::Serialize;
9use serde_json::value::RawValue;
10use serde_json::{json, Value};
11
12use crate::capability::{builtin, CapabilityError, CapabilityKind, CapabilityRegistry, Context};
13use crate::index::Index;
14use crate::live::{IntoLive, Live, Memo};
15
16use super::protocol::{resource_json, tool_json};
17use crate::peers::PeerId;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20/// One resource as a client lists it.
21pub struct Resource {
22    /// The URI a client reads.
23    pub uri: String,
24    /// The short name: the identity, or `repository`.
25    pub name: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    /// The title, when there is one.
28    pub title: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    /// The description, when there is one.
31    pub description: Option<String>,
32    /// IANA media type of what a read returns.
33    pub media_type: String,
34    /// Canonical id, kind and provenance, carried as metadata beside the resource.
35    pub meta: Value,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39/// What a read returns.
40pub struct ResourceContent {
41    /// The URI read.
42    pub uri: String,
43    /// IANA media type of `text`.
44    pub media_type: String,
45    /// The content.
46    pub text: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50/// One tool as a client lists it.
51pub struct Tool {
52    /// The tool name a client calls.
53    pub name: String,
54    /// The title.
55    pub title: String,
56    /// The description.
57    pub description: String,
58    /// The canonical id the tool projects.
59    pub id: String,
60    /// The canonical input schema.
61    pub input_schema: Value,
62    /// The canonical output schema.
63    pub output_schema: Value,
64    /// What the call changes: nothing, this process's memory, or the repository.
65    pub effect: crate::capability::Effect,
66    /// What a caller may assume before calling, classified from the effect. The tool's
67    /// annotations are this value and nothing else.
68    pub hints: crate::capability::Hints,
69}
70
71impl Tool {
72    /// The tool a capability projects, when it projects one.
73    ///
74    /// The one place a capability becomes a tool: its name is the declared exposure, and
75    /// everything else — the schemas, the effect, what a caller may assume — is read from
76    /// the capability, so a tool cannot say anything its capability does not.
77    ///
78    /// ```
79    /// use majordomus_cli::capability::{builtin, CapabilityRegistry, Effect};
80    /// use majordomus_cli::mcp::surface::Tool;
81    /// let registry = CapabilityRegistry::builder().with_builtin(builtin::all()).build().unwrap();
82    /// let c = registry.by_mcp_tool("majordomus_plan_transition").unwrap();
83    /// let tool = Tool::of(c).unwrap();
84    /// assert_eq!(tool.id, "plan.transition");
85    /// assert_eq!(tool.effect, Effect::RepositoryMutation);
86    /// assert!(tool.hints.destructive, "a tool that writes the repository is announced as one");
87    /// ```
88    pub fn of(c: &crate::capability::Capability) -> Option<Tool> {
89        let name = c.exposure.mcp.as_ref()?.tool.clone()?;
90        Some(Tool {
91            name,
92            title: c.title.clone(),
93            description: c.description.clone(),
94            id: c.id.to_string(),
95            input_schema: c.input.for_mcp(),
96            output_schema: c.output.for_mcp(),
97            effect: c.execution.effect,
98            hints: c.execution.hints(),
99        })
100    }
101}
102
103/// What a tool call produced: a value, or a refusal with the reason. Refusals are
104/// results, not protocol errors.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ToolOutcome {
107    /// The value the handler produced.
108    Ok(Value),
109    /// The handler declined: the category as one word ([`CapabilityError::code`]) and the
110    /// reason as the handler gave it.
111    Refused {
112        /// `invalid_input`, `not_found`, `refused` or `forbidden`.
113        code: &'static str,
114        /// The message, category included, as every transport shows it.
115        reason: String,
116    },
117}
118
119/// Why a request to the surface could not be answered.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121pub enum SurfaceError {
122    #[error("unknown resource: {0}")]
123    /// No capability projects this URI.
124    UnknownResource(String),
125    #[error("unknown tool: {0}")]
126    /// No capability projects this tool name.
127    UnknownTool(String),
128    #[error("internal: {0}")]
129    /// The handler itself failed.
130    Internal(String),
131}
132
133/// The tool and resource listings, computed once per shared listing and served from
134/// memory: `tools/list` and `resources/list` clone prepared JSON, they do not walk the
135/// registry.
136#[derive(Default)]
137struct Listing {
138    tools: std::sync::OnceLock<Arc<Vec<Tool>>>,
139    tools_json: std::sync::OnceLock<Arc<Vec<Value>>>,
140    tools_raw: std::sync::OnceLock<Arc<Box<RawValue>>>,
141    resources: std::sync::OnceLock<Arc<Vec<Resource>>>,
142    resources_json: std::sync::OnceLock<Arc<Vec<Value>>>,
143    resources_raw: std::sync::OnceLock<Arc<Box<RawValue>>>,
144}
145
146#[derive(Clone)]
147/// The registry seen as resources and tools.
148///
149/// The context is taken from [`Live`] per call rather than held, because an MCP session
150/// outlives many commits: a surface that captured one context would answer a client
151/// attached this morning with the repository as it was this morning, for the whole day.
152/// The listings are memoised per generation of the repository, so a client that lists its
153/// tools a thousand times builds them once, and a client that lists them after a commit
154/// that added a capability sees it.
155pub struct Surface {
156    live: Arc<Live>,
157    caller: Option<PeerId>,
158    listing: Arc<Memo<Listing>>,
159}
160
161impl std::fmt::Debug for Surface {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("Surface")
164            .field("capabilities", &self.context().registry.len())
165            .finish()
166    }
167}
168
169impl Surface {
170    /// A surface over a view of the repository.
171    pub fn new(live: impl IntoLive) -> Self {
172        Surface {
173            live: live.into_live(),
174            caller: None,
175            listing: Arc::new(Memo::default()),
176        }
177    }
178
179    /// The same surface, seen from one peer: every call it makes carries that identity,
180    /// which is how `peers.announce` knows who is speaking. The listings are shared.
181    pub fn for_peer(&self, peer: PeerId) -> Self {
182        Surface {
183            live: Arc::clone(&self.live),
184            caller: Some(peer),
185            listing: Arc::clone(&self.listing),
186        }
187    }
188
189    /// The listings of the generation that is current, built once for it.
190    fn listing(&self) -> Arc<Listing> {
191        self.listing
192            .get_or_init(self.live.view().generation, Listing::default)
193    }
194
195    /// The tool listing as `tools/list` answers it, prepared once per generation.
196    pub fn tools_json(&self) -> Arc<Vec<Value>> {
197        let listing = self.listing();
198        Arc::clone(
199            listing
200                .tools_json
201                .get_or_init(|| Arc::new(self.tools().iter().map(tool_json).collect())),
202        )
203    }
204
205    /// The resource listing as `resources/list` answers it, prepared once per generation.
206    pub fn resources_json(&self) -> Arc<Vec<Value>> {
207        let listing = self.listing();
208        Arc::clone(
209            listing
210                .resources_json
211                .get_or_init(|| Arc::new(self.resources().iter().map(resource_json).collect())),
212        )
213    }
214
215    /// The whole `tools/list` result, `{"tools": [...]}`, serialised once: a frame that
216    /// carries it copies bytes and builds nothing.
217    pub fn tools_result(&self) -> Arc<Box<RawValue>> {
218        let listing = self.listing();
219        Arc::clone(listing.tools_raw.get_or_init(|| {
220            let text = serde_json::to_string(&json!({ "tools": *self.tools_json() }))
221                .unwrap_or_else(|_| "{\"tools\":[]}".into());
222            Arc::new(RawValue::from_string(text).expect("serialised JSON"))
223        }))
224    }
225
226    /// The whole `resources/list` result, `{"resources": [...]}`, serialised once.
227    pub fn resources_result(&self) -> Arc<Box<RawValue>> {
228        let listing = self.listing();
229        Arc::clone(listing.resources_raw.get_or_init(|| {
230            let text = serde_json::to_string(&json!({ "resources": *self.resources_json() }))
231                .unwrap_or_else(|_| "{\"resources\":[]}".into());
232            Arc::new(RawValue::from_string(text).expect("serialised JSON"))
233        }))
234    }
235
236    /// The context behind the surface, as the repository is now.
237    ///
238    /// Owned rather than borrowed: the context this answers with is replaced when the
239    /// repository moves, so a reference into the surface would be a promise it cannot
240    /// keep. Callers hold the `Arc` for as long as they read it, which is what keeps one
241    /// answer built from one generation.
242    ///
243    /// ```
244    /// # use std::sync::Arc;
245    /// # use majordomus_cli::mcp::Surface;
246    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
247    /// let surface = Surface::new(ctx);
248    /// // the peer board is this process's own life and is the same across every reading
249    /// assert!(Arc::ptr_eq(&surface.context().peers, &surface.context().peers));
250    /// # }
251    /// ```
252    pub fn context(&self) -> Arc<Context> {
253        match &self.caller {
254            Some(peer) => Arc::new(self.live.current().for_caller(peer.clone())),
255            None => self.live.current(),
256        }
257    }
258
259    /// The peer this surface speaks for, when it speaks for one.
260    pub fn peer(&self) -> Option<&PeerId> {
261        self.caller.as_ref()
262    }
263
264    /// The index behind the surface, as the repository is now.
265    ///
266    /// ```
267    /// # use std::sync::Arc;
268    /// # use majordomus_cli::mcp::Surface;
269    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
270    /// let surface = Surface::new(ctx);
271    /// assert_eq!(surface.index().objects.len(), surface.context().index.objects.len());
272    /// # }
273    /// ```
274    pub fn index(&self) -> Arc<Index> {
275        Arc::clone(&self.context().index)
276    }
277
278    /// The registry behind the surface, as the repository is now.
279    ///
280    /// ```
281    /// # use std::sync::Arc;
282    /// # use majordomus_cli::mcp::Surface;
283    /// # fn example(ctx: Arc<majordomus_cli::capability::Context>) {
284    /// let surface = Surface::new(ctx);
285    /// assert_eq!(surface.registry().len(), surface.context().registry.len());
286    /// # }
287    /// ```
288    pub fn registry(&self) -> Arc<CapabilityRegistry> {
289        Arc::clone(&self.context().registry)
290    }
291
292    /// Every resource: executable ones (a query read as a document) first, then the
293    /// declarative ones, each group by canonical id. Computed once and shared.
294    pub fn resources(&self) -> Arc<Vec<Resource>> {
295        let listing = self.listing();
296        Arc::clone(
297            listing
298                .resources
299                .get_or_init(|| Arc::new(self.compute_resources())),
300        )
301    }
302
303    fn compute_resources(&self) -> Vec<Resource> {
304        let ctx = self.context();
305        let _phase = crate::perf::phase(crate::perf::Phase::McpProjectionBuild);
306        crate::perf::Counters::bump(&crate::perf::COUNTERS.mcp_projection_builds);
307        let mut out: Vec<(u8, Resource)> = Vec::new();
308        for c in ctx.registry.iter() {
309            let Some(res) = c.exposure.mcp.as_ref().and_then(|m| m.resource.as_ref()) else {
310                continue;
311            };
312            let (rank, media_type, meta) = match c.kind {
313                CapabilityKind::Query | CapabilityKind::Command => (
314                    0,
315                    "application/json".to_string(),
316                    json!({ "id": c.id, "kind": "query", "provenance": c.provenance }),
317                ),
318                CapabilityKind::Resource => {
319                    let object = ctx.index.get(&res.uri);
320                    (
321                        1,
322                        object
323                            .map(|o| o.media_type.to_string())
324                            .unwrap_or_else(|| "text/plain".into()),
325                        json!({
326                            "id": c.id,
327                            "kind": object.map(|o| o.kind.as_str()).unwrap_or("resource"),
328                            "identity": object.map(|o| o.identity.as_str()).unwrap_or(&res.name),
329                            "provenance": object.map(|o| serde_json::to_value(&o.provenance).unwrap_or(Value::Null)).unwrap_or(Value::Null),
330                        }),
331                    )
332                }
333            };
334            out.push((
335                rank,
336                Resource {
337                    uri: res.uri.clone(),
338                    name: res.name.clone(),
339                    title: Some(c.title.clone()),
340                    description: (!c.description.is_empty()).then(|| c.description.clone()),
341                    media_type,
342                    meta,
343                },
344            ));
345        }
346        out.sort_by_key(|(rank, _)| *rank);
347        out.into_iter().map(|(_, r)| r).collect()
348    }
349
350    /// Read one resource: an object's content, or a query with a resource exposure answered
351    /// as JSON. The resolution is `objects.get`'s ([`builtin::resolve`]), so the tool, the
352    /// HTTP route and this read answer one URI alike.
353    pub fn read(&self, uri: &str) -> Result<ResourceContent, SurfaceError> {
354        let ctx = self.context();
355        let resolved = builtin::resolve(&ctx, uri).map_err(|e| match e {
356            CapabilityError::NotFound(_) => SurfaceError::UnknownResource(uri.to_string()),
357            other => SurfaceError::Internal(other.to_string()),
358        })?;
359        Ok(ResourceContent {
360            uri: uri.to_string(),
361            media_type: resolved.media_type(),
362            text: resolved.text(),
363        })
364    }
365
366    /// Every tool, by canonical id. Computed once and shared.
367    pub fn tools(&self) -> Arc<Vec<Tool>> {
368        let listing = self.listing();
369        Arc::clone(listing.tools.get_or_init(|| Arc::new(self.compute_tools())))
370    }
371
372    fn compute_tools(&self) -> Vec<Tool> {
373        let ctx = self.context();
374        let _phase = crate::perf::phase(crate::perf::Phase::McpProjectionBuild);
375        crate::perf::Counters::bump(&crate::perf::COUNTERS.mcp_projection_builds);
376        ctx.registry.iter().filter_map(Tool::of).collect()
377    }
378
379    /// Call a tool by name, for a caller on this machine.
380    pub fn call(&self, name: &str, args: &Value) -> Result<ToolOutcome, SurfaceError> {
381        self.call_from(name, args, false)
382    }
383
384    /// Call a tool by name, saying whether the caller is on another host. A remote caller
385    /// is refused a tool that changes something and does not authenticate its input, with
386    /// the same word and sentence the HTTP route answers (ADR 0126).
387    pub(crate) fn call_from(
388        &self,
389        name: &str,
390        args: &Value,
391        remote: bool,
392    ) -> Result<ToolOutcome, SurfaceError> {
393        let ctx = self.context();
394        let c = ctx
395            .registry
396            .by_mcp_tool(name)
397            .ok_or_else(|| SurfaceError::UnknownTool(name.to_string()))?;
398        if remote && !c.execution.admits_remote() {
399            let e = CapabilityError::remote(c.id.as_str());
400            return Ok(ToolOutcome::Refused {
401                code: e.code(),
402                reason: e.to_string(),
403            });
404        }
405        match ctx.execute(c.id.as_str(), args.clone()) {
406            Ok(v) => Ok(ToolOutcome::Ok(v)),
407            Err(CapabilityError::Internal(e)) => Err(SurfaceError::Internal(e)),
408            Err(e) => Ok(ToolOutcome::Refused {
409                code: e.code(),
410                reason: crate::redaction::redact_secrets(&e.to_string()).text,
411            }),
412        }
413    }
414
415    /// The repository report, as the `repository.info` capability answers it.
416    pub fn repository_info(&self) -> Result<Value, SurfaceError> {
417        self.context()
418            .execute("repository.info", json!({}))
419            .map_err(|e| SurfaceError::Internal(e.to_string()))
420    }
421}