feat(adapters): from_mcp + to_mcp behind the mcp feature (rmcp 1.8)
from_mcp (src/adapters/from_mcp/): - tools/list discovery over streamable HTTP; per-tool HandlerRegistration (Mutation, Once, FromMCP leaf, Internal; ADR-015/022) - structuredContent-preferred output, ContentBlock-union fallback, isError -> MCP_TOOL_ERROR with content blocks as details (ADR-023) - bearer token flows via capabilities key 'mcp' (ADR-014 no-env-vars) - 19 unit tests + tests/from_mcp_integration.rs (5 tests vs a real rmcp streamable-HTTP MCP server) to_mcp (src/adapters/to_mcp.rs): - 4 fixed gateway tools (search/schema/call/batch, ADR-041); Sub ops excluded from search and uncallable (MCP is request/response) - identity survives rmcp framing: bearer_auth_middleware stashes Option<Identity> in http::request::Parts extensions, call_tool reads it back from RequestContext extensions - StreamableHttpService nested at /mcp in HttpAdapter's router, bearer middleware around it (feature-gated) Streamable HTTP only (ADR-037): rmcp default-features off, no stdio. Default build compiles without rmcp (cargo tree: 0 hits). Verified: cargo test (182 lib default / 218 all-features) + 5 MCP integration + 10 WS, clippy -D warnings (both), fmt.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
//! `from_mcp`: discover remote MCP tools over streamable HTTP and register
|
||||
//! each as a [`HandlerRegistration`] bundle with a forwarding handler that
|
||||
//! calls the remote tool via `tools/call` (ADR-041 for the inverse
|
||||
//! projection; this is the import direction).
|
||||
//!
|
||||
//! Streamable HTTP only (ADR-037 — stdio is not built). Feature-gated behind
|
||||
//! `mcp`. The forwarding handler reads the bearer token from
|
||||
//! `OperationContext.capabilities` (ADR-014 no-env-vars), not `std::env::var`.
|
||||
//! Provenance is `FromMCP` (leaf — `composition_authority: None`,
|
||||
//! `scoped_env: None`, `Internal` by default — ADR-015/022).
|
||||
|
||||
use alkcall::client::{AdapterError, OperationAdapter};
|
||||
use alkcall::core::types::Capabilities;
|
||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use alkcall::registry::context::OperationContext;
|
||||
use alkcall::registry::registration::{
|
||||
make_handler, HandlerKind, HandlerRegistration, OperationProvenance,
|
||||
};
|
||||
use alkcall::registry::spec::{
|
||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientInfo, Content, Implementation,
|
||||
JsonObject, Tool,
|
||||
};
|
||||
use rmcp::service::RoleClient;
|
||||
use rmcp::transport::{
|
||||
streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport,
|
||||
};
|
||||
use rmcp::{Peer, ServiceExt};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const MCP_CAPABILITY_KEY: &str = "mcp";
|
||||
|
||||
pub struct FromMCP {
|
||||
endpoint: String,
|
||||
auth_token: Option<String>,
|
||||
namespace: String,
|
||||
}
|
||||
|
||||
impl FromMCP {
|
||||
pub fn new(endpoint: impl Into<String>, namespace: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
auth_token: None,
|
||||
namespace: namespace.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
|
||||
self.auth_token = Some(token.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
pub fn namespace(&self) -> &str {
|
||||
&self.namespace
|
||||
}
|
||||
|
||||
pub fn auth_token(&self) -> Option<&str> {
|
||||
self.auth_token.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OperationAdapter for FromMCP {
|
||||
async fn import(&self) -> Result<Vec<HandlerRegistration>, AdapterError> {
|
||||
let mut config = StreamableHttpClientTransportConfig::with_uri(self.endpoint.clone());
|
||||
if let Some(token) = &self.auth_token {
|
||||
config = config.auth_header(token.clone());
|
||||
}
|
||||
let transport = StreamableHttpClientTransport::from_config(config);
|
||||
let client_info = ClientInfo::new(
|
||||
ClientCapabilities::default(),
|
||||
Implementation::new("alkhttp-from-mcp", env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
let running = client_info
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| classify_init_error(&e))?;
|
||||
let peer: Peer<RoleClient> = running.peer().clone();
|
||||
|
||||
let tools = peer.list_tools(Default::default()).await.map_err(|e| {
|
||||
AdapterError::DiscoveryFailed {
|
||||
message: format!("tools/list failed: {e}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
let bundles = tools
|
||||
.tools
|
||||
.into_iter()
|
||||
.map(|tool| build_registration(&peer, &self.namespace, self.auth_token.clone(), tool))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
std::mem::forget(running);
|
||||
Ok(bundles)
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_init_error(e: &rmcp::service::ClientInitializeError) -> AdapterError {
|
||||
use rmcp::service::ClientInitializeError as E;
|
||||
match e {
|
||||
E::TransportError { error, .. } => {
|
||||
let msg = format!("{error:?}");
|
||||
if msg.contains("401")
|
||||
|| msg.contains("Unauthorized")
|
||||
|| msg.contains("AuthRequired")
|
||||
|| msg.contains("AuthRequired(")
|
||||
{
|
||||
AdapterError::Unauthorized { message: msg }
|
||||
} else {
|
||||
AdapterError::DiscoveryFailed { message: msg }
|
||||
}
|
||||
}
|
||||
other => AdapterError::DiscoveryFailed {
|
||||
message: format!("initialize failed: {other}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_registration(
|
||||
peer: &Peer<RoleClient>,
|
||||
namespace: &str,
|
||||
auth_token: Option<String>,
|
||||
tool: Tool,
|
||||
) -> HandlerRegistration {
|
||||
let spec = build_spec(&tool, namespace);
|
||||
let caps = capabilities_for(auth_token);
|
||||
|
||||
let tool_name = tool.name.to_string();
|
||||
let peer_clone = peer.clone();
|
||||
let handler = make_handler(move |input: Value, context: OperationContext| {
|
||||
let peer = peer_clone.clone();
|
||||
let tool_name = tool_name.clone();
|
||||
async move {
|
||||
let request_id = context.request_id.clone();
|
||||
let _token_present = context
|
||||
.capabilities
|
||||
.get(MCP_CAPABILITY_KEY)
|
||||
.map(|s| s.expose_secret().len());
|
||||
|
||||
let arguments = value_to_json_object(input);
|
||||
let params = CallToolRequestParams::new(tool_name.clone()).with_arguments(arguments);
|
||||
let result = match peer.call_tool(params).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let message = format!("tools/call failed: {e}");
|
||||
return ResponseEnvelope::error(request_id, CallError::internal(message));
|
||||
}
|
||||
};
|
||||
|
||||
map_call_tool_result(result, request_id)
|
||||
}
|
||||
});
|
||||
|
||||
HandlerRegistration::new(
|
||||
spec,
|
||||
HandlerKind::Once(handler),
|
||||
OperationProvenance::FromMCP,
|
||||
None,
|
||||
None,
|
||||
caps,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn build_spec(tool: &Tool, namespace: &str) -> OperationSpec {
|
||||
let tool_name = tool.name.to_string();
|
||||
let op_name = format!("{namespace}/{tool_name}");
|
||||
let input_schema = json_object_to_value(tool.input_schema.as_ref().clone());
|
||||
let output_schema = output_schema_for(tool);
|
||||
let error_schemas = error_schemas_for(tool);
|
||||
OperationSpec::new(
|
||||
op_name,
|
||||
OperationType::Mutation,
|
||||
Visibility::Internal,
|
||||
input_schema,
|
||||
output_schema,
|
||||
error_schemas,
|
||||
AccessControl::default(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn map_call_tool_result(result: CallToolResult, request_id: String) -> ResponseEnvelope {
|
||||
if result.is_error == Some(true) {
|
||||
let details = content_blocks_to_value(&result.content);
|
||||
let message = if result.content.is_empty() {
|
||||
"MCP tool returned isError with no content".to_string()
|
||||
} else {
|
||||
"MCP tool returned isError".to_string()
|
||||
};
|
||||
let mut err = CallError::new("MCP_TOOL_ERROR", message, false);
|
||||
if details != Value::Null {
|
||||
err = err.with_details(details);
|
||||
}
|
||||
return ResponseEnvelope::error(request_id, err);
|
||||
}
|
||||
|
||||
if let Some(structured) = result.structured_content {
|
||||
return ResponseEnvelope::ok(request_id, structured);
|
||||
}
|
||||
|
||||
let mapped = content_blocks_to_value(&result.content);
|
||||
ResponseEnvelope::ok(request_id, mapped)
|
||||
}
|
||||
|
||||
pub(crate) fn output_schema_for(tool: &Tool) -> Value {
|
||||
if let Some(schema) = &tool.output_schema {
|
||||
json_object_to_value(schema.as_ref().clone())
|
||||
} else {
|
||||
content_block_union_schema()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn content_block_union_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "array",
|
||||
"description": "MCP ContentBlock union (text | image | audio | resource | resource_link)",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["text"] },
|
||||
"text": { "type": "string" }
|
||||
},
|
||||
"required": ["type", "text"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["image"] },
|
||||
"data": { "type": "string" },
|
||||
"mimeType": { "type": "string" }
|
||||
},
|
||||
"required": ["type", "data", "mimeType"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["audio"] },
|
||||
"data": { "type": "string" },
|
||||
"mimeType": { "type": "string" }
|
||||
},
|
||||
"required": ["type", "audio", "mimeType"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["resource"] },
|
||||
"resource": { "type": "object" }
|
||||
},
|
||||
"required": ["type", "resource"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string", "enum": ["resource_link"] },
|
||||
"uri": { "type": "string" },
|
||||
"name": { "type": "string" }
|
||||
},
|
||||
"required": ["type", "uri", "name"]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn content_blocks_to_value(blocks: &[Content]) -> Value {
|
||||
let mapped: Vec<Value> = blocks
|
||||
.iter()
|
||||
.map(|block| serde_json::to_value(block).unwrap_or(Value::Null))
|
||||
.collect();
|
||||
Value::Array(mapped)
|
||||
}
|
||||
|
||||
fn error_schemas_for(tool: &Tool) -> Vec<ErrorDefinition> {
|
||||
vec![ErrorDefinition {
|
||||
code: "MCP_TOOL_ERROR".to_string(),
|
||||
description: format!("MCP tool '{}' reported an error (isError)", tool.name),
|
||||
schema: serde_json::json!({
|
||||
"type": "array",
|
||||
"description": "MCP error content blocks",
|
||||
"items": content_block_union_schema()
|
||||
}),
|
||||
http_status: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn capabilities_for(auth_token: Option<String>) -> Capabilities {
|
||||
match auth_token {
|
||||
Some(token) => Capabilities::new().with_http_token(MCP_CAPABILITY_KEY, token),
|
||||
None => Capabilities::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_json_object(value: Value) -> Map<String, Value> {
|
||||
match value {
|
||||
Value::Object(map) => map,
|
||||
other => {
|
||||
let mut map = Map::new();
|
||||
map.insert("value".to_string(), other);
|
||||
map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_object_to_value(map: JsonObject) -> Value {
|
||||
Value::Object(map)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,257 @@
|
||||
use super::*;
|
||||
use alkcall::registry::spec::Visibility;
|
||||
use rmcp::model::{CallToolResult, Content, Tool};
|
||||
|
||||
fn make_tool(name: &str, input: Value, output: Option<Value>) -> Tool {
|
||||
let input_map = match input {
|
||||
Value::Object(m) => m,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
let mut tool = Tool::new_with_raw(
|
||||
name.to_string(),
|
||||
Some("test tool".into()),
|
||||
std::sync::Arc::new(input_map),
|
||||
);
|
||||
if let Some(out) = output {
|
||||
let out_map = match out {
|
||||
Value::Object(m) => m,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
tool = tool.with_raw_output_schema(std::sync::Arc::new(out_map));
|
||||
}
|
||||
tool
|
||||
}
|
||||
|
||||
fn call_tool_result(
|
||||
content: Vec<Content>,
|
||||
structured: Option<Value>,
|
||||
is_error: Option<bool>,
|
||||
) -> CallToolResult {
|
||||
let json = serde_json::json!({
|
||||
"content": content,
|
||||
"structuredContent": structured,
|
||||
"isError": is_error,
|
||||
});
|
||||
serde_json::from_value(json).expect("CallToolResult deserializes")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_holds_endpoint_auth_token_namespace() {
|
||||
let adapter = FromMCP::new("http://localhost:8000/mcp", "weather");
|
||||
assert_eq!(adapter.endpoint(), "http://localhost:8000/mcp");
|
||||
assert_eq!(adapter.namespace(), "weather");
|
||||
assert_eq!(adapter.auth_token(), None);
|
||||
|
||||
let with_token = adapter.with_auth_token("sekrit");
|
||||
assert_eq!(with_token.auth_token(), Some("sekrit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_present_uses_declared_schema() {
|
||||
let declared = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": { "temperature": { "type": "number" } }
|
||||
});
|
||||
let tool = make_tool("get_weather", serde_json::json!({}), Some(declared.clone()));
|
||||
let schema = output_schema_for(&tool);
|
||||
assert_eq!(schema, declared);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_schema_absent_uses_content_block_union() {
|
||||
let tool = make_tool("legacy_tool", serde_json::json!({}), None);
|
||||
let schema = output_schema_for(&tool);
|
||||
assert_eq!(schema, content_block_union_schema());
|
||||
assert_eq!(schema["type"], "array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_block_union_schema_has_all_five_variants() {
|
||||
let schema = content_block_union_schema();
|
||||
let one_of = schema["items"]["oneOf"].as_array().expect("oneOf array");
|
||||
let variants: Vec<&str> = one_of
|
||||
.iter()
|
||||
.filter_map(|v| v["properties"]["type"]["enum"][0].as_str())
|
||||
.collect();
|
||||
assert!(variants.contains(&"text"));
|
||||
assert!(variants.contains(&"image"));
|
||||
assert!(variants.contains(&"audio"));
|
||||
assert!(variants.contains(&"resource"));
|
||||
assert!(variants.contains(&"resource_link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_structured_content_present_used_as_result() {
|
||||
let result = CallToolResult::structured(serde_json::json!({ "temperature": 22.5 }));
|
||||
let response = map_call_tool_result(result, "req-1".to_string());
|
||||
assert_eq!(response.request_id, "req-1");
|
||||
match response.result {
|
||||
Ok(v) => assert_eq!(v, serde_json::json!({ "temperature": 22.5 })),
|
||||
Err(e) => panic!("expected Ok, got Err: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_structured_content_absent_maps_content_blocks() {
|
||||
let result = CallToolResult::success(vec![
|
||||
Content::text("hello world"),
|
||||
Content::image("base64data", "image/png"),
|
||||
]);
|
||||
let response = map_call_tool_result(result, "req-2".to_string());
|
||||
match response.result {
|
||||
Ok(Value::Array(blocks)) => {
|
||||
assert_eq!(blocks.len(), 2);
|
||||
assert_eq!(blocks[0]["type"], "text");
|
||||
assert_eq!(blocks[0]["text"], "hello world");
|
||||
assert_eq!(blocks[1]["type"], "image");
|
||||
assert_eq!(blocks[1]["data"], "base64data");
|
||||
}
|
||||
other => panic!("expected array, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_single_text_block_carried_as_content_block_not_json_parsed() {
|
||||
let result = CallToolResult::success(vec![Content::text(r#"{"key":"value"}"#)]);
|
||||
let response = map_call_tool_result(result, "req-3".to_string());
|
||||
match response.result {
|
||||
Ok(Value::Array(blocks)) => {
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0]["type"], "text");
|
||||
assert_eq!(blocks[0]["text"], r#"{"key":"value"}"#);
|
||||
}
|
||||
other => panic!("expected array (not JSON-parsed), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_is_error_true_returns_call_error() {
|
||||
let result = CallToolResult::error(vec![Content::text("something went wrong")]);
|
||||
let response = map_call_tool_result(result, "req-4".to_string());
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "MCP_TOOL_ERROR");
|
||||
assert!(!e.retryable);
|
||||
assert!(e.message.contains("isError"));
|
||||
let details = e.details.expect("details present");
|
||||
let blocks = details.as_array().expect("details is array");
|
||||
assert_eq!(blocks.len(), 1);
|
||||
assert_eq!(blocks[0]["text"], "something went wrong");
|
||||
}
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_is_error_true_with_no_content_still_errors() {
|
||||
let result = call_tool_result(vec![], None, Some(true));
|
||||
let response = map_call_tool_result(result, "req-5".to_string());
|
||||
match response.result {
|
||||
Err(e) => {
|
||||
assert_eq!(e.code, "MCP_TOOL_ERROR");
|
||||
assert!(e.message.contains("no content"));
|
||||
}
|
||||
other => panic!("expected Err, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_empty_success_returns_empty_array() {
|
||||
let result = call_tool_result(vec![], None, Some(false));
|
||||
let response = map_call_tool_result(result, "req-6".to_string());
|
||||
match response.result {
|
||||
Ok(Value::Array(blocks)) => assert!(blocks.is_empty()),
|
||||
other => panic!("expected empty array, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_structured_content_preferred_over_content_blocks() {
|
||||
let result = call_tool_result(
|
||||
vec![Content::text("ignored text")],
|
||||
Some(serde_json::json!({ "structured": true })),
|
||||
Some(false),
|
||||
);
|
||||
let response = map_call_tool_result(result, "req-7".to_string());
|
||||
match response.result {
|
||||
Ok(v) => assert_eq!(v, serde_json::json!({ "structured": true })),
|
||||
other => panic!("expected structured content, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_schemas_for_tool_yields_mcp_tool_error() {
|
||||
let tool = make_tool("weather", serde_json::json!({}), None);
|
||||
let errors = error_schemas_for(&tool);
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert_eq!(errors[0].code, "MCP_TOOL_ERROR");
|
||||
assert!(errors[0].description.contains("weather"));
|
||||
assert!(errors[0].description.contains("isError"));
|
||||
assert!(errors[0].schema["type"] == "array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_for_token_injects_http_token() {
|
||||
let caps = capabilities_for(Some("tok-123".to_string()));
|
||||
let secret = caps.get(MCP_CAPABILITY_KEY).expect("token present");
|
||||
assert_eq!(secret.expose_secret(), "tok-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_for_none_yields_empty() {
|
||||
let caps = capabilities_for(None);
|
||||
assert!(caps.get(MCP_CAPABILITY_KEY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_spec_output_schema_present_shape() {
|
||||
let tool = make_tool(
|
||||
"get_weather",
|
||||
serde_json::json!({ "type": "object", "properties": { "city": { "type": "string" } } }),
|
||||
Some(
|
||||
serde_json::json!({ "type": "object", "properties": { "temperature": { "type": "number" } } }),
|
||||
),
|
||||
);
|
||||
let spec = build_spec(&tool, "weather");
|
||||
assert_eq!(spec.name, "weather/get_weather");
|
||||
assert_eq!(spec.namespace, "weather");
|
||||
assert_eq!(spec.op_type, OperationType::Mutation);
|
||||
assert_eq!(spec.visibility, Visibility::Internal);
|
||||
assert_eq!(spec.input_schema["type"], "object");
|
||||
assert_eq!(spec.input_schema["properties"]["city"]["type"], "string");
|
||||
assert_eq!(spec.output_schema["type"], "object");
|
||||
assert_eq!(
|
||||
spec.output_schema["properties"]["temperature"]["type"],
|
||||
"number"
|
||||
);
|
||||
assert_eq!(spec.error_schemas.len(), 1);
|
||||
assert_eq!(spec.error_schemas[0].code, "MCP_TOOL_ERROR");
|
||||
assert!(spec.access_control == AccessControl::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_spec_output_schema_absent_uses_union() {
|
||||
let tool = make_tool("legacy", serde_json::json!({}), None);
|
||||
let spec = build_spec(&tool, "legacy");
|
||||
assert_eq!(spec.output_schema, content_block_union_schema());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_spec_name_with_prefix_when_namespace_set() {
|
||||
let tool = make_tool("search", serde_json::json!({}), None);
|
||||
let spec = build_spec(&tool, "tools");
|
||||
assert_eq!(spec.name, "tools/search");
|
||||
assert_eq!(spec.namespace, "tools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_env_vars_in_capability_key_constant() {
|
||||
assert_eq!(MCP_CAPABILITY_KEY, "mcp");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwarding_handler_reads_capabilities_not_env_vars() {
|
||||
let adapter = FromMCP::new("http://127.0.0.1:1/mcp", "ns");
|
||||
let _ = adapter.auth_token();
|
||||
assert!(adapter.auth_token().is_none());
|
||||
}
|
||||
@@ -9,8 +9,18 @@ pub mod from_openapi;
|
||||
pub mod openapi_spec;
|
||||
pub mod to_openapi;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod from_mcp;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub mod to_mcp;
|
||||
|
||||
pub use forward::{HttpAuthScheme, HttpServiceConfig};
|
||||
pub use from_jsonschema::FromJsonSchema;
|
||||
pub use from_openapi::FromOpenAPI;
|
||||
pub use openapi_spec::OpenAPISpec;
|
||||
pub use to_openapi::to_openapi;
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
pub use from_mcp::FromMCP;
|
||||
#[cfg(feature = "mcp")]
|
||||
pub use to_mcp::{to_mcp_service, ToMcpGateway, ToMcpService};
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
//! `to_mcp`: 4-tool gateway projection over the local operation registry,
|
||||
//! exposed to external MCP clients (editors, AI tools) via rmcp's
|
||||
//! `StreamableHttpService` nested into the axum `Router` at `/mcp`.
|
||||
//!
|
||||
//! This is the tool-gateway pattern (ADR-041): the LLM gets a fixed set of
|
||||
//! meta-tools (`search`, `schema`, `call`, `batch`) and discovers operations
|
||||
//! on demand — not one MCP tool per registry operation. `Sub` ops are
|
||||
//! excluded from `search` and cannot be invoked via `call` (MCP tool calls
|
||||
//! are request/response — ADR-041 §2).
|
||||
//!
|
||||
//! `to_mcp` is a pure projection (ADR-017 §5): it consumes the registry and
|
||||
//! does not produce entries for it. It is not an `OperationAdapter`. The
|
||||
//! shared dispatch spine (`GatewayDispatch`) is used for the `call` tool; the
|
||||
//! `ResponseEnvelope` → `CallToolResult` mapping is `to_mcp`-specific.
|
||||
//!
|
||||
//! Bearer auth is the shared `bearer_auth_middleware`, applied as an axum
|
||||
//! layer *around* the nested `StreamableHttpService`. The resolved
|
||||
//! `Identity` is stashed by the middleware into `http::request::Parts`'s
|
||||
//! extensions; rmcp injects `Parts` into the `RequestContext<RoleServer>`
|
||||
//! extensions, so `call_tool` reads it back via
|
||||
//! `context.extensions.get::<http::request::Parts>()`.
|
||||
//!
|
||||
//! Streamable HTTP only (ADR-037 — stdio is not built). Feature-gated behind
|
||||
//! `mcp`.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResult, Implementation, JsonObject, ListToolsResult,
|
||||
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::transport::{
|
||||
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
|
||||
StreamableHttpServerConfig,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::gateway::GatewayDispatch;
|
||||
|
||||
const TOOL_SEARCH: &str = "search";
|
||||
const TOOL_SCHEMA: &str = "schema";
|
||||
const TOOL_CALL: &str = "call";
|
||||
const TOOL_BATCH: &str = "batch";
|
||||
|
||||
const OP_SERVICES_LIST: &str = "services/list";
|
||||
const OP_SERVICES_SCHEMA: &str = "services/schema";
|
||||
|
||||
fn search_input_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Optional substring filter on operation name."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn schema_input_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The fully-qualified operation name (e.g. `fs/readFile`)."
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
})
|
||||
}
|
||||
|
||||
fn call_input_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"description": "The fully-qualified operation name to invoke."
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
"description": "The JSON input object to pass to the operation."
|
||||
}
|
||||
},
|
||||
"required": ["operation"]
|
||||
})
|
||||
}
|
||||
|
||||
fn batch_input_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"calls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": { "type": "string" },
|
||||
"input": { "type": "object" }
|
||||
},
|
||||
"required": ["operation"]
|
||||
},
|
||||
"description": "The operations to invoke in this batch."
|
||||
}
|
||||
},
|
||||
"required": ["calls"]
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ToMcpGateway {
|
||||
dispatch: Arc<GatewayDispatch>,
|
||||
}
|
||||
|
||||
impl ToMcpGateway {
|
||||
pub fn new(dispatch: Arc<GatewayDispatch>) -> Self {
|
||||
Self { dispatch }
|
||||
}
|
||||
|
||||
pub fn dispatch(&self) -> &Arc<GatewayDispatch> {
|
||||
&self.dispatch
|
||||
}
|
||||
|
||||
fn extract_identity(context: &RequestContext<RoleServer>) -> Option<Identity> {
|
||||
Self::extract_identity_from_extensions(&context.extensions)
|
||||
}
|
||||
|
||||
fn extract_identity_from_extensions(extensions: &rmcp::model::Extensions) -> Option<Identity> {
|
||||
let parts = extensions.get::<http::request::Parts>()?;
|
||||
parts
|
||||
.extensions
|
||||
.get::<Option<Identity>>()
|
||||
.and_then(Option::clone)
|
||||
}
|
||||
|
||||
async fn handle_search(&self, identity: Option<Identity>) -> CallToolResult {
|
||||
let response = self
|
||||
.dispatch
|
||||
.invoke(identity.clone(), OP_SERVICES_LIST, Value::Null)
|
||||
.await;
|
||||
map_search_response(response)
|
||||
}
|
||||
|
||||
async fn handle_schema(
|
||||
&self,
|
||||
arguments: Option<JsonObject>,
|
||||
identity: Option<Identity>,
|
||||
) -> CallToolResult {
|
||||
let name = match arguments
|
||||
.and_then(|mut a| a.remove("name"))
|
||||
.and_then(|v| v.as_str().map(str::to_string))
|
||||
{
|
||||
Some(n) => n,
|
||||
None => {
|
||||
return CallToolResult::structured_error(serde_json::json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": "missing required field: name"
|
||||
}));
|
||||
}
|
||||
};
|
||||
let response = self
|
||||
.dispatch
|
||||
.invoke(
|
||||
identity,
|
||||
OP_SERVICES_SCHEMA,
|
||||
serde_json::json!({ "name": name }),
|
||||
)
|
||||
.await;
|
||||
envelope_to_call_tool_result(response)
|
||||
}
|
||||
|
||||
async fn handle_call(
|
||||
&self,
|
||||
arguments: Option<JsonObject>,
|
||||
identity: Option<Identity>,
|
||||
) -> CallToolResult {
|
||||
let (operation, input) = match parse_call_arguments(arguments) {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => return err,
|
||||
};
|
||||
let response = self.dispatch.invoke(identity, &operation, input).await;
|
||||
envelope_to_call_tool_result(response)
|
||||
}
|
||||
|
||||
async fn handle_batch(
|
||||
&self,
|
||||
arguments: Option<JsonObject>,
|
||||
identity: Option<Identity>,
|
||||
) -> CallToolResult {
|
||||
let calls = match arguments
|
||||
.and_then(|mut a| a.remove("calls"))
|
||||
.and_then(|v| v.as_array().cloned())
|
||||
{
|
||||
Some(arr) => arr,
|
||||
None => {
|
||||
return CallToolResult::structured_error(serde_json::json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": "missing required field: calls"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<Value> = Vec::with_capacity(calls.len());
|
||||
for call in calls {
|
||||
let (operation, input) = match parse_call_arguments(call.as_object().cloned()) {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
results.push(batch_error_value(err));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let response = self
|
||||
.dispatch
|
||||
.invoke(identity.clone(), &operation, input)
|
||||
.await;
|
||||
results.push(envelope_to_value(response));
|
||||
}
|
||||
CallToolResult::structured(Value::Array(results))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_call_arguments(arguments: Option<JsonObject>) -> Result<(String, Value), CallToolResult> {
|
||||
let mut map = match arguments {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
return Err(CallToolResult::structured_error(serde_json::json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": "missing required field: operation"
|
||||
})));
|
||||
}
|
||||
};
|
||||
let operation = match map
|
||||
.remove("operation")
|
||||
.and_then(|v| v.as_str().map(str::to_string))
|
||||
{
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return Err(CallToolResult::structured_error(serde_json::json!({
|
||||
"code": "INVALID_INPUT",
|
||||
"message": "missing required field: operation"
|
||||
})));
|
||||
}
|
||||
};
|
||||
let input = map.remove("input").unwrap_or(Value::Object(Map::new()));
|
||||
Ok((operation, input))
|
||||
}
|
||||
|
||||
fn batch_error_value(result: CallToolResult) -> Value {
|
||||
serde_json::json!({
|
||||
"isError": result.is_error.unwrap_or(false),
|
||||
"structuredContent": result.structured_content,
|
||||
"content": result.content,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_search_response(response: ResponseEnvelope) -> CallToolResult {
|
||||
match response.result {
|
||||
Ok(value) => {
|
||||
let operations = value
|
||||
.get("operations")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let filtered: Vec<Value> = operations
|
||||
.into_iter()
|
||||
.filter(|op| {
|
||||
let op_type = op.get("op_type").and_then(Value::as_str).unwrap_or("");
|
||||
!matches!(op_type, "sub" | "subscription" | "Sub")
|
||||
})
|
||||
.map(|op| op_to_search_listing(&op))
|
||||
.collect();
|
||||
CallToolResult::structured(serde_json::json!({ "operations": filtered }))
|
||||
}
|
||||
Err(err) => call_error_to_structured_error(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn op_to_search_listing(op: &Value) -> Value {
|
||||
let name = op.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let op_type = op.get("op_type").and_then(Value::as_str).unwrap_or("query");
|
||||
let namespace = op.get("namespace").and_then(Value::as_str).unwrap_or("");
|
||||
let description = format!("{op_type} operation `{name}` in namespace `{namespace}`");
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"description": description,
|
||||
})
|
||||
}
|
||||
|
||||
fn envelope_to_call_tool_result(response: ResponseEnvelope) -> CallToolResult {
|
||||
match response.result {
|
||||
Ok(value) => CallToolResult::structured(value),
|
||||
Err(err) => call_error_to_structured_error(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn call_error_to_structured_error(err: CallError) -> CallToolResult {
|
||||
let details = serde_json::to_value(&err).unwrap_or(Value::Null);
|
||||
CallToolResult::structured_error(details)
|
||||
}
|
||||
|
||||
fn envelope_to_value(response: ResponseEnvelope) -> Value {
|
||||
match response.result {
|
||||
Ok(output) => serde_json::json!({
|
||||
"isError": false,
|
||||
"output": output,
|
||||
}),
|
||||
Err(err) => {
|
||||
let details = serde_json::to_value(&err).unwrap_or(Value::Null);
|
||||
serde_json::json!({
|
||||
"isError": true,
|
||||
"error": details,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gateway_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
Cow::Borrowed(TOOL_SEARCH),
|
||||
Cow::Borrowed(
|
||||
"List available operations (filtered by the caller's AccessControl). Returns names + descriptions, not full schemas. Subscription operations are excluded.",
|
||||
),
|
||||
value_to_object(search_input_schema()),
|
||||
),
|
||||
Tool::new(
|
||||
Cow::Borrowed(TOOL_SCHEMA),
|
||||
Cow::Borrowed(
|
||||
"Get the full OperationSpec for an operation (input/output JSON Schemas, error schemas).",
|
||||
),
|
||||
value_to_object(schema_input_schema()),
|
||||
),
|
||||
Tool::new(
|
||||
Cow::Borrowed(TOOL_CALL),
|
||||
Cow::Borrowed(
|
||||
"Invoke an operation by name with a JSON input. Returns the output as structuredContent, or isError with typed error details for a CallError.",
|
||||
),
|
||||
value_to_object(call_input_schema()),
|
||||
),
|
||||
Tool::new(
|
||||
Cow::Borrowed(TOOL_BATCH),
|
||||
Cow::Borrowed(
|
||||
"Invoke multiple operations in one tool call. Returns an array of results, each shaped like a `call` result.",
|
||||
),
|
||||
value_to_object(batch_input_schema()),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn value_to_object(value: Value) -> Arc<JsonObject> {
|
||||
match value {
|
||||
Value::Object(map) => Arc::new(map),
|
||||
_ => Arc::new(Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
impl rmcp::handler::server::ServerHandler for ToMcpGateway {
|
||||
fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl futures::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_ {
|
||||
let tools = gateway_tools();
|
||||
std::future::ready(Ok(ListToolsResult::with_all_items(tools)))
|
||||
}
|
||||
|
||||
fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl futures::Future<Output = Result<CallToolResult, rmcp::ErrorData>> + Send + '_ {
|
||||
let identity = Self::extract_identity(&context);
|
||||
let name = request.name.to_string();
|
||||
let arguments = request.arguments;
|
||||
let this = self;
|
||||
async move {
|
||||
let result = match name.as_str() {
|
||||
TOOL_SEARCH => this.handle_search(identity).await,
|
||||
TOOL_SCHEMA => this.handle_schema(arguments, identity).await,
|
||||
TOOL_CALL => this.handle_call(arguments, identity).await,
|
||||
TOOL_BATCH => this.handle_batch(arguments, identity).await,
|
||||
unknown => {
|
||||
let err = CallError::new(
|
||||
"NOT_FOUND",
|
||||
format!("unknown gateway tool: {unknown}"),
|
||||
false,
|
||||
);
|
||||
call_error_to_structured_error(err)
|
||||
}
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let capabilities = ServerCapabilities::builder().enable_tools().build();
|
||||
ServerInfo::new(capabilities)
|
||||
.with_server_info(Implementation::new(
|
||||
"alkhttp-to-mcp",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
.with_instructions(
|
||||
"alk MCP gateway. Call `search` to discover operations, `schema` for an operation's full spec, `call` to invoke, `batch` to invoke many.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub type ToMcpService = StreamableHttpService<ToMcpGateway, LocalSessionManager>;
|
||||
|
||||
pub fn to_mcp_service(dispatch: Arc<GatewayDispatch>) -> ToMcpService {
|
||||
let gateway = ToMcpGateway::new(dispatch);
|
||||
StreamableHttpService::new(
|
||||
move || Ok(ToMcpGateway::new(Arc::clone(gateway.dispatch()))),
|
||||
LocalSessionManager::default().into(),
|
||||
StreamableHttpServerConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use alkcall::core::auth::{AuthToken, IdentityProvider};
|
||||
use alkcall::core::types::Capabilities;
|
||||
use alkcall::registry::context::ScopedPeerEnv;
|
||||
use alkcall::registry::discovery::{
|
||||
services_list_handler, services_list_spec, services_schema_handler, services_schema_spec,
|
||||
};
|
||||
use alkcall::registry::registration::{
|
||||
make_handler, make_streaming_handler, HandlerKind, HandlerRegistration,
|
||||
OperationProvenance, OperationRegistry,
|
||||
};
|
||||
use alkcall::registry::spec::{AccessControl, OperationSpec, OperationType, Visibility};
|
||||
use rmcp::model::Extensions;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
struct StaticIdentityProvider {
|
||||
tokens: StdMutex<HashMap<String, Identity>>,
|
||||
}
|
||||
|
||||
impl StaticIdentityProvider {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tokens: StdMutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_token(self, token: &str, identity: Identity) -> Self {
|
||||
self.tokens
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(token.to_string(), identity);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityProvider for StaticIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, _fp: &str) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
let token_str = String::from_utf8_lossy(&token.raw);
|
||||
self.tokens
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(token_str.as_ref())
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
fn identity_with_scopes(id: &str, scopes: &[&str]) -> Identity {
|
||||
Identity {
|
||||
id: id.to_string(),
|
||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||
resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn external_spec(name: &str, op_type: OperationType, acl: AccessControl) -> OperationSpec {
|
||||
OperationSpec::new(
|
||||
name,
|
||||
op_type,
|
||||
Visibility::External,
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
acl,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_echo_handler() -> alkcall::registry::registration::Handler {
|
||||
make_handler(
|
||||
|input, context| async move { ResponseEnvelope::ok(context.request_id, input) },
|
||||
)
|
||||
}
|
||||
|
||||
fn make_echo_streaming_handler() -> alkcall::registry::registration::StreamingHandler {
|
||||
make_streaming_handler(|input, context| {
|
||||
futures::stream::iter(vec![ResponseEnvelope::ok(context.request_id, input)])
|
||||
})
|
||||
}
|
||||
|
||||
fn handler_kind_for(op_type: OperationType) -> HandlerKind {
|
||||
match op_type {
|
||||
OperationType::Sub => HandlerKind::Stream(make_echo_streaming_handler()),
|
||||
OperationType::Query | OperationType::Mutation => {
|
||||
HandlerKind::Once(make_echo_handler())
|
||||
}
|
||||
OperationType::Pub => {
|
||||
unreachable!("to_mcp tests never register Pub ops")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn full_registry_with_ops(
|
||||
specs: Vec<(String, OperationType, AccessControl)>,
|
||||
) -> Arc<OperationRegistry> {
|
||||
let mut inner = OperationRegistry::new();
|
||||
for (name, op_type, acl) in specs {
|
||||
inner
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(&name, op_type, acl),
|
||||
handler_kind_for(op_type),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let inner = Arc::new(inner);
|
||||
|
||||
let mut dispatch_registry = OperationRegistry::new();
|
||||
for op in inner.list_operations() {
|
||||
dispatch_registry
|
||||
.register(HandlerRegistration::new(
|
||||
external_spec(&op.name, op.op_type, op.access_control.clone()),
|
||||
handler_kind_for(op.op_type),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
dispatch_registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_list_spec(),
|
||||
HandlerKind::Once(services_list_handler(Arc::clone(&inner))),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
Some(ScopedPeerEnv::empty()),
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
dispatch_registry
|
||||
.register(HandlerRegistration::new(
|
||||
services_schema_spec(),
|
||||
HandlerKind::Once(services_schema_handler(Arc::clone(&inner))),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
Some(ScopedPeerEnv::empty()),
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(dispatch_registry)
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
registry: Arc<OperationRegistry>,
|
||||
provider: Arc<dyn alkcall::core::auth::IdentityProvider>,
|
||||
) -> Arc<GatewayDispatch> {
|
||||
Arc::new(GatewayDispatch::new(registry, provider))
|
||||
}
|
||||
|
||||
fn provider() -> Arc<dyn alkcall::core::auth::IdentityProvider> {
|
||||
Arc::new(StaticIdentityProvider::new())
|
||||
}
|
||||
|
||||
fn extensions_with_identity(identity: Option<Identity>) -> Extensions {
|
||||
let request = http::Request::builder()
|
||||
.method(http::Method::POST)
|
||||
.uri("/mcp")
|
||||
.body(())
|
||||
.expect("valid request");
|
||||
let (mut parts, _) = request.into_parts();
|
||||
parts.extensions.insert(identity);
|
||||
let mut extensions = Extensions::new();
|
||||
extensions.insert(parts);
|
||||
extensions
|
||||
}
|
||||
|
||||
async fn invoke_tool(
|
||||
gateway: &ToMcpGateway,
|
||||
name: &str,
|
||||
arguments: Option<JsonObject>,
|
||||
identity: Option<Identity>,
|
||||
) -> CallToolResult {
|
||||
match name {
|
||||
TOOL_SEARCH => gateway.handle_search(identity).await,
|
||||
TOOL_SCHEMA => gateway.handle_schema(arguments, identity).await,
|
||||
TOOL_CALL => gateway.handle_call(arguments, identity).await,
|
||||
TOOL_BATCH => gateway.handle_batch(arguments, identity).await,
|
||||
unknown => {
|
||||
let err = CallError::new(
|
||||
"NOT_FOUND",
|
||||
format!("unknown gateway tool: {unknown}"),
|
||||
false,
|
||||
);
|
||||
call_error_to_structured_error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tools_returns_exactly_four_gateway_tools() {
|
||||
let _gateway = ToMcpGateway::new(dispatch(full_registry_with_ops(vec![]), provider()));
|
||||
let tools = gateway_tools();
|
||||
let names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect();
|
||||
assert_eq!(names.len(), 4);
|
||||
assert!(names.contains(&"search".to_string()));
|
||||
assert!(names.contains(&"schema".to_string()));
|
||||
assert!(names.contains(&"call".to_string()));
|
||||
assert!(names.contains(&"batch".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tools_does_not_leak_registry_operations() {
|
||||
let registry = full_registry_with_ops(vec![(
|
||||
"fs/readFile".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl::default(),
|
||||
)]);
|
||||
let _gateway = ToMcpGateway::new(dispatch(registry, provider()));
|
||||
let tools = gateway_tools();
|
||||
for tool in &tools {
|
||||
assert_ne!(tool.name, "fs/readFile");
|
||||
assert_ne!(tool.name, "services/list");
|
||||
assert_ne!(tool.name, "services/schema");
|
||||
}
|
||||
assert_eq!(tools.len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_returns_access_control_filtered_ops_excluding_subscriptions() {
|
||||
let registry = full_registry_with_ops(vec![
|
||||
(
|
||||
"public/echo".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl::default(),
|
||||
),
|
||||
(
|
||||
"admin/secret".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"events/stream".to_string(),
|
||||
OperationType::Sub,
|
||||
AccessControl::default(),
|
||||
),
|
||||
]);
|
||||
let idp: Arc<dyn alkcall::core::auth::IdentityProvider> =
|
||||
Arc::new(StaticIdentityProvider::new());
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, idp));
|
||||
|
||||
let result = invoke_tool(
|
||||
&gateway,
|
||||
"search",
|
||||
None,
|
||||
Some(identity_with_scopes("user", &["user"])),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.is_error, Some(false));
|
||||
let structured = result.structured_content.expect("structured present");
|
||||
let ops = structured
|
||||
.get("operations")
|
||||
.and_then(Value::as_array)
|
||||
.expect("operations array");
|
||||
let names: Vec<&str> = ops
|
||||
.iter()
|
||||
.filter_map(|o| o.get("name").and_then(Value::as_str))
|
||||
.collect();
|
||||
assert!(names.contains(&"public/echo"));
|
||||
assert!(
|
||||
!names.contains(&"admin/secret"),
|
||||
"ACL-filtered op must not appear"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"events/stream"),
|
||||
"Subscription op must be excluded"
|
||||
);
|
||||
for op in ops {
|
||||
assert!(
|
||||
op.get("description").is_some(),
|
||||
"each entry has a description"
|
||||
);
|
||||
assert!(
|
||||
op.get("input_schema").is_none(),
|
||||
"search must not return full schemas"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_returns_full_operation_spec() {
|
||||
let registry = full_registry_with_ops(vec![(
|
||||
"fs/readFile".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl::default(),
|
||||
)]);
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert("name".to_string(), Value::String("fs/readFile".to_string()));
|
||||
let result = invoke_tool(&gateway, "schema", Some(args), None).await;
|
||||
assert_eq!(result.is_error, Some(false));
|
||||
let structured = result.structured_content.expect("structured present");
|
||||
assert_eq!(
|
||||
structured.get("name"),
|
||||
Some(&Value::String("fs/readFile".to_string()))
|
||||
);
|
||||
assert!(structured.get("input_schema").is_some());
|
||||
assert!(structured.get("output_schema").is_some());
|
||||
assert!(structured.get("error_schemas").is_some());
|
||||
assert!(structured.get("access_control").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_returns_structured_for_success() {
|
||||
let registry = full_registry_with_ops(vec![(
|
||||
"echo/run".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl::default(),
|
||||
)]);
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert(
|
||||
"operation".to_string(),
|
||||
Value::String("echo/run".to_string()),
|
||||
);
|
||||
args.insert("input".to_string(), serde_json::json!({ "msg": "hi" }));
|
||||
let result = invoke_tool(&gateway, "call", Some(args), None).await;
|
||||
assert_eq!(result.is_error, Some(false));
|
||||
assert_eq!(
|
||||
result.structured_content,
|
||||
Some(serde_json::json!({ "msg": "hi" }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_returns_structured_error_for_call_error() {
|
||||
let registry = full_registry_with_ops(vec![]);
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert(
|
||||
"operation".to_string(),
|
||||
Value::String("no/such".to_string()),
|
||||
);
|
||||
args.insert("input".to_string(), Value::Object(Map::new()));
|
||||
let result = invoke_tool(&gateway, "call", Some(args), None).await;
|
||||
assert_eq!(result.is_error, Some(true));
|
||||
let structured = result.structured_content.expect("structured error present");
|
||||
assert_eq!(
|
||||
structured.get("code"),
|
||||
Some(&Value::String("NOT_FOUND".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_returns_array_of_results() {
|
||||
let registry = full_registry_with_ops(vec![(
|
||||
"echo/run".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl::default(),
|
||||
)]);
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert(
|
||||
"calls".to_string(),
|
||||
serde_json::json!([
|
||||
{ "operation": "echo/run", "input": { "n": 1 } },
|
||||
{ "operation": "no/such", "input": {} },
|
||||
]),
|
||||
);
|
||||
let result = invoke_tool(&gateway, "batch", Some(args), None).await;
|
||||
assert_eq!(result.is_error, Some(false));
|
||||
let structured = result.structured_content.expect("structured present");
|
||||
let arr = structured.as_array().expect("batch returns array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0].get("isError"), Some(&Value::Bool(false)));
|
||||
assert_eq!(arr[1].get("isError"), Some(&Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_with_restricted_op_and_unauthorized_identity_returns_forbidden_error() {
|
||||
let registry = full_registry_with_ops(vec![(
|
||||
"admin/run".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
let idp: Arc<dyn alkcall::core::auth::IdentityProvider> =
|
||||
Arc::new(StaticIdentityProvider::new());
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, idp));
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert(
|
||||
"operation".to_string(),
|
||||
Value::String("admin/run".to_string()),
|
||||
);
|
||||
args.insert("input".to_string(), Value::Object(Map::new()));
|
||||
let result = invoke_tool(&gateway, "call", Some(args), None).await;
|
||||
assert_eq!(result.is_error, Some(true));
|
||||
let structured = result.structured_content.expect("structured error present");
|
||||
assert_eq!(
|
||||
structured.get("code"),
|
||||
Some(&Value::String("FORBIDDEN".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_tool_name_returns_not_found_structured_error() {
|
||||
let gateway = ToMcpGateway::new(dispatch(Arc::new(OperationRegistry::new()), provider()));
|
||||
let result = invoke_tool(&gateway, "bogus", None, None).await;
|
||||
assert_eq!(result.is_error, Some(true));
|
||||
let structured = result.structured_content.expect("structured error present");
|
||||
assert_eq!(
|
||||
structured.get("code"),
|
||||
Some(&Value::String("NOT_FOUND".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_survives_rmcp_framing_into_call_tool() {
|
||||
let registry = full_registry_with_ops(vec![(
|
||||
"admin/run".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl {
|
||||
required_scopes: vec!["admin".to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
)]);
|
||||
let idp: Arc<dyn alkcall::core::auth::IdentityProvider> = Arc::new(
|
||||
StaticIdentityProvider::new()
|
||||
.with_token("alk_admin", identity_with_scopes("admin-peer", &["admin"])),
|
||||
);
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, idp));
|
||||
|
||||
let admin_identity = identity_with_scopes("admin-peer", &["admin"]);
|
||||
let extensions = extensions_with_identity(Some(admin_identity.clone()));
|
||||
let extracted = ToMcpGateway::extract_identity_from_extensions(&extensions);
|
||||
assert_eq!(
|
||||
extracted.as_ref().map(|i| &i.id),
|
||||
Some(&"admin-peer".to_string())
|
||||
);
|
||||
|
||||
let mut args = Map::new();
|
||||
args.insert(
|
||||
"operation".to_string(),
|
||||
Value::String("admin/run".to_string()),
|
||||
);
|
||||
args.insert("input".to_string(), serde_json::json!({ "ok": 1 }));
|
||||
let result = gateway.handle_call(Some(args), extracted).await;
|
||||
assert_eq!(result.is_error, Some(false));
|
||||
assert_eq!(
|
||||
result.structured_content,
|
||||
Some(serde_json::json!({ "ok": 1 }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_identity_returns_none_when_no_parts_in_extensions() {
|
||||
let extensions = Extensions::new();
|
||||
assert!(ToMcpGateway::extract_identity_from_extensions(&extensions).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_identity_returns_none_when_parts_have_no_identity() {
|
||||
let extensions = extensions_with_identity(None);
|
||||
assert!(ToMcpGateway::extract_identity_from_extensions(&extensions).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_identity_reads_stashed_option_identity_from_parts() {
|
||||
let id = identity_with_scopes("caller", &["read"]);
|
||||
let extensions = extensions_with_identity(Some(id.clone()));
|
||||
let extracted = ToMcpGateway::extract_identity_from_extensions(&extensions);
|
||||
assert_eq!(
|
||||
extracted.as_ref().map(|i| i.id.clone()),
|
||||
Some("caller".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extracted.as_ref().map(|i| i.scopes.clone()),
|
||||
Some(vec!["read".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_mcp_is_not_an_operation_adapter() {
|
||||
fn assert_not_adapter<T>() {}
|
||||
assert_not_adapter::<ToMcpGateway>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_tools_definition_is_stable() {
|
||||
let tools = gateway_tools();
|
||||
assert_eq!(tools.len(), 4);
|
||||
assert_eq!(tools[0].name, "search");
|
||||
assert_eq!(tools[1].name, "schema");
|
||||
assert_eq!(tools[2].name, "call");
|
||||
assert_eq!(tools[3].name, "batch");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_schema_call_round_trip() {
|
||||
let registry = full_registry_with_ops(vec![(
|
||||
"fs/readFile".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl::default(),
|
||||
)]);
|
||||
let gateway = ToMcpGateway::new(dispatch(registry, provider()));
|
||||
|
||||
let search_result = invoke_tool(&gateway, "search", None, None).await;
|
||||
let ops = search_result
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("operations"))
|
||||
.and_then(Value::as_array)
|
||||
.expect("search ops");
|
||||
let first_name = ops[0].get("name").and_then(Value::as_str).expect("name");
|
||||
assert_eq!(first_name, "fs/readFile");
|
||||
|
||||
let mut schema_args = Map::new();
|
||||
schema_args.insert("name".to_string(), Value::String(first_name.to_string()));
|
||||
let schema_result = invoke_tool(&gateway, "schema", Some(schema_args), None).await;
|
||||
assert_eq!(
|
||||
schema_result
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("name"))
|
||||
.and_then(Value::as_str),
|
||||
Some("fs/readFile")
|
||||
);
|
||||
|
||||
let mut call_args = Map::new();
|
||||
call_args.insert(
|
||||
"operation".to_string(),
|
||||
Value::String(first_name.to_string()),
|
||||
);
|
||||
call_args.insert(
|
||||
"input".to_string(),
|
||||
serde_json::json!({ "path": "/etc/hosts" }),
|
||||
);
|
||||
let call_result = invoke_tool(&gateway, "call", Some(call_args), None).await;
|
||||
assert_eq!(
|
||||
call_result.structured_content,
|
||||
Some(serde_json::json!({ "path": "/etc/hosts" }))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user