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}