feat: add grpc support

This commit is contained in:
a.tolmachev
2026-03-25 21:23:57 +03:00
parent ada2436e54
commit 8005510ad2
33 changed files with 1725 additions and 32 deletions
+1
View File
@@ -18,4 +18,5 @@ thiserror.workspace = true
[dev-dependencies]
axum.workspace = true
mcpaas-adapter-grpc = { path = "../mcpaas-adapter-grpc", features = ["test-support"] }
tokio.workspace = true
+3
View File
@@ -1,4 +1,5 @@
use mcpaas_adapter_graphql::GraphqlAdapterError;
use mcpaas_adapter_grpc::GrpcAdapterError;
use mcpaas_adapter_rest::RestAdapterError;
use mcpaas_core::Protocol;
use mcpaas_mapping::MappingError;
@@ -14,6 +15,8 @@ pub enum RuntimeError {
#[error(transparent)]
GraphqlAdapter(#[from] GraphqlAdapterError),
#[error(transparent)]
GrpcAdapter(#[from] GrpcAdapterError),
#[error(transparent)]
RestAdapter(#[from] RestAdapterError),
#[error("protocol {protocol:?} is not supported by runtime")]
UnsupportedProtocol { protocol: Protocol },
+117 -8
View File
@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use mcpaas_adapter_graphql::{GraphqlAdapter, GraphqlRequest};
use mcpaas_adapter_grpc::{GrpcAdapter, GrpcRequest};
use mcpaas_adapter_rest::{RestAdapter, RestRequest};
use mcpaas_core::Target;
use serde_json::{Map, Value, json};
@@ -10,6 +11,7 @@ use crate::{AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation};
#[derive(Clone, Debug)]
pub struct RuntimeExecutor {
graphql_adapter: GraphqlAdapter,
grpc_adapter: GrpcAdapter,
rest_adapter: RestAdapter,
}
@@ -23,6 +25,7 @@ impl RuntimeExecutor {
pub fn new() -> Self {
Self {
graphql_adapter: GraphqlAdapter::new(),
grpc_adapter: GrpcAdapter::new(),
rest_adapter: RestAdapter::new(),
}
}
@@ -38,6 +41,28 @@ impl RuntimeExecutor {
let prepared_request = PreparedRequest::from_mapping_output(&mapped_input)?;
let adapter_response = match &operation.target {
Target::Grpc(target) => {
let request = GrpcRequest {
headers: merge_headers(
&BTreeMap::new(),
&operation.execution_config.headers,
&prepared_request.headers,
),
body: prepared_request
.grpc
.clone()
.unwrap_or(Value::Object(Map::new())),
timeout_ms: operation.execution_config.timeout_ms,
};
let response = self.grpc_adapter.execute(target, &request).await?;
AdapterResponse {
status_code: response.status_code,
headers: response.headers,
body: response.body.clone(),
data: response.body,
}
}
Target::Graphql(target) => {
let request = GraphqlRequest {
headers: merge_headers(
@@ -78,11 +103,6 @@ impl RuntimeExecutor {
data: Value::Null,
}
}
_ => {
return Err(RuntimeError::UnsupportedProtocol {
protocol: operation.protocol,
});
}
};
let finalized_output = finalize_output(operation, &adapter_response)?;
@@ -106,6 +126,7 @@ impl PreparedRequest {
path_params: read_string_map(request.get("path"), "request.path")?,
query_params: read_string_map(request.get("query"), "request.query")?,
headers: read_string_map(request.get("headers"), "request.headers")?,
grpc: non_empty_payload(request.get("grpc").cloned()),
variables: non_empty_payload(request.get("variables").cloned()),
body: request.get("body").and_then(non_empty_body).cloned(),
})
@@ -196,10 +217,11 @@ mod tests {
use std::collections::BTreeMap;
use axum::{Json, Router, routing::post};
use mcpaas_adapter_grpc::test_support as grpc_test_support;
use mcpaas_core::{
ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget,
HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget, Samples, Target,
ToolDescription, ToolExample,
DescriptorId, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlOperationType,
GraphqlTarget, GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, Protocol,
RestTarget, Samples, Target, ToolDescription, ToolExample,
};
use mcpaas_mapping::{MappingRule, MappingSet};
use mcpaas_schema::{Schema, SchemaKind};
@@ -236,6 +258,20 @@ mod tests {
assert_eq!(output, json!({ "id": "lead_123" }));
}
#[tokio::test]
async fn executes_grpc_operation_end_to_end() {
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
let executor = RuntimeExecutor::new();
let operation = test_grpc_operation(&server_addr);
let output = executor
.execute(&operation, &json!({ "message": "hello" }))
.await
.unwrap();
assert_eq!(output, json!({ "message": "hello" }));
}
#[tokio::test]
async fn rejects_invalid_input_shape() {
let base_url = spawn_runtime_server().await;
@@ -523,6 +559,79 @@ mod tests {
})
}
fn test_grpc_operation(server_addr: &str) -> RuntimeOperation {
RuntimeOperation::from(Operation {
id: OperationId::new("op_grpc_runtime"),
name: "echo_unary_grpc".to_owned(),
display_name: "Unary Echo gRPC".to_owned(),
protocol: Protocol::Grpc,
status: OperationStatus::Published,
version: 1,
target: Target::Grpc(GrpcTarget {
server_addr: server_addr.to_owned(),
package: "echo".to_owned(),
service: "EchoService".to_owned(),
method: "UnaryEcho".to_owned(),
descriptor_ref: DescriptorId::new("desc_echo"),
descriptor_set_b64: grpc_test_support::descriptor_set_b64(),
}),
input_schema: object_schema("message", SchemaKind::String),
output_schema: object_schema("message", SchemaKind::String),
input_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.mcp.message".to_owned(),
target: "$.request.grpc.message".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
}],
},
output_mapping: MappingSet {
rules: vec![MappingRule {
source: "$.response.data.message".to_owned(),
target: "$.output.message".to_owned(),
required: true,
default_value: None,
transform: None,
condition: None,
notes: None,
}],
},
execution_config: ExecutionConfig {
timeout_ms: 1_000,
retry_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
protocol_options: None,
},
tool_description: ToolDescription {
title: "Unary Echo gRPC".to_owned(),
description: "Echoes a unary gRPC payload".to_owned(),
tags: vec!["grpc".to_owned()],
examples: vec![ToolExample {
input: json!({ "message": "hello" }),
}],
},
samples: Some(Samples::default()),
generated_draft: Some(GeneratedDraft {
status: GeneratedDraftStatus::Available,
source_types: vec!["descriptor_set".to_owned()],
generated_at: Some("2026-03-25T20:00:00Z".to_owned()),
input_schema_generated: true,
output_schema_generated: true,
input_mapping_generated: true,
output_mapping_generated: true,
warnings: Vec::new(),
}),
config_export: None,
created_at: "2026-03-25T20:00:00Z".to_owned(),
updated_at: "2026-03-25T20:00:00Z".to_owned(),
published_at: Some("2026-03-25T20:00:00Z".to_owned()),
})
}
fn object_schema(field_name: &str, kind: SchemaKind) -> Schema {
Schema {
kind: SchemaKind::Object,
+2
View File
@@ -46,6 +46,8 @@ pub struct PreparedRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grpc: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variables: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,