Skip to main content

mcpr_core/proxy/
rewrite.rs

1//! # Response rewriting for widget CSP
2//!
3//! Mutates JSON-RPC response bodies so widgets see the right CSP regardless of
4//! which host renders them.
5//!
6//! Three things happen on every widget meta rewrite:
7//!
8//! 1. **Aggregate.** Upstream CSP domains are collected from both the OpenAI
9//!    (`openai/widgetCSP`) and spec (`ui.csp`) shapes, per directive.
10//! 2. **Merge.** [`super::csp::effective_domains`] applies the per-directive
11//!    mode, widget-scoped overrides, and proxy URL to produce one domain list
12//!    per directive.
13//! 3. **Emit both shapes.** The merge result is written to *both*
14//!    `openai/widgetCSP` (snake_case) and `ui.csp` (camelCase). ChatGPT reads
15//!    the former, Claude and VS Code read the latter; unknown keys are
16//!    ignored, so emitting both means the same declared config works on every
17//!    host.
18//!
19//! A deep scan walks the entire response afterwards and prepends the proxy URL
20//! to any CSP domain array it finds, catching servers that embed CSP in
21//! non-standard locations.
22//!
23//! Response body `text` and `blob` fields are never touched — widget HTML is
24//! served verbatim.
25
26use serde_json::Value;
27
28use super::csp::{CspConfig, Directive, effective_domains};
29use crate::protocol as jsonrpc;
30
31/// Runtime configuration for response rewriting.
32#[derive(Clone)]
33pub struct RewriteConfig {
34    /// Proxy URL (scheme + host, no trailing slash) to insert into every CSP
35    /// array so widgets can reach the proxy.
36    pub proxy_url: String,
37    /// Bare proxy host (no scheme) used when rewriting `widgetDomain`.
38    pub proxy_domain: String,
39    /// Upstream MCP URL, used to recognise and strip upstream self-references
40    /// from the CSP arrays the server returns.
41    pub mcp_upstream: String,
42    /// Declarative CSP config — global policies plus widget-scoped overrides.
43    pub csp: CspConfig,
44}
45
46impl RewriteConfig {
47    /// Wrap this config in the lock-free `ArcSwap` shape expected by
48    /// [`crate::proxy::ProxyState::rewrite_config`]. Saves every caller
49    /// from importing `arc_swap` directly.
50    pub fn into_swap(self) -> std::sync::Arc<arc_swap::ArcSwap<RewriteConfig>> {
51        std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(self))
52    }
53}
54
55/// Rewrite a JSON-RPC response in place for the given method.
56///
57/// Returns `true` iff the body was actually mutated. Callers use this to
58/// decide whether the response needs to be re-serialized — see
59/// `pipeline::handlers::buffered` for the buffered-path dispatcher.
60#[must_use]
61pub fn rewrite_response(method: &str, body: &mut Value, config: &RewriteConfig) -> bool {
62    let mut mutated = false;
63    match method {
64        jsonrpc::TOOLS_LIST => {
65            if let Some(tools) = body
66                .get_mut("result")
67                .and_then(|r| r.get_mut("tools"))
68                .and_then(|t| t.as_array_mut())
69            {
70                for tool in tools {
71                    if let Some(meta) = tool.get_mut("meta") {
72                        rewrite_widget_meta(meta, None, config);
73                        mutated = true;
74                    }
75                }
76            }
77        }
78        jsonrpc::TOOLS_CALL => {
79            if let Some(meta) = body.get_mut("result").and_then(|r| r.get_mut("meta")) {
80                rewrite_widget_meta(meta, None, config);
81                mutated = true;
82            }
83        }
84        jsonrpc::RESOURCES_LIST => {
85            if let Some(resources) = body
86                .get_mut("result")
87                .and_then(|r| r.get_mut("resources"))
88                .and_then(|r| r.as_array_mut())
89            {
90                for resource in resources {
91                    let uri = resource
92                        .get("uri")
93                        .and_then(|v| v.as_str())
94                        .map(String::from);
95                    if let Some(meta) = resource.get_mut("meta") {
96                        rewrite_widget_meta(meta, uri.as_deref(), config);
97                        mutated = true;
98                    }
99                }
100            }
101        }
102        jsonrpc::RESOURCES_TEMPLATES_LIST => {
103            if let Some(templates) = body
104                .get_mut("result")
105                .and_then(|r| r.get_mut("resourceTemplates"))
106                .and_then(|t| t.as_array_mut())
107            {
108                for template in templates {
109                    // Templates expose `uriTemplate`, not a concrete URI; treat
110                    // it as the match key so operators can glob on template IDs.
111                    let uri = template
112                        .get("uriTemplate")
113                        .and_then(|v| v.as_str())
114                        .map(String::from);
115                    if let Some(meta) = template.get_mut("meta") {
116                        rewrite_widget_meta(meta, uri.as_deref(), config);
117                        mutated = true;
118                    }
119                }
120            }
121        }
122        jsonrpc::RESOURCES_READ => {
123            if let Some(contents) = body
124                .get_mut("result")
125                .and_then(|r| r.get_mut("contents"))
126                .and_then(|c| c.as_array_mut())
127            {
128                for content in contents {
129                    let uri = content
130                        .get("uri")
131                        .and_then(|v| v.as_str())
132                        .map(String::from);
133                    if let Some(meta) = content.get_mut("meta") {
134                        rewrite_widget_meta(meta, uri.as_deref(), config);
135                        mutated = true;
136                    }
137                }
138            }
139        }
140        _ => {}
141    }
142
143    // Safety net: any CSP-shaped domain array elsewhere in the tree still gets
144    // the proxy URL. The merge rules above do not run here — this pass only
145    // guarantees the proxy URL is present.
146    mutated |= inject_proxy_into_all_csp(body, config);
147    mutated
148}
149
150/// Rewrite a widget metadata object.
151///
152/// Goal: an operator declares their CSP once in `mcpr.toml`, and the widget
153/// works on every host. Hosts disagree about where they read CSP from —
154/// ChatGPT reads `openai/widgetCSP` (snake_case), Claude and VS Code read
155/// `ui.csp` (camelCase). Instead of detecting the host, the rewrite does the
156/// same merge once and emits the result to *both* shapes. Extra keys are
157/// ignored by hosts that don't understand them.
158///
159/// Upstream domains that feed into the merge are aggregated from both shapes
160/// so a server declaring only one shape still informs the merge for the other.
161///
162/// `explicit_uri` is the resource URI the caller already resolved (for example,
163/// a `resources/read` caller knows the URI from the containing object). When
164/// missing, the URI is inferred from `meta.ui.resourceUri` or the legacy
165/// `openai/outputTemplate` field. The URI picks which `[[csp.widget]]`
166/// overrides apply.
167///
168/// The rewrite is skipped entirely for meta objects that show no sign of being
169/// a widget — a tool call result's `meta`, for example — so non-widget metas
170/// are not polluted with CSP fields they don't need.
171fn rewrite_widget_meta(meta: &mut Value, explicit_uri: Option<&str>, config: &RewriteConfig) {
172    if meta.get("openai/widgetDomain").is_some() {
173        meta["openai/widgetDomain"] = Value::String(config.proxy_domain.clone());
174    }
175
176    if !is_widget_meta(meta, explicit_uri) {
177        // Inside rewrite_widget_meta: the caller's match arm has already
178        // flagged mutation, so the return value is uninteresting here.
179        let _ = inject_proxy_into_all_csp(meta, config);
180        return;
181    }
182
183    let inferred = explicit_uri
184        .map(String::from)
185        .or_else(|| extract_resource_uri(meta));
186    let uri = inferred.as_deref();
187    let upstream_host = strip_scheme(&config.mcp_upstream);
188
189    // Merge once per directive, using the union of upstream declarations from
190    // both shapes so a server that only declared `ui.csp` still informs the
191    // merge for the `openai/widgetCSP` output and vice versa.
192    let connect = merged_domains(meta, Directive::Connect, uri, &upstream_host, config);
193    let resource = merged_domains(meta, Directive::Resource, uri, &upstream_host, config);
194    let frame = merged_domains(meta, Directive::Frame, uri, &upstream_host, config);
195
196    write_openai_csp(meta, &connect, &resource, &frame);
197    write_spec_csp(meta, &connect, &resource, &frame);
198
199    // Same reasoning as above — caller already flags mutation.
200    let _ = inject_proxy_into_all_csp(meta, config);
201}
202
203/// Return `true` when the meta object belongs to a widget, either because it
204/// already holds widget-shaped fields or because the caller resolved an
205/// explicit resource URI for it.
206fn is_widget_meta(meta: &Value, explicit_uri: Option<&str>) -> bool {
207    if explicit_uri.is_some() {
208        return true;
209    }
210    meta.get("openai/widgetCSP").is_some()
211        || meta.get("openai/widgetDomain").is_some()
212        || meta.get("openai/outputTemplate").is_some()
213        || meta.pointer("/ui/csp").is_some()
214        || meta.pointer("/ui/resourceUri").is_some()
215        || meta.pointer("/ui/domain").is_some()
216}
217
218/// Extract a resource URI from a widget meta object. Prefers the spec field
219/// (`ui.resourceUri`) and falls back to the OpenAI legacy key.
220fn extract_resource_uri(meta: &Value) -> Option<String> {
221    if let Some(u) = meta.pointer("/ui/resourceUri").and_then(|v| v.as_str()) {
222        return Some(u.to_string());
223    }
224    meta.get("openai/outputTemplate")
225        .and_then(|v| v.as_str())
226        .map(String::from)
227}
228
229/// Compute the effective domain list for one directive, seeded from whatever
230/// the upstream server declared in either CSP shape.
231fn merged_domains(
232    meta: &Value,
233    directive: Directive,
234    resource_uri: Option<&str>,
235    upstream_host: &str,
236    config: &RewriteConfig,
237) -> Vec<String> {
238    let upstream = collect_upstream(meta, directive);
239    effective_domains(
240        &config.csp,
241        directive,
242        resource_uri,
243        &upstream,
244        upstream_host,
245        &config.proxy_url,
246    )
247}
248
249/// Gather every string domain the upstream declared for `directive`, looking
250/// at both `openai/widgetCSP` and `ui.csp`. Duplicates are removed in order.
251fn collect_upstream(meta: &Value, directive: Directive) -> Vec<String> {
252    let (openai_key, spec_key) = match directive {
253        Directive::Connect => ("connect_domains", "connectDomains"),
254        Directive::Resource => ("resource_domains", "resourceDomains"),
255        Directive::Frame => ("frame_domains", "frameDomains"),
256    };
257
258    let mut out: Vec<String> = Vec::new();
259    let mut append = |arr: &Vec<Value>| {
260        for v in arr {
261            if let Some(s) = v.as_str() {
262                let s = s.to_string();
263                if !out.contains(&s) {
264                    out.push(s);
265                }
266            }
267        }
268    };
269
270    if let Some(arr) = meta
271        .get("openai/widgetCSP")
272        .and_then(|c| c.get(openai_key))
273        .and_then(|v| v.as_array())
274    {
275        append(arr);
276    }
277    if let Some(arr) = meta
278        .pointer("/ui/csp")
279        .and_then(|c| c.get(spec_key))
280        .and_then(|v| v.as_array())
281    {
282        append(arr);
283    }
284    out
285}
286
287/// Write the OpenAI-shaped CSP block, creating the parent object when needed.
288fn write_openai_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
289    let Some(obj) = meta.as_object_mut() else {
290        return;
291    };
292    obj.insert(
293        "openai/widgetCSP".to_string(),
294        serde_json::json!({
295            "connect_domains": connect,
296            "resource_domains": resource,
297            "frame_domains": frame,
298        }),
299    );
300}
301
302/// Write the spec-shaped CSP block under `ui.csp`, creating `ui` when needed.
303fn write_spec_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
304    let Some(obj) = meta.as_object_mut() else {
305        return;
306    };
307    let ui = obj
308        .entry("ui".to_string())
309        .or_insert_with(|| Value::Object(serde_json::Map::new()));
310    if !ui.is_object() {
311        *ui = Value::Object(serde_json::Map::new());
312    }
313    let ui_obj = ui.as_object_mut().unwrap();
314    ui_obj.insert(
315        "csp".to_string(),
316        serde_json::json!({
317            "connectDomains": connect,
318            "resourceDomains": resource,
319            "frameDomains": frame,
320        }),
321    );
322}
323
324/// Recursively ensure the proxy URL is present in every CSP-shaped domain array.
325/// Does not apply the merge rules — that would require URI context the deep scan
326/// does not have. The only guarantee is "proxy URL is reachable from the widget."
327/// Walk `value` and insert `config.proxy_url` at the front of every CSP
328/// domain array that doesn't already contain it. Returns `true` iff any
329/// insertion happened — lets callers skip re-serialization on no-op walks.
330#[must_use]
331fn inject_proxy_into_all_csp(value: &mut Value, config: &RewriteConfig) -> bool {
332    let mut mutated = false;
333    match value {
334        Value::Object(map) => {
335            for key in [
336                "connect_domains",
337                "resource_domains",
338                "frame_domains",
339                "connectDomains",
340                "resourceDomains",
341                "frameDomains",
342            ] {
343                if let Some(arr) = map.get_mut(key).and_then(|v| v.as_array_mut()) {
344                    let has_proxy = arr.iter().any(|v| v.as_str() == Some(&config.proxy_url));
345                    if !has_proxy {
346                        arr.insert(0, Value::String(config.proxy_url.clone()));
347                        mutated = true;
348                    }
349                }
350            }
351            for (_, v) in map.iter_mut() {
352                mutated |= inject_proxy_into_all_csp(v, config);
353            }
354        }
355        Value::Array(arr) => {
356            for item in arr {
357                mutated |= inject_proxy_into_all_csp(item, config);
358            }
359        }
360        _ => {}
361    }
362    mutated
363}
364
365fn strip_scheme(url: &str) -> String {
366    url.trim_start_matches("https://")
367        .trim_start_matches("http://")
368        .split('/')
369        .next()
370        .unwrap_or("")
371        .to_string()
372}
373
374#[cfg(test)]
375#[allow(non_snake_case)]
376mod tests {
377    use super::*;
378    use crate::proxy::csp::{DirectivePolicy, Mode, WidgetScoped};
379    use serde_json::json;
380
381    // ── helpers ────────────────────────────────────────────────────────────
382
383    fn rewrite_config() -> RewriteConfig {
384        RewriteConfig {
385            proxy_url: "https://abc.tunnel.example.com".into(),
386            proxy_domain: "abc.tunnel.example.com".into(),
387            mcp_upstream: "http://localhost:9000".into(),
388            csp: CspConfig::default(),
389        }
390    }
391
392    fn as_strs(arr: &Value) -> Vec<&str> {
393        arr.as_array()
394            .unwrap()
395            .iter()
396            .map(|v| v.as_str().unwrap())
397            .collect()
398    }
399
400    // ── resources/read: HTML body is never touched ─────────────────────────
401
402    #[test]
403    fn rewrite_response__resources_read_preserves_html() {
404        let config = rewrite_config();
405        let mut body = json!({
406            "jsonrpc": "2.0", "id": 1,
407            "result": {
408                "contents": [{
409                    "uri": "ui://widget/question",
410                    "mimeType": "text/html",
411                    "text": "<html><script src=\"/assets/main.js\"></script></html>"
412                }]
413            }
414        });
415        let original = body["result"]["contents"][0]["text"]
416            .as_str()
417            .unwrap()
418            .to_string();
419
420        rewrite_response("resources/read", &mut body, &config);
421
422        assert_eq!(
423            body["result"]["contents"][0]["text"].as_str().unwrap(),
424            original
425        );
426    }
427
428    // ── resources/read: rewrites meta, not text ────────────────────────────
429
430    #[test]
431    fn rewrite_response__resources_read_rewrites_meta_not_text() {
432        let config = rewrite_config();
433        let mut body = json!({
434            "result": {
435                "contents": [{
436                    "uri": "ui://widget/question",
437                    "mimeType": "text/html",
438                    "text": "<html><body>Hello</body></html>",
439                    "meta": {
440                        "openai/widgetDomain": "localhost:9000",
441                        "openai/widgetCSP": {
442                            "resource_domains": ["http://localhost:9000"],
443                            "connect_domains": ["http://localhost:9000"]
444                        }
445                    }
446                }]
447            }
448        });
449
450        rewrite_response("resources/read", &mut body, &config);
451
452        let content = &body["result"]["contents"][0];
453        assert_eq!(
454            content["text"].as_str().unwrap(),
455            "<html><body>Hello</body></html>"
456        );
457        assert_eq!(
458            content["meta"]["openai/widgetDomain"].as_str().unwrap(),
459            "abc.tunnel.example.com"
460        );
461        let resources = as_strs(&content["meta"]["openai/widgetCSP"]["resource_domains"]);
462        assert!(resources.contains(&"https://abc.tunnel.example.com"));
463        assert!(!resources.iter().any(|d| d.contains("localhost")));
464    }
465
466    // ── tools/list: per-tool meta rewrite ──────────────────────────────────
467
468    #[test]
469    fn rewrite_response__tools_list_rewrites_widget_domain() {
470        let config = rewrite_config();
471        let mut body = json!({
472            "result": {
473                "tools": [{
474                    "name": "create_question",
475                    "meta": {
476                        "openai/widgetDomain": "old.domain.com",
477                        "openai/widgetCSP": {
478                            "resource_domains": ["http://localhost:4444"],
479                            "connect_domains": ["http://localhost:9000", "https://api.external.com"]
480                        }
481                    }
482                }]
483            }
484        });
485
486        rewrite_response("tools/list", &mut body, &config);
487
488        let meta = &body["result"]["tools"][0]["meta"];
489        assert_eq!(
490            meta["openai/widgetDomain"].as_str().unwrap(),
491            "abc.tunnel.example.com"
492        );
493        let connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
494        assert!(connect.contains(&"https://abc.tunnel.example.com"));
495        assert!(connect.contains(&"https://api.external.com"));
496        assert!(!connect.iter().any(|d| d.contains("localhost")));
497    }
498
499    // ── tools/call: rewrites result.meta ───────────────────────────────────
500
501    #[test]
502    fn rewrite_response__tools_call_rewrites_meta() {
503        let config = rewrite_config();
504        let mut body = json!({
505            "result": {
506                "content": [{"type": "text", "text": "some result"}],
507                "meta": {
508                    "openai/widgetDomain": "old.domain.com",
509                    "openai/widgetCSP": {
510                        "resource_domains": ["http://localhost:4444"]
511                    }
512                }
513            }
514        });
515
516        rewrite_response("tools/call", &mut body, &config);
517
518        assert_eq!(
519            body["result"]["meta"]["openai/widgetDomain"]
520                .as_str()
521                .unwrap(),
522            "abc.tunnel.example.com"
523        );
524        assert_eq!(
525            body["result"]["content"][0]["text"].as_str().unwrap(),
526            "some result"
527        );
528    }
529
530    // ── resources/list ─────────────────────────────────────────────────────
531
532    #[test]
533    fn rewrite_response__resources_list_rewrites_meta() {
534        let config = rewrite_config();
535        let mut body = json!({
536            "result": {
537                "resources": [{
538                    "uri": "ui://widget/question",
539                    "name": "Question Widget",
540                    "meta": {
541                        "openai/widgetDomain": "old.domain.com"
542                    }
543                }]
544            }
545        });
546
547        rewrite_response("resources/list", &mut body, &config);
548
549        assert_eq!(
550            body["result"]["resources"][0]["meta"]["openai/widgetDomain"]
551                .as_str()
552                .unwrap(),
553            "abc.tunnel.example.com"
554        );
555    }
556
557    // ── resources/templates/list ───────────────────────────────────────────
558
559    #[test]
560    fn rewrite_response__resources_templates_list_rewrites_meta() {
561        let config = rewrite_config();
562        let mut body = json!({
563            "result": {
564                "resourceTemplates": [{
565                    "uriTemplate": "file:///{path}",
566                    "name": "File Access",
567                    "meta": {
568                        "openai/widgetDomain": "old.domain.com",
569                        "openai/widgetCSP": {
570                            "resource_domains": ["http://localhost:4444"],
571                            "connect_domains": ["http://localhost:9000"]
572                        }
573                    }
574                }]
575            }
576        });
577
578        rewrite_response("resources/templates/list", &mut body, &config);
579
580        let meta = &body["result"]["resourceTemplates"][0]["meta"];
581        assert_eq!(
582            meta["openai/widgetDomain"].as_str().unwrap(),
583            "abc.tunnel.example.com"
584        );
585        let resources = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
586        assert!(resources.contains(&"https://abc.tunnel.example.com"));
587        assert!(!resources.iter().any(|d| d.contains("localhost")));
588    }
589
590    // ── CSP merge: localhost stripping ─────────────────────────────────────
591
592    #[test]
593    fn rewrite_response__csp_strips_localhost() {
594        let config = rewrite_config();
595        let mut body = json!({
596            "result": {
597                "tools": [{
598                    "name": "test",
599                    "meta": {
600                        "openai/widgetCSP": {
601                            "resource_domains": [
602                                "http://localhost:4444",
603                                "http://127.0.0.1:4444",
604                                "http://localhost:9000",
605                                "https://cdn.external.com"
606                            ]
607                        }
608                    }
609                }]
610            }
611        });
612
613        rewrite_response("tools/list", &mut body, &config);
614
615        let domains =
616            as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]);
617        assert_eq!(
618            domains,
619            vec!["https://abc.tunnel.example.com", "https://cdn.external.com"]
620        );
621    }
622
623    // ── CSP merge: declared global domains are appended ────────────────────
624
625    #[test]
626    fn rewrite_response__global_connect_domains_appended() {
627        let mut config = rewrite_config();
628        config.csp.connect_domains = DirectivePolicy {
629            domains: vec!["https://extra.example.com".into()],
630            mode: Mode::Extend,
631        };
632
633        let mut body = json!({
634            "result": {
635                "tools": [{
636                    "name": "test",
637                    "meta": {
638                        "openai/widgetCSP": {
639                            "connect_domains": ["http://localhost:9000"]
640                        }
641                    }
642                }]
643            }
644        });
645
646        rewrite_response("tools/list", &mut body, &config);
647
648        let domains =
649            as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
650        assert!(domains.contains(&"https://extra.example.com"));
651        assert!(domains.contains(&"https://abc.tunnel.example.com"));
652    }
653
654    // ── CSP merge: no duplicate proxy entries ──────────────────────────────
655
656    #[test]
657    fn rewrite_response__csp_no_duplicate_proxy() {
658        let config = rewrite_config();
659        let mut body = json!({
660            "result": {
661                "tools": [{
662                    "name": "test",
663                    "meta": {
664                        "openai/widgetCSP": {
665                            "resource_domains": ["https://abc.tunnel.example.com", "https://cdn.example.com"]
666                        }
667                    }
668                }]
669            }
670        });
671
672        rewrite_response("tools/list", &mut body, &config);
673
674        let domains =
675            as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]);
676        let count = domains
677            .iter()
678            .filter(|d| **d == "https://abc.tunnel.example.com")
679            .count();
680        assert_eq!(count, 1);
681    }
682
683    // ── Claude format parity ───────────────────────────────────────────────
684
685    #[test]
686    fn rewrite_response__claude_csp_format() {
687        let config = rewrite_config();
688        let mut body = json!({
689            "result": {
690                "tools": [{
691                    "name": "test",
692                    "meta": {
693                        "ui": {
694                            "csp": {
695                                "connectDomains": ["http://localhost:9000"],
696                                "resourceDomains": ["http://localhost:4444"]
697                            }
698                        }
699                    }
700                }]
701            }
702        });
703
704        rewrite_response("tools/list", &mut body, &config);
705
706        let meta = &body["result"]["tools"][0]["meta"]["ui"]["csp"];
707        let connect = as_strs(&meta["connectDomains"]);
708        let resource = as_strs(&meta["resourceDomains"]);
709        assert!(connect.contains(&"https://abc.tunnel.example.com"));
710        assert!(resource.contains(&"https://abc.tunnel.example.com"));
711        assert!(!connect.iter().any(|d| d.contains("localhost")));
712        assert!(!resource.iter().any(|d| d.contains("localhost")));
713    }
714
715    // ── Deep CSP injection fallback ────────────────────────────────────────
716
717    #[test]
718    fn rewrite_response__deep_csp_injection() {
719        let config = rewrite_config();
720        let mut body = json!({
721            "result": {
722                "content": [{
723                    "type": "text",
724                    "text": "result",
725                    "deeply": {
726                        "nested": {
727                            "connect_domains": ["https://only-external.com"]
728                        }
729                    }
730                }]
731            }
732        });
733
734        rewrite_response("tools/call", &mut body, &config);
735
736        let domains = as_strs(&body["result"]["content"][0]["deeply"]["nested"]["connect_domains"]);
737        assert!(domains.contains(&"https://abc.tunnel.example.com"));
738    }
739
740    // ── Unknown methods: only deep scan runs ───────────────────────────────
741
742    #[test]
743    fn rewrite_response__unknown_method_passthrough() {
744        let config = rewrite_config();
745        let mut body = json!({
746            "result": {
747                "data": "unchanged",
748                "meta": { "openai/widgetDomain": "should-stay.com" }
749            }
750        });
751        rewrite_response("notifications/message", &mut body, &config);
752
753        assert_eq!(
754            body["result"]["meta"]["openai/widgetDomain"]
755                .as_str()
756                .unwrap(),
757            "should-stay.com"
758        );
759        assert_eq!(body["result"]["data"].as_str().unwrap(), "unchanged");
760    }
761
762    // ── Global replace mode ───────────────────────────────────────────────
763
764    #[test]
765    fn rewrite_response__replace_mode_ignores_upstream() {
766        let mut config = rewrite_config();
767        config.csp.resource_domains = DirectivePolicy {
768            domains: vec!["https://allowed.example.com".into()],
769            mode: Mode::Replace,
770        };
771        config.csp.connect_domains = DirectivePolicy {
772            domains: vec!["https://allowed.example.com".into()],
773            mode: Mode::Replace,
774        };
775
776        let mut body = json!({
777            "result": {
778                "tools": [{
779                    "name": "test",
780                    "meta": {
781                        "openai/widgetCSP": {
782                            "resource_domains": ["https://cdn.external.com", "https://api.external.com"],
783                            "connect_domains": ["https://api.external.com", "http://localhost:9000"]
784                        }
785                    }
786                }]
787            }
788        });
789
790        rewrite_response("tools/list", &mut body, &config);
791
792        let resources =
793            as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["resource_domains"]);
794        assert_eq!(
795            resources,
796            vec![
797                "https://abc.tunnel.example.com",
798                "https://allowed.example.com"
799            ]
800        );
801        let connect =
802            as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
803        assert_eq!(
804            connect,
805            vec![
806                "https://abc.tunnel.example.com",
807                "https://allowed.example.com"
808            ]
809        );
810    }
811
812    // ── Widget-scoped overrides ────────────────────────────────────────────
813
814    #[test]
815    fn rewrite_response__widget_scope_matches_resource_uri() {
816        // A widget override should only apply when the resource URI matches.
817        let mut config = rewrite_config();
818        config.csp.widgets.push(WidgetScoped {
819            match_pattern: "ui://widget/payment*".into(),
820            connect_domains: vec!["https://api.stripe.com".into()],
821            connect_domains_mode: Mode::Extend,
822            ..Default::default()
823        });
824
825        let mut body = json!({
826            "result": {
827                "resources": [
828                    {
829                        "uri": "ui://widget/payment-form",
830                        "meta": {
831                            "openai/widgetCSP": { "connect_domains": [] }
832                        }
833                    },
834                    {
835                        "uri": "ui://widget/search",
836                        "meta": {
837                            "openai/widgetCSP": { "connect_domains": [] }
838                        }
839                    }
840                ]
841            }
842        });
843
844        rewrite_response("resources/list", &mut body, &config);
845
846        let payment_connect =
847            as_strs(&body["result"]["resources"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
848        assert!(payment_connect.contains(&"https://api.stripe.com"));
849
850        let search_connect =
851            as_strs(&body["result"]["resources"][1]["meta"]["openai/widgetCSP"]["connect_domains"]);
852        assert!(!search_connect.contains(&"https://api.stripe.com"));
853    }
854
855    #[test]
856    fn rewrite_response__widget_replace_mode_wipes_upstream() {
857        let mut config = rewrite_config();
858        config.csp.widgets.push(WidgetScoped {
859            match_pattern: "ui://widget/*".into(),
860            connect_domains: vec!["https://api.stripe.com".into()],
861            connect_domains_mode: Mode::Replace,
862            ..Default::default()
863        });
864
865        let mut body = json!({
866            "result": {
867                "contents": [{
868                    "uri": "ui://widget/payment",
869                    "meta": {
870                        "openai/widgetCSP": {
871                            "connect_domains": [
872                                "https://api.external.com",
873                                "https://another.external.com"
874                            ]
875                        }
876                    }
877                }]
878            }
879        });
880
881        rewrite_response("resources/read", &mut body, &config);
882
883        let connect =
884            as_strs(&body["result"]["contents"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
885        assert_eq!(
886            connect,
887            vec!["https://abc.tunnel.example.com", "https://api.stripe.com"]
888        );
889    }
890
891    #[test]
892    fn rewrite_response__widget_uri_inferred_from_tool_meta() {
893        // tools/list responses do not carry a URI on the tool itself, but the
894        // widget resource URI lives in meta.ui.resourceUri; widget overrides
895        // should match against that.
896        let mut config = rewrite_config();
897        config.csp.widgets.push(WidgetScoped {
898            match_pattern: "ui://widget/payment*".into(),
899            connect_domains: vec!["https://api.stripe.com".into()],
900            connect_domains_mode: Mode::Extend,
901            ..Default::default()
902        });
903
904        let mut body = json!({
905            "result": {
906                "tools": [{
907                    "name": "take_payment",
908                    "meta": {
909                        "ui": { "resourceUri": "ui://widget/payment-form" },
910                        "openai/widgetCSP": { "connect_domains": [] }
911                    }
912                }]
913            }
914        });
915
916        rewrite_response("tools/list", &mut body, &config);
917
918        let connect =
919            as_strs(&body["result"]["tools"][0]["meta"]["openai/widgetCSP"]["connect_domains"]);
920        assert!(connect.contains(&"https://api.stripe.com"));
921    }
922
923    // ── Both shapes emitted from one declaration ───────────────────────────
924
925    #[test]
926    fn rewrite_response__spec_only_upstream_also_emits_openai_shape() {
927        // Upstream only declared ui.csp (spec shape). The rewrite must also
928        // synthesize openai/widgetCSP so ChatGPT — which reads the legacy
929        // key — receives the same effective CSP.
930        let config = rewrite_config();
931        let mut body = json!({
932            "result": {
933                "contents": [{
934                    "uri": "ui://widget/search",
935                    "mimeType": "text/html",
936                    "meta": {
937                        "ui": {
938                            "csp": {
939                                "connectDomains": ["https://api.external.com"],
940                                "resourceDomains": ["https://cdn.external.com"]
941                            }
942                        }
943                    }
944                }]
945            }
946        });
947
948        rewrite_response("resources/read", &mut body, &config);
949
950        let meta = &body["result"]["contents"][0]["meta"];
951        let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
952        let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
953        assert_eq!(oa_connect, spec_connect);
954        assert!(oa_connect.contains(&"https://api.external.com"));
955        assert!(oa_connect.contains(&"https://abc.tunnel.example.com"));
956    }
957
958    #[test]
959    fn rewrite_response__openai_only_upstream_also_emits_spec_shape() {
960        // Reverse of the above: upstream only declared openai/widgetCSP and
961        // Claude/VS Code clients must still see ui.csp.
962        let config = rewrite_config();
963        let mut body = json!({
964            "result": {
965                "contents": [{
966                    "uri": "ui://widget/search",
967                    "mimeType": "text/html",
968                    "meta": {
969                        "openai/widgetCSP": {
970                            "connect_domains": ["https://api.external.com"],
971                            "resource_domains": ["https://cdn.external.com"]
972                        }
973                    }
974                }]
975            }
976        });
977
978        rewrite_response("resources/read", &mut body, &config);
979
980        let meta = &body["result"]["contents"][0]["meta"];
981        let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
982        let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
983        assert_eq!(oa_connect, spec_connect);
984        assert!(spec_connect.contains(&"https://api.external.com"));
985        assert!(spec_connect.contains(&"https://abc.tunnel.example.com"));
986    }
987
988    #[test]
989    fn rewrite_response__declared_config_synthesizes_both_shapes_from_empty() {
990        // The server declared neither CSP shape. The operator's mcpr.toml
991        // declares connectDomains. Both shapes appear in the response with
992        // the declared domain, keyed off the URI on the containing resource.
993        let mut config = rewrite_config();
994        config.csp.connect_domains = DirectivePolicy {
995            domains: vec!["https://api.declared.com".into()],
996            mode: Mode::Extend,
997        };
998
999        let mut body = json!({
1000            "result": {
1001                "resources": [{
1002                    "uri": "ui://widget/search",
1003                    "meta": {
1004                        "openai/widgetDomain": "old.domain.com"
1005                    }
1006                }]
1007            }
1008        });
1009
1010        rewrite_response("resources/list", &mut body, &config);
1011
1012        let meta = &body["result"]["resources"][0]["meta"];
1013        let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1014        let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1015        assert_eq!(oa, spec);
1016        assert!(oa.contains(&"https://api.declared.com"));
1017        assert!(oa.contains(&"https://abc.tunnel.example.com"));
1018    }
1019
1020    #[test]
1021    fn rewrite_response__upstream_declarations_unioned_across_shapes() {
1022        // The server filled different domains into each shape. The merge must
1023        // see the union, not pick one.
1024        let config = rewrite_config();
1025        let mut body = json!({
1026            "result": {
1027                "contents": [{
1028                    "uri": "ui://widget/search",
1029                    "mimeType": "text/html",
1030                    "meta": {
1031                        "openai/widgetCSP": {
1032                            "connect_domains": ["https://api.only-openai.com"]
1033                        },
1034                        "ui": {
1035                            "csp": {
1036                                "connectDomains": ["https://api.only-spec.com"]
1037                            }
1038                        }
1039                    }
1040                }]
1041            }
1042        });
1043
1044        rewrite_response("resources/read", &mut body, &config);
1045
1046        let meta = &body["result"]["contents"][0]["meta"];
1047        let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1048        let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1049        assert_eq!(oa, spec);
1050        assert!(oa.contains(&"https://api.only-openai.com"));
1051        assert!(oa.contains(&"https://api.only-spec.com"));
1052    }
1053
1054    #[test]
1055    fn rewrite_response__non_widget_meta_is_not_polluted() {
1056        // A tool call result with plain meta (no widget indicators) must not
1057        // gain synthesized CSP fields — those only belong on widget metas.
1058        let config = rewrite_config();
1059        let mut body = json!({
1060            "result": {
1061                "content": [{"type": "text", "text": "plain result"}],
1062                "meta": { "requestId": "abc-123" }
1063            }
1064        });
1065
1066        rewrite_response("tools/call", &mut body, &config);
1067
1068        let meta = &body["result"]["meta"];
1069        assert!(meta.get("openai/widgetCSP").is_none());
1070        assert!(meta.get("ui").is_none());
1071        assert_eq!(meta["requestId"].as_str().unwrap(), "abc-123");
1072    }
1073
1074    #[test]
1075    fn rewrite_response__all_three_directives_synthesized() {
1076        // All three directives land in both shapes, not just connect.
1077        let mut config = rewrite_config();
1078        config.csp.connect_domains = DirectivePolicy {
1079            domains: vec!["https://api.example.com".into()],
1080            mode: Mode::Extend,
1081        };
1082        config.csp.resource_domains = DirectivePolicy {
1083            domains: vec!["https://cdn.example.com".into()],
1084            mode: Mode::Extend,
1085        };
1086
1087        let mut body = json!({
1088            "result": {
1089                "resources": [{
1090                    "uri": "ui://widget/search",
1091                    "meta": { "openai/widgetDomain": "x" }
1092                }]
1093            }
1094        });
1095
1096        rewrite_response("resources/list", &mut body, &config);
1097
1098        let meta = &body["result"]["resources"][0]["meta"];
1099        for shape in ["openai/widgetCSP"] {
1100            assert!(meta[shape]["connect_domains"].is_array());
1101            assert!(meta[shape]["resource_domains"].is_array());
1102            assert!(meta[shape]["frame_domains"].is_array());
1103        }
1104        assert!(meta["ui"]["csp"]["connectDomains"].is_array());
1105        assert!(meta["ui"]["csp"]["resourceDomains"].is_array());
1106        assert!(meta["ui"]["csp"]["frameDomains"].is_array());
1107    }
1108
1109    // ── Frame directive defaults strict ────────────────────────────────────
1110
1111    #[test]
1112    fn rewrite_response__frame_domains_default_replace_drops_upstream() {
1113        // Default config treats frameDomains as replace, so upstream values are
1114        // dropped even in the absence of any declared frame domains.
1115        let config = rewrite_config();
1116        let mut body = json!({
1117            "result": {
1118                "tools": [{
1119                    "name": "test",
1120                    "meta": {
1121                        "ui": {
1122                            "csp": {
1123                                "frameDomains": ["https://embed.external.com"]
1124                            }
1125                        }
1126                    }
1127                }]
1128            }
1129        });
1130
1131        rewrite_response("tools/list", &mut body, &config);
1132
1133        let frames = as_strs(&body["result"]["tools"][0]["meta"]["ui"]["csp"]["frameDomains"]);
1134        assert_eq!(frames, vec!["https://abc.tunnel.example.com"]);
1135    }
1136
1137    // ── End-to-end scenario ────────────────────────────────────────────────
1138
1139    #[test]
1140    fn rewrite_response__end_to_end_mcp_schema() {
1141        // Scenario exercising the full pipeline:
1142        // - A realistic multi-tool tools/list response from an upstream MCP
1143        //   server that declares widgets with mixed metadata shapes.
1144        // - A mcpr.toml with declared global CSP across all three directives
1145        //   and a widget-scoped override for the payment widget.
1146        //
1147        // After rewriting, every tool's meta should carry both CSP shapes
1148        // populated from the same merge, with upstream self-references
1149        // dropped, declared config applied, and the payment widget's Stripe
1150        // override only appearing on the payment tool.
1151        let mut config = rewrite_config();
1152        config.csp.connect_domains = DirectivePolicy {
1153            domains: vec!["https://api.myshop.com".into()],
1154            mode: Mode::Extend,
1155        };
1156        config.csp.resource_domains = DirectivePolicy {
1157            domains: vec!["https://cdn.myshop.com".into()],
1158            mode: Mode::Extend,
1159        };
1160        config.csp.widgets.push(WidgetScoped {
1161            match_pattern: "ui://widget/payment*".into(),
1162            connect_domains: vec!["https://api.stripe.com".into()],
1163            connect_domains_mode: Mode::Extend,
1164            resource_domains: vec!["https://js.stripe.com".into()],
1165            resource_domains_mode: Mode::Extend,
1166            ..Default::default()
1167        });
1168
1169        let mut body = json!({
1170            "jsonrpc": "2.0",
1171            "id": 42,
1172            "result": {
1173                "tools": [
1174                    {
1175                        "name": "search_products",
1176                        "description": "Search the product catalog",
1177                        "inputSchema": { "type": "object" },
1178                        "meta": {
1179                            "openai/widgetDomain": "old.shop.com",
1180                            "openai/outputTemplate": "ui://widget/search",
1181                            "openai/widgetCSP": {
1182                                "connect_domains": ["http://localhost:9000"],
1183                                "resource_domains": ["http://localhost:4444"]
1184                            }
1185                        }
1186                    },
1187                    {
1188                        "name": "take_payment",
1189                        "description": "Charge a card",
1190                        "inputSchema": { "type": "object" },
1191                        "meta": {
1192                            "ui": {
1193                                "resourceUri": "ui://widget/payment-form",
1194                                "csp": {
1195                                    "connectDomains": ["https://api.myshop.com"]
1196                                }
1197                            }
1198                        }
1199                    },
1200                    {
1201                        "name": "get_order_status",
1202                        "description": "Look up an order",
1203                        "inputSchema": { "type": "object" }
1204                    }
1205                ]
1206            }
1207        });
1208
1209        rewrite_response("tools/list", &mut body, &config);
1210
1211        let tools = body["result"]["tools"].as_array().unwrap();
1212
1213        // ── Tool 0: search — upstream declared only OpenAI shape ──────────
1214        let search_meta = &tools[0]["meta"];
1215        assert_eq!(
1216            search_meta["openai/widgetDomain"].as_str().unwrap(),
1217            "abc.tunnel.example.com"
1218        );
1219        let search_oa_connect = as_strs(&search_meta["openai/widgetCSP"]["connect_domains"]);
1220        let search_spec_connect = as_strs(&search_meta["ui"]["csp"]["connectDomains"]);
1221        assert_eq!(search_oa_connect, search_spec_connect);
1222        // Proxy first, then declared global, upstream localhost dropped.
1223        assert_eq!(
1224            search_oa_connect,
1225            vec!["https://abc.tunnel.example.com", "https://api.myshop.com"]
1226        );
1227        // The payment widget override must NOT apply to the search tool.
1228        assert!(!search_oa_connect.contains(&"https://api.stripe.com"));
1229        // Frame directive defaults strict — empty apart from the proxy URL.
1230        let search_oa_frame = as_strs(&search_meta["openai/widgetCSP"]["frame_domains"]);
1231        assert_eq!(search_oa_frame, vec!["https://abc.tunnel.example.com"]);
1232
1233        // ── Tool 1: payment — upstream declared only spec shape ──────────
1234        let payment_meta = &tools[1]["meta"];
1235        let payment_oa_connect = as_strs(&payment_meta["openai/widgetCSP"]["connect_domains"]);
1236        let payment_spec_connect = as_strs(&payment_meta["ui"]["csp"]["connectDomains"]);
1237        assert_eq!(payment_oa_connect, payment_spec_connect);
1238        // Proxy + global + widget override (Stripe) all present, in that order.
1239        assert_eq!(
1240            payment_oa_connect,
1241            vec![
1242                "https://abc.tunnel.example.com",
1243                "https://api.myshop.com",
1244                "https://api.stripe.com",
1245            ]
1246        );
1247        let payment_oa_resource = as_strs(&payment_meta["openai/widgetCSP"]["resource_domains"]);
1248        assert_eq!(
1249            payment_oa_resource,
1250            vec![
1251                "https://abc.tunnel.example.com",
1252                "https://cdn.myshop.com",
1253                "https://js.stripe.com",
1254            ]
1255        );
1256
1257        // ── Tool 2: plain tool, no widget metadata ────────────────────────
1258        // Non-widget metas must not gain synthesized CSP fields.
1259        let plain = &tools[2];
1260        assert!(plain.get("meta").is_none());
1261    }
1262}