diff --git a/.github/workflows/prep-release.yml b/.github/workflows/prep-release.yml index e0ded069..04dc979c 100644 --- a/.github/workflows/prep-release.yml +++ b/.github/workflows/prep-release.yml @@ -232,7 +232,7 @@ jobs: - name: Open/Update draft PR to main env: - HEAD: ${{ github.ref_name }} + HEAD: release/${{ steps.bump.outputs.new_version }} TITLE: Releasing ${{ steps.bump.outputs.new_version }} shell: bash run: | diff --git a/.vscode/launch.json b/.vscode/launch.json index 9363ac0d..66814a53 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,75 +1,86 @@ { - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Run apollo-mcp-server [Weather][Streamable HTTP]", - "runtimeExecutable": "cargo", - "runtimeArgs": [ - "run", - "--bin", - "apollo-mcp-server", - "--", - "graphql/weather/config.yaml", - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "env": { - "RUST_BACKTRACE": "1" - } - }, - { - "type": "lldb", - "request": "launch", - "name": "Debug apollo-mcp-server [Weather][Streamable HTTP]", - "cargo": { - "args": [ - "build", - "--bin=apollo-mcp-server", - "--lib" - ], - "filter": { - "name": "apollo-mcp-server", - "kind": "bin" - } - }, - "args": [ - "graphql/weather/config.yaml", - ], - "cwd": "${workspaceFolder}", - "env": { - "RUST_BACKTRACE": "1" - } - }, - { - "type": "node", - "request": "launch", - "name": "Run apollo-mcp-server [TheSpaceDevs][Streamable HTTP]", - "runtimeExecutable": "cargo", - "runtimeArgs": [ - "run", - "--bin", - "apollo-mcp-server", - "--", - "graphql/TheSpaceDevs/config.yaml", - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "env": { - "RUST_BACKTRACE": "1" - } - }, - { - "type": "node", - "request": "launch", - "name": "Run mcp-inspector", - "runtimeExecutable": "npx", - "runtimeArgs": [ - "@modelcontextprotocol/inspector" - ], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal" + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Run apollo-mcp-server [Weather][Streamable HTTP]", + "runtimeExecutable": "cargo", + "runtimeArgs": [ + "run", + "--bin", + "apollo-mcp-server", + "--", + "graphql/weather/config.yaml" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "env": { + "RUST_BACKTRACE": "1" + } + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug apollo-mcp-server [Weather][Streamable HTTP]", + "cargo": { + "args": ["build", "--bin=apollo-mcp-server", "--lib"], + "filter": { + "name": "apollo-mcp-server", + "kind": "bin" } - ] -} \ No newline at end of file + }, + "args": ["graphql/weather/config.yaml"], + "cwd": "${workspaceFolder}", + "env": { + "RUST_BACKTRACE": "1", + "APOLLO_MCP_LOGGING__LEVEL": "debug" + } + }, + { + "type": "node", + "request": "launch", + "name": "Run apollo-mcp-server [TheSpaceDevs][Streamable HTTP]", + "runtimeExecutable": "cargo", + "runtimeArgs": [ + "run", + "--bin", + "apollo-mcp-server", + "--", + "graphql/TheSpaceDevs/config.yaml" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "env": { + "RUST_BACKTRACE": "1" + } + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug apollo-mcp-server [TheSpaceDevs][Streamable HTTP]", + "cargo": { + "args": ["build", "--bin=apollo-mcp-server", "--lib"], + "filter": { + "name": "apollo-mcp-server", + "kind": "bin" + } + }, + "args": ["graphql/TheSpaceDevs/config.yaml"], + "cwd": "${workspaceFolder}", + "env": { + "RUST_BACKTRACE": "1", + "APOLLO_MCP_LOGGING__LEVEL": "debug" + } + }, + { + "type": "node", + "request": "launch", + "name": "Run mcp-inspector", + "runtimeExecutable": "npx", + "runtimeArgs": ["@modelcontextprotocol/inspector"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a67ebda..f37b9e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,188 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# [0.9.0] - 2025-09-24 + +## 🚀 Features + +### Prototype OpenTelemetry Traces in MCP Server - @swcollard PR #274 + +Pulls in new crates and SDKs for prototyping instrumenting the Apollo MCP Server with Open Telemetry Traces. + +- Adds new rust crates to support OTel +- Annotates excecute and call_tool functions with trace macro +- Adds Axum and Tower middleware's for OTel tracing +- Refactors Logging so that all the tracing_subscribers are set together in a single module. + +### Add CORS support - @DaleSeo PR #362 + +This PR implements comprehensive CORS support for Apollo MCP Server to enable web-based MCP clients to connect without CORS errors. The implementation and configuration draw heavily from the Router's approach. Similar to other features like health checks and telemetry, CORS is supported only for the StreamableHttp transport, making it a top-level configuration. + +### Enhance tool descriptions - @DaleSeo PR #350 + +This PR enhances the descriptions of the introspect and search tools to offer clearer guidance for AI models on efficient GraphQL schema exploration patterns. + +### Telemetry: Trace operations and auth - @swcollard PR #375 + +- Adds traces for the MCP server generating Tools from Operations and performing authorization +- Includes the HTTP status code to the top level HTTP trace + +### Implement metrics for mcp tool and operation counts and durations - @swcollard PR #297 + +This PR adds metrics to count and measure request duration to events throughout the MCP server + +- apollo.mcp.operation.duration +- apollo.mcp.operation.count +- apollo.mcp.tool.duration +- apollo.mcp.tool.count +- apollo.mcp.initialize.count +- apollo.mcp.list_tools.count +- apollo.mcp.get_info.count + +### Adding ability to omit attributes for traces and metrics - @alocay PR #358 + +Adding ability to configure which attributes are omitted from telemetry traces and metrics. + +1. Using a Rust build script (`build.rs`) to auto-generate telemetry attribute code based on the data found in `telemetry.toml`. +2. Utilizing an enum for attributes so typos in the config file raise an error. +3. Omitting trace attributes by filtering it out in a custom exporter. +4. Omitting metric attributes by indicating which attributes are allowed via a view. +5. Created `telemetry_attributes.rs` to map `TelemetryAttribute` enum to a OTEL `Key`. + +The `telemetry.toml` file includes attributes (both for metrics and traces) as well as list of metrics gathered. An example would look like the following: + +``` +[attributes.apollo.mcp] +my_attribute = "Some attribute info" + +[metrics.apollo.mcp] +some.count = "Some metric count info" +``` + +This would generate a file that looks like the following: + +``` +/// All TelemetryAttribute values +pub const ALL_ATTRS: &[TelemetryAttribute; 1usize] = &[ + TelemetryAttribute::MyAttribute +]; +#[derive(Debug, ::serde::Deserialize, ::schemars::JsonSchema,, Clone, Eq, PartialEq, Hash, Copy)] +pub enum TelemetryAttribute { + ///Some attribute info + #[serde(alias = "my_attribute")] + MyAttribute, +} +impl TelemetryAttribute { + /// Supported telemetry attribute (tags) values + pub const fn as_str(&self) -> &'static str { + match self { + TelemetryAttribute::MyAttribute => "apollo.mcp.my_attribute", + } + } +} +#[derive(Debug, ::serde::Deserialize, ::schemars::JsonSchema,, Clone, Eq, PartialEq, Hash, Copy)] +pub enum TelemetryMetric { + ///Some metric count info + #[serde(alias = "some.count")] + SomeCount, +} +impl TelemetryMetric { + /// Converts TelemetryMetric to &str + pub const fn as_str(&self) -> &'static str { + match self { + TelemetryMetric::SomeCount => "apollo.mcp.some.count", + } + } +} +``` + +An example configuration that omits `tool_name` attribute for metrics and `request_id` for tracing would look like the following: + +``` +telemetry: + exporters: + metrics: + otlp: + endpoint: "http://localhost:4317" + protocol: "grpc" + omitted_attributes: + - tool_name + tracing: + otlp: + endpoint: "http://localhost:4317" + protocol: "grpc" + omitted_attributes: + - request_id +``` + +### Configuration for disabling authorization token passthrough - @swcollard PR #336 + +A new optional new MCP Server configuration parameter, `transport.auth.disable_auth_token_passthrough`, which is `false` by default, that when true, will no longer pass through validated Auth tokens to the GraphQL API. + +### Adding config option for trace sampling - @alocay PR #366 + +Adding configuration option to sample traces. Can use the following options: + +1. Ratio based samples (ratio >= 1 is always sample) +2. Always on +3. Always off + +Defaults to always on if not provided. + +## 🐛 Fixes + +### Update SDL handling in sdl_to_api_schema function - @lennyburdette PR #365 + +Loads supergraph schemas using a function that supports various features, including Apollo Connectors. When supergraph loading failed, it would load it as a standard GraphQL schema, which reveals Federation query planning directives in when using the `search` and `introspection` tools. + +### Include the cargo feature and TraceContextPropagator to send otel headers downstream - @swcollard PR #307 + +Inside the reqwest middleware, if the global text_map_propagator is not set, it will no op and not send the traceparent and tracestate headers to the Router. Adding this is needed to correlate traces from the mcp server to the router or other downstream APIs + +### Add support for deprecated directive - @esilverm PR #367 + +Includes any existing `@deprecated` directives in the schema in the minified output of builtin tools. Now operations generated via these tools should take into account deprecated fields when being generated. + +## 📃 Configuration + +### Add basic config file options to otel telemetry - @swcollard PR #330 + +Adds new Configuration options for setting up configuration beyond the standard OTEL environment variables needed before. + +- Renames trace->telemetry +- Adds OTLP options for metrics and tracing to choose grpc or http upload protocols and setting the endpoints +- This configuration is all optional, so by default nothing will be logged + +### Disable statefulness to fix initialize race condition - @swcollard PR #351 + +We've been seeing errors with state and session handling in the MCP Server. Whether that is requests being sent before the initialized notification is processed. Or running a fleet of MCP Server pods behind a round robin load balancer. A new configuration option under the streamable_http transport `stateful_mode`, allows disabling session handling which appears to fix the race condition issue. + +## 🛠 Maintenance + +### Update RMCP dependency - @nicholascioli PR #328 + +Update the RMCP dependency to the latest version, pulling in newer specification changes. + +### Pin stable rust version - @nicholascioli PR #287 + +Pins the stable version of Rust to the current latest version to ensure backwards compatibility with future versions. + +### Add tests for server event and SupergraphSdlQuery - @DaleSeo PR #347 + +This PR adds tests for some uncovered parts of the codebase to check the Codecov integration. + +### Configure Codecov with coverage targets - @DaleSeo PR #337 + +This PR adds `codecov.yml` to set up Codecov with specific coverage targets and quality standards. It helps define clear expectations for code quality. It also includes some documentation about code coverage in `CONTRIBUTING.md` and adds the Codecov badge to `README.md`. + +### Implement Test Coverage Measurement and Reporting - @DaleSeo PR #335 + +This PR adds the bare minimum for code coverage reporting using [cargo-llvm-cov](https://crates.io/crates/cargo-llvm-cov) and integrates with [Codecov](https://www.codecov.io/). It adds a new `coverage` job to the CI workflow that generates and uploads coverage reporting in parallel with existing tests. The setup mirrors that of Router, except it uses `nextest` instead of the built-in test runner and CircleCI instead of GitHub Actions. + +### Fix version on mcp server tester - @alocay PR #374 + +Add a specific version when calling the mcp-server-tester for e2e tests. The current latest (1.4.1) as an issue so to avoid problems now and in the future updating the test script to invoke the testing tool via specific version. + # [0.8.0] - 2025-09-12 ## 🚀 Features @@ -30,8 +212,6 @@ Update the RMCP dependency to the latest version, pulling in newer specification Pins the stable version of Rust to the current latest version to ensure backwards compatibility with future versions. - - # [0.7.5] - 2025-09-03 ## 🐛 Fixes @@ -51,6 +231,7 @@ This change also updates the input schema to the execute tool to make it more cl Adding some basic e2e tests using [mcp-server-tester](https://github.com/steviec/mcp-server-tester). Currently, the tool does not always exit (ctrl+c is sometimes needed) so this should be run manually. ### How to run tests? + Added a script `run_tests.sh` (may need to run `chmod +x` to run it) to run tests. Basic usage found via `./run_tests.sh -h`. The script does the following: 1. Builds test/config yaml paths and verifies the files exist. @@ -60,6 +241,7 @@ Added a script `run_tests.sh` (may need to run `chmod +x` to run it) to run test 5. On script exit the generated config is cleaned up. ### Example run: + To run the tests for `local-operations` simply run `./run_tests.sh local-operations` ### Update snapshot format - @DaleSeo PR #313 diff --git a/CHANGELOG_SECTION.md b/CHANGELOG_SECTION.md index 139ce9fe..d156904d 100644 --- a/CHANGELOG_SECTION.md +++ b/CHANGELOG_SECTION.md @@ -23,4 +23,3 @@ Update the RMCP dependency to the latest version, pulling in newer specification ### ci: Pin stable rust version ([Issue #287](https://github.com/apollographql/apollo-mcp-server/issues/287)) Pins the stable version of Rust to the current latest version to ensure backwards compatibility with future versions. - diff --git a/Cargo.lock b/Cargo.lock index 5da0ac42..9773eb4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,12 +46,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -113,15 +107,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "apollo-compiler" -version = "1.29.0" +version = "1.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4369d2ac382b0752cc5ff8cdb020e7a3c74480e7d940fc99f139281f8701fb81" +checksum = "87e4c0116cde9e3e5679806cf91c464d9efb7f1e231abffc505e0f6d4b911260" dependencies = [ "ahash", "apollo-parser", @@ -131,22 +125,24 @@ dependencies = [ "rowan", "serde", "serde_json_bytes", - "thiserror 2.0.14", + "thiserror 2.0.16", "triomphe", "typed-arena", ] [[package]] name = "apollo-federation" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbea4f0fcfcd95ec6f03c41a104488f66eafb37d05bd304a219702d1814449c5" +checksum = "1595bfb0fd31882d0b2dd258205ccac93a43c0ae37038a1a6e1cc2834eaf958f" dependencies = [ "apollo-compiler", + "countmap", "derive_more", "either", + "encoding_rs", "form_urlencoded", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "http", "indexmap", "itertools", @@ -167,7 +163,7 @@ dependencies = [ "shape", "strum", "strum_macros", - "thiserror 2.0.14", + "thiserror 2.0.16", "time", "tracing", "url", @@ -188,7 +184,7 @@ dependencies = [ "serde", "serde_json", "test-log", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tokio-stream", "tower", @@ -210,11 +206,15 @@ dependencies = [ "apollo-federation", "apollo-mcp-registry", "apollo-schema-index", + "async-trait", "axum", "axum-extra", + "axum-otel-metrics", + "axum-tracing-opentelemetry", "bon", "chrono", "clap", + "cruet", "figment", "futures", "headers", @@ -226,19 +226,35 @@ dependencies = [ "jwks", "lz-str", "mockito", + "opentelemetry", + "opentelemetry-appender-log", + "opentelemetry-otlp", + "opentelemetry-resource-detectors", + "opentelemetry-semantic-conventions", + "opentelemetry-stdout", + "opentelemetry_sdk", + "prettyplease", + "quote", "regex", "reqwest", + "reqwest-middleware", + "reqwest-tracing", "rmcp", "rstest", "schemars", "serde", "serde_json", - "thiserror 2.0.14", + "syn 2.0.106", + "thiserror 2.0.16", "tokio", "tokio-util", + "toml", + "tower", "tower-http", "tracing", "tracing-appender", + "tracing-core", + "tracing-opentelemetry", "tracing-subscriber", "tracing-test", "url", @@ -265,7 +281,7 @@ dependencies = [ "itertools", "rstest", "tantivy", - "thiserror 2.0.14", + "thiserror 2.0.16", "tracing", ] @@ -298,13 +314,13 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.27" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" +checksum = "977eb15ea9efd848bb8a4a1a2500347ed7f0bf794edf0dc3ddcf439f43d36b23" dependencies = [ - "flate2", + "compression-codecs", + "compression-core", "futures-core", - "memchr", "pin-project-lite", "tokio", ] @@ -317,7 +333,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -418,6 +434,41 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-otel-metrics" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82cf7343b4fc88312e4d7b731152a1a09edcdb6def398d836ef8bccb57f066a" +dependencies = [ + "axum", + "futures-util", + "http", + "http-body", + "opentelemetry", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "pin-project-lite", + "tower", +] + +[[package]] +name = "axum-tracing-opentelemetry" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd3e188039e0e9e3dce1ad873358fd6bab72e6496e18b898bc36d72c07af4b26" +dependencies = [ + "axum", + "futures-core", + "futures-util", + "http", + "opentelemetry", + "pin-project-lite", + "tower", + "tracing", + "tracing-opentelemetry", + "tracing-opentelemetry-instrumentation-sdk", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -462,9 +513,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "bitpacking" @@ -486,9 +537,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.7.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a0c21249ad725ebcadcb1b1885f8e3d56e8e6b8924f560268aab000982d637" +checksum = "c2529c31017402be841eb45892278a6c21a000c0a17643af326c73a73f83f0fb" dependencies = [ "bon-macros", "rustversion", @@ -496,17 +547,17 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.7.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a660ebdea4d4d3ec7788cfc9c035b66efb66028b9b97bf6cde7023ccc8e77e28" +checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005" dependencies = [ - "darling 0.21.2", + "darling", "ident_case", "prettyplease", "proc-macro2", "quote", "rustversion", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -557,10 +608,11 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.32" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -574,30 +626,29 @@ checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", - "num-traits", + "num-traits 0.2.19", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "clap" -version = "4.5.45" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -605,9 +656,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.44" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -617,14 +668,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -645,7 +696,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -658,6 +709,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485abf41ac0c8047c07c87c72c8fb3eb5197f6e9d7ded615dfd1a00ae00a0f64" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" + [[package]] name = "concolor" version = "0.1.1" @@ -715,6 +783,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "countmap" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ef2a403c4af585607826502480ab6e453f320c230ef67255eee21f0cc72c0a6" +dependencies = [ + "num-traits 0.1.43", +] + [[package]] name = "countme" version = "3.0.1" @@ -773,6 +850,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "cruet" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a9ae414b9768aada1b316493261653e41af05c9d2ccc9c504a8fc051c6a790" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -791,81 +878,47 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08440b3dd222c3d0433e63e097463969485f112baff337dfdaca043a0d760570" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.21.2", - "darling_macro 0.21.2", + "darling_core", + "darling_macro", ] [[package]] name = "darling_core" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "syn 2.0.105", -] - -[[package]] -name = "darling_core" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25b7912bc28a04ab1b7715a68ea03aaa15662b43a1a4b2c480531fd19f8bf7e" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.105", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "darling_macro" -version = "0.21.2" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce154b9bea7fb0c8e8326e62d00354000c36e79770ff21b8c84e3aa267d9d531" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core 0.21.2", + "darling_core", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "deadpool" -version = "0.10.0" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb84100978c1c7b37f09ed3ce3e5f843af02c2a2c431bae5b19230dad2c1b490" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "async-trait", "deadpool-runtime", + "lazy_static", "num_cpus", "tokio", ] @@ -878,12 +931,12 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -904,7 +957,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "unicode-xid", ] @@ -926,14 +979,14 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "downcast-rs" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea8a8b81cacc08888170eef4d13b775126db426d0b348bee9d18c2c1eaf123cf" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" [[package]] name = "dyn-clone" @@ -950,7 +1003,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -974,6 +1027,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-ordinalize" version = "4.3.0" @@ -991,28 +1053,28 @@ checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "enumset" -version = "1.1.7" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ee17054f550fd7400e1906e2f9356c7672643ed34008a9e8abe147ccd2d821" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" dependencies = [ "enumset_derive", ] [[package]] name = "enumset_derive" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76d07902c93376f1e96c34abc4d507c0911df3816cef50b01f5a2ff3ad8c370d" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -1023,19 +1085,19 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] name = "fancy-regex" -version = "0.16.1" +version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf04c5ec15464ace8355a7b440a33aece288993475556d461154d7a62ad9947c" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" dependencies = [ "bit-set", "regex-automata", @@ -1070,6 +1132,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1109,6 +1177,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1126,9 +1200,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -1219,7 +1293,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -1290,7 +1364,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -1409,7 +1483,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1508,9 +1593,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "humantime-serde" @@ -1524,13 +1609,14 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -1538,11 +1624,25 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -1561,9 +1661,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64", "bytes", @@ -1594,9 +1694,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1710,9 +1810,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1731,13 +1831,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -1752,7 +1853,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "inotify-sys", "libc", ] @@ -1768,9 +1869,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.43.1" +version = "1.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371" +checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" dependencies = [ "console", "globset", @@ -1784,11 +1885,11 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "libc", ] @@ -1817,7 +1918,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1843,9 +1944,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom 0.3.3", "libc", @@ -1853,9 +1954,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -1889,7 +1990,7 @@ dependencies = [ "idna", "itoa", "num-cmp", - "num-traits", + "num-traits 0.2.19", "once_cell", "percent-encoding", "referencing", @@ -1970,9 +2071,9 @@ checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libm" @@ -1994,9 +2095,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -2016,9 +2117,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lru" @@ -2073,9 +2174,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memmap2" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" +checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" dependencies = [ "libc", ] @@ -2202,7 +2303,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "fsevent-sys", "inotify", "kqueue", @@ -2240,7 +2341,7 @@ dependencies = [ "num-integer", "num-iter", "num-rational", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2250,7 +2351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2265,7 +2366,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2280,7 +2381,7 @@ version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2291,7 +2392,7 @@ checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2302,7 +2403,16 @@ checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ "num-bigint", "num-integer", - "num-traits", + "num-traits 0.2.19", +] + +[[package]] +name = "num-traits" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e5113e9fd4cc14ded8e499429f396a20f98c772a47cc8622a736e1ec843c31" +dependencies = [ + "num-traits 0.2.19", ] [[package]] @@ -2367,7 +2477,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "cfg-if", "foreign-types", "libc", @@ -2384,7 +2494,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2415,6 +2525,120 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "opentelemetry" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf416e4cb72756655126f7dd7bb0af49c674f4c1b9903e80c009e0c37e552e6" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.16", + "tracing", +] + +[[package]] +name = "opentelemetry-appender-log" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e688026e48f4603494f619583e0aa0b0edd9c0b9430e1c46804df2ff32bc8798" +dependencies = [ + "log", + "opentelemetry", +] + +[[package]] +name = "opentelemetry-http" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror 2.0.16", + "tokio", + "tonic", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e046fd7660710fe5a05e8748e70d9058dc15c94ba914e7c4faa7c728f0e8ddc" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", +] + +[[package]] +name = "opentelemetry-resource-detectors" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a44e076f07fa3d76e741991f4f7d3ecbac0eed8521ced491fbdf8db77d024cf" +dependencies = [ + "opentelemetry", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d059a296a47436748557a353c5e6c5705b9470ef6c95cfc52c21a8814ddac2" + +[[package]] +name = "opentelemetry-stdout" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447191061af41c3943e082ea359ab8b64ff27d6d34d30d327df309ddef1eef6f" +dependencies = [ + "chrono", + "opentelemetry", + "opentelemetry_sdk", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f644aa9e5e31d11896e024305d7e3c98a88884d9f8919dbf37a9991bc47a4b" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.2", + "serde_json", + "thiserror 2.0.16", + "tokio", + "tokio-stream", +] + [[package]] name = "outref" version = "0.5.2" @@ -2479,7 +2703,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2494,26 +2718,26 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" dependencies = [ "memchr", - "thiserror 2.0.14", + "thiserror 2.0.16", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663" dependencies = [ "pest", "pest_generator", @@ -2521,22 +2745,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "pest_meta" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420" dependencies = [ "pest", "sha2", @@ -2572,7 +2796,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2595,9 +2819,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -2619,28 +2843,28 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.97" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -2653,11 +2877,34 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "version_check", "yansi", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "quote" version = "1.0.40" @@ -2738,7 +2985,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" dependencies = [ - "num-traits", + "num-traits 0.2.19", "rand 0.8.5", ] @@ -2768,7 +3015,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", ] [[package]] @@ -2788,7 +3035,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2807,9 +3054,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", @@ -2819,9 +3066,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" dependencies = [ "aho-corasick", "memchr", @@ -2830,9 +3077,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" [[package]] name = "relative-path" @@ -2880,6 +3127,39 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reqwest-middleware" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror 1.0.69", + "tower-service", +] + +[[package]] +name = "reqwest-tracing" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d70ea85f131b2ee9874f0b160ac5976f8af75f3c9badfe0d955880257d10bd83" +dependencies = [ + "anyhow", + "async-trait", + "getrandom 0.2.16", + "http", + "matchit", + "opentelemetry", + "reqwest", + "reqwest-middleware", + "tracing", + "tracing-opentelemetry", +] + [[package]] name = "ring" version = "0.17.14" @@ -2916,7 +3196,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-util", @@ -2931,11 +3211,11 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1827cd98dab34cade0513243c6fe0351f0f0b2c9d6825460bcf45b42804bdda0" dependencies = [ - "darling 0.21.2", + "darling", "proc-macro2", "quote", "serde_json", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2976,7 +3256,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.105", + "syn 2.0.106", "unicode-ident", ] @@ -3023,24 +3303,24 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.0", ] [[package]] @@ -3075,11 +3355,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -3106,7 +3386,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3131,7 +3411,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "core-foundation", "core-foundation-sys", "libc", @@ -3140,9 +3420,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -3150,28 +3430,38 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.226" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3182,20 +3472,21 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "indexmap", "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -3215,12 +3506,22 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +dependencies = [ + "serde_core", ] [[package]] @@ -3272,9 +3573,9 @@ dependencies = [ [[package]] name = "shape" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "362c1523018b16b65737aa0ea76a731edbcd399e273c0130ba829b148f89dbd2" +checksum = "914e2afe9130bf8acf52c5e20b4222f7d2e5eb8327e05fb668fe70aad4b3a896" dependencies = [ "apollo-compiler", "indexmap", @@ -3319,8 +3620,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", - "num-traits", - "thiserror 2.0.14", + "num-traits 0.2.19", + "thiserror 2.0.16", "time", ] @@ -3395,7 +3696,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3411,9 +3712,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.105" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -3437,7 +3738,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3486,7 +3787,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror 2.0.14", + "thiserror 2.0.16", "time", "uuid", "winapi", @@ -3588,15 +3889,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.8", - "windows-sys 0.52.0", + "rustix 1.1.2", + "windows-sys 0.61.0", ] [[package]] @@ -3617,7 +3918,7 @@ checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3637,11 +3938,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.14" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.14", + "thiserror-impl 2.0.16", ] [[package]] @@ -3652,18 +3953,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] name = "thiserror-impl" -version = "2.0.14" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3677,9 +3978,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", @@ -3694,15 +3995,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -3746,7 +4047,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3783,23 +4084,83 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" dependencies = [ "indexmap", "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" + +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3808,9 +4169,12 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -3822,7 +4186,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.4", "bytes", "futures-util", "http", @@ -3832,6 +4196,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3878,7 +4243,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3914,6 +4279,36 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcf5959f39507d0d04d6413119c04f33b623f4f951ebcbdddddfad2d0623a9c" +dependencies = [ + "js-sys", + "once_cell", + "opentelemetry", + "opentelemetry_sdk", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-opentelemetry-instrumentation-sdk" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13836788f587ab71400ef44b07196430782b5e483e189933c38dddb81381574" +dependencies = [ + "http", + "opentelemetry", + "tracing", + "tracing-opentelemetry", +] + [[package]] name = "tracing-serde" version = "0.2.0" @@ -3963,7 +4358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4011,9 +4406,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-segmentation" @@ -4047,9 +4442,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -4077,9 +4472,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -4149,44 +4544,54 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -4197,9 +4602,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4207,31 +4612,41 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -4255,11 +4670,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -4270,13 +4685,13 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.0", "windows-result", "windows-strings", ] @@ -4289,7 +4704,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4300,7 +4715,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4309,22 +4724,28 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -4363,6 +4784,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -4400,7 +4830,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -4551,21 +4981,20 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] [[package]] name = "wiremock" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "async-trait", "base64", "deadpool", "futures", @@ -4583,13 +5012,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -4623,28 +5049,28 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4664,7 +5090,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "synstructure", ] @@ -4704,7 +5130,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4727,9 +5153,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index c3c09c07..6364840a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,12 @@ url = { version = "2.4", features = ["serde"] } [workspace.metadata] crane.name = "apollo-mcp" +# This allows usage of coverage(off) attribute without causing a linting error. +# This attribute doesn't work in stable Rust yet and can be removed whenever it does. +# See https://github.com/apollographql/apollo-mcp-server/pull/372 +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + [workspace.lints.clippy] exit = "deny" expect_used = "deny" diff --git a/crates/apollo-mcp-registry/src/platform_api/operation_collections/collection_poller.rs b/crates/apollo-mcp-registry/src/platform_api/operation_collections/collection_poller.rs index 9f1a6605..faeab984 100644 --- a/crates/apollo-mcp-registry/src/platform_api/operation_collections/collection_poller.rs +++ b/crates/apollo-mcp-registry/src/platform_api/operation_collections/collection_poller.rs @@ -248,7 +248,7 @@ impl From<&OperationCollectionDefaultEntry> for OperationData { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum CollectionSource { Id(String, PlatformApiConfig), Default(String, PlatformApiConfig), diff --git a/crates/apollo-mcp-registry/src/uplink.rs b/crates/apollo-mcp-registry/src/uplink.rs index 015fc172..ed64c1c1 100644 --- a/crates/apollo-mcp-registry/src/uplink.rs +++ b/crates/apollo-mcp-registry/src/uplink.rs @@ -413,3 +413,283 @@ where let response_body: graphql_client::Response = res.json().await?; Ok(response_body) } + +#[cfg(test)] +mod test { + use super::*; + use futures::stream::StreamExt; + use secrecy::SecretString; + use std::str::FromStr; + use std::time::Duration; + use url::Url; + + #[tokio::test] + async fn test_stream_from_uplink() { + for url in &[GCP_URL, AWS_URL] { + if let (Ok(apollo_key), Ok(apollo_graph_ref)) = ( + std::env::var("TEST_APOLLO_KEY"), + std::env::var("TEST_APOLLO_GRAPH_REF"), + ) { + let results = + stream_from_uplink::(UplinkConfig { + apollo_key: SecretString::from(apollo_key), + apollo_graph_ref, + endpoints: Some(Endpoints::fallback(vec![ + Url::from_str(url).expect("url must be valid"), + ])), + poll_interval: Duration::from_secs(1), + timeout: Duration::from_secs(5), + }) + .take(1) + .collect::>() + .await; + + let schema = results + .first() + .unwrap_or_else(|| panic!("expected one result from {url}")) + .as_ref() + .unwrap_or_else(|_| panic!("schema should be OK from {url}")); + assert!(schema.contains("type Product")) + } + } + } + + #[test] + fn test_uplink_config_for_tests() { + let endpoints = Endpoints::fallback(vec![ + Url::from_str("http://test1.example.com").unwrap(), + Url::from_str("http://test2.example.com").unwrap(), + ]); + + let config = UplinkConfig::for_tests(endpoints.clone()); + + assert_eq!(config.apollo_key.expose_secret(), "key"); + assert_eq!(config.apollo_graph_ref, "graph"); + assert_eq!(config.poll_interval, Duration::from_secs(2)); + assert_eq!(config.timeout, Duration::from_secs(5)); + + // Check endpoints + if let Some(Endpoints::Fallback { urls }) = config.endpoints { + assert_eq!(urls.len(), 2); + assert_eq!(urls[0].as_str(), "http://test1.example.com/"); + assert_eq!(urls[1].as_str(), "http://test2.example.com/"); + } else { + panic!("Expected fallback endpoints"); + } + } + + #[test] + fn test_endpoints_fallback() { + let urls = vec![ + Url::from_str("http://test1.example.com").unwrap(), + Url::from_str("http://test2.example.com").unwrap(), + ]; + let endpoints = Endpoints::fallback(urls.clone()); + + if let Endpoints::Fallback { + urls: fallback_urls, + } = endpoints + { + assert_eq!(fallback_urls.len(), 2); + assert_eq!(fallback_urls[0], urls[0]); + assert_eq!(fallback_urls[1], urls[1]); + } else { + panic!("Expected fallback endpoints"); + } + } + + #[test] + fn test_endpoints_round_robin() { + let urls = vec![ + Url::from_str("http://test1.example.com").unwrap(), + Url::from_str("http://test2.example.com").unwrap(), + ]; + let endpoints = Endpoints::round_robin(urls.clone()); + + if let Endpoints::RoundRobin { + urls: rr_urls, + current, + } = endpoints + { + assert_eq!(rr_urls.len(), 2); + assert_eq!(rr_urls[0], urls[0]); + assert_eq!(rr_urls[1], urls[1]); + assert_eq!(current, 0); + } else { + panic!("Expected round robin endpoints"); + } + } + + #[test] + fn test_endpoints_url_count() { + let urls = vec![ + Url::from_str("http://test1.example.com").unwrap(), + Url::from_str("http://test2.example.com").unwrap(), + Url::from_str("http://test3.example.com").unwrap(), + ]; + + let fallback = Endpoints::fallback(urls.clone()); + assert_eq!(fallback.url_count(), 3); + + let round_robin = Endpoints::round_robin(urls); + assert_eq!(round_robin.url_count(), 3); + } + + #[test] + fn test_endpoints_iter_fallback() { + let urls = vec![ + Url::from_str("http://test1.example.com").unwrap(), + Url::from_str("http://test2.example.com").unwrap(), + ]; + let mut endpoints = Endpoints::fallback(urls.clone()); + + { + let iter_urls: Vec<&Url> = endpoints.iter().collect(); + assert_eq!(iter_urls.len(), 2); + assert_eq!(iter_urls[0], &urls[0]); + assert_eq!(iter_urls[1], &urls[1]); + } + + // Fallback should always return the same order + { + let iter_urls2: Vec<&Url> = endpoints.iter().collect(); + assert_eq!(iter_urls2.len(), 2); + assert_eq!(iter_urls2[0], &urls[0]); + assert_eq!(iter_urls2[1], &urls[1]); + } + } + + #[test] + fn test_endpoints_iter_round_robin() { + let urls = vec![ + Url::from_str("http://test1.example.com").unwrap(), + Url::from_str("http://test2.example.com").unwrap(), + Url::from_str("http://test3.example.com").unwrap(), + ]; + let mut endpoints = Endpoints::round_robin(urls.clone()); + + // First iteration should start at index 0 + { + let iter_urls1: Vec<&Url> = endpoints.iter().collect(); + assert_eq!(iter_urls1.len(), 3); + assert_eq!(iter_urls1[0], &urls[0]); + assert_eq!(iter_urls1[1], &urls[1]); + assert_eq!(iter_urls1[2], &urls[2]); + } + + // Second iteration should start at index 3 (current incremented to 3, then mod 3 = 0) + // But since the inspect closure increments current for each item yielded, + // the actual behavior is that current advances as the iterator is consumed + { + let iter_urls2: Vec<&Url> = endpoints.iter().collect(); + assert_eq!(iter_urls2.len(), 3); + // After the first iteration consumed 3 items, current should be 3, then 3 % 3 = 0 + assert_eq!(iter_urls2[0], &urls[0]); + assert_eq!(iter_urls2[1], &urls[1]); + assert_eq!(iter_urls2[2], &urls[2]); + } + } + + #[test] + fn test_endpoints_default() { + let endpoints = Endpoints::default(); + assert_eq!(endpoints.url_count(), 2); // GCP_URL and AWS_URL + + if let Endpoints::Fallback { urls } = endpoints { + // URLs parsed with trailing slash + assert_eq!(urls[0].as_str(), "https://uplink.api.apollographql.com/"); + assert_eq!( + urls[1].as_str(), + "https://aws.uplink.api.apollographql.com/" + ); + } else { + panic!("Expected fallback endpoints"); + } + } + + #[test] + fn test_uplink_config_default() { + let config = UplinkConfig::default(); + + assert_eq!(config.apollo_key.expose_secret(), ""); + assert_eq!(config.apollo_graph_ref, ""); + assert!(config.endpoints.is_none()); + assert_eq!(config.poll_interval, Duration::from_secs(0)); + assert_eq!(config.timeout, Duration::from_secs(0)); + } + + #[test] + fn test_error_display() { + let error1 = Error::FetchFailedSingle; + assert_eq!( + error1.to_string(), + "fetch failed from uplink endpoint, and there are no fallback endpoints configured" + ); + + let error2 = Error::FetchFailedMultiple { url_count: 3 }; + assert_eq!( + error2.to_string(), + "fetch failed from all 3 uplink endpoints" + ); + + let error3 = Error::UplinkError { + code: "AUTH_FAILED".to_string(), + message: "Invalid API key".to_string(), + }; + assert_eq!( + error3.to_string(), + "uplink error: code=AUTH_FAILED message=Invalid API key" + ); + + let error4 = Error::UplinkErrorNoRetry { + code: "UNKNOWN_REF".to_string(), + message: "Graph not found".to_string(), + }; + assert_eq!( + error4.to_string(), + "uplink error, the request will not be retried: code=UNKNOWN_REF message=Graph not found" + ); + } + + #[test] + fn test_uplink_request_debug() { + let request = UplinkRequest { + api_key: "test_api_key".to_string(), + graph_ref: "test@main".to_string(), + id: Some("test_id".to_string()), + }; + + let debug_output = format!("{:?}", request); + assert!(debug_output.contains("test_api_key")); + assert!(debug_output.contains("test@main")); + assert!(debug_output.contains("test_id")); + } + + #[test] + fn test_uplink_response_debug() { + let response_new = UplinkResponse::New { + response: "test_response".to_string(), + id: "test_id".to_string(), + delay: 30, + }; + let debug_new = format!("{:?}", response_new); + assert!(debug_new.contains("New")); + assert!(debug_new.contains("test_response")); + + let response_unchanged = UplinkResponse::::Unchanged { + id: Some("test_id".to_string()), + delay: Some(30), + }; + let debug_unchanged = format!("{:?}", response_unchanged); + assert!(debug_unchanged.contains("Unchanged")); + + let response_error = UplinkResponse::::Error { + retry_later: true, + code: "RETRY_LATER".to_string(), + message: "Try again".to_string(), + }; + let debug_error = format!("{:?}", response_error); + assert!(debug_error.contains("Error")); + assert!(debug_error.contains("retry_later: true")); + } +} diff --git a/crates/apollo-mcp-registry/src/uplink/schema.rs b/crates/apollo-mcp-registry/src/uplink/schema.rs index bd3c83a1..b03bbefd 100644 --- a/crates/apollo-mcp-registry/src/uplink/schema.rs +++ b/crates/apollo-mcp-registry/src/uplink/schema.rs @@ -9,7 +9,6 @@ use std::pin::Pin; use std::time::Duration; use crate::uplink::UplinkConfig; -use crate::uplink::schema::schema_stream::SupergraphSdlQuery; use crate::uplink::stream_from_uplink; use derive_more::Display; use derive_more::From; @@ -17,6 +16,7 @@ use educe::Educe; use event::Event; use event::Event::{NoMoreSchema, UpdateSchema}; use futures::prelude::*; +pub(crate) use schema_stream::SupergraphSdlQuery; use url::Url; /// Represents the new state of a schema after an update. diff --git a/crates/apollo-mcp-registry/src/uplink/schema/schema_stream.rs b/crates/apollo-mcp-registry/src/uplink/schema/schema_stream.rs index be04b494..3c230f37 100644 --- a/crates/apollo-mcp-registry/src/uplink/schema/schema_stream.rs +++ b/crates/apollo-mcp-registry/src/uplink/schema/schema_stream.rs @@ -99,47 +99,81 @@ impl From for UplinkResponse { #[cfg(test)] mod test { - use std::str::FromStr; - use std::time::Duration; - - use futures::stream::StreamExt; - use secrecy::SecretString; - use url::Url; - - use crate::uplink::AWS_URL; - use crate::uplink::Endpoints; - use crate::uplink::GCP_URL; - use crate::uplink::UplinkConfig; - use crate::uplink::schema::schema_stream::SupergraphSdlQuery; - use crate::uplink::stream_from_uplink; - - #[tokio::test] - async fn integration_test() { - for url in &[GCP_URL, AWS_URL] { - if let (Ok(apollo_key), Ok(apollo_graph_ref)) = ( - std::env::var("TEST_APOLLO_KEY"), - std::env::var("TEST_APOLLO_GRAPH_REF"), - ) { - let results = stream_from_uplink::(UplinkConfig { - apollo_key: SecretString::from(apollo_key), - apollo_graph_ref, - endpoints: Some(Endpoints::fallback(vec![ - Url::from_str(url).expect("url must be valid"), - ])), - poll_interval: Duration::from_secs(1), - timeout: Duration::from_secs(5), - }) - .take(1) - .collect::>() - .await; - - let schema = results - .first() - .unwrap_or_else(|| panic!("expected one result from {url}")) - .as_ref() - .unwrap_or_else(|_| panic!("schema should be OK from {url}")); - assert!(schema.contains("type Product")) - } - } + use super::*; + + #[test] + fn test_uplink_request_to_graphql_variables() { + let request = UplinkRequest { + api_key: "test_key".to_string(), + graph_ref: "test_ref".to_string(), + id: Some("test_id".to_string()), + }; + + let variables: supergraph_sdl_query::Variables = request.into(); + + assert_eq!(variables.api_key, "test_key"); + assert_eq!(variables.graph_ref, "test_ref"); + assert_eq!(variables.if_after_id, Some("test_id".to_string())); + } + + #[test] + fn test_graphql_response_to_uplink_response_new() { + let response = supergraph_sdl_query::ResponseData { + router_config: SupergraphSdlQueryRouterConfig::RouterConfigResult( + supergraph_sdl_query::SupergraphSdlQueryRouterConfigOnRouterConfigResult { + supergraph_sdl: "test_sdl".to_string(), + id: "result_id".to_string(), + min_delay_seconds: 42.0, + }, + ), + }; + + let uplink_response: UplinkResponse = response.into(); + + assert!(matches!( + uplink_response, + UplinkResponse::New { response, id, delay } + if response == "test_sdl" && id == "result_id" && delay == 42 + )); + } + + #[test] + fn test_graphql_response_to_uplink_response_unchanged() { + let response = supergraph_sdl_query::ResponseData { + router_config: SupergraphSdlQueryRouterConfig::Unchanged( + supergraph_sdl_query::SupergraphSdlQueryRouterConfigOnUnchanged { + id: "unchanged_id".to_string(), + min_delay_seconds: 30.0, + }, + ), + }; + + let uplink_response: UplinkResponse = response.into(); + + assert!(matches!( + uplink_response, + UplinkResponse::Unchanged { id, delay } + if id == Some("unchanged_id".to_string()) && delay == Some(30) + )); + } + + #[test] + fn test_graphql_response_to_uplink_response_error() { + let response = supergraph_sdl_query::ResponseData { + router_config: SupergraphSdlQueryRouterConfig::FetchError( + supergraph_sdl_query::SupergraphSdlQueryRouterConfigOnFetchError { + code: FetchErrorCode::RETRY_LATER, + message: "Try again later".to_string(), + }, + ), + }; + + let uplink_response: UplinkResponse = response.into(); + + assert!(matches!( + uplink_response, + UplinkResponse::Error { retry_later, code, message } + if retry_later && code == "RETRY_LATER" && message == "Try again later" + )); } } diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index 3fe245b7..b2b0fcbc 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -6,6 +6,7 @@ license-file.workspace = true repository.workspace = true rust-version.workspace = true version.workspace = true +build = "build.rs" default-run = "apollo-mcp-server" @@ -17,6 +18,8 @@ apollo-mcp-registry = { path = "../apollo-mcp-registry" } apollo-schema-index = { path = "../apollo-schema-index" } axum = "0.8.4" axum-extra = { version = "0.10.1", features = ["typed-header"] } +axum-otel-metrics = "0.12.0" +axum-tracing-opentelemetry = "0.29.0" bon = "3.6.3" clap = { version = "4.5.36", features = ["derive", "env"] } figment = { version = "0.10.19", features = ["env", "yaml"] } @@ -28,7 +31,24 @@ jsonschema = "0.33.0" jsonwebtoken = "9" jwks = "0.4.0" lz-str = "0.2.1" +opentelemetry = "0.30.0" +opentelemetry-appender-log = "0.30.0" +opentelemetry-otlp = { version = "0.30.0", features = [ + "grpc-tonic", + "tonic", + "http-proto", + "metrics", + "trace", +] } +opentelemetry-resource-detectors = "0.9.0" +opentelemetry-semantic-conventions = "0.30.0" +opentelemetry-stdout = "0.30.0" +opentelemetry_sdk = { version = "0.30.0", features = [ + "spec_unstable_metrics_views", +] } regex = "1.11.1" +reqwest-middleware = "0.4.2" +reqwest-tracing = { version = "0.5.8", features = ["opentelemetry_0_30"] } reqwest.workspace = true rmcp = { version = "0.6", features = [ "server", @@ -41,22 +61,35 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true -tracing.workspace = true +tokio-util = "0.7.15" +tower-http = { version = "0.6.6", features = ["cors", "trace"] } tracing-appender = "0.2.3" +tracing-core.workspace = true +tracing-opentelemetry = "0.31.0" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tokio-util = "0.7.15" -tower-http = { version = "0.6.6", features = ["cors"] } +tracing.workspace = true url.workspace = true +async-trait = "0.1.89" [dev-dependencies] chrono = { version = "0.4.41", default-features = false, features = ["now"] } figment = { version = "0.10.19", features = ["test"] } insta.workspace = true mockito = "1.7.0" +opentelemetry_sdk = { version = "0.30.0", features = ["testing"] } rstest.workspace = true tokio.workspace = true +tower = "0.5.2" tracing-test = "0.2.5" +[build-dependencies] +cruet = "0.15.0" +prettyplease = "0.2.37" +quote = "1.0.40" +serde.workspace = true +syn = "2.0.106" +toml = "0.9.5" + [lints] workspace = true diff --git a/crates/apollo-mcp-server/build.rs b/crates/apollo-mcp-server/build.rs new file mode 100644 index 00000000..f213cc35 --- /dev/null +++ b/crates/apollo-mcp-server/build.rs @@ -0,0 +1,169 @@ +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] +#![allow(clippy::panic)] + +//! Build Script for the Apollo MCP Server +//! +//! This mostly compiles all the available telemetry attributes +use quote::__private::TokenStream; +use quote::quote; +use serde::Deserialize; +use std::io::Write; +use std::{collections::VecDeque, io::Read as _}; +use syn::{Ident, parse2}; + +#[derive(Deserialize)] +struct TelemetryTomlData { + attributes: toml::Table, + metrics: toml::Table, +} + +#[derive(Eq, PartialEq, Debug, Clone)] +struct TelemetryData { + name: String, + alias: String, + value: String, + description: String, +} + +fn flatten(table: toml::Table) -> Vec { + let mut to_visit = VecDeque::from_iter(table.into_iter().map(|(key, val)| (vec![key], val))); + let mut telemetry_data = Vec::new(); + + while let Some((key, value)) = to_visit.pop_front() { + match value { + toml::Value::String(val) => { + let last_key = key.last().unwrap().clone(); + telemetry_data.push(TelemetryData { + name: cruet::to_pascal_case(last_key.as_str()), + alias: last_key, + value: key.join("."), + description: val, + }); + } + toml::Value::Table(map) => to_visit.extend( + map.into_iter() + .map(|(nested_key, value)| ([key.clone(), vec![nested_key]].concat(), value)), + ), + + _ => panic!("telemetry values should be string descriptions"), + }; + } + + telemetry_data +} + +fn generate_enum(telemetry_data: &[TelemetryData]) -> Vec { + telemetry_data + .iter() + .map(|t| { + let enum_value_ident = quote::format_ident!("{}", &t.name); + let alias = &t.alias; + let doc_message = &t.description; + quote! { + #[doc = #doc_message] + #[serde(alias = #alias)] + #enum_value_ident + } + }) + .collect::>() +} + +fn generate_enum_as_str_matches( + telemetry_data: &[TelemetryData], + enum_ident: Ident, +) -> Vec { + telemetry_data + .iter() + .map(|t| { + let name_ident = quote::format_ident!("{}", &t.name); + let value = &t.value; + quote! { + #enum_ident::#name_ident => #value + } + }) + .collect::>() +} + +fn main() { + // Parse the telemetry file + let telemetry: TelemetryTomlData = { + let mut raw = String::new(); + std::fs::File::open("telemetry.toml") + .expect("could not open telemetry file") + .read_to_string(&mut raw) + .expect("could not read telemetry file"); + + toml::from_str(&raw).expect("could not parse telemetry file") + }; + + // Generate the keys + let telemetry_attribute_data = flatten(telemetry.attributes); + let telemetry_metrics_data = flatten(telemetry.metrics); + + // Write out the generated keys + let out_dir = std::env::var_os("OUT_DIR").expect("could not retrieve output directory"); + let dest_path = std::path::Path::new(&out_dir).join("telemetry_attributes.rs"); + let mut generated_file = + std::fs::File::create(&dest_path).expect("could not create generated code file"); + + let attribute_keys_len = telemetry_attribute_data.len(); + let attribute_enum_keys = generate_enum(&telemetry_attribute_data); + let all_attribute_enum_values = &telemetry_attribute_data + .iter() + .map(|t| quote::format_ident!("{}", t.name)); + let all_attribute_enum_values = (*all_attribute_enum_values).clone(); + let attribute_enum_name = quote::format_ident!("{}", "TelemetryAttribute"); + let attribute_enum_as_str_matches = + generate_enum_as_str_matches(&telemetry_attribute_data, attribute_enum_name.clone()); + + let metric_enum_name = quote::format_ident!("{}", "TelemetryMetric"); + let metric_enum_keys = generate_enum(&telemetry_metrics_data); + let metric_enum_as_str_matches = + generate_enum_as_str_matches(&telemetry_metrics_data, metric_enum_name.clone()); + + let tokens = quote! { + /// All TelemetryAttribute values + pub const ALL_ATTRS: &[TelemetryAttribute; #attribute_keys_len] = &[#(TelemetryAttribute::#all_attribute_enum_values),*]; + + /// Supported telemetry attribute (tags) values + #[derive(Debug, ::serde::Deserialize, ::schemars::JsonSchema, Clone, Eq, PartialEq, Hash, Copy)] + pub enum #attribute_enum_name { + #(#attribute_enum_keys),* + } + + impl #attribute_enum_name { + /// Converts TelemetryAttribute to &str + pub const fn as_str(&self) -> &'static str { + match self { + #(#attribute_enum_as_str_matches),* + } + } + } + + /// Supported telemetry metrics + #[derive(Debug, ::serde::Deserialize, ::schemars::JsonSchema, Clone, Eq, PartialEq, Hash, Copy)] + pub enum #metric_enum_name { + #(#metric_enum_keys),* + } + + impl #metric_enum_name { + /// Converts TelemetryMetric to &str + pub const fn as_str(&self) -> &'static str { + match self { + #(#metric_enum_as_str_matches),* + } + } + } + }; + + let file = parse2(tokens).expect("Could not parse TokenStream"); + let code = prettyplease::unparse(&file); + + write!(generated_file, "{}", code).expect("Failed to write generated code"); + + // Inform cargo that we only want this to run when either this file or the telemetry + // one changes. + println!("cargo::rerun-if-changed=build.rs"); + println!("cargo::rerun-if-changed=telemetry.toml"); +} diff --git a/crates/apollo-mcp-server/src/auth.rs b/crates/apollo-mcp-server/src/auth.rs index 1c802828..e52e1729 100644 --- a/crates/apollo-mcp-server/src/auth.rs +++ b/crates/apollo-mcp-server/src/auth.rs @@ -84,6 +84,7 @@ impl Config { } /// Validate that requests made have a corresponding bearer JWT token +#[tracing::instrument(skip_all, fields(status_code, reason))] async fn oauth_validate( State(auth_config): State, token: Option>>, @@ -104,17 +105,85 @@ async fn oauth_validate( }; let validator = NetworkedTokenValidator::new(&auth_config.audiences, &auth_config.servers); - let token = token.ok_or_else(unauthorized_error)?; - - let valid_token = validator - .validate(token.0) - .await - .ok_or_else(unauthorized_error)?; + let token = token.ok_or_else(|| { + tracing::Span::current().record("reason", "missing_token"); + tracing::Span::current().record("status_code", StatusCode::UNAUTHORIZED.as_u16()); + unauthorized_error() + })?; + + let valid_token = validator.validate(token.0).await.ok_or_else(|| { + tracing::Span::current().record("reason", "invalid_token"); + tracing::Span::current().record("status_code", StatusCode::UNAUTHORIZED.as_u16()); + unauthorized_error() + })?; // Insert new context to ensure that handlers only use our enforced token verification // for propagation request.extensions_mut().insert(valid_token); let response = next.run(request).await; + tracing::Span::current().record("status_code", response.status().as_u16()); Ok(response) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::middleware::from_fn_with_state; + use axum::routing::get; + use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, + }; + use http::header::{AUTHORIZATION, WWW_AUTHENTICATE}; + use tower::ServiceExt; // for .oneshot() + use url::Url; + + fn test_config() -> Config { + Config { + servers: vec![Url::parse("http://localhost:1234").unwrap()], + audiences: vec!["test-audience".to_string()], + resource: Url::parse("http://localhost:4000").unwrap(), + resource_documentation: None, + scopes: vec!["read".to_string()], + disable_auth_token_passthrough: false, + } + } + + fn test_router(config: Config) -> Router { + Router::new() + .route("/test", get(|| async { "ok" })) + .layer(from_fn_with_state(config, oauth_validate)) + } + + #[tokio::test] + async fn missing_token_returns_unauthorized() { + let config = test_config(); + let app = test_router(config.clone()); + let req = Request::builder().uri("/test").body(Body::empty()).unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let headers = res.headers(); + let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap(); + assert!(www_auth.contains("Bearer")); + assert!(www_auth.contains("resource_metadata")); + } + + #[tokio::test] + async fn invalid_token_returns_unauthorized() { + let config = test_config(); + let app = test_router(config.clone()); + let req = Request::builder() + .uri("/test") + .header(AUTHORIZATION, "Bearer invalidtoken") + .body(Body::empty()) + .unwrap(); + let res = app.oneshot(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let headers = res.headers(); + let www_auth = headers.get(WWW_AUTHENTICATE).unwrap().to_str().unwrap(); + assert!(www_auth.contains("Bearer")); + assert!(www_auth.contains("resource_metadata")); + } +} diff --git a/crates/apollo-mcp-server/src/cors.rs b/crates/apollo-mcp-server/src/cors.rs new file mode 100644 index 00000000..8b8facd9 --- /dev/null +++ b/crates/apollo-mcp-server/src/cors.rs @@ -0,0 +1,837 @@ +use http::Method; +use regex::Regex; +use schemars::JsonSchema; +use serde::Deserialize; +use tower_http::cors::{AllowOrigin, Any, CorsLayer}; +use url::Url; + +use crate::errors::ServerError; + +/// CORS configuration options +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(default)] +pub struct CorsConfig { + /// Enable CORS support + pub enabled: bool, + + /// List of allowed origins (exact match) + pub origins: Vec, + + /// List of origin patterns (regex matching) + pub match_origins: Vec, + + /// Allow any origin (use with caution) + pub allow_any_origin: bool, + + /// Allow credentials in CORS requests + pub allow_credentials: bool, + + /// Allowed HTTP methods + pub allow_methods: Vec, + + /// Allowed request headers + pub allow_headers: Vec, + + /// Headers exposed to the browser + pub expose_headers: Vec, + + /// Max age for preflight cache (in seconds) + pub max_age: Option, +} + +impl Default for CorsConfig { + fn default() -> Self { + Self { + enabled: false, + origins: Vec::new(), + match_origins: Vec::new(), + allow_any_origin: false, + allow_credentials: false, + allow_methods: vec![ + "GET".to_string(), + "POST".to_string(), + "DELETE".to_string(), // Clients that no longer need a particular session SHOULD send an HTTP DELETE to explicitly terminate the session + ], + allow_headers: vec![ + "content-type".to_string(), + "mcp-protocol-version".to_string(), // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#protocol-version-header + "mcp-session-id".to_string(), // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management + "traceparent".to_string(), // https://www.w3.org/TR/trace-context/#traceparent-header + "tracestate".to_string(), // https://www.w3.org/TR/trace-context/#tracestate-header + ], + expose_headers: vec![ + "mcp-session-id".to_string(), // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management + "traceparent".to_string(), // https://www.w3.org/TR/trace-context/#traceparent-header + "tracestate".to_string(), // https://www.w3.org/TR/trace-context/#tracestate-header + ], + max_age: Some(7200), // 2 hours + } + } +} + +impl CorsConfig { + /// Build a CorsLayer from this configuration + pub fn build_cors_layer(&self) -> Result { + if !self.enabled { + return Err(ServerError::Cors("CORS is not enabled".to_string())); + } + + // Validate configuration + self.validate()?; + + let mut cors = CorsLayer::new(); + + // Configure origins + if self.allow_any_origin { + cors = cors.allow_origin(Any); + } else { + // Collect all origins (exact and regex patterns) + let mut origin_list = Vec::new(); + + // Parse exact origins + for origin_str in &self.origins { + let origin = origin_str.parse::().map_err(|e| { + ServerError::Cors(format!("Invalid origin '{}': {}", origin_str, e)) + })?; + origin_list.push(origin); + } + + // For regex patterns, we need to use a predicate function + if !self.match_origins.is_empty() { + // Parse regex patterns to validate them + let mut regex_patterns = Vec::new(); + for pattern in &self.match_origins { + let regex = Regex::new(pattern).map_err(|e| { + ServerError::Cors(format!("Invalid origin pattern '{}': {}", pattern, e)) + })?; + regex_patterns.push(regex); + } + + // Use predicate function that combines exact origins and regex patterns + let exact_origins = origin_list; + cors = cors.allow_origin(AllowOrigin::predicate(move |origin, _| { + let origin_str = origin.to_str().unwrap_or(""); + + // Check exact origins + if exact_origins + .iter() + .any(|exact| exact.as_bytes() == origin.as_bytes()) + { + return true; + } + + // Check regex patterns + regex_patterns + .iter() + .any(|regex| regex.is_match(origin_str)) + })); + } else if !origin_list.is_empty() { + // Only exact origins, no regex + cors = cors.allow_origin(origin_list); + } + } + + // Configure credentials + cors = cors.allow_credentials(self.allow_credentials); + + // Configure methods + let methods: Result, _> = self + .allow_methods + .iter() + .map(|m| m.parse::()) + .collect(); + let methods = + methods.map_err(|e| ServerError::Cors(format!("Invalid HTTP method: {}", e)))?; + cors = cors.allow_methods(methods); + + // Configure headers + if !self.allow_headers.is_empty() { + let headers: Result, _> = self + .allow_headers + .iter() + .map(|h| h.parse::()) + .collect(); + let headers = + headers.map_err(|e| ServerError::Cors(format!("Invalid header name: {}", e)))?; + cors = cors.allow_headers(headers); + } + + // Configure exposed headers + if !self.expose_headers.is_empty() { + let headers: Result, _> = self + .expose_headers + .iter() + .map(|h| h.parse::()) + .collect(); + let headers = headers + .map_err(|e| ServerError::Cors(format!("Invalid exposed header name: {}", e)))?; + cors = cors.expose_headers(headers); + } + + // Configure max age + if let Some(max_age) = self.max_age { + cors = cors.max_age(std::time::Duration::from_secs(max_age)); + } + + Ok(cors) + } + + /// Validate the configuration for consistency + fn validate(&self) -> Result<(), ServerError> { + // Cannot use credentials with any origin + if self.allow_credentials && self.allow_any_origin { + return Err(ServerError::Cors( + "Cannot use allow_credentials with allow_any_origin for security reasons. See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#requests_with_credentials" + .to_string(), + )); + } + + // Must have at least some origin configuration if not allowing any origin + if !self.allow_any_origin && self.origins.is_empty() && self.match_origins.is_empty() { + return Err(ServerError::Cors( + "Must specify origins, match_origins, or allow_any_origin when CORS is enabled" + .to_string(), + )); + } + + // Validate that origin strings are valid URLs + for origin in &self.origins { + Url::parse(origin).map_err(|e| { + ServerError::Cors(format!("Invalid origin URL '{}': {}", origin, e)) + })?; + } + + // Validate regex patterns + for pattern in &self.match_origins { + Regex::new(pattern).map_err(|e| { + ServerError::Cors(format!("Invalid regex pattern '{}': {}", pattern, e)) + })?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{Router, routing::get}; + use http::{HeaderValue, Method, Request, StatusCode}; + use tower::util::ServiceExt; + + #[test] + fn test_default_config() { + let config = CorsConfig::default(); + assert!(!config.enabled); + assert!(!config.allow_any_origin); + assert!(!config.allow_credentials); + assert_eq!( + config.allow_methods, + vec!["GET".to_string(), "POST".to_string(), "DELETE".to_string()] + ); + assert_eq!( + config.allow_headers, + vec![ + "content-type".to_string(), + "mcp-protocol-version".to_string(), + "mcp-session-id".to_string(), + "traceparent".to_string(), + "tracestate".to_string(), + ] + ); + assert_eq!( + config.expose_headers, + vec![ + "mcp-session-id".to_string(), + "traceparent".to_string(), + "tracestate".to_string(), + ] + ); + assert_eq!(config.max_age, Some(7200)); + } + + #[test] + fn test_disabled_cors_fails_to_build() { + let config = CorsConfig::default(); + assert!(config.build_cors_layer().is_err()); + } + + #[test] + fn test_allow_any_origin_builds() { + let config = CorsConfig { + enabled: true, + allow_any_origin: true, + ..Default::default() + }; + assert!(config.build_cors_layer().is_ok()); + } + + #[test] + fn test_specific_origins_build() { + let config = CorsConfig { + enabled: true, + origins: vec![ + "http://localhost:3000".to_string(), + "https://studio.apollographql.com".to_string(), + ], + ..Default::default() + }; + assert!(config.build_cors_layer().is_ok()); + } + + #[test] + fn test_regex_origins_build() { + let config = CorsConfig { + enabled: true, + match_origins: vec!["^http://localhost:[0-9]+$".to_string()], + ..Default::default() + }; + assert!(config.build_cors_layer().is_ok()); + } + + #[test] + fn test_credentials_with_any_origin_fails() { + let config = CorsConfig { + enabled: true, + allow_any_origin: true, + allow_credentials: true, + ..Default::default() + }; + assert!(config.build_cors_layer().is_err()); + } + + #[test] + fn test_no_origins_fails() { + let config = CorsConfig { + enabled: true, + allow_any_origin: false, + origins: vec![], + match_origins: vec![], + ..Default::default() + }; + assert!(config.build_cors_layer().is_err()); + } + + #[test] + fn test_invalid_origin_fails() { + let config = CorsConfig { + enabled: true, + origins: vec!["not-a-valid-url".to_string()], + ..Default::default() + }; + assert!(config.build_cors_layer().is_err()); + } + + #[test] + fn test_invalid_regex_fails() { + let config = CorsConfig { + enabled: true, + match_origins: vec!["[invalid regex".to_string()], + ..Default::default() + }; + assert!(config.build_cors_layer().is_err()); + } + + #[test] + fn test_invalid_method_fails() { + let config = CorsConfig { + enabled: true, + origins: vec!["http://localhost:3000".to_string()], + allow_methods: vec!["invalid method with spaces".to_string()], + ..Default::default() + }; + assert!(config.build_cors_layer().is_err()); + } + + #[tokio::test] + async fn test_preflight_request_with_exact_origin() { + let config = CorsConfig { + enabled: true, + origins: vec!["http://localhost:3000".to_string()], + max_age: Some(3600), + ..Default::default() + }; + + let app = Router::new().layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/test") + .header("Origin", "http://localhost:3000") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "content-type,authorization", + ) + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static("http://localhost:3000")) + ); + } + + #[tokio::test] + async fn test_simple_request_with_exact_origin() { + let config = CorsConfig { + enabled: true, + origins: vec!["http://localhost:3000".to_string()], + ..Default::default() + }; + + let app = Router::new() + .route("/health", get(|| async { "test response" })) + .layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::GET) + .uri("/health") + .header("Origin", "http://localhost:3000") + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static("http://localhost:3000")) + ); + } + + #[tokio::test] + async fn test_preflight_request_with_regex_origin() { + let config = CorsConfig { + enabled: true, + match_origins: vec!["^http://localhost:[0-9]+$".to_string()], + ..Default::default() + }; + + let app = Router::new().layer(config.build_cors_layer().unwrap()); + + // Test matching port + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/test") + .header("Origin", "http://localhost:4321") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "content-type,authorization", + ) + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static("http://localhost:4321")) + ); + } + + #[tokio::test] + async fn test_simple_request_with_regex_origin() { + let config = CorsConfig { + enabled: true, + match_origins: vec!["^https://.*\\.apollographql\\.com$".to_string()], + ..Default::default() + }; + + let app = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::GET) + .uri("/test") + .header("Origin", "https://www.apollographql.com") + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static("https://www.apollographql.com")) + ); + } + + #[tokio::test] + async fn test_mixed_exact_and_regex_origins() { + let config = CorsConfig { + enabled: true, + origins: vec!["http://localhost:3000".to_string()], + match_origins: vec!["^https://.*\\.apollographql\\.com$".to_string()], + ..Default::default() + }; + + let cors_layer = config.build_cors_layer().unwrap(); + + // Test exact origin + let app1 = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(cors_layer.clone()); + + let request1 = Request::builder() + .method(Method::GET) + .uri("/test") + .header("Origin", "http://localhost:3000") + .body(axum::body::Body::empty()) + .unwrap(); + + let response1 = app1.oneshot(request1).await.unwrap(); + assert_eq!( + response1.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static("http://localhost:3000")) + ); + + // Test regex origin + let app2 = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(cors_layer); + + let request2 = Request::builder() + .method(Method::GET) + .uri("/test") + .header("Origin", "https://studio.apollographql.com") + .body(axum::body::Body::empty()) + .unwrap(); + + let response2 = app2.oneshot(request2).await.unwrap(); + assert_eq!( + response2.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static( + "https://studio.apollographql.com" + )) + ); + } + + #[tokio::test] + async fn test_preflight_request_rejected_origin_exact() { + let config = CorsConfig { + enabled: true, + origins: vec!["https://allowed.com".to_string()], + ..Default::default() + }; + + let app = Router::new().layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/test") + .header("Origin", "https://blocked.com") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "content-type,authorization", + ) + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); + } + + #[tokio::test] + async fn test_simple_request_rejected_origin_exact() { + let config = CorsConfig { + enabled: true, + origins: vec!["https://allowed.com".to_string()], + ..Default::default() + }; + + let app = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::GET) + .uri("/test") + .header("Origin", "https://blocked.com") + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); + } + + #[tokio::test] + async fn test_preflight_request_rejected_origin_regex() { + let config = CorsConfig { + enabled: true, + match_origins: vec!["^https://.*\\.allowed\\.com$".to_string()], + ..Default::default() + }; + + let cors_layer = config.build_cors_layer().unwrap(); + let app = Router::new().layer(cors_layer); + + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/test") + .header("Origin", "https://malicious.blocked.com") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "content-type,authorization", + ) + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); + } + + #[tokio::test] + async fn test_simple_request_rejected_origin_regex() { + let config = CorsConfig { + enabled: true, + match_origins: vec!["^https://.*\\.allowed\\.com$".to_string()], + ..Default::default() + }; + + let cors_layer = config.build_cors_layer().unwrap(); + let app = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(cors_layer); + + let request = Request::builder() + .method(Method::GET) + .uri("/test") + .header("Origin", "https://malicious.blocked.com") + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); + } + + #[tokio::test] + async fn test_preflight_request_any_origin() { + let config = CorsConfig { + enabled: true, + allow_any_origin: true, + ..Default::default() + }; + + let app = Router::new().layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/test") + .header("Origin", "https://any-domain.com") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "content-type,authorization", + ) + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static("*")) + ); + } + + #[tokio::test] + async fn test_simple_request_any_origin() { + let config = CorsConfig { + enabled: true, + allow_any_origin: true, + ..Default::default() + }; + + let app = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::GET) + .uri("/test") + .header("Origin", "https://any-domain.com") + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-origin"), + Some(&HeaderValue::from_static("*")) + ); + } + + #[tokio::test] + async fn test_non_cors_request() { + let config = CorsConfig { + enabled: true, + origins: vec!["https://allowed.com".to_string()], + ..Default::default() + }; + + let cors_layer = config.build_cors_layer().unwrap(); + let app = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(cors_layer); + + let request = Request::builder() + .method(Method::GET) + .uri("/test") + // No Origin header + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + // Request should succeed but without CORS headers + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); + } + + #[tokio::test] + async fn test_multiple_request_headers() { + let config = CorsConfig { + enabled: true, + origins: vec!["https://allowed.com".to_string()], + allow_headers: vec![ + "content-type".to_string(), + "authorization".to_string(), + "x-api-key".to_string(), + "x-requested-with".to_string(), + ], + ..Default::default() + }; + + let app = Router::new().layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/test") + .header("Origin", "https://allowed.com") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "content-type,authorization,x-api-key,disallowed-header", + ) + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let allow_headers = response + .headers() + .get("access-control-allow-headers") + .unwrap(); + let headers_str = allow_headers.to_str().unwrap(); + assert!(headers_str.contains("content-type")); + assert!(headers_str.contains("authorization")); + assert!(headers_str.contains("x-api-key")); + assert!(!headers_str.contains("disallowed-header")); + } + + #[tokio::test] + async fn test_preflight_request_with_credentials() { + let config = CorsConfig { + enabled: true, + origins: vec!["https://allowed.com".to_string()], + allow_credentials: true, + ..Default::default() + }; + + let app = Router::new().layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::OPTIONS) + .uri("/test") + .header("Origin", "https://allowed.com") + .header("Access-Control-Request-Method", "POST") + .header( + "Access-Control-Request-Headers", + "content-type,authorization", + ) + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-credentials"), + Some(&HeaderValue::from_static("true")) + ); + } + + #[tokio::test] + async fn test_simple_request_with_credentials() { + let config = CorsConfig { + enabled: true, + origins: vec!["https://allowed.com".to_string()], + allow_credentials: true, + ..Default::default() + }; + + let app = Router::new() + .route("/test", get(|| async { "test response" })) + .layer(config.build_cors_layer().unwrap()); + + let request = Request::builder() + .method(Method::GET) + .uri("/test") + .header("Origin", "https://allowed.com") + .header("Cookie", "sessionid=abc123") + .body(axum::body::Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("access-control-allow-credentials"), + Some(&HeaderValue::from_static("true")) + ); + } +} diff --git a/crates/apollo-mcp-server/src/errors.rs b/crates/apollo-mcp-server/src/errors.rs index caaa7f53..51771036 100644 --- a/crates/apollo-mcp-server/src/errors.rs +++ b/crates/apollo-mcp-server/src/errors.rs @@ -100,6 +100,9 @@ pub enum ServerError { #[error("Failed to index schema: {0}")] Indexing(#[from] IndexingError), + + #[error("CORS configuration error: {0}")] + Cors(String), } /// An MCP tool error diff --git a/crates/apollo-mcp-server/src/event.rs b/crates/apollo-mcp-server/src/event.rs index f5f4e0b4..a8c4651f 100644 --- a/crates/apollo-mcp-server/src/event.rs +++ b/crates/apollo-mcp-server/src/event.rs @@ -3,6 +3,7 @@ use apollo_mcp_registry::platform_api::operation_collections::error::CollectionE use apollo_mcp_registry::uplink::schema::event::Event as SchemaEvent; use std::fmt::Debug; use std::fmt::Formatter; +use std::fmt::Result; use std::io; /// MCP Server events @@ -24,7 +25,7 @@ pub enum Event { } impl Debug for Event { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut Formatter) -> Result { match self { Event::SchemaUpdated(event) => { write!(f, "SchemaUpdated({event:?})") @@ -44,3 +45,46 @@ impl Debug for Event { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_debug_event_schema_updated() { + let event = Event::SchemaUpdated(SchemaEvent::NoMoreSchema); + let output = format!("{:?}", event); + assert_eq!(output, "SchemaUpdated(NoMoreSchema)"); + } + + #[test] + fn test_debug_event_operations_updated() { + let event = Event::OperationsUpdated(vec![]); + let output = format!("{:?}", event); + assert_eq!(output, "OperationsChanged([])"); + } + + #[test] + fn test_debug_event_operation_error() { + let event = Event::OperationError(std::io::Error::other("TEST"), None); + let output = format!("{:?}", event); + assert_eq!( + output, + r#"OperationError(Custom { kind: Other, error: "TEST" }, None)"# + ); + } + + #[test] + fn test_debug_event_collection_error() { + let event = Event::CollectionError(CollectionError::Response("TEST".to_string())); + let output = format!("{:?}", event); + assert_eq!(output, r#"OperationError(Response("TEST"))"#); + } + + #[test] + fn test_debug_event_shutdown() { + let event = Event::Shutdown; + let output = format!("{:?}", event); + assert_eq!(output, "Shutdown"); + } +} diff --git a/crates/apollo-mcp-server/src/graphql.rs b/crates/apollo-mcp-server/src/graphql.rs index d47e61e2..8ed86941 100644 --- a/crates/apollo-mcp-server/src/graphql.rs +++ b/crates/apollo-mcp-server/src/graphql.rs @@ -1,11 +1,17 @@ //! Execute GraphQL operations from an MCP tool use crate::errors::McpError; +use crate::generated::telemetry::{TelemetryAttribute, TelemetryMetric}; +use crate::meter; +use opentelemetry::KeyValue; use reqwest::header::{HeaderMap, HeaderValue}; +use reqwest_middleware::{ClientBuilder, Extension}; +use reqwest_tracing::{OtelName, TracingMiddleware}; use rmcp::model::{CallToolResult, Content, ErrorCode}; use serde_json::{Map, Value}; use url::Url; +#[derive(Debug)] pub struct Request<'a> { pub input: Value, pub endpoint: &'a Url, @@ -33,7 +39,11 @@ pub trait Executable { fn headers(&self, default_headers: &HeaderMap) -> HeaderMap; /// Execute as a GraphQL operation using the endpoint and headers + #[tracing::instrument(skip(self, request))] async fn execute(&self, request: Request<'_>) -> Result { + let meter = &meter::METER; + let start = std::time::Instant::now(); + let mut op_id: Option = None; let client_metadata = serde_json::json!({ "name": "mcp", "version": std::env!("CARGO_PKG_VERSION") @@ -55,6 +65,7 @@ pub trait Executable { "clientLibrary": client_metadata, }), ); + op_id = Some(id.to_string()); } else { let OperationDetails { query, @@ -70,11 +81,17 @@ pub trait Executable { ); if let Some(op_name) = operation_name { + op_id = Some(op_name.clone()); request_body.insert(String::from("operationName"), Value::String(op_name)); } } - reqwest::Client::new() + let client = ClientBuilder::new(reqwest::Client::new()) + .with_init(Extension(OtelName("mcp-graphql-client".into()))) + .with(TracingMiddleware::default()) + .build(); + + let result = client .post(request.endpoint.as_str()) .headers(self.headers(&request.headers)) .body(Value::Object(request_body).to_string()) @@ -109,15 +126,50 @@ pub trait Executable { ), meta: None, structured_content: Some(json), - }) + }); + + // Record response metrics + let attributes = vec![ + KeyValue::new( + TelemetryAttribute::Success.to_key(), + result.as_ref().is_ok_and(|r| r.is_error != Some(true)), + ), + KeyValue::new( + TelemetryAttribute::OperationId.to_key(), + op_id.unwrap_or("".to_string()), + ), + KeyValue::new( + TelemetryAttribute::OperationSource.to_key(), + match self.persisted_query_id() { + Some(_) => "persisted_query", + None => "operation", + }, + ), + ]; + meter + .f64_histogram(TelemetryMetric::OperationDuration.as_str()) + .build() + .record(start.elapsed().as_millis() as f64, &attributes); + meter + .u64_counter(TelemetryMetric::OperationCount.as_str()) + .build() + .add(1, &attributes); + + result } } #[cfg(test)] mod test { use crate::errors::McpError; + use crate::generated::telemetry::TelemetryMetric; use crate::graphql::{Executable, OperationDetails, Request}; use http::{HeaderMap, HeaderValue}; + use opentelemetry::global; + use opentelemetry_sdk::metrics::data::{AggregatedMetrics, MetricData}; + use opentelemetry_sdk::metrics::{ + InMemoryMetricExporter, MeterProviderBuilder, PeriodicReader, + }; use serde_json::{Map, Value, json}; use url::Url; @@ -357,4 +409,76 @@ mod test { assert!(result.is_error.is_some()); assert!(result.is_error.unwrap()); } + + #[tokio::test] + async fn validate_metric_attributes_success_false() { + // given + let exporter = InMemoryMetricExporter::default(); + let meter_provider = MeterProviderBuilder::default() + .with_reader(PeriodicReader::builder(exporter.clone()).build()) + .build(); + global::set_meter_provider(meter_provider.clone()); + + let mut server = mockito::Server::new_async().await; + let url = Url::parse(server.url().as_str()).unwrap(); + let mock_request = Request { + input: json!({}), + endpoint: &url, + headers: HeaderMap::new(), + }; + + server + .mock("POST", "/") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "data": null, "errors": ["an error"] }).to_string()) + .expect(1) + .create_async() + .await; + + // when + let test_executable = TestExecutableWithPersistedQueryId {}; + let result = test_executable.execute(mock_request).await.unwrap(); + + // then + assert!(result.is_error.is_some()); + assert!(result.is_error.unwrap()); + + // Retrieve the finished metrics from the exporter + let finished_metrics = exporter.get_finished_metrics().unwrap(); + + // validate the attributes of the apollo.mcp.operation.count counter + for resource_metrics in finished_metrics { + if let Some(scope_metrics) = resource_metrics + .scope_metrics() + .find(|scope_metrics| scope_metrics.scope().name() == "apollo.mcp") + { + for metric in scope_metrics.metrics() { + if metric.name() == TelemetryMetric::OperationCount.as_str() + && let AggregatedMetrics::U64(MetricData::Sum(data)) = metric.data() + { + for point in data.data_points() { + let attributes = point.attributes(); + let mut attr_map = std::collections::HashMap::new(); + for kv in attributes { + attr_map.insert(kv.key.as_str(), kv.value.as_str()); + } + assert_eq!( + attr_map.get("operation.id").map(|s| s.as_ref()), + Some("mock_operation") + ); + assert_eq!( + attr_map.get("operation.type").map(|s| s.as_ref()), + Some("persisted_query") + ); + assert_eq!( + attr_map.get("success"), + Some(&std::borrow::Cow::Borrowed("false")) + ); + } + } + } + } + } + } } diff --git a/crates/apollo-mcp-server/src/introspection/minify.rs b/crates/apollo-mcp-server/src/introspection/minify.rs index 81af9534..6cc47a8a 100644 --- a/crates/apollo-mcp-server/src/introspection/minify.rs +++ b/crates/apollo-mcp-server/src/introspection/minify.rs @@ -1,6 +1,6 @@ use apollo_compiler::schema::{ExtendedType, Type}; use regex::Regex; -use std::sync::OnceLock; +use std::{collections::HashMap, sync::OnceLock}; pub trait MinifyExt { /// Serialize in minified form @@ -72,6 +72,38 @@ fn minify_input_object(input_object_type: &apollo_compiler::schema::InputObjectT format!("I:{type_name}:{fields}") } +// We should only minify directives that assist the LLM in understanding the schema. This included @deprecated +fn minify_directives(directives: &apollo_compiler::ast::DirectiveList) -> String { + let mut result = String::new(); + + static DIRECTIVES_TO_MINIFY: OnceLock> = OnceLock::new(); + let directives_to_minify = + DIRECTIVES_TO_MINIFY.get_or_init(|| HashMap::from([("deprecated", "D")])); + + for directive in directives.iter() { + if let Some(minified_name) = directives_to_minify.get(directive.name.as_str()) { + // Since we're only handling @deprecated right now we can just add the reason and minify it. + // We should handle this more generically in the future. + if !directive.arguments.is_empty() + && let Some(reason) = directive + .arguments + .iter() + .find(|a| a.name == "reason") + .and_then(|a| a.value.as_str()) + { + result.push_str(&format!( + "@{}(\"{}\")", + minified_name, + normalize_description(reason) + )); + } else { + result.push_str(&format!("@{}", minified_name)); + } + } + } + result +} + fn minify_fields( fields: &apollo_compiler::collections::IndexMap< apollo_compiler::Name, @@ -99,6 +131,8 @@ fn minify_fields( // Add field type result.push(':'); result.push_str(&type_name(&field.ty)); + result.push_str(&minify_directives(&field.directives)); + result.push(','); } @@ -128,6 +162,7 @@ fn minify_input_fields( result.push_str(field_name.as_str()); result.push(':'); result.push_str(&type_name(&field.ty)); + result.push_str(&minify_directives(&field.directives)); result.push(','); } @@ -147,13 +182,19 @@ fn minify_arguments( .map(|arg| { if let Some(desc) = arg.description.as_ref() { format!( - "\"{}\"{}:{}", + "\"{}\"{}:{}{}", normalize_description(desc), arg.name.as_str(), - type_name(&arg.ty) + type_name(&arg.ty), + minify_directives(&arg.directives) ) } else { - format!("{}:{}", arg.name.as_str(), type_name(&arg.ty)) + format!( + "{}:{}{}", + arg.name.as_str(), + type_name(&arg.ty), + minify_directives(&arg.directives) + ) } }) .collect::>() @@ -211,3 +252,27 @@ fn normalize_description(desc: &str) -> String { let re = WHITESPACE_PATTERN.get_or_init(|| Regex::new(r"\s+").expect("regex pattern compiles")); re.replace_all(desc, "").to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SCHEMA: &str = include_str!("tools/testdata/schema.graphql"); + + #[test] + fn test_minify_schema() { + let schema = apollo_compiler::schema::Schema::parse(TEST_SCHEMA, "schema.graphql") + .expect("Failed to parse schema") + .validate() + .expect("Failed to validate schema"); + + let minified = schema + .types + .iter() + .map(|(_, type_)| format!("{}: {}", type_.name().as_str(), type_.minify())) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(minified); + } +} diff --git a/crates/apollo-mcp-server/src/introspection/snapshots/apollo_mcp_server__introspection__minify__tests__minify_schema.snap b/crates/apollo-mcp-server/src/introspection/snapshots/apollo_mcp_server__introspection__minify__tests__minify_schema.snap new file mode 100644 index 00000000..439853a6 --- /dev/null +++ b/crates/apollo-mcp-server/src/introspection/snapshots/apollo_mcp_server__introspection__minify__tests__minify_schema.snap @@ -0,0 +1,64 @@ +--- +source: crates/apollo-mcp-server/src/introspection/minify.rs +expression: minified +--- +__Schema: T:"AGraphQLSchemadefinesthecapabilitiesofaGraphQLserver.Itexposesallavailabletypesanddirectivesontheserver,aswellastheentrypointsforquery,mutation,andsubscriptionoperations."__Schema:description:s,"Alistofalltypessupportedbythisserver."types:[__Type],"Thetypethatqueryoperationswillberootedat."queryType:__Type!,"Ifthisserversupportsmutation,thetypethatmutationoperationswillberootedat."mutationType:__Type,"Ifthisserversupportsubscription,thetypethatsubscriptionoperationswillberootedat."subscriptionType:__Type,"Alistofalldirectivessupportedbythisserver."directives:[__Directive] +__Type: T:"ThefundamentalunitofanyGraphQLSchemaisthetype.TherearemanykindsoftypesinGraphQLasrepresentedbythe`__TypeKind`enum.Dependingonthekindofatype,certainfieldsdescribeinformationaboutthattype.Scalartypesprovidenoinformationbeyondaname,descriptionandoptional`specifiedByURL`,whileEnumtypesprovidetheirvalues.ObjectandInterfacetypesprovidethefieldstheydescribe.Abstracttypes,UnionandInterface,providetheObjecttypespossibleatruntime.ListandNonNulltypescomposeothertypes."__Type:kind:__TypeKind!,name:s,description:s,fields(includeDeprecated:b):[__Field],interfaces:[__Type],possibleTypes:[__Type],enumValues(includeDeprecated:b):[__EnumValue],inputFields(includeDeprecated:b):[__InputValue],ofType:__Type,specifiedByURL:s +__TypeKind: E:"Anenumdescribingwhatkindoftypeagiven`__Type`is."__TypeKind:SCALAR,OBJECT,INTERFACE,UNION,ENUM,INPUT_OBJECT,LIST,NON_NULL +__Field: T:"ObjectandInterfacetypesaredescribedbyalistofFields,eachofwhichhasaname,potentiallyalistofarguments,andareturntype."__Field:name:s!,description:s,args(includeDeprecated:b):[__InputValue],type:__Type!,isDeprecated:b!,deprecationReason:s +__InputValue: T:"ArgumentsprovidedtoFieldsorDirectivesandtheinputfieldsofanInputObjectarerepresentedasInputValueswhichdescribetheirtypeandoptionallyadefaultvalue."__InputValue:name:s!,description:s,type:__Type!,"AGraphQL-formattedstringrepresentingthedefaultvalueforthisinputvalue."defaultValue:s,isDeprecated:b!,deprecationReason:s +__EnumValue: T:"OnepossiblevalueforagivenEnum.Enumvaluesareuniquevalues,notaplaceholderforastringornumericvalue.HoweveranEnumvalueisreturnedinaJSONresponseasastring."__EnumValue:name:s!,description:s,isDeprecated:b!,deprecationReason:s +__Directive: T:"ADirectiveprovidesawaytodescribealternateruntimeexecutionandtypevalidationbehaviorinaGraphQLdocument.Insomecases,youneedtoprovideoptionstoalterGraphQL'sexecutionbehaviorinwaysfieldargumentswillnotsuffice,suchasconditionallyincludingorskippingafield.Directivesprovidethisbydescribingadditionalinformationtotheexecutor."__Directive:name:s!,description:s,locations:[__DirectiveLocation],args(includeDeprecated:b):[__InputValue],isRepeatable:b! +__DirectiveLocation: E:"ADirectivecanbeadjacenttomanypartsoftheGraphQLlanguage,a__DirectiveLocationdescribesonesuchpossibleadjacencies."__DirectiveLocation:QUERY,MUTATION,SUBSCRIPTION,FIELD,FRAGMENT_DEFINITION,FRAGMENT_SPREAD,INLINE_FRAGMENT,VARIABLE_DEFINITION,SCHEMA,SCALAR,OBJECT,FIELD_DEFINITION,ARGUMENT_DEFINITION,INTERFACE,UNION,ENUM,ENUM_VALUE,INPUT_OBJECT,INPUT_FIELD_DEFINITION +Int: i +Float: f +String: s +Boolean: b +ID: d +DateTime: DateTime +JSON: JSON +Upload: Upload +UserRole: E:UserRole:ADMIN,MODERATOR,USER,GUEST +ContentStatus: E:ContentStatus:DRAFT,PUBLISHED,ARCHIVED,DELETED +NotificationPriority: E:NotificationPriority:LOW,MEDIUM,HIGH,URGENT +MediaType: E:MediaType:IMAGE,VIDEO,AUDIO,DOCUMENT +Node: F:Node:id:d!,createdAt:DateTime!,updatedAt:DateTime! +Content: F:Content:id:d!,title:s!,status:ContentStatus!,author:User!,metadata:JSON +User: T:User:id:d!,createdAt:DateTime!,updatedAt:DateTime!,username:s!,email:s!,role:UserRole!,profile:UserProfile,posts:[Post],comments:[Comment],notifications:[Notification],preferences:UserPreferences! +UserProfile: T:UserProfile:firstName:s,lastName:s,bio:s,avatar:Media,socialLinks:[SocialLink],location:Location +Location: T:Location:country:s!,city:s,coordinates:Coordinates +Coordinates: T:Coordinates:latitude:f!,longitude:f! +SocialLink: T:SocialLink:platform:s!,url:s!,verified:b! +Post: T:Post:id:d!,createdAt:DateTime!,updatedAt:DateTime!,title:s!,content:s!,status:ContentStatus!,author:User!,metadata:JSON,comments:[Comment],media:[Media],tags:[Tag],analytics:PostAnalytics! +Comment: T:Comment:id:d!,createdAt:DateTime!,updatedAt:DateTime!,content:s!,author:User!,post:Post!,parentComment:Comment,replies:[Comment],reactions:[Reaction] +Media: T:Media:id:d!,type:MediaType!,url:s!,thumbnail:s,metadata:MediaMetadata!,uploader:User! +MediaMetadata: T:MediaMetadata:size:i!,format:s!,dimensions:Dimensions,duration:i +Dimensions: T:Dimensions:width:i!,height:i! +Tag: T:Tag:id:d!,name:s!,slug:s!,description:s,posts:[Post] +Reaction: T:Reaction:id:d!,type:s!,user:User!,comment:Comment!,createdAt:DateTime! +Notification: T:Notification:id:d!,type:s!,priority:NotificationPriority!,message:s!,recipient:User!,read:b!,createdAt:DateTime!,metadata:JSON +PostAnalytics: T:PostAnalytics:views:i!,likes:i!,shares:i!,comments:i!,engagement:f!,demographics:Demographics! +Demographics: T:Demographics:ageGroups:[AgeGroup],locations:[LocationStats],devices:[DeviceStats] +AgeGroup: T:AgeGroup:range:s!,percentage:f! +LocationStats: T:LocationStats:country:s!,count:i! +DeviceStats: T:DeviceStats:type:s!,count:i! +UserPreferences: T:UserPreferences:theme:s!,oldTheme:s@D,language:s!,notifications:NotificationPreferences!,privacy:PrivacySettings! +NotificationPreferences: T:NotificationPreferences:email:b!,push:b!,sms:b!,frequency:s! +PrivacySettings: T:PrivacySettings:profileVisibility:s!,showEmail:b!,showLocation:b! +CreateUserInput: I:CreateUserInput:username:s!,email:s!,password:s!,role:UserRole,profile:CreateUserProfileInput +CreateUserProfileInput: I:CreateUserProfileInput:firstName:s,lastName:s,bio:s,location:CreateLocationInput +CreateLocationInput: I:CreateLocationInput:country:s!,city:s,coordinates:CreateCoordinatesInput +CreateCoordinatesInput: I:CreateCoordinatesInput:latitude:f!,longitude:f! +CreatePostInput: I:CreatePostInput:title:s!,content:s!,status:ContentStatus,tags:[s],media:[Upload] +UpdatePostInput: I:UpdatePostInput:title:s,content:s,status:ContentStatus,tags:[s] +CreateCommentInput: I:CreateCommentInput:content:s!,postId:d!,parentCommentId:d +NotificationFilter: I:NotificationFilter:priority:NotificationPriority,read:b,type:s,startDate:DateTime,endDate:DateTime +Query: T:Query:node(id:d!):Node,user(id:d!):User,post(id:d!):Post,postsOld(filter:[d]):[Post]@D("Usepostsinstead"),posts(filter:PostFilter):[Post],comments(postId:d!):[Comment],notifications(filter:NotificationFilter):[Notification],search(query:s!):SearchResult! +Mutation: T:Mutation:createUser(input:CreateUserInput!):User!,createPost(input:CreatePostInput!):Post!,updatePost(id:d!,input:UpdatePostInput!):Post!,createComment(input:CreateCommentInput!):Comment!,deletePost(id:d!):b!,uploadMedia(file:Upload!):Media!,updateUserPreferences(id:d!,preferences:UserPreferencesInput!):UserPreferences! +Subscription: T:Subscription:postUpdated(id:d!):Post!,newComment(postId:d!):Comment!,notificationReceived(userId:d!):Notification! +SearchResult: U:SearchResult:User,Post,Comment,Tag +PostFilter: I:PostFilter:status:ContentStatus,authorId:d,tags:[s],dateRange:DateRangeInput +DateRangeInput: I:DateRangeInput:start:DateTime!,end:DateTime! +UserPreferencesInput: I:UserPreferencesInput:theme:s,language:s,notifications:NotificationPreferencesInput,privacy:PrivacySettingsInput +NotificationPreferencesInput: I:NotificationPreferencesInput:email:b,push:b,sms:b,frequency:s +PrivacySettingsInput: I:PrivacySettingsInput:profileVisibility:s,showEmail:b,showLocation:b diff --git a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs index 436c89e3..0bdcc2b3 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs @@ -27,7 +27,7 @@ pub struct Introspect { } /// Input for the introspect tool. -#[derive(JsonSchema, Deserialize)] +#[derive(JsonSchema, Deserialize, Debug)] pub struct Input { /// The name of the type to get information about. type_name: String, @@ -55,6 +55,7 @@ impl Introspect { } } + #[tracing::instrument(skip(self))] pub async fn execute(&self, input: Input) -> Result { let schema = self.schema.lock().await; let type_name = input.type_name.as_str(); @@ -122,16 +123,12 @@ fn tool_description( minify: bool, ) -> String { if minify { - "Get GraphQL type information - T=type,I=input,E=enum,U=union,F=interface;s=String,i=Int,f=Float,b=Boolean,d=ID;!=required,[]=list,<>=implements;".to_string() + "Get GraphQL type information - T=type,I=input,E=enum,U=union,F=interface;s=String,i=Int,f=Float,b=Boolean,d=ID;@D=deprecated;!=required,[]=list,<>=implements;".to_string() } else { format!( - "Get detailed information about types from the GraphQL schema.{}{}", - root_query_type - .map(|t| format!(" Use the type name `{t}` to get root query fields.")) - .unwrap_or_default(), - root_mutation_type - .map(|t| format!(" Use the type name `{t}` to get root mutation fields.")) - .unwrap_or_default() + "Get information about a given GraphQL type defined in the schema. Instructions: Use this tool to explore the schema by providing specific type names. Start with the root query ({}) or mutation ({}) types to discover available fields. If the search tool is also available, use this tool first to get the fields, then use the search tool with relevant field return types and argument input types (ignore default GraphQL scalars) as search terms.", + root_query_type.as_deref().unwrap_or("Query"), + root_mutation_type.as_deref().unwrap_or("Mutation") ) } } @@ -140,3 +137,55 @@ fn tool_description( fn default_depth() -> usize { 1 } + +#[cfg(test)] +mod tests { + use super::*; + use apollo_compiler::Schema; + use apollo_compiler::validation::Valid; + use rstest::{fixture, rstest}; + use std::sync::Arc; + use tokio::sync::Mutex; + + const TEST_SCHEMA: &str = include_str!("testdata/schema.graphql"); + + #[fixture] + fn schema() -> Valid { + Schema::parse(TEST_SCHEMA, "schema.graphql") + .expect("Failed to parse test schema") + .validate() + .expect("Failed to validate test schema") + } + + #[rstest] + #[tokio::test] + async fn test_introspect_tool_description_is_not_minified(schema: Valid) { + let introspect = Introspect::new(Arc::new(Mutex::new(schema)), None, None, false); + + let description = introspect.tool.description.unwrap(); + + assert!( + description + .contains("Get information about a given GraphQL type defined in the schema") + ); + assert!(description.contains("Instructions: Use this tool to explore the schema")); + // Should not contain minification legend + assert!(!description.contains("T=type,I=input")); + // Should mention conditional search tool usage + assert!(description.contains("If the search tool is also available")); + } + + #[rstest] + #[tokio::test] + async fn test_introspect_tool_description_is_minified_with_an_appropriate_legend( + schema: Valid, + ) { + let introspect = Introspect::new(Arc::new(Mutex::new(schema)), None, None, true); + + let description = introspect.tool.description.unwrap(); + + // Should contain minification legend + assert!(description.contains("T=type,I=input,E=enum,U=union,F=interface")); + assert!(description.contains("s=String,i=Int,f=Float,b=Boolean,d=ID")); + } +} diff --git a/crates/apollo-mcp-server/src/introspection/tools/search.rs b/crates/apollo-mcp-server/src/introspection/tools/search.rs index 3e146b63..595f11da 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/search.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/search.rs @@ -36,7 +36,7 @@ pub struct Search { } /// Input for the search tool. -#[derive(JsonSchema, Deserialize)] +#[derive(JsonSchema, Deserialize, Debug)] pub struct Input { /// The search terms terms: Vec, @@ -75,9 +75,9 @@ impl Search { tool: Tool::new( SEARCH_TOOL_NAME, format!( - "Search a GraphQL schema{}", + "Search a GraphQL schema for types matching the provided search terms. Returns complete type definitions including all related types needed to construct GraphQL operations. Instructions: If the introspect tool is also available, you can discover type names by using the introspect tool starting from the root Query or Mutation types. Avoid reusing previously searched terms for more efficient exploration.{}", if minify { - " - T=type,I=input,E=enum,U=union,F=interface;s=String,i=Int,f=Float,b=Boolean,d=ID;!=required,[]=list,<>=implements" + " - T=type,I=input,E=enum,U=union,F=interface;s=String,i=Int,f=Float,b=Boolean,d=ID;@D=deprecated;!=required,[]=list,<>=implements" } else { "" } @@ -87,6 +87,7 @@ impl Search { }) } + #[tracing::instrument(skip(self))] pub async fn execute(&self, input: Input) -> Result { let mut root_paths = self .index @@ -246,4 +247,37 @@ mod tests { "Expected to find the createUser mutation in search results" ); } + + #[rstest] + #[tokio::test] + async fn test_search_tool_description_is_not_minified(schema: Valid) { + let schema = Arc::new(Mutex::new(schema)); + let search = Search::new(schema.clone(), false, 1, 15_000_000, false) + .expect("Failed to create search tool"); + + let description = search.tool.description.unwrap(); + + assert!( + description + .contains("Search a GraphQL schema for types matching the provided search terms") + ); + assert!(description.contains("Instructions: If the introspect tool is also available")); + assert!(description.contains("Avoid reusing previously searched terms")); + // Should not contain minification legend + assert!(!description.contains("T=type,I=input")); + } + + #[rstest] + #[tokio::test] + async fn test_tool_description_minified(schema: Valid) { + let schema = Arc::new(Mutex::new(schema)); + let search = Search::new(schema.clone(), false, 1, 15_000_000, true) + .expect("Failed to create search tool"); + + let description = search.tool.description.unwrap(); + + // Should contain minification legend + assert!(description.contains("T=type,I=input,E=enum,U=union,F=interface")); + assert!(description.contains("s=String,i=Int,f=Float,b=Boolean,d=ID")); + } } diff --git a/crates/apollo-mcp-server/src/introspection/tools/snapshots/apollo_mcp_server__introspection__tools__search__tests__search_tool.snap b/crates/apollo-mcp-server/src/introspection/tools/snapshots/apollo_mcp_server__introspection__tools__search__tests__search_tool.snap index e62de8e1..997c8996 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/snapshots/apollo_mcp_server__introspection__tools__search__tests__search_tool.snap +++ b/crates/apollo-mcp-server/src/introspection/tools/snapshots/apollo_mcp_server__introspection__tools__search__tests__search_tool.snap @@ -28,7 +28,7 @@ type Post implements Node & Content { type Query { user(id: ID!): User - post(id: ID!): Post + postsOld(filter: [ID!]): [Post!]! @deprecated(reason: "Use posts instead") posts(filter: PostFilter): [Post!]! } diff --git a/crates/apollo-mcp-server/src/introspection/tools/testdata/schema.graphql b/crates/apollo-mcp-server/src/introspection/tools/testdata/schema.graphql index 5407ba1b..5c4bbc0d 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/testdata/schema.graphql +++ b/crates/apollo-mcp-server/src/introspection/tools/testdata/schema.graphql @@ -191,6 +191,7 @@ type DeviceStats { type UserPreferences { theme: String! + oldTheme: String @deprecated language: String! notifications: NotificationPreferences! privacy: PrivacySettings! @@ -268,6 +269,7 @@ type Query { node(id: ID!): Node user(id: ID!): User post(id: ID!): Post + postsOld(filter: [ID!]) : [Post!]! @deprecated(reason: "Use posts instead") posts(filter: PostFilter): [Post!]! comments(postId: ID!): [Comment!]! notifications(filter: NotificationFilter): [Notification!]! diff --git a/crates/apollo-mcp-server/src/introspection/tools/validate.rs b/crates/apollo-mcp-server/src/introspection/tools/validate.rs index e104cc92..7d403f12 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/validate.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/validate.rs @@ -25,7 +25,7 @@ pub struct Validate { } /// Input for the validate tool -#[derive(JsonSchema, Deserialize)] +#[derive(JsonSchema, Deserialize, Debug)] pub struct Input { /// The GraphQL operation operation: String, @@ -46,6 +46,7 @@ impl Validate { } /// Validates the provided GraphQL query + #[tracing::instrument(skip(self))] pub async fn execute(&self, input: Value) -> Result { let input = serde_json::from_value::(input).map_err(|_| { McpError::new(ErrorCode::INVALID_PARAMS, "Invalid input".to_string(), None) diff --git a/crates/apollo-mcp-server/src/lib.rs b/crates/apollo-mcp-server/src/lib.rs index 21b89ce8..1737b4e1 100644 --- a/crates/apollo-mcp-server/src/lib.rs +++ b/crates/apollo-mcp-server/src/lib.rs @@ -1,4 +1,7 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + pub mod auth; +pub mod cors; pub mod custom_scalar_map; pub mod errors; pub mod event; @@ -7,7 +10,16 @@ mod graphql; pub mod health; mod introspection; pub mod json_schema; +pub(crate) mod meter; pub mod operations; pub mod sanitize; pub(crate) mod schema_tree_shake; pub mod server; +pub mod telemetry_attributes; + +/// These values are generated at build time by build.rs using telemetry.toml as input. +pub mod generated { + pub mod telemetry { + include!(concat!(env!("OUT_DIR"), "/telemetry_attributes.rs")); + } +} diff --git a/crates/apollo-mcp-server/src/main.rs b/crates/apollo-mcp-server/src/main.rs index 3f3d8738..0d80e937 100644 --- a/crates/apollo-mcp-server/src/main.rs +++ b/crates/apollo-mcp-server/src/main.rs @@ -11,7 +11,6 @@ use clap::Parser; use clap::builder::Styles; use clap::builder::styling::{AnsiColor, Effects}; use runtime::IdOrDefault; -use runtime::logging::Logging; use tracing::{info, warn}; mod runtime; @@ -42,9 +41,7 @@ async fn main() -> anyhow::Result<()> { None => runtime::read_config_from_env().unwrap_or_default(), }; - // WorkerGuard is not used but needed to be at least defined or else the guard - // is cleaned up too early and file appender logging does not work - let _guard = Logging::setup(&config)?; + let _guard = runtime::telemetry::init_tracing_subscriber(&config)?; info!( "Apollo MCP Server v{} // (c) Apollo Graph, Inc. // Licensed under MIT", @@ -145,6 +142,7 @@ async fn main() -> anyhow::Result<()> { .search_leaf_depth(config.introspection.search.leaf_depth) .index_memory_bytes(config.introspection.search.index_memory_bytes) .health_check(config.health_check) + .cors(config.cors) .build() .start() .await?) diff --git a/crates/apollo-mcp-server/src/meter.rs b/crates/apollo-mcp-server/src/meter.rs new file mode 100644 index 00000000..a52f7447 --- /dev/null +++ b/crates/apollo-mcp-server/src/meter.rs @@ -0,0 +1,4 @@ +use opentelemetry::{global, metrics::Meter}; +use std::sync::LazyLock; + +pub static METER: LazyLock = LazyLock::new(|| global::meter(env!("CARGO_PKG_NAME"))); diff --git a/crates/apollo-mcp-server/src/operations/operation.rs b/crates/apollo-mcp-server/src/operations/operation.rs index 6a0ac72e..79ac5aa0 100644 --- a/crates/apollo-mcp-server/src/operations/operation.rs +++ b/crates/apollo-mcp-server/src/operations/operation.rs @@ -48,6 +48,7 @@ impl Operation { self.inner } + #[tracing::instrument(skip(graphql_schema, custom_scalar_map))] pub fn from_document( raw_operation: RawOperation, graphql_schema: &GraphqlSchema, @@ -138,6 +139,7 @@ impl Operation { } /// Generate a description for an operation based on documentation in the schema + #[tracing::instrument(skip(comments, tree_shaker, graphql_schema, operation_def), fields(operation_type = ?operation_def.operation_type, operation_id = ?operation_def.name))] fn tool_description( comments: Option, tree_shaker: &mut SchemaTreeShaker, @@ -335,6 +337,7 @@ impl graphql::Executable for Operation { } #[allow(clippy::type_complexity)] +#[tracing::instrument(skip_all)] pub fn operation_defs( source_text: &str, allow_mutations: bool, @@ -424,6 +427,7 @@ pub fn operation_name( .to_string()) } +#[tracing::instrument(skip_all, fields(operation_type = ?operation_definition.operation_type, operation_id = ?operation_definition.name))] pub fn variable_description_overrides( source_text: &str, operation_definition: &Node, @@ -455,6 +459,7 @@ pub fn variable_description_overrides( argument_overrides_map } +#[tracing::instrument(skip_all, fields(operation_type = ?operation_definition.operation_type, operation_id = ?operation_definition.name))] pub fn find_opening_parens_offset( source_text: &str, operation_definition: &Node, @@ -512,6 +517,7 @@ fn tool_character_length(tool: &Tool) -> Result { + tool_schema_string.len()) } +#[tracing::instrument(skip_all)] fn get_json_schema( operation: &Node, schema_argument_descriptions: &HashMap>, diff --git a/crates/apollo-mcp-server/src/operations/operation_source.rs b/crates/apollo-mcp-server/src/operations/operation_source.rs index d34b2a9b..65f49890 100644 --- a/crates/apollo-mcp-server/src/operations/operation_source.rs +++ b/crates/apollo-mcp-server/src/operations/operation_source.rs @@ -22,7 +22,7 @@ use super::RawOperation; const OPERATION_DOCUMENT_EXTENSION: &str = "graphql"; /// The source of the operations exposed as MCP tools -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum OperationSource { /// GraphQL document files Files(Vec), @@ -38,6 +38,7 @@ pub enum OperationSource { } impl OperationSource { + #[tracing::instrument(skip_all, fields(operation_source = ?self))] pub async fn into_stream(self) -> impl Stream { match self { OperationSource::Files(paths) => Self::stream_file_changes(paths).boxed(), @@ -73,6 +74,7 @@ impl OperationSource { } } + #[tracing::instrument] fn stream_file_changes(paths: Vec) -> impl Stream { let path_count = paths.len(); let state = Arc::new(Mutex::new(HashMap::>::new())); diff --git a/crates/apollo-mcp-server/src/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index bc63f7af..71a39684 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -5,6 +5,7 @@ mod config; mod endpoint; +mod filtering_exporter; mod graphos; mod introspection; pub mod logging; @@ -12,6 +13,7 @@ mod operation_source; mod overrides; mod schema_source; mod schemas; +pub mod telemetry; use std::path::Path; @@ -130,8 +132,13 @@ mod test { let config = " endpoint: http://from_file:4000/ "; + let saved_path = std::env::var("PATH").unwrap_or_default(); + let workspace = env!("CARGO_MANIFEST_DIR"); figment::Jail::expect_with(move |jail| { + jail.clear_env(); + jail.set_env("PATH", &saved_path); + jail.set_env("INSTA_WORKSPACE_ROOT", workspace); let path = "config.yaml"; jail.create_file(path, config)?; @@ -144,6 +151,33 @@ mod test { insta::assert_debug_snapshot!(config, @r#" Config { + cors: CorsConfig { + enabled: false, + origins: [], + match_origins: [], + allow_any_origin: false, + allow_credentials: false, + allow_methods: [ + "GET", + "POST", + "DELETE", + ], + allow_headers: [ + "content-type", + "mcp-protocol-version", + "mcp-session-id", + "traceparent", + "tracestate", + ], + expose_headers: [ + "mcp-session-id", + "traceparent", + "tracestate", + ], + max_age: Some( + 7200, + ), + }, custom_scalars: None, endpoint: Endpoint( Url { @@ -242,6 +276,11 @@ mod test { path: None, rotation: Hourly, }, + telemetry: Telemetry { + exporters: None, + service_name: None, + version: None, + }, operations: Infer, overrides: Overrides { disable_type_description: false, diff --git a/crates/apollo-mcp-server/src/runtime/config.rs b/crates/apollo-mcp-server/src/runtime/config.rs index 8f64f518..598462bd 100644 --- a/crates/apollo-mcp-server/src/runtime/config.rs +++ b/crates/apollo-mcp-server/src/runtime/config.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use apollo_mcp_server::{health::HealthCheckConfig, server::Transport}; +use apollo_mcp_server::{cors::CorsConfig, health::HealthCheckConfig, server::Transport}; use reqwest::header::HeaderMap; use schemars::JsonSchema; use serde::Deserialize; @@ -8,13 +8,16 @@ use url::Url; use super::{ OperationSource, SchemaSource, endpoint::Endpoint, graphos::GraphOSConfig, - introspection::Introspection, logging::Logging, overrides::Overrides, + introspection::Introspection, logging::Logging, overrides::Overrides, telemetry::Telemetry, }; /// Configuration for the MCP server #[derive(Debug, Default, Deserialize, JsonSchema)] #[serde(default)] pub struct Config { + /// CORS configuration + pub cors: CorsConfig, + /// Path to a custom scalar map pub custom_scalars: Option, @@ -40,6 +43,9 @@ pub struct Config { /// Logging configuration pub logging: Logging, + /// Telemetry configuration + pub telemetry: Telemetry, + /// Operations pub operations: OperationSource, diff --git a/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs b/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs new file mode 100644 index 00000000..e56fc3eb --- /dev/null +++ b/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs @@ -0,0 +1,232 @@ +use opentelemetry::{Key, KeyValue}; +use opentelemetry_sdk::Resource; +use opentelemetry_sdk::error::OTelSdkResult; +use opentelemetry_sdk::trace::{SpanData, SpanExporter}; +use std::collections::HashSet; +use std::fmt::Debug; + +#[derive(Debug)] +pub struct FilteringExporter { + inner: E, + omitted: HashSet, +} + +impl FilteringExporter { + pub fn new(inner: E, omitted: impl IntoIterator) -> Self { + Self { + inner, + omitted: omitted.into_iter().collect(), + } + } +} + +impl SpanExporter for FilteringExporter +where + E: SpanExporter + Send + Sync, +{ + fn export(&self, mut batch: Vec) -> impl Future + Send { + for span in &mut batch { + span.attributes + .retain(|kv| filter_omitted_apollo_attributes(kv, &self.omitted)); + } + + self.inner.export(batch) + } + + fn shutdown(&mut self) -> OTelSdkResult { + self.inner.shutdown() + } + fn force_flush(&mut self) -> OTelSdkResult { + self.inner.force_flush() + } + fn set_resource(&mut self, r: &Resource) { + self.inner.set_resource(r) + } +} + +fn filter_omitted_apollo_attributes(kv: &KeyValue, omitted_attributes: &HashSet) -> bool { + !kv.key.as_str().starts_with("apollo.") || !omitted_attributes.contains(&kv.key) +} + +#[cfg(test)] +mod tests { + use crate::runtime::filtering_exporter::FilteringExporter; + use opentelemetry::trace::{SpanContext, SpanKind, Status, TraceState}; + use opentelemetry::{InstrumentationScope, Key, KeyValue, SpanId, TraceFlags, TraceId}; + use opentelemetry_sdk::Resource; + use opentelemetry_sdk::error::{OTelSdkError, OTelSdkResult}; + use opentelemetry_sdk::trace::{SpanData, SpanEvents, SpanExporter, SpanLinks}; + use std::collections::HashSet; + use std::fmt::Debug; + use std::future::ready; + use std::time::SystemTime; + + #[cfg_attr(coverage_nightly, coverage(off))] + fn create_mock_span_data() -> SpanData { + let span_context: SpanContext = SpanContext::new( + TraceId::from_u128(1), + SpanId::from_u64(12345), + TraceFlags::default(), + true, // is_remote + TraceState::default(), + ); + + SpanData { + span_context, + parent_span_id: SpanId::from_u64(54321), + span_kind: SpanKind::Internal, + name: "test-span".into(), + start_time: SystemTime::UNIX_EPOCH, + end_time: SystemTime::UNIX_EPOCH, + attributes: vec![ + KeyValue::new("http.method", "GET"), + KeyValue::new("apollo.mock", "mock"), + ], + dropped_attributes_count: 0, + events: SpanEvents::default(), + links: SpanLinks::default(), + status: Status::Ok, + instrumentation_scope: InstrumentationScope::builder("test-service") + .with_version("1.0.0") + .build(), + } + } + + #[tokio::test] + async fn filtering_exporter_filters_omitted_apollo_attributes() { + #[derive(Debug)] + struct TestExporter {} + + #[cfg_attr(coverage_nightly, coverage(off))] + impl SpanExporter for TestExporter { + fn export(&self, batch: Vec) -> impl Future + Send { + batch.into_iter().for_each(|span| { + if span + .attributes + .iter() + .any(|kv| kv.key.as_str().starts_with("apollo.")) + { + panic!("Omitted attributes were not filtered"); + } + }); + + ready(Ok(())) + } + + fn shutdown(&mut self) -> OTelSdkResult { + Ok(()) + } + + fn force_flush(&mut self) -> OTelSdkResult { + Ok(()) + } + + fn set_resource(&mut self, _resource: &Resource) {} + } + + let mut omitted = HashSet::new(); + omitted.insert(Key::from_static_str("apollo.mock")); + let mock_exporter = TestExporter {}; + let mock_span_data = create_mock_span_data(); + + let filtering_exporter = FilteringExporter::new(mock_exporter, omitted); + filtering_exporter + .export(vec![mock_span_data]) + .await + .expect("Export error"); + } + + #[tokio::test] + async fn filtering_exporter_calls_inner_exporter_on_shutdown() { + #[derive(Debug)] + struct TestExporter {} + + #[cfg_attr(coverage_nightly, coverage(off))] + impl SpanExporter for TestExporter { + fn export(&self, _batch: Vec) -> impl Future + Send { + ready(Err(OTelSdkError::InternalFailure( + "unexpected call".to_string(), + ))) + } + + fn shutdown(&mut self) -> OTelSdkResult { + Ok(()) + } + + fn force_flush(&mut self) -> OTelSdkResult { + Err(OTelSdkError::InternalFailure("unexpected call".to_string())) + } + + fn set_resource(&mut self, _resource: &Resource) { + unreachable!("should not be called"); + } + } + + let mock_exporter = TestExporter {}; + + let mut filtering_exporter = FilteringExporter::new(mock_exporter, HashSet::new()); + assert!(filtering_exporter.shutdown().is_ok()); + } + + #[tokio::test] + async fn filtering_exporter_calls_inner_exporter_on_force_flush() { + #[derive(Debug)] + struct TestExporter {} + + #[cfg_attr(coverage_nightly, coverage(off))] + impl SpanExporter for TestExporter { + fn export(&self, _batch: Vec) -> impl Future + Send { + ready(Err(OTelSdkError::InternalFailure( + "unexpected call".to_string(), + ))) + } + + fn shutdown(&mut self) -> OTelSdkResult { + Err(OTelSdkError::InternalFailure("unexpected call".to_string())) + } + + fn force_flush(&mut self) -> OTelSdkResult { + Ok(()) + } + + fn set_resource(&mut self, _resource: &Resource) { + unreachable!("should not be called"); + } + } + + let mock_exporter = TestExporter {}; + + let mut filtering_exporter = FilteringExporter::new(mock_exporter, HashSet::new()); + assert!(filtering_exporter.force_flush().is_ok()); + } + + #[tokio::test] + async fn filtering_exporter_calls_inner_exporter_on_set_resource() { + #[derive(Debug)] + struct TestExporter {} + + #[cfg_attr(coverage_nightly, coverage(off))] + impl SpanExporter for TestExporter { + fn export(&self, _batch: Vec) -> impl Future + Send { + ready(Err(OTelSdkError::InternalFailure( + "unexpected call".to_string(), + ))) + } + + fn shutdown(&mut self) -> OTelSdkResult { + Err(OTelSdkError::InternalFailure("unexpected call".to_string())) + } + + fn force_flush(&mut self) -> OTelSdkResult { + Err(OTelSdkError::InternalFailure("unexpected call".to_string())) + } + + fn set_resource(&mut self, _resource: &Resource) {} + } + + let mock_exporter = TestExporter {}; + + let mut filtering_exporter = FilteringExporter::new(mock_exporter, HashSet::new()); + filtering_exporter.set_resource(&Resource::builder_empty().build()); + } +} diff --git a/crates/apollo-mcp-server/src/runtime/logging.rs b/crates/apollo-mcp-server/src/runtime/logging.rs index 70a47e30..45c9d256 100644 --- a/crates/apollo-mcp-server/src/runtime/logging.rs +++ b/crates/apollo-mcp-server/src/runtime/logging.rs @@ -12,14 +12,10 @@ use schemars::JsonSchema; use serde::Deserialize; use std::path::PathBuf; use tracing::Level; -use tracing_appender::non_blocking::WorkerGuard; use tracing_appender::rolling::RollingFileAppender; use tracing_subscriber::EnvFilter; +use tracing_subscriber::fmt::Layer; use tracing_subscriber::fmt::writer::BoxMakeWriter; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; - -use super::Config; /// Logging related options #[derive(Debug, Deserialize, JsonSchema)] @@ -52,31 +48,43 @@ impl Default for Logging { } } +type LoggingLayerResult = ( + Layer< + tracing_subscriber::Registry, + tracing_subscriber::fmt::format::DefaultFields, + tracing_subscriber::fmt::format::Format, + BoxMakeWriter, + >, + Option, +); + impl Logging { - pub fn setup(config: &Config) -> Result, anyhow::Error> { - let mut env_filter = - EnvFilter::from_default_env().add_directive(config.logging.level.into()); + pub fn env_filter(logging: &Logging) -> Result { + let mut env_filter = EnvFilter::from_default_env().add_directive(logging.level.into()); - if config.logging.level == Level::INFO { + if logging.level == Level::INFO { env_filter = env_filter .add_directive("rmcp=warn".parse()?) .add_directive("tantivy=warn".parse()?); } + Ok(env_filter) + } + pub fn logging_layer(logging: &Logging) -> Result { macro_rules! log_error { () => { |e| eprintln!("Failed to setup logging: {e:?}") }; } - let (writer, guard, with_ansi) = match config.logging.path.clone() { + let (writer, guard, with_ansi) = match logging.path.clone() { Some(path) => std::fs::create_dir_all(&path) .map(|_| path) .inspect_err(log_error!()) .ok() .and_then(|path| { RollingFileAppender::builder() - .rotation(config.logging.rotation.clone().into()) + .rotation(logging.rotation.clone().into()) .filename_prefix("apollo_mcp_server") .filename_suffix("log") .build(path) @@ -98,17 +106,13 @@ impl Logging { None => (BoxMakeWriter::new(std::io::stdout), None, true), }; - tracing_subscriber::registry() - .with(env_filter) - .with( - tracing_subscriber::fmt::layer() - .with_writer(writer) - .with_ansi(with_ansi) - .with_target(false), - ) - .init(); - - Ok(guard) + Ok(( + tracing_subscriber::fmt::layer() + .with_writer(writer) + .with_ansi(with_ansi) + .with_target(false), + guard, + )) } } diff --git a/crates/apollo-mcp-server/src/runtime/telemetry.rs b/crates/apollo-mcp-server/src/runtime/telemetry.rs new file mode 100644 index 00000000..d5d0688b --- /dev/null +++ b/crates/apollo-mcp-server/src/runtime/telemetry.rs @@ -0,0 +1,408 @@ +mod sampler; + +use crate::runtime::Config; +use crate::runtime::filtering_exporter::FilteringExporter; +use crate::runtime::logging::Logging; +use crate::runtime::telemetry::sampler::SamplerOption; +use apollo_mcp_server::generated::telemetry::TelemetryAttribute; +use opentelemetry::{Key, KeyValue, global, trace::TracerProvider as _}; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::metrics::{Instrument, Stream}; +use opentelemetry_sdk::{ + Resource, + metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider}, + propagation::TraceContextPropagator, + trace::{RandomIdGenerator, SdkTracerProvider}, +}; +use opentelemetry_semantic_conventions::{ + SCHEMA_URL, + attribute::{DEPLOYMENT_ENVIRONMENT_NAME, SERVICE_VERSION}, +}; +use schemars::JsonSchema; +use serde::Deserialize; +use std::collections::HashSet; +use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +/// Telemetry related options +#[derive(Debug, Deserialize, JsonSchema, Default)] +pub struct Telemetry { + exporters: Option, + service_name: Option, + version: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct Exporters { + metrics: Option, + tracing: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct MetricsExporters { + otlp: Option, + omitted_attributes: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OTLPMetricExporter { + endpoint: String, + protocol: String, +} + +impl Default for OTLPMetricExporter { + fn default() -> Self { + Self { + endpoint: "http://localhost:4317".into(), + protocol: "grpc".into(), + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TracingExporters { + otlp: Option, + sampler: Option, + omitted_attributes: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct OTLPTracingExporter { + endpoint: String, + protocol: String, +} + +impl Default for OTLPTracingExporter { + fn default() -> Self { + Self { + endpoint: "http://localhost:4317".into(), + protocol: "grpc".into(), + } + } +} + +fn resource(telemetry: &Telemetry) -> Resource { + let service_name = telemetry + .service_name + .clone() + .unwrap_or_else(|| env!("CARGO_PKG_NAME").to_string()); + + let service_version = telemetry + .version + .clone() + .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); + + let deployment_env = std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()); + + Resource::builder() + .with_service_name(service_name) + .with_schema_url( + [ + KeyValue::new(SERVICE_VERSION, service_version), + KeyValue::new(DEPLOYMENT_ENVIRONMENT_NAME, deployment_env), + ], + SCHEMA_URL, + ) + .build() +} + +fn init_meter_provider(telemetry: &Telemetry) -> Result { + let metrics_exporters = telemetry + .exporters + .as_ref() + .and_then(|exporters| exporters.metrics.as_ref()); + + let otlp = metrics_exporters + .and_then(|metrics_exporters| metrics_exporters.otlp.as_ref()) + .ok_or_else(|| { + anyhow::anyhow!("No metrics exporters configured, at least one is required") + })?; + + let exporter = match otlp.protocol.as_str() { + "grpc" => opentelemetry_otlp::MetricExporter::builder() + .with_tonic() + .with_endpoint(otlp.endpoint.clone()) + .build()?, + "http/protobuf" => opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_endpoint(otlp.endpoint.clone()) + .build()?, + other => { + return Err(anyhow::anyhow!( + "Unsupported OTLP protocol: {other}. Supported protocols are: grpc, http/protobuf" + )); + } + }; + + let omitted_attributes: HashSet = metrics_exporters + .and_then(|exporters| exporters.omitted_attributes.clone()) + .unwrap_or_default(); + let included_attributes: Vec = TelemetryAttribute::included_attributes(omitted_attributes) + .iter() + .map(|a| a.to_key()) + .collect(); + + let reader = PeriodicReader::builder(exporter) + .with_interval(std::time::Duration::from_secs(30)) + .build(); + + let filtered_view = move |i: &Instrument| { + if i.name().starts_with("apollo.") { + Stream::builder() + .with_allowed_attribute_keys(included_attributes.clone()) // if available in your version + .build() + .ok() + } else { + None + } + }; + + let meter_provider = MeterProviderBuilder::default() + .with_resource(resource(telemetry)) + .with_reader(reader) + .with_view(filtered_view) + .build(); + + Ok(meter_provider) +} + +fn init_tracer_provider(telemetry: &Telemetry) -> Result { + let tracer_exporters = telemetry + .exporters + .as_ref() + .and_then(|exporters| exporters.tracing.as_ref()); + + let otlp = tracer_exporters + .and_then(|tracing_exporters| tracing_exporters.otlp.as_ref()) + .ok_or_else(|| { + anyhow::anyhow!("No tracing exporters configured, at least one is required") + })?; + + let exporter = match otlp.protocol.as_str() { + "grpc" => opentelemetry_otlp::SpanExporter::builder() + .with_tonic() + .with_endpoint(otlp.endpoint.clone()) + .build()?, + "http/protobuf" => opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_endpoint(otlp.endpoint.clone()) + .build()?, + other => { + return Err(anyhow::anyhow!( + "Unsupported OTLP protocol: {other}. Supported protocols are: grpc, http/protobuf" + )); + } + }; + + let sampler: opentelemetry_sdk::trace::Sampler = tracer_exporters + .as_ref() + .and_then(|e| e.sampler.clone()) + .unwrap_or_default() + .into(); + + let omitted_attributes: HashSet = tracer_exporters + .and_then(|exporters| exporters.omitted_attributes.clone()) + .map(|set| set.iter().map(|a| a.to_key()).collect()) + .unwrap_or_default(); + + let filtering_exporter = FilteringExporter::new(exporter, omitted_attributes); + + let tracer_provider = SdkTracerProvider::builder() + .with_id_generator(RandomIdGenerator::default()) + .with_resource(resource(telemetry)) + .with_batch_exporter(filtering_exporter) + .with_sampler(sampler) + .build(); + + Ok(tracer_provider) +} + +/// Initialize tracing-subscriber and return TelemetryGuard for logging and opentelemetry-related termination processing +pub fn init_tracing_subscriber(config: &Config) -> Result { + let tracer_provider = if let Some(exporters) = &config.telemetry.exporters { + if let Some(_tracing_exporters) = &exporters.tracing { + init_tracer_provider(&config.telemetry)? + } else { + SdkTracerProvider::builder().build() + } + } else { + SdkTracerProvider::builder().build() + }; + let meter_provider = if let Some(exporters) = &config.telemetry.exporters { + if let Some(_metrics_exporters) = &exporters.metrics { + init_meter_provider(&config.telemetry)? + } else { + SdkMeterProvider::builder().build() + } + } else { + SdkMeterProvider::builder().build() + }; + let env_filter = Logging::env_filter(&config.logging)?; + let (logging_layer, logging_guard) = Logging::logging_layer(&config.logging)?; + + let tracer = tracer_provider.tracer("apollo-mcp-trace"); + + global::set_meter_provider(meter_provider.clone()); + global::set_text_map_propagator(TraceContextPropagator::new()); + global::set_tracer_provider(tracer_provider.clone()); + + tracing_subscriber::registry() + .with(logging_layer) + .with(env_filter) + .with(MetricsLayer::new(meter_provider.clone())) + .with(OpenTelemetryLayer::new(tracer)) + .try_init()?; + + Ok(TelemetryGuard { + tracer_provider, + meter_provider, + logging_guard, + }) +} + +pub struct TelemetryGuard { + tracer_provider: SdkTracerProvider, + meter_provider: SdkMeterProvider, + logging_guard: Option, +} + +impl Drop for TelemetryGuard { + fn drop(&mut self) { + if let Err(err) = self.tracer_provider.shutdown() { + tracing::error!("{err:?}"); + } + if let Err(err) = self.meter_provider.shutdown() { + tracing::error!("{err:?}"); + } + drop(self.logging_guard.take()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config( + service_name: Option<&str>, + version: Option<&str>, + metrics: Option, + tracing: Option, + ) -> Config { + Config { + telemetry: Telemetry { + exporters: Some(Exporters { metrics, tracing }), + service_name: service_name.map(|s| s.to_string()), + version: version.map(|v| v.to_string()), + }, + ..Default::default() + } + } + + #[tokio::test] + async fn guard_is_provided_when_tracing_configured() { + let mut ommitted = HashSet::new(); + ommitted.insert(TelemetryAttribute::RequestId); + + let config = test_config( + Some("test-config"), + Some("1.0.0"), + Some(MetricsExporters { + otlp: Some(OTLPMetricExporter::default()), + omitted_attributes: None, + }), + Some(TracingExporters { + otlp: Some(OTLPTracingExporter::default()), + sampler: Default::default(), + omitted_attributes: Some(ommitted), + }), + ); + // init_tracing_subscriber can only be called once in the test suite to avoid + // panic when calling global::set_tracer_provider multiple times + let guard = init_tracing_subscriber(&config); + assert!(guard.is_ok()); + } + + #[tokio::test] + async fn unknown_protocol_raises_meter_provider_error() { + let config = test_config( + None, + None, + Some(MetricsExporters { + otlp: Some(OTLPMetricExporter { + protocol: "bogus".to_string(), + endpoint: "http://localhost:4317".to_string(), + }), + omitted_attributes: None, + }), + None, + ); + let result = init_meter_provider(&config.telemetry); + assert!( + result + .err() + .map(|e| e.to_string().contains("Unsupported OTLP protocol")) + .unwrap_or(false) + ); + } + + #[tokio::test] + async fn http_protocol_returns_valid_meter_provider() { + let config = test_config( + None, + None, + Some(MetricsExporters { + otlp: Some(OTLPMetricExporter { + protocol: "http/protobuf".to_string(), + endpoint: "http://localhost:4318/v1/metrics".to_string(), + }), + omitted_attributes: None, + }), + None, + ); + let result = init_meter_provider(&config.telemetry); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn unknown_protocol_raises_tracer_provider_error() { + let config = test_config( + None, + None, + None, + Some(TracingExporters { + otlp: Some(OTLPTracingExporter { + protocol: "bogus".to_string(), + endpoint: "http://localhost:4317".to_string(), + }), + sampler: Default::default(), + omitted_attributes: None, + }), + ); + let result = init_tracer_provider(&config.telemetry); + assert!( + result + .err() + .map(|e| e.to_string().contains("Unsupported OTLP protocol")) + .unwrap_or(false) + ); + } + + #[tokio::test] + async fn http_protocol_returns_valid_tracer_provider() { + let config = test_config( + None, + None, + None, + Some(TracingExporters { + otlp: Some(OTLPTracingExporter { + protocol: "http/protobuf".to_string(), + endpoint: "http://localhost:4318/v1/traces".to_string(), + }), + sampler: Default::default(), + omitted_attributes: None, + }), + ); + let result = init_tracer_provider(&config.telemetry); + assert!(result.is_ok()); + } +} diff --git a/crates/apollo-mcp-server/src/runtime/telemetry/sampler.rs b/crates/apollo-mcp-server/src/runtime/telemetry/sampler.rs new file mode 100644 index 00000000..873bccd7 --- /dev/null +++ b/crates/apollo-mcp-server/src/runtime/telemetry/sampler.rs @@ -0,0 +1,98 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, untagged)] +pub(crate) enum SamplerOption { + /// Sample a given fraction. Fractions >= 1 will always sample. + RatioBased(f64), + Always(Sampler), +} + +#[derive(Clone, Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub(crate) enum Sampler { + /// Always sample + AlwaysOn, + /// Never sample + AlwaysOff, +} + +impl From for opentelemetry_sdk::trace::Sampler { + fn from(s: Sampler) -> Self { + match s { + Sampler::AlwaysOn => opentelemetry_sdk::trace::Sampler::AlwaysOn, + Sampler::AlwaysOff => opentelemetry_sdk::trace::Sampler::AlwaysOff, + } + } +} + +impl From for opentelemetry_sdk::trace::Sampler { + fn from(s: SamplerOption) -> Self { + match s { + SamplerOption::Always(s) => s.into(), + SamplerOption::RatioBased(ratio) => { + opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(ratio) + } + } + } +} + +impl Default for SamplerOption { + fn default() -> Self { + SamplerOption::Always(Sampler::AlwaysOn) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sampler_always_on_maps_to_otel_always_on() { + assert!(matches!( + Sampler::AlwaysOn.into(), + opentelemetry_sdk::trace::Sampler::AlwaysOn + )); + } + + #[test] + fn sampler_always_off_maps_to_otel_always_off() { + assert!(matches!( + Sampler::AlwaysOff.into(), + opentelemetry_sdk::trace::Sampler::AlwaysOff + )); + } + + #[test] + fn sampler_option_always_on_maps_to_otel_always_on() { + assert!(matches!( + SamplerOption::Always(Sampler::AlwaysOn).into(), + opentelemetry_sdk::trace::Sampler::AlwaysOn + )); + } + + #[test] + fn sampler_option_always_off_maps_to_otel_always_off() { + assert!(matches!( + SamplerOption::Always(Sampler::AlwaysOff).into(), + opentelemetry_sdk::trace::Sampler::AlwaysOff + )); + } + + #[test] + fn sampler_option_ratio_based_maps_to_otel_ratio_based_sampler() { + assert!(matches!( + SamplerOption::RatioBased(0.5).into(), + opentelemetry_sdk::trace::Sampler::TraceIdRatioBased(0.5) + )); + } + + #[test] + fn default_sampler_option_is_always_on() { + assert!(matches!( + SamplerOption::default(), + SamplerOption::Always(Sampler::AlwaysOn) + )); + } +} diff --git a/crates/apollo-mcp-server/src/server.rs b/crates/apollo-mcp-server/src/server.rs index 6ef0153c..cdbd72e3 100644 --- a/crates/apollo-mcp-server/src/server.rs +++ b/crates/apollo-mcp-server/src/server.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use url::Url; use crate::auth; +use crate::cors::CorsConfig; use crate::custom_scalar_map::CustomScalarMap; use crate::errors::ServerError; use crate::event::Event as ServerEvent; @@ -40,6 +41,7 @@ pub struct Server { search_leaf_depth: usize, index_memory_bytes: usize, health_check: HealthCheckConfig, + cors: CorsConfig, } #[derive(Debug, Clone, Deserialize, Default, JsonSchema)] @@ -80,6 +82,9 @@ pub enum Transport { /// The port to bind to #[serde(default = "Transport::default_port")] port: u16, + + #[serde(default = "Transport::default_stateful_mode")] + stateful_mode: bool, }, } @@ -91,6 +96,10 @@ impl Transport { fn default_port() -> u16 { 5000 } + + fn default_stateful_mode() -> bool { + true + } } #[bon] @@ -117,6 +126,7 @@ impl Server { search_leaf_depth: usize, index_memory_bytes: usize, health_check: HealthCheckConfig, + cors: CorsConfig, ) -> Self { let headers = { let mut headers = headers.clone(); @@ -144,6 +154,7 @@ impl Server { search_leaf_depth, index_memory_bytes, health_check, + cors, } } diff --git a/crates/apollo-mcp-server/src/server/states.rs b/crates/apollo-mcp-server/src/server/states.rs index e154f010..c89f3a63 100644 --- a/crates/apollo-mcp-server/src/server/states.rs +++ b/crates/apollo-mcp-server/src/server/states.rs @@ -6,6 +6,7 @@ use reqwest::header::HeaderMap; use url::Url; use crate::{ + cors::CorsConfig, custom_scalar_map::CustomScalarMap, errors::{OperationError, ServerError}, health::HealthCheckConfig, @@ -48,6 +49,7 @@ struct Config { search_leaf_depth: usize, index_memory_bytes: usize, health_check: HealthCheckConfig, + cors: CorsConfig, } impl StateMachine { @@ -81,6 +83,7 @@ impl StateMachine { search_leaf_depth: server.search_leaf_depth, index_memory_bytes: server.index_memory_bytes, health_check: server.health_check, + cors: server.cors, }, }); @@ -153,7 +156,7 @@ impl StateMachine { #[allow(clippy::result_large_err)] fn sdl_to_api_schema(schema_state: SchemaState) -> Result, ServerError> { - match Supergraph::new(&schema_state.sdl) { + match Supergraph::new_with_router_specs(&schema_state.sdl) { Ok(supergraph) => Ok(supergraph .to_api_schema(ApiSchemaOptions::default()) .map_err(|e| ServerError::Federation(Box::new(e)))? diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index b1cc4e90..6111e0ce 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use apollo_compiler::{Schema, validation::Valid}; use headers::HeaderMapExt as _; +use opentelemetry::trace::FutureExt; +use opentelemetry::{Context, KeyValue}; use reqwest::header::HeaderMap; use rmcp::model::Implementation; use rmcp::{ @@ -19,6 +21,8 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error}; use url::Url; +use crate::generated::telemetry::{TelemetryAttribute, TelemetryMetric}; +use crate::meter; use crate::{ auth::ValidToken, custom_scalar_map::CustomScalarMap, @@ -101,6 +105,7 @@ impl Running { Ok(self) } + #[tracing::instrument(skip_all)] pub(super) async fn update_operations( self, operations: Vec, @@ -142,6 +147,7 @@ impl Running { } /// Notify any peers that tools have changed. Drops unreachable peers from the list. + #[tracing::instrument(skip_all)] async fn notify_tool_list_changed(peers: Arc>>>) { let mut peers = peers.write().await; if !peers.is_empty() { @@ -170,41 +176,61 @@ impl Running { } impl ServerHandler for Running { + #[tracing::instrument(skip_all, fields(apollo.mcp.client_name = request.client_info.name, apollo.mcp.client_version = request.client_info.version))] async fn initialize( &self, - _request: InitializeRequestParam, + request: InitializeRequestParam, context: RequestContext, ) -> Result { + let meter = &meter::METER; + let attributes = vec![ + KeyValue::new( + TelemetryAttribute::ClientName.to_key(), + request.client_info.name.clone(), + ), + KeyValue::new( + TelemetryAttribute::ClientVersion.to_key(), + request.client_info.version.clone(), + ), + ]; + meter + .u64_counter(TelemetryMetric::InitializeCount.as_str()) + .build() + .add(1, &attributes); // TODO: how to remove these? let mut peers = self.peers.write().await; peers.push(context.peer); Ok(self.get_info()) } + #[tracing::instrument(skip_all, fields(apollo.mcp.tool_name = request.name.as_ref(), apollo.mcp.request_id = %context.id.clone()))] async fn call_tool( &self, request: CallToolRequestParam, context: RequestContext, ) -> Result { - let result = match request.name.as_ref() { + let meter = &meter::METER; + let start = std::time::Instant::now(); + let tool_name = request.name.clone(); + let result = match tool_name.as_ref() { INTROSPECT_TOOL_NAME => { self.introspect_tool .as_ref() - .ok_or(tool_not_found(&request.name))? + .ok_or(tool_not_found(&tool_name))? .execute(convert_arguments(request)?) .await } SEARCH_TOOL_NAME => { self.search_tool .as_ref() - .ok_or(tool_not_found(&request.name))? + .ok_or(tool_not_found(&tool_name))? .execute(convert_arguments(request)?) .await } EXPLORER_TOOL_NAME => { self.explorer_tool .as_ref() - .ok_or(tool_not_found(&request.name))? + .ok_or(tool_not_found(&tool_name))? .execute(convert_arguments(request)?) .await } @@ -226,7 +252,7 @@ impl ServerHandler for Running { self.execute_tool .as_ref() - .ok_or(tool_not_found(&request.name))? + .ok_or(tool_not_found(&tool_name))? .execute(graphql::Request { input: Value::from(request.arguments.clone()), endpoint: &self.endpoint, @@ -237,7 +263,7 @@ impl ServerHandler for Running { VALIDATE_TOOL_NAME => { self.validate_tool .as_ref() - .ok_or(tool_not_found(&request.name))? + .ok_or(tool_not_found(&tool_name))? .execute(convert_arguments(request)?) .await } @@ -266,9 +292,10 @@ impl ServerHandler for Running { .lock() .await .iter() - .find(|op| op.as_ref().name == request.name) - .ok_or(tool_not_found(&request.name))? + .find(|op| op.as_ref().name == tool_name) + .ok_or(tool_not_found(&tool_name))? .execute(graphql_request) + .with_context(Context::current()) .await } }; @@ -278,14 +305,37 @@ impl ServerHandler for Running { health_check.record_rejection(); } + let attributes = vec![ + KeyValue::new( + TelemetryAttribute::Success.to_key(), + result.as_ref().is_ok_and(|r| r.is_error != Some(true)), + ), + KeyValue::new(TelemetryAttribute::ToolName.to_key(), tool_name), + ]; + // Record response time and status + meter + .f64_histogram(TelemetryMetric::ToolDuration.as_str()) + .build() + .record(start.elapsed().as_millis() as f64, &attributes); + meter + .u64_counter(TelemetryMetric::ToolCount.as_str()) + .build() + .add(1, &attributes); + result } + #[tracing::instrument(skip_all)] async fn list_tools( &self, _request: Option, _context: RequestContext, ) -> Result { + let meter = &meter::METER; + meter + .u64_counter(TelemetryMetric::ListToolsCount.as_str()) + .build() + .add(1, &[]); Ok(ListToolsResult { next_cursor: None, tools: self @@ -304,6 +354,11 @@ impl ServerHandler for Running { } fn get_info(&self) -> ServerInfo { + let meter = &meter::METER; + meter + .u64_counter(TelemetryMetric::GetInfoCount.as_str()) + .build() + .add(1, &[]); ServerInfo { server_info: Implementation { name: "Apollo MCP Server".to_string(), diff --git a/crates/apollo-mcp-server/src/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index 4ef0dac4..c377da5a 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -2,8 +2,10 @@ use std::{net::SocketAddr, sync::Arc}; use apollo_compiler::{Name, Schema, ast::OperationType, validation::Valid}; use axum::{Router, extract::Query, http::StatusCode, response::Json, routing::get}; -use rmcp::transport::StreamableHttpService; +use axum_otel_metrics::HttpMetricsLayerBuilder; +use axum_tracing_opentelemetry::middleware::{OtelAxumLayer, OtelInResponseLayer}; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService}; use rmcp::{ ServiceExt as _, transport::{SseServer, sse_server::SseServerConfig, stdio}, @@ -11,6 +13,7 @@ use rmcp::{ use serde_json::json; use tokio::sync::{Mutex, RwLock}; use tokio_util::sync::CancellationToken; +use tower_http::trace::TraceLayer; use tracing::{Instrument as _, debug, error, info, trace}; use crate::{ @@ -126,6 +129,7 @@ impl Starting { auth: _, address: _, port: _, + stateful_mode: _, }, true, ) => Some(HealthCheck::new(self.config.health_check.clone())), @@ -163,11 +167,32 @@ impl Starting { router }}; } + + // Helper to enable CORS + macro_rules! with_cors { + ($router:expr, $config:expr) => {{ + let mut router = $router; + if $config.enabled { + match $config.build_cors_layer() { + Ok(cors_layer) => { + router = router.layer(cors_layer); + } + Err(e) => { + error!("Failed to build CORS layer: {}", e); + return Err(e); + } + } + } + router + }}; + } + match self.config.transport { Transport::StreamableHttp { auth, address, port, + stateful_mode, } => { info!(port = ?port, address = ?address, "Starting MCP server in Streamable HTTP mode"); let running = running.clone(); @@ -175,16 +200,48 @@ impl Starting { let service = StreamableHttpService::new( move || Ok(running.clone()), LocalSessionManager::default().into(), - Default::default(), + StreamableHttpServerConfig { + stateful_mode, + ..Default::default() + }, + ); + let mut router = with_cors!( + with_auth!(axum::Router::new().nest_service("/mcp", service), auth), + self.config.cors + ) + .layer(HttpMetricsLayerBuilder::new().build()) + // include trace context as header into the response + .layer(OtelInResponseLayer) + //start OpenTelemetry trace on incoming request + .layer(OtelAxumLayer::default()) + // Add tower-http tracing layer for additional HTTP-level tracing + .layer( + TraceLayer::new_for_http() + .make_span_with(|request: &axum::http::Request<_>| { + tracing::info_span!( + "mcp_server", + method = %request.method(), + uri = %request.uri(), + status_code = tracing::field::Empty, + ) + }) + .on_response( + |response: &axum::http::Response<_>, + _latency: std::time::Duration, + span: &tracing::Span| { + span.record("status", tracing::field::display(response.status())); + }, + ), ); - let mut router = - with_auth!(axum::Router::new().nest_service("/mcp", service), auth); // Add health check endpoint if configured if let Some(health_check) = health_check.filter(|h| h.config().enabled) { - let health_router = Router::new() - .route(&health_check.config().path, get(health_endpoint)) - .with_state(health_check.clone()); + let health_router = with_cors!( + Router::new() + .route(&health_check.config().path, get(health_endpoint)) + .with_state(health_check.clone()), + self.config.cors + ); router = router.merge(health_router); } @@ -274,3 +331,53 @@ async fn health_endpoint( Ok((status_code, Json(json!(health)))) } + +#[cfg(test)] +mod tests { + use http::HeaderMap; + use url::Url; + + use crate::health::HealthCheckConfig; + + use super::*; + + #[tokio::test] + async fn start_basic_server() { + let starting = Starting { + config: Config { + transport: Transport::StreamableHttp { + auth: None, + address: "127.0.0.1".parse().unwrap(), + port: 7799, + stateful_mode: false, + }, + endpoint: Url::parse("http://localhost:4000").expect("valid url"), + mutation_mode: MutationMode::All, + execute_introspection: true, + headers: HeaderMap::new(), + validate_introspection: true, + introspect_introspection: true, + search_introspection: true, + introspect_minify: false, + search_minify: false, + explorer_graph_ref: None, + custom_scalar_map: None, + disable_type_description: false, + disable_schema_description: false, + disable_auth_token_passthrough: false, + search_leaf_depth: 5, + index_memory_bytes: 1024 * 1024 * 1024, + health_check: HealthCheckConfig { + enabled: true, + ..Default::default() + }, + cors: Default::default(), + }, + schema: Schema::parse_and_validate("type Query { hello: String }", "test.graphql") + .expect("Valid schema"), + operations: vec![], + }; + let running = starting.start(); + assert!(running.await.is_ok()); + } +} diff --git a/crates/apollo-mcp-server/src/telemetry_attributes.rs b/crates/apollo-mcp-server/src/telemetry_attributes.rs new file mode 100644 index 00000000..540cd443 --- /dev/null +++ b/crates/apollo-mcp-server/src/telemetry_attributes.rs @@ -0,0 +1,42 @@ +use crate::generated::telemetry::{ALL_ATTRS, TelemetryAttribute}; +use opentelemetry::Key; +use std::collections::HashSet; + +impl TelemetryAttribute { + pub const fn to_key(self) -> Key { + match self { + TelemetryAttribute::ToolName => { + Key::from_static_str(TelemetryAttribute::ToolName.as_str()) + } + TelemetryAttribute::OperationId => { + Key::from_static_str(TelemetryAttribute::OperationId.as_str()) + } + TelemetryAttribute::OperationSource => { + Key::from_static_str(TelemetryAttribute::OperationSource.as_str()) + } + TelemetryAttribute::Success => { + Key::from_static_str(TelemetryAttribute::Success.as_str()) + } + TelemetryAttribute::RequestId => { + Key::from_static_str(TelemetryAttribute::RequestId.as_str()) + } + TelemetryAttribute::RawOperation => { + Key::from_static_str(TelemetryAttribute::RawOperation.as_str()) + } + TelemetryAttribute::ClientName => { + Key::from_static_str(TelemetryAttribute::ClientName.as_str()) + } + TelemetryAttribute::ClientVersion => { + Key::from_static_str(TelemetryAttribute::ClientVersion.as_str()) + } + } + } + + pub fn included_attributes(omitted: HashSet) -> Vec { + ALL_ATTRS + .iter() + .copied() + .filter(|a| !omitted.contains(a)) + .collect() + } +} diff --git a/crates/apollo-mcp-server/telemetry.toml b/crates/apollo-mcp-server/telemetry.toml new file mode 100644 index 00000000..f25ea0d2 --- /dev/null +++ b/crates/apollo-mcp-server/telemetry.toml @@ -0,0 +1,18 @@ +[attributes.apollo.mcp] +tool_name = "The tool name" +operation_id = "The operation id - either persisted query id, operation name, or unknown" +operation_source = "The operation source - either operation (local file/op collection), persisted query, or LLM generated" +request_id = "The request id" +success = "Sucess flag indicator" +raw_operation = "Graphql operation text and metadata used for Tool generation" +client_name = "The client name that initializes with the MCP Server" +client_version = "The client version that initializes with the MCP Server" + +[metrics.apollo.mcp] +"initialize.count" = "Number of times initialize has been called" +"tool.count" = "Number of times call_tool has been called" +"tool.duration" = "Duration of call_tool" +"list_tools.count" = "Number of times list_tools has been called" +"get_info.count" = "Number of times get_info has been called" +"operation.duration" = "Duration of graphql execute" +"operation.count" = "Number of times graphql execute has been called" diff --git a/docs/source/_sidebar.yaml b/docs/source/_sidebar.yaml index 4b8bf4cc..531eaceb 100644 --- a/docs/source/_sidebar.yaml +++ b/docs/source/_sidebar.yaml @@ -28,8 +28,12 @@ items: href: "./deploy" - label: "Health Checks" href: "./health-checks" + - label: "CORS" + href: "./cors" - label: "Authorization" href: "./auth" + - label: "Telemetry" + href: "./telemetry" - label: "Best Practices" href: "./best-practices" - label: "Licensing" diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 294bf697..54021eaa 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -17,6 +17,7 @@ All fields are optional. | Option | Type | Default | Description | | :--------------- | :-------------------- | :----------------------- | :--------------------------------------------------------------- | +| `cors` | `Cors` | | CORS configuration | | `custom_scalars` | `FilePath` | | Path to a [custom scalar map](/apollo-mcp-server/custom-scalars) | | `endpoint` | `URL` | `http://localhost:4000/` | The target GraphQL endpoint | | `graphos` | `GraphOS` | | Apollo-specific credential overrides | @@ -28,6 +29,8 @@ All fields are optional. | `overrides` | `Overrides` | | Overrides for server behavior | | `schema` | `SchemaSource` | | Schema configuration | | `transport` | `Transport` | | The type of server transport to use | +| `telemetry` | `Telemetry` | | Configuration to export metrics and traces via OTLP | + ### GraphOS @@ -40,6 +43,22 @@ These fields are under the top-level `graphos` key and define your GraphOS graph | `apollo_registry_url` | `URL` | | The URL to use for Apollo's registry | | `apollo_uplink_endpoints` | `URL` | | List of uplink URL overrides. You can also provide this with the `APOLLO_UPLINK_ENDPOINTS` environment variable | +### CORS + +These fields are under the top-level `cors` key and configure Cross-Origin Resource Sharing (CORS) for browser-based MCP clients. + +| Option | Type | Default | Description | +| :------------------ | :------------- | :---------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------- | +| `enabled` | `bool` | `false` | Enable CORS support | +| `origins` | `List` | `[]` | List of allowed origins (exact matches). Use `["*"]` to allow any origin (not recommended in production) | +| `match_origins` | `List` | `[]` | List of regex patterns to match allowed origins (e.g., `"^https://localhost:[0-9]+$"`) | +| `allow_any_origin` | `bool` | `false` | Allow requests from any origin. Cannot be used with `allow_credentials: true` | +| `allow_credentials` | `bool` | `false` | Allow credentials (cookies, authorization headers) in CORS requests | +| `allow_methods` | `List` | `["GET", "POST", "OPTIONS"]` | List of allowed HTTP methods | +| `allow_headers` | `List` | `["content-type", "mcp-protocol-version", "mcp-session-id", "traceparent", "tracestate"]` | List of allowed request headers | +| `expose_headers` | `List` | `["mcp-session-id", "traceparent", "tracestate"]` | List of response headers exposed to the browser (includes MCP and W3C Trace Context headers) | +| `max_age` | `number` | `86400` | Maximum age (in seconds) for preflight cache | + ### Health checks These fields are under the top-level `health_check` key. Learn more about [health checks](/apollo-mcp-server/health-checks). @@ -147,11 +166,12 @@ The available fields depend on the value of the nested `type` key: ##### Streamable HTTP -| Option | Value | Value Type | Description | -| :-------- | :-------------------- | :--------- | :------------------------------------------------------------------------ | -| `type` | `"streamable_http"` | | Host the MCP server on the configuration, using streamable HTTP messages. | -| `address` | `127.0.0.1` (default) | `IpAddr` | The IP address to bind to | -| `port` | `5000` (default) | `u16` | The port to bind to | +| Option | Value | Value Type | Description | +| :-------------- | :-------------------- | :--------- | :------------------------------------------------------------------------ | +| `type` | `"streamable_http"` | | Host the MCP server on the configuration, using streamable HTTP messages. | +| `address` | `127.0.0.1` (default) | `IpAddr` | The IP address to bind to | +| `port` | `5000` (default) | `u16` | The port to bind to | +| `stateful_mode` | `true` (default) | `bool` | Flag to enable or disable stateful mode and session management. | ##### SSE (Deprecated, use StreamableHTTP) @@ -165,14 +185,14 @@ The available fields depend on the value of the nested `type` key: These fields are under the top-level `transport` key, nested under the `auth` key. Learn more about [authorization and authentication](/apollo-mcp-server/auth). -| Option | Type | Default | Description | -| :-------------------------------- | :------------- | :------ | :------------------------------------------------------------------------------------------------- | -| `servers` | `List` | | List of upstream delegated OAuth servers (must support OIDC metadata discovery endpoint) | -| `audiences` | `List` | | List of accepted audiences from upstream signed JWTs | -| `resource` | `string` | | The externally available URL pointing to this MCP server. Can be `localhost` when testing locally. | -| `resource_documentation` | `string` | | Optional link to more documentation relating to this MCP server | -| `scopes` | `List` | | List of queryable OAuth scopes from the upstream OAuth servers | -| `disable_auth_token_passthrough` | `bool` | `false` | Optional flag to disable passing validated Authorization header to downstream API | +| Option | Type | Default | Description | +| :------------------------------- | :------------- | :------ | :------------------------------------------------------------------------------------------------- | +| `servers` | `List` | | List of upstream delegated OAuth servers (must support OIDC metadata discovery endpoint) | +| `audiences` | `List` | | List of accepted audiences from upstream signed JWTs | +| `resource` | `string` | | The externally available URL pointing to this MCP server. Can be `localhost` when testing locally. | +| `resource_documentation` | `string` | | Optional link to more documentation relating to this MCP server | +| `scopes` | `List` | | List of queryable OAuth scopes from the upstream OAuth servers | +| `disable_auth_token_passthrough` | `bool` | `false` | Optional flag to disable passing validated Authorization header to downstream API | Below is an example configuration using `StreamableHTTP` transport with authentication: @@ -206,6 +226,52 @@ transport: - profile ``` +### Telemetry + +| Option | Type | Default | Description | +| :-------------- | :---------- | :-------------------------- | :--------------------------------------- | +| `service_name` | `string` | "apollo-mcp-server" | The service name in telemetry data. | +| `version` | `string` | Current crate version | The service version in telemetry data. | +| `exporters` | `Exporters` | `null` (Telemetry disabled) | Configuration for telemetry exporters. | + +#### Exporters + +| Option | Type | Default | Description | +| :--------- | :---------- | :-------------------------- | :--------------------------------------- | +| `metrics` | `Metrics` | `null` (Metrics disabled) | Configuration for exporting metrics. | +| `tracing` | `Tracing` | `null` (Tracing disabled) | Configuration for exporting traces. | + + +#### Metrics + +| Option | Type | Default | Description | +| :-------------------- | :--------------- | :-------------------------- | :--------------------------------------------- | +| `otlp` | `OTLP Exporter` | `null` (Exporting disabled) | Configuration for exporting metrics via OTLP. | +| `omitted_attributes` | `List` | | List of attributes to be omitted from metrics. | + +#### Traces + +| Option | Type | Default | Description | +| :-------------------- | :--------------- | :-------------------------- | :--------------------------------------------- | +| `otlp` | `OTLP Exporter` | `null` (Exporting disabled) | Configuration for exporting traces via OTLP. | +| `sampler` | `SamplerOption` | `ALWAYS_ON` | Configuration to control sampling of traces. | +| `omitted_attributes` | `List` | | List of attributes to be omitted from traces. | + +#### OTLP Exporter + +| Option | Type | Default | Description | +| :--------- | :-------- | :-------------------------- | :--------------------------------------------------------------- | +| `endpoint` | `URL` | `http://localhost:4137` | URL to export data to. Requires full path. | +| `protocol` | `string` | `grpc` | Protocol for export. `grpc` and `http/protobuf` are supported. | + +#### SamplerOption + +| Option | Type | Description | +| :----------- | :-------- | :------------------------------------------------------- | +| `always_on` | `string` | All traces will be exported. | +| `always_off` | `string` | Sampling is turned off, no traces will be exported. | +| `0.0-1.0` | `f64` | Percentage of traces to export. | + ## Example config file The following example file sets your endpoint to `localhost:4001`, configures transport over Streamable HTTP, enables introspection, and provides two local MCP operations for the server to expose. @@ -240,4 +306,4 @@ For example, to override the `transport.auth.servers` option, you can set the `A ```sh APOLLO_MCP_TRANSPORT__AUTH__SERVERS='[server_url_1,server_url_2]' -``` \ No newline at end of file +``` diff --git a/docs/source/cors.mdx b/docs/source/cors.mdx new file mode 100644 index 00000000..aeaea24b --- /dev/null +++ b/docs/source/cors.mdx @@ -0,0 +1,244 @@ +--- +title: Configuring CORS +--- + +## Configuring CORS + +Control browser access to your MCP server + +--- + +**This article describes CORS configuration that's specific to Apollo MCP Server**. For a more general introduction to CORS and common considerations, see [MDN's CORS documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). + +By default, Apollo MCP Server has CORS disabled. If your MCP server serves tools to browser-based applications, you need to enable CORS and configure one of the following in the `cors` section of your server's YAML config file: + +- Add the origins of those web applications to the server's list of allowed `origins`. + - Use this option if there is a known, finite list of web applications that consume your MCP server. +- Add a regex that matches the origins of those web applications to the server's list of allowed `match_origins`. + - This option comes in handy if you want to match origins against a pattern, see the example below that matches subdomains of a specific namespace. +- Enable the `allow_any_origin` option. + - Use this option if your MCP server is a public API with arbitrarily many web app consumers. + - With this option enabled, the server sends the wildcard (\*) value for the `Access-Control-Allow-Origin` header. This enables _any_ website to initiate browser connections to it (but they can't provide cookies or other credentials). +- If clients need to authenticate their requests with cookies, you _must_ use either `origins`, `match_origins`, or the combination of both options. When using both options, note that `origins` is evaluated before `match_origins`. + +The following snippet includes an example of each option (use either `allow_any_origin`, or `origins` and/or `match_origins`): + +```yaml title="mcp.yaml" +transport: + type: streamable_http + port: 5000 + +cors: + # Enable CORS support + enabled: true + + # Set to true to allow any origin + # (Defaults to false) + allow_any_origin: true + + # List of accepted origins + # (Ignored if allow_any_origin is true) + # + # An origin is a combination of scheme, hostname and port. + # It does not have any path section, so no trailing slash. + origins: + - https://www.your-app.example.com + + # List of origin patterns (regex matching) + match_origins: + - "^https://([a-z0-9]+[.])*api[.]example[.]com$" # any host that uses https and ends with .api.example.com +``` + +You can also disable CORS entirely by setting `enabled` to `false` or omitting the `cors` section: + +```yaml title="mcp.yaml" +cors: + enabled: false +``` + +If your MCP server serves exclusively _non_-browser-based clients, you probably don't need to enable CORS configuration. + +### Passing credentials + +If your MCP server requires requests to include a user's credentials (e.g., via cookies), you need to modify your CORS configuration to tell the browser those credentials are allowed. + +You can enable credentials with CORS by setting the Access-Control-Allow-Credentials HTTP header to `true`. + +To allow browsers to pass credentials to the server, set `allow_credentials` to `true`, like so: + +```yaml title="mcp.yaml" +cors: + enabled: true + origins: + - https://www.your-app.example.com + allow_credentials: true +``` + +**To support credentialed requests, your server's config file must specify individual `origins` or `match_origins`**. If your server enables `allow_any_origin`, your browser will refuse to send credentials. + +### All `cors` options + +The following snippet shows all CORS configuration defaults for Apollo MCP Server: + +```yaml title="mcp.yaml" +# +# CORS (Cross Origin Resource Sharing) +# +cors: + # Enable CORS support + enabled: false + + # Set to true to allow any origin + allow_any_origin: false + + # List of accepted origins + # (Ignored if allow_any_origin is set to true) + # + # An origin is a combination of scheme, hostname and port. + # It does not have any path section, so no trailing slash. + origins: [] + + # List of origin patterns (regex matching) + # Useful for matching dynamic ports or subdomains + match_origins: [] + + # Set to true to add the `Access-Control-Allow-Credentials` header + allow_credentials: false + + # Allowed request methods + allow_methods: + - GET + - POST + + # The headers to allow. + # These are the default headers required for MCP protocol and trace context + allow_headers: + - accept + - content-type + - mcp-protocol-version + - mcp-session-id + - traceparent # W3C Trace Context + - tracestate # W3C Trace Context + + # Which response headers are available to scripts running in the + # browser in response to a cross-origin request. + # The mcp-session-id header should be exposed for MCP session management. + # Trace context headers are exposed for distributed tracing. + expose_headers: + - mcp-session-id + - traceparent # W3C Trace Context + - tracestate # W3C Trace Context + + # Adds the Access-Control-Max-Age header + # Maximum age (in seconds) for preflight cache + max_age: 7200 # 2 hours +``` + +### Origin matching + +Apollo MCP Server supports two types of origin matching: + +#### Exact origins + +Use the `origins` array for exact origin matches: + +```yaml +cors: + enabled: true + origins: + - http://localhost:3000 + - https://myapp.example.com +``` + +#### Pattern matching + +Use the `match_origins` array for regex pattern matching: + +```yaml +cors: + enabled: true + match_origins: + - "^https://localhost:[0-9]+$" # Any localhost HTTPS port + - "^http://localhost:[0-9]+$" # Any localhost HTTP port + - "^https://.*\\.example\\.com$" # Any subdomain of example.com +``` + +### Common configurations + +#### Development setup + +For local development with hot reloading and various ports: + +```yaml title="mcp.yaml" +cors: + enabled: true + match_origins: + - "^http://localhost:[0-9]+$" + allow_credentials: true +``` + +#### Production setup + +For production with specific known origins: + +```yaml title="mcp.yaml" +cors: + enabled: true + origins: + - https://myapp.example.com + allow_credentials: true + max_age: 86400 # 24 hours +``` + +#### Public API setup + +For public APIs that don't require credentials: + +```yaml title="mcp.yaml" +cors: + enabled: true + allow_any_origin: true + allow_credentials: false # Cannot use credentials with any origin +``` + +### Browser integration example + +Here's a simple example of connecting to Apollo MCP Server from a browser: + +```javascript +async function connectToMCP() { + const response = await fetch("http://127.0.0.1:5000/mcp", { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "Browser Client", version: "1.0" }, + }, + id: 1, + }), + }); + + // Extract session ID from response headers (automatically exposed) + const sessionId = response.headers.get("mcp-session-id"); + + // Handle SSE format response (starts with "data: ") + const responseText = await response.text(); + const jsonData = responseText.startsWith("data: ") + ? responseText.slice(6) // Remove "data: " prefix + : responseText; + + const result = JSON.parse(jsonData); + console.log("Connected:", result); + console.log("Session ID:", sessionId); +} + +connectToMCP(); +``` diff --git a/docs/source/define-tools.mdx b/docs/source/define-tools.mdx index f561d237..246eb1d8 100644 --- a/docs/source/define-tools.mdx +++ b/docs/source/define-tools.mdx @@ -48,25 +48,58 @@ You can also use the `operations` option to specify a directory. The server then Files and directories specified with `operations` are hot reloaded. When you specify a file, the MCP tool is updated when the file contents are modified. When you specify a directory, operations exposed as MCP tools are updated when files are added, modified, or removed from the directory. -### From an operation collection +### From operation collections -For graphs managed by GraphOS, Apollo MCP Server can get operations from an [operation collection](/graphos/platform/explorer/operation-collections). +For graphs managed by GraphOS, Apollo MCP Server can retrieve operations from an [operation collection](/graphos/platform/explorer/operation-collections). -To use a GraphOS operation collection, you must set your graph credentials `APOLLO_GRAPH_REF` and `APOLLO_KEY` as environment variables. +Use GraphOS Studio Explorer to create and manage operation collections. -Use the `operations.source: collection` option to specify that tools should be loaded from an operation collection. +#### Configuring the MCP Server to use a GraphOS operation collection + +To use a GraphOS operation collection, you must set your graph credentials (`APOLLO_GRAPH_REF` and `APOLLO_KEY`) as environment variables. + +Each graph variant has its own default MCP Tools Collection, but you can specify any shared collection by using `operations.source: collection`. Specify the collection to use with the `operations.id` option. To view the ID of a collection, click the ••• button next to its entry, select **View details**, and copy the **Collection ID**. -Each graph variant has its own default collection called **Default MCP Tools**. To use this default collection, specify `operations.id: default`. +Each graph variant has its own default collection called **Default MCP Tools**. To use this default collection, specify `operations.id: default`. Apollo MCP Server automatically fetches the default collection if no ID is specified. -```yaml title="Example config file for using the default MCP Tools operation collection" +```yaml title="Example config file for using a GraphOS operation collection" operations: source: collection id: default ``` -The MCP Server supports hot reloading of the GraphOS operation collection, so it can automatically pick up changes from GraphOS without restarting. +The MCP Server supports hot reloading of the GraphOS operation collection, so it automatically picks up changes from GraphOS without restarting. + +#### Setting operation collection variables + +When saving operation collections, remove any dynamic variables from the **Variables** panel of Explorer. This enables the LLM to modify the variables when calling the operation. + +Any variables set to any valid value (even `null`) in the Variables panel of a saved operation are used as a hardcoded override for that operation's variable. + +For example, if you create the following operation for an operation collection: + +```graphql +query GetProduct($productId: ID!) { + product(id: $productId) { + id + description + } +} +``` + +And the Variables panel has `productId` set to `1234`: + +```json +{ + "productId": "1234" +} +``` + +Then, every time the LLM calls the `GetProduct` operation, the `productId` variable is always set to `1234`. The same is true if `productId` is set to `null`. + +If you want to use dynamic variables that the LLM can modify, remove any variables from the Variables panel and save that operation to the collection. ### From persisted query manifests @@ -140,6 +173,7 @@ Both the `introspect` and `search` tools support minification of their results t - **Type prefixes**: `T=type`, `I=input`, `E=enum`, `U=union`, `F=interface` - **Scalar abbreviations**: `s=String`, `i=Int`, `f=Float`, `b=Boolean`, `d=ID` +- **Directive abbreviations**: `@D=deprecated` - **Type modifiers**: `!=required`, `[]=list`, `<>=implements` Example comparison: diff --git a/docs/source/telemetry.mdx b/docs/source/telemetry.mdx new file mode 100644 index 00000000..9b02f395 --- /dev/null +++ b/docs/source/telemetry.mdx @@ -0,0 +1,168 @@ +--- +title: OpenTelemetry Integration +--- + +AI agents create unpredictable usage patterns and complex request flows that are hard to monitor with traditional methods. The Apollo MCP Server's OpenTelemetry integration provides the visibility you need to run a reliable service for AI agents. + +## What you can monitor + +- **Agent behavior**: Which tools and operations are used most frequently +- **Performance**: Response times and bottlenecks across tool executions and GraphQL operations +- **Reliability**: Error rates, failed operations, and request success patterns +- **Distributed request flows**: Complete traces from agent request through your Apollo Router and subgraphs, with automatic trace context propagation + +## How it works + +The server exports metrics, traces, and events using the OpenTelemetry Protocol (OTLP), ensuring compatibility with your existing observability stack and seamless integration with other instrumented Apollo services. + +## Usage guide + +### Quick start: Local development + +The fastest way to see Apollo MCP Server telemetry in action is with a local setup that requires only Docker. + +#### 5-minute setup +1. Start local observability stack: +docker run -p 3000:3000 -p 4317:4317 -p 4318:4318 --rm -ti grafana/otel-lgtm +1. Add telemetry config to your `config.yaml`: + ```yaml + telemetry: + exporters: + metrics: + otlp: + endpoint: "http://localhost:4318/v1/metrics" + protocol: "http/protobuf" + tracing: + otlp: + endpoint: "http://localhost:4318/v1/traces" + protocol: "http/protobuf" + ``` +1. Restart your MCP server with the updated config +1. Open Grafana at `http://localhost:3000` and explore your telemetry data. Default credentials are username `admin` with password `admin`. + +For detailed steps and dashboard examples, see the [complete Grafana setup guide](guides/telemetry-grafana.mdx). + +### Production deployment + +For production environments, configure your MCP server to send telemetry to any OTLP-compatible backend. The Apollo MCP Server uses standard OpenTelemetry protocols, ensuring compatibility with all major observability platforms. + +#### Configuration example + +```yaml +telemetry: + service_name: "mcp-server-prod" # Custom service name + exporters: + metrics: + otlp: + endpoint: "https://your-metrics-endpoint" + protocol: "http/protobuf" # or "grpc" + tracing: + otlp: + endpoint: "https://your-traces-endpoint" + protocol: "http/protobuf" +``` + +#### Observability platform integration + +The MCP server works with any OTLP-compatible backend. Consult your provider's documentation for specific endpoint URLs and authentication: + +- [Datadog OTLP Integration](https://docs.datadoghq.com/opentelemetry/setup/otlp_ingest_in_the_agent/) - Native OTLP support +- [New Relic OpenTelemetry](https://docs.newrelic.com/docs/opentelemetry/best-practices/opentelemetry-otlp/) - Direct OTLP ingestion +- [AWS Observability](https://aws-otel.github.io/docs/introduction) - Via AWS Distro for OpenTelemetry +- [Grafana Cloud](https://grafana.com/docs/grafana-cloud/send-data/otlp/) - Hosted Grafana with OTLP +- [Honeycomb](https://docs.honeycomb.io/getting-data-in/opentelemetry/) - OpenTelemetry-native platform +- [Jaeger](https://www.jaegertracing.io/docs/1.50/deployment/) - Self-hosted tracing +- [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/deployment/) - Self-hosted with flexible routing + +#### Production configuration best practices + +##### Environment and security +```yaml +# Set via environment variable +export ENVIRONMENT=production + +telemetry: + service_name: "apollo-mcp-server" + version: "1.0.0" # Version for correlation + exporters: + metrics: + otlp: + endpoint: "https://secure-endpoint" # Always use HTTPS + protocol: "http/protobuf" # Generally more reliable than gRPC +``` + +##### Performance considerations +- **Protocol choice**: `http/protobuf` is often more reliable through firewalls and load balancers than `grpc` +- **Batch export**: OpenTelemetry automatically batches telemetry data for efficiency +- **Network timeouts**: Default timeouts are usually appropriate, but monitor for network issues + +##### Resource correlation +- The `ENVIRONMENT` variable automatically tags all telemetry with `deployment.environment.name` +- Use consistent `service_name` across all your Apollo infrastructure (Router, subgraphs, MCP server) +- Set `version` to track releases and correlate issues with deployments + +#### Troubleshooting + +##### Common issues +- **Connection refused**: Verify endpoint URL and network connectivity +- **Authentication errors**: Check if your provider requires API keys or special headers +- **Missing data**: Confirm your observability platform supports OTLP and is configured to receive data +- **High memory usage**: Monitor telemetry export frequency and consider sampling for high-volume environments + +##### Verification +```bash +# Check if telemetry is being exported (look for connection attempts) +curl -v https://your-endpoint/v1/metrics + +# Monitor server logs for OpenTelemetry export errors +./apollo-mcp-server --config config.yaml 2>&1 | grep -i "otel\|telemetry" +``` + +## Configuration Reference + +The OpenTelemetry integration is configured via the `telemetry` section of the [configuration reference page](/apollo-mcp-server/config-file#telemetry). + +## Emitted Metrics + +The server emits the following metrics, which are invaluable for monitoring and alerting. All duration metrics are in milliseconds. + +| Metric Name | Type | Description | Attributes | +|---|---|---|---| +| `apollo.mcp.initialize.count` | Counter | Incremented for each `initialize` request. | (none) | +| `apollo.mcp.list_tools.count` | Counter | Incremented for each `list_tools` request. | (none) | +| `apollo.mcp.get_info.count` | Counter | Incremented for each `get_info` request. | (none) | +| `apollo.mcp.tool.count` | Counter | Incremented for each tool call. | `tool_name`, `success` (bool) | +| `apollo.mcp.tool.duration` | Histogram | Measures the execution duration of each tool call. | `tool_name`, `success` (bool) | +| `apollo.mcp.operation.count`| Counter | Incremented for each downstream GraphQL operation executed by a tool. | `operation.id`, `operation.type` ("persisted_query" or "operation"), `success` (bool) | +| `apollo.mcp.operation.duration`| Histogram | Measures the round-trip duration of each downstream GraphQL operation. | `operation.id`, `operation.type`, `success` (bool) | + +In addition to these metrics, the server also emits standard [HTTP server metrics](https://opentelemetry.io/docs/specs/semconv/http/http-metrics/) (e.g., `http.server.duration`, `http.server.active_requests`) courtesy of the `axum-otel-metrics` library. + + +## Emitted Traces + +Spans are generated for the following actions: + +- **Incoming HTTP Requests**: A root span is created for every HTTP request to the MCP server. +- **MCP Handler Methods**: Nested spans are created for each of the main MCP protocol methods (`initialize`, `call_tool`, `list_tools`). +- **Tool Execution**: `call_tool` spans contain nested spans for the specific tool being executed (e.g., `introspect`, `search`, or a custom GraphQL operation). +- **Downstream GraphQL Calls**: The `execute` tool and custom operation tools create child spans for their outgoing `reqwest` HTTP calls, capturing the duration of the downstream request. The `traceparent` and `tracestate` headers are propagated automatically, enabling distributed traces. + +### Cardinality Control + +High-cardinality metrics can occur in MCP Servers with large number of tools or when clients are allowed to generate freeform operations. +To prevent performance issues and reduce costs, the Apollo MCP Server provides two mechanisms to control metric cardinality, trace sampling and attribute filtering. + +#### Trace Sampling + +Configure the Apollo MCP Server to sample traces sent to your OpenTelemetry Collector using the `sampler` field in the `telemetry.tracing` configuration: + +- **always_on** - Send every trace +- **always_off** - Disable trace collection entirely +- **0.0-1.0** - Send a specified percentage of traces + +#### Attribute Filtering + +The Apollo MCP Server configuration also allows for omitting attributes such as `tool_name` or `operation_id` that can often lead to high cardinality metrics in systems that treat each collected attribute value as a new metric. +Both traces and metrics have an `omitted_attributes` option that takes a list of strings. Any attribute name in the list will be filtered out and not sent to the collector. +For detailed configuration options, see the [telemetry configuration reference](/apollo-mcp-server/config-file#telemetry). diff --git a/e2e/mcp-server-tester/run_tests.sh b/e2e/mcp-server-tester/run_tests.sh index 91647ef5..95c2a6bf 100755 --- a/e2e/mcp-server-tester/run_tests.sh +++ b/e2e/mcp-server-tester/run_tests.sh @@ -71,4 +71,4 @@ safe_dir="${safe_dir//|/\\|}" sed "s||$safe_dir|g" "$TEMPLATE_PATH" > "$GEN_CONFIG" # Run the command -npx -y mcp-server-tester tools "$TESTS" --server-config "$GEN_CONFIG" \ No newline at end of file +npx -y mcp-server-tester@1.4.0 tools "$TESTS" --server-config "$GEN_CONFIG" \ No newline at end of file diff --git a/scripts/nix/install.sh b/scripts/nix/install.sh index de8fa9d0..bdc43c89 100755 --- a/scripts/nix/install.sh +++ b/scripts/nix/install.sh @@ -14,7 +14,7 @@ BINARY_DOWNLOAD_PREFIX="${APOLLO_MCP_SERVER_BINARY_DOWNLOAD_PREFIX:="https://git # Apollo MCP Server version defined in apollo-mcp-server's Cargo.toml # Note: Change this line manually during the release steps. -PACKAGE_VERSION="v0.8.0" +PACKAGE_VERSION="v0.9.0" download_binary_and_run_installer() { downloader --check diff --git a/scripts/windows/install.ps1 b/scripts/windows/install.ps1 index 05072c3e..6963bb75 100644 --- a/scripts/windows/install.ps1 +++ b/scripts/windows/install.ps1 @@ -8,7 +8,7 @@ # Apollo MCP Server version defined in apollo-mcp-server's Cargo.toml # Note: Change this line manually during the release steps. -$package_version = 'v0.8.0' +$package_version = 'v0.9.0' function Install-Binary($apollo_mcp_server_install_args) { $old_erroractionpreference = $ErrorActionPreference