Skip to main content

mcp_proxy/
param_override.rs

1//! Parameter override middleware for tool customization.
2//!
3//! Modifies tool schemas and call arguments to hide parameters (injecting
4//! defaults) and rename parameters. This turns generic tools into
5//! domain-specific ones via config.
6//!
7//! # Configuration
8//!
9//! ```toml
10//! [[backends.param_overrides]]
11//! tool = "list_directory"
12//! hide = ["path"]
13//! defaults = { path = "/home/docs" }
14//! rename = { recursive = "deep_search" }
15//! ```
16//!
17//! On `ListTools`: hidden parameters are removed from the tool's `input_schema`,
18//! and renamed parameters have their schema keys swapped.
19//!
20//! On `CallTool`: hidden parameter defaults are injected, and renamed
21//! parameters are mapped back to their original names before forwarding.
22
23use std::collections::HashMap;
24use std::convert::Infallible;
25use std::future::Future;
26use std::pin::Pin;
27use std::sync::Arc;
28use std::task::{Context, Poll};
29
30use tower::{Layer, Service};
31use tower_mcp::router::{RouterRequest, RouterResponse};
32use tower_mcp_types::protocol::{McpRequest, McpResponse};
33
34/// Tower layer that produces a [`ParamOverrideService`].
35///
36/// # Example
37///
38/// ```rust
39/// use tower::ServiceBuilder;
40/// # let proxy = tower_mcp::McpRouter::new();
41/// use mcp_proxy::param_override::ParamOverrideLayer;
42/// let overrides = Vec::new(); // Supply ToolOverride rules for your tools.
43///
44/// let service = ServiceBuilder::new()
45///     .layer(ParamOverrideLayer::new(overrides))
46///     .service(proxy);
47/// ```
48#[derive(Clone)]
49pub struct ParamOverrideLayer {
50    overrides: Vec<ToolOverride>,
51}
52
53impl ParamOverrideLayer {
54    /// Create a new parameter override layer.
55    pub fn new(overrides: Vec<ToolOverride>) -> Self {
56        Self { overrides }
57    }
58}
59
60impl<S> Layer<S> for ParamOverrideLayer {
61    type Service = ParamOverrideService<S>;
62
63    fn layer(&self, inner: S) -> Self::Service {
64        ParamOverrideService::new(inner, self.overrides.clone())
65    }
66}
67
68/// Resolved parameter override rules for a single tool.
69#[derive(Debug, Clone)]
70pub struct ToolOverride {
71    /// Namespaced tool name (e.g. "fs/list_directory").
72    namespaced_tool: String,
73    /// Parameters to hide from the schema.
74    hide: Vec<String>,
75    /// Default values for hidden parameters.
76    defaults: serde_json::Map<String, serde_json::Value>,
77    /// Forward rename map: original_name -> new_name (for schema rewriting).
78    rename_forward: HashMap<String, String>,
79    /// Reverse rename map: new_name -> original_name (for call rewriting).
80    rename_reverse: HashMap<String, String>,
81}
82
83impl ToolOverride {
84    /// Create a new tool override from config.
85    pub fn new(namespace: &str, config: &crate::config::ParamOverrideConfig) -> Self {
86        let rename_forward: HashMap<String, String> = config.rename.clone();
87        let rename_reverse: HashMap<String, String> = config
88            .rename
89            .iter()
90            .map(|(orig, new)| (new.clone(), orig.clone()))
91            .collect();
92
93        Self {
94            namespaced_tool: format!("{namespace}{}", config.tool),
95            hide: config.hide.clone(),
96            defaults: config.defaults.clone(),
97            rename_forward,
98            rename_reverse,
99        }
100    }
101}
102
103/// Parameter override middleware.
104///
105/// Intercepts `ListTools` responses to modify tool schemas (hiding and
106/// renaming parameters) and `CallTool` requests to inject hidden defaults
107/// and reverse-map renamed parameters.
108#[derive(Clone)]
109pub struct ParamOverrideService<S> {
110    inner: S,
111    overrides: Arc<Vec<ToolOverride>>,
112}
113
114impl<S> ParamOverrideService<S> {
115    /// Create a new parameter override service.
116    pub fn new(inner: S, overrides: Vec<ToolOverride>) -> Self {
117        Self {
118            inner,
119            overrides: Arc::new(overrides),
120        }
121    }
122}
123
124/// Remove hidden properties from a JSON Schema object and apply renames.
125fn rewrite_schema(
126    schema: &mut serde_json::Value,
127    hide: &[String],
128    rename_forward: &HashMap<String, String>,
129) {
130    let Some(obj) = schema.as_object_mut() else {
131        return;
132    };
133
134    // Rewrite "properties" object: remove hidden, rename others
135    if let Some(props) = obj.get_mut("properties").and_then(|v| v.as_object_mut()) {
136        for param in hide {
137            props.remove(param);
138        }
139        for (original, renamed) in rename_forward {
140            if let Some(prop_schema) = props.remove(original) {
141                props.insert(renamed.clone(), prop_schema);
142            }
143        }
144    }
145
146    // Rewrite "required" array: remove hidden, rename others
147    if let Some(required) = obj.get_mut("required").and_then(|v| v.as_array_mut()) {
148        required.retain(|v| {
149            v.as_str()
150                .map(|s| !hide.contains(&s.to_string()))
151                .unwrap_or(true)
152        });
153        for entry in required.iter_mut() {
154            if let Some(s) = entry.as_str()
155                && let Some(new_name) = rename_forward.get(s)
156            {
157                *entry = serde_json::Value::String(new_name.clone());
158            }
159        }
160    }
161}
162
163impl<S> Service<RouterRequest> for ParamOverrideService<S>
164where
165    S: Service<RouterRequest, Response = RouterResponse, Error = Infallible>
166        + Clone
167        + Send
168        + 'static,
169    S::Future: Send,
170{
171    type Response = RouterResponse;
172    type Error = Infallible;
173    type Future = Pin<Box<dyn Future<Output = Result<RouterResponse, Infallible>> + Send>>;
174
175    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
176        self.inner.poll_ready(cx)
177    }
178
179    fn call(&mut self, mut req: RouterRequest) -> Self::Future {
180        let overrides = Arc::clone(&self.overrides);
181
182        // On CallTool: inject hidden defaults, reverse-map renamed params
183        if let McpRequest::CallTool(ref mut params) = req.inner {
184            for tool_override in overrides.iter() {
185                if params.name != tool_override.namespaced_tool {
186                    continue;
187                }
188
189                // Inject defaults for hidden parameters
190                if let serde_json::Value::Object(ref mut args) = params.arguments {
191                    for (key, value) in &tool_override.defaults {
192                        if !args.contains_key(key) {
193                            args.insert(key.clone(), value.clone());
194                        }
195                    }
196
197                    // Reverse-map renamed parameters back to originals
198                    let keys_to_rename: Vec<(String, String)> = args
199                        .keys()
200                        .filter_map(|k| {
201                            tool_override
202                                .rename_reverse
203                                .get(k)
204                                .map(|orig| (k.clone(), orig.clone()))
205                        })
206                        .collect();
207
208                    for (new_name, original_name) in keys_to_rename {
209                        if let Some(value) = args.remove(&new_name) {
210                            args.insert(original_name, value);
211                        }
212                    }
213                }
214
215                break;
216            }
217        }
218
219        let fut = self.inner.call(req);
220
221        Box::pin(async move {
222            let mut resp = fut.await?;
223
224            // On ListTools: rewrite schemas
225            if let Ok(McpResponse::ListTools(ref mut result)) = resp.inner {
226                for tool in &mut result.tools {
227                    for tool_override in overrides.iter() {
228                        if tool.name == tool_override.namespaced_tool {
229                            rewrite_schema(
230                                &mut tool.input_schema,
231                                &tool_override.hide,
232                                &tool_override.rename_forward,
233                            );
234                            break;
235                        }
236                    }
237                }
238            }
239
240            Ok(resp)
241        })
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::config::ParamOverrideConfig;
249    use crate::test_util::{MockService, call_service};
250    use tower_mcp_types::protocol::{CallToolParams, McpRequest, McpResponse};
251
252    /// Create a MockService with a tool that has a rich input_schema.
253    fn mock_with_schema(name: &str, schema: serde_json::Value) -> MockService {
254        use tower_mcp_types::protocol::ToolDefinition;
255        MockService {
256            tools: vec![ToolDefinition {
257                name: name.to_string(),
258                title: None,
259                description: Some(format!("{name} tool")),
260                input_schema: schema,
261                output_schema: None,
262                icons: None,
263                annotations: None,
264                execution: None,
265                meta: None,
266            }],
267        }
268    }
269
270    fn list_dir_schema() -> serde_json::Value {
271        serde_json::json!({
272            "type": "object",
273            "properties": {
274                "path": { "type": "string" },
275                "recursive": { "type": "boolean" },
276                "pattern": { "type": "string" }
277            },
278            "required": ["path"]
279        })
280    }
281
282    fn make_overrides(namespace: &str, configs: Vec<ParamOverrideConfig>) -> Vec<ToolOverride> {
283        configs
284            .iter()
285            .map(|c| ToolOverride::new(namespace, c))
286            .collect()
287    }
288
289    #[tokio::test]
290    async fn test_hide_removes_param_from_schema() {
291        let mock = mock_with_schema("fs/list_directory", list_dir_schema());
292        let overrides = make_overrides(
293            "fs/",
294            vec![ParamOverrideConfig {
295                tool: "list_directory".to_string(),
296                hide: vec!["path".to_string()],
297                defaults: {
298                    let mut m = serde_json::Map::new();
299                    m.insert("path".to_string(), serde_json::json!("/home/docs"));
300                    m
301                },
302                rename: HashMap::new(),
303            }],
304        );
305        let mut svc = ParamOverrideService::new(mock, overrides);
306
307        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
308        match resp.inner.unwrap() {
309            McpResponse::ListTools(result) => {
310                let tool = &result.tools[0];
311                let props = tool.input_schema["properties"].as_object().unwrap();
312                assert!(
313                    !props.contains_key("path"),
314                    "path should be hidden from schema"
315                );
316                assert!(props.contains_key("recursive"), "recursive should remain");
317                assert!(props.contains_key("pattern"), "pattern should remain");
318                // "path" should be removed from required
319                let required = tool.input_schema["required"].as_array().unwrap();
320                let req_strs: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
321                assert!(!req_strs.contains(&"path"), "path should not be required");
322            }
323            other => panic!("expected ListTools, got: {:?}", other),
324        }
325    }
326
327    #[tokio::test]
328    async fn test_hide_injects_defaults_on_call() {
329        let mock = mock_with_schema("fs/list_directory", list_dir_schema());
330        let overrides = make_overrides(
331            "fs/",
332            vec![ParamOverrideConfig {
333                tool: "list_directory".to_string(),
334                hide: vec!["path".to_string()],
335                defaults: {
336                    let mut m = serde_json::Map::new();
337                    m.insert("path".to_string(), serde_json::json!("/home/docs"));
338                    m
339                },
340                rename: HashMap::new(),
341            }],
342        );
343        let mut svc = ParamOverrideService::new(mock, overrides);
344
345        let resp = call_service(
346            &mut svc,
347            McpRequest::CallTool(CallToolParams {
348                name: "fs/list_directory".to_string(),
349                arguments: serde_json::json!({"recursive": true}),
350                input_responses: None,
351                request_state: None,
352                meta: None,
353                task: None,
354            }),
355        )
356        .await;
357
358        assert!(resp.inner.is_ok(), "call should succeed");
359    }
360
361    #[tokio::test]
362    async fn test_rename_rewrites_schema() {
363        let mock = mock_with_schema("fs/list_directory", list_dir_schema());
364        let overrides = make_overrides(
365            "fs/",
366            vec![ParamOverrideConfig {
367                tool: "list_directory".to_string(),
368                hide: vec![],
369                defaults: serde_json::Map::new(),
370                rename: {
371                    let mut m = HashMap::new();
372                    m.insert("recursive".to_string(), "deep_search".to_string());
373                    m
374                },
375            }],
376        );
377        let mut svc = ParamOverrideService::new(mock, overrides);
378
379        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
380        match resp.inner.unwrap() {
381            McpResponse::ListTools(result) => {
382                let tool = &result.tools[0];
383                let props = tool.input_schema["properties"].as_object().unwrap();
384                assert!(
385                    !props.contains_key("recursive"),
386                    "recursive should be renamed"
387                );
388                assert!(
389                    props.contains_key("deep_search"),
390                    "deep_search should appear"
391                );
392                assert!(props.contains_key("path"), "path should remain");
393            }
394            other => panic!("expected ListTools, got: {:?}", other),
395        }
396    }
397
398    #[tokio::test]
399    async fn test_rename_reverse_maps_on_call() {
400        let mock = mock_with_schema("fs/list_directory", list_dir_schema());
401        let overrides = make_overrides(
402            "fs/",
403            vec![ParamOverrideConfig {
404                tool: "list_directory".to_string(),
405                hide: vec![],
406                defaults: serde_json::Map::new(),
407                rename: {
408                    let mut m = HashMap::new();
409                    m.insert("recursive".to_string(), "deep_search".to_string());
410                    m
411                },
412            }],
413        );
414        let mut svc = ParamOverrideService::new(mock, overrides);
415
416        // Client sends "deep_search" (the renamed param)
417        let resp = call_service(
418            &mut svc,
419            McpRequest::CallTool(CallToolParams {
420                name: "fs/list_directory".to_string(),
421                arguments: serde_json::json!({"path": "/tmp", "deep_search": true}),
422                input_responses: None,
423                request_state: None,
424                meta: None,
425                task: None,
426            }),
427        )
428        .await;
429
430        assert!(resp.inner.is_ok(), "call should succeed");
431    }
432
433    #[tokio::test]
434    async fn test_hide_and_rename_combined() {
435        let mock = mock_with_schema("fs/list_directory", list_dir_schema());
436        let overrides = make_overrides(
437            "fs/",
438            vec![ParamOverrideConfig {
439                tool: "list_directory".to_string(),
440                hide: vec!["path".to_string()],
441                defaults: {
442                    let mut m = serde_json::Map::new();
443                    m.insert("path".to_string(), serde_json::json!("/home/docs"));
444                    m
445                },
446                rename: {
447                    let mut m = HashMap::new();
448                    m.insert("recursive".to_string(), "deep_search".to_string());
449                    m
450                },
451            }],
452        );
453        let mut svc = ParamOverrideService::new(mock, overrides);
454
455        // Verify schema: path hidden, recursive renamed
456        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
457        match resp.inner.unwrap() {
458            McpResponse::ListTools(result) => {
459                let props = result.tools[0].input_schema["properties"]
460                    .as_object()
461                    .unwrap();
462                assert!(!props.contains_key("path"));
463                assert!(!props.contains_key("recursive"));
464                assert!(props.contains_key("deep_search"));
465                assert!(props.contains_key("pattern"));
466            }
467            other => panic!("expected ListTools, got: {:?}", other),
468        }
469    }
470
471    #[tokio::test]
472    async fn test_non_matching_tool_passes_through() {
473        let mock = mock_with_schema("db/query", list_dir_schema());
474        let overrides = make_overrides(
475            "fs/",
476            vec![ParamOverrideConfig {
477                tool: "list_directory".to_string(),
478                hide: vec!["path".to_string()],
479                defaults: serde_json::Map::new(),
480                rename: HashMap::new(),
481            }],
482        );
483        let mut svc = ParamOverrideService::new(mock, overrides);
484
485        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
486        match resp.inner.unwrap() {
487            McpResponse::ListTools(result) => {
488                // db/query schema should be untouched
489                let props = result.tools[0].input_schema["properties"]
490                    .as_object()
491                    .unwrap();
492                assert!(props.contains_key("path"), "unmatched tool is untouched");
493            }
494            other => panic!("expected ListTools, got: {:?}", other),
495        }
496    }
497
498    #[tokio::test]
499    async fn test_non_call_tool_passes_through() {
500        let mock = MockService::with_tools(&["fs/list_directory"]);
501        let overrides = make_overrides(
502            "fs/",
503            vec![ParamOverrideConfig {
504                tool: "list_directory".to_string(),
505                hide: vec!["path".to_string()],
506                defaults: serde_json::Map::new(),
507                rename: HashMap::new(),
508            }],
509        );
510        let mut svc = ParamOverrideService::new(mock, overrides);
511
512        // ListTools should pass through without error
513        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
514        assert!(resp.inner.is_ok());
515    }
516
517    #[tokio::test]
518    async fn test_rename_updates_required_array() {
519        let schema = serde_json::json!({
520            "type": "object",
521            "properties": {
522                "path": { "type": "string" },
523                "recursive": { "type": "boolean" }
524            },
525            "required": ["path", "recursive"]
526        });
527        let mock = mock_with_schema("fs/list_directory", schema);
528        let overrides = make_overrides(
529            "fs/",
530            vec![ParamOverrideConfig {
531                tool: "list_directory".to_string(),
532                hide: vec![],
533                defaults: serde_json::Map::new(),
534                rename: {
535                    let mut m = HashMap::new();
536                    m.insert("recursive".to_string(), "deep_search".to_string());
537                    m
538                },
539            }],
540        );
541        let mut svc = ParamOverrideService::new(mock, overrides);
542
543        let resp = call_service(&mut svc, McpRequest::ListTools(Default::default())).await;
544        match resp.inner.unwrap() {
545            McpResponse::ListTools(result) => {
546                let required = result.tools[0].input_schema["required"].as_array().unwrap();
547                let req_strs: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
548                assert!(req_strs.contains(&"path"));
549                assert!(req_strs.contains(&"deep_search"));
550                assert!(!req_strs.contains(&"recursive"));
551            }
552            other => panic!("expected ListTools, got: {:?}", other),
553        }
554    }
555
556    #[test]
557    fn test_rewrite_schema_no_properties() {
558        // Schema without properties should be a no-op
559        let mut schema = serde_json::json!({"type": "object"});
560        rewrite_schema(&mut schema, &["path".to_string()], &HashMap::new());
561        assert_eq!(schema, serde_json::json!({"type": "object"}));
562    }
563
564    #[test]
565    fn test_rewrite_schema_non_object() {
566        // Non-object schema is a no-op
567        let mut schema = serde_json::json!("string");
568        rewrite_schema(&mut schema, &["path".to_string()], &HashMap::new());
569        assert_eq!(schema, serde_json::json!("string"));
570    }
571
572    #[test]
573    fn test_tool_override_construction() {
574        let config = ParamOverrideConfig {
575            tool: "list_directory".to_string(),
576            hide: vec!["path".to_string()],
577            defaults: {
578                let mut m = serde_json::Map::new();
579                m.insert("path".to_string(), serde_json::json!("/home"));
580                m
581            },
582            rename: {
583                let mut m = HashMap::new();
584                m.insert("recursive".to_string(), "deep_search".to_string());
585                m
586            },
587        };
588        let to = ToolOverride::new("fs/", &config);
589        assert_eq!(to.namespaced_tool, "fs/list_directory");
590        assert_eq!(to.hide, vec!["path"]);
591        assert_eq!(to.rename_forward.get("recursive").unwrap(), "deep_search");
592        assert_eq!(to.rename_reverse.get("deep_search").unwrap(), "recursive");
593    }
594
595    #[tokio::test]
596    async fn test_hidden_default_does_not_overwrite_explicit_arg() {
597        let _mock = mock_with_schema("fs/list_directory", list_dir_schema());
598        let overrides = make_overrides(
599            "fs/",
600            vec![ParamOverrideConfig {
601                tool: "list_directory".to_string(),
602                hide: vec!["path".to_string()],
603                defaults: {
604                    let mut m = serde_json::Map::new();
605                    m.insert("path".to_string(), serde_json::json!("/home/docs"));
606                    m
607                },
608                rename: HashMap::new(),
609            }],
610        );
611
612        // Even though "path" is hidden, if the client passes it, the default
613        // should not overwrite. This is consistent with inject behavior.
614        let mut req = RouterRequest {
615            id: tower_mcp::protocol::RequestId::Number(1),
616            inner: McpRequest::CallTool(CallToolParams {
617                name: "fs/list_directory".to_string(),
618                arguments: serde_json::json!({"path": "/custom"}),
619                input_responses: None,
620                request_state: None,
621                meta: None,
622                task: None,
623            }),
624            extensions: tower_mcp::router::Extensions::new(),
625        };
626
627        // Simulate the override logic manually
628        if let McpRequest::CallTool(ref mut params) = req.inner
629            && let serde_json::Value::Object(ref mut args) = params.arguments
630        {
631            let defaults = &overrides[0].defaults;
632            for (key, value) in defaults {
633                if !args.contains_key(key) {
634                    args.insert(key.clone(), value.clone());
635                }
636            }
637            // path should still be /custom
638            assert_eq!(args.get("path").unwrap(), "/custom");
639        }
640    }
641}