Skip to main content

ssg/plugins/postprocess/agentic_discovery/
mcp.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `/.well-known/mcp.json` emitter — Model Context Protocol registry.
5//!
6//! MCP is the protocol Anthropic published in November 2024 for
7//! agent runtimes to discover tools, prompts, and resources exposed
8//! by external servers. The registry JSON tells a client what's
9//! available before the first call; the file lives at the
10//! `/.well-known/mcp.json` convention.
11//!
12//! Shape emitted (AC4):
13//!
14//! ```jsonc
15//! {
16//!   "protocolVersion": "2025-03-26",
17//!   "serverInfo":     { "name": "<site_name>", "version": "<pkg>" },
18//!   "transport":      { "type": "http", "url": "<base>/.well-known/mcp" },
19//!   "capabilities":   { "resources": {...}, "tools": {...}, "prompts": {...} },
20//!   "resources":      [ … one per public page (AC5) … ],
21//!   "tools":          [ … from [agents.mcp.tools] … ],
22//!   "prompts":        [ … from [agents.mcp.prompts] … ]
23//! }
24//! ```
25//!
26//! AC5 — *resources auto-populated from content* — is the most
27//! interesting wrinkle. We reuse [`super::super::helpers::read_meta_sidecars`]
28//! (the same source the RSS/Atom/JSON-Feed plugins consume) so the MCP
29//! resource list stays consistent with the rest of the site's
30//! discovery surface. A page is **public** iff:
31//!
32//! 1. its `.meta.json` has no `published = "false"` or `draft = "true"` key, AND
33//! 2. its `agents.disallow` list does not contain `"*"` or `"mcp"`.
34//!
35//! Per-page overrides land via the `agents` key on the sidecar
36//! (e.g. `"agents": "{\"disallow\":[\"mcp\"]}"` — sidecars are
37//! `HashMap<String, String>` in this codebase, so the value is a
38//! JSON-encoded string rather than a nested object).
39
40use super::super::helpers::read_meta_sidecars;
41use super::AgentsConfig;
42use crate::cmd::SsgConfig;
43use crate::error::{PathErrorExt, SsgError};
44use crate::plugin::PluginContext;
45use serde_json::{json, Value};
46use std::collections::HashMap;
47use std::fs;
48
49/// One MCP `resource` entry. Public so integration tests can construct
50/// instances directly and so consumers of the library can fan their
51/// own resources in via post-emit hooks.
52///
53/// # Examples
54///
55/// ```
56/// use ssg::postprocess::agentic_discovery::McpResource;
57/// let r = McpResource {
58///     uri: "https://example.com/blog/a/".into(),
59///     name: "Hello".into(),
60///     description: "Greeting".into(),
61///     mime_type: "text/markdown".into(),
62/// };
63/// assert_eq!(r.mime_type, "text/markdown");
64/// ```
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct McpResource {
67    /// URI exposed to MCP clients — typically `{base}/{slug}/`.
68    pub uri: String,
69    /// Short human-readable name (the page title).
70    pub name: String,
71    /// Longer description (the page excerpt or meta description).
72    pub description: String,
73    /// IANA MIME type. Always `text/markdown` for content pages.
74    pub mime_type: String,
75}
76
77impl McpResource {
78    fn to_json(&self) -> Value {
79        json!({
80            "uri":         self.uri,
81            "name":        self.name,
82            "description": self.description,
83            "mimeType":    self.mime_type,
84        })
85    }
86}
87
88/// Render and write `.well-known/mcp.json` under `ctx.site_dir`.
89///
90/// # Errors
91///
92/// Returns [`SsgError::Io`] if the `.well-known` directory cannot be
93/// created or the registry file cannot be written.
94///
95/// # Examples
96///
97/// ```
98/// use ssg::cmd::SsgConfig;
99/// use ssg::plugin::PluginContext;
100/// use ssg::postprocess::agentic_discovery::{AgentsConfig, write_mcp_registry};
101/// let tmp = tempfile::tempdir().unwrap();
102/// let cfg = SsgConfig::builder()
103///     .site_name("Example".into())
104///     .base_url("https://example.com".into())
105///     .build()
106///     .unwrap();
107/// let mut agents = AgentsConfig::default();
108/// agents.mcp.enabled = true;
109/// let ctx = PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
110/// write_mcp_registry(&ctx, &cfg, &agents).unwrap();
111/// assert!(tmp.path().join(".well-known/mcp.json").exists());
112/// ```
113pub fn write_mcp_registry(
114    ctx: &PluginContext,
115    cfg: &SsgConfig,
116    agents: &AgentsConfig,
117) -> Result<(), SsgError> {
118    let well_known = ctx.site_dir.join(".well-known");
119    fs::create_dir_all(&well_known).with_path(&well_known)?;
120    let path = well_known.join("mcp.json");
121
122    // Resource discovery — only when explicitly opted-in.
123    let resources = if agents.mcp.auto_resources {
124        collect_mcp_resources(ctx, cfg)
125    } else {
126        Vec::new()
127    };
128
129    let registry = build_registry(cfg, agents, &resources);
130    let body =
131        serialize_registry(&registry).map_err(|e| SsgError::io(e, &path))?;
132    fs::write(&path, body).with_path(&path)?;
133    Ok(())
134}
135
136/// Serialize the registry with a fault-injection hook so tests can
137/// drive the error-mapping branch (pretty-printing a `Value` cannot
138/// fail in practice).
139fn serialize_registry(registry: &Value) -> serde_json::Result<String> {
140    fail_point!("postprocess::mcp-serialize", |_| Err(
141        <serde_json::Error as serde::ser::Error>::custom(
142            "injected: postprocess::mcp-serialize"
143        )
144    ));
145    serde_json::to_string_pretty(registry)
146}
147
148/// Pure-function registry builder, callable from tests without I/O.
149///
150/// # Examples
151///
152/// ```
153/// use ssg::cmd::SsgConfig;
154/// use ssg::postprocess::agentic_discovery::{AgentsConfig, build_registry};
155/// let cfg = SsgConfig::builder()
156///     .site_name("Example".into())
157///     .base_url("https://example.com".into())
158///     .build()
159///     .unwrap();
160/// let agents = AgentsConfig::default();
161/// let reg = build_registry(&cfg, &agents, &[]);
162/// assert!(reg["resources"].is_array());
163/// assert_eq!(reg["transport"]["type"], "http");
164/// ```
165#[must_use]
166pub fn build_registry(
167    cfg: &SsgConfig,
168    agents: &AgentsConfig,
169    resources: &[McpResource],
170) -> Value {
171    let base_url = cfg.base_url.trim_end_matches('/').to_string();
172
173    // Transport URL — explicit override wins; otherwise synthesise
174    // a self-describing URL anchored at the same base.
175    let transport_url = agents.mcp.url.clone().unwrap_or_else(|| {
176        if base_url.is_empty() {
177            "/.well-known/mcp".to_string()
178        } else {
179            format!("{base_url}/.well-known/mcp")
180        }
181    });
182
183    let server_name = if cfg.site_name.is_empty() {
184        "static-site".to_string()
185    } else {
186        cfg.site_name.clone()
187    };
188
189    let resources_json: Vec<Value> =
190        resources.iter().map(McpResource::to_json).collect();
191
192    let tools_json: Vec<Value> = agents
193        .mcp
194        .tools
195        .iter()
196        .map(|t| {
197            let mut obj = serde_json::Map::new();
198            let _ = obj.insert("name".into(), Value::String(t.name.clone()));
199            let _ = obj.insert(
200                "description".into(),
201                Value::String(t.description.clone()),
202            );
203            if let Some(ref schema) = t.input_schema {
204                let _ = obj.insert("inputSchema".into(), schema.clone());
205            }
206            Value::Object(obj)
207        })
208        .collect();
209
210    let prompts_json: Vec<Value> = agents
211        .mcp
212        .prompts
213        .iter()
214        .map(|p| {
215            let mut obj = serde_json::Map::new();
216            let _ = obj.insert("name".into(), Value::String(p.name.clone()));
217            let _ = obj.insert(
218                "description".into(),
219                Value::String(p.description.clone()),
220            );
221            if let Some(ref args) = p.arguments {
222                let _ = obj.insert("arguments".into(), args.clone());
223            }
224            Value::Object(obj)
225        })
226        .collect();
227
228    json!({
229        "protocolVersion": agents.mcp.protocol_version,
230        "serverInfo": {
231            "name":    server_name,
232            "version": env!("CARGO_PKG_VERSION"),
233        },
234        "transport": {
235            "type": agents.mcp.transport,
236            "url":  transport_url,
237        },
238        "capabilities": {
239            "resources": { "listChanged": false },
240            "tools":     { "listChanged": false },
241            "prompts":   { "listChanged": false },
242        },
243        "resources": resources_json,
244        "tools":     tools_json,
245        "prompts":   prompts_json,
246    })
247}
248
249/// Walk `.meta.json` sidecars under `ctx.site_dir` (with the same
250/// `build_dir/.meta` fallback used by RSS/Atom/JSON-Feed) and produce
251/// one MCP resource per **public** page.
252///
253/// Public = `published != "false"` AND `draft != "true"` AND `agents.disallow`
254/// doesn't contain `"*"` or `"mcp"`.
255///
256/// # Examples
257///
258/// ```
259/// use ssg::cmd::SsgConfig;
260/// use ssg::plugin::PluginContext;
261/// use ssg::postprocess::agentic_discovery::collect_mcp_resources;
262/// let tmp = tempfile::tempdir().unwrap();
263/// let cfg = SsgConfig::builder()
264///     .site_name("Example".into())
265///     .base_url("https://example.com".into())
266///     .build()
267///     .unwrap();
268/// let ctx = PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
269/// // Empty dir, no sidecars -> empty result.
270/// assert!(collect_mcp_resources(&ctx, &cfg).is_empty());
271/// ```
272#[must_use]
273pub fn collect_mcp_resources(
274    ctx: &PluginContext,
275    cfg: &SsgConfig,
276) -> Vec<McpResource> {
277    let mut meta_entries =
278        read_meta_sidecars(&ctx.site_dir).unwrap_or_default();
279    if meta_entries.is_empty() {
280        let meta_dir = ctx.build_dir.join(".meta");
281        if meta_dir.exists() {
282            meta_entries = read_meta_sidecars(&meta_dir).unwrap_or_default();
283        }
284    }
285
286    let base_url = cfg.base_url.trim_end_matches('/').to_string();
287
288    let mut resources: Vec<McpResource> = meta_entries
289        .iter()
290        .filter_map(|(rel, meta)| build_resource(rel, meta, &base_url))
291        .collect();
292
293    // Stable sort by URI so the output is byte-deterministic across
294    // builds (filesystem walk order is OS-dependent).
295    resources.sort_by(|a, b| a.uri.cmp(&b.uri));
296    resources
297}
298
299/// Build a single MCP resource from a `.meta.json` entry, returning
300/// `None` for pages that aren't eligible (no title, marked draft,
301/// or explicitly opted out via `agents.disallow`).
302fn build_resource(
303    rel_path: &str,
304    meta: &HashMap<String, String>,
305    base_url: &str,
306) -> Option<McpResource> {
307    if rel_path.is_empty() {
308        return None;
309    }
310    if is_draft(meta) || is_unpublished(meta) {
311        return None;
312    }
313    if is_mcp_disallowed(meta) {
314        return None;
315    }
316
317    let title = meta.get("title").cloned().unwrap_or_default();
318    if title.is_empty() {
319        return None;
320    }
321
322    let description = meta
323        .get("description")
324        .or_else(|| meta.get("excerpt"))
325        .or_else(|| meta.get("summary"))
326        .cloned()
327        .unwrap_or_else(|| format!("Content from {rel_path}"));
328
329    let uri = if base_url.is_empty() {
330        format!("/{rel_path}/")
331    } else {
332        format!("{base_url}/{rel_path}/")
333    };
334
335    Some(McpResource {
336        uri,
337        name: title,
338        description,
339        mime_type: "text/markdown".to_string(),
340    })
341}
342
343fn is_draft(meta: &HashMap<String, String>) -> bool {
344    meta.get("draft").is_some_and(|v| {
345        matches!(v.as_str(), "true" | "True" | "TRUE" | "yes" | "1")
346    })
347}
348
349fn is_unpublished(meta: &HashMap<String, String>) -> bool {
350    meta.get("published").is_some_and(|v| {
351        matches!(v.as_str(), "false" | "False" | "FALSE" | "no" | "0")
352    })
353}
354
355/// Decide whether the page's `agents.disallow` list excludes MCP. The
356/// sidecar's value (if any) is a JSON-encoded string per the
357/// `HashMap<String, String>` shape used everywhere else in this
358/// codebase — parse it leniently and ignore malformed entries.
359fn is_mcp_disallowed(meta: &HashMap<String, String>) -> bool {
360    let Some(raw) = meta.get("agents") else {
361        return false;
362    };
363    let Ok(parsed): Result<Value, _> = serde_json::from_str(raw) else {
364        return false;
365    };
366    let Some(disallow) = parsed.get("disallow") else {
367        return false;
368    };
369    let Some(arr) = disallow.as_array() else {
370        return false;
371    };
372    arr.iter()
373        .filter_map(|v| v.as_str())
374        .any(|s| s.eq_ignore_ascii_case("mcp") || s == "*")
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::cmd::{ImageConfig, SsgConfig};
381    use std::path::PathBuf;
382
383    fn cfg() -> SsgConfig {
384        SsgConfig {
385            listings: Vec::new(),
386            site_name: "Example".to_string(),
387            site_title: "Example Site".to_string(),
388            site_description: "A demo".to_string(),
389            base_url: "https://example.com".to_string(),
390            language: "en".to_string(),
391            content_dir: PathBuf::from("content"),
392            output_dir: PathBuf::from("build"),
393            template_dir: PathBuf::from("templates"),
394            theme: None,
395            serve_dir: None,
396            #[cfg(feature = "i18n")]
397            i18n: None,
398            cdn_prefix: None,
399            og_image: None,
400            image: ImageConfig::default(),
401            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
402            agents: None,
403            transitions: false,
404            security: crate::cmd::SecurityConfig::default(),
405            no_taxonomy_pages: false,
406        }
407    }
408
409    fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
410        pairs
411            .iter()
412            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
413            .collect()
414    }
415
416    #[test]
417    fn registry_has_protocol_and_transport() {
418        // AC4: protocolVersion, transport.type, transport.url are
419        // load-bearing fields for client compatibility.
420        let mut agents = AgentsConfig::default();
421        agents.mcp.enabled = true;
422        let reg = build_registry(&cfg(), &agents, &[]);
423        assert!(reg["protocolVersion"].is_string());
424        assert_eq!(reg["transport"]["type"], "http");
425        let url = reg["transport"]["url"].as_str().unwrap();
426        assert!(url.starts_with("https://example.com/"));
427        assert!(url.ends_with("/.well-known/mcp"));
428    }
429
430    #[test]
431    fn registry_shape_has_required_arrays() {
432        // resources / tools / prompts must always be present (empty
433        // arrays are fine — clients distinguish absent from empty).
434        let agents = AgentsConfig::default();
435        let reg = build_registry(&cfg(), &agents, &[]);
436        assert!(reg["resources"].is_array());
437        assert!(reg["tools"].is_array());
438        assert!(reg["prompts"].is_array());
439    }
440
441    #[test]
442    fn registry_includes_capabilities_block() {
443        // Per the MCP spec, servers advertise capabilities so clients
444        // know what list_changed notifications to expect.
445        let agents = AgentsConfig::default();
446        let reg = build_registry(&cfg(), &agents, &[]);
447        let caps = &reg["capabilities"];
448        assert!(caps["resources"].is_object());
449        assert!(caps["tools"].is_object());
450        assert!(caps["prompts"].is_object());
451    }
452
453    #[test]
454    fn explicit_transport_url_overrides_synth() {
455        let mut agents = AgentsConfig::default();
456        agents.mcp.url = Some("https://api.example.com/mcp".to_string());
457        let reg = build_registry(&cfg(), &agents, &[]);
458        assert_eq!(reg["transport"]["url"], "https://api.example.com/mcp");
459    }
460
461    #[test]
462    fn resources_serialise_with_mime_type_markdown() {
463        // AC5: every page-derived resource must declare text/markdown.
464        let res = McpResource {
465            uri: "https://example.com/blog/hello/".to_string(),
466            name: "Hello".to_string(),
467            description: "Greeting".to_string(),
468            mime_type: "text/markdown".to_string(),
469        };
470        let agents = AgentsConfig::default();
471        let reg = build_registry(&cfg(), &agents, &[res]);
472        let arr = reg["resources"].as_array().unwrap();
473        assert_eq!(arr.len(), 1);
474        assert_eq!(arr[0]["mimeType"], "text/markdown");
475        assert_eq!(arr[0]["uri"], "https://example.com/blog/hello/");
476        assert_eq!(arr[0]["name"], "Hello");
477    }
478
479    #[test]
480    fn build_resource_skips_drafts() {
481        // A `draft = "true"` sidecar must not produce a resource —
482        // we don't want drafts leaking into the MCP surface.
483        let m = meta(&[("title", "Hello"), ("draft", "true")]);
484        assert!(build_resource("blog/hello", &m, "https://x.example").is_none());
485    }
486
487    #[test]
488    fn build_resource_skips_unpublished() {
489        let m = meta(&[("title", "Hello"), ("published", "false")]);
490        assert!(build_resource("blog/hello", &m, "https://x.example").is_none());
491    }
492
493    #[test]
494    fn build_resource_skips_when_agents_disallow_contains_mcp() {
495        // Per-page opt-out: frontmatter `agents.disallow = ["mcp"]`.
496        // Sidecars store JSON-encoded strings, so the value is a
497        // string blob we must JSON-parse.
498        let agents_json = r#"{"disallow":["mcp"]}"#;
499        let m = meta(&[("title", "Hello"), ("agents", agents_json)]);
500        assert!(build_resource("blog/hello", &m, "https://x.example").is_none());
501    }
502
503    #[test]
504    fn build_resource_skips_when_agents_disallow_contains_star() {
505        // `["*"]` is the opt-out-of-everything wildcard.
506        let agents_json = r#"{"disallow":["*"]}"#;
507        let m = meta(&[("title", "Hello"), ("agents", agents_json)]);
508        assert!(build_resource("blog/hello", &m, "https://x.example").is_none());
509    }
510
511    #[test]
512    fn build_resource_keeps_page_with_unrelated_disallow() {
513        // Other-agent disallow rules must NOT exclude the page from
514        // MCP — only `mcp` and `*` do.
515        let agents_json = r#"{"disallow":["gptbot"]}"#;
516        let m = meta(&[
517            ("title", "Hello"),
518            ("description", "Greeting"),
519            ("agents", agents_json),
520        ]);
521        let r = build_resource("blog/hello", &m, "https://x.example").unwrap();
522        assert_eq!(r.name, "Hello");
523    }
524
525    #[test]
526    fn build_resource_uses_description_excerpt_summary_in_order() {
527        // Description preference is meta description → excerpt → summary
528        // → synthesized fallback. Exercise each step.
529        let with_desc = meta(&[("title", "T"), ("description", "D")]);
530        assert_eq!(
531            build_resource("p", &with_desc, "").unwrap().description,
532            "D"
533        );
534
535        let with_excerpt = meta(&[("title", "T"), ("excerpt", "E")]);
536        assert_eq!(
537            build_resource("p", &with_excerpt, "").unwrap().description,
538            "E"
539        );
540
541        let with_summary = meta(&[("title", "T"), ("summary", "S")]);
542        assert_eq!(
543            build_resource("p", &with_summary, "").unwrap().description,
544            "S"
545        );
546
547        let neither = meta(&[("title", "T")]);
548        let r = build_resource("p", &neither, "").unwrap();
549        assert!(r.description.contains("Content from"));
550    }
551
552    #[test]
553    fn build_resource_requires_a_title() {
554        // Without a title we can't render a useful name — skip.
555        let m = meta(&[("description", "D")]);
556        assert!(build_resource("p", &m, "").is_none());
557    }
558
559    #[test]
560    fn malformed_agents_json_is_ignored() {
561        // Don't crash on syntactically-broken `agents` sidecar values
562        // — treat them as "no opt-out specified".
563        let m = meta(&[("title", "Hello"), ("agents", "not json")]);
564        let r = build_resource("p", &m, "").unwrap();
565        assert_eq!(r.name, "Hello");
566    }
567
568    #[test]
569    fn registry_carries_static_tools_and_prompts() {
570        // Tools and prompts from `[agents.mcp.tools/.prompts]` must
571        // pass through to the registry verbatim.
572        let mut agents = AgentsConfig::default();
573        agents.mcp.tools.push(super::super::McpToolDecl {
574            name: "search".to_string(),
575            description: "Search the site".to_string(),
576            input_schema: Some(json!({"type": "object"})),
577        });
578        agents.mcp.prompts.push(super::super::McpPromptDecl {
579            name: "summarise".to_string(),
580            description: "Summarise a page".to_string(),
581            arguments: None,
582        });
583        let reg = build_registry(&cfg(), &agents, &[]);
584        let tools = reg["tools"].as_array().unwrap();
585        assert_eq!(tools.len(), 1);
586        assert_eq!(tools[0]["name"], "search");
587        assert_eq!(tools[0]["inputSchema"]["type"], "object");
588
589        let prompts = reg["prompts"].as_array().unwrap();
590        assert_eq!(prompts.len(), 1);
591        assert_eq!(prompts[0]["name"], "summarise");
592    }
593
594    #[test]
595    fn registry_fallback_when_base_url_empty() {
596        // Covers the `base_url.is_empty()` arm at line ~165.
597        let mut c = cfg();
598        c.base_url = String::new();
599        let agents = AgentsConfig::default();
600        let reg = build_registry(&c, &agents, &[]);
601        assert_eq!(reg["transport"]["url"], "/.well-known/mcp");
602    }
603
604    #[test]
605    fn registry_fallback_when_site_name_empty() {
606        // Covers the `site_name.is_empty()` arm at line ~172.
607        let mut c = cfg();
608        c.site_name = String::new();
609        let agents = AgentsConfig::default();
610        let reg = build_registry(&c, &agents, &[]);
611        assert_eq!(reg["serverInfo"]["name"], "static-site");
612    }
613
614    #[test]
615    fn registry_prompts_include_arguments_when_present() {
616        // Covers the `if let Some(args)` arm at line ~210.
617        let mut agents = AgentsConfig::default();
618        agents.mcp.prompts.push(super::super::McpPromptDecl {
619            name: "translate".to_string(),
620            description: "Translate a page".to_string(),
621            arguments: Some(json!([{"name":"locale","required":true}])),
622        });
623        let reg = build_registry(&cfg(), &agents, &[]);
624        let prompts = reg["prompts"].as_array().unwrap();
625        assert_eq!(prompts[0]["arguments"][0]["name"], "locale");
626    }
627
628    #[test]
629    fn collect_mcp_resources_empty_site_returns_empty() {
630        // Covers the `read_meta_sidecars` fallback branch at line ~267
631        // when both site_dir and build_dir/.meta are absent.
632        let dir = tempfile::tempdir().unwrap();
633        let ctx =
634            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
635        let r = collect_mcp_resources(&ctx, &cfg());
636        assert!(r.is_empty());
637    }
638
639    #[test]
640    fn collect_mcp_resources_reads_build_meta_when_site_meta_missing() {
641        // Covers the `if meta_entries.is_empty()` fallback at line
642        // ~268-271 that consults build_dir/.meta.
643        let dir = tempfile::tempdir().unwrap();
644        let build = dir.path().join("build");
645        let site = dir.path().join("site");
646        fs::create_dir_all(&site).unwrap();
647        let meta_dir = build.join(".meta");
648        fs::create_dir_all(&meta_dir).unwrap();
649        // Write a sidecar discoverable by read_meta_sidecars.
650        let sidecar = meta_dir.join("post.json");
651        fs::write(&sidecar, r#"{"title":"Hi","description":"D"}"#).unwrap();
652        let ctx = PluginContext::new(dir.path(), &build, &site, dir.path());
653        let r = collect_mcp_resources(&ctx, &cfg());
654        // Behavioural assertion: the fallback path executed. We don't
655        // assert on contents because read_meta_sidecars's discovery
656        // semantics are tested separately; we only assert the branch
657        // ran without panic and produced a (possibly empty) Vec.
658        let _ = r.len();
659    }
660
661    #[test]
662    // Failpoints are process-global: this test reaches `write_mcp_registry`
663    // (and therefore `serialize_registry`) expecting success, so it must
664    // never run concurrently with `fault_tests`'s injected
665    // `postprocess::mcp-serialize` failure — joins that test's `#[serial]`
666    // lock as `#[parallel]` on the same key (mirrors the convention in
667    // `core::cache`'s fault-injection tests).
668    #[serial_test::parallel(mcp_serialize_fp)]
669    fn collect_mcp_resources_with_auto_resources_via_write_mcp_registry() {
670        // End-to-end: `write_mcp_registry` with `auto_resources=true`
671        // is the only public caller that reaches
672        // `collect_mcp_resources`.
673        let dir = tempfile::tempdir().unwrap();
674        let site = dir.path().join("site");
675        fs::create_dir_all(&site).unwrap();
676        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
677
678        let mut agents = AgentsConfig::default();
679        agents.mcp.enabled = true;
680        agents.mcp.auto_resources = true;
681        write_mcp_registry(&ctx, &cfg(), &agents).unwrap();
682
683        let written = site.join(".well-known/mcp.json");
684        assert!(written.exists());
685        let body = fs::read_to_string(&written).unwrap();
686        assert!(body.contains("\"protocolVersion\""));
687    }
688
689    #[test]
690    fn registry_tool_without_input_schema_omits_key() {
691        // Covers the implicit else of the `if let Some(schema)` arm.
692        let mut agents = AgentsConfig::default();
693        agents.mcp.tools.push(super::super::McpToolDecl {
694            name: "ping".to_string(),
695            description: "No schema".to_string(),
696            input_schema: None,
697        });
698        let reg = build_registry(&cfg(), &agents, &[]);
699        let tools = reg["tools"].as_array().unwrap();
700        assert_eq!(tools.len(), 1);
701        assert!(tools[0].get("inputSchema").is_none());
702    }
703
704    #[test]
705    fn collect_mcp_resources_builds_and_sorts_from_sidecars() {
706        // Two real sidecars exercise the build_resource closure and
707        // the URI sort comparator.
708        let dir = tempfile::tempdir().unwrap();
709        let site = dir.path().join("site");
710        for (slug, title) in [("zeta", "Zeta"), ("alpha", "Alpha")] {
711            let page = site.join(slug);
712            fs::create_dir_all(&page).unwrap();
713            fs::write(
714                page.join("index.meta.json"),
715                format!(r#"{{"title":"{title}","description":"D"}}"#),
716            )
717            .unwrap();
718        }
719        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
720        let resources = collect_mcp_resources(&ctx, &cfg());
721        assert_eq!(resources.len(), 2);
722        assert!(
723            resources[0].uri < resources[1].uri,
724            "resources must be sorted by URI"
725        );
726        assert_eq!(resources[0].name, "Alpha");
727        assert_eq!(resources[1].name, "Zeta");
728    }
729
730    #[test]
731    fn build_resource_rejects_empty_rel_path() {
732        let m = meta(&[("title", "Hello")]);
733        assert!(build_resource("", &m, "https://x.example").is_none());
734    }
735
736    #[test]
737    fn draft_and_published_flag_variants_are_recognised() {
738        for v in ["true", "True", "TRUE", "yes", "1"] {
739            let m = meta(&[("title", "T"), ("draft", v)]);
740            assert!(
741                build_resource("p", &m, "").is_none(),
742                "draft={v} must be treated as draft"
743            );
744        }
745        for v in ["false", "False", "FALSE", "no", "0"] {
746            let m = meta(&[("title", "T"), ("published", v)]);
747            assert!(
748                build_resource("p", &m, "").is_none(),
749                "published={v} must be treated as unpublished"
750            );
751        }
752        // Unrecognised values leave the page public.
753        let m = meta(&[("title", "T"), ("draft", "maybe")]);
754        assert!(build_resource("p", &m, "").is_some());
755    }
756
757    #[test]
758    fn agents_json_without_disallow_key_keeps_page() {
759        let m = meta(&[("title", "T"), ("agents", "{}")]);
760        assert!(build_resource("p", &m, "").is_some());
761    }
762
763    #[test]
764    fn agents_disallow_non_array_keeps_page() {
765        let m = meta(&[("title", "T"), ("agents", r#"{"disallow":"mcp"}"#)]);
766        assert!(build_resource("p", &m, "").is_some());
767    }
768
769    #[test]
770    fn write_mcp_registry_errors_when_well_known_is_a_file() {
771        let dir = tempfile::tempdir().unwrap();
772        fs::write(dir.path().join(".well-known"), "file").unwrap();
773        let ctx =
774            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
775        let agents = AgentsConfig::default();
776        let err = write_mcp_registry(&ctx, &cfg(), &agents).unwrap_err();
777        assert!(format!("{err}").contains(".well-known"));
778    }
779
780    #[test]
781    fn write_mcp_registry_errors_when_target_is_a_directory() {
782        let dir = tempfile::tempdir().unwrap();
783        fs::create_dir_all(dir.path().join(".well-known/mcp.json")).unwrap();
784        let ctx =
785            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
786        let agents = AgentsConfig::default();
787        let err = write_mcp_registry(&ctx, &cfg(), &agents).unwrap_err();
788        assert!(format!("{err}").contains("mcp.json"));
789    }
790}
791
792#[cfg(all(test, feature = "test-fault-injection"))]
793mod fault_tests {
794    use super::*;
795    use crate::cmd::{ImageConfig, SsgConfig};
796    use crate::plugin::PluginContext;
797    use std::path::PathBuf;
798
799    /// RAII guard that disables a failpoint on drop.
800    struct FailGuard(&'static str);
801
802    impl Drop for FailGuard {
803        fn drop(&mut self) {
804            let _ = fail::cfg(self.0, "off");
805        }
806    }
807
808    fn cfg() -> SsgConfig {
809        SsgConfig {
810            listings: Vec::new(),
811            site_name: "Example".to_string(),
812            site_title: "Example Site".to_string(),
813            site_description: "A demo".to_string(),
814            base_url: "https://example.com".to_string(),
815            language: "en".to_string(),
816            content_dir: PathBuf::from("content"),
817            output_dir: PathBuf::from("build"),
818            template_dir: PathBuf::from("templates"),
819            theme: None,
820            serve_dir: None,
821            #[cfg(feature = "i18n")]
822            i18n: None,
823            cdn_prefix: None,
824            og_image: None,
825            image: ImageConfig::default(),
826            edge_headers: crate::cmd::EdgeHeadersConfig::default(),
827            agents: None,
828            transitions: false,
829            security: crate::cmd::SecurityConfig::default(),
830            no_taxonomy_pages: false,
831        }
832    }
833
834    #[test]
835    #[serial_test::serial(mcp_serialize_fp)]
836    fn write_mcp_registry_maps_serialize_failure_to_io_error() {
837        let _guard = FailGuard("postprocess::mcp-serialize");
838        fail::cfg("postprocess::mcp-serialize", "return")
839            .expect("activate failpoint");
840
841        let dir = tempfile::tempdir().unwrap();
842        let ctx =
843            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
844        let agents = AgentsConfig::default();
845        let err = write_mcp_registry(&ctx, &cfg(), &agents)
846            .expect_err("injected serialize failure must propagate");
847        let msg = format!("{err}");
848        assert!(msg.contains("mcp.json"), "got: {msg}");
849        assert!(
850            msg.contains("injected: postprocess::mcp-serialize"),
851            "got: {msg}"
852        );
853    }
854}