use std::collections::BTreeMap;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
pub const PRESET_CATALOG_SCHEMA_VERSION: u32 = 2;
const BUILTIN_TOML: &str = include_str!("mcp_presets.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PresetTransport {
Stdio,
Http,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PresetAuthKind {
None,
Oauth,
ApiToken,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PresetCategory {
Productivity,
Development,
Local,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
pub struct PresetPlaceholder {
pub key: String,
pub label: String,
pub target: PlaceholderTarget,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
pub required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlaceholderTarget {
Env,
Arg,
Url,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
pub struct IdentityProbeDescriptor {
pub display_template: String,
#[serde(default)]
pub sources: Vec<IdentityProbeSource>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
pub struct IdentityProbeSource {
pub kind: IdentityProbeKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fields: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IdentityProbeKind {
TokenResponse,
Tool,
Http,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
pub struct McpPreset {
pub id: String,
pub name: String,
pub description: String,
pub icon: String,
pub category: PresetCategory,
pub transport: PresetTransport,
#[serde(default)]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub url: String,
pub auth_kind: PresetAuthKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_scopes: Option<String>,
#[serde(default)]
pub placeholders: Vec<PresetPlaceholder>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<IdentityProbeDescriptor>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PresetCatalog {
#[serde(rename = "schemaVersion")]
pub schema_version: u32,
pub presets: Vec<McpPreset>,
}
#[derive(Debug, Default, Deserialize)]
struct PresetFile {
#[serde(default)]
presets: Vec<McpPreset>,
}
static CATALOG: OnceLock<PresetCatalog> = OnceLock::new();
fn load() -> &'static PresetCatalog {
CATALOG.get_or_init(build_catalog)
}
fn build_catalog() -> PresetCatalog {
let mut presets = parse_presets(BUILTIN_TOML)
.expect("embedded mcp_presets.toml must parse — invariant checked by tests");
if let Some(overlay) = load_overlay() {
merge_presets(&mut presets, overlay);
}
PresetCatalog {
schema_version: PRESET_CATALOG_SCHEMA_VERSION,
presets,
}
}
fn parse_presets(src: &str) -> Result<Vec<McpPreset>, toml::de::Error> {
Ok(toml::from_str::<PresetFile>(src)?.presets)
}
fn load_overlay() -> Option<Vec<McpPreset>> {
if let Ok(path) = std::env::var("HARN_MCP_PRESETS_CONFIG") {
return read_overlay(&path);
}
if should_load_home_overlay() {
let home = crate::user_dirs::home_dir()?;
let path = home.join(".config").join("harn").join("mcp_presets.toml");
return read_overlay(&path.to_string_lossy());
}
None
}
fn read_overlay(path: &str) -> Option<Vec<McpPreset>> {
let content = std::fs::read_to_string(path).ok()?;
match parse_presets(&content) {
Ok(presets) => Some(presets),
Err(error) => {
eprintln!("[mcp_presets] TOML parse error in {path}: {error}");
None
}
}
}
fn should_load_home_overlay() -> bool {
!cfg!(test)
}
fn merge_presets(base: &mut Vec<McpPreset>, overlay: Vec<McpPreset>) {
for preset in overlay {
if let Some(existing) = base.iter_mut().find(|existing| existing.id == preset.id) {
*existing = preset;
} else {
base.push(preset);
}
}
}
pub fn presets() -> &'static [McpPreset] {
load().presets.as_slice()
}
pub fn preset(id: &str) -> Option<&'static McpPreset> {
load().presets.iter().find(|preset| preset.id == id)
}
pub fn catalog() -> PresetCatalog {
load().clone()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn base_presets() -> Vec<McpPreset> {
parse_presets(BUILTIN_TOML).expect("bundled catalog parses")
}
#[test]
fn bundled_catalog_parses() {
let presets = base_presets();
assert_eq!(presets.len(), 4, "bundled catalog should ship 4 presets");
}
#[test]
fn catalog_carries_schema_version() {
let catalog = catalog();
assert_eq!(catalog.schema_version, PRESET_CATALOG_SCHEMA_VERSION);
assert_eq!(catalog.presets.len(), presets().len());
}
#[test]
fn preset_ids_are_unique() {
let presets = base_presets();
let ids: HashSet<&str> = presets.iter().map(|preset| preset.id.as_str()).collect();
assert_eq!(ids.len(), presets.len(), "preset ids must be unique");
}
#[test]
fn ships_the_well_known_servers() {
for id in ["notion", "linear", "github", "filesystem"] {
assert!(preset(id).is_some(), "missing preset {id}");
}
}
#[test]
fn transport_specific_fields_are_coherent() {
for preset in base_presets() {
match preset.transport {
PresetTransport::Http => {
assert!(!preset.url.is_empty(), "{} http needs a url", preset.id);
assert!(
preset.command.is_empty(),
"{} http must not set a command",
preset.id
);
}
PresetTransport::Stdio => {
assert!(
!preset.command.is_empty(),
"{} stdio needs a command",
preset.id
);
assert!(
preset.url.is_empty(),
"{} stdio must not set a url",
preset.id
);
}
}
}
}
#[test]
fn oauth_scopes_only_on_oauth_presets() {
for preset in base_presets() {
if preset.oauth_scopes.is_some() {
assert_eq!(
preset.auth_kind,
PresetAuthKind::Oauth,
"{} declares scopes but is not oauth",
preset.id
);
}
}
}
#[test]
fn json_shape_is_stable() {
let json = serde_json::to_value(catalog()).expect("serialize catalog");
assert_eq!(json["schemaVersion"], serde_json::json!(2));
let notion = json["presets"]
.as_array()
.expect("presets array")
.iter()
.find(|preset| preset["id"] == serde_json::json!("notion"))
.expect("notion preset present");
assert_eq!(notion["transport"], serde_json::json!("http"));
assert_eq!(notion["authKind"], serde_json::json!("oauth"));
assert_eq!(
notion["url"],
serde_json::json!("https://mcp.notion.com/mcp")
);
assert!(
notion.get("oauthScopes").is_none(),
"Notion MCP does not currently expose configurable OAuth scopes"
);
assert_eq!(
notion["identity"]["displayTemplate"],
serde_json::json!("{name} <{email}> — {workspace}")
);
assert_eq!(
notion["identity"]["sources"][0]["kind"],
serde_json::json!("token_response")
);
}
#[test]
fn github_placeholder_round_trips_from_toml() {
let github = base_presets()
.into_iter()
.find(|preset| preset.id == "github")
.expect("github preset present");
assert_eq!(github.placeholders.len(), 1);
let placeholder = &github.placeholders[0];
assert_eq!(placeholder.key, "GITHUB_PERSONAL_ACCESS_TOKEN");
assert_eq!(placeholder.target, PlaceholderTarget::Env);
assert!(placeholder.required);
assert!(placeholder.token.is_none());
}
#[test]
fn overlay_overrides_by_id_and_appends_new() {
let mut base = base_presets();
let overlay = parse_presets(
r#"
[[presets]]
id = "notion"
name = "Notion (corp)"
description = "Corp Notion workspace."
icon = "doc.text.fill"
category = "productivity"
transport = "http"
url = "https://notion.corp.example/mcp"
auth_kind = "oauth"
[[presets]]
id = "sentry"
name = "Sentry"
description = "Errors and issues from Sentry."
icon = "exclamationmark.triangle.fill"
category = "development"
transport = "http"
url = "https://mcp.sentry.dev/mcp"
auth_kind = "oauth"
"#,
)
.expect("overlay parses");
merge_presets(&mut base, overlay);
let notion = base.iter().find(|preset| preset.id == "notion").unwrap();
assert_eq!(notion.name, "Notion (corp)");
assert_eq!(notion.url, "https://notion.corp.example/mcp");
assert!(
base.iter().any(|preset| preset.id == "sentry"),
"new overlay preset should be appended"
);
assert_eq!(base.len(), 5, "4 base + 1 appended");
}
#[test]
fn identity_descriptor_parses_from_toml() {
let presets = parse_presets(
r#"
[[presets]]
id = "notion"
name = "Notion"
description = "Notion workspace."
icon = "doc.text.fill"
category = "productivity"
transport = "http"
url = "https://mcp.notion.com/mcp"
auth_kind = "oauth"
[presets.identity]
display_template = "{name} <{email}> — {workspace}"
[[presets.identity.sources]]
kind = "token_response"
[presets.identity.sources.fields]
name = "owner.user.name"
email = "owner.user.person.email"
workspace = "workspace_name"
[[presets.identity.sources]]
kind = "tool"
tool = "notion-get-self"
[presets.identity.sources.fields]
name = "name"
email = "person.email"
"#,
)
.expect("identity descriptor parses");
let identity = presets[0]
.identity
.as_ref()
.expect("notion has identity descriptor");
assert_eq!(identity.display_template, "{name} <{email}> — {workspace}");
assert_eq!(identity.sources.len(), 2);
assert_eq!(identity.sources[0].kind, IdentityProbeKind::TokenResponse);
assert_eq!(
identity.sources[0]
.fields
.get("workspace")
.map(String::as_str),
Some("workspace_name")
);
assert_eq!(identity.sources[1].kind, IdentityProbeKind::Tool);
assert_eq!(identity.sources[1].tool.as_deref(), Some("notion-get-self"));
let json = serde_json::to_value(&presets[0]).expect("serialize");
assert_eq!(
json["identity"]["displayTemplate"],
serde_json::json!("{name} <{email}> — {workspace}")
);
}
}