1use std::collections::BTreeMap;
48
49use serde::{Deserialize, Serialize};
50
51pub const MCP_ALLOWLIST_SCHEMA_VERSION: u32 = 1;
54
55#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct McpAllowlistItem {
81 pub server: String,
83 pub kind: McpItemKind,
85 pub name: String,
88 pub enabled: bool,
90}
91
92impl McpAllowlistItem {
93 fn key(&self) -> (String, McpItemKind, String) {
95 (self.server.clone(), self.kind, self.name.clone())
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct McpAllowlist {
106 #[serde(default = "default_schema_version")]
108 pub schema_version: u32,
109 #[serde(default = "default_true")]
114 pub default_enabled: bool,
115 #[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 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 pub fn to_json(&self) -> String {
148 serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
149 }
150
151 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct AdvertisedItem {
190 pub kind: McpItemKind,
191 pub name: String,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub title: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub description: Option<String>,
199}
200
201#[derive(Debug, Clone, Default, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct CatalogRequest {
209 #[serde(default)]
212 pub allowlist: Option<McpAllowlist>,
213 #[serde(default)]
215 pub overlay: Option<McpAllowlist>,
216 #[serde(default)]
218 pub advertised: BTreeMap<String, Vec<AdvertisedItem>>,
219}
220
221pub 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct McpCatalog {
262 pub schema_version: u32,
263 pub default_enabled: bool,
265 pub servers: Vec<McpCatalogServer>,
266}
267
268pub 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 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 assert!(allowlist.is_enabled("github", McpItemKind::Tool, "list_issues"));
334 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 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 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 items: vec![item("github", McpItemKind::Tool, "delete_repo", true)],
396 };
397 let merged = base.merge_overlay(&overlay);
398 assert!(!merged.default_enabled);
400 assert!(merged.is_enabled("github", McpItemKind::Tool, "delete_repo"));
402 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 assert_eq!(catalog.servers[0].name, "github");
450 assert_eq!(catalog.servers[1].name, "notion");
451 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 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 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 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 assert!(create_issue.get("title").is_none());
516 }
517}