diff --git a/Cargo.lock b/Cargo.lock index 9287803516..7f982549b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,7 +50,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -851,7 +851,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", - "tower", + "tower 0.5.2", "tracing", ] @@ -1011,7 +1011,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -1579,6 +1579,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chalk-derive" version = "0.103.0" @@ -2068,6 +2079,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-assembler-x64" version = "0.120.0" @@ -2443,7 +2463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -2483,6 +2503,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2511,6 +2541,19 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.110", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -2533,6 +2576,17 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.110", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -2547,6 +2601,20 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "7.0.0-rc2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a1e35a65fe0538a60167f0ada6e195ad5d477f6ddae273943596d4a1a5730b" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "equivalent", + "hashbrown 0.15.5", + "lock_api", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -3721,6 +3789,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -4246,7 +4328,7 @@ dependencies = [ "tokio-util", "tonic 0.14.2", "tonic-tracing-opentelemetry", - "tower", + "tower 0.5.2", "tracing", "tryhard", "url", @@ -4520,6 +4602,7 @@ dependencies = [ "chrono", "cookie", "criterion", + "dashmap 7.0.0-rc2", "derive_more", "desert_rust", "fastrand", @@ -4556,6 +4639,7 @@ dependencies = [ "prost-types", "regex", "reqwest", + "rmcp", "rsa", "rustc-hash 2.1.1", "rustls 0.23.35", @@ -5758,7 +5842,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tracing", ] @@ -7015,6 +7099,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pear" version = "0.2.9" @@ -7388,6 +7478,7 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.27.0", "tokio-util", + "tower 0.4.13", "tracing", "uuid", "wildmatch", @@ -7461,7 +7552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -8072,7 +8163,7 @@ version = "0.0.295" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15f4bf02d615730c6a651b808c48fa58649b037175e4c4e978d058a688d0c01b" dependencies = [ - "dashmap", + "dashmap 6.1.0", "indexmap 2.12.0", "la-arena", "ra_ap_cfg", @@ -8381,7 +8472,7 @@ version = "0.0.295" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60757258a7451954a11c3b54388608e67b0cc4e9b5a89ba11f4c1368a0ad91b" dependencies = [ - "dashmap", + "dashmap 6.1.0", "hashbrown 0.14.5", "rustc-hash 2.1.1", "triomphe", @@ -8629,6 +8720,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -8667,6 +8769,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_xorshift" version = "0.3.0" @@ -8913,7 +9021,7 @@ dependencies = [ "tokio-native-tls", "tokio-rustls 0.26.4", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -8988,6 +9096,50 @@ dependencies = [ "libc", ] +[[package]] +name = "rmcp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c9c94680f75470ee8083a0667988b5d7b5beb70b9f998a8e51de7c682ce60" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes 1.11.0", + "chrono", + "futures", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.0", + "rmcp-macros", + "schemars 1.1.0", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c23c8f26cae4da838fbc3eadfaecf2d549d97c04b558e7bd90526a9c28b42a" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.110", +] + [[package]] name = "rowan" version = "0.15.15" @@ -9364,7 +9516,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", ] @@ -9387,8 +9539,10 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive 1.1.0", "serde", "serde_json", ] @@ -9405,6 +9559,18 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "schemars_derive" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.110", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -9714,7 +9880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -9731,7 +9897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -10196,6 +10362,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes 1.11.0", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -10970,7 +11149,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tokio-stream", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -11056,7 +11235,7 @@ dependencies = [ "opentelemetry 0.30.0", "pin-project-lite", "tonic 0.13.1", - "tower", + "tower 0.5.2", "tracing", "tracing-opentelemetry", "tracing-opentelemetry-instrumentation-sdk", @@ -11074,6 +11253,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project 1.1.10", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -11108,7 +11304,7 @@ dependencies = [ "iri-string", "mime", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -11712,7 +11908,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -11819,6 +12024,16 @@ dependencies = [ "wasmparser 0.241.2", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + [[package]] name = "wasm-metadata" version = "0.229.0" @@ -11869,6 +12084,18 @@ dependencies = [ "wasmparser 0.240.0", ] +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + [[package]] name = "wasm-rquickjs" version = "0.0.23" @@ -11964,6 +12191,18 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.12.0", + "semver", +] + [[package]] name = "wasmprinter" version = "0.229.0" @@ -12980,6 +13219,15 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + [[package]] name = "wit-bindgen-core" version = "0.43.0" @@ -13002,6 +13250,17 @@ dependencies = [ "wit-parser 0.240.0", ] +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + [[package]] name = "wit-bindgen-moonbit" version = "0.47.0" @@ -13031,6 +13290,37 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.12.0", + "prettyplease", + "syn 2.0.110", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.110", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust", +] + [[package]] name = "wit-component" version = "0.235.0" @@ -13069,6 +13359,25 @@ dependencies = [ "wit-parser 0.240.0", ] +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.12.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + [[package]] name = "wit-encoder" version = "0.235.0" @@ -13136,6 +13445,24 @@ dependencies = [ "wasmparser 0.240.0", ] +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.12.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + [[package]] name = "witx" version = "0.9.1" diff --git a/Cargo.toml b/Cargo.toml index fd33992d7e..9569cf2f45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,7 @@ criterion = "0.5" crossbeam-channel = "0.5.15" crossterm = "0.28.1" darling = "0.20.11" +dashmap = "7.0.0-rc2" derive_more = { version = "2.0.1", features = ["display", "into", "from_str"] } desert_rust = { version = "0.1.7", features = ["bigdecimal", "uuid", "chrono", "nonempty-collections", "serde-json", "bit-vec", "url", "mac_address"] } dir-diff = "0.3.3" @@ -205,6 +206,7 @@ regex = "1.11.1" reqwest = { version = "0.12.13", features = ["gzip", "json", "multipart", "stream", ] } ringbuf = "0.4.7" rlimit = "0.10.2" +rmcp = {version = "0.16.0", features = ["server", "transport-streamable-http-server"] } rsa = "0.9.7" rustc-hash = "2.1.1" diff --git a/cli/golem-cli/src/client.rs b/cli/golem-cli/src/client.rs index 766d2d5e87..306d6ed8b1 100644 --- a/cli/golem-cli/src/client.rs +++ b/cli/golem-cli/src/client.rs @@ -20,7 +20,7 @@ use golem_client::api::{ ApiDeploymentClientLive, ApiDomainClientLive, ApiSecurityClientLive, ApplicationClientLive, ComponentClientLive, DeploymentClientLive, EnvironmentClientLive, GrantClientLive, HealthCheckClientLive, HttpApiDefinitionClientLive, LimitsClientLive, LoginClientLive, - PluginClientLive, TokenClientLive, WorkerClientLive, + McpDeploymentClientLive, PluginClientLive, TokenClientLive, WorkerClientLive, }; use golem_client::{Context as ClientContext, Security}; use golem_common::model::account::AccountId; @@ -47,6 +47,7 @@ pub struct GolemClients { pub grant: GrantClientLive, pub limits: LimitsClientLive, pub login: LoginClientLive, + pub mcp_deployment: McpDeploymentClientLive, pub plugin: PluginClientLive, pub token: TokenClientLive, pub worker: WorkerClientLive, @@ -159,6 +160,9 @@ impl GolemClients { login: LoginClientLive { context: login_context(), }, + mcp_deployment: McpDeploymentClientLive { + context: registry_context(), + }, plugin: PluginClientLive { context: registry_context(), }, diff --git a/cli/golem-cli/src/command.rs b/cli/golem-cli/src/command.rs index 2856b475b8..85874cf40e 100644 --- a/cli/golem-cli/src/command.rs +++ b/cli/golem-cli/src/command.rs @@ -1573,6 +1573,10 @@ pub mod server { #[clap(long)] pub custom_request_port: Option, + /// Port to serve custom requests on, defaults to 9006 + #[clap(long)] + pub mcp_port: Option, + /// Directory to store data in. Defaults to $XDG_STATE_HOME/golem #[clap(long)] pub data_dir: Option, @@ -1594,6 +1598,9 @@ pub mod server { pub fn custom_request_port(&self) -> u16 { self.custom_request_port.unwrap_or(9006) } + pub fn mcp_port(&self) -> u16 { + self.mcp_port.unwrap_or(9007) + } } #[derive(Debug, Subcommand)] diff --git a/cli/golem-cli/src/command_handler/api/deployment.rs b/cli/golem-cli/src/command_handler/api/deployment.rs index 8b79436997..e48c71223a 100644 --- a/cli/golem-cli/src/command_handler/api/deployment.rs +++ b/cli/golem-cli/src/command_handler/api/deployment.rs @@ -18,10 +18,10 @@ use crate::context::Context; use crate::error::service::{AnyhowMapServiceError, ServiceError}; use crate::log::{log_action, log_warn_action, LogColorize, LogIndent}; use crate::model::environment::{EnvironmentResolveMode, ResolvedEnvironmentIdentity}; -use crate::model::http_api::HttpApiDeploymentDeployProperties; +use crate::model::http_api::{HttpApiDeploymentDeployProperties, McpDeploymentDeployProperties}; use crate::model::text::http_api_deployment::HttpApiDeploymentGetView; use anyhow::{anyhow, bail}; -use golem_client::api::ApiDeploymentClient; +use golem_client::api::{ApiDeploymentClient, McpDeploymentClient}; use golem_common::cache::SimpleCache; use golem_common::model::deployment::DeploymentPlanHttpApiDeploymentEntry; use golem_common::model::diff; @@ -158,6 +158,34 @@ impl ApiDeploymentCommandHandler { .unwrap_or_default()) } + pub async fn deployable_manifest_mcp_deployments( + &self, + environment_name: &EnvironmentName, + ) -> anyhow::Result> + { + let app_ctx = self.ctx.app_context_lock().await; + let app_ctx = app_ctx.some_or_err()?; + Ok(app_ctx + .application() + .mcp_deployments(environment_name) + .map( + |deployments: &BTreeMap< + golem_common::model::domain_registration::Domain, + crate::model::app::WithSource< + crate::model::http_api::McpDeploymentDeployProperties, + >, + >| { + deployments + .iter() + .map(|(domain, mcp_deployment)| { + (domain.clone(), mcp_deployment.value.clone()) + }) + .collect::>() + }, + ) + .unwrap_or_default()) + } + pub async fn get_http_api_deployment_revision_by_id( &self, http_api_deployment_id: &HttpApiDeploymentId, @@ -337,4 +365,151 @@ impl ApiDeploymentCommandHandler { Ok(()) } + + pub async fn create_staged_mcp_deployment( + &self, + environment_id: &ResolvedEnvironmentIdentity, + domain: &Domain, + deployable_mcp_deployment: &McpDeploymentDeployProperties, + ) -> anyhow::Result<()> { + let clients = self.ctx.golem_clients().await?; + + log_action( + "Creating", + format!("MCP deployment {}", domain.0.log_color_highlight()), + ); + let _indent = LogIndent::new(); + + let agents = deployable_mcp_deployment + .agents + .keys() + .map(|k| { + ( + k.clone(), + golem_common::model::mcp_deployment::McpDeploymentAgentOptions::default(), + ) + }) + .collect(); + + let mcp_creation = golem_common::model::mcp_deployment::McpDeploymentCreation { + domain: domain.clone(), + agents, + }; + + let create = async || { + clients + .mcp_deployment + .create_mcp_deployment(&environment_id.environment_id.0, &mcp_creation) + .await + .map_err(ServiceError::from) + }; + + let deployment = match create().await { + Ok(result) => Ok(result), + Err(err) if err.is_domain_is_not_registered() => { + self.ctx + .api_domain_handler() + .register_missing_domain(&environment_id.environment_id, domain) + .await?; + + create().await + } + Err(err) => Err(err), + }?; + + log_action( + "Created", + format!( + "MCP deployment revision: {} {}", + deployment.domain.0.log_color_highlight(), + deployment.revision.to_string().log_color_highlight() + ), + ); + + Ok(()) + } + + pub async fn delete_staged_mcp_deployment( + &self, + mcp_deployment: &golem_common::model::deployment::DeploymentPlanMcpDeploymentEntry, + ) -> anyhow::Result<()> { + log_warn_action( + "Deleting", + format!( + "MCP deployment {}", + mcp_deployment.domain.0.log_color_highlight() + ), + ); + let _indent = LogIndent::new(); + + self.ctx + .golem_clients() + .await? + .mcp_deployment + .delete_mcp_deployment(&mcp_deployment.id.0, mcp_deployment.revision.into()) + .await + .map_service_error()?; + + log_action( + "Deleted", + format!( + "MCP deployment revision: {} {}", + mcp_deployment.domain.0.log_color_highlight(), + mcp_deployment.revision.to_string().log_color_highlight() + ), + ); + + Ok(()) + } + + pub async fn update_staged_mcp_deployment( + &self, + mcp_deployment: &golem_common::model::deployment::DeploymentPlanMcpDeploymentEntry, + update: &golem_common::model::mcp_deployment::McpDeploymentUpdate, + diff: &diff::DiffForHashOf, + ) -> anyhow::Result<()> { + log_action( + "Updating", + format!( + "MCP deployment {}", + mcp_deployment.domain.0.log_color_highlight() + ), + ); + let _indent = LogIndent::new(); + + let agents_changed = match diff { + diff::DiffForHashOf::HashDiff { .. } => true, + diff::DiffForHashOf::ValueDiff { diff } => !diff.agents_changes.is_empty(), + }; + + let deployment = self + .ctx + .golem_clients() + .await? + .mcp_deployment + .update_mcp_deployment( + &mcp_deployment.id.0, + &golem_common::model::mcp_deployment::McpDeploymentUpdate { + current_revision: update.current_revision, + agents: if agents_changed { + update.agents.clone() + } else { + None + }, + }, + ) + .await + .map_service_error()?; + + log_action( + "Updated", + format!( + "MCP deployment revision: {} {}", + deployment.domain.0.log_color_highlight(), + deployment.revision.to_string().log_color_highlight() + ), + ); + + Ok(()) + } } diff --git a/cli/golem-cli/src/command_handler/app/deploy_diff.rs b/cli/golem-cli/src/command_handler/app/deploy_diff.rs index 1c93391d02..e06b5dffed 100644 --- a/cli/golem-cli/src/command_handler/app/deploy_diff.rs +++ b/cli/golem-cli/src/command_handler/app/deploy_diff.rs @@ -14,7 +14,7 @@ use crate::model::component::{show_exported_agents, ComponentDeployProperties}; use crate::model::environment::ResolvedEnvironmentIdentity; -use crate::model::http_api::HttpApiDeploymentDeployProperties; +use crate::model::http_api::{HttpApiDeploymentDeployProperties, McpDeploymentDeployProperties}; use crate::model::text::component::is_sensitive_env_var_name; use anyhow::bail; use golem_client::model::{DeploymentPlan, DeploymentSummary}; @@ -38,6 +38,8 @@ pub struct DeployQuickDiff { pub deployable_manifest_components: BTreeMap, pub deployable_manifest_http_api_deployments: BTreeMap, + #[allow(dead_code)] + pub deployable_manifest_mcp_deployments: BTreeMap, pub diffable_local_deployment: diff::Deployment, pub local_deployment_hash: diff::Hash, } @@ -75,6 +77,8 @@ pub struct DeployDiff { pub environment: ResolvedEnvironmentIdentity, pub deployable_components: BTreeMap, pub deployable_http_api_deployments: BTreeMap, + #[allow(dead_code)] + pub deployable_mcp_deployments: BTreeMap, pub diffable_local_deployment: diff::Deployment, pub local_deployment_hash: diff::Hash, #[allow(unused)] // NOTE: for debug logs @@ -226,6 +230,21 @@ impl DeployDiff { }) } + #[allow(dead_code)] + pub fn deployable_manifest_mcp_deployment( + &self, + domain: &Domain, + ) -> &McpDeploymentDeployProperties { + self.deployable_mcp_deployments + .get(domain) + .unwrap_or_else(|| { + panic!( + "Expected MCP deployment {} not found in deployable manifest", + domain + ) + }) + } + pub fn staged_component_identity( &self, component_name: &ComponentName, @@ -258,6 +277,22 @@ impl DeployDiff { }) } + pub fn staged_mcp_deployment_identity( + &self, + domain: &Domain, + ) -> &golem_common::model::deployment::DeploymentPlanMcpDeploymentEntry { + self.staged_deployment + .mcp_deployments + .iter() + .find(|dep| &dep.domain == domain) + .unwrap_or_else(|| { + panic!( + "Expected MCP deployment {} not found in staged deployment", + domain + ) + }) + } + pub fn current_component_identity( &self, component_name: &ComponentName, @@ -759,6 +794,14 @@ fn normalized_diff_deployment( }) .map(|(k, v)| (k.clone(), v.clone())) .collect(), + mcp_deployments: deployment + .mcp_deployments + .iter() + .filter(|(domain, _)| { + diff.is_some_and(|diff| diff.mcp_deployments.contains_key(*domain)) + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), } } diff --git a/cli/golem-cli/src/command_handler/app/mod.rs b/cli/golem-cli/src/command_handler/app/mod.rs index 9cef61568c..aa6335a231 100644 --- a/cli/golem-cli/src/command_handler/app/mod.rs +++ b/cli/golem-cli/src/command_handler/app/mod.rs @@ -945,6 +945,12 @@ impl AppCommandHandler { .deployable_manifest_api_deployments(&environment.environment_name) .await?; + let deployable_manifest_mcp_deployments = self + .ctx + .api_deployment_handler() + .deployable_manifest_mcp_deployments(&environment.environment_name) + .await?; + let diffable_local_components = { let mut diffable_components = BTreeMap::>::new(); for (component_name, component_deploy_properties) in &deployable_manifest_components { @@ -984,9 +990,25 @@ impl AppCommandHandler { diffable_local_http_api_deployments }; + let diffable_local_mcp_deployments = { + let mut diffable_local_mcp_deployments = + BTreeMap::>::new(); + for (domain, mcp_deployment) in &deployable_manifest_mcp_deployments { + let agents = mcp_deployment + .agents + .iter() + .map(|(k, v)| (k.0.clone(), v.to_diffable())) + .collect(); + diffable_local_mcp_deployments + .insert(domain.0.clone(), diff::McpDeployment { agents }.into()); + } + diffable_local_mcp_deployments + }; + let diffable_local_deployment = diff::Deployment { components: diffable_local_components, http_api_deployments: diffable_local_http_api_deployments, + mcp_deployments: diffable_local_mcp_deployments, }; let local_deployment_hash = diffable_local_deployment.hash(); @@ -995,6 +1017,7 @@ impl AppCommandHandler { environment, deployable_manifest_components, deployable_manifest_http_api_deployments, + deployable_manifest_mcp_deployments, diffable_local_deployment, local_deployment_hash, }) @@ -1052,6 +1075,7 @@ impl AppCommandHandler { deployable_components: deploy_quick_diff.deployable_manifest_components, deployable_http_api_deployments: deploy_quick_diff .deployable_manifest_http_api_deployments, + deployable_mcp_deployments: deploy_quick_diff.deployable_manifest_mcp_deployments, diffable_local_deployment: deploy_quick_diff.diffable_local_deployment, local_deployment_hash: deploy_quick_diff.local_deployment_hash, current_deployment, @@ -1405,6 +1429,7 @@ impl AppCommandHandler { Ok(()) }; + // TODO for (component_name, component_diff) in &diff_stage.components { approve()?; @@ -1474,6 +1499,53 @@ impl AppCommandHandler { } } + for (domain, mcp_deployment_diff) in &diff_stage.mcp_deployments { + approve()?; + + let domain = Domain(domain.to_string()); + + match mcp_deployment_diff { + diff::BTreeMapDiffValue::Create => { + let mcp_deployment_handler = self.ctx.api_deployment_handler(); + mcp_deployment_handler + .create_staged_mcp_deployment( + &deploy_diff.environment, + &domain, + deploy_diff.deployable_manifest_mcp_deployment(&domain), + ) + .await? + } + diff::BTreeMapDiffValue::Delete => { + let mcp_deployment_handler = self.ctx.api_deployment_handler(); + mcp_deployment_handler + .delete_staged_mcp_deployment( + deploy_diff.staged_mcp_deployment_identity(&domain), + ) + .await? + } + diff::BTreeMapDiffValue::Update(mcp_deployment_diff) => { + let mcp_deployment_handler = self.ctx.api_deployment_handler(); + let mcp_deployment = deploy_diff.deployable_manifest_mcp_deployment(&domain); + let agents = mcp_deployment + .agents.keys().map(|k| (k.clone(), golem_common::model::mcp_deployment::McpDeploymentAgentOptions::default())) + .collect(); + + mcp_deployment_handler + .update_staged_mcp_deployment( + deploy_diff.staged_mcp_deployment_identity(&domain), + &golem_common::model::mcp_deployment::McpDeploymentUpdate { + current_revision: deploy_diff + .staged_mcp_deployment_identity(&domain) + .revision, + agents: Some(agents), + }, + mcp_deployment_diff, + ) + .await? + } + } + } + Ok(()) } diff --git a/cli/golem-cli/src/error.rs b/cli/golem-cli/src/error.rs index c11f50bcb5..cf20ce486a 100644 --- a/cli/golem-cli/src/error.rs +++ b/cli/golem-cli/src/error.rs @@ -77,7 +77,8 @@ pub mod service { ApplicationError, ComponentError, EnvironmentError, LoginCompleteOauth2DeviceFlowError, LoginCurrentLoginTokenError, LoginLoginOauth2Error, LoginPollOauth2WebflowError, LoginStartOauth2DeviceFlowError, LoginStartOauth2WebflowError, - LoginSubmitOauth2WebflowCallbackError, PluginError, TokenError, WorkerError, + LoginSubmitOauth2WebflowCallbackError, McpDeploymentError, PluginError, TokenError, + WorkerError, }; use golem_common::model::{PromiseId, WorkerId}; use itertools::Itertools; @@ -105,7 +106,7 @@ pub mod service { pub fn is_domain_is_not_registered(&self) -> bool { match &self.kind { ServiceErrorKind::ErrorResponse(err) => { - err.status_code == 409 + (err.status_code == 409 || err.status_code == 404) && err.message.starts_with("Domain") && err.message.ends_with("is not registered") } @@ -1051,6 +1052,47 @@ pub mod service { } } + impl HasServiceName for McpDeploymentError { + fn service_name() -> &'static str { + "MCP Deployment" + } + } + + impl From for ServiceErrorResponse { + fn from(value: McpDeploymentError) -> Self { + match value { + McpDeploymentError::Error400(errors) => ServiceErrorResponse { + status_code: 400, + message: errors.errors.join("\n"), + }, + McpDeploymentError::Error401(error) => ServiceErrorResponse { + status_code: 401, + message: error.error, + }, + McpDeploymentError::Error403(error) => ServiceErrorResponse { + status_code: 403, + message: error.error, + }, + McpDeploymentError::Error404(error) => ServiceErrorResponse { + status_code: 404, + message: error.error, + }, + McpDeploymentError::Error409(error) => ServiceErrorResponse { + status_code: 409, + message: error.error, + }, + McpDeploymentError::Error422(error) => ServiceErrorResponse { + status_code: 422, + message: error.error, + }, + McpDeploymentError::Error500(error) => ServiceErrorResponse { + status_code: 500, + message: error.error, + }, + } + } + } + pub fn display_worker_id(worker_id: WorkerId) -> String { format!("{}/{}", worker_id.component_id, worker_id.worker_name) } diff --git a/cli/golem-cli/src/model/app.rs b/cli/golem-cli/src/model/app.rs index c573e186d9..f1e79e26a3 100644 --- a/cli/golem-cli/src/model/app.rs +++ b/cli/golem-cli/src/model/app.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::http_api::HttpApiDeploymentDeployProperties; +use super::http_api::{HttpApiDeploymentDeployProperties, McpDeploymentDeployProperties}; use crate::bridge_gen::bridge_client_directory_name; use crate::fs; use crate::log::LogColorize; @@ -338,6 +338,8 @@ pub struct Application { clean: Vec>, http_api_deployments: BTreeMap>>, + mcp_deployments: + BTreeMap>>, bridge_sdks: WithSource, } @@ -485,6 +487,13 @@ impl Application { ) -> Option<&BTreeMap>> { self.http_api_deployments.get(environment) } + + pub fn mcp_deployments( + &self, + environment: &EnvironmentName, + ) -> Option<&BTreeMap>> { + self.mcp_deployments.get(environment) + } } #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] @@ -1442,7 +1451,9 @@ mod app_builder { }; use crate::model::app_raw; use crate::model::cascade::store::Store; - use crate::model::http_api::HttpApiDeploymentDeployProperties; + use crate::model::http_api::{ + HttpApiDeploymentDeployProperties, McpDeploymentAgentOptions, McpDeploymentDeployProperties, + }; use crate::validation::{ValidatedResult, ValidationBuilder}; use crate::{fs, fuzzy}; use colored::Colorize; @@ -1556,6 +1567,9 @@ mod app_builder { BTreeMap>, >, + mcp_deployments: + BTreeMap>>, + bridge_sdks: WithSource, all_sources: BTreeSet, @@ -1603,6 +1617,7 @@ mod app_builder { custom_commands: builder.custom_commands, clean: builder.clean, http_api_deployments: builder.http_api_deployments, + mcp_deployments: builder.mcp_deployments, bridge_sdks: builder.bridge_sdks, }) } @@ -1778,6 +1793,25 @@ mod app_builder { } } + if let Some(mcp) = app.application.mcp { + for (environment, deployments) in mcp.deployments { + for mcp_deployment in deployments { + let mcp_deployments = + self.mcp_deployments.entry(environment.clone()).or_default(); + + let agents = mcp_deployment.agents + .into_iter() + .map(|(k, _v)| (k, McpDeploymentAgentOptions {})) + .collect(); + + mcp_deployments.entry(mcp_deployment.domain.clone()).or_insert(WithSource::new( + app.source.to_path_buf(), + McpDeploymentDeployProperties { agents }, + )); + } + } + } + if let Some(bridge) = app.application.bridge { if self .add_entity_source(UniqueSourceCheckedEntityKey::Bridge, app_source_dir) diff --git a/cli/golem-cli/src/model/app_raw.rs b/cli/golem-cli/src/model/app_raw.rs index 5d9e28ed4c..24a3fb6014 100644 --- a/cli/golem-cli/src/model/app_raw.rs +++ b/cli/golem-cli/src/model/app_raw.rs @@ -100,6 +100,8 @@ pub struct Application { pub clean: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub http_api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp: Option, #[serde(default, skip_serializing_if = "IndexMap::is_empty")] pub environments: IndexMap, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -257,6 +259,27 @@ pub struct HttpApiDeployment { pub agents: IndexMap, } +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Mcp { + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub deployments: IndexMap>, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpDeployment { + pub domain: Domain, + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub agents: IndexMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpDeploymentAgentOptions { + // MCP agent configuration options coming soon +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Environment { diff --git a/cli/golem-cli/src/model/http_api.rs b/cli/golem-cli/src/model/http_api.rs index c4949522ab..1261e37203 100644 --- a/cli/golem-cli/src/model/http_api.rs +++ b/cli/golem-cli/src/model/http_api.rs @@ -21,3 +21,19 @@ pub struct HttpApiDeploymentDeployProperties { pub webhooks_url: String, pub agents: BTreeMap, } + +#[derive(Clone, Debug)] +pub struct McpDeploymentDeployProperties { + pub agents: BTreeMap, +} + +#[derive(Clone, Debug)] +pub struct McpDeploymentAgentOptions { + // MCP agent configuration options coming soon +} + +impl McpDeploymentAgentOptions { + pub fn to_diffable(&self) -> golem_common::model::diff::McpDeploymentAgentOptions { + golem_common::model::diff::McpDeploymentAgentOptions {} + } +} diff --git a/cli/golem-cli/src/model/text/diff.rs b/cli/golem-cli/src/model/text/diff.rs index 2bbe42c354..6cd97b67ee 100644 --- a/cli/golem-cli/src/model/text/diff.rs +++ b/cli/golem-cli/src/model/text/diff.rs @@ -185,6 +185,35 @@ impl TextView for DeploymentDiff { } logln(""); } + if !self.mcp_deployments.is_empty() { + logln("MCP deployment changes:".log_color_help_group().to_string()); + for (domain, mcp_deployment_diff) in &self.mcp_deployments { + match mcp_deployment_diff { + BTreeMapDiffValue::Create => { + logln(format!( + " - {} MCP deployment {}", + "create".green(), + domain.log_color_highlight() + )); + } + BTreeMapDiffValue::Delete => { + logln(format!( + " - {} MCP deployment {}", + "delete".red(), + domain.log_color_highlight() + )); + } + BTreeMapDiffValue::Update(_diff) => { + logln(format!( + " - {} MCP deployment {}", + "update".yellow(), + domain.log_color_highlight() + )); + } + } + } + logln(""); + } } } diff --git a/cli/golem/src/command_handler.rs b/cli/golem/src/command_handler.rs index 2a135cb623..dd48de59ed 100644 --- a/cli/golem/src/command_handler.rs +++ b/cli/golem/src/command_handler.rs @@ -52,6 +52,7 @@ impl CommandHandlerHooks for ServerCommandHandler { router_addr: args.router_addr().to_string(), router_port: args.router_port(), custom_request_port: args.custom_request_port(), + mcp_port: args.mcp_port(), data_dir, }) .await?; @@ -73,6 +74,7 @@ impl CommandHandlerHooks for ServerCommandHandler { router_addr: args.router_addr().to_string(), router_port: args.router_port(), custom_request_port: args.custom_request_port(), + mcp_port: args.mcp_port(), data_dir: default_data_dir()?, }) .await?; diff --git a/cli/golem/src/launch.rs b/cli/golem/src/launch.rs index 4aad4c4b68..797786f3a1 100644 --- a/cli/golem/src/launch.rs +++ b/cli/golem/src/launch.rs @@ -61,6 +61,7 @@ pub struct LaunchArgs { pub router_addr: String, pub router_port: u16, pub custom_request_port: u16, + pub mcp_port: u16, pub data_dir: PathBuf, } @@ -332,6 +333,7 @@ fn worker_service_config( WorkerServiceConfig { port: 0, custom_request_port: args.custom_request_port, + mcp_port: args.mcp_port, grpc: golem_worker_service::config::GrpcApiConfig { port: 0, ..Default::default() diff --git a/golem-api-grpc/proto/golem/customapi/core.proto b/golem-api-grpc/proto/golem/customapi/core.proto index 5b3730ea24..12f9655112 100644 --- a/golem-api-grpc/proto/golem/customapi/core.proto +++ b/golem-api-grpc/proto/golem/customapi/core.proto @@ -147,6 +147,7 @@ message MethodParameter { message UnstructuredBinaryBody { } } + message CompiledRoutes { golem.common.AccountId account_id = 1; golem.common.EnvironmentId environment_id = 2; diff --git a/golem-api-grpc/proto/golem/mcp/core.proto b/golem-api-grpc/proto/golem/mcp/core.proto new file mode 100644 index 0000000000..411896abfb --- /dev/null +++ b/golem-api-grpc/proto/golem/mcp/core.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package golem.mcp; + +import "golem/common/environment.proto"; +import "golem/common/account_id.proto"; +import "golem/registry/agent_deployment.proto"; + +message CompiledMcp { + golem.common.AccountId account_id = 1; + golem.common.EnvironmentId environment_id = 2; + uint64 deployment_revision = 3; + string domain = 4; + map agent_type_implementers = 5; +} diff --git a/golem-api-grpc/proto/golem/registry/v1/registry_service.proto b/golem-api-grpc/proto/golem/registry/v1/registry_service.proto index 6c4669863b..61935a55af 100644 --- a/golem-api-grpc/proto/golem/registry/v1/registry_service.proto +++ b/golem-api-grpc/proto/golem/registry/v1/registry_service.proto @@ -18,6 +18,7 @@ import "golem/registry/agent_deployment.proto"; import "golem/registry/v1/registry_service_error.proto"; import "golem/worker/worker_id.proto"; import "golem/customapi/core.proto"; +import "golem/mcp/core.proto"; import "golem/auth/auth_details_for_environment.proto"; service RegistryService { @@ -48,6 +49,7 @@ service RegistryService { // current deployment agents/routes rpc GetActiveRoutesForDomain (GetActiveRoutesForDomainRequest) returns (GetActiveRoutesForDomainResponse); + rpc GetActiveMcpForDomain (GetActiveMcpForDomainRequest) returns (GetActiveMcpForDomainResponse); rpc GetAgentDeployments (GetAgentDeploymentsRequest) returns (GetAgentDeploymentsResponse); } @@ -311,6 +313,10 @@ message GetActiveRoutesForDomainRequest { string domain = 1; } +message GetActiveMcpForDomainRequest { + string domain = 1; +} + message GetActiveRoutesForDomainResponse { oneof result { GetActiveRoutesForDomainSuccessResponse success = 1; @@ -318,10 +324,21 @@ message GetActiveRoutesForDomainResponse { } } +message GetActiveMcpForDomainResponse { + oneof result { + GetActiveMcpForDomainSuccessResponse success = 1; + RegistryServiceError error = 2; + } +} + message GetActiveRoutesForDomainSuccessResponse { golem.customapi.CompiledRoutes compiled_routes = 1; } +message GetActiveMcpForDomainSuccessResponse { + golem.mcp.CompiledMcp compiled_mcp = 1; +} + message GetAgentDeploymentsRequest { golem.common.EnvironmentId environment_id = 1; } diff --git a/golem-client/build.rs b/golem-client/build.rs index 553cbd7207..d9331cf17f 100644 --- a/golem-client/build.rs +++ b/golem-client/build.rs @@ -287,6 +287,18 @@ fn generate(yaml_path: PathBuf, out_dir: OsString) { "HttpApiDeploymentUpdate", "golem_common::model::http_api_deployment::HttpApiDeploymentUpdate", ), + ( + "McpDeployment", + "golem_common::model::mcp_deployment::McpDeployment", + ), + ( + "McpDeploymentCreation", + "golem_common::model::mcp_deployment::McpDeploymentCreation", + ), + ( + "McpDeploymentUpdate", + "golem_common::model::mcp_deployment::McpDeploymentUpdate", + ), // common ("Empty", "golem_common::model::Empty"), ("ErrorBody", "golem_common::model::error::ErrorBody"), diff --git a/golem-common/src/base_model/deployment.rs b/golem-common/src/base_model/deployment.rs index e1b09a44c9..425d3662ef 100644 --- a/golem-common/src/base_model/deployment.rs +++ b/golem-common/src/base_model/deployment.rs @@ -17,6 +17,7 @@ use crate::base_model::diff::Hash; use crate::base_model::domain_registration::Domain; use crate::base_model::environment::EnvironmentId; use crate::base_model::http_api_deployment::{HttpApiDeploymentId, HttpApiDeploymentRevision}; +use crate::base_model::mcp_deployment::{McpDeploymentId, McpDeploymentRevision}; use crate::{declare_revision, declare_structs, declare_transparent_newtypes}; use derive_more::Display; @@ -75,7 +76,8 @@ declare_structs! { pub current_revision: Option, pub deployment_hash: Hash, pub components: Vec, - pub http_api_deployments: Vec + pub http_api_deployments: Vec, + pub mcp_deployments: Vec, } /// Summary of all entities tracked by the deployment @@ -83,7 +85,8 @@ declare_structs! { pub deployment_revision: DeploymentRevision, pub deployment_hash: Hash, pub components: Vec, - pub http_api_deployments: Vec + pub http_api_deployments: Vec, + pub mcp_deployments: Vec, } pub struct DeploymentPlanComponentEntry { @@ -99,4 +102,11 @@ declare_structs! { pub domain: Domain, pub hash: Hash, } + + pub struct DeploymentPlanMcpDeploymentEntry { + pub id: McpDeploymentId, + pub revision: McpDeploymentRevision, + pub domain: Domain, + pub hash: Hash, + } } diff --git a/golem-common/src/base_model/mcp_deployment.rs b/golem-common/src/base_model/mcp_deployment.rs new file mode 100644 index 0000000000..098b45e25e --- /dev/null +++ b/golem-common/src/base_model/mcp_deployment.rs @@ -0,0 +1,53 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::base_model::agent::AgentTypeName; +use crate::base_model::diff; +use crate::base_model::domain_registration::Domain; +use crate::base_model::environment::EnvironmentId; +use crate::{declare_revision, declare_structs, newtype_uuid}; +use chrono::DateTime; +use std::collections::BTreeMap; + +newtype_uuid!(McpDeploymentId); + +declare_revision!(McpDeploymentRevision); + +declare_structs! { + #[derive(Default)] + #[cfg_attr(feature = "full", derive(desert_rust::BinaryCodec))] + pub struct McpDeploymentAgentOptions { + // TODO: MCP agent configuration options coming soon + } + + pub struct McpDeploymentCreation { + pub domain: Domain, + pub agents: BTreeMap, + } + + pub struct McpDeploymentUpdate { + pub current_revision: McpDeploymentRevision, + pub agents: Option>, + } + + pub struct McpDeployment { + pub id: McpDeploymentId, + pub revision: McpDeploymentRevision, + pub environment_id: EnvironmentId, + pub domain: Domain, + pub hash: diff::Hash, + pub agents: BTreeMap, + pub created_at: DateTime, + } +} diff --git a/golem-common/src/base_model/mod.rs b/golem-common/src/base_model/mod.rs index 635a851194..6a3c354652 100644 --- a/golem-common/src/base_model/mod.rs +++ b/golem-common/src/base_model/mod.rs @@ -31,6 +31,7 @@ pub mod error; pub mod http_api_deployment; pub mod invocation_context; pub mod login; +pub mod mcp_deployment; pub mod oplog; pub mod plan; pub mod plugin_registration; diff --git a/golem-common/src/model/deployment.rs b/golem-common/src/model/deployment.rs index 1697cca5d1..178277175d 100644 --- a/golem-common/src/model/deployment.rs +++ b/golem-common/src/model/deployment.rs @@ -40,6 +40,11 @@ impl DeploymentPlan { .iter() .map(|had| (had.domain.0.clone(), had.hash.into())) .collect(), + mcp_deployments: self + .mcp_deployments + .iter() + .map(|mcd| (mcd.domain.0.clone(), mcd.hash.into())) + .collect(), } } } @@ -57,6 +62,11 @@ impl DeploymentSummary { .iter() .map(|had| (had.domain.0.clone(), had.hash.into())) .collect(), + mcp_deployments: self + .mcp_deployments + .iter() + .map(|mcd| (mcd.domain.0.clone(), mcd.hash.into())) + .collect(), } } } diff --git a/golem-common/src/model/diff/deployment.rs b/golem-common/src/model/diff/deployment.rs index ca200d43f5..10e2513f16 100644 --- a/golem-common/src/model/diff/deployment.rs +++ b/golem-common/src/model/diff/deployment.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::HttpApiDeployment; +use super::{HttpApiDeployment, McpDeployment}; use crate::model::diff::component::Component; use crate::model::diff::hash::{hash_from_serialized_value, Hash, HashOf, Hashable}; use crate::model::diff::ser::serialize_with_mode; @@ -29,6 +29,9 @@ pub struct Deployment { #[serde(skip_serializing_if = "BTreeMap::is_empty")] #[serde(serialize_with = "serialize_with_mode")] pub http_api_deployments: BTreeMap>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + #[serde(serialize_with = "serialize_with_mode")] + pub mcp_deployments: BTreeMap>, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -38,6 +41,8 @@ pub struct DeploymentDiff { pub components: BTreeMapDiff>, #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub http_api_deployments: BTreeMapDiff>, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub mcp_deployments: BTreeMapDiff>, } impl Diffable for Deployment { @@ -48,11 +53,15 @@ impl Diffable for Deployment { let http_api_deployments = new .http_api_deployments .diff_with_current(¤t.http_api_deployments); + let mcp_deployments = new + .mcp_deployments + .diff_with_current(¤t.mcp_deployments); - if components.is_some() || http_api_deployments.is_some() { + if components.is_some() || http_api_deployments.is_some() || mcp_deployments.is_some() { Some(DeploymentDiff { components: components.unwrap_or_default(), http_api_deployments: http_api_deployments.unwrap_or_default(), + mcp_deployments: mcp_deployments.unwrap_or_default(), }) } else { None diff --git a/golem-common/src/model/diff/mcp_deployment.rs b/golem-common/src/model/diff/mcp_deployment.rs new file mode 100644 index 0000000000..607c859d41 --- /dev/null +++ b/golem-common/src/model/diff/mcp_deployment.rs @@ -0,0 +1,74 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::model::diff::{hash_from_serialized_value, BTreeMapDiff, Diffable, Hash, Hashable}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpDeploymentAgentOptions { + // TODO: MCP agent configuration options coming soon +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpDeploymentAgentOptionsDiff { + // TODO: MCP agent configuration diff tracking coming soon +} + +impl Diffable for McpDeploymentAgentOptions { + type DiffResult = McpDeploymentAgentOptionsDiff; + + fn diff(_new: &Self, _current: &Self) -> Option { + // TODO: Implement diff when configuration options are added + None + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpDeployment { + pub agents: BTreeMap, +} + +impl Hashable for McpDeployment { + fn hash(&self) -> Hash { + hash_from_serialized_value(self) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpDeploymentDiff { + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub agents_changes: BTreeMapDiff, +} + +impl Diffable for McpDeployment { + type DiffResult = McpDeploymentDiff; + + fn diff(new: &Self, current: &Self) -> Option { + let agents_changes = new + .agents + .diff_with_current(¤t.agents) + .unwrap_or_default(); + + if !agents_changes.is_empty() { + Some(Self::DiffResult { agents_changes }) + } else { + None + } + } +} diff --git a/golem-common/src/model/diff/mod.rs b/golem-common/src/model/diff/mod.rs index 9edbf2c678..d8e5c630d5 100644 --- a/golem-common/src/model/diff/mod.rs +++ b/golem-common/src/model/diff/mod.rs @@ -17,6 +17,7 @@ mod deployment; mod environment; mod hash; mod http_api_deployment; +mod mcp_deployment; mod plugin; mod ser; @@ -25,6 +26,7 @@ pub use deployment::*; pub use environment::*; pub use hash::*; pub use http_api_deployment::*; +pub use mcp_deployment::*; pub use plugin::*; pub use ser::*; diff --git a/golem-common/src/model/mcp_deployment.rs b/golem-common/src/model/mcp_deployment.rs new file mode 100644 index 0000000000..b30a577320 --- /dev/null +++ b/golem-common/src/model/mcp_deployment.rs @@ -0,0 +1,28 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub use crate::base_model::mcp_deployment::*; +use crate::model::diff; + +impl McpDeployment { + pub fn to_diffable(&self) -> diff::McpDeployment { + diff::McpDeployment { + agents: self + .agents + .keys() + .map(|k| (k.0.clone(), diff::McpDeploymentAgentOptions::default())) + .collect(), + } + } +} diff --git a/golem-common/src/model/mod.rs b/golem-common/src/model/mod.rs index 0e4699238f..2297554cf0 100644 --- a/golem-common/src/model/mod.rs +++ b/golem-common/src/model/mod.rs @@ -32,6 +32,7 @@ pub mod http_api_deployment; pub mod invocation_context; pub mod login; pub mod lucene; +pub mod mcp_deployment; pub mod oplog; pub mod optional_field_update; #[cfg(feature = "full")] diff --git a/golem-registry-service/db/migration/postgres/005_mcp_deployment.sql b/golem-registry-service/db/migration/postgres/005_mcp_deployment.sql new file mode 100644 index 0000000000..e26f7f4c54 --- /dev/null +++ b/golem-registry-service/db/migration/postgres/005_mcp_deployment.sql @@ -0,0 +1,79 @@ +CREATE TABLE mcp_deployments +( + mcp_deployment_id UUID NOT NULL, + environment_id UUID NOT NULL, + domain TEXT NOT NULL, + + created_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + modified_by UUID NOT NULL, + + current_revision_id BIGINT NOT NULL, + + CONSTRAINT mcp_deployments_pk + PRIMARY KEY (mcp_deployment_id), + CONSTRAINT mcp_deployments_environments_fk + FOREIGN KEY (environment_id) REFERENCES environments +); + +CREATE UNIQUE INDEX mcp_deployments_domain_uk + ON mcp_deployments (environment_id, domain) + WHERE deleted_at IS NULL; + +CREATE TABLE mcp_deployment_revisions +( + mcp_deployment_id UUID NOT NULL, + revision_id BIGINT NOT NULL, + + hash BYTEA NOT NULL, + + created_at TIMESTAMP NOT NULL, + created_by UUID NOT NULL, + deleted BOOLEAN NOT NULL, + + data BYTEA NOT NULL, + + CONSTRAINT mcp_deployment_revisions_pk + PRIMARY KEY (mcp_deployment_id, revision_id), + CONSTRAINT mcp_deployment_revisions_deployments_fk + FOREIGN KEY (mcp_deployment_id) REFERENCES mcp_deployments +); + +CREATE INDEX mcp_deployment_revisions_latest_revision_by_id_idx + ON mcp_deployment_revisions (mcp_deployment_id, revision_id DESC); + +CREATE TABLE deployment_compiled_mcp +( + account_id UUID NOT NULL, + environment_id UUID NOT NULL, + deployment_revision_id BIGINT NOT NULL, + domain TEXT NOT NULL, + mcp_data BYTEA NOT NULL, + + CONSTRAINT deployment_compiled_mcp_pk + PRIMARY KEY (environment_id, deployment_revision_id, domain), + CONSTRAINT deployment_compiled_mcp_deployments_fk + FOREIGN KEY (environment_id, deployment_revision_id) REFERENCES deployment_revisions +); + +CREATE INDEX deployment_compiled_mcp_domain_idx + ON deployment_compiled_mcp (domain); + +CREATE TABLE deployment_mcp_deployment_revisions +( + environment_id UUID NOT NULL, + deployment_revision_id BIGINT NOT NULL, + mcp_deployment_id UUID NOT NULL, + mcp_deployment_revision_id BIGINT NOT NULL, + CONSTRAINT deployment_mcp_deployment_revisions_pk + PRIMARY KEY (environment_id, deployment_revision_id, mcp_deployment_id), + CONSTRAINT deployment_mcp_deployment_revisions_deployment_fk + FOREIGN KEY (environment_id, deployment_revision_id) + REFERENCES deployment_revisions (environment_id, revision_id), + CONSTRAINT deployment_mcp_deployment_revisions_mcp_fk + FOREIGN KEY (mcp_deployment_id, mcp_deployment_revision_id) + REFERENCES mcp_deployment_revisions (mcp_deployment_id, revision_id) +); + +CREATE INDEX deployment_mcp_deployment_revisions_mcp_idx + ON deployment_mcp_deployment_revisions (mcp_deployment_id, mcp_deployment_revision_id); \ No newline at end of file diff --git a/golem-registry-service/db/migration/sqlite/005_mcp_deployment.sql b/golem-registry-service/db/migration/sqlite/005_mcp_deployment.sql new file mode 100644 index 0000000000..ea35db3f35 --- /dev/null +++ b/golem-registry-service/db/migration/sqlite/005_mcp_deployment.sql @@ -0,0 +1,73 @@ +CREATE TABLE mcp_deployments +( + mcp_deployment_id TEXT NOT NULL, + environment_id TEXT NOT NULL, + domain TEXT NOT NULL, + + created_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + modified_by TEXT NOT NULL, + + current_revision_id INTEGER NOT NULL, + + PRIMARY KEY (mcp_deployment_id), + FOREIGN KEY (environment_id) REFERENCES environments +); + +CREATE UNIQUE INDEX mcp_deployments_domain_uk + ON mcp_deployments (environment_id, domain) + WHERE deleted_at IS NULL; + +CREATE TABLE mcp_deployment_revisions +( + mcp_deployment_id TEXT NOT NULL, + revision_id INTEGER NOT NULL, + + hash BLOB NOT NULL, + + created_at TIMESTAMP NOT NULL, + created_by TEXT NOT NULL, + deleted BOOLEAN NOT NULL, + + data BLOB NOT NULL, + + PRIMARY KEY (mcp_deployment_id, revision_id), + FOREIGN KEY (mcp_deployment_id) REFERENCES mcp_deployments +); + +CREATE INDEX mcp_deployment_revisions_latest_revision_by_id_idx + ON mcp_deployment_revisions (mcp_deployment_id, revision_id DESC); + +CREATE TABLE deployment_compiled_mcp +( + account_id TEXT NOT NULL, + environment_id TEXT NOT NULL, + deployment_revision_id INTEGER NOT NULL, + domain TEXT NOT NULL, + mcp_data BLOB NOT NULL, + + PRIMARY KEY (environment_id, deployment_revision_id, domain), + FOREIGN KEY (environment_id, deployment_revision_id) REFERENCES deployment_revisions +); + +CREATE INDEX deployment_compiled_mcp_domain_idx + ON deployment_compiled_mcp (domain); + +CREATE TABLE deployment_mcp_deployment_revisions +( + environment_id UUID NOT NULL, + deployment_revision_id BIGINT NOT NULL, + mcp_deployment_id UUID NOT NULL, + mcp_deployment_revision_id BIGINT NOT NULL, + CONSTRAINT deployment_mcp_deployment_revisions_pk + PRIMARY KEY (environment_id, deployment_revision_id, mcp_deployment_id), + CONSTRAINT deployment_mcp_deployment_revisions_deployment_fk + FOREIGN KEY (environment_id, deployment_revision_id) + REFERENCES deployment_revisions (environment_id, revision_id), + CONSTRAINT deployment_mcp_deployment_revisions_mcp_fk + FOREIGN KEY (mcp_deployment_id, mcp_deployment_revision_id) + REFERENCES mcp_deployment_revisions (mcp_deployment_id, revision_id) +); + +CREATE INDEX deployment_mcp_deployment_revisions_mcp_idx + ON deployment_mcp_deployment_revisions (mcp_deployment_id, mcp_deployment_revision_id); diff --git a/golem-registry-service/src/api/error.rs b/golem-registry-service/src/api/error.rs index 7f29434c56..dbeda573f2 100644 --- a/golem-registry-service/src/api/error.rs +++ b/golem-registry-service/src/api/error.rs @@ -23,6 +23,7 @@ use crate::services::environment::EnvironmentError; use crate::services::environment_plugin_grant::EnvironmentPluginGrantError; use crate::services::environment_share::EnvironmentShareError; use crate::services::http_api_deployment::HttpApiDeploymentError; +use crate::services::mcp_deployment::McpDeploymentError; use crate::services::oauth2::OAuth2Error; use crate::services::plan::PlanError; use crate::services::plugin_registration::PluginRegistrationError; @@ -594,3 +595,29 @@ impl From for ApiError { } } } + +impl From for ApiError { + fn from(value: McpDeploymentError) -> Self { + let error: String = value.to_safe_string(); + match value { + McpDeploymentError::ParentEnvironmentNotFound(_) + | McpDeploymentError::DeploymentRevisionNotFound(_) + | McpDeploymentError::McpDeploymentNotFound(_) + | McpDeploymentError::McpDeploymentByDomainNotFound(_) => { + Self::NotFound(Json(ErrorBody { error, cause: None })) + } + + McpDeploymentError::DomainNotRegistered(_) + | McpDeploymentError::McpDeploymentForDomainAlreadyExists(_) + | McpDeploymentError::ConcurrentUpdate => { + Self::Conflict(Json(ErrorBody { error, cause: None })) + } + + McpDeploymentError::Unauthorized(inner) => inner.into(), + McpDeploymentError::InternalError(_) => Self::InternalError(Json(ErrorBody { + error, + cause: Some(value.into_anyhow()), + })), + } + } +} diff --git a/golem-registry-service/src/api/mcp_deployments.rs b/golem-registry-service/src/api/mcp_deployments.rs new file mode 100644 index 0000000000..17468b6dc2 --- /dev/null +++ b/golem-registry-service/src/api/mcp_deployments.rs @@ -0,0 +1,440 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::ApiResult; +use crate::services::auth::AuthService; +use crate::services::mcp_deployment::McpDeploymentService; +use golem_common::model::Page; +use golem_common::model::deployment::DeploymentRevision; +use golem_common::model::domain_registration::Domain; +use golem_common::model::environment::EnvironmentId; +use golem_common::model::mcp_deployment::{McpDeployment, McpDeploymentCreation}; +use golem_common::model::poem::NoContentResponse; +use golem_common::recorded_http_api_request; +use golem_service_base::api_tags::ApiTags; +use golem_service_base::model::auth::{AuthCtx, GolemSecurityScheme}; +use poem_openapi::param::{Path, Query}; +use poem_openapi::payload::Json; +use poem_openapi::*; +use std::sync::Arc; +use tracing::Instrument; + +pub struct McpDeploymentsApi { + mcp_deployment_service: Arc, + auth_service: Arc, +} + +#[OpenApi( + prefix_path = "/v1", + tag = ApiTags::RegistryService, + tag = ApiTags::McpDeployment +)] +#[allow(unused_variables)] +impl McpDeploymentsApi { + pub fn new( + mcp_deployment_service: Arc, + auth_service: Arc, + ) -> Self { + Self { + mcp_deployment_service, + auth_service, + } + } + + /// Create a new MCP deployment in the environment + #[oai( + path = "/envs/:environment_id/mcp-deployments", + method = "post", + operation_id = "create_mcp_deployment", + tag = ApiTags::Environment, + )] + async fn create_mcp_deployment( + &self, + environment_id: Path, + payload: Json, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "create_mcp_deployment", + environment_id = environment_id.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .create_mcp_deployment_internal(environment_id.0, payload.0, auth) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn create_mcp_deployment_internal( + &self, + environment_id: EnvironmentId, + payload: McpDeploymentCreation, + auth: AuthCtx, + ) -> ApiResult> { + let result = self + .mcp_deployment_service + .create(environment_id, payload, &auth) + .await?; + + Ok(Json(result)) + } + + /// Get MCP deployment by domain in the environment + #[oai( + path = "/envs/:environment_id/mcp-deployments/:domain", + method = "get", + operation_id = "get_mcp_deployment_in_environment", + tag = ApiTags::Environment + )] + async fn get_mcp_deployment_in_environment( + &self, + environment_id: Path, + domain: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "get_mcp_deployment_in_environment", + environment_id = environment_id.0.to_string(), + domain = domain.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .get_mcp_deployment_in_environment_internal(environment_id.0, domain.0, auth) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn get_mcp_deployment_in_environment_internal( + &self, + environment_id: EnvironmentId, + domain: Domain, + auth: AuthCtx, + ) -> ApiResult> { + let mcp_deployment = self + .mcp_deployment_service + .get_staged_by_domain(environment_id, &domain, &auth) + .await?; + + Ok(Json(mcp_deployment)) + } + + /// List MCP deployments in the environment + #[oai( + path = "/envs/:environment_id/mcp-deployments", + method = "get", + operation_id = "list_mcp_deployments_in_environment", + tag = ApiTags::Environment + )] + async fn list_mcp_deployments_in_environment( + &self, + environment_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult>> { + let record = recorded_http_api_request!( + "list_mcp_deployments_in_environment", + environment_id = environment_id.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .list_mcp_deployments_in_environment_internal(environment_id.0, auth) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn list_mcp_deployments_in_environment_internal( + &self, + environment_id: EnvironmentId, + auth: AuthCtx, + ) -> ApiResult>> { + let values = self + .mcp_deployment_service + .list_staged(environment_id, &auth) + .await?; + + Ok(Json(Page { values })) + } + + /// Get MCP deployment by ID + #[oai( + path = "/mcp-deployments/:mcp_deployment_id", + method = "get", + operation_id = "get_mcp_deployment" + )] + async fn get_mcp_deployment( + &self, + mcp_deployment_id: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "get_mcp_deployment", + mcp_deployment_id = mcp_deployment_id.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .get_mcp_deployment_internal(mcp_deployment_id.0, auth) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn get_mcp_deployment_internal( + &self, + mcp_deployment_id: golem_common::model::mcp_deployment::McpDeploymentId, + auth: AuthCtx, + ) -> ApiResult> { + let mcp_deployment = self + .mcp_deployment_service + .get_staged(mcp_deployment_id, &auth) + .await?; + + Ok(Json(mcp_deployment)) + } + + /// Update MCP deployment + #[oai( + path = "/mcp-deployments/:mcp_deployment_id", + method = "patch", + operation_id = "update_mcp_deployment" + )] + async fn update_mcp_deployment( + &self, + mcp_deployment_id: Path, + payload: Json, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "update_mcp_deployment", + mcp_deployment_id = mcp_deployment_id.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .update_mcp_deployment_internal(mcp_deployment_id.0, payload.0, auth) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn update_mcp_deployment_internal( + &self, + mcp_deployment_id: golem_common::model::mcp_deployment::McpDeploymentId, + payload: golem_common::model::mcp_deployment::McpDeploymentUpdate, + auth: AuthCtx, + ) -> ApiResult> { + let mcp_deployment = self + .mcp_deployment_service + .update(mcp_deployment_id, payload, &auth) + .await?; + + Ok(Json(mcp_deployment)) + } + + /// Delete MCP deployment + #[oai( + path = "/mcp-deployments/:mcp_deployment_id", + method = "delete", + operation_id = "delete_mcp_deployment" + )] + async fn delete_mcp_deployment( + &self, + mcp_deployment_id: Path, + current_revision: Query, + token: GolemSecurityScheme, + ) -> ApiResult { + let record = recorded_http_api_request!( + "delete_mcp_deployment", + mcp_deployment_id = mcp_deployment_id.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .delete_mcp_deployment_internal(mcp_deployment_id.0, current_revision.0, auth) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn delete_mcp_deployment_internal( + &self, + mcp_deployment_id: golem_common::model::mcp_deployment::McpDeploymentId, + current_revision: golem_common::model::mcp_deployment::McpDeploymentRevision, + auth: AuthCtx, + ) -> ApiResult { + self.mcp_deployment_service + .delete(mcp_deployment_id, current_revision, &auth) + .await?; + + Ok(NoContentResponse::NoContent) + } + + /// Get a specific MCP deployment revision + #[oai( + path = "/mcp-deployment/:mcp_deployment_id/revisions/:revision", + method = "get", + operation_id = "get_mcp_deployment_revision" + )] + async fn get_mcp_deployment_revision( + &self, + mcp_deployment_id: Path, + revision: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "get_mcp_deployment_revision", + mcp_deployment_id = mcp_deployment_id.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .get_mcp_deployment_revision_internal(mcp_deployment_id.0, revision.0, auth) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn get_mcp_deployment_revision_internal( + &self, + mcp_deployment_id: golem_common::model::mcp_deployment::McpDeploymentId, + revision: golem_common::model::mcp_deployment::McpDeploymentRevision, + auth: AuthCtx, + ) -> ApiResult> { + let result = self + .mcp_deployment_service + .get_revision(mcp_deployment_id, revision, &auth) + .await?; + + Ok(Json(result)) + } + + /// Get MCP deployment by domain in the deployment + #[oai( + path = "/envs/:environment_id/deployments/:deployment_revision/mcp-deployments/:domain", + method = "get", + operation_id = "get_mcp_deployment_in_deployment", + tag = ApiTags::Environment, + tag = ApiTags::Deployment, + )] + async fn get_mcp_deployment_in_deployment( + &self, + environment_id: Path, + deployment_revision: Path, + domain: Path, + token: GolemSecurityScheme, + ) -> ApiResult> { + let record = recorded_http_api_request!( + "get_mcp_deployment_in_deployment", + environment_id = environment_id.0.to_string(), + deployment_revision = deployment_revision.0.to_string(), + domain = domain.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .get_mcp_deployment_in_deployment_internal( + environment_id.0, + deployment_revision.0, + domain.0, + auth, + ) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn get_mcp_deployment_in_deployment_internal( + &self, + environment_id: EnvironmentId, + deployment_revision: DeploymentRevision, + domain: Domain, + auth: AuthCtx, + ) -> ApiResult> { + let mcp_deployment = self + .mcp_deployment_service + .get_in_deployment_by_domain(environment_id, deployment_revision, &domain, &auth) + .await?; + + Ok(Json(mcp_deployment)) + } + + /// List MCP deployments by domain in the deployment + #[oai( + path = "/envs/:environment_id/deployments/:deployment_revision/mcp-deployments", + method = "get", + operation_id = "list_mcp_deployments_in_deployment", + tag = ApiTags::Environment, + tag = ApiTags::Deployment, + )] + async fn list_mcp_deployments_in_deployment( + &self, + environment_id: Path, + deployment_revision: Path, + token: GolemSecurityScheme, + ) -> ApiResult>> { + let record = recorded_http_api_request!( + "list_mcp_deployments_in_deployment", + environment_id = environment_id.0.to_string(), + deployment_revision = deployment_revision.0.to_string(), + ); + + let auth = self.auth_service.authenticate_token(token.secret()).await?; + + let response = self + .list_mcp_deployments_in_deployment_internal( + environment_id.0, + deployment_revision.0, + auth, + ) + .instrument(record.span.clone()) + .await; + + record.result(response) + } + + async fn list_mcp_deployments_in_deployment_internal( + &self, + environment_id: EnvironmentId, + deployment_revision: DeploymentRevision, + auth: AuthCtx, + ) -> ApiResult>> { + let values = self + .mcp_deployment_service + .list_in_deployment(environment_id, deployment_revision, &auth) + .await?; + + Ok(Json(Page { values })) + } +} diff --git a/golem-registry-service/src/api/mod.rs b/golem-registry-service/src/api/mod.rs index 8e9e8a840b..aaa80f1221 100644 --- a/golem-registry-service/src/api/mod.rs +++ b/golem-registry-service/src/api/mod.rs @@ -23,6 +23,7 @@ pub mod environments; pub mod error; pub mod http_api_deployments; pub mod login; +pub mod mcp_deployments; pub mod plugin_registrations; pub mod reports; pub mod security_schemes; @@ -39,6 +40,7 @@ use self::environments::EnvironmentsApi; use self::error::ApiError; use self::http_api_deployments::HttpApiDeploymentsApi; use self::login::LoginApi; +use self::mcp_deployments::McpDeploymentsApi; use self::plugin_registrations::PluginRegistrationsApi; use self::reports::ReportsApi; use self::security_schemes::SecuritySchemesApi; @@ -59,6 +61,7 @@ pub type Apis = ( EnvironmentSharesApi, ), HttpApiDeploymentsApi, + McpDeploymentsApi, LoginApi, PluginRegistrationsApi, ReportsApi, @@ -115,6 +118,10 @@ pub fn make_open_api_service(services: &Services) -> OpenApiService { services.http_api_deployment_service.clone(), services.auth_service.clone(), ), + McpDeploymentsApi::new( + services.mcp_deployment_service.clone(), + services.auth_service.clone(), + ), LoginApi::new( services.login_system.clone(), services.token_service.clone(), diff --git a/golem-registry-service/src/bootstrap/mod.rs b/golem-registry-service/src/bootstrap/mod.rs index 9a6d7bbf45..104b85eda1 100644 --- a/golem-registry-service/src/bootstrap/mod.rs +++ b/golem-registry-service/src/bootstrap/mod.rs @@ -28,6 +28,7 @@ use crate::repo::environment_plugin_grant::{ }; use crate::repo::environment_share::{DbEnvironmentShareRepo, EnvironmentShareRepo}; use crate::repo::http_api_deployment::{DbHttpApiDeploymentRepo, HttpApiDeploymentRepo}; +use crate::repo::mcp_deployment::{DbMcpDeploymentRepo, McpDeploymentRepo}; use crate::repo::oauth2_token::{DbOAuth2TokenRepo, OAuth2TokenRepo}; use crate::repo::oauth2_webflow_state::{DbOAuth2WebflowStateRepo, OAuth2WebflowStateRepo}; use crate::repo::plan::{DbPlanRepo, PlanRepo}; @@ -44,13 +45,14 @@ use crate::services::component_compilation::ComponentCompilationService; use crate::services::component_object_store::ComponentObjectStore; use crate::services::component_resolver::ComponentResolverService; use crate::services::deployment::{ - DeployedRoutesService, DeploymentService, DeploymentWriteService, + DeployedMcpService, DeployedRoutesService, DeploymentService, DeploymentWriteService, }; use crate::services::domain_registration::DomainRegistrationService; use crate::services::environment::EnvironmentService; use crate::services::environment_plugin_grant::EnvironmentPluginGrantService; use crate::services::environment_share::EnvironmentShareService; use crate::services::http_api_deployment::HttpApiDeploymentService; +use crate::services::mcp_deployment::McpDeploymentService; use crate::services::plan::PlanService; use crate::services::plugin_registration::PluginRegistrationService; use crate::services::reports::ReportsService; @@ -83,6 +85,7 @@ pub struct Services { pub component_service: Arc, pub component_write_service: Arc, pub deployed_routes_service: Arc, + pub deployed_mcp_service: Arc, pub deployment_service: Arc, pub deployment_write_service: Arc, pub domain_registration_service: Arc, @@ -90,6 +93,7 @@ pub struct Services { pub environment_service: Arc, pub environment_share_service: Arc, pub http_api_deployment_service: Arc, + pub mcp_deployment_service: Arc, pub login_system: LoginSystem, pub plan_service: Arc, pub plugin_registration_service: Arc, @@ -109,6 +113,7 @@ struct Repos { environment_repo: Arc, environment_share_repo: Arc, http_api_deployment_repo: Arc, + mcp_deployment_repo: Arc, oauth2_token_repo: Arc, oauth2_webflow_state_repo: Arc, plan_repo: Arc, @@ -264,16 +269,26 @@ impl Services { domain_registration_service.clone(), )); + let mcp_deployment_service = Arc::new(McpDeploymentService::new( + repos.mcp_deployment_repo.clone(), + environment_service.clone(), + deployment_service.clone(), + domain_registration_service.clone(), + )); + let deployment_write_service = Arc::new(DeploymentWriteService::new( environment_service.clone(), repos.deployment_repo.clone(), component_service.clone(), http_api_deployment_service.clone(), + mcp_deployment_service.clone(), )); let deployed_routes_service = Arc::new(DeployedRoutesService::new(repos.deployment_repo.clone())); + let deployed_mcp_service = Arc::new(DeployedMcpService::new(repos.deployment_repo.clone())); + Ok(Self { account_service, account_usage_service, @@ -284,6 +299,7 @@ impl Services { component_service, component_write_service, deployed_routes_service, + deployed_mcp_service, deployment_service, deployment_write_service, domain_registration_service, @@ -291,6 +307,7 @@ impl Services { environment_service, environment_share_service, http_api_deployment_service, + mcp_deployment_service, login_system, plan_service, plugin_registration_service, @@ -333,6 +350,7 @@ async fn make_repos(db_config: &DbConfig) -> anyhow::Result { let security_scheme_repo = Arc::new(DbSecuritySchemeRepo::logged(db_pool.clone())); let http_api_deployment_repo = Arc::new(DbHttpApiDeploymentRepo::logged(db_pool.clone())); + let mcp_deployment_repo = Arc::new(DbMcpDeploymentRepo::logged(db_pool.clone())); Ok(Repos { account_repo, @@ -345,6 +363,7 @@ async fn make_repos(db_config: &DbConfig) -> anyhow::Result { environment_repo, environment_share_repo, http_api_deployment_repo, + mcp_deployment_repo, oauth2_token_repo, oauth2_webflow_state_repo, plan_repo, @@ -382,6 +401,7 @@ async fn make_repos(db_config: &DbConfig) -> anyhow::Result { let security_scheme_repo = Arc::new(DbSecuritySchemeRepo::logged(db_pool.clone())); let http_api_deployment_repo = Arc::new(DbHttpApiDeploymentRepo::logged(db_pool.clone())); + let mcp_deployment_repo = Arc::new(DbMcpDeploymentRepo::logged(db_pool.clone())); Ok(Repos { account_repo, @@ -394,6 +414,7 @@ async fn make_repos(db_config: &DbConfig) -> anyhow::Result { environment_repo, environment_share_repo, http_api_deployment_repo, + mcp_deployment_repo, oauth2_token_repo, oauth2_webflow_state_repo, plan_repo, diff --git a/golem-registry-service/src/grpc/api_impl.rs b/golem-registry-service/src/grpc/api_impl.rs index 3b9e234d5b..1f4811f26c 100644 --- a/golem-registry-service/src/grpc/api_impl.rs +++ b/golem-registry-service/src/grpc/api_impl.rs @@ -17,7 +17,7 @@ use crate::services::account_usage::AccountUsageService; use crate::services::auth::AuthService; use crate::services::component::ComponentService; use crate::services::component_resolver::ComponentResolverService; -use crate::services::deployment::{DeployedRoutesService, DeploymentService}; +use crate::services::deployment::{DeployedMcpService, DeployedRoutesService, DeploymentService}; use crate::services::environment::EnvironmentService; use applying::Apply; use async_trait::async_trait; @@ -28,15 +28,17 @@ use golem_api_grpc::proto::golem::registry::v1::get_agent_deployments_response:: use golem_api_grpc::proto::golem::registry::v1::{ AuthenticateTokenRequest, AuthenticateTokenResponse, AuthenticateTokenSuccessResponse, BatchUpdateFuelUsageRequest, BatchUpdateFuelUsageResponse, BatchUpdateFuelUsageSuccessResponse, - DownloadComponentRequest, DownloadComponentResponse, GetActiveRoutesForDomainRequest, - GetActiveRoutesForDomainResponse, GetActiveRoutesForDomainSuccessResponse, - GetAgentDeploymentsRequest, GetAgentDeploymentsResponse, GetAgentTypeRequest, - GetAgentTypeResponse, GetAgentTypeSuccessResponse, GetAllAgentTypesRequest, - GetAllAgentTypesResponse, GetAllAgentTypesSuccessResponse, - GetAllDeployedComponentRevisionsRequest, GetAllDeployedComponentRevisionsResponse, - GetAllDeployedComponentRevisionsSuccessResponse, GetAuthDetailsForEnvironmentRequest, - GetAuthDetailsForEnvironmentResponse, GetAuthDetailsForEnvironmentSuccessResponse, - GetComponentMetadataRequest, GetComponentMetadataResponse, GetComponentMetadataSuccessResponse, + DownloadComponentRequest, DownloadComponentResponse, GetActiveMcpForDomainRequest, + GetActiveMcpForDomainResponse, GetActiveMcpForDomainSuccessResponse, + GetActiveRoutesForDomainRequest, GetActiveRoutesForDomainResponse, + GetActiveRoutesForDomainSuccessResponse, GetAgentDeploymentsRequest, + GetAgentDeploymentsResponse, GetAgentTypeRequest, GetAgentTypeResponse, + GetAgentTypeSuccessResponse, GetAllAgentTypesRequest, GetAllAgentTypesResponse, + GetAllAgentTypesSuccessResponse, GetAllDeployedComponentRevisionsRequest, + GetAllDeployedComponentRevisionsResponse, GetAllDeployedComponentRevisionsSuccessResponse, + GetAuthDetailsForEnvironmentRequest, GetAuthDetailsForEnvironmentResponse, + GetAuthDetailsForEnvironmentSuccessResponse, GetComponentMetadataRequest, + GetComponentMetadataResponse, GetComponentMetadataSuccessResponse, GetDeployedComponentMetadataRequest, GetDeployedComponentMetadataResponse, GetDeployedComponentMetadataSuccessResponse, GetResourceLimitsRequest, GetResourceLimitsResponse, GetResourceLimitsSuccessResponse, RegistryServiceError, @@ -48,10 +50,11 @@ use golem_api_grpc::proto::golem::registry::v1::{ ResolveLatestAgentTypeByNamesSuccessResponse, UpdateWorkerConnectionLimitRequest, UpdateWorkerConnectionLimitResponse, UpdateWorkerLimitRequest, UpdateWorkerLimitResponse, authenticate_token_response, batch_update_fuel_usage_response, download_component_response, - get_active_routes_for_domain_response, get_agent_deployments_response, get_agent_type_response, - get_all_agent_types_response, get_all_deployed_component_revisions_response, - get_auth_details_for_environment_response, get_component_metadata_response, - get_deployed_component_metadata_response, get_resource_limits_response, registry_service_error, + get_active_mcp_for_domain_response, get_active_routes_for_domain_response, + get_agent_deployments_response, get_agent_type_response, get_all_agent_types_response, + get_all_deployed_component_revisions_response, get_auth_details_for_environment_response, + get_component_metadata_response, get_deployed_component_metadata_response, + get_resource_limits_response, registry_service_error, resolve_agent_type_at_deployment_response, resolve_agent_type_by_names_response, resolve_component_response, resolve_latest_agent_type_by_names_response, update_worker_connection_limit_response, update_worker_limit_response, @@ -84,6 +87,7 @@ pub struct RegistryServiceGrpcApi { component_resolver_service: Arc, deployment_service: Arc, deployed_routes_service: Arc, + deployed_mcp_service: Arc, } impl RegistryServiceGrpcApi { @@ -95,6 +99,7 @@ impl RegistryServiceGrpcApi { component_resolver_service: Arc, deployment_service: Arc, deployed_routes_service: Arc, + deployed_mcp_service: Arc, ) -> Self { Self { auth_service, @@ -104,6 +109,7 @@ impl RegistryServiceGrpcApi { component_resolver_service, deployment_service, deployed_routes_service, + deployed_mcp_service, } } @@ -402,6 +408,24 @@ impl RegistryServiceGrpcApi { }) } + async fn get_active_mcp_routes_for_domain_internal( + &self, + request: GetActiveMcpForDomainRequest, + ) -> Result { + let domain: Domain = Domain(request.domain); + + let compiled_mcp = self + .deployed_mcp_service + .get_currently_active_mcp(&domain) + .await?; + + Ok(GetActiveMcpForDomainSuccessResponse { + compiled_mcp: Some(golem_api_grpc::proto::golem::mcp::CompiledMcp::from( + compiled_mcp, + )), + }) + } + async fn get_agent_deployments_internal( &self, request: GetAgentDeploymentsRequest, @@ -974,6 +998,31 @@ impl golem_api_grpc::proto::golem::registry::v1::registry_service_server::Regist })) } + async fn get_active_mcp_for_domain( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let record = recorded_grpc_api_request!( + "get_active_mcp_for_domain", + get_active_mcp_for_domain = request.domain, + ); + + let response = match self + .get_active_mcp_routes_for_domain_internal(request) + .instrument(record.span.clone()) + .await + .apply(|r| record.result(r)) + { + Ok(result) => get_active_mcp_for_domain_response::Result::Success(result), + Err(error) => get_active_mcp_for_domain_response::Result::Error(error.into()), + }; + + Ok(Response::new(GetActiveMcpForDomainResponse { + result: Some(response), + })) + } + async fn get_agent_deployments( &self, request: Request, diff --git a/golem-registry-service/src/grpc/error.rs b/golem-registry-service/src/grpc/error.rs index 81c0bce91b..0ba6d0c6dd 100644 --- a/golem-registry-service/src/grpc/error.rs +++ b/golem-registry-service/src/grpc/error.rs @@ -16,7 +16,7 @@ use crate::services::account_usage::error::{AccountUsageError, LimitExceededErro use crate::services::auth::AuthError; use crate::services::component::ComponentError; use crate::services::component_resolver::ComponentResolverError; -use crate::services::deployment::{DeployedRoutesError, DeploymentError}; +use crate::services::deployment::{DeployedMcpError, DeployedRoutesError, DeploymentError}; use crate::services::environment::EnvironmentError; use golem_common::IntoAnyhow; use golem_common::metrics::api::ApiErrorDetails; @@ -219,6 +219,22 @@ impl From for GrpcApiError { } } +impl From for GrpcApiError { + fn from(value: DeployedMcpError) -> Self { + let error: String = value.to_string(); + + match value { + DeployedMcpError::NoActiveMcpForDomain(_) => { + Self::NotFound(ErrorBody { error, cause: None }) + } + DeployedMcpError::InternalError(_) => Self::InternalError(ErrorBody { + error, + cause: Some(value.into_anyhow()), + }), + } + } +} + impl From for GrpcApiError { fn from(value: EnvironmentError) -> Self { let error: String = value.to_string(); diff --git a/golem-registry-service/src/grpc/mod.rs b/golem-registry-service/src/grpc/mod.rs index 1f5b266dfd..8b8eb30b0d 100644 --- a/golem-registry-service/src/grpc/mod.rs +++ b/golem-registry-service/src/grpc/mod.rs @@ -75,6 +75,7 @@ pub async fn start_grpc_server( services.component_resolver_service.clone(), services.deployment_service.clone(), services.deployed_routes_service.clone(), + services.deployed_mcp_service.clone(), )) .send_compressed(CompressionEncoding::Gzip) .accept_compressed(CompressionEncoding::Gzip), diff --git a/golem-registry-service/src/repo/deployment.rs b/golem-registry-service/src/repo/deployment.rs index 72c630f285..21001daf49 100644 --- a/golem-registry-service/src/repo/deployment.rs +++ b/golem-registry-service/src/repo/deployment.rs @@ -14,12 +14,14 @@ use super::model::BindFields; use super::model::deployment::{ - CurrentDeploymentExtRevisionRecord, DeploymentCompiledRouteWithSecuritySchemeRecord, - DeploymentRevisionCreationRecord, ResolvedAgentTypeRecord, + CurrentDeploymentExtRevisionRecord, DeploymentCompiledMcpRecord, + DeploymentCompiledRouteWithSecuritySchemeRecord, DeploymentRevisionCreationRecord, + ResolvedAgentTypeRecord, }; use super::model::deployment::{ DeploymentCompiledRouteRecord, DeploymentComponentRevisionRecord, - DeploymentHttpApiDeploymentRevisionRecord, DeploymentRegisteredAgentTypeRecord, + DeploymentHttpApiDeploymentRevisionRecord, DeploymentMcpDeploymentRevisionRecord, + DeploymentRegisteredAgentTypeRecord, }; use crate::repo::model::audit::RevisionAuditFields; use crate::repo::model::component::ComponentRevisionIdentityRecord; @@ -29,6 +31,7 @@ use crate::repo::model::deployment::{ }; use crate::repo::model::hash::SqlBlake3Hash; use crate::repo::model::http_api_deployment::HttpApiDeploymentRevisionIdentityRecord; +use crate::repo::model::mcp_deployment::McpDeploymentRevisionIdentityRecord; use async_trait::async_trait; use conditional_trait_gen::trait_gen; use futures::FutureExt; @@ -95,6 +98,11 @@ pub trait DeploymentRepo: Send + Sync { domain: &str, ) -> RepoResult>; + async fn get_active_mcp_for_domain( + &self, + domain: &str, + ) -> RepoResult>; + async fn list_compiled_routes_for_domain_and_deployment( &self, environment_id: Uuid, @@ -307,6 +315,16 @@ impl DeploymentRepo for LoggedDeploymentRepo { .await } + async fn get_active_mcp_for_domain( + &self, + domain: &str, + ) -> RepoResult> { + self.repo + .get_active_mcp_for_domain(domain) + .instrument(Self::span_domain(domain)) + .await + } + async fn list_compiled_routes_for_domain_and_deployment( &self, environment_id: Uuid, @@ -663,6 +681,9 @@ impl DeploymentRepo for DbDeploymentRepo { http_api_deployments: self .get_deployed_http_api_deployments(environment_id, revision_id) .await?, + mcp_deployments: self + .get_deployed_mcp_deployments(environment_id, revision_id) + .await?, }, })) } @@ -715,6 +736,10 @@ impl DeploymentRepo for DbDeploymentRepo { Self::create_deployment_http_api_deployment_revision(tx, deployment).await? } + for deployment in &deployment_creation.mcp_deployments { + Self::create_deployment_mcp_deployment_revision(tx, deployment).await? + } + for compiled_route in &deployment_creation.compiled_routes { Self::create_deployment_compiled_route(tx, compiled_route).await? } @@ -724,6 +749,10 @@ impl DeploymentRepo for DbDeploymentRepo { .await?; } + for compiled_mcp in &deployment_creation.compiled_mcp { + Self::create_deployment_mcp(tx, compiled_mcp).await?; + } + let revision = Self::set_current_deployment_internal( tx, user_account_id, @@ -745,6 +774,51 @@ impl DeploymentRepo for DbDeploymentRepo { .await } + async fn get_active_mcp_for_domain( + &self, + domain: &str, + ) -> RepoResult> { + self.with_ro("get_active_mcp_for_domain") + .fetch_optional_as( + sqlx::query_as(indoc! { r#" + SELECT + cm.account_id, + cm.environment_id, + cm.deployment_revision_id, + cm.domain, + cm.mcp_data + + FROM deployment_compiled_mcp cm + + -- active deployment + JOIN current_deployments cd + ON cd.environment_id = cm.environment_id + AND cd.current_revision_id = cm.deployment_revision_id + + -- parent objects not deleted + JOIN environments e + ON e.environment_id = cm.environment_id + AND e.deleted_at IS NULL + JOIN applications a + ON a.application_id = e.application_id + AND a.deleted_at IS NULL + JOIN accounts ac + ON ac.account_id = a.account_id + AND ac.deleted_at IS NULL + + -- registered domains + JOIN domain_registrations dr + ON dr.environment_id = cm.environment_id + AND dr.domain = cm.domain + AND dr.deleted_at IS NULL + + WHERE cm.domain = $1 + "#}) + .bind(domain), + ) + .await + } + async fn list_active_compiled_routes_for_domain( &self, domain: &str, @@ -1207,6 +1281,11 @@ trait DeploymentRepoInternal: DeploymentRepo { environment_id: Uuid, ) -> RepoResult>; + async fn get_staged_mcp_deployments( + tx: &mut Self::Tx, + environment_id: Uuid, + ) -> RepoResult>; + async fn create_deployment_component_revision( tx: &mut Self::Tx, environment_id: Uuid, @@ -1219,11 +1298,21 @@ trait DeploymentRepoInternal: DeploymentRepo { http_api_deployment: &DeploymentHttpApiDeploymentRevisionRecord, ) -> RepoResult<()>; + async fn create_deployment_mcp_deployment_revision( + tx: &mut Self::Tx, + mcp_deployment: &DeploymentMcpDeploymentRevisionRecord, + ) -> RepoResult<()>; + async fn create_deployment_compiled_route( tx: &mut Self::Tx, compiled_route: &DeploymentCompiledRouteRecord, ) -> RepoResult<()>; + async fn create_deployment_mcp( + tx: &mut Self::Tx, + mcp: &DeploymentCompiledMcpRecord, + ) -> RepoResult<()>; + async fn create_deployment_registered_agent_type( tx: &mut Self::Tx, registered_agent_type: &DeploymentRegisteredAgentTypeRecord, @@ -1248,6 +1337,12 @@ trait DeploymentRepoInternal: DeploymentRepo { revision_id: i64, ) -> RepoResult>; + async fn get_deployed_mcp_deployments( + &self, + environment_id: Uuid, + revision_id: i64, + ) -> RepoResult>; + async fn version_exists(&self, environment_id: Uuid, version: &str) -> RepoResult; } @@ -1297,6 +1392,7 @@ impl DeploymentRepoInternal for DbDeploymentRepo { Ok(DeploymentIdentity { components: Self::get_staged_components(tx, environment_id).await?, http_api_deployments: Self::get_staged_http_api_deployments(tx, environment_id).await?, + mcp_deployments: Self::get_staged_mcp_deployments(tx, environment_id).await?, }) } @@ -1336,6 +1432,24 @@ impl DeploymentRepoInternal for DbDeploymentRepo { .await } + async fn get_staged_mcp_deployments( + tx: &mut Self::Tx, + environment_id: Uuid, + ) -> RepoResult> { + tx.fetch_all_as( + sqlx::query_as(indoc! { r#" + SELECT d.mcp_deployment_id, d.domain, dr.revision_id, dr.hash + FROM mcp_deployments d + JOIN mcp_deployment_revisions dr + ON d.mcp_deployment_id = dr.mcp_deployment_id + AND d.current_revision_id = dr.revision_id + WHERE d.environment_id = $1 AND d.deleted_at IS NULL + "#}) + .bind(environment_id), + ) + .await + } + async fn create_deployment_component_revision( tx: &mut Self::Tx, environment_id: Uuid, @@ -1377,6 +1491,47 @@ impl DeploymentRepoInternal for DbDeploymentRepo { Ok(()) } + async fn create_deployment_mcp_deployment_revision( + tx: &mut Self::Tx, + mcp_deployment: &DeploymentMcpDeploymentRevisionRecord, + ) -> RepoResult<()> { + tx.execute( + sqlx::query(indoc! { r#" + INSERT INTO deployment_mcp_deployment_revisions + (environment_id, deployment_revision_id, mcp_deployment_id, mcp_deployment_revision_id) + VALUES ($1, $2, $3, $4) + "#}) + .bind(mcp_deployment.environment_id) + .bind(mcp_deployment.deployment_revision_id) + .bind(mcp_deployment.mcp_deployment_id) + .bind(mcp_deployment.mcp_deployment_revision_id) + ) + .await?; + + Ok(()) + } + + async fn create_deployment_mcp( + tx: &mut Self::Tx, + mcp: &DeploymentCompiledMcpRecord, + ) -> RepoResult<()> { + tx.execute( + sqlx::query(indoc! { r#" + INSERT INTO deployment_compiled_mcp + (account_id, environment_id, deployment_revision_id, domain, mcp_data) + VALUES ($1, $2, $3, $4, $5) + "#}) + .bind(mcp.account_id) + .bind(mcp.environment_id) + .bind(mcp.deployment_revision_id) + .bind(&mcp.domain) + .bind(&mcp.mcp_data), + ) + .await?; + + Ok(()) + } + async fn create_deployment_compiled_route( tx: &mut Self::Tx, compiled_route: &DeploymentCompiledRouteRecord, @@ -1527,6 +1682,31 @@ impl DeploymentRepoInternal for DbDeploymentRepo { Ok(deployments) } + async fn get_deployed_mcp_deployments( + &self, + environment_id: Uuid, + revision_id: i64, + ) -> RepoResult> { + let deployments: Vec = self.with_ro("get_deployed_mcp_deployments - deployments") + .fetch_all_as( + sqlx::query_as(indoc! { r#" + SELECT md.mcp_deployment_id, md.domain, mdr.revision_id, mdr.hash + FROM mcp_deployments md + JOIN mcp_deployment_revisions mdr ON md.mcp_deployment_id = mdr.mcp_deployment_id + JOIN deployment_mcp_deployment_revisions dmdr + ON dmdr.mcp_deployment_id = mdr.mcp_deployment_id + AND dmdr.mcp_deployment_revision_id = mdr.revision_id + WHERE dmdr.environment_id = $1 AND dmdr.deployment_revision_id = $2 + ORDER BY md.domain + "#}) + .bind(environment_id) + .bind(revision_id), + ) + .await?; + + Ok(deployments) + } + async fn version_exists(&self, environment_id: Uuid, version: &str) -> RepoResult { Ok(self .with_ro("version_exists") diff --git a/golem-registry-service/src/repo/mcp_deployment.rs b/golem-registry-service/src/repo/mcp_deployment.rs new file mode 100644 index 0000000000..bcb3531154 --- /dev/null +++ b/golem-registry-service/src/repo/mcp_deployment.rs @@ -0,0 +1,591 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::repo::model::BindFields; +use crate::repo::model::mcp_deployment::{ + McpDeploymentExtRevisionRecord, McpDeploymentRepoError, McpDeploymentRevisionIdentityRecord, + McpDeploymentRevisionRecord, +}; +use async_trait::async_trait; +use conditional_trait_gen::trait_gen; +use futures::FutureExt; +use futures::future::BoxFuture; +use golem_service_base::db::postgres::PostgresPool; +use golem_service_base::db::sqlite::SqlitePool; +use golem_service_base::db::{LabelledPoolApi, LabelledPoolTransaction, Pool, PoolApi}; +use golem_service_base::repo::{RepoError, RepoResult, ResultExt}; +use indoc::indoc; +use sqlx::Database; +use std::fmt::Debug; +use tracing::{Instrument, Span, info_span}; +use uuid::Uuid; + +#[async_trait] +pub trait McpDeploymentRepo: Send + Sync { + async fn create( + &self, + environment_id: Uuid, + domain: &str, + revision: McpDeploymentRevisionRecord, + ) -> Result; + + async fn update( + &self, + revision: McpDeploymentRevisionRecord, + ) -> Result; + + async fn delete( + &self, + user_account_id: Uuid, + mcp_deployment_id: Uuid, + revision_id: i64, + ) -> Result<(), McpDeploymentRepoError>; + + async fn get_staged_by_id( + &self, + mcp_deployment_id: Uuid, + ) -> RepoResult>; + + async fn get_staged_by_domain( + &self, + environment_id: Uuid, + domain: &str, + ) -> RepoResult>; + + async fn get_by_id_and_revision( + &self, + mcp_deployment_id: Uuid, + revision_id: i64, + ) -> RepoResult>; + + async fn list_staged( + &self, + environment_id: Uuid, + ) -> RepoResult>; + + async fn list_by_deployment( + &self, + environment_id: Uuid, + deployment_revision_id: i64, + ) -> RepoResult>; + + async fn get_in_deployment_by_domain( + &self, + environment_id: Uuid, + deployment_revision_id: i64, + domain: &str, + ) -> RepoResult>; +} + +pub struct LoggedMcpDeploymentRepo { + repo: Repo, +} + +static SPAN_NAME: &str = "mcp_deployment repository"; + +impl LoggedMcpDeploymentRepo { + pub fn new(repo: Repo) -> Self { + Self { repo } + } + + fn span_name(environment_id: Uuid, domain: &str) -> Span { + info_span!(SPAN_NAME, environment_id = %environment_id, domain) + } + + fn span_env(environment_id: Uuid) -> Span { + info_span!(SPAN_NAME, environment_id = %environment_id) + } + + fn span_env_and_deployment(environment_id: Uuid, deployment_revision_id: i64) -> Span { + info_span!(SPAN_NAME, environment_id = %environment_id, deployment_revision_id) + } + + fn span_id(mcp_deployment_id: Uuid) -> Span { + info_span!(SPAN_NAME, mcp_deployment_id = %mcp_deployment_id) + } + + fn span_id_and_revision(mcp_deployment_id: Uuid, revision_id: i64) -> Span { + info_span!(SPAN_NAME, mcp_deployment_id = %mcp_deployment_id, revision_id) + } +} + +#[async_trait] +impl McpDeploymentRepo for LoggedMcpDeploymentRepo { + async fn create( + &self, + environment_id: Uuid, + domain: &str, + revision: McpDeploymentRevisionRecord, + ) -> Result { + self.repo + .create(environment_id, domain, revision) + .instrument(Self::span_name(environment_id, domain)) + .await + } + + async fn update( + &self, + revision: McpDeploymentRevisionRecord, + ) -> Result { + let span = Self::span_id(revision.mcp_deployment_id); + self.repo.update(revision).instrument(span).await + } + + async fn delete( + &self, + user_account_id: Uuid, + mcp_deployment_id: Uuid, + revision_id: i64, + ) -> Result<(), McpDeploymentRepoError> { + self.repo + .delete(user_account_id, mcp_deployment_id, revision_id) + .instrument(Self::span_id(mcp_deployment_id)) + .await + } + + async fn get_staged_by_id( + &self, + mcp_deployment_id: Uuid, + ) -> RepoResult> { + self.repo + .get_staged_by_id(mcp_deployment_id) + .instrument(Self::span_id(mcp_deployment_id)) + .await + } + + async fn get_staged_by_domain( + &self, + environment_id: Uuid, + domain: &str, + ) -> RepoResult> { + self.repo + .get_staged_by_domain(environment_id, domain) + .instrument(Self::span_name(environment_id, domain)) + .await + } + + async fn get_by_id_and_revision( + &self, + mcp_deployment_id: Uuid, + revision_id: i64, + ) -> RepoResult> { + self.repo + .get_by_id_and_revision(mcp_deployment_id, revision_id) + .instrument(Self::span_id_and_revision(mcp_deployment_id, revision_id)) + .await + } + + async fn list_staged( + &self, + environment_id: Uuid, + ) -> RepoResult> { + self.repo + .list_staged(environment_id) + .instrument(Self::span_env(environment_id)) + .await + } + + async fn list_by_deployment( + &self, + environment_id: Uuid, + deployment_revision_id: i64, + ) -> RepoResult> { + self.repo + .list_by_deployment(environment_id, deployment_revision_id) + .instrument(Self::span_env_and_deployment( + environment_id, + deployment_revision_id, + )) + .await + } + + async fn get_in_deployment_by_domain( + &self, + environment_id: Uuid, + deployment_revision_id: i64, + domain: &str, + ) -> RepoResult> { + self.repo + .get_in_deployment_by_domain(environment_id, deployment_revision_id, domain) + .instrument(Self::span_env_and_deployment( + environment_id, + deployment_revision_id, + )) + .await + } +} + +pub struct DbMcpDeploymentRepo { + db_pool: DBP, +} + +static METRICS_SVC_NAME: &str = "mcp_deployment_repo"; + +impl DbMcpDeploymentRepo { + pub fn new(db_pool: DBP) -> Self { + Self { db_pool } + } + + pub fn logged(db_pool: DBP) -> LoggedMcpDeploymentRepo + where + Self: McpDeploymentRepo, + { + LoggedMcpDeploymentRepo::new(Self::new(db_pool)) + } + + fn with_ro(&self, api_name: &'static str) -> DBP::LabelledApi { + self.db_pool.with_ro(METRICS_SVC_NAME, api_name) + } + + async fn with_tx_err(&self, api_name: &'static str, f: F) -> Result + where + R: Send, + E: Debug + Send + From, + F: for<'f> FnOnce( + &'f mut ::LabelledTransaction, + ) -> BoxFuture<'f, Result> + + Send, + { + self.db_pool + .with_tx_err(METRICS_SVC_NAME, api_name, f) + .await + } +} + +#[trait_gen(PostgresPool -> PostgresPool, SqlitePool)] +#[async_trait] +impl McpDeploymentRepo for DbMcpDeploymentRepo { + async fn create( + &self, + environment_id: Uuid, + domain: &str, + revision: McpDeploymentRevisionRecord, + ) -> Result { + let opt_deleted_revision: Option = self + .with_ro("create - get opt deleted") + .fetch_optional_as( + sqlx::query_as(indoc! { r#" + SELECT m.mcp_deployment_id, m.domain, mr.revision_id, mr.hash + FROM mcp_deployments m + JOIN mcp_deployment_revisions mr + ON m.mcp_deployment_id = mr.mcp_deployment_id + AND m.current_revision_id = mr.revision_id + WHERE m.environment_id = $1 AND m.domain = $2 AND m.deleted_at IS NOT NULL + "#}) + .bind(environment_id) + .bind(domain), + ) + .await?; + + if let Some(deleted_revision) = opt_deleted_revision { + let recreated_revision = revision.for_recreation( + deleted_revision.mcp_deployment_id, + deleted_revision.revision_id, + )?; + return self.update(recreated_revision).await; + } + + let domain = domain.to_owned(); + + self.with_tx_err("create", |tx| { + async move { + tx + .execute( + sqlx::query(indoc! { r#" + INSERT INTO mcp_deployments + (mcp_deployment_id, environment_id, domain, created_at, deleted_at, modified_by, current_revision_id) + VALUES ($1, $2, $3, $4, NULL, $5, 0) + "# }) + .bind(revision.mcp_deployment_id) + .bind(environment_id) + .bind(&domain) + .bind(&revision.audit.created_at) + .bind(revision.audit.created_by), + ) + .await + .to_error_on_unique_violation(McpDeploymentRepoError::McpDeploymentViolatesUniqueness)?; + + let revision = Self::insert_revision(tx, revision).await?; + + Ok(McpDeploymentExtRevisionRecord { + environment_id, + domain, + entity_created_at: revision.audit.created_at.clone(), + revision, + }) + } + .boxed() + }) + .await + } + + async fn update( + &self, + revision: McpDeploymentRevisionRecord, + ) -> Result { + self.with_tx_err("update", |tx| { + async move { + let revision = Self::insert_revision(tx, revision).await?; + + let mcp_deployment: (Uuid, crate::repo::model::datetime::SqlDateTime, String) = tx + .fetch_one_as( + sqlx::query_as(indoc! { r#" + UPDATE mcp_deployments + SET current_revision_id = $1, modified_by = $2, deleted_at = NULL + WHERE mcp_deployment_id = $3 + RETURNING environment_id, created_at, domain + "# }) + .bind(revision.revision_id) + .bind(revision.audit.created_by) + .bind(revision.mcp_deployment_id), + ) + .await?; + + Ok(McpDeploymentExtRevisionRecord { + environment_id: mcp_deployment.0, + domain: mcp_deployment.2, + entity_created_at: mcp_deployment.1, + revision, + }) + } + .boxed() + }) + .await + } + + async fn delete( + &self, + user_account_id: Uuid, + mcp_deployment_id: Uuid, + revision_id: i64, + ) -> Result<(), McpDeploymentRepoError> { + self.with_tx_err("delete", |tx| { + async move { + let revision: McpDeploymentRevisionRecord = Self::insert_revision( + tx, + McpDeploymentRevisionRecord::deletion( + user_account_id, + mcp_deployment_id, + revision_id, + ), + ) + .await?; + + tx.execute( + sqlx::query(indoc! { r#" + UPDATE mcp_deployments + SET deleted_at = $1, modified_by = $2, current_revision_id = $3 + WHERE mcp_deployment_id = $4 + "# }) + .bind(&revision.audit.created_at) + .bind(revision.audit.created_by) + .bind(revision.revision_id) + .bind(revision.mcp_deployment_id), + ) + .await?; + + Ok(()) + } + .boxed() + }) + .await + } + + async fn get_staged_by_id( + &self, + mcp_deployment_id: Uuid, + ) -> RepoResult> { + self.with_ro("get_staged_by_id") + .fetch_optional_as( + sqlx::query_as(indoc! { r#" + SELECT m.environment_id, m.domain, mr.mcp_deployment_id, + mr.revision_id, mr.hash, mr.data, + mr.created_at, mr.created_by, mr.deleted, + m.created_at as entity_created_at + FROM mcp_deployments m + JOIN mcp_deployment_revisions mr + ON m.mcp_deployment_id = mr.mcp_deployment_id + AND m.current_revision_id = mr.revision_id + WHERE m.mcp_deployment_id = $1 AND m.deleted_at IS NULL + "# }) + .bind(mcp_deployment_id), + ) + .await + } + + async fn get_staged_by_domain( + &self, + environment_id: Uuid, + domain: &str, + ) -> RepoResult> { + self.with_ro("get_staged_by_domain") + .fetch_optional_as( + sqlx::query_as(indoc! { r#" + SELECT m.environment_id, m.domain, mr.mcp_deployment_id, + mr.revision_id, mr.hash, mr.data, + mr.created_at, mr.created_by, mr.deleted, + m.created_at as entity_created_at + FROM mcp_deployments m + JOIN mcp_deployment_revisions mr + ON m.mcp_deployment_id = mr.mcp_deployment_id + AND m.current_revision_id = mr.revision_id + WHERE m.environment_id = $1 AND m.domain = $2 AND m.deleted_at IS NULL + "# }) + .bind(environment_id) + .bind(domain), + ) + .await + } + + async fn get_by_id_and_revision( + &self, + mcp_deployment_id: Uuid, + revision_id: i64, + ) -> RepoResult> { + self.with_ro("get_by_id_and_revision") + .fetch_optional_as( + sqlx::query_as(indoc! { r#" + SELECT m.environment_id, m.domain, mr.mcp_deployment_id, + mr.revision_id, mr.hash, mr.data, + mr.created_at, mr.created_by, mr.deleted, + m.created_at as entity_created_at + FROM mcp_deployments m + JOIN mcp_deployment_revisions mr + ON m.mcp_deployment_id = mr.mcp_deployment_id + WHERE m.mcp_deployment_id = $1 AND mr.revision_id = $2 AND mr.deleted = FALSE + "# }) + .bind(mcp_deployment_id) + .bind(revision_id), + ) + .await + } + + async fn list_staged( + &self, + environment_id: Uuid, + ) -> RepoResult> { + self.with_ro("list_staged") + .fetch_all_as( + sqlx::query_as(indoc! { r#" + SELECT m.environment_id, m.domain, mr.mcp_deployment_id, + mr.revision_id, mr.hash, mr.data, + mr.created_at, mr.created_by, mr.deleted, + m.created_at as entity_created_at + FROM mcp_deployments m + JOIN mcp_deployment_revisions mr + ON m.mcp_deployment_id = mr.mcp_deployment_id + AND m.current_revision_id = mr.revision_id + WHERE m.environment_id = $1 AND m.deleted_at IS NULL + ORDER BY m.domain + "# }) + .bind(environment_id), + ) + .await + } + + async fn list_by_deployment( + &self, + environment_id: Uuid, + deployment_revision_id: i64, + ) -> RepoResult> { + self.with_ro("list_by_deployment") + .fetch_all_as( + sqlx::query_as(indoc! { r#" + SELECT m.environment_id, m.domain, mr.mcp_deployment_id, + mr.revision_id, mr.hash, mr.data, + mr.created_at, mr.created_by, mr.deleted, + m.created_at as entity_created_at + FROM mcp_deployments m + JOIN mcp_deployment_revisions mr ON m.mcp_deployment_id = mr.mcp_deployment_id + JOIN deployment_mcp_deployment_revisions dmdr + ON dmdr.mcp_deployment_id = mr.mcp_deployment_id + AND dmdr.mcp_deployment_revision_id = mr.revision_id + WHERE dmdr.environment_id = $1 AND dmdr.deployment_revision_id = $2 + ORDER BY m.domain + "# }) + .bind(environment_id) + .bind(deployment_revision_id), + ) + .await + } + + async fn get_in_deployment_by_domain( + &self, + environment_id: Uuid, + deployment_revision_id: i64, + domain: &str, + ) -> RepoResult> { + self.with_ro("get_in_deployment_by_domain") + .fetch_optional_as( + sqlx::query_as(indoc! { r#" + SELECT m.environment_id, m.domain, mr.mcp_deployment_id, + mr.revision_id, mr.hash, mr.data, + mr.created_at, mr.created_by, mr.deleted, + m.created_at as entity_created_at + FROM mcp_deployments m + JOIN mcp_deployment_revisions mr ON m.mcp_deployment_id = mr.mcp_deployment_id + JOIN deployment_mcp_deployment_revisions dmdr + ON dmdr.mcp_deployment_id = mr.mcp_deployment_id + AND dmdr.mcp_deployment_revision_id = mr.revision_id + WHERE dmdr.environment_id = $1 AND dmdr.deployment_revision_id = $2 AND m.domain = $3 + "# }) + .bind(environment_id) + .bind(deployment_revision_id) + .bind(domain), + ) + .await + } +} + +#[async_trait] +trait McpDeploymentRepoInternal: McpDeploymentRepo { + type Db: Database; + type Tx: LabelledPoolTransaction; + + async fn insert_revision( + tx: &mut Self::Tx, + revision: McpDeploymentRevisionRecord, + ) -> Result; +} + +#[trait_gen(PostgresPool -> PostgresPool, SqlitePool)] +#[async_trait] +impl McpDeploymentRepoInternal for DbMcpDeploymentRepo { + type Db = ::Db; + type Tx = <::LabelledApi as LabelledPoolApi>::LabelledTransaction; + + async fn insert_revision( + tx: &mut Self::Tx, + revision: McpDeploymentRevisionRecord, + ) -> Result { + let revision = revision.with_updated_hash(); + tx.fetch_one_as( + sqlx::query_as(indoc! { r#" + INSERT INTO mcp_deployment_revisions + (mcp_deployment_id, revision_id, data, + hash, created_at, created_by, deleted) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING mcp_deployment_id, revision_id, hash, + created_at, created_by, deleted, data + "# }) + .bind(revision.mcp_deployment_id) + .bind(revision.revision_id) + .bind(&revision.data) + .bind(revision.hash) + .bind_deletable_revision_audit(revision.audit), + ) + .await + .to_error_on_unique_violation(McpDeploymentRepoError::ConcurrentModification) + } +} diff --git a/golem-registry-service/src/repo/mod.rs b/golem-registry-service/src/repo/mod.rs index ba08e47deb..e041fae130 100644 --- a/golem-registry-service/src/repo/mod.rs +++ b/golem-registry-service/src/repo/mod.rs @@ -23,6 +23,7 @@ pub mod environment; pub mod environment_plugin_grant; pub mod environment_share; pub mod http_api_deployment; +pub mod mcp_deployment; pub mod oauth2_token; pub mod oauth2_webflow_state; pub mod plan; diff --git a/golem-registry-service/src/repo/model/deployment.rs b/golem-registry-service/src/repo/model/deployment.rs index 39d689b302..80c3944798 100644 --- a/golem-registry-service/src/repo/model/deployment.rs +++ b/golem-registry-service/src/repo/model/deployment.rs @@ -17,7 +17,10 @@ use crate::repo::model::audit::RevisionAuditFields; use crate::repo::model::component::ComponentRevisionIdentityRecord; use crate::repo::model::hash::SqlBlake3Hash; use crate::repo::model::http_api_deployment::HttpApiDeploymentRevisionIdentityRecord; +use crate::repo::model::mcp_deployment::McpDeploymentRevisionIdentityRecord; use anyhow::anyhow; +use desert_rust::BinaryCodec; +use golem_common::base_model::domain_registration::Domain; use golem_common::error_forwarding; use golem_common::model::account::AccountId; use golem_common::model::agent::DeployedRegisteredAgentType; @@ -29,8 +32,10 @@ use golem_common::model::deployment::{ use golem_common::model::diff::{self, Hash, Hashable}; use golem_common::model::environment::EnvironmentId; use golem_common::model::http_api_deployment::HttpApiDeployment; +use golem_common::model::mcp_deployment::McpDeployment; use golem_common::model::security_scheme::{Provider, SecuritySchemeId, SecuritySchemeName}; use golem_service_base::custom_api::SecuritySchemeDetails; +use golem_service_base::mcp::CompiledMcp; use golem_service_base::model::Component; use golem_service_base::repo::RepoError; use golem_service_base::repo::blob::Blob; @@ -168,6 +173,30 @@ impl DeploymentHttpApiDeploymentRevisionRecord { } } +#[derive(Debug, Clone, FromRow, PartialEq)] +pub struct DeploymentMcpDeploymentRevisionRecord { + pub environment_id: Uuid, + pub deployment_revision_id: i64, + pub mcp_deployment_id: Uuid, + pub mcp_deployment_revision_id: i64, +} + +impl DeploymentMcpDeploymentRevisionRecord { + pub fn from_model( + environment_id: EnvironmentId, + deployment_revision: DeploymentRevision, + mcp_deployment_id: Uuid, + mcp_deployment_revision_id: i64, + ) -> Self { + Self { + environment_id: environment_id.0, + deployment_revision_id: deployment_revision.into(), + mcp_deployment_id, + mcp_deployment_revision_id, + } + } +} + pub struct DeploymentHashes { pub env_hash: SqlBlake3Hash, pub deployment_hash: SqlBlake3Hash, @@ -176,6 +205,7 @@ pub struct DeploymentHashes { pub struct DeploymentIdentity { pub components: Vec, pub http_api_deployments: Vec, + pub mcp_deployments: Vec, } impl DeploymentIdentity { @@ -196,6 +226,11 @@ impl DeploymentIdentity { .into_iter() .map(|had| had.try_into()) .collect::, _>>()?, + mcp_deployments: self + .mcp_deployments + .into_iter() + .map(|mcd| mcd.try_into()) + .collect::, _>>()?, }) } } @@ -223,6 +258,16 @@ impl DeploymentIdentity { ) }) .collect(), + mcp_deployments: self + .mcp_deployments + .iter() + .map(|deployment| { + ( + (&deployment.domain).into(), + diff::HashOf::from_blake3_hash(deployment.hash.into()), + ) + }) + .collect(), } } } @@ -250,6 +295,12 @@ impl TryFrom for DeploymentSummary { .into_iter() .map(|had| had.try_into()) .collect::, _>>()?, + mcp_deployments: value + .identity + .mcp_deployments + .into_iter() + .map(|mcd| mcd.try_into()) + .collect::, _>>()?, }) } } @@ -371,7 +422,9 @@ pub struct DeploymentRevisionCreationRecord { pub components: Vec, pub http_api_deployments: Vec, + pub mcp_deployments: Vec, pub compiled_routes: Vec, + pub compiled_mcp: Vec, pub registered_agent_types: Vec, } @@ -383,7 +436,9 @@ impl DeploymentRevisionCreationRecord { hash: diff::Hash, components: Vec, http_api_deployments: Vec, + mcp_deployments: Vec, compiled_routes: Vec, + compiled_mcp: Vec, registered_agent_types: Vec, ) -> Self { Self { @@ -411,6 +466,17 @@ impl DeploymentRevisionCreationRecord { ) }) .collect(), + mcp_deployments: mcp_deployments + .into_iter() + .map(|mcd| { + DeploymentMcpDeploymentRevisionRecord::from_model( + environment_id, + deployment_revision, + mcd.id.0, + mcd.revision.into(), + ) + }) + .collect(), compiled_routes: compiled_routes .into_iter() .map(|r| { @@ -421,6 +487,10 @@ impl DeploymentRevisionCreationRecord { ) }) .collect(), + compiled_mcp: compiled_mcp + .into_iter() + .map(DeploymentCompiledMcpRecord::from_model) + .collect(), registered_agent_types: registered_agent_types .into_iter() .map(|r| { @@ -435,6 +505,50 @@ impl DeploymentRevisionCreationRecord { } } +#[derive(Debug, Clone, BinaryCodec)] +pub struct CompiledMcpData { + pub implementers: golem_service_base::mcp::AgentTypeImplementers, +} + +#[derive(FromRow)] +pub struct DeploymentCompiledMcpRecord { + pub account_id: Uuid, + pub environment_id: Uuid, + pub deployment_revision_id: i64, + pub domain: String, + pub mcp_data: Blob, +} + +impl DeploymentCompiledMcpRecord { + pub fn from_model(compiled_mcp: CompiledMcp) -> Self { + Self { + account_id: compiled_mcp.account_id.0, + environment_id: compiled_mcp.environment_id.0, + deployment_revision_id: compiled_mcp.deployment_revision.into(), + domain: compiled_mcp.domain.0.clone(), + mcp_data: Blob::new(CompiledMcpData { + implementers: compiled_mcp.agent_type_implementers, + }), + } + } +} + +impl TryFrom for CompiledMcp { + type Error = DeployRepoError; + + fn try_from(value: DeploymentCompiledMcpRecord) -> Result { + let mcp_data = value.mcp_data.into_value(); + + Ok(Self { + account_id: AccountId(value.account_id), + environment_id: EnvironmentId(value.environment_id), + deployment_revision: value.deployment_revision_id.try_into()?, + domain: Domain(value.domain), + agent_type_implementers: mcp_data.implementers, + }) + } +} + #[derive(FromRow)] pub struct DeploymentCompiledRouteWithSecuritySchemeRecord { pub account_id: Uuid, diff --git a/golem-registry-service/src/repo/model/mcp_deployment.rs b/golem-registry-service/src/repo/model/mcp_deployment.rs new file mode 100644 index 0000000000..c42d0000dc --- /dev/null +++ b/golem-registry-service/src/repo/model/mcp_deployment.rs @@ -0,0 +1,195 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::audit::DeletableRevisionAuditFields; +use super::hash::SqlBlake3Hash; +use crate::repo::model::datetime::SqlDateTime; +use desert_rust::BinaryCodec; +use golem_common::error_forwarding; +use golem_common::model::account::AccountId; +use golem_common::model::agent::AgentTypeName; +use golem_common::model::deployment::DeploymentPlanMcpDeploymentEntry; +use golem_common::model::diff::{ + Hashable, McpDeployment as DiffMcpDeployment, + McpDeploymentAgentOptions as DiffMcpDeploymentAgentOptions, +}; +use golem_common::model::domain_registration::Domain; +use golem_common::model::environment::EnvironmentId; +use golem_common::model::mcp_deployment::{ + McpDeployment, McpDeploymentAgentOptions, McpDeploymentId, McpDeploymentRevision, +}; +use golem_service_base::repo::RepoError; +use golem_service_base::repo::blob::Blob; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::collections::BTreeMap; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum McpDeploymentRepoError { + #[error("MCP deployment violates unique index")] + McpDeploymentViolatesUniqueness, + #[error("Concurrent modification")] + ConcurrentModification, + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +error_forwarding!(McpDeploymentRepoError, RepoError); + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, BinaryCodec)] +pub struct McpDeploymentData { + pub agents: BTreeMap, +} + +#[derive(Debug, Clone, FromRow, PartialEq)] +pub struct McpDeploymentRevisionRecord { + pub mcp_deployment_id: Uuid, + pub revision_id: i64, + pub hash: SqlBlake3Hash, + #[sqlx(flatten)] + pub audit: DeletableRevisionAuditFields, + pub data: Blob, +} + +impl McpDeploymentRevisionRecord { + pub(in crate::repo) fn for_recreation( + mut self, + mcp_deployment_id: Uuid, + revision_id: i64, + ) -> Result { + let revision: McpDeploymentRevision = revision_id.try_into()?; + let next_revision_id = revision.next()?.into(); + + self.mcp_deployment_id = mcp_deployment_id; + self.revision_id = next_revision_id; + + Ok(self) + } + + pub fn creation( + mcp_deployment_id: McpDeploymentId, + actor: AccountId, + agents: BTreeMap, + ) -> Self { + let mut value = Self { + mcp_deployment_id: mcp_deployment_id.0, + revision_id: McpDeploymentRevision::INITIAL.into(), + hash: SqlBlake3Hash::empty(), + audit: DeletableRevisionAuditFields::new(actor.0), + data: Blob::new(McpDeploymentData { agents }), + }; + value.update_hash(); + value + } + + pub fn from_model(deployment: McpDeployment, audit: DeletableRevisionAuditFields) -> Self { + let mut value = Self { + mcp_deployment_id: deployment.id.0, + revision_id: deployment.revision.into(), + hash: SqlBlake3Hash::empty(), + audit, + data: Blob::new(McpDeploymentData { + agents: deployment.agents, + }), + }; + value.update_hash(); + value + } + + pub fn deletion( + created_by: uuid::Uuid, + mcp_deployment_id: Uuid, + current_revision_id: i64, + ) -> Self { + let mut value = Self { + mcp_deployment_id, + revision_id: current_revision_id, + hash: SqlBlake3Hash::empty(), + audit: DeletableRevisionAuditFields::deletion(created_by), + data: Blob::new(McpDeploymentData { + agents: Default::default(), + }), + }; + value.update_hash(); + value + } + + pub fn to_diffable(&self) -> DiffMcpDeployment { + DiffMcpDeployment { + agents: self + .data + .value() + .agents + .keys() + .map(|k| (k.0.clone(), DiffMcpDeploymentAgentOptions::default())) + .collect(), + } + } + + pub fn update_hash(&mut self) { + self.hash = self.to_diffable().hash().into_blake3().into(); + } + + pub fn with_updated_hash(mut self) -> Self { + self.update_hash(); + self + } +} + +#[derive(Debug, Clone, FromRow, PartialEq)] +pub struct McpDeploymentExtRevisionRecord { + pub environment_id: Uuid, + pub domain: String, + pub entity_created_at: SqlDateTime, + #[sqlx(flatten)] + pub revision: McpDeploymentRevisionRecord, +} + +impl TryFrom for McpDeployment { + type Error = McpDeploymentRepoError; + + fn try_from(value: McpDeploymentExtRevisionRecord) -> Result { + let data = value.revision.data.into_value(); + Ok(McpDeployment { + id: McpDeploymentId(value.revision.mcp_deployment_id), + revision: value.revision.revision_id.try_into()?, + environment_id: EnvironmentId(value.environment_id), + domain: Domain(value.domain), + hash: value.revision.hash.into(), + agents: data.agents, + created_at: value.entity_created_at.into(), + }) + } +} + +#[derive(Debug, Clone, FromRow, PartialEq)] +pub struct McpDeploymentRevisionIdentityRecord { + pub mcp_deployment_id: Uuid, + pub domain: String, + pub revision_id: i64, + pub hash: SqlBlake3Hash, +} + +impl TryFrom for DeploymentPlanMcpDeploymentEntry { + type Error = RepoError; + fn try_from(value: McpDeploymentRevisionIdentityRecord) -> Result { + Ok(Self { + id: McpDeploymentId(value.mcp_deployment_id), + revision: value.revision_id.try_into()?, + domain: Domain(value.domain), + hash: value.hash.into(), + }) + } +} diff --git a/golem-registry-service/src/repo/model/mod.rs b/golem-registry-service/src/repo/model/mod.rs index 9ee5c80f2a..e80b92dd5c 100644 --- a/golem-registry-service/src/repo/model/mod.rs +++ b/golem-registry-service/src/repo/model/mod.rs @@ -25,6 +25,7 @@ pub mod environment_plugin_grant; pub mod environment_share; pub mod hash; pub mod http_api_deployment; +pub mod mcp_deployment; pub mod oauth2_token; pub mod oauth2_webflow_state; pub mod plan; diff --git a/golem-registry-service/src/services/deployment/deployment_context.rs b/golem-registry-service/src/services/deployment/deployment_context.rs index b23ccd704f..63e7c51b46 100644 --- a/golem-registry-service/src/services/deployment/deployment_context.rs +++ b/golem-registry-service/src/services/deployment/deployment_context.rs @@ -22,6 +22,7 @@ use crate::services::deployment::route_compilation::{ make_invalid_agent_mount_error_maker, }; use crate::services::deployment::write::DeployValidationError; +use golem_common::base_model::account::AccountId; use golem_common::model::agent::DeployedRegisteredAgentType; use golem_common::model::agent::wit_naming::ToWitNaming; use golem_common::model::agent::{AgentType, AgentTypeName, RegisteredAgentTypeImplementer}; @@ -57,6 +58,7 @@ pub struct DeploymentContext { pub environment: Environment, pub components: BTreeMap, pub http_api_deployments: BTreeMap, + pub mcp_deployments: BTreeMap, } impl DeploymentContext { @@ -64,6 +66,7 @@ impl DeploymentContext { environment: Environment, components: Vec, http_api_deployments: Vec, + mcp_deployments: Vec, ) -> Self { Self { environment, @@ -75,6 +78,10 @@ impl DeploymentContext { .into_iter() .map(|had| (had.domain.clone(), had)) .collect(), + mcp_deployments: mcp_deployments + .into_iter() + .map(|mcd| (mcd.domain.clone(), mcd)) + .collect(), } } @@ -90,6 +97,11 @@ impl DeploymentContext { .iter() .map(|(k, v)| (k.0.clone(), HashOf::from_hash(v.hash))) .collect(), + mcp_deployments: self + .mcp_deployments + .iter() + .map(|(k, v)| (k.0.clone(), HashOf::from_hash(v.hash))) + .collect(), }; diffable.hash() } @@ -259,6 +271,56 @@ impl DeploymentContext { Ok(all_routes) } + + pub fn compile_mcp_deployments( + &self, + registered_agent_types: &HashMap, + account_id: AccountId, + deployment_revision: golem_common::model::deployment::DeploymentRevision, + ) -> Result, DeploymentWriteError> { + let mut all_compiled_mcps = Vec::new(); + let mut errors = Vec::new(); + + for (domain, mcp_deployment) in &self.mcp_deployments { + let mut agent_type_implementers: golem_service_base::mcp::AgentTypeImplementers = + HashMap::new(); + + for agent_type in mcp_deployment.agents.keys() { + let registered_agent_type = ok_or_continue!( + registered_agent_types.get(agent_type).ok_or( + DeployValidationError::McpDeploymentMissingAgentType { + mcp_deployment_domain: domain.clone(), + missing_agent_type: agent_type.clone(), + } + ), + errors + ); + + agent_type_implementers.insert( + agent_type.clone(), + ( + registered_agent_type.implemented_by.component_id, + registered_agent_type.implemented_by.component_revision, + ), + ); + } + + let compiled_mcp = golem_service_base::mcp::CompiledMcp { + account_id, + environment_id: self.environment.id, + deployment_revision, + domain: domain.clone(), + agent_type_implementers, + }; + all_compiled_mcps.push(compiled_mcp); + } + + if !errors.is_empty() { + return Err(DeploymentWriteError::DeploymentValidationFailed(errors)); + }; + + Ok(all_compiled_mcps) + } } fn validate_final_router( diff --git a/golem-registry-service/src/services/deployment/mcp.rs b/golem-registry-service/src/services/deployment/mcp.rs new file mode 100644 index 0000000000..a039e531d5 --- /dev/null +++ b/golem-registry-service/src/services/deployment/mcp.rs @@ -0,0 +1,55 @@ +use crate::repo::deployment::DeploymentRepo; +use crate::repo::model::deployment::DeployRepoError; +use golem_common::base_model::domain_registration::Domain; +use golem_common::{SafeDisplay, error_forwarding}; +use golem_service_base::mcp::CompiledMcp; +use golem_service_base::repo::RepoError; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub enum DeployedMcpError { + #[error("No active mcp capabilities for domain {0} found")] + NoActiveMcpForDomain(Domain), + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +impl SafeDisplay for DeployedMcpError { + fn to_safe_string(&self) -> String { + match self { + Self::NoActiveMcpForDomain(_) => self.to_string(), + Self::InternalError(_) => "Internal error".to_string(), + } + } +} + +error_forwarding!(DeployedMcpError, RepoError, DeployRepoError); + +pub struct DeployedMcpService { + deployment_repo: Arc, +} + +impl DeployedMcpService { + pub fn new(deployment_repo: Arc) -> Self { + Self { deployment_repo } + } + + pub async fn get_currently_active_mcp( + &self, + domain: &Domain, + ) -> Result { + let optional_deployment = self + .deployment_repo + .get_active_mcp_for_domain(&domain.0) + .await?; + + match optional_deployment { + Some(deployment) => { + let compiled_mcp = CompiledMcp::try_from(deployment)?; + + Ok(compiled_mcp) + } + None => Err(DeployedMcpError::NoActiveMcpForDomain(domain.clone())), + } + } +} diff --git a/golem-registry-service/src/services/deployment/mod.rs b/golem-registry-service/src/services/deployment/mod.rs index 17310b872f..c34fe334e2 100644 --- a/golem-registry-service/src/services/deployment/mod.rs +++ b/golem-registry-service/src/services/deployment/mod.rs @@ -14,11 +14,13 @@ mod deployment_context; mod http_parameter_conversion; +mod mcp; mod read; mod route_compilation; mod routes; mod write; +pub use self::mcp::{DeployedMcpError, DeployedMcpService}; pub use self::read::{DeploymentError, DeploymentService}; pub use self::routes::{DeployedRoutesError, DeployedRoutesService}; pub use self::write::{DeploymentWriteError, DeploymentWriteService}; diff --git a/golem-registry-service/src/services/deployment/write.rs b/golem-registry-service/src/services/deployment/write.rs index 480c126c6b..276ba26a50 100644 --- a/golem-registry-service/src/services/deployment/write.rs +++ b/golem-registry-service/src/services/deployment/write.rs @@ -19,6 +19,7 @@ use crate::services::component::{ComponentError, ComponentService}; use crate::services::deployment::route_compilation::render_http_method; use crate::services::environment::{EnvironmentError, EnvironmentService}; use crate::services::http_api_deployment::{HttpApiDeploymentError, HttpApiDeploymentService}; +use crate::services::mcp_deployment::{McpDeploymentError, McpDeploymentService}; use futures::TryFutureExt; use golem_common::model::agent::{AgentTypeName, DeployedRegisteredAgentType, HttpMethod}; use golem_common::model::component::ComponentName; @@ -89,7 +90,8 @@ error_forwarding!( EnvironmentError, DeployRepoError, ComponentError, - HttpApiDeploymentError + HttpApiDeploymentError, + McpDeploymentError ); #[derive(Debug, Clone, thiserror::Error, PartialEq)] @@ -101,6 +103,13 @@ pub enum DeployValidationError { http_api_deployment_domain: Domain, missing_agent_type: AgentTypeName, }, + #[error( + "Agent type {missing_agent_type} requested by mcp deployment {mcp_deployment_domain} is not part of the deployment" + )] + McpDeploymentMissingAgentType { + mcp_deployment_domain: Domain, + missing_agent_type: AgentTypeName, + }, #[error("Invalid path pattern: {0}")] HttpApiDefinitionInvalidPathPattern(String), #[error("Invalid http cors binding expression: {0}")] @@ -179,6 +188,7 @@ pub struct DeploymentWriteService { deployment_repo: Arc, component_service: Arc, http_api_deployment_service: Arc, + mcp_deployment_service: Arc, } impl DeploymentWriteService { @@ -187,12 +197,14 @@ impl DeploymentWriteService { deployment_repo: Arc, component_service: Arc, http_api_deployment_service: Arc, + mcp_deployment_service: Arc, ) -> DeploymentWriteService { Self { environment_service, deployment_repo, component_service, http_api_deployment_service, + mcp_deployment_service, } } @@ -249,17 +261,32 @@ impl DeploymentWriteService { tracing::info!("Creating deployment for environment: {environment_id}"); - let (components, http_api_deployments) = tokio::try_join!( + let (components, http_api_deployments, mcp_deployments) = tokio::try_join!( self.component_service .list_staged_components_for_environment(&environment, auth) .map_err(DeploymentWriteError::from), self.http_api_deployment_service .list_staged_for_environment(&environment, auth) .map_err(DeploymentWriteError::from), + self.mcp_deployment_service + .list_staged_for_environment(&environment, auth) + .map_err(DeploymentWriteError::from), )?; - let deployment_context = - DeploymentContext::new(environment, components, http_api_deployments); + tracing::info!( + "Fetched staged deployment data for environment: {environment_id}, components: {}, http api deployments: {}, mcp deployments: {}", + components.len(), + http_api_deployments.len(), + mcp_deployments.len() + ); + + let account_id = environment.owner_account_id; + let deployment_context = DeploymentContext::new( + environment, + components, + http_api_deployments, + mcp_deployments, + ); { let actual_hash = deployment_context.hash(); @@ -275,6 +302,12 @@ impl DeploymentWriteService { let compiled_routes = deployment_context.compile_http_api_routes(®istered_agent_types)?; + let compiled_mcps = deployment_context.compile_mcp_deployments( + ®istered_agent_types, + account_id, + next_deployment_revision, + )?; + let record = DeploymentRevisionCreationRecord::from_model( environment_id, next_deployment_revision, @@ -285,7 +318,9 @@ impl DeploymentWriteService { .http_api_deployments .into_values() .collect(), + deployment_context.mcp_deployments.into_values().collect(), compiled_routes, + compiled_mcps, registered_agent_types .into_values() .map(DeployedRegisteredAgentType::from) diff --git a/golem-registry-service/src/services/mcp_deployment.rs b/golem-registry-service/src/services/mcp_deployment.rs new file mode 100644 index 0000000000..a4f5875449 --- /dev/null +++ b/golem-registry-service/src/services/mcp_deployment.rs @@ -0,0 +1,497 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::deployment::{DeploymentError, DeploymentService}; +use super::domain_registration::{DomainRegistrationError, DomainRegistrationService}; +use super::environment::{EnvironmentError, EnvironmentService}; +use crate::repo::mcp_deployment::McpDeploymentRepo; +use crate::repo::model::audit::DeletableRevisionAuditFields; +use crate::repo::model::mcp_deployment::{McpDeploymentRepoError, McpDeploymentRevisionRecord}; +use golem_common::model::deployment::DeploymentRevision; +use golem_common::model::domain_registration::Domain; +use golem_common::model::environment::{Environment, EnvironmentId}; +use golem_common::model::mcp_deployment::{ + McpDeployment, McpDeploymentCreation, McpDeploymentId, McpDeploymentRevision, + McpDeploymentUpdate, +}; +use golem_common::{SafeDisplay, error_forwarding}; +use golem_service_base::model::auth::{AuthCtx, AuthorizationError, EnvironmentAction}; +use golem_service_base::repo::RepoError; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub enum McpDeploymentError { + #[error("Environment {0} not found")] + ParentEnvironmentNotFound(EnvironmentId), + #[error("MCP deployment for id {0} not found")] + McpDeploymentNotFound(McpDeploymentId), + #[error("MCP deployment for domain {0} not found")] + McpDeploymentByDomainNotFound(Domain), + #[error("Deployment revision {0} does not exist")] + DeploymentRevisionNotFound(DeploymentRevision), + #[error("MCP deployment for domain {0} already exists in this environment")] + McpDeploymentForDomainAlreadyExists(Domain), + #[error("Domain {0} is not registered")] + DomainNotRegistered(Domain), + #[error("Concurrent update attempt")] + ConcurrentUpdate, + #[error(transparent)] + Unauthorized(#[from] AuthorizationError), + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +impl SafeDisplay for McpDeploymentError { + fn to_safe_string(&self) -> String { + match self { + Self::McpDeploymentNotFound(_) => self.to_string(), + Self::McpDeploymentByDomainNotFound(_) => self.to_string(), + Self::ParentEnvironmentNotFound(_) => self.to_string(), + Self::DeploymentRevisionNotFound(_) => self.to_string(), + Self::McpDeploymentForDomainAlreadyExists(_) => self.to_string(), + Self::DomainNotRegistered(_) => self.to_string(), + Self::ConcurrentUpdate => self.to_string(), + Self::Unauthorized(inner) => inner.to_safe_string(), + Self::InternalError(_) => "Internal error".to_string(), + } + } +} + +error_forwarding!( + McpDeploymentError, + McpDeploymentRepoError, + RepoError, + EnvironmentError, + DeploymentError, + DomainRegistrationError, +); + +pub struct McpDeploymentService { + mcp_deployment_repo: Arc, + environment_service: Arc, + deployment_service: Arc, + domain_registration_service: Arc, +} + +impl McpDeploymentService { + pub fn new( + mcp_deployment_repo: Arc, + environment_service: Arc, + deployment_service: Arc, + domain_registration_service: Arc, + ) -> Self { + Self { + mcp_deployment_repo, + environment_service, + deployment_service, + domain_registration_service, + } + } + + pub async fn create( + &self, + environment_id: EnvironmentId, + data: McpDeploymentCreation, + auth: &AuthCtx, + ) -> Result { + let environment = self + .environment_service + .get(environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(id) => { + McpDeploymentError::ParentEnvironmentNotFound(id) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::CreateMcpDeployment, + )?; + + self.domain_registration_service + .get_in_environment(&environment, &data.domain, auth) + .await + .map_err(|err| match err { + DomainRegistrationError::DomainRegistrationByDomainNotFound(domain) => { + McpDeploymentError::DomainNotRegistered(domain) + } + other => other.into(), + })?; + + let id = McpDeploymentId::new(); + let record = McpDeploymentRevisionRecord::creation(id, auth.account_id(), data.agents); + + let stored_mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .create(environment_id.0, &data.domain.0, record) + .await + .map_err(|err| match err { + McpDeploymentRepoError::ConcurrentModification => { + McpDeploymentError::ConcurrentUpdate + } + McpDeploymentRepoError::McpDeploymentViolatesUniqueness => { + McpDeploymentError::McpDeploymentForDomainAlreadyExists(data.domain) + } + other => other.into(), + })? + .try_into()?; + + Ok(stored_mcp_deployment) + } + + pub async fn update( + &self, + mcp_deployment_id: McpDeploymentId, + update: McpDeploymentUpdate, + auth: &AuthCtx, + ) -> Result { + let mut mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .get_staged_by_id(mcp_deployment_id.0) + .await? + .ok_or(McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))? + .try_into()?; + + let environment = self + .environment_service + .get(mcp_deployment.environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(_) => { + McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + ) + .map_err(|_| McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::UpdateMcpDeployment, + )?; + + if update.current_revision != mcp_deployment.revision { + Err(McpDeploymentError::ConcurrentUpdate)? + }; + + mcp_deployment.revision = mcp_deployment.revision.next()?; + if let Some(agents) = update.agents { + mcp_deployment.agents = agents; + }; + + let record = McpDeploymentRevisionRecord::from_model( + mcp_deployment, + DeletableRevisionAuditFields::new(auth.account_id().0), + ); + + let stored_mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .update(record) + .await + .map_err(|err| match err { + McpDeploymentRepoError::ConcurrentModification => { + McpDeploymentError::ConcurrentUpdate + } + other => other.into(), + })? + .try_into()?; + + Ok(stored_mcp_deployment) + } + + pub async fn delete( + &self, + mcp_deployment_id: McpDeploymentId, + current_revision: McpDeploymentRevision, + auth: &AuthCtx, + ) -> Result<(), McpDeploymentError> { + let mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .get_staged_by_id(mcp_deployment_id.0) + .await? + .ok_or(McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))? + .try_into()?; + + let environment = self + .environment_service + .get(mcp_deployment.environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(_) => { + McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + ) + .map_err(|_| McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::DeleteMcpDeployment, + )?; + + if current_revision != mcp_deployment.revision { + Err(McpDeploymentError::ConcurrentUpdate)? + }; + + self.mcp_deployment_repo + .delete( + auth.account_id().0, + mcp_deployment_id.0, + current_revision.next()?.into(), + ) + .await + .map_err(|err| match err { + McpDeploymentRepoError::ConcurrentModification => { + McpDeploymentError::ConcurrentUpdate + } + other => other.into(), + })?; + + Ok(()) + } + + pub async fn get_staged( + &self, + mcp_deployment_id: McpDeploymentId, + auth: &AuthCtx, + ) -> Result { + let mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .get_staged_by_id(mcp_deployment_id.0) + .await? + .ok_or(McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))? + .try_into()?; + + let environment = self + .environment_service + .get(mcp_deployment.environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(_) => { + McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + ) + .map_err(|_| McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))?; + + Ok(mcp_deployment) + } + + pub async fn list_staged( + &self, + environment_id: EnvironmentId, + auth: &AuthCtx, + ) -> Result, McpDeploymentError> { + let environment = self + .environment_service + .get(environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(environment_id) => { + McpDeploymentError::ParentEnvironmentNotFound(environment_id) + } + other => other.into(), + })?; + + self.list_staged_for_environment(&environment, auth).await + } + + pub async fn list_staged_for_environment( + &self, + environment: &Environment, + auth: &AuthCtx, + ) -> Result, McpDeploymentError> { + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + )?; + + let mcp_deployments: Vec = self + .mcp_deployment_repo + .list_staged(environment.id.0) + .await? + .into_iter() + .map(|r| r.try_into()) + .collect::, _>>()?; + + Ok(mcp_deployments) + } + + pub async fn get_staged_by_domain( + &self, + environment_id: EnvironmentId, + domain: &Domain, + auth: &AuthCtx, + ) -> Result { + let environment = self + .environment_service + .get(environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(environment_id) => { + McpDeploymentError::ParentEnvironmentNotFound(environment_id) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + ) + .map_err(|_| McpDeploymentError::McpDeploymentByDomainNotFound(domain.clone()))?; + + let mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .get_staged_by_domain(environment_id.0, &domain.0) + .await? + .ok_or(McpDeploymentError::McpDeploymentByDomainNotFound( + domain.clone(), + ))? + .try_into()?; + + Ok(mcp_deployment) + } + + pub async fn get_revision( + &self, + mcp_deployment_id: McpDeploymentId, + revision: McpDeploymentRevision, + auth: &AuthCtx, + ) -> Result { + let mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .get_by_id_and_revision(mcp_deployment_id.0, revision.into()) + .await? + .ok_or(McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))? + .try_into()?; + + let environment = self + .environment_service + .get(mcp_deployment.environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(_) => { + McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + ) + .map_err(|_| McpDeploymentError::McpDeploymentNotFound(mcp_deployment_id))?; + + Ok(mcp_deployment) + } + + pub async fn get_in_deployment_by_domain( + &self, + environment_id: EnvironmentId, + deployment_revision: DeploymentRevision, + domain: &Domain, + auth: &AuthCtx, + ) -> Result { + let (_, environment) = self + .deployment_service + .get_deployment_and_environment(environment_id, deployment_revision, auth) + .await + .map_err(|err| match err { + DeploymentError::ParentEnvironmentNotFound(environment_id) => { + McpDeploymentError::ParentEnvironmentNotFound(environment_id) + } + DeploymentError::DeploymentNotFound(deployment_revision) => { + McpDeploymentError::DeploymentRevisionNotFound(deployment_revision) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + ) + .map_err(|_| McpDeploymentError::McpDeploymentByDomainNotFound(domain.clone()))?; + + let mcp_deployment: McpDeployment = self + .mcp_deployment_repo + .get_in_deployment_by_domain(environment_id.0, deployment_revision.into(), &domain.0) + .await? + .ok_or(McpDeploymentError::McpDeploymentByDomainNotFound( + domain.clone(), + ))? + .try_into()?; + + Ok(mcp_deployment) + } + + pub async fn list_in_deployment( + &self, + environment_id: EnvironmentId, + deployment_revision: DeploymentRevision, + auth: &AuthCtx, + ) -> Result, McpDeploymentError> { + let environment = self + .environment_service + .get(environment_id, false, auth) + .await + .map_err(|err| match err { + EnvironmentError::EnvironmentNotFound(environment_id) => { + McpDeploymentError::ParentEnvironmentNotFound(environment_id) + } + other => other.into(), + })?; + + auth.authorize_environment_action( + environment.owner_account_id, + &environment.roles_from_active_shares, + EnvironmentAction::ViewMcpDeployment, + )?; + + let mcp_deployments: Vec = self + .mcp_deployment_repo + .list_by_deployment(environment_id.0, deployment_revision.into()) + .await? + .into_iter() + .map(|r| r.try_into()) + .collect::, _>>()?; + + Ok(mcp_deployments) + } +} diff --git a/golem-registry-service/src/services/mod.rs b/golem-registry-service/src/services/mod.rs index 47e3f1ad4b..9191597ead 100644 --- a/golem-registry-service/src/services/mod.rs +++ b/golem-registry-service/src/services/mod.rs @@ -26,6 +26,7 @@ pub mod environment; pub mod environment_plugin_grant; pub mod environment_share; pub mod http_api_deployment; +pub mod mcp_deployment; pub mod oauth2; pub mod oauth2_github_client; pub mod plan; diff --git a/golem-registry-service/tests/repo/common.rs b/golem-registry-service/tests/repo/common.rs index 3dd2c4e245..6477b7e9d8 100644 --- a/golem-registry-service/tests/repo/common.rs +++ b/golem-registry-service/tests/repo/common.rs @@ -50,6 +50,9 @@ use golem_registry_service::repo::model::hash::SqlBlake3Hash; use golem_registry_service::repo::model::http_api_deployment::{ HttpApiDeploymentData, HttpApiDeploymentRepoError, HttpApiDeploymentRevisionRecord, }; +use golem_registry_service::repo::model::mcp_deployment::{ + McpDeploymentData, McpDeploymentRepoError, McpDeploymentRevisionRecord, +}; use golem_registry_service::repo::model::new_repo_uuid; use golem_registry_service::repo::model::plugin::PluginRecord; use golem_service_base::repo::blob::Blob; @@ -1263,7 +1266,9 @@ async fn setup_resolve_env(deps: &Deps) -> ResolveTestEnv { hash: SqlBlake3Hash::empty(), components: vec![], http_api_deployments: vec![], + mcp_deployments: vec![], compiled_routes: vec![], + compiled_mcp: vec![], registered_agent_types: vec![agent_type_record], }; @@ -1481,3 +1486,183 @@ pub async fn test_resolve_agent_type_unknown_email_returns_none(deps: &Deps) { assert!(result.is_none()); } + +pub async fn test_mcp_deployment_create_and_update(deps: &Deps) { + let user = deps.create_account().await; + let app = deps.create_application(user.revision.account_id).await; + let env = deps.create_env(app.revision.application_id).await; + + let deployment_id = new_repo_uuid(); + let domain = "test-mcp.com"; + let revision_0 = McpDeploymentRevisionRecord { + mcp_deployment_id: deployment_id, + revision_id: 0, + hash: SqlBlake3Hash::empty(), + data: Blob::new(McpDeploymentData { + agents: Default::default(), + }), + audit: DeletableRevisionAuditFields::new(user.revision.account_id), + }; + + let _created_deployment = deps + .mcp_deployment_repo + .create(env.revision.environment_id, domain, revision_0.clone()) + .await + .unwrap(); + + let fetched_deployment = deps + .mcp_deployment_repo + .get_staged_by_id(deployment_id) + .await + .unwrap(); + let_assert!(Some(fetched_deployment) = fetched_deployment); + assert!(fetched_deployment.revision.revision_id == revision_0.revision_id); + assert!(fetched_deployment.domain == domain); + + let fetched_by_domain = deps + .mcp_deployment_repo + .get_staged_by_domain(env.revision.environment_id, domain) + .await + .unwrap(); + let_assert!(Some(fetched_by_domain) = fetched_by_domain); + assert!(fetched_by_domain.revision.revision_id == revision_0.revision_id); + assert!(fetched_by_domain.domain == domain); + + // Update the deployment (domain stays the same, only data changes) + let revision_1 = McpDeploymentRevisionRecord { + mcp_deployment_id: deployment_id, + revision_id: 1, + hash: SqlBlake3Hash::empty(), + data: Blob::new(McpDeploymentData { + agents: Default::default(), + }), + audit: DeletableRevisionAuditFields::new(user.revision.account_id), + }; + + let updated_deployment = deps + .mcp_deployment_repo + .update(revision_1.clone()) + .await + .unwrap(); + + assert!(updated_deployment.revision.revision_id == revision_1.revision_id); + assert!(updated_deployment.domain == domain); + + // Domain should still be found + let domain_query = deps + .mcp_deployment_repo + .get_staged_by_domain(env.revision.environment_id, domain) + .await + .unwrap(); + let_assert!(Some(domain_query) = domain_query); + assert!(domain_query.revision.revision_id == revision_1.revision_id); + assert!(domain_query.domain == domain); +} + +pub async fn test_mcp_deployment_list_and_delete(deps: &Deps) { + let user = deps.create_account().await; + let app = deps.create_application(user.revision.account_id).await; + let env = deps.create_env(app.revision.application_id).await; + + let deployment_id = new_repo_uuid(); + let domain = "test-mcp-1.com"; + let revision_0 = McpDeploymentRevisionRecord { + mcp_deployment_id: deployment_id, + revision_id: 0, + hash: SqlBlake3Hash::empty(), + data: Blob::new(McpDeploymentData { + agents: Default::default(), + }), + audit: DeletableRevisionAuditFields::new(user.revision.account_id), + }; + + let _created_deployment = deps + .mcp_deployment_repo + .create(env.revision.environment_id, domain, revision_0.clone()) + .await + .unwrap(); + + let deployments = deps + .mcp_deployment_repo + .list_staged(env.revision.environment_id) + .await + .unwrap(); + + assert!(deployments.len() == 1); + + // Update the deployment + let revision_1 = McpDeploymentRevisionRecord { + mcp_deployment_id: deployment_id, + revision_id: 1, + hash: SqlBlake3Hash::empty(), + data: Blob::new(McpDeploymentData { + agents: Default::default(), + }), + audit: DeletableRevisionAuditFields::new(user.revision.account_id), + }; + + let _updated_deployment = deps + .mcp_deployment_repo + .update(revision_1.clone()) + .await + .unwrap(); + + let deployments = deps + .mcp_deployment_repo + .list_staged(env.revision.environment_id) + .await + .unwrap(); + + assert!(deployments.len() == 1); + + // Create another deployment + let other_deployment_id = new_repo_uuid(); + let other_domain = "test-mcp-2.com"; + let other_revision_0 = McpDeploymentRevisionRecord { + mcp_deployment_id: other_deployment_id, + revision_id: 0, + hash: SqlBlake3Hash::empty(), + data: Blob::new(McpDeploymentData { + agents: Default::default(), + }), + audit: DeletableRevisionAuditFields::new(user.revision.account_id), + }; + + let _created_other_deployment = deps + .mcp_deployment_repo + .create( + env.revision.environment_id, + other_domain, + other_revision_0.clone(), + ) + .await + .unwrap(); + + let deployments = deps + .mcp_deployment_repo + .list_staged(env.revision.environment_id) + .await + .unwrap(); + + assert!(deployments.len() == 2); + + let delete_with_old_revision = deps + .mcp_deployment_repo + .delete(user.revision.account_id, deployment_id, 1) + .await; + + let_assert!(Err(McpDeploymentRepoError::ConcurrentModification) = delete_with_old_revision); + + deps.mcp_deployment_repo + .delete(user.revision.account_id, deployment_id, 2) + .await + .unwrap(); + + let deployments = deps + .mcp_deployment_repo + .list_staged(env.revision.environment_id) + .await + .unwrap(); + + assert!(deployments.len() == 1); +} diff --git a/golem-registry-service/tests/repo/mod.rs b/golem-registry-service/tests/repo/mod.rs index 2f99e8a013..934a6c216b 100644 --- a/golem-registry-service/tests/repo/mod.rs +++ b/golem-registry-service/tests/repo/mod.rs @@ -21,6 +21,7 @@ use golem_registry_service::repo::deployment::DeploymentRepo; use golem_registry_service::repo::environment::EnvironmentRepo; use golem_registry_service::repo::environment_share::EnvironmentShareRepo; use golem_registry_service::repo::http_api_deployment::HttpApiDeploymentRepo; +use golem_registry_service::repo::mcp_deployment::McpDeploymentRepo; use golem_registry_service::repo::model::account::{ AccountExtRevisionRecord, AccountRevisionRecord, }; @@ -56,6 +57,7 @@ pub struct Deps { pub plan_repo: Box, pub component_repo: Box, pub http_api_deployment_repo: Box, + pub mcp_deployment_repo: Box, pub deployment_repo: Box, pub full_deployment_repo: Box, pub environment_share_repo: Box, diff --git a/golem-registry-service/tests/repo/postgres.rs b/golem-registry-service/tests/repo/postgres.rs index 25d3cd7c4e..9cd1013e53 100644 --- a/golem-registry-service/tests/repo/postgres.rs +++ b/golem-registry-service/tests/repo/postgres.rs @@ -23,6 +23,7 @@ use golem_registry_service::repo::deployment::DbDeploymentRepo; use golem_registry_service::repo::environment::DbEnvironmentRepo; use golem_registry_service::repo::environment_share::DbEnvironmentShareRepo; use golem_registry_service::repo::http_api_deployment::DbHttpApiDeploymentRepo; +use golem_registry_service::repo::mcp_deployment::DbMcpDeploymentRepo; use golem_registry_service::repo::plan::DbPlanRepo; use golem_registry_service::repo::plugin::DbPluginRepo; use golem_service_base::db; @@ -158,6 +159,7 @@ async fn deps(db: &PostgresDb) -> Deps { plan_repo: Box::new(DbPlanRepo::logged(db.pool.clone())), component_repo: Box::new(DbComponentRepo::logged(db.pool.clone())), http_api_deployment_repo: Box::new(DbHttpApiDeploymentRepo::logged(db.pool.clone())), + mcp_deployment_repo: Box::new(DbMcpDeploymentRepo::logged(db.pool.clone())), deployment_repo: Box::new(DbHttpApiDeploymentRepo::logged(db.pool.clone())), full_deployment_repo: Box::new(DbDeploymentRepo::logged(db.pool.clone())), environment_share_repo: Box::new(DbEnvironmentShareRepo::logged(db.pool.clone())), @@ -253,3 +255,13 @@ async fn test_resolve_agent_type_nonexistent_revision_returns_none(deps: &Deps) async fn test_resolve_agent_type_unknown_email_returns_none(deps: &Deps) { crate::repo::common::test_resolve_agent_type_unknown_email_returns_none(deps).await; } + +#[test] +async fn test_mcp_deployment_create_and_update(deps: &Deps) { + crate::repo::common::test_mcp_deployment_create_and_update(deps).await; +} + +#[test] +async fn test_mcp_deployment_list_and_delete(deps: &Deps) { + crate::repo::common::test_mcp_deployment_list_and_delete(deps).await; +} diff --git a/golem-registry-service/tests/repo/sqlite.rs b/golem-registry-service/tests/repo/sqlite.rs index 34b3873ada..a9ec5d5cc0 100644 --- a/golem-registry-service/tests/repo/sqlite.rs +++ b/golem-registry-service/tests/repo/sqlite.rs @@ -23,6 +23,7 @@ use golem_registry_service::repo::deployment::DbDeploymentRepo; use golem_registry_service::repo::environment::DbEnvironmentRepo; use golem_registry_service::repo::environment_share::DbEnvironmentShareRepo; use golem_registry_service::repo::http_api_deployment::DbHttpApiDeploymentRepo; +use golem_registry_service::repo::mcp_deployment::DbMcpDeploymentRepo; use golem_registry_service::repo::model::new_repo_uuid; use golem_registry_service::repo::plan::DbPlanRepo; use golem_registry_service::repo::plugin::DbPluginRepo; @@ -88,6 +89,7 @@ async fn deps(db: &SqliteDb) -> Deps { plan_repo: Box::new(DbPlanRepo::logged(db.pool.clone())), component_repo: Box::new(DbComponentRepo::logged(db.pool.clone())), http_api_deployment_repo: Box::new(DbHttpApiDeploymentRepo::logged(db.pool.clone())), + mcp_deployment_repo: Box::new(DbMcpDeploymentRepo::logged(db.pool.clone())), deployment_repo: Box::new(DbHttpApiDeploymentRepo::logged(db.pool.clone())), full_deployment_repo: Box::new(DbDeploymentRepo::logged(db.pool.clone())), environment_share_repo: Box::new(DbEnvironmentShareRepo::logged(db.pool.clone())), @@ -183,3 +185,13 @@ async fn test_resolve_agent_type_nonexistent_revision_returns_none(deps: &Deps) async fn test_resolve_agent_type_unknown_email_returns_none(deps: &Deps) { crate::repo::common::test_resolve_agent_type_unknown_email_returns_none(deps).await; } + +#[test] +async fn test_mcp_deployment_create_and_update(deps: &Deps) { + crate::repo::common::test_mcp_deployment_create_and_update(deps).await; +} + +#[test] +async fn test_mcp_deployment_list_and_delete(deps: &Deps) { + crate::repo::common::test_mcp_deployment_list_and_delete(deps).await; +} diff --git a/golem-service-base/src/api_tags.rs b/golem-service-base/src/api_tags.rs index 0ab863cecf..5abb32b1ff 100644 --- a/golem-service-base/src/api_tags.rs +++ b/golem-service-base/src/api_tags.rs @@ -24,6 +24,7 @@ pub enum ApiTags { ApiCertificate, ApiDeployment, ApiDomain, + McpDeployment, ApiSecurity, Application, Component, diff --git a/golem-service-base/src/clients/registry.rs b/golem-service-base/src/clients/registry.rs index be81f71ce8..4d332a9e18 100644 --- a/golem-service-base/src/clients/registry.rs +++ b/golem-service-base/src/clients/registry.rs @@ -14,6 +14,7 @@ use crate::custom_api::CompiledRoutes; use crate::grpc::client::{GrpcClient, GrpcClientConfig}; +use crate::mcp::CompiledMcp; use crate::model::auth::{AuthCtx, AuthDetailsForEnvironment, UserAuthCtx}; use crate::model::{AccountResourceLimits, AgentDeploymentDetails, Component, ResourceLimits}; use async_trait::async_trait; @@ -21,20 +22,21 @@ use golem_api_grpc::proto::golem::registry::FuelUsageUpdate; use golem_api_grpc::proto::golem::registry::v1::registry_service_client::RegistryServiceClient; use golem_api_grpc::proto::golem::registry::v1::{ AuthenticateTokenRequest, BatchUpdateFuelUsageRequest, DownloadComponentRequest, - GetActiveRoutesForDomainRequest, GetAgentDeploymentsRequest, GetAgentTypeRequest, - GetAllAgentTypesRequest, GetAllDeployedComponentRevisionsRequest, + GetActiveMcpForDomainRequest, GetActiveRoutesForDomainRequest, GetAgentDeploymentsRequest, + GetAgentTypeRequest, GetAllAgentTypesRequest, GetAllDeployedComponentRevisionsRequest, GetAuthDetailsForEnvironmentRequest, GetComponentMetadataRequest, GetDeployedComponentMetadataRequest, GetResourceLimitsRequest, ResolveAgentTypeAtDeploymentRequest, ResolveAgentTypeByNamesRequest, ResolveComponentRequest, UpdateWorkerConnectionLimitRequest, UpdateWorkerLimitRequest, authenticate_token_response, batch_update_fuel_usage_response, download_component_response, - get_active_routes_for_domain_response, get_agent_deployments_response, get_agent_type_response, - get_all_agent_types_response, get_all_deployed_component_revisions_response, - get_auth_details_for_environment_response, get_component_metadata_response, - get_deployed_component_metadata_response, get_resource_limits_response, - resolve_agent_type_at_deployment_response, resolve_agent_type_by_names_response, - resolve_component_response, resolve_latest_agent_type_by_names_response, - update_worker_connection_limit_response, update_worker_limit_response, + get_active_mcp_for_domain_response, get_active_routes_for_domain_response, + get_agent_deployments_response, get_agent_type_response, get_all_agent_types_response, + get_all_deployed_component_revisions_response, get_auth_details_for_environment_response, + get_component_metadata_response, get_deployed_component_metadata_response, + get_resource_limits_response, resolve_agent_type_at_deployment_response, + resolve_agent_type_by_names_response, resolve_component_response, + resolve_latest_agent_type_by_names_response, update_worker_connection_limit_response, + update_worker_limit_response, }; use golem_common::config::{ConfigExample, HasConfigExamples}; use golem_common::model::WorkerId; @@ -182,6 +184,11 @@ pub trait RegistryService: Send + Sync { domain: &Domain, ) -> Result; + async fn get_active_compiled_mcps_for_domain( + &self, + domain: &Domain, + ) -> Result; + async fn get_agent_deployments( &self, environment_id: EnvironmentId, @@ -800,6 +807,34 @@ impl RegistryService for GrpcRegistryService { } } + async fn get_active_compiled_mcps_for_domain( + &self, + domain: &Domain, + ) -> Result { + let response = self + .client + .call("get_active_mcp_for_domain", move |client| { + let request = GetActiveMcpForDomainRequest { + domain: domain.0.clone(), + }; + Box::pin(client.get_active_mcp_for_domain(request)) + }) + .await? + .into_inner(); + + match response.result { + None => Err(RegistryServiceError::empty_response()), + Some(get_active_mcp_for_domain_response::Result::Success(payload)) => { + let converted = payload + .compiled_mcp + .ok_or("missing compiled_mcp field")? + .try_into()?; + Ok(converted) + } + Some(get_active_mcp_for_domain_response::Result::Error(error)) => Err(error.into()), + } + } + async fn get_agent_deployments( &self, environment_id: EnvironmentId, diff --git a/golem-service-base/src/lib.rs b/golem-service-base/src/lib.rs index 2a3e9a78d8..c5acc0ea24 100644 --- a/golem-service-base/src/lib.rs +++ b/golem-service-base/src/lib.rs @@ -21,6 +21,7 @@ pub mod db; pub mod error; pub mod grpc; pub mod headers; +pub mod mcp; pub mod metrics; pub mod migration; pub mod model; diff --git a/golem-service-base/src/mcp/mod.rs b/golem-service-base/src/mcp/mod.rs new file mode 100644 index 0000000000..b0f2421310 --- /dev/null +++ b/golem-service-base/src/mcp/mod.rs @@ -0,0 +1,25 @@ +mod protobuf; + +use golem_common::base_model::account::AccountId; +use golem_common::base_model::deployment::DeploymentRevision; +use golem_common::base_model::domain_registration::Domain; +use golem_common::base_model::environment::EnvironmentId; +use golem_common::model::agent::AgentTypeName; +use golem_common::model::component::{ComponentId, ComponentRevision}; +use std::collections::HashMap; + +pub type AgentTypeImplementers = HashMap; + +pub struct CompiledMcp { + pub account_id: AccountId, + pub environment_id: EnvironmentId, + pub deployment_revision: DeploymentRevision, + pub domain: Domain, + pub agent_type_implementers: AgentTypeImplementers, +} + +impl CompiledMcp { + pub fn agent_types(&self) -> Vec { + self.agent_type_implementers.keys().cloned().collect() + } +} diff --git a/golem-service-base/src/mcp/protobuf.rs b/golem-service-base/src/mcp/protobuf.rs new file mode 100644 index 0000000000..ac3b03fe49 --- /dev/null +++ b/golem-service-base/src/mcp/protobuf.rs @@ -0,0 +1,71 @@ +use crate::mcp::{AgentTypeImplementers, CompiledMcp}; +use golem_common::base_model::domain_registration::Domain; +use golem_common::model::agent::AgentTypeName; + +impl From for golem_api_grpc::proto::golem::mcp::CompiledMcp { + fn from(value: CompiledMcp) -> Self { + Self { + account_id: Some(value.account_id.into()), + environment_id: Some(value.environment_id.into()), + deployment_revision: value.deployment_revision.into(), + domain: value.domain.0, + agent_type_implementers: value + .agent_type_implementers + .into_iter() + .map(|(name, (component_id, component_revision))| { + ( + name.0, + golem_api_grpc::proto::golem::registry::RegisteredAgentTypeImplementer { + component_id: Some(component_id.into()), + component_revision: component_revision.into(), + }, + ) + }) + .collect(), + } + } +} + +impl TryFrom for CompiledMcp { + type Error = String; + + fn try_from( + value: golem_api_grpc::proto::golem::mcp::CompiledMcp, + ) -> Result { + let agent_type_implementers: AgentTypeImplementers = value + .agent_type_implementers + .into_iter() + .map(|(name, implementer)| { + let component_id = implementer + .component_id + .ok_or("Missing component_id")? + .try_into() + .map_err(|e| format!("Invalid component_id: {}", e))?; + let component_revision = implementer + .component_revision + .try_into() + .map_err(|e| format!("Invalid component_revision: {}", e))?; + Ok((AgentTypeName(name), (component_id, component_revision))) + }) + .collect::>()?; + + Ok(Self { + account_id: value + .account_id + .ok_or("Missing account_id")? + .try_into() + .map_err(|e| format!("Invalid account_id: {}", e))?, + environment_id: value + .environment_id + .ok_or("Missing environment_id")? + .try_into() + .map_err(|e| format!("Invalid environment_id: {}", e))?, + deployment_revision: value + .deployment_revision + .try_into() + .map_err(|e| format!("Invalid deployment_revision: {}", e))?, + domain: Domain(value.domain), + agent_type_implementers, + }) + } +} diff --git a/golem-service-base/src/model/auth/mod.rs b/golem-service-base/src/model/auth/mod.rs index 7553d92a50..a18d6f1915 100644 --- a/golem-service-base/src/model/auth/mod.rs +++ b/golem-service-base/src/model/auth/mod.rs @@ -194,6 +194,7 @@ pub enum EnvironmentAction { CreateEnvironmentPluginGrant, CreateHttpApiDefinition, CreateHttpApiDeployment, + CreateMcpDeployment, CreateSecurityScheme, CreateShare, CreateWorker, @@ -203,6 +204,7 @@ pub enum EnvironmentAction { DeleteEnvironmentPluginGrant, DeleteHttpApiDefinition, DeleteHttpApiDeployment, + DeleteMcpDeployment, DeleteSecurityScheme, DeleteShare, DeleteWorker, @@ -211,6 +213,7 @@ pub enum EnvironmentAction { UpdateEnvironment, UpdateHttpApiDefinition, UpdateHttpApiDeployment, + UpdateMcpDeployment, UpdateSecurityScheme, UpdateShare, UpdateWorker, @@ -223,6 +226,7 @@ pub enum EnvironmentAction { ViewEnvironmentPluginGrant, ViewHttpApiDefinition, ViewHttpApiDeployment, + ViewMcpDeployment, ViewSecurityScheme, ViewShares, ViewWorker, @@ -618,6 +622,27 @@ impl AuthCtx { EnvironmentRole::Viewer, ], ), + // Mcp deployment + EnvironmentAction::CreateMcpDeployment => has_any_role( + roles_from_shares, + &[EnvironmentRole::Admin, EnvironmentRole::Deployer], + ), + EnvironmentAction::UpdateMcpDeployment => has_any_role( + roles_from_shares, + &[EnvironmentRole::Admin, EnvironmentRole::Deployer], + ), + EnvironmentAction::DeleteMcpDeployment => has_any_role( + roles_from_shares, + &[EnvironmentRole::Admin, EnvironmentRole::Deployer], + ), + EnvironmentAction::ViewMcpDeployment => has_any_role( + roles_from_shares, + &[ + EnvironmentRole::Admin, + EnvironmentRole::Deployer, + EnvironmentRole::Viewer, + ], + ), // agent types EnvironmentAction::ViewAgentTypes => has_any_role( roles_from_shares, diff --git a/golem-worker-service/Cargo.toml b/golem-worker-service/Cargo.toml index e946d5faa2..6f831e019a 100644 --- a/golem-worker-service/Cargo.toml +++ b/golem-worker-service/Cargo.toml @@ -43,6 +43,7 @@ bigdecimal = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } cookie = { workspace = true } +dashmap = { workspace = true } derive_more = { workspace = true } desert_rust = { workspace = true } figment = { workspace = true } @@ -62,7 +63,7 @@ openidconnect = { workspace = true } opentelemetry = { workspace = true } opentelemetry-prometheus-text-exporter = { workspace = true } opentelemetry_sdk = { workspace = true } -poem = { workspace = true, features = ["prometheus", "opentelemetry"] } +poem = { workspace = true, features = ["prometheus", "opentelemetry", "tower-compat"] } poem-derive = { workspace = true } poem-openapi = { workspace = true } poem-openapi-derive = { workspace = true } @@ -70,6 +71,7 @@ prometheus = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } regex = { workspace = true } +rmcp = { workspace = true } rustc-hash = { workspace = true } rustls = { workspace = true, features = [ "ring" ] } serde = { workspace = true, features = ["derive"] } @@ -93,6 +95,7 @@ url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } + [dev-dependencies] golem-test-framework.workspace = true diff --git a/golem-worker-service/config/worker-service.sample.env b/golem-worker-service/config/worker-service.sample.env index 2a913db2f8..fa09acb92d 100644 --- a/golem-worker-service/config/worker-service.sample.env +++ b/golem-worker-service/config/worker-service.sample.env @@ -3,6 +3,7 @@ GOLEM__CORS_ORIGIN_REGEX="https://*.golem.cloud" GOLEM__CUSTOM_REQUEST_PORT=9006 GOLEM__ENVIRONMENT="local" +GOLEM__MCP_PORT=9007 GOLEM__PORT=9005 GOLEM__WORKSPACE="release" GOLEM__AUTH_SERVICE__AUTH_CTX_CACHE_EVICTION_PERIOD="1m" diff --git a/golem-worker-service/config/worker-service.toml b/golem-worker-service/config/worker-service.toml index 6c143e1182..8d873f9577 100644 --- a/golem-worker-service/config/worker-service.toml +++ b/golem-worker-service/config/worker-service.toml @@ -2,6 +2,7 @@ cors_origin_regex = "https://*.golem.cloud" custom_request_port = 9006 environment = "local" +mcp_port = 9007 port = 9005 workspace = "release" diff --git a/golem-worker-service/src/bootstrap.rs b/golem-worker-service/src/bootstrap.rs index 83b9a8a3b6..24bd825053 100644 --- a/golem-worker-service/src/bootstrap.rs +++ b/golem-worker-service/src/bootstrap.rs @@ -23,6 +23,7 @@ use crate::custom_api::oidc::session_store::{RedisSessionStore, SessionStore, Sq use crate::custom_api::request_handler::RequestHandler; use crate::custom_api::route_resolver::RouteResolver; use crate::custom_api::webhoooks::WebhookCallbackHandler; +use crate::mcp::{McpCapabilityLookup, RegistryServiceMcpCapabilityLookup}; use crate::service::auth::{AuthService, RemoteAuthService}; use crate::service::component::{ComponentService, RemoteComponentService}; use crate::service::limit::{LimitService, RemoteLimitService}; @@ -43,6 +44,7 @@ pub struct Services { pub component_service: Arc, pub worker_service: Arc, pub request_handler: Arc, + pub mcp_capability_lookup: Arc, } impl Services { @@ -100,6 +102,10 @@ impl Services { api_definition_lookup_service.clone(), )); + let mcp_capability_lookup = Arc::new(RegistryServiceMcpCapabilityLookup::new( + registry_service_client.clone(), + )); + let call_agent_handler = Arc::new(CallAgentHandler::new(worker_service.clone())); let identity_provider = Arc::new(DefaultIdentityProvider); @@ -155,6 +161,7 @@ impl Services { component_service, worker_service, request_handler, + mcp_capability_lookup, }) } } diff --git a/golem-worker-service/src/config.rs b/golem-worker-service/src/config.rs index 79df01e781..bb1c03e25d 100644 --- a/golem-worker-service/src/config.rs +++ b/golem-worker-service/src/config.rs @@ -40,6 +40,7 @@ pub struct WorkerServiceConfig { pub worker_executor: WorkerExecutorClientConfig, pub workspace: String, pub registry_service: GrpcRegistryServiceConfig, + pub mcp_port: u16, pub cors_origin_regex: String, pub route_resolver: RouteResolverConfig, pub component_service: ComponentServiceConfig, @@ -71,6 +72,7 @@ impl SafeDisplay for WorkerServiceConfig { "Custom request port: {}", self.custom_request_port ); + let _ = writeln!(&mut result, "MCP port: {}", self.mcp_port); let _ = writeln!(&mut result, "grpc:"); let _ = writeln!(&mut result, "{}", self.grpc.to_safe_string_indented()); @@ -134,6 +136,7 @@ impl Default for WorkerServiceConfig { tracing: TracingConfig::local_dev("worker-service"), port: 9005, custom_request_port: 9006, + mcp_port: 9007, grpc: GrpcApiConfig::default(), routing_table: RoutingTableConfig::default(), worker_executor: WorkerExecutorClientConfig::default(), diff --git a/golem-worker-service/src/lib.rs b/golem-worker-service/src/lib.rs index dd641a5fb8..57ba122ae1 100644 --- a/golem-worker-service/src/lib.rs +++ b/golem-worker-service/src/lib.rs @@ -17,6 +17,7 @@ pub mod bootstrap; pub mod config; pub mod custom_api; pub mod grpcapi; +pub mod mcp; pub mod metrics; pub mod model; pub mod path; @@ -24,15 +25,19 @@ pub mod service; use crate::bootstrap::Services; use crate::config::WorkerServiceConfig; +use crate::mcp::GolemAgentMcpServer; use anyhow::{Context, anyhow}; use golem_common::poem::LazyEndpointExt; use opentelemetry_sdk::trace::SdkTracer; +use poem::endpoint::TowerCompatExt; use poem::endpoint::{BoxEndpoint, PrometheusExporter}; use poem::listener::Acceptor; use poem::listener::Listener; use poem::middleware::{CookieJarManager, Cors, OpenTelemetryMetrics, OpenTelemetryTracing}; use poem::{EndpointExt, Route}; use prometheus::Registry; +use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService}; use tokio::task::JoinSet; use tracing::{Instrument, info}; @@ -43,11 +48,13 @@ pub struct RunDetails { pub http_port: u16, pub grpc_port: u16, pub custom_request_port: u16, + pub mcp_port: u16, } pub struct TrafficReadyEndpoints { pub grpc_port: u16, pub custom_request_port: u16, + pub mcp_port: u16, pub api_endpoint: BoxEndpoint<'static>, } @@ -79,17 +86,21 @@ impl WorkerService { ) -> anyhow::Result { let grpc_port = self.start_grpc_server(join_set).await?; let http_port = self.start_http_server(join_set, tracer.clone()).await?; - let custom_request_port = self.start_api_gateway_server(join_set, tracer).await?; + let custom_request_port = self + .start_api_gateway_server(join_set, tracer.clone()) + .await?; + let mcp_port = self.start_mcp_server(join_set, tracer).await?; info!( - "Started worker service on ports: http: {}, grpc: {}, gateway: {}", - http_port, grpc_port, custom_request_port + "Started worker service on ports: http: {}, grpc: {}, gateway: {}, mcp: {}", + http_port, grpc_port, custom_request_port, mcp_port ); Ok(RunDetails { http_port, grpc_port, custom_request_port, + mcp_port, }) } @@ -100,11 +111,16 @@ impl WorkerService { tracer: Option, ) -> Result { let grpc_port = self.start_grpc_server(join_set).await?; - let custom_request_port = self.start_api_gateway_server(join_set, tracer).await?; + let custom_request_port = self + .start_api_gateway_server(join_set, tracer.clone()) + .await?; + let mcp_port = self.start_mcp_server(join_set, tracer).await?; let api_endpoint = api::make_open_api_service(&self.services).boxed(); + Ok(TrafficReadyEndpoints { grpc_port, api_endpoint, + mcp_port, custom_request_port, }) } @@ -200,4 +216,54 @@ impl WorkerService { Ok(port) } + + async fn start_mcp_server( + &self, + join_set: &mut JoinSet>, + tracer: Option, + ) -> anyhow::Result { + let poem_listener = + poem::listener::TcpListener::bind(format!("0.0.0.0:{}", self.config.mcp_port)); + + let acceptor = poem_listener.into_acceptor().await?; + + let port = acceptor.local_addr()[0] + .as_socket_addr() + .expect("socket address") + .port(); + + let mcp_capability_lookup = self.services.mcp_capability_lookup.clone(); + + let worker_service = self.services.worker_service.clone(); + + let service = StreamableHttpService::new( + move || { + Ok(GolemAgentMcpServer::new( + mcp_capability_lookup.clone(), + worker_service.clone(), + )) + }, + LocalSessionManager::default().into(), + StreamableHttpServerConfig::default(), + ); + + let route = Route::new() + .nest("/mcp", service.compat()) + .with(OpenTelemetryMetrics::new()) + .with_if_lazy(tracer.is_some(), || { + OpenTelemetryTracing::new(tracer.unwrap()) + }); + + join_set.spawn( + async move { + poem::Server::new_with_acceptor(acceptor) + .run(route) + .await + .map_err(|err| anyhow!(err).context("MCP server gateway failed")) + } + .in_current_span(), + ); + + Ok(port) + } } diff --git a/golem-worker-service/src/mcp/agent_mcp_capability.rs b/golem-worker-service/src/mcp/agent_mcp_capability.rs new file mode 100644 index 0000000000..f064564e8c --- /dev/null +++ b/golem-worker-service/src/mcp/agent_mcp_capability.rs @@ -0,0 +1,104 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::mcp::agent_mcp_resource::AgentMcpResource; +use crate::mcp::agent_mcp_tool::AgentMcpTool; +use crate::mcp::schema::{McpToolSchema, get_mcp_schema, get_mcp_tool_schema}; +use golem_common::base_model::account::AccountId; +use golem_common::base_model::agent::{AgentMethod, AgentTypeName, DataSchema}; +use golem_common::base_model::component::ComponentId; +use golem_common::base_model::environment::EnvironmentId; +use golem_common::model::agent::AgentConstructor; +use rmcp::model::Tool; +use std::borrow::Cow; +use std::sync::Arc; + +#[derive(Clone)] +pub enum McpAgentCapability { + Tool(Box), + #[allow(unused)] + Resource(AgentMcpResource), +} + +impl McpAgentCapability { + pub fn from( + account_id: &AccountId, + environment_id: &EnvironmentId, + agent_type_name: &AgentTypeName, + method: &AgentMethod, + constructor: &AgentConstructor, + component_id: ComponentId, + ) -> Self { + match &method.input_schema { + DataSchema::Tuple(schemas) => { + if !schemas.elements.is_empty() { + tracing::debug!( + "Method {} of agent type {} has input parameters, exposing as tool", + method.name, + agent_type_name.0 + ); + + let constructor_schema = get_mcp_schema(&constructor.input_schema); + + let McpToolSchema { + mut input_schema, + output_schema, + } = get_mcp_tool_schema(method); + + input_schema.prepend_schema(constructor_schema); + + let tool = Tool { + name: Cow::from(get_tool_name(agent_type_name, method)), + title: None, + description: Some(method.description.clone().into()), + input_schema: Arc::new(rmcp::model::JsonObject::from(input_schema)), + output_schema: output_schema + .map(|internal| Arc::new(rmcp::model::JsonObject::from(internal))), + annotations: None, + execution: None, + icons: None, + meta: None, + }; + + Self::Tool(Box::new(AgentMcpTool { + environment_id: *environment_id, + account_id: *account_id, + constructor: constructor.clone(), + raw_method: method.clone(), + tool, + component_id, + agent_type_name: agent_type_name.clone(), + })) + } else { + tracing::debug!( + "Method {} of agent type {} has no input parameters, exposing as resource", + method.name, + agent_type_name.0 + ); + + Self::Resource(AgentMcpResource { + resource: method.clone(), + }) + } + } + DataSchema::Multimodal(_) => { + todo!("Multimodal schema handling not implemented yet") + } + } + } +} + +fn get_tool_name(agent_type_name: &AgentTypeName, method: &AgentMethod) -> String { + format!("{}-{}", agent_type_name.0, method.name) +} diff --git a/golem-worker-service/src/mcp/agent_mcp_prompt.rs b/golem-worker-service/src/mcp/agent_mcp_prompt.rs new file mode 100644 index 0000000000..21c676b369 --- /dev/null +++ b/golem-worker-service/src/mcp/agent_mcp_prompt.rs @@ -0,0 +1,72 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::mcp::GolemAgentMcpServer; +use futures::FutureExt; +use futures::future::BoxFuture; +use golem_common::base_model::agent::AgentMethod; +use rmcp::ErrorData; +use rmcp::handler::server::prompt::{GetPromptHandler, PromptContext}; +use rmcp::handler::server::router::prompt::{IntoPromptRoute, PromptRoute}; +use rmcp::model::{ + GetPromptResult, Prompt, PromptMessage, PromptMessageContent, PromptMessageRole, +}; + +#[allow(unused)] +#[derive(Clone)] +pub struct AgentMcpPrompt { + pub agent_method: AgentMethod, + pub raw_prompt: Prompt, +} + +impl GetPromptHandler for AgentMcpPrompt { + fn handle( + self, + context: PromptContext<'_, GolemAgentMcpServer>, + ) -> BoxFuture<'_, Result> { + async move { + let parameters = context + .arguments + .map(|x| { + x.iter() + .map(|(k, v)| format!("{}: {}", k, v)) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "no parameters".to_string()); + + let result = GetPromptResult { + description: None, + messages: vec![PromptMessage { + role: PromptMessageRole::User, + content: PromptMessageContent::Text { + text: format!( + "{}, call {} with the following parameters: {}", + "developer-given prompt", self.agent_method.name, parameters + ), + }, + }], + }; + + Ok(result) + } + .boxed() + } +} + +impl IntoPromptRoute for AgentMcpPrompt { + fn into_prompt_route(self) -> PromptRoute { + PromptRoute::new(self.raw_prompt.clone(), self) + } +} diff --git a/golem-worker-service/src/mcp/agent_mcp_resource.rs b/golem-worker-service/src/mcp/agent_mcp_resource.rs new file mode 100644 index 0000000000..f3fbfbd501 --- /dev/null +++ b/golem-worker-service/src/mcp/agent_mcp_resource.rs @@ -0,0 +1,21 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use golem_common::base_model::agent::AgentMethod; + +#[derive(Clone)] +pub struct AgentMcpResource { + #[allow(dead_code)] + pub resource: AgentMethod, +} diff --git a/golem-worker-service/src/mcp/agent_mcp_server.rs b/golem-worker-service/src/mcp/agent_mcp_server.rs new file mode 100644 index 0000000000..1666f6f78f --- /dev/null +++ b/golem-worker-service/src/mcp/agent_mcp_server.rs @@ -0,0 +1,292 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::mcp::McpCapabilityLookup; +use crate::mcp::agent_mcp_capability::McpAgentCapability; +use crate::mcp::agent_mcp_tool::AgentMcpTool; +use crate::mcp::invoke::agent_invoke; +use crate::service::worker::WorkerService; +use dashmap::DashMap; +use golem_common::base_model::domain_registration::Domain; +use poem::http; +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, handler::server::router::tool::ToolRouter, + model::*, service::RequestContext, task_handler, task_manager::OperationProcessor, +}; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +// Every client will get an instance of this +#[derive(Clone)] +pub struct GolemAgentMcpServer { + processor: Arc>, + tool_router: Arc>>>, + tools: Arc>, + domain: Arc>>, + mcp_definitions_lookup: Arc, + worker_service: Arc, +} + +impl GolemAgentMcpServer { + pub fn new( + mcp_definitions_lookup: Arc, + worker_service: Arc, + ) -> Self { + Self { + tool_router: Arc::new(RwLock::new(None)), + tools: Arc::new(DashMap::new()), + processor: Arc::new(Mutex::new(OperationProcessor::new())), + domain: Arc::new(RwLock::new(None)), + mcp_definitions_lookup, + worker_service, + } + } + + pub async fn invoke( + &self, + args_map: JsonObject, + mcp_tool: &AgentMcpTool, + ) -> Result { + agent_invoke(&self.worker_service, args_map, mcp_tool).await + } + + async fn tool_router(&self, domain: &Domain) -> ToolRouter { + let tool_handlers = get_agent_tool_and_handlers(domain, &self.mcp_definitions_lookup).await; + + let mut router = ToolRouter::::new(); + + for tool in tool_handlers { + router = router.with_route(tool); + } + + router + } +} + +pub async fn get_agent_tool_and_handlers( + domain: &Domain, + mcp_definition_lookup: &Arc, +) -> Vec { + let compiled_mcp = match mcp_definition_lookup.get(domain).await { + Ok(mcp) => mcp, + Err(e) => { + tracing::error!("Failed to get compiled MCP for domain {}: {}", domain.0, e); + return vec![]; + } + }; + + let mut tools = vec![]; + + let account_id = compiled_mcp.account_id; + let environment_id = compiled_mcp.environment_id; + + let agent_types = compiled_mcp.agent_types(); + + tracing::info!( + "Found {} agent types for domain {}: {:?}", + agent_types.len(), + domain.0, + agent_types + .iter() + .map(|at| at.0.clone()) + .collect::>() + ); + + for agent_type_name in &agent_types { + match mcp_definition_lookup + .resolve_agent_type(domain, agent_type_name) + .await + { + Ok(registered_agent_type) => { + tracing::debug!( + "Resolved agent type {} for domain {}: implemented by component {}, methods: {:?}", + agent_type_name.0, + domain.0, + registered_agent_type.implemented_by.component_id.0, + registered_agent_type + .agent_type + .methods + .iter() + .map(|m| m.name.clone()) + .collect::>() + ); + + let agent_type = ®istered_agent_type.agent_type; + let component_id = registered_agent_type.implemented_by.component_id; + for method in &agent_type.methods { + let agent_method_mcp = McpAgentCapability::from( + &account_id, + &environment_id, + &agent_type.type_name, + method, + &agent_type.constructor, + component_id, + ); + + match agent_method_mcp { + McpAgentCapability::Tool(agent_mcp_tool) => { + tools.push(*agent_mcp_tool); + } + McpAgentCapability::Resource(_) => {} + } + } + } + Err(e) => { + tracing::error!( + "Failed to resolve agent type {} for domain {}: {}", + agent_type_name.0, + domain.0, + e + ); + } + } + } + + if tools.is_empty() { + tracing::warn!("No tools found for domain {}", domain.0); + } else { + tracing::info!("Found {} tools for domain {}", tools.len(), domain.0); + } + + tools +} + +#[allow(deprecated)] +#[task_handler] +impl ServerHandler for GolemAgentMcpServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + // This is not the latest, + // ProtocolVersion::V_2025_06_18 is the latest, however RMCP + // is not widely tested with this version as per comments + protocol_version: ProtocolVersion::V_2025_03_26, + capabilities: ServerCapabilities::builder() + .enable_prompts() + .enable_resources() + .enable_tools() + .build(), + server_info: Implementation::from_build_env(), + instructions: Some("This server provides tools related to agent in golem and prompts. Tools: increment, decrement, get_value, say_hello, echo, sum. Prompts: example_prompt (takes a message), counter_analysis (analyzes counter state with a goal).".to_string()), + } + } + + fn get_tool(&self, name: &str) -> Option { + self.tools.get(name).map(|ref_multi| ref_multi.clone()) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let tool_router = self.tool_router.read().await; + + if let Some(tool_router) = tool_router.as_ref() { + tracing::info!("Listing tools: {:?}", tool_router.list_all()); + + Ok(ListToolsResult { + tools: tool_router.list_all(), + meta: Some(Meta(object(::serde_json::Value::Object({ + let mut object = ::serde_json::Map::new(); + let _ = object.insert( + ("tool_meta_key").into(), + ::serde_json::to_value("tool_meta_value").unwrap(), + ); + object + })))), + next_cursor: None, + }) + } else { + Err(McpError::invalid_params( + "tool router not initialized", + None, + )) + } + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: rmcp::service::RequestContext, + ) -> Result { + let tool_router = self.tool_router.read().await; + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + if let Some(tool_router) = tool_router.as_ref() { + tool_router.call(tcc).await + } else { + Err(McpError::invalid_params( + "tool router not initialized", + None, + )) + } + } + + async fn read_resource( + &self, + ReadResourceRequestParams { meta: _, uri }: ReadResourceRequestParams, + _: RequestContext, + ) -> Result { + // TODO; Include Git tickets here + todo!("Resource support is not implemented yet. URI: {}", uri) + } + + async fn list_resource_templates( + &self, + _request: Option, + _: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + next_cursor: None, + resource_templates: Vec::new(), + meta: None, + }) + } + + async fn initialize( + &self, + _request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + if let Some(parts) = context.extensions.get::() { + tracing::info!( + version = ?parts.version, + method = ?parts.method, + uri = %parts.uri, + headers = ?parts.headers, + "initialize from http server" + ); + + if let Some(session_header) = parts.headers.get("mcp-session-id") { + tracing::info!( + "Session ID from header: {}", + session_header.to_str().unwrap_or("invalid session id") + ); + } else { + tracing::info!("No session ID found in headers"); + } + + if let Some(host) = parts.headers.get("host") { + let domain = Domain(host.to_str().unwrap().to_string()); + let tool_router = self.tool_router(&domain).await; + for tool in tool_router.list_all() { + self.tools.insert(tool.name.to_string(), tool); + } + *self.domain.write().await = Some(domain); + *self.tool_router.write().await = Some(tool_router); + } + } + + Ok(self.get_info()) + } +} diff --git a/golem-worker-service/src/mcp/agent_mcp_tool.rs b/golem-worker-service/src/mcp/agent_mcp_tool.rs new file mode 100644 index 0000000000..a341b0a766 --- /dev/null +++ b/golem-worker-service/src/mcp/agent_mcp_tool.rs @@ -0,0 +1,57 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::mcp::GolemAgentMcpServer; +use futures::FutureExt; +use futures::future::BoxFuture; +use golem_common::base_model::account::AccountId; +use golem_common::base_model::agent::{AgentConstructor, AgentMethod, AgentTypeName}; +use golem_common::base_model::component::ComponentId; +use golem_common::base_model::environment::EnvironmentId; +use rmcp::ErrorData; +use rmcp::handler::server::router::tool::IntoToolRoute; +use rmcp::handler::server::tool::{CallToolHandler, ToolCallContext, ToolRoute}; +use rmcp::model::{CallToolResult, Tool}; + +#[derive(Clone)] +pub struct AgentMcpTool { + pub tool: Tool, + pub environment_id: EnvironmentId, + pub account_id: AccountId, + pub constructor: AgentConstructor, + pub raw_method: AgentMethod, + pub component_id: ComponentId, + pub agent_type_name: AgentTypeName, +} + +impl CallToolHandler for AgentMcpTool { + fn call( + self, + context: ToolCallContext<'_, GolemAgentMcpServer>, + ) -> BoxFuture<'_, Result> { + async move { + context + .service + .invoke(context.arguments.unwrap_or_default(), &self) + .await + } + .boxed() + } +} + +impl IntoToolRoute for AgentMcpTool { + fn into_tool_route(self) -> ToolRoute { + ToolRoute::new(self.tool.clone(), self) + } +} diff --git a/golem-worker-service/src/mcp/invoke.rs b/golem-worker-service/src/mcp/invoke.rs new file mode 100644 index 0000000000..b44bdb72eb --- /dev/null +++ b/golem-worker-service/src/mcp/invoke.rs @@ -0,0 +1,239 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::mcp::agent_mcp_tool::AgentMcpTool; +use crate::service::worker::WorkerService; +use golem_common::base_model::WorkerId; +use golem_common::base_model::agent::*; +use golem_wasm::ValueAndType; +use golem_wasm::analysis::AnalysedType; +use golem_wasm::json::ValueAndTypeJsonExtensions; +use rmcp::ErrorData; +use rmcp::model::{CallToolResult, JsonObject}; +use serde_json::json; +use std::sync::Arc; + +pub async fn agent_invoke( + worker_service: &Arc, + args_map: JsonObject, + mcp_tool: &AgentMcpTool, +) -> Result { + let constructor_params = extract_parameters_by_schema( + &args_map, + &mcp_tool.constructor.input_schema, + |value_and_type| ComponentModelElementValue { + value: value_and_type, + }, + ) + .map_err(|e| { + tracing::error!("Failed to extract constructor parameters: {}", e); + ErrorData::invalid_params( + format!("Failed to extract constructor parameters: {}", e), + None, + ) + })?; + + let agent_id = AgentId::new( + mcp_tool.agent_type_name.clone(), + golem_common::model::agent::DataValue::Tuple(golem_common::model::agent::ElementValues { + elements: constructor_params + .into_iter() + .map(golem_common::model::agent::ElementValue::ComponentModel) + .collect(), + }), + None, + ); + + let method_params = + extract_parameters_by_schema(&args_map, &mcp_tool.raw_method.input_schema, |vat| { + UntypedElementValue::ComponentModel(vat.value) + }) + .map_err(|e| { + tracing::error!("Failed to extract method parameters: {}", e); + ErrorData::invalid_params(format!("Failed to extract method parameters: {}", e), None) + })?; + + let method_params_data_value = UntypedDataValue::Tuple(method_params); + + let proto_method_parameters: golem_api_grpc::proto::golem::component::UntypedDataValue = + method_params_data_value.into(); + + let principal = Principal::anonymous(); + let proto_principal: golem_api_grpc::proto::golem::component::Principal = principal.into(); + + let worker_id = WorkerId { + component_id: mcp_tool.component_id, + worker_name: agent_id.to_string(), + }; + + let auth_ctx = golem_service_base::model::auth::AuthCtx::impersonated_user(mcp_tool.account_id); + + let agent_output = worker_service + .invoke_agent( + &worker_id, + mcp_tool.raw_method.name.clone(), + proto_method_parameters, + golem_api_grpc::proto::golem::workerexecutor::v1::AgentInvocationMode::Await as i32, + None, + None, + None, + auth_ctx, + proto_principal, + ) + .await + .map_err(|e| { + tracing::error!("Failed to invoke worker: {:?}", e); + ErrorData::internal_error(format!("Failed to invoke worker: {:?}", e), None) + })?; + + let agent_result = match agent_output.result { + golem_common::model::AgentInvocationResult::AgentMethod { output } => Some(output), + _ => None, + }; + + interpret_agent_response(agent_result, &mcp_tool.raw_method.output_schema) + .map(CallToolResult::structured) +} + +pub fn interpret_agent_response( + invoke_result: Option, + expected_type: &DataSchema, +) -> Result { + match invoke_result { + Some(untyped_data_value) => { + map_successful_agent_response(untyped_data_value, expected_type) + .map(|json_value| { + json!({ + "return-value": json_value, + }) + }) + .map_err(|e| { + tracing::error!("Failed to map successful agent response: {}", e); + ErrorData::internal_error( + format!("Failed to map successful agent response: {}", e), + None, + ) + }) + } + None => Ok(json!({})), + } +} + +fn map_successful_agent_response( + agent_response: UntypedDataValue, + expected_type: &DataSchema, +) -> Result { + let typed_value = + DataValue::try_from_untyped(agent_response, expected_type.clone()).map_err(|error| { + ErrorData::internal_error(format!("Agent response type mismatch: {error}"), None) + })?; + + match typed_value { + DataValue::Tuple(ElementValues { elements }) => match elements.len() { + 0 => Ok(json!({})), + 1 => map_single_element_agent_response(elements.into_iter().next().unwrap()).map_err( + |e| { + tracing::error!("Failed to map single element agent response: {}", e); + ErrorData::internal_error( + format!("Failed to map single element agent response: {}", e), + None, + ) + }, + ), + _ => Err(ErrorData::internal_error( + "Unexpected number of response tuple elements".to_string(), + None, + )), + }, + DataValue::Multimodal(_) => Err(ErrorData::internal_error( + "multi modal response not yet supported".to_string(), + None, + )), + } +} + +fn map_single_element_agent_response(element: ElementValue) -> Result { + match element { + ElementValue::ComponentModel(component_model_value) => { + component_model_value.value.to_json_value() + } + + ElementValue::UnstructuredBinary(_) => Err( + "Received unstructured binary response, which is not supported in this context" + .to_string(), + ), + + ElementValue::UnstructuredText(_) => Err( + "Received unstructured text response, which is not supported in this context" + .to_string(), + ), + } +} + +fn extract_parameters_by_schema( + args_map: &JsonObject, + schema: &DataSchema, + f: F, +) -> Result, String> +where + F: Fn(ValueAndType) -> A, +{ + match schema { + DataSchema::Tuple(named_schemas) => { + let mut params = Vec::new(); + + for NamedElementSchema { + name, + schema: elem_schema, + } in &named_schemas.elements + { + match elem_schema { + ElementSchema::ComponentModel(ComponentModelElementSchema { element_type }) => { + let json_value = match args_map.get(name) { + Some(value) => value.clone(), + None => { + if matches!(element_type, AnalysedType::Option(_)) { + serde_json::Value::Null + } else { + return Err(format!("Missing parameter: {}", name)); + } + } + }; + + let value_and_type = + golem_wasm::ValueAndType::parse_with_type(&json_value, element_type) + .map_err(|errs| { + format!( + "Failed to parse parameter '{}': {}", + name, + errs.join(", ") + ) + })?; + + params.push(f(value_and_type)); + } + _ => { + return Err(format!( + "Unsupported element schema type for parameter '{}'", + name + )); + } + } + } + + Ok(params) + } + DataSchema::Multimodal(_) => Err("Multimodal schema is not yet supported".to_string()), + } +} diff --git a/golem-worker-service/src/mcp/mcp_capabilities_lookup.rs b/golem-worker-service/src/mcp/mcp_capabilities_lookup.rs new file mode 100644 index 0000000000..58da0a56ad --- /dev/null +++ b/golem-worker-service/src/mcp/mcp_capabilities_lookup.rs @@ -0,0 +1,106 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use async_trait::async_trait; +use golem_common::base_model::domain_registration::Domain; +use golem_common::model::agent::{AgentTypeName, RegisteredAgentType}; +use golem_common::{SafeDisplay, error_forwarding}; +use golem_service_base::clients::registry::{RegistryService, RegistryServiceError}; +use golem_service_base::mcp::CompiledMcp; +use std::sync::Arc; + +#[async_trait] +pub trait McpCapabilityLookup: Send + Sync { + async fn get(&self, domain: &Domain) -> Result; + + // Cache this so that multiple MCP clients using the same server can make use of the cache + // This can be moved to the deployment level too if needed, but result in more storage. + async fn resolve_agent_type( + &self, + domain: &Domain, + agent_type_name: &AgentTypeName, + ) -> Result; +} + +#[derive(Debug, thiserror::Error)] +pub enum McpCapabilitiesLookupError { + #[error("No mcp capabilities found for site {0}")] + UnknownSite(Domain), + #[error(transparent)] + InternalError(#[from] anyhow::Error), +} + +error_forwarding!(McpCapabilitiesLookupError, RegistryServiceError); + +impl SafeDisplay for McpCapabilitiesLookupError { + fn to_safe_string(&self) -> String { + match self { + McpCapabilitiesLookupError::InternalError(_) => "Internal error".to_string(), + McpCapabilitiesLookupError::UnknownSite(_) => "Unknown authority".to_string(), + } + } +} + +// Note: No caching here, the caching is part of MCP session +pub struct RegistryServiceMcpCapabilityLookup { + registry_service_client: Arc, +} + +impl RegistryServiceMcpCapabilityLookup { + pub fn new(registry_service_client: Arc) -> Self { + Self { + registry_service_client, + } + } +} + +#[async_trait] +impl McpCapabilityLookup for RegistryServiceMcpCapabilityLookup { + async fn get(&self, domain: &Domain) -> Result { + self.registry_service_client + .get_active_compiled_mcps_for_domain(domain) + .await + .map_err(|e| e.into()) + } + + async fn resolve_agent_type( + &self, + domain: &Domain, + agent_type_name: &AgentTypeName, + ) -> Result { + let compiled_mcp = self.get(domain).await?; + + let (component_id, component_revision) = compiled_mcp + .agent_type_implementers + .get(agent_type_name) + .copied() + .ok_or_else(|| { + McpCapabilitiesLookupError::InternalError(anyhow::anyhow!( + "Agent type {} not found in MCP for domain {}", + agent_type_name.0, + domain.0 + )) + })?; + + self.registry_service_client + .get_agent_type( + compiled_mcp.environment_id, + component_id, + component_revision, + agent_type_name, + ) + .await + .map_err(|e| e.into()) + } +} diff --git a/golem-worker-service/src/mcp/mod.rs b/golem-worker-service/src/mcp/mod.rs new file mode 100644 index 0000000000..cbeb9c6b31 --- /dev/null +++ b/golem-worker-service/src/mcp/mod.rs @@ -0,0 +1,11 @@ +pub use agent_mcp_server::*; +pub use mcp_capabilities_lookup::*; + +mod agent_mcp_capability; +mod agent_mcp_prompt; +mod agent_mcp_resource; +mod agent_mcp_server; +mod agent_mcp_tool; +mod invoke; +mod mcp_capabilities_lookup; +mod schema; diff --git a/golem-worker-service/src/mcp/schema/mcp_schema.rs b/golem-worker-service/src/mcp/schema/mcp_schema.rs new file mode 100644 index 0000000000..3ce1708b89 --- /dev/null +++ b/golem-worker-service/src/mcp/schema/mcp_schema.rs @@ -0,0 +1,215 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use golem_common::base_model::agent::{ + ComponentModelElementSchema, ElementSchema, NamedElementSchema, +}; +use golem_wasm::analysis::AnalysedType; +use serde_json::{Map, Value, json}; + +#[derive(Default)] +pub struct McpSchema { + pub properties: Map, + pub required: Vec, +} + +impl From for rmcp::model::JsonObject { + fn from(value: McpSchema) -> Self { + let json_value = json!({ + "type": "object", + "properties": value.properties, + "required": value.required, + }); + + rmcp::model::object(json_value) + } +} + +impl McpSchema { + pub fn prepend_schema(&mut self, mut new_schema: McpSchema) { + new_schema + .properties + .extend(std::mem::take(&mut self.properties)); + + new_schema + .required + .extend(std::mem::take(&mut self.required)); + + *self = new_schema; + } + + pub fn from_named_element_schemas(schemas: &[NamedElementSchema]) -> McpSchema { + let named_types: Vec<(&str, &AnalysedType)> = schemas + .iter() + .map(|s| match &s.schema { + ElementSchema::ComponentModel(ComponentModelElementSchema { element_type }) => { + (s.name.as_str(), element_type) + } + _ => todo!("Unsupported element schema type in MCP schema mapping"), + }) + .collect(); + + Self::from_record_fields(&named_types) + } + + pub fn from_record_fields(fields: &[(&str, &AnalysedType)]) -> McpSchema { + let mut properties: Map = Map::new(); + let mut required = Vec::new(); + + for (name, typ) in fields { + properties.insert(name.to_string(), analysed_type_to_json_schema(typ)); + if !matches!(typ, AnalysedType::Option(_)) { + required.push(name.to_string()); + } + } + + McpSchema { + properties, + required, + } + } +} + +pub type JsonTypeDescription = Value; +pub type FieldName = String; + +// Based on https://modelcontextprotocol.io/specification/2025-11-25/server/tools and +// https://json-schema.org/draft/2020-12/json-schema-core (Example: oneOf) +// while ensuring how golem-wasm treats JSON values +fn analysed_type_to_json_schema(analysed_type: &AnalysedType) -> JsonTypeDescription { + match analysed_type { + AnalysedType::Bool(_) => json!({"type": "boolean"}), + AnalysedType::Str(_) => json!({"type": "string"}), + AnalysedType::Chr(_) => json!({"type": "integer"}), + AnalysedType::U8(_) + | AnalysedType::U16(_) + | AnalysedType::U32(_) + | AnalysedType::U64(_) + | AnalysedType::S8(_) + | AnalysedType::S16(_) + | AnalysedType::S32(_) + | AnalysedType::S64(_) => json!({"type": "integer"}), + AnalysedType::F32(_) | AnalysedType::F64(_) => json!({"type": "number"}), + + AnalysedType::List(type_list) => { + let items = analysed_type_to_json_schema(&type_list.inner); + json!({"type": "array", "items": items}) + } + + AnalysedType::Tuple(type_tuple) => { + let prefix_items: Vec = type_tuple + .items + .iter() + .map(analysed_type_to_json_schema) + .collect(); + + json!({ + "type": "array", + "prefixItems": prefix_items, + "items": false + }) + } + + AnalysedType::Record(type_record) => { + let fields: Vec<(&str, &AnalysedType)> = type_record + .fields + .iter() + .map(|f| (f.name.as_str(), &f.typ)) + .collect(); + + let schema = McpSchema::from_record_fields(&fields); + + json!({ + "type": "object", + "properties": schema.properties, + "required": schema.required + }) + } + + AnalysedType::Option(type_option) => { + let inner = analysed_type_to_json_schema(&type_option.inner); + json!({ + "oneOf": [ + inner, + {"type": "null"} + ] + }) + } + + AnalysedType::Enum(type_enum) => { + json!({"type": "string", "enum": type_enum.cases}) + } + + AnalysedType::Flags(type_flags) => { + json!({ + "type": "array", + "items": {"type": "string", "enum": type_flags.names}, + "uniqueItems": true + }) + } + + AnalysedType::Variant(type_variant) => { + let one_of: Vec = type_variant + .cases + .iter() + .map(|case| { + let value_schema = match &case.typ { + Some(payload_type) => analysed_type_to_json_schema(payload_type), + None => json!({"type": "null"}), + }; + json!({ + "type": "object", + "properties": { + case.name.clone(): value_schema, + }, + "required": [case.name], + "additionalProperties": false + }) + }) + .collect(); + json!({"oneOf": one_of}) + } + + AnalysedType::Result(type_result) => { + let ok_schema = match &type_result.ok { + Some(ok_type) => analysed_type_to_json_schema(ok_type), + None => json!({"type": "null"}), + }; + let err_schema = match &type_result.err { + Some(err_type) => analysed_type_to_json_schema(err_type), + None => json!({"type": "null"}), + }; + json!({ + "oneOf": [ + { + "type": "object", + "properties": {"ok": ok_schema}, + "required": ["ok"], + "additionalProperties": false + }, + { + "type": "object", + "properties": {"err": err_schema}, + "required": ["err"], + "additionalProperties": false + } + ] + }) + } + + AnalysedType::Handle(_) => { + json!({"type": "string"}) + } + } +} diff --git a/golem-worker-service/src/mcp/schema/mcp_schema_mapping.rs b/golem-worker-service/src/mcp/schema/mcp_schema_mapping.rs new file mode 100644 index 0000000000..d8aed41306 --- /dev/null +++ b/golem-worker-service/src/mcp/schema/mcp_schema_mapping.rs @@ -0,0 +1,25 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::mcp::schema::mcp_schema::McpSchema; +use golem_common::base_model::agent::DataSchema; + +pub fn get_mcp_schema(data_schema: &DataSchema) -> McpSchema { + match data_schema { + DataSchema::Tuple(schemas) => McpSchema::from_named_element_schemas(&schemas.elements), + DataSchema::Multimodal(_) => { + todo!("Multimodal schema is not supported in this example") + } + } +} diff --git a/golem-worker-service/src/mcp/schema/mcp_tool_schema.rs b/golem-worker-service/src/mcp/schema/mcp_tool_schema.rs new file mode 100644 index 0000000000..8c2ad16dd5 --- /dev/null +++ b/golem-worker-service/src/mcp/schema/mcp_tool_schema.rs @@ -0,0 +1,32 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::mcp::schema::mcp_schema::McpSchema; +use crate::mcp::schema::mcp_schema_mapping::get_mcp_schema; +use golem_common::base_model::agent::AgentMethod; + +pub struct McpToolSchema { + pub input_schema: McpSchema, + pub output_schema: Option, +} + +pub fn get_mcp_tool_schema(method: &AgentMethod) -> McpToolSchema { + let input_schema = get_mcp_schema(&method.input_schema); + let output_schema = get_mcp_schema(&method.output_schema); + + McpToolSchema { + input_schema, + output_schema: Some(output_schema), + } +} diff --git a/golem-worker-service/src/mcp/schema/mod.rs b/golem-worker-service/src/mcp/schema/mod.rs new file mode 100644 index 0000000000..c6c0507c28 --- /dev/null +++ b/golem-worker-service/src/mcp/schema/mod.rs @@ -0,0 +1,20 @@ +// Copyright 2024-2025 Golem Cloud +// +// Licensed under the Golem Source License v1.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://license.golem.cloud/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +pub use mcp_schema_mapping::*; +pub use mcp_tool_schema::*; + +mod mcp_schema; +mod mcp_schema_mapping; +mod mcp_tool_schema; diff --git a/openapi/golem-registry-service.yaml b/openapi/golem-registry-service.yaml index 5efb942d6b..8b1d885af0 100644 --- a/openapi/golem-registry-service.yaml +++ b/openapi/golem-registry-service.yaml @@ -29,6 +29,7 @@ tags: description: The limits API allows users to query their current resource limits. - name: Login description: The login endpoints are implementing an OAuth2 flow. +- name: McpDeployment - name: Plugin - name: Project description: |- @@ -4783,6 +4784,676 @@ paths: - Cookie: [] - Token: [] operationId: list_http_api_deployments_in_deployment + /v1/envs/{environment_id}/mcp-deployments: + post: + tags: + - RegistryService + - McpDeployment + - Environment + summary: Create a new MCP deployment in the environment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeploymentCreation' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: create_mcp_deployment + get: + tags: + - RegistryService + - McpDeployment + - Environment + summary: List MCP deployments in the environment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Page_McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: list_mcp_deployments_in_environment + /v1/envs/{environment_id}/mcp-deployments/{domain}: + get: + tags: + - RegistryService + - McpDeployment + - Environment + summary: Get MCP deployment by domain in the environment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: domain + schema: + type: string + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_mcp_deployment_in_environment + /v1/mcp-deployments/{mcp_deployment_id}: + get: + tags: + - RegistryService + - McpDeployment + summary: Get MCP deployment by ID + parameters: + - name: mcp_deployment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_mcp_deployment + patch: + tags: + - RegistryService + - McpDeployment + summary: Update MCP deployment + parameters: + - name: mcp_deployment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeploymentUpdate' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: update_mcp_deployment + delete: + tags: + - RegistryService + - McpDeployment + summary: Delete MCP deployment + parameters: + - name: mcp_deployment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: current_revision + schema: + type: integer + format: uint64 + in: query + required: true + deprecated: false + explode: true + responses: + '204': + description: '' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: delete_mcp_deployment + /v1/mcp-deployment/{mcp_deployment_id}/revisions/{revision}: + get: + tags: + - RegistryService + - McpDeployment + summary: Get a specific MCP deployment revision + parameters: + - name: mcp_deployment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: revision + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_mcp_deployment_revision + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments/{domain}: + get: + tags: + - RegistryService + - McpDeployment + - Environment + - Deployment + summary: Get MCP deployment by domain in the deployment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: deployment_revision + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + - name: domain + schema: + type: string + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: get_mcp_deployment_in_deployment + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments: + get: + tags: + - RegistryService + - McpDeployment + - Environment + - Deployment + summary: List MCP deployments by domain in the deployment + parameters: + - name: environment_id + schema: + type: string + format: uuid + in: path + required: true + deprecated: false + explode: true + - name: deployment_revision + schema: + type: integer + format: uint64 + in: path + required: true + deprecated: false + explode: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Page_McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + operationId: list_mcp_deployments_in_deployment /v1/login/oauth2: post: tags: @@ -7153,6 +7824,7 @@ components: - deploymentHash - components - httpApiDeployments + - mcpDeployments properties: currentRevision: type: integer @@ -7168,6 +7840,10 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanHttpApiDeploymentEntry' + mcpDeployments: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' DeploymentPlanComponentEntry: type: object title: DeploymentPlanComponentEntry @@ -7208,6 +7884,26 @@ components: hash: type: string format: hash + DeploymentPlanMcpDeploymentEntry: + type: object + title: DeploymentPlanMcpDeploymentEntry + required: + - id + - revision + - domain + - hash + properties: + id: + type: string + format: uuid + revision: + type: integer + format: uint64 + domain: + type: string + hash: + type: string + format: hash DeploymentRollback: type: object title: DeploymentRollback @@ -7230,6 +7926,7 @@ components: - deploymentHash - components - httpApiDeployments + - mcpDeployments properties: deploymentRevision: type: integer @@ -7245,6 +7942,10 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanHttpApiDeploymentEntry' + mcpDeployments: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' DomainRegistration: type: object title: DomainRegistration @@ -7929,6 +8630,68 @@ components: items: type: string value: {} + McpDeployment: + type: object + title: McpDeployment + required: + - id + - revision + - environmentId + - domain + - hash + - agents + - createdAt + properties: + id: + type: string + format: uuid + revision: + type: integer + format: uint64 + environmentId: + type: string + format: uuid + domain: + type: string + hash: + type: string + format: hash + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/McpDeploymentAgentOptions' + createdAt: + type: string + format: date-time + McpDeploymentAgentOptions: + type: object + title: McpDeploymentAgentOptions + McpDeploymentCreation: + type: object + title: McpDeploymentCreation + required: + - domain + - agents + properties: + domain: + type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/McpDeploymentAgentOptions' + McpDeploymentUpdate: + type: object + title: McpDeploymentUpdate + required: + - currentRevision + properties: + currentRevision: + type: integer + format: uint64 + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/McpDeploymentAgentOptions' NameOptionTypePair: type: object title: NameOptionTypePair @@ -8136,6 +8899,16 @@ components: type: array items: $ref: '#/components/schemas/HttpApiDeployment' + Page_McpDeployment: + type: object + title: Page_McpDeployment + required: + - values + properties: + values: + type: array + items: + $ref: '#/components/schemas/McpDeployment' Page_PluginRegistrationDto: type: object title: Page_PluginRegistrationDto diff --git a/openapi/golem-service.yaml b/openapi/golem-service.yaml index 459a01a505..df0ac10248 100644 --- a/openapi/golem-service.yaml +++ b/openapi/golem-service.yaml @@ -6468,6 +6468,691 @@ paths: security: - Cookie: [] - Token: [] + /v1/envs/{environment_id}/mcp-deployments: + get: + tags: + - RegistryService + - McpDeployment + - Environment + summary: List MCP deployments in the environment + operationId: list_mcp_deployments_in_environment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Page_McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + post: + tags: + - RegistryService + - McpDeployment + - Environment + summary: Create a new MCP deployment in the environment + operationId: create_mcp_deployment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeploymentCreation' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/envs/{environment_id}/mcp-deployments/{domain}: + get: + tags: + - RegistryService + - McpDeployment + - Environment + summary: Get MCP deployment by domain in the environment + operationId: get_mcp_deployment_in_environment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: domain + required: true + deprecated: false + schema: + type: string + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/mcp-deployments/{mcp_deployment_id}: + get: + tags: + - RegistryService + - McpDeployment + summary: Get MCP deployment by ID + operationId: get_mcp_deployment + parameters: + - in: path + name: mcp_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + delete: + tags: + - RegistryService + - McpDeployment + summary: Delete MCP deployment + operationId: delete_mcp_deployment + parameters: + - in: path + name: mcp_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: query + name: current_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: form + responses: + '204': + description: '' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + patch: + tags: + - RegistryService + - McpDeployment + summary: Update MCP deployment + operationId: update_mcp_deployment + parameters: + - in: path + name: mcp_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + requestBody: + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeploymentUpdate' + required: true + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/mcp-deployment/{mcp_deployment_id}/revisions/{revision}: + get: + tags: + - RegistryService + - McpDeployment + summary: Get a specific MCP deployment revision + operationId: get_mcp_deployment_revision + parameters: + - in: path + name: mcp_deployment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments/{domain}: + get: + tags: + - RegistryService + - McpDeployment + - Environment + - Deployment + summary: Get MCP deployment by domain in the deployment + operationId: get_mcp_deployment_in_deployment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: deployment_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + - in: path + name: domain + required: true + deprecated: false + schema: + type: string + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] + /v1/envs/{environment_id}/deployments/{deployment_revision}/mcp-deployments: + get: + tags: + - RegistryService + - McpDeployment + - Environment + - Deployment + summary: List MCP deployments by domain in the deployment + operationId: list_mcp_deployments_in_deployment + parameters: + - in: path + name: environment_id + required: true + deprecated: false + schema: + type: string + format: uuid + explode: true + style: simple + - in: path + name: deployment_revision + required: true + deprecated: false + schema: + type: integer + format: uint64 + explode: true + style: simple + responses: + '200': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/Page_McpDeployment' + '400': + description: Invalid request, returning with a list of issues detected in the request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorsBody' + '401': + description: Unauthorized request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden Request + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Entity not found + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: '' + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '422': + description: Limits of the plan exceeded + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + '500': + description: Internal server error + content: + application/json; charset=utf-8: + schema: + $ref: '#/components/schemas/ErrorBody' + security: + - Cookie: [] + - Token: [] /v1/login/oauth2: post: tags: @@ -12023,10 +12708,15 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanHttpApiDeploymentEntry' + mcpDeployments: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' required: - deploymentHash - components - httpApiDeployments + - mcpDeployments DeploymentPlanComponentEntry: title: DeploymentPlanComponentEntry type: object @@ -12067,6 +12757,26 @@ components: - revision - domain - hash + DeploymentPlanMcpDeploymentEntry: + title: DeploymentPlanMcpDeploymentEntry + type: object + properties: + id: + type: string + format: uuid + revision: + type: integer + format: uint64 + domain: + type: string + hash: + type: string + format: hash + required: + - id + - revision + - domain + - hash DeploymentRollback: title: DeploymentRollback type: object @@ -12099,11 +12809,16 @@ components: type: array items: $ref: '#/components/schemas/DeploymentPlanHttpApiDeploymentEntry' + mcpDeployments: + type: array + items: + $ref: '#/components/schemas/DeploymentPlanMcpDeploymentEntry' required: - deploymentRevision - deploymentHash - components - httpApiDeployments + - mcpDeployments DomainRegistration: title: DomainRegistration type: object @@ -12767,6 +13482,68 @@ components: - agent - key - value + McpDeployment: + title: McpDeployment + type: object + properties: + id: + type: string + format: uuid + revision: + type: integer + format: uint64 + environmentId: + type: string + format: uuid + domain: + type: string + hash: + type: string + format: hash + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/McpDeploymentAgentOptions' + createdAt: + type: string + format: date-time + required: + - id + - revision + - environmentId + - domain + - hash + - agents + - createdAt + McpDeploymentAgentOptions: + title: McpDeploymentAgentOptions + type: object + McpDeploymentCreation: + title: McpDeploymentCreation + type: object + properties: + domain: + type: string + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/McpDeploymentAgentOptions' + required: + - domain + - agents + McpDeploymentUpdate: + title: McpDeploymentUpdate + type: object + properties: + currentRevision: + type: integer + format: uint64 + agents: + type: object + additionalProperties: + $ref: '#/components/schemas/McpDeploymentAgentOptions' + required: + - currentRevision NamedElementSchema: title: NamedElementSchema type: object @@ -12953,6 +13730,16 @@ components: $ref: '#/components/schemas/HttpApiDeployment' required: - values + Page_McpDeployment: + title: Page_McpDeployment + type: object + properties: + values: + type: array + items: + $ref: '#/components/schemas/McpDeployment' + required: + - values Page_PluginRegistrationDto: title: Page_PluginRegistrationDto type: object @@ -13638,6 +14425,7 @@ tags: description: The limits API allows users to query their current resource limits. - name: Login description: The login endpoints are implementing an OAuth2 flow. +- name: McpDeployment - name: Plugin - name: Project description: |- diff --git a/openapi/golem-worker-service.yaml b/openapi/golem-worker-service.yaml index 3b55d22202..a4b3033e1e 100644 --- a/openapi/golem-worker-service.yaml +++ b/openapi/golem-worker-service.yaml @@ -29,6 +29,7 @@ tags: description: The limits API allows users to query their current resource limits. - name: Login description: The login endpoints are implementing an OAuth2 flow. +- name: McpDeployment - name: Plugin - name: Project description: |-