Skip to main content

mcpr_core/proxy/
rewrite.rs

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