From 61de0d8fe812137cec943d58309b26df1dd227b5 Mon Sep 17 00:00:00 2001 From: thomas Date: Tue, 28 Jul 2026 05:12:24 +0000 Subject: [PATCH] Upgrade rmcp to 3.0.0-beta.3 (#35720) ## What changed - Update the `rmcp` model and transport integrations for `3.0.0-beta.3`. - Preserve legacy `elicitation/create` form requests, schema defaults, metadata, and wire-format compatibility while adopting the new elicitation types. - Harden OAuth discovery by using GET-first discovery without starting an MCP session, preventing cross-origin header redirects, validating authorization-server and callback issuers, and retaining compatibility with metadata that omits an issuer. - Keep stored OAuth credentials readable and avoid requiring reauthorization after transient refresh failures. ## Testing - Add coverage for legacy elicitation round trips, OAuth discovery variants, callback issuer validation, credential migration, and refresh failures. GitOrigin-RevId: 756197d26f3fd347c28c400228f6b3d06ed493b7 --- MODULE.bazel.lock | 6 +- codex-rs/Cargo.lock | 12 +- codex-rs/Cargo.toml | 2 +- .../src/protocol/v2/mcp.rs | 43 +++- .../src/protocol/v2/tests.rs | 53 ++++- .../tests/suite/v2/app_installed.rs | 8 +- .../app-server/tests/suite/v2/app_list.rs | 6 +- .../app-server/tests/suite/v2/executor_mcp.rs | 11 +- .../app-server/tests/suite/v2/mcp_resource.rs | 106 +++++---- .../tests/suite/v2/mcp_server_elicitation.rs | 133 +++++++++-- .../tests/suite/v2/mcp_server_status.rs | 24 +- .../app-server/tests/suite/v2/mcp_tool.rs | 53 +++-- .../tests/suite/v2/plugin_install.rs | 6 +- .../codex-mcp/src/connection_manager_tests.rs | 81 ++++--- codex-rs/codex-mcp/src/elicitation.rs | 40 ++-- codex-rs/core/src/config/config_tests.rs | 7 +- codex-rs/core/src/config/mod.rs | 7 +- codex-rs/core/src/mcp_tool_call.rs | 1 + codex-rs/core/src/session/handlers.rs | 1 + codex-rs/core/src/session/mcp.rs | 20 +- codex-rs/core/src/session/mcp_tests.rs | 61 +++-- codex-rs/core/src/session/tests.rs | 14 +- .../src/tools/handlers/mcp_resource_tests.rs | 30 +-- .../tools/handlers/request_plugin_install.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 6 +- codex-rs/mcp-server/src/message_processor.rs | 59 +++-- codex-rs/mcp-server/src/outgoing_message.rs | 31 ++- .../mcp-server/tests/common/mcp_process.rs | 8 +- codex-rs/rmcp-client/Cargo.toml | 2 +- codex-rs/rmcp-client/src/auth_status.rs | 139 +++++++++++- .../rmcp-client/src/bin/rmcp_test_server.rs | 12 +- .../rmcp-client/src/bin/test_stdio_server.rs | 125 ++++------- .../src/bin/test_streamable_http_server.rs | 71 ++---- .../src/elicitation_client_service.rs | 147 ++++++++++-- .../rmcp-client/src/http_client_adapter.rs | 19 +- .../rmcp-client/src/logging_client_handler.rs | 14 +- .../src/oauth/refresh_transaction.rs | 13 +- .../src/oauth/tests/persistor_tests.rs | 34 +++ .../src/oauth/tests/store_lock_tests.rs | 42 ++++ codex-rs/rmcp-client/src/oauth_http_client.rs | 52 ++++- .../rmcp-client/src/perform_oauth_login.rs | 163 +++++++++++++- codex-rs/rmcp-client/src/rmcp_client.rs | 43 ++-- codex-rs/rmcp-client/src/utils.rs | 12 - .../tests/mcp_2026_oauth_discovery.rs | 209 ++++++++++++++++++ codex-rs/rmcp-client/tests/resources.rs | 52 ++--- .../tests/streamable_http_test_support.rs | 9 +- codex-rs/tui/src/history_cell/mcp.rs | 20 +- codex-rs/tui/src/history_cell/tests.rs | 22 +- 48 files changed, 1391 insertions(+), 639 deletions(-) create mode 100644 codex-rs/rmcp-client/tests/mcp_2026_oauth_discovery.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 63c1201dbf54..dc70e7dd7e4a 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1424,8 +1424,8 @@ "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "rfc6979_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", - "rmcp-macros_1.8.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"local\":[]}}", - "rmcp_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.8.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", + "rmcp-macros_3.0.0-beta.3": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"local\":[]}}", + "rmcp_3.0.0-beta.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.13\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"features\":[\"aws_lc_rs\"],\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^3.0.0-beta.3\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2.4\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\",\"dep:bytes\",\"base64\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"request-state\":[\"dep:hmac\",\"dep:sha2\",\"base64\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"schemars\",\"dep:pastey\",\"uuid\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\",\"base64\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", "rouille_3.6.2": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.13\"},{\"name\":\"brotli\",\"optional\":true,\"req\":\"^3.3.2\"},{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"req\":\"^0.4.19\"},{\"features\":[\"gzip\"],\"name\":\"deflate\",\"optional\":true,\"req\":\"^1.0.0\"},{\"name\":\"filetime\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"server\"],\"name\":\"multipart\",\"req\":\"^0.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postgres\",\"req\":\"^0.19\"},{\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_derive\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"sha1_smol\",\"req\":\"^1.0.0\"},{\"name\":\"threadpool\",\"req\":\"^1\"},{\"features\":[\"local-offset\"],\"name\":\"time\",\"req\":\"^0.3.15\"},{\"default_features\":false,\"name\":\"tiny_http\",\"req\":\"^0.12.0\"},{\"name\":\"url\",\"req\":\"^2\"}],\"features\":{\"default\":[\"gzip\",\"brotli\"],\"gzip\":[\"deflate\"],\"rustls\":[\"tiny_http/ssl-rustls\"],\"ssl\":[\"tiny_http/ssl\"]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", @@ -1547,7 +1547,7 @@ "sqlx-postgres_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.13.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^2.0.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"dep:serde\",\"dep:serde_json\",\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\",\"dep:crc\"],\"offline\":[\"json\",\"sqlx-core/offline\",\"smallvec/serde\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", "sqlx-sqlite_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.12.0\"},{\"name\":\"form_urlencoded\",\"req\":\"^1.2.2\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\"],\"name\":\"libsqlite3-sys\",\"req\":\">=0.30.1, <0.38.0\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"}],\"features\":{\"_unstable-all-sqlite-features\":[\"deserialize\",\"load-extension\",\"preupdate-hook\",\"unlock-notify\"],\"_unstable-all-types\":[\"json\",\"chrono\",\"time\",\"uuid\"],\"_unstable-docs\":[\"bundled\",\"any\",\"_unstable-all-types\",\"_unstable-all-sqlite-features\"],\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"deserialize\":[],\"json\":[\"sqlx-core/json\",\"serde\"],\"load-extension\":[],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"rust_decimal\":[],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"unlock-notify\":[\"libsqlite3-sys/unlock_notify\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", "sqlx_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"features\":[\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.47\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[],\"_rt-async-std\":[],\"_rt-smol\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"_unstable-docs\":[\"all-databases\",\"_unstable-all-types\",\"sqlx-sqlite/_unstable-docs\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\",\"sqlx-core/offline\",\"sqlx-mysql?/offline\",\"sqlx-postgres?/offline\",\"sqlx-sqlite?/offline\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\",\"sqlx-macros?/mysql-rsa\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-global-executor\":[\"_rt-async-global-executor\",\"sqlx-core/_rt-async-global-executor\",\"sqlx-macros?/_rt-async-global-executor\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-smol\":[\"_rt-smol\",\"sqlx-core/_rt-smol\",\"sqlx-macros?/_rt-smol\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"sqlite-bundled\",\"sqlite-deserialize\",\"sqlite-load-extension\",\"sqlite-unlock-notify\"],\"sqlite-bundled\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-deserialize\":[\"sqlx-sqlite/deserialize\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\",\"sqlx-macros?/sqlite-load-extension\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"sqlite-unlock-notify\":[\"sqlx-sqlite/unlock-notify\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-macros?/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", - "sse-stream_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"features\":[\"tracing\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"tracing\":[\"dep:tracing\"]}}", + "sse-stream_0.2.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"features\":[\"tracing\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"tracing\":[\"dep:tracing\"]}}", "stable_deref_trait_1.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "starlark_0.14.2": "{\"dependencies\":[{\"features\":[\"bumpalo\",\"num-bigint\"],\"name\":\"allocative\",\"req\":\"^0.3.6\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"features\":[\"default\",\"rayon\",\"std\",\"traits-preview\"],\"name\":\"blake3\",\"req\":\"=1.8.2\"},{\"name\":\"bumpalo\",\"req\":\"^3.8\"},{\"name\":\"cmp_any\",\"req\":\"^0.8.1\"},{\"name\":\"dashmap\",\"req\":\"^6.1.0\"},{\"name\":\"debugserver-types\",\"req\":\"^0.5.0\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"display_container\",\"req\":\"^0.9.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.1\"},{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"erased-serde\",\"req\":\"^0.3.12\"},{\"name\":\"hashbrown\",\"req\":\"^0.16.1\"},{\"name\":\"indexmap\",\"req\":\"^2.2\"},{\"name\":\"inventory\",\"req\":\"^0.3.8\"},{\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"name\":\"maplit\",\"req\":\"^1.0.2\"},{\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"features\":[\"serde\"],\"name\":\"num-bigint\",\"req\":\"^0.4.6\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.21.4\"},{\"name\":\"pagable\",\"req\":\"^0.4.1\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"name\":\"rustyline\",\"req\":\"^14.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"arbitrary_precision\"],\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_derive\",\"req\":\"=0.14.2\"},{\"features\":[\"pagable_dep\"],\"name\":\"starlark_map\",\"req\":\"^0.14.2\"},{\"name\":\"starlark_syntax\",\"req\":\"^0.14.2\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strong_hash\",\"req\":\"^0.1.0\"},{\"name\":\"strsim\",\"req\":\"^0.10.0\"},{\"name\":\"textwrap\",\"req\":\"^0.11\"},{\"name\":\"thiserror\",\"req\":\"^2.0.18\"}],\"features\":{\"default\":[],\"pagable\":[]}}", "starlark_derive_0.14.2": "{\"dependencies\":[{\"name\":\"dupe\",\"req\":\"^0.9.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"extra-traits\",\"full\",\"visit\",\"visit-mut\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a899ae156f78..6ca0e133bdfe 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -11245,9 +11245,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.8.0" +version = "3.0.0-beta.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +checksum = "e13a8472324b3d6c74f092cc5d07b2707b128196b020ed7c3c2d52bede542680" dependencies = [ "async-trait", "base64 0.22.1", @@ -11280,9 +11280,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.8.0" +version = "3.0.0-beta.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +checksum = "faa1de06ecdc489a8a03ca84f8491111c1e37acd8c69a815e253123039619d72" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -12568,9 +12568,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.1" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7af5673c10fc..0222004c1d4f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -377,7 +377,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ regex = "1.12.3" regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } -rmcp = { version = "1.8.0", default-features = false } +rmcp = { version = "=3.0.0-beta.3", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustls = { version = "0.23", default-features = false, features = [ "aws_lc_rs", diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index d24da0c8794a..3331646bfd3e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -287,6 +287,7 @@ impl From for McpServerElicitationAction { rmcp::model::ElicitationAction::Accept => Self::Accept, rmcp::model::ElicitationAction::Decline => Self::Decline, rmcp::model::ElicitationAction::Cancel => Self::Cancel, + _ => Self::Cancel, } } } @@ -344,10 +345,38 @@ pub enum McpElicitationObjectType { pub enum McpElicitationPrimitiveSchema { Enum(McpElicitationEnumSchema), String(McpElicitationStringSchema), + #[serde(serialize_with = "serialize_mcp_elicitation_number_schema")] Number(McpElicitationNumberSchema), Boolean(McpElicitationBooleanSchema), } +fn serialize_mcp_elicitation_number_schema( + schema: &McpElicitationNumberSchema, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + if schema.type_ != McpElicitationNumberType::Integer { + return schema.serialize(serializer); + } + + let mut value = serde_json::to_value(schema).map_err(serde::ser::Error::custom)?; + if let Some(object) = value.as_object_mut() { + for key in ["minimum", "maximum", "default"] { + if let Some(value) = object.get_mut(key) + && let Some(number) = value.as_f64() + && number.fract() == 0.0 + && number >= i64::MIN as f64 + && number < -(i64::MIN as f64) + { + *value = serde_json::Value::from(number as i64); + } + } + } + value.serialize(serializer) +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export_to = "v2/")] @@ -716,18 +745,16 @@ pub struct McpServerElicitationRequestResponse { pub meta: Option, } -impl From for rmcp::model::CreateElicitationResult { +impl From for rmcp::model::ElicitResult { fn from(value: McpServerElicitationRequestResponse) -> Self { - Self { - action: value.action.into(), - content: value.content, - meta: None, - } + let mut result = Self::new(value.action.into()); + result.content = value.content; + result } } -impl From for McpServerElicitationRequestResponse { - fn from(value: rmcp::model::CreateElicitationResult) -> Self { +impl From for McpServerElicitationRequestResponse { + fn from(value: rmcp::model::ElicitResult) -> Self { Self { action: value.action.into(), content: value.content, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 4850e06ea8ed..300ac4c3ebe3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1989,13 +1989,10 @@ fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { #[test] fn mcp_server_elicitation_response_round_trips_rmcp_result() { - let rmcp_result = rmcp::model::CreateElicitationResult { - action: rmcp::model::ElicitationAction::Accept, - content: Some(json!({ + let rmcp_result = rmcp::model::ElicitResult::new(rmcp::model::ElicitationAction::Accept) + .with_content(json!({ "confirmed": true, - })), - meta: None, - }; + })); let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); assert_eq!( @@ -2008,10 +2005,7 @@ fn mcp_server_elicitation_response_round_trips_rmcp_result() { meta: None, } ); - assert_eq!( - rmcp::model::CreateElicitationResult::from(v2_response), - rmcp_result - ); + assert_eq!(rmcp::model::ElicitResult::from(v2_response), rmcp_result); } #[test] @@ -2204,6 +2198,45 @@ fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { ); } +#[test] +fn mcp_elicitation_preserves_integer_and_number_schema_wire_values() { + for (number_type, expected_type, expected_minimum, expected_maximum, expected_default) in [ + ( + McpElicitationNumberType::Integer, + "integer", + json!(1), + json!(99), + json!(30), + ), + ( + McpElicitationNumberType::Number, + "number", + json!(1.0), + json!(99.0), + json!(30.0), + ), + ] { + let schema = McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { + type_: number_type, + title: None, + description: None, + minimum: Some(1.0), + maximum: Some(99.0), + default: Some(30.0), + }); + + assert_eq!( + serde_json::to_value(schema).expect("numeric elicitation schema must serialize"), + json!({ + "type": expected_type, + "minimum": expected_minimum, + "maximum": expected_maximum, + "default": expected_default, + }) + ); + } +} + #[test] fn mcp_server_elicitation_request_rejects_null_core_form_schema() { let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { diff --git a/codex-rs/app-server/tests/suite/v2/app_installed.rs b/codex-rs/app-server/tests/suite/v2/app_installed.rs index 1327e21feb6d..3928af8e07a7 100644 --- a/codex-rs/app-server/tests/suite/v2/app_installed.rs +++ b/codex-rs/app-server/tests/suite/v2/app_installed.rs @@ -330,15 +330,13 @@ impl ServerHandler for InstalledAppsMcpServer { )); } - Ok(ListToolsResult { - meta: None, - next_cursor: None, - tools: state + Ok(ListToolsResult::with_all_items( + state .tools .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(), - }) + )) } } } diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index f16c80ea71ec..1e39fbe2dc71 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -1581,11 +1581,7 @@ impl ServerHandler for AppListMcpServer { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - Ok(ListToolsResult { - tools, - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(tools)) } } } diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index 5067eea4b2fa..f5d931471d23 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -419,18 +419,14 @@ impl ServerHandler for ExecutorHttpMcpServer { ); tool.annotations = Some(ToolAnnotations::new().read_only(true)); - Ok(ListToolsResult { - tools: vec![tool], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![tool])) } async fn call_tool( &self, request: CallToolRequestParams, _context: RequestContext, - ) -> Result { + ) -> Result { let message = request .arguments .as_ref() @@ -439,7 +435,8 @@ impl ServerHandler for ExecutorHttpMcpServer { .unwrap_or_default(); Ok(CallToolResult::structured(json!({ "echo": format!("ECHOING: {message}") - }))) + })) + .into()) } } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 0021ce90bbc6..64a45b91265b 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -36,16 +36,15 @@ use core_test_support::responses; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::BooleanSchema; -use rmcp::model::CreateElicitationRequestParams; -use rmcp::model::CreateElicitationResult; +use rmcp::model::ElicitRequestParams; +use rmcp::model::ElicitResult; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; use rmcp::model::ListResourcesResult; use rmcp::model::Meta; use rmcp::model::PaginatedRequestParams; -use rmcp::model::PrimitiveSchema; +use rmcp::model::PrimitiveSchemaDefinition; use rmcp::model::ProtocolVersion; -use rmcp::model::RawResource; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::Resource; @@ -791,18 +790,16 @@ impl ServerHandler for ResourceAppsMcpServer { self.calls.list_resources.fetch_add(1, Ordering::Relaxed); let cursor = request.and_then(|request| request.cursor); if cursor.is_none() { - return Ok(ListResourcesResult { - resources: vec![skill_resource( - "skill://plugin_ignored/ignored", - "plugin_ignored/ignored", - "Not an MCP skill resource.", - "text/plain", - "ignored-plugin", - "ignored", - )], - next_cursor: Some("skills-page".to_string()), - meta: None, - }); + let mut result = ListResourcesResult::with_all_items(vec![skill_resource( + "skill://plugin_ignored/ignored", + "plugin_ignored/ignored", + "Not an MCP skill resource.", + "text/plain", + "ignored-plugin", + "ignored", + )]); + result.next_cursor = Some("skills-page".to_string()); + return Ok(result); } if cursor.as_deref() == Some("failing-page") { return Err(rmcp::ErrorData::internal_error( @@ -817,75 +814,76 @@ impl ServerHandler for ResourceAppsMcpServer { )); } - Ok(ListResourcesResult { - resources: vec![skill_resource( - SKILL_RESOURCE_URI, - "plugin_demo/deploy", - RAW_SKILL_DESCRIPTION, - "mcp/skill", - "demo-plugin", - "deploy", - )], - next_cursor: Some("failing-page".to_string()), - meta: None, - }) + let mut result = ListResourcesResult::with_all_items(vec![skill_resource( + SKILL_RESOURCE_URI, + "plugin_demo/deploy", + RAW_SKILL_DESCRIPTION, + "mcp/skill", + "demo-plugin", + "deploy", + )]); + result.next_cursor = Some("failing-page".to_string()); + Ok(result) } async fn read_resource( &self, request: ReadResourceRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { let uri = request.uri; if uri == TEST_ELICITATION_RESOURCE_URI { let requested_schema = ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .required_property( + "confirmed", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) .build() .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; let result = context .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message: "Confirm the resource read.".to_string(), requested_schema, }) .await .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; - assert_eq!( - result, - CreateElicitationResult::new(ElicitationAction::Decline) - ); + assert_eq!(result, ElicitResult::new(ElicitationAction::Decline)); - return Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { + return Ok( + ReadResourceResult::new(vec![ResourceContents::TextResourceContents { uri: TEST_ELICITATION_RESOURCE_URI.to_string(), mime_type: Some("text/plain".to_string()), text: TEST_ELICITATION_RESOURCE_TEXT.to_string(), meta: None, - }, - ])); + }]) + .into(), + ); } if uri == SKILL_MAIN_PROMPT_URI { self.calls.main_prompt_reads.fetch_add(1, Ordering::Relaxed); - return Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { + return Ok( + ReadResourceResult::new(vec![ResourceContents::TextResourceContents { uri: SKILL_MAIN_PROMPT_URI.to_string(), mime_type: Some("text/markdown".to_string()), text: SKILL_CONTENTS.to_string(), meta: None, - }, - ])); + }]) + .into(), + ); } if uri == SKILL_REFERENCE_URI { self.calls.reference_reads.fetch_add(1, Ordering::Relaxed); - return Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { + return Ok( + ReadResourceResult::new(vec![ResourceContents::TextResourceContents { uri: SKILL_REFERENCE_URI.to_string(), mime_type: Some("text/markdown".to_string()), text: SKILL_REFERENCE_CONTENTS.to_string(), meta: None, - }, - ])); + }]) + .into(), + ); } if uri != TEST_RESOURCE_URI { return Err(rmcp::ErrorData::resource_not_found( @@ -907,7 +905,8 @@ impl ServerHandler for ResourceAppsMcpServer { blob: TEST_RESOURCE_BLOB.to_string(), meta: None, }, - ])) + ]) + .into()) } } @@ -919,13 +918,10 @@ fn skill_resource( plugin_name: &str, skill_name: &str, ) -> Resource { - Resource::new( - RawResource::new(uri, name) - .with_description(description) - .with_mime_type(mime_type) - .with_meta(skill_resource_meta(plugin_name, skill_name)), - /*annotations*/ None, - ) + Resource::new(uri, name) + .with_description(description) + .with_mime_type(mime_type) + .with_meta(skill_resource_meta(plugin_name, skill_name)) } fn skill_resource_meta(plugin_name: &str, skill_name: &str) -> Meta { diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index c6f1fc03a9aa..1946b84213ea 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -44,9 +44,9 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::BooleanSchema; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; -use rmcp::model::Content; -use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ContentBlock; use rmcp::model::CustomRequest; +use rmcp::model::ElicitRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; use rmcp::model::InitializeRequestParams; @@ -54,7 +54,7 @@ use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; use rmcp::model::Meta; -use rmcp::model::PrimitiveSchema; +use rmcp::model::PrimitiveSchemaDefinition; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::ServerRequest as McpServerRequest; @@ -96,6 +96,7 @@ const IMAGE_DATA_URL: &str = #[derive(Clone, Copy)] enum ElicitationScenario { StandardForm, + LegacySep1034Defaults, OpenAiForm, } @@ -105,7 +106,10 @@ async fn mcp_server_form_elicitation_round_trip() -> Result<()> { let (request_id, params) = fixture.read_elicitation().await?; let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .required_property( + "confirmed", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) .build() .map_err(anyhow::Error::msg)?, )?)?; @@ -129,6 +133,28 @@ async fn mcp_server_form_elicitation_round_trip() -> Result<()> { fixture.finish(request_id, "accepted").await } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn mcp_server_legacy_sep1034_elicitation_defaults_round_trip() -> Result<()> { + let mut fixture = + ElicitationRoundTripFixture::start(ElicitationScenario::LegacySep1034Defaults).await?; + let (request_id, params) = fixture.read_elicitation().await?; + let McpServerElicitationRequest::Form { + message, + requested_schema, + .. + } = params.request + else { + anyhow::bail!("omitted legacy elicitation mode must default to form"); + }; + + assert_eq!(message, ELICITATION_MESSAGE); + assert_eq!(serde_json::to_value(requested_schema)?, sep1034_schema()); + fixture + .accept(request_id.clone(), sep1034_defaults()) + .await?; + fixture.finish(request_id, "legacy defaults accepted").await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> { let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiForm).await?; @@ -674,27 +700,26 @@ impl ServerHandler for ElicitationAppsMcpServer { .insert("connector_name".to_string(), json!(CONNECTOR_NAME)); tool.meta = Some(meta); - Ok(ListToolsResult { - tools: vec![tool], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![tool])) } async fn call_tool( &self, _request: CallToolRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { match self.scenario { ElicitationScenario::StandardForm => { let requested_schema = ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .required_property( + "confirmed", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) .build() .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; let result = context .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message: ELICITATION_MESSAGE.to_string(), requested_schema, @@ -711,8 +736,49 @@ impl ServerHandler for ElicitationAppsMcpServer { ElicitationAction::Accept => "accepted", ElicitationAction::Decline => "declined", ElicitationAction::Cancel => "cancelled", + _ => { + return Err(rmcp::ErrorData::invalid_params( + "unsupported MCP elicitation action", + None, + )); + } }; - Ok(CallToolResult::success(vec![Content::text(output)])) + Ok(CallToolResult::success(vec![ContentBlock::text(output)]).into()) + } + ElicitationScenario::LegacySep1034Defaults => { + let result = context + .peer + .send_request(McpServerRequest::CustomRequest(CustomRequest::new( + "elicitation/create", + Some(json!({ + "message": ELICITATION_MESSAGE, + "requestedSchema": sep1034_schema(), + })), + ))) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let result = match result { + rmcp::model::ClientResult::CustomResult(result) => result.0, + rmcp::model::ClientResult::ElicitResult(result) => serde_json::to_value(result) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?, + result => { + return Err(rmcp::ErrorData::internal_error( + format!("unexpected legacy elicitation response: {result:?}"), + None, + )); + } + }; + assert_eq!( + result, + json!({ + "action": "accept", + "content": sep1034_defaults(), + }) + ); + Ok( + CallToolResult::success(vec![ContentBlock::text("legacy defaults accepted")]) + .into(), + ) } ElicitationScenario::OpenAiForm => { let result = context @@ -742,10 +808,8 @@ impl ServerHandler for ElicitationAppsMcpServer { .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; let result = match result { rmcp::model::ClientResult::CustomResult(result) => result.0, - rmcp::model::ClientResult::CreateElicitationResult(result) => { - serde_json::to_value(result) - .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))? - } + rmcp::model::ClientResult::ElicitResult(result) => serde_json::to_value(result) + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?, result => { return Err(rmcp::ErrorData::internal_error( format!("unexpected OpenAI form response: {result:?}"), @@ -762,14 +826,43 @@ impl ServerHandler for ElicitationAppsMcpServer { }, }) ); - Ok(CallToolResult::success(vec![Content::text( - "accepted monthly-review", - )])) + Ok( + CallToolResult::success(vec![ContentBlock::text("accepted monthly-review")]) + .into(), + ) } } } } +fn sep1034_schema() -> Value { + json!({ + "type": "object", + "properties": { + "name": {"type": "string", "default": "John Doe"}, + "age": {"type": "integer", "default": 30}, + "score": {"type": "number", "default": 95.5}, + "status": { + "type": "string", + "enum": ["active", "inactive", "pending"], + "default": "active", + }, + "verified": {"type": "boolean", "default": true}, + }, + "required": [], + }) +} + +fn sep1034_defaults() -> Value { + json!({ + "name": "John Doe", + "age": 30, + "score": 95.5, + "status": "active", + "verified": true, + }) +} + async fn start_apps_server(scenario: ElicitationScenario) -> Result<(String, JoinHandle<()>)> { let state = Arc::new(AppsServerState { expected_bearer: "Bearer chatgpt-token".to_string(), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index e892b0491799..47504f6d6fb4 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -313,11 +313,7 @@ impl ServerHandler for McpStatusServer { ); tool.annotations = Some(ToolAnnotations::new().read_only(true)); - Ok(ListToolsResult { - tools: vec![tool], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![tool])) } } @@ -354,11 +350,7 @@ impl ServerHandler for SlowInventoryServer { ); tool.annotations = Some(ToolAnnotations::new().read_only(true)); - Ok(ListToolsResult { - tools: vec![tool], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![tool])) } async fn list_resources( @@ -367,11 +359,7 @@ impl ServerHandler for SlowInventoryServer { _context: RequestContext, ) -> Result { tokio::time::sleep(Duration::from_secs(2)).await; - Ok(ListResourcesResult { - resources: Vec::new(), - next_cursor: None, - meta: None, - }) + Ok(ListResourcesResult::with_all_items(Vec::new())) } async fn list_resource_templates( @@ -380,11 +368,7 @@ impl ServerHandler for SlowInventoryServer { _context: RequestContext, ) -> Result { tokio::time::sleep(Duration::from_secs(2)).await; - Ok(ListResourceTemplatesResult { - resource_templates: Vec::new(), - next_cursor: None, - meta: None, - }) + Ok(ListResourceTemplatesResult::with_all_items(Vec::new())) } } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index eae0efab0d42..8b681837e91d 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -41,14 +41,14 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::BooleanSchema; use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; -use rmcp::model::Content; -use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ContentBlock; +use rmcp::model::ElicitRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; use rmcp::model::Meta; -use rmcp::model::PrimitiveSchema; +use rmcp::model::PrimitiveSchemaDefinition; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -219,7 +219,10 @@ async fn mcp_server_tool_call_round_trips_elicitation() -> Result<()> { }; let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value( ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .required_property( + "confirmed", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) .build() .map_err(anyhow::Error::msg)?, )?)?; @@ -607,18 +610,14 @@ impl ServerHandler for ToolAppsMcpServer { ); tool.annotations = Some(ToolAnnotations::new().read_only(true)); - Ok(ListToolsResult { - tools: vec![tool], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![tool])) } async fn call_tool( &self, request: CallToolRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { assert_eq!(request.name.as_ref(), TEST_TOOL_NAME); let message = request .arguments @@ -629,6 +628,7 @@ impl ServerHandler for ToolAppsMcpServer { let thread_id = context .meta .0 + .0 .get("threadId") .and_then(|value| value.as_str()) .unwrap_or_default(); @@ -641,19 +641,22 @@ impl ServerHandler for ToolAppsMcpServer { let mut result = CallToolResult::structured(json!({ "large": "structured-value-".repeat(DEFAULT_OUTPUT_BYTES_CAP / 8), })); - result.content = vec![Content::text(large_text)]; + result.content = vec![ContentBlock::text(large_text)]; result.meta = Some(meta); - return Ok(result); + return Ok(result.into()); } if message == ELICITATION_TRIGGER_MESSAGE { let requested_schema = ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .required_property( + "confirmed", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) .build() .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; let result = context .peer - .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + .create_elicitation(ElicitRequestParams::FormElicitationParams { meta: None, message: ELICITATION_MESSAGE.to_string(), requested_schema, @@ -672,14 +675,20 @@ impl ServerHandler for ToolAppsMcpServer { } ElicitationAction::Decline => "declined", ElicitationAction::Cancel => "cancelled", + _ => { + return Err(rmcp::ErrorData::invalid_params( + "unsupported MCP elicitation action", + None, + )); + } }; - return Ok(CallToolResult::success(vec![Content::text(output)])); + return Ok(CallToolResult::success(vec![ContentBlock::text(output)]).into()); } if message == URL_ELICITATION_TRIGGER_MESSAGE { let result = context .peer - .create_elicitation(CreateElicitationRequestParams::UrlElicitationParams { + .create_elicitation(ElicitRequestParams::UrlElicitationParams { meta: None, message: URL_ELICITATION_MESSAGE.to_string(), url: URL_ELICITATION_URL.to_string(), @@ -694,17 +703,23 @@ impl ServerHandler for ToolAppsMcpServer { } ElicitationAction::Decline => "declined", ElicitationAction::Cancel => "cancelled", + _ => { + return Err(rmcp::ErrorData::invalid_params( + "unsupported MCP elicitation action", + None, + )); + } }; - return Ok(CallToolResult::success(vec![Content::text(output)])); + return Ok(CallToolResult::success(vec![ContentBlock::text(output)]).into()); } let mut result = CallToolResult::structured(json!({ "echoed": message, "threadId": thread_id, })); - result.content = vec![Content::text(format!("echo: {message}"))]; + result.content = vec![ContentBlock::text(format!("echo: {message}"))]; result.meta = Some(meta); - Ok(result) + Ok(result.into()) } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 1d7fdf25b6e8..88e513410289 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1885,11 +1885,7 @@ impl ServerHandler for PluginInstallMcpServer { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .clone(); - Ok(ListToolsResult { - tools, - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(tools)) } } } diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 82e97b1da206..e0742e91f2e3 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -51,7 +51,7 @@ use rmcp::RoleServer; use rmcp::ServerHandler; use rmcp::ServiceExt; use rmcp::model::ClientCapabilities; -use rmcp::model::CreateElicitationRequestParams; +use rmcp::model::ElicitRequestParams; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationCapability; use rmcp::model::Implementation; @@ -244,11 +244,7 @@ impl ServerHandler for RefreshTestTransportFactory { if let Some(release_list) = &self.release_list { release_list.notified().await; } - Ok(ListToolsResult { - tools: vec![self.tool.clone()], - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(vec![self.tool.clone()])) } } @@ -567,15 +563,13 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { let response = sender( NumberOrString::Number(1), - codex_rmcp_client::Elicitation::Mcp( - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "Confirm?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, - ), + codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "Confirm?".to_string(), + requested_schema: rmcp::model::ElicitationSchema::builder() + .build() + .expect("schema should build"), + }), ) .await .expect("elicitation should auto accept"); @@ -604,19 +598,20 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel let response = sender( NumberOrString::Number(1), - codex_rmcp_client::Elicitation::Mcp( - CreateElicitationRequestParams::FormElicitationParams { - meta: None, - message: "What should I say?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() + codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams { + meta: None, + message: "What should I say?".to_string(), + requested_schema: + rmcp::model::ElicitationSchema::builder() .required_property( "message", - rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), + rmcp::model::PrimitiveSchemaDefinition::String( + rmcp::model::StringSchema::new(), + ), ) .build() .expect("schema should build"), - }, - ), + }), ) .await .expect("elicitation should auto decline"); @@ -658,15 +653,14 @@ async fn concurrent_authority_updates_never_auto_approve_mixed_policy() { } }); let sender = manager.make_sender("server".to_string(), /*tx_event*/ None); - let elicitation = codex_rmcp_client::Elicitation::Mcp( - CreateElicitationRequestParams::FormElicitationParams { + let elicitation = + codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams { meta: None, message: "Confirm?".to_string(), requested_schema: rmcp::model::ElicitationSchema::builder() .build() .expect("schema should build"), - }, - ); + }); for _ in 0..1_000 { let response = sender(NumberOrString::Number(1), elicitation.clone()) @@ -721,19 +715,21 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() { let (tx_event, rx_event) = async_channel::bounded(2); let sender_a = manager_a.make_sender("server".to_string(), Some(tx_event.clone())); let sender_b = manager_b.make_sender("server".to_string(), Some(tx_event)); - let elicitation = codex_rmcp_client::Elicitation::Mcp( - CreateElicitationRequestParams::FormElicitationParams { + let elicitation = + codex_rmcp_client::Elicitation::Mcp(ElicitRequestParams::FormElicitationParams { meta: None, message: "Which runtime?".to_string(), - requested_schema: rmcp::model::ElicitationSchema::builder() - .required_property( - "runtime", - rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()), - ) - .build() - .expect("schema should build"), - }, - ); + requested_schema: + rmcp::model::ElicitationSchema::builder() + .required_property( + "runtime", + rmcp::model::PrimitiveSchemaDefinition::String( + rmcp::model::StringSchema::new(), + ), + ) + .build() + .expect("schema should build"), + }); let pending_a = tokio::spawn(sender_a(NumberOrString::Number(1), elicitation.clone())); let EventMsg::ElicitationRequest(request_a) = rx_event.recv().await.expect("request A").msg @@ -2376,10 +2372,11 @@ fn elicitation_capability_uses_2025_06_18_shape_for_form_only_support() { #[test] fn elicitation_capability_advertises_url_support_when_enabled() { - let capability = Some(ElicitationCapability { - form: Some(rmcp::model::FormElicitationCapability::default()), - url: Some(rmcp::model::UrlElicitationCapability::default()), - }); + let capability = Some( + ElicitationCapability::new() + .with_form(rmcp::model::FormElicitationCapability::new()) + .with_url(rmcp::model::UrlElicitationCapability::new()), + ); assert_eq!( serde_json::to_value(capability).expect("serialize elicitation capability"), serde_json::json!({ diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index 34d7b314df67..c61fd5c0d922 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -248,13 +248,11 @@ impl ElicitationRequestManager { ); let routed_request_id = RequestId::String(public_request_id.clone().into()); let request = match elicitation { - Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::FormElicitationParams { - meta, - message, - requested_schema, - }, - ) => ElicitationRequest::Form { + Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams { + meta, + message, + requested_schema, + }) => ElicitationRequest::Form { meta: meta .map(serde_json::to_value) .transpose() @@ -263,14 +261,12 @@ impl ElicitationRequestManager { requested_schema: serde_json::to_value(requested_schema) .context("failed to serialize MCP elicitation schema")?, }, - Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { - meta, - message, - url, - elicitation_id, - }, - ) => ElicitationRequest::Url { + Elicitation::Mcp(rmcp::model::ElicitRequestParams::UrlElicitationParams { + meta, + message, + url, + elicitation_id, + }) => ElicitationRequest::Url { meta: meta .map(serde_json::to_value) .transpose() @@ -279,6 +275,13 @@ impl ElicitationRequestManager { url, elicitation_id, }, + Elicitation::Mcp(_) => { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + } Elicitation::OpenAiForm { meta, message, @@ -327,16 +330,13 @@ type ResponderMap = HashMap<(String, RequestId), oneshot::Sender bool { match elicitation { - Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams { requested_schema, .. }) => { // Auto-accept confirm/approval elicitations without schema requirements. requested_schema.properties.is_empty() } - Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { - .. - }) - | Elicitation::OpenAiForm { .. } => false, + Elicitation::Mcp(_) | Elicitation::OpenAiForm { .. } => false, } } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 448d7a22eff6..cf0690ac017b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6410,10 +6410,9 @@ async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std:: let mcp_config = config.to_mcp_config(&plugins_manager).await; assert_eq!( mcp_config.client_elicitation_capability, - ElicitationCapability { - form: Some(FormElicitationCapability::default()), - url: Some(UrlElicitationCapability::default()), - } + ElicitationCapability::new() + .with_form(FormElicitationCapability::new()) + .with_url(UrlElicitationCapability::new()) ); let _ = config.features.disable(Feature::AuthElicitation); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 652d4e247a17..3bff0d0fb9af 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1735,10 +1735,9 @@ impl Config { Vec::new() }, client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) { - ElicitationCapability { - form: Some(FormElicitationCapability::default()), - url: Some(UrlElicitationCapability::default()), - } + ElicitationCapability::new() + .with_form(FormElicitationCapability::new()) + .with_url(UrlElicitationCapability::new()) } else { // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities // indicates this should be an empty object. diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 3ae964292345..9cebe69cf587 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1850,6 +1850,7 @@ fn parse_mcp_tool_approval_elicitation_response( } ElicitationAction::Decline => McpToolApprovalDecision::Decline { message: None }, ElicitationAction::Cancel => McpToolApprovalDecision::Cancel, + _ => McpToolApprovalDecision::Cancel, } } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index d7ac66201309..740f1321a57e 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -324,6 +324,7 @@ pub async fn resolve_elicitation( // Preserve the legacy fallback for clients that only send an action. ElicitationAction::Accept => Some(content.unwrap_or_else(|| serde_json::json!({}))), ElicitationAction::Decline | ElicitationAction::Cancel => None, + _ => None, }; let response = ElicitationResponse { action, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 3bc630de476a..eb57ce1d2178 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -25,7 +25,7 @@ use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_P use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY; use codex_rmcp_client::Elicitation; use rmcp::model::ElicitationAction; -use rmcp::model::Meta; +use rmcp::model::RequestMetaObject; use serde_json::Map; const MCP_ELICITATION_DECLINE_MESSAGE_KEY: &str = "message"; @@ -594,7 +594,7 @@ async fn review_guardian_mcp_elicitation( ) && matches!( &request.elicitation, Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + rmcp::model::ElicitRequestParams::FormElicitationParams { requested_schema, .. } @@ -656,14 +656,13 @@ fn guardian_elicitation_review_request( request: &ElicitationReviewRequest, ) -> GuardianElicitationReview { let (meta, requested_schema) = match &request.elicitation { - Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams { + Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams { meta, requested_schema, .. }) => (meta, Some(requested_schema)), - Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { - meta, - .. + Elicitation::Mcp(rmcp::model::ElicitRequestParams::UrlElicitationParams { + meta, .. }) => { return if meta_requests_approval_request(meta) { GuardianElicitationReview::Decline( @@ -673,10 +672,15 @@ fn guardian_elicitation_review_request( GuardianElicitationReview::NotRequested }; } + Elicitation::Mcp(_) => { + return GuardianElicitationReview::Decline( + "guardian MCP elicitation review does not support this elicitation mode", + ); + } Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested, }; - let Some(meta) = meta.as_ref().map(|meta| &meta.0) else { + let Some(meta) = meta.as_ref().map(|meta| &meta.0.0) else { return GuardianElicitationReview::NotRequested; }; if metadata_str(meta, MCP_ELICITATION_REQUEST_TYPE_KEY) @@ -742,7 +746,7 @@ fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> { .and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY)) } -fn meta_requests_approval_request(meta: &Option) -> bool { +fn meta_requests_approval_request(meta: &Option) -> bool { meta.as_ref() .and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_REQUEST_TYPE_KEY)) == Some(MCP_ELICITATION_REQUEST_TYPE_APPROVAL_REQUEST) diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index 1dd941d7ef02..ec4ee2ed2234 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -1,17 +1,17 @@ use super::*; use rmcp::model::BooleanSchema; use rmcp::model::ElicitationSchema; -use rmcp::model::PrimitiveSchema; +use rmcp::model::PrimitiveSchemaDefinition; use serde_json::json; -fn meta(value: Value) -> Option { +fn meta(value: Value) -> Option { let Value::Object(map) = value else { panic!("metadata must be an object"); }; - Some(Meta(map)) + Some(RequestMetaObject::from(map)) } -fn guardian_meta(tool_params: Option) -> Option { +fn guardian_meta(tool_params: Option) -> Option { let mut value = json!({ "codex_approval_kind": "mcp_tool_call", "codex_request_type": "approval_request", @@ -26,19 +26,17 @@ fn guardian_meta(tool_params: Option) -> Option { meta(value) } -fn form_request(meta: Option) -> ElicitationReviewRequest { +fn form_request(meta: Option) -> ElicitationReviewRequest { ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(7), - elicitation: Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::FormElicitationParams { - meta, - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .build() - .expect("schema should build"), - }, - ), + elicitation: Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams { + meta, + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .build() + .expect("schema should build"), + }), } } @@ -175,14 +173,12 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let url_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(8), - elicitation: Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::UrlElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Open URL".to_string(), - url: "https://example.com".to_string(), - elicitation_id: "elicit-1".to_string(), - }, - ), + elicitation: Elicitation::Mcp(rmcp::model::ElicitRequestParams::UrlElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Open URL".to_string(), + url: "https://example.com".to_string(), + elicitation_id: "elicit-1".to_string(), + }), }; assert!(matches!( guardian_elicitation_review_request(&url_request), @@ -192,16 +188,17 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() { let non_empty_schema_request = ElicitationReviewRequest { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(9), - elicitation: Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::FormElicitationParams { - meta: guardian_meta(Some(json!({}))), - message: "Allow origin?".to_string(), - requested_schema: ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) - .build() - .expect("schema should build"), - }, - ), + elicitation: Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams { + meta: guardian_meta(Some(json!({}))), + message: "Allow origin?".to_string(), + requested_schema: ElicitationSchema::builder() + .required_property( + "confirmed", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) + .build() + .expect("schema should build"), + }), }; assert!(matches!( guardian_elicitation_review_request(&non_empty_schema_request), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c0dea6f759a6..88b9345f705c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8190,12 +8190,14 @@ async fn mcp_elicitation_reviewer_uses_latest_runtime_authority() { server_name: "browser-use".to_string(), request_id: rmcp::model::NumberOrString::Number(7), elicitation: codex_rmcp_client::Elicitation::Mcp( - rmcp::model::CreateElicitationRequestParams::FormElicitationParams { - meta: Some(rmcp::model::Meta(serde_json::Map::from_iter([ - ("codex_approval_kind".to_string(), json!("mcp_tool_call")), - ("codex_request_type".to_string(), json!("approval_request")), - ("tool_name".to_string(), json!("access_browser_origin")), - ]))), + rmcp::model::ElicitRequestParams::FormElicitationParams { + meta: Some(rmcp::model::RequestMetaObject::from( + serde_json::Map::from_iter([ + ("codex_approval_kind".to_string(), json!("mcp_tool_call")), + ("codex_request_type".to_string(), json!("approval_request")), + ("tool_name".to_string(), json!("access_browser_origin")), + ]), + )), message: "Allow origin?".to_string(), requested_schema: rmcp::model::ElicitationSchema::builder() .build() diff --git a/codex-rs/core/src/tools/handlers/mcp_resource_tests.rs b/codex-rs/core/src/tools/handlers/mcp_resource_tests.rs index b52ab1681abc..1155060d7c97 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource_tests.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource_tests.rs @@ -1,33 +1,14 @@ use super::*; use pretty_assertions::assert_eq; -use rmcp::model::AnnotateAble; use rmcp::model::ResourceContents; use serde_json::json; fn resource(uri: &str, name: &str) -> Resource { - rmcp::model::RawResource { - uri: uri.to_string(), - name: name.to_string(), - title: None, - description: None, - mime_type: None, - size: None, - icons: None, - meta: None, - } - .no_annotation() + Resource::new(uri, name) } fn template(uri_template: &str, name: &str) -> ResourceTemplate { - rmcp::model::RawResourceTemplate { - uri_template: uri_template.to_string(), - name: name.to_string(), - title: None, - description: None, - mime_type: None, - icons: None, - } - .no_annotation() + ResourceTemplate::new(uri_template, name) } #[test] @@ -42,11 +23,8 @@ fn resource_with_server_serializes_server_field() { #[test] fn list_resources_payload_from_single_server_copies_next_cursor() { - let result = ListResourcesResult { - meta: None, - next_cursor: Some("cursor-1".to_string()), - resources: vec![resource("memo://id", "memo")], - }; + let mut result = ListResourcesResult::with_all_items(vec![resource("memo://id", "memo")]); + result.next_cursor = Some("cursor-1".to_string()); let payload = ListResourcesPayload::from_single_server("srv".to_string(), result); let value = serde_json::to_value(&payload).expect("serialize payload"); diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index 5afb527fcd1d..6bc7507a4049 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -251,6 +251,7 @@ impl RequestPluginInstallHandler { Some(ElicitationAction::Accept) => "accept", Some(ElicitationAction::Decline) => "decline", Some(ElicitationAction::Cancel) => "cancel", + Some(_) => "unknown", None => "unavailable", }; turn.session_telemetry.record_plugin_install_suggestion( diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 0e9131b23451..4be99e9503a7 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -25,7 +25,7 @@ use codex_protocol::protocol::Submission; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::user_input::UserInput; use rmcp::model::CallToolResult; -use rmcp::model::Content; +use rmcp::model::ContentBlock; use rmcp::model::RequestId; use serde_json::json; use tokio::sync::Mutex; @@ -40,7 +40,7 @@ pub(crate) fn create_call_tool_result_with_thread_id( is_error: Option, ) -> CallToolResult { let content_text = text; - let content = vec![Content::text(content_text.clone())]; + let content = vec![ContentBlock::text(content_text.clone())]; let structured_content = json!({ "threadId": thread_id, "content": content_text, @@ -73,7 +73,7 @@ pub async fn run_codex_tool_session( { Ok(res) => res, Err(e) => { - let result = CallToolResult::error(vec![Content::text(format!( + let result = CallToolResult::error(vec![ContentBlock::text(format!( "Failed to start Codex session: {e}" ))]); outgoing.send_response(id.clone(), result).await; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 2a13a4d61f6f..38824ab002ac 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -147,16 +147,8 @@ impl MessageProcessor { ClientRequest::CompleteRequest(params) => { self.handle_complete(params.params); } - ClientRequest::GetTaskInfoRequest(_) => { - self.handle_unsupported_request(request_id, "tasks/get_info") - .await; - } - ClientRequest::ListTasksRequest(_) => { - self.handle_unsupported_request(request_id, "tasks/list") - .await; - } - ClientRequest::GetTaskResultRequest(_) => { - self.handle_unsupported_request(request_id, "tasks/get_result") + ClientRequest::GetTaskRequest(_) => { + self.handle_unsupported_request(request_id, "tasks/get") .await; } ClientRequest::CancelTaskRequest(_) => { @@ -176,6 +168,10 @@ impl MessageProcessor { ) .await; } + request => { + self.handle_unsupported_request(request_id, request.method()) + .await; + } } } @@ -205,6 +201,9 @@ impl MessageProcessor { ClientNotification::CustomNotification(_) => { tracing::warn!("ignoring custom client notification"); } + _ => { + tracing::warn!("ignoring unsupported client notification"); + } } } @@ -330,14 +329,10 @@ impl MessageProcessor { params: Option, ) { tracing::trace!("tools/list -> {params:?}"); - let result = rmcp::model::ListToolsResult { - meta: None, - tools: vec![ - create_tool_for_codex_tool_call_param(), - create_tool_for_codex_tool_call_reply_param(), - ], - next_cursor: None, - }; + let result = rmcp::model::ListToolsResult::with_all_items(vec![ + create_tool_for_codex_tool_call_param(), + create_tool_for_codex_tool_call_reply_param(), + ]); self.outgoing.send_response(id, result).await; } @@ -355,7 +350,7 @@ impl MessageProcessor { .await } _ => { - let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(format!( "Unknown tool '{name}'" ))]); self.outgoing.send_response(id, result).await; @@ -374,7 +369,7 @@ impl MessageProcessor { Ok(tool_cfg) => match tool_cfg.into_config(self.arg0_paths.clone()).await { Ok(cfg) => cfg, Err(e) => { - let result = CallToolResult::error(vec![rmcp::model::Content::text( + let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text( format!("Failed to load Codex configuration from overrides: {e}"), )]); self.outgoing.send_response(id, result).await; @@ -382,15 +377,15 @@ impl MessageProcessor { } }, Err(e) => { - let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( - "Failed to parse configuration for Codex tool: {e}" - ))]); + let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text( + format!("Failed to parse configuration for Codex tool: {e}"), + )]); self.outgoing.send_response(id, result).await; return; } }, None => { - let result = CallToolResult::error(vec![rmcp::model::Content::text( + let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text( "Missing arguments for codex tool-call; the `prompt` field is required.", )]); self.outgoing.send_response(id, result).await; @@ -433,9 +428,9 @@ impl MessageProcessor { Ok(params) => params, Err(e) => { tracing::error!("Failed to parse Codex tool call reply parameters: {e}"); - let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( - "Failed to parse configuration for Codex tool: {e}" - ))]); + let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text( + format!("Failed to parse configuration for Codex tool: {e}"), + )]); self.outgoing.send_response(request_id, result).await; return; } @@ -444,7 +439,7 @@ impl MessageProcessor { tracing::error!( "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required." ); - let result = CallToolResult::error(vec![rmcp::model::Content::text( + let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text( "Missing arguments for codex-reply tool-call; the `thread_id` and `prompt` fields are required.", )]); self.outgoing.send_response(request_id, result).await; @@ -456,7 +451,7 @@ impl MessageProcessor { Ok(id) => id, Err(e) => { tracing::error!("Failed to parse thread_id: {e}"); - let result = CallToolResult::error(vec![rmcp::model::Content::text(format!( + let result = CallToolResult::error(vec![rmcp::model::ContentBlock::text(format!( "Failed to parse thread_id: {e}" ))]); self.outgoing.send_response(request_id, result).await; @@ -502,6 +497,7 @@ impl MessageProcessor { }); } + #[allow(deprecated)] fn handle_set_level(&self, params: rmcp::model::SetLevelRequestParams) { tracing::info!("logging/setLevel -> params: {:?}", params); } @@ -528,7 +524,10 @@ impl MessageProcessor { // --------------------------------------------------------------------- async fn handle_cancelled_notification(&self, params: rmcp::model::CancelledNotificationParam) { - let request_id = params.request_id; + let Some(request_id) = params.request_id else { + tracing::warn!("ignoring cancellation without a request id"); + return; + }; // Create a stable string form early for logging and submission id. let request_id_string = request_id.to_string(); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 3790fed60bf7..bb32f2a4b8b2 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -80,7 +80,7 @@ impl OutgoingMessageSender { } pub(crate) async fn send_response(&self, id: RequestId, response: T) { - let result = match serde_json::to_value(response) { + let mut result = match serde_json::to_value(response) { Ok(result) => result, Err(err) => { self.send_error( @@ -92,6 +92,16 @@ impl OutgoingMessageSender { } }; + // rmcp result constructors include the modern discriminator by default. + // This legacy server serializes responses directly, bypassing rmcp's + // protocol-aware response handling, so preserve the historical wire shape: + // https://github.com/modelcontextprotocol/rust-sdk/issues/1036 + if let Value::Object(object) = &mut result + && object.get("resultType").and_then(Value::as_str) == Some("complete") + { + object.remove("resultType"); + } + let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result }); let _ = self.sender.send(outgoing_message); } @@ -286,6 +296,25 @@ mod tests { ); } + #[tokio::test] + async fn outgoing_tool_response_preserves_legacy_wire_format() { + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); + let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); + + outgoing_message_sender + .send_response( + RequestId::Number(1), + rmcp::model::CallToolResult::success(Vec::new()), + ) + .await; + + let Some(OutgoingMessage::Response(response)) = outgoing_rx.recv().await else { + panic!("expected a tool-call response"); + }; + assert_eq!(response.id, RequestId::Number(1)); + assert_eq!(response.result, json!({ "content": [], "isError": false })); + } + #[tokio::test] async fn test_send_event_as_notification() -> Result<()> { let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index 007d2c11cca7..9b4badd4ce7c 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -115,12 +115,8 @@ impl McpProcess { let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }); + capabilities.elicitation = + Some(ElicitationCapability::new().with_form(FormElicitationCapability::new())); let params = InitializeRequestParams::new( capabilities, Implementation::new("elicitation test", "0.0.0").with_title("Elicitation Test"), diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 8c6dcd0e569a..7dd513b64dc3 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -50,7 +50,7 @@ rmcp = { workspace = true, default-features = false, features = [ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } -sse-stream = "0.2.1" +sse-stream = "0.2.5" thiserror = { workspace = true } tiny_http = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs index 092de2f056c9..8b0d48e065e2 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -4,6 +4,9 @@ use std::time::Duration; use anyhow::Result; use codex_exec_server::HttpClient; +use codex_exec_server::RouteAwareHttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_protocol::protocol::McpAuthStatus; use futures::FutureExt; use reqwest::Client; @@ -16,7 +19,6 @@ use tracing::debug; use crate::oauth::StoredOAuthTokenStatus; use crate::oauth::oauth_token_status; use crate::oauth_http_client::OAuthHttpClientAdapter; -use crate::utils::apply_default_headers; use crate::utils::build_default_headers; use codex_config::types::AuthKeyringBackendKind; use codex_config::types::OAuthCredentialsStoreMode; @@ -258,10 +260,23 @@ async fn discover_streamable_http_oauth_with_headers( ) -> Result> { // Use no_proxy to avoid a bug in the system-configuration crate that // can result in a panic. See #8912. - let builder = Client::builder().timeout(DISCOVERY_TIMEOUT).no_proxy(); - let client = apply_default_headers(builder, default_headers).build()?; - let mut authorization_manager = AuthorizationManager::new(url).await?; - authorization_manager.with_client(client)?; + let client = Client::builder() + .timeout(DISCOVERY_TIMEOUT) + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let oauth_http_client = Arc::new( + OAuthHttpClientAdapter::new( + Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))), + default_headers.clone(), + ) + .with_local_http_client(client), + ); + let mut authorization_manager = + AuthorizationManager::new_with_oauth_http_client(url, oauth_http_client).await?; + authorization_manager.set_allow_missing_issuer(true); discover_streamable_http_oauth_with_manager(&authorization_manager).await } @@ -279,17 +294,19 @@ async fn discover_streamable_http_oauth_with_headers_and_http_client( OAuthHttpClientAdapter::new_with_max_timeout(http_client, default_headers, max_timeout) } }; - let authorization_manager = + let mut authorization_manager = AuthorizationManager::new_with_oauth_http_client(url, Arc::new(oauth_http_client)).await?; + authorization_manager.set_allow_missing_issuer(true); discover_streamable_http_oauth_with_manager(&authorization_manager).await } async fn discover_streamable_http_oauth_with_manager( authorization_manager: &AuthorizationManager, ) -> Result> { - match authorization_manager.discover_metadata().boxed().await { - Ok(metadata) => Ok(Some(StreamableHttpOAuthDiscovery { - scopes_supported: normalize_scopes(metadata.scopes_supported), + match authorization_manager.resolve_metadata().boxed().await { + Ok(resolution) if !resolution.source.is_discovered() => Ok(None), + Ok(resolution) => Ok(Some(StreamableHttpOAuthDiscovery { + scopes_supported: normalize_scopes(resolution.metadata.scopes_supported), })), Err(AuthError::NoAuthorizationSupport) => Ok(None), Err(err) => Err(err.into()), @@ -337,6 +354,12 @@ mod tests { use std::ffi::OsString; use std::sync::Mutex; use tokio::task::JoinHandle; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::header; + use wiremock::matchers::method; + use wiremock::matchers::path; struct TestServer { url: String, @@ -388,6 +411,12 @@ mod tests { .await .expect("listener should bind"); let address = listener.local_addr().expect("listener should have address"); + let mut metadata = metadata; + if let Some(metadata) = metadata.as_object_mut() { + metadata + .entry("issuer") + .or_insert_with(|| format!("http://{address}/mcp").into()); + } let app = Router::new().route( "/.well-known/oauth-authorization-server/mcp", get({ @@ -482,6 +511,98 @@ mod tests { assert_eq!(status, McpAuthState::BearerToken); } + #[tokio::test] + async fn oauth_metadata_preserves_login_without_probing_anonymous_tools() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let metadata = serde_json::json!({ + "issuer": format!("http://{address}/mcp"), + "authorization_endpoint": format!("http://{address}/authorize"), + "token_endpoint": format!("http://{address}/token"), + }); + let app = Router::new() + .route( + "/mcp", + get(|| async { StatusCode::METHOD_NOT_ALLOWED }).post( + |Json(request): Json| async move { + let result = match request["method"].as_str() { + Some("initialize") => serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "oauth", "version": "1"}, + }), + Some("tools/list") => serde_json::json!({"tools": []}), + _ => serde_json::json!({}), + }; + Json(serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": result, + })) + }, + ), + ) + .route( + "/.well-known/oauth-authorization-server/mcp", + get(move || async move { Json(metadata) }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.expect("server should run"); + }); + let url = format!("http://{address}/mcp"); + let discovery = discover_streamable_http_oauth( + &url, /*http_headers*/ None, /*env_http_headers*/ None, + ) + .await; + assert_eq!( + determine_auth_status_from_discovery("server", &url, discovery) + .expect("auth status should compute"), + McpAuthState::LoggedOut(McpLoginRequirement::Login) + ); + server.abort(); + } + + #[tokio::test] + async fn oauth_discovery_does_not_follow_cross_origin_redirects() { + let redirect_target = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/redirect-target")) + .and(header("x-api-key", "sensitive-key")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&redirect_target) + .await; + + let resource_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/mcp")) + .and(header("x-api-key", "sensitive-key")) + .respond_with(ResponseTemplate::new(302).insert_header( + "location", + format!("{}/redirect-target", redirect_target.uri()), + )) + .expect(1) + .mount(&resource_server) + .await; + + let discovery = discover_streamable_http_oauth( + &format!("{}/mcp", resource_server.uri()), + Some(HashMap::from([( + "x-api-key".to_string(), + "sensitive-key".to_string(), + )])), + /*env_http_headers*/ None, + ) + .await + .expect("discovery should complete without following the redirect"); + + assert_eq!(discovery, None); + redirect_target.verify().await; + resource_server.verify().await; + } + #[tokio::test] async fn discover_streamable_http_oauth_returns_normalized_scopes() { let server = spawn_oauth_discovery_server(serde_json::json!({ diff --git a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs index 6911afb21168..a2ab794f5ab7 100644 --- a/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs +++ b/codex-rs/rmcp-client/src/bin/rmcp_test_server.rs @@ -93,20 +93,14 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> impl std::future::Future> + Send + '_ { let tools = self.tools.clone(); - async move { - Ok(ListToolsResult { - tools: (*tools).clone(), - next_cursor: None, - meta: None, - }) - } + async move { Ok(ListToolsResult::with_all_items((*tools).clone())) } } async fn call_tool( &self, request: CallToolRequestParams, _context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { match request.name.as_ref() { "echo" => { let args: EchoArgs = match request.arguments { @@ -131,7 +125,7 @@ impl ServerHandler for TestToolServer { let mut result = CallToolResult::success(Vec::new()); result.structured_content = Some(structured_content); - Ok(result) + Ok(result.into()) } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 38944ab44bae..0978fd073a30 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -22,8 +22,6 @@ use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::Meta; use rmcp::model::PaginatedRequestParams; -use rmcp::model::RawResource; -use rmcp::model::RawResourceTemplate; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::Resource; @@ -368,31 +366,17 @@ impl TestToolServer { } fn memo_resource() -> Resource { - let raw = RawResource { - uri: MEMO_URI.to_string(), - name: "example-note".to_string(), - title: Some("Example Note".to_string()), - description: Some("A sample MCP resource exposed for integration tests.".to_string()), - mime_type: Some("text/plain".to_string()), - size: None, - icons: None, - meta: None, - }; - Resource::new(raw, None) + Resource::new(MEMO_URI, "example-note") + .with_title("Example Note") + .with_description("A sample MCP resource exposed for integration tests.") + .with_mime_type("text/plain") } fn memo_template() -> ResourceTemplate { - let raw = RawResourceTemplate { - uri_template: "memo://codex/{slug}".to_string(), - name: "codex-memo".to_string(), - title: Some("Codex Memo".to_string()), - description: Some( - "Template for memo://codex/{slug} resources used in tests.".to_string(), - ), - mime_type: Some("text/plain".to_string()), - icons: None, - }; - ResourceTemplate::new(raw, None) + ResourceTemplate::new("memo://codex/{slug}", "codex-memo") + .with_title("Codex Memo") + .with_description("Template for memo://codex/{slug} resources used in tests.") + .with_mime_type("text/plain") } fn memo_text() -> &'static str { @@ -533,11 +517,7 @@ impl ServerHandler for TestToolServer { .get_or_insert_with(Meta::new) .insert("ui".to_string(), json!({ "visibility": ["app"] })); } - Ok(ListToolsResult { - tools, - next_cursor: None, - meta: None, - }) + Ok(ListToolsResult::with_all_items(tools)) } } @@ -547,13 +527,7 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> impl std::future::Future> + Send + '_ { let resources = self.resources.clone(); - async move { - Ok(ListResourcesResult { - resources: (*resources).clone(), - next_cursor: None, - meta: None, - }) - } + async move { Ok(ListResourcesResult::with_all_items((*resources).clone())) } } async fn list_resource_templates( @@ -561,27 +535,26 @@ impl ServerHandler for TestToolServer { _request: Option, _context: rmcp::service::RequestContext, ) -> Result { - Ok(ListResourceTemplatesResult { - resource_templates: (*self.resource_templates).clone(), - next_cursor: None, - meta: None, - }) + Ok(ListResourceTemplatesResult::with_all_items( + (*self.resource_templates).clone(), + )) } async fn read_resource( &self, ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams, _context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { if uri == MEMO_URI { - Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { + Ok( + ReadResourceResult::new(vec![ResourceContents::TextResourceContents { uri, mime_type: Some("text/plain".to_string()), text: Self::memo_text().to_string(), meta: None, - }, - ])) + }]) + .into(), + ) } else { Err(McpError::resource_not_found( "resource_not_found", @@ -594,7 +567,7 @@ impl ServerHandler for TestToolServer { &self, request: CallToolRequestParams, context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { match request.name.as_ref() { "client_capabilities" => Ok(Self::structured_result(json!({ "supportsOpenaiFormElicitation": self @@ -602,7 +575,7 @@ impl ServerHandler for TestToolServer { .load(Ordering::Relaxed), }))), "sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object( - context.meta.0, + context.meta.0.0, ))), "cwd" => { let cwd = std::env::current_dir() @@ -620,10 +593,12 @@ impl ServerHandler for TestToolServer { McpError::invalid_params("missing threadId metadata".to_string(), None) })?; Ok(CallToolResult::success(vec![ - rmcp::model::Content::text(format!( + rmcp::model::ContentBlock::text(format!( "manual history hint for thread {thread_id}" )), - rmcp::model::Content::text("unstructured notes/thread_hint fixture result"), + rmcp::model::ContentBlock::text( + "unstructured notes/thread_hint fixture result", + ), ])) } "echo" | "echo-tool" => { @@ -655,13 +630,9 @@ impl ServerHandler for TestToolServer { let mut meta = Meta::new(); meta.insert("codex/encryptedContent".to_string(), json!(true)); let mut result = CallToolResult::success(vec![ - rmcp::model::Content::text("Lookup completed"), - rmcp::model::Annotated::new( - rmcp::model::RawContent::Text(rmcp::model::RawTextContent { - text: "gAAAA-test".to_string(), - meta: Some(meta), - }), - None, + rmcp::model::ContentBlock::text("Lookup completed"), + rmcp::model::ContentBlock::Text( + rmcp::model::TextContent::new("gAAAA-test").with_meta(meta), ), ]); result.structured_content = Some(json!({"encrypted_output": "ignored"})); @@ -684,9 +655,9 @@ impl ServerHandler for TestToolServer { ) })?; - Ok(CallToolResult::success(vec![rmcp::model::Content::image( - data_b64, mime_type, - )])) + Ok(CallToolResult::success(vec![ + rmcp::model::ContentBlock::image(data_b64, mime_type), + ])) } "image_scenario" => { let args = Self::parse_call_args::(&request, "image_scenario")?; @@ -705,6 +676,7 @@ impl ServerHandler for TestToolServer { None, )), } + .map(Into::into) } } @@ -744,7 +716,7 @@ impl TestToolServer { let mut content = Vec::new(); match args.scenario { ImageScenario::ImageOnly => { - content.push(rmcp::model::Content::image(valid_data_b64, mime_type)); + content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type)); } ImageScenario::ImageOnlyOriginalDetail => { let mut meta = rmcp::model::Meta::new(); @@ -752,46 +724,41 @@ impl TestToolServer { "codex/imageDetail".to_string(), serde_json::json!("original"), ); - content.push(rmcp::model::Annotated::new( - rmcp::model::RawContent::Image(rmcp::model::RawImageContent { - data: valid_data_b64, - mime_type, - meta: Some(meta), - }), - None, + content.push(rmcp::model::ContentBlock::Image( + rmcp::model::ImageContent::new(valid_data_b64, mime_type).with_meta(meta), )); } ImageScenario::TextThenImage => { - content.push(rmcp::model::Content::text(caption)); - content.push(rmcp::model::Content::image(valid_data_b64, mime_type)); + content.push(rmcp::model::ContentBlock::text(caption)); + content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type)); } ImageScenario::InvalidBase64ThenImage => { - content.push(rmcp::model::Content::image( + content.push(rmcp::model::ContentBlock::image( "not-base64".to_string(), "image/png".to_string(), )); - content.push(rmcp::model::Content::image(valid_data_b64, mime_type)); + content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type)); } ImageScenario::InvalidImageBytesThenImage => { - content.push(rmcp::model::Content::image( + content.push(rmcp::model::ContentBlock::image( "bm90IGFuIGltYWdl".to_string(), "image/png".to_string(), )); - content.push(rmcp::model::Content::image(valid_data_b64, mime_type)); + content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type)); } ImageScenario::MultipleValidImages => { - content.push(rmcp::model::Content::image( + content.push(rmcp::model::ContentBlock::image( valid_data_b64.clone(), mime_type.clone(), )); - content.push(rmcp::model::Content::image(valid_data_b64, mime_type)); + content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type)); } ImageScenario::ImageThenText => { - content.push(rmcp::model::Content::image(valid_data_b64, mime_type)); - content.push(rmcp::model::Content::text(caption)); + content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type)); + content.push(rmcp::model::ContentBlock::text(caption)); } ImageScenario::TextOnly => { - content.push(rmcp::model::Content::text(caption)); + content.push(rmcp::model::ContentBlock::text(caption)); } } diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 5232c04dd427..e4e5dd98a95e 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -34,8 +34,6 @@ use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParams; -use rmcp::model::RawResource; -use rmcp::model::RawResourceTemplate; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::Resource; @@ -222,13 +220,7 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> impl std::future::Future> + Send + '_ { let tools = self.tools.clone(); - async move { - Ok(ListToolsResult { - tools: (*tools).clone(), - next_cursor: None, - meta: None, - }) - } + async move { Ok(ListToolsResult::with_all_items((*tools).clone())) } } fn list_resources( @@ -237,13 +229,7 @@ impl ServerHandler for TestToolServer { _context: rmcp::service::RequestContext, ) -> impl std::future::Future> + Send + '_ { let resources = self.resources.clone(); - async move { - Ok(ListResourcesResult { - resources: (*resources).clone(), - next_cursor: None, - meta: None, - }) - } + async move { Ok(ListResourcesResult::with_all_items((*resources).clone())) } } async fn list_resource_templates( @@ -251,27 +237,26 @@ impl ServerHandler for TestToolServer { _request: Option, _context: rmcp::service::RequestContext, ) -> Result { - Ok(ListResourceTemplatesResult { - resource_templates: (*self.resource_templates).clone(), - next_cursor: None, - meta: None, - }) + Ok(ListResourceTemplatesResult::with_all_items( + (*self.resource_templates).clone(), + )) } async fn read_resource( &self, ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams, _context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { if uri == MEMO_URI { - Ok(ReadResourceResult::new(vec![ - ResourceContents::TextResourceContents { + Ok( + ReadResourceResult::new(vec![ResourceContents::TextResourceContents { uri, mime_type: Some("text/plain".to_string()), text: Self::memo_text().to_string(), meta: None, - }, - ])) + }]) + .into(), + ) } else { Err(McpError::resource_not_found( "resource_not_found", @@ -284,7 +269,7 @@ impl ServerHandler for TestToolServer { &self, request: CallToolRequestParams, _context: rmcp::service::RequestContext, - ) -> Result { + ) -> Result { match request.name.as_ref() { "echo" => { let args: EchoArgs = match request.arguments { @@ -308,7 +293,7 @@ impl ServerHandler for TestToolServer { let mut result = CallToolResult::success(Vec::new()); result.structured_content = Some(structured_content); - Ok(result) + Ok(result.into()) } other => Err(McpError::invalid_params( format!("unknown tool: {other}"), @@ -370,31 +355,17 @@ impl TestToolServer { } fn memo_resource() -> Resource { - let raw = RawResource { - uri: MEMO_URI.to_string(), - name: "example-note".to_string(), - title: Some("Example Note".to_string()), - description: Some("A sample MCP resource exposed for integration tests.".to_string()), - mime_type: Some("text/plain".to_string()), - size: None, - icons: None, - meta: None, - }; - Resource::new(raw, None) + Resource::new(MEMO_URI, "example-note") + .with_title("Example Note") + .with_description("A sample MCP resource exposed for integration tests.") + .with_mime_type("text/plain") } fn memo_template() -> ResourceTemplate { - let raw = RawResourceTemplate { - uri_template: "memo://codex/{slug}".to_string(), - name: "codex-memo".to_string(), - title: Some("Codex Memo".to_string()), - description: Some( - "Template for memo://codex/{slug} resources used in tests.".to_string(), - ), - mime_type: Some("text/plain".to_string()), - icons: None, - }; - ResourceTemplate::new(raw, None) + ResourceTemplate::new("memo://codex/{slug}", "codex-memo") + .with_title("Codex Memo") + .with_description("Template for memo://codex/{slug} resources used in tests.") + .with_mime_type("text/plain") } fn memo_text() -> &'static str { diff --git a/codex-rs/rmcp-client/src/elicitation_client_service.rs b/codex-rs/rmcp-client/src/elicitation_client_service.rs index 2227ee4d63c2..5d89c08ca9a5 100644 --- a/codex-rs/rmcp-client/src/elicitation_client_service.rs +++ b/codex-rs/rmcp-client/src/elicitation_client_service.rs @@ -6,7 +6,7 @@ use rmcp::model::ClientResult; use rmcp::model::CustomRequest; use rmcp::model::CustomResult; use rmcp::model::ElicitationAction; -use rmcp::model::Meta; +use rmcp::model::RequestMetaObject; use rmcp::model::RequestParamsMeta; use rmcp::model::ServerNotification; use rmcp::model::ServerRequest; @@ -25,6 +25,7 @@ use crate::rmcp_client::ElicitationResponse; use crate::rmcp_client::SendElicitation; const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken"; +const MCP_ELICITATION_CREATE_METHOD: &str = "elicitation/create"; const OPENAI_FORM_METHOD: &str = "openai/form"; #[derive(Deserialize)] @@ -92,14 +93,24 @@ impl Service for ElicitationClientService { context: RequestContext, ) -> Result { match request { - ServerRequest::CreateElicitationRequest(request) => { + ServerRequest::ElicitRequest(request) => { let response = self .create_elicitation(Elicitation::Mcp(request.params), context) .await?; - // RMCP's typed CreateElicitationResult does not model result-level `_meta`. + // RMCP's typed ElicitResult does not model result-level `_meta`. let result = elicitation_response_result(response)?; Ok(ClientResult::CustomResult(result)) } + ServerRequest::CustomRequest(request) + if request.method == MCP_ELICITATION_CREATE_METHOD => + { + let response = self + .create_elicitation(custom_mcp_elicitation(request)?, context) + .await?; + Ok(ClientResult::CustomResult(elicitation_response_result( + response, + )?)) + } ServerRequest::CustomRequest(request) if request.method == OPENAI_FORM_METHOD && self.supports_openai_form => { @@ -139,6 +150,15 @@ impl Service for ElicitationClientService { } } +fn custom_mcp_elicitation(request: CustomRequest) -> Result { + let raw_params = request + .params + .ok_or_else(|| rmcp::ErrorData::invalid_params("missing params", None))?; + let params: rmcp::model::ElicitRequestParams = serde_json::from_value(raw_params) + .map_err(|err| rmcp::ErrorData::invalid_params(err.to_string(), None))?; + Ok(Elicitation::Mcp(params)) +} + fn openai_form_elicitation(request: CustomRequest) -> Result { let params = request .params_as::() @@ -151,7 +171,10 @@ fn openai_form_elicitation(request: CustomRequest) -> Result Elicitation { +fn restore_context_meta( + mut request: Elicitation, + mut context_meta: RequestMetaObject, +) -> Elicitation { // RMCP lifts JSON-RPC `_meta` into RequestContext before invoking services. context_meta.remove(MCP_PROGRESS_TOKEN_META_KEY); if context_meta.is_empty() { @@ -161,14 +184,14 @@ fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Eli match &mut request { Elicitation::Mcp(request) => request .meta_mut() - .get_or_insert_with(Meta::new) + .get_or_insert_with(RequestMetaObject::new) .extend(context_meta), Elicitation::OpenAiForm { meta, .. } => { let meta = meta .get_or_insert_with(|| Value::Object(Map::new())) .as_object_mut(); if let Some(meta) = meta { - meta.extend(context_meta.0); + meta.extend(context_meta.0.0); } } } @@ -208,9 +231,9 @@ fn elicitation_response_result( mod tests { use pretty_assertions::assert_eq; use rmcp::model::BooleanSchema; - use rmcp::model::CreateElicitationRequestParams; + use rmcp::model::ElicitRequestParams; use rmcp::model::ElicitationSchema; - use rmcp::model::PrimitiveSchema; + use rmcp::model::PrimitiveSchemaDefinition; use serde_json::Value; use serde_json::json; @@ -234,6 +257,101 @@ mod tests { ); } + #[test] + fn legacy_sep1034_elicitation_without_mode_preserves_schema_defaults() { + let request = json!({ + "method": "elicitation/create", + "params": { + "message": "Confirm the default values", + "requestedSchema": { + "type": "object", + "properties": { + "name": {"type": "string", "default": "John Doe"}, + "age": {"type": "integer", "default": 30}, + "score": {"type": "number", "default": 95.5}, + "status": { + "type": "string", + "enum": ["active", "inactive"], + "default": "active", + }, + "verified": {"type": "boolean", "default": true}, + }, + "required": [], + }, + }, + }); + + let request = serde_json::from_value::(request) + .expect("legacy form elicitations must deserialize without a mode"); + let ServerRequest::ElicitRequest(request) = request else { + panic!("legacy elicitation/create must dispatch to the typed handler"); + }; + let rmcp::model::ElicitRequestParams::FormElicitationParams { + requested_schema, .. + } = request.params + else { + panic!("an omitted legacy elicitation mode must default to form"); + }; + + assert_eq!( + serde_json::to_value(requested_schema) + .expect("legacy schema defaults must remain serializable"), + json!({ + "type": "object", + "properties": { + "name": {"type": "string", "default": "John Doe"}, + "age": {"type": "integer", "default": 30}, + "score": {"type": "number", "default": 95.5}, + "status": { + "type": "string", + "enum": ["active", "inactive"], + "default": "active", + }, + "verified": {"type": "boolean", "default": true}, + }, + "required": [], + }) + ); + } + + #[test] + fn parses_legacy_custom_elicitation_without_mode() { + let request = CustomRequest::new( + MCP_ELICITATION_CREATE_METHOD, + Some(json!({ + "message": "Confirm?", + "requestedSchema": { + "type": "object", + "properties": { + "confirmed": {"type": "boolean"}, + "age": {"type": "integer", "minimum": 1, "maximum": 99, "default": 30}, + }, + "required": ["confirmed"], + }, + })), + ); + let Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams { + requested_schema, + .. + }) = custom_mcp_elicitation(request) + .expect("legacy custom elicitation parameters must deserialize") + else { + panic!("omitted legacy elicitation mode must default to form"); + }; + + assert_eq!( + serde_json::to_value(requested_schema).expect("schema must serialize"), + json!({ + "type": "object", + "properties": { + "confirmed": {"type": "boolean"}, + "age": {"type": "integer", "minimum": 1, "maximum": 99, "default": 30}, + }, + "required": ["confirmed"], + }) + ); + } + #[test] fn parses_openai_form_custom_requests() { let elicitation = openai_form_elicitation(CustomRequest::new( @@ -300,21 +418,24 @@ mod tests { ); } - fn form_request(meta: Option) -> CreateElicitationRequestParams { - CreateElicitationRequestParams::FormElicitationParams { + fn form_request(meta: Option) -> ElicitRequestParams { + ElicitRequestParams::FormElicitationParams { meta, message: "Confirm?".to_string(), requested_schema: ElicitationSchema::builder() - .required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new())) + .required_property( + "confirmed", + PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()), + ) .build() .expect("schema should build"), } } - fn meta(value: Value) -> Meta { + fn meta(value: Value) -> RequestMetaObject { let Value::Object(map) = value else { panic!("meta must be an object"); }; - Meta(map) + RequestMetaObject::from(map) } } diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs index 19befb62355e..fee3b3fc234a 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter.rs @@ -285,7 +285,7 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { async fn get_stream( &self, uri: Arc, - session_id: Arc, + session_id: Option>, last_event_id: Option, auth_token: Option, custom_headers: HashMap, @@ -302,12 +302,14 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { [EVENT_STREAM_MIME_TYPE, JSON_MIME_TYPE].join(", "), StreamableHttpClientAdapterError::Header, )?; - insert_header( - &mut headers, - HeaderName::from_static("mcp-session-id"), - session_id.to_string(), - StreamableHttpClientAdapterError::Header, - )?; + if let Some(session_id) = session_id { + insert_header( + &mut headers, + HeaderName::from_static("mcp-session-id"), + session_id.to_string(), + StreamableHttpClientAdapterError::Header, + )?; + } if let Some(last_event_id) = last_event_id { insert_header( &mut headers, @@ -422,6 +424,7 @@ fn client_jsonrpc_message_fields( ClientNotification::CustomNotification(notification) => { notification.method.as_str() } + _ => return (None, None), }; (Some(method.to_string()), None) } @@ -555,7 +558,7 @@ async fn collect_body( fn sse_stream_from_body( body_stream: HttpResponseBodyStream, ) -> BoxStream<'static, std::result::Result> { - SseStream::from_byte_stream(stream::unfold(body_stream, |mut body_stream| async move { + SseStream::from_bytes_stream(stream::unfold(body_stream, |mut body_stream| async move { match body_stream.recv().await { Ok(Some(bytes)) => Some((Ok(Bytes::from(bytes)), body_stream)), Ok(None) => None, diff --git a/codex-rs/rmcp-client/src/logging_client_handler.rs b/codex-rs/rmcp-client/src/logging_client_handler.rs index e575966cffff..98887d749011 100644 --- a/codex-rs/rmcp-client/src/logging_client_handler.rs +++ b/codex-rs/rmcp-client/src/logging_client_handler.rs @@ -4,9 +4,11 @@ use rmcp::ClientHandler; use rmcp::RoleClient; use rmcp::model::CancelledNotificationParam; use rmcp::model::ClientInfo; -use rmcp::model::CreateElicitationRequestParams; -use rmcp::model::CreateElicitationResult; +use rmcp::model::ElicitRequestParams; +use rmcp::model::ElicitResult; +#[allow(deprecated)] use rmcp::model::LoggingLevel; +#[allow(deprecated)] use rmcp::model::LoggingMessageNotificationParam; use rmcp::model::ProgressNotificationParam; use rmcp::model::ResourceUpdatedNotificationParam; @@ -38,9 +40,9 @@ impl LoggingClientHandler { impl ClientHandler for LoggingClientHandler { async fn create_elicitation( &self, - request: CreateElicitationRequestParams, + request: ElicitRequestParams, context: RequestContext, - ) -> Result { + ) -> Result { (self.send_elicitation)(context.id, Elicitation::Mcp(request)) .await .map(Into::into) @@ -53,7 +55,7 @@ impl ClientHandler for LoggingClientHandler { _context: NotificationContext, ) { info!( - "MCP server cancelled request (request_id: {}, reason: {:?})", + "MCP server cancelled request (request_id: {:?}, reason: {:?})", params.request_id, params.reason ); } @@ -93,6 +95,7 @@ impl ClientHandler for LoggingClientHandler { self.client_info.clone() } + #[allow(deprecated)] async fn on_logging_message( &self, params: LoggingMessageNotificationParam, @@ -102,6 +105,7 @@ impl ClientHandler for LoggingClientHandler { level, logger, data, + .. } = params; let logger = logger.as_deref(); match level { diff --git a/codex-rs/rmcp-client/src/oauth/refresh_transaction.rs b/codex-rs/rmcp-client/src/oauth/refresh_transaction.rs index 9a656ed3cf5f..14a3d8645464 100644 --- a/codex-rs/rmcp-client/src/oauth/refresh_transaction.rs +++ b/codex-rs/rmcp-client/src/oauth/refresh_transaction.rs @@ -175,17 +175,12 @@ impl OAuthPersistor { debug!("received refreshed MCP OAuth credentials from the provider"); refreshed_tokens(token_response, &latest, &self.inner) } - Ok(Err(error @ AuthError::TokenRefreshFailed(_))) => { - // RMCP 1.8 collapses definitive OAuth rejection (for example, - // `invalid_grant`) and transient token-endpoint failures into this string - // variant. Match RMCP's own request path for now so rejected refresh tokens - // prompt reauthorization instead of surfacing as generic MCP startup failures. - // This can also prompt reauthorization after a transient failure. - // TODO: When RMCP exposes a typed distinction for refresh-token rejection, - // map only that definitive rejection to `AuthorizationRequired` here. + Ok(Err(error @ AuthError::TokenRefreshRejected(_))) => { + // RMCP 3 distinguishes definitive refresh-token rejection from transient + // provider failures. Only a rejected token requires a fresh authorization. warn!( error = %error, - "MCP OAuth refresh failed; reauthorization required by RMCP compatibility policy" + "MCP OAuth refresh token was rejected; reauthorization required" ); return Err(AuthError::AuthorizationRequired).with_context(|| { format!( diff --git a/codex-rs/rmcp-client/src/oauth/tests/persistor_tests.rs b/codex-rs/rmcp-client/src/oauth/tests/persistor_tests.rs index c6006ff99f0d..7f8a62a5ec56 100644 --- a/codex-rs/rmcp-client/src/oauth/tests/persistor_tests.rs +++ b/codex-rs/rmcp-client/src/oauth/tests/persistor_tests.rs @@ -243,6 +243,39 @@ async fn rejected_refresh_token_requires_reauthorization() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn transient_refresh_failure_does_not_require_reauthorization() -> Result<()> { + let (_env, server, initial) = test_context().await?; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains("refresh_token=refresh-token")) + .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({ + "error": "temporarily_unavailable", + "error_description": "provider is temporarily unavailable", + }))) + .expect(1) + .mount(&server) + .await; + save_oauth_tokens_to_file(&initial)?; + let persistor = persistor_for(&initial).await?; + + let error = persistor + .refresh_if_needed() + .await + .expect_err("a transient provider failure should not erase valid credentials"); + assert!(!is_authentication_required_error(&error)); + assert!(error.chain().any(|source| matches!( + source.downcast_ref::(), + Some(AuthError::TokenRefreshFailed(_)) + ))); + let stored = load_oauth_tokens_from_file(&initial.server_name, &initial.url)? + .expect("a transient refresh failure must preserve durable credentials"); + assert_tokens_match_without_expiry(&stored, &initial); + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn caller_cancellation_does_not_cancel_refresh_persistence() -> Result<()> { let (_env, server, initial) = test_context().await?; @@ -382,6 +415,7 @@ async fn mount_oauth_metadata(server: &MockServer) { Mock::given(method("GET")) .and(path("/.well-known/oauth-authorization-server/mcp")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": format!("{}/mcp", server.uri()), "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), "token_endpoint": format!("{}/oauth/token", server.uri()), "scopes_supported": ["scope-a", "scope-b"], diff --git a/codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs b/codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs index 685c9d569e4e..3278365e01c7 100644 --- a/codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs +++ b/codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs @@ -6,6 +6,7 @@ use std::time::Instant; use anyhow::Context; use anyhow::Result; use codex_config::types::AuthKeyringBackendKind; +use codex_keyring_store::KeyringStore; use codex_keyring_store::tests::MockKeyringStore; use oauth2::AccessToken; use oauth2::RefreshToken; @@ -105,6 +106,47 @@ fn sample_tokens() -> StoredOAuthTokens { } } +#[test] +fn legacy_rmcp_oauth_keyring_credentials_remain_readable() -> Result<()> { + let _env = TempCodexHome::new(); + let keyring_store = MockKeyringStore::default(); + let mut expected = sample_tokens(); + expected.expires_at = None; + expected.token_response.0.set_expires_in(None); + + let serialized = serde_json::json!({ + "server_name": "test-server", + "url": "https://example.test", + "client_id": "client-id", + "token_response": { + "access_token": "access-token", + "token_type": "Bearer", + "refresh_token": "refresh-token", + "scope": "scope-a scope-b", + }, + }) + .to_string(); + let key = crate::oauth::compute_store_key(&expected.server_name, &expected.url)?; + keyring_store.save(crate::oauth::KEYRING_SERVICE, &key, &serialized)?; + + let resolved = resolve_oauth_tokens_from_store_policy( + &keyring_store, + &expected.server_name, + &expected.url, + OAuthCredentialsStoreMode::Auto, + AuthKeyringBackendKind::Direct, + )? + .expect("OAuth credentials written before the rmcp upgrade should remain readable"); + + assert_eq!( + resolved.store, + crate::oauth::ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct) + ); + assert_tokens_match_without_expiry(&resolved.tokens, &expected); + assert!(crate::oauth::oauth_tokens_are_usable(&resolved.tokens)); + Ok(()) +} + const LOCK_HOLDER_CHILD_TEST: &str = "oauth::store_lock::tests::store_lock_is_released_when_holder_process_exits_child"; const LOCK_HOLDER_READY_PATH_ENV: &str = "CODEX_OAUTH_STORE_LOCK_CHILD_READY_PATH"; diff --git a/codex-rs/rmcp-client/src/oauth_http_client.rs b/codex-rs/rmcp-client/src/oauth_http_client.rs index a3f23d4cf40d..3cc9ed49a5d1 100644 --- a/codex-rs/rmcp-client/src/oauth_http_client.rs +++ b/codex-rs/rmcp-client/src/oauth_http_client.rs @@ -7,8 +7,10 @@ use codex_exec_server::HttpClient; use codex_exec_server::HttpHeader; use codex_exec_server::HttpRedirectPolicy; use codex_exec_server::HttpRequestParams; +use futures::StreamExt; use oauth2::HttpRequest; use oauth2::HttpResponse; +use reqwest::Client; use reqwest::header::HeaderMap; use rmcp::transport::auth::OAuthHttpClient; use rmcp::transport::auth::OAuthHttpClientError; @@ -26,6 +28,7 @@ pub(crate) struct OAuthHttpClientAdapter { http_client: Arc, default_headers: HeaderMap, timeout: OAuthDiscoveryTimeout, + local_http_client: Option, } impl OAuthHttpClientAdapter { @@ -34,6 +37,7 @@ impl OAuthHttpClientAdapter { http_client, default_headers, timeout: OAuthDiscoveryTimeout::Requested, + local_http_client: None, } } @@ -46,10 +50,16 @@ impl OAuthHttpClientAdapter { http_client, default_headers, timeout: OAuthDiscoveryTimeout::Capped(max_timeout), + local_http_client: None, } } - async fn execute_request( + pub(crate) fn with_local_http_client(mut self, client: Client) -> Self { + self.local_http_client = Some(client); + self + } + + async fn execute_transport_request( &self, request: HttpRequest, redirect_policy: OAuthHttpRedirectPolicy, @@ -64,12 +74,44 @@ impl OAuthHttpClientAdapter { )); } }; - let (parts, body) = request.into_parts(); + let (mut parts, body) = request.into_parts(); let mut headers = self.default_headers.clone(); for name in parts.headers.keys() { headers.remove(name); } headers.extend(parts.headers); + + if let Some(client) = &self.local_http_client { + parts.headers = headers; + let request = HttpRequest::from_parts(parts, body); + let request = reqwest::Request::try_from(request) + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + let response = client + .execute(request) + .await + .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + let mut builder = oauth2::http::Response::builder() + .status(response.status()) + .version(response.version()); + for (name, value) in response.headers() { + builder = builder.header(name, value); + } + let mut body = Vec::new(); + let mut body_stream = response.bytes_stream(); + while let Some(chunk) = body_stream.next().await { + let chunk = chunk.map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + if chunk.len() > MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES - body.len() { + return Err(OAuthHttpClientError::new(format!( + "OAuth HTTP response body exceeds {MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES} bytes" + ))); + } + body.extend_from_slice(&chunk); + } + return builder + .body(body) + .map_err(|error| OAuthHttpClientError::new(error.to_string())); + } + let headers = headers .iter() .map(|(name, value)| { @@ -133,6 +175,10 @@ impl OAuthHttpClientAdapter { impl OAuthHttpClient for OAuthHttpClientAdapter { fn execute(&self, request: OAuthHttpRequest) -> OAuthHttpClientFuture<'_> { - Box::pin(self.execute_request(request.request, request.redirect_policy, request.timeout)) + Box::pin(self.execute_transport_request( + request.request, + request.redirect_policy, + request.timeout, + )) } } diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index e5723f114b31..ad493a2956bf 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -15,6 +15,7 @@ use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use reqwest::Url; use rmcp::transport::AuthorizationManager; +use rmcp::transport::AuthorizationRequest; use rmcp::transport::AuthorizationSession; use rmcp::transport::auth::OAuthClientConfig; use rmcp::transport::auth::OAuthHttpClient; @@ -272,16 +273,22 @@ fn spawn_callback_server( while let Ok(request) = server.recv() { let path = request.url().to_string(); match parse_oauth_callback(&path, &expected_callback_path) { - CallbackOutcome::Success(OauthCallbackResult { code, state }) => { + CallbackOutcome::Success(OauthCallbackResult { + code, + state, + issuer, + }) => { let response = Response::from_string( "Authentication complete. You may close this window.", ); if let Err(err) = request.respond(response) { eprintln!("Failed to respond to OAuth callback: {err}"); } - if let Err(err) = - tx.send(CallbackResult::Success(OauthCallbackResult { code, state })) - { + if let Err(err) = tx.send(CallbackResult::Success(OauthCallbackResult { + code, + state, + issuer, + })) { eprintln!("Failed to send OAuth callback: {err:?}"); } break; @@ -312,6 +319,7 @@ fn spawn_callback_server( struct OauthCallbackResult { code: String, state: String, + issuer: Option, } #[derive(Debug)] @@ -339,6 +347,7 @@ fn parse_oauth_callback(path: &str, expected_callback_path: &str) -> CallbackOut let mut state = None; let mut error = None; let mut error_description = None; + let mut issuer = None; for pair in query.split('&') { let Some((key, value)) = pair.split_once('=') else { @@ -353,12 +362,17 @@ fn parse_oauth_callback(path: &str, expected_callback_path: &str) -> CallbackOut "state" => state = Some(decoded), "error" => error = Some(decoded), "error_description" => error_description = Some(decoded), + "iss" => issuer = Some(decoded), _ => {} } } if let (Some(code), Some(state)) = (code, state) { - return CallbackOutcome::Success(OauthCallbackResult { code, state }); + return CallbackOutcome::Success(OauthCallbackResult { + code, + state, + issuer, + }); } if error.is_some() || error_description.is_some() { @@ -603,13 +617,14 @@ impl OauthLoginFlow { let OauthCallbackResult { code, state: csrf_state, + issuer, } = match callback { CallbackResult::Success(callback) => callback, CallbackResult::Error(error) => return Err(anyhow!(error)), }; self.oauth_state - .handle_callback(&code, &csrf_state) + .handle_callback_with_issuer(&code, &csrf_state, issuer.as_deref()) .await .context("failed to handle OAuth callback")?; @@ -671,19 +686,24 @@ async fn start_authorization( redirect_uri: &str, oauth_client_id: Option<&str>, ) -> Result { + let mut auth_manager = + AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?; + auth_manager.set_allow_missing_issuer(true); + let Some(oauth_client_id) = oauth_client_id.filter(|client_id| !client_id.trim().is_empty()) else { - let mut oauth_state = - OAuthState::new_with_oauth_http_client(server_url, http_client).await?; + let mut oauth_state = OAuthState::Unauthorized(auth_manager); oauth_state - .start_authorization(scopes, redirect_uri, Some("Codex")) + .start_authorization( + AuthorizationRequest::new(redirect_uri) + .with_scopes(scopes.iter().copied()) + .with_client_name("Codex"), + ) .await?; return Ok(oauth_state); }; - let mut auth_manager = - AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?; - let metadata = auth_manager.discover_metadata().await?; + let metadata = auth_manager.resolve_metadata().await?.metadata; auth_manager.set_metadata(metadata); auth_manager.configure_client( OAuthClientConfig::new(oauth_client_id, redirect_uri) @@ -716,10 +736,13 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String { #[cfg(test)] mod tests { use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; use axum::Json; use axum::Router; use axum::routing::get; + use axum::routing::post; use codex_exec_server::RouteAwareHttpClient; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; @@ -746,6 +769,7 @@ mod tests { let addr = listener.local_addr().expect("read metadata listener addr"); let base_url = format!("http://{addr}"); let metadata = json!({ + "issuer": format!("{base_url}/mcp"), "authorization_endpoint": format!("{base_url}/oauth/authorize"), "token_endpoint": format!("{base_url}/oauth/token"), "scopes_supported": [""], @@ -807,12 +831,127 @@ mod tests { assert_eq!(client_id.as_deref(), Some("eci-prd-pub-codex-123")); } + #[tokio::test] + async fn oauth_callback_validates_rfc_9207_issuer_before_token_exchange() { + for (supports_issuer, callback_issuer, expected_token_requests) in [ + (true, Some("matching"), 1), + (true, Some("mismatched"), 0), + (true, None, 0), + (false, Some("mismatched"), 0), + (false, None, 1), + ] { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind authorization metadata listener"); + let issuer = format!( + "http://{}", + listener.local_addr().expect("read listener address") + ); + let token_requests = Arc::new(AtomicUsize::new(0)); + let captured_token_requests = Arc::clone(&token_requests); + let authorization_issuer = format!("{issuer}/mcp"); + let metadata = json!({ + "issuer": authorization_issuer, + "authorization_endpoint": format!("{issuer}/authorize"), + "token_endpoint": format!("{issuer}/token"), + "authorization_response_iss_parameter_supported": supports_issuer, + }); + let scoped_metadata = metadata.clone(); + let app = Router::new() + .route( + "/.well-known/oauth-authorization-server/mcp", + get(move || { + let metadata = scoped_metadata.clone(); + async move { Json(metadata) } + }), + ) + .route( + "/.well-known/oauth-authorization-server", + get(move || { + let metadata = metadata.clone(); + async move { Json(metadata) } + }), + ) + .route( + "/token", + post(move || { + let token_requests = Arc::clone(&captured_token_requests); + async move { + token_requests.fetch_add(1, Ordering::SeqCst); + Json(json!({"access_token":"test-token","token_type":"Bearer"})) + } + }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve authorization metadata fixture"); + }); + let mut state = start_authorization( + &format!("{issuer}/mcp"), + Arc::new(OAuthHttpClientAdapter::new( + Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))), + HeaderMap::new(), + )), + &[], + "http://127.0.0.1/callback", + Some("test-client"), + ) + .await + .expect("start issuer-aware authorization"); + let csrf_state = Url::parse( + &state + .get_authorization_url() + .await + .expect("retrieve authorization URL"), + ) + .expect("parse authorization URL") + .query_pairs() + .find(|(key, _)| key == "state") + .map(|(_, value)| value.into_owned()) + .expect("authorization URL should contain state"); + let callback_issuer = match callback_issuer { + Some("matching") => Some(authorization_issuer.as_str()), + Some(_) => Some("https://unexpected.example"), + None => None, + }; + let result = state + .handle_callback_with_issuer("test-code", &csrf_state, callback_issuer) + .await; + + assert_eq!( + token_requests.load(Ordering::SeqCst), + expected_token_requests + ); + assert_eq!(result.is_ok(), expected_token_requests == 1); + server.abort(); + } + } + #[test] fn parse_oauth_callback_accepts_default_path() { let parsed = parse_oauth_callback("/callback?code=abc&state=xyz", "/callback"); assert!(matches!(parsed, CallbackOutcome::Success(_))); } + #[test] + fn parse_oauth_callback_preserves_rfc_9207_issuer() { + let parsed = parse_oauth_callback( + "/callback?code=abc&state=xyz&iss=https%3A%2F%2Fissuer.example", + "/callback", + ); + assert_eq!( + parsed, + CallbackOutcome::Success(super::OauthCallbackResult { + code: "abc".to_string(), + state: "xyz".to_string(), + issuer: Some("https://issuer.example".to_string()), + }) + ); + } + #[test] fn parse_oauth_callback_accepts_custom_path() { let parsed = parse_oauth_callback("/oauth/callback?code=abc&state=xyz", "/oauth/callback"); diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index d649afa5ff07..a02d61753135 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -25,10 +25,10 @@ use rmcp::model::CallToolRequestParams; use rmcp::model::CallToolResult; use rmcp::model::ClientNotification; use rmcp::model::ClientRequest; -use rmcp::model::CreateElicitationRequestParams; -use rmcp::model::CreateElicitationResult; use rmcp::model::CustomNotification; use rmcp::model::CustomRequest; +use rmcp::model::ElicitRequestParams; +use rmcp::model::ElicitResult; use rmcp::model::ElicitationAction; use rmcp::model::Extensions; use rmcp::model::InitializeRequestParams; @@ -40,12 +40,15 @@ use rmcp::model::PaginatedRequestParams; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; +use rmcp::model::RequestMetaObject; use rmcp::model::RequestParamsMeta; use rmcp::model::ServerResult; use rmcp::model::Tool; +use rmcp::service::ClientCacheConfig; use rmcp::service::RoleClient; use rmcp::service::RunningService; use rmcp::service::{self}; +use rmcp::transport::AuthorizationManager; use rmcp::transport::StreamableHttpClientTransport; use rmcp::transport::auth::AuthClient; use rmcp::transport::auth::AuthError; @@ -258,7 +261,7 @@ fn remaining_operation_timeout( #[derive(Debug, Clone, PartialEq)] pub enum Elicitation { - Mcp(CreateElicitationRequestParams), + Mcp(ElicitRequestParams), OpenAiForm { meta: Option, message: String, @@ -269,7 +272,7 @@ pub enum Elicitation { impl Elicitation { pub fn meta(&self) -> Option<&serde_json::Map> { match self { - Self::Mcp(request) => request.meta().map(|meta| &meta.0), + Self::Mcp(request) => request.meta().map(|meta| &meta.0.0), Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object), } } @@ -284,8 +287,8 @@ pub struct ElicitationResponse { pub meta: Option, } -impl From for ElicitationResponse { - fn from(value: CreateElicitationResult) -> Self { +impl From for ElicitationResponse { + fn from(value: ElicitResult) -> Self { Self { action: value.action, content: value.content, @@ -294,13 +297,11 @@ impl From for ElicitationResponse { } } -impl From for CreateElicitationResult { +impl From for ElicitResult { fn from(value: ElicitationResponse) -> Self { - Self { - action: value.action, - content: value.content, - meta: None, - } + let mut result = Self::new(value.action); + result.content = value.content; + result } } @@ -595,7 +596,7 @@ impl RmcpClient { let result = self .run_service_operation("resources/read", timeout, move |service| { let params = params.clone(); - async move { service.read_resource(params).await }.boxed() + async move { service.peer().read_resource(params).await }.boxed() }) .await?; self.persist_oauth_tokens().await; @@ -620,7 +621,7 @@ impl RmcpClient { None => None, }; let meta = match meta { - Some(Value::Object(map)) => Some(rmcp::model::Meta(map)), + Some(Value::Object(map)) => Some(RequestMetaObject::from(map)), Some(other) => { return Err(anyhow!( "MCP tool request _meta must be a JSON object, got {other}" @@ -988,6 +989,13 @@ impl RmcpClient { } }; + // Preserve Codex's existing snapshot and request-freshness behavior. rmcp 3 + // enables response caching and stale-on-error fallback by default. + service + .peer() + .set_response_cache_config(ClientCacheConfig::disabled()) + .await; + Ok((Arc::new(service), oauth_persistor)) } @@ -1223,8 +1231,11 @@ async fn create_oauth_transport_and_runtime( http_client.clone(), default_headers.clone(), )); - let mut oauth_state = - OAuthState::new_with_oauth_http_client(url.to_string(), oauth_http_client).await?; + let mut manager = + AuthorizationManager::new_with_oauth_http_client(url.to_string(), oauth_http_client) + .await?; + manager.set_allow_missing_issuer(true); + let mut oauth_state = OAuthState::Unauthorized(manager); oauth_state .set_credentials( diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index db6170c8318f..1ba7e45d8cd4 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -1,7 +1,6 @@ use anyhow::Result; use anyhow::anyhow; use codex_config::types::McpServerEnvVar; -use reqwest::ClientBuilder; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; @@ -119,17 +118,6 @@ pub(crate) fn build_default_headers( Ok(headers) } -pub(crate) fn apply_default_headers( - builder: ClientBuilder, - default_headers: &HeaderMap, -) -> ClientBuilder { - if default_headers.is_empty() { - builder - } else { - builder.default_headers(default_headers.clone()) - } -} - #[cfg(unix)] pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[ "HOME", diff --git a/codex-rs/rmcp-client/tests/mcp_2026_oauth_discovery.rs b/codex-rs/rmcp-client/tests/mcp_2026_oauth_discovery.rs new file mode 100644 index 000000000000..acb58b687646 --- /dev/null +++ b/codex-rs/rmcp-client/tests/mcp_2026_oauth_discovery.rs @@ -0,0 +1,209 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use codex_exec_server::RouteAwareHttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use codex_rmcp_client::OAuthDiscoveryTimeout; +use codex_rmcp_client::StreamableHttpOAuthDiscovery; +use codex_rmcp_client::discover_streamable_http_oauth; +use codex_rmcp_client::discover_streamable_http_oauth_with_http_client; +use pretty_assertions::assert_eq; +use rmcp::transport::auth::AuthError; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const RESOURCE_AUTHORIZATION: &str = "Bearer resource-only-secret"; +const RESOURCE_API_KEY: &str = "resource-api-key-secret"; + +type DiscoveryResult = anyhow::Result>; + +#[derive(Clone, Copy)] +enum AuthorizationMetadataIssuer { + Matching, + Missing, + Mismatched, +} + +fn resource_headers() -> Option> { + Some(HashMap::from([ + ( + "Authorization".to_string(), + RESOURCE_AUTHORIZATION.to_string(), + ), + ("X-Api-Key".to_string(), RESOURCE_API_KEY.to_string()), + ])) +} + +async fn discover_legacy_oauth_without_starting_an_mcp_session( + metadata_issuer: AuthorizationMetadataIssuer, +) -> anyhow::Result<(DiscoveryResult, DiscoveryResult)> { + let resource_server = MockServer::start().await; + let authorization_server = MockServer::start().await; + let resource_url = format!("{}/mcp", resource_server.uri()); + let resource_metadata_url = format!("{}/resource-metadata", resource_server.uri()); + + Mock::given(method("GET")) + .and(path("/mcp")) + .and(header("authorization", RESOURCE_AUTHORIZATION)) + .and(header("x-api-key", RESOURCE_API_KEY)) + .respond_with(ResponseTemplate::new(401).insert_header( + "www-authenticate", + format!("Bearer resource_metadata=\"{resource_metadata_url}\""), + )) + .expect(2) + .mount(&resource_server) + .await; + + Mock::given(method("POST")) + .and(path("/mcp")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&resource_server) + .await; + + Mock::given(method("GET")) + .and(path("/resource-metadata")) + .and(header("authorization", RESOURCE_AUTHORIZATION)) + .and(header("x-api-key", RESOURCE_API_KEY)) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "resource": resource_url, + "authorization_servers": [authorization_server.uri()], + }))) + .expect(2) + .mount(&resource_server) + .await; + + let mut metadata = json!({ + "authorization_endpoint": format!("{}/authorize", authorization_server.uri()), + "token_endpoint": format!("{}/token", authorization_server.uri()), + "scopes_supported": ["mcp:read"], + "code_challenge_methods_supported": ["S256"], + }); + match metadata_issuer { + AuthorizationMetadataIssuer::Matching => { + metadata["issuer"] = json!(authorization_server.uri()); + } + AuthorizationMetadataIssuer::Missing => {} + AuthorizationMetadataIssuer::Mismatched => { + metadata["issuer"] = json!("https://unexpected-issuer.example"); + } + } + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .expect(2) + .mount(&authorization_server) + .await; + + let local_discovery = discover_streamable_http_oauth( + &resource_url, + resource_headers(), + /*env_http_headers*/ None, + ) + .await; + let routed_discovery = discover_streamable_http_oauth_with_http_client( + &resource_url, + resource_headers(), + /*env_http_headers*/ None, + Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))), + OAuthDiscoveryTimeout::LOCAL, + ) + .await; + + resource_server.verify().await; + authorization_server.verify().await; + Ok((local_discovery, routed_discovery)) +} + +#[tokio::test] +async fn oauth_discovery_uses_get_first_without_starting_a_legacy_mcp_session() -> anyhow::Result<()> +{ + let discoveries = discover_legacy_oauth_without_starting_an_mcp_session( + AuthorizationMetadataIssuer::Matching, + ) + .await?; + + for discovery in [discoveries.0, discoveries.1] { + assert_eq!( + discovery?, + Some(StreamableHttpOAuthDiscovery { + scopes_supported: Some(vec!["mcp:read".to_string()]), + }), + ); + } + Ok(()) +} + +#[tokio::test] +async fn legacy_oauth_discovery_accepts_authorization_metadata_without_an_issuer() +-> anyhow::Result<()> { + let discoveries = + discover_legacy_oauth_without_starting_an_mcp_session(AuthorizationMetadataIssuer::Missing) + .await?; + + for discovery in [discoveries.0, discoveries.1] { + assert_eq!( + discovery?, + Some(StreamableHttpOAuthDiscovery { + scopes_supported: Some(vec!["mcp:read".to_string()]), + }), + ); + } + Ok(()) +} + +#[tokio::test] +async fn legacy_oauth_discovery_rejects_an_explicit_mismatched_issuer() -> anyhow::Result<()> { + let discoveries = discover_legacy_oauth_without_starting_an_mcp_session( + AuthorizationMetadataIssuer::Mismatched, + ) + .await?; + + for discovery in [discoveries.0, discoveries.1] { + let error = discovery.expect_err("a mismatched issuer must not be accepted"); + assert!( + matches!( + error.downcast_ref::(), + Some(AuthError::AuthorizationServerMismatch { .. }), + ), + "expected an authorization-server issuer mismatch: {error:#}", + ); + } + Ok(()) +} + +#[tokio::test] +async fn oauth_discovery_does_not_invent_support_for_an_unauthenticated_legacy_server() +-> anyhow::Result<()> { + let resource_server = MockServer::start().await; + + let server_url = format!("{}/mcp", resource_server.uri()); + let executor_discovery = discover_streamable_http_oauth_with_http_client( + &server_url, + /*http_headers*/ None, + /*env_http_headers*/ None, + Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))), + OAuthDiscoveryTimeout::LOCAL, + ) + .await?; + let local_discovery = discover_streamable_http_oauth( + &server_url, + /*http_headers*/ None, + /*env_http_headers*/ None, + ) + .await?; + + assert_eq!(executor_discovery, None); + assert_eq!(local_discovery, None); + Ok(()) +} diff --git a/codex-rs/rmcp-client/tests/resources.rs b/codex-rs/rmcp-client/tests/resources.rs index ef7c2f7cdf86..be1755f4a510 100644 --- a/codex-rs/rmcp-client/tests/resources.rs +++ b/codex-rs/rmcp-client/tests/resources.rs @@ -9,7 +9,6 @@ use codex_rmcp_client::LocalStdioServerLauncher; use codex_rmcp_client::RmcpClient; use codex_utils_cargo_bin::CargoBinError; use futures::FutureExt as _; -use rmcp::model::AnnotateAble; use rmcp::model::ClientCapabilities; use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; @@ -29,12 +28,8 @@ fn stdio_server_bin() -> Result { fn init_params() -> InitializeRequestParams { let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }); + capabilities.elicitation = + Some(ElicitationCapability::new().with_form(FormElicitationCapability::new())); InitializeRequestParams::new( capabilities, Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp resource test"), @@ -81,41 +76,22 @@ async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> { .expect("memo resource present"); assert_eq!( memo, - &rmcp::model::RawResource { - uri: RESOURCE_URI.to_string(), - name: "example-note".to_string(), - title: Some("Example Note".to_string()), - description: Some("A sample MCP resource exposed for integration tests.".to_string()), - mime_type: Some("text/plain".to_string()), - size: None, - icons: None, - meta: None, - } - .no_annotation() + &rmcp::model::Resource::new(RESOURCE_URI, "example-note") + .with_title("Example Note") + .with_description("A sample MCP resource exposed for integration tests.") + .with_mime_type("text/plain") ); let templates = client .list_resource_templates(/*params*/ None, Some(Duration::from_secs(5))) .await?; - assert_eq!( - templates, - ListResourceTemplatesResult { - meta: None, - next_cursor: None, - resource_templates: vec![ - rmcp::model::RawResourceTemplate { - uri_template: "memo://codex/{slug}".to_string(), - name: "codex-memo".to_string(), - title: Some("Codex Memo".to_string()), - description: Some( - "Template for memo://codex/{slug} resources used in tests.".to_string(), - ), - mime_type: Some("text/plain".to_string()), - icons: None, - } - .no_annotation() - ], - } - ); + let mut expected_templates = ListResourceTemplatesResult::with_all_items(vec![ + rmcp::model::ResourceTemplate::new("memo://codex/{slug}", "codex-memo") + .with_title("Codex Memo") + .with_description("Template for memo://codex/{slug} resources used in tests.") + .with_mime_type("text/plain"), + ]); + expected_templates.result_type = None; + assert_eq!(templates, expected_templates); let read = client .read_resource( diff --git a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs index a765665b4856..c00b2ba2b161 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs @@ -58,12 +58,8 @@ fn streamable_http_server_bin() -> Result { fn init_params() -> InitializeRequestParams { let mut capabilities = ClientCapabilities::default(); - capabilities.elicitation = Some(ElicitationCapability { - form: Some(FormElicitationCapability { - schema_validation: None, - }), - url: None, - }); + capabilities.elicitation = + Some(ElicitationCapability::new().with_form(FormElicitationCapability::new())); InitializeRequestParams::new( capabilities, Implementation::new("codex-test", "0.0.0-test").with_title("Codex rmcp recovery test"), @@ -73,6 +69,7 @@ fn init_params() -> InitializeRequestParams { pub(crate) fn expected_echo_result(message: &str) -> CallToolResult { let mut result = CallToolResult::success(Vec::new()); + result.result_type = None; result.structured_content = Some(json!({ "echo": format!("ECHOING: {message}"), "env": null, diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index eab2cf2d30b6..a31e98344927 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -86,7 +86,7 @@ impl McpToolCallCell { } fn render_content_block(block: &serde_json::Value, width: usize) -> String { - let content = match serde_json::from_value::(block.clone()) { + let content = match serde_json::from_value::(block.clone()) { Ok(content) => content, Err(_) => { return format_and_truncate_tool_result( @@ -97,20 +97,22 @@ impl McpToolCallCell { } }; - match content.raw { - rmcp::model::RawContent::Text(text) => { + match content { + rmcp::model::ContentBlock::Text(text) => { format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width) } - rmcp::model::RawContent::Image(_) => "".to_string(), - rmcp::model::RawContent::Audio(_) => "