ssg/plugins/postprocess/agentic_discovery/
mod.rs1mod 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct AgentsConfig {
71 #[serde(default)]
73 pub agents_txt: bool,
74
75 #[serde(default)]
77 pub ai_plugin: bool,
78
79 #[serde(default)]
81 pub mcp: McpConfig,
82
83 #[serde(default)]
88 pub rules: HashMap<String, AgentRule>,
89
90 #[serde(default)]
93 pub default_rule: Option<AgentRule>,
94}
95
96impl AgentsConfig {
97 #[must_use]
110 pub const fn any_enabled(&self) -> bool {
111 self.agents_txt || self.ai_plugin || self.mcp.enabled
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct McpConfig {
126 #[serde(default)]
128 pub enabled: bool,
129
130 #[serde(default = "default_transport")]
134 pub transport: String,
135
136 #[serde(default)]
140 pub url: Option<String>,
141
142 #[serde(default = "default_protocol_version")]
146 pub protocol_version: String,
147
148 #[serde(default)]
151 pub auto_resources: bool,
152
153 #[serde(default)]
156 pub tools: Vec<McpToolDecl>,
157
158 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
189pub struct AgentRule {
190 #[serde(default)]
192 pub allow: Vec<String>,
193
194 #[serde(default)]
196 pub disallow: Vec<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct McpToolDecl {
204 pub name: String,
206 pub description: String,
208 #[serde(default, rename = "inputSchema")]
212 pub input_schema: Option<serde_json::Value>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct McpPromptDecl {
218 pub name: String,
220 pub description: String,
222 #[serde(default)]
224 pub arguments: Option<serde_json::Value>,
225}
226
227#[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#[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 assert_eq!(AgenticDiscoveryPlugin.name(), "agentic-discovery");
307 }
308
309 #[test]
310 fn no_config_is_no_op() {
311 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 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 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 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 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 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 #[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 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}