use camel_api::CamelError;
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use serde::Deserialize;
use std::collections::BTreeMap;
use crate::route_ast::{RouteDslRoute, RouteDslSecurityPolicy};
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct RouteDslMcp {
pub server: RouteDslMcpServer,
#[serde(default)]
pub tools: Vec<RouteDslMcpTool>,
#[serde(default)]
pub resources: Vec<RouteDslMcpResource>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct RouteDslMcpServer {
pub name: String,
pub bind: String,
#[serde(default)]
pub tls: Option<serde_json::Value>,
#[serde(default)]
pub security_policy: Option<RouteDslSecurityPolicy>,
#[serde(default = "default_mcp_cap")]
pub max_tools: usize,
#[serde(default = "default_mcp_cap")]
pub max_resources: usize,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct RouteDslMcpTool {
pub name: String,
pub input_schema: serde_json::Value,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema, ts_rs::TS))]
#[derive(Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct RouteDslMcpResource {
pub name: String,
pub uri: String,
}
fn default_mcp_cap() -> usize {
128
}
fn validate_mcp_name(kind: &str, name: &str) -> Result<(), CamelError> {
let valid = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
if valid {
Ok(())
} else {
Err(CamelError::RouteError(format!(
"mcp {kind} name '{name}' is invalid: names must match [A-Za-z0-9._-]+ — \
a '?' truncates the lowered URI and can shadow the schema param; a '/' \
breaks the <server>/<kind>/<name> segment shape"
)))
}
}
pub fn lower_all_mcp_to_routes(blocks: &[RouteDslMcp]) -> Result<Vec<RouteDslRoute>, CamelError> {
let mut routes = Vec::new();
for block in blocks {
validate_mcp_name("server", &block.server.name)?;
let security_policy = block.server.security_policy.clone();
for tool in &block.tools {
validate_mcp_name("tool", &tool.name)?;
let schema =
utf8_percent_encode(&tool.input_schema.to_string(), NON_ALPHANUMERIC).to_string();
let from = format!(
"mcp:{}/tool/{}?schema={schema}",
block.server.name, tool.name
);
routes.push(consumer_route(
&format!("mcp-{}-tool-{}", block.server.name, tool.name),
from,
security_policy.clone(),
));
}
for resource in &block.resources {
validate_mcp_name("resource", &resource.name)?;
let uri = utf8_percent_encode(&resource.uri, NON_ALPHANUMERIC).to_string();
let from = format!(
"mcp:{}/resource/{}?uri={uri}",
block.server.name, resource.name
);
routes.push(consumer_route(
&format!("mcp-{}-resource-{}", block.server.name, resource.name),
from,
security_policy.clone(),
));
}
}
Ok(routes)
}
pub fn expand_mcp_into(
routes: &mut Vec<RouteDslRoute>,
blocks: &[RouteDslMcp],
) -> Result<(), CamelError> {
if blocks.is_empty() {
return Ok(());
}
let lowered = lower_all_mcp_to_routes(blocks)?;
routes.extend(lowered);
Ok(())
}
fn consumer_route(
id: &str,
from: String,
security_policy: Option<RouteDslSecurityPolicy>,
) -> RouteDslRoute {
RouteDslRoute {
id: id.to_string(),
from,
parameters: BTreeMap::new(),
steps: Vec::new(),
auto_startup: true,
startup_order: 0,
sequential: false,
concurrent: None,
error_handler: None,
circuit_breaker: None,
security_policy,
on_complete: None,
on_failure: None,
}
}
#[cfg(test)]
mod tests {
use noyalib::compat::serde_yaml as serde_yml;
use super::*;
use crate::route_ast::{RouteDslRoute, RouteDslRoutes};
#[test]
fn parse_mcp_block_from_yaml() {
let yaml = r#"
mcp:
- server:
name: crm
bind: 127.0.0.1:9100
security_policy:
roles: [admin]
tools:
- name: lookup
input_schema:
type: object
properties:
id:
type: string
required: [id]
resources:
- name: customers
uri: crm://customers
"#;
let parsed: RouteDslRoutes = serde_yml::from_str(yaml).unwrap();
assert_eq!(parsed.mcp.len(), 1);
let mcp = &parsed.mcp[0];
assert_eq!(mcp.server.name, "crm");
assert_eq!(mcp.server.bind, "127.0.0.1:9100");
assert_eq!(
mcp.server
.security_policy
.as_ref()
.and_then(|sp| sp.roles.clone()),
Some(vec!["admin".to_string()])
);
assert!(mcp.server.tls.is_none());
assert_eq!(mcp.tools.len(), 1);
let tool = &mcp.tools[0];
assert_eq!(tool.name, "lookup");
assert_eq!(
tool.input_schema,
serde_json::json!({
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"]
})
);
assert_eq!(mcp.resources.len(), 1);
let resource = &mcp.resources[0];
assert_eq!(resource.name, "customers");
assert_eq!(resource.uri, "crm://customers");
}
#[test]
fn unknown_server_key_rejected() {
let yaml = r#"
mcp:
- server:
name: crm
bind: 127.0.0.1:9100
session: true
"#;
let result = serde_yml::from_str::<RouteDslRoutes>(yaml);
assert!(result.is_err(), "session key must be rejected");
}
#[test]
fn defaults_caps_128() {
let yaml = r#"
mcp:
- server:
name: crm
bind: 127.0.0.1:9100
"#;
let parsed: RouteDslRoutes = serde_yml::from_str(yaml).unwrap();
let mcp = &parsed.mcp[0];
assert_eq!(mcp.server.max_tools, 128);
assert_eq!(mcp.server.max_resources, 128);
assert!(mcp.tools.is_empty());
assert!(mcp.resources.is_empty());
}
#[test]
fn initialize_at_block_level_rejected() {
let yaml = r#"
mcp:
- server:
name: crm
bind: 127.0.0.1:9100
initialize: true
"#;
let result = serde_yml::from_str::<RouteDslRoutes>(yaml);
assert!(
result.is_err(),
"initialize key at block level must be rejected"
);
}
#[test]
fn unknown_tool_key_rejected() {
let yaml = r#"
mcp:
- server:
name: crm
bind: 127.0.0.1:9100
tools:
- name: lookup
input_schema:
type: object
session: true
"#;
let result = serde_yml::from_str::<RouteDslRoutes>(yaml);
assert!(
result.is_err(),
"unknown key inside a tool must be rejected"
);
}
fn make_block() -> RouteDslMcp {
RouteDslMcp {
server: RouteDslMcpServer {
name: "crm".to_string(),
bind: "127.0.0.1:9100".to_string(),
tls: None,
security_policy: None,
max_tools: 128,
max_resources: 128,
},
tools: vec![],
resources: vec![],
}
}
fn make_existing_route() -> RouteDslRoute {
RouteDslRoute {
id: "existing".to_string(),
from: "direct:existing".to_string(),
parameters: BTreeMap::new(),
steps: vec![],
auto_startup: true,
startup_order: 0,
sequential: false,
concurrent: None,
error_handler: None,
circuit_breaker: None,
security_policy: None,
on_complete: None,
on_failure: None,
}
}
#[test]
fn dsl_block_lowers_to_consumer_routes() {
let schema = serde_json::json!({
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"]
});
let mut block = make_block();
block.tools = vec![RouteDslMcpTool {
name: "lookup".to_string(),
input_schema: schema.clone(),
}];
let routes = lower_all_mcp_to_routes(&[block]).unwrap();
let tool_route = routes
.iter()
.find(|r| r.from.starts_with("mcp:crm/tool/lookup?schema="))
.expect("tool consumer route must be present");
let encoded = tool_route
.from
.strip_prefix("mcp:crm/tool/lookup?schema=")
.unwrap();
let decoded = percent_encoding::percent_decode_str(encoded)
.decode_utf8()
.unwrap()
.to_string();
assert_eq!(decoded, schema.to_string());
}
#[test]
fn resource_lowers_with_uri() {
let mut block = make_block();
block.resources = vec![RouteDslMcpResource {
name: "customers".to_string(),
uri: "crm://customers".to_string(),
}];
let routes = lower_all_mcp_to_routes(&[block]).unwrap();
let resource_route = routes
.iter()
.find(|r| r.from.starts_with("mcp:crm/resource/customers?uri="))
.expect("resource consumer route must be present");
let encoded = resource_route
.from
.strip_prefix("mcp:crm/resource/customers?uri=")
.unwrap();
assert!(
encoded.contains('%'),
"uri must be percent-encoded, got: {encoded}"
);
let decoded = percent_encoding::percent_decode_str(encoded)
.decode_utf8()
.unwrap()
.to_string();
assert_eq!(decoded, "crm://customers");
}
#[test]
fn name_with_invalid_charset_rejected() {
let mut block = make_block();
block.tools = vec![RouteDslMcpTool {
name: "bad?name".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}];
let err = lower_all_mcp_to_routes(&[block])
.err()
.expect("tool name with '?' must be rejected");
let msg = err.to_string();
assert!(
msg.contains("bad?name"),
"error must name the offending key, got: {msg}"
);
}
#[test]
fn schema_not_in_headers_or_body() {
let schema = serde_json::json!({
"type": "object",
"properties": { "id": { "type": "string" } },
"required": ["id"]
});
let mut block = make_block();
block.tools = vec![RouteDslMcpTool {
name: "lookup".to_string(),
input_schema: schema.clone(),
}];
let routes = lower_all_mcp_to_routes(&[block]).unwrap();
let route = &routes[0];
let schema_str = schema.to_string();
assert!(
route
.steps
.iter()
.all(|s| !format!("{s:?}").contains(&schema_str)),
"schema value must not leak into any header or body step"
);
}
#[test]
fn expand_mcp_into_appends_to_routes() {
let mut routes = vec![make_existing_route()];
let mut block = make_block();
block.tools = vec![RouteDslMcpTool {
name: "lookup".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}];
block.resources = vec![RouteDslMcpResource {
name: "customers".to_string(),
uri: "crm://customers".to_string(),
}];
expand_mcp_into(&mut routes, &[block]).unwrap();
assert_eq!(routes.len(), 3);
assert_eq!(routes[0].id, "existing");
assert!(
routes
.iter()
.any(|r| r.from.starts_with("mcp:crm/tool/lookup?"))
);
assert!(
routes
.iter()
.any(|r| r.from.starts_with("mcp:crm/resource/customers?"))
);
}
#[test]
fn expand_mcp_into_is_noop_for_empty() {
let mut routes = vec![make_existing_route()];
let len = routes.len();
expand_mcp_into(&mut routes, &[]).unwrap();
assert_eq!(routes.len(), len, "existing routes must be untouched");
assert!(lower_all_mcp_to_routes(&[]).unwrap().is_empty());
}
#[test]
fn dsl_policy_propagates_to_lowered_routes() {
let policy = RouteDslSecurityPolicy {
roles: Some(vec!["mcp-client".to_string()]),
scopes: None,
all_required: None,
trust_upstream_principal: None,
r#ref: None,
wasm: None,
config: None,
permission: None,
credential_sources: None,
};
let mut block = make_block();
block.server.security_policy = Some(policy);
block.tools = vec![RouteDslMcpTool {
name: "lookup".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}];
block.resources = vec![RouteDslMcpResource {
name: "customers".to_string(),
uri: "crm://customers".to_string(),
}];
let routes = lower_all_mcp_to_routes(&[block]).unwrap();
assert_eq!(routes.len(), 2);
for route in &routes {
let sp = route
.security_policy
.as_ref()
.expect("every lowered route must carry the block policy");
assert_eq!(
sp.roles,
Some(vec!["mcp-client".to_string()]),
"lowered route '{}' must carry the roles policy",
route.id
);
}
assert!(
routes
.iter()
.any(|r| r.from.starts_with("mcp:crm/tool/lookup?")),
"the tool route must be present"
);
assert!(
routes
.iter()
.any(|r| r.from.starts_with("mcp:crm/resource/customers?")),
"the resource route must be present"
);
}
}