diff --git a/Cargo.lock b/Cargo.lock index 2f674e9..6d49f50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 2200459..3959bfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/TASKS.md b/TASKS.md index a05dcf2..ec4910e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 45a5d50..e14eb08 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -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 diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index e3e747e..324efd1 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -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::() + .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::() + .await + .unwrap(); + let services = client + .get(format!( + "{base_url}/operations/{operation_id}/grpc/services" + )) + .send() + .await + .unwrap() + .json::() + .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::() + .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::() + .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::() + .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, diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs index 541c846..b101923 100644 --- a/apps/admin-api/src/routes/operations.rs +++ b/apps/admin-api/src/routes/operations.rs @@ -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, + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, 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, + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, 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, + Query(query): Query, + State(state): State, +) -> Result, 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, State(state): State, @@ -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 { + headers + .get("x-file-name") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index b093dc3..db8c0ad 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -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, } +#[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, +} + +#[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 { + 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 { + 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, + ) -> Result, 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::, _>>() + } + 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 { .format(&Rfc3339) .map_err(|error| ApiError::internal(error.to_string())) } + +fn proto_service_summary(service: ProtoService) -> Result { + 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::, ApiError>>()?; + + Ok(GrpcServiceSummary { + package: service.package, + service: service.name, + methods, + }) +} diff --git a/apps/admin-api/src/storage.rs b/apps/admin-api/src/storage.rs index cd734ac..8a626d4 100644 --- a/apps/admin-api/src/storage.rs +++ b/apps/admin-api/src/storage.rs @@ -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 { + 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, 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()) } diff --git a/apps/mcp-server/Cargo.toml b/apps/mcp-server/Cargo.toml index d0e0f67..0a54aa6 100644 --- a/apps/mcp-server/Cargo.toml +++ b/apps/mcp-server/Cargo.toml @@ -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" } diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 9e39f12..6c9d9a3 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -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", diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 24bdec4..f0b95ea 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -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 { + 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, diff --git a/crates/mcpaas-adapter-grpc/Cargo.toml b/crates/mcpaas-adapter-grpc/Cargo.toml index ced060b..2496598 100644 --- a/crates/mcpaas-adapter-grpc/Cargo.toml +++ b/crates/mcpaas-adapter-grpc/Cargo.toml @@ -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 diff --git a/crates/mcpaas-adapter-grpc/build.rs b/crates/mcpaas-adapter-grpc/build.rs new file mode 100644 index 0000000..2ee0f1f --- /dev/null +++ b/crates/mcpaas-adapter-grpc/build.rs @@ -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"); +} diff --git a/crates/mcpaas-adapter-grpc/proto/echo.proto b/crates/mcpaas-adapter-grpc/proto/echo.proto new file mode 100644 index 0000000..c26a235 --- /dev/null +++ b/crates/mcpaas-adapter-grpc/proto/echo.proto @@ -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; +} diff --git a/crates/mcpaas-adapter-grpc/src/client.rs b/crates/mcpaas-adapter-grpc/src/client.rs new file mode 100644 index 0000000..a8fb744 --- /dev/null +++ b/crates/mcpaas-adapter-grpc/src/client.rs @@ -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 { + 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 { + 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, + headers: &BTreeMap, +) -> 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 { + 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" })); + } +} diff --git a/crates/mcpaas-adapter-grpc/src/codec.rs b/crates/mcpaas-adapter-grpc/src/codec.rs new file mode 100644 index 0000000..1f3b5ce --- /dev/null +++ b/crates/mcpaas-adapter-grpc/src/codec.rs @@ -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, 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())) + } +} diff --git a/crates/mcpaas-adapter-grpc/src/error.rs b/crates/mcpaas-adapter-grpc/src/error.rs new file mode 100644 index 0000000..0cf158a --- /dev/null +++ b/crates/mcpaas-adapter-grpc/src/error.rs @@ -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 for GrpcAdapterError { + fn from(value: Status) -> Self { + Self::Status { + code: value.code(), + message: value.message().to_owned(), + } + } +} diff --git a/crates/mcpaas-adapter-grpc/src/lib.rs b/crates/mcpaas-adapter-grpc/src/lib.rs index 8523de6..fde7905 100644 --- a/crates/mcpaas-adapter-grpc/src/lib.rs +++ b/crates/mcpaas-adapter-grpc/src/lib.rs @@ -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; diff --git a/crates/mcpaas-adapter-grpc/src/model.rs b/crates/mcpaas-adapter-grpc/src/model.rs new file mode 100644 index 0000000..c04f468 --- /dev/null +++ b/crates/mcpaas-adapter-grpc/src/model.rs @@ -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, + 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, + pub body: Value, +} diff --git a/crates/mcpaas-adapter-grpc/src/test_support.rs b/crates/mcpaas-adapter-grpc/src/test_support.rs new file mode 100644 index 0000000..4ad7dfd --- /dev/null +++ b/crates/mcpaas-adapter-grpc/src/test_support.rs @@ -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, + ) -> Result, 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) +} diff --git a/crates/mcpaas-core/src/operation.rs b/crates/mcpaas-core/src/operation.rs index 6a05526..e13f385 100644 --- a/crates/mcpaas-core/src/operation.rs +++ b/crates/mcpaas-core/src/operation.rs @@ -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)] diff --git a/crates/mcpaas-proto/Cargo.toml b/crates/mcpaas-proto/Cargo.toml index f08b19d..274e5ec 100644 --- a/crates/mcpaas-proto/Cargo.toml +++ b/crates/mcpaas-proto/Cargo.toml @@ -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 - diff --git a/crates/mcpaas-proto/src/errors.rs b/crates/mcpaas-proto/src/errors.rs index 11dfc56..d5b190b 100644 --- a/crates/mcpaas-proto/src/errors.rs +++ b/crates/mcpaas-proto/src/errors.rs @@ -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, } diff --git a/crates/mcpaas-proto/src/lib.rs b/crates/mcpaas-proto/src/lib.rs index 69c90a9..f3adff4 100644 --- a/crates/mcpaas-proto/src/lib.rs +++ b/crates/mcpaas-proto/src/lib.rs @@ -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, 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(), + } +} diff --git a/crates/mcpaas-runtime/Cargo.toml b/crates/mcpaas-runtime/Cargo.toml index cef03ad..de9fa35 100644 --- a/crates/mcpaas-runtime/Cargo.toml +++ b/crates/mcpaas-runtime/Cargo.toml @@ -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 diff --git a/crates/mcpaas-runtime/src/error.rs b/crates/mcpaas-runtime/src/error.rs index 62cc92b..1ae1f1a 100644 --- a/crates/mcpaas-runtime/src/error.rs +++ b/crates/mcpaas-runtime/src/error.rs @@ -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 }, diff --git a/crates/mcpaas-runtime/src/executor.rs b/crates/mcpaas-runtime/src/executor.rs index f6c922e..ab1ef6b 100644 --- a/crates/mcpaas-runtime/src/executor.rs +++ b/crates/mcpaas-runtime/src/executor.rs @@ -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, diff --git a/crates/mcpaas-runtime/src/model.rs b/crates/mcpaas-runtime/src/model.rs index f48abea..54a27e6 100644 --- a/crates/mcpaas-runtime/src/model.rs +++ b/crates/mcpaas-runtime/src/model.rs @@ -46,6 +46,8 @@ pub struct PreparedRequest { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] + pub grpc: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub variables: Option, #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, diff --git a/docs/admin-api.md b/docs/admin-api.md index 968279c..5266fb3 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -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` diff --git a/docs/architecture.md b/docs/architecture.md index f1f6b2c..5256527 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -230,6 +230,7 @@ gRPC target: - `service` - `method` - `descriptor_ref` +- `descriptor_set_b64` ### Schema diff --git a/docs/data-model.md b/docs/data-model.md index d9a805d..508a3a7 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -213,7 +213,8 @@ "package": "crm.v1", "service": "LeadService", "method": "CreateLead", - "descriptor_ref": "desc_01hr7yn4d6g1x6vwt7h9n0e7ab" + "descriptor_ref": "desc_01hr7yn4d6g1x6vwt7h9n0e7ab", + "descriptor_set_b64": "" } ``` @@ -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` diff --git a/docs/diagrams.md b/docs/diagrams.md index 59745f8..76b3ac7 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -142,6 +142,7 @@ classDiagram +service +method +descriptor_ref + +descriptor_set_b64 } class Schema { diff --git a/docs/protocols/grpc.md b/docs/protocols/grpc.md index ace02c1..6a1e873 100644 --- a/docs/protocols/grpc.md +++ b/docs/protocols/grpc.md @@ -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` с вариантами-объектами, каждый из которых содержит одно допустимое поле;