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