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