use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const MCP_ALLOWLIST_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum McpItemKind {
Tool,
Resource,
Prompt,
}
impl McpItemKind {
pub fn as_str(self) -> &'static str {
match self {
McpItemKind::Tool => "tool",
McpItemKind::Resource => "resource",
McpItemKind::Prompt => "prompt",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpAllowlistItem {
pub server: String,
pub kind: McpItemKind,
pub name: String,
pub enabled: bool,
}
impl McpAllowlistItem {
fn key(&self) -> (String, McpItemKind, String) {
(self.server.clone(), self.kind, self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpAllowlist {
#[serde(default = "default_schema_version")]
pub schema_version: u32,
#[serde(default = "default_true")]
pub default_enabled: bool,
#[serde(default)]
pub items: Vec<McpAllowlistItem>,
}
fn default_schema_version() -> u32 {
MCP_ALLOWLIST_SCHEMA_VERSION
}
fn default_true() -> bool {
true
}
impl Default for McpAllowlist {
fn default() -> Self {
Self {
schema_version: MCP_ALLOWLIST_SCHEMA_VERSION,
default_enabled: true,
items: Vec::new(),
}
}
}
impl McpAllowlist {
pub fn from_json(json: &str) -> Result<Self, String> {
serde_json::from_str(json).map_err(|error| format!("invalid mcp allowlist JSON: {error}"))
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
}
pub fn is_enabled(&self, server: &str, kind: McpItemKind, name: &str) -> bool {
self.items
.iter()
.find(|item| item.server == server && item.kind == kind && item.name == name)
.map(|item| item.enabled)
.unwrap_or(self.default_enabled)
}
pub fn merge_overlay(&self, overlay: &McpAllowlist) -> McpAllowlist {
let mut merged: BTreeMap<(String, McpItemKind, String), McpAllowlistItem> = self
.items
.iter()
.map(|item| (item.key(), item.clone()))
.collect();
for item in &overlay.items {
merged.insert(item.key(), item.clone());
}
McpAllowlist {
schema_version: overlay.schema_version,
default_enabled: overlay.default_enabled,
items: merged.into_values().collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdvertisedItem {
pub kind: McpItemKind,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogRequest {
#[serde(default)]
pub allowlist: Option<McpAllowlist>,
#[serde(default)]
pub overlay: Option<McpAllowlist>,
#[serde(default)]
pub advertised: BTreeMap<String, Vec<AdvertisedItem>>,
}
pub fn catalog_for_request(request: &CatalogRequest) -> McpCatalog {
let base = request.allowlist.clone().unwrap_or_default();
let effective = match &request.overlay {
Some(overlay) => base.merge_overlay(overlay),
None => base,
};
build_catalog(&effective, &request.advertised)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCatalogItem {
pub kind: McpItemKind,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCatalogServer {
pub name: String,
pub items: Vec<McpCatalogItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpCatalog {
pub schema_version: u32,
pub default_enabled: bool,
pub servers: Vec<McpCatalogServer>,
}
pub fn build_catalog(
allowlist: &McpAllowlist,
advertised: &BTreeMap<String, Vec<AdvertisedItem>>,
) -> McpCatalog {
let mut servers = Vec::with_capacity(advertised.len());
for (server_name, items) in advertised {
let mut catalog_items: Vec<McpCatalogItem> = items
.iter()
.map(|item| McpCatalogItem {
kind: item.kind,
name: item.name.clone(),
title: item.title.clone(),
description: item.description.clone(),
enabled: allowlist.is_enabled(server_name, item.kind, &item.name),
})
.collect();
catalog_items
.sort_by(|left, right| (left.kind, &left.name).cmp(&(right.kind, &right.name)));
servers.push(McpCatalogServer {
name: server_name.clone(),
items: catalog_items,
});
}
servers.sort_by(|left, right| left.name.cmp(&right.name));
McpCatalog {
schema_version: MCP_ALLOWLIST_SCHEMA_VERSION,
default_enabled: allowlist.default_enabled,
servers,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn item(server: &str, kind: McpItemKind, name: &str, enabled: bool) -> McpAllowlistItem {
McpAllowlistItem {
server: server.to_string(),
kind,
name: name.to_string(),
enabled,
}
}
#[test]
fn default_is_permissive() {
let allowlist = McpAllowlist::default();
assert_eq!(allowlist.schema_version, MCP_ALLOWLIST_SCHEMA_VERSION);
assert!(allowlist.default_enabled);
assert!(allowlist.items.is_empty());
assert!(allowlist.is_enabled("github", McpItemKind::Tool, "anything"));
}
#[test]
fn explicit_item_overrides_default() {
let allowlist = McpAllowlist {
schema_version: 1,
default_enabled: true,
items: vec![item("github", McpItemKind::Tool, "create_issue", false)],
};
assert!(!allowlist.is_enabled("github", McpItemKind::Tool, "create_issue"));
assert!(allowlist.is_enabled("github", McpItemKind::Tool, "list_issues"));
assert!(allowlist.is_enabled("github", McpItemKind::Resource, "create_issue"));
}
#[test]
fn deny_by_default_requires_explicit_enable() {
let allowlist = McpAllowlist {
schema_version: 1,
default_enabled: false,
items: vec![item("notion", McpItemKind::Prompt, "summarize", true)],
};
assert!(allowlist.is_enabled("notion", McpItemKind::Prompt, "summarize"));
assert!(!allowlist.is_enabled("notion", McpItemKind::Prompt, "other"));
}
#[test]
fn roundtrips_through_json() {
let allowlist = McpAllowlist {
schema_version: 1,
default_enabled: false,
items: vec![
item("github", McpItemKind::Tool, "create_issue", false),
item("notion", McpItemKind::Resource, "page://root", true),
],
};
let json = allowlist.to_json();
let parsed = McpAllowlist::from_json(&json).expect("parse");
assert_eq!(parsed, allowlist);
}
#[test]
fn parses_minimal_document_with_defaults() {
let parsed = McpAllowlist::from_json("{}").expect("parse");
assert_eq!(parsed.schema_version, MCP_ALLOWLIST_SCHEMA_VERSION);
assert!(parsed.default_enabled);
assert!(parsed.items.is_empty());
}
#[test]
fn ignores_unknown_fields() {
let json = r#"{ "schemaVersion": 1, "futureField": 42, "items": [] }"#;
let parsed = McpAllowlist::from_json(json).expect("parse");
assert!(parsed.items.is_empty());
}
#[test]
fn overlay_replaces_matching_items_and_wins_default() {
let base = McpAllowlist {
schema_version: 1,
default_enabled: true,
items: vec![
item("github", McpItemKind::Tool, "create_issue", true),
item("github", McpItemKind::Tool, "delete_repo", false),
],
};
let overlay = McpAllowlist {
schema_version: 1,
default_enabled: false,
items: vec![item("github", McpItemKind::Tool, "delete_repo", true)],
};
let merged = base.merge_overlay(&overlay);
assert!(!merged.default_enabled);
assert!(merged.is_enabled("github", McpItemKind::Tool, "delete_repo"));
assert!(merged.is_enabled("github", McpItemKind::Tool, "create_issue"));
assert_eq!(merged.items.len(), 2);
}
fn advertised() -> BTreeMap<String, Vec<AdvertisedItem>> {
let mut map = BTreeMap::new();
map.insert(
"notion".to_string(),
vec![AdvertisedItem {
kind: McpItemKind::Prompt,
name: "summarize".to_string(),
title: Some("Summarize".to_string()),
description: None,
}],
);
map.insert(
"github".to_string(),
vec![
AdvertisedItem {
kind: McpItemKind::Tool,
name: "list_issues".to_string(),
title: None,
description: Some("List issues".to_string()),
},
AdvertisedItem {
kind: McpItemKind::Tool,
name: "create_issue".to_string(),
title: None,
description: None,
},
],
);
map
}
#[test]
fn catalog_applies_allowlist_and_sorts() {
let allowlist = McpAllowlist {
schema_version: 1,
default_enabled: true,
items: vec![item("github", McpItemKind::Tool, "create_issue", false)],
};
let catalog = build_catalog(&allowlist, &advertised());
assert_eq!(catalog.schema_version, MCP_ALLOWLIST_SCHEMA_VERSION);
assert!(catalog.default_enabled);
assert_eq!(catalog.servers[0].name, "github");
assert_eq!(catalog.servers[1].name, "notion");
let github = &catalog.servers[0];
assert_eq!(github.items[0].name, "create_issue");
assert!(!github.items[0].enabled);
assert_eq!(github.items[1].name, "list_issues");
assert!(github.items[1].enabled);
assert!(catalog.servers[1].items[0].enabled);
}
#[test]
fn catalog_for_request_merges_overlay_then_projects() {
let json = serde_json::json!({
"allowlist": { "schemaVersion": 1, "defaultEnabled": true, "items": [
{ "server": "github", "kind": "tool", "name": "create_issue", "enabled": false }
] },
"overlay": { "schemaVersion": 1, "defaultEnabled": true, "items": [
{ "server": "github", "kind": "tool", "name": "create_issue", "enabled": true }
] },
"advertised": {
"github": [ { "kind": "tool", "name": "create_issue" } ]
}
});
let request: CatalogRequest = serde_json::from_value(json).expect("parse request");
let catalog = catalog_for_request(&request);
assert!(catalog.servers[0].items[0].enabled);
}
#[test]
fn catalog_for_request_uses_permissive_defaults_when_absent() {
let request = CatalogRequest {
allowlist: None,
overlay: None,
advertised: advertised(),
};
let catalog = catalog_for_request(&request);
assert!(catalog.default_enabled);
for server in &catalog.servers {
for item in &server.items {
assert!(item.enabled);
}
}
}
#[test]
fn catalog_json_shape_is_stable() {
let allowlist = McpAllowlist {
schema_version: 1,
default_enabled: true,
items: vec![item("github", McpItemKind::Tool, "create_issue", false)],
};
let catalog = build_catalog(&allowlist, &advertised());
let value = serde_json::to_value(&catalog).expect("serialize");
assert_eq!(value["schemaVersion"], serde_json::json!(1));
assert_eq!(value["defaultEnabled"], serde_json::json!(true));
let github = &value["servers"][0];
assert_eq!(github["name"], serde_json::json!("github"));
let create_issue = &github["items"][0];
assert_eq!(create_issue["kind"], serde_json::json!("tool"));
assert_eq!(create_issue["name"], serde_json::json!("create_issue"));
assert_eq!(create_issue["enabled"], serde_json::json!(false));
assert!(create_issue.get("title").is_none());
}
}