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