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
Generated
+436
View File
@@ -7,8 +7,10 @@ name = "admin-api"
version = "0.1.0"
dependencies = [
"axum",
"mcpaas-adapter-grpc",
"mcpaas-core",
"mcpaas-mapping",
"mcpaas-proto",
"mcpaas-registry",
"mcpaas-runtime",
"mcpaas-schema",
@@ -46,6 +48,17 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atoi"
version = "2.0.0"
@@ -290,6 +303,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "etcetera"
version = "0.8.0"
@@ -312,12 +335,30 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -443,6 +484,25 @@ dependencies = [
"wasip3",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -563,6 +623,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -592,6 +653,19 @@ dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "hyper-timeout"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper",
"hyper-util",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
@@ -751,6 +825,15 @@ dependencies = [
"serde",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -797,6 +880,12 @@ dependencies = [
"redox_syscall 0.7.3",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -844,6 +933,7 @@ name = "mcp-server"
version = "0.1.0"
dependencies = [
"axum",
"mcpaas-adapter-grpc",
"mcpaas-core",
"mcpaas-mapping",
"mcpaas-registry",
@@ -877,11 +967,20 @@ dependencies = [
name = "mcpaas-adapter-grpc"
version = "0.1.0"
dependencies = [
"base64",
"mcpaas-core",
"mcpaas-proto",
"prost",
"prost-reflect",
"prost-types",
"protoc-bin-vendored",
"serde",
"serde_json",
"thiserror",
"tokio",
"tonic",
"tonic-prost",
"tonic-prost-build",
]
[[package]]
@@ -924,6 +1023,9 @@ version = "0.1.0"
dependencies = [
"mcpaas-core",
"mcpaas-schema",
"prost",
"prost-reflect",
"prost-types",
"serde",
"thiserror",
]
@@ -1003,6 +1105,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multimap"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1033,6 +1141,15 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -1068,6 +1185,37 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "pin-project"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1129,6 +1277,156 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-build"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
"heck",
"itertools",
"log",
"multimap",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"pulldown-cmark",
"pulldown-cmark-to-cmark",
"regex",
"syn",
"tempfile",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "prost-reflect"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b89455ef41ed200cafc47c76c552ee7792370ac420497e551f16123a9135f76e"
dependencies = [
"base64",
"prost",
"prost-types",
"serde",
"serde-value",
]
[[package]]
name = "prost-types"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost",
]
[[package]]
name = "protoc-bin-vendored"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-s390_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-aarch_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c"
[[package]]
name = "protoc-bin-vendored-linux-s390_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78"
[[package]]
name = "protoc-bin-vendored-macos-aarch_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3"
[[package]]
name = "pulldown-cmark"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]]
name = "pulldown-cmark-to-cmark"
version = "22.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1282,6 +1580,18 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
@@ -1357,6 +1667,19 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
@@ -1426,6 +1749,16 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -1720,6 +2053,19 @@ dependencies = [
"syn",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -1852,6 +2198,87 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tonic"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
dependencies = [
"async-trait",
"axum",
"base64",
"bytes",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-timeout",
"hyper-util",
"percent-encoding",
"pin-project",
"socket2",
"sync_wrapper",
"tokio",
"tokio-stream",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-build"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1882ac3bf5ef12877d7ed57aad87e75154c11931c2ba7e6cde5e22d63522c734"
dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tonic-prost"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3144df636917574672e93d0f56d7edec49f90305749c668df5101751bb8f95a"
dependencies = [
"prettyplease",
"proc-macro2",
"prost-build",
"prost-types",
"quote",
"syn",
"tempfile",
"tonic-build",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -1860,9 +2287,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@@ -1972,6 +2402,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
+9
View File
@@ -22,6 +22,11 @@ version = "0.1.0"
[workspace.dependencies]
axum = "0.8"
base64 = "0.22"
prost = "0.14"
prost-reflect = { version = "0.16", features = ["serde"] }
prost-types = "0.14"
protoc-bin-vendored = "3"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -30,6 +35,10 @@ sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-r
thiserror = "2"
time = { version = "0.3", features = ["formatting", "parsing"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tonic = { version = "0.14", features = ["transport"] }
tonic-prost = "0.14"
tonic-build = "0.14"
tonic-prost-build = "0.14"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1", features = ["serde", "v7"] }
+6 -7
View File
@@ -2,21 +2,20 @@
## Current
### `feat/graphql-support`
### `feat/grpc-support`
Status: completed
DoD:
- GraphQL operation can be created, tested and published
- variables mapping and `response_path` work in runtime
- GraphQL `errors` are translated into runtime failures
- published GraphQL tool is callable through MCP
- integration tests cover admin-api, runtime and MCP GraphQL scenarios
- gRPC descriptor set can be uploaded and inspected through admin-api
- unary gRPC operation can be tested through runtime
- published gRPC tool is callable through MCP
- integration tests cover adapter, runtime, admin-api and MCP gRPC scenarios
## Next
- `feat/grpc-support`
- `feat/hardening`
## Backlog
+2
View File
@@ -9,6 +9,7 @@ version.workspace = true
axum.workspace = true
mcpaas-core = { path = "../../crates/mcpaas-core" }
mcpaas-mapping = { path = "../../crates/mcpaas-mapping" }
mcpaas-proto = { path = "../../crates/mcpaas-proto" }
mcpaas-registry = { path = "../../crates/mcpaas-registry" }
mcpaas-runtime = { path = "../../crates/mcpaas-runtime" }
mcpaas-schema = { path = "../../crates/mcpaas-schema" }
@@ -23,5 +24,6 @@ tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
mcpaas-adapter-grpc = { path = "../../crates/mcpaas-adapter-grpc", features = ["test-support"] }
reqwest.workspace = true
sqlx.workspace = true
+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())
}
+1
View File
@@ -18,6 +18,7 @@ tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
mcpaas-adapter-grpc = { path = "../../crates/mcpaas-adapter-grpc", features = ["test-support"] }
mcpaas-core = { path = "../../crates/mcpaas-core" }
mcpaas-mapping = { path = "../../crates/mcpaas-mapping" }
mcpaas-schema = { path = "../../crates/mcpaas-schema" }
+1
View File
@@ -437,6 +437,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
RuntimeError::Schema(_) => "schema_validation_error",
RuntimeError::Mapping(_) => "mapping_error",
RuntimeError::GraphqlAdapter(_) => "adapter_execution_error",
RuntimeError::GrpcAdapter(_) => "adapter_execution_error",
RuntimeError::RestAdapter(_) => "adapter_execution_error",
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
+119 -2
View File
@@ -49,9 +49,10 @@ mod tests {
};
use axum::{Json, Router, http::header, routing::post};
use mcpaas_adapter_grpc::test_support as grpc_test_support;
use mcpaas_core::{
ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, Operation, OperationId,
OperationStatus, Protocol, RestTarget, Target, ToolDescription,
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
Operation, OperationId, OperationStatus, Protocol, RestTarget, Target, ToolDescription,
};
use mcpaas_mapping::{MappingRule, MappingSet};
use mcpaas_registry::{PostgresRegistry, PublishRequest};
@@ -183,6 +184,60 @@ mod tests {
assert_eq!(call_result["result"]["isError"], false);
}
#[tokio::test]
async fn initializes_and_calls_published_grpc_tool_via_mcp() {
let registry = test_registry().await;
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
let operation = test_grpc_operation(&server_addr, "echo_unary_grpc");
registry
.create_operation(&operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
operation_id: &operation.id,
version: 1,
published_at: "2026-03-26T10:00:00Z",
published_by: Some("alice"),
})
.await
.unwrap();
let base_url = spawn_mcp_server(build_app(
registry,
Duration::from_millis(0),
Some("https://rmcp.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let initialized_session = initialize_session(&client, &base_url).await;
let call_result = post_jsonrpc(
&client,
&base_url,
Some(&initialized_session),
json!({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "echo_unary_grpc",
"arguments": {
"message": "hello"
}
}
}),
)
.await;
assert_eq!(
call_result["result"]["structuredContent"],
json!({ "message": "hello" })
);
assert_eq!(call_result["result"]["isError"], false);
}
#[tokio::test]
async fn requires_initialized_notification_before_tool_methods() {
let registry = test_registry().await;
@@ -572,6 +627,68 @@ mod tests {
}
}
fn test_grpc_operation(server_addr: &str, name: &str) -> Operation<Schema, MappingSet> {
Operation {
id: OperationId::new(format!("op_{name}")),
name: name.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"),
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(),
},
samples: None,
generated_draft: None,
config_export: None,
created_at: "2026-03-26T10:00:00Z".to_owned(),
updated_at: "2026-03-26T10:00:00Z".to_owned(),
published_at: Some("2026-03-26T10:00:00Z".to_owned()),
}
}
fn object_schema(field_name: &str) -> Schema {
Schema {
kind: SchemaKind::Object,
+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>,
+15 -2
View File
@@ -216,7 +216,7 @@
## 7. gRPC descriptor endpoints
Следующие endpoints относятся к фазе `grpc-support` и не входят в `admin-api v1`:
Следующие endpoints относятся к фазе `grpc-support` и реализованы как часть gRPC vertical slice:
### `POST /api/admin/operations/{operation_id}/descriptors/proto`
@@ -230,6 +230,15 @@
- загрузить `descriptor set`.
Ответ:
```json
{
"descriptor_id": "desc_01",
"version": 1
}
```
### `GET /api/admin/operations/{operation_id}/grpc/services`
Назначение:
@@ -247,7 +256,9 @@
"methods": [
{
"name": "CreateLead",
"kind": "unary"
"kind": "unary",
"input_schema": { "type": "object", "fields": {} },
"output_schema": { "type": "object", "fields": {} }
}
]
}
@@ -255,6 +266,8 @@
}
```
Discovery endpoint используется для выбора unary метода и для построения UI-формы входа/выхода до публикации операции.
## 8. Тестовый запуск
### `POST /api/admin/operations/{operation_id}/test-runs`
+1
View File
@@ -230,6 +230,7 @@ gRPC target:
- `service`
- `method`
- `descriptor_ref`
- `descriptor_set_b64`
### Schema
+7 -1
View File
@@ -213,7 +213,8 @@
"package": "crm.v1",
"service": "LeadService",
"method": "CreateLead",
"descriptor_ref": "desc_01hr7yn4d6g1x6vwt7h9n0e7ab"
"descriptor_ref": "desc_01hr7yn4d6g1x6vwt7h9n0e7ab",
"descriptor_set_b64": "<base64-encoded-descriptor-set>"
}
```
@@ -225,6 +226,11 @@
- `service`
- `method`
- `descriptor_ref`
- `descriptor_set_b64`
`descriptor_ref` остается ссылкой на загруженный descriptor artifact в storage и registry.
`descriptor_set_b64` - runtime-ready snapshot descriptor set, который используется unary gRPC adapter для динамического вызова метода без генерации Rust-кода.
## 5. `Schema`
+1
View File
@@ -142,6 +142,7 @@ classDiagram
+service
+method
+descriptor_ref
+descriptor_set_b64
}
class Schema {
+3 -1
View File
@@ -52,6 +52,7 @@ gRPC operation должна включать:
- `service`
- `method`
- `descriptor_ref`
- `descriptor_set_b64`
- `input_schema`
- `output_schema`
- `input_mapping`
@@ -66,7 +67,7 @@ gRPC operation должна включать:
3. Оператор выбирает конкретный unary-метод.
4. UI показывает структуру request message и response message.
5. При необходимости загружает примеры JSON для MCP input/output.
6. Система строит черновую схему и стартовый mapping.
6. Система строит черновую схему, стартовый mapping и runtime-ready snapshot descriptor set для выбранного метода.
7. Оператор задает или уточняет входные MCP-параметры.
8. Настраивает маппинг во входные protobuf fields.
9. Настраивает маппинг из response fields в MCP output.
@@ -90,6 +91,7 @@ gRPC operation должна включать:
- `.proto` и descriptor handling должны быть отделены от runtime-вызова;
- protobuf discovery не должен жить внутри gRPC adapter;
- runtime использует сохраненный `descriptor_set_b64`, а не исходный `.proto`;
- `oneof`, `enum`, `repeated`, `map` и well-known types требуют отдельной нормализации;
- `map` на слое нормализованной schema модели представляется как `array` объектов вида `{ key, value }`;
- `oneof` на слое нормализованной schema модели представляется как `oneof` с вариантами-объектами, каждый из которых содержит одно допустимое поле;