From 8a7e8dbd64e50a8e7a6ab0a413296ccbc3df3306 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Wed, 25 Mar 2026 18:28:44 +0300 Subject: [PATCH] feat: implement rest vertical slice --- Cargo.lock | 391 ++++++++++++++++++++++- Cargo.toml | 1 + TASKS.md | 14 +- apps/mcp-server/Cargo.toml | 9 + apps/mcp-server/src/main.rs | 336 ++++++++++++++++++- crates/mcpaas-adapter-rest/Cargo.toml | 4 + crates/mcpaas-adapter-rest/src/client.rs | 289 +++++++++++++++++ crates/mcpaas-adapter-rest/src/error.rs | 20 ++ crates/mcpaas-adapter-rest/src/lib.rs | 10 +- crates/mcpaas-adapter-rest/src/model.rs | 25 ++ crates/mcpaas-runtime/Cargo.toml | 3 + crates/mcpaas-runtime/src/error.rs | 19 ++ crates/mcpaas-runtime/src/executor.rs | 381 ++++++++++++++++++++++ crates/mcpaas-runtime/src/lib.rs | 10 +- crates/mcpaas-runtime/src/model.rs | 58 ++++ 15 files changed, 1547 insertions(+), 23 deletions(-) create mode 100644 crates/mcpaas-adapter-rest/src/client.rs create mode 100644 crates/mcpaas-adapter-rest/src/error.rs create mode 100644 crates/mcpaas-adapter-rest/src/model.rs create mode 100644 crates/mcpaas-runtime/src/error.rs create mode 100644 crates/mcpaas-runtime/src/executor.rs create mode 100644 crates/mcpaas-runtime/src/model.rs diff --git a/Cargo.lock b/Cargo.lock index 85ab3c7..7381f00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -150,6 +156,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -371,8 +383,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", ] [[package]] @@ -504,6 +532,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -512,13 +558,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -633,12 +687,38 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -684,6 +764,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -704,7 +790,14 @@ name = "mcp-server" version = "0.1.0" dependencies = [ "axum", + "mcpaas-core", + "mcpaas-mapping", + "mcpaas-registry", + "mcpaas-runtime", + "mcpaas-schema", + "reqwest", "serde_json", + "sqlx", "tokio", "tracing", "tracing-subscriber", @@ -735,10 +828,13 @@ dependencies = [ name = "mcpaas-adapter-rest" version = "0.1.0" dependencies = [ + "axum", "mcpaas-core", + "reqwest", "serde", "serde_json", "thiserror", + "tokio", ] [[package]] @@ -790,6 +886,7 @@ dependencies = [ name = "mcpaas-runtime" version = "0.1.0" dependencies = [ + "axum", "mcpaas-adapter-graphql", "mcpaas-adapter-grpc", "mcpaas-adapter-rest", @@ -799,6 +896,7 @@ dependencies = [ "serde", "serde_json", "thiserror", + "tokio", ] [[package]] @@ -949,6 +1047,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -958,6 +1111,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -965,8 +1124,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -976,7 +1145,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -985,7 +1164,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1023,6 +1211,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + [[package]] name = "ring" version = "0.17.14" @@ -1031,12 +1257,18 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustls" version = "0.23.37" @@ -1057,6 +1289,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -1071,6 +1304,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -1321,7 +1560,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", @@ -1372,6 +1611,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1464,6 +1706,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -1491,6 +1743,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1565,6 +1835,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -1640,18 +1916,115 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1843,6 +2216,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index ac4f785..59b32ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ version = "0.1.0" [workspace.dependencies] axum = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/TASKS.md b/TASKS.md index 7143f3d..2ad3559 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,21 +2,21 @@ ## Current -### `feat/registry-storage` +### `feat/rest-vertical-slice` Status: completed DoD: -- migrations create tables from `database-schema` -- version snapshots persist and load correctly -- publish linkage works -- auth profiles and artifact metadata are stored -- registry integration tests run on a real SQLite database +- REST operation can be created, tested, and published +- runtime executes REST operation end-to-end +- published REST tool is callable through MCP layer +- negative tests for mapping and external errors exist +- at least one demo REST scenario is automated ## Next -- `feat/rest-vertical-slice` +- `feat/admin-api-v1` ## Backlog diff --git a/apps/mcp-server/Cargo.toml b/apps/mcp-server/Cargo.toml index e87dbc7..e941f71 100644 --- a/apps/mcp-server/Cargo.toml +++ b/apps/mcp-server/Cargo.toml @@ -7,7 +7,16 @@ version.workspace = true [dependencies] axum.workspace = true +mcpaas-registry = { path = "../../crates/mcpaas-registry" } +mcpaas-runtime = { path = "../../crates/mcpaas-runtime" } serde_json.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true + +[dev-dependencies] +mcpaas-core = { path = "../../crates/mcpaas-core" } +mcpaas-mapping = { path = "../../crates/mcpaas-mapping" } +mcpaas-schema = { path = "../../crates/mcpaas-schema" } +reqwest.workspace = true +sqlx.workspace = true diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 92a9719..908cc2f 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -1,10 +1,23 @@ -use std::{env, net::SocketAddr}; +use std::{env, net::SocketAddr, sync::Arc}; -use axum::{Json, Router, routing::get}; -use serde_json::json; +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, +}; +use mcpaas_registry::PostgresRegistry; +use mcpaas_runtime::{RuntimeExecutor, RuntimeOperation}; +use serde_json::{Value, json}; use tokio::net::TcpListener; use tracing::info; +#[derive(Clone)] +struct AppState { + registry: PostgresRegistry, + runtime: RuntimeExecutor, +} + #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() @@ -14,9 +27,15 @@ async fn main() -> Result<(), Box> { ) .init(); + let database_url = env::var("MCPAAS_DATABASE_URL")?; let bind_addr = env::var("MCPAAS_MCP_BIND").unwrap_or_else(|_| "0.0.0.0:3002".into()); let socket_addr: SocketAddr = bind_addr.parse()?; - let app = Router::new().route("/health", get(health)); + let registry = PostgresRegistry::connect(&database_url).await?; + let state = Arc::new(AppState { + registry, + runtime: RuntimeExecutor::new(), + }); + let app = build_app(state); let listener = TcpListener::bind(socket_addr).await?; info!("mcp-server listening on {}", socket_addr); @@ -26,9 +45,318 @@ async fn main() -> Result<(), Box> { Ok(()) } +fn build_app(state: Arc) -> Router { + Router::new() + .route("/health", get(health)) + .route("/tools", get(list_tools)) + .route("/tools/{tool_name}", post(call_tool)) + .with_state(state) +} + async fn health() -> Json { Json(json!({ "service": "mcp-server", "status": "ok" })) } + +async fn list_tools( + State(state): State>, +) -> Result, (StatusCode, Json)> { + let operations = state + .registry + .list_published_operations() + .await + .map_err(internal_error)?; + + let tools = operations + .into_iter() + .map(|operation| { + json!({ + "name": operation.name, + "title": operation.tool_description.title, + "description": operation.tool_description.description + }) + }) + .collect::>(); + + Ok(Json(json!({ "tools": tools }))) +} + +async fn call_tool( + Path(tool_name): Path, + State(state): State>, + Json(input): Json, +) -> Result, (StatusCode, Json)> { + let operation = state + .registry + .list_published_operations() + .await + .map_err(internal_error)? + .into_iter() + .find(|operation| operation.name == tool_name) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({ + "error": { + "code": "not_found", + "message": format!("tool {tool_name} was not found") + } + })), + ) + })?; + + let output = state + .runtime + .execute(&RuntimeOperation::from(operation), &input) + .await + .map_err(runtime_error)?; + + Ok(Json(json!({ + "tool_name": tool_name, + "result": output + }))) +} + +fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": { + "code": "internal_error", + "message": error.to_string() + } + })), + ) +} + +fn runtime_error(error: impl std::fmt::Display) -> (StatusCode, Json) { + ( + StatusCode::BAD_GATEWAY, + Json(json!({ + "error": { + "code": "runtime_error", + "message": error.to_string() + } + })), + ) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + env, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, + }; + + use axum::{Json, Router, routing::post}; + use mcpaas_core::{ + ExecutionConfig, HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget, + Target, ToolDescription, + }; + use mcpaas_mapping::{MappingRule, MappingSet}; + use mcpaas_registry::{PostgresRegistry, PublishRequest}; + use mcpaas_runtime::RuntimeExecutor; + use mcpaas_schema::{Schema, SchemaKind}; + use serde_json::{Value, json}; + use sqlx::{Executor, postgres::PgPoolOptions}; + use tokio::net::TcpListener; + + use crate::{AppState, build_app}; + + #[tokio::test] + async fn lists_and_calls_published_rest_tool() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_operation(&upstream_base_url); + + 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 state = Arc::new(AppState { + registry, + runtime: RuntimeExecutor::new(), + }); + let base_url = spawn_mcp_server(build_app(state)).await; + let client = reqwest::Client::new(); + + let tools = client + .get(format!("{base_url}/tools")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let call_result = client + .post(format!("{base_url}/tools/crm_create_lead")) + .json(&json!({ "email": "user@example.com" })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(tools["tools"][0]["name"], "crm_create_lead"); + assert_eq!(call_result["result"], json!({ "id": "lead_123" })); + } + + async fn spawn_upstream_server() -> String { + let app = Router::new().route("/crm/leads", post(create_lead)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + + async fn spawn_mcp_server(app: Router) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + + async fn create_lead(Json(payload): Json) -> Json { + Json(json!({ + "id": "lead_123", + "email": payload["email"] + })) + } + + async fn test_registry() -> PostgresRegistry { + let database_url = env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned()); + let admin_pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .unwrap(); + let schema = format!( + "test_mcp_server_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + + admin_pool + .execute(sqlx::query(&format!("create schema {schema}"))) + .await + .unwrap(); + + PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}")) + .await + .unwrap() + } + + fn test_operation(base_url: &str) -> Operation { + Operation { + id: OperationId::new("op_mcp_rest"), + name: "crm_create_lead".to_owned(), + display_name: "Create Lead".to_owned(), + protocol: Protocol::Rest, + status: OperationStatus::Published, + version: 1, + target: Target::Rest(RestTarget { + base_url: base_url.to_owned(), + method: HttpMethod::Post, + path_template: "/crm/leads".to_owned(), + static_headers: BTreeMap::new(), + }), + input_schema: object_schema("email"), + output_schema: object_schema("id"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.body.id".to_owned(), + target: "$.output.id".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: "Create Lead".to_owned(), + description: "Creates CRM lead".to_owned(), + tags: Vec::new(), + 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, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + field_name.to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + } +} diff --git a/crates/mcpaas-adapter-rest/Cargo.toml b/crates/mcpaas-adapter-rest/Cargo.toml index cbabb78..b2bcef9 100644 --- a/crates/mcpaas-adapter-rest/Cargo.toml +++ b/crates/mcpaas-adapter-rest/Cargo.toml @@ -7,7 +7,11 @@ version.workspace = true [dependencies] mcpaas-core = { path = "../mcpaas-core" } +reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +[dev-dependencies] +axum.workspace = true +tokio.workspace = true diff --git a/crates/mcpaas-adapter-rest/src/client.rs b/crates/mcpaas-adapter-rest/src/client.rs new file mode 100644 index 0000000..246526c --- /dev/null +++ b/crates/mcpaas-adapter-rest/src/client.rs @@ -0,0 +1,289 @@ +use std::{collections::BTreeMap, time::Duration}; + +use mcpaas_core::{HttpMethod, RestTarget}; +use reqwest::{ + Client, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +use serde_json::Value; + +use crate::{RestAdapterError, RestRequest, RestResponse}; + +#[derive(Clone, Debug)] +pub struct RestAdapter { + client: Client, +} + +impl Default for RestAdapter { + fn default() -> Self { + Self::new() + } +} + +impl RestAdapter { + pub fn new() -> Self { + Self { + client: Client::new(), + } + } + + pub async fn execute( + &self, + target: &RestTarget, + request: &RestRequest, + ) -> Result { + let url = build_url(target, request)?; + let headers = build_headers(target, request)?; + let mut builder = self + .client + .request(to_reqwest_method(target.method), url) + .headers(headers) + .timeout(Duration::from_millis(request.timeout_ms)); + + if let Some(body) = &request.body { + builder = builder.json(body); + } + + let response = builder.send().await?; + let status = response.status(); + let headers = normalize_headers(response.headers()); + let body = decode_body(response).await?; + + if !status.is_success() { + return Err(RestAdapterError::UnexpectedStatus { + status: status.as_u16(), + body, + }); + } + + Ok(RestResponse { + status_code: status.as_u16(), + headers, + body, + }) + } +} + +fn build_url(target: &RestTarget, request: &RestRequest) -> Result { + let base_url = + reqwest::Url::parse(&target.base_url).map_err(|_| RestAdapterError::InvalidBaseUrl { + url: target.base_url.clone(), + })?; + let path = substitute_path_params(&target.path_template, &request.path_params); + let mut url = base_url.join(path.trim_start_matches('/')).map_err(|_| { + RestAdapterError::InvalidBaseUrl { + url: target.base_url.clone(), + } + })?; + + { + let mut query = url.query_pairs_mut(); + for (key, value) in &request.query_params { + query.append_pair(key, value); + } + } + + Ok(url) +} + +fn substitute_path_params(path_template: &str, path_params: &BTreeMap) -> String { + let mut rendered = path_template.to_owned(); + + for (key, value) in path_params { + rendered = rendered.replace(&format!("{{{key}}}"), value); + } + + rendered +} + +fn build_headers( + target: &RestTarget, + request: &RestRequest, +) -> Result { + let mut headers = HeaderMap::new(); + + for (name, value) in &target.static_headers { + insert_header(&mut headers, name, value)?; + } + + for (name, value) in &request.headers { + insert_header(&mut headers, name, value)?; + } + + Ok(headers) +} + +fn insert_header(headers: &mut HeaderMap, name: &str, value: &str) -> Result<(), RestAdapterError> { + let header_name = + HeaderName::try_from(name).map_err(|_| RestAdapterError::InvalidHeaderName { + header: name.to_owned(), + })?; + let header_value = + HeaderValue::try_from(value).map_err(|_| RestAdapterError::InvalidHeaderValue { + header: name.to_owned(), + })?; + + headers.insert(header_name, header_value); + Ok(()) +} + +async fn decode_body(response: reqwest::Response) -> Result { + let bytes = response.bytes().await?; + + if bytes.is_empty() { + return Ok(Value::Null); + } + + match serde_json::from_slice::(&bytes) { + Ok(value) => Ok(value), + Err(_) => Ok(Value::String( + String::from_utf8_lossy(&bytes).trim().to_owned(), + )), + } +} + +fn normalize_headers(headers: &HeaderMap) -> BTreeMap { + headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect() +} + +fn to_reqwest_method(method: HttpMethod) -> reqwest::Method { + match method { + HttpMethod::Get => reqwest::Method::GET, + HttpMethod::Post => reqwest::Method::POST, + HttpMethod::Put => reqwest::Method::PUT, + HttpMethod::Patch => reqwest::Method::PATCH, + HttpMethod::Delete => reqwest::Method::DELETE, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use axum::{ + Json, Router, + extract::{Path, Query}, + http::HeaderMap, + routing::{get, post}, + }; + use mcpaas_core::{HttpMethod, RestTarget}; + use serde_json::{Value, json}; + use tokio::net::TcpListener; + + use crate::{RestAdapter, RestAdapterError, RestRequest}; + + #[tokio::test] + async fn executes_rest_request_and_normalizes_json_response() { + let base_url = spawn_test_server().await; + let adapter = RestAdapter::new(); + let target = RestTarget { + base_url, + method: HttpMethod::Post, + path_template: "/users/{user_id}".to_owned(), + static_headers: BTreeMap::from([("x-static".to_owned(), "static".to_owned())]), + }; + let request = RestRequest { + path_params: BTreeMap::from([("user_id".to_owned(), "42".to_owned())]), + query_params: BTreeMap::from([("expand".to_owned(), "true".to_owned())]), + headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]), + body: Some(json!({ "name": "Ada" })), + timeout_ms: 1_000, + }; + + let response = adapter.execute(&target, &request).await.unwrap(); + + assert_eq!(response.status_code, 200); + assert_eq!( + response.body, + json!({ + "id": "42", + "query": "true", + "trace": "trace-123", + "static": "static", + "payload": { "name": "Ada" } + }) + ); + } + + #[tokio::test] + async fn returns_unexpected_status_with_normalized_body() { + let base_url = spawn_test_server().await; + let adapter = RestAdapter::new(); + let target = RestTarget { + base_url, + method: HttpMethod::Get, + path_template: "/fail".to_owned(), + static_headers: BTreeMap::new(), + }; + let request = RestRequest { + path_params: BTreeMap::new(), + query_params: BTreeMap::new(), + headers: BTreeMap::new(), + body: None, + timeout_ms: 1_000, + }; + + let error = adapter.execute(&target, &request).await.unwrap_err(); + + assert!(matches!( + error, + RestAdapterError::UnexpectedStatus { + status: 502, + body: Value::Object(_) + } + )); + } + + async fn spawn_test_server() -> String { + let app = Router::new() + .route("/users/{user_id}", post(create_user)) + .route("/fail", get(fail)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + + async fn create_user( + Path(user_id): Path, + Query(query): Query>, + headers: HeaderMap, + Json(payload): Json, + ) -> Json { + let trace = headers + .get("x-trace-id") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let static_header = headers + .get("x-static") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + + Json(json!({ + "id": user_id, + "query": query.get("expand").cloned().unwrap_or_default(), + "trace": trace, + "static": static_header, + "payload": payload + })) + } + + async fn fail() -> (axum::http::StatusCode, Json) { + ( + axum::http::StatusCode::BAD_GATEWAY, + Json(json!({ "error": "upstream failed" })), + ) + } +} diff --git a/crates/mcpaas-adapter-rest/src/error.rs b/crates/mcpaas-adapter-rest/src/error.rs new file mode 100644 index 0000000..1f18032 --- /dev/null +++ b/crates/mcpaas-adapter-rest/src/error.rs @@ -0,0 +1,20 @@ +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RestAdapterError { + #[error("invalid base url: {url}")] + InvalidBaseUrl { url: String }, + #[error("invalid path parameter {key}")] + InvalidPathParameter { key: String }, + #[error("invalid query parameter {key}")] + InvalidQueryParameter { key: String }, + #[error("invalid header name {header}")] + InvalidHeaderName { header: String }, + #[error("invalid header value for {header}")] + InvalidHeaderValue { header: String }, + #[error("request failed")] + Transport(#[from] reqwest::Error), + #[error("rest endpoint returned status {status}")] + UnexpectedStatus { status: u16, body: Value }, +} diff --git a/crates/mcpaas-adapter-rest/src/lib.rs b/crates/mcpaas-adapter-rest/src/lib.rs index 15bc142..8f25b10 100644 --- a/crates/mcpaas-adapter-rest/src/lib.rs +++ b/crates/mcpaas-adapter-rest/src/lib.rs @@ -1,3 +1,7 @@ -pub fn crate_name() -> &'static str { - "mcpaas-adapter-rest" -} +mod client; +mod error; +mod model; + +pub use client::RestAdapter; +pub use error::RestAdapterError; +pub use model::{RestRequest, RestResponse}; diff --git a/crates/mcpaas-adapter-rest/src/model.rs b/crates/mcpaas-adapter-rest/src/model.rs new file mode 100644 index 0000000..f04ecec --- /dev/null +++ b/crates/mcpaas-adapter-rest/src/model.rs @@ -0,0 +1,25 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +pub struct RestRequest { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub path_params: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub query_params: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + pub timeout_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RestResponse { + pub status_code: u16, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub body: Value, +} diff --git a/crates/mcpaas-runtime/Cargo.toml b/crates/mcpaas-runtime/Cargo.toml index b8a18f4..cef03ad 100644 --- a/crates/mcpaas-runtime/Cargo.toml +++ b/crates/mcpaas-runtime/Cargo.toml @@ -16,3 +16,6 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true +[dev-dependencies] +axum.workspace = true +tokio.workspace = true diff --git a/crates/mcpaas-runtime/src/error.rs b/crates/mcpaas-runtime/src/error.rs new file mode 100644 index 0000000..e691b25 --- /dev/null +++ b/crates/mcpaas-runtime/src/error.rs @@ -0,0 +1,19 @@ +use mcpaas_adapter_rest::RestAdapterError; +use mcpaas_core::Protocol; +use mcpaas_mapping::MappingError; +use mcpaas_schema::SchemaError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error(transparent)] + Schema(#[from] SchemaError), + #[error(transparent)] + Mapping(#[from] MappingError), + #[error(transparent)] + RestAdapter(#[from] RestAdapterError), + #[error("protocol {protocol:?} is not supported by runtime")] + UnsupportedProtocol { protocol: Protocol }, + #[error("invalid prepared request: {details}")] + InvalidPreparedRequest { details: String }, +} diff --git a/crates/mcpaas-runtime/src/executor.rs b/crates/mcpaas-runtime/src/executor.rs new file mode 100644 index 0000000..f7aa678 --- /dev/null +++ b/crates/mcpaas-runtime/src/executor.rs @@ -0,0 +1,381 @@ +use std::collections::BTreeMap; + +use mcpaas_adapter_rest::{RestAdapter, RestRequest}; +use mcpaas_core::Target; +use serde_json::{Map, Value, json}; + +use crate::{AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation}; + +#[derive(Clone, Debug)] +pub struct RuntimeExecutor { + rest_adapter: RestAdapter, +} + +impl Default for RuntimeExecutor { + fn default() -> Self { + Self::new() + } +} + +impl RuntimeExecutor { + pub fn new() -> Self { + Self { + rest_adapter: RestAdapter::new(), + } + } + + pub async fn execute( + &self, + operation: &RuntimeOperation, + input: &Value, + ) -> Result { + operation.input_schema.validate_shape(input)?; + + let mapped_input = operation.input_mapping.apply(&json!({ "mcp": input }))?; + let prepared_request = PreparedRequest::from_mapping_output(&mapped_input)?; + + let adapter_response = match &operation.target { + Target::Rest(target) => { + let request = RestRequest { + path_params: prepared_request.path_params.clone(), + query_params: prepared_request.query_params.clone(), + headers: merge_headers( + &target.static_headers, + &operation.execution_config.headers, + &prepared_request.headers, + ), + body: prepared_request.body.clone(), + timeout_ms: operation.execution_config.timeout_ms, + }; + let response = self.rest_adapter.execute(target, &request).await?; + + AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + } + } + _ => { + return Err(RuntimeError::UnsupportedProtocol { + protocol: operation.protocol, + }); + } + }; + + let finalized_output = finalize_output(operation, &adapter_response)?; + + operation.output_schema.validate_shape(&finalized_output)?; + + Ok(finalized_output) + } +} + +impl PreparedRequest { + pub fn from_mapping_output(mapped: &Value) -> Result { + let request = + mapped + .get("request") + .ok_or_else(|| RuntimeError::InvalidPreparedRequest { + details: "mapped input must contain request root".to_owned(), + })?; + + Ok(Self { + 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")?, + body: request.get("body").and_then(non_empty_body).cloned(), + }) + } +} + +fn finalize_output( + operation: &RuntimeOperation, + response: &AdapterResponse, +) -> Result { + let mapped = operation.output_mapping.apply(&json!({ + "response": { + "body": response.body, + "data": response.body, + "headers": response.headers, + "status": response.status_code + } + }))?; + + Ok(mapped + .get("output") + .cloned() + .unwrap_or_else(|| Value::Object(Map::new()))) +} + +fn merge_headers( + static_headers: &BTreeMap, + execution_headers: &BTreeMap, + request_headers: &BTreeMap, +) -> BTreeMap { + let mut headers = static_headers.clone(); + headers.extend(execution_headers.clone()); + headers.extend(request_headers.clone()); + headers +} + +fn read_string_map( + value: Option<&Value>, + field_name: &str, +) -> Result, RuntimeError> { + let Some(value) = value else { + return Ok(BTreeMap::new()); + }; + + let Some(object) = value.as_object() else { + return Err(RuntimeError::InvalidPreparedRequest { + details: format!("{field_name} must be an object"), + }); + }; + + object + .iter() + .map(|(key, value)| stringify_value(value).map(|value| (key.clone(), value))) + .collect::, _>>() + .map_err(|details| RuntimeError::InvalidPreparedRequest { details }) +} + +fn stringify_value(value: &Value) -> Result { + match value { + Value::Null => Ok("null".to_owned()), + Value::Bool(value) => Ok(value.to_string()), + Value::Number(value) => Ok(value.to_string()), + Value::String(value) => Ok(value.clone()), + Value::Array(_) | Value::Object(_) => { + Err("request path/query/headers accept only scalar values".to_owned()) + } + } +} + +fn non_empty_body(value: &Value) -> Option<&Value> { + match value { + Value::Null => None, + Value::Object(object) if object.is_empty() => None, + _ => Some(value), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use axum::{Json, Router, routing::post}; + use mcpaas_core::{ + ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, HttpMethod, Operation, OperationId, + OperationStatus, Protocol, RestTarget, Samples, Target, ToolDescription, ToolExample, + }; + use mcpaas_mapping::{MappingRule, MappingSet}; + use mcpaas_schema::{Schema, SchemaKind}; + use serde_json::{Value, json}; + use tokio::net::TcpListener; + + use crate::{RuntimeError, RuntimeExecutor, RuntimeOperation}; + + #[tokio::test] + async fn executes_rest_operation_end_to_end() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_rest_operation(&base_url, false, false); + + let output = executor + .execute(&operation, &json!({ "email": "user@example.com" })) + .await + .unwrap(); + + assert_eq!(output, json!({ "id": "lead_123" })); + } + + #[tokio::test] + async fn rejects_invalid_input_shape() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_rest_operation(&base_url, false, false); + + let error = executor.execute(&operation, &json!({})).await.unwrap_err(); + + assert!(matches!(error, RuntimeError::Schema(_))); + } + + #[tokio::test] + async fn propagates_external_rest_errors() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_rest_operation(&base_url, true, false); + + let error = executor + .execute(&operation, &json!({ "email": "user@example.com" })) + .await + .unwrap_err(); + + assert!(matches!(error, RuntimeError::RestAdapter(_))); + } + + #[tokio::test] + async fn fails_when_output_mapping_requires_missing_field() { + let base_url = spawn_runtime_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_rest_operation(&base_url, false, true); + + let error = executor + .execute(&operation, &json!({ "email": "user@example.com" })) + .await + .unwrap_err(); + + assert!(matches!(error, RuntimeError::Mapping(_))); + } + + async fn spawn_runtime_server() -> String { + let app = Router::new().route("/leads", post(create_lead)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + + async fn create_lead(Json(payload): Json) -> (axum::http::StatusCode, Json) { + let should_fail = payload + .get("fail") + .and_then(Value::as_bool) + .unwrap_or(false); + + if should_fail { + return ( + axum::http::StatusCode::BAD_GATEWAY, + Json(json!({ "error": "upstream failed" })), + ); + } + + ( + axum::http::StatusCode::OK, + Json(json!({ "id": "lead_123", "email": payload["email"] })), + ) + } + + fn test_rest_operation( + base_url: &str, + should_fail: bool, + invalid_output: bool, + ) -> RuntimeOperation { + let mut input_rules = vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }]; + + if should_fail { + input_rules.push(MappingRule { + source: "$.mcp.fail".to_owned(), + target: "$.request.body.fail".to_owned(), + required: false, + default_value: Some(Value::Bool(true)), + transform: None, + condition: None, + notes: None, + }); + } + + let output_source = if invalid_output { + "$.response.body.missing" + } else { + "$.response.body.id" + }; + + RuntimeOperation::from(Operation { + id: OperationId::new("op_rest_runtime"), + name: "crm_create_lead".to_owned(), + display_name: "Create Lead".to_owned(), + protocol: Protocol::Rest, + status: OperationStatus::Published, + version: 1, + target: Target::Rest(RestTarget { + base_url: base_url.to_owned(), + method: HttpMethod::Post, + path_template: "/leads".to_owned(), + static_headers: BTreeMap::new(), + }), + input_schema: object_schema("email", SchemaKind::String), + output_schema: object_schema("id", SchemaKind::String), + input_mapping: MappingSet { rules: input_rules }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: output_source.to_owned(), + target: "$.output.id".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: "Create Lead".to_owned(), + description: "Creates a CRM lead".to_owned(), + tags: vec!["crm".to_owned()], + examples: vec![ToolExample { + input: json!({ "email": "user@example.com" }), + }], + }, + samples: Some(Samples::default()), + generated_draft: Some(GeneratedDraft { + status: GeneratedDraftStatus::Available, + source_types: vec!["input_json".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, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + field_name.to_owned(), + Schema { + kind, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + } +} diff --git a/crates/mcpaas-runtime/src/lib.rs b/crates/mcpaas-runtime/src/lib.rs index d8cd3a1..f712205 100644 --- a/crates/mcpaas-runtime/src/lib.rs +++ b/crates/mcpaas-runtime/src/lib.rs @@ -1,3 +1,7 @@ -pub fn crate_name() -> &'static str { - "mcpaas-runtime" -} +mod error; +mod executor; +mod model; + +pub use error::RuntimeError; +pub use executor::RuntimeExecutor; +pub use model::{AdapterResponse, PreparedRequest, RuntimeOperation}; diff --git a/crates/mcpaas-runtime/src/model.rs b/crates/mcpaas-runtime/src/model.rs new file mode 100644 index 0000000..532fee3 --- /dev/null +++ b/crates/mcpaas-runtime/src/model.rs @@ -0,0 +1,58 @@ +use std::collections::BTreeMap; + +use mcpaas_core::{ExecutionConfig, Operation, OperationId, Protocol, Target, ToolDescription}; +use mcpaas_mapping::MappingSet; +use mcpaas_schema::Schema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeOperation { + pub operation_id: OperationId, + pub tool_name: String, + pub protocol: Protocol, + pub target: Target, + pub input_schema: Schema, + pub output_schema: Schema, + pub input_mapping: MappingSet, + pub output_mapping: MappingSet, + pub execution_config: ExecutionConfig, + pub tool_description: ToolDescription, +} + +impl From> for RuntimeOperation { + fn from(value: Operation) -> Self { + Self { + operation_id: value.id, + tool_name: value.name, + protocol: value.protocol, + target: value.target, + input_schema: value.input_schema, + output_schema: value.output_schema, + input_mapping: value.input_mapping, + output_mapping: value.output_mapping, + execution_config: value.execution_config, + tool_description: value.tool_description, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +pub struct PreparedRequest { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub path_params: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub query_params: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AdapterResponse { + pub status_code: u16, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub body: Value, +}