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
+170 -4
View File
@@ -8,8 +8,9 @@ use crate::{
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
operations::{
create_operation, create_version, export_operation, generate_draft, get_operation,
get_operation_version, list_operations, publish_operation, run_test, upload_input_json,
upload_output_json,
get_operation_version, list_grpc_services, list_operations, publish_operation,
run_test, upload_descriptor_set, upload_input_json, upload_output_json,
upload_proto_descriptor,
},
},
state::AppState,
@@ -38,6 +39,18 @@ pub fn build_app(state: AppState) -> Router {
"/operations/{operation_id}/samples/output-json",
post(upload_output_json),
)
.route(
"/operations/{operation_id}/descriptors/proto",
post(upload_proto_descriptor),
)
.route(
"/operations/{operation_id}/descriptors/descriptor-set",
post(upload_descriptor_set),
)
.route(
"/operations/{operation_id}/grpc/services",
get(list_grpc_services),
)
.route(
"/operations/{operation_id}/drafts/generate",
post(generate_draft),
@@ -67,9 +80,10 @@ mod tests {
};
use axum::{Json, Router, routing::post};
use mcpaas_adapter_grpc::test_support as grpc_test_support;
use mcpaas_core::{
ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, Protocol, RestTarget,
Target, ToolDescription,
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
Protocol, RestTarget, Target, ToolDescription,
};
use mcpaas_mapping::{MappingRule, MappingSet};
use mcpaas_registry::PostgresRegistry;
@@ -199,6 +213,105 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123");
}
#[tokio::test]
async fn uploads_descriptor_set_and_lists_grpc_services() {
let registry = test_registry().await;
let storage_root = test_storage_root("grpc_descriptor");
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let created = client
.post(format!("{base_url}/operations"))
.json(&test_grpc_operation_payload(
&server_addr,
"echo_descriptor",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let uploaded = client
.post(format!(
"{base_url}/operations/{operation_id}/descriptors/descriptor-set"
))
.header("x-file-name", "echo_descriptor.bin")
.body(grpc_test_support::echo::FILE_DESCRIPTOR_SET.to_vec())
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let services = client
.get(format!(
"{base_url}/operations/{operation_id}/grpc/services"
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert!(uploaded["descriptor_id"].as_str().is_some());
assert_eq!(services["services"][0]["package"], "echo");
assert_eq!(services["services"][0]["service"], "EchoService");
assert_eq!(services["services"][0]["methods"][0]["name"], "UnaryEcho");
assert_eq!(services["services"][0]["methods"][0]["kind"], "unary");
}
#[tokio::test]
async fn creates_publishes_and_tests_grpc_operation() {
let registry = test_registry().await;
let storage_root = test_storage_root("grpc_runtime");
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let created = client
.post(format!("{base_url}/operations"))
.json(&test_grpc_operation_payload(&server_addr, "echo_runtime"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let published = client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let test_run = client
.post(format!("{base_url}/operations/{operation_id}/test-runs"))
.json(&json!({
"version": 1,
"input": { "message": "hello" }
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(published["published_version"], 1);
assert_eq!(test_run["ok"], true);
assert_eq!(test_run["request_preview"]["grpc"]["message"], "hello");
assert_eq!(test_run["response_preview"]["message"], "hello");
}
#[tokio::test]
async fn manages_auth_profiles_and_yaml_upsert() {
let registry = test_registry().await;
@@ -536,6 +649,59 @@ mod tests {
}
}
fn test_grpc_operation_payload(server_addr: &str, name: &str) -> OperationPayload {
OperationPayload {
name: name.to_owned(),
display_name: "Unary Echo gRPC".to_owned(),
protocol: Protocol::Grpc,
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"),
output_schema: object_schema("message"),
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::new(),
},
}
}
fn object_schema(field_name: &str) -> Schema {
Schema {
kind: SchemaKind::Object,
+58
View File
@@ -1,5 +1,6 @@
use axum::{
Json,
body::Bytes,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::IntoResponse,
@@ -128,6 +129,56 @@ pub async fn upload_output_json(
})))
}
pub async fn upload_proto_descriptor(
Path(operation_id): Path<String>,
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, ApiError> {
let source_name = header_file_name(&headers);
let descriptor = state
.service
.upload_proto_file(
&operation_id.as_str().into(),
source_name.as_deref(),
body.as_ref(),
)
.await?;
Ok(Json(json!(descriptor)))
}
pub async fn upload_descriptor_set(
Path(operation_id): Path<String>,
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Json<Value>, ApiError> {
let source_name = header_file_name(&headers);
let descriptor = state
.service
.upload_descriptor_set(
&operation_id.as_str().into(),
source_name.as_deref(),
body.as_ref(),
)
.await?;
Ok(Json(json!(descriptor)))
}
pub async fn list_grpc_services(
Path(operation_id): Path<String>,
Query(query): Query<ExportQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let services = state
.service
.list_grpc_services(&operation_id.as_str().into(), query.version)
.await?;
Ok(Json(json!({ "services": services })))
}
pub async fn generate_draft(
Path(operation_id): Path<String>,
State(state): State<AppState>,
@@ -166,3 +217,10 @@ pub async fn import_operation(
let imported = state.service.import_operation(query, &body).await?;
Ok(Json(json!(imported)))
}
fn header_file_name(headers: &HeaderMap) -> Option<String> {
headers
.get("x-file-name")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
}
+178 -1
View File
@@ -5,10 +5,11 @@ use mcpaas_core::{
GeneratedDraftStatus, OperationId, OperationStatus, Protocol, SampleId, Samples, Target,
};
use mcpaas_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use mcpaas_proto::{ProtoService, services_from_descriptor_set_bytes};
use mcpaas_registry::{
CreateVersionRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
PostgresRegistry, PublishRequest, RegistryOperation, SampleKind, SaveAuthProfileRequest,
SaveSampleMetadataRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
};
use mcpaas_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use mcpaas_schema::Schema;
@@ -139,6 +140,27 @@ pub struct ImportResponse {
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct DescriptorUploadResponse {
pub descriptor_id: String,
pub version: u32,
}
#[derive(Clone, Debug, Serialize)]
pub struct GrpcServiceSummary {
pub package: String,
pub service: String,
pub methods: Vec<GrpcMethodSummary>,
}
#[derive(Clone, Debug, Serialize)]
pub struct GrpcMethodSummary {
pub name: String,
pub kind: String,
pub input_schema: Schema,
pub output_schema: Schema,
}
impl AdminService {
pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self {
Self {
@@ -345,6 +367,138 @@ impl AdminService {
Ok(self.registry.list_auth_profiles().await?)
}
pub async fn upload_descriptor_set(
&self,
operation_id: &OperationId,
source_name: Option<&str>,
payload: &[u8],
) -> Result<DescriptorUploadResponse, ApiError> {
let summary = self.get_operation(operation_id).await?;
if summary.protocol != Protocol::Grpc {
return Err(ApiError::validation(
"descriptor upload is only allowed for grpc operations",
));
}
let services = services_from_descriptor_set_bytes(payload)
.map_err(|error| ApiError::validation(error.to_string()))?;
let descriptor_id = mcpaas_core::DescriptorId::new(new_prefixed_id("desc"));
let storage_ref = self
.storage
.write_descriptor(
operation_id,
summary.current_draft_version,
mcpaas_registry::DescriptorKind::DescriptorSet,
&descriptor_id,
source_name,
payload,
)
.await?;
self.registry
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
descriptor: &mcpaas_registry::DescriptorMetadata {
id: descriptor_id.clone(),
operation_id: Some(operation_id.clone()),
version: Some(summary.current_draft_version),
descriptor_kind: mcpaas_registry::DescriptorKind::DescriptorSet,
storage_ref,
source_name: source_name.map(ToOwned::to_owned),
package_index: Some(
serde_json::to_value(&services)
.map_err(|error| ApiError::internal(error.to_string()))?,
),
created_at: now_string()?,
},
})
.await?;
Ok(DescriptorUploadResponse {
descriptor_id: descriptor_id.as_str().to_owned(),
version: summary.current_draft_version,
})
}
pub async fn upload_proto_file(
&self,
operation_id: &OperationId,
source_name: Option<&str>,
payload: &[u8],
) -> Result<DescriptorUploadResponse, ApiError> {
let summary = self.get_operation(operation_id).await?;
if summary.protocol != Protocol::Grpc {
return Err(ApiError::validation(
"proto upload is only allowed for grpc operations",
));
}
let descriptor_id = mcpaas_core::DescriptorId::new(new_prefixed_id("desc"));
let storage_ref = self
.storage
.write_descriptor(
operation_id,
summary.current_draft_version,
mcpaas_registry::DescriptorKind::ProtoUpload,
&descriptor_id,
source_name,
payload,
)
.await?;
self.registry
.save_descriptor_metadata(SaveDescriptorMetadataRequest {
descriptor: &mcpaas_registry::DescriptorMetadata {
id: descriptor_id.clone(),
operation_id: Some(operation_id.clone()),
version: Some(summary.current_draft_version),
descriptor_kind: mcpaas_registry::DescriptorKind::ProtoUpload,
storage_ref,
source_name: source_name.map(ToOwned::to_owned),
package_index: None,
created_at: now_string()?,
},
})
.await?;
Ok(DescriptorUploadResponse {
descriptor_id: descriptor_id.as_str().to_owned(),
version: summary.current_draft_version,
})
}
pub async fn list_grpc_services(
&self,
operation_id: &OperationId,
version: Option<u32>,
) -> Result<Vec<GrpcServiceSummary>, ApiError> {
let summary = self.get_operation(operation_id).await?;
if summary.protocol != Protocol::Grpc {
return Err(ApiError::validation(
"grpc services are only available for grpc operations",
));
}
let version = version.unwrap_or(summary.current_draft_version);
let descriptor = self
.registry
.list_descriptor_metadata(operation_id, version)
.await?
.into_iter()
.rev()
.find(|descriptor| {
descriptor.descriptor_kind == mcpaas_registry::DescriptorKind::DescriptorSet
})
.ok_or_else(|| ApiError::not_found("descriptor set was not uploaded"))?;
let bytes = self.storage.read_bytes(&descriptor.storage_ref).await?;
let services = services_from_descriptor_set_bytes(&bytes)
.map_err(|error| ApiError::validation(error.to_string()))?;
services
.into_iter()
.map(proto_service_summary)
.collect::<Result<Vec<_>, _>>()
}
pub async fn get_auth_profile(
&self,
auth_profile_id: &AuthProfileId,
@@ -612,6 +766,7 @@ fn build_request_preview(
"path": prepared.path_params,
"query": prepared.query_params,
"headers": prepared.headers,
"grpc": prepared.grpc.unwrap_or(Value::Null),
"variables": prepared.variables.unwrap_or(Value::Null),
"body": prepared.body.unwrap_or(Value::Null)
}))
@@ -672,3 +827,25 @@ fn now_string() -> Result<String, ApiError> {
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))
}
fn proto_service_summary(service: ProtoService) -> Result<GrpcServiceSummary, ApiError> {
let methods = service
.unary_methods()
.map(|method| {
Ok(GrpcMethodSummary {
name: method.name.clone(),
kind: "unary".to_owned(),
input_schema: mcpaas_proto::message_to_schema(&method.input)
.map_err(|error| ApiError::validation(error.to_string()))?,
output_schema: mcpaas_proto::message_to_schema(&method.output)
.map_err(|error| ApiError::validation(error.to_string()))?,
})
})
.collect::<Result<Vec<_>, ApiError>>()?;
Ok(GrpcServiceSummary {
package: service.package,
service: service.name,
methods,
})
}
+44 -2
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use mcpaas_core::{OperationId, SampleId};
use mcpaas_registry::SampleKind;
use mcpaas_core::{DescriptorId, OperationId, SampleId};
use mcpaas_registry::{DescriptorKind, SampleKind};
use serde_json::Value;
use thiserror::Error;
use tokio::fs;
@@ -57,6 +57,39 @@ impl LocalArtifactStorage {
Ok(serde_json::from_slice(&bytes)?)
}
pub async fn write_descriptor(
&self,
operation_id: &OperationId,
version: u32,
descriptor_kind: DescriptorKind,
descriptor_id: &DescriptorId,
source_name: Option<&str>,
payload: &[u8],
) -> Result<String, StorageError> {
let file_name = match descriptor_kind {
DescriptorKind::ProtoUpload => source_name.unwrap_or("schema.proto").to_owned(),
DescriptorKind::DescriptorSet => source_name.unwrap_or("descriptor-set.bin").to_owned(),
DescriptorKind::ReflectionSnapshot => {
source_name.unwrap_or("reflection.bin").to_owned()
}
};
let path = self
.root
.join("descriptors")
.join(operation_id.as_str())
.join(format!("v{version}"))
.join(format!("{}_{}", descriptor_id.as_str(), file_name));
write_bytes(&path, payload).await?;
Ok(to_storage_ref(&path))
}
pub async fn read_bytes(&self, storage_ref: &str) -> Result<Vec<u8>, StorageError> {
let path = from_storage_ref(storage_ref)?;
Ok(fs::read(path).await?)
}
}
async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageError> {
@@ -69,6 +102,15 @@ async fn write_json_file(path: &Path, payload: &Value) -> Result<(), StorageErro
Ok(())
}
async fn write_bytes(path: &Path, payload: &[u8]) -> Result<(), StorageError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(path, payload).await?;
Ok(())
}
fn to_storage_ref(path: &Path) -> String {
format!("file://{}", path.display())
}