1use serde_json::Value;
27
28use super::csp::{CspConfig, Directive, effective_domains};
29use crate::protocol as jsonrpc;
30
31#[derive(Clone)]
33pub struct RewriteConfig {
34 pub proxy_url: String,
37 pub proxy_domain: String,
39 pub mcp_upstream: String,
42 pub csp: CspConfig,
44}
45
46impl RewriteConfig {
47 pub fn into_swap(self) -> std::sync::Arc<arc_swap::ArcSwap<RewriteConfig>> {
51 std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(self))
52 }
53}
54
55#[must_use]
61pub fn rewrite_response(method: &str, body: &mut Value, config: &RewriteConfig) -> bool {
62 let mut mutated = false;
63 match method {
64 jsonrpc::TOOLS_LIST => {
65 if let Some(tools) = body
66 .get_mut("result")
67 .and_then(|r| r.get_mut("tools"))
68 .and_then(|t| t.as_array_mut())
69 {
70 for tool in tools {
71 if let Some(meta) = tool.get_mut("meta") {
72 rewrite_widget_meta(meta, None, config);
73 mutated = true;
74 }
75 }
76 }
77 }
78 jsonrpc::TOOLS_CALL => {
79 if let Some(meta) = body.get_mut("result").and_then(|r| r.get_mut("meta")) {
80 rewrite_widget_meta(meta, None, config);
81 mutated = true;
82 }
83 }
84 jsonrpc::RESOURCES_LIST => {
85 if let Some(resources) = body
86 .get_mut("result")
87 .and_then(|r| r.get_mut("resources"))
88 .and_then(|r| r.as_array_mut())
89 {
90 for resource in resources {
91 let uri = resource
92 .get("uri")
93 .and_then(|v| v.as_str())
94 .map(String::from);
95 if let Some(meta) = resource.get_mut("meta") {
96 rewrite_widget_meta(meta, uri.as_deref(), config);
97 mutated = true;
98 }
99 }
100 }
101 }
102 jsonrpc::RESOURCES_TEMPLATES_LIST => {
103 if let Some(templates) = body
104 .get_mut("result")
105 .and_then(|r| r.get_mut("resourceTemplates"))
106 .and_then(|t| t.as_array_mut())
107 {
108 for template in templates {
109 let uri = template
112 .get("uriTemplate")
113 .and_then(|v| v.as_str())
114 .map(String::from);
115 if let Some(meta) = template.get_mut("meta") {
116 rewrite_widget_meta(meta, uri.as_deref(), config);
117 mutated = true;
118 }
119 }
120 }
121 }
122 jsonrpc::RESOURCES_READ => {
123 if let Some(contents) = body
124 .get_mut("result")
125 .and_then(|r| r.get_mut("contents"))
126 .and_then(|c| c.as_array_mut())
127 {
128 for content in contents {
129 let uri = content
130 .get("uri")
131 .and_then(|v| v.as_str())
132 .map(String::from);
133 if let Some(meta) = content.get_mut("meta") {
134 rewrite_widget_meta(meta, uri.as_deref(), config);
135 mutated = true;
136 }
137 }
138 }
139 }
140 _ => {}
141 }
142
143 mutated |= inject_proxy_into_all_csp(body, config);
147 mutated
148}
149
150fn rewrite_widget_meta(meta: &mut Value, explicit_uri: Option<&str>, config: &RewriteConfig) {
172 if meta.get("openai/widgetDomain").is_some() {
173 meta["openai/widgetDomain"] = Value::String(config.proxy_domain.clone());
174 }
175
176 if !is_widget_meta(meta, explicit_uri) {
177 let _ = inject_proxy_into_all_csp(meta, config);
180 return;
181 }
182
183 let inferred = explicit_uri
184 .map(String::from)
185 .or_else(|| extract_resource_uri(meta));
186 let uri = inferred.as_deref();
187 let upstream_host = strip_scheme(&config.mcp_upstream);
188
189 let connect = merged_domains(meta, Directive::Connect, uri, &upstream_host, config);
193 let resource = merged_domains(meta, Directive::Resource, uri, &upstream_host, config);
194 let frame = merged_domains(meta, Directive::Frame, uri, &upstream_host, config);
195
196 write_openai_csp(meta, &connect, &resource, &frame);
197 write_spec_csp(meta, &connect, &resource, &frame);
198
199 let _ = inject_proxy_into_all_csp(meta, config);
201}
202
203fn is_widget_meta(meta: &Value, explicit_uri: Option<&str>) -> bool {
207 if explicit_uri.is_some() {
208 return true;
209 }
210 meta.get("openai/widgetCSP").is_some()
211 || meta.get("openai/widgetDomain").is_some()
212 || meta.get("openai/outputTemplate").is_some()
213 || meta.pointer("/ui/csp").is_some()
214 || meta.pointer("/ui/resourceUri").is_some()
215 || meta.pointer("/ui/domain").is_some()
216}
217
218fn extract_resource_uri(meta: &Value) -> Option<String> {
221 if let Some(u) = meta.pointer("/ui/resourceUri").and_then(|v| v.as_str()) {
222 return Some(u.to_string());
223 }
224 meta.get("openai/outputTemplate")
225 .and_then(|v| v.as_str())
226 .map(String::from)
227}
228
229fn merged_domains(
232 meta: &Value,
233 directive: Directive,
234 resource_uri: Option<&str>,
235 upstream_host: &str,
236 config: &RewriteConfig,
237) -> Vec<String> {
238 let upstream = collect_upstream(meta, directive);
239 effective_domains(
240 &config.csp,
241 directive,
242 resource_uri,
243 &upstream,
244 upstream_host,
245 &config.proxy_url,
246 )
247}
248
249fn collect_upstream(meta: &Value, directive: Directive) -> Vec<String> {
252 let (openai_key, spec_key) = match directive {
253 Directive::Connect => ("connect_domains", "connectDomains"),
254 Directive::Resource => ("resource_domains", "resourceDomains"),
255 Directive::Frame => ("frame_domains", "frameDomains"),
256 };
257
258 let mut out: Vec<String> = Vec::new();
259 let mut append = |arr: &Vec<Value>| {
260 for v in arr {
261 if let Some(s) = v.as_str() {
262 let s = s.to_string();
263 if !out.contains(&s) {
264 out.push(s);
265 }
266 }
267 }
268 };
269
270 if let Some(arr) = meta
271 .get("openai/widgetCSP")
272 .and_then(|c| c.get(openai_key))
273 .and_then(|v| v.as_array())
274 {
275 append(arr);
276 }
277 if let Some(arr) = meta
278 .pointer("/ui/csp")
279 .and_then(|c| c.get(spec_key))
280 .and_then(|v| v.as_array())
281 {
282 append(arr);
283 }
284 out
285}
286
287fn write_openai_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
289 let Some(obj) = meta.as_object_mut() else {
290 return;
291 };
292 obj.insert(
293 "openai/widgetCSP".to_string(),
294 serde_json::json!({
295 "connect_domains": connect,
296 "resource_domains": resource,
297 "frame_domains": frame,
298 }),
299 );
300}
301
302fn write_spec_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
304 let Some(obj) = meta.as_object_mut() else {
305 return;
306 };
307 let ui = obj
308 .entry("ui".to_string())
309 .or_insert_with(|| Value::Object(serde_json::Map::new()));
310 if !ui.is_object() {
311 *ui = Value::Object(serde_json::Map::new());
312 }
313 let ui_obj = ui.as_object_mut().unwrap();
314 ui_obj.insert(
315 "csp".to_string(),
316 serde_json::json!({
317 "connectDomains": connect,
318 "resourceDomains": resource,
319 "frameDomains": frame,
320 }),
321 );
322}
323
324#[must_use]
331fn inject_proxy_into_all_csp(value: &mut Value, config: &RewriteConfig) -> bool {
332 let mut mutated = false;
333 match value {
334 Value::Object(map) => {
335 for key in [
336 "connect_domains",
337 "resource_domains",
338 "frame_domains",
339 "connectDomains",
340 "resourceDomains",
341 "frameDomains",
342 ] {
343 if let Some(arr) = map.get_mut(key).and_then(|v| v.as_array_mut()) {
344 let has_proxy = arr.iter().any(|v| v.as_str() == Some(&config.proxy_url));
345 if !has_proxy {
346 arr.insert(0, Value::String(config.proxy_url.clone()));
347 mutated = true;
348 }
349 }
350 }
351 for (_, v) in map.iter_mut() {
352 mutated |= inject_proxy_into_all_csp(v, config);
353 }
354 }
355 Value::Array(arr) => {
356 for item in arr {
357 mutated |= inject_proxy_into_all_csp(item, config);
358 }
359 }
360 _ => {}
361 }
362 mutated
363}
364
365fn strip_scheme(url: &str) -> String {
366 url.trim_start_matches("https://")
367 .trim_start_matches("http://")
368 .split('/')
369 .next()
370 .unwrap_or("")
371 .to_string()
372}
373
374#[cfg(test)]
375#[allow(non_snake_case)]
376mod tests {
377 use super::*;
378 use crate::proxy::csp::{DirectivePolicy, Mode, WidgetScoped};
379 use serde_json::json;
380
381 fn rewrite_config() -> RewriteConfig {
384 RewriteConfig {
385 proxy_url: "https://abc.tunnel.example.com".into(),
386 proxy_domain: "abc.tunnel.example.com".into(),
387 mcp_upstream: "http://localhost:9000".into(),
388 csp: CspConfig::default(),
389 }
390 }
391
392 fn as_strs(arr: &Value) -> Vec<&str> {
393 arr.as_array()
394 .unwrap()
395 .iter()
396 .map(|v| v.as_str().unwrap())
397 .collect()
398 }
399
400 #[test]
403 fn rewrite_response__resources_read_preserves_html() {
404 let config = rewrite_config();
405 let mut body = json!({
406 "jsonrpc": "2.0", "id": 1,
407 "result": {
408 "contents": [{
409 "uri": "ui://widget/question",
410 "mimeType": "text/html",
411 "text": "<html><script src=\"/assets/main.js\"></script></html>"
412 }]
413 }
414 });
415 let original = body["result"]["contents"][0]["text"]
416 .as_str()
417 .unwrap()
418 .to_string();
419
420 rewrite_response("resources/read", &mut body, &config);
421
422 assert_eq!(
423 body["result"]["contents"][0]["text"].as_str().unwrap(),
424 original
425 );
426 }
427
428 #[test]
431 fn rewrite_response__resources_read_rewrites_meta_not_text() {
432 let config = rewrite_config();
433 let mut body = json!({
434 "result": {
435 "contents": [{
436 "uri": "ui://widget/question",
437 "mimeType": "text/html",
438 "text": "<html><body>Hello</body></html>",
439 "meta": {
440 "openai/widgetDomain": "localhost:9000",
441 "openai/widgetCSP": {
442 "resource_domains": ["http://localhost:9000"],
443 "connect_domains": ["http://localhost:9000"]
444 }
445 }
446 }]
447 }
448 });
449
450 rewrite_response("resources/read", &mut body, &config);
451
452 let content = &body["result"]["contents"][0];
453 assert_eq!(
454 content["text"].as_str().unwrap(),
455 "<html><body>Hello</body></html>"
456 );
457 assert_eq!(
458 content["meta"]["openai/widgetDomain"].as_str().unwrap(),
459 "abc.tunnel.example.com"
460 );
461 let resources = as_strs(&content["meta"]["openai/widgetCSP"]["resource_domains"]);
462 assert!(resources.contains(&"https://abc.tunnel.example.com"));
463 assert!(!resources.iter().any(|d| d.contains("localhost")));
464 }
465
466 #[test]
469 fn rewrite_response__tools_list_rewrites_widget_domain() {
470 let config = rewrite_config();
471 let mut body = json!({
472 "result": {
473 "tools": [{
474 "name": "create_question",
475 "meta": {
476 "openai/widgetDomain": "old.domain.com",
477 "openai/widgetCSP": {
478 "resource_domains": ["http://localhost:4444"],
479 "connect_domains": ["http://localhost:9000", "https://api.external.com"]
480 }
481 }
482 }]
483 }
484 });
485
486 rewrite_response("tools/list", &mut body, &config);
487
488 let meta = &body["result"]["tools"][0]["meta"];
489 assert_eq!(
490 meta["openai/widgetDomain"].as_str().unwrap(),
491 "abc.tunnel.example.com"
492 );
493 let connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
494 assert!(connect.contains(&"https://abc.tunnel.example.com"));
495 assert!(connect.contains(&"https://api.external.com"));
496 assert!(!connect.iter().any(|d| d.contains("localhost")));
497 }
498
499 #[test]
502 fn rewrite_response__tools_call_rewrites_meta() {
503 let config = rewrite_config();
504 let mut body = json!({
505 "result": {
506 "content": [{"type": "text", "text": "some result"}],
507 "meta": {
508 "openai/widgetDomain": "old.domain.com",
509 "openai/widgetCSP": {
510 "resource_domains": ["http://localhost:4444"]
511 }
512 }
513 }
514 });
515
516 rewrite_response("tools/call", &mut body, &config);
517
518 assert_eq!(
519 body["result"]["meta"]["openai/widgetDomain"]
520 .as_str()
521 .unwrap(),
522 "abc.tunnel.example.com"
523 );
524 assert_eq!(
525 body["result"]["content"][0]["text"].as_str().unwrap(),
526 "some result"
527 );
528 }
529
530 #[test]
533 fn rewrite_response__resources_list_rewrites_meta() {
534 let config = rewrite_config();
535 let mut body = json!({
536 "result": {
537 "resources": [{
538 "uri": "ui://widget/question",
539 "name": "Question Widget",
540 "meta": {
541 "openai/widgetDomain": "old.domain.com"
542 }
543 }]
544 }
545 });
546
547 rewrite_response("resources/list", &mut body, &config);
548
549 assert_eq!(
550 body["result"]["resources"][0]["meta"]["openai/widgetDomain"]
551 .as_str()
552 .unwrap(),
553 "abc.tunnel.example.com"
554 );
555 }
556
557 #[test]
560 fn rewrite_response__resources_templates_list_rewrites_meta() {
561 let config = rewrite_config();
562 let mut body = json!({
563 "result": {
564 "resourceTemplates": [{
565 "uriTemplate": "file:///{path}",
566 "name": "File Access",
567 "meta": {
568 "openai/widgetDomain": "old.domain.com",
569 "openai/widgetCSP": {
570 "resource_domains": ["http://localhost:4444"],
571 "connect_domains": ["http://localhost:9000"]
572 }
573 }
574 }]
575 }
576 });
577
578 rewrite_response("resources/templates/list", &mut body, &config);
579
580 let meta = &body["result"]["resourceTemplates"][0]["meta"];
581 assert_eq!(
582 meta["openai/widgetDomain"].as_str().unwrap(),
583 "abc.tunnel.example.com"
584 );
585 let resources = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
586 assert!(resources.contains(&"https://abc.tunnel.example.com"));
587 assert!(!resources.iter().any(|d| d.contains("localhost")));
588 }
589
590 #[test]
593 fn rewrite_response__csp_strips_localhost() {
594 let config = rewrite_config();
595 let mut body = json!({
596 "result": {
597 "tools": [{
598 "name": "test",
599 "meta": {
600 "openai/widgetCSP": {
601 "resource_domains": [
602 "http://localhost:4444",
603 "http://127.0.0.1:4444",
604 "http://localhost:9000",
605 "https://cdn.external.com"
606 ]
607 }
608 }
609 }]
610 }
611 });
612
613 rewrite_response("tools/list", &mut body, &config);
614
615 let domains =
616 as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]);
617 assert_eq!(
618 domains,
619 vec!["https://abc.tunnel.example.com", "https://cdn.external.com"]
620 );
621 }
622
623 #[test]
626 fn rewrite_response__global_connect_domains_appended() {
627 let mut config = rewrite_config();
628 config.csp.connect_domains = DirectivePolicy {
629 domains: vec!["https://extra.example.com".into()],
630 mode: Mode::Extend,
631 };
632
633 let mut body = json!({
634 "result": {
635 "tools": [{
636 "name": "test",
637 "meta": {
638 "openai/widgetCSP": {
639 "connect_domains": ["http://localhost:9000"]
640 }
641 }
642 }]
643 }
644 });
645
646 rewrite_response("tools/list", &mut body, &config);
647
648 let domains =
649 as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
650 assert!(domains.contains(&"https://extra.example.com"));
651 assert!(domains.contains(&"https://abc.tunnel.example.com"));
652 }
653
654 #[test]
657 fn rewrite_response__csp_no_duplicate_proxy() {
658 let config = rewrite_config();
659 let mut body = json!({
660 "result": {
661 "tools": [{
662 "name": "test",
663 "meta": {
664 "openai/widgetCSP": {
665 "resource_domains": ["https://abc.tunnel.example.com", "https://cdn.example.com"]
666 }
667 }
668 }]
669 }
670 });
671
672 rewrite_response("tools/list", &mut body, &config);
673
674 let domains =
675 as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]);
676 let count = domains
677 .iter()
678 .filter(|d| **d == "https://abc.tunnel.example.com")
679 .count();
680 assert_eq!(count, 1);
681 }
682
683 #[test]
686 fn rewrite_response__claude_csp_format() {
687 let config = rewrite_config();
688 let mut body = json!({
689 "result": {
690 "tools": [{
691 "name": "test",
692 "meta": {
693 "ui": {
694 "csp": {
695 "connectDomains": ["http://localhost:9000"],
696 "resourceDomains": ["http://localhost:4444"]
697 }
698 }
699 }
700 }]
701 }
702 });
703
704 rewrite_response("tools/list", &mut body, &config);
705
706 let meta = &body["result"]["tools"][0]["meta"]["ui"]["csp"];
707 let connect = as_strs(&meta["connectDomains"]);
708 let resource = as_strs(&meta["resourceDomains"]);
709 assert!(connect.contains(&"https://abc.tunnel.example.com"));
710 assert!(resource.contains(&"https://abc.tunnel.example.com"));
711 assert!(!connect.iter().any(|d| d.contains("localhost")));
712 assert!(!resource.iter().any(|d| d.contains("localhost")));
713 }
714
715 #[test]
718 fn rewrite_response__deep_csp_injection() {
719 let config = rewrite_config();
720 let mut body = json!({
721 "result": {
722 "content": [{
723 "type": "text",
724 "text": "result",
725 "deeply": {
726 "nested": {
727 "connect_domains": ["https://only-external.com"]
728 }
729 }
730 }]
731 }
732 });
733
734 rewrite_response("tools/call", &mut body, &config);
735
736 let domains = as_strs(&body["result"]["content"][0]["deeply"]["nested"]["connect_domains"]);
737 assert!(domains.contains(&"https://abc.tunnel.example.com"));
738 }
739
740 #[test]
743 fn rewrite_response__unknown_method_passthrough() {
744 let config = rewrite_config();
745 let mut body = json!({
746 "result": {
747 "data": "unchanged",
748 "meta": { "openai/widgetDomain": "should-stay.com" }
749 }
750 });
751 rewrite_response("notifications/message", &mut body, &config);
752
753 assert_eq!(
754 body["result"]["meta"]["openai/widgetDomain"]
755 .as_str()
756 .unwrap(),
757 "should-stay.com"
758 );
759 assert_eq!(body["result"]["data"].as_str().unwrap(), "unchanged");
760 }
761
762 #[test]
765 fn rewrite_response__replace_mode_ignores_upstream() {
766 let mut config = rewrite_config();
767 config.csp.resource_domains = DirectivePolicy {
768 domains: vec!["https://allowed.example.com".into()],
769 mode: Mode::Replace,
770 };
771 config.csp.connect_domains = DirectivePolicy {
772 domains: vec!["https://allowed.example.com".into()],
773 mode: Mode::Replace,
774 };
775
776 let mut body = json!({
777 "result": {
778 "tools": [{
779 "name": "test",
780 "meta": {
781 "openai/widgetCSP": {
782 "resource_domains": ["https://cdn.external.com", "https://api.external.com"],
783 "connect_domains": ["https://api.external.com", "http://localhost:9000"]
784 }
785 }
786 }]
787 }
788 });
789
790 rewrite_response("tools/list", &mut body, &config);
791
792 let resources =
793 as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]);
794 assert_eq!(
795 resources,
796 vec![
797 "https://abc.tunnel.example.com",
798 "https://allowed.example.com"
799 ]
800 );
801 let connect =
802 as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
803 assert_eq!(
804 connect,
805 vec![
806 "https://abc.tunnel.example.com",
807 "https://allowed.example.com"
808 ]
809 );
810 }
811
812 #[test]
815 fn rewrite_response__widget_scope_matches_resource_uri() {
816 let mut config = rewrite_config();
818 config.csp.widgets.push(WidgetScoped {
819 match_pattern: "ui://widget/payment*".into(),
820 connect_domains: vec!["https://api.stripe.com".into()],
821 connect_domains_mode: Mode::Extend,
822 ..Default::default()
823 });
824
825 let mut body = json!({
826 "result": {
827 "resources": [
828 {
829 "uri": "ui://widget/payment-form",
830 "meta": {
831 "openai/widgetCSP": { "connect_domains": [] }
832 }
833 },
834 {
835 "uri": "ui://widget/search",
836 "meta": {
837 "openai/widgetCSP": { "connect_domains": [] }
838 }
839 }
840 ]
841 }
842 });
843
844 rewrite_response("resources/list", &mut body, &config);
845
846 let payment_connect =
847 as_strs(&body["result"]["resources"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
848 assert!(payment_connect.contains(&"https://api.stripe.com"));
849
850 let search_connect =
851 as_strs(&body["result"]["resources"][1]["meta"]["openai/widgetCSP"]["connect_domains"]);
852 assert!(!search_connect.contains(&"https://api.stripe.com"));
853 }
854
855 #[test]
856 fn rewrite_response__widget_replace_mode_wipes_upstream() {
857 let mut config = rewrite_config();
858 config.csp.widgets.push(WidgetScoped {
859 match_pattern: "ui://widget/*".into(),
860 connect_domains: vec!["https://api.stripe.com".into()],
861 connect_domains_mode: Mode::Replace,
862 ..Default::default()
863 });
864
865 let mut body = json!({
866 "result": {
867 "contents": [{
868 "uri": "ui://widget/payment",
869 "meta": {
870 "openai/widgetCSP": {
871 "connect_domains": [
872 "https://api.external.com",
873 "https://another.external.com"
874 ]
875 }
876 }
877 }]
878 }
879 });
880
881 rewrite_response("resources/read", &mut body, &config);
882
883 let connect =
884 as_strs(&body["result"]["contents"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
885 assert_eq!(
886 connect,
887 vec!["https://abc.tunnel.example.com", "https://api.stripe.com"]
888 );
889 }
890
891 #[test]
892 fn rewrite_response__widget_uri_inferred_from_tool_meta() {
893 let mut config = rewrite_config();
897 config.csp.widgets.push(WidgetScoped {
898 match_pattern: "ui://widget/payment*".into(),
899 connect_domains: vec!["https://api.stripe.com".into()],
900 connect_domains_mode: Mode::Extend,
901 ..Default::default()
902 });
903
904 let mut body = json!({
905 "result": {
906 "tools": [{
907 "name": "take_payment",
908 "meta": {
909 "ui": { "resourceUri": "ui://widget/payment-form" },
910 "openai/widgetCSP": { "connect_domains": [] }
911 }
912 }]
913 }
914 });
915
916 rewrite_response("tools/list", &mut body, &config);
917
918 let connect =
919 as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
920 assert!(connect.contains(&"https://api.stripe.com"));
921 }
922
923 #[test]
926 fn rewrite_response__spec_only_upstream_also_emits_openai_shape() {
927 let config = rewrite_config();
931 let mut body = json!({
932 "result": {
933 "contents": [{
934 "uri": "ui://widget/search",
935 "mimeType": "text/html",
936 "meta": {
937 "ui": {
938 "csp": {
939 "connectDomains": ["https://api.external.com"],
940 "resourceDomains": ["https://cdn.external.com"]
941 }
942 }
943 }
944 }]
945 }
946 });
947
948 rewrite_response("resources/read", &mut body, &config);
949
950 let meta = &body["result"]["contents"][0]["meta"];
951 let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
952 let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
953 assert_eq!(oa_connect, spec_connect);
954 assert!(oa_connect.contains(&"https://api.external.com"));
955 assert!(oa_connect.contains(&"https://abc.tunnel.example.com"));
956 }
957
958 #[test]
959 fn rewrite_response__openai_only_upstream_also_emits_spec_shape() {
960 let config = rewrite_config();
963 let mut body = json!({
964 "result": {
965 "contents": [{
966 "uri": "ui://widget/search",
967 "mimeType": "text/html",
968 "meta": {
969 "openai/widgetCSP": {
970 "connect_domains": ["https://api.external.com"],
971 "resource_domains": ["https://cdn.external.com"]
972 }
973 }
974 }]
975 }
976 });
977
978 rewrite_response("resources/read", &mut body, &config);
979
980 let meta = &body["result"]["contents"][0]["meta"];
981 let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
982 let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
983 assert_eq!(oa_connect, spec_connect);
984 assert!(spec_connect.contains(&"https://api.external.com"));
985 assert!(spec_connect.contains(&"https://abc.tunnel.example.com"));
986 }
987
988 #[test]
989 fn rewrite_response__declared_config_synthesizes_both_shapes_from_empty() {
990 let mut config = rewrite_config();
994 config.csp.connect_domains = DirectivePolicy {
995 domains: vec!["https://api.declared.com".into()],
996 mode: Mode::Extend,
997 };
998
999 let mut body = json!({
1000 "result": {
1001 "resources": [{
1002 "uri": "ui://widget/search",
1003 "meta": {
1004 "openai/widgetDomain": "old.domain.com"
1005 }
1006 }]
1007 }
1008 });
1009
1010 rewrite_response("resources/list", &mut body, &config);
1011
1012 let meta = &body["result"]["resources"][0]["meta"];
1013 let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1014 let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1015 assert_eq!(oa, spec);
1016 assert!(oa.contains(&"https://api.declared.com"));
1017 assert!(oa.contains(&"https://abc.tunnel.example.com"));
1018 }
1019
1020 #[test]
1021 fn rewrite_response__upstream_declarations_unioned_across_shapes() {
1022 let config = rewrite_config();
1025 let mut body = json!({
1026 "result": {
1027 "contents": [{
1028 "uri": "ui://widget/search",
1029 "mimeType": "text/html",
1030 "meta": {
1031 "openai/widgetCSP": {
1032 "connect_domains": ["https://api.only-openai.com"]
1033 },
1034 "ui": {
1035 "csp": {
1036 "connectDomains": ["https://api.only-spec.com"]
1037 }
1038 }
1039 }
1040 }]
1041 }
1042 });
1043
1044 rewrite_response("resources/read", &mut body, &config);
1045
1046 let meta = &body["result"]["contents"][0]["meta"];
1047 let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1048 let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1049 assert_eq!(oa, spec);
1050 assert!(oa.contains(&"https://api.only-openai.com"));
1051 assert!(oa.contains(&"https://api.only-spec.com"));
1052 }
1053
1054 #[test]
1055 fn rewrite_response__non_widget_meta_is_not_polluted() {
1056 let config = rewrite_config();
1059 let mut body = json!({
1060 "result": {
1061 "content": [{"type": "text", "text": "plain result"}],
1062 "meta": { "requestId": "abc-123" }
1063 }
1064 });
1065
1066 rewrite_response("tools/call", &mut body, &config);
1067
1068 let meta = &body["result"]["meta"];
1069 assert!(meta.get("openai/widgetCSP").is_none());
1070 assert!(meta.get("ui").is_none());
1071 assert_eq!(meta["requestId"].as_str().unwrap(), "abc-123");
1072 }
1073
1074 #[test]
1075 fn rewrite_response__all_three_directives_synthesized() {
1076 let mut config = rewrite_config();
1078 config.csp.connect_domains = DirectivePolicy {
1079 domains: vec!["https://api.example.com".into()],
1080 mode: Mode::Extend,
1081 };
1082 config.csp.resource_domains = DirectivePolicy {
1083 domains: vec!["https://cdn.example.com".into()],
1084 mode: Mode::Extend,
1085 };
1086
1087 let mut body = json!({
1088 "result": {
1089 "resources": [{
1090 "uri": "ui://widget/search",
1091 "meta": { "openai/widgetDomain": "x" }
1092 }]
1093 }
1094 });
1095
1096 rewrite_response("resources/list", &mut body, &config);
1097
1098 let meta = &body["result"]["resources"][0]["meta"];
1099 for shape in ["openai/widgetCSP"] {
1100 assert!(meta[shape]["connect_domains"].is_array());
1101 assert!(meta[shape]["resource_domains"].is_array());
1102 assert!(meta[shape]["frame_domains"].is_array());
1103 }
1104 assert!(meta["ui"]["csp"]["connectDomains"].is_array());
1105 assert!(meta["ui"]["csp"]["resourceDomains"].is_array());
1106 assert!(meta["ui"]["csp"]["frameDomains"].is_array());
1107 }
1108
1109 #[test]
1112 fn rewrite_response__frame_domains_default_replace_drops_upstream() {
1113 let config = rewrite_config();
1116 let mut body = json!({
1117 "result": {
1118 "tools": [{
1119 "name": "test",
1120 "meta": {
1121 "ui": {
1122 "csp": {
1123 "frameDomains": ["https://embed.external.com"]
1124 }
1125 }
1126 }
1127 }]
1128 }
1129 });
1130
1131 rewrite_response("tools/list", &mut body, &config);
1132
1133 let frames = as_strs(&body["result"]["tools"][0]["meta"]["ui"]["csp"]["frameDomains"]);
1134 assert_eq!(frames, vec!["https://abc.tunnel.example.com"]);
1135 }
1136
1137 #[test]
1140 fn rewrite_response__end_to_end_mcp_schema() {
1141 let mut config = rewrite_config();
1152 config.csp.connect_domains = DirectivePolicy {
1153 domains: vec!["https://api.myshop.com".into()],
1154 mode: Mode::Extend,
1155 };
1156 config.csp.resource_domains = DirectivePolicy {
1157 domains: vec!["https://cdn.myshop.com".into()],
1158 mode: Mode::Extend,
1159 };
1160 config.csp.widgets.push(WidgetScoped {
1161 match_pattern: "ui://widget/payment*".into(),
1162 connect_domains: vec!["https://api.stripe.com".into()],
1163 connect_domains_mode: Mode::Extend,
1164 resource_domains: vec!["https://js.stripe.com".into()],
1165 resource_domains_mode: Mode::Extend,
1166 ..Default::default()
1167 });
1168
1169 let mut body = json!({
1170 "jsonrpc": "2.0",
1171 "id": 42,
1172 "result": {
1173 "tools": [
1174 {
1175 "name": "search_products",
1176 "description": "Search the product catalog",
1177 "inputSchema": { "type": "object" },
1178 "meta": {
1179 "openai/widgetDomain": "old.shop.com",
1180 "openai/outputTemplate": "ui://widget/search",
1181 "openai/widgetCSP": {
1182 "connect_domains": ["http://localhost:9000"],
1183 "resource_domains": ["http://localhost:4444"]
1184 }
1185 }
1186 },
1187 {
1188 "name": "take_payment",
1189 "description": "Charge a card",
1190 "inputSchema": { "type": "object" },
1191 "meta": {
1192 "ui": {
1193 "resourceUri": "ui://widget/payment-form",
1194 "csp": {
1195 "connectDomains": ["https://api.myshop.com"]
1196 }
1197 }
1198 }
1199 },
1200 {
1201 "name": "get_order_status",
1202 "description": "Look up an order",
1203 "inputSchema": { "type": "object" }
1204 }
1205 ]
1206 }
1207 });
1208
1209 rewrite_response("tools/list", &mut body, &config);
1210
1211 let tools = body["result"]["tools"].as_array().unwrap();
1212
1213 let search_meta = &tools[0]["meta"];
1215 assert_eq!(
1216 search_meta["openai/widgetDomain"].as_str().unwrap(),
1217 "abc.tunnel.example.com"
1218 );
1219 let search_oa_connect = as_strs(&search_meta["openai/widgetCSP"]["connect_domains"]);
1220 let search_spec_connect = as_strs(&search_meta["ui"]["csp"]["connectDomains"]);
1221 assert_eq!(search_oa_connect, search_spec_connect);
1222 assert_eq!(
1224 search_oa_connect,
1225 vec!["https://abc.tunnel.example.com", "https://api.myshop.com"]
1226 );
1227 assert!(!search_oa_connect.contains(&"https://api.stripe.com"));
1229 let search_oa_frame = as_strs(&search_meta["openai/widgetCSP"]["frame_domains"]);
1231 assert_eq!(search_oa_frame, vec!["https://abc.tunnel.example.com"]);
1232
1233 let payment_meta = &tools[1]["meta"];
1235 let payment_oa_connect = as_strs(&payment_meta["openai/widgetCSP"]["connect_domains"]);
1236 let payment_spec_connect = as_strs(&payment_meta["ui"]["csp"]["connectDomains"]);
1237 assert_eq!(payment_oa_connect, payment_spec_connect);
1238 assert_eq!(
1240 payment_oa_connect,
1241 vec![
1242 "https://abc.tunnel.example.com",
1243 "https://api.myshop.com",
1244 "https://api.stripe.com",
1245 ]
1246 );
1247 let payment_oa_resource = as_strs(&payment_meta["openai/widgetCSP"]["resource_domains"]);
1248 assert_eq!(
1249 payment_oa_resource,
1250 vec![
1251 "https://abc.tunnel.example.com",
1252 "https://cdn.myshop.com",
1253 "https://js.stripe.com",
1254 ]
1255 );
1256
1257 let plain = &tools[2];
1260 assert!(plain.get("meta").is_none());
1261 }
1262}