Skip to main content

ssg/plugins/postprocess/agentic_discovery/
mod.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Agentic discovery emitters (issue #552).
5//!
6//! Coordinates three modern agentic-discovery protocol files alongside
7//! the existing `robots.txt` / `sitemap.xml` family:
8//!
9//! 1. **`agents.txt`** — a `robots.txt`-shaped plain-text spec listing
10//!    AI agent identifiers and allow/disallow rules. Emitted at
11//!    `/agents.txt`.
12//! 2. **`.well-known/ai-plugin.json`** — the `OpenAI` plugin manifest
13//!    spec (still the de-facto plugin descriptor across agent runtimes
14//!    in 2026). Emitted at `/.well-known/ai-plugin.json`.
15//! 3. **MCP registry** (`/.well-known/mcp.json`) — Model Context
16//!    Protocol registry listing exposed `resources`, `tools`, and
17//!    `prompts` over HTTP transport (the default delivery channel).
18//!
19//! Each emitter is **opt-in** per the `[agents]` section of `ssg.toml`.
20//! When `[agents]` is absent (or every flag is `false`), this plugin is
21//! a no-op — none of the three files are written. This preserves the
22//! "you didn't ask for it" guarantee.
23//!
24//! # Configuration shape
25//!
26//! ```toml
27//! [agents]
28//! agents_txt = true
29//! ai_plugin  = true
30//!
31//! [agents.mcp]
32//! enabled         = true
33//! transport       = "http"
34//! auto_resources  = true   # walk content sidecars and emit MCP
35//!                          # resources for every public page
36//!
37//! # Per-agent overrides for agents.txt (robots.txt style)
38//! [agents.rules.gptbot]
39//! allow    = ["/blog/*"]
40//! disallow = ["/"]
41//! ```
42//!
43//! Per-page frontmatter may also carry an `agents.disallow` list (read
44//! from `.meta.json` sidecars by the MCP emitter), which causes the
45//! page to be skipped when populating MCP resources.
46
47mod agents_txt;
48mod ai_plugin;
49mod mcp;
50
51use crate::error::SsgError;
52use crate::plugin::{Plugin, PluginContext};
53use serde::{Deserialize, Serialize};
54use std::collections::HashMap;
55
56pub use agents_txt::{render_agents_txt, write_agents_txt};
57pub use ai_plugin::{build_manifest, write_ai_plugin_json};
58pub use mcp::{
59    build_registry, collect_mcp_resources, write_mcp_registry, McpResource,
60};
61
62// =====================================================================
63// Configuration types — surfaced from `ssg.toml` via `SsgConfig::agents`
64// =====================================================================
65
66/// The `[agents]` section of `ssg.toml`. Every field is optional;
67/// absent values fall back to safe "off" defaults so that omitting
68/// the section produces no new files.
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct AgentsConfig {
71    /// Emit `/agents.txt` (a `robots.txt`-shaped agent policy file).
72    #[serde(default)]
73    pub agents_txt: bool,
74
75    /// Emit `/.well-known/ai-plugin.json` (`OpenAI` plugin manifest).
76    #[serde(default)]
77    pub ai_plugin: bool,
78
79    /// MCP registry settings. Absent ⇒ disabled.
80    #[serde(default)]
81    pub mcp: McpConfig,
82
83    /// Per-agent allow/disallow overrides keyed by agent identifier
84    /// (e.g. `"gptbot"`, `"claudebot"`). The agent ID is preserved
85    /// in TOML lowercase but rendered in canonical case
86    /// (`User-agent: GPTBot`) by [`agents_txt::write_agents_txt`].
87    #[serde(default)]
88    pub rules: HashMap<String, AgentRule>,
89
90    /// Default `User-agent: *` rule. When `None`, a permissive
91    /// `Allow: /` + `Disallow: /private/` default is emitted.
92    #[serde(default)]
93    pub default_rule: Option<AgentRule>,
94}
95
96impl AgentsConfig {
97    /// Returns `true` when at least one emitter is enabled. When this
98    /// is `false`, the coordinator plugin is a complete no-op.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use ssg::postprocess::agentic_discovery::AgentsConfig;
104    /// let mut cfg = AgentsConfig::default();
105    /// assert!(!cfg.any_enabled());
106    /// cfg.agents_txt = true;
107    /// assert!(cfg.any_enabled());
108    /// ```
109    #[must_use]
110    pub const fn any_enabled(&self) -> bool {
111        self.agents_txt || self.ai_plugin || self.mcp.enabled
112    }
113}
114
115/// MCP registry settings. Mirrors the JSON shape consumed by clients
116/// (Claude Desktop, IDE integrations, …) but expressed in TOML so site
117/// authors can edit it alongside the rest of `ssg.toml`.
118///
119/// Hand-rolled `Default` so the `transport` and `protocol_version`
120/// fields carry the same defaults that `#[serde(default = …)]` applies
121/// during deserialisation — otherwise constructing the struct via
122/// `McpConfig::default()` (rather than parsing TOML) would leave both
123/// strings empty.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct McpConfig {
126    /// Master toggle. When `false`, no `mcp.json` is written.
127    #[serde(default)]
128    pub enabled: bool,
129
130    /// Transport descriptor — currently always `"http"`. Held as a
131    /// string so future transports (`"stdio"`, `"sse"`) can land
132    /// without bumping the schema.
133    #[serde(default = "default_transport")]
134    pub transport: String,
135
136    /// Override the URL the transport listens on. When omitted, the
137    /// emitter synthesises `{base_url}/.well-known/mcp` so the
138    /// registry is self-describing.
139    #[serde(default)]
140    pub url: Option<String>,
141
142    /// MCP protocol version reported in the registry. Defaults to
143    /// `"2025-03-26"`, the version pinned across the public MCP
144    /// servers we tested against during the v0.0.44 cycle.
145    #[serde(default = "default_protocol_version")]
146    pub protocol_version: String,
147
148    /// When `true`, walk `.meta.json` sidecars under `site_dir` and
149    /// emit one MCP `resource` per public page. AC5 of #552.
150    #[serde(default)]
151    pub auto_resources: bool,
152
153    /// Statically-declared MCP tools. Optional — most sites won't
154    /// expose any. Authors may extend this list in `ssg.toml`.
155    #[serde(default)]
156    pub tools: Vec<McpToolDecl>,
157
158    /// Statically-declared MCP prompt templates.
159    #[serde(default)]
160    pub prompts: Vec<McpPromptDecl>,
161}
162
163fn default_transport() -> String {
164    "http".to_string()
165}
166
167fn default_protocol_version() -> String {
168    "2025-03-26".to_string()
169}
170
171impl Default for McpConfig {
172    fn default() -> Self {
173        Self {
174            enabled: false,
175            transport: default_transport(),
176            url: None,
177            protocol_version: default_protocol_version(),
178            auto_resources: false,
179            tools: Vec::new(),
180            prompts: Vec::new(),
181        }
182    }
183}
184
185/// A single `User-agent: …` block for `agents.txt`. Mirrors the
186/// `robots.txt` grammar but is also reachable from per-page frontmatter
187/// (via the `agents:` key on a `.meta.json` sidecar).
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct AgentRule {
190    /// Allowed URL prefixes (e.g. `"/blog/*"`).
191    #[serde(default)]
192    pub allow: Vec<String>,
193
194    /// Disallowed URL prefixes.
195    #[serde(default)]
196    pub disallow: Vec<String>,
197}
198
199/// Static MCP tool declaration. The emitter passes these through
200/// verbatim to the registry JSON — schema validation is the consumer's
201/// problem.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct McpToolDecl {
204    /// Tool identifier (must be unique within the registry).
205    pub name: String,
206    /// One-line description shown in client UIs.
207    pub description: String,
208    /// Optional JSON Schema for the tool's input. Stored as a free-form
209    /// `serde_json::Value` so authors can paste arbitrary schemas in
210    /// `ssg.toml` without us re-modelling JSON Schema in Rust.
211    #[serde(default, rename = "inputSchema")]
212    pub input_schema: Option<serde_json::Value>,
213}
214
215/// Static MCP prompt declaration.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct McpPromptDecl {
218    /// Prompt identifier.
219    pub name: String,
220    /// Human-readable description.
221    pub description: String,
222    /// Optional argument schema (free-form JSON).
223    #[serde(default)]
224    pub arguments: Option<serde_json::Value>,
225}
226
227// =====================================================================
228// Plugin
229// =====================================================================
230
231/// Coordinator plugin that fans out to the three agentic-discovery
232/// emitters. Registered once in `register_default_plugins` and runs in
233/// `after_compile`.
234///
235/// When no [`AgentsConfig`] is present on the context, or every flag is
236/// `false`, the plugin is a no-op — none of the three files are
237/// written. This means existing sites upgrading to v0.0.44 see no
238/// behavioural change until they opt in.
239///
240/// # Examples
241///
242/// ```
243/// use ssg::plugin::Plugin;
244/// use ssg::postprocess::agentic_discovery::AgenticDiscoveryPlugin;
245/// assert_eq!(AgenticDiscoveryPlugin.name(), "agentic-discovery");
246/// ```
247#[derive(Debug, Clone, Copy, Default)]
248pub struct AgenticDiscoveryPlugin;
249
250impl Plugin for AgenticDiscoveryPlugin {
251    fn name(&self) -> &'static str {
252        "agentic-discovery"
253    }
254
255    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
256        if !ctx.site_dir.exists() {
257            return Ok(());
258        }
259
260        let Some(cfg) = ctx.config.as_ref() else {
261            return Ok(());
262        };
263        let Some(ref agents) = cfg.agents else {
264            return Ok(());
265        };
266
267        if !agents.any_enabled() {
268            return Ok(());
269        }
270
271        if agents.agents_txt {
272            write_agents_txt(ctx, agents)?;
273        }
274
275        if agents.ai_plugin {
276            write_ai_plugin_json(ctx, cfg)?;
277        }
278
279        if agents.mcp.enabled {
280            write_mcp_registry(ctx, cfg, agents)?;
281        }
282
283        Ok(())
284    }
285}
286
287// =====================================================================
288// Tests — unit-level only; integration coverage lives in
289// `tests/agentic_discovery.rs`.
290// =====================================================================
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::path::Path;
296    use tempfile::tempdir;
297
298    fn test_ctx(dir: &Path) -> PluginContext {
299        PluginContext::new(dir, dir, dir, dir)
300    }
301
302    #[test]
303    fn plugin_name_is_stable() {
304        // The name appears in log lines and the PluginManager API —
305        // pin it so renames are deliberate.
306        assert_eq!(AgenticDiscoveryPlugin.name(), "agentic-discovery");
307    }
308
309    #[test]
310    fn no_config_is_no_op() {
311        // A context with no SsgConfig must not crash and must not
312        // produce any files.
313        let dir = tempdir().unwrap();
314        let ctx = test_ctx(dir.path());
315        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
316        assert!(!dir.path().join("agents.txt").exists());
317        assert!(!dir.path().join(".well-known/ai-plugin.json").exists());
318        assert!(!dir.path().join(".well-known/mcp.json").exists());
319    }
320
321    #[test]
322    fn no_site_dir_is_no_op() {
323        // Site dir missing → plugin succeeds silently.
324        let dir = tempdir().unwrap();
325        let missing = dir.path().join("nope");
326        let ctx = test_ctx(&missing);
327        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
328        assert!(!missing.exists());
329    }
330
331    #[test]
332    fn any_enabled_false_by_default() {
333        // Empty config — every flag off → any_enabled() is false →
334        // emitters are skipped.
335        let cfg = AgentsConfig::default();
336        assert!(!cfg.any_enabled());
337    }
338
339    #[test]
340    fn any_enabled_reflects_each_flag() {
341        let mut cfg = AgentsConfig::default();
342        cfg.agents_txt = true;
343        assert!(cfg.any_enabled());
344
345        let mut cfg = AgentsConfig::default();
346        cfg.ai_plugin = true;
347        assert!(cfg.any_enabled());
348
349        let mut cfg = AgentsConfig::default();
350        cfg.mcp.enabled = true;
351        assert!(cfg.any_enabled());
352    }
353
354    #[test]
355    fn mcp_defaults_are_sane() {
356        // The auto-populated MCP defaults match what we promise in
357        // the issue body (HTTP transport, off-by-default).
358        let mcp = McpConfig::default();
359        assert!(!mcp.enabled);
360        assert_eq!(mcp.transport, "http");
361        assert_eq!(mcp.protocol_version, "2025-03-26");
362        assert!(!mcp.auto_resources);
363        assert!(mcp.tools.is_empty());
364        assert!(mcp.prompts.is_empty());
365        assert!(mcp.url.is_none());
366    }
367
368    #[test]
369    fn parses_toml_with_all_three_emitters_enabled() {
370        // The canonical config snippet from the issue body must
371        // round-trip cleanly via toml::from_str.
372        let toml_str = r#"
373            agents_txt = true
374            ai_plugin  = true
375
376            [mcp]
377            enabled = true
378            transport = "http"
379            auto_resources = true
380
381            [rules.gptbot]
382            allow    = ["/blog/*"]
383            disallow = ["/"]
384        "#;
385        let cfg: AgentsConfig = toml::from_str(toml_str).unwrap();
386        assert!(cfg.agents_txt);
387        assert!(cfg.ai_plugin);
388        assert!(cfg.mcp.enabled);
389        assert!(cfg.mcp.auto_resources);
390        assert_eq!(cfg.mcp.transport, "http");
391        let rule = cfg.rules.get("gptbot").unwrap();
392        assert_eq!(rule.allow, vec!["/blog/*"]);
393        assert_eq!(rule.disallow, vec!["/"]);
394    }
395
396    #[test]
397    fn empty_toml_parses_with_defaults() {
398        // Absent fields must deserialise to the safe "off" defaults.
399        let cfg: AgentsConfig = toml::from_str("").unwrap();
400        assert!(!cfg.any_enabled());
401        assert!(cfg.rules.is_empty());
402        assert!(cfg.default_rule.is_none());
403    }
404
405    fn ctx_with_config(dir: &Path, agents: AgentsConfig) -> PluginContext {
406        let mut cfg = crate::cmd::SsgConfig::default();
407        cfg.base_url = "https://example.test".to_string();
408        cfg.site_name = "Example".to_string();
409        cfg.site_title = "Example".to_string();
410        cfg.site_description = "A demo".to_string();
411        cfg.agents = Some(agents);
412        PluginContext::with_config(dir, dir, dir, dir, cfg)
413    }
414
415    #[test]
416    fn agents_txt_enabled_writes_file() {
417        let dir = tempdir().unwrap();
418        let agents = AgentsConfig {
419            agents_txt: true,
420            ..AgentsConfig::default()
421        };
422        let ctx = ctx_with_config(dir.path(), agents);
423        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
424        let body =
425            std::fs::read_to_string(dir.path().join("agents.txt")).unwrap();
426        assert!(body.contains("User-agent: *"));
427        assert!(!dir.path().join(".well-known/ai-plugin.json").exists());
428        assert!(!dir.path().join(".well-known/mcp.json").exists());
429    }
430
431    #[test]
432    fn ai_plugin_enabled_writes_file() {
433        let dir = tempdir().unwrap();
434        let agents = AgentsConfig {
435            ai_plugin: true,
436            ..AgentsConfig::default()
437        };
438        let ctx = ctx_with_config(dir.path(), agents);
439        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
440        let path = dir.path().join(".well-known/ai-plugin.json");
441        assert!(path.exists());
442        let body = std::fs::read_to_string(&path).unwrap();
443        assert!(body.contains("\"schema_version\""));
444        assert!(!dir.path().join("agents.txt").exists());
445    }
446
447    #[test]
448    fn mcp_enabled_writes_file() {
449        let dir = tempdir().unwrap();
450        let mut agents = AgentsConfig::default();
451        agents.mcp.enabled = true;
452        let ctx = ctx_with_config(dir.path(), agents);
453        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
454        let path = dir.path().join(".well-known/mcp.json");
455        assert!(path.exists());
456        let body = std::fs::read_to_string(&path).unwrap();
457        assert!(body.contains("\"protocolVersion\""));
458    }
459
460    #[test]
461    fn all_three_emitters_enabled_writes_all_files() {
462        let dir = tempdir().unwrap();
463        let mut agents = AgentsConfig {
464            agents_txt: true,
465            ai_plugin: true,
466            ..AgentsConfig::default()
467        };
468        agents.mcp.enabled = true;
469        let ctx = ctx_with_config(dir.path(), agents);
470        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
471        assert!(dir.path().join("agents.txt").exists());
472        assert!(dir.path().join(".well-known/ai-plugin.json").exists());
473        assert!(dir.path().join(".well-known/mcp.json").exists());
474    }
475
476    #[test]
477    fn config_present_but_agents_none_is_no_op() {
478        let dir = tempdir().unwrap();
479        let cfg = crate::cmd::SsgConfig::default();
480        let ctx = PluginContext::with_config(
481            dir.path(),
482            dir.path(),
483            dir.path(),
484            dir.path(),
485            cfg,
486        );
487        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
488        assert!(!dir.path().join("agents.txt").exists());
489    }
490
491    #[test]
492    fn all_flags_off_is_no_op_even_with_rules() {
493        let dir = tempdir().unwrap();
494        let mut agents = AgentsConfig::default();
495        let _ = agents.rules.insert(
496            "gptbot".to_string(),
497            AgentRule {
498                allow: vec!["/blog/*".to_string()],
499                disallow: vec![],
500            },
501        );
502        assert!(!agents.any_enabled());
503        let ctx = ctx_with_config(dir.path(), agents);
504        AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
505        assert!(!dir.path().join("agents.txt").exists());
506    }
507
508    #[test]
509    fn mcp_config_serde_round_trip() {
510        let mcp = McpConfig {
511            enabled: true,
512            transport: "http".to_string(),
513            url: Some("https://api.example/mcp".to_string()),
514            protocol_version: "2025-03-26".to_string(),
515            auto_resources: true,
516            tools: vec![McpToolDecl {
517                name: "search".to_string(),
518                description: "Search the site".to_string(),
519                input_schema: Some(serde_json::json!({"type":"object"})),
520            }],
521            prompts: vec![McpPromptDecl {
522                name: "summary".to_string(),
523                description: "Summarise".to_string(),
524                arguments: None,
525            }],
526        };
527        let json = serde_json::to_string(&mcp).unwrap();
528        let back: McpConfig = serde_json::from_str(&json).unwrap();
529        assert_eq!(back.transport, "http");
530        assert_eq!(back.tools.len(), 1);
531        assert_eq!(back.tools[0].name, "search");
532        assert_eq!(back.prompts[0].name, "summary");
533    }
534
535    #[test]
536    fn plugin_default_and_copy_traits() {
537        let a = AgenticDiscoveryPlugin;
538        let b: AgenticDiscoveryPlugin = a;
539        let _c = a;
540        assert_eq!(a.name(), b.name());
541        let default_plugin = <AgenticDiscoveryPlugin as Default>::default();
542        assert_eq!(default_plugin.name(), "agentic-discovery");
543        assert!(format!("{a:?}").contains("AgenticDiscoveryPlugin"));
544    }
545
546    // -----------------------------------------------------------------
547    // Emitter failures propagate through the coordinator
548    // -----------------------------------------------------------------
549
550    #[test]
551    fn agents_txt_failure_propagates() {
552        let dir = tempdir().unwrap();
553        std::fs::create_dir_all(dir.path().join("agents.txt")).unwrap();
554        let agents = AgentsConfig {
555            agents_txt: true,
556            ..AgentsConfig::default()
557        };
558        let ctx = ctx_with_config(dir.path(), agents);
559        let err = AgenticDiscoveryPlugin.after_compile(&ctx).unwrap_err();
560        assert!(format!("{err}").contains("agents.txt"));
561    }
562
563    #[test]
564    fn ai_plugin_failure_propagates() {
565        let dir = tempdir().unwrap();
566        // A file named .well-known blocks create_dir_all.
567        std::fs::write(dir.path().join(".well-known"), "file").unwrap();
568        let agents = AgentsConfig {
569            ai_plugin: true,
570            ..AgentsConfig::default()
571        };
572        let ctx = ctx_with_config(dir.path(), agents);
573        let err = AgenticDiscoveryPlugin.after_compile(&ctx).unwrap_err();
574        assert!(format!("{err}").contains(".well-known"));
575    }
576
577    #[test]
578    fn mcp_failure_propagates() {
579        let dir = tempdir().unwrap();
580        std::fs::write(dir.path().join(".well-known"), "file").unwrap();
581        let mut agents = AgentsConfig::default();
582        agents.mcp.enabled = true;
583        let ctx = ctx_with_config(dir.path(), agents);
584        let err = AgenticDiscoveryPlugin.after_compile(&ctx).unwrap_err();
585        assert!(format!("{err}").contains(".well-known"));
586    }
587}