1use std::collections::BTreeMap;
27use std::sync::OnceLock;
28
29use serde::{Deserialize, Serialize};
30
31pub const PRESET_CATALOG_SCHEMA_VERSION: u32 = 3;
36
37const BUILTIN_TOML: &str = include_str!("mcp_presets.toml");
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum PresetTransport {
46 Stdio,
48 Http,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum PresetAuthKind {
57 None,
59 Oauth,
61 ApiToken,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum PresetCategory {
69 Productivity,
70 Development,
71 Design,
72 Finance,
73 Cloud,
74 Local,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
83pub struct PresetPlaceholder {
84 pub key: String,
86 pub label: String,
88 pub target: PlaceholderTarget,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub token: Option<String>,
94 pub required: bool,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum PlaceholderTarget {
102 Env,
104 Arg,
106 Url,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
117pub struct IdentityProbeDescriptor {
118 #[serde(default)]
121 pub resolution: IdentityResolutionKind,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub confidence: Option<IdentityDescriptorConfidence>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub source_url: Option<String>,
128 #[serde(default)]
132 pub display_template: String,
133 #[serde(default)]
136 pub sources: Vec<IdentityProbeSource>,
137}
138
139#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum IdentityResolutionKind {
143 #[default]
145 User,
146 Account,
148 None,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum IdentityDescriptorConfidence {
157 Documented,
159 Observed,
162 AccountOnly,
164 None,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
173pub struct IdentityProbeSource {
174 pub kind: IdentityProbeKind,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub tool: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub url: Option<String>,
182 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
185 pub fields: BTreeMap<String, String>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum IdentityProbeKind {
192 TokenResponse,
195 Tool,
197 Http,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
206pub struct McpPreset {
207 pub id: String,
209 pub name: String,
211 pub description: String,
213 pub icon: String,
216 pub category: PresetCategory,
218 pub transport: PresetTransport,
220 #[serde(default)]
222 pub command: String,
223 #[serde(default)]
225 pub args: Vec<String>,
226 #[serde(default)]
228 pub url: String,
229 pub auth_kind: PresetAuthKind,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub oauth_scopes: Option<String>,
234 #[serde(default)]
236 pub placeholders: Vec<PresetPlaceholder>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub identity: Option<IdentityProbeDescriptor>,
240}
241
242#[derive(Debug, Clone, Serialize)]
244pub struct PresetCatalog {
245 #[serde(rename = "schemaVersion")]
246 pub schema_version: u32,
247 pub presets: Vec<McpPreset>,
248}
249
250#[derive(Debug, Default, Deserialize)]
252struct PresetFile {
253 #[serde(default)]
254 presets: Vec<McpPreset>,
255}
256
257static CATALOG: OnceLock<PresetCatalog> = OnceLock::new();
259
260fn load() -> &'static PresetCatalog {
261 CATALOG.get_or_init(build_catalog)
262}
263
264fn build_catalog() -> PresetCatalog {
265 let mut presets = parse_presets(BUILTIN_TOML)
266 .expect("embedded mcp_presets.toml must parse — invariant checked by tests");
267 if let Some(overlay) = load_overlay() {
268 merge_presets(&mut presets, overlay);
269 }
270 PresetCatalog {
271 schema_version: PRESET_CATALOG_SCHEMA_VERSION,
272 presets,
273 }
274}
275
276fn parse_presets(src: &str) -> Result<Vec<McpPreset>, toml::de::Error> {
278 Ok(toml::from_str::<PresetFile>(src)?.presets)
279}
280
281fn load_overlay() -> Option<Vec<McpPreset>> {
285 if let Ok(path) = std::env::var("HARN_MCP_PRESETS_CONFIG") {
286 return read_overlay(&path);
287 }
288 if should_load_home_overlay() {
289 let home = crate::user_dirs::home_dir()?;
290 let path = home.join(".config").join("harn").join("mcp_presets.toml");
291 return read_overlay(&path.to_string_lossy());
292 }
293 None
294}
295
296fn read_overlay(path: &str) -> Option<Vec<McpPreset>> {
297 let content = std::fs::read_to_string(path).ok()?;
298 match parse_presets(&content) {
299 Ok(presets) => Some(presets),
300 Err(error) => {
301 eprintln!("[mcp_presets] TOML parse error in {path}: {error}");
302 None
303 }
304 }
305}
306
307fn should_load_home_overlay() -> bool {
308 !cfg!(test)
309}
310
311fn merge_presets(base: &mut Vec<McpPreset>, overlay: Vec<McpPreset>) {
314 for preset in overlay {
315 if let Some(existing) = base.iter_mut().find(|existing| existing.id == preset.id) {
316 *existing = preset;
317 } else {
318 base.push(preset);
319 }
320 }
321}
322
323pub fn presets() -> &'static [McpPreset] {
325 load().presets.as_slice()
326}
327
328pub fn preset(id: &str) -> Option<&'static McpPreset> {
330 load().presets.iter().find(|preset| preset.id == id)
331}
332
333pub fn catalog() -> PresetCatalog {
335 load().clone()
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::collections::HashSet;
342
343 fn base_presets() -> Vec<McpPreset> {
344 parse_presets(BUILTIN_TOML).expect("bundled catalog parses")
345 }
346
347 #[test]
348 fn bundled_catalog_parses() {
349 let presets = base_presets();
350 assert_eq!(presets.len(), 9, "bundled catalog should ship 9 presets");
351 }
352
353 #[test]
354 fn catalog_carries_schema_version() {
355 let catalog = catalog();
356 assert_eq!(catalog.schema_version, PRESET_CATALOG_SCHEMA_VERSION);
357 assert_eq!(catalog.presets.len(), presets().len());
358 }
359
360 #[test]
361 fn preset_ids_are_unique() {
362 let presets = base_presets();
363 let ids: HashSet<&str> = presets.iter().map(|preset| preset.id.as_str()).collect();
364 assert_eq!(ids.len(), presets.len(), "preset ids must be unique");
365 }
366
367 #[test]
368 fn ships_the_well_known_servers() {
369 for id in [
370 "notion",
371 "linear",
372 "github",
373 "sentry",
374 "figma",
375 "atlassian",
376 "stripe",
377 "cloudflare",
378 "filesystem",
379 ] {
380 assert!(preset(id).is_some(), "missing preset {id}");
381 }
382 }
383
384 #[test]
385 fn transport_specific_fields_are_coherent() {
386 for preset in base_presets() {
387 match preset.transport {
388 PresetTransport::Http => {
389 assert!(!preset.url.is_empty(), "{} http needs a url", preset.id);
390 assert!(
391 preset.command.is_empty(),
392 "{} http must not set a command",
393 preset.id
394 );
395 }
396 PresetTransport::Stdio => {
397 assert!(
398 !preset.command.is_empty(),
399 "{} stdio needs a command",
400 preset.id
401 );
402 assert!(
403 preset.url.is_empty(),
404 "{} stdio must not set a url",
405 preset.id
406 );
407 }
408 }
409 }
410 }
411
412 #[test]
413 fn oauth_scopes_only_on_oauth_presets() {
414 for preset in base_presets() {
415 if preset.oauth_scopes.is_some() {
416 assert_eq!(
417 preset.auth_kind,
418 PresetAuthKind::Oauth,
419 "{} declares scopes but is not oauth",
420 preset.id
421 );
422 }
423 }
424 }
425
426 #[test]
427 fn json_shape_is_stable() {
428 let json = serde_json::to_value(catalog()).expect("serialize catalog");
429 assert_eq!(json["schemaVersion"], serde_json::json!(3));
430 let notion = json["presets"]
431 .as_array()
432 .expect("presets array")
433 .iter()
434 .find(|preset| preset["id"] == serde_json::json!("notion"))
435 .expect("notion preset present");
436 assert_eq!(notion["transport"], serde_json::json!("http"));
437 assert_eq!(notion["authKind"], serde_json::json!("oauth"));
438 assert_eq!(
439 notion["url"],
440 serde_json::json!("https://mcp.notion.com/mcp")
441 );
442 assert!(
443 notion.get("oauthScopes").is_none(),
444 "Notion MCP does not currently expose configurable OAuth scopes"
445 );
446 assert_eq!(notion["identity"]["resolution"], serde_json::json!("user"));
448 assert_eq!(
449 notion["identity"]["confidence"],
450 serde_json::json!("documented")
451 );
452 assert_eq!(
453 notion["identity"]["sourceUrl"],
454 serde_json::json!("https://developers.notion.com/reference/create-a-token")
455 );
456 assert_eq!(
457 notion["identity"]["displayTemplate"],
458 serde_json::json!("{name} <{email}> — {workspace}")
459 );
460 assert_eq!(
461 notion["identity"]["sources"][0]["kind"],
462 serde_json::json!("token_response")
463 );
464 }
465
466 #[test]
467 fn vetted_identity_descriptors_are_declared_for_well_known_servers() {
468 let presets = base_presets();
469 let expected = [
470 ("notion", IdentityResolutionKind::User),
471 ("linear", IdentityResolutionKind::User),
472 ("github", IdentityResolutionKind::User),
473 ("sentry", IdentityResolutionKind::User),
474 ("figma", IdentityResolutionKind::User),
475 ("atlassian", IdentityResolutionKind::User),
476 ("stripe", IdentityResolutionKind::Account),
477 ("cloudflare", IdentityResolutionKind::Account),
478 ];
479
480 for (id, resolution) in expected {
481 let preset = presets
482 .iter()
483 .find(|preset| preset.id == id)
484 .unwrap_or_else(|| panic!("missing preset {id}"));
485 let identity = preset
486 .identity
487 .as_ref()
488 .unwrap_or_else(|| panic!("{id} must declare an identity descriptor"));
489 assert_eq!(
490 identity.resolution, resolution,
491 "{id} identity resolution drifted"
492 );
493 assert!(
494 identity.confidence.is_some(),
495 "{id} identity must declare confidence"
496 );
497 assert!(
498 identity
499 .source_url
500 .as_deref()
501 .is_some_and(|url| url.starts_with("https://")),
502 "{id} identity must cite an https source"
503 );
504 assert!(
505 !identity.display_template.trim().is_empty(),
506 "{id} identity needs a display template"
507 );
508 assert!(
509 !identity.sources.is_empty(),
510 "{id} identity needs at least one source"
511 );
512 }
513 }
514
515 #[test]
516 fn identity_source_shapes_are_coherent() {
517 for preset in base_presets() {
518 let Some(identity) = preset.identity.as_ref() else {
519 continue;
520 };
521 if identity.resolution == IdentityResolutionKind::None {
522 assert!(
523 identity.sources.is_empty(),
524 "{} none identity must not carry sources",
525 preset.id
526 );
527 assert!(
528 identity.display_template.trim().is_empty(),
529 "{} none identity must not carry a display template",
530 preset.id
531 );
532 continue;
533 }
534
535 assert!(
536 !identity.display_template.trim().is_empty(),
537 "{} identity must render a display template",
538 preset.id
539 );
540 for source in &identity.sources {
541 assert!(
542 !source.fields.is_empty(),
543 "{} identity source {:?} must map fields",
544 preset.id,
545 source.kind
546 );
547 for (name, path) in &source.fields {
548 assert!(
549 !name.trim().is_empty() && !path.trim().is_empty(),
550 "{} identity source fields must not be blank",
551 preset.id
552 );
553 }
554 match source.kind {
555 IdentityProbeKind::TokenResponse => {
556 assert!(
557 source.tool.is_none(),
558 "{} token_response source must not set tool",
559 preset.id
560 );
561 assert!(
562 source.url.is_none(),
563 "{} token_response source must not set url",
564 preset.id
565 );
566 }
567 IdentityProbeKind::Tool => {
568 assert!(
569 source
570 .tool
571 .as_deref()
572 .is_some_and(|tool| !tool.trim().is_empty()),
573 "{} tool source must name a tool",
574 preset.id
575 );
576 assert!(
577 source.url.is_none(),
578 "{} tool source must not set url",
579 preset.id
580 );
581 }
582 IdentityProbeKind::Http => {
583 assert!(
584 source
585 .url
586 .as_deref()
587 .is_some_and(|url| url.starts_with("https://")),
588 "{} http source must cite an https url",
589 preset.id
590 );
591 assert!(
592 source.tool.is_none(),
593 "{} http source must not set tool",
594 preset.id
595 );
596 }
597 }
598 }
599 }
600 }
601
602 #[test]
603 fn github_placeholder_round_trips_from_toml() {
604 let github = base_presets()
605 .into_iter()
606 .find(|preset| preset.id == "github")
607 .expect("github preset present");
608 assert_eq!(github.placeholders.len(), 1);
609 let placeholder = &github.placeholders[0];
610 assert_eq!(placeholder.key, "GITHUB_PERSONAL_ACCESS_TOKEN");
611 assert_eq!(placeholder.target, PlaceholderTarget::Env);
612 assert!(placeholder.required);
613 assert!(placeholder.token.is_none());
614 }
615
616 #[test]
617 fn overlay_overrides_by_id_and_appends_new() {
618 let mut base = base_presets();
619 let overlay = parse_presets(
620 r#"
621[[presets]]
622id = "notion"
623name = "Notion (corp)"
624description = "Corp Notion workspace."
625icon = "doc.text.fill"
626category = "productivity"
627transport = "http"
628url = "https://notion.corp.example/mcp"
629auth_kind = "oauth"
630
631[[presets]]
632id = "sentry"
633name = "Sentry"
634description = "Errors and issues from Sentry."
635icon = "exclamationmark.triangle.fill"
636category = "development"
637transport = "http"
638url = "https://mcp.sentry.dev/mcp"
639auth_kind = "oauth"
640"#,
641 )
642 .expect("overlay parses");
643 merge_presets(&mut base, overlay);
644
645 let notion = base.iter().find(|preset| preset.id == "notion").unwrap();
646 assert_eq!(notion.name, "Notion (corp)");
647 assert_eq!(notion.url, "https://notion.corp.example/mcp");
648 assert!(
649 base.iter().any(|preset| preset.id == "sentry"),
650 "existing sentry preset should remain present after replacement"
651 );
652 assert_eq!(base.len(), 9, "sentry overlay replaces bundled preset");
653 }
654
655 #[test]
656 fn identity_descriptor_parses_from_toml() {
657 let presets = parse_presets(
658 r#"
659[[presets]]
660id = "notion"
661name = "Notion"
662description = "Notion workspace."
663icon = "doc.text.fill"
664category = "productivity"
665transport = "http"
666url = "https://mcp.notion.com/mcp"
667auth_kind = "oauth"
668
669[presets.identity]
670display_template = "{name} <{email}> — {workspace}"
671
672[[presets.identity.sources]]
673kind = "token_response"
674[presets.identity.sources.fields]
675name = "owner.user.name"
676email = "owner.user.person.email"
677workspace = "workspace_name"
678
679[[presets.identity.sources]]
680kind = "tool"
681tool = "notion-get-self"
682[presets.identity.sources.fields]
683name = "name"
684email = "person.email"
685"#,
686 )
687 .expect("identity descriptor parses");
688 let identity = presets[0]
689 .identity
690 .as_ref()
691 .expect("notion has identity descriptor");
692 assert_eq!(identity.resolution, IdentityResolutionKind::User);
693 assert!(identity.confidence.is_none());
694 assert!(identity.source_url.is_none());
695 assert_eq!(identity.display_template, "{name} <{email}> — {workspace}");
696 assert_eq!(identity.sources.len(), 2);
697 assert_eq!(identity.sources[0].kind, IdentityProbeKind::TokenResponse);
698 assert_eq!(
699 identity.sources[0]
700 .fields
701 .get("workspace")
702 .map(String::as_str),
703 Some("workspace_name")
704 );
705 assert_eq!(identity.sources[1].kind, IdentityProbeKind::Tool);
706 assert_eq!(identity.sources[1].tool.as_deref(), Some("notion-get-self"));
707
708 let json = serde_json::to_value(&presets[0]).expect("serialize");
710 assert_eq!(
711 json["identity"]["displayTemplate"],
712 serde_json::json!("{name} <{email}> — {workspace}")
713 );
714 }
715}