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