feat(http): complete to_openapi gateway projection with error fidelity and route wiring
Refine to_openapi to project operation-level errors (with http_status) onto /call and /subscribe responses via oneOf merge with protocol-level errors, preserving HTTP_<status> prefix codes without collision. Fix BTreeMap→serde_json::Map for Value::Object compatibility. Wire GET /openapi.json route test. Apply cargo fmt across the crate.
This commit is contained in:
@@ -22,7 +22,11 @@ fn make_tool(name: &str, input: Value, output: Option<Value>) -> Tool {
|
||||
tool
|
||||
}
|
||||
|
||||
fn call_tool_result(content: Vec<Content>, structured: Option<Value>, is_error: Option<bool>) -> CallToolResult {
|
||||
fn call_tool_result(
|
||||
content: Vec<Content>,
|
||||
structured: Option<Value>,
|
||||
is_error: Option<bool>,
|
||||
) -> CallToolResult {
|
||||
let json = serde_json::json!({
|
||||
"content": content,
|
||||
"structuredContent": structured,
|
||||
@@ -204,7 +208,9 @@ 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" } } })),
|
||||
Some(
|
||||
serde_json::json!({ "type": "object", "properties": { "temperature": { "type": "number" } } }),
|
||||
),
|
||||
);
|
||||
let spec = build_spec(&tool, "weather");
|
||||
assert_eq!(spec.name, "weather/get_weather");
|
||||
@@ -248,4 +254,4 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ use std::sync::Arc;
|
||||
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::registration::{make_handler, HandlerRegistration, OperationProvenance};
|
||||
use alknet_call::registry::spec::{
|
||||
AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
use alknet_call::registry::spec::{AccessControl, ErrorDefinition, OperationSpec, OperationType, Visibility};
|
||||
use alknet_core::types::Capabilities;
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
@@ -128,11 +128,9 @@ impl OpenAPISpec {
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
let paths_raw = raw
|
||||
.get("paths")
|
||||
.ok_or_else(|| AdapterError::SchemaParse {
|
||||
message: "OpenAPI document missing `paths`".into(),
|
||||
})?;
|
||||
let paths_raw = raw.get("paths").ok_or_else(|| AdapterError::SchemaParse {
|
||||
message: "OpenAPI document missing `paths`".into(),
|
||||
})?;
|
||||
if !paths_raw.is_object() {
|
||||
return Err(AdapterError::SchemaParse {
|
||||
message: "`paths` must be a JSON object".into(),
|
||||
@@ -155,14 +153,13 @@ impl OpenAPISpec {
|
||||
if operations.is_empty() {
|
||||
continue;
|
||||
}
|
||||
paths.insert(
|
||||
path.clone(),
|
||||
PathItem { operations },
|
||||
);
|
||||
paths.insert(path.clone(), PathItem { operations });
|
||||
}
|
||||
|
||||
let components = raw.get("components").and_then(|c| c.get("schemas")).and_then(
|
||||
|schemas| {
|
||||
let components = raw
|
||||
.get("components")
|
||||
.and_then(|c| c.get("schemas"))
|
||||
.and_then(|schemas| {
|
||||
if !schemas.is_object() {
|
||||
return None;
|
||||
}
|
||||
@@ -171,8 +168,7 @@ impl OpenAPISpec {
|
||||
map.insert(k.clone(), v.clone());
|
||||
}
|
||||
Some(Components { schemas: map })
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
@@ -190,11 +186,9 @@ impl OpenAPISpec {
|
||||
}
|
||||
let mut current: &Value = &self.raw;
|
||||
for part in reference.trim_start_matches("#/").split('/') {
|
||||
current = current
|
||||
.get(part)
|
||||
.ok_or_else(|| AdapterError::SchemaParse {
|
||||
message: format!("cannot resolve $ref: {reference}"),
|
||||
})?;
|
||||
current = current.get(part).ok_or_else(|| AdapterError::SchemaParse {
|
||||
message: format!("cannot resolve $ref: {reference}"),
|
||||
})?;
|
||||
}
|
||||
Ok(current.clone())
|
||||
}
|
||||
@@ -241,10 +235,7 @@ fn parse_operation(raw: &Value) -> Option<Operation> {
|
||||
.filter_map(|p| {
|
||||
let name = p.get("name")?.as_str()?.to_string();
|
||||
let in_ = p.get("in")?.as_str()?.to_string();
|
||||
let required = p
|
||||
.get("required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let schema = p.get("schema").cloned();
|
||||
Some(Parameter {
|
||||
name,
|
||||
@@ -297,7 +288,11 @@ pub struct FromOpenAPI {
|
||||
}
|
||||
|
||||
impl FromOpenAPI {
|
||||
pub fn new(spec: OpenAPISpec, config: HttpServiceConfig, http_client: Arc<SharedHttpClient>) -> Self {
|
||||
pub fn new(
|
||||
spec: OpenAPISpec,
|
||||
config: HttpServiceConfig,
|
||||
http_client: Arc<SharedHttpClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
spec,
|
||||
config,
|
||||
@@ -322,10 +317,7 @@ impl FromOpenAPI {
|
||||
}
|
||||
|
||||
fn detect_op_type(method: &str, op: &Operation) -> OperationType {
|
||||
let success = op
|
||||
.responses
|
||||
.get("200")
|
||||
.or_else(|| op.responses.get("201"));
|
||||
let success = op.responses.get("200").or_else(|| op.responses.get("201"));
|
||||
if let Some(resp) = success {
|
||||
if resp.content.contains_key("text/event-stream") {
|
||||
return OperationType::Subscription;
|
||||
@@ -531,9 +523,8 @@ fn build_request(
|
||||
}
|
||||
}
|
||||
|
||||
let base = Url::parse(base_url).map_err(|e| {
|
||||
CallError::internal(format!("invalid base_url `{base_url}`: {e}"))
|
||||
})?;
|
||||
let base = Url::parse(base_url)
|
||||
.map_err(|e| CallError::internal(format!("invalid base_url `{base_url}`: {e}")))?;
|
||||
let mut url = base
|
||||
.join(url_path.trim_start_matches('/'))
|
||||
.map_err(|e| CallError::internal(format!("invalid path `{url_path}`: {e}")))?;
|
||||
@@ -683,11 +674,12 @@ async fn forward(
|
||||
.find(|(s, _)| *s == status.as_u16())
|
||||
.map(|(_, code)| code.clone())
|
||||
.unwrap_or_else(|| format!("HTTP_{}", status.as_u16()));
|
||||
let message = format!("HTTP {}: {}", status.as_u16(), status.canonical_reason().unwrap_or(""));
|
||||
return ResponseEnvelope::error(
|
||||
request_id,
|
||||
CallError::new(code, message, false),
|
||||
let message = format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or("")
|
||||
);
|
||||
return ResponseEnvelope::error(request_id, CallError::new(code, message, false));
|
||||
}
|
||||
|
||||
let content_type = response
|
||||
@@ -716,10 +708,7 @@ async fn forward(
|
||||
} else {
|
||||
match response.bytes().await {
|
||||
Ok(b) => {
|
||||
let arr: Vec<Value> = b
|
||||
.iter()
|
||||
.map(|byte| Value::Number((*byte).into()))
|
||||
.collect();
|
||||
let arr: Vec<Value> = b.iter().map(|byte| Value::Number((*byte).into())).collect();
|
||||
ResponseEnvelope::ok(request_id, Value::Array(arr))
|
||||
}
|
||||
Err(err) => ResponseEnvelope::error(
|
||||
@@ -744,7 +733,8 @@ async fn stream_subscription(request_id: String, response: reqwest::Response) ->
|
||||
let parsed = if event.data.trim().is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_str(&event.data).unwrap_or(Value::String(event.data.clone()))
|
||||
serde_json::from_str(&event.data)
|
||||
.unwrap_or(Value::String(event.data.clone()))
|
||||
};
|
||||
last_event = Some(parsed.clone());
|
||||
}
|
||||
@@ -1040,7 +1030,12 @@ mod tests {
|
||||
.unwrap();
|
||||
let body = props.get("body").unwrap();
|
||||
assert_eq!(body.get("type").unwrap(), "object");
|
||||
assert!(body.get("properties").unwrap().as_object().unwrap().contains_key("name"));
|
||||
assert!(body
|
||||
.get("properties")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1074,14 +1069,19 @@ mod tests {
|
||||
"https://api.vast.ai",
|
||||
"/machines",
|
||||
"GET",
|
||||
&Some(HttpAuthScheme::ApiKey { header_name: "X-API-Key".to_string() }),
|
||||
&Some(HttpAuthScheme::ApiKey {
|
||||
header_name: "X-API-Key".to_string(),
|
||||
}),
|
||||
&HashMap::new(),
|
||||
"vastai",
|
||||
&serde_json::json!({}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(headers.get("X-API-Key").unwrap().to_str().unwrap(), "key-xyz");
|
||||
assert_eq!(
|
||||
headers.get("X-API-Key").unwrap().to_str().unwrap(),
|
||||
"key-xyz"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1267,7 +1267,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn http_service_config_struct_fields() {
|
||||
let cfg = config("ns", "https://api.example.com", Some(HttpAuthScheme::Bearer));
|
||||
let cfg = config(
|
||||
"ns",
|
||||
"https://api.example.com",
|
||||
Some(HttpAuthScheme::Bearer),
|
||||
);
|
||||
assert_eq!(cfg.namespace, "ns");
|
||||
assert_eq!(cfg.base_url, "https://api.example.com");
|
||||
assert!(matches!(cfg.auth, Some(HttpAuthScheme::Bearer)));
|
||||
@@ -1289,7 +1293,12 @@ mod tests {
|
||||
}"#;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
assert!(spec.components.is_some());
|
||||
assert!(spec.components.as_ref().unwrap().schemas.contains_key("Foo"));
|
||||
assert!(spec
|
||||
.components
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.schemas
|
||||
.contains_key("Foo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1342,7 +1351,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn resolve_ref_missing_target_returns_schema_parse() {
|
||||
let spec = OpenAPISpec::from_json(minimal_spec_json()).unwrap();
|
||||
let err = spec.resolve_ref("#/components/schemas/Missing").unwrap_err();
|
||||
let err = spec
|
||||
.resolve_ref("#/components/schemas/Missing")
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, AdapterError::SchemaParse { .. }));
|
||||
}
|
||||
|
||||
@@ -1409,7 +1420,8 @@ mod tests {
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
|
||||
let response =
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
|
||||
sock.write_all(response.as_bytes()).await.unwrap();
|
||||
sock.flush().await.unwrap();
|
||||
});
|
||||
@@ -1440,12 +1452,19 @@ mod tests {
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(response.result.is_ok(), "expected Ok, got {:?}", response.result);
|
||||
assert!(
|
||||
response.result.is_ok(),
|
||||
"expected Ok, got {:?}",
|
||||
response.result
|
||||
);
|
||||
let captured = rx.await.unwrap();
|
||||
assert_eq!(captured.method, "POST");
|
||||
assert_eq!(captured.path, "/items/42");
|
||||
assert_eq!(captured.query, "filter=new");
|
||||
assert_eq!(captured.headers.get("content-type").unwrap(), "application/json");
|
||||
assert_eq!(
|
||||
captured.headers.get("content-type").unwrap(),
|
||||
"application/json"
|
||||
);
|
||||
assert!(captured.body.contains("\"name\":\"widget\""));
|
||||
}
|
||||
|
||||
@@ -1457,19 +1476,19 @@ mod tests {
|
||||
}"#;
|
||||
let (base, rx) = spawn_capturing_server().await;
|
||||
let spec = OpenAPISpec::from_json(doc).unwrap();
|
||||
let bundles = adapter(
|
||||
spec,
|
||||
config("openai", &base, Some(HttpAuthScheme::Bearer)),
|
||||
)
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
let bundles = adapter(spec, config("openai", &base, Some(HttpAuthScheme::Bearer)))
|
||||
.import()
|
||||
.await
|
||||
.unwrap();
|
||||
let registration = &bundles[0];
|
||||
let caps = Capabilities::new().with_http_token("openai", "sk-test-token".to_string());
|
||||
let ctx = noop_context("req-17", caps);
|
||||
let _ = (registration.handler)(serde_json::json!({}), ctx).await;
|
||||
let captured = rx.await.unwrap();
|
||||
assert_eq!(captured.headers.get("authorization").unwrap(), "Bearer sk-test-token");
|
||||
assert_eq!(
|
||||
captured.headers.get("authorization").unwrap(),
|
||||
"Bearer sk-test-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1527,4 +1546,4 @@ mod tests {
|
||||
other => panic!("expected HTTP_500, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ use rmcp::model::{
|
||||
};
|
||||
use rmcp::service::{RequestContext, RoleServer};
|
||||
use rmcp::transport::{
|
||||
StreamableHttpServerConfig,
|
||||
streamable_http_server::{session::local::LocalSessionManager, tower::StreamableHttpService},
|
||||
StreamableHttpServerConfig,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -133,7 +133,10 @@ impl ToMcpGateway {
|
||||
|
||||
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)
|
||||
parts
|
||||
.extensions
|
||||
.get::<Option<Identity>>()
|
||||
.and_then(Option::clone)
|
||||
}
|
||||
|
||||
async fn handle_search(&self, identity: Option<Identity>) -> CallToolResult {
|
||||
@@ -144,8 +147,15 @@ impl ToMcpGateway {
|
||||
map_search_response(response, identity.as_ref())
|
||||
}
|
||||
|
||||
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)) {
|
||||
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!({
|
||||
@@ -156,12 +166,20 @@ impl ToMcpGateway {
|
||||
};
|
||||
let response = self
|
||||
.dispatch
|
||||
.invoke(identity, OP_SERVICES_SCHEMA, serde_json::json!({ "name": name }))
|
||||
.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 {
|
||||
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,
|
||||
@@ -170,7 +188,11 @@ impl ToMcpGateway {
|
||||
envelope_to_call_tool_result(response)
|
||||
}
|
||||
|
||||
async fn handle_batch(&self, arguments: Option<JsonObject>, identity: Option<Identity>) -> CallToolResult {
|
||||
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())
|
||||
@@ -193,7 +215,10 @@ impl ToMcpGateway {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let response = self.dispatch.invoke(identity.clone(), &operation, input).await;
|
||||
let response = self
|
||||
.dispatch
|
||||
.invoke(identity.clone(), &operation, input)
|
||||
.await;
|
||||
results.push(envelope_to_value(response));
|
||||
}
|
||||
CallToolResult::structured(Value::Array(results))
|
||||
@@ -210,7 +235,10 @@ fn parse_call_arguments(arguments: Option<JsonObject>) -> Result<(String, Value)
|
||||
})));
|
||||
}
|
||||
};
|
||||
let operation = match map.remove("operation").and_then(|v| v.as_str().map(str::to_string)) {
|
||||
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!({
|
||||
@@ -359,7 +387,11 @@ impl rmcp::handler::server::ServerHandler for ToMcpGateway {
|
||||
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);
|
||||
let err = CallError::new(
|
||||
"NOT_FOUND",
|
||||
format!("unknown gateway tool: {unknown}"),
|
||||
false,
|
||||
);
|
||||
call_error_to_structured_error(err)
|
||||
}
|
||||
};
|
||||
@@ -368,9 +400,7 @@ impl rmcp::handler::server::ServerHandler for ToMcpGateway {
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
let capabilities = ServerCapabilities::builder()
|
||||
.enable_tools()
|
||||
.build();
|
||||
let capabilities = ServerCapabilities::builder().enable_tools().build();
|
||||
ServerInfo::new(capabilities)
|
||||
.with_server_info(Implementation::new(
|
||||
"alknet-to-mcp",
|
||||
@@ -462,10 +492,14 @@ mod tests {
|
||||
}
|
||||
|
||||
fn make_echo_handler() -> alknet_call::registry::registration::Handler {
|
||||
make_handler(|input, context| async move { ResponseEnvelope::ok(context.request_id, input) })
|
||||
make_handler(
|
||||
|input, context| async move { ResponseEnvelope::ok(context.request_id, input) },
|
||||
)
|
||||
}
|
||||
|
||||
fn full_registry_with_ops(specs: Vec<(String, OperationType, AccessControl)>) -> Arc<OperationRegistry> {
|
||||
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(
|
||||
@@ -509,7 +543,10 @@ mod tests {
|
||||
Arc::new(dispatch_registry)
|
||||
}
|
||||
|
||||
fn dispatch(registry: Arc<OperationRegistry>, provider: Arc<dyn IdentityProvider>) -> Arc<GatewayDispatch> {
|
||||
fn dispatch(
|
||||
registry: Arc<OperationRegistry>,
|
||||
provider: Arc<dyn IdentityProvider>,
|
||||
) -> Arc<GatewayDispatch> {
|
||||
Arc::new(GatewayDispatch::new(registry, provider))
|
||||
}
|
||||
|
||||
@@ -542,7 +579,11 @@ mod tests {
|
||||
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);
|
||||
let err = CallError::new(
|
||||
"NOT_FOUND",
|
||||
format!("unknown gateway tool: {unknown}"),
|
||||
false,
|
||||
);
|
||||
call_error_to_structured_error(err)
|
||||
}
|
||||
}
|
||||
@@ -550,10 +591,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_tools_returns_exactly_four_gateway_tools() {
|
||||
let _gateway = ToMcpGateway::new(dispatch(
|
||||
full_registry_with_ops(vec![]),
|
||||
provider(),
|
||||
));
|
||||
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);
|
||||
@@ -583,7 +621,11 @@ mod tests {
|
||||
#[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()),
|
||||
(
|
||||
"public/echo".to_string(),
|
||||
OperationType::Query,
|
||||
AccessControl::default(),
|
||||
),
|
||||
(
|
||||
"admin/secret".to_string(),
|
||||
OperationType::Query,
|
||||
@@ -592,13 +634,22 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
("events/stream".to_string(), OperationType::Subscription, AccessControl::default()),
|
||||
(
|
||||
"events/stream".to_string(),
|
||||
OperationType::Subscription,
|
||||
AccessControl::default(),
|
||||
),
|
||||
]);
|
||||
let idp: Arc<dyn 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;
|
||||
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
|
||||
@@ -610,11 +661,23 @@ mod tests {
|
||||
.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");
|
||||
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");
|
||||
assert!(
|
||||
op.get("description").is_some(),
|
||||
"each entry has a description"
|
||||
);
|
||||
assert!(
|
||||
op.get("input_schema").is_none(),
|
||||
"search must not return full schemas"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,7 +695,10 @@ mod tests {
|
||||
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_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());
|
||||
@@ -649,7 +715,10 @@ mod tests {
|
||||
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(
|
||||
"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));
|
||||
@@ -665,12 +734,18 @@ mod tests {
|
||||
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(
|
||||
"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())));
|
||||
assert_eq!(
|
||||
structured.get("code"),
|
||||
Some(&Value::String("NOT_FOUND".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -713,12 +788,18 @@ mod tests {
|
||||
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(
|
||||
"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())));
|
||||
assert_eq!(
|
||||
structured.get("code"),
|
||||
Some(&Value::String("FORBIDDEN".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -727,7 +808,10 @@ mod tests {
|
||||
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())));
|
||||
assert_eq!(
|
||||
structured.get("code"),
|
||||
Some(&Value::String("NOT_FOUND".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -749,10 +833,16 @@ mod tests {
|
||||
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()));
|
||||
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(
|
||||
"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));
|
||||
@@ -779,7 +869,10 @@ mod tests {
|
||||
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.id.clone()),
|
||||
Some("caller".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extracted.as_ref().map(|i| i.scopes.clone()),
|
||||
Some(vec!["read".to_string()])
|
||||
@@ -834,12 +927,18 @@ mod tests {
|
||||
);
|
||||
|
||||
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" }));
|
||||
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" }))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user