1use 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#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct McpResource {
67 pub uri: String,
69 pub name: String,
71 pub description: String,
73 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
88pub 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 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(®istry).map_err(|e| SsgError::io(e, &path))?;
132 fs::write(&path, body).with_path(&path)?;
133 Ok(())
134}
135
136fn 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#[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 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#[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 resources.sort_by(|a, b| a.uri.cmp(&b.uri));
296 resources
297}
298
299fn 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
355fn 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 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 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 let agents = AgentsConfig::default();
446 let reg = build_registry(&cfg(), &agents, &[]);
447 let caps = ®["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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let _ = r.len();
659 }
660
661 #[test]
662 #[serial_test::parallel(mcp_serialize_fp)]
669 fn collect_mcp_resources_with_auto_resources_via_write_mcp_registry() {
670 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 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 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 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 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}