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
+15
View File
@@ -5,10 +5,25 @@ license.workspace = true
rust-version.workspace = true
version.workspace = true
[features]
test-support = []
[dependencies]
base64.workspace = true
mcpaas-core = { path = "../mcpaas-core" }
mcpaas-proto = { path = "../mcpaas-proto" }
prost.workspace = true
prost-reflect.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tonic.workspace = true
tonic-prost.workspace = true
tokio.workspace = true
[dev-dependencies]
prost-types.workspace = true
[build-dependencies]
protoc-bin-vendored.workspace = true
tonic-prost-build.workspace = true
+17
View File
@@ -0,0 +1,17 @@
use std::{env, path::PathBuf};
fn main() {
let protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc must exist");
unsafe {
env::set_var("PROTOC", protoc);
}
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR must exist"));
let descriptor_path = out_dir.join("echo_descriptor.bin");
tonic_prost_build::configure()
.build_server(true)
.build_client(true)
.file_descriptor_set_path(descriptor_path)
.compile_protos(&["proto/echo.proto"], &["proto"])
.expect("echo proto must compile");
}
@@ -0,0 +1,15 @@
syntax = "proto3";
package echo;
service EchoService {
rpc UnaryEcho(EchoRequest) returns (EchoResponse);
}
message EchoRequest {
string message = 1;
}
message EchoResponse {
string message = 1;
}
+168
View File
@@ -0,0 +1,168 @@
use std::{collections::BTreeMap, str::FromStr, time::Duration};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use mcpaas_core::GrpcTarget;
use prost::Message;
use prost_reflect::{DescriptorPool, MethodDescriptor, prost_types::FileDescriptorSet};
use tonic::{
Request,
client::Grpc,
metadata::{KeyAndValueRef, MetadataKey, MetadataValue},
transport::Endpoint,
};
use crate::{GrpcAdapterError, GrpcRequest, GrpcResponse, codec::JsonCodec};
#[derive(Clone, Debug, Default)]
pub struct GrpcAdapter;
impl GrpcAdapter {
pub fn new() -> Self {
Self
}
pub async fn execute(
&self,
target: &GrpcTarget,
request: &GrpcRequest,
) -> Result<GrpcResponse, GrpcAdapterError> {
let method = resolve_method(target)?;
if method.is_client_streaming() || method.is_server_streaming() {
return Err(GrpcAdapterError::UnsupportedMethodKind {
service: format!("{}.{}", target.package, target.service),
method: target.method.clone(),
});
}
let endpoint = Endpoint::from_shared(target.server_addr.clone())?
.timeout(Duration::from_millis(request.timeout_ms));
let channel = endpoint.connect().await?;
let mut grpc = Grpc::new(channel);
grpc.ready()
.await
.map_err(|error| GrpcAdapterError::Status {
code: tonic::Code::Unavailable,
message: error.to_string(),
})?;
let service_name = method.parent_service().full_name().to_owned();
let method_name = method.name().to_owned();
let path = tonic::codegen::http::uri::PathAndQuery::from_str(&format!(
"/{service_name}/{method_name}"
))
.map_err(|_| GrpcAdapterError::InvalidMethodPath {
service: service_name,
method: method_name,
})?;
let codec = JsonCodec::new(method.input(), method.output());
let mut tonic_request = Request::new(request.body.clone());
apply_headers(&mut tonic_request, &request.headers)?;
let response = grpc.unary(tonic_request, path, codec).await?;
let headers = normalize_headers(response.metadata());
let body = response.into_inner();
Ok(GrpcResponse {
status_code: 200,
headers,
body,
})
}
}
fn resolve_method(target: &GrpcTarget) -> Result<MethodDescriptor, GrpcAdapterError> {
let bytes = STANDARD
.decode(&target.descriptor_set_b64)
.map_err(|_| GrpcAdapterError::InvalidDescriptorEncoding)?;
let descriptor_set = FileDescriptorSet::decode(bytes.as_slice())
.map_err(|_| GrpcAdapterError::InvalidDescriptorSet)?;
let pool = DescriptorPool::from_file_descriptor_set(descriptor_set)
.map_err(|_| GrpcAdapterError::InvalidDescriptorPool)?;
let full_service_name = if target.package.is_empty() {
target.service.clone()
} else {
format!("{}.{}", target.package, target.service)
};
let service = pool
.get_service_by_name(&full_service_name)
.ok_or_else(|| GrpcAdapterError::ServiceNotFound {
service: full_service_name.clone(),
})?;
service
.methods()
.find(|method| method.name() == target.method)
.ok_or_else(|| GrpcAdapterError::MethodNotFound {
service: full_service_name,
method: target.method.clone(),
})
}
fn apply_headers(
request: &mut Request<serde_json::Value>,
headers: &BTreeMap<String, String>,
) -> Result<(), GrpcAdapterError> {
for (key, value) in headers {
let metadata_key =
MetadataKey::from_str(key).map_err(|source| GrpcAdapterError::InvalidMetadataKey {
key: key.clone(),
source,
})?;
let metadata_value = MetadataValue::from_str(value).map_err(|source| {
GrpcAdapterError::InvalidMetadataValue {
key: key.clone(),
source,
}
})?;
request.metadata_mut().insert(metadata_key, metadata_value);
}
Ok(())
}
fn normalize_headers(metadata: &tonic::metadata::MetadataMap) -> BTreeMap<String, String> {
metadata
.iter()
.filter_map(|entry| match entry {
KeyAndValueRef::Ascii(key, value) => {
Some((key.as_str().to_owned(), value.to_str().ok()?.to_owned()))
}
KeyAndValueRef::Binary(_, _) => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use mcpaas_core::{DescriptorId, GrpcTarget};
use serde_json::json;
use crate::{GrpcAdapter, GrpcRequest, test_support};
#[tokio::test]
async fn executes_unary_grpc_request() {
let server_addr = test_support::spawn_unary_echo_server().await;
let adapter = GrpcAdapter::new();
let target = GrpcTarget {
server_addr,
package: "echo".to_owned(),
service: "EchoService".to_owned(),
method: "UnaryEcho".to_owned(),
descriptor_ref: DescriptorId::new("desc_echo"),
descriptor_set_b64: test_support::descriptor_set_b64(),
};
let request = GrpcRequest {
headers: BTreeMap::new(),
body: json!({ "message": "hello" }),
timeout_ms: 1_000,
};
let response = adapter.execute(&target, &request).await.unwrap();
assert_eq!(response.body, json!({ "message": "hello" }));
}
}
+72
View File
@@ -0,0 +1,72 @@
use prost::Message;
use prost_reflect::{DynamicMessage, MessageDescriptor};
use tonic::{
Status,
codec::{Codec, DecodeBuf, Decoder, EncodeBuf, Encoder},
};
pub struct JsonCodec {
request_descriptor: MessageDescriptor,
response_descriptor: MessageDescriptor,
}
impl JsonCodec {
pub fn new(
request_descriptor: MessageDescriptor,
response_descriptor: MessageDescriptor,
) -> Self {
Self {
request_descriptor,
response_descriptor,
}
}
}
impl Codec for JsonCodec {
type Encode = serde_json::Value;
type Decode = serde_json::Value;
type Encoder = JsonEncoder;
type Decoder = JsonDecoder;
fn encoder(&mut self) -> Self::Encoder {
JsonEncoder(self.request_descriptor.clone())
}
fn decoder(&mut self) -> Self::Decoder {
JsonDecoder(self.response_descriptor.clone())
}
}
pub struct JsonEncoder(MessageDescriptor);
impl Encoder for JsonEncoder {
type Item = serde_json::Value;
type Error = Status;
fn encode(&mut self, item: Self::Item, dst: &mut EncodeBuf<'_>) -> Result<(), Self::Error> {
let message = DynamicMessage::deserialize(self.0.clone(), item)
.map_err(|error| Status::invalid_argument(error.to_string()))?;
message.encode_raw(dst);
Ok(())
}
}
pub struct JsonDecoder(MessageDescriptor);
impl Decoder for JsonDecoder {
type Item = serde_json::Value;
type Error = Status;
fn decode(&mut self, src: &mut DecodeBuf<'_>) -> Result<Option<Self::Item>, Self::Error> {
let mut message = DynamicMessage::new(self.0.clone());
message
.merge(src)
.map_err(|error| Status::internal(error.to_string()))?;
serde_json::to_value(&message)
.map(Some)
.map_err(|error| Status::internal(error.to_string()))
}
}
+48
View File
@@ -0,0 +1,48 @@
use thiserror::Error;
use tonic::{
Code, Status,
metadata::errors::{InvalidMetadataKey, InvalidMetadataValue},
};
#[derive(Debug, Error)]
pub enum GrpcAdapterError {
#[error("invalid descriptor set encoding")]
InvalidDescriptorEncoding,
#[error("invalid descriptor set")]
InvalidDescriptorSet,
#[error("invalid descriptor pool")]
InvalidDescriptorPool,
#[error("service {service} was not found in descriptor set")]
ServiceNotFound { service: String },
#[error("method {method} was not found in service {service}")]
MethodNotFound { service: String, method: String },
#[error("grpc method path is invalid for service {service} and method {method}")]
InvalidMethodPath { service: String, method: String },
#[error("grpc method {service}/{method} is not unary")]
UnsupportedMethodKind { service: String, method: String },
#[error("invalid metadata key {key}")]
InvalidMetadataKey {
key: String,
#[source]
source: InvalidMetadataKey,
},
#[error("invalid metadata value for key {key}")]
InvalidMetadataValue {
key: String,
#[source]
source: InvalidMetadataValue,
},
#[error("transport endpoint is invalid")]
InvalidEndpoint(#[from] tonic::transport::Error),
#[error("grpc status {code}: {message}")]
Status { code: Code, message: String },
}
impl From<Status> for GrpcAdapterError {
fn from(value: Status) -> Self {
Self::Status {
code: value.code(),
message: value.message().to_owned(),
}
}
}
+11 -3
View File
@@ -1,3 +1,11 @@
pub fn crate_name() -> &'static str {
"mcpaas-adapter-grpc"
}
mod client;
mod codec;
mod error;
mod model;
pub use client::GrpcAdapter;
pub use error::GrpcAdapterError;
pub use model::{GrpcRequest, GrpcResponse};
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
+20
View File
@@ -0,0 +1,20 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct GrpcRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub body: Value,
pub timeout_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GrpcResponse {
pub status_code: u16,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub body: Value,
}
@@ -0,0 +1,45 @@
use base64::{Engine as _, engine::general_purpose::STANDARD};
use tokio::net::TcpListener;
use tonic::{Request, Response, Status, transport::Server};
pub mod echo {
tonic::include_proto!("echo");
pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("echo_descriptor");
}
#[derive(Default)]
pub struct EchoServiceImpl;
#[tonic::async_trait]
impl echo::echo_service_server::EchoService for EchoServiceImpl {
async fn unary_echo(
&self,
request: Request<echo::EchoRequest>,
) -> Result<Response<echo::EchoResponse>, Status> {
Ok(Response::new(echo::EchoResponse {
message: request.into_inner().message,
}))
}
}
pub async fn spawn_unary_echo_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let incoming = tonic::transport::server::TcpIncoming::from(listener);
tokio::spawn(async move {
Server::builder()
.add_service(echo::echo_service_server::EchoServiceServer::new(
EchoServiceImpl,
))
.serve_with_incoming(incoming)
.await
.unwrap();
});
format!("http://{}", address)
}
pub fn descriptor_set_b64() -> String {
STANDARD.encode(echo::FILE_DESCRIPTOR_SET)
}
+1
View File
@@ -42,6 +42,7 @@ pub struct GrpcTarget {
pub service: String,
pub method: String,
pub descriptor_ref: DescriptorId,
pub descriptor_set_b64: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+3 -1
View File
@@ -8,6 +8,8 @@ version.workspace = true
[dependencies]
mcpaas-core = { path = "../mcpaas-core" }
mcpaas-schema = { path = "../mcpaas-schema" }
prost.workspace = true
prost-reflect.workspace = true
prost-types.workspace = true
serde.workspace = true
thiserror.workspace = true
+4
View File
@@ -4,4 +4,8 @@ use thiserror::Error;
pub enum ProtoError {
#[error("oneof {oneof_name} must contain at least one variant")]
EmptyOneof { oneof_name: String },
#[error("descriptor set could not be decoded")]
InvalidDescriptorSet,
#[error("descriptor pool could not be built")]
InvalidDescriptorPool,
}
+132
View File
@@ -2,9 +2,141 @@ pub mod convert;
pub mod errors;
pub mod model;
use prost::Message;
use prost_reflect::{
Cardinality, DescriptorPool, EnumDescriptor, FieldDescriptor, Kind, MessageDescriptor,
MethodDescriptor, ServiceDescriptor,
};
use prost_types::FileDescriptorSet;
pub use convert::to_schema::message_to_schema;
pub use errors::ProtoError;
pub use model::{
ProtoEnum, ProtoField, ProtoFieldCardinality, ProtoFieldType, ProtoMapField, ProtoMessage,
ProtoMethod, ProtoOneof, ProtoScalarKind, ProtoService,
};
pub fn services_from_descriptor_set_bytes(bytes: &[u8]) -> Result<Vec<ProtoService>, ProtoError> {
let descriptor_set =
FileDescriptorSet::decode(bytes).map_err(|_| ProtoError::InvalidDescriptorSet)?;
let pool = DescriptorPool::from_file_descriptor_set(descriptor_set)
.map_err(|_| ProtoError::InvalidDescriptorPool)?;
Ok(pool.services().map(service_from_descriptor).collect())
}
fn service_from_descriptor(service: ServiceDescriptor) -> ProtoService {
ProtoService {
package: service.parent_file().package_name().to_owned(),
name: service.name().to_owned(),
methods: service.methods().map(method_from_descriptor).collect(),
}
}
fn method_from_descriptor(method: MethodDescriptor) -> ProtoMethod {
ProtoMethod {
name: method.name().to_owned(),
input: message_from_descriptor(method.input()),
output: message_from_descriptor(method.output()),
client_streaming: method.is_client_streaming(),
server_streaming: method.is_server_streaming(),
}
}
fn message_from_descriptor(message: MessageDescriptor) -> ProtoMessage {
let fields = message
.fields()
.filter(|field| field.containing_oneof().is_none())
.map(field_from_descriptor)
.collect();
let oneofs = message
.oneofs()
.map(|oneof| ProtoOneof {
name: oneof.name().to_owned(),
variants: oneof.fields().map(field_from_descriptor).collect(),
})
.collect();
ProtoMessage {
name: message.name().to_owned(),
fields,
oneofs,
}
}
fn field_from_descriptor(field: FieldDescriptor) -> ProtoField {
let cardinality = if field.is_list() && !field.is_map() {
ProtoFieldCardinality::Repeated
} else {
match field.cardinality() {
Cardinality::Optional => ProtoFieldCardinality::Optional,
Cardinality::Required => ProtoFieldCardinality::Required,
Cardinality::Repeated => ProtoFieldCardinality::Repeated,
}
};
ProtoField {
name: field.name().to_owned(),
json_name: field.json_name().to_owned(),
cardinality,
field_type: field_type_from_descriptor(field),
}
}
fn field_type_from_descriptor(field: FieldDescriptor) -> ProtoFieldType {
if field.is_map() {
return map_field_from_descriptor(field);
}
match field.kind() {
Kind::Bool => scalar_field(ProtoScalarKind::Bool),
Kind::String => scalar_field(ProtoScalarKind::String),
Kind::Bytes => scalar_field(ProtoScalarKind::Bytes),
Kind::Int32 | Kind::Sint32 | Kind::Sfixed32 => scalar_field(ProtoScalarKind::Int32),
Kind::Int64 | Kind::Sint64 | Kind::Sfixed64 => scalar_field(ProtoScalarKind::Int64),
Kind::Uint32 | Kind::Fixed32 => scalar_field(ProtoScalarKind::Uint32),
Kind::Uint64 | Kind::Fixed64 => scalar_field(ProtoScalarKind::Uint64),
Kind::Float => scalar_field(ProtoScalarKind::Float),
Kind::Double => scalar_field(ProtoScalarKind::Double),
Kind::Message(message) => ProtoFieldType::Message {
message: Box::new(message_from_descriptor(message)),
},
Kind::Enum(enumeration) => ProtoFieldType::Enum {
enumeration: enum_from_descriptor(enumeration),
},
}
}
fn map_field_from_descriptor(field: FieldDescriptor) -> ProtoFieldType {
let message = match field.kind() {
Kind::Message(message) => message,
_ => unreachable!(),
};
let key_field = message.map_entry_key_field();
let value_field = message.map_entry_value_field();
let key = match field_type_from_descriptor(key_field) {
ProtoFieldType::Scalar { scalar } => scalar,
_ => ProtoScalarKind::String,
};
ProtoFieldType::Map {
map: ProtoMapField {
key,
value: Box::new(field_type_from_descriptor(value_field)),
},
}
}
fn scalar_field(scalar: ProtoScalarKind) -> ProtoFieldType {
ProtoFieldType::Scalar { scalar }
}
fn enum_from_descriptor(enumeration: EnumDescriptor) -> ProtoEnum {
ProtoEnum {
name: enumeration.name().to_owned(),
values: enumeration
.values()
.map(|value| value.name().to_owned())
.collect(),
}
}
+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>,