Skip to main content

mcpr_core/proxy/
rewrite.rs

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
15/// Rewrite a JSON-RPC response based on the method.
16/// Phase 1: inject proxy URL into known metadata fields (no dynamic learning).
17pub 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            // _meta is at result.meta in the JSON-RPC response
34            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        _ => {} // passthrough
78    }
79
80    // Always do a deep scan on the entire response to catch any CSP arrays we missed
81    inject_proxy_into_all_csp(body, config);
82}
83
84/// Rewrite widget metadata: domain, CSP arrays (both OpenAI and Claude formats).
85fn rewrite_widget_meta(meta: &mut Value, config: &RewriteConfig) {
86    // openai/widgetDomain → proxy domain
87    if meta.get("openai/widgetDomain").is_some() {
88        meta["openai/widgetDomain"] = Value::String(config.proxy_domain.clone());
89    }
90
91    // openai/widgetCSP → rewrite CSP arrays
92    rewrite_csp_object(meta, "openai/widgetCSP", "resource_domains", config);
93    rewrite_csp_object(meta, "openai/widgetCSP", "connect_domains", config);
94
95    // ui.csp → rewrite CSP arrays (Claude format)
96    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    // Deep scan: ensure proxy URL is in ALL CSP domain arrays anywhere in the tree
102    inject_proxy_into_all_csp(meta, config);
103}
104
105/// Recursively find any CSP domain arrays and ensure proxy URL is present.
106fn inject_proxy_into_all_csp(value: &mut Value, config: &RewriteConfig) {
107    match value {
108        Value::Object(map) => {
109            // Check known CSP array keys at this level
110            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            // Recurse into all values
124            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
137/// Rewrite a CSP domain array inside a parent object.
138/// - Extend mode: keep external domains from upstream, strip localhost/upstream, add extras + tunnel
139/// - Override mode: ignore upstream entirely, use only configured domains + tunnel domain
140fn 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    // Always start with proxy (tunnel) domain
149    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                    // Skip localhost/upstream — we replace them with proxy
161                    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            // Ignore all upstream domains
175        }
176    }
177
178    // Append extra CSP domains from config
179    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    // ── Standalone proxy mode: resources/read must NOT touch HTML text ──
206
207    #[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        // HTML text must be untouched — rewrite only applies to meta
229        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        // Text untouched
259        assert_eq!(
260            content["text"].as_str().unwrap(),
261            "<html><body>Hello</body></html>"
262        );
263        // Meta rewritten
264        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    // ── tools/list: rewrite widget meta on tools ──
279
280    #[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        // External domains preserved, localhost stripped
306        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    // ── tools/call: rewrite meta ──
318
319    #[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        // Tool result text content is NOT touched
343        assert_eq!(
344            body["result"]["content"][0]["text"].as_str().unwrap(),
345            "some result"
346        );
347    }
348
349    // ── resources/list: rewrite meta on each resource ──
350
351    #[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    // ── resources/templates/list: rewrite meta on each template ──
377
378    #[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    // ── CSP rewriting details ──
415
416    #[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    // ── Claude format (ui.csp) ──
515
516    #[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    // ── Deep CSP injection ──
557
558    #[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    // ── Unknown methods are passthrough ──
588
589    #[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        // meta.openai/widgetDomain should NOT be rewritten for unknown methods
603        // (only deep CSP injection runs, which doesn't touch widgetDomain)
604        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    // ── Override mode ──
614
615    #[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        // Only proxy + configured extras — no upstream domains
652        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        // Override: only proxy domain, no upstream
706        assert_eq!(connect, vec!["https://abc.tunnel.example.com"]);
707    }
708}