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, is_public_proxy_origin};
29
30/// Runtime configuration for response rewriting.
31#[derive(Clone)]
32pub struct RewriteConfig {
33    /// Proxy URL (scheme + host, no trailing slash) to insert into every CSP
34    /// array so widgets can reach the proxy.
35    pub proxy_url: String,
36    /// Bare proxy host (no scheme) written into `openai/widgetDomain` on
37    /// widget metas. Not written into `_meta.ui.domain` — Claude derives
38    /// that field itself and rejects mismatching values.
39    pub proxy_domain: String,
40    /// Upstream MCP URL, used to recognise and strip upstream self-references
41    /// from the CSP arrays the server returns.
42    pub mcp_upstream: String,
43    /// Declarative CSP config — global policies plus widget-scoped overrides.
44    pub csp: CspConfig,
45}
46
47impl RewriteConfig {
48    /// Wrap this config in the lock-free `ArcSwap` shape expected by
49    /// [`crate::proxy::ProxyState::rewrite_config`]. Saves every caller
50    /// from importing `arc_swap` directly.
51    pub fn into_swap(self) -> std::sync::Arc<arc_swap::ArcSwap<RewriteConfig>> {
52        std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(self))
53    }
54}
55
56/// Rewrite a JSON-RPC response in place for the given method.
57///
58/// Returns `true` iff the body was actually mutated. Callers use this to
59/// decide whether the response needs to be re-serialized — see
60/// `pipeline::handlers::buffered` for the buffered-path dispatcher.
61#[must_use]
62pub fn rewrite_response(method: &str, body: &mut Value, config: &RewriteConfig) -> bool {
63    let mut mutated = false;
64    match method {
65        "tools/list" => {
66            if let Some(tools) = body
67                .get_mut("result")
68                .and_then(|r| r.get_mut("tools"))
69                .and_then(|t| t.as_array_mut())
70            {
71                for tool in tools {
72                    if let Some(meta) = tool.get_mut("_meta") {
73                        rewrite_widget_meta(meta, None, config);
74                        mutated = true;
75                    }
76                }
77            }
78        }
79        "tools/call" => {
80            if let Some(meta) = body.get_mut("result").and_then(|r| r.get_mut("_meta")) {
81                rewrite_widget_meta(meta, None, config);
82                mutated = true;
83            }
84        }
85        "resources/list" => {
86            if let Some(resources) = body
87                .get_mut("result")
88                .and_then(|r| r.get_mut("resources"))
89                .and_then(|r| r.as_array_mut())
90            {
91                for resource in resources {
92                    let uri = resource
93                        .get("uri")
94                        .and_then(|v| v.as_str())
95                        .map(String::from);
96                    // A URI alone is enough to treat this as a widget resource
97                    // per the MCP Apps spec, so synthesize `_meta` when the
98                    // upstream omits it — otherwise declared CSP silently
99                    // wouldn't apply to under-declaring servers.
100                    let has_existing_meta = resource.get("_meta").is_some();
101                    if (uri.is_some() || has_existing_meta)
102                        && let Some(meta) = ensure_meta(resource)
103                    {
104                        rewrite_widget_meta(meta, uri.as_deref(), config);
105                        mutated = true;
106                    }
107                }
108            }
109        }
110        "resources/templates/list" => {
111            if let Some(templates) = body
112                .get_mut("result")
113                .and_then(|r| r.get_mut("resourceTemplates"))
114                .and_then(|t| t.as_array_mut())
115            {
116                for template in templates {
117                    // Templates expose `uriTemplate`, not a concrete URI; treat
118                    // it as the match key so operators can glob on template IDs.
119                    let uri = template
120                        .get("uriTemplate")
121                        .and_then(|v| v.as_str())
122                        .map(String::from);
123                    let has_existing_meta = template.get("_meta").is_some();
124                    if (uri.is_some() || has_existing_meta)
125                        && let Some(meta) = ensure_meta(template)
126                    {
127                        rewrite_widget_meta(meta, uri.as_deref(), config);
128                        mutated = true;
129                    }
130                }
131            }
132        }
133        "resources/read" => {
134            if let Some(contents) = body
135                .get_mut("result")
136                .and_then(|r| r.get_mut("contents"))
137                .and_then(|c| c.as_array_mut())
138            {
139                for content in contents {
140                    let uri = content
141                        .get("uri")
142                        .and_then(|v| v.as_str())
143                        .map(String::from);
144                    let has_existing_meta = content.get("_meta").is_some();
145                    if (uri.is_some() || has_existing_meta)
146                        && let Some(meta) = ensure_meta(content)
147                    {
148                        rewrite_widget_meta(meta, uri.as_deref(), config);
149                        mutated = true;
150                    }
151                }
152            }
153        }
154        _ => {}
155    }
156
157    // Safety net: any CSP-shaped domain array elsewhere in the tree still gets
158    // the proxy URL. The merge rules above do not run here — this pass only
159    // guarantees the proxy URL is present.
160    mutated |= inject_proxy_into_all_csp(body, config);
161    mutated
162}
163
164/// Rewrite a widget metadata object.
165///
166/// Goal: an operator declares their CSP once in `mcpr.toml`, and the widget
167/// works on every host. Hosts disagree about where they read CSP from —
168/// ChatGPT reads `openai/widgetCSP` (snake_case), Claude and VS Code read
169/// `ui.csp` (camelCase). Instead of detecting the host, the rewrite does the
170/// same merge once and emits the result to *both* shapes. Extra keys are
171/// ignored by hosts that don't understand them.
172///
173/// Upstream domains that feed into the merge are aggregated from both shapes
174/// so a server declaring only one shape still informs the merge for the other.
175///
176/// `explicit_uri` is the resource URI the caller already resolved (for example,
177/// a `resources/read` caller knows the URI from the containing object). When
178/// missing, the URI is inferred from `meta.ui.resourceUri` or the legacy
179/// `openai/outputTemplate` field. The URI picks which `[[csp.widget]]`
180/// overrides apply.
181///
182/// The rewrite is skipped entirely for meta objects that show no sign of being
183/// a widget — a tool call result's `_meta`, for example — so non-widget metas
184/// are not polluted with CSP fields they don't need.
185fn rewrite_widget_meta(meta: &mut Value, explicit_uri: Option<&str>, config: &RewriteConfig) {
186    if !is_widget_meta(meta, explicit_uri) {
187        // Non-widget meta — not our surface. Run the deep CSP injection so any
188        // stray CSP arrays still get the proxy URL prepended, but don't write
189        // widget-domain keys: that would pollute metas that aren't widgets.
190        let _ = inject_proxy_into_all_csp(meta, config);
191        return;
192    }
193
194    // Operator's `csp.domain` flows into `openai/widgetDomain` only.
195    //
196    // `_meta.ui.domain` is intentionally NOT written. Claude validates that
197    // field against a hash it derives from the proxy URL itself (the format
198    // is `{sha256(url)[:32]}.claudemcpcontent.com`), so any value an MCP
199    // layer supplies is guaranteed to fail. Leaving the field absent lets
200    // Claude compute the value it expects; ChatGPT, which has no equivalent
201    // check, still gets the operator-declared host through `openai/widgetDomain`.
202    //
203    // The `proxy_domain.is_empty()` guard remains: in local-only dev with no
204    // public origin available, writing "" or `localhost` is never useful.
205    if !config.proxy_domain.is_empty() {
206        write_widget_domain(meta, &config.proxy_domain);
207    }
208
209    let inferred = explicit_uri
210        .map(String::from)
211        .or_else(|| extract_resource_uri(meta));
212    let uri = inferred.as_deref();
213    let upstream_host = strip_scheme(&config.mcp_upstream);
214
215    // Merge once per directive, using the union of upstream declarations from
216    // both shapes so a server that only declared `ui.csp` still informs the
217    // merge for the `openai/widgetCSP` output and vice versa.
218    let connect = merged_domains(meta, Directive::Connect, uri, &upstream_host, config);
219    let resource = merged_domains(meta, Directive::Resource, uri, &upstream_host, config);
220    let frame = merged_domains(meta, Directive::Frame, uri, &upstream_host, config);
221
222    write_openai_csp(meta, &connect, &resource, &frame);
223    write_spec_csp(meta, &connect, &resource, &frame);
224
225    // Caller's match arm already flags mutation, so the return value here is
226    // uninteresting.
227    let _ = inject_proxy_into_all_csp(meta, config);
228}
229
230/// Return `true` when the meta object belongs to a widget, either because it
231/// already holds widget-shaped fields or because the caller resolved an
232/// explicit resource URI for it.
233fn is_widget_meta(meta: &Value, explicit_uri: Option<&str>) -> bool {
234    if explicit_uri.is_some() {
235        return true;
236    }
237    meta.get("openai/widgetCSP").is_some()
238        || meta.get("openai/widgetDomain").is_some()
239        || meta.get("openai/outputTemplate").is_some()
240        || meta.pointer("/ui/csp").is_some()
241        || meta.pointer("/ui/resourceUri").is_some()
242        || meta.pointer("/ui/domain").is_some()
243}
244
245/// Extract a resource URI from a widget meta object. Prefers the spec field
246/// (`ui.resourceUri`) and falls back to the OpenAI legacy key.
247fn extract_resource_uri(meta: &Value) -> Option<String> {
248    if let Some(u) = meta.pointer("/ui/resourceUri").and_then(|v| v.as_str()) {
249        return Some(u.to_string());
250    }
251    meta.get("openai/outputTemplate")
252        .and_then(|v| v.as_str())
253        .map(String::from)
254}
255
256/// Compute the effective domain list for one directive, seeded from whatever
257/// the upstream server declared in either CSP shape.
258fn merged_domains(
259    meta: &Value,
260    directive: Directive,
261    resource_uri: Option<&str>,
262    upstream_host: &str,
263    config: &RewriteConfig,
264) -> Vec<String> {
265    let upstream = collect_upstream(meta, directive);
266    effective_domains(
267        &config.csp,
268        directive,
269        resource_uri,
270        &upstream,
271        upstream_host,
272        &config.proxy_url,
273    )
274}
275
276/// Gather every string domain the upstream declared for `directive`, looking
277/// at both `openai/widgetCSP` and `ui.csp`. Duplicates are removed in order.
278fn collect_upstream(meta: &Value, directive: Directive) -> Vec<String> {
279    let (openai_key, spec_key) = match directive {
280        Directive::Connect => ("connect_domains", "connectDomains"),
281        Directive::Resource => ("resource_domains", "resourceDomains"),
282        Directive::Frame => ("frame_domains", "frameDomains"),
283    };
284
285    let mut out: Vec<String> = Vec::new();
286    let mut append = |arr: &Vec<Value>| {
287        for v in arr {
288            if let Some(s) = v.as_str() {
289                let s = s.to_string();
290                if !out.contains(&s) {
291                    out.push(s);
292                }
293            }
294        }
295    };
296
297    if let Some(arr) = meta
298        .get("openai/widgetCSP")
299        .and_then(|c| c.get(openai_key))
300        .and_then(|v| v.as_array())
301    {
302        append(arr);
303    }
304    if let Some(arr) = meta
305        .pointer("/ui/csp")
306        .and_then(|c| c.get(spec_key))
307        .and_then(|v| v.as_array())
308    {
309        append(arr);
310    }
311    out
312}
313
314/// Write the proxy's public domain into `openai/widgetDomain`.
315///
316/// `_meta.ui.domain` is intentionally not written here — see the comment in
317/// `rewrite_widget_meta` for why Claude rejects any operator-supplied value.
318fn write_widget_domain(meta: &mut Value, domain: &str) {
319    let Some(obj) = meta.as_object_mut() else {
320        return;
321    };
322    obj.insert(
323        "openai/widgetDomain".to_string(),
324        Value::String(domain.to_string()),
325    );
326}
327
328fn write_openai_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
329    let Some(obj) = meta.as_object_mut() else {
330        return;
331    };
332    obj.insert(
333        "openai/widgetCSP".to_string(),
334        serde_json::json!({
335            "connect_domains": connect,
336            "resource_domains": resource,
337            "frame_domains": frame,
338        }),
339    );
340}
341
342/// Write the spec-shaped CSP block under `ui.csp`, creating `ui` when needed.
343fn write_spec_csp(meta: &mut Value, connect: &[String], resource: &[String], frame: &[String]) {
344    let Some(obj) = meta.as_object_mut() else {
345        return;
346    };
347    let ui = obj
348        .entry("ui".to_string())
349        .or_insert_with(|| Value::Object(serde_json::Map::new()));
350    if !ui.is_object() {
351        *ui = Value::Object(serde_json::Map::new());
352    }
353    let ui_obj = ui.as_object_mut().unwrap();
354    ui_obj.insert(
355        "csp".to_string(),
356        serde_json::json!({
357            "connectDomains": connect,
358            "resourceDomains": resource,
359            "frameDomains": frame,
360        }),
361    );
362}
363
364/// Recursively ensure the proxy URL is present in every CSP-shaped domain array
365/// that needs to reach the proxy (connect, resource). Frame arrays are skipped —
366/// see `csp::effective_domains`.
367///
368/// Does not apply the merge rules — that would require URI context the deep scan
369/// does not have. The only guarantee is "proxy URL is reachable from the widget."
370/// Walk `value` and insert `config.proxy_url` at the front of every CSP
371/// domain array that doesn't already contain it. Returns `true` iff any
372/// insertion happened — lets callers skip re-serialization on no-op walks.
373#[must_use]
374fn inject_proxy_into_all_csp(value: &mut Value, config: &RewriteConfig) -> bool {
375    // Skip entirely when there is no public origin worth injecting. A
376    // localhost proxy URL in a submitted widget's CSP is useless to the
377    // host (ChatGPT/Claude can't reach it) and just clutters the emitted
378    // config — better to leave the arrays alone.
379    if !is_public_proxy_origin(&config.proxy_url) {
380        return false;
381    }
382    let mut mutated = false;
383    match value {
384        Value::Object(map) => {
385            for key in [
386                "connect_domains",
387                "resource_domains",
388                "connectDomains",
389                "resourceDomains",
390            ] {
391                if let Some(arr) = map.get_mut(key).and_then(|v| v.as_array_mut()) {
392                    let has_proxy = arr.iter().any(|v| v.as_str() == Some(&config.proxy_url));
393                    if !has_proxy {
394                        arr.insert(0, Value::String(config.proxy_url.clone()));
395                        mutated = true;
396                    }
397                }
398            }
399            for (_, v) in map.iter_mut() {
400                mutated |= inject_proxy_into_all_csp(v, config);
401            }
402        }
403        Value::Array(arr) => {
404            for item in arr {
405                mutated |= inject_proxy_into_all_csp(item, config);
406            }
407        }
408        _ => {}
409    }
410    mutated
411}
412
413fn strip_scheme(url: &str) -> String {
414    url.trim_start_matches("https://")
415        .trim_start_matches("http://")
416        .split('/')
417        .next()
418        .unwrap_or("")
419        .to_string()
420}
421
422/// Get `container._meta`, inserting an empty object if absent. `None` only when
423/// the container isn't a JSON object — MCP resource/content entries always are,
424/// so callers can treat `None` as a malformed upstream and skip.
425fn ensure_meta(container: &mut Value) -> Option<&mut Value> {
426    let obj = container.as_object_mut()?;
427    Some(
428        obj.entry("_meta".to_string())
429            .or_insert_with(|| Value::Object(serde_json::Map::new())),
430    )
431}
432
433#[cfg(test)]
434#[allow(non_snake_case)]
435mod tests {
436    use super::*;
437    use crate::proxy::csp::{DirectivePolicy, Mode, WidgetScoped};
438    use serde_json::json;
439
440    // ── helpers ────────────────────────────────────────────────────────────
441
442    fn rewrite_config() -> RewriteConfig {
443        RewriteConfig {
444            proxy_url: "https://abc.tunnel.example.com".into(),
445            proxy_domain: "abc.tunnel.example.com".into(),
446            mcp_upstream: "http://localhost:9000".into(),
447            csp: CspConfig::default(),
448        }
449    }
450
451    fn as_strs(arr: &Value) -> Vec<&str> {
452        arr.as_array()
453            .unwrap()
454            .iter()
455            .map(|v| v.as_str().unwrap())
456            .collect()
457    }
458
459    // ── resources/read: HTML body is never touched ─────────────────────────
460
461    #[test]
462    fn rewrite_response__resources_read_preserves_html() {
463        let config = rewrite_config();
464        let mut body = json!({
465            "jsonrpc": "2.0", "id": 1,
466            "result": {
467                "contents": [{
468                    "uri": "ui://widget/question",
469                    "mimeType": "text/html",
470                    "text": "<html><script src=\"/assets/main.js\"></script></html>"
471                }]
472            }
473        });
474        let original = body["result"]["contents"][0]["text"]
475            .as_str()
476            .unwrap()
477            .to_string();
478
479        let _ = rewrite_response("resources/read", &mut body, &config);
480
481        assert_eq!(
482            body["result"]["contents"][0]["text"].as_str().unwrap(),
483            original
484        );
485    }
486
487    // ── resources/read: rewrites meta, not text ────────────────────────────
488
489    #[test]
490    fn rewrite_response__resources_read_rewrites_meta_not_text() {
491        let config = rewrite_config();
492        let mut body = json!({
493            "result": {
494                "contents": [{
495                    "uri": "ui://widget/question",
496                    "mimeType": "text/html",
497                    "text": "<html><body>Hello</body></html>",
498                    "_meta": {
499                        "openai/widgetDomain": "localhost:9000",
500                        "openai/widgetCSP": {
501                            "resource_domains": ["http://localhost:9000"],
502                            "connect_domains": ["http://localhost:9000"]
503                        }
504                    }
505                }]
506            }
507        });
508
509        let _ = rewrite_response("resources/read", &mut body, &config);
510
511        let content = &body["result"]["contents"][0];
512        assert_eq!(
513            content["text"].as_str().unwrap(),
514            "<html><body>Hello</body></html>"
515        );
516        assert_eq!(
517            content["_meta"]["openai/widgetDomain"].as_str().unwrap(),
518            "abc.tunnel.example.com"
519        );
520        let resources = as_strs(&content["_meta"]["openai/widgetCSP"]["resource_domains"]);
521        assert!(resources.contains(&"https://abc.tunnel.example.com"));
522        assert!(!resources.iter().any(|d| d.contains("localhost")));
523    }
524
525    // ── tools/list: per-tool meta rewrite ──────────────────────────────────
526
527    #[test]
528    fn rewrite_response__tools_list_rewrites_widget_domain() {
529        let config = rewrite_config();
530        let mut body = json!({
531            "result": {
532                "tools": [{
533                    "name": "create_question",
534                    "_meta": {
535                        "openai/widgetDomain": "old.domain.com",
536                        "openai/widgetCSP": {
537                            "resource_domains": ["http://localhost:4444"],
538                            "connect_domains": ["http://localhost:9000", "https://api.external.com"]
539                        }
540                    }
541                }]
542            }
543        });
544
545        let _ = rewrite_response("tools/list", &mut body, &config);
546
547        let meta = &body["result"]["tools"][0]["_meta"];
548        assert_eq!(
549            meta["openai/widgetDomain"].as_str().unwrap(),
550            "abc.tunnel.example.com"
551        );
552        let connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
553        assert!(connect.contains(&"https://abc.tunnel.example.com"));
554        assert!(connect.contains(&"https://api.external.com"));
555        assert!(!connect.iter().any(|d| d.contains("localhost")));
556    }
557
558    #[test]
559    fn rewrite_widget_meta__upstream_openai_overwritten_ui_domain_absent() {
560        let config = rewrite_config();
561        let mut meta = json!({
562            "openai/widgetDomain": "old.domain.com"
563        });
564
565        rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
566
567        assert_eq!(
568            meta["openai/widgetDomain"].as_str().unwrap(),
569            "abc.tunnel.example.com"
570        );
571        // ui.domain is Claude's territory — Claude validates it against a
572        // hash it derives from the proxy URL. We never write it.
573        assert!(meta.pointer("/ui/domain").is_none());
574    }
575
576    #[test]
577    fn rewrite_widget_meta__upstream_ui_domain_left_untouched() {
578        // Upstream's `ui.domain` is preserved verbatim. We never overwrite it
579        // with the operator's `csp.domain` — Claude would reject any value
580        // other than its own derived `{hash}.claudemcpcontent.com`.
581        let config = rewrite_config();
582        let mut meta = json!({
583            "ui": { "domain": "old.domain.com" }
584        });
585
586        rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
587
588        assert_eq!(meta["ui"]["domain"].as_str().unwrap(), "old.domain.com");
589        assert_eq!(
590            meta["openai/widgetDomain"].as_str().unwrap(),
591            "abc.tunnel.example.com"
592        );
593    }
594
595    #[test]
596    fn rewrite_widget_meta__synthesizes_openai_only_when_upstream_declared_neither() {
597        // Operator declared a public domain. Only `openai/widgetDomain` is
598        // synthesized; `ui.domain` is left absent so Claude derives its own.
599        let config = rewrite_config();
600        let mut meta = json!({
601            "openai/widgetCSP": { "connect_domains": [] }
602        });
603
604        rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
605
606        assert_eq!(
607            meta["openai/widgetDomain"].as_str().unwrap(),
608            "abc.tunnel.example.com"
609        );
610        assert!(meta.pointer("/ui/domain").is_none());
611    }
612
613    #[test]
614    fn rewrite_widget_meta__synthesizes_openai_only_when_explicit_uri_marks_widget() {
615        let config = rewrite_config();
616        let mut meta = json!({});
617
618        rewrite_widget_meta(&mut meta, Some("ui://widget/x"), &config);
619
620        assert_eq!(
621            meta["openai/widgetDomain"].as_str().unwrap(),
622            "abc.tunnel.example.com"
623        );
624        assert!(meta.pointer("/ui/domain").is_none());
625    }
626
627    #[test]
628    fn rewrite_widget_meta__non_widget_meta_does_not_get_widget_domain() {
629        // Regression guard: a meta object with no widget markers and no
630        // explicit URI is not a widget surface. We must not pollute it with
631        // `openai/widgetDomain` just because the operator configured one.
632        let config = rewrite_config();
633        let mut meta = json!({ "some/unrelated": "value" });
634
635        rewrite_widget_meta(&mut meta, None, &config);
636
637        assert!(meta.get("openai/widgetDomain").is_none());
638        assert!(meta.pointer("/ui/domain").is_none());
639    }
640
641    // ── tools/call: rewrites result.meta ───────────────────────────────────
642
643    #[test]
644    fn rewrite_response__tools_call_rewrites_meta() {
645        let config = rewrite_config();
646        let mut body = json!({
647            "result": {
648                "content": [{"type": "text", "text": "some result"}],
649                "_meta": {
650                    "openai/widgetDomain": "old.domain.com",
651                    "openai/widgetCSP": {
652                        "resource_domains": ["http://localhost:4444"]
653                    }
654                }
655            }
656        });
657
658        let _ = rewrite_response("tools/call", &mut body, &config);
659
660        assert_eq!(
661            body["result"]["_meta"]["openai/widgetDomain"]
662                .as_str()
663                .unwrap(),
664            "abc.tunnel.example.com"
665        );
666        assert_eq!(
667            body["result"]["content"][0]["text"].as_str().unwrap(),
668            "some result"
669        );
670    }
671
672    // ── resources/list ─────────────────────────────────────────────────────
673
674    #[test]
675    fn rewrite_response__resources_list_rewrites_meta() {
676        let config = rewrite_config();
677        let mut body = json!({
678            "result": {
679                "resources": [{
680                    "uri": "ui://widget/question",
681                    "name": "Question Widget",
682                    "_meta": {
683                        "openai/widgetDomain": "old.domain.com"
684                    }
685                }]
686            }
687        });
688
689        let _ = rewrite_response("resources/list", &mut body, &config);
690
691        assert_eq!(
692            body["result"]["resources"][0]["_meta"]["openai/widgetDomain"]
693                .as_str()
694                .unwrap(),
695            "abc.tunnel.example.com"
696        );
697    }
698
699    // ── resources/templates/list ───────────────────────────────────────────
700
701    #[test]
702    fn rewrite_response__resources_templates_list_rewrites_meta() {
703        let config = rewrite_config();
704        let mut body = json!({
705            "result": {
706                "resourceTemplates": [{
707                    "uriTemplate": "file:///{path}",
708                    "name": "File Access",
709                    "_meta": {
710                        "openai/widgetDomain": "old.domain.com",
711                        "openai/widgetCSP": {
712                            "resource_domains": ["http://localhost:4444"],
713                            "connect_domains": ["http://localhost:9000"]
714                        }
715                    }
716                }]
717            }
718        });
719
720        let _ = rewrite_response("resources/templates/list", &mut body, &config);
721
722        let meta = &body["result"]["resourceTemplates"][0]["_meta"];
723        assert_eq!(
724            meta["openai/widgetDomain"].as_str().unwrap(),
725            "abc.tunnel.example.com"
726        );
727        let resources = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
728        assert!(resources.contains(&"https://abc.tunnel.example.com"));
729        assert!(!resources.iter().any(|d| d.contains("localhost")));
730    }
731
732    // ── CSP merge: localhost stripping ─────────────────────────────────────
733
734    #[test]
735    fn rewrite_response__csp_strips_localhost() {
736        let config = rewrite_config();
737        let mut body = json!({
738            "result": {
739                "tools": [{
740                    "name": "test",
741                    "_meta": {
742                        "openai/widgetCSP": {
743                            "resource_domains": [
744                                "http://localhost:4444",
745                                "http://127.0.0.1:4444",
746                                "http://localhost:9000",
747                                "https://cdn.external.com"
748                            ]
749                        }
750                    }
751                }]
752            }
753        });
754
755        let _ = rewrite_response("tools/list", &mut body, &config);
756
757        let domains =
758            as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["resource_domains"]);
759        assert_eq!(
760            domains,
761            vec!["https://abc.tunnel.example.com", "https://cdn.external.com"]
762        );
763    }
764
765    // ── CSP merge: declared global domains are appended ────────────────────
766
767    #[test]
768    fn rewrite_response__global_connect_domains_appended() {
769        let mut config = rewrite_config();
770        config.csp.connect_domains = DirectivePolicy {
771            domains: vec!["https://extra.example.com".into()],
772            mode: Mode::Extend,
773        };
774
775        let mut body = json!({
776            "result": {
777                "tools": [{
778                    "name": "test",
779                    "_meta": {
780                        "openai/widgetCSP": {
781                            "connect_domains": ["http://localhost:9000"]
782                        }
783                    }
784                }]
785            }
786        });
787
788        let _ = rewrite_response("tools/list", &mut body, &config);
789
790        let domains =
791            as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
792        assert!(domains.contains(&"https://extra.example.com"));
793        assert!(domains.contains(&"https://abc.tunnel.example.com"));
794    }
795
796    // ── CSP merge: no duplicate proxy entries ──────────────────────────────
797
798    #[test]
799    fn rewrite_response__csp_no_duplicate_proxy() {
800        let config = rewrite_config();
801        let mut body = json!({
802            "result": {
803                "tools": [{
804                    "name": "test",
805                    "_meta": {
806                        "openai/widgetCSP": {
807                            "resource_domains": ["https://abc.tunnel.example.com", "https://cdn.example.com"]
808                        }
809                    }
810                }]
811            }
812        });
813
814        let _ = rewrite_response("tools/list", &mut body, &config);
815
816        let domains =
817            as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["resource_domains"]);
818        let count = domains
819            .iter()
820            .filter(|d| **d == "https://abc.tunnel.example.com")
821            .count();
822        assert_eq!(count, 1);
823    }
824
825    // ── Claude format parity ───────────────────────────────────────────────
826
827    #[test]
828    fn rewrite_response__claude_csp_format() {
829        let config = rewrite_config();
830        let mut body = json!({
831            "result": {
832                "tools": [{
833                    "name": "test",
834                    "_meta": {
835                        "ui": {
836                            "csp": {
837                                "connectDomains": ["http://localhost:9000"],
838                                "resourceDomains": ["http://localhost:4444"]
839                            }
840                        }
841                    }
842                }]
843            }
844        });
845
846        let _ = rewrite_response("tools/list", &mut body, &config);
847
848        let meta = &body["result"]["tools"][0]["_meta"]["ui"]["csp"];
849        let connect = as_strs(&meta["connectDomains"]);
850        let resource = as_strs(&meta["resourceDomains"]);
851        assert!(connect.contains(&"https://abc.tunnel.example.com"));
852        assert!(resource.contains(&"https://abc.tunnel.example.com"));
853        assert!(!connect.iter().any(|d| d.contains("localhost")));
854        assert!(!resource.iter().any(|d| d.contains("localhost")));
855    }
856
857    // ── Deep CSP injection fallback ────────────────────────────────────────
858
859    #[test]
860    fn rewrite_response__deep_csp_injection() {
861        let config = rewrite_config();
862        let mut body = json!({
863            "result": {
864                "content": [{
865                    "type": "text",
866                    "text": "result",
867                    "deeply": {
868                        "nested": {
869                            "connect_domains": ["https://only-external.com"]
870                        }
871                    }
872                }]
873            }
874        });
875
876        let _ = rewrite_response("tools/call", &mut body, &config);
877
878        let domains = as_strs(&body["result"]["content"][0]["deeply"]["nested"]["connect_domains"]);
879        assert!(domains.contains(&"https://abc.tunnel.example.com"));
880    }
881
882    #[test]
883    fn rewrite_response__deep_csp_injection_skips_frame_arrays() {
884        // Regression guard for the frame-domains fix: the safety-net deep scan
885        // must not prepend the proxy URL to frame arrays, or every mcpr-proxied
886        // widget would look like an iframe-embedder to ChatGPT and trigger
887        // extra security review.
888        let config = rewrite_config();
889        let mut body = json!({
890            "result": {
891                "content": [{
892                    "type": "text",
893                    "text": "result",
894                    "deeply": {
895                        "nested": {
896                            "frame_domains": ["https://embed.partner.com"],
897                            "frameDomains": ["https://embed.partner.com"]
898                        }
899                    }
900                }]
901            }
902        });
903
904        let _ = rewrite_response("tools/call", &mut body, &config);
905
906        let nested = &body["result"]["content"][0]["deeply"]["nested"];
907        let snake = as_strs(&nested["frame_domains"]);
908        let camel = as_strs(&nested["frameDomains"]);
909        assert_eq!(snake, vec!["https://embed.partner.com"]);
910        assert_eq!(camel, vec!["https://embed.partner.com"]);
911    }
912
913    // ── Unknown methods: only deep scan runs ───────────────────────────────
914
915    #[test]
916    fn rewrite_response__unknown_method_passthrough() {
917        let config = rewrite_config();
918        let mut body = json!({
919            "result": {
920                "data": "unchanged",
921                "_meta": { "openai/widgetDomain": "should-stay.com" }
922            }
923        });
924        let _ = rewrite_response("notifications/message", &mut body, &config);
925
926        assert_eq!(
927            body["result"]["_meta"]["openai/widgetDomain"]
928                .as_str()
929                .unwrap(),
930            "should-stay.com"
931        );
932        assert_eq!(body["result"]["data"].as_str().unwrap(), "unchanged");
933    }
934
935    // ── Global replace mode ───────────────────────────────────────────────
936
937    #[test]
938    fn rewrite_response__replace_mode_ignores_upstream() {
939        let mut config = rewrite_config();
940        config.csp.resource_domains = DirectivePolicy {
941            domains: vec!["https://allowed.example.com".into()],
942            mode: Mode::Replace,
943        };
944        config.csp.connect_domains = DirectivePolicy {
945            domains: vec!["https://allowed.example.com".into()],
946            mode: Mode::Replace,
947        };
948
949        let mut body = json!({
950            "result": {
951                "tools": [{
952                    "name": "test",
953                    "_meta": {
954                        "openai/widgetCSP": {
955                            "resource_domains": ["https://cdn.external.com", "https://api.external.com"],
956                            "connect_domains": ["https://api.external.com", "http://localhost:9000"]
957                        }
958                    }
959                }]
960            }
961        });
962
963        let _ = rewrite_response("tools/list", &mut body, &config);
964
965        let resources =
966            as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["resource_domains"]);
967        assert_eq!(
968            resources,
969            vec![
970                "https://abc.tunnel.example.com",
971                "https://allowed.example.com"
972            ]
973        );
974        let connect =
975            as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
976        assert_eq!(
977            connect,
978            vec![
979                "https://abc.tunnel.example.com",
980                "https://allowed.example.com"
981            ]
982        );
983    }
984
985    // ── Widget-scoped overrides ────────────────────────────────────────────
986
987    #[test]
988    fn rewrite_response__widget_scope_matches_resource_uri() {
989        // A widget override should only apply when the resource URI matches.
990        let mut config = rewrite_config();
991        config.csp.widgets.push(WidgetScoped {
992            match_pattern: "ui://widget/payment*".into(),
993            connect_domains: vec!["https://api.stripe.com".into()],
994            connect_domains_mode: Mode::Extend,
995            ..Default::default()
996        });
997
998        let mut body = json!({
999            "result": {
1000                "resources": [
1001                    {
1002                        "uri": "ui://widget/payment-form",
1003                        "_meta": {
1004                            "openai/widgetCSP": { "connect_domains": [] }
1005                        }
1006                    },
1007                    {
1008                        "uri": "ui://widget/search",
1009                        "_meta": {
1010                            "openai/widgetCSP": { "connect_domains": [] }
1011                        }
1012                    }
1013                ]
1014            }
1015        });
1016
1017        let _ = rewrite_response("resources/list", &mut body, &config);
1018
1019        let payment_connect = as_strs(
1020            &body["result"]["resources"][0]["_meta"]["openai/widgetCSP"]["connect_domains"],
1021        );
1022        assert!(payment_connect.contains(&"https://api.stripe.com"));
1023
1024        let search_connect = as_strs(
1025            &body["result"]["resources"][1]["_meta"]["openai/widgetCSP"]["connect_domains"],
1026        );
1027        assert!(!search_connect.contains(&"https://api.stripe.com"));
1028    }
1029
1030    #[test]
1031    fn rewrite_response__widget_replace_mode_wipes_upstream() {
1032        let mut config = rewrite_config();
1033        config.csp.widgets.push(WidgetScoped {
1034            match_pattern: "ui://widget/*".into(),
1035            connect_domains: vec!["https://api.stripe.com".into()],
1036            connect_domains_mode: Mode::Replace,
1037            ..Default::default()
1038        });
1039
1040        let mut body = json!({
1041            "result": {
1042                "contents": [{
1043                    "uri": "ui://widget/payment",
1044                    "_meta": {
1045                        "openai/widgetCSP": {
1046                            "connect_domains": [
1047                                "https://api.external.com",
1048                                "https://another.external.com"
1049                            ]
1050                        }
1051                    }
1052                }]
1053            }
1054        });
1055
1056        let _ = rewrite_response("resources/read", &mut body, &config);
1057
1058        let connect =
1059            as_strs(&body["result"]["contents"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
1060        assert_eq!(
1061            connect,
1062            vec!["https://abc.tunnel.example.com", "https://api.stripe.com"]
1063        );
1064    }
1065
1066    #[test]
1067    fn rewrite_response__widget_uri_inferred_from_tool_meta() {
1068        // tools/list responses do not carry a URI on the tool itself, but the
1069        // widget resource URI lives in meta.ui.resourceUri; widget overrides
1070        // should match against that.
1071        let mut config = rewrite_config();
1072        config.csp.widgets.push(WidgetScoped {
1073            match_pattern: "ui://widget/payment*".into(),
1074            connect_domains: vec!["https://api.stripe.com".into()],
1075            connect_domains_mode: Mode::Extend,
1076            ..Default::default()
1077        });
1078
1079        let mut body = json!({
1080            "result": {
1081                "tools": [{
1082                    "name": "take_payment",
1083                    "_meta": {
1084                        "ui": { "resourceUri": "ui://widget/payment-form" },
1085                        "openai/widgetCSP": { "connect_domains": [] }
1086                    }
1087                }]
1088            }
1089        });
1090
1091        let _ = rewrite_response("tools/list", &mut body, &config);
1092
1093        let connect =
1094            as_strs(&body["result"]["tools"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
1095        assert!(connect.contains(&"https://api.stripe.com"));
1096    }
1097
1098    // ── Both shapes emitted from one declaration ───────────────────────────
1099
1100    #[test]
1101    fn rewrite_response__spec_only_upstream_also_emits_openai_shape() {
1102        // Upstream only declared ui.csp (spec shape). The rewrite must also
1103        // synthesize openai/widgetCSP so ChatGPT — which reads the legacy
1104        // key — receives the same effective CSP.
1105        let config = rewrite_config();
1106        let mut body = json!({
1107            "result": {
1108                "contents": [{
1109                    "uri": "ui://widget/search",
1110                    "mimeType": "text/html",
1111                    "_meta": {
1112                        "ui": {
1113                            "csp": {
1114                                "connectDomains": ["https://api.external.com"],
1115                                "resourceDomains": ["https://cdn.external.com"]
1116                            }
1117                        }
1118                    }
1119                }]
1120            }
1121        });
1122
1123        let _ = rewrite_response("resources/read", &mut body, &config);
1124
1125        let meta = &body["result"]["contents"][0]["_meta"];
1126        let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1127        let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1128        assert_eq!(oa_connect, spec_connect);
1129        assert!(oa_connect.contains(&"https://api.external.com"));
1130        assert!(oa_connect.contains(&"https://abc.tunnel.example.com"));
1131    }
1132
1133    #[test]
1134    fn rewrite_response__openai_only_upstream_also_emits_spec_shape() {
1135        // Reverse of the above: upstream only declared openai/widgetCSP and
1136        // Claude/VS Code clients must still see ui.csp.
1137        let config = rewrite_config();
1138        let mut body = json!({
1139            "result": {
1140                "contents": [{
1141                    "uri": "ui://widget/search",
1142                    "mimeType": "text/html",
1143                    "_meta": {
1144                        "openai/widgetCSP": {
1145                            "connect_domains": ["https://api.external.com"],
1146                            "resource_domains": ["https://cdn.external.com"]
1147                        }
1148                    }
1149                }]
1150            }
1151        });
1152
1153        let _ = rewrite_response("resources/read", &mut body, &config);
1154
1155        let meta = &body["result"]["contents"][0]["_meta"];
1156        let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1157        let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1158        assert_eq!(oa_connect, spec_connect);
1159        assert!(spec_connect.contains(&"https://api.external.com"));
1160        assert!(spec_connect.contains(&"https://abc.tunnel.example.com"));
1161    }
1162
1163    #[test]
1164    fn rewrite_response__declared_config_synthesizes_both_shapes_from_empty() {
1165        // The server declared neither CSP shape. The operator's mcpr.toml
1166        // declares connectDomains. Both shapes appear in the response with
1167        // the declared domain, keyed off the URI on the containing resource.
1168        let mut config = rewrite_config();
1169        config.csp.connect_domains = DirectivePolicy {
1170            domains: vec!["https://api.declared.com".into()],
1171            mode: Mode::Extend,
1172        };
1173
1174        let mut body = json!({
1175            "result": {
1176                "resources": [{
1177                    "uri": "ui://widget/search",
1178                    "_meta": {
1179                        "openai/widgetDomain": "old.domain.com"
1180                    }
1181                }]
1182            }
1183        });
1184
1185        let _ = rewrite_response("resources/list", &mut body, &config);
1186
1187        let meta = &body["result"]["resources"][0]["_meta"];
1188        let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1189        let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1190        assert_eq!(oa, spec);
1191        assert!(oa.contains(&"https://api.declared.com"));
1192        assert!(oa.contains(&"https://abc.tunnel.example.com"));
1193    }
1194
1195    #[test]
1196    fn rewrite_response__upstream_declarations_unioned_across_shapes() {
1197        // The server filled different domains into each shape. The merge must
1198        // see the union, not pick one.
1199        let config = rewrite_config();
1200        let mut body = json!({
1201            "result": {
1202                "contents": [{
1203                    "uri": "ui://widget/search",
1204                    "mimeType": "text/html",
1205                    "_meta": {
1206                        "openai/widgetCSP": {
1207                            "connect_domains": ["https://api.only-openai.com"]
1208                        },
1209                        "ui": {
1210                            "csp": {
1211                                "connectDomains": ["https://api.only-spec.com"]
1212                            }
1213                        }
1214                    }
1215                }]
1216            }
1217        });
1218
1219        let _ = rewrite_response("resources/read", &mut body, &config);
1220
1221        let meta = &body["result"]["contents"][0]["_meta"];
1222        let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1223        let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1224        assert_eq!(oa, spec);
1225        assert!(oa.contains(&"https://api.only-openai.com"));
1226        assert!(oa.contains(&"https://api.only-spec.com"));
1227    }
1228
1229    #[test]
1230    fn rewrite_response__non_widget_meta_is_not_polluted() {
1231        // A tool call result with plain meta (no widget indicators) must not
1232        // gain synthesized CSP fields — those only belong on widget metas.
1233        let config = rewrite_config();
1234        let mut body = json!({
1235            "result": {
1236                "content": [{"type": "text", "text": "plain result"}],
1237                "_meta": { "requestId": "abc-123" }
1238            }
1239        });
1240
1241        let _ = rewrite_response("tools/call", &mut body, &config);
1242
1243        let meta = &body["result"]["_meta"];
1244        assert!(meta.get("openai/widgetCSP").is_none());
1245        assert!(meta.get("ui").is_none());
1246        assert_eq!(meta["requestId"].as_str().unwrap(), "abc-123");
1247    }
1248
1249    #[test]
1250    fn rewrite_response__all_three_directives_synthesized() {
1251        // All three directives land in both shapes, not just connect.
1252        let mut config = rewrite_config();
1253        config.csp.connect_domains = DirectivePolicy {
1254            domains: vec!["https://api.example.com".into()],
1255            mode: Mode::Extend,
1256        };
1257        config.csp.resource_domains = DirectivePolicy {
1258            domains: vec!["https://cdn.example.com".into()],
1259            mode: Mode::Extend,
1260        };
1261
1262        let mut body = json!({
1263            "result": {
1264                "resources": [{
1265                    "uri": "ui://widget/search",
1266                    "_meta": { "openai/widgetDomain": "x" }
1267                }]
1268            }
1269        });
1270
1271        let _ = rewrite_response("resources/list", &mut body, &config);
1272
1273        let meta = &body["result"]["resources"][0]["_meta"];
1274        let shape = "openai/widgetCSP";
1275        assert!(meta[shape]["connect_domains"].is_array());
1276        assert!(meta[shape]["resource_domains"].is_array());
1277        assert!(meta[shape]["frame_domains"].is_array());
1278        assert!(meta["ui"]["csp"]["connectDomains"].is_array());
1279        assert!(meta["ui"]["csp"]["resourceDomains"].is_array());
1280        assert!(meta["ui"]["csp"]["frameDomains"].is_array());
1281    }
1282
1283    // ── Frame directive defaults strict ────────────────────────────────────
1284
1285    #[test]
1286    fn rewrite_response__frame_domains_default_replace_drops_upstream() {
1287        // Default config treats frameDomains as replace, so upstream values are
1288        // dropped. Unlike connect/resource, the proxy URL is NOT prepended to
1289        // frame — the widget doesn't iframe the proxy back into itself, and
1290        // prepending it flags the widget as an iframe-embedder to hosts.
1291        let config = rewrite_config();
1292        let mut body = json!({
1293            "result": {
1294                "tools": [{
1295                    "name": "test",
1296                    "_meta": {
1297                        "ui": {
1298                            "csp": {
1299                                "frameDomains": ["https://embed.external.com"]
1300                            }
1301                        }
1302                    }
1303                }]
1304            }
1305        });
1306
1307        let _ = rewrite_response("tools/list", &mut body, &config);
1308
1309        let frames = as_strs(&body["result"]["tools"][0]["_meta"]["ui"]["csp"]["frameDomains"]);
1310        assert!(
1311            frames.is_empty(),
1312            "frame_domains should be empty, got {frames:?}"
1313        );
1314    }
1315
1316    // ── End-to-end scenario ────────────────────────────────────────────────
1317
1318    #[test]
1319    fn rewrite_response__end_to_end_mcp_schema() {
1320        // Scenario exercising the full pipeline:
1321        // - A realistic multi-tool tools/list response from an upstream MCP
1322        //   server that declares widgets with mixed metadata shapes.
1323        // - A mcpr.toml with declared global CSP across all three directives
1324        //   and a widget-scoped override for the payment widget.
1325        //
1326        // After rewriting, every tool's meta should carry both CSP shapes
1327        // populated from the same merge, with upstream self-references
1328        // dropped, declared config applied, and the payment widget's Stripe
1329        // override only appearing on the payment tool.
1330        let mut config = rewrite_config();
1331        config.csp.connect_domains = DirectivePolicy {
1332            domains: vec!["https://api.myshop.com".into()],
1333            mode: Mode::Extend,
1334        };
1335        config.csp.resource_domains = DirectivePolicy {
1336            domains: vec!["https://cdn.myshop.com".into()],
1337            mode: Mode::Extend,
1338        };
1339        config.csp.widgets.push(WidgetScoped {
1340            match_pattern: "ui://widget/payment*".into(),
1341            connect_domains: vec!["https://api.stripe.com".into()],
1342            connect_domains_mode: Mode::Extend,
1343            resource_domains: vec!["https://js.stripe.com".into()],
1344            resource_domains_mode: Mode::Extend,
1345            ..Default::default()
1346        });
1347
1348        let mut body = json!({
1349            "jsonrpc": "2.0",
1350            "id": 42,
1351            "result": {
1352                "tools": [
1353                    {
1354                        "name": "search_products",
1355                        "description": "Search the product catalog",
1356                        "inputSchema": { "type": "object" },
1357                        "_meta": {
1358                            "openai/widgetDomain": "old.shop.com",
1359                            "openai/outputTemplate": "ui://widget/search",
1360                            "openai/widgetCSP": {
1361                                "connect_domains": ["http://localhost:9000"],
1362                                "resource_domains": ["http://localhost:4444"]
1363                            }
1364                        }
1365                    },
1366                    {
1367                        "name": "take_payment",
1368                        "description": "Charge a card",
1369                        "inputSchema": { "type": "object" },
1370                        "_meta": {
1371                            "ui": {
1372                                "resourceUri": "ui://widget/payment-form",
1373                                "csp": {
1374                                    "connectDomains": ["https://api.myshop.com"]
1375                                }
1376                            }
1377                        }
1378                    },
1379                    {
1380                        "name": "get_order_status",
1381                        "description": "Look up an order",
1382                        "inputSchema": { "type": "object" }
1383                    }
1384                ]
1385            }
1386        });
1387
1388        let _ = rewrite_response("tools/list", &mut body, &config);
1389
1390        let tools = body["result"]["tools"].as_array().unwrap();
1391
1392        // ── Tool 0: search — upstream declared only OpenAI shape ──────────
1393        let search_meta = &tools[0]["_meta"];
1394        assert_eq!(
1395            search_meta["openai/widgetDomain"].as_str().unwrap(),
1396            "abc.tunnel.example.com"
1397        );
1398        let search_oa_connect = as_strs(&search_meta["openai/widgetCSP"]["connect_domains"]);
1399        let search_spec_connect = as_strs(&search_meta["ui"]["csp"]["connectDomains"]);
1400        assert_eq!(search_oa_connect, search_spec_connect);
1401        // Proxy first, then declared global, upstream localhost dropped.
1402        assert_eq!(
1403            search_oa_connect,
1404            vec!["https://abc.tunnel.example.com", "https://api.myshop.com"]
1405        );
1406        // The payment widget override must NOT apply to the search tool.
1407        assert!(!search_oa_connect.contains(&"https://api.stripe.com"));
1408        // Frame directive defaults strict AND the proxy URL is not prepended
1409        // — so with no declared frame domains the array is fully empty.
1410        let search_oa_frame = as_strs(&search_meta["openai/widgetCSP"]["frame_domains"]);
1411        assert!(search_oa_frame.is_empty());
1412
1413        // ── Tool 1: payment — upstream declared only spec shape ──────────
1414        let payment_meta = &tools[1]["_meta"];
1415        let payment_oa_connect = as_strs(&payment_meta["openai/widgetCSP"]["connect_domains"]);
1416        let payment_spec_connect = as_strs(&payment_meta["ui"]["csp"]["connectDomains"]);
1417        assert_eq!(payment_oa_connect, payment_spec_connect);
1418        // Proxy + global + widget override (Stripe) all present, in that order.
1419        assert_eq!(
1420            payment_oa_connect,
1421            vec![
1422                "https://abc.tunnel.example.com",
1423                "https://api.myshop.com",
1424                "https://api.stripe.com",
1425            ]
1426        );
1427        let payment_oa_resource = as_strs(&payment_meta["openai/widgetCSP"]["resource_domains"]);
1428        assert_eq!(
1429            payment_oa_resource,
1430            vec![
1431                "https://abc.tunnel.example.com",
1432                "https://cdn.myshop.com",
1433                "https://js.stripe.com",
1434            ]
1435        );
1436
1437        // ── Tool 2: plain tool, no widget metadata ────────────────────────
1438        // Non-widget metas must not gain synthesized CSP fields.
1439        let plain = &tools[2];
1440        assert!(plain.get("_meta").is_none());
1441    }
1442
1443    // ── Real MCP wire shape: `_meta` key, not `meta` ──────────────────────
1444
1445    #[test]
1446    fn rewrite_response__tools_call_underscore_meta_is_rewritten() {
1447        // Regression: real MCP servers emit `_meta` (with underscore) per spec,
1448        // not `meta`. Earlier dispatch arms mistakenly read `meta`, silently
1449        // skipping every rewrite in production.
1450        let mut config = rewrite_config();
1451        config.csp.connect_domains = DirectivePolicy {
1452            domains: vec!["https://assets.usestudykit.com".into()],
1453            mode: Mode::Replace,
1454        };
1455        config.csp.resource_domains = DirectivePolicy {
1456            domains: vec!["https://assets.usestudykit.com".into()],
1457            mode: Mode::Replace,
1458        };
1459
1460        let mut body = json!({
1461            "result": {
1462                "_meta": {
1463                    "openai/outputTemplate": "ui://widget/vocab_review.html",
1464                    "openai/widgetDomain": "assets.usestudykit.com/src",
1465                    "openai/widgetCSP": {
1466                        "connect_domains": [
1467                            "http://localhost:9002",
1468                            "https://api.dictionaryapi.dev"
1469                        ],
1470                        "resource_domains": [
1471                            "http://localhost:9002",
1472                            "https://api.dictionaryapi.dev"
1473                        ]
1474                    },
1475                    "ui": {
1476                        "csp": {
1477                            "connectDomains": ["https://api.dictionaryapi.dev"],
1478                            "resourceDomains": ["https://api.dictionaryapi.dev"]
1479                        },
1480                        "resourceUri": "ui://widget/vocab_review.html"
1481                    }
1482                },
1483                "content": [{"type": "text", "text": "payload"}],
1484                "structuredContent": {"data": {"items": []}}
1485            }
1486        });
1487
1488        let _ = rewrite_response("tools/call", &mut body, &config);
1489
1490        let meta = &body["result"]["_meta"];
1491        assert_eq!(
1492            meta["openai/widgetDomain"].as_str().unwrap(),
1493            "abc.tunnel.example.com"
1494        );
1495        let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1496        let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1497        assert_eq!(oa_connect, spec_connect);
1498        assert_eq!(
1499            oa_connect,
1500            vec![
1501                "https://abc.tunnel.example.com",
1502                "https://assets.usestudykit.com"
1503            ]
1504        );
1505        let oa_resource = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
1506        assert_eq!(
1507            oa_resource,
1508            vec![
1509                "https://abc.tunnel.example.com",
1510                "https://assets.usestudykit.com"
1511            ]
1512        );
1513        assert_eq!(
1514            body["result"]["content"][0]["text"].as_str().unwrap(),
1515            "payload"
1516        );
1517    }
1518
1519    #[test]
1520    fn rewrite_response__resources_read_underscore_meta_is_rewritten() {
1521        let config = rewrite_config();
1522        let mut body = json!({
1523            "result": {
1524                "contents": [{
1525                    "uri": "ui://widget/question",
1526                    "mimeType": "text/html",
1527                    "text": "<html/>",
1528                    "_meta": {
1529                        "openai/widgetDomain": "old.domain.com"
1530                    }
1531                }]
1532            }
1533        });
1534
1535        let _ = rewrite_response("resources/read", &mut body, &config);
1536
1537        assert_eq!(
1538            body["result"]["contents"][0]["_meta"]["openai/widgetDomain"]
1539                .as_str()
1540                .unwrap(),
1541            "abc.tunnel.example.com"
1542        );
1543    }
1544
1545    #[test]
1546    fn rewrite_response__legacy_meta_key_is_ignored() {
1547        // Defensive: if an upstream sends the wrong key (`meta` without
1548        // underscore), we must not rewrite it — MCP spec uses `_meta` only.
1549        let config = rewrite_config();
1550        let mut body = json!({
1551            "result": {
1552                "_meta": {"openai/widgetDomain": "real.domain.com"},
1553                "meta":  {"openai/widgetDomain": "should-stay.com"}
1554            }
1555        });
1556
1557        let _ = rewrite_response("tools/call", &mut body, &config);
1558
1559        assert_eq!(
1560            body["result"]["_meta"]["openai/widgetDomain"]
1561                .as_str()
1562                .unwrap(),
1563            "abc.tunnel.example.com"
1564        );
1565        assert_eq!(
1566            body["result"]["meta"]["openai/widgetDomain"]
1567                .as_str()
1568                .unwrap(),
1569            "should-stay.com"
1570        );
1571    }
1572
1573    // ── _meta synthesis when upstream under-declares ──────────────────────
1574
1575    #[test]
1576    fn rewrite_response__resources_list_synthesizes_meta_when_upstream_omits() {
1577        // Widget resources with no `_meta` at all must still receive the
1578        // declared CSP — under-declaring servers are common in practice.
1579        let mut config = rewrite_config();
1580        config.csp.connect_domains = DirectivePolicy {
1581            domains: vec!["https://api.declared.com".into()],
1582            mode: Mode::Replace,
1583        };
1584        config.csp.resource_domains = DirectivePolicy {
1585            domains: vec!["https://cdn.declared.com".into()],
1586            mode: Mode::Extend,
1587        };
1588
1589        let mut body = json!({
1590            "result": {
1591                "resources": [{
1592                    "uri": "ui://widget/search",
1593                    "name": "Search Widget"
1594                }]
1595            }
1596        });
1597
1598        let mutated = rewrite_response("resources/list", &mut body, &config);
1599        assert!(mutated);
1600
1601        let meta = &body["result"]["resources"][0]["_meta"];
1602        let oa_connect = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1603        let spec_connect = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1604        assert_eq!(oa_connect, spec_connect);
1605        assert_eq!(
1606            oa_connect,
1607            vec!["https://abc.tunnel.example.com", "https://api.declared.com"]
1608        );
1609        let oa_resource = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
1610        assert!(oa_resource.contains(&"https://abc.tunnel.example.com"));
1611        assert!(oa_resource.contains(&"https://cdn.declared.com"));
1612    }
1613
1614    #[test]
1615    fn rewrite_response__resources_read_synthesizes_meta_when_upstream_omits() {
1616        let mut config = rewrite_config();
1617        config.csp.connect_domains = DirectivePolicy {
1618            domains: vec!["https://api.declared.com".into()],
1619            mode: Mode::Replace,
1620        };
1621
1622        let mut body = json!({
1623            "result": {
1624                "contents": [{
1625                    "uri": "ui://widget/question",
1626                    "mimeType": "text/html",
1627                    "text": "<html><body>Hello</body></html>"
1628                }]
1629            }
1630        });
1631
1632        let mutated = rewrite_response("resources/read", &mut body, &config);
1633        assert!(mutated);
1634
1635        assert_eq!(
1636            body["result"]["contents"][0]["text"].as_str().unwrap(),
1637            "<html><body>Hello</body></html>"
1638        );
1639        let meta = &body["result"]["contents"][0]["_meta"];
1640        let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1641        let spec = as_strs(&meta["ui"]["csp"]["connectDomains"]);
1642        assert_eq!(oa, spec);
1643        assert_eq!(
1644            oa,
1645            vec!["https://abc.tunnel.example.com", "https://api.declared.com"]
1646        );
1647    }
1648
1649    #[test]
1650    fn rewrite_response__resources_list_injects_into_empty_meta() {
1651        let mut config = rewrite_config();
1652        config.csp.connect_domains = DirectivePolicy {
1653            domains: vec!["https://api.declared.com".into()],
1654            mode: Mode::Extend,
1655        };
1656
1657        let mut body = json!({
1658            "result": {
1659                "resources": [{
1660                    "uri": "ui://widget/search",
1661                    "_meta": {}
1662                }]
1663            }
1664        });
1665
1666        let _ = rewrite_response("resources/list", &mut body, &config);
1667
1668        let meta = &body["result"]["resources"][0]["_meta"];
1669        let oa = as_strs(&meta["openai/widgetCSP"]["connect_domains"]);
1670        assert!(oa.contains(&"https://api.declared.com"));
1671        assert!(oa.contains(&"https://abc.tunnel.example.com"));
1672    }
1673
1674    #[test]
1675    fn rewrite_response__resources_templates_list_synthesizes_meta() {
1676        let mut config = rewrite_config();
1677        config.csp.resource_domains = DirectivePolicy {
1678            domains: vec!["https://cdn.declared.com".into()],
1679            mode: Mode::Extend,
1680        };
1681
1682        let mut body = json!({
1683            "result": {
1684                "resourceTemplates": [{
1685                    "uriTemplate": "ui://widget/{name}.html",
1686                    "name": "Widget Template"
1687                }]
1688            }
1689        });
1690
1691        let _ = rewrite_response("resources/templates/list", &mut body, &config);
1692
1693        let meta = &body["result"]["resourceTemplates"][0]["_meta"];
1694        let oa = as_strs(&meta["openai/widgetCSP"]["resource_domains"]);
1695        assert!(oa.contains(&"https://cdn.declared.com"));
1696        assert!(oa.contains(&"https://abc.tunnel.example.com"));
1697    }
1698
1699    #[test]
1700    fn rewrite_response__tools_call_no_meta_is_not_synthesized() {
1701        // Spec-aligned asymmetry: tools/call without widget indicators must
1702        // NOT grow synthesized CSP fields — CSP belongs on widget resources.
1703        let mut config = rewrite_config();
1704        config.csp.connect_domains = DirectivePolicy {
1705            domains: vec!["https://api.declared.com".into()],
1706            mode: Mode::Replace,
1707        };
1708
1709        let mut body = json!({
1710            "result": {
1711                "content": [{"type": "text", "text": "London 14C"}],
1712                "structuredContent": {"city": "London", "temp": 14}
1713            }
1714        });
1715
1716        let _ = rewrite_response("tools/call", &mut body, &config);
1717
1718        assert!(body["result"].get("_meta").is_none());
1719        assert_eq!(
1720            body["result"]["content"][0]["text"].as_str().unwrap(),
1721            "London 14C"
1722        );
1723    }
1724
1725    #[test]
1726    fn rewrite_response__resources_list_skips_when_no_uri_and_no_meta() {
1727        let config = rewrite_config();
1728        let mut body = json!({
1729            "result": {
1730                "resources": [{
1731                    "name": "malformed"
1732                }]
1733            }
1734        });
1735
1736        let _ = rewrite_response("resources/list", &mut body, &config);
1737
1738        assert!(body["result"]["resources"][0].get("_meta").is_none());
1739    }
1740
1741    // ── local-only mode: no public origin, don't pollute widget CSP ────────
1742
1743    fn local_only_config() -> RewriteConfig {
1744        // Mirrors what main.rs builds when tunnel is off and
1745        // `csp.domain` is unset: proxy_url stays as the local
1746        // bind address for internal wiring, proxy_domain is empty to flag
1747        // "no public origin".
1748        RewriteConfig {
1749            proxy_url: "http://localhost:9002".into(),
1750            proxy_domain: String::new(),
1751            mcp_upstream: "http://localhost:9000".into(),
1752            csp: CspConfig::default(),
1753        }
1754    }
1755
1756    #[test]
1757    fn rewrite_response__local_only_leaves_widget_domain_untouched() {
1758        let config = local_only_config();
1759        let mut body = json!({
1760            "result": {
1761                "contents": [{
1762                    "uri": "ui://widget/card",
1763                    "_meta": {
1764                        "openai/widgetDomain": "dev.example.com"
1765                    }
1766                }]
1767            }
1768        });
1769
1770        let _ = rewrite_response("resources/read", &mut body, &config);
1771
1772        assert_eq!(
1773            body["result"]["contents"][0]["_meta"]["openai/widgetDomain"]
1774                .as_str()
1775                .unwrap(),
1776            "dev.example.com",
1777        );
1778    }
1779
1780    #[test]
1781    fn rewrite_response__local_only_leaves_ui_domain_untouched() {
1782        let config = local_only_config();
1783        let mut body = json!({
1784            "result": {
1785                "contents": [{
1786                    "uri": "ui://widget/card",
1787                    "_meta": {
1788                        "ui": { "domain": "dev.example.com" }
1789                    }
1790                }]
1791            }
1792        });
1793
1794        let _ = rewrite_response("resources/read", &mut body, &config);
1795
1796        assert_eq!(
1797            body["result"]["contents"][0]["_meta"]["ui"]["domain"]
1798                .as_str()
1799                .unwrap(),
1800            "dev.example.com",
1801        );
1802        assert!(
1803            body["result"]["contents"][0]["_meta"]
1804                .get("openai/widgetDomain")
1805                .is_none(),
1806            "must not synthesize the openai shape in local-only mode"
1807        );
1808    }
1809
1810    #[test]
1811    fn rewrite_response__local_only_skips_csp_injection() {
1812        let config = local_only_config();
1813        let mut body = json!({
1814            "result": {
1815                "contents": [{
1816                    "uri": "ui://widget/card",
1817                    "_meta": {
1818                        "openai/widgetCSP": {
1819                            "connect_domains": ["https://api.example.com"],
1820                            "resource_domains": ["https://cdn.example.com"],
1821                            "frame_domains": []
1822                        }
1823                    }
1824                }]
1825            }
1826        });
1827
1828        let _ = rewrite_response("resources/read", &mut body, &config);
1829
1830        let csp = &body["result"]["contents"][0]["_meta"]["openai/widgetCSP"];
1831        assert_eq!(
1832            as_strs(&csp["connect_domains"]),
1833            vec!["https://api.example.com"],
1834            "localhost proxy_url must not be injected",
1835        );
1836        assert_eq!(
1837            as_strs(&csp["resource_domains"]),
1838            vec!["https://cdn.example.com"],
1839        );
1840    }
1841
1842    #[test]
1843    fn rewrite_response__public_domain_is_injected() {
1844        // Sanity: with a public proxy origin, injection still happens.
1845        let config = rewrite_config();
1846        let mut body = json!({
1847            "result": {
1848                "contents": [{
1849                    "uri": "ui://widget/card",
1850                    "_meta": {
1851                        "openai/widgetCSP": {
1852                            "connect_domains": ["https://api.example.com"],
1853                            "resource_domains": [],
1854                            "frame_domains": []
1855                        }
1856                    }
1857                }]
1858            }
1859        });
1860
1861        let _ = rewrite_response("resources/read", &mut body, &config);
1862
1863        let connect =
1864            as_strs(&body["result"]["contents"][0]["_meta"]["openai/widgetCSP"]["connect_domains"]);
1865        assert!(connect.contains(&"https://abc.tunnel.example.com"));
1866    }
1867}