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