feat(http): implement from_mcp adapter (rmcp streamable HTTP client, tools/list discovery, structuredContent handling)
FromMCP (OperationAdapter, feature-gated behind mcp) discovers remote MCP tools over streamable HTTP via rmcp's StreamableHttpClientTransport, calls tools/list, and registers each as a HandlerRegistration bundle with a forwarding handler that calls the remote tool via tools/call. Output handling follows the structuredContent-preferred-over-content-blocks rule: declared outputSchema + structuredContent is the composable result; absent outputSchema falls back to the MCP ContentBlock union. isError:true maps to a CallError with the error content. No-env-vars invariant: the handler reads context.capabilities (injected at registration), never std::env::var (ADR-014). Streamable HTTP only — stdio is not built (ADR-037). Provenance is FromMCP (leaf: composition_authority None, scoped_env None, Internal by default, ADR-015/022). Includes unit tests for schema/mapping logic and an integration test that spins up a real rmcp streamable HTTP server and exercises the forwarding handler end-to-end.
This commit is contained in:
318
crates/alknet-http/src/adapters/from_mcp/mod.rs
Normal file
318
crates/alknet-http/src/adapters/from_mcp/mod.rs
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
//! `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`.
|
||||||
|
//!
|
||||||
|
//! 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). See
|
||||||
|
//! `docs/architecture/crates/http/http-mcp.md`.
|
||||||
|
|
||||||
|
use alknet_call::client::{AdapterError, OperationAdapter};
|
||||||
|
use alknet_call::protocol::wire::{CallError, ResponseEnvelope};
|
||||||
|
use alknet_call::registry::context::OperationContext;
|
||||||
|
use alknet_call::registry::registration::{
|
||||||
|
make_handler, HandlerRegistration, OperationProvenance,
|
||||||
|
};
|
||||||
|
use alknet_call::registry::spec::{AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility};
|
||||||
|
use alknet_core::types::Capabilities;
|
||||||
|
use rmcp::model::{
|
||||||
|
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientInfo, Content,
|
||||||
|
Implementation, JsonObject, Tool,
|
||||||
|
};
|
||||||
|
use rmcp::service::RoleClient;
|
||||||
|
use rmcp::transport::{
|
||||||
|
StreamableHttpClientTransport,
|
||||||
|
streamable_http_client::StreamableHttpClientTransportConfig,
|
||||||
|
};
|
||||||
|
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("alknet-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,
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "data", "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;
|
||||||
251
crates/alknet-http/src/adapters/from_mcp/tests.rs
Normal file
251
crates/alknet-http/src/adapters/from_mcp/tests.rs
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
use super::*;
|
||||||
|
use alknet_call::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());
|
||||||
|
}
|
||||||
@@ -8,4 +8,8 @@
|
|||||||
//! `docs/architecture/crates/http/http-adapters.md` and
|
//! `docs/architecture/crates/http/http-adapters.md` and
|
||||||
//! `docs/architecture/crates/http/http-mcp.md`.
|
//! `docs/architecture/crates/http/http-mcp.md`.
|
||||||
|
|
||||||
// TODO: implement
|
#[cfg(feature = "mcp")]
|
||||||
|
pub mod from_mcp;
|
||||||
|
|
||||||
|
#[cfg(feature = "mcp")]
|
||||||
|
pub use from_mcp::FromMCP;
|
||||||
237
crates/alknet-http/tests/from_mcp_integration.rs
Normal file
237
crates/alknet-http/tests/from_mcp_integration.rs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
//! Integration test for `FromMCP`: spins up a real rmcp streamable HTTP MCP
|
||||||
|
//! server, imports its tools via `FromMCP::import()`, and invokes a
|
||||||
|
//! forwarding handler end-to-end. Verifies the handler calls the remote MCP
|
||||||
|
//! tool via rmcp and reads `context.capabilities` (not `std::env::var`).
|
||||||
|
|
||||||
|
#![cfg(feature = "mcp")]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use alknet_call::protocol::wire::ResponseEnvelope;
|
||||||
|
use alknet_call::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||||
|
use alknet_call::registry::env::OperationEnv;
|
||||||
|
use alknet_call::registry::registration::OperationProvenance;
|
||||||
|
use alknet_call::client::OperationAdapter;
|
||||||
|
use alknet_core::types::Capabilities;
|
||||||
|
use alknet_http::adapters::FromMCP;
|
||||||
|
use axum::Router;
|
||||||
|
use rmcp::model::{
|
||||||
|
CallToolRequestParams, CallToolResult, Content, ListToolsResult, PaginatedRequestParams, Tool,
|
||||||
|
};
|
||||||
|
use rmcp::service::RequestContext;
|
||||||
|
use rmcp::transport::{
|
||||||
|
StreamableHttpServerConfig,
|
||||||
|
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
|
||||||
|
};
|
||||||
|
use rmcp::{RoleServer, ServerHandler};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
struct NoopEnv;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl OperationEnv for NoopEnv {
|
||||||
|
async fn invoke_with_policy(
|
||||||
|
&self,
|
||||||
|
_ns: &str,
|
||||||
|
_op: &str,
|
||||||
|
_input: Value,
|
||||||
|
parent: &OperationContext,
|
||||||
|
_policy: AbortPolicy,
|
||||||
|
) -> ResponseEnvelope {
|
||||||
|
ResponseEnvelope::ok(parent.request_id.clone(), Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains(&self, _name: &str) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_context(request_id: &str, caps: Capabilities) -> OperationContext {
|
||||||
|
OperationContext {
|
||||||
|
request_id: request_id.to_string(),
|
||||||
|
parent_request_id: None,
|
||||||
|
identity: None,
|
||||||
|
handler_identity: None,
|
||||||
|
forwarded_for: None,
|
||||||
|
capabilities: caps,
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
scoped_env: ScopedPeerEnv::empty(),
|
||||||
|
env: Arc::new(NoopEnv),
|
||||||
|
abort_policy: AbortPolicy::default(),
|
||||||
|
deadline: Some(Instant::now() + Duration::from_secs(30)),
|
||||||
|
internal: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EchoServer;
|
||||||
|
|
||||||
|
impl ServerHandler for EchoServer {
|
||||||
|
fn list_tools(
|
||||||
|
&self,
|
||||||
|
_request: Option<PaginatedRequestParams>,
|
||||||
|
_context: RequestContext<RoleServer>,
|
||||||
|
) -> impl std::future::Future<
|
||||||
|
Output = Result<ListToolsResult, rmcp::ErrorData>,
|
||||||
|
> + rmcp::service::MaybeSendFuture + '_ {
|
||||||
|
let tools = vec![
|
||||||
|
Tool::new_with_raw(
|
||||||
|
"echo",
|
||||||
|
Some("Echo the input back as structured content".into()),
|
||||||
|
Arc::new(serde_json::Map::new()),
|
||||||
|
)
|
||||||
|
.with_raw_output_schema(Arc::new(serde_json::Map::from_iter([
|
||||||
|
("type".to_string(), Value::String("object".into())),
|
||||||
|
]))),
|
||||||
|
Tool::new_with_raw(
|
||||||
|
"legacy",
|
||||||
|
Some("Legacy tool returning text content blocks".into()),
|
||||||
|
Arc::new(serde_json::Map::new()),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
std::future::ready(Ok(ListToolsResult {
|
||||||
|
meta: None,
|
||||||
|
next_cursor: None,
|
||||||
|
tools,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call_tool(
|
||||||
|
&self,
|
||||||
|
request: CallToolRequestParams,
|
||||||
|
_context: RequestContext<RoleServer>,
|
||||||
|
) -> impl std::future::Future<
|
||||||
|
Output = Result<CallToolResult, rmcp::ErrorData>,
|
||||||
|
> + rmcp::service::MaybeSendFuture + '_ {
|
||||||
|
let name = request.name.to_string();
|
||||||
|
std::future::ready(Ok(match name.as_str() {
|
||||||
|
"echo" => {
|
||||||
|
let args = request
|
||||||
|
.arguments
|
||||||
|
.map(Value::Object)
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
CallToolResult::structured(serde_json::json!({ "echoed": args }))
|
||||||
|
}
|
||||||
|
"legacy" => CallToolResult::success(vec![Content::text("plain text result")]),
|
||||||
|
other => CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"unknown tool: {other}"
|
||||||
|
))]),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_info(&self) -> rmcp::model::ServerInfo {
|
||||||
|
rmcp::model::ServerInfo::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn_server() -> (String, tokio::task::JoinHandle<()>) {
|
||||||
|
let mcp_service: StreamableHttpService<EchoServer, LocalSessionManager> =
|
||||||
|
StreamableHttpService::new(
|
||||||
|
|| Ok(EchoServer),
|
||||||
|
LocalSessionManager::default().into(),
|
||||||
|
StreamableHttpServerConfig::default(),
|
||||||
|
);
|
||||||
|
let app = Router::new().nest_service("/mcp", mcp_service);
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
(format!("http://{addr}/mcp"), handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn import_discovers_tools_and_builds_registrations() {
|
||||||
|
let (endpoint, _handle) = spawn_server().await;
|
||||||
|
let adapter = FromMCP::new(endpoint, "echo");
|
||||||
|
let bundles = adapter
|
||||||
|
.import()
|
||||||
|
.await
|
||||||
|
.expect("import succeeds against running server");
|
||||||
|
assert_eq!(bundles.len(), 2);
|
||||||
|
let names: Vec<&str> = bundles.iter().map(|b| b.spec.name.as_str()).collect();
|
||||||
|
assert!(names.contains(&"echo/echo"));
|
||||||
|
assert!(names.contains(&"echo/legacy"));
|
||||||
|
for b in &bundles {
|
||||||
|
assert_eq!(b.provenance, OperationProvenance::FromMCP);
|
||||||
|
assert!(b.composition_authority.is_none());
|
||||||
|
assert!(b.scoped_env.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn forwarding_handler_calls_echo_and_returns_structured_content() {
|
||||||
|
let (endpoint, _handle) = spawn_server().await;
|
||||||
|
let adapter = FromMCP::new(endpoint, "echo");
|
||||||
|
let bundles = adapter.import().await.expect("import succeeds");
|
||||||
|
let echo = bundles
|
||||||
|
.into_iter()
|
||||||
|
.find(|b| b.spec.name == "echo/echo")
|
||||||
|
.expect("echo tool present");
|
||||||
|
|
||||||
|
let caps = Capabilities::new().with_http_token("mcp", "unused-on-server".to_string());
|
||||||
|
let ctx = test_context("req-echo", caps);
|
||||||
|
let input = serde_json::json!({ "msg": "hello" });
|
||||||
|
let response = (echo.handler)(input, ctx).await;
|
||||||
|
|
||||||
|
assert_eq!(response.request_id, "req-echo");
|
||||||
|
match response.result {
|
||||||
|
Ok(v) => {
|
||||||
|
let obj = v.as_object().expect("structured object");
|
||||||
|
assert!(obj.contains_key("echoed"));
|
||||||
|
}
|
||||||
|
Err(e) => panic!("expected Ok, got Err: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn forwarding_handler_calls_legacy_and_returns_content_blocks() {
|
||||||
|
let (endpoint, _handle) = spawn_server().await;
|
||||||
|
let adapter = FromMCP::new(endpoint, "echo");
|
||||||
|
let bundles = adapter.import().await.expect("import succeeds");
|
||||||
|
let legacy = bundles
|
||||||
|
.into_iter()
|
||||||
|
.find(|b| b.spec.name == "echo/legacy")
|
||||||
|
.expect("legacy tool present");
|
||||||
|
|
||||||
|
let ctx = test_context("req-legacy", Capabilities::new());
|
||||||
|
let response = (legacy.handler)(serde_json::json!({}), ctx).await;
|
||||||
|
|
||||||
|
match response.result {
|
||||||
|
Ok(Value::Array(blocks)) => {
|
||||||
|
assert_eq!(blocks.len(), 1);
|
||||||
|
assert_eq!(blocks[0]["type"], "text");
|
||||||
|
assert_eq!(blocks[0]["text"], "plain text result");
|
||||||
|
}
|
||||||
|
other => panic!("expected array of content blocks, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn forwarding_handler_does_not_read_env_vars() {
|
||||||
|
std::env::set_var("MCP_TOKEN", "should-not-be-used");
|
||||||
|
let (endpoint, _handle) = spawn_server().await;
|
||||||
|
let adapter = FromMCP::new(endpoint, "echo");
|
||||||
|
let bundles = adapter.import().await.expect("import succeeds");
|
||||||
|
let echo = bundles
|
||||||
|
.into_iter()
|
||||||
|
.find(|b| b.spec.name == "echo/echo")
|
||||||
|
.expect("echo tool present");
|
||||||
|
|
||||||
|
let ctx = test_context("req-noenv", Capabilities::new());
|
||||||
|
let response = (echo.handler)(serde_json::json!({ "x": 1 }), ctx).await;
|
||||||
|
assert!(response.result.is_ok(), "handler works without env var");
|
||||||
|
std::env::remove_var("MCP_TOKEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn import_unreachable_server_returns_discovery_failed() {
|
||||||
|
let adapter = FromMCP::new("http://127.0.0.1:1/mcp", "x");
|
||||||
|
match adapter.import().await {
|
||||||
|
Ok(_) => panic!("expected Err for unreachable server"),
|
||||||
|
Err(alknet_call::client::AdapterError::DiscoveryFailed { .. }) => {}
|
||||||
|
Err(alknet_call::client::AdapterError::Transport { .. }) => {}
|
||||||
|
Err(other) => panic!("expected DiscoveryFailed or Transport, got {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user