From a05bcda3dbd68729caa2f11027b7f43974fda298 Mon Sep 17 00:00:00 2001 From: thomas Date: Wed, 29 Jul 2026 17:32:58 +0000 Subject: [PATCH] Upgrade rmcp to 3.0.0 (#36001) ## What changed - Update the Rust MCP SDK from `3.0.0-beta.3` to `3.0.0` and adapt to its renamed metadata and server discovery types. - Accept discovery responses without server identity metadata, using the configured server name as a fallback, and rely on the SDK's native support for namespaced server identity metadata. - Preserve typed OAuth HTTP errors so transport failures, cross-origin redirects, and transient HTTP responses are reported instead of being treated as anonymous access. ## Testing - Cover modern discovery with namespaced or missing server identity over HTTP, SSE, and stdio. - Cover OAuth discovery error propagation for transport failures, redirects, and transient status codes. GitOrigin-RevId: 12c1e45136cca89ce4fb15986c2b5df14608682a --- MODULE.bazel.lock | 5 +- codex-rs/Cargo.lock | 16 ++-- codex-rs/Cargo.toml | 2 +- .../app-server/tests/suite/v2/app_list.rs | 4 +- .../app-server/tests/suite/v2/mcp_resource.rs | 6 +- .../tests/suite/v2/mcp_server_elicitation.rs | 4 +- .../app-server/tests/suite/v2/mcp_tool.rs | 4 +- .../tests/suite/v2/plugin_install.rs | 4 +- codex-rs/cli/tests/mcp_list.rs | 1 + .../src/codex_apps/file_params_tests.rs | 6 +- codex-rs/codex-mcp/src/rmcp_client.rs | 51 ++++++++++++- codex-rs/core/src/connectors_tests.rs | 4 +- codex-rs/core/src/mcp_tool_exposure_test.rs | 4 +- codex-rs/rmcp-client/src/auth_status.rs | 76 ++++++++++++++++--- .../rmcp-client/src/bin/test_stdio_server.rs | 10 +-- codex-rs/rmcp-client/src/incoming_jsonrpc.rs | 75 ++++-------------- .../rmcp-client/src/incoming_jsonrpc_tests.rs | 29 +++++-- codex-rs/rmcp-client/src/oauth_http_client.rs | 43 ++++++----- codex-rs/rmcp-client/src/rmcp_client.rs | 6 +- .../rmcp-client/tests/mcp_2026_discovery.rs | 63 ++++++++++----- codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs | 7 +- codex-rs/rmcp-client/tests/mcp_2026_stdio.rs | 9 ++- 22 files changed, 274 insertions(+), 155 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 11e7ee5946..6f8a694fb5 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -679,6 +679,7 @@ "base64_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"=0.3.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.6.1\"},{\"kind\":\"dev\",\"name\":\"structopt\",\"req\":\"^0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[]}}", "base64_0.21.7": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.2.25\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.25\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "base64_0.22.1": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.2.25\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.13.0\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.25\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", + "base64_0.23.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.6.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.28.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"simd-unsafe\"],\"simd-unsafe\":[],\"std\":[\"alloc\"]}}", "base64ct_1.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", "basic-toml_0.1.10": "{\"dependencies\":[{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^1.0.17\"},{\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.194\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"}],\"features\":{}}", "bech32_0.9.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"strict\":[]}}", @@ -1456,8 +1457,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_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\"]}}", + "rmcp-macros_3.0.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_3.0.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.23\"},{\"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\":\"^11\"},{\"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\"},{\"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\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index af9c81b1fb..1dc5999095 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1252,6 +1252,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64-simd" version = "0.8.0" @@ -11557,12 +11563,12 @@ dependencies = [ [[package]] name = "rmcp" -version = "3.0.0-beta.3" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13a8472324b3d6c74f092cc5d07b2707b128196b020ed7c3c2d52bede542680" +checksum = "fcd2b6dd3b18129368955f32661a7718969e8c152c7d8866434c09cf15a512e0" dependencies = [ "async-trait", - "base64 0.22.1", + "base64 0.23.0", "bytes", "chrono", "futures", @@ -11592,9 +11598,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.0.0-beta.3" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa1de06ecdc489a8a03ca84f8491111c1e37acd8c69a815e253123039619d72" +checksum = "e1aa4b9345795260a43fc23d6d05e096407c8b953903f673af7c4404b49fb2d6" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 164cf05cad..a6db565517 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -381,7 +381,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 = "=3.0.0-beta.3", default-features = false } +rmcp = { version = "=3.0.0", 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/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 1e39fbe2dc..e962a69315 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -41,7 +41,7 @@ use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -1761,7 +1761,7 @@ pub(super) fn connector_tool(connector_id: &str, connector_name: &str) -> Result ); tool.annotations = Some(ToolAnnotations::new().read_only(true)); - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.0 .insert("connector_id".to_string(), json!(connector_id)); meta.0 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 64a45b9126..ce4e1e36f5 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -41,7 +41,7 @@ use rmcp::model::ElicitResult; use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; use rmcp::model::ListResourcesResult; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::PaginatedRequestParams; use rmcp::model::PrimitiveSchemaDefinition; use rmcp::model::ProtocolVersion; @@ -924,8 +924,8 @@ fn skill_resource( .with_meta(skill_resource_meta(plugin_name, skill_name)) } -fn skill_resource_meta(plugin_name: &str, skill_name: &str) -> Meta { - Meta(serde_json::Map::from_iter([ +fn skill_resource_meta(plugin_name: &str, skill_name: &str) -> MetaObject { + MetaObject(serde_json::Map::from_iter([ ("plugin_name".to_string(), json!(plugin_name)), ("skill_name".to_string(), json!(skill_name)), ])) 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 1946b84213..956227ac30 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 @@ -53,7 +53,7 @@ use rmcp::model::InitializeRequestParams; use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::PrimitiveSchemaDefinition; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; @@ -693,7 +693,7 @@ impl ServerHandler for ElicitationAppsMcpServer { ); tool.annotations = Some(ToolAnnotations::new().read_only(true)); - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.0 .insert("connector_id".to_string(), json!(CONNECTOR_ID)); meta.0 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 8b681837e9..db24be1278 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -47,7 +47,7 @@ use rmcp::model::ElicitationAction; use rmcp::model::ElicitationSchema; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::PrimitiveSchemaDefinition; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; @@ -633,7 +633,7 @@ impl ServerHandler for ToolAppsMcpServer { .and_then(|value| value.as_str()) .unwrap_or_default(); - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.0.insert("calledBy".to_string(), json!("mcp-app")); if message == LARGE_RESPONSE_MESSAGE { 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 690ea366f2..a8e76d28e4 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -38,7 +38,7 @@ use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -2337,7 +2337,7 @@ fn connector_tool(connector_id: &str, connector_name: &str) -> Result { ); tool.annotations = Some(ToolAnnotations::new().read_only(true)); - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.0 .insert("connector_id".to_string(), json!(connector_id)); meta.0 diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 9bbc9a6a41..735ca34b54 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -165,6 +165,7 @@ async fn list_discovers_local_oauth_server_through_environment_proxy() -> Result Err(error) => return Err(error.into()), } }; + stream.set_nonblocking(false)?; stream.set_read_timeout(Some(Duration::from_secs(5)))?; let mut request = Vec::new(); let mut buffer = [0_u8; 1024]; diff --git a/codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs b/codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs index 2f5bef86e2..3d7f326ef6 100644 --- a/codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs +++ b/codex-rs/codex-mcp/src/codex_apps/file_params_tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::Tool; use super::*; @@ -71,7 +71,7 @@ fn prepare_openai_file_params_for_model_masks_file_params() { .expect("object") .clone(), ); - tool.meta = Some(Meta( + tool.meta = Some(MetaObject( serde_json::json!({ "openai/fileParams": ["file", "files"] }) @@ -219,7 +219,7 @@ fn prepare_openai_file_params_for_model_derives_supported_optional_fields() { .clone(), ), ); - tool.meta = Some(Meta( + tool.meta = Some(MetaObject( serde_json::json!({ "openai/fileParams": [ "photoshop_image", diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 3f4ca26efa..0e99ce27a1 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -898,7 +898,8 @@ async fn start_server_task( ) .await .map_err(StartupOutcomeError::from)?; - let server_info = mcp_server_info_from_implementation(initialize_result.server_info); + let server_info = + mcp_server_info_from_implementation(&server_name, initialize_result.server_info); let shared_tools = match (codex_apps_tools_cache_context.as_ref(), fetch_ticket) { (Some(cache_context), Some(fetch_ticket)) => cache_context.publish_if_newest_accepted( fetch_ticket, @@ -954,7 +955,11 @@ fn mcp_initialize_request_params( .with_protocol_version(ProtocolVersion::V_2025_06_18) } -fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { +fn mcp_server_info_from_implementation( + server_name: &str, + server_info: Option, +) -> McpServerInfo { + let server_info = server_info.unwrap_or_else(|| Implementation::new(server_name, "")); McpServerInfo { name: server_info.name, title: server_info.title, @@ -1085,7 +1090,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; - use rmcp::model::Meta; + use rmcp::model::MetaObject; use rmcp::transport::auth::AuthError; #[test] @@ -1098,6 +1103,44 @@ mod tests { assert!(error.is_authentication_required()); } + #[test] + fn missing_server_implementation_uses_configured_server_name() { + assert_eq!( + mcp_server_info_from_implementation("configured-server", /*server_info*/ None), + McpServerInfo { + name: "configured-server".to_string(), + title: None, + version: String::new(), + description: None, + icons: None, + website_url: None, + } + ); + } + + #[test] + fn advertised_server_implementation_takes_precedence_over_configured_name() { + assert_eq!( + mcp_server_info_from_implementation( + "configured-server", + Some( + Implementation::new("advertised-server", "1.2.3") + .with_title("Advertised server") + .with_description("Advertised description") + .with_website_url("https://example.com"), + ), + ), + McpServerInfo { + name: "advertised-server".to_string(), + title: Some("Advertised server".to_string()), + version: "1.2.3".to_string(), + description: Some("Advertised description".to_string()), + icons: None, + website_url: Some("https://example.com".to_string()), + } + ); + } + #[test] fn mcp_initialize_advertises_openai_form_only_when_supported() { let unsupported = mcp_initialize_request_params( @@ -1125,7 +1168,7 @@ mod tests { "test tool", Arc::new(JsonObject::default()), ) - .with_meta(Meta( + .with_meta(MetaObject( serde_json::json!({ "connector_id": "connector_gmail", "connector_name": "Gmail", diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index e28f0f204e..37aedcce63 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -17,7 +17,7 @@ use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::Tool; use std::collections::BTreeMap; use std::collections::HashSet; @@ -150,7 +150,7 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { #[test] fn synthetic_links_are_exposed_to_the_agent_but_not_accessible_in_app_list() { let mut synthetic_tool = codex_app_tool("gmail_batch_read_email", "gmail", Some("Gmail"), &[]); - synthetic_tool.tool.meta = Some(Meta( + synthetic_tool.tool.meta = Some(MetaObject( serde_json::json!({ "resource_name": "gmail.batch_read_email", "_codex_apps": { diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 605ee763f0..e66edcc95e 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -7,7 +7,7 @@ use codex_tools::ToolExposure; use codex_tools::ToolName; use pretty_assertions::assert_eq; use rmcp::model::JsonObject; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::Tool; use super::*; @@ -98,7 +98,7 @@ fn runtimes_by_name(runtimes: &[Arc]) -> HashMap ToolInfo { - tool.tool.meta = Some(Meta( + tool.tool.meta = Some(MetaObject( serde_json::json!({ "ui": { "visibility": visibility } }) .as_object() .expect("metadata object") diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs index 7ccc9e0442..e82a4fa953 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -348,6 +348,18 @@ mod tests { } } + fn assert_recorded_discovery_failure(discovery: Result>) { + let error = discovery.expect_err("the recording HTTP client rejects OAuth discovery"); + assert!( + matches!( + error.downcast_ref::(), + Some(AuthError::MetadataError(reason)) + if reason.contains("expected discovery request failure") + ), + "OAuth discovery must preserve the executor transport failure: {error:#}" + ); + } + async fn spawn_oauth_discovery_server(metadata: serde_json::Value) -> TestServer { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await @@ -517,6 +529,7 @@ mod tests { #[tokio::test] async fn oauth_discovery_does_not_follow_cross_origin_redirects() { let redirect_target = MockServer::start().await; + let redirect_url = format!("{}/redirect-target", redirect_target.uri()); Mock::given(method("GET")) .and(path("/redirect-target")) .and(header("x-api-key", "sensitive-key")) @@ -529,15 +542,14 @@ mod tests { 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()), - )) + .respond_with( + ResponseTemplate::new(302).insert_header("location", redirect_url.clone()), + ) .expect(1) .mount(&resource_server) .await; - let discovery = discover_streamable_http_oauth( + let error = discover_streamable_http_oauth( &format!("{}/mcp", resource_server.uri()), Some(HashMap::from([( "x-api-key".to_string(), @@ -548,13 +560,57 @@ mod tests { OAuthDiscoveryTimeout::LOCAL, ) .await - .expect("discovery should complete without following the redirect"); + .expect_err("cross-origin OAuth discovery redirects must be rejected"); - assert_eq!(discovery, None); + assert!( + matches!( + error.downcast_ref::(), + Some(AuthError::MetadataError(reason)) + if reason.contains("OAuth discovery redirect to non-same-origin URL rejected") + && reason.contains(&redirect_url) + ), + "OAuth discovery must preserve the cross-origin redirect rejection: {error:#}" + ); redirect_target.verify().await; resource_server.verify().await; } + #[tokio::test] + async fn oauth_discovery_preserves_transient_http_errors() { + for status in [ + StatusCode::REQUEST_TIMEOUT, + StatusCode::TOO_EARLY, + StatusCode::TOO_MANY_REQUESTS, + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/mcp")) + .respond_with(ResponseTemplate::new(status.as_u16())) + .expect(1) + .mount(&server) + .await; + + let error = discover_streamable_http_oauth( + &format!("{}/mcp", server.uri()), + /*http_headers*/ None, + /*env_http_headers*/ None, + test_http_client(), + OAuthDiscoveryTimeout::LOCAL, + ) + .await + .expect_err("transient OAuth discovery failures must not become anonymous access"); + + assert!( + matches!( + error.downcast_ref::(), + Some(AuthError::MetadataError(reason)) if reason.contains(status.as_str()) + ), + "OAuth discovery must preserve HTTP {status}: {error:#}" + ); + server.verify().await; + } + } + #[tokio::test] async fn discover_streamable_http_oauth_returns_normalized_scopes() { let server = spawn_oauth_discovery_server(serde_json::json!({ @@ -594,7 +650,7 @@ mod tests { ) .await; - assert!(matches!(discovery, Ok(None))); + assert_recorded_discovery_failure(discovery); assert_eq!( *http_client .timeout_ms @@ -620,7 +676,7 @@ mod tests { ) .await; - assert!(matches!(discovery, Ok(None))); + assert_recorded_discovery_failure(discovery); assert_eq!( *http_client .timeout_ms @@ -646,7 +702,7 @@ mod tests { ) .await; - assert!(matches!(discovery, Ok(None))); + assert_recorded_discovery_failure(discovery); let headers = http_client .headers .lock() 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 f1051dd9ea..d95a939dff 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -20,7 +20,7 @@ use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; -use rmcp::model::Meta; +use rmcp::model::MetaObject; use rmcp::model::PaginatedRequestParams; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; @@ -93,7 +93,7 @@ impl TestToolServer { Arc::new(thread_hint_schema), ); thread_hint_tool.annotations = Some(ToolAnnotations::new().read_only(true)); - let mut thread_hint_meta = Meta::new(); + let mut thread_hint_meta = MetaObject::new(); thread_hint_meta.insert("ui".to_string(), json!({ "visibility": [] })); thread_hint_tool.meta = Some(thread_hint_meta); @@ -524,7 +524,7 @@ impl ServerHandler for TestToolServer { && let Some(cwd) = tools.iter_mut().find(|tool| tool.name == "cwd") { cwd.meta - .get_or_insert_with(Meta::new) + .get_or_insert_with(MetaObject::new) .insert("ui".to_string(), json!({ "visibility": ["app"] })); } Ok(ListToolsResult::with_all_items(tools)) @@ -637,7 +637,7 @@ impl ServerHandler for TestToolServer { Ok(Self::structured_result(structured_content)) } "encrypted_output" => { - let mut meta = Meta::new(); + let mut meta = MetaObject::new(); meta.insert("codex/encryptedContent".to_string(), json!(true)); let mut result = CallToolResult::success(vec![ rmcp::model::ContentBlock::text("Lookup completed"), @@ -729,7 +729,7 @@ impl TestToolServer { content.push(rmcp::model::ContentBlock::image(valid_data_b64, mime_type)); } ImageScenario::ImageOnlyOriginalDetail => { - let mut meta = rmcp::model::Meta::new(); + let mut meta = MetaObject::new(); meta.insert( "codex/imageDetail".to_string(), serde_json::json!("original"), diff --git a/codex-rs/rmcp-client/src/incoming_jsonrpc.rs b/codex-rs/rmcp-client/src/incoming_jsonrpc.rs index 209d0d8f94..a8bc5c145d 100644 --- a/codex-rs/rmcp-client/src/incoming_jsonrpc.rs +++ b/codex-rs/rmcp-client/src/incoming_jsonrpc.rs @@ -1,38 +1,24 @@ -//! Compatibility decoding for server messages whose modern wire shapes are -//! not yet represented correctly by rmcp's untagged result union. +//! Compatibility decoding for multi-round-trip tool results that rmcp's +//! untagged result union does not yet represent correctly. -use rmcp::model::DiscoverResult; use rmcp::model::InputRequiredResult; use rmcp::model::JsonRpcResponse; use rmcp::model::ServerJsonRpcMessage; use rmcp::model::ServerResult; use serde_json::Value; -// Remove this compatibility decoder once rmcp accepts namespaced server identity: -// https://github.com/modelcontextprotocol/rust-sdk/issues/1039 -const SERVER_INFO_METADATA_KEY: &str = "io.modelcontextprotocol/serverInfo"; - /// Decode at the transport boundary, before rmcp's untagged `ServerResult` /// deserializer can mistake a modern result for a completed tool call. pub(crate) fn deserialize_incoming_jsonrpc_message( bytes: &[u8], ) -> serde_json::Result { - let mut message: Value = serde_json::from_slice(bytes)?; - let changed_discovery = normalize_discovery_server_info(&mut message); + let message: Value = serde_json::from_slice(bytes)?; - let Some(result) = message.get("result") else { - return serde_json::from_value(message); - }; - - if changed_discovery { - let response: JsonRpcResponse = serde_json::from_value(message)?; - return Ok(ServerJsonRpcMessage::response( - ServerResult::DiscoverResult(response.result), - response.id, - )); - } - - if result.get("resultType").and_then(Value::as_str) == Some("input_required") { + if message + .pointer("/result/resultType") + .and_then(Value::as_str) + == Some("input_required") + { let response: JsonRpcResponse = serde_json::from_value(message)?; return Ok(ServerJsonRpcMessage::response( ServerResult::InputRequiredResult(response.result), @@ -52,48 +38,19 @@ pub(crate) fn normalize_sse_jsonrpc_message(payload: &str, modern_session: bool) } let mut message: Value = serde_json::from_str(payload).ok()?; - let changed_discovery = normalize_discovery_server_info(&mut message); - let changed_mrtr = if message + if message .pointer("/result/resultType") .and_then(Value::as_str) - == Some("input_required") + != Some("input_required") { - message - .get_mut("result") - .and_then(Value::as_object_mut) - .is_some_and(|result| result.remove("_meta").is_some()) - } else { - false - }; - - if changed_discovery || changed_mrtr { - serde_json::to_string(&message).ok() - } else { - None - } -} - -fn normalize_discovery_server_info(message: &mut Value) -> bool { - let Some(result) = message.get_mut("result").and_then(Value::as_object_mut) else { - return false; - }; - if result.contains_key("serverInfo") - || !result.contains_key("supportedVersions") - || !result.contains_key("capabilities") - { - return false; + return None; } - let Some(server_info) = result - .get("_meta") - .and_then(Value::as_object) - .and_then(|meta| meta.get(SERVER_INFO_METADATA_KEY)) - .cloned() - else { - return false; - }; - result.insert("serverInfo".to_owned(), server_info); - true + message + .get_mut("result") + .and_then(Value::as_object_mut)? + .remove("_meta")?; + serde_json::to_string(&message).ok() } #[cfg(test)] diff --git a/codex-rs/rmcp-client/src/incoming_jsonrpc_tests.rs b/codex-rs/rmcp-client/src/incoming_jsonrpc_tests.rs index 1750ccaca9..321c73ec66 100644 --- a/codex-rs/rmcp-client/src/incoming_jsonrpc_tests.rs +++ b/codex-rs/rmcp-client/src/incoming_jsonrpc_tests.rs @@ -35,7 +35,10 @@ fn discovery_accepts_metadata_namespaced_server_identity() { let ServerResult::DiscoverResult(result) = response.result else { panic!("metadata-only identity must not become a completed tool result"); }; - assert_eq!(result.server_info.name, "modern-server"); + assert_eq!( + result.server_info().map(|server_info| server_info.name), + Some("modern-server".to_owned()) + ); assert_eq!( result.meta.and_then(|meta| meta.get("retained").cloned()), Some(json!("metadata")) @@ -93,7 +96,7 @@ fn input_required_discriminator_wins_over_tool_result_metadata() { } #[test] -fn sse_discovery_promotes_metadata_namespaced_identity() { +fn sse_discovery_preserves_metadata_namespaced_identity_without_normalization() { let message = json!({ "jsonrpc": "2.0", "id": 1, @@ -111,11 +114,23 @@ fn sse_discovery_promotes_metadata_namespaced_identity() { }, }, }); - let normalized = - normalize_sse_jsonrpc_message(&message.to_string(), /*modern_session*/ true) - .expect("metadata-only SSE discovery must be normalized"); - let normalized: serde_json::Value = serde_json::from_str(&normalized).unwrap(); - assert_eq!(normalized["result"]["serverInfo"]["name"], "sse-server"); + assert_eq!( + normalize_sse_jsonrpc_message(&message.to_string(), /*modern_session*/ true), + None + ); + + let decoded = deserialize_incoming_jsonrpc_message(&serde_json::to_vec(&message).unwrap()) + .expect("rmcp must natively decode metadata-only SSE discovery"); + let JsonRpcMessage::Response(response) = decoded else { + panic!("expected a discovery response"); + }; + let ServerResult::DiscoverResult(result) = response.result else { + panic!("metadata-only identity must decode as discovery without normalization"); + }; + assert_eq!( + result.server_info().map(|server_info| server_info.name), + Some("sse-server".to_owned()) + ); } #[test] diff --git a/codex-rs/rmcp-client/src/oauth_http_client.rs b/codex-rs/rmcp-client/src/oauth_http_client.rs index 457fea5582..4787238c7f 100644 --- a/codex-rs/rmcp-client/src/oauth_http_client.rs +++ b/codex-rs/rmcp-client/src/oauth_http_client.rs @@ -21,6 +21,20 @@ use crate::auth_status::OAuthDiscoveryTimeout; const MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES: usize = 1024 * 1024; static NEXT_OAUTH_REQUEST_ID: AtomicU64 = AtomicU64::new(0); +#[derive(Debug, thiserror::Error)] +enum OAuthHttpClientAdapterError { + #[error("unsupported OAuth HTTP redirect policy")] + UnsupportedRedirectPolicy, + #[error("OAuth HTTP response body exceeds {maximum_bytes} bytes")] + ResponseBodyTooLarge { maximum_bytes: usize }, +} + +fn oauth_http_client_error( + error: impl std::error::Error + Send + Sync + 'static, +) -> OAuthHttpClientError { + Box::new(error) +} + #[derive(Clone)] pub(crate) struct OAuthHttpClientAdapter { http_client: Arc, @@ -59,8 +73,8 @@ impl OAuthHttpClientAdapter { OAuthHttpRedirectPolicy::Follow => HttpRedirectPolicy::Follow, OAuthHttpRedirectPolicy::Stop => HttpRedirectPolicy::Stop, _ => { - return Err(OAuthHttpClientError::new( - "unsupported OAuth HTTP redirect policy", + return Err(oauth_http_client_error( + OAuthHttpClientAdapterError::UnsupportedRedirectPolicy, )); } }; @@ -76,10 +90,7 @@ impl OAuthHttpClientAdapter { .map(|(name, value)| { Ok(HttpHeader { name: name.as_str().to_string(), - value: value - .to_str() - .map_err(|error| OAuthHttpClientError::new(error.to_string()))? - .to_string(), + value: value.to_str().map_err(oauth_http_client_error)?.to_string(), }) }) .collect::, OAuthHttpClientError>>()?; @@ -108,17 +119,15 @@ impl OAuthHttpClientAdapter { stream_response: true, }) .await - .map_err(|error| OAuthHttpClientError::new(error.to_string()))?; + .map_err(oauth_http_client_error)?; let mut body = Vec::new(); - while let Some(chunk) = body_stream - .recv() - .await - .map_err(|error| OAuthHttpClientError::new(error.to_string()))? - { + while let Some(chunk) = body_stream.recv().await.map_err(oauth_http_client_error)? { 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" - ))); + return Err(oauth_http_client_error( + OAuthHttpClientAdapterError::ResponseBodyTooLarge { + maximum_bytes: MAX_OAUTH_HTTP_RESPONSE_BODY_BYTES, + }, + )); } body.extend_from_slice(&chunk); } @@ -126,9 +135,7 @@ impl OAuthHttpClientAdapter { for header in response.headers { builder = builder.header(header.name, header.value); } - builder - .body(body) - .map_err(|error| OAuthHttpClientError::new(error.to_string())) + builder.body(body).map_err(oauth_http_client_error) } } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 22f876e6a2..74cc903704 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -33,7 +33,6 @@ use rmcp::model::ElicitResult; use rmcp::model::ElicitationAction; use rmcp::model::Extensions; use rmcp::model::InitializeRequestParams; -use rmcp::model::InitializeResult; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; @@ -44,6 +43,7 @@ use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; use rmcp::model::RequestMetaObject; use rmcp::model::RequestParamsMeta; +use rmcp::model::ServerPeerInfo; use rmcp::model::ServerResult; use rmcp::model::Tool; use rmcp::service::ClientCacheConfig; @@ -515,7 +515,7 @@ impl RmcpClient { params: InitializeRequestParams, timeout: Option, send_elicitation: SendElicitation, - ) -> Result { + ) -> Result { let client_service = ElicitationClientService::new( params.clone(), send_elicitation, @@ -629,7 +629,7 @@ impl RmcpClient { }) } - fn meta_string(meta: Option<&rmcp::model::Meta>, key: &str) -> Option { + fn meta_string(meta: Option<&rmcp::model::MetaObject>, key: &str) -> Option { meta.and_then(|meta| meta.get(key)) .and_then(Value::as_str) .map(str::trim) diff --git a/codex-rs/rmcp-client/tests/mcp_2026_discovery.rs b/codex-rs/rmcp-client/tests/mcp_2026_discovery.rs index a3ec5564ab..ed77a2a559 100644 --- a/codex-rs/rmcp-client/tests/mcp_2026_discovery.rs +++ b/codex-rs/rmcp-client/tests/mcp_2026_discovery.rs @@ -17,8 +17,9 @@ use pretty_assertions::assert_eq; use rmcp::model::ClientCapabilities; use rmcp::model::Implementation; use rmcp::model::InitializeRequestParams; -use rmcp::model::InitializeResult; use rmcp::model::ProtocolVersion; +use rmcp::model::ServerCapabilities; +use rmcp::model::ServerPeerInfo; use serde_json::Value; use serde_json::json; use wiremock::Mock; @@ -57,7 +58,7 @@ async fn create_client(server: &MockServer, mode: McpProtocolMode) -> anyhow::Re .await } -async fn initialize_client(client: &RmcpClient) -> anyhow::Result { +async fn initialize_client(client: &RmcpClient) -> anyhow::Result { client .initialize( initialize_params(), @@ -88,7 +89,12 @@ fn modern_discover_result(request: &Value) -> Value { "resultType": "complete", "supportedVersions": [MODERN_VERSION], "capabilities": {"tools": {}}, - "serverInfo": {"name": "modern-test", "version": "1.0.0"}, + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "modern-test", + "version": "1.0.0", + }, + }, "ttlMs": 0, "cacheScope": "private", }, @@ -277,6 +283,30 @@ async fn legacy_mcp_requests_preserve_existing_redirect_behavior() -> anyhow::Re #[tokio::test] async fn modern_discovery_accepts_metadata_namespaced_server_identity() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/mcp")) + .respond_with(|request: &Request| { + let body: Value = request.body_json().expect("valid JSON-RPC request"); + assert_eq!(body["method"], "server/discover"); + modern_discover_response(&body) + }) + .expect(1) + .mount(&server) + .await; + + let client = create_client(&server, McpProtocolMode::V20260728).await?; + let server_info = initialize_client(&client).await?; + assert_eq!( + server_info.server_info, + Some(Implementation::new("modern-test", "1.0.0")) + ); + client.shutdown().await; + Ok(()) +} + +#[tokio::test] +async fn modern_discovery_accepts_missing_server_identity() -> anyhow::Result<()> { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/mcp")) @@ -284,14 +314,10 @@ async fn modern_discovery_accepts_metadata_namespaced_server_identity() -> anyho let body: Value = request.body_json().expect("valid JSON-RPC request"); assert_eq!(body["method"], "server/discover"); let mut response = modern_discover_result(&body); - let server_info = response["result"] + response["result"] .as_object_mut() .expect("discovery object") - .remove("serverInfo") - .expect("top-level identity"); - response["result"]["_meta"] = json!({ - "io.modelcontextprotocol/serverInfo": server_info, - }); + .remove("_meta"); ResponseTemplate::new(200).set_body_json(response) }) .expect(1) @@ -299,7 +325,14 @@ async fn modern_discovery_accepts_metadata_namespaced_server_identity() -> anyho .await; let client = create_client(&server, McpProtocolMode::V20260728).await?; - initialize_client(&client).await?; + let server_info = initialize_client(&client).await?; + assert_eq!( + server_info, + ServerPeerInfo::new( + ProtocolVersion::V_2026_07_28, + ServerCapabilities::builder().enable_tools().build(), + ) + ); client.shutdown().await; Ok(()) } @@ -337,15 +370,7 @@ async fn modern_discovery_accepts_metadata_namespaced_server_identity_over_sse() .respond_with(|request: &Request| { let body: Value = request.body_json().expect("valid JSON-RPC request"); assert_eq!(body["method"], "server/discover"); - let mut response = modern_discover_result(&body); - let server_info = response["result"] - .as_object_mut() - .expect("discovery object") - .remove("serverInfo") - .expect("top-level identity"); - response["result"]["_meta"] = json!({ - "io.modelcontextprotocol/serverInfo": server_info, - }); + let response = modern_discover_result(&body); ResponseTemplate::new(200).set_body_raw( format!("event: message\ndata: {response}\n\n"), "text/event-stream; charset=utf-8", diff --git a/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs b/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs index 22f55cd34e..5ace4180b2 100644 --- a/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs +++ b/codex-rs/rmcp-client/tests/mcp_2026_mrtr.rs @@ -41,7 +41,12 @@ fn discover_response(body: &Value) -> ResponseTemplate { "resultType": "complete", "supportedVersions": [MODERN_VERSION], "capabilities": {"tools": {}, "resources": {}}, - "serverInfo": {"name": "mrtr-test", "version": "1.0.0"}, + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "mrtr-test", + "version": "1.0.0", + }, + }, "ttlMs": 0, "cacheScope": "private", }, diff --git a/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs b/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs index cc75a2c156..3cb7bf5cbc 100644 --- a/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs +++ b/codex-rs/rmcp-client/tests/mcp_2026_stdio.rs @@ -128,12 +128,15 @@ async fn exercise_stdio_server( }; assert_eq!(initialized.protocol_version, expected_version); assert_eq!( - initialized.server_info.name, - if legacy_session { + initialized + .server_info + .as_ref() + .map(|server_info| server_info.name.as_str()), + Some(if legacy_session { "legacy-stdio-test" } else { "strict-stdio-test" - } + }) ); let tools = client .list_tools(/*params*/ None, Some(Duration::from_secs(5)))