diff --git a/.env.example b/.env.example index c2719c1..e08f38f 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ CRANK_MCP_BIND=0.0.0.0:3002 CRANK_MCP_REFRESH_MS=5000 CRANK_LOG_LEVEL=info CRANK_SECRET_PROVIDER=env +CRANK_MASTER_KEY=change-me-master-key CRANK_SESSION_SECRET=change-me-session-secret CRANK_PASSWORD_PEPPER=change-me-password-pepper CRANK_SESSION_TTL_HOURS=24 diff --git a/Cargo.lock b/Cargo.lock index 402bd99..023753a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,7 @@ dependencies = [ "axum-extra", "base64", "crank-adapter-grpc", + "crank-adapter-soap", "crank-core", "crank-mapping", "crank-proto", @@ -33,6 +34,41 @@ dependencies = [ "uuid", ] +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -249,6 +285,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -317,6 +363,7 @@ dependencies = [ "base64", "crank-core", "crank-proto", + "futures-util", "prost", "prost-reflect", "prost-types", @@ -336,6 +383,7 @@ version = "0.1.0" dependencies = [ "axum", "crank-core", + "futures-util", "reqwest", "serde", "serde_json", @@ -343,6 +391,35 @@ dependencies = [ "tokio", ] +[[package]] +name = "crank-adapter-soap" +version = "0.1.0" +dependencies = [ + "axum", + "crank-core", + "crank-mapping", + "reqwest", + "roxmltree", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "crank-adapter-websocket" +version = "0.1.0" +dependencies = [ + "crank-core", + "futures-util", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", +] + [[package]] name = "crank-core" version = "0.1.0" @@ -396,17 +473,24 @@ dependencies = [ name = "crank-runtime" version = "0.1.0" dependencies = [ + "aes-gcm", "axum", + "base64", "crank-adapter-graphql", "crank-adapter-grpc", "crank-adapter-rest", + "crank-adapter-soap", + "crank-adapter-websocket", "crank-core", "crank-mapping", "crank-schema", + "futures-util", "serde", "serde_json", + "sha2", "thiserror", "tokio", + "tokio-tungstenite", ] [[package]] @@ -457,9 +541,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -636,6 +736,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -656,6 +767,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -713,6 +825,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "h2" version = "0.4.13" @@ -1038,6 +1160,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1175,6 +1306,7 @@ dependencies = [ "crank-registry", "crank-runtime", "crank-schema", + "futures-util", "reqwest", "serde", "serde_json", @@ -1256,6 +1388,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "ordered-float" version = "2.10.1" @@ -1360,6 +1498,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1762,6 +1912,7 @@ dependencies = [ "cookie", "cookie_store", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -1781,12 +1932,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.6", ] @@ -1805,6 +1958,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2013,6 +2172,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2383,6 +2553,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2581,6 +2767,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2626,6 +2831,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -2650,6 +2865,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2800,6 +3021,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index d3af950..5ed234e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "crates/crank-adapter-rest", "crates/crank-adapter-graphql", "crates/crank-adapter-grpc", + "crates/crank-adapter-websocket", + "crates/crank-adapter-soap", ] resolver = "3" @@ -21,6 +23,7 @@ rust-version = "1.85" version = "0.1.0" [workspace.dependencies] +aes-gcm = "0.10" argon2 = "0.5" axum = "0.8" axum-extra = { version = "0.10", features = ["cookie"] } @@ -31,6 +34,7 @@ prost-types = "0.14" protoc-bin-vendored = "3" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["cookies", "json", "rustls-tls"] } +roxmltree = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -45,4 +49,5 @@ tonic-build = "0.14" tonic-prost-build = "0.14" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } uuid = { version = "1", features = ["serde", "v7"] } diff --git a/README.md b/README.md index dc1132d..90576fb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Crank - платформа для публикации внешних API в в - Разработать MCP server на Rust. - Поддержать динамическое добавление интеграций через UI или конфигурацию. -- Обеспечить единый сценарий работы оператора для REST, GraphQL и gRPC. +- Обеспечить единый сценарий работы оператора для REST, GraphQL, gRPC, WebSocket и SOAP. - Нормализовать внешние протоколы в единую внутреннюю модель операции. - Ограничивать набор tools на уровне конкретного агента, а не отдавать один глобальный каталог. - Поддержать workspace-изоляцию, platform access и observability. @@ -20,7 +20,10 @@ Crank - платформа для публикации внешних API в в - `Agent` как curated MCP surface для LLM. - Поддержка REST для `GET`, `POST`, `PUT`, `PATCH` и `DELETE`. - Поддержка GraphQL для `query` и `mutation`. -- Поддержка только unary-методов gRPC. +- Поддержка unary и bounded server-streaming для gRPC. +- Поддержка WebSocket upstream integrations в bounded execution modes. +- Поддержка SOAP/WSDL enterprise integrations. +- Поддержка controlled streaming modes поверх MCP `Streamable HTTP`. - Platform API keys и membership layer. - Observability: invocation logs, usage aggregates, latency/error metrics. - Импорт и экспорт operation-конфигураций в `YAML`. @@ -40,11 +43,21 @@ Crank - платформа для публикации внешних API в в - `docs/diagrams.md` - диаграммы компонентов, сущностей и БД. - `docs/mcp-interface.md` - модель MCP transport и agent-scoped publishing. - `docs/testing-strategy.md` - стратегия тестирования. +- `docs/manual-regression-checklist.md` - post-integration regression baseline и ручной smoke checklist. - `docs/runtime-config.md` - конфигурация окружения. - `docs/deployment.md` - деплой, reverse proxy и CI/CD. +- `docs/deploy-and-staging-smoke.md` - канонический post-deploy smoke pass для staging/production-like окружения. +- `docs/authenticated-staging-pass.md` - browser-authenticated pass для UI flows, secrets, wizard и protocol smoke на стенде. +- `docs/staging-regression-notes.md` - журнал реальных замечаний и результатов post-deploy проверок на стенде. - `docs/demo-runbook.md` - демонстрационный сценарий. - `docs/public-smoke-targets.md` - готовые публичные upstream-сервисы и payload-ы для smoke-проверки MCP. - `docs/secrets-auth-plan.md` - целевая модель upstream secrets, auth profiles и пошаговый план реализации. +- `docs/streaming-mcp-plan.md` - целевая модель MCP transport streaming, upstream streaming и поэтапный план реализации. +- `docs/streaming-admin-api.md` - точные HTTP-контракты и DTO для streaming configuration, sessions и jobs. +- `docs/streaming-runtime-design.md` - функция-за-функцией разложенная streaming runtime architecture. +- `docs/streaming-ui-contract.md` - точный UI-контракт для streaming configuration и test flows. +- `docs/protocol-capability-matrix.md` - capability matrix по всем protocol families и execution modes. +- `docs/streaming-implementation-spec.md` - execution-oriented план реализации по срезам, файлам, тестам и DoD. - `docs/rust-design.md` - правила распределения поведения в Rust. - `docs/development-rules.md` - правила разработки и workflow. - `docs/rust-code-rules.md` - Rust-specific coding rules. @@ -52,6 +65,8 @@ Crank - платформа для публикации внешних API в в - `docs/protocols/rest.md` - требования и ограничения для REST. - `docs/protocols/graphql.md` - требования и ограничения для GraphQL. - `docs/protocols/grpc.md` - требования и ограничения для gRPC. +- `docs/protocols/websocket.md` - требования и ограничения для WebSocket. +- `docs/protocols/soap.md` - требования и ограничения для SOAP. ## Ключевая идея продукта @@ -88,8 +103,10 @@ Crank - платформа для публикации внешних API в в - REST - GraphQL - gRPC +- WebSocket +- SOAP -`SOAP` сознательно не входит в текущий scope. +Все пять протокольных семейств входят в целевой product scope. Разница только в очередности реализации. ## Frontend e2e @@ -114,3 +131,23 @@ npm run e2e ```bash just ui-e2e ``` + +Для post-deploy smoke: + +```bash +just staging-smoke https:// +``` + +Для browser-authenticated smoke на реальном стенде: + +```bash +export CRANK_STAGING_ADMIN_EMAIL=owner@example.com +export CRANK_STAGING_ADMIN_PASSWORD=secret +just authenticated-staging-smoke https:// +``` + +Чтобы быстро подготовить запись для `docs/staging-regression-notes.md`: + +```bash +just staging-note-block "codex + operator" +``` diff --git a/TASKS.md b/TASKS.md index 2c4d8b6..a37df50 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,24 +2,19 @@ ## Current -### `feat/secrets-auth-plan` +### `feat/websocket-test-run-polish` -Status: completed +Status: pending DoD: -- Docs describe the target secret store and auth profile model -- Backend, runtime, and UI gaps are captured as vertical slices -- TASKS and implementation plan reflect the new sequence +- WebSocket wizard test-run shows reconnect / bounded-result semantics clearly +- WebSocket test-run flow is covered by local fixtures and Playwright +- `TASKS.md` stays aligned with the actual streaming branch state ## Next -- `feat/secret-store-foundation` +- `feat/live-authenticated-staging-entry` ## Backlog -- `feat/secret-store-foundation` -- `feat/auth-profile-secret-resolution` -- `feat/runtime-upstream-auth` -- `feat/secrets-ui` -- `feat/wizard-auth-selector` -- `feat/manual-regression-pass` +- `feat/live-authenticated-staging-entry` diff --git a/apps/admin-api/Cargo.toml b/apps/admin-api/Cargo.toml index 9897ec2..77d5621 100644 --- a/apps/admin-api/Cargo.toml +++ b/apps/admin-api/Cargo.toml @@ -11,6 +11,7 @@ axum.workspace = true axum-extra.workspace = true base64.workspace = true crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] } +crank-adapter-soap = { path = "../../crates/crank-adapter-soap" } crank-core = { path = "../../crates/crank-core" } crank-mapping = { path = "../../crates/crank-mapping" } crank-proto = { path = "../../crates/crank-proto" } diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index d5081cf..78c300e 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -24,8 +24,14 @@ use crate::{ operations::{ archive_operation, create_operation, create_version, delete_operation, export_operation, generate_draft, get_operation, get_operation_version, - list_grpc_services, list_operations, publish_operation, run_test, update_operation, - upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor, + list_grpc_services, list_operations, list_soap_services, publish_operation, run_test, + update_operation, upload_descriptor_set, upload_input_json, upload_output_json, + upload_proto_descriptor, upload_wsdl_descriptor, upload_xsd_descriptor, + }, + secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret}, + streaming::{ + cancel_async_job, get_async_job, get_async_job_result, get_stream_session, + list_async_jobs, list_protocol_capabilities, list_stream_sessions, stop_stream_session, }, workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace}, }, @@ -72,10 +78,22 @@ pub fn build_app(state: AppState) -> Router { "/operations/{operation_id}/descriptors/descriptor-set", post(upload_descriptor_set), ) + .route( + "/operations/{operation_id}/descriptors/wsdl", + post(upload_wsdl_descriptor), + ) + .route( + "/operations/{operation_id}/descriptors/xsd", + post(upload_xsd_descriptor), + ) .route( "/operations/{operation_id}/grpc/services", get(list_grpc_services), ) + .route( + "/operations/{operation_id}/soap/services", + get(list_soap_services), + ) .route( "/operations/{operation_id}/drafts/generate", post(generate_draft), @@ -99,6 +117,12 @@ pub fn build_app(state: AppState) -> Router { get(list_auth_profiles).post(create_auth_profile), ) .route("/auth-profiles/{auth_profile_id}", get(get_auth_profile)) + .route("/secrets", get(list_secrets).post(create_secret)) + .route( + "/secrets/{secret_id}", + get(get_secret).delete(delete_secret), + ) + .route("/secrets/{secret_id}/rotate", post(rotate_secret)) .route("/members", get(list_memberships)) .route( "/members/{user_id}", @@ -126,7 +150,18 @@ pub fn build_app(state: AppState) -> Router { .route("/logs/{log_id}", get(get_log)) .route("/usage", get(get_usage)) .route("/usage/operations/{operation_id}", get(get_operation_usage)) - .route("/usage/agents/{agent_id}", get(get_agent_usage)); + .route("/usage/agents/{agent_id}", get(get_agent_usage)) + .route("/protocol-capabilities", get(list_protocol_capabilities)) + .route("/stream-sessions", get(list_stream_sessions)) + .route("/stream-sessions/{session_id}", get(get_stream_session)) + .route( + "/stream-sessions/{session_id}/stop", + post(stop_stream_session), + ) + .route("/async-jobs", get(list_async_jobs)) + .route("/async-jobs/{job_id}", get(get_async_job)) + .route("/async-jobs/{job_id}/cancel", post(cancel_async_job)) + .route("/async-jobs/{job_id}/result", get(get_async_job_result)); let workspace_root_router = Router::new() .route("/workspaces", get(list_workspaces).post(create_workspace)) @@ -186,11 +221,14 @@ mod tests { use axum::{Json, Router, routing::post}; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ - DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, - MembershipRole, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, + DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget, + GrpcTarget, HttpMethod, MembershipRole, Protocol, RestTarget, SecretKind, SoapBindingStyle, + SoapOperationMetadata, SoapTarget, SoapVersion, Target, ToolDescription, TransportBehavior, + WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::PostgresRegistry; + use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use serial_test::serial; @@ -209,6 +247,7 @@ mod tests { const TEST_AUTH_PASSWORD: &str = "test-password"; const TEST_PASSWORD_PEPPER: &str = "test-password-pepper"; const TEST_SESSION_SECRET: &str = "test-session-secret"; + const TEST_MASTER_KEY: &str = "test-master-key"; struct TestServer { base_url: String, @@ -981,7 +1020,12 @@ mod tests { async fn seeds_demo_assets_for_live_ui() { let registry = test_registry().await; let storage_root = test_storage_root("demo_seed"); - let service = AdminService::new(registry.clone(), storage_root, test_auth_settings()); + let service = AdminService::new( + registry.clone(), + storage_root, + test_auth_settings(), + test_secret_crypto(), + ); service.bootstrap_admin_user().await.unwrap(); service.seed_demo_assets().await.unwrap(); @@ -1398,6 +1442,248 @@ mod tests { assert_eq!(test_run["response_preview"]["message"], "hello"); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn returns_window_metadata_for_streaming_test_runs() { + let registry = test_registry().await; + let storage_root = test_storage_root("grpc_window_test_run"); + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_grpc_window_operation_payload( + &server_addr, + "echo_window_runtime", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "message": "hello" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["mode"], "window"); + assert!(test_run["window"].is_object()); + assert!(test_run["window"]["window_complete"].is_boolean()); + assert!(test_run["window"]["truncated"].is_boolean()); + assert!(test_run["window"]["has_more"].is_boolean()); + assert!(test_run["response_preview"]["items"].is_array()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn starts_stream_session_from_streaming_test_runs() { + let registry = test_registry().await; + let storage_root = test_storage_root("grpc_session_test_run"); + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_grpc_session_operation_payload( + &server_addr, + "echo_session_runtime", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "message": "hello" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let session_id = test_run["stream_session"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + + let session = client + .get(format!("{base_url}/stream-sessions/{session_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["mode"], "session"); + assert_eq!(test_run["stream_session"]["status"], "running"); + assert!(test_run["response_preview"]["preview"]["items"].is_array()); + assert_eq!(session["id"], session_id); + assert_eq!(session["status"], "running"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn starts_async_job_from_streaming_test_runs() { + let registry = test_registry().await; + let storage_root = test_storage_root("async_job_test_run"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_rest_async_job_operation_payload( + &upstream_base_url, + "crm_async_job_runtime", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "user@example.com" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let job_id = test_run["async_job"]["job_id"].as_str().unwrap().to_owned(); + + let mut job = Value::Null; + for _ in 0..20 { + job = client + .get(format!("{base_url}/async-jobs/{job_id}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + if job["status"] == "completed" { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + let result = client + .get(format!("{base_url}/async-jobs/{job_id}/result")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["mode"], "async_job"); + assert_eq!(test_run["async_job"]["status"], "running"); + assert_eq!(job["status"], "completed"); + assert_eq!(result["id"], "lead_123"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn uploads_wsdl_and_tests_soap_operation() { + let registry = test_registry().await; + let storage_root = test_storage_root("soap_runtime"); + let endpoint = spawn_soap_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_soap_operation_payload( + &endpoint, + "crm_create_lead_soap", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let uploaded = client + .post(format!( + "{base_url}/operations/{operation_id}/descriptors/wsdl" + )) + .header("x-file-name", "lead.wsdl") + .body(SOAP_TEST_WSDL) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let services = client + .get(format!( + "{base_url}/operations/{operation_id}/soap/services" + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "user@example.com" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(uploaded["version"], 1); + assert_eq!(services["services"][0]["service_name"], "LeadService"); + assert_eq!(services["services"][0]["ports"][0]["port_name"], "LeadPort"); + assert_eq!( + services["services"][0]["ports"][0]["operations"][0]["operation_name"], + "CreateLead" + ); + assert_eq!(test_run["ok"], true); + assert_eq!(test_run["response_preview"]["id"], "lead_123"); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn manages_auth_profiles_and_yaml_upsert() { @@ -1406,6 +1692,18 @@ mod tests { let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; let client = authorized_client(&base_url).await; + let secret = client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "crm-api-token", + "kind": SecretKind::Token, + "value": { "token": "super-secret-token" } + })) + .send() + .await + .unwrap(); + let secret = assert_success_json(secret).await; + let secret_id = secret["id"].as_str().unwrap(); let auth_profile = client .post(format!("{base_url}/auth-profiles")) @@ -1415,7 +1713,7 @@ mod tests { "config": { "api_key_header": { "header_name": "X-Api-Key", - "secret_ref": "secret://crm/api-key" + "secret_id": secret_id } } })) @@ -1457,11 +1755,177 @@ mod tests { .unwrap(); assert_eq!(auth_profile["kind"], "api_key_header"); + assert_eq!( + auth_profile["config"]["api_key_header"]["secret_id"], + secret_id + ); assert_eq!(imported["operation_id"], operation_id); assert_eq!(imported["version"], 2); assert_eq!(imported["import_mode"], "upsert"); } + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn manages_workspace_secrets_without_exposing_plaintext() { + let registry = test_registry().await; + let storage_root = test_storage_root("secrets"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let created = client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "crm-api-token", + "kind": SecretKind::Token, + "value": { "token": "super-secret-token" } + })) + .send() + .await + .unwrap(); + let created = assert_success_json(created).await; + let secret_id = created["id"].as_str().unwrap().to_owned(); + + let listed = client + .get(format!("{base_url}/secrets")) + .send() + .await + .unwrap(); + let listed = assert_success_json(listed).await; + + let fetched = client + .get(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap(); + let fetched = assert_success_json(fetched).await; + + let rotated = client + .post(format!("{base_url}/secrets/{secret_id}/rotate")) + .json(&json!({ + "value": { "token": "rotated-token" } + })) + .send() + .await + .unwrap(); + let rotated = assert_success_json(rotated).await; + + let deleted = client + .delete(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap(); + let deleted = assert_success_json(deleted).await; + + let missing = client + .get(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap(); + let missing_status = missing.status(); + let missing = missing.json::().await.unwrap(); + + assert_eq!(created["name"], "crm-api-token"); + assert_eq!(created["kind"], "token"); + assert_eq!(created["current_version"], 1); + assert!(created.get("value").is_none()); + assert_eq!(listed["items"].as_array().unwrap().len(), 1); + assert_eq!(fetched["id"], secret_id); + assert!(fetched.get("value").is_none()); + assert_eq!(rotated["current_version"], 2); + assert_eq!(deleted["ok"], true); + assert_eq!(missing_status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(missing["error"]["code"], "not_found"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_auth_profile_with_missing_secret() { + let registry = test_registry().await; + let storage_root = test_storage_root("missing_secret_auth"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let response = client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "crm-header", + "kind": "api_key_header", + "config": { + "api_key_header": { + "header_name": "X-Api-Key", + "secret_id": "secret_missing" + } + } + })) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(body["error"]["code"], "not_found"); + assert_eq!( + body["error"]["message"], + "secret secret_missing was not found" + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn rejects_deleting_secret_referenced_by_auth_profile() { + let registry = test_registry().await; + let storage_root = test_storage_root("secret_references"); + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let secret = client + .post(format!("{base_url}/secrets")) + .json(&json!({ + "name": "crm-api-token", + "kind": SecretKind::Token, + "value": { "token": "super-secret-token" } + })) + .send() + .await + .unwrap(); + let secret = assert_success_json(secret).await; + let secret_id = secret["id"].as_str().unwrap(); + + let auth_profile = client + .post(format!("{base_url}/auth-profiles")) + .json(&json!({ + "name": "crm-header", + "kind": "api_key_header", + "config": { + "api_key_header": { + "header_name": "X-Api-Key", + "secret_id": secret_id + } + } + })) + .send() + .await + .unwrap(); + let auth_profile = assert_success_json(auth_profile).await; + let auth_profile_id = auth_profile["id"].as_str().unwrap(); + + let response = client + .delete(format!("{base_url}/secrets/{secret_id}")) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.json::().await.unwrap(); + + assert_eq!(status, reqwest::StatusCode::CONFLICT); + assert_eq!(body["error"]["code"], "conflict"); + assert_eq!( + body["error"]["message"], + format!("secret {secret_id} is referenced by auth profile {auth_profile_id}") + ); + } + #[tokio::test(flavor = "multi_thread")] #[serial] async fn roundtrips_graphql_operation_through_yaml_upsert() { @@ -1644,7 +2108,12 @@ mod tests { fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router { build_app(AppState { - service: AdminService::new(registry, storage_root, test_auth_settings()), + service: AdminService::new( + registry, + storage_root, + test_auth_settings(), + test_secret_crypto(), + ), }) } @@ -1672,6 +2141,18 @@ mod tests { format!("http://{}", address) } + async fn spawn_soap_server() -> String { + let app = Router::new().route("/", post(soap_handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + async fn spawn_admin_api(app: Router) -> TestServer { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -1760,6 +2241,23 @@ mod tests { })) } + async fn soap_handler(body: String) -> (axum::http::StatusCode, String) { + assert!(body.contains("user@example.com")); + + ( + axum::http::StatusCode::OK, + r#" + + + lead_123 + created + + + "# + .to_owned(), + ) + } + async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:5432/crank".to_owned()); @@ -1823,6 +2321,10 @@ mod tests { } } + fn test_secret_crypto() -> SecretCrypto { + SecretCrypto::new(TEST_MASTER_KEY).unwrap() + } + fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload { OperationPayload { name: name.to_owned(), @@ -1865,6 +2367,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead".to_owned(), @@ -1920,6 +2423,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead GraphQL".to_owned(), @@ -1974,6 +2478,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Unary Echo gRPC".to_owned(), @@ -1984,6 +2489,180 @@ mod tests { } } + fn test_grpc_window_operation_payload(server_addr: &str, name: &str) -> OperationPayload { + let mut payload = test_grpc_operation_payload(server_addr, name); + payload.display_name = "Server Echo Window".to_owned(); + payload.target = Target::Grpc(GrpcTarget { + server_addr: server_addr.to_owned(), + package: "echo".to_owned(), + service: "EchoService".to_owned(), + method: "ServerEcho".to_owned(), + descriptor_ref: DescriptorId::new("desc_echo_stream"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }); + payload.execution_config.streaming = Some(crank_core::StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(100), + poll_interval_ms: None, + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items: Some(2), + max_bytes: None, + aggregation_mode: crank_core::AggregationMode::RawItems, + summary_path: None, + items_path: Some("$.response.body.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.response.body.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: crank_core::ToolFamilyConfig::default(), + }); + payload + } + + fn test_grpc_session_operation_payload(server_addr: &str, name: &str) -> OperationPayload { + let mut payload = test_grpc_window_operation_payload(server_addr, name); + payload.display_name = "Server Echo Session".to_owned(); + if let Some(streaming) = payload.execution_config.streaming.as_mut() { + streaming.mode = ExecutionMode::Session; + streaming.poll_interval_ms = Some(1_000); + streaming.max_session_lifetime_ms = Some(60_000); + streaming.tool_family = crank_core::ToolFamilyConfig { + start_tool_name: Some("echo_session_start".to_owned()), + poll_tool_name: Some("echo_session_poll".to_owned()), + stop_tool_name: Some("echo_session_stop".to_owned()), + status_tool_name: None, + result_tool_name: None, + cancel_tool_name: None, + }; + } + payload + } + + fn test_rest_async_job_operation_payload(base_url: &str, name: &str) -> OperationPayload { + let mut payload = test_operation_payload(base_url, name); + payload.display_name = "Create Lead Async Job".to_owned(); + payload.execution_config.streaming = Some(crank_core::StreamingConfig { + mode: ExecutionMode::AsyncJob, + transport_behavior: TransportBehavior::DeferredResult, + window_duration_ms: None, + poll_interval_ms: Some(1_000), + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: Some(300_000), + max_items: None, + max_bytes: None, + aggregation_mode: crank_core::AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: None, + cursor_path: None, + status_path: None, + done_path: None, + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: crank_core::ToolFamilyConfig { + start_tool_name: Some("crm_async_start".to_owned()), + poll_tool_name: None, + stop_tool_name: None, + status_tool_name: Some("crm_async_status".to_owned()), + result_tool_name: Some("crm_async_result".to_owned()), + cancel_tool_name: Some("crm_async_cancel".to_owned()), + }, + }); + payload + } + + fn test_soap_operation_payload(endpoint: &str, name: &str) -> OperationPayload { + OperationPayload { + name: name.to_owned(), + display_name: "Create Lead SOAP".to_owned(), + category: "sales".to_owned(), + protocol: Protocol::Soap, + target: Target::Soap(SoapTarget { + wsdl_ref: "sample_wsdl".into(), + service_name: "LeadService".to_owned(), + port_name: "LeadPort".to_owned(), + operation_name: "CreateLead".to_owned(), + endpoint_override: Some(endpoint.to_owned()), + soap_version: SoapVersion::Soap11, + soap_action: Some("urn:createLead".to_owned()), + binding_style: SoapBindingStyle::DocumentLiteral, + headers: Vec::new(), + fault_contract: None, + metadata: SoapOperationMetadata { + input_part_names: vec!["CreateLeadRequest".to_owned()], + output_part_names: vec!["CreateLeadResponse".to_owned()], + namespaces: vec!["urn:crm".to_owned()], + }, + }), + input_schema: object_schema("email"), + output_schema: object_schema("id"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.body.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.body.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + streaming: None, + }, + tool_description: ToolDescription { + title: "Create Lead SOAP".to_owned(), + description: "Creates a CRM lead through SOAP".to_owned(), + tags: vec!["crm".to_owned(), "soap".to_owned()], + examples: Vec::new(), + }, + } + } + + const SOAP_TEST_WSDL: &str = r#" + + + + + + + + + + + + +"#; + fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 9018099..4647661 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -139,6 +139,15 @@ impl From for ApiError { RegistryError::PlatformApiKeyNotFound { key_id } => { Self::not_found(format!("platform api key {key_id} was not found")) } + RegistryError::SecretNotFound { secret_id } => { + Self::not_found(format!("secret {secret_id} was not found")) + } + RegistryError::StreamSessionNotFound { session_id } => { + Self::not_found(format!("stream session {session_id} was not found")) + } + RegistryError::AsyncJobNotFound { job_id } => { + Self::not_found(format!("async job {job_id} was not found")) + } RegistryError::InvocationLogNotFound { log_id } => { Self::not_found(format!("invocation log {log_id} was not found")) } @@ -171,6 +180,17 @@ impl From for ApiError { RegistryError::WorkspaceSlugAlreadyExists { slug } => { Self::conflict(format!("workspace with slug {slug} already exists")) } + RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict( + format!("secret with name {name} already exists in workspace {workspace_id}"), + ), + RegistryError::SecretReferencedByAuthProfile { + secret_id, + auth_profile_id, + } => Self::conflict(format!( + "secret {secret_id} is referenced by auth profile {auth_profile_id}" + )), + RegistryError::InvalidStreamSessionTransition { .. } + | RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()), RegistryError::UserEmailAlreadyExists { email } => { Self::conflict(format!("user with email {email} already exists")) } @@ -224,7 +244,18 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error", RuntimeError::GrpcAdapter(_) => "runtime_grpc_error", RuntimeError::RestAdapter(_) => "runtime_rest_error", + RuntimeError::SoapAdapter(_) => "runtime_soap_error", + RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error", RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", + RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error", + RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error", + RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error", + RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "runtime_secret_error" + } + RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error", + RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error", } } diff --git a/apps/admin-api/src/main.rs b/apps/admin-api/src/main.rs index 2eb1ab1..5a48f89 100644 --- a/apps/admin-api/src/main.rs +++ b/apps/admin-api/src/main.rs @@ -18,6 +18,7 @@ use crate::{ service::AdminService, state::AppState, }; +use crank_runtime::SecretCrypto; #[tokio::main] async fn main() -> Result<(), Box> { @@ -51,7 +52,8 @@ async fn main() -> Result<(), Box> { .unwrap_or_else(|_| "Crank Owner".into()), }, }; - let service = AdminService::new(registry, storage_root, auth_settings); + let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; + let service = AdminService::new(registry, storage_root, auth_settings, secret_crypto); service.bootstrap_admin_user().await?; if env_flag("CRANK_DEMO_SEED") { service.seed_demo_assets().await?; diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index 2f2828d..f53ac88 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -4,6 +4,8 @@ pub mod auth; pub mod auth_profiles; pub mod observability; pub mod operations; +pub mod secrets; +pub mod streaming; pub mod workspaces; use axum::Json; diff --git a/apps/admin-api/src/routes/operations.rs b/apps/admin-api/src/routes/operations.rs index 6820ffb..68239ab 100644 --- a/apps/admin-api/src/routes/operations.rs +++ b/apps/admin-api/src/routes/operations.rs @@ -269,6 +269,46 @@ pub async fn upload_descriptor_set( Ok(Json(json!(descriptor))) } +pub async fn upload_wsdl_descriptor( + Path(path): Path, + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let source_name = header_file_name(&headers); + let descriptor = state + .service + .upload_wsdl_file( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + source_name.as_deref(), + body.as_ref(), + ) + .await?; + + Ok(Json(json!(descriptor))) +} + +pub async fn upload_xsd_descriptor( + Path(path): Path, + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result, ApiError> { + let source_name = header_file_name(&headers); + let descriptor = state + .service + .upload_xsd_file( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + source_name.as_deref(), + body.as_ref(), + ) + .await?; + + Ok(Json(json!(descriptor))) +} + pub async fn list_grpc_services( Path(path): Path, Query(query): Query, @@ -285,6 +325,22 @@ pub async fn list_grpc_services( Ok(Json(json!({ "services": services }))) } +pub async fn list_soap_services( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let services = state + .service + .list_soap_services( + &path.workspace_id.as_str().into(), + &path.operation_id.as_str().into(), + query.version, + ) + .await?; + Ok(Json(json!({ "services": services }))) +} + pub async fn generate_draft( Path(path): Path, State(state): State, diff --git a/apps/admin-api/src/routes/secrets.rs b/apps/admin-api/src/routes/secrets.rs new file mode 100644 index 0000000..4bb1817 --- /dev/null +++ b/apps/admin-api/src/routes/secrets.rs @@ -0,0 +1,98 @@ +use axum::{ + Extension, Json, + extract::{Path, State}, +}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::{ + auth::AuthenticatedSession, + error::ApiError, + service::{RotateSecretPayload, SecretPayload}, + state::AppState, +}; + +#[derive(Deserialize)] +pub struct WorkspacePath { + pub workspace_id: String, +} + +#[derive(Deserialize)] +pub struct WorkspaceSecretPath { + pub workspace_id: String, + pub secret_id: String, +} + +pub async fn list_secrets( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let items = state + .service + .list_secrets(&path.workspace_id.as_str().into()) + .await?; + Ok(Json(json!({ "items": items }))) +} + +pub async fn create_secret( + Path(path): Path, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let secret = state + .service + .create_secret( + &path.workspace_id.as_str().into(), + Some(&session.user.id), + payload, + ) + .await?; + Ok(Json(json!(secret))) +} + +pub async fn get_secret( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let secret = state + .service + .get_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + ) + .await?; + Ok(Json(json!(secret))) +} + +pub async fn rotate_secret( + Path(path): Path, + State(state): State, + Extension(session): Extension, + Json(payload): Json, +) -> Result, ApiError> { + let secret = state + .service + .rotate_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + Some(&session.user.id), + payload, + ) + .await?; + Ok(Json(json!(secret))) +} + +pub async fn delete_secret( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + state + .service + .delete_secret( + &path.workspace_id.as_str().into(), + &path.secret_id.as_str().into(), + ) + .await?; + Ok(Json(json!({ "ok": true }))) +} diff --git a/apps/admin-api/src/routes/streaming.rs b/apps/admin-api/src/routes/streaming.rs new file mode 100644 index 0000000..fae361f --- /dev/null +++ b/apps/admin-api/src/routes/streaming.rs @@ -0,0 +1,137 @@ +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use serde_json::{Value, json}; + +use crate::{ + error::ApiError, + routes::access::WorkspacePath, + service::{AsyncJobsQuery, StreamSessionsQuery}, + state::AppState, +}; + +#[derive(serde::Deserialize)] +pub struct WorkspaceStreamSessionPath { + pub workspace_id: String, + pub session_id: String, +} + +#[derive(serde::Deserialize)] +pub struct WorkspaceAsyncJobPath { + pub workspace_id: String, + pub job_id: String, +} + +pub async fn list_protocol_capabilities( + Path(_path): Path, + State(state): State, +) -> Result, ApiError> { + Ok(Json(json!({ + "items": state.service.list_protocol_capabilities().await + }))) +} + +pub async fn list_stream_sessions( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let page = state + .service + .list_stream_sessions(&path.workspace_id.as_str().into(), query.clone()) + .await?; + Ok(Json(json!({ + "items": page.items, + "page": query.page.unwrap_or(1), + "page_size": query.page_size.unwrap_or(20), + "total": page.total, + }))) +} + +pub async fn get_stream_session( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let session = state + .service + .get_stream_session( + &path.workspace_id.as_str().into(), + &path.session_id.as_str().into(), + ) + .await?; + Ok(Json(json!(session))) +} + +pub async fn stop_stream_session( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let session = state + .service + .stop_stream_session( + &path.workspace_id.as_str().into(), + &path.session_id.as_str().into(), + ) + .await?; + Ok(Json(json!(session))) +} + +pub async fn list_async_jobs( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, ApiError> { + let page = state + .service + .list_async_jobs(&path.workspace_id.as_str().into(), query.clone()) + .await?; + Ok(Json(json!({ + "items": page.items, + "page": query.page.unwrap_or(1), + "page_size": query.page_size.unwrap_or(20), + "total": page.total, + }))) +} + +pub async fn get_async_job( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let job = state + .service + .get_async_job( + &path.workspace_id.as_str().into(), + &path.job_id.as_str().into(), + ) + .await?; + Ok(Json(json!(job))) +} + +pub async fn cancel_async_job( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let job = state + .service + .cancel_async_job( + &path.workspace_id.as_str().into(), + &path.job_id.as_str().into(), + ) + .await?; + Ok(Json(json!(job))) +} + +pub async fn get_async_job_result( + Path(path): Path, + State(state): State, +) -> Result, ApiError> { + let result = state + .service + .get_async_job_result( + &path.workspace_id.as_str().into(), + &path.job_id.as_str().into(), + ) + .await?; + Ok(Json(json!(result))) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index c2a6d8e..c9ba37c 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -6,29 +6,35 @@ use base64::{ engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, }; use crank_adapter_grpc::test_support as grpc_test_support; +use crank_adapter_soap::{SoapServiceSummary, inspect_wsdl}; use crank_core::{ - Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind, - AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus, - InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog, - InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId, - OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, - Protocol, SampleId, Samples, Target, UsagePeriod, UserSessionId, Workspace, WorkspaceId, - WorkspaceStatus, + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, + AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExecutionMode, + ExportMode, GeneratedDraft, GeneratedDraftStatus, InvitationId, InvitationStatus, + InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, + InvocationStatus, JobStatus, MembershipRole, OperationId, OperationStatus, PlatformApiKey, + PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, SampleId, Samples, + Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target, + TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus, }; use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples}; use crank_proto::{ProtoService, services_from_descriptor_set_bytes}; use crank_registry::{ - AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest, - CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, - CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, - MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, - OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord, PostgresRegistry, - PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest, - SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + AgentSummary, AgentVersionRecord, AsyncJobFilter, CreateAgentRequest, CreateAsyncJobRequest, + CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, + CreateSecretRequest, CreateStreamSessionRequest, CreateVersionRequest, CreateWorkspaceRequest, + InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, + OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, + OperationVersionRecord, Page, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, + PublishRequest, RegistryError, RegistryOperation, RotateSecretRequest, SampleKind, + SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, + SaveSampleMetadataRequest, StreamSessionFilter, UpdateAsyncJobStatusRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, }; -use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation}; +use crank_runtime::{ + PreparedRequest, ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto, +}; use crank_schema::Schema; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -52,6 +58,7 @@ pub struct AdminService { runtime: RuntimeExecutor, storage: LocalArtifactStorage, auth_settings: AuthSettings, + secret_crypto: SecretCrypto, } #[derive(Clone, Debug, Deserialize)] @@ -122,9 +129,123 @@ pub struct TestRunPayload { #[derive(Clone, Debug, Serialize)] pub struct TestRunResult { pub ok: bool, + pub mode: ExecutionMode, pub request_preview: Value, pub response_preview: Value, pub errors: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub window: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub async_job: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WindowTestRunView { + pub window_complete: bool, + pub truncated: bool, + pub has_more: bool, + pub cursor: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionStartView { + pub session_id: String, + pub status: StreamStatus, + pub expires_at: String, + pub poll_after_ms: u64, + pub preview: Value, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobStartView { + pub job_id: String, + pub status: JobStatus, + pub progress: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +struct StoredSessionState { + input: Value, + summary: Value, + items: Vec, + next_index: usize, + batch_size: usize, +} + +enum TestRunOutcome { + Unary { + output: Value, + }, + Window { + output: crank_runtime::WindowExecutionResult, + }, + Session { + output: StreamSessionStartView, + }, + AsyncJob { + output: AsyncJobStartView, + }, +} + +impl TestRunOutcome { + fn into_result_views( + self, + ) -> ( + &'static str, + Value, + Option, + Option, + Option, + ) { + match self { + Self::Unary { output } => ("admin test run completed", output, None, None, None), + Self::Window { output } => ( + "admin window test run completed", + json!({ + "summary": output.summary, + "items": output.items, + "cursor": output.cursor, + "window_complete": output.window_complete, + "truncated": output.truncated, + "has_more": output.has_more, + }), + Some(WindowTestRunView { + window_complete: output.window_complete, + truncated: output.truncated, + has_more: output.has_more, + cursor: output.cursor, + }), + None, + None, + ), + Self::Session { output } => ( + "admin session test run started", + json!({ + "session_id": output.session_id, + "status": output.status, + "expires_at": output.expires_at, + "poll_after_ms": output.poll_after_ms, + "preview": output.preview, + }), + None, + Some(output), + None, + ), + Self::AsyncJob { output } => ( + "admin async job test run started", + json!({ + "job_id": output.job_id, + "status": output.status, + "progress": output.progress, + }), + None, + None, + Some(output), + ), + } + } } #[derive(Clone, Debug, Deserialize)] @@ -134,6 +255,18 @@ pub struct AuthProfilePayload { pub config: AuthConfig, } +#[derive(Clone, Debug, Deserialize)] +pub struct SecretPayload { + pub name: String, + pub kind: SecretKind, + pub value: Value, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct RotateSecretPayload { + pub value: Value, +} + #[derive(Clone, Debug, Deserialize)] pub struct WorkspacePayload { pub slug: String, @@ -288,6 +421,78 @@ pub struct UsageOverviewResponse { pub agents: Vec, } +#[derive(Clone, Debug, Serialize)] +pub struct ProtocolCapabilityView { + pub protocol: Protocol, + pub supports_execution_modes: Vec, + pub supports_transport_behaviors: Vec, + pub supports_auth_kinds: Vec, + pub supports_upload_artifacts: Vec, + pub supports_cursor_path: bool, + pub supports_done_path: bool, + pub supports_aggregation_mode: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct StreamSessionsQuery { + pub operation_id: Option, + pub agent_id: Option, + pub status: Option, + pub mode: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionSummaryView { + pub id: String, + pub operation_id: String, + pub agent_id: Option, + pub protocol: Protocol, + pub mode: ExecutionMode, + pub status: StreamStatus, + pub expires_at: String, + pub last_poll_at: Option, + pub created_at: String, + pub closed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionDetailView { + #[serde(flatten)] + pub summary: StreamSessionSummaryView, + pub cursor: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AsyncJobsQuery { + pub operation_id: Option, + pub agent_id: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobSummaryView { + pub id: String, + pub operation_id: String, + pub agent_id: Option, + pub status: JobStatus, + pub progress: Value, + pub created_at: String, + pub updated_at: String, + pub finished_at: Option, + pub expires_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobDetailView { + #[serde(flatten)] + pub summary: AsyncJobSummaryView, + pub error: Option, +} + #[derive(Clone, Debug, Deserialize)] pub struct GenerateDraftPayload { #[serde(default)] @@ -475,6 +680,8 @@ pub struct GrpcMethodSummary { pub output_schema: Schema, } +pub type SoapServiceCatalog = Vec; + fn default_operation_category() -> String { "general".to_owned() } @@ -484,12 +691,14 @@ impl AdminService { registry: PostgresRegistry, storage_root: PathBuf, auth_settings: AuthSettings, + secret_crypto: SecretCrypto, ) -> Self { Self { registry, runtime: RuntimeExecutor::new(), storage: LocalArtifactStorage::new(storage_root), auth_settings, + secret_crypto, } } @@ -1256,6 +1465,191 @@ impl AdminService { }) } + pub async fn list_protocol_capabilities(&self) -> Vec { + [ + Protocol::Rest, + Protocol::Graphql, + Protocol::Grpc, + Protocol::Websocket, + Protocol::Soap, + ] + .into_iter() + .map(protocol_capability_view) + .collect() + } + + #[instrument(skip(self))] + pub async fn list_stream_sessions( + &self, + workspace_id: &WorkspaceId, + query: StreamSessionsQuery, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + let operation_id = query.operation_id.as_deref().map(OperationId::new); + let agent_id = query.agent_id.as_deref().map(AgentId::new); + let limit = query.page_size.unwrap_or(20).max(1); + let page = self + .registry + .list_stream_sessions(StreamSessionFilter { + workspace_id, + agent_id: agent_id.as_ref(), + operation_id: operation_id.as_ref(), + status: query.status, + mode: query.mode, + limit, + }) + .await?; + + Ok(Page { + items: page + .items + .into_iter() + .map(stream_session_summary_view) + .collect(), + total: page.total, + }) + } + + #[instrument(skip(self))] + pub async fn get_stream_session( + &self, + workspace_id: &WorkspaceId, + session_id: &crank_core::StreamSessionId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let session = self + .registry + .get_stream_session(session_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "stream session {} was not found", + session_id.as_str() + )) + })?; + ensure_stream_session_workspace(&session, workspace_id)?; + + Ok(stream_session_detail_view(session)) + } + + #[instrument(skip(self))] + pub async fn stop_stream_session( + &self, + workspace_id: &WorkspaceId, + session_id: &crank_core::StreamSessionId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let session = self + .registry + .get_stream_session(session_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "stream session {} was not found", + session_id.as_str() + )) + })?; + ensure_stream_session_workspace(&session, workspace_id)?; + self.registry + .close_stream_session(session_id, &now_string()?) + .await?; + let updated = self + .registry + .get_stream_session(session_id) + .await? + .ok_or_else(|| { + ApiError::not_found(format!( + "stream session {} was not found", + session_id.as_str() + )) + })?; + + Ok(stream_session_detail_view(updated)) + } + + #[instrument(skip(self))] + pub async fn list_async_jobs( + &self, + workspace_id: &WorkspaceId, + query: AsyncJobsQuery, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + let operation_id = query.operation_id.as_deref().map(OperationId::new); + let agent_id = query.agent_id.as_deref().map(AgentId::new); + let limit = query.page_size.unwrap_or(20).max(1); + let page = self + .registry + .list_async_jobs(AsyncJobFilter { + workspace_id, + agent_id: agent_id.as_ref(), + operation_id: operation_id.as_ref(), + status: query.status, + limit, + }) + .await?; + + Ok(Page { + items: page.items.into_iter().map(async_job_summary_view).collect(), + total: page.total, + }) + } + + #[instrument(skip(self))] + pub async fn get_async_job( + &self, + workspace_id: &WorkspaceId, + job_id: &crank_core::AsyncJobId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + ensure_async_job_workspace(&job, workspace_id)?; + + Ok(async_job_detail_view(job)) + } + + #[instrument(skip(self))] + pub async fn cancel_async_job( + &self, + workspace_id: &WorkspaceId, + job_id: &crank_core::AsyncJobId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + ensure_async_job_workspace(&job, workspace_id)?; + self.registry + .cancel_async_job(job_id, &now_string()?) + .await?; + let updated = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + + Ok(async_job_detail_view(updated)) + } + + #[instrument(skip(self))] + pub async fn get_async_job_result( + &self, + workspace_id: &WorkspaceId, + job_id: &crank_core::AsyncJobId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| { + ApiError::not_found(format!("async job {} was not found", job_id.as_str())) + })?; + ensure_async_job_workspace(&job, workspace_id)?; + + match job.status { + JobStatus::Completed => Ok(job.result.unwrap_or(Value::Null)), + JobStatus::Failed => Err(ApiError::conflict("async job failed")), + JobStatus::Cancelled => Err(ApiError::conflict("async job was cancelled")), + _ => Err(ApiError::conflict("async job result is not ready")), + } + } + pub async fn list_operations( &self, workspace_id: &WorkspaceId, @@ -1610,6 +2004,12 @@ impl AdminService { .get_operation_version(workspace_id, operation_id, payload.version) .await?; let runtime = RuntimeOperation::from(record.snapshot.clone()); + let mode = runtime + .execution_config + .streaming + .as_ref() + .map(|streaming| streaming.mode) + .unwrap_or(ExecutionMode::Unary); let request_preview = match build_request_preview(&record.snapshot.input_mapping, &payload.input) { Ok(preview) => preview, @@ -1631,20 +2031,57 @@ impl AdminService { .await?; return Ok(TestRunResult { ok: false, + mode, request_preview: Value::Null, response_preview: Value::Null, errors: vec![crate::error::runtime_test_failure(&RuntimeError::Mapping( error, ))], + window: None, + stream_session: None, + async_job: None, }); } }; + let resolved_auth = self + .resolve_operation_auth(workspace_id, &runtime.execution_config) + .await; let started_at = std::time::Instant::now(); - match self.runtime.execute(&runtime, &payload.input).await { - Ok(output) => { + match match resolved_auth { + Ok(resolved_auth) => match mode { + ExecutionMode::Unary => self + .runtime + .execute_with_auth(&runtime, &payload.input, resolved_auth.as_ref()) + .await + .map(|output| TestRunOutcome::Unary { output }), + ExecutionMode::Window => self + .runtime + .execute_window_with_auth(&runtime, &payload.input, resolved_auth.as_ref()) + .await + .map(|output| TestRunOutcome::Window { output }), + ExecutionMode::Session => self + .start_stream_session_test( + workspace_id, + &record.snapshot, + &runtime, + &payload.input, + resolved_auth.as_ref(), + ) + .await + .map(|output| TestRunOutcome::Session { output }), + ExecutionMode::AsyncJob => self + .start_async_job_test(workspace_id, &record.snapshot, &runtime, &payload.input) + .await + .map(|output| TestRunOutcome::AsyncJob { output }), + }, + Err(error) => Err(error), + } { + Ok(outcome) => { let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + let (message, response_preview, window, stream_session, async_job) = + outcome.into_result_views(); self.record_invocation(InvocationRecordRequest { workspace_id, agent_id: None, @@ -1652,19 +2089,23 @@ impl AdminService { source: InvocationSource::AdminTestRun, level: InvocationLevel::Info, status: InvocationStatus::Ok, - message: "admin test run completed".to_owned(), + message: message.to_owned(), status_code: None, error_kind: None, duration_ms, request_preview: request_preview.clone(), - response_preview: output.clone(), + response_preview: response_preview.clone(), }) .await?; Ok(TestRunResult { ok: true, + mode, request_preview, - response_preview: output, + response_preview, errors: Vec::new(), + window, + stream_session, + async_job, }) } Err(error) => { @@ -1687,14 +2128,279 @@ impl AdminService { .await?; Ok(TestRunResult { ok: false, + mode, request_preview, response_preview: Value::Null, errors: vec![crate::error::runtime_test_failure(&error)], + window: None, + stream_session: None, + async_job: None, }) } } } + async fn resolve_operation_auth( + &self, + workspace_id: &WorkspaceId, + execution_config: &crank_core::ExecutionConfig, + ) -> Result, RuntimeError> { + let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { + return Ok(None); + }; + + let auth_profile = self + .registry + .get_auth_profile(workspace_id, auth_profile_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + })?; + + self.resolve_auth_profile(workspace_id, &auth_profile) + .await + .map(Some) + } + + async fn start_stream_session_test( + &self, + workspace_id: &WorkspaceId, + operation: &RegistryOperation, + runtime: &RuntimeOperation, + input: &Value, + resolved_auth: Option<&ResolvedAuth>, + ) -> Result { + let streaming = runtime.execution_config.streaming.as_ref().ok_or_else(|| { + RuntimeError::MissingStreamingConfig { + operation_id: runtime.operation_id.as_str().to_owned(), + } + })?; + let seed = self + .runtime + .execute_session_seed_with_auth(runtime, input, resolved_auth) + .await?; + let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize; + let preview_count = seed.items.len().min(batch_size); + let preview_items = seed.items[..preview_count].to_vec(); + let next_index = preview_count; + let created_at = now_string().map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + let expires_at = add_millis( + &created_at, + streaming.max_session_lifetime_ms.unwrap_or(60_000), + ) + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + let session = StreamSession { + id: crank_core::StreamSessionId::new(new_prefixed_id("sess")), + workspace_id: workspace_id.clone(), + agent_id: None, + operation_id: operation.id.clone(), + protocol: operation.protocol, + mode: ExecutionMode::Session, + status: StreamStatus::Running, + cursor: (next_index < seed.items.len()).then(|| json!(next_index)), + state: json!(StoredSessionState { + input: input.clone(), + summary: seed.summary.clone(), + items: seed.items.clone(), + next_index, + batch_size, + }), + expires_at: expires_at.clone(), + last_poll_at: Some(created_at.clone()), + created_at, + closed_at: None, + }; + self.registry + .create_stream_session(CreateStreamSessionRequest { session: &session }) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + + Ok(StreamSessionStartView { + session_id: session.id.as_str().to_owned(), + status: session.status, + expires_at, + poll_after_ms: streaming.poll_interval_ms.unwrap_or(1_000), + preview: json!({ + "summary": seed.summary, + "items": preview_items, + }), + }) + } + + async fn start_async_job_test( + &self, + workspace_id: &WorkspaceId, + operation: &RegistryOperation, + runtime: &RuntimeOperation, + input: &Value, + ) -> Result { + let created_at = now_string().map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + let streaming = runtime.execution_config.streaming.as_ref().ok_or_else(|| { + RuntimeError::MissingStreamingConfig { + operation_id: runtime.operation_id.as_str().to_owned(), + } + })?; + let expires_at = add_millis( + &created_at, + streaming.max_session_lifetime_ms.unwrap_or(300_000), + ) + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + let job = AsyncJobHandle { + id: crank_core::AsyncJobId::new(new_prefixed_id("job")), + workspace_id: workspace_id.clone(), + agent_id: None, + operation_id: operation.id.clone(), + status: JobStatus::Running, + progress: json!({ "pct": 0 }), + result: None, + error: None, + expires_at: Some(expires_at), + created_at: created_at.clone(), + updated_at: created_at.clone(), + finished_at: None, + }; + self.registry + .create_async_job(CreateAsyncJobRequest { job: &job }) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + + let registry = self.registry.clone(); + let secret_crypto = self.secret_crypto.clone(); + let runtime_for_task = self.runtime.clone(); + let workspace_for_task = workspace_id.clone(); + let operation_for_task = runtime.clone(); + let input_for_task = input.clone(); + let job_id = job.id.clone(); + tokio::spawn(async move { + let resolved_auth = resolve_runtime_auth_for_task( + ®istry, + &secret_crypto, + &workspace_for_task, + &operation_for_task.execution_config, + ) + .await; + let result = match resolved_auth { + Ok(resolved_auth) => { + runtime_for_task + .execute_with_auth( + &operation_for_task, + &input_for_task, + resolved_auth.as_ref(), + ) + .await + } + Err(error) => Err(error), + }; + let finished_at = match now_string() { + Ok(value) => value, + Err(_) => return, + }; + let _ = match result { + Ok(output) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Completed, + progress: &json!({ "pct": 100 }), + result: Some(&output), + error: None, + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + Err(error) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Failed, + progress: &json!({ "pct": 100 }), + result: None, + error: Some(&json!({ + "code": runtime_error_code(&error), + "message": error.to_string(), + })), + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + }; + }); + + Ok(AsyncJobStartView { + job_id: job.id.as_str().to_owned(), + status: job.status, + progress: job.progress, + }) + } + + async fn resolve_auth_profile( + &self, + workspace_id: &WorkspaceId, + auth_profile: &AuthProfile, + ) -> Result { + let mut secrets = BTreeMap::new(); + let used_at = now_string().map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + + for secret_id in auth_profile.config.secret_ids() { + let secret = self + .registry + .get_secret(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + })?; + let version = self + .registry + .get_current_secret_version(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecretVersion { + secret_id: secret_id.as_str().to_owned(), + version: secret.secret.current_version, + })?; + let plaintext = self + .secret_crypto + .decrypt(&version.secret_version.ciphertext)?; + self.registry + .touch_secret(workspace_id, secret_id, &used_at) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + secrets.insert(secret_id.clone(), plaintext); + } + + ResolvedAuth::from_profile(auth_profile, &secrets) + } + #[instrument(skip(self))] pub async fn list_auth_profiles( &self, @@ -1704,6 +2410,133 @@ impl AdminService { Ok(self.registry.list_auth_profiles(workspace_id).await?) } + #[instrument(skip(self))] + pub async fn list_secrets(&self, workspace_id: &WorkspaceId) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + Ok(self + .registry + .list_secrets(workspace_id) + .await? + .into_iter() + .map(|record| record.secret) + .collect()) + } + + #[instrument(skip(self))] + pub async fn get_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + self.registry + .get_secret(workspace_id, secret_id) + .await? + .map(|record| record.secret) + .ok_or_else(|| { + ApiError::not_found(format!("secret {} was not found", secret_id.as_str())) + }) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_name = %payload.name))] + pub async fn create_secret( + &self, + workspace_id: &WorkspaceId, + created_by: Option<&UserId>, + payload: SecretPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + validate_secret_payload(&payload)?; + + let now = now_string()?; + let secret = Secret { + id: SecretId::new(new_prefixed_id("secret")), + workspace_id: workspace_id.clone(), + name: payload.name.trim().to_owned(), + kind: payload.kind, + status: SecretStatus::Active, + current_version: 1, + created_at: now.clone(), + updated_at: now, + last_used_at: None, + }; + let ciphertext = self + .secret_crypto + .encrypt(&payload.value) + .map_err(|error| ApiError::internal(error.to_string()))?; + + self.registry + .create_secret(CreateSecretRequest { + secret: &secret, + ciphertext: &ciphertext, + key_version: self.secret_crypto.key_version(), + created_by, + }) + .await?; + info!(secret_id = %secret.id.as_str(), "secret created"); + + Ok(secret) + } + + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] + pub async fn rotate_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + created_by: Option<&UserId>, + payload: RotateSecretPayload, + ) -> Result { + self.ensure_workspace_exists(workspace_id).await?; + if payload.value.is_null() { + return Err(ApiError::validation("secret value must not be null")); + } + + let now = now_string()?; + let ciphertext = self + .secret_crypto + .encrypt(&payload.value) + .map_err(|error| ApiError::internal(error.to_string()))?; + self.registry + .rotate_secret(RotateSecretRequest { + workspace_id, + secret_id, + ciphertext: &ciphertext, + key_version: self.secret_crypto.key_version(), + created_at: &now, + updated_at: &now, + created_by, + }) + .await?; + info!(secret_id = %secret_id.as_str(), "secret rotated"); + + self.get_secret(workspace_id, secret_id).await + } + + #[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), secret_id = %secret_id.as_str()))] + pub async fn delete_secret( + &self, + workspace_id: &WorkspaceId, + secret_id: &SecretId, + ) -> Result<(), ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + if let Some(profile) = self + .registry + .list_auth_profiles_referencing_secret(workspace_id, secret_id) + .await? + .into_iter() + .next() + { + return Err(RegistryError::SecretReferencedByAuthProfile { + secret_id: secret_id.as_str().to_owned(), + auth_profile_id: profile.id.as_str().to_owned(), + } + .into()); + } + self.registry.delete_secret(workspace_id, secret_id).await?; + info!(secret_id = %secret_id.as_str(), "secret deleted"); + Ok(()) + } + #[instrument(skip(self))] pub async fn list_agents( &self, @@ -2216,6 +3049,153 @@ impl AdminService { .collect::, _>>() } + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("service.wsdl")))] + pub async fn upload_wsdl_file( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + source_name: Option<&str>, + payload: &[u8], + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + if summary.protocol != Protocol::Soap { + return Err(ApiError::validation( + "wsdl upload is only allowed for soap operations", + )); + } + + let services = + inspect_wsdl(payload).map_err(|error| ApiError::validation(error.to_string()))?; + let descriptor_id = crank_core::DescriptorId::new(new_prefixed_id("desc")); + let storage_ref = self + .storage + .write_descriptor( + operation_id, + summary.current_draft_version, + crank_registry::DescriptorKind::WsdlUpload, + &descriptor_id, + source_name, + payload, + ) + .await?; + + self.registry + .save_descriptor_metadata(SaveDescriptorMetadataRequest { + descriptor: &crank_registry::DescriptorMetadata { + id: descriptor_id.clone(), + operation_id: Some(operation_id.clone()), + version: Some(summary.current_draft_version), + descriptor_kind: crank_registry::DescriptorKind::WsdlUpload, + storage_ref, + source_name: source_name.map(ToOwned::to_owned), + package_index: Some( + serde_json::to_value(&services) + .map_err(|error| ApiError::internal(error.to_string()))?, + ), + created_at: now_string()?, + }, + }) + .await?; + info!( + operation_id = %operation_id.as_str(), + descriptor_id = %descriptor_id.as_str(), + version = summary.current_draft_version, + "wsdl uploaded" + ); + + Ok(DescriptorUploadResponse { + descriptor_id: descriptor_id.as_str().to_owned(), + version: summary.current_draft_version, + }) + } + + #[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("schema.xsd")))] + pub async fn upload_xsd_file( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + source_name: Option<&str>, + payload: &[u8], + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + if summary.protocol != Protocol::Soap { + return Err(ApiError::validation( + "xsd upload is only allowed for soap operations", + )); + } + + let descriptor_id = crank_core::DescriptorId::new(new_prefixed_id("desc")); + let storage_ref = self + .storage + .write_descriptor( + operation_id, + summary.current_draft_version, + crank_registry::DescriptorKind::XsdUpload, + &descriptor_id, + source_name, + payload, + ) + .await?; + + self.registry + .save_descriptor_metadata(SaveDescriptorMetadataRequest { + descriptor: &crank_registry::DescriptorMetadata { + id: descriptor_id.clone(), + operation_id: Some(operation_id.clone()), + version: Some(summary.current_draft_version), + descriptor_kind: crank_registry::DescriptorKind::XsdUpload, + storage_ref, + source_name: source_name.map(ToOwned::to_owned), + package_index: None, + created_at: now_string()?, + }, + }) + .await?; + info!( + operation_id = %operation_id.as_str(), + descriptor_id = %descriptor_id.as_str(), + version = summary.current_draft_version, + "xsd uploaded" + ); + + Ok(DescriptorUploadResponse { + descriptor_id: descriptor_id.as_str().to_owned(), + version: summary.current_draft_version, + }) + } + + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version))] + pub async fn list_soap_services( + &self, + workspace_id: &WorkspaceId, + operation_id: &OperationId, + version: Option, + ) -> Result { + let summary = self.get_operation(workspace_id, operation_id).await?; + if summary.protocol != Protocol::Soap { + return Err(ApiError::validation( + "soap services are only available for soap operations", + )); + } + + let version = version.unwrap_or(summary.current_draft_version); + let descriptor = self + .registry + .list_descriptor_metadata(operation_id, version) + .await? + .into_iter() + .rev() + .find(|descriptor| { + descriptor.descriptor_kind == crank_registry::DescriptorKind::WsdlUpload + }) + .ok_or_else(|| ApiError::not_found("wsdl was not uploaded"))?; + let package_index = descriptor + .package_index + .ok_or_else(|| ApiError::not_found("wsdl inspection metadata is not available"))?; + + serde_json::from_value(package_index).map_err(|error| ApiError::internal(error.to_string())) + } + #[instrument(skip(self))] pub async fn get_auth_profile( &self, @@ -2241,6 +3221,8 @@ impl AdminService { ) -> Result { validate_auth_profile_kind(payload.kind, &payload.config)?; self.ensure_workspace_exists(workspace_id).await?; + self.validate_auth_profile_secret_ids(workspace_id, &payload.config) + .await?; let now = now_string()?; let profile = AuthProfile { @@ -2264,6 +3246,18 @@ impl AdminService { Ok(profile) } + async fn validate_auth_profile_secret_ids( + &self, + workspace_id: &WorkspaceId, + config: &AuthConfig, + ) -> Result<(), ApiError> { + for secret_id in config.secret_ids() { + self.get_secret(workspace_id, secret_id).await?; + } + + Ok(()) + } + #[instrument(skip(self), fields(operation_id = %operation_id.as_str(), version = query.version.unwrap_or_default(), mode = ?query.mode))] pub async fn export_operation( &self, @@ -3224,6 +4218,8 @@ fn validate_protocol_target(protocol: Protocol, target: &Target) -> Result<(), A (Protocol::Rest, Target::Rest(_)) | (Protocol::Graphql, Target::Graphql(_)) | (Protocol::Grpc, Target::Grpc(_)) + | (Protocol::Websocket, Target::Websocket(_)) + | (Protocol::Soap, Target::Soap(_)) ); if is_match { @@ -3249,6 +4245,18 @@ fn validate_auth_profile_kind(kind: AuthKind, config: &AuthConfig) -> Result<(), Err(ApiError::validation("auth kind and config must match")) } +fn validate_secret_payload(payload: &SecretPayload) -> Result<(), ApiError> { + if payload.name.trim().is_empty() { + return Err(ApiError::validation("secret name must not be empty")); + } + + if payload.value.is_null() { + return Err(ApiError::validation("secret value must not be null")); + } + + Ok(()) +} + fn latest_sample_ref( samples: &[OperationSampleMetadata], sample_kind: SampleKind, @@ -3331,6 +4339,7 @@ fn demo_rest_operation_payload() -> OperationPayload { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Create CRM Lead".to_owned(), @@ -3384,6 +4393,7 @@ fn demo_graphql_operation_payload() -> OperationPayload { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Get Invoice Status".to_owned(), @@ -3436,7 +4446,10 @@ fn demo_grpc_operation_payload() -> OperationPayload { headers: BTreeMap::new(), protocol_options: Some(crank_core::ProtocolOptions { grpc: Some(crank_core::GrpcProtocolOptions { use_tls: false }), + websocket: None, + soap: None, }), + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Lookup Support Ticket".to_owned(), @@ -3494,6 +4507,7 @@ fn demo_archived_operation_payload() -> OperationPayload { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: crank_core::ToolDescription { title: "Archive Marketing Contact".to_owned(), @@ -3598,6 +4612,74 @@ fn now_string() -> Result { .map_err(|error| ApiError::internal(error.to_string())) } +fn add_millis(timestamp: &str, millis: u64) -> Result { + let parsed = OffsetDateTime::parse(timestamp, &Rfc3339) + .map_err(|error| ApiError::internal(error.to_string()))?; + parsed + .checked_add(time::Duration::milliseconds(millis as i64)) + .ok_or_else(|| ApiError::internal("failed to add millis to timestamp"))? + .format(&Rfc3339) + .map_err(|error| ApiError::internal(error.to_string())) +} + +async fn resolve_runtime_auth_for_task( + registry: &PostgresRegistry, + secret_crypto: &SecretCrypto, + workspace_id: &WorkspaceId, + execution_config: &crank_core::ExecutionConfig, +) -> Result, RuntimeError> { + let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { + return Ok(None); + }; + + let auth_profile = registry + .get_auth_profile(workspace_id, auth_profile_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + })?; + + let mut secrets = BTreeMap::new(); + let used_at = now_string().map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + + for secret_id in auth_profile.config.secret_ids() { + let secret = registry + .get_secret(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + })?; + let version = registry + .get_current_secret_version(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecretVersion { + secret_id: secret_id.as_str().to_owned(), + version: secret.secret.current_version, + })?; + let plaintext = secret_crypto.decrypt(&version.secret_version.ciphertext)?; + registry + .touch_secret(workspace_id, secret_id, &used_at) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + secrets.insert(secret_id.clone(), plaintext); + } + + ResolvedAuth::from_profile(&auth_profile, &secrets).map(Some) +} + fn default_invitation_expiry() -> Result { OffsetDateTime::now_utc() .checked_add(time::Duration::days(7)) @@ -3614,10 +4696,143 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "graphql_error", RuntimeError::GrpcAdapter(_) => "grpc_error", RuntimeError::RestAdapter(_) => "rest_error", + RuntimeError::SoapAdapter(_) => "soap_error", + RuntimeError::WebsocketAdapter(_) => "websocket_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", + RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", + RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", + RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error", + RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "secret_not_found" + } + RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error", + RuntimeError::SecretCrypto { .. } => "secret_crypto_error", } } +fn protocol_capability_view(protocol: Protocol) -> ProtocolCapabilityView { + let supports_execution_modes = [ + ExecutionMode::Unary, + ExecutionMode::Window, + ExecutionMode::Session, + ExecutionMode::AsyncJob, + ] + .into_iter() + .filter(|mode| protocol.supports_execution_mode(*mode)) + .collect(); + let supports_transport_behaviors = [ + TransportBehavior::RequestResponse, + TransportBehavior::ServerStream, + TransportBehavior::StatefulSession, + TransportBehavior::DeferredResult, + ] + .into_iter() + .filter(|behavior| protocol.supports_transport_behavior(*behavior)) + .collect(); + let supports_upload_artifacts = match protocol { + Protocol::Rest | Protocol::Graphql | Protocol::Websocket => Vec::new(), + Protocol::Grpc => vec!["proto".to_owned(), "descriptor_set".to_owned()], + Protocol::Soap => vec!["wsdl".to_owned(), "xsd".to_owned()], + }; + + ProtocolCapabilityView { + protocol, + supports_execution_modes, + supports_transport_behaviors, + supports_auth_kinds: vec![ + "none".to_owned(), + "bearer".to_owned(), + "basic".to_owned(), + "api_key_header".to_owned(), + "api_key_query".to_owned(), + ], + supports_upload_artifacts, + supports_cursor_path: !matches!(protocol, Protocol::Graphql), + supports_done_path: !matches!(protocol, Protocol::Graphql), + supports_aggregation_mode: vec![ + AggregationMode::RawItems, + AggregationMode::SummaryOnly, + AggregationMode::SummaryPlusSamples, + AggregationMode::Stats, + AggregationMode::LatestState, + ], + } +} + +fn stream_session_summary_view(session: StreamSession) -> StreamSessionSummaryView { + StreamSessionSummaryView { + id: session.id.as_str().to_owned(), + operation_id: session.operation_id.as_str().to_owned(), + agent_id: session.agent_id.map(|value| value.as_str().to_owned()), + protocol: session.protocol, + mode: session.mode, + status: session.status, + expires_at: session.expires_at, + last_poll_at: session.last_poll_at, + created_at: session.created_at, + closed_at: session.closed_at, + } +} + +fn stream_session_detail_view(session: StreamSession) -> StreamSessionDetailView { + StreamSessionDetailView { + cursor: session.cursor.clone(), + summary: stream_session_summary_view(session), + } +} + +fn async_job_summary_view(job: AsyncJobHandle) -> AsyncJobSummaryView { + AsyncJobSummaryView { + id: job.id.as_str().to_owned(), + operation_id: job.operation_id.as_str().to_owned(), + agent_id: job.agent_id.map(|value| value.as_str().to_owned()), + status: job.status, + progress: job.progress, + created_at: job.created_at, + updated_at: job.updated_at, + finished_at: job.finished_at, + expires_at: job.expires_at, + } +} + +fn async_job_detail_view(job: AsyncJobHandle) -> AsyncJobDetailView { + AsyncJobDetailView { + error: job.error.clone(), + summary: async_job_summary_view(job), + } +} + +fn ensure_stream_session_workspace( + session: &StreamSession, + workspace_id: &WorkspaceId, +) -> Result<(), ApiError> { + if &session.workspace_id == workspace_id { + return Ok(()); + } + + Err(ApiError::not_found(format!( + "stream session {} was not found in workspace {}", + session.id.as_str(), + workspace_id.as_str() + ))) +} + +fn ensure_async_job_workspace( + job: &AsyncJobHandle, + workspace_id: &WorkspaceId, +) -> Result<(), ApiError> { + if &job.workspace_id == workspace_id { + return Ok(()); + } + + Err(ApiError::not_found(format!( + "async job {} was not found in workspace {}", + job.id.as_str(), + workspace_id.as_str() + ))) +} + fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> { let now = OffsetDateTime::now_utc(); let (start, bucket) = match period { diff --git a/apps/admin-api/src/storage.rs b/apps/admin-api/src/storage.rs index 9e84a5d..f323f7c 100644 --- a/apps/admin-api/src/storage.rs +++ b/apps/admin-api/src/storage.rs @@ -73,6 +73,8 @@ impl LocalArtifactStorage { DescriptorKind::ReflectionSnapshot => { source_name.unwrap_or("reflection.bin").to_owned() } + DescriptorKind::WsdlUpload => source_name.unwrap_or("service.wsdl").to_owned(), + DescriptorKind::XsdUpload => source_name.unwrap_or("schema.xsd").to_owned(), }; let path = self .root diff --git a/apps/mcp-server/Cargo.toml b/apps/mcp-server/Cargo.toml index 6f419a6..3dfe7f1 100644 --- a/apps/mcp-server/Cargo.toml +++ b/apps/mcp-server/Cargo.toml @@ -12,6 +12,7 @@ crank-core = { path = "../../crates/crank-core" } crank-registry = { path = "../../crates/crank-registry" } crank-runtime = { path = "../../crates/crank-runtime" } crank-schema = { path = "../../crates/crank-schema" } +futures-util = "0.3" serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 8a980b3..321004b 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -1,4 +1,6 @@ use std::{ + collections::BTreeMap, + convert::Infallible, sync::Arc, time::{Duration, Instant}, }; @@ -10,16 +12,25 @@ use axum::{ HeaderMap, HeaderValue, StatusCode, header::{self, ACCEPT, AUTHORIZATION}, }, - response::{IntoResponse, Response}, + response::{ + IntoResponse, Response, + sse::{Event, KeepAlive, Sse}, + }, routing::get, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ - InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, - PlatformApiKeyScope, + AsyncJobHandle, AsyncJobId, AuthProfile, InvocationLevel, InvocationLog, InvocationLogId, + InvocationSource, InvocationStatus, JobStatus, PlatformApiKeyScope, SecretId, StreamSession, + StreamSessionId, StreamStatus, }; -use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool}; -use crank_runtime::{RuntimeError, RuntimeExecutor, RuntimeOperation}; +use crank_registry::{ + CreateAsyncJobRequest, CreateInvocationLogRequest, CreateStreamSessionRequest, + PostgresRegistry, PublishedAgentTool, UpdateAsyncJobStatusRequest, + UpdateStreamSessionStateRequest, +}; +use crank_runtime::{ResolvedAuth, RuntimeError, RuntimeExecutor, RuntimeOperation, SecretCrypto}; +use futures_util::stream; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -38,11 +49,18 @@ use crate::{ const HEADER_MCP_SESSION_ID: &str = "MCP-Session-Id"; const HEADER_MCP_PROTOCOL_VERSION: &str = "MCP-Protocol-Version"; +#[derive(Clone, Copy)] +enum ResponseMode { + Json, + Sse, +} + #[derive(Clone)] pub struct AppState { registry: PostgresRegistry, catalog: PublishedToolCatalog, runtime: RuntimeExecutor, + secret_crypto: SecretCrypto, sessions: SessionStore, allowed_origins: AllowedOrigins, } @@ -65,6 +83,34 @@ struct ToolCallParams { arguments: Value, } +#[derive(Clone, Copy)] +enum GeneratedToolKind { + Base, + SessionStart, + SessionPoll, + SessionStop, + AsyncJobStart, + AsyncJobStatus, + AsyncJobResult, + AsyncJobCancel, +} + +#[derive(Clone)] +struct ResolvedToolCall { + tool: PublishedAgentTool, + kind: GeneratedToolKind, +} + +#[derive(Debug, Deserialize)] +struct SessionControlArgs { + session_id: String, +} + +#[derive(Debug, Deserialize)] +struct AsyncJobControlArgs { + job_id: String, +} + #[derive(Clone, Debug, Deserialize)] struct AgentRoutePath { workspace_slug: String, @@ -75,11 +121,13 @@ pub fn build_app( registry: PostgresRegistry, refresh_interval: Duration, public_base_url: Option, + secret_crypto: SecretCrypto, ) -> Router { let state = Arc::new(AppState { registry: registry.clone(), catalog: PublishedToolCatalog::new(registry, refresh_interval), runtime: RuntimeExecutor::new(), + secret_crypto, sessions: SessionStore::new(), allowed_origins: AllowedOrigins::new(public_base_url), }); @@ -100,8 +148,53 @@ async fn health() -> Json { })) } -async fn mcp_get(Path(_path): Path) -> Response { - StatusCode::METHOD_NOT_ALLOWED.into_response() +async fn mcp_get( + Path(path): Path, + State(state): State>, + headers: HeaderMap, +) -> Response { + if let Err(status) = validate_origin(&state.allowed_origins, &headers) { + return status.into_response(); + } + + if let Err(status) = validate_get_accept_header(&headers) { + return status.into_response(); + } + + if let Err(status) = + require_platform_api_key(&state, &path, &headers, PlatformApiKeyScope::Read).await + { + return status.into_response(); + } + + let session_id = match session_id_from_headers(&headers) { + Ok(Some(session_id)) => session_id, + Ok(None) => return StatusCode::BAD_REQUEST.into_response(), + Err(status) => return status.into_response(), + }; + + let Some(session) = state.sessions.get(&session_id).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + + if session.workspace_slug != path.workspace_slug || session.agent_slug != path.agent_slug { + return StatusCode::NOT_FOUND.into_response(); + } + + if !session.initialized { + return StatusCode::BAD_REQUEST.into_response(); + } + + if let Err(status) = validate_session_protocol_version(&headers, &session.protocol_version) { + return status.into_response(); + } + + sse_response( + StatusCode::OK, + stream::pending::>(), + Some(&session_id), + Some(&session.protocol_version), + ) } async fn mcp_delete( @@ -145,9 +238,10 @@ async fn mcp_post( return status.into_response(); } - if let Err(status) = validate_accept_header(&headers) { - return status.into_response(); - } + let response_mode = match negotiate_post_response_mode(&headers) { + Ok(mode) => mode, + Err(status) => return status.into_response(), + }; if is_response(&message) || is_notification(&message) && method_name(&message).is_none() { return StatusCode::ACCEPTED.into_response(); @@ -158,6 +252,18 @@ async fn mcp_post( Err(status) => return status.into_response(), }; + if let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID) { + if let Ok(session_id) = session_id.to_str() { + if let Some(session) = state.sessions.get(session_id).await { + if let Err(status) = + validate_session_protocol_version(&headers, &session.protocol_version) + { + return status.into_response(); + } + } + } + } + let required_scope = match method_name(&message) { Some("tools/call") => PlatformApiKeyScope::Write, _ => PlatformApiKeyScope::Read, @@ -168,7 +274,7 @@ async fn mcp_post( match method_name(&message) { Some("initialize") if is_request(&message) => { - handle_initialize(state, &path, &message).await + handle_initialize(state, &path, &message, response_mode).await } Some("notifications/initialized") if is_notification(&message) => { handle_initialized_notification(state, &path, &headers).await @@ -180,14 +286,15 @@ async fn mcp_post( Err(response) => return response, }; - json_response( + transport_response( StatusCode::OK, jsonrpc_result( request_id(&message), json!({ "protocolVersion": session.protocol_version }), ), + response_mode, None, - Some(&protocol_version), + Some(&session.protocol_version), ) } Some("tools/list") if is_request(&message) => { @@ -203,11 +310,12 @@ async fn mcp_post( .await { Ok(tools) => { - let definitions = tools.iter().map(tool_definition).collect::>(); + let definitions = tools.iter().flat_map(tool_definitions).collect::>(); - json_response( + transport_response( StatusCode::OK, jsonrpc_result(request_id(&message), json!({ "tools": definitions })), + response_mode, None, Some(&session.protocol_version), ) @@ -224,9 +332,10 @@ async fn mcp_post( let tool_call_params: ToolCallParams = match serde_json::from_value(params(&message)) { Ok(value) => value, Err(error) => { - return json_response( + return transport_response( StatusCode::OK, jsonrpc_error(request_id(&message), -32602, error.to_string()), + response_mode, None, Some(&session.protocol_version), ); @@ -241,107 +350,33 @@ async fn mcp_post( match state .catalog - .get_tool( - &session.workspace_slug, - &session.agent_slug, - &tool_call_params.name, - ) + .list_tools(&session.workspace_slug, &session.agent_slug) .await { - Ok(Some(tool)) => { - let runtime_operation = runtime_operation(&tool); - let request_preview = - build_request_preview(&state.runtime, &runtime_operation, &arguments); - let started_at = Instant::now(); - - match state.runtime.execute(&runtime_operation, &arguments).await { - Ok(output) => json_response( - StatusCode::OK, - { - let _ = persist_invocation( - &state, - &tool, - InvocationRecord { - status: InvocationStatus::Ok, - level: InvocationLevel::Info, - message: "agent tool call completed", - status_code: None, - error_kind: None, - duration: started_at.elapsed(), - request_preview, - response_preview: output.clone(), - }, - ) - .await; - jsonrpc_result( - request_id(&message), - json!({ - "content": [ - { - "type": "text", - "text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned()) - } - ], - "structuredContent": output, - "isError": false - }), - ) - }, - None, - Some(&session.protocol_version), - ), - Err(error) => { - let _ = persist_invocation( - &state, - &tool, - InvocationRecord { - status: InvocationStatus::Error, - level: InvocationLevel::Error, - message: &error.to_string(), - status_code: None, - error_kind: Some(runtime_error_code(&error)), - duration: started_at.elapsed(), - request_preview, - response_preview: Value::Null, - }, - ) - .await; - json_response( - StatusCode::OK, - jsonrpc_result( - request_id(&message), - json!({ - "content": [ - { - "type": "text", - "text": error.to_string() - } - ], - "structuredContent": { - "error": { - "code": runtime_error_code(&error), - "message": error.to_string() - } - }, - "isError": true - }), - ), - None, - Some(&session.protocol_version), - ) - } + Ok(tools) => match resolve_generated_tool(&tools, &tool_call_params.name) { + Some(resolved) => { + handle_tool_call( + state.clone(), + &session, + &message, + response_mode, + resolved, + arguments, + ) + .await } - } - Ok(None) => json_response( - StatusCode::OK, - jsonrpc_error( - request_id(&message), - -32602, - format!("tool {} was not found", tool_call_params.name), + None => transport_response( + StatusCode::OK, + jsonrpc_error( + request_id(&message), + -32602, + format!("tool {} was not found", tool_call_params.name), + ), + response_mode, + None, + Some(&session.protocol_version), ), - None, - Some(&session.protocol_version), - ), + }, Err(error) => internal_jsonrpc_error(&message, error), } } @@ -349,36 +384,1072 @@ async fn mcp_post( let _ = method; StatusCode::ACCEPTED.into_response() } - Some(method) => json_response( + Some(method) => transport_response( StatusCode::OK, jsonrpc_error( request_id(&message), -32601, format!("method {method} is not supported"), ), + response_mode, None, Some(&protocol_version), ), - None => json_response( + None => transport_response( StatusCode::BAD_REQUEST, jsonrpc_error(Value::Null, -32600, "invalid JSON-RPC message"), + response_mode, None, Some(&protocol_version), ), } } +async fn handle_tool_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + resolved: ResolvedToolCall, + arguments: Value, +) -> Response { + match resolved.kind { + GeneratedToolKind::Base => { + handle_base_tool_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::SessionStart => { + handle_session_start_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::SessionPoll => { + handle_session_poll_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::SessionStop => { + handle_session_stop_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobStart => { + handle_async_job_start_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobStatus => { + handle_async_job_status_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobResult => { + handle_async_job_result_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + GeneratedToolKind::AsyncJobCancel => { + handle_async_job_cancel_call( + state, + session, + message, + response_mode, + resolved.tool, + arguments, + ) + .await + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct StoredSessionState { + input: Value, + summary: Value, + items: Vec, + next_index: usize, + batch_size: usize, +} + +async fn resolve_operation_auth( + state: &Arc, + workspace_id: &crank_core::WorkspaceId, + execution_config: &crank_core::ExecutionConfig, +) -> Result, RuntimeError> { + resolve_runtime_auth_for_task( + &state.registry, + &state.secret_crypto, + workspace_id, + execution_config, + ) + .await +} + +async fn resolve_runtime_auth_for_task( + registry: &PostgresRegistry, + secret_crypto: &SecretCrypto, + workspace_id: &crank_core::WorkspaceId, + execution_config: &crank_core::ExecutionConfig, +) -> Result, RuntimeError> { + let Some(auth_profile_id) = execution_config.auth_profile_ref.as_ref() else { + return Ok(None); + }; + + let auth_profile = registry + .get_auth_profile(workspace_id, auth_profile_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingAuthProfile { + auth_profile_id: auth_profile_id.as_str().to_owned(), + })?; + + resolve_auth_profile(registry, secret_crypto, workspace_id, &auth_profile) + .await + .map(Some) +} + +async fn resolve_auth_profile( + registry: &PostgresRegistry, + secret_crypto: &SecretCrypto, + workspace_id: &crank_core::WorkspaceId, + auth_profile: &AuthProfile, +) -> Result { + let mut secrets = BTreeMap::new(); + let used_at = now_rfc3339(); + + for secret_id in auth_profile.config.secret_ids() { + let secret = registry + .get_secret(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecret { + secret_id: secret_id.as_str().to_owned(), + })?; + let version = registry + .get_current_secret_version(workspace_id, secret_id) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })? + .ok_or_else(|| RuntimeError::MissingSecretVersion { + secret_id: secret_id.as_str().to_owned(), + version: secret.secret.current_version, + })?; + let plaintext = secret_crypto.decrypt(&version.secret_version.ciphertext)?; + registry + .touch_secret(workspace_id, secret_id, &used_at) + .await + .map_err(|error| RuntimeError::SecretCrypto { + details: error.to_string(), + })?; + secrets.insert(SecretId::new(secret_id.as_str()), plaintext); + } + + ResolvedAuth::from_profile(auth_profile, &secrets) +} + +async fn handle_base_tool_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let operation = runtime_operation(&tool); + let request_preview = build_request_preview(&state.runtime, &operation, &arguments); + let started_at = Instant::now(); + let is_window_mode = matches!( + operation + .execution_config + .streaming + .as_ref() + .map(|streaming| streaming.mode), + Some(crank_core::ExecutionMode::Window) + ); + let resolved_auth = + resolve_operation_auth(&state, &tool.workspace_id, &operation.execution_config).await; + + let result = match resolved_auth { + Ok(resolved_auth) if is_window_mode => state + .runtime + .execute_window_with_auth(&operation, &arguments, resolved_auth.as_ref()) + .await + .map(|output| { + json!({ + "summary": output.summary, + "items": output.items, + "cursor": output.cursor, + "window_complete": output.window_complete, + "truncated": output.truncated, + "has_more": output.has_more, + }) + }), + Ok(resolved_auth) => { + state + .runtime + .execute_with_auth(&operation, &arguments, resolved_auth.as_ref()) + .await + } + Err(error) => Err(error), + }; + + match result { + Ok(output) => { + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: &tool.tool_name, + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "agent tool call completed", + status_code: None, + error_kind: None, + duration: started_at.elapsed(), + request_preview, + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) + } + Err(error) => { + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: &tool.tool_name, + status: InvocationStatus::Error, + level: InvocationLevel::Error, + message: &error.to_string(), + status_code: None, + error_kind: Some(runtime_error_code(&error)), + duration: started_at.elapsed(), + request_preview, + response_preview: Value::Null, + }, + ) + .await; + + tool_error_response( + message, + response_mode, + &session.protocol_version, + runtime_error_code(&error), + error.to_string(), + ) + } + } +} + +async fn handle_session_start_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let runtime_operation = runtime_operation(&tool); + let request_preview = build_request_preview(&state.runtime, &runtime_operation, &arguments); + let started_at = Instant::now(); + let Some(streaming) = runtime_operation.execution_config.streaming.as_ref() else { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_config_error", + "streaming config is required for session tools".to_owned(), + ); + }; + + let resolved_auth = resolve_operation_auth( + &state, + &tool.workspace_id, + &runtime_operation.execution_config, + ) + .await; + match match resolved_auth { + Ok(resolved_auth) => { + state + .runtime + .execute_session_seed_with_auth( + &runtime_operation, + &arguments, + resolved_auth.as_ref(), + ) + .await + } + Err(error) => Err(error), + } { + Ok(seed) => { + let batch_size = streaming.max_items.unwrap_or(10).max(1) as usize; + let preview_count = seed.items.len().min(batch_size); + let preview_items = seed.items[..preview_count].to_vec(); + let next_index = preview_count; + let session_id = + StreamSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple())); + let now = now_rfc3339(); + let expires_at = add_millis(&now, streaming.max_session_lifetime_ms.unwrap_or(60_000)); + let session_record = StreamSession { + id: session_id.clone(), + workspace_id: tool.workspace_id.clone(), + agent_id: Some(tool.agent_id.clone()), + operation_id: tool.operation.id.clone(), + protocol: tool.operation.protocol, + mode: crank_core::ExecutionMode::Session, + status: StreamStatus::Running, + cursor: (next_index < seed.items.len()).then(|| json!(next_index)), + state: json!(StoredSessionState { + input: arguments.clone(), + summary: seed.summary.clone(), + items: seed.items.clone(), + next_index, + batch_size, + }), + expires_at: expires_at.clone(), + last_poll_at: Some(now.clone()), + created_at: now.clone(), + closed_at: None, + }; + + if let Err(error) = state + .registry + .create_stream_session(CreateStreamSessionRequest { + session: &session_record, + }) + .await + { + return internal_jsonrpc_error(message, error); + } + + let output = json!({ + "session_id": session_id.as_str(), + "status": "running", + "expires_at": expires_at, + "poll_after_ms": streaming.poll_interval_ms.unwrap_or(1000), + "preview": { + "summary": seed.summary, + "items": preview_items, + } + }); + + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .start_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "stream session started", + status_code: None, + error_kind: None, + duration: started_at.elapsed(), + request_preview, + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) + } + Err(error) => { + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .start_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Error, + level: InvocationLevel::Error, + message: &error.to_string(), + status_code: None, + error_kind: Some(runtime_error_code(&error)), + duration: started_at.elapsed(), + request_preview, + response_preview: Value::Null, + }, + ) + .await; + + tool_error_response( + message, + response_mode, + &session.protocol_version, + runtime_error_code(&error), + error.to_string(), + ) + } + } +} + +async fn handle_session_poll_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: SessionControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_config_error", + "streaming config is required for session tools".to_owned(), + ); + }; + + let now = now_rfc3339(); + let loaded = match state + .registry + .get_stream_session(&StreamSessionId::new(control.session_id.clone())) + .await + { + Ok(Some(session_record)) => session_record, + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_not_found", + format!("stream session {} was not found", control.session_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + if !stream_session_belongs_to_tool(&loaded, &tool) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_not_found", + format!("stream session {} was not found", control.session_id), + ); + } + + if loaded.is_expired(&now) { + let _ = state + .registry + .update_stream_session_state(UpdateStreamSessionStateRequest { + session_id: &loaded.id, + current_status: loaded.status, + next_status: StreamStatus::Expired, + cursor: loaded.cursor.as_ref(), + state: &loaded.state, + expires_at: None, + last_poll_at: Some(&now), + closed_at: Some(&now), + }) + .await; + + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_expired", + format!("stream session {} has expired", control.session_id), + ); + } + + if !loaded.can_poll(&now) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_not_found", + format!("stream session {} is not running", control.session_id), + ); + } + + let mut state_payload: StoredSessionState = match serde_json::from_value(loaded.state.clone()) { + Ok(value) => value, + Err(error) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_payload_error", + error.to_string(), + ); + } + }; + + let next_end = + (state_payload.next_index + state_payload.batch_size).min(state_payload.items.len()); + let items = state_payload.items[state_payload.next_index..next_end].to_vec(); + state_payload.next_index = next_end; + let has_more = state_payload.next_index < state_payload.items.len(); + let next_status = if has_more { + StreamStatus::Running + } else { + StreamStatus::Stopped + }; + let cursor = has_more.then(|| json!(state_payload.next_index)); + + let updated = match state + .registry + .update_stream_session_state(UpdateStreamSessionStateRequest { + session_id: &loaded.id, + current_status: loaded.status, + next_status, + cursor: cursor.as_ref(), + state: &json!(state_payload), + expires_at: Some(&add_millis( + &now, + streaming.max_session_lifetime_ms.unwrap_or(60_000), + )), + last_poll_at: Some(&now), + closed_at: matches!(next_status, StreamStatus::Stopped).then_some(now.as_str()), + }) + .await + { + Ok(session_record) => session_record, + Err(error) => return internal_jsonrpc_error(message, error), + }; + + let output = json!({ + "session_id": updated.id.as_str(), + "status": serialize_stream_status(updated.status), + "expires_at": updated.expires_at, + "summary": serde_json::from_value::(updated.state.clone()).map(|value| value.summary).unwrap_or(Value::Null), + "items": items, + "cursor": updated.cursor, + "has_more": updated.status == StreamStatus::Running, + }); + + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .poll_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "stream session polled", + status_code: None, + error_kind: None, + duration: Duration::ZERO, + request_preview: json!({ "session_id": control.session_id }), + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) +} + +async fn handle_session_stop_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: SessionControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + let loaded = match state + .registry + .get_stream_session(&StreamSessionId::new(control.session_id.clone())) + .await + { + Ok(Some(session_record)) => session_record, + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_not_found", + format!("stream session {} was not found", control.session_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + if !stream_session_belongs_to_tool(&loaded, &tool) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "stream_session_not_found", + format!("stream session {} was not found", control.session_id), + ); + } + + match state + .registry + .close_stream_session( + &StreamSessionId::new(control.session_id.clone()), + &now_rfc3339(), + ) + .await + { + Ok(()) => success_tool_response( + message, + response_mode, + &session.protocol_version, + json!({ + "session_id": control.session_id, + "status": "stopped" + }), + ), + Err(error) => internal_jsonrpc_error(message, error), + } +} + +async fn handle_async_job_start_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let operation = runtime_operation(&tool); + let request_preview = build_request_preview(&state.runtime, &operation, &arguments); + let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "streaming_config_error", + "streaming config is required for async job tools".to_owned(), + ); + }; + + let now = now_rfc3339(); + let job = AsyncJobHandle { + id: AsyncJobId::new(format!("job_{}", uuid::Uuid::now_v7().simple())), + workspace_id: tool.workspace_id.clone(), + agent_id: Some(tool.agent_id.clone()), + operation_id: tool.operation.id.clone(), + status: JobStatus::Running, + progress: json!({ "pct": 0 }), + result: None, + error: None, + expires_at: Some(add_millis( + &now, + streaming.max_session_lifetime_ms.unwrap_or(300_000), + )), + created_at: now.clone(), + updated_at: now.clone(), + finished_at: None, + }; + + if let Err(error) = state + .registry + .create_async_job(CreateAsyncJobRequest { job: &job }) + .await + { + return internal_jsonrpc_error(message, error); + } + + let registry = state.registry.clone(); + let secret_crypto = state.secret_crypto.clone(); + let tool_for_task = tool.clone(); + let arguments_for_task = arguments.clone(); + let job_id = job.id.clone(); + tokio::spawn(async move { + let runtime = RuntimeExecutor::new(); + let task_operation = runtime_operation(&tool_for_task); + let resolved_auth = resolve_runtime_auth_for_task( + ®istry, + &secret_crypto, + &tool_for_task.workspace_id, + &task_operation.execution_config, + ) + .await; + let result = match resolved_auth { + Ok(resolved_auth) => { + runtime + .execute_with_auth(&task_operation, &arguments_for_task, resolved_auth.as_ref()) + .await + } + Err(error) => Err(error), + }; + let finished_at = now_rfc3339(); + + let update_result = match result { + Ok(output) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Completed, + progress: &json!({ "pct": 100 }), + result: Some(&output), + error: None, + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + Err(error) => { + registry + .update_async_job_status(UpdateAsyncJobStatusRequest { + job_id: &job_id, + current_status: JobStatus::Running, + next_status: JobStatus::Failed, + progress: &json!({ "pct": 100 }), + result: None, + error: Some(&json!({ + "code": runtime_error_code(&error), + "message": error.to_string() + })), + expires_at: None, + updated_at: &finished_at, + finished_at: Some(&finished_at), + }) + .await + } + }; + + let _ = update_result; + }); + + let output = json!({ + "job_id": job.id.as_str(), + "status": "running", + "progress": job.progress, + }); + + let _ = persist_invocation( + &state, + &tool, + InvocationRecord { + tool_name: streaming + .tool_family + .start_tool_name + .as_deref() + .unwrap_or(&tool.tool_name), + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "async job started", + status_code: None, + error_kind: None, + duration: Duration::ZERO, + request_preview, + response_preview: output.clone(), + }, + ) + .await; + + success_tool_response(message, response_mode, &session.protocol_version, output) +} + +async fn handle_async_job_status_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + _tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: AsyncJobControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + let job = match state + .registry + .get_async_job(&AsyncJobId::new(control.job_id.clone())) + .await + { + Ok(Some(job)) => job, + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + if !async_job_belongs_to_tool(&job, &_tool) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + + success_tool_response( + message, + response_mode, + &session.protocol_version, + json!({ + "job_id": job.id.as_str(), + "status": serialize_job_status(job.status), + "progress": job.progress, + "updated_at": job.updated_at, + "finished_at": job.finished_at, + }), + ) +} + +async fn handle_async_job_result_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + _tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: AsyncJobControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + let job = match state + .registry + .get_async_job(&AsyncJobId::new(control.job_id.clone())) + .await + { + Ok(Some(job)) => job, + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + if !async_job_belongs_to_tool(&job, &_tool) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + + match job.status { + JobStatus::Completed => success_tool_response( + message, + response_mode, + &session.protocol_version, + job.result.unwrap_or(Value::Null), + ), + JobStatus::Failed => tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_failed", + job.error + .and_then(|value| value.get("message").cloned()) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "async job failed".to_owned()), + ), + JobStatus::Cancelled => tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_cancelled", + "async job was cancelled".to_owned(), + ), + _ => tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_ready", + "async job result is not ready".to_owned(), + ), + } +} + +async fn handle_async_job_cancel_call( + state: Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + _tool: PublishedAgentTool, + arguments: Value, +) -> Response { + let control: AsyncJobControlArgs = match serde_json::from_value(arguments.clone()) { + Ok(value) => value, + Err(error) => { + return transport_response( + StatusCode::OK, + jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, + None, + Some(&session.protocol_version), + ); + } + }; + + match state + .registry + .get_async_job(&AsyncJobId::new(control.job_id.clone())) + .await + { + Ok(Some(job)) => { + if !async_job_belongs_to_tool(&job, &_tool) { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + } + Ok(None) => { + return tool_error_response( + message, + response_mode, + &session.protocol_version, + "async_job_not_found", + format!("async job {} was not found", control.job_id), + ); + } + Err(error) => return internal_jsonrpc_error(message, error), + }; + + match state + .registry + .cancel_async_job(&AsyncJobId::new(control.job_id.clone()), &now_rfc3339()) + .await + { + Ok(()) => success_tool_response( + message, + response_mode, + &session.protocol_version, + json!({ + "job_id": control.job_id, + "status": "cancelled" + }), + ), + Err(error) => internal_jsonrpc_error(message, error), + } +} + async fn handle_initialize( state: Arc, path: &AgentRoutePath, message: &Value, + response_mode: ResponseMode, ) -> Response { let initialize_params: InitializeParams = match serde_json::from_value(params(message)) { Ok(value) => value, Err(error) => { - return json_response( + return transport_response( StatusCode::OK, jsonrpc_error(request_id(message), -32602, error.to_string()), + response_mode, None, Some(DEFAULT_PROTOCOL_VERSION), ); @@ -386,7 +1457,7 @@ async fn handle_initialize( }; let Some(protocol_version) = negotiated_protocol_version(&initialize_params.protocol_version) else { - return json_response( + return transport_response( StatusCode::OK, jsonrpc_error( request_id(message), @@ -396,6 +1467,7 @@ async fn handle_initialize( initialize_params.protocol_version ), ), + response_mode, None, Some(DEFAULT_PROTOCOL_VERSION), ); @@ -405,7 +1477,7 @@ async fn handle_initialize( .create(protocol_version, &path.workspace_slug, &path.agent_slug) .await; - json_response( + transport_response( StatusCode::OK, jsonrpc_result( request_id(message), @@ -422,6 +1494,7 @@ async fn handle_initialize( } }), ), + response_mode, Some(&session_id), Some(protocol_version), ) @@ -567,22 +1640,90 @@ fn validate_origin( Err(StatusCode::FORBIDDEN) } -fn validate_accept_header(headers: &HeaderMap) -> Result<(), StatusCode> { +fn negotiate_post_response_mode(headers: &HeaderMap) -> Result { let Some(accept) = headers.get(ACCEPT) else { return Err(StatusCode::BAD_REQUEST); }; let Ok(accept) = accept.to_str() else { return Err(StatusCode::BAD_REQUEST); }; - let normalized = accept.to_ascii_lowercase(); - if normalized.contains("application/json") && normalized.contains("text/event-stream") { + let mut saw_json = false; + let mut saw_sse = false; + let mut preferred = None; + + for part in accept.split(',') { + let media_type = part + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + + match media_type.as_str() { + "application/json" => { + saw_json = true; + if preferred.is_none() { + preferred = Some(ResponseMode::Json); + } + } + "text/event-stream" => { + saw_sse = true; + if preferred.is_none() { + preferred = Some(ResponseMode::Sse); + } + } + _ => {} + } + } + + if saw_json || saw_sse { + return preferred.ok_or(StatusCode::NOT_ACCEPTABLE); + } + + Err(StatusCode::NOT_ACCEPTABLE) +} + +fn validate_get_accept_header(headers: &HeaderMap) -> Result<(), StatusCode> { + let Some(accept) = headers.get(ACCEPT) else { + return Err(StatusCode::BAD_REQUEST); + }; + let Ok(accept) = accept.to_str() else { + return Err(StatusCode::BAD_REQUEST); + }; + + if accept + .split(',') + .map(|part| { + part.split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + }) + .any(|media_type| media_type == "text/event-stream") + { return Ok(()); } Err(StatusCode::NOT_ACCEPTABLE) } +fn stream_session_belongs_to_tool( + session_record: &StreamSession, + tool: &PublishedAgentTool, +) -> bool { + session_record.workspace_id == tool.workspace_id + && session_record.agent_id.as_ref() == Some(&tool.agent_id) + && session_record.operation_id == tool.operation.id +} + +fn async_job_belongs_to_tool(job: &AsyncJobHandle, tool: &PublishedAgentTool) -> bool { + job.workspace_id == tool.workspace_id + && job.agent_id.as_ref() == Some(&tool.agent_id) + && job.operation_id == tool.operation.id +} + fn protocol_version_from_headers(headers: &HeaderMap) -> Result { let Some(version) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { return Ok(DEFAULT_PROTOCOL_VERSION.to_owned()); @@ -598,6 +1739,28 @@ fn protocol_version_from_headers(headers: &HeaderMap) -> Result Result<(), StatusCode> { + let Some(version) = headers.get(HEADER_MCP_PROTOCOL_VERSION) else { + return Ok(()); + }; + let Ok(version) = version.to_str() else { + return Err(StatusCode::BAD_REQUEST); + }; + + if negotiated_protocol_version(version).is_none() { + return Err(StatusCode::BAD_REQUEST); + } + + if version != negotiated_session_version { + return Err(StatusCode::BAD_REQUEST); + } + + Ok(()) +} + fn session_id_from_headers(headers: &HeaderMap) -> Result, StatusCode> { let Some(session_id) = headers.get(HEADER_MCP_SESSION_ID) else { return Ok(None); @@ -610,9 +1773,10 @@ fn session_id_from_headers(headers: &HeaderMap) -> Result, Status } fn internal_jsonrpc_error(message: &Value, error: impl std::fmt::Display) -> Response { - json_response( + transport_response( StatusCode::INTERNAL_SERVER_ERROR, jsonrpc_error(request_id(message), -32603, error.to_string()), + ResponseMode::Json, None, Some(DEFAULT_PROTOCOL_VERSION), ) @@ -637,6 +1801,7 @@ fn build_request_preview( } struct InvocationRecord<'a> { + tool_name: &'a str, status: InvocationStatus, level: InvocationLevel, message: &'a str, @@ -664,7 +1829,7 @@ async fn persist_invocation( source: InvocationSource::AgentToolCall, level: record.level, status: record.status, - tool_name: tool.tool_name.clone(), + tool_name: record.tool_name.to_owned(), message: record.message.to_owned(), request_id: None, status_code: record.status_code, @@ -681,6 +1846,104 @@ async fn persist_invocation( .await } +fn success_tool_response( + message: &Value, + response_mode: ResponseMode, + protocol_version: &str, + output: Value, +) -> Response { + transport_response( + StatusCode::OK, + jsonrpc_result( + request_id(message), + json!({ + "content": [ + { + "type": "text", + "text": serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_owned()) + } + ], + "structuredContent": output, + "isError": false + }), + ), + response_mode, + None, + Some(protocol_version), + ) +} + +fn tool_error_response( + message: &Value, + response_mode: ResponseMode, + protocol_version: &str, + code: &str, + error_message: String, +) -> Response { + transport_response( + StatusCode::OK, + jsonrpc_result( + request_id(message), + json!({ + "content": [ + { + "type": "text", + "text": error_message + } + ], + "structuredContent": { + "error": { + "code": code, + "message": error_message + } + }, + "isError": true + }), + ), + response_mode, + None, + Some(protocol_version), + ) +} + +fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) +} + +fn add_millis(timestamp: &str, millis: u64) -> String { + let Ok(parsed) = OffsetDateTime::parse(timestamp, &Rfc3339) else { + return timestamp.to_owned(); + }; + let delta = time::Duration::milliseconds(i64::try_from(millis).unwrap_or(i64::MAX)); + + (parsed + delta) + .format(&Rfc3339) + .unwrap_or_else(|_| timestamp.to_owned()) +} + +fn serialize_stream_status(status: StreamStatus) -> &'static str { + match status { + StreamStatus::Created => "created", + StreamStatus::Running => "running", + StreamStatus::Stopped => "stopped", + StreamStatus::Failed => "failed", + StreamStatus::Expired => "expired", + } +} + +fn serialize_job_status(status: JobStatus) -> &'static str { + match status { + JobStatus::Created => "created", + JobStatus::Running => "running", + JobStatus::Completed => "completed", + JobStatus::Failed => "failed", + JobStatus::Cancelled => "cancelled", + JobStatus::Expired => "expired", + } +} + fn runtime_error_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "schema_validation_error", @@ -688,8 +1951,19 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { RuntimeError::GraphqlAdapter(_) => "adapter_execution_error", RuntimeError::GrpcAdapter(_) => "adapter_execution_error", RuntimeError::RestAdapter(_) => "adapter_execution_error", + RuntimeError::SoapAdapter(_) => "adapter_execution_error", + RuntimeError::WebsocketAdapter(_) => "adapter_execution_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", + RuntimeError::MissingStreamingConfig { .. } => "streaming_config_error", + RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error", RuntimeError::InvalidPreparedRequest { .. } => "runtime_error", + RuntimeError::InvalidStreamingPayload { .. } => "streaming_payload_error", + RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found", + RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { + "secret_not_found" + } + RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error", + RuntimeError::SecretCrypto { .. } => "secret_crypto_error", } } @@ -725,15 +1999,240 @@ fn json_response( response } -fn tool_definition(tool: &PublishedAgentTool) -> Value { +fn transport_response( + status: StatusCode, + payload: Value, + response_mode: ResponseMode, + session_id: Option<&str>, + protocol_version: Option<&str>, +) -> Response { + if status == StatusCode::OK && matches!(response_mode, ResponseMode::Sse) { + let payload = payload.to_string(); + let stream = stream::once(async move { Ok(Event::default().data(payload)) }); + + return sse_response(status, stream, session_id, protocol_version); + } + + json_response(status, payload, session_id, protocol_version) +} + +fn sse_response( + status: StatusCode, + stream: S, + session_id: Option<&str>, + protocol_version: Option<&str>, +) -> Response +where + S: futures_util::stream::Stream> + Send + 'static, +{ + let mut response = ( + status, + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))), + ) + .into_response(); + + if let Some(session_id) = session_id { + response.headers_mut().insert( + HEADER_MCP_SESSION_ID, + HeaderValue::from_str(session_id) + .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + ); + } + + if let Some(protocol_version) = protocol_version { + response.headers_mut().insert( + HEADER_MCP_PROTOCOL_VERSION, + HeaderValue::from_str(protocol_version) + .unwrap_or_else(|_| HeaderValue::from_static(CURRENT_PROTOCOL_VERSION)), + ); + } + + response +} + +fn tool_definitions(tool: &PublishedAgentTool) -> Vec { + let mut definitions = Vec::new(); + let operation = &tool.operation; + + match operation + .execution_config + .streaming + .as_ref() + .map(|value| value.mode) + { + Some(crank_core::ExecutionMode::Session) => { + let streaming = operation + .execution_config + .streaming + .as_ref() + .expect("streaming"); + if let (Some(start), Some(poll), Some(stop)) = ( + streaming.tool_family.start_tool_name.as_ref(), + streaming.tool_family.poll_tool_name.as_ref(), + streaming.tool_family.stop_tool_name.as_ref(), + ) { + definitions.push(tool_definition( + start, + &format!("{} Start", tool.tool_title), + &format!("Start session for {}", tool.tool_description), + schema_to_json_schema(&operation.input_schema), + )); + definitions.push(tool_definition( + poll, + &format!("{} Poll", tool.tool_title), + &format!("Poll session for {}", tool.tool_description), + id_input_schema("session_id"), + )); + definitions.push(tool_definition( + stop, + &format!("{} Stop", tool.tool_title), + &format!("Stop session for {}", tool.tool_description), + id_input_schema("session_id"), + )); + } + } + Some(crank_core::ExecutionMode::AsyncJob) => { + let streaming = operation + .execution_config + .streaming + .as_ref() + .expect("streaming"); + if let (Some(start), Some(status), Some(result), Some(cancel)) = ( + streaming.tool_family.start_tool_name.as_ref(), + streaming.tool_family.status_tool_name.as_ref(), + streaming.tool_family.result_tool_name.as_ref(), + streaming.tool_family.cancel_tool_name.as_ref(), + ) { + definitions.push(tool_definition( + start, + &format!("{} Start", tool.tool_title), + &format!("Start async job for {}", tool.tool_description), + schema_to_json_schema(&operation.input_schema), + )); + definitions.push(tool_definition( + status, + &format!("{} Status", tool.tool_title), + &format!("Get job status for {}", tool.tool_description), + id_input_schema("job_id"), + )); + definitions.push(tool_definition( + result, + &format!("{} Result", tool.tool_title), + &format!("Get async job result for {}", tool.tool_description), + id_input_schema("job_id"), + )); + definitions.push(tool_definition( + cancel, + &format!("{} Cancel", tool.tool_title), + &format!("Cancel async job for {}", tool.tool_description), + id_input_schema("job_id"), + )); + } + } + _ => { + definitions.push(tool_definition( + &tool.tool_name, + &tool.tool_title, + &tool.tool_description, + schema_to_json_schema(&operation.input_schema), + )); + } + } + + definitions +} + +fn tool_definition(name: &str, title: &str, description: &str, input_schema: Value) -> Value { json!({ - "name": tool.tool_name, - "title": tool.tool_title, - "description": tool.tool_description, - "inputSchema": schema_to_json_schema(&tool.operation.input_schema) + "name": name, + "title": title, + "description": description, + "inputSchema": input_schema }) } +fn id_input_schema(field_name: &str) -> Value { + json!({ + "type": "object", + "properties": { + field_name: { + "type": "string" + } + }, + "required": [field_name] + }) +} + +fn resolve_generated_tool( + tools: &[PublishedAgentTool], + tool_name: &str, +) -> Option { + for tool in tools { + if tool.tool_name == tool_name { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::Base, + }); + } + + let Some(streaming) = tool.operation.execution_config.streaming.as_ref() else { + continue; + }; + + match streaming.mode { + crank_core::ExecutionMode::Session => { + if streaming.tool_family.start_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::SessionStart, + }); + } + if streaming.tool_family.poll_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::SessionPoll, + }); + } + if streaming.tool_family.stop_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::SessionStop, + }); + } + } + crank_core::ExecutionMode::AsyncJob => { + if streaming.tool_family.start_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobStart, + }); + } + if streaming.tool_family.status_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobStatus, + }); + } + if streaming.tool_family.result_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobResult, + }); + } + if streaming.tool_family.cancel_tool_name.as_deref() == Some(tool_name) { + return Some(ResolvedToolCall { + tool: tool.clone(), + kind: GeneratedToolKind::AsyncJobCancel, + }); + } + } + _ => {} + } + } + + None +} + fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation { let mut operation = RuntimeOperation::from(tool.operation.clone()); operation.tool_name = tool.tool_name.clone(); diff --git a/apps/mcp-server/src/catalog.rs b/apps/mcp-server/src/catalog.rs index 3a2df58..02521b9 100644 --- a/apps/mcp-server/src/catalog.rs +++ b/apps/mcp-server/src/catalog.rs @@ -25,7 +25,6 @@ struct CatalogKey { struct CachedCatalog { loaded_at: Option, tools: Vec, - tools_by_name: HashMap, } impl PublishedToolCatalog { @@ -50,20 +49,6 @@ impl PublishedToolCatalog { .unwrap_or_default()) } - pub async fn get_tool( - &self, - workspace_slug: &str, - agent_slug: &str, - tool_name: &str, - ) -> Result, RegistryError> { - self.refresh_if_stale(workspace_slug, agent_slug).await?; - let guard = self.cached.read().await; - Ok(guard - .get(&CatalogKey::new(workspace_slug, agent_slug)) - .and_then(|entry| entry.tools_by_name.get(tool_name)) - .cloned()) - } - async fn refresh_if_stale( &self, workspace_slug: &str, @@ -92,11 +77,6 @@ impl PublishedToolCatalog { Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(), Err(error) => return Err(error), }; - let tools_by_name = tools - .iter() - .cloned() - .map(|tool| (tool.tool_name.clone(), tool)) - .collect::>(); let mut guard = self.cached.write().await; let previous_count = guard .get(&key) @@ -108,7 +88,6 @@ impl PublishedToolCatalog { CachedCatalog { loaded_at: Some(Instant::now()), tools, - tools_by_name, }, ); diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index 12617a3..330419c 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -6,6 +6,7 @@ mod session; use std::{env, net::SocketAddr, time::Duration}; use crank_registry::PostgresRegistry; +use crank_runtime::SecretCrypto; use tokio::net::TcpListener; use tracing::info; @@ -30,7 +31,8 @@ async fn main() -> Result<(), Box> { .unwrap_or_else(|| Duration::from_secs(5)); let socket_addr: SocketAddr = bind_addr.parse()?; let registry = PostgresRegistry::connect(&database_url).await?; - let app = build_app(registry, refresh_interval, public_base_url); + let secret_crypto = SecretCrypto::new(&env::var("CRANK_MASTER_KEY")?)?; + let app = build_app(registry, refresh_interval, public_base_url, secret_crypto); let listener = TcpListener::bind(socket_addr).await?; info!("mcp-server listening on {}", socket_addr); @@ -48,25 +50,34 @@ mod tests { time::{Duration, SystemTime, UNIX_EPOCH}, }; - use axum::{Json, Router, http::header, routing::post}; + use axum::{ + Json, Router, + http::header, + response::sse::{Event, KeepAlive, Sse}, + routing::{get, post}, + }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ - Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, DescriptorId, - ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, Operation, - OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode, + DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget, + GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey, + PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, + StreamingConfig, Target, ToolDescription, ToolFamilyConfig, TransportBehavior, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest, }; + use crank_runtime::SecretCrypto; use crank_schema::{Schema, SchemaKind}; + use futures_util::stream; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use sqlx::{Executor, postgres::PgPoolOptions}; use tokio::net::TcpListener; + use tokio::time::sleep; use crate::app::build_app; @@ -78,6 +89,19 @@ mod tests { "default" } + fn build_test_app( + registry: PostgresRegistry, + refresh_interval: Duration, + public_base_url: Option, + ) -> axum::Router { + build_app( + registry, + refresh_interval, + public_base_url, + SecretCrypto::new("test-master-key").unwrap(), + ) + } + #[tokio::test] async fn initializes_lists_and_calls_published_tool_via_mcp() { let registry = test_registry().await; @@ -106,7 +130,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -212,7 +236,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -276,7 +300,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -318,7 +342,7 @@ mod tests { publish_agent_with_bindings(®istry, "sales-init", vec![]).await; let api_key = create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -370,11 +394,259 @@ mod tests { assert_eq!(tools_list["error"]["code"], -32002); } + #[tokio::test] + async fn initialize_can_return_sse_response_when_client_prefers_event_stream() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-sse-init", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-sse-init", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let response = client + .post(agent_mcp_url(&base_url, "sales-sse-init")) + .header(header::ACCEPT, "text/event-stream, application/json") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25" + } + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "text/event-stream" + ); + assert!(response.headers().get("MCP-Session-Id").is_some()); + + let body = response.text().await.unwrap(); + assert!(body.contains("\"jsonrpc\":\"2.0\"")); + assert!(body.contains("\"protocolVersion\":\"2025-11-25\"")); + } + + #[tokio::test] + async fn get_opens_sse_stream_for_initialized_session() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-get-sse", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-get-sse", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-get-sse"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let response = client + .get(&mcp_url) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-11-25") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap(), + "text/event-stream" + ); + assert_eq!( + response + .headers() + .get("MCP-Session-Id") + .unwrap() + .to_str() + .unwrap(), + initialized_session + ); + } + + #[tokio::test] + async fn get_requires_session_header() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-get-sse-missing", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-get-sse-missing", + &[PlatformApiKeyScope::Read], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + + let response = client + .get(agent_mcp_url(&base_url, "sales-get-sse-missing")) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn delete_terminates_transport_session() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-delete-session", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-delete-session", + &[PlatformApiKeyScope::Read], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-delete-session"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let delete_response = client + .delete(&mcp_url) + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-11-25") + .send() + .await + .unwrap(); + + assert_eq!(delete_response.status(), reqwest::StatusCode::NO_CONTENT); + + let after_delete = client + .get(&mcp_url) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-11-25") + .send() + .await + .unwrap(); + + assert_eq!(after_delete.status(), reqwest::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn initialize_accepts_json_only_response_negotiation() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-json-accept", vec![]).await; + let api_key = + create_platform_api_key(®istry, "mcp-json-accept", &[PlatformApiKeyScope::Read]) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let response = client + .post(agent_mcp_url(&base_url, "sales-json-accept")) + .header(header::ACCEPT, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25" + } + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap(), + "application/json" + ); + } + + #[tokio::test] + async fn rejects_get_with_protocol_version_mismatch() { + let registry = test_registry().await; + publish_agent_with_bindings(®istry, "sales-get-bad-version", vec![]).await; + let api_key = create_platform_api_key( + ®istry, + "mcp-get-bad-version", + &[PlatformApiKeyScope::Read], + ) + .await; + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-get-bad-version"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let response = client + .get(&mcp_url) + .header(header::ACCEPT, "text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {api_key}")) + .header("MCP-Session-Id", &initialized_session) + .header("MCP-Protocol-Version", "2025-06-18") + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn refreshes_published_tools_without_restart() { let registry = test_registry().await; let upstream_base_url = spawn_upstream_server().await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -485,7 +757,7 @@ mod tests { ) .await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -532,7 +804,7 @@ mod tests { async fn rejects_initialize_without_platform_api_key() { let registry = test_registry().await; publish_agent_with_bindings(®istry, "sales-auth", vec![]).await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -581,7 +853,7 @@ mod tests { let api_key = create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await; - let base_url = spawn_mcp_server(build_app( + let base_url = spawn_mcp_server(build_test_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), @@ -614,6 +886,551 @@ mod tests { assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); } + #[tokio::test] + async fn exposes_and_runs_session_tool_family_via_mcp() { + let registry = test_registry().await; + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let operation = test_grpc_session_operation(&server_addr, "echo_stream_session"); + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-session").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-session", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-session"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let tools = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }), + ) + .await; + let tool_names = tools["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_owned()) + .collect::>(); + assert!(!tool_names.contains(&operation.name)); + assert!(tool_names.contains(&"echo_stream_session_start".to_owned())); + assert!(tool_names.contains(&"echo_stream_session_poll".to_owned())); + assert!(tool_names.contains(&"echo_stream_session_stop".to_owned())); + + let start_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "echo_stream_session_start", + "arguments": { + "message": "hello" + } + } + }), + ) + .await; + let session_id = start_response["result"]["structuredContent"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + start_response["result"]["structuredContent"]["status"], + json!("running") + ); + + let poll_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "echo_stream_session_poll", + "arguments": { + "session_id": session_id + } + } + }), + ) + .await; + assert_eq!( + poll_response["result"]["structuredContent"]["session_id"], + json!(session_id) + ); + assert!( + poll_response["result"]["structuredContent"]["items"] + .as_array() + .is_some_and(|items| !items.is_empty()) + ); + + let stop_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "echo_stream_session_stop", + "arguments": { + "session_id": session_id + } + } + }), + ) + .await; + assert_eq!( + stop_response["result"]["structuredContent"], + json!({ + "session_id": session_id, + "status": "stopped" + }) + ); + } + + #[tokio::test] + async fn rejects_cross_agent_session_poll() { + let registry = test_registry().await; + let server_addr = grpc_test_support::spawn_unary_echo_server().await; + let operation_a = test_grpc_session_operation(&server_addr, "echo_stream_session_a"); + let operation_b = test_grpc_session_operation(&server_addr, "echo_stream_session_b"); + + for operation in [&operation_a, &operation_b] { + registry + .create_operation(&test_workspace_id(), operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + } + + publish_agent_for_operation(®istry, &operation_a, "sales-session-a").await; + publish_agent_for_operation(®istry, &operation_b, "sales-session-b").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-cross-session", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let agent_a_url = agent_mcp_url(&base_url, "sales-session-a"); + let agent_b_url = agent_mcp_url(&base_url, "sales-session-b"); + let session_a = initialize_session(&client, &agent_a_url, &api_key).await; + let session_b = initialize_session(&client, &agent_b_url, &api_key).await; + + let start_response = post_jsonrpc( + &client, + &agent_a_url, + &api_key, + Some(&session_a), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "echo_stream_session_a_start", + "arguments": { "message": "hello" } + } + }), + ) + .await; + let foreign_session_id = start_response["result"]["structuredContent"]["session_id"] + .as_str() + .unwrap() + .to_owned(); + + let poll_response = post_jsonrpc( + &client, + &agent_b_url, + &api_key, + Some(&session_b), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "echo_stream_session_b_poll", + "arguments": { "session_id": foreign_session_id } + } + }), + ) + .await; + + assert_eq!( + poll_response["result"]["structuredContent"]["error"]["code"], + json!("stream_session_not_found") + ); + assert!( + poll_response["result"]["structuredContent"]["error"]["message"] + .as_str() + .is_some_and(|message| message.starts_with("stream session ")) + ); + } + + #[tokio::test] + async fn rejects_cross_agent_async_job_access() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation_a = test_rest_async_job_operation(&upstream_base_url, "crm_async_a"); + let operation_b = test_rest_async_job_operation(&upstream_base_url, "crm_async_b"); + + for operation in [&operation_a, &operation_b] { + registry + .create_operation(&test_workspace_id(), operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + } + + publish_agent_for_operation(®istry, &operation_a, "sales-async-a").await; + publish_agent_for_operation(®istry, &operation_b, "sales-async-b").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-cross-async", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let agent_a_url = agent_mcp_url(&base_url, "sales-async-a"); + let agent_b_url = agent_mcp_url(&base_url, "sales-async-b"); + let session_a = initialize_session(&client, &agent_a_url, &api_key).await; + let session_b = initialize_session(&client, &agent_b_url, &api_key).await; + + let start_response = post_jsonrpc( + &client, + &agent_a_url, + &api_key, + Some(&session_a), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "crm_async_a_start", + "arguments": { "email": "user@example.com" } + } + }), + ) + .await; + let foreign_job_id = start_response["result"]["structuredContent"]["job_id"] + .as_str() + .unwrap() + .to_owned(); + + let status_response = post_jsonrpc( + &client, + &agent_b_url, + &api_key, + Some(&session_b), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "crm_async_b_status", + "arguments": { "job_id": foreign_job_id } + } + }), + ) + .await; + + assert_eq!( + status_response["result"]["structuredContent"]["error"]["code"], + json!("async_job_not_found") + ); + assert!( + status_response["result"]["structuredContent"]["error"]["message"] + .as_str() + .is_some_and(|message| message.starts_with("async job ")) + ); + } + + #[tokio::test] + async fn exposes_and_runs_async_job_tool_family_via_mcp() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_async_job_operation(&upstream_base_url, "crm_async_create_lead"); + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-async").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-async", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry.clone(), + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-async"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let tools = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }), + ) + .await; + let tool_names = tools["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap().to_owned()) + .collect::>(); + assert!(!tool_names.contains(&operation.name)); + assert!(tool_names.contains(&"crm_async_create_lead_start".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_status".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_result".to_owned())); + assert!(tool_names.contains(&"crm_async_create_lead_cancel".to_owned())); + + let start_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_start", + "arguments": { + "email": "user@example.com" + } + } + }), + ) + .await; + let job_id = start_response["result"]["structuredContent"]["job_id"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!( + start_response["result"]["structuredContent"]["status"], + json!("running") + ); + + let mut status_response = Value::Null; + for _ in 0..20 { + status_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_status", + "arguments": { + "job_id": job_id + } + } + }), + ) + .await; + + if status_response["result"]["structuredContent"]["status"] == json!("completed") { + break; + } + + sleep(Duration::from_millis(25)).await; + } + + assert_eq!( + status_response["result"]["structuredContent"]["status"], + json!("completed") + ); + + let result_response = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "crm_async_create_lead_result", + "arguments": { + "job_id": job_id + } + } + }), + ) + .await; + assert_eq!( + result_response["result"]["structuredContent"], + json!({ "id": "lead_123" }) + ); + } + + #[tokio::test] + async fn executes_window_operation_via_mcp() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let operation = test_rest_window_operation(&upstream_base_url, "crm_window_logs"); + + registry + .create_operation(&test_workspace_id(), &operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + workspace_id: &test_workspace_id(), + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_for_operation(®istry, &operation, "sales-window").await; + let api_key = create_platform_api_key( + ®istry, + "mcp-window", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + + let base_url = spawn_mcp_server(build_test_app( + registry, + Duration::from_millis(0), + Some("https://crank.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let mcp_url = agent_mcp_url(&base_url, "sales-window"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let call_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "crm_window_logs", + "arguments": {} + } + }), + ) + .await; + + assert_eq!(call_result["result"]["isError"], false); + assert_eq!( + call_result["result"]["structuredContent"]["items"][0]["message"], + json!("billing started") + ); + assert_eq!( + call_result["result"]["structuredContent"]["window_complete"], + json!(true) + ); + } + async fn initialize_session(client: &reqwest::Client, mcp_url: &str, api_key: &str) -> String { let initialize_response = client .post(mcp_url) @@ -719,7 +1536,10 @@ mod tests { } async fn spawn_upstream_server() -> String { - let app = Router::new().route("/crm/leads", post(create_lead)); + let app = Router::new() + .route("/sse/logs", get(stream_logs)) + .route("/crm/leads", post(create_lead)) + .route("/crm/slow-leads", post(create_slow_lead)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -832,6 +1652,28 @@ mod tests { })) } + async fn create_slow_lead(Json(payload): Json) -> Json { + sleep(Duration::from_millis(250)).await; + + Json(json!({ + "id": "lead_123", + "email": payload["email"] + })) + } + + async fn stream_logs() + -> Sse>> { + let events = vec![ + json!({ "level": "info", "message": "billing started" }), + json!({ "level": "warn", "message": "cache warmup slow" }), + json!({ "level": "error", "message": "invoice timeout" }), + ]; + let stream = stream::iter(events.into_iter().map(|payload| { + Ok::<_, std::convert::Infallible>(Event::default().data(payload.to_string())) + })); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) + } + async fn graphql_handler(Json(payload): Json) -> Json { let email = payload .get("variables") @@ -921,6 +1763,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead".to_owned(), @@ -985,6 +1828,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Create Lead GraphQL".to_owned(), @@ -1048,6 +1892,7 @@ mod tests { auth_profile_ref: None, headers: BTreeMap::new(), protocol_options: None, + streaming: None, }, tool_description: ToolDescription { title: "Unary Echo gRPC".to_owned(), @@ -1064,6 +1909,157 @@ mod tests { } } + fn test_grpc_session_operation(server_addr: &str, name: &str) -> Operation { + let mut operation = test_grpc_operation(server_addr, name); + operation.target = Target::Grpc(GrpcTarget { + server_addr: server_addr.to_owned(), + package: "echo".to_owned(), + service: "EchoService".to_owned(), + method: "ServerEcho".to_owned(), + descriptor_ref: DescriptorId::new("desc_echo"), + descriptor_set_b64: grpc_test_support::descriptor_set_b64(), + }); + operation.display_name = "Server Echo Session".to_owned(); + operation.tool_description = ToolDescription { + title: "Server Echo Session".to_owned(), + description: "Streams echo messages through a bounded session".to_owned(), + tags: vec!["grpc".to_owned(), "streaming".to_owned()], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::Session, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(1_000), + poll_interval_ms: Some(250), + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: Some(5_000), + max_session_lifetime_ms: Some(60_000), + max_items: Some(1), + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: Some("$.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig { + start_tool_name: Some(format!("{name}_start")), + poll_tool_name: Some(format!("{name}_poll")), + stop_tool_name: Some(format!("{name}_stop")), + status_tool_name: None, + result_tool_name: None, + cancel_tool_name: None, + }, + }); + operation + } + + fn test_rest_async_job_operation(base_url: &str, name: &str) -> Operation { + let mut operation = test_operation(base_url, name); + operation.display_name = "Create Lead Async".to_owned(); + operation.tool_description = ToolDescription { + title: "Create Lead Async".to_owned(), + description: "Creates a CRM lead through an async job tool family".to_owned(), + tags: vec!["rest".to_owned(), "async_job".to_owned()], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::AsyncJob, + transport_behavior: TransportBehavior::DeferredResult, + window_duration_ms: None, + poll_interval_ms: Some(250), + upstream_timeout_ms: Some(2_000), + idle_timeout_ms: None, + max_session_lifetime_ms: Some(300_000), + max_items: None, + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryOnly, + summary_path: None, + items_path: None, + cursor_path: None, + status_path: None, + done_path: None, + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig { + start_tool_name: Some(format!("{name}_start")), + poll_tool_name: None, + stop_tool_name: None, + status_tool_name: Some(format!("{name}_status")), + result_tool_name: Some(format!("{name}_result")), + cancel_tool_name: Some(format!("{name}_cancel")), + }, + }); + operation + } + + fn test_rest_window_operation(base_url: &str, name: &str) -> Operation { + let mut operation = test_operation(base_url, name); + operation.display_name = "Window Logs".to_owned(); + operation.target = Target::Rest(RestTarget { + base_url: base_url.to_owned(), + method: HttpMethod::Get, + path_template: "/sse/logs".to_owned(), + static_headers: BTreeMap::new(), + }); + operation.input_schema = optional_object_schema("window"); + operation.output_schema = empty_object_schema(); + operation.input_mapping = MappingSet { + rules: vec![MappingRule { + source: "$.mcp.window".to_owned(), + target: "$.request.query.window".to_owned(), + required: false, + default_value: Some(json!("recent")), + transform: None, + condition: None, + notes: None, + }], + }; + operation.output_mapping = MappingSet { rules: Vec::new() }; + operation.tool_description = ToolDescription { + title: "Window Logs".to_owned(), + description: "Collects a bounded SSE log window".to_owned(), + tags: vec![ + "rest".to_owned(), + "streaming".to_owned(), + "window".to_owned(), + ], + examples: Vec::new(), + }; + operation.execution_config.streaming = Some(StreamingConfig { + mode: ExecutionMode::Window, + transport_behavior: TransportBehavior::ServerStream, + window_duration_ms: Some(1_000), + poll_interval_ms: None, + upstream_timeout_ms: Some(1_000), + idle_timeout_ms: None, + max_session_lifetime_ms: None, + max_items: Some(3), + max_bytes: Some(16 * 1024), + aggregation_mode: AggregationMode::SummaryPlusSamples, + summary_path: None, + items_path: Some("$.items".to_owned()), + cursor_path: None, + status_path: None, + done_path: Some("$.done".to_owned()), + redacted_paths: Vec::new(), + truncate_item_fields: false, + max_field_length: None, + drop_duplicates: false, + sampling_rate: None, + tool_family: ToolFamilyConfig::default(), + }); + operation + } + fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, @@ -1090,4 +2086,45 @@ mod tests { variants: Vec::new(), } } + + fn empty_object_schema() -> Schema { + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + } + + fn optional_object_schema(field_name: &str) -> Schema { + Schema { + kind: SchemaKind::Object, + description: None, + required: true, + nullable: false, + default_value: None, + fields: BTreeMap::from([( + field_name.to_owned(), + Schema { + kind: SchemaKind::String, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + )]), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } + } } diff --git a/apps/ui/css/login.css b/apps/ui/css/login.css index 3cb948c..9b00f7f 100644 --- a/apps/ui/css/login.css +++ b/apps/ui/css/login.css @@ -61,7 +61,18 @@ .login-sub { font-size: 13.5px; color: var(--text-muted); - margin-bottom: 28px; + margin-bottom: 12px; +} + +.login-note { + margin-bottom: 24px; + padding: 10px 12px; + border-radius: 9px; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.18); + color: var(--text-secondary); + font-size: 12.5px; + line-height: 1.5; } .field { @@ -103,13 +114,31 @@ } .field-link { + display: inline-flex; + align-items: center; + gap: 8px; font-size: 12px; color: var(--accent); text-decoration: none; + background: transparent; + border: none; + padding: 0; } .field-link:hover { text-decoration: underline; } +.field-link.is-disabled, +.field-link:disabled { + color: var(--text-muted); + cursor: not-allowed; + text-decoration: none; +} + +.field-link.is-disabled:hover, +.field-link:disabled:hover { + text-decoration: none; +} + .btn-signin { width: 100%; padding: 10px; @@ -184,9 +213,49 @@ .btn-sso:hover { background: var(--bg-muted); color: var(--text-primary); } +.btn-sso:disabled { + cursor: not-allowed; + opacity: 0.7; + background: var(--bg-overlay); + color: var(--text-muted); +} + +.btn-sso:disabled:hover { + background: var(--bg-overlay); + color: var(--text-muted); +} + +.login-inline-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(148, 163, 184, 0.08); + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + .version-badge { text-align: center; margin-top: 28px; font-size: 11.5px; color: var(--text-muted); } + +.login-footer-action { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 6px; + color: var(--text-muted); + background: transparent; + border: none; + padding: 0; + cursor: not-allowed; + font: inherit; +} diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css index c7316a7..22338dc 100644 --- a/apps/ui/css/pages.css +++ b/apps/ui/css/pages.css @@ -1550,3 +1550,174 @@ width: auto; } } + +.list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.list-toolbar-group { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.page-select { + min-height: 36px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-overlay); + color: var(--text-secondary); + font-size: 13px; + font-family: 'Inter', sans-serif; +} + +.resource-list { + display: grid; + gap: 14px; +} + +.resource-card { + background: var(--bg-overlay); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px; +} + +.resource-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; +} + +.resource-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.resource-card-subtitle { + margin-top: 4px; + font-size: 12px; + color: var(--text-muted); + word-break: break-all; +} + +.resource-card-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.resource-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.resource-meta-item { + min-width: 0; +} + +.resource-meta-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 4px; +} + +.resource-meta-value { + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + word-break: break-word; +} + +.resource-detail-block { + margin-top: 12px; + border-top: 1px solid var(--border-subtle); + padding-top: 12px; +} + +.resource-detail-title { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} + +.resource-detail-pre { + margin: 0; + padding: 12px 14px; + border-radius: 12px; + background: rgba(12, 17, 24, 0.8); + border: 1px solid var(--border-subtle); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.resource-pill-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.resource-status-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 9px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); +} + +.resource-status-pill.active, +.resource-status-pill.running, +.resource-status-pill.succeeded { + border-color: rgba(63, 185, 80, 0.28); + background: rgba(63, 185, 80, 0.12); + color: #71dd8a; +} + +.resource-status-pill.idle, +.resource-status-pill.pending { + border-color: rgba(110, 118, 129, 0.28); + background: rgba(110, 118, 129, 0.12); + color: var(--text-secondary); +} + +.resource-status-pill.failed, +.resource-status-pill.cancelled, +.resource-status-pill.stopped { + border-color: rgba(248, 81, 73, 0.28); + background: rgba(248, 81, 73, 0.12); + color: #ff8a83; +} + +.resource-status-pill.completed { + border-color: rgba(56, 189, 248, 0.28); + background: rgba(56, 189, 248, 0.12); + color: #79d8ff; +} diff --git a/apps/ui/css/settings.css b/apps/ui/css/settings.css index 452ddc1..339a2aa 100644 --- a/apps/ui/css/settings.css +++ b/apps/ui/css/settings.css @@ -142,3 +142,64 @@ .role-admin { background: rgba(13,148,136,0.1); border-color: rgba(13,148,136,0.25); color: var(--accent); } .role-member { background: var(--bg-overlay); border-color: var(--border); color: var(--text-muted); } .role-viewer { background: rgba(88,166,255,0.07); border-color: var(--blue-border); color: var(--blue); } + +.planned-section-title { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + font-size: 13.5px; + font-weight: 600; + color: var(--text-primary); +} + +.planned-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid rgba(245, 158, 11, 0.24); + background: rgba(245, 158, 11, 0.09); + color: var(--amber); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.planned-badge.small { + padding: 1px 7px; +} + +.planned-list { + display: grid; + gap: 12px; +} + +.planned-item { + padding: 14px 16px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.02); +} + +.planned-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.planned-item-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.planned-item-desc { + font-size: 12.5px; + line-height: 1.5; + color: var(--text-muted); +} diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index f99a4b3..a074bd6 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -137,6 +137,38 @@ align-items: flex-start; } +.checkbox-pill { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 38px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-overlay); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.checkbox-pill:hover { + border-color: var(--bg-muted); + color: var(--text-primary); +} + +.checkbox-pill input { + width: 15px; + height: 15px; + accent-color: var(--accent); +} + +.checkbox-pill:has(input:checked) { + border-color: rgba(13, 148, 136, 0.35); + background: rgba(13, 148, 136, 0.12); + color: var(--text-primary); +} + /* ══════════════════════════════════════════════════ STEP SIDEBAR ══════════════════════════════════════════════════ */ @@ -1326,6 +1358,11 @@ color: #58a6ff; border: 1px solid rgba(88, 166, 255, 0.2); } +.upstream-auth-badge.auth-basic { + background: rgba(240, 173, 78, 0.12); + color: #f0ad4e; + border: 1px solid rgba(240, 173, 78, 0.2); +} .upstream-auth-badge.auth-apikey { background: rgba(188, 140, 255, 0.1); color: #bc8cff; diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index ef7e6c0..1cefcc1 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -51,6 +51,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -90,6 +91,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index c211252..bd07007 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -63,6 +63,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -106,6 +107,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/async-jobs.html b/apps/ui/html/async-jobs.html new file mode 100644 index 0000000..2aa5872 --- /dev/null +++ b/apps/ui/html/async-jobs.html @@ -0,0 +1,114 @@ + + + + + + + Crank — Async Jobs + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + + + diff --git a/apps/ui/html/login.html b/apps/ui/html/login.html index ea49dbb..5a406e3 100644 --- a/apps/ui/html/login.html +++ b/apps/ui/html/login.html @@ -26,6 +26,7 @@ -
Crank v0.9.0-beta
diff --git a/apps/ui/html/logs.html b/apps/ui/html/logs.html index 515ef97..889db3f 100644 --- a/apps/ui/html/logs.html +++ b/apps/ui/html/logs.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -87,6 +88,7 @@ Operations Agents API Keys + Secrets Logs Usage diff --git a/apps/ui/html/secrets.html b/apps/ui/html/secrets.html new file mode 100644 index 0000000..2b08fac --- /dev/null +++ b/apps/ui/html/secrets.html @@ -0,0 +1,242 @@ + + + + + + + Crank — Secrets + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + +
+ Secret plaintext is write-only. + Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references. +
+
+ +
+
+
+
Workspace secrets
+
+
+ +
+
+ + + + + + + + + + + + + +
NameKindVersionLast usedUsed byStatus
+
+
+ +
+
+
+
Auth profile references
+
Profiles resolve secrets at runtime before upstream execution.
+
+
+
+
+
+
+
+ + + + + + + diff --git a/apps/ui/html/settings.html b/apps/ui/html/settings.html index b327b30..9cebd9e 100644 --- a/apps/ui/html/settings.html +++ b/apps/ui/html/settings.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -87,6 +88,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -274,7 +276,10 @@
-
Planned security capabilities
+
+ Planned security capabilities + Planned +
@@ -305,7 +310,10 @@
-
Notification preferences
+
+ Notification preferences + Planned +
@@ -319,15 +327,24 @@
-
Operation error spike
+
+
Operation error spike
+ Planned +
Alert when error rate exceeds a rolling threshold for a published tool.
-
Upstream latency degradation
+
+
Upstream latency degradation
+ Planned +
Notify operators when p99 latency rises sharply above the baseline.
-
Quota and usage digests
+
+
Quota and usage digests
+ Planned +
Deliver periodic summaries for quota consumption, errors and workspace activity.
diff --git a/apps/ui/html/stream-sessions.html b/apps/ui/html/stream-sessions.html new file mode 100644 index 0000000..3890dbb --- /dev/null +++ b/apps/ui/html/stream-sessions.html @@ -0,0 +1,119 @@ + + + + + + + Crank — Stream Sessions + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ + + + + diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html index 9db8a9c..888f3ea 100644 --- a/apps/ui/html/usage.html +++ b/apps/ui/html/usage.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage
@@ -87,6 +88,7 @@ Operations Agents API Keys + Secrets Logs Usage
diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 9f973b4..232371c 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -50,6 +50,7 @@ Operations Agents API Keys + Secrets Logs Usage
@@ -93,6 +94,7 @@ Operations Agents API Keys + Secrets Logs Usage
@@ -235,6 +237,43 @@
+ + + diff --git a/apps/ui/html/wizard/step1.html b/apps/ui/html/wizard/step1.html index 3c7e5ce..b5f1004 100644 --- a/apps/ui/html/wizard/step1.html +++ b/apps/ui/html/wizard/step1.html @@ -12,7 +12,7 @@
- - - + + + +
diff --git a/apps/ui/html/wizard/step2.html b/apps/ui/html/wizard/step2.html index 5f2c137..bc3c461 100644 --- a/apps/ui/html/wizard/step2.html +++ b/apps/ui/html/wizard/step2.html @@ -84,21 +84,88 @@
-
Root URL — no trailing slash. Supports ${secrets.*}.
+
Root URL without a trailing slash. Auth is configured separately below.
- + + +
Use secrets-backed auth profiles instead of embedding credential headers in the upstream definition.
+
+ + + + + +
+
- json / auth-headers + json / static-headers
-
-
These headers are sent on every request to this upstream. Use secrets for credentials.
+
Optional non-secret headers sent on every request to this upstream. Use auth profiles for credentials.
diff --git a/apps/ui/html/wizard/step3-soap.html b/apps/ui/html/wizard/step3-soap.html new file mode 100644 index 0000000..f6d4c04 --- /dev/null +++ b/apps/ui/html/wizard/step3-soap.html @@ -0,0 +1,101 @@ +
+
+
+
+ Step 3 of 5 +
+

WSDL and SOAP binding

+

Upload the WSDL/XSD bundle, inspect services and choose the exact service, port and operation that Crank should expose as an MCP tool.

+
+ +
+
+
+ + + + +
+
+
Contract files
+
WSDL is required. Supporting XSD files are optional and can be uploaded before discovery.
+
+ Required +
+
+
+ + + No WSDL selected +
+
+ + + No XSD selected +
+
+ +
+
+
+
+
+ +
+
+
+ + + +
+
+
Selected binding
+
The chosen service, port and operation become the target binding for this operation draft.
+
+ Optional +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
Defaults to the upstream base URL from step 2. Override only when the binding address differs from the selected upstream.
+
+
+
+
diff --git a/apps/ui/html/wizard/step3-websocket.html b/apps/ui/html/wizard/step3-websocket.html new file mode 100644 index 0000000..f2dd30c --- /dev/null +++ b/apps/ui/html/wizard/step3-websocket.html @@ -0,0 +1,94 @@ +
+
+
+
+ Step 3 of 5 +
+

WebSocket subscription

+

Define the subscription handshake, the message templates and the connection tuning that Crank should use before collecting bounded WebSocket events.

+
+ +
+
+
+ + + +
+
+
Connection
+
The upstream base URL from step 2 becomes the WebSocket endpoint unless you override it here.
+
+ Optional +
+
+
+ + +
Appended to the upstream URL from step 2. Leave empty if the upstream URL already points to the full WebSocket endpoint.
+
+
+ + +
One subprotocol per line. Crank offers them during the handshake in the same order.
+
+
+
+ +
+
+
+ + + + +
+
+
Subscription messages
+
Templates are sent after the socket opens. They can reference MCP input through the mapping step.
+
+ Optional +
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + + +
+
+
Connection tuning
+
Used by the runtime adapter for heartbeats and reconnects in bounded session-oriented flows.
+
+ Optional +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
diff --git a/apps/ui/html/wizard/step5.html b/apps/ui/html/wizard/step5.html index ffb37a7..c7fba9e 100644 --- a/apps/ui/html/wizard/step5.html +++ b/apps/ui/html/wizard/step5.html @@ -5,7 +5,7 @@ Step 5 of 5

Mapping and execution

-

Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.

+

Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 2.

@@ -87,6 +87,164 @@ tls:
+
+
+
+ + + + +
+
+
Streaming execution
+
Bounded stream, session and async job settings for MCP tool families.
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Unary keeps the existing request-response model. Other modes create bounded stream-aware MCP tools.
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+
Live validation and publishing
diff --git a/apps/ui/html/workspace-setup.html b/apps/ui/html/workspace-setup.html index 97b3eb1..4fa41fd 100644 --- a/apps/ui/html/workspace-setup.html +++ b/apps/ui/html/workspace-setup.html @@ -95,6 +95,8 @@ + +
Pre-selected in the operation wizard.
diff --git a/apps/ui/index.html b/apps/ui/index.html index fa03f88..fcb97c8 100644 --- a/apps/ui/index.html +++ b/apps/ui/index.html @@ -49,6 +49,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -98,6 +99,7 @@ Operations Agents API Keys + Secrets Logs Usage @@ -181,13 +183,13 @@