1use serde_json::Value;
27
28use super::csp::{CspConfig, Directive, effective_domains, is_public_proxy_origin};
29
30#[derive(Clone)]
32pub struct RewriteConfig {
33 pub proxy_url: String,
36 pub proxy_domain: String,
40 pub mcp_upstream: String,
43 pub csp: CspConfig,
45}
46
47impl RewriteConfig {
48 pub fn into_swap(self) -> std::sync::Arc<arc_swap::ArcSwap<RewriteConfig>> {
52 std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(self))
53 }
54}
55
56#[must_use]
62pub fn rewrite_response(method: &str, body: &mut Value, config: &RewriteConfig) -> bool {
63 let mut mutated = false;
64 match method {
65 "tools/list" => {
66 if let Some(tools) = body
67 .get_mut("result")
68 .and_then(|r| r.get_mut("tools"))
69 .and_then(|t| t.as_array_mut())
70 {
71 for tool in tools {
72 if let Some(meta) = tool.get_mut("_meta") {
73 rewrite_widget_meta(meta, None, config);
74 mutated = true;
75 }
76 }
77 }
78 }
79 "tools/call" => {
80 if let Some(meta) = body.get_mut("result").and_then(|r| r.get_mut("_meta")) {
81 rewrite_widget_meta(meta, None, config);
82 mutated = true;
83 }
84 }
85 "resources/list" => {
86 if let Some(resources) = body
87 .get_mut("result")
88 .and_then(|r| r.get_mut("resources"))
89 .and_then(|r| r.as_array_mut())
90 {
91 for resource in resources {
92 let uri = resource
93 .get("uri")
94 .and_then(|v| v.as_str())
95 .map(String::from);
96 let has_existing_meta = resource.get("_meta").is_some();
101 if (uri.is_some() || has_existing_meta)
102 && let Some(meta) = ensure_meta(resource)
103 {
104 rewrite_widget_meta(meta, uri.as_deref(), config);
105 mutated = true;
106 }
107 }
108 }
109 }
110 "resources/templates/list" => {
111 if let Some(templates) = body
112 .get_mut("result")
113 .and_then(|r| r.get_mut("resourceTemplates"))
114 .and_then(|t| t.as_array_mut())
115 {
116 for template in templates {
117 let uri = template
120 .get("uriTemplate")
121 .and_then(|v| v.as_str())
122 .map(String::from);
123 let has_existing_meta = template.get("_meta").is_some();
124 if (uri.is_some() || has_existing_meta)
125 && let Some(meta) = ensure_meta(template)
126 {
127 rewrite_widget_meta(meta, uri.as_deref(), config);
128 mutated = true;
129 }
130 }
131 }
132 }
133 "resources/read" => {
134 if let Some(contents) = body
135 .get_mut("result")
136 .and_then(|r| r.get_mut("contents"))
137 .and_then(|c| c.as_array_mut())
138 {
139 for content in contents {
140 let uri = content
141 .get("uri")
142 .and_then(|v| v.as_str())
143 .map(String::from);
144 let has_existing_meta = content.get("_meta").is_some();
145 if (uri.is_some() || has_existing_meta)
146 && let Some(meta) = ensure_meta(content)
147 {
148 rewrite_widget_meta(meta, uri.as_deref(), config);
149 mutated = true;
150 }
151 }
152 }
153 }
154 _ => {}
155 }
156
157 mutated |= inject_proxy_into_all_csp(body, config);
161 mutated
162}
163
164fn rewrite_widget_meta(meta: &mut Value, explicit_uri: Option<&str>, config: &RewriteConfig) {
186 if !is_widget_meta(meta, explicit_uri) {
187 let _ = inject_proxy_into_all_csp(meta, config);
191 return;
192 }
193
194 if !config.proxy_domain.is_empty() {
206 write_widget_domain(meta, &config.proxy_domain);
207 }
208
209 let inferred = explicit_uri
210 .map(String::from)
211 .or_else(|| extract_resource_uri(meta));
212 let uri = inferred.as_deref();
213 let upstream_host = strip_scheme(&config.mcp_upstream);
214
215 let connect = merged_domains(meta, Directive::Connect, uri, &upstream_host, config);
219 let resource = merged_domains(meta, Directive::Resource, uri, &upstream_host, config);
220 let frame = merged_domains(meta, Directive::Frame, uri, &upstream_host, config);
221
222 write_openai_csp(meta, &connect, &resource, &frame);
223 write_spec_csp(meta, &connect, &resource, &frame);
224
225 let _ = inject_proxy_into_all_csp(meta, config);
228}
229
230fn is_widget_meta(meta: &Value, explicit_uri: Option<&str>) -> bool {
234 if explicit_uri.is_some() {
235 return true;
236 }
237 meta.get("openai/widgetCSP").is_some()
238 || meta.get("openai/widgetDomain").is_some()
239 || meta.get("openai/outputTemplate").is_some()
240 || meta.pointer("/ui/csp").is_some()
241 || meta.pointer("/ui/resourceUri").is_some()
242 || meta.pointer("/ui/domain").is_some()
243}
244
245fn extract_resource_uri(meta: &Value) -> Option<String> {
248 if let Some(u) = meta.pointer("/ui/resourceUri").and_then(|v| v.as_str()) {
249 return Some(u.to_string());
250 }
251 meta.get("openai/outputTemplate")
252 .and_then(|v| v.as_str())
253 .map(String::from)
254}
255
256fn merged_domains(
259 meta: &Value,
260 directive: Directive,
261 resource_uri: Option<&str>,
262 upstream_host: &str,
263 config: &RewriteConfig,
264) -> Vec<String> {
265 let upstream = collect_upstream(meta, directive);
266 effective_domains(
267 &config.csp,
268 directive,
269 resource_uri,
270 &upstream,
271 upstream_host,
272 &config.proxy_url,
273 )
274}
275
276fn collect_upstream(meta: &Value, directive: Directive) -> Vec<String> {
279 let (openai_key, spec_key) = match directive {
280 Directive::Connect => ("connect_domains", "connectDomains"),
281 Directive::Resource => ("resource_domains", "resourceDomains"),
282 Directive::Frame => ("frame_domains", "frameDomains"),
283 };
284
285 let mut out: Vec<String> = Vec::new();
286 let mut append = |arr: &Vec<Value>| {
287 for v in arr {
288 if let Some(s) = v.as_str() {
289 let s = s.to_string();
290 if !out.contains(&s) {
291 out.push(s);
292 }
293 }
294 }
295 };
296
297 if let Some(arr) = meta
298 .get("openai/widgetCSP")
299 .and_then(|c| c.get(openai_key))
300 .and_then(|v| v.as_array())
301 {
302 append(arr);
303 }
304 if let Some(arr) = meta
305 .pointer("/ui/csp")
306 .and_then(|c| c.get(spec_key))
307 .and_then(|v| v.as_array())
308 {
309 append(arr);
310 }
311 out
312}
313
314fn write_widget_domain(meta: &mut Value, domain: &str) {
319 let Some(obj) = meta.as_object_mut() else {
320 return;
321 };
322 obj.insert(
323 "openai/widgetDomain".to_string(),
324 Value::String(domain.to_string()),
325 );
326}
327
328fn write_openai_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
329 let Some(obj) = meta.as_object_mut() else {
330 return;
331 };
332 obj.insert(
333 "openai/widgetCSP".to_string(),
334 serde_json::json!({
335 "connect_domains": connect,
336 "resource_domains": resource,
337 "frame_domains": frame,
338 }),
339 );
340}
341
342fn write_spec_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
344 let Some(obj) = meta.as_object_mut() else {
345 return;
346 };
347 let ui = obj
348 .entry("ui".to_string())
349 .or_insert_with(|| Value::Object(serde_json::Map::new()));
350 if !ui.is_object() {
351 *ui = Value::Object(serde_json::Map::new());
352 }
353 let ui_obj = ui.as_object_mut().unwrap();
354 ui_obj.insert(
355 "csp".to_string(),
356 serde_json::json!({
357 "connectDomains": connect,
358 "resourceDomains": resource,
359 "frameDomains": frame,
360 }),
361 );
362}
363
364#[must_use]
374fn inject_proxy_into_all_csp(value: &mut Value, config: &RewriteConfig) -> bool {
375 if !is_public_proxy_origin(&config.proxy_url) {
380 return false;
381 }
382 let mut mutated = false;
383 match value {
384 Value::Object(map) => {
385 for key in [
386 "connect_domains",
387 "resource_domains",
388 "connectDomains",
389 "resourceDomains",
390 ] {
391 if let Some(arr) = map.get_mut(key).and_then(|v| v.as_array_mut()) {
392 let has_proxy = arr.iter().any(|v| v.as_str() == Some(&config.proxy_url));
393 if !has_proxy {
394 arr.insert(0, Value::String(config.proxy_url.clone()));
395 mutated = true;
396 }
397 }
398 }
399 for (_, v) in map.iter_mut() {
400 mutated |= inject_proxy_into_all_csp(v, config);
401 }
402 }
403 Value::Array(arr) => {
404 for item in arr {
405 mutated |= inject_proxy_into_all_csp(item, config);
406 }
407 }
408 _ => {}
409 }
410 mutated
411}
412
413fn strip_scheme(url: &str) -> String {
414 url.trim_start_matches("https://")
415 .trim_start_matches("http://")
416 .split('/')
417 .next()
418 .unwrap_or("")
419 .to_string()
420}
421
422fn ensure_meta(container: &mut Value) -> Option<&mut Value> {
426 let obj = container.as_object_mut()?;
427 Some(
428 obj.entry("_meta".to_string())
429 .or_insert_with(|| Value::Object(serde_json::Map::new())),
430 )
431}
432
433#[cfg(test)]
434#[allow(non_snake_case)]
435mod tests {
436 use super::*;
437 use crate::proxy::csp::{DirectivePolicy, Mode, WidgetScoped};
438 use serde_json::json;
439
440 fn rewrite_config() -> RewriteConfig {
443 RewriteConfig {
444 proxy_url: "https://abc.tunnel.example.com".into(),
445 proxy_domain: "abc.tunnel.example.com".into(),
446 mcp_upstream: "http://localhost:9000".into(),
447 csp: CspConfig::default(),
448 }
449 }
450
451 fn as_strs(arr: &Value) -> Vec<&str> {
452 arr.as_array()
453 .unwrap()
454 .iter()
455 .map(|v| v.as_str().unwrap())
456 .collect()
457 }
458
459 #[test]
462 fn rewrite_response__resources_read_preserves_html() {
463 let config = rewrite_config();
464 let mut body = json!({
465 "jsonrpc": "2.0", "id": 1,
466 "result": {
467 "contents": [{
468 "uri": "ui://widget/question",
469 "mimeType": "text/html",
470 "text": "<html><script src=\"/assets/main.js\"></script></html>"
471 }]
472 }
473 });
474 let original = body["result"]["contents"][0]["text"]
475 .as_str()
476 .unwrap()
477 .to_string();
478
479 let _ = rewrite_response("resources/read", &mut body, &config);
480
481 assert_eq!(
482 body["result"]["contents"][0]["text"].as_str().unwrap(),
483 original
484 );
485 }
486
487 #[test]
490 fn rewrite_response__resources_read_rewrites_meta_not_text() {
491 let config = rewrite_config();
492 let mut body = json!({
493 "result": {
494 "contents": [{
495 "uri": "ui://widget/question",
496 "mimeType": "text/html",
497 "text": "<html><body>Hello</body></html>",
498 "_meta": {
499 "openai/widgetDomain": "localhost:9000",
500 "openai/widgetCSP": {
501 "resource_domains": ["http://localhost:9000"],
502 "connect_domains": ["http://localhost:9000"]
503 }
504 }
505 }]
506 }
507 });
508
509 let _ = rewrite_response("resources/read", &mut body, &config);
510
511 let content = &body["result"]["contents"][0];
512 assert_eq!(
513 content["text"].as_str().unwrap(),
514 "<html><body>Hello</body></html>"
515 );
516 assert_eq!(
517 content["_meta"]["openai/widgetDomain"].as_str().unwrap(),
518 "abc.tunnel.example.com"
519 );
520 let resources = as_strs(&content["_meta"]["openai/widgetCSP"]["resource_domains"]);
521 assert!(resources.contains(&"https://abc.tunnel.example.com"));
522 assert!(!resources.iter().any(|d| d.contains("localhost")));
523 }
524
525 #[test]
528 fn rewrite_response__tools_list_rewrites_widget_domain() {
529 let config = rewrite_config();
530 let mut body = json!({
531 "result": {
532 "tools": [{
533 "name": "create_question",
534 "_meta": {
535 "openai/widgetDomain": "old.domain.com",
536 "openai/widgetCSP": {
537 "resource_domains": ["http://localhost:4444"],
538 "connect_domains": ["http://localhost:9000", "https://api.external.com"]
539 }
540 }
541 }]
542 }
543 });
544
545 let _ = rewrite_response("tools/list", &mut body, &config);
546
547 let meta = &body["result"]["tools"][0]["_meta"];
548 assert_eq!(
549 meta["openai/widgetDomain"].as_str().unwrap(),
550 "abc.tunnel.example.com"
551 );
552 let connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
553 assert!(connect.contains(&"https://abc.tunnel.example.com"));
554 assert!(connect.contains(&"https://api.external.com"));
555 assert!(!connect.iter().any(|d| d.contains("localhost")));
556 }
557
558 #[test]
559 fn rewrite_widget_meta__upstream_openai_overwritten_ui_domain_absent() {
560 let config = rewrite_config();
561 let mut meta = json!({
562 "openai/widgetDomain": "old.domain.com"
563 });
564
565 rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
566
567 assert_eq!(
568 meta["openai/widgetDomain"].as_str().unwrap(),
569 "abc.tunnel.example.com"
570 );
571 assert!(meta.pointer("/ui/domain").is_none());
574 }
575
576 #[test]
577 fn rewrite_widget_meta__upstream_ui_domain_left_untouched() {
578 let config = rewrite_config();
582 let mut meta = json!({
583 "ui": { "domain": "old.domain.com" }
584 });
585
586 rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
587
588 assert_eq!(meta["ui"]["domain"].as_str().unwrap(), "old.domain.com");
589 assert_eq!(
590 meta["openai/widgetDomain"].as_str().unwrap(),
591 "abc.tunnel.example.com"
592 );
593 }
594
595 #[test]
596 fn rewrite_widget_meta__synthesizes_openai_only_when_upstream_declared_neither() {
597 let config = rewrite_config();
600 let mut meta = json!({
601 "openai/widgetCSP": { "connect_domains": [] }
602 });
603
604 rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
605
606 assert_eq!(
607 meta["openai/widgetDomain"].as_str().unwrap(),
608 "abc.tunnel.example.com"
609 );
610 assert!(meta.pointer("/ui/domain").is_none());
611 }
612
613 #[test]
614 fn rewrite_widget_meta__synthesizes_openai_only_when_explicit_uri_marks_widget() {
615 let config = rewrite_config();
616 let mut meta = json!({});
617
618 rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
619
620 assert_eq!(
621 meta["openai/widgetDomain"].as_str().unwrap(),
622 "abc.tunnel.example.com"
623 );
624 assert!(meta.pointer("/ui/domain").is_none());
625 }
626
627 #[test]
628 fn rewrite_widget_meta__non_widget_meta_does_not_get_widget_domain() {
629 let config = rewrite_config();
633 let mut meta = json!({ "some/unrelated": "value" });
634
635 rewrite_widget_meta(&mut meta, None, &config);
636
637 assert!(meta.get("openai/widgetDomain").is_none());
638 assert!(meta.pointer("/ui/domain").is_none());
639 }
640
641 #[test]
644 fn rewrite_response__tools_call_rewrites_meta() {
645 let config = rewrite_config();
646 let mut body = json!({
647 "result": {
648 "content": [{"type": "text", "text": "some result"}],
649 "_meta": {
650 "openai/widgetDomain": "old.domain.com",
651 "openai/widgetCSP": {
652 "resource_domains": ["http://localhost:4444"]
653 }
654 }
655 }
656 });
657
658 let _ = rewrite_response("tools/call", &mut body, &config);
659
660 assert_eq!(
661 body["result"]["_meta"]["openai/widgetDomain"]
662 .as_str()
663 .unwrap(),
664 "abc.tunnel.example.com"
665 );
666 assert_eq!(
667 body["result"]["content"][0]["text"].as_str().unwrap(),
668 "some result"
669 );
670 }
671
672 #[test]
675 fn rewrite_response__resources_list_rewrites_meta() {
676 let config = rewrite_config();
677 let mut body = json!({
678 "result": {
679 "resources": [{
680 "uri": "ui://widget/question",
681 "name": "Question Widget",
682 "_meta": {
683 "openai/widgetDomain": "old.domain.com"
684 }
685 }]
686 }
687 });
688
689 let _ = rewrite_response("resources/list", &mut body, &config);
690
691 assert_eq!(
692 body["result"]["resources"][0]["_meta"]["openai/widgetDomain"]
693 .as_str()
694 .unwrap(),
695 "abc.tunnel.example.com"
696 );
697 }
698
699 #[test]
702 fn rewrite_response__resources_templates_list_rewrites_meta() {
703 let config = rewrite_config();
704 let mut body = json!({
705 "result": {
706 "resourceTemplates": [{
707 "uriTemplate": "file:///{path}",
708 "name": "File Access",
709 "_meta": {
710 "openai/widgetDomain": "old.domain.com",
711 "openai/widgetCSP": {
712 "resource_domains": ["http://localhost:4444"],
713 "connect_domains": ["http://localhost:9000"]
714 }
715 }
716 }]
717 }
718 });
719
720 let _ = rewrite_response("resources/templates/list", &mut body, &config);
721
722 let meta = &body["result"]["resourceTemplates"][0]["_meta"];
723 assert_eq!(
724 meta["openai/widgetDomain"].as_str().unwrap(),
725 "abc.tunnel.example.com"
726 );
727 let resources = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
728 assert!(resources.contains(&"https://abc.tunnel.example.com"));
729 assert!(!resources.iter().any(|d| d.contains("localhost")));
730 }
731
732 #[test]
735 fn rewrite_response__csp_strips_localhost() {
736 let config = rewrite_config();
737 let mut body = json!({
738 "result": {
739 "tools": [{
740 "name": "test",
741 "_meta": {
742 "openai/widgetCSP": {
743 "resource_domains": [
744 "http://localhost:4444",
745 "http://127.0.0.1:4444",
746 "http://localhost:9000",
747 "https://cdn.external.com"
748 ]
749 }
750 }
751 }]
752 }
753 });
754
755 let _ = rewrite_response("tools/list", &mut body, &config);
756
757 let domains =
758 as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["resource_domains"]);
759 assert_eq!(
760 domains,
761 vec!["https://abc.tunnel.example.com", "https://cdn.external.com"]
762 );
763 }
764
765 #[test]
768 fn rewrite_response__global_connect_domains_appended() {
769 let mut config = rewrite_config();
770 config.csp.connect_domains = DirectivePolicy {
771 domains: vec!["https://extra.example.com".into()],
772 mode: Mode::Extend,
773 };
774
775 let mut body = json!({
776 "result": {
777 "tools": [{
778 "name": "test",
779 "_meta": {
780 "openai/widgetCSP": {
781 "connect_domains": ["http://localhost:9000"]
782 }
783 }
784 }]
785 }
786 });
787
788 let _ = rewrite_response("tools/list", &mut body, &config);
789
790 let domains =
791 as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
792 assert!(domains.contains(&"https://extra.example.com"));
793 assert!(domains.contains(&"https://abc.tunnel.example.com"));
794 }
795
796 #[test]
799 fn rewrite_response__csp_no_duplicate_proxy() {
800 let config = rewrite_config();
801 let mut body = json!({
802 "result": {
803 "tools": [{
804 "name": "test",
805 "_meta": {
806 "openai/widgetCSP": {
807 "resource_domains": ["https://abc.tunnel.example.com", "https://cdn.example.com"]
808 }
809 }
810 }]
811 }
812 });
813
814 let _ = rewrite_response("tools/list", &mut body, &config);
815
816 let domains =
817 as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["resource_domains"]);
818 let count = domains
819 .iter()
820 .filter(|d| **d == "https://abc.tunnel.example.com")
821 .count();
822 assert_eq!(count, 1);
823 }
824
825 #[test]
828 fn rewrite_response__claude_csp_format() {
829 let config = rewrite_config();
830 let mut body = json!({
831 "result": {
832 "tools": [{
833 "name": "test",
834 "_meta": {
835 "ui": {
836 "csp": {
837 "connectDomains": ["http://localhost:9000"],
838 "resourceDomains": ["http://localhost:4444"]
839 }
840 }
841 }
842 }]
843 }
844 });
845
846 let _ = rewrite_response("tools/list", &mut body, &config);
847
848 let meta = &body["result"]["tools"][0]["_meta"]["ui"]["csp"];
849 let connect = as_strs(&meta["connectDomains"]);
850 let resource = as_strs(&meta["resourceDomains"]);
851 assert!(connect.contains(&"https://abc.tunnel.example.com"));
852 assert!(resource.contains(&"https://abc.tunnel.example.com"));
853 assert!(!connect.iter().any(|d| d.contains("localhost")));
854 assert!(!resource.iter().any(|d| d.contains("localhost")));
855 }
856
857 #[test]
860 fn rewrite_response__deep_csp_injection() {
861 let config = rewrite_config();
862 let mut body = json!({
863 "result": {
864 "content": [{
865 "type": "text",
866 "text": "result",
867 "deeply": {
868 "nested": {
869 "connect_domains": ["https://only-external.com"]
870 }
871 }
872 }]
873 }
874 });
875
876 let _ = rewrite_response("tools/call", &mut body, &config);
877
878 let domains = as_strs(&body["result"]["content"][0]["deeply"]["nested"]["connect_domains"]);
879 assert!(domains.contains(&"https://abc.tunnel.example.com"));
880 }
881
882 #[test]
883 fn rewrite_response__deep_csp_injection_skips_frame_arrays() {
884 let config = rewrite_config();
889 let mut body = json!({
890 "result": {
891 "content": [{
892 "type": "text",
893 "text": "result",
894 "deeply": {
895 "nested": {
896 "frame_domains": ["https://embed.partner.com"],
897 "frameDomains": ["https://embed.partner.com"]
898 }
899 }
900 }]
901 }
902 });
903
904 let _ = rewrite_response("tools/call", &mut body, &config);
905
906 let nested = &body["result"]["content"][0]["deeply"]["nested"];
907 let snake = as_strs(&nested["frame_domains"]);
908 let camel = as_strs(&nested["frameDomains"]);
909 assert_eq!(snake, vec!["https://embed.partner.com"]);
910 assert_eq!(camel, vec!["https://embed.partner.com"]);
911 }
912
913 #[test]
916 fn rewrite_response__unknown_method_passthrough() {
917 let config = rewrite_config();
918 let mut body = json!({
919 "result": {
920 "data": "unchanged",
921 "_meta": { "openai/widgetDomain": "should-stay.com" }
922 }
923 });
924 let _ = rewrite_response("notifications/message", &mut body, &config);
925
926 assert_eq!(
927 body["result"]["_meta"]["openai/widgetDomain"]
928 .as_str()
929 .unwrap(),
930 "should-stay.com"
931 );
932 assert_eq!(body["result"]["data"].as_str().unwrap(), "unchanged");
933 }
934
935 #[test]
938 fn rewrite_response__replace_mode_ignores_upstream() {
939 let mut config = rewrite_config();
940 config.csp.resource_domains = DirectivePolicy {
941 domains: vec!["https://allowed.example.com".into()],
942 mode: Mode::Replace,
943 };
944 config.csp.connect_domains = DirectivePolicy {
945 domains: vec!["https://allowed.example.com".into()],
946 mode: Mode::Replace,
947 };
948
949 let mut body = json!({
950 "result": {
951 "tools": [{
952 "name": "test",
953 "_meta": {
954 "openai/widgetCSP": {
955 "resource_domains": ["https://cdn.external.com", "https://api.external.com"],
956 "connect_domains": ["https://api.external.com", "http://localhost:9000"]
957 }
958 }
959 }]
960 }
961 });
962
963 let _ = rewrite_response("tools/list", &mut body, &config);
964
965 let resources =
966 as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["resource_domains"]);
967 assert_eq!(
968 resources,
969 vec![
970 "https://abc.tunnel.example.com",
971 "https://allowed.example.com"
972 ]
973 );
974 let connect =
975 as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
976 assert_eq!(
977 connect,
978 vec![
979 "https://abc.tunnel.example.com",
980 "https://allowed.example.com"
981 ]
982 );
983 }
984
985 #[test]
988 fn rewrite_response__widget_scope_matches_resource_uri() {
989 let mut config = rewrite_config();
991 config.csp.widgets.push(WidgetScoped {
992 match_pattern: "ui://widget/payment*".into(),
993 connect_domains: vec!["https://api.stripe.com".into()],
994 connect_domains_mode: Mode::Extend,
995 ..Default::default()
996 });
997
998 let mut body = json!({
999 "result": {
1000 "resources": [
1001 {
1002 "uri": "ui://widget/payment-form",
1003 "_meta": {
1004 "openai/widgetCSP": { "connect_domains": [] }
1005 }
1006 },
1007 {
1008 "uri": "ui://widget/search",
1009 "_meta": {
1010 "openai/widgetCSP": { "connect_domains": [] }
1011 }
1012 }
1013 ]
1014 }
1015 });
1016
1017 let _ = rewrite_response("resources/list", &mut body, &config);
1018
1019 let payment_connect = as_strs(
1020 &body["result"]["resources"][0]["_meta"]["openai/widgetCSP"]["connect_domains"],
1021 );
1022 assert!(payment_connect.contains(&"https://api.stripe.com"));
1023
1024 let search_connect = as_strs(
1025 &body["result"]["resources"][1]["_meta"]["openai/widgetCSP"]["connect_domains"],
1026 );
1027 assert!(!search_connect.contains(&"https://api.stripe.com"));
1028 }
1029
1030 #[test]
1031 fn rewrite_response__widget_replace_mode_wipes_upstream() {
1032 let mut config = rewrite_config();
1033 config.csp.widgets.push(WidgetScoped {
1034 match_pattern: "ui://widget/*".into(),
1035 connect_domains: vec!["https://api.stripe.com".into()],
1036 connect_domains_mode: Mode::Replace,
1037 ..Default::default()
1038 });
1039
1040 let mut body = json!({
1041 "result": {
1042 "contents": [{
1043 "uri": "ui://widget/payment",
1044 "_meta": {
1045 "openai/widgetCSP": {
1046 "connect_domains": [
1047 "https://api.external.com",
1048 "https://another.external.com"
1049 ]
1050 }
1051 }
1052 }]
1053 }
1054 });
1055
1056 let _ = rewrite_response("resources/read", &mut body, &config);
1057
1058 let connect =
1059 as_strs(&body["result"]["contents"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
1060 assert_eq!(
1061 connect,
1062 vec!["https://abc.tunnel.example.com", "https://api.stripe.com"]
1063 );
1064 }
1065
1066 #[test]
1067 fn rewrite_response__widget_uri_inferred_from_tool_meta() {
1068 let mut config = rewrite_config();
1072 config.csp.widgets.push(WidgetScoped {
1073 match_pattern: "ui://widget/payment*".into(),
1074 connect_domains: vec!["https://api.stripe.com".into()],
1075 connect_domains_mode: Mode::Extend,
1076 ..Default::default()
1077 });
1078
1079 let mut body = json!({
1080 "result": {
1081 "tools": [{
1082 "name": "take_payment",
1083 "_meta": {
1084 "ui": { "resourceUri": "ui://widget/payment-form" },
1085 "openai/widgetCSP": { "connect_domains": [] }
1086 }
1087 }]
1088 }
1089 });
1090
1091 let _ = rewrite_response("tools/list", &mut body, &config);
1092
1093 let connect =
1094 as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
1095 assert!(connect.contains(&"https://api.stripe.com"));
1096 }
1097
1098 #[test]
1101 fn rewrite_response__spec_only_upstream_also_emits_openai_shape() {
1102 let config = rewrite_config();
1106 let mut body = json!({
1107 "result": {
1108 "contents": [{
1109 "uri": "ui://widget/search",
1110 "mimeType": "text/html",
1111 "_meta": {
1112 "ui": {
1113 "csp": {
1114 "connectDomains": ["https://api.external.com"],
1115 "resourceDomains": ["https://cdn.external.com"]
1116 }
1117 }
1118 }
1119 }]
1120 }
1121 });
1122
1123 let _ = rewrite_response("resources/read", &mut body, &config);
1124
1125 let meta = &body["result"]["contents"][0]["_meta"];
1126 let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1127 let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1128 assert_eq!(oa_connect, spec_connect);
1129 assert!(oa_connect.contains(&"https://api.external.com"));
1130 assert!(oa_connect.contains(&"https://abc.tunnel.example.com"));
1131 }
1132
1133 #[test]
1134 fn rewrite_response__openai_only_upstream_also_emits_spec_shape() {
1135 let config = rewrite_config();
1138 let mut body = json!({
1139 "result": {
1140 "contents": [{
1141 "uri": "ui://widget/search",
1142 "mimeType": "text/html",
1143 "_meta": {
1144 "openai/widgetCSP": {
1145 "connect_domains": ["https://api.external.com"],
1146 "resource_domains": ["https://cdn.external.com"]
1147 }
1148 }
1149 }]
1150 }
1151 });
1152
1153 let _ = rewrite_response("resources/read", &mut body, &config);
1154
1155 let meta = &body["result"]["contents"][0]["_meta"];
1156 let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1157 let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1158 assert_eq!(oa_connect, spec_connect);
1159 assert!(spec_connect.contains(&"https://api.external.com"));
1160 assert!(spec_connect.contains(&"https://abc.tunnel.example.com"));
1161 }
1162
1163 #[test]
1164 fn rewrite_response__declared_config_synthesizes_both_shapes_from_empty() {
1165 let mut config = rewrite_config();
1169 config.csp.connect_domains = DirectivePolicy {
1170 domains: vec!["https://api.declared.com".into()],
1171 mode: Mode::Extend,
1172 };
1173
1174 let mut body = json!({
1175 "result": {
1176 "resources": [{
1177 "uri": "ui://widget/search",
1178 "_meta": {
1179 "openai/widgetDomain": "old.domain.com"
1180 }
1181 }]
1182 }
1183 });
1184
1185 let _ = rewrite_response("resources/list", &mut body, &config);
1186
1187 let meta = &body["result"]["resources"][0]["_meta"];
1188 let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1189 let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1190 assert_eq!(oa, spec);
1191 assert!(oa.contains(&"https://api.declared.com"));
1192 assert!(oa.contains(&"https://abc.tunnel.example.com"));
1193 }
1194
1195 #[test]
1196 fn rewrite_response__upstream_declarations_unioned_across_shapes() {
1197 let config = rewrite_config();
1200 let mut body = json!({
1201 "result": {
1202 "contents": [{
1203 "uri": "ui://widget/search",
1204 "mimeType": "text/html",
1205 "_meta": {
1206 "openai/widgetCSP": {
1207 "connect_domains": ["https://api.only-openai.com"]
1208 },
1209 "ui": {
1210 "csp": {
1211 "connectDomains": ["https://api.only-spec.com"]
1212 }
1213 }
1214 }
1215 }]
1216 }
1217 });
1218
1219 let _ = rewrite_response("resources/read", &mut body, &config);
1220
1221 let meta = &body["result"]["contents"][0]["_meta"];
1222 let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1223 let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1224 assert_eq!(oa, spec);
1225 assert!(oa.contains(&"https://api.only-openai.com"));
1226 assert!(oa.contains(&"https://api.only-spec.com"));
1227 }
1228
1229 #[test]
1230 fn rewrite_response__non_widget_meta_is_not_polluted() {
1231 let config = rewrite_config();
1234 let mut body = json!({
1235 "result": {
1236 "content": [{"type": "text", "text": "plain result"}],
1237 "_meta": { "requestId": "abc-123" }
1238 }
1239 });
1240
1241 let _ = rewrite_response("tools/call", &mut body, &config);
1242
1243 let meta = &body["result"]["_meta"];
1244 assert!(meta.get("openai/widgetCSP").is_none());
1245 assert!(meta.get("ui").is_none());
1246 assert_eq!(meta["requestId"].as_str().unwrap(), "abc-123");
1247 }
1248
1249 #[test]
1250 fn rewrite_response__all_three_directives_synthesized() {
1251 let mut config = rewrite_config();
1253 config.csp.connect_domains = DirectivePolicy {
1254 domains: vec!["https://api.example.com".into()],
1255 mode: Mode::Extend,
1256 };
1257 config.csp.resource_domains = DirectivePolicy {
1258 domains: vec!["https://cdn.example.com".into()],
1259 mode: Mode::Extend,
1260 };
1261
1262 let mut body = json!({
1263 "result": {
1264 "resources": [{
1265 "uri": "ui://widget/search",
1266 "_meta": { "openai/widgetDomain": "x" }
1267 }]
1268 }
1269 });
1270
1271 let _ = rewrite_response("resources/list", &mut body, &config);
1272
1273 let meta = &body["result"]["resources"][0]["_meta"];
1274 let shape = "openai/widgetCSP";
1275 assert!(meta[shape]["connect_domains"].is_array());
1276 assert!(meta[shape]["resource_domains"].is_array());
1277 assert!(meta[shape]["frame_domains"].is_array());
1278 assert!(meta["ui"]["csp"]["connectDomains"].is_array());
1279 assert!(meta["ui"]["csp"]["resourceDomains"].is_array());
1280 assert!(meta["ui"]["csp"]["frameDomains"].is_array());
1281 }
1282
1283 #[test]
1286 fn rewrite_response__frame_domains_default_replace_drops_upstream() {
1287 let config = rewrite_config();
1292 let mut body = json!({
1293 "result": {
1294 "tools": [{
1295 "name": "test",
1296 "_meta": {
1297 "ui": {
1298 "csp": {
1299 "frameDomains": ["https://embed.external.com"]
1300 }
1301 }
1302 }
1303 }]
1304 }
1305 });
1306
1307 let _ = rewrite_response("tools/list", &mut body, &config);
1308
1309 let frames = as_strs(&body["result"]["tools"][0]["_meta"]["ui"]["csp"]["frameDomains"]);
1310 assert!(
1311 frames.is_empty(),
1312 "frame_domains should be empty, got {frames:?}"
1313 );
1314 }
1315
1316 #[test]
1319 fn rewrite_response__end_to_end_mcp_schema() {
1320 let mut config = rewrite_config();
1331 config.csp.connect_domains = DirectivePolicy {
1332 domains: vec!["https://api.myshop.com".into()],
1333 mode: Mode::Extend,
1334 };
1335 config.csp.resource_domains = DirectivePolicy {
1336 domains: vec!["https://cdn.myshop.com".into()],
1337 mode: Mode::Extend,
1338 };
1339 config.csp.widgets.push(WidgetScoped {
1340 match_pattern: "ui://widget/payment*".into(),
1341 connect_domains: vec!["https://api.stripe.com".into()],
1342 connect_domains_mode: Mode::Extend,
1343 resource_domains: vec!["https://js.stripe.com".into()],
1344 resource_domains_mode: Mode::Extend,
1345 ..Default::default()
1346 });
1347
1348 let mut body = json!({
1349 "jsonrpc": "2.0",
1350 "id": 42,
1351 "result": {
1352 "tools": [
1353 {
1354 "name": "search_products",
1355 "description": "Search the product catalog",
1356 "inputSchema": { "type": "object" },
1357 "_meta": {
1358 "openai/widgetDomain": "old.shop.com",
1359 "openai/outputTemplate": "ui://widget/search",
1360 "openai/widgetCSP": {
1361 "connect_domains": ["http://localhost:9000"],
1362 "resource_domains": ["http://localhost:4444"]
1363 }
1364 }
1365 },
1366 {
1367 "name": "take_payment",
1368 "description": "Charge a card",
1369 "inputSchema": { "type": "object" },
1370 "_meta": {
1371 "ui": {
1372 "resourceUri": "ui://widget/payment-form",
1373 "csp": {
1374 "connectDomains": ["https://api.myshop.com"]
1375 }
1376 }
1377 }
1378 },
1379 {
1380 "name": "get_order_status",
1381 "description": "Look up an order",
1382 "inputSchema": { "type": "object" }
1383 }
1384 ]
1385 }
1386 });
1387
1388 let _ = rewrite_response("tools/list", &mut body, &config);
1389
1390 let tools = body["result"]["tools"].as_array().unwrap();
1391
1392 let search_meta = &tools[0]["_meta"];
1394 assert_eq!(
1395 search_meta["openai/widgetDomain"].as_str().unwrap(),
1396 "abc.tunnel.example.com"
1397 );
1398 let search_oa_connect = as_strs(&search_meta["openai/widgetCSP"]["connect_domains"]);
1399 let search_spec_connect = as_strs(&search_meta["ui"]["csp"]["connectDomains"]);
1400 assert_eq!(search_oa_connect, search_spec_connect);
1401 assert_eq!(
1403 search_oa_connect,
1404 vec!["https://abc.tunnel.example.com", "https://api.myshop.com"]
1405 );
1406 assert!(!search_oa_connect.contains(&"https://api.stripe.com"));
1408 let search_oa_frame = as_strs(&search_meta["openai/widgetCSP"]["frame_domains"]);
1411 assert!(search_oa_frame.is_empty());
1412
1413 let payment_meta = &tools[1]["_meta"];
1415 let payment_oa_connect = as_strs(&payment_meta["openai/widgetCSP"]["connect_domains"]);
1416 let payment_spec_connect = as_strs(&payment_meta["ui"]["csp"]["connectDomains"]);
1417 assert_eq!(payment_oa_connect, payment_spec_connect);
1418 assert_eq!(
1420 payment_oa_connect,
1421 vec![
1422 "https://abc.tunnel.example.com",
1423 "https://api.myshop.com",
1424 "https://api.stripe.com",
1425 ]
1426 );
1427 let payment_oa_resource = as_strs(&payment_meta["openai/widgetCSP"]["resource_domains"]);
1428 assert_eq!(
1429 payment_oa_resource,
1430 vec![
1431 "https://abc.tunnel.example.com",
1432 "https://cdn.myshop.com",
1433 "https://js.stripe.com",
1434 ]
1435 );
1436
1437 let plain = &tools[2];
1440 assert!(plain.get("_meta").is_none());
1441 }
1442
1443 #[test]
1446 fn rewrite_response__tools_call_underscore_meta_is_rewritten() {
1447 let mut config = rewrite_config();
1451 config.csp.connect_domains = DirectivePolicy {
1452 domains: vec!["https://assets.usestudykit.com".into()],
1453 mode: Mode::Replace,
1454 };
1455 config.csp.resource_domains = DirectivePolicy {
1456 domains: vec!["https://assets.usestudykit.com".into()],
1457 mode: Mode::Replace,
1458 };
1459
1460 let mut body = json!({
1461 "result": {
1462 "_meta": {
1463 "openai/outputTemplate": "ui://widget/vocab_review.html",
1464 "openai/widgetDomain": "assets.usestudykit.com/src",
1465 "openai/widgetCSP": {
1466 "connect_domains": [
1467 "http://localhost:9002",
1468 "https://api.dictionaryapi.dev"
1469 ],
1470 "resource_domains": [
1471 "http://localhost:9002",
1472 "https://api.dictionaryapi.dev"
1473 ]
1474 },
1475 "ui": {
1476 "csp": {
1477 "connectDomains": ["https://api.dictionaryapi.dev"],
1478 "resourceDomains": ["https://api.dictionaryapi.dev"]
1479 },
1480 "resourceUri": "ui://widget/vocab_review.html"
1481 }
1482 },
1483 "content": [{"type": "text", "text": "payload"}],
1484 "structuredContent": {"data": {"items": []}}
1485 }
1486 });
1487
1488 let _ = rewrite_response("tools/call", &mut body, &config);
1489
1490 let meta = &body["result"]["_meta"];
1491 assert_eq!(
1492 meta["openai/widgetDomain"].as_str().unwrap(),
1493 "abc.tunnel.example.com"
1494 );
1495 let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1496 let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1497 assert_eq!(oa_connect, spec_connect);
1498 assert_eq!(
1499 oa_connect,
1500 vec![
1501 "https://abc.tunnel.example.com",
1502 "https://assets.usestudykit.com"
1503 ]
1504 );
1505 let oa_resource = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
1506 assert_eq!(
1507 oa_resource,
1508 vec![
1509 "https://abc.tunnel.example.com",
1510 "https://assets.usestudykit.com"
1511 ]
1512 );
1513 assert_eq!(
1514 body["result"]["content"][0]["text"].as_str().unwrap(),
1515 "payload"
1516 );
1517 }
1518
1519 #[test]
1520 fn rewrite_response__resources_read_underscore_meta_is_rewritten() {
1521 let config = rewrite_config();
1522 let mut body = json!({
1523 "result": {
1524 "contents": [{
1525 "uri": "ui://widget/question",
1526 "mimeType": "text/html",
1527 "text": "<html/>",
1528 "_meta": {
1529 "openai/widgetDomain": "old.domain.com"
1530 }
1531 }]
1532 }
1533 });
1534
1535 let _ = rewrite_response("resources/read", &mut body, &config);
1536
1537 assert_eq!(
1538 body["result"]["contents"][0]["_meta"]["openai/widgetDomain"]
1539 .as_str()
1540 .unwrap(),
1541 "abc.tunnel.example.com"
1542 );
1543 }
1544
1545 #[test]
1546 fn rewrite_response__legacy_meta_key_is_ignored() {
1547 let config = rewrite_config();
1550 let mut body = json!({
1551 "result": {
1552 "_meta": {"openai/widgetDomain": "real.domain.com"},
1553 "meta": {"openai/widgetDomain": "should-stay.com"}
1554 }
1555 });
1556
1557 let _ = rewrite_response("tools/call", &mut body, &config);
1558
1559 assert_eq!(
1560 body["result"]["_meta"]["openai/widgetDomain"]
1561 .as_str()
1562 .unwrap(),
1563 "abc.tunnel.example.com"
1564 );
1565 assert_eq!(
1566 body["result"]["meta"]["openai/widgetDomain"]
1567 .as_str()
1568 .unwrap(),
1569 "should-stay.com"
1570 );
1571 }
1572
1573 #[test]
1576 fn rewrite_response__resources_list_synthesizes_meta_when_upstream_omits() {
1577 let mut config = rewrite_config();
1580 config.csp.connect_domains = DirectivePolicy {
1581 domains: vec!["https://api.declared.com".into()],
1582 mode: Mode::Replace,
1583 };
1584 config.csp.resource_domains = DirectivePolicy {
1585 domains: vec!["https://cdn.declared.com".into()],
1586 mode: Mode::Extend,
1587 };
1588
1589 let mut body = json!({
1590 "result": {
1591 "resources": [{
1592 "uri": "ui://widget/search",
1593 "name": "Search Widget"
1594 }]
1595 }
1596 });
1597
1598 let mutated = rewrite_response("resources/list", &mut body, &config);
1599 assert!(mutated);
1600
1601 let meta = &body["result"]["resources"][0]["_meta"];
1602 let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1603 let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1604 assert_eq!(oa_connect, spec_connect);
1605 assert_eq!(
1606 oa_connect,
1607 vec!["https://abc.tunnel.example.com", "https://api.declared.com"]
1608 );
1609 let oa_resource = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
1610 assert!(oa_resource.contains(&"https://abc.tunnel.example.com"));
1611 assert!(oa_resource.contains(&"https://cdn.declared.com"));
1612 }
1613
1614 #[test]
1615 fn rewrite_response__resources_read_synthesizes_meta_when_upstream_omits() {
1616 let mut config = rewrite_config();
1617 config.csp.connect_domains = DirectivePolicy {
1618 domains: vec!["https://api.declared.com".into()],
1619 mode: Mode::Replace,
1620 };
1621
1622 let mut body = json!({
1623 "result": {
1624 "contents": [{
1625 "uri": "ui://widget/question",
1626 "mimeType": "text/html",
1627 "text": "<html><body>Hello</body></html>"
1628 }]
1629 }
1630 });
1631
1632 let mutated = rewrite_response("resources/read", &mut body, &config);
1633 assert!(mutated);
1634
1635 assert_eq!(
1636 body["result"]["contents"][0]["text"].as_str().unwrap(),
1637 "<html><body>Hello</body></html>"
1638 );
1639 let meta = &body["result"]["contents"][0]["_meta"];
1640 let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1641 let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1642 assert_eq!(oa, spec);
1643 assert_eq!(
1644 oa,
1645 vec!["https://abc.tunnel.example.com", "https://api.declared.com"]
1646 );
1647 }
1648
1649 #[test]
1650 fn rewrite_response__resources_list_injects_into_empty_meta() {
1651 let mut config = rewrite_config();
1652 config.csp.connect_domains = DirectivePolicy {
1653 domains: vec!["https://api.declared.com".into()],
1654 mode: Mode::Extend,
1655 };
1656
1657 let mut body = json!({
1658 "result": {
1659 "resources": [{
1660 "uri": "ui://widget/search",
1661 "_meta": {}
1662 }]
1663 }
1664 });
1665
1666 let _ = rewrite_response("resources/list", &mut body, &config);
1667
1668 let meta = &body["result"]["resources"][0]["_meta"];
1669 let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1670 assert!(oa.contains(&"https://api.declared.com"));
1671 assert!(oa.contains(&"https://abc.tunnel.example.com"));
1672 }
1673
1674 #[test]
1675 fn rewrite_response__resources_templates_list_synthesizes_meta() {
1676 let mut config = rewrite_config();
1677 config.csp.resource_domains = DirectivePolicy {
1678 domains: vec!["https://cdn.declared.com".into()],
1679 mode: Mode::Extend,
1680 };
1681
1682 let mut body = json!({
1683 "result": {
1684 "resourceTemplates": [{
1685 "uriTemplate": "ui://widget/{name}.html",
1686 "name": "Widget Template"
1687 }]
1688 }
1689 });
1690
1691 let _ = rewrite_response("resources/templates/list", &mut body, &config);
1692
1693 let meta = &body["result"]["resourceTemplates"][0]["_meta"];
1694 let oa = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
1695 assert!(oa.contains(&"https://cdn.declared.com"));
1696 assert!(oa.contains(&"https://abc.tunnel.example.com"));
1697 }
1698
1699 #[test]
1700 fn rewrite_response__tools_call_no_meta_is_not_synthesized() {
1701 let mut config = rewrite_config();
1704 config.csp.connect_domains = DirectivePolicy {
1705 domains: vec!["https://api.declared.com".into()],
1706 mode: Mode::Replace,
1707 };
1708
1709 let mut body = json!({
1710 "result": {
1711 "content": [{"type": "text", "text": "London 14C"}],
1712 "structuredContent": {"city": "London", "temp": 14}
1713 }
1714 });
1715
1716 let _ = rewrite_response("tools/call", &mut body, &config);
1717
1718 assert!(body["result"].get("_meta").is_none());
1719 assert_eq!(
1720 body["result"]["content"][0]["text"].as_str().unwrap(),
1721 "London 14C"
1722 );
1723 }
1724
1725 #[test]
1726 fn rewrite_response__resources_list_skips_when_no_uri_and_no_meta() {
1727 let config = rewrite_config();
1728 let mut body = json!({
1729 "result": {
1730 "resources": [{
1731 "name": "malformed"
1732 }]
1733 }
1734 });
1735
1736 let _ = rewrite_response("resources/list", &mut body, &config);
1737
1738 assert!(body["result"]["resources"][0].get("_meta").is_none());
1739 }
1740
1741 fn local_only_config() -> RewriteConfig {
1744 RewriteConfig {
1749 proxy_url: "http://localhost:9002".into(),
1750 proxy_domain: String::new(),
1751 mcp_upstream: "http://localhost:9000".into(),
1752 csp: CspConfig::default(),
1753 }
1754 }
1755
1756 #[test]
1757 fn rewrite_response__local_only_leaves_widget_domain_untouched() {
1758 let config = local_only_config();
1759 let mut body = json!({
1760 "result": {
1761 "contents": [{
1762 "uri": "ui://widget/card",
1763 "_meta": {
1764 "openai/widgetDomain": "dev.example.com"
1765 }
1766 }]
1767 }
1768 });
1769
1770 let _ = rewrite_response("resources/read", &mut body, &config);
1771
1772 assert_eq!(
1773 body["result"]["contents"][0]["_meta"]["openai/widgetDomain"]
1774 .as_str()
1775 .unwrap(),
1776 "dev.example.com",
1777 );
1778 }
1779
1780 #[test]
1781 fn rewrite_response__local_only_leaves_ui_domain_untouched() {
1782 let config = local_only_config();
1783 let mut body = json!({
1784 "result": {
1785 "contents": [{
1786 "uri": "ui://widget/card",
1787 "_meta": {
1788 "ui": { "domain": "dev.example.com" }
1789 }
1790 }]
1791 }
1792 });
1793
1794 let _ = rewrite_response("resources/read", &mut body, &config);
1795
1796 assert_eq!(
1797 body["result"]["contents"][0]["_meta"]["ui"]["domain"]
1798 .as_str()
1799 .unwrap(),
1800 "dev.example.com",
1801 );
1802 assert!(
1803 body["result"]["contents"][0]["_meta"]
1804 .get("openai/widgetDomain")
1805 .is_none(),
1806 "must not synthesize the openai shape in local-only mode"
1807 );
1808 }
1809
1810 #[test]
1811 fn rewrite_response__local_only_skips_csp_injection() {
1812 let config = local_only_config();
1813 let mut body = json!({
1814 "result": {
1815 "contents": [{
1816 "uri": "ui://widget/card",
1817 "_meta": {
1818 "openai/widgetCSP": {
1819 "connect_domains": ["https://api.example.com"],
1820 "resource_domains": ["https://cdn.example.com"],
1821 "frame_domains": []
1822 }
1823 }
1824 }]
1825 }
1826 });
1827
1828 let _ = rewrite_response("resources/read", &mut body, &config);
1829
1830 let csp = &body["result"]["contents"][0]["_meta"]["openai/widgetCSP"];
1831 assert_eq!(
1832 as_strs(&csp["connect_domains"]),
1833 vec!["https://api.example.com"],
1834 "localhost proxy_url must not be injected",
1835 );
1836 assert_eq!(
1837 as_strs(&csp["resource_domains"]),
1838 vec!["https://cdn.example.com"],
1839 );
1840 }
1841
1842 #[test]
1843 fn rewrite_response__public_domain_is_injected() {
1844 let config = rewrite_config();
1846 let mut body = json!({
1847 "result": {
1848 "contents": [{
1849 "uri": "ui://widget/card",
1850 "_meta": {
1851 "openai/widgetCSP": {
1852 "connect_domains": ["https://api.example.com"],
1853 "resource_domains": [],
1854 "frame_domains": []
1855 }
1856 }
1857 }]
1858 }
1859 });
1860
1861 let _ = rewrite_response("resources/read", &mut body, &config);
1862
1863 let connect =
1864 as_strs(&body["result"]["contents"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
1865 assert!(connect.contains(&"https://abc.tunnel.example.com"));
1866 }
1867}