Skip to main content

harn_vm/
mcp_presets.rs

1//! Canonical catalog of well-known MCP server presets (harn#2650).
2//!
3//! Thin clients (an IDE host's TUI and the macOS GUI) read this shared
4//! harn-owned source of truth for "one-click" MCP servers — Notion, Linear,
5//! GitHub, a local filesystem server, etc. Keep client lists derived from this
6//! catalog so presets do not drift across surfaces.
7//!
8//! **Data, not code (harn#3348).** The catalog ships as bundled TOML
9//! (`mcp_presets.toml`, compiled in via `include_str!`) and is overlayable at
10//! runtime without a recompile: set `HARN_MCP_PRESETS_CONFIG` to a TOML file,
11//! or drop one at `~/.config/harn/mcp_presets.toml`. Overlays merge
12//! last-writer-wins by `id`, then append new presets — mirroring how
13//! `llm_config` layers `providers.toml`. On-disk fields are snake_case; the
14//! serialized JSON contract (see [`PresetCatalog`]) stays camelCase so existing
15//! consumers are byte-for-byte unaffected.
16//!
17//! The catalog is **descriptive metadata only** — it never connects to a
18//! server or fabricates credentials. A preset is a template a client fills in
19//! (allowed roots for filesystem, an OAuth login for Notion) before handing the
20//! resolved spec to the MCP registry. Required substitutions are declared as
21//! [`PresetPlaceholder`]s so a client can prompt for them.
22//!
23//! Bumping the serialized shape requires bumping [`PRESET_CATALOG_SCHEMA_VERSION`]
24//! and coordinating consumers.
25
26use std::collections::BTreeMap;
27use std::sync::OnceLock;
28
29use serde::{Deserialize, Serialize};
30
31/// JSON schema version for the preset catalog. Increment on any breaking
32/// shape change to [`PresetCatalog`] / [`McpPreset`]. Bumped to 3 in harn#3351
33/// when identity descriptors gained explicit resolution/confidence/source
34/// metadata and the catalog expanded to vetted remote MCP servers.
35pub const PRESET_CATALOG_SCHEMA_VERSION: u32 = 3;
36
37/// Bundled default catalog. Editable here; overlayable at runtime.
38const BUILTIN_TOML: &str = include_str!("mcp_presets.toml");
39
40/// Transport a preset's server speaks. Mirrors the `transport` field of
41/// [`crate::mcp::McpServerSpec`] so a resolved preset drops straight into a
42/// server spec.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum PresetTransport {
46    /// Local subprocess speaking MCP over stdio.
47    Stdio,
48    /// Remote streamable-HTTP MCP endpoint.
49    Http,
50}
51
52/// Hint about how a client authenticates to the server, so the UI can route
53/// to the right setup affordance. Purely advisory — harn does not enforce it.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum PresetAuthKind {
57    /// No credential needed (e.g. a local filesystem server).
58    None,
59    /// Interactive OAuth login (`harn mcp login`).
60    Oauth,
61    /// A static API token / personal access token supplied via env.
62    ApiToken,
63}
64
65/// Loose grouping for client-side organization. Advisory; clients may ignore.
66#[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/// One value a client must collect before the preset can connect. The
78/// `target` says where the resolved value goes (an env var, a CLI arg slot,
79/// or the URL), and `token` is the literal token embedded in the template that
80/// the client replaces.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
83pub struct PresetPlaceholder {
84    /// Stable identifier for the value (e.g. `"allowed_root"`).
85    pub key: String,
86    /// Human-readable label for a prompt (e.g. `"Allowed directory"`).
87    pub label: String,
88    /// Where the resolved value belongs.
89    pub target: PlaceholderTarget,
90    /// The literal token in the template to substitute, if any. `None` means
91    /// the value is appended (e.g. a filesystem allowed-root positional arg).
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub token: Option<String>,
94    /// Whether the preset cannot connect without this value.
95    pub required: bool,
96}
97
98/// Where a [`PresetPlaceholder`] value is substituted.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum PlaceholderTarget {
102    /// An environment variable named by the placeholder `key`.
103    Env,
104    /// A positional CLI argument (appended to `args`).
105    Arg,
106    /// Substituted into the `url` template.
107    Url,
108}
109
110/// Declarative recipe for fetching a human-readable "logged in as …" string
111/// for a server after auth (harn#3348 schema; the probe runner ships in
112/// harn#3349). MCP has no standard `whoami`, so each known server needs a
113/// vetted recipe. **Pure data** — defining it here changes no behavior until
114/// the runner exists.
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
117pub struct IdentityProbeDescriptor {
118    /// What kind of identity this descriptor can surface. Defaults to a user
119    /// identity for older overlay descriptors.
120    #[serde(default)]
121    pub resolution: IdentityResolutionKind,
122    /// Confidence level for the descriptor, based on source quality.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub confidence: Option<IdentityDescriptorConfidence>,
125    /// Primary public source that documents the server URL or identity tool.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub source_url: Option<String>,
128    /// Display template referencing captured field names in braces, e.g.
129    /// `"{name} <{email}> — {workspace}"`. The runner elides unresolved
130    /// `{field}` placeholders (and any bracketed segment left empty).
131    #[serde(default)]
132    pub display_template: String,
133    /// Ordered probe sources; the runner tries each until one yields a
134    /// non-empty identity.
135    #[serde(default)]
136    pub sources: Vec<IdentityProbeSource>,
137}
138
139/// The type of authenticated identity a descriptor represents.
140#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum IdentityResolutionKind {
143    /// Human user/workspace identity.
144    #[default]
145    User,
146    /// Account/workspace identity only; no stable human principal is exposed.
147    Account,
148    /// No known identity surface. Clients should display only the server label
149    /// or any separately configured account name.
150    None,
151}
152
153/// Confidence in a descriptor's shape.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum IdentityDescriptorConfidence {
157    /// Directly documented by the provider's public MCP/API documentation.
158    Documented,
159    /// Empirically observed or documented by a first-party source without a
160    /// fully specified result schema.
161    Observed,
162    /// Provider only exposes account/workspace identity, not a user principal.
163    AccountOnly,
164    /// Provider has no known identity surface.
165    None,
166}
167
168/// Where the identity runner looks. A flat struct (rather than a tagged enum)
169/// keeps the TOML simple and dodges internally-tagged-enum/TOML edge cases;
170/// the runner validates that the fields relevant to `kind` are present.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
173pub struct IdentityProbeSource {
174    /// Which kind of probe this is.
175    pub kind: IdentityProbeKind,
176    /// MCP tool to call when `kind = tool` (e.g. Notion's self/whoami tool).
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub tool: Option<String>,
179    /// HTTP endpoint to GET (with the bearer) when `kind = http`.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub url: Option<String>,
182    /// capture-name → dotted JSON path into the source's JSON payload, e.g.
183    /// `name = "owner.user.name"`. Captures feed `display_template`.
184    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
185    pub fields: BTreeMap<String, String>,
186}
187
188/// The kind of identity probe a [`IdentityProbeSource`] performs.
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "snake_case")]
191pub enum IdentityProbeKind {
192    /// Capture fields from the OAuth token-exchange JSON response (Notion, for
193    /// instance, returns `workspace_name` + `owner.user` inline).
194    TokenResponse,
195    /// Call a named MCP tool and capture fields from its JSON result.
196    Tool,
197    /// GET an authenticated HTTP endpoint and capture fields from its JSON.
198    Http,
199}
200
201/// A single well-known MCP server preset. Fields after `transport` are
202/// transport-specific: `command`/`args` populate a stdio spec, `url` populates
203/// an HTTP spec. Empty strings mean "not applicable for this transport".
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(rename_all(serialize = "camelCase", deserialize = "snake_case"))]
206pub struct McpPreset {
207    /// Stable lookup key (e.g. `"notion"`). Unique across the catalog.
208    pub id: String,
209    /// Display name for the client UI (e.g. `"Notion"`).
210    pub name: String,
211    /// One-line description of what the server exposes.
212    pub description: String,
213    /// SF Symbols-style icon hint for the macOS GUI; clients without an icon
214    /// model may ignore it.
215    pub icon: String,
216    /// Advisory category for grouping.
217    pub category: PresetCategory,
218    /// Transport the resolved server speaks.
219    pub transport: PresetTransport,
220    /// stdio command (empty for HTTP presets).
221    #[serde(default)]
222    pub command: String,
223    /// stdio command arguments (empty for HTTP presets).
224    #[serde(default)]
225    pub args: Vec<String>,
226    /// HTTP endpoint URL template (empty for stdio presets).
227    #[serde(default)]
228    pub url: String,
229    /// How a client authenticates.
230    pub auth_kind: PresetAuthKind,
231    /// Suggested OAuth scope string, when `auth_kind` is `oauth`.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub oauth_scopes: Option<String>,
234    /// Values the client must collect before connecting.
235    #[serde(default)]
236    pub placeholders: Vec<PresetPlaceholder>,
237    /// Optional recipe for displaying the authenticated identity (harn#3348).
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub identity: Option<IdentityProbeDescriptor>,
240}
241
242/// The full catalog, ready to serialize as the stable JSON contract.
243#[derive(Debug, Clone, Serialize)]
244pub struct PresetCatalog {
245    #[serde(rename = "schemaVersion")]
246    pub schema_version: u32,
247    pub presets: Vec<McpPreset>,
248}
249
250/// Deserialization envelope for a catalog TOML file (bundled or overlay).
251#[derive(Debug, Default, Deserialize)]
252struct PresetFile {
253    #[serde(default)]
254    presets: Vec<McpPreset>,
255}
256
257/// Lazily-built effective catalog (bundled base + runtime overlay).
258static 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
276/// Parse a catalog TOML document into its preset list.
277fn parse_presets(src: &str) -> Result<Vec<McpPreset>, toml::de::Error> {
278    Ok(toml::from_str::<PresetFile>(src)?.presets)
279}
280
281/// Resolve the runtime overlay, if any: the `HARN_MCP_PRESETS_CONFIG` path
282/// wins, else `~/.config/harn/mcp_presets.toml`. Skipped under `cfg(test)` so
283/// unit tests see only the bundled defaults plus explicit overlays.
284fn 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
311/// Merge an overlay into the base list: replace presets sharing an `id`
312/// (last-writer-wins), append genuinely new ones in overlay order.
313fn 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
323/// Borrow the effective preset list. The single source of truth.
324pub fn presets() -> &'static [McpPreset] {
325    load().presets.as_slice()
326}
327
328/// Look up one preset by its stable `id`.
329pub fn preset(id: &str) -> Option<&'static McpPreset> {
330    load().presets.iter().find(|preset| preset.id == id)
331}
332
333/// Build the serializable catalog envelope.
334pub 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        // Notion declares a token_response identity descriptor (harn#3349).
447        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        // Round-trips to camelCase JSON for thin clients.
709        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}