Skip to main content

harn_vm/
mcp_allowlist.rs

1//! Persisted MCP enable/disable allowlist + effective catalog (harn#2647).
2//!
3//! Part of the MCP comprehensiveness sub-epic: one
4//! harn-owned MCP implementation, with thin clients (an IDE host's Rust
5//! TUI and macOS GUI) that render toggle UIs **without storing any toggle
6//! state of their own**. This module is the single source of truth for
7//! which MCP **items** — tools, resources, and prompts — are enabled.
8//!
9//! ## Config shape
10//!
11//! The allowlist is a small, JSON-serializable document. The canonical
12//! on-disk form is `mcp.json` (the burin clients keep it at
13//! `~/.burin/mcp.json`) with an optional per-project overlay that layers on
14//! top. harn-vm never hardcodes those paths — callers (harn-cli / the burin
15//! host) pass file contents or paths in; this keeps the VM cross-platform
16//! and free of client-specific layout assumptions.
17//!
18//! ```jsonc
19//! {
20//!   "schemaVersion": 1,
21//!   "defaultEnabled": true,          // items absent from `items` use this
22//!   "items": [
23//!     { "server": "github", "kind": "tool",     "name": "create_issue", "enabled": false },
24//!     { "server": "notion", "kind": "resource", "name": "page://root",  "enabled": true  },
25//!     { "server": "notion", "kind": "prompt",   "name": "summarize",    "enabled": false }
26//!   ]
27//! }
28//! ```
29//!
30//! An **overlay** merges over a **base**: the overlay's `defaultEnabled`
31//! (when present) wins, and any overlay item replaces the matching base
32//! item by its `(server, kind, name)` key. This lets a project pin a
33//! narrower set than the user's global default.
34//!
35//! ## Effective catalog
36//!
37//! Given the live set of advertised items per server, [`build_catalog`]
38//! projects the allowlist onto them to produce the **effective catalog**:
39//! servers → items, each item carrying its resolved `enabled` flag. That
40//! catalog is the stable contract a thin client renders as a toggle list;
41//! the client reads it and never has to reconcile its own state.
42//!
43//! Both the [`McpAllowlist`] document and the [`McpCatalog`] projection are
44//! versioned (see [`MCP_ALLOWLIST_SCHEMA_VERSION`]); bump the version on any
45//! breaking shape change and coordinate consumers.
46
47use std::collections::BTreeMap;
48
49use serde::{Deserialize, Serialize};
50
51/// JSON schema version shared by [`McpAllowlist`] and [`McpCatalog`].
52/// Increment on any breaking shape change and coordinate consumers.
53pub const MCP_ALLOWLIST_SCHEMA_VERSION: u32 = 1;
54
55/// The three categories of item an MCP server can advertise. Mirrors the
56/// MCP `tools/list`, `resources/list`, and `prompts/list` surfaces.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum McpItemKind {
60    Tool,
61    Resource,
62    Prompt,
63}
64
65impl McpItemKind {
66    /// Stable lowercase tag used in JSON and in dedup keys.
67    pub fn as_str(self) -> &'static str {
68        match self {
69            McpItemKind::Tool => "tool",
70            McpItemKind::Resource => "resource",
71            McpItemKind::Prompt => "prompt",
72        }
73    }
74}
75
76/// One persisted enable/disable decision for a specific item. Items not
77/// listed in an [`McpAllowlist`] fall back to its `default_enabled`.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct McpAllowlistItem {
81    /// The owning MCP server's name (matches `harn.toml` `[[mcp]].name`).
82    pub server: String,
83    /// Which surface the item belongs to.
84    pub kind: McpItemKind,
85    /// The item's identifier within its server+kind: tool name, resource
86    /// URI, or prompt name.
87    pub name: String,
88    /// Whether the item is enabled. `false` hides it from the agent.
89    pub enabled: bool,
90}
91
92impl McpAllowlistItem {
93    /// Stable identity key for dedup/merge: `(server, kind, name)`.
94    fn key(&self) -> (String, McpItemKind, String) {
95        (self.server.clone(), self.kind, self.name.clone())
96    }
97}
98
99/// A persisted enable/disable allowlist covering tools, resources, and
100/// prompts across every configured MCP server. This is the document a
101/// client reads from / writes to disk; harn projects it into the effective
102/// catalog and (eventually) enforces it at dispatch.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct McpAllowlist {
106    /// Schema version of this document.
107    #[serde(default = "default_schema_version")]
108    pub schema_version: u32,
109    /// Enablement for any item not explicitly listed in `items`. A
110    /// permissive default (`true`) means "allow everything except what is
111    /// explicitly disabled"; `false` means "deny everything except what is
112    /// explicitly enabled".
113    #[serde(default = "default_true")]
114    pub default_enabled: bool,
115    /// Explicit per-item decisions. Order is not significant; the
116    /// `(server, kind, name)` triple is the identity.
117    #[serde(default)]
118    pub items: Vec<McpAllowlistItem>,
119}
120
121fn default_schema_version() -> u32 {
122    MCP_ALLOWLIST_SCHEMA_VERSION
123}
124
125fn default_true() -> bool {
126    true
127}
128
129impl Default for McpAllowlist {
130    fn default() -> Self {
131        Self {
132            schema_version: MCP_ALLOWLIST_SCHEMA_VERSION,
133            default_enabled: true,
134            items: Vec::new(),
135        }
136    }
137}
138
139impl McpAllowlist {
140    /// Parse an allowlist from a JSON string. Unknown fields are ignored so
141    /// older harn binaries can read documents written by newer clients.
142    pub fn from_json(json: &str) -> Result<Self, String> {
143        serde_json::from_str(json).map_err(|error| format!("invalid mcp allowlist JSON: {error}"))
144    }
145
146    /// Serialize to a stable, pretty-printed JSON document.
147    pub fn to_json(&self) -> String {
148        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
149    }
150
151    /// Resolve the enablement of one item: an explicit entry wins, else the
152    /// document default.
153    pub fn is_enabled(&self, server: &str, kind: McpItemKind, name: &str) -> bool {
154        self.items
155            .iter()
156            .find(|item| item.server == server && item.kind == kind && item.name == name)
157            .map(|item| item.enabled)
158            .unwrap_or(self.default_enabled)
159    }
160
161    /// Merge an overlay over `self` (the base), returning a new allowlist.
162    ///
163    /// - The overlay's `default_enabled` and `schema_version` win.
164    /// - Each overlay item replaces the base item with the same
165    ///   `(server, kind, name)` key; otherwise it is appended.
166    /// - Base items the overlay does not mention are preserved.
167    pub fn merge_overlay(&self, overlay: &McpAllowlist) -> McpAllowlist {
168        let mut merged: BTreeMap<(String, McpItemKind, String), McpAllowlistItem> = self
169            .items
170            .iter()
171            .map(|item| (item.key(), item.clone()))
172            .collect();
173        for item in &overlay.items {
174            merged.insert(item.key(), item.clone());
175        }
176        McpAllowlist {
177            schema_version: overlay.schema_version,
178            default_enabled: overlay.default_enabled,
179            items: merged.into_values().collect(),
180        }
181    }
182}
183
184/// One advertised item a server exposes, as seen by harn before the
185/// allowlist is applied. The caller assembles these from live
186/// `tools/list` / `resources/list` / `prompts/list` results.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct AdvertisedItem {
190    pub kind: McpItemKind,
191    /// Tool name, resource URI, or prompt name.
192    pub name: String,
193    /// Optional human-readable label/title for the client UI.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub title: Option<String>,
196    /// Optional one-line description for the client UI.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub description: Option<String>,
199}
200
201/// The params shape for the `mcp/catalog` ACP request. A thin client (or
202/// the burin host) supplies the persisted allowlist, an optional
203/// per-project overlay, and the live advertised items per server; harn
204/// merges + projects them into the effective catalog. Keeping the merge in
205/// harn means clients store no toggle state of their own.
206#[derive(Debug, Clone, Default, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct CatalogRequest {
209    /// Base allowlist (e.g. `~/.burin/mcp.json`). Absent → permissive
210    /// defaults.
211    #[serde(default)]
212    pub allowlist: Option<McpAllowlist>,
213    /// Optional per-project overlay merged over `allowlist`.
214    #[serde(default)]
215    pub overlay: Option<McpAllowlist>,
216    /// Live advertised items per server name.
217    #[serde(default)]
218    pub advertised: BTreeMap<String, Vec<AdvertisedItem>>,
219}
220
221/// Build the effective catalog from a [`CatalogRequest`]. Merges the
222/// optional overlay over the base allowlist (or permissive defaults) and
223/// projects the result onto the advertised items.
224pub fn catalog_for_request(request: &CatalogRequest) -> McpCatalog {
225    let base = request.allowlist.clone().unwrap_or_default();
226    let effective = match &request.overlay {
227        Some(overlay) => base.merge_overlay(overlay),
228        None => base,
229    };
230    build_catalog(&effective, &request.advertised)
231}
232
233/// One item in the effective catalog: an advertised item with its resolved
234/// `enabled` flag from the allowlist.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "camelCase")]
237pub struct McpCatalogItem {
238    pub kind: McpItemKind,
239    pub name: String,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub title: Option<String>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub description: Option<String>,
244    pub enabled: bool,
245}
246
247/// One server's slice of the effective catalog: its advertised items, each
248/// with its resolved enablement. Items are sorted by `(kind, name)` for
249/// stable diffs.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct McpCatalogServer {
253    pub name: String,
254    pub items: Vec<McpCatalogItem>,
255}
256
257/// The effective catalog: the stable JSON contract a thin client renders as
258/// a toggle UI. Servers are sorted by name.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct McpCatalog {
262    pub schema_version: u32,
263    /// Effective document default applied to items with no explicit entry.
264    pub default_enabled: bool,
265    pub servers: Vec<McpCatalogServer>,
266}
267
268/// Project `allowlist` onto the per-server advertised items to build the
269/// effective catalog. Servers and items are sorted for stable output.
270pub fn build_catalog(
271    allowlist: &McpAllowlist,
272    advertised: &BTreeMap<String, Vec<AdvertisedItem>>,
273) -> McpCatalog {
274    let mut servers = Vec::with_capacity(advertised.len());
275    for (server_name, items) in advertised {
276        let mut catalog_items: Vec<McpCatalogItem> = items
277            .iter()
278            .map(|item| McpCatalogItem {
279                kind: item.kind,
280                name: item.name.clone(),
281                title: item.title.clone(),
282                description: item.description.clone(),
283                enabled: allowlist.is_enabled(server_name, item.kind, &item.name),
284            })
285            .collect();
286        catalog_items
287            .sort_by(|left, right| (left.kind, &left.name).cmp(&(right.kind, &right.name)));
288        servers.push(McpCatalogServer {
289            name: server_name.clone(),
290            items: catalog_items,
291        });
292    }
293    servers.sort_by(|left, right| left.name.cmp(&right.name));
294    McpCatalog {
295        schema_version: MCP_ALLOWLIST_SCHEMA_VERSION,
296        default_enabled: allowlist.default_enabled,
297        servers,
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn item(server: &str, kind: McpItemKind, name: &str, enabled: bool) -> McpAllowlistItem {
306        McpAllowlistItem {
307            server: server.to_string(),
308            kind,
309            name: name.to_string(),
310            enabled,
311        }
312    }
313
314    #[test]
315    fn default_is_permissive() {
316        let allowlist = McpAllowlist::default();
317        assert_eq!(allowlist.schema_version, MCP_ALLOWLIST_SCHEMA_VERSION);
318        assert!(allowlist.default_enabled);
319        assert!(allowlist.items.is_empty());
320        // Unlisted items follow the default.
321        assert!(allowlist.is_enabled("github", McpItemKind::Tool, "anything"));
322    }
323
324    #[test]
325    fn explicit_item_overrides_default() {
326        let allowlist = McpAllowlist {
327            schema_version: 1,
328            default_enabled: true,
329            items: vec![item("github", McpItemKind::Tool, "create_issue", false)],
330        };
331        assert!(!allowlist.is_enabled("github", McpItemKind::Tool, "create_issue"));
332        // A different name still follows the default.
333        assert!(allowlist.is_enabled("github", McpItemKind::Tool, "list_issues"));
334        // Same name, different kind is a different item.
335        assert!(allowlist.is_enabled("github", McpItemKind::Resource, "create_issue"));
336    }
337
338    #[test]
339    fn deny_by_default_requires_explicit_enable() {
340        let allowlist = McpAllowlist {
341            schema_version: 1,
342            default_enabled: false,
343            items: vec![item("notion", McpItemKind::Prompt, "summarize", true)],
344        };
345        assert!(allowlist.is_enabled("notion", McpItemKind::Prompt, "summarize"));
346        assert!(!allowlist.is_enabled("notion", McpItemKind::Prompt, "other"));
347    }
348
349    #[test]
350    fn roundtrips_through_json() {
351        let allowlist = McpAllowlist {
352            schema_version: 1,
353            default_enabled: false,
354            items: vec![
355                item("github", McpItemKind::Tool, "create_issue", false),
356                item("notion", McpItemKind::Resource, "page://root", true),
357            ],
358        };
359        let json = allowlist.to_json();
360        let parsed = McpAllowlist::from_json(&json).expect("parse");
361        assert_eq!(parsed, allowlist);
362    }
363
364    #[test]
365    fn parses_minimal_document_with_defaults() {
366        // No defaultEnabled / schemaVersion → serde defaults apply.
367        let parsed = McpAllowlist::from_json("{}").expect("parse");
368        assert_eq!(parsed.schema_version, MCP_ALLOWLIST_SCHEMA_VERSION);
369        assert!(parsed.default_enabled);
370        assert!(parsed.items.is_empty());
371    }
372
373    #[test]
374    fn ignores_unknown_fields() {
375        // Forward-compat: a newer client may add fields harn does not know.
376        let json = r#"{ "schemaVersion": 1, "futureField": 42, "items": [] }"#;
377        let parsed = McpAllowlist::from_json(json).expect("parse");
378        assert!(parsed.items.is_empty());
379    }
380
381    #[test]
382    fn overlay_replaces_matching_items_and_wins_default() {
383        let base = McpAllowlist {
384            schema_version: 1,
385            default_enabled: true,
386            items: vec![
387                item("github", McpItemKind::Tool, "create_issue", true),
388                item("github", McpItemKind::Tool, "delete_repo", false),
389            ],
390        };
391        let overlay = McpAllowlist {
392            schema_version: 1,
393            default_enabled: false,
394            // Re-enable delete_repo for this project, leave create_issue.
395            items: vec![item("github", McpItemKind::Tool, "delete_repo", true)],
396        };
397        let merged = base.merge_overlay(&overlay);
398        // Overlay default wins.
399        assert!(!merged.default_enabled);
400        // Overlay item replaces the base decision.
401        assert!(merged.is_enabled("github", McpItemKind::Tool, "delete_repo"));
402        // Base item the overlay didn't mention is preserved.
403        assert!(merged.is_enabled("github", McpItemKind::Tool, "create_issue"));
404        assert_eq!(merged.items.len(), 2);
405    }
406
407    fn advertised() -> BTreeMap<String, Vec<AdvertisedItem>> {
408        let mut map = BTreeMap::new();
409        map.insert(
410            "notion".to_string(),
411            vec![AdvertisedItem {
412                kind: McpItemKind::Prompt,
413                name: "summarize".to_string(),
414                title: Some("Summarize".to_string()),
415                description: None,
416            }],
417        );
418        map.insert(
419            "github".to_string(),
420            vec![
421                AdvertisedItem {
422                    kind: McpItemKind::Tool,
423                    name: "list_issues".to_string(),
424                    title: None,
425                    description: Some("List issues".to_string()),
426                },
427                AdvertisedItem {
428                    kind: McpItemKind::Tool,
429                    name: "create_issue".to_string(),
430                    title: None,
431                    description: None,
432                },
433            ],
434        );
435        map
436    }
437
438    #[test]
439    fn catalog_applies_allowlist_and_sorts() {
440        let allowlist = McpAllowlist {
441            schema_version: 1,
442            default_enabled: true,
443            items: vec![item("github", McpItemKind::Tool, "create_issue", false)],
444        };
445        let catalog = build_catalog(&allowlist, &advertised());
446        assert_eq!(catalog.schema_version, MCP_ALLOWLIST_SCHEMA_VERSION);
447        assert!(catalog.default_enabled);
448        // Servers sorted by name: github before notion.
449        assert_eq!(catalog.servers[0].name, "github");
450        assert_eq!(catalog.servers[1].name, "notion");
451        // github items sorted by (kind, name): create_issue before list_issues.
452        let github = &catalog.servers[0];
453        assert_eq!(github.items[0].name, "create_issue");
454        assert!(!github.items[0].enabled);
455        assert_eq!(github.items[1].name, "list_issues");
456        assert!(github.items[1].enabled);
457        // notion prompt follows default (enabled).
458        assert!(catalog.servers[1].items[0].enabled);
459    }
460
461    #[test]
462    fn catalog_for_request_merges_overlay_then_projects() {
463        let json = serde_json::json!({
464            "allowlist": { "schemaVersion": 1, "defaultEnabled": true, "items": [
465                { "server": "github", "kind": "tool", "name": "create_issue", "enabled": false }
466            ] },
467            "overlay": { "schemaVersion": 1, "defaultEnabled": true, "items": [
468                { "server": "github", "kind": "tool", "name": "create_issue", "enabled": true }
469            ] },
470            "advertised": {
471                "github": [ { "kind": "tool", "name": "create_issue" } ]
472            }
473        });
474        let request: CatalogRequest = serde_json::from_value(json).expect("parse request");
475        let catalog = catalog_for_request(&request);
476        // Overlay re-enabled create_issue.
477        assert!(catalog.servers[0].items[0].enabled);
478    }
479
480    #[test]
481    fn catalog_for_request_uses_permissive_defaults_when_absent() {
482        let request = CatalogRequest {
483            allowlist: None,
484            overlay: None,
485            advertised: advertised(),
486        };
487        let catalog = catalog_for_request(&request);
488        assert!(catalog.default_enabled);
489        // All items enabled under the permissive default.
490        for server in &catalog.servers {
491            for item in &server.items {
492                assert!(item.enabled);
493            }
494        }
495    }
496
497    #[test]
498    fn catalog_json_shape_is_stable() {
499        let allowlist = McpAllowlist {
500            schema_version: 1,
501            default_enabled: true,
502            items: vec![item("github", McpItemKind::Tool, "create_issue", false)],
503        };
504        let catalog = build_catalog(&allowlist, &advertised());
505        let value = serde_json::to_value(&catalog).expect("serialize");
506        assert_eq!(value["schemaVersion"], serde_json::json!(1));
507        assert_eq!(value["defaultEnabled"], serde_json::json!(true));
508        let github = &value["servers"][0];
509        assert_eq!(github["name"], serde_json::json!("github"));
510        let create_issue = &github["items"][0];
511        assert_eq!(create_issue["kind"], serde_json::json!("tool"));
512        assert_eq!(create_issue["name"], serde_json::json!("create_issue"));
513        assert_eq!(create_issue["enabled"], serde_json::json!(false));
514        // Optional fields omitted when absent.
515        assert!(create_issue.get("title").is_none());
516    }
517}