1use serde_json::Value;
2
3use super::csp::CspMode;
4use crate::protocol as jsonrpc;
5
6#[derive(Clone)]
7pub struct RewriteConfig {
8 pub proxy_url: String,
9 pub proxy_domain: String,
10 pub mcp_upstream: String,
11 pub extra_csp_domains: Vec<String>,
12 pub csp_mode: CspMode,
13}
14
15pub fn rewrite_response(method: &str, body: &mut Value, config: &RewriteConfig) {
18 match method {
19 jsonrpc::TOOLS_LIST => {
20 if let Some(tools) = body
21 .get_mut("result")
22 .and_then(|r| r.get_mut("tools"))
23 .and_then(|t| t.as_array_mut())
24 {
25 for tool in tools {
26 if let Some(meta) = tool.get_mut("meta") {
27 rewrite_widget_meta(meta, config);
28 }
29 }
30 }
31 }
32 jsonrpc::TOOLS_CALL => {
33 if let Some(meta) = body.get_mut("result").and_then(|r| r.get_mut("meta")) {
35 rewrite_widget_meta(meta, config);
36 }
37 }
38 jsonrpc::RESOURCES_LIST => {
39 if let Some(resources) = body
40 .get_mut("result")
41 .and_then(|r| r.get_mut("resources"))
42 .and_then(|r| r.as_array_mut())
43 {
44 for resource in resources {
45 if let Some(meta) = resource.get_mut("meta") {
46 rewrite_widget_meta(meta, config);
47 }
48 }
49 }
50 }
51 jsonrpc::RESOURCES_TEMPLATES_LIST => {
52 if let Some(templates) = body
53 .get_mut("result")
54 .and_then(|r| r.get_mut("resourceTemplates"))
55 .and_then(|t| t.as_array_mut())
56 {
57 for template in templates {
58 if let Some(meta) = template.get_mut("meta") {
59 rewrite_widget_meta(meta, config);
60 }
61 }
62 }
63 }
64 jsonrpc::RESOURCES_READ => {
65 if let Some(contents) = body
66 .get_mut("result")
67 .and_then(|r| r.get_mut("contents"))
68 .and_then(|c| c.as_array_mut())
69 {
70 for content in contents {
71 if let Some(meta) = content.get_mut("meta") {
72 rewrite_widget_meta(meta, config);
73 }
74 }
75 }
76 }
77 _ => {} }
79
80 inject_proxy_into_all_csp(body, config);
82}
83
84fn rewrite_widget_meta(meta: &mut Value, config: &RewriteConfig) {
86 if meta.get("openai/widgetDomain").is_some() {
88 meta["openai/widgetDomain"] = Value::String(config.proxy_domain.clone());
89 }
90
91 rewrite_csp_object(meta, "openai/widgetCSP", "resource_domains", config);
93 rewrite_csp_object(meta, "openai/widgetCSP", "connect_domains", config);
94
95 if let Some(ui) = meta.get_mut("ui") {
97 rewrite_csp_object(ui, "csp", "connectDomains", config);
98 rewrite_csp_object(ui, "csp", "resourceDomains", config);
99 }
100
101 inject_proxy_into_all_csp(meta, config);
103}
104
105fn inject_proxy_into_all_csp(value: &mut Value, config: &RewriteConfig) {
107 match value {
108 Value::Object(map) => {
109 for key in [
111 "resource_domains",
112 "connect_domains",
113 "connectDomains",
114 "resourceDomains",
115 ] {
116 if let Some(arr) = map.get_mut(key).and_then(|v| v.as_array_mut()) {
117 let has_proxy = arr.iter().any(|v| v.as_str() == Some(&config.proxy_url));
118 if !has_proxy {
119 arr.insert(0, Value::String(config.proxy_url.clone()));
120 }
121 }
122 }
123 for (_, v) in map.iter_mut() {
125 inject_proxy_into_all_csp(v, config);
126 }
127 }
128 Value::Array(arr) => {
129 for item in arr {
130 inject_proxy_into_all_csp(item, config);
131 }
132 }
133 _ => {}
134 }
135}
136
137fn rewrite_csp_object(parent: &mut Value, obj_key: &str, array_key: &str, config: &RewriteConfig) {
141 let Some(obj) = parent.get_mut(obj_key) else {
142 return;
143 };
144 let Some(arr) = obj.get_mut(array_key).and_then(|v| v.as_array_mut()) else {
145 return;
146 };
147
148 let mut new_domains: Vec<String> = vec![config.proxy_url.clone()];
150
151 match config.csp_mode {
152 CspMode::Extend => {
153 let upstream_domain = config
154 .mcp_upstream
155 .trim_start_matches("https://")
156 .trim_start_matches("http://");
157
158 for entry in arr.iter() {
159 if let Some(domain) = entry.as_str() {
160 if domain.contains("localhost")
162 || domain.contains("127.0.0.1")
163 || domain.contains(upstream_domain)
164 {
165 continue;
166 }
167 if !new_domains.contains(&domain.to_string()) {
168 new_domains.push(domain.to_string());
169 }
170 }
171 }
172 }
173 CspMode::Override => {
174 }
176 }
177
178 for extra in &config.extra_csp_domains {
180 if !new_domains.contains(extra) {
181 new_domains.push(extra.clone());
182 }
183 }
184
185 *obj.get_mut(array_key).unwrap() =
186 Value::Array(new_domains.into_iter().map(Value::String).collect());
187}
188
189#[cfg(test)]
190#[allow(non_snake_case)]
191mod tests {
192 use super::*;
193 use serde_json::json;
194
195 fn test_config() -> RewriteConfig {
196 RewriteConfig {
197 proxy_url: "https://abc.tunnel.example.com".into(),
198 proxy_domain: "abc.tunnel.example.com".into(),
199 mcp_upstream: "http://localhost:9000".into(),
200 extra_csp_domains: vec![],
201 csp_mode: CspMode::Extend,
202 }
203 }
204
205 #[test]
208 fn rewrite_response__resources_read_preserves_html() {
209 let config = test_config();
210 let mut body = json!({
211 "jsonrpc": "2.0",
212 "id": 1,
213 "result": {
214 "contents": [{
215 "uri": "ui://widget/question",
216 "mimeType": "text/html",
217 "text": "<html><script src=\"/assets/main.js\"></script></html>"
218 }]
219 }
220 });
221 let original_html = body["result"]["contents"][0]["text"]
222 .as_str()
223 .unwrap()
224 .to_string();
225
226 rewrite_response("resources/read", &mut body, &config);
227
228 let html = body["result"]["contents"][0]["text"].as_str().unwrap();
230 assert_eq!(html, original_html);
231 }
232
233 #[test]
234 fn rewrite_response__resources_read_rewrites_meta_not_text() {
235 let config = test_config();
236 let mut body = json!({
237 "jsonrpc": "2.0",
238 "id": 1,
239 "result": {
240 "contents": [{
241 "uri": "ui://widget/question",
242 "mimeType": "text/html",
243 "text": "<html><body>Hello</body></html>",
244 "meta": {
245 "openai/widgetDomain": "localhost:9000",
246 "openai/widgetCSP": {
247 "resource_domains": ["http://localhost:9000"],
248 "connect_domains": ["http://localhost:9000"]
249 }
250 }
251 }]
252 }
253 });
254
255 rewrite_response("resources/read", &mut body, &config);
256
257 let content = &body["result"]["contents"][0];
258 assert_eq!(
260 content["text"].as_str().unwrap(),
261 "<html><body>Hello</body></html>"
262 );
263 assert_eq!(
265 content["meta"]["openai/widgetDomain"].as_str().unwrap(),
266 "abc.tunnel.example.com"
267 );
268 let resource_domains: Vec<&str> = content["meta"]["openai/widgetCSP"]["resource_domains"]
269 .as_array()
270 .unwrap()
271 .iter()
272 .map(|v| v.as_str().unwrap())
273 .collect();
274 assert!(resource_domains.contains(&"https://abc.tunnel.example.com"));
275 assert!(!resource_domains.iter().any(|d| d.contains("localhost")));
276 }
277
278 #[test]
281 fn rewrite_response__tools_list_rewrites_widget_domain() {
282 let config = test_config();
283 let mut body = json!({
284 "result": {
285 "tools": [{
286 "name": "create_question",
287 "meta": {
288 "openai/widgetDomain": "old.domain.com",
289 "openai/widgetCSP": {
290 "resource_domains": ["http://localhost:4444"],
291 "connect_domains": ["http://localhost:9000", "https://api.external.com"]
292 }
293 }
294 }]
295 }
296 });
297
298 rewrite_response("tools/list", &mut body, &config);
299
300 let meta = &body["result"]["tools"][0]["meta"];
301 assert_eq!(
302 meta["openai/widgetDomain"].as_str().unwrap(),
303 "abc.tunnel.example.com"
304 );
305 let connect: Vec<&str> = meta["openai/widgetCSP"]["connect_domains"]
307 .as_array()
308 .unwrap()
309 .iter()
310 .map(|v| v.as_str().unwrap())
311 .collect();
312 assert!(connect.contains(&"https://abc.tunnel.example.com"));
313 assert!(connect.contains(&"https://api.external.com"));
314 assert!(!connect.iter().any(|d| d.contains("localhost")));
315 }
316
317 #[test]
320 fn rewrite_response__tools_call_rewrites_meta() {
321 let config = test_config();
322 let mut body = json!({
323 "result": {
324 "content": [{"type": "text", "text": "some result"}],
325 "meta": {
326 "openai/widgetDomain": "old.domain.com",
327 "openai/widgetCSP": {
328 "resource_domains": ["http://localhost:4444"]
329 }
330 }
331 }
332 });
333
334 rewrite_response("tools/call", &mut body, &config);
335
336 assert_eq!(
337 body["result"]["meta"]["openai/widgetDomain"]
338 .as_str()
339 .unwrap(),
340 "abc.tunnel.example.com"
341 );
342 assert_eq!(
344 body["result"]["content"][0]["text"].as_str().unwrap(),
345 "some result"
346 );
347 }
348
349 #[test]
352 fn rewrite_response__resources_list_rewrites_meta() {
353 let config = test_config();
354 let mut body = json!({
355 "result": {
356 "resources": [{
357 "uri": "ui://widget/question",
358 "name": "Question Widget",
359 "meta": {
360 "openai/widgetDomain": "old.domain.com"
361 }
362 }]
363 }
364 });
365
366 rewrite_response("resources/list", &mut body, &config);
367
368 assert_eq!(
369 body["result"]["resources"][0]["meta"]["openai/widgetDomain"]
370 .as_str()
371 .unwrap(),
372 "abc.tunnel.example.com"
373 );
374 }
375
376 #[test]
379 fn rewrite_response__resources_templates_list_rewrites_meta() {
380 let config = test_config();
381 let mut body = json!({
382 "result": {
383 "resourceTemplates": [{
384 "uriTemplate": "file:///{path}",
385 "name": "File Access",
386 "meta": {
387 "openai/widgetDomain": "old.domain.com",
388 "openai/widgetCSP": {
389 "resource_domains": ["http://localhost:4444"],
390 "connect_domains": ["http://localhost:9000"]
391 }
392 }
393 }]
394 }
395 });
396
397 rewrite_response("resources/templates/list", &mut body, &config);
398
399 let meta = &body["result"]["resourceTemplates"][0]["meta"];
400 assert_eq!(
401 meta["openai/widgetDomain"].as_str().unwrap(),
402 "abc.tunnel.example.com"
403 );
404 let resource: Vec<&str> = meta["openai/widgetCSP"]["resource_domains"]
405 .as_array()
406 .unwrap()
407 .iter()
408 .map(|v| v.as_str().unwrap())
409 .collect();
410 assert!(resource.contains(&"https://abc.tunnel.example.com"));
411 assert!(!resource.iter().any(|d| d.contains("localhost")));
412 }
413
414 #[test]
417 fn rewrite_response__csp_strips_localhost() {
418 let config = test_config();
419 let mut body = json!({
420 "result": {
421 "tools": [{
422 "name": "test",
423 "meta": {
424 "openai/widgetCSP": {
425 "resource_domains": [
426 "http://localhost:4444",
427 "http://127.0.0.1:4444",
428 "http://localhost:9000",
429 "https://cdn.external.com"
430 ]
431 }
432 }
433 }]
434 }
435 });
436
437 rewrite_response("tools/list", &mut body, &config);
438
439 let domains: Vec<&str> =
440 body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]
441 .as_array()
442 .unwrap()
443 .iter()
444 .map(|v| v.as_str().unwrap())
445 .collect();
446 assert_eq!(
447 domains,
448 vec!["https://abc.tunnel.example.com", "https://cdn.external.com"]
449 );
450 }
451
452 #[test]
453 fn rewrite_response__csp_extra_domains_appended() {
454 let mut config = test_config();
455 config.extra_csp_domains = vec!["https://extra.example.com".into()];
456
457 let mut body = json!({
458 "result": {
459 "tools": [{
460 "name": "test",
461 "meta": {
462 "openai/widgetCSP": {
463 "connect_domains": ["http://localhost:9000"]
464 }
465 }
466 }]
467 }
468 });
469
470 rewrite_response("tools/list", &mut body, &config);
471
472 let domains: Vec<&str> =
473 body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]
474 .as_array()
475 .unwrap()
476 .iter()
477 .map(|v| v.as_str().unwrap())
478 .collect();
479 assert!(domains.contains(&"https://extra.example.com"));
480 }
481
482 #[test]
483 fn rewrite_response__csp_no_duplicate_proxy() {
484 let config = test_config();
485 let mut body = json!({
486 "result": {
487 "tools": [{
488 "name": "test",
489 "meta": {
490 "openai/widgetCSP": {
491 "resource_domains": ["https://abc.tunnel.example.com", "https://cdn.example.com"]
492 }
493 }
494 }]
495 }
496 });
497
498 rewrite_response("tools/list", &mut body, &config);
499
500 let domains: Vec<&str> =
501 body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]
502 .as_array()
503 .unwrap()
504 .iter()
505 .map(|v| v.as_str().unwrap())
506 .collect();
507 let proxy_count = domains
508 .iter()
509 .filter(|d| **d == "https://abc.tunnel.example.com")
510 .count();
511 assert_eq!(proxy_count, 1);
512 }
513
514 #[test]
517 fn rewrite_response__claude_csp_format() {
518 let config = test_config();
519 let mut body = json!({
520 "result": {
521 "tools": [{
522 "name": "test",
523 "meta": {
524 "ui": {
525 "csp": {
526 "connectDomains": ["http://localhost:9000"],
527 "resourceDomains": ["http://localhost:4444"]
528 }
529 }
530 }
531 }]
532 }
533 });
534
535 rewrite_response("tools/list", &mut body, &config);
536
537 let meta = &body["result"]["tools"][0]["meta"]["ui"]["csp"];
538 let connect: Vec<&str> = meta["connectDomains"]
539 .as_array()
540 .unwrap()
541 .iter()
542 .map(|v| v.as_str().unwrap())
543 .collect();
544 let resource: Vec<&str> = meta["resourceDomains"]
545 .as_array()
546 .unwrap()
547 .iter()
548 .map(|v| v.as_str().unwrap())
549 .collect();
550 assert!(connect.contains(&"https://abc.tunnel.example.com"));
551 assert!(resource.contains(&"https://abc.tunnel.example.com"));
552 assert!(!connect.iter().any(|d| d.contains("localhost")));
553 assert!(!resource.iter().any(|d| d.contains("localhost")));
554 }
555
556 #[test]
559 fn rewrite_response__deep_csp_injection() {
560 let config = test_config();
561 let mut body = json!({
562 "result": {
563 "content": [{
564 "type": "text",
565 "text": "result",
566 "deeply": {
567 "nested": {
568 "connect_domains": ["https://only-external.com"]
569 }
570 }
571 }]
572 }
573 });
574
575 rewrite_response("tools/call", &mut body, &config);
576
577 let domains: Vec<&str> =
578 body["result"]["content"][0]["deeply"]["nested"]["connect_domains"]
579 .as_array()
580 .unwrap()
581 .iter()
582 .map(|v| v.as_str().unwrap())
583 .collect();
584 assert!(domains.contains(&"https://abc.tunnel.example.com"));
585 }
586
587 #[test]
590 fn rewrite_response__unknown_method_passthrough() {
591 let config = test_config();
592 let mut body = json!({
593 "result": {
594 "data": "unchanged",
595 "meta": {
596 "openai/widgetDomain": "should-stay.com"
597 }
598 }
599 });
600 rewrite_response("notifications/message", &mut body, &config);
601
602 assert_eq!(
605 body["result"]["meta"]["openai/widgetDomain"]
606 .as_str()
607 .unwrap(),
608 "should-stay.com"
609 );
610 assert_eq!(body["result"]["data"].as_str().unwrap(), "unchanged");
611 }
612
613 #[test]
616 fn rewrite_response__override_mode_ignores_upstream() {
617 let mut config = test_config();
618 config.csp_mode = CspMode::Override;
619 config.extra_csp_domains = vec!["https://allowed.example.com".into()];
620
621 let mut body = json!({
622 "result": {
623 "tools": [{
624 "name": "test",
625 "meta": {
626 "openai/widgetCSP": {
627 "resource_domains": [
628 "https://cdn.external.com",
629 "https://api.external.com",
630 "http://localhost:4444"
631 ],
632 "connect_domains": [
633 "https://api.external.com",
634 "http://localhost:9000"
635 ]
636 }
637 }
638 }]
639 }
640 });
641
642 rewrite_response("tools/list", &mut body, &config);
643
644 let resource: Vec<&str> =
645 body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]
646 .as_array()
647 .unwrap()
648 .iter()
649 .map(|v| v.as_str().unwrap())
650 .collect();
651 assert_eq!(
653 resource,
654 vec![
655 "https://abc.tunnel.example.com",
656 "https://allowed.example.com"
657 ]
658 );
659
660 let connect: Vec<&str> =
661 body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]
662 .as_array()
663 .unwrap()
664 .iter()
665 .map(|v| v.as_str().unwrap())
666 .collect();
667 assert_eq!(
668 connect,
669 vec![
670 "https://abc.tunnel.example.com",
671 "https://allowed.example.com"
672 ]
673 );
674 }
675
676 #[test]
677 fn rewrite_response__override_mode_claude_format() {
678 let mut config = test_config();
679 config.csp_mode = CspMode::Override;
680
681 let mut body = json!({
682 "result": {
683 "tools": [{
684 "name": "test",
685 "meta": {
686 "ui": {
687 "csp": {
688 "connectDomains": ["https://api.external.com"],
689 "resourceDomains": ["https://cdn.external.com"]
690 }
691 }
692 }
693 }]
694 }
695 });
696
697 rewrite_response("tools/list", &mut body, &config);
698
699 let connect: Vec<&str> = body["result"]["tools"][0]["meta"]["ui"]["csp"]["connectDomains"]
700 .as_array()
701 .unwrap()
702 .iter()
703 .map(|v| v.as_str().unwrap())
704 .collect();
705 assert_eq!(connect, vec!["https://abc.tunnel.example.com"]);
707 }
708}