From 9365d62b37cbcd26cf89fcdd1e0a554e52077cca Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 8 Sep 2025 10:16:51 -0400 Subject: [PATCH 01/58] feat: Implement Test Coverage Measurement and Reporting --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb42f641..3e00ac1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,3 +143,4 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos files: codecov.json fail_ci_if_error: true + slug: ${{ github.repository }} From 71f815304b9c651d40f7c27887f2ad2b80289323 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 8 Sep 2025 12:57:32 -0400 Subject: [PATCH 02/58] chore: add changeset --- .changesets/maint_coverage.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changesets/maint_coverage.md diff --git a/.changesets/maint_coverage.md b/.changesets/maint_coverage.md new file mode 100644 index 00000000..bc2e4c60 --- /dev/null +++ b/.changesets/maint_coverage.md @@ -0,0 +1,3 @@ +### 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. From 0907f07beed9ca0331fc0e273922a6d6a5f25415 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 8 Sep 2025 13:24:56 -0400 Subject: [PATCH 03/58] feat: remove unnecessary codecov setting --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e00ac1c..fb42f641 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,4 +143,3 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos files: codecov.json fail_ci_if_error: true - slug: ${{ github.repository }} From aee6522ab6b6392cbb437f99ead9b41a3c7f5b2f Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Tue, 9 Sep 2025 12:17:56 -0400 Subject: [PATCH 04/58] chore: add changeset --- .changesets/maint_codecov.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changesets/maint_codecov.md diff --git a/.changesets/maint_codecov.md b/.changesets/maint_codecov.md new file mode 100644 index 00000000..9d0fb911 --- /dev/null +++ b/.changesets/maint_codecov.md @@ -0,0 +1,3 @@ +### 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`. \ No newline at end of file From 8426e825e1c824b6cacb2c672be3755ad1827e5e Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Tue, 9 Sep 2025 15:08:33 -0500 Subject: [PATCH 05/58] feat: Configuration for disabling authorization token passthrough (#336) * Allow config for not forwarding Auth tokens to GraphQL API * use serde default to make new config optional * Changeset * Fix default case in main.rs * Add new config option to documentation --- .changesets/feat_auth_token_passthrough_disable.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changesets/feat_auth_token_passthrough_disable.md diff --git a/.changesets/feat_auth_token_passthrough_disable.md b/.changesets/feat_auth_token_passthrough_disable.md new file mode 100644 index 00000000..dd9ac487 --- /dev/null +++ b/.changesets/feat_auth_token_passthrough_disable.md @@ -0,0 +1,3 @@ +### feat: 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. \ No newline at end of file From f9fbb356f690cc64d844feacf613e3239bccdd86 Mon Sep 17 00:00:00 2001 From: Nicholas Cioli Date: Thu, 11 Sep 2025 17:36:15 -0400 Subject: [PATCH 06/58] chore: update RMCP version to latest (#328) This commit updates the RMCP dependency to the latest version 0.6.4. Sadly, schemars was also updated with this, so a lot of unrelated changes were needed to conform with the new stable schemars version. --- .changesets/maint_nc_rmcp_update.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changesets/maint_nc_rmcp_update.md diff --git a/.changesets/maint_nc_rmcp_update.md b/.changesets/maint_nc_rmcp_update.md new file mode 100644 index 00000000..9df34c15 --- /dev/null +++ b/.changesets/maint_nc_rmcp_update.md @@ -0,0 +1,3 @@ +### chore: update RMCP dependency ([328](https://github.com/apollographql/apollo-mcp-server/issues/328)) + +Update the RMCP dependency to the latest version, pulling in newer specification changes. From b6464f92745d8e3d2378097e446633bc037dac61 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Tue, 9 Sep 2025 16:37:25 -0400 Subject: [PATCH 07/58] test: add tests for mcp server event --- crates/apollo-mcp-server/src/event.rs | 46 ++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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"); + } +} From 1d3363f544462b944a229942c898c94fffa43aca Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Wed, 10 Sep 2025 16:04:57 -0400 Subject: [PATCH 08/58] test: add tests for SupergraphSdlQuery --- crates/apollo-mcp-registry/src/uplink.rs | 41 ++++++ .../apollo-mcp-registry/src/uplink/schema.rs | 2 +- .../src/uplink/schema/schema_stream.rs | 118 +++++++++++------- 3 files changed, 118 insertions(+), 43 deletions(-) diff --git a/crates/apollo-mcp-registry/src/uplink.rs b/crates/apollo-mcp-registry/src/uplink.rs index 015fc172..d1c2e6c2 100644 --- a/crates/apollo-mcp-registry/src/uplink.rs +++ b/crates/apollo-mcp-registry/src/uplink.rs @@ -413,3 +413,44 @@ 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")) + } + } + } +} 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" + )); } } From 946a7c3d3dd7d7b25ee0edab8a0a946b1017e894 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Thu, 11 Sep 2025 11:38:36 -0400 Subject: [PATCH 09/58] chore: add changeset --- .changesets/maint_tests_for_server_event.md | 3 + crates/apollo-mcp-registry/src/uplink.rs | 239 ++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 .changesets/maint_tests_for_server_event.md diff --git a/.changesets/maint_tests_for_server_event.md b/.changesets/maint_tests_for_server_event.md new file mode 100644 index 00000000..f8adc4ab --- /dev/null +++ b/.changesets/maint_tests_for_server_event.md @@ -0,0 +1,3 @@ +### test: 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. diff --git a/crates/apollo-mcp-registry/src/uplink.rs b/crates/apollo-mcp-registry/src/uplink.rs index d1c2e6c2..ed64c1c1 100644 --- a/crates/apollo-mcp-registry/src/uplink.rs +++ b/crates/apollo-mcp-registry/src/uplink.rs @@ -453,4 +453,243 @@ mod test { } } } + + #[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")); + } } From fcb0f341d3a3f326db001948df445d9af74bb5d8 Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Fri, 12 Sep 2025 10:59:20 -0400 Subject: [PATCH 10/58] update HEAD option to be the release branch and not where the workflow was running (#357) --- .github/workflows/prep-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: | From be7b5e83d8488d49609eabe9fa5b0f15021ef2f3 Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Fri, 12 Sep 2025 13:16:40 -0500 Subject: [PATCH 11/58] fix: Disable statefulness to attempt to fix initialize race condition (#351) * fix: Disable statefulness to attempt to fix initialize race condition * Change stateful_mode to being configuratable on the transport * Use ..Default syntax * changeset * Add new flag to docs config reference page --- .changesets/config_fix_disable_stateful_sessions.md | 3 +++ crates/apollo-mcp-server/src/server.rs | 7 +++++++ .../apollo-mcp-server/src/server/states/starting.rs | 9 +++++++-- docs/source/config-file.mdx | 12 +++++++----- 4 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 .changesets/config_fix_disable_stateful_sessions.md diff --git a/.changesets/config_fix_disable_stateful_sessions.md b/.changesets/config_fix_disable_stateful_sessions.md new file mode 100644 index 00000000..cbb982a2 --- /dev/null +++ b/.changesets/config_fix_disable_stateful_sessions.md @@ -0,0 +1,3 @@ +### fix: 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. \ No newline at end of file diff --git a/crates/apollo-mcp-server/src/server.rs b/crates/apollo-mcp-server/src/server.rs index 6ef0153c..d6b08186 100644 --- a/crates/apollo-mcp-server/src/server.rs +++ b/crates/apollo-mcp-server/src/server.rs @@ -80,6 +80,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 +94,10 @@ impl Transport { fn default_port() -> u16 { 5000 } + + fn default_stateful_mode() -> bool { + true + } } #[bon] diff --git a/crates/apollo-mcp-server/src/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index 4ef0dac4..eecd0191 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -2,8 +2,8 @@ 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 rmcp::transport::streamable_http_server::session::local::LocalSessionManager; +use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService}; use rmcp::{ ServiceExt as _, transport::{SseServer, sse_server::SseServerConfig, stdio}, @@ -126,6 +126,7 @@ impl Starting { auth: _, address: _, port: _, + stateful_mode: _, }, true, ) => Some(HealthCheck::new(self.config.health_check.clone())), @@ -168,6 +169,7 @@ impl Starting { auth, address, port, + stateful_mode, } => { info!(port = ?port, address = ?address, "Starting MCP server in Streamable HTTP mode"); let running = running.clone(); @@ -175,7 +177,10 @@ impl Starting { let service = StreamableHttpService::new( move || Ok(running.clone()), LocalSessionManager::default().into(), - Default::default(), + StreamableHttpServerConfig { + stateful_mode, + ..Default::default() + }, ); let mut router = with_auth!(axum::Router::new().nest_service("/mcp", service), auth); diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 294bf697..83c404d8 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -147,11 +147,13 @@ 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) From d3681e3cc7e8473c9de50213c298135ec4cf3f66 Mon Sep 17 00:00:00 2001 From: Lenny Burdette Date: Tue, 16 Sep 2025 12:32:34 -0400 Subject: [PATCH 12/58] Update SDL handling in sdl_to_api_schema function `::new` uses "default" supergraph specs, which does not include Connectors, so all connector supergraph result in an error and are parsed as standard GraphQL. This ends up exposing join directives, which is a huge waste of tokens. --- .changesets/fix_lb_supergraph_new_specs.md | 3 +++ crates/apollo-mcp-server/src/server/states.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 .changesets/fix_lb_supergraph_new_specs.md diff --git a/.changesets/fix_lb_supergraph_new_specs.md b/.changesets/fix_lb_supergraph_new_specs.md new file mode 100644 index 00000000..27f0997e --- /dev/null +++ b/.changesets/fix_lb_supergraph_new_specs.md @@ -0,0 +1,3 @@ +### 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. diff --git a/crates/apollo-mcp-server/src/server/states.rs b/crates/apollo-mcp-server/src/server/states.rs index e154f010..540a3e0e 100644 --- a/crates/apollo-mcp-server/src/server/states.rs +++ b/crates/apollo-mcp-server/src/server/states.rs @@ -153,7 +153,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)))? From 301774e4cd2c0ab3433e4647c792b763c6b1de98 Mon Sep 17 00:00:00 2001 From: apollo-bot2 <50160705+apollo-bot2@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:55:21 -0600 Subject: [PATCH 13/58] =?UTF-8?q?Sync=20main=20=E2=86=92=20develop=20(PR?= =?UTF-8?q?=20#355)=20(#363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Redirect /docs/apollo-mcp-server/guides to fix 404 * Fix bullet point formatting * Add load balancer configuration details for Apollo MCP Added instructions for configuring load balancers with Apollo MCP Server to ensure session affinity. * Update docs/source/deploy.mdx Co-authored-by: Michelle Mabuyo * Update deploy.mdx --------- Co-authored-by: Samuel Collard Co-authored-by: Michelle Mabuyo Co-authored-by: Alyssa Hursh <15177211+alyssahursh@users.noreply.github.com> Co-authored-by: Lenny Burdette From a678c8548f59f2bf99de8833e9ee5583dbcd9587 Mon Sep 17 00:00:00 2001 From: apollo-bot2 <50160705+apollo-bot2@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:56:08 -0600 Subject: [PATCH 14/58] =?UTF-8?q?Sync=20main=20=E2=86=92=20develop=20(PR?= =?UTF-8?q?=20#353)=20(#361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Redirect /docs/apollo-mcp-server/guides to fix 404 * Fix bullet point formatting --------- Co-authored-by: Samuel Collard Co-authored-by: Michelle Mabuyo Co-authored-by: Alyssa Hursh <15177211+alyssahursh@users.noreply.github.com> From 00ce281f3c93217ccd80b758b6951086d6c20eec Mon Sep 17 00:00:00 2001 From: Armando Locay Date: Fri, 12 Sep 2025 12:26:45 -0400 Subject: [PATCH 15/58] docs: updating define operation collections section with note on variables --- docs/source/define-tools.mdx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/source/define-tools.mdx b/docs/source/define-tools.mdx index f561d237..dc46e11e 100644 --- a/docs/source/define-tools.mdx +++ b/docs/source/define-tools.mdx @@ -68,6 +68,34 @@ operations: The MCP Server supports hot reloading of the GraphOS operation collection, so it can automatically pick up changes from GraphOS without restarting. +#### A note on operation collection variables #### +Operation collection variables are treated a bit differently than how they are treated from local file operations. In +short, any variables set to any valid value (even `null`) in the Variables panel when saved will be used as a hardcoded +override for that operation’s variable. The LLM cannot modify or pass in these values and therefore are omitted from +the resulting input schema. + +For example, let's say the following operation is created for an operation collection: +```graphql +query ProductQuery($productId: ID!) { + product(id: $productId) { + id + description + } +} +``` +The Variables panel has `productid` set to `1234`: +```json +{ + "productId": "1234" +} +``` +then every time this operation is called by the LLM, the `productId` variable will always be this value. +The same would be true if `productId` was set to `null`. + +To get a similar to behavior to what is seen when using the local file operations source one can simply delete +`productId` from the Variables panel and then re-save the operation to update the collection. This will keep it from +being hard-coded and will be added to the resulting schema. + ### From persisted query manifests Apollo MCP Server supports reading GraphQL operations from Apollo-formatted [persisted query manifest](/graphos/platform/security/persisted-queries#manifest-format) files. From 8f51dd573a2ce3f9451f00f29ddf0ccd19fc8578 Mon Sep 17 00:00:00 2001 From: Michelle Mabuyo Date: Mon, 15 Sep 2025 17:20:29 -0400 Subject: [PATCH 16/58] edit for simplicity, lead with the intent --- docs/source/define-tools.mdx | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/source/define-tools.mdx b/docs/source/define-tools.mdx index dc46e11e..6030a602 100644 --- a/docs/source/define-tools.mdx +++ b/docs/source/define-tools.mdx @@ -68,33 +68,34 @@ operations: The MCP Server supports hot reloading of the GraphOS operation collection, so it can automatically pick up changes from GraphOS without restarting. -#### A note on operation collection variables #### -Operation collection variables are treated a bit differently than how they are treated from local file operations. In -short, any variables set to any valid value (even `null`) in the Variables panel when saved will be used as a hardcoded -override for that operation’s variable. The LLM cannot modify or pass in these values and therefore are omitted from -the resulting input schema. +#### Setting operation collection variables + +When saving operation collections, remove any dynamic variables from the **Variables** panel of Explorer. This will enable 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 will be used as a hardcoded override for that operation's variable. + +For example, if the following operation is created for an operation collection: -For example, let's say the following operation is created for an operation collection: ```graphql -query ProductQuery($productId: ID!) { - product(id: $productId) { - id - description - } +query GetProduct($productId: ID!) { + product(id: $productId) { + id + description + } } ``` -The Variables panel has `productid` set to `1234`: + +And the Variables panel has `productId` set to `1234`: + ```json { "productId": "1234" } ``` -then every time this operation is called by the LLM, the `productId` variable will always be this value. -The same would be true if `productId` was set to `null`. -To get a similar to behavior to what is seen when using the local file operations source one can simply delete -`productId` from the Variables panel and then re-save the operation to update the collection. This will keep it from -being hard-coded and will be added to the resulting schema. +Then every time the `GetProduct` operation is called by the LLM, the `productId` variable will always be set to `1234`. The same would be true if `productId` was 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 From 77dedb3ff31bd05ad77ac4c25bbe2ac769d5c1a5 Mon Sep 17 00:00:00 2001 From: Michelle Mabuyo Date: Mon, 15 Sep 2025 17:20:50 -0400 Subject: [PATCH 17/58] edit operation collections section for style consistency and clarity --- docs/source/define-tools.mdx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/source/define-tools.mdx b/docs/source/define-tools.mdx index 6030a602..4bffc604 100644 --- a/docs/source/define-tools.mdx +++ b/docs/source/define-tools.mdx @@ -48,19 +48,23 @@ 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. + +#### 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. Use the `operations.source: collection` option to specify that tools should be loaded from an operation 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 From 359efbd36d702781a0aa94d9aff1f04b548f36d6 Mon Sep 17 00:00:00 2001 From: Michelle Mabuyo Date: Mon, 15 Sep 2025 17:24:48 -0400 Subject: [PATCH 18/58] librarian edits --- docs/source/define-tools.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/source/define-tools.mdx b/docs/source/define-tools.mdx index 4bffc604..3a87e360 100644 --- a/docs/source/define-tools.mdx +++ b/docs/source/define-tools.mdx @@ -58,7 +58,7 @@ Use GraphOS Studio Explorer to create and manage operation collections. To use a GraphOS operation collection, you must set your graph credentials (`APOLLO_GRAPH_REF` and `APOLLO_KEY`) as environment variables. -Use the `operations.source: collection` option to specify that tools should be loaded from an operation collection. +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**. @@ -70,15 +70,15 @@ operations: 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 will enable the LLM to modify the variables when calling the operation. +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 will be used as a hardcoded override for that operation's variable. +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 the following operation is created for an operation collection: +For example, if you create the following operation for an operation collection: ```graphql query GetProduct($productId: ID!) { @@ -97,7 +97,7 @@ And the Variables panel has `productId` set to `1234`: } ``` -Then every time the `GetProduct` operation is called by the LLM, the `productId` variable will always be set to `1234`. The same would be true if `productId` was set to `null`. +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 c18795ed9e9563931bf4105d37131bfd4455470c Mon Sep 17 00:00:00 2001 From: Armando Locay Date: Fri, 19 Sep 2025 10:05:56 -0400 Subject: [PATCH 19/58] test: updating e2e test script to specify a specific version for testing tool --- e2e/mcp-server-tester/run_tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From bbb9cd2e139cedd89c14e122a8681401d281fe2f Mon Sep 17 00:00:00 2001 From: Armando Locay Date: Fri, 19 Sep 2025 10:08:38 -0400 Subject: [PATCH 20/58] chore: adding changeset --- .changesets/maint_fix_version_on_mcp_server_tester_alocay.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changesets/maint_fix_version_on_mcp_server_tester_alocay.md diff --git a/.changesets/maint_fix_version_on_mcp_server_tester_alocay.md b/.changesets/maint_fix_version_on_mcp_server_tester_alocay.md new file mode 100644 index 00000000..c7028a76 --- /dev/null +++ b/.changesets/maint_fix_version_on_mcp_server_tester_alocay.md @@ -0,0 +1,3 @@ +### 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. From 2d3943f68db811013517d5eca595a8c152b5053e Mon Sep 17 00:00:00 2001 From: Armando Locay Date: Fri, 19 Sep 2025 10:31:38 -0400 Subject: [PATCH 21/58] re-run checks From 8ac2f287809c37e9e04db9fc1eff0387cc627c35 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Thu, 11 Sep 2025 10:32:52 -0400 Subject: [PATCH 22/58] feat: improve search tool description --- crates/apollo-mcp-server/src/introspection/tools/search.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/apollo-mcp-server/src/introspection/tools/search.rs b/crates/apollo-mcp-server/src/introspection/tools/search.rs index 3e146b63..ead56a17 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/search.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/search.rs @@ -75,7 +75,7 @@ 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. For best results, use specific type names (which can be discovered 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" } else { From 45fdea231b4e4e58e46fb840dc745ce06107ef43 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Thu, 11 Sep 2025 10:38:59 -0400 Subject: [PATCH 23/58] feat: improve introspect tool description --- .../src/introspection/tools/introspect.rs | 10 +++------- .../src/introspection/tools/search.rs | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs index 436c89e3..1af80600 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs @@ -125,13 +125,9 @@ fn tool_description( "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() } 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: Always use this tool first to get the fields for the root query ({} type) or mutation ({} type), then use the search tool with all of the relevant field return type and argument input types (ignore default GraphQL scalars) as search terms, only use this tool once when fulfilling a request.", + root_query_type.as_deref().unwrap_or("Query"), + root_mutation_type.as_deref().unwrap_or("Mutation") ) } } diff --git a/crates/apollo-mcp-server/src/introspection/tools/search.rs b/crates/apollo-mcp-server/src/introspection/tools/search.rs index ead56a17..d470f0d2 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/search.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/search.rs @@ -75,7 +75,7 @@ impl Search { tool: Tool::new( SEARCH_TOOL_NAME, format!( - "Search a GraphQL schema for types matching the provided search terms. Returns complete type definitions including all related types needed to construct GraphQL operations. For best results, use specific type names (which can be discovered using the introspect tool starting from the root Query or Mutation types). Avoid reusing previously searched terms for more efficient exploration.{}", + "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: For best results, use specific type names (which can be discovered 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" } else { From 7097712ffdca613d555dc688b222bd03481de5f9 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Thu, 11 Sep 2025 12:01:23 -0400 Subject: [PATCH 24/58] test: add tests to verify updated tool descriptions --- .../src/introspection/tools/introspect.rs | 48 +++++++++++++++++++ .../src/introspection/tools/search.rs | 32 +++++++++++++ 2 files changed, 80 insertions(+) diff --git a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs index 1af80600..9e8d37b4 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs @@ -136,3 +136,51 @@ 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_tool_description(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: Always use this tool first")); + // Should not contain minification legend + assert!(!description.contains("T=type,I=input")); + } + + #[rstest] + #[tokio::test] + async fn test_tool_description_minified(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 d470f0d2..fb63be03 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/search.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/search.rs @@ -246,4 +246,36 @@ mod tests { "Expected to find the createUser mutation in search results" ); } + + #[rstest] + #[tokio::test] + async fn test_tool_description_non_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: For best results, use specific type names")); + // 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")); + } } From edd442c5148685ffb76745b13107787202c4f9a3 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Thu, 11 Sep 2025 12:02:49 -0400 Subject: [PATCH 25/58] chore: add changeset --- .changesets/feat_imporove_tool_descriptions.md | 3 +++ .../src/introspection/tools/introspect.rs | 8 +++++--- .../apollo-mcp-server/src/introspection/tools/search.rs | 5 +++-- 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 .changesets/feat_imporove_tool_descriptions.md diff --git a/.changesets/feat_imporove_tool_descriptions.md b/.changesets/feat_imporove_tool_descriptions.md new file mode 100644 index 00000000..980dcb53 --- /dev/null +++ b/.changesets/feat_imporove_tool_descriptions.md @@ -0,0 +1,3 @@ +### feat: 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. diff --git a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs index 9e8d37b4..91f1b07e 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs @@ -125,7 +125,7 @@ fn tool_description( "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() } else { format!( - "Get information about a given GraphQL type defined in the schema. Instructions: Always use this tool first to get the fields for the root query ({} type) or mutation ({} type), then use the search tool with all of the relevant field return type and argument input types (ignore default GraphQL scalars) as search terms, only use this tool once when fulfilling a request.", + "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") ) @@ -158,7 +158,7 @@ mod tests { #[rstest] #[tokio::test] - async fn test_tool_description(schema: Valid) { + async fn test_tool_description_non_minified(schema: Valid) { let introspect = Introspect::new(Arc::new(Mutex::new(schema)), None, None, false); let description = introspect.tool.description.unwrap(); @@ -167,9 +167,11 @@ mod tests { description .contains("Get information about a given GraphQL type defined in the schema") ); - assert!(description.contains("Instructions: Always use this tool first")); + 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] diff --git a/crates/apollo-mcp-server/src/introspection/tools/search.rs b/crates/apollo-mcp-server/src/introspection/tools/search.rs index fb63be03..5d7a5356 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/search.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/search.rs @@ -75,7 +75,7 @@ impl Search { tool: Tool::new( SEARCH_TOOL_NAME, format!( - "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: For best results, use specific type names (which can be discovered using the introspect tool starting from the root Query or Mutation types). Avoid reusing previously searched terms for more efficient exploration.{}", + "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" } else { @@ -260,7 +260,8 @@ mod tests { description .contains("Search a GraphQL schema for types matching the provided search terms") ); - assert!(description.contains("Instructions: For best results, use specific type names")); + 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")); } From 51837d04432de020b354b0da4300e936052ddf39 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 15 Sep 2025 14:21:28 -0400 Subject: [PATCH 26/58] chore: rename test methods --- .../apollo-mcp-server/src/introspection/tools/introspect.rs | 6 ++++-- crates/apollo-mcp-server/src/introspection/tools/search.rs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs index 91f1b07e..4757d013 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs @@ -158,7 +158,7 @@ mod tests { #[rstest] #[tokio::test] - async fn test_tool_description_non_minified(schema: Valid) { + 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(); @@ -176,7 +176,9 @@ mod tests { #[rstest] #[tokio::test] - async fn test_tool_description_minified(schema: Valid) { + 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(); diff --git a/crates/apollo-mcp-server/src/introspection/tools/search.rs b/crates/apollo-mcp-server/src/introspection/tools/search.rs index 5d7a5356..6d8a70c2 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/search.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/search.rs @@ -249,7 +249,7 @@ mod tests { #[rstest] #[tokio::test] - async fn test_tool_description_non_minified(schema: Valid) { + 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"); From 0155c34f608531a498fbb000c2e5d53b6d7d2d50 Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Fri, 12 Sep 2025 14:15:51 -0400 Subject: [PATCH 27/58] add minify support for directives and specify deprecated --- .../src/introspection/minify.rs | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/apollo-mcp-server/src/introspection/minify.rs b/crates/apollo-mcp-server/src/introspection/minify.rs index 81af9534..c029e1b9 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,34 @@ 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()) { + if !directive.arguments.is_empty() { + // Since we're only handling @deprecated right now we can just add the reason and minify it + let reason = directive + .arguments + .iter() + .find(|a| a.name == "reason") + .and_then(|a| a.value.as_str()) + .unwrap_or("No longer supported") + .to_string(); + result.push_str(&format!("@{}(\"{}\")", minified_name, reason)); + } else { + result.push_str(&format!("@{}", minified_name)); + } + } + } + result +} + fn minify_fields( fields: &apollo_compiler::collections::IndexMap< apollo_compiler::Name, @@ -99,6 +127,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 +158,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 +178,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::>() From 088062c1aa2818ae2d284c1862354f7684022c63 Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Fri, 12 Sep 2025 14:16:17 -0400 Subject: [PATCH 28/58] update tool descriptions to support Deprecated directive understanding in the LLM --- crates/apollo-mcp-server/src/introspection/tools/introspect.rs | 2 +- crates/apollo-mcp-server/src/introspection/tools/search.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs index 4757d013..7d92996e 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs @@ -122,7 +122,7 @@ 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 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.", diff --git a/crates/apollo-mcp-server/src/introspection/tools/search.rs b/crates/apollo-mcp-server/src/introspection/tools/search.rs index 6d8a70c2..29816bcc 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/search.rs +++ b/crates/apollo-mcp-server/src/introspection/tools/search.rs @@ -77,7 +77,7 @@ impl Search { format!( "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 { "" } From 5c080841bbf0186ab20b65c29cb0cb28963b55fe Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Wed, 17 Sep 2025 10:25:20 -0400 Subject: [PATCH 29/58] normalize reason text --- crates/apollo-mcp-server/src/introspection/minify.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/apollo-mcp-server/src/introspection/minify.rs b/crates/apollo-mcp-server/src/introspection/minify.rs index c029e1b9..b7bf7ff8 100644 --- a/crates/apollo-mcp-server/src/introspection/minify.rs +++ b/crates/apollo-mcp-server/src/introspection/minify.rs @@ -83,7 +83,8 @@ fn minify_directives(directives: &apollo_compiler::ast::DirectiveList) -> String for directive in directives.iter() { if let Some(minified_name) = directives_to_minify.get(directive.name.as_str()) { if !directive.arguments.is_empty() { - // Since we're only handling @deprecated right now we can just add the reason and minify it + // 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. let reason = directive .arguments .iter() @@ -91,7 +92,11 @@ fn minify_directives(directives: &apollo_compiler::ast::DirectiveList) -> String .and_then(|a| a.value.as_str()) .unwrap_or("No longer supported") .to_string(); - result.push_str(&format!("@{}(\"{}\")", minified_name, reason)); + result.push_str(&format!( + "@{}(\"{}\")", + minified_name, + normalize_description(&reason) + )); } else { result.push_str(&format!("@{}", minified_name)); } From 62344c4ffab007fa0eae723b85da88d75ead202e Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Wed, 17 Sep 2025 10:32:47 -0400 Subject: [PATCH 30/58] add snapshot tests for minification --- .../src/introspection/minify.rs | 24 +++++++ ...pection__minify__tests__minify_schema.snap | 64 +++++++++++++++++++ .../tools/testdata/schema.graphql | 1 + 3 files changed, 89 insertions(+) create mode 100644 crates/apollo-mcp-server/src/introspection/snapshots/apollo_mcp_server__introspection__minify__tests__minify_schema.snap diff --git a/crates/apollo-mcp-server/src/introspection/minify.rs b/crates/apollo-mcp-server/src/introspection/minify.rs index b7bf7ff8..d4026983 100644 --- a/crates/apollo-mcp-server/src/introspection/minify.rs +++ b/crates/apollo-mcp-server/src/introspection/minify.rs @@ -253,3 +253,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..f9c1f6e3 --- /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!,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/testdata/schema.graphql b/crates/apollo-mcp-server/src/introspection/tools/testdata/schema.graphql index 5407ba1b..a70e2660 100644 --- a/crates/apollo-mcp-server/src/introspection/tools/testdata/schema.graphql +++ b/crates/apollo-mcp-server/src/introspection/tools/testdata/schema.graphql @@ -268,6 +268,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!]! From 0c7d8284f0250bff22955118849c301c55d10e5e Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Wed, 17 Sep 2025 10:45:30 -0400 Subject: [PATCH 31/58] add changeset --- .changesets/fix_evan_minify_include_builtin_directives.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changesets/fix_evan_minify_include_builtin_directives.md diff --git a/.changesets/fix_evan_minify_include_builtin_directives.md b/.changesets/fix_evan_minify_include_builtin_directives.md new file mode 100644 index 00000000..d5e08bda --- /dev/null +++ b/.changesets/fix_evan_minify_include_builtin_directives.md @@ -0,0 +1,3 @@ +### Minify: 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. \ No newline at end of file From ef69a8c1f6fd6e5f323a657c876d69678b8274a7 Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Wed, 17 Sep 2025 10:56:15 -0400 Subject: [PATCH 32/58] update existing snapshots --- ...erver__introspection__tools__search__tests__search_tool.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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!]! } From 56abe7189a81d873d42cec4e2fe777fc5335b9e1 Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Thu, 18 Sep 2025 12:02:44 -0400 Subject: [PATCH 33/58] move reason handling to if let so it is clear that we only handle the arguments if we have that present --- .../apollo-mcp-server/src/introspection/minify.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/apollo-mcp-server/src/introspection/minify.rs b/crates/apollo-mcp-server/src/introspection/minify.rs index d4026983..6cc47a8a 100644 --- a/crates/apollo-mcp-server/src/introspection/minify.rs +++ b/crates/apollo-mcp-server/src/introspection/minify.rs @@ -82,20 +82,19 @@ fn minify_directives(directives: &apollo_compiler::ast::DirectiveList) -> String for directive in directives.iter() { if let Some(minified_name) = directives_to_minify.get(directive.name.as_str()) { - if !directive.arguments.is_empty() { - // 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. - let reason = directive + // 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()) - .unwrap_or("No longer supported") - .to_string(); + { result.push_str(&format!( "@{}(\"{}\")", minified_name, - normalize_description(&reason) + normalize_description(reason) )); } else { result.push_str(&format!("@{}", minified_name)); From a33499e77206342b07dccfb0401dbfedaefb8ac0 Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Thu, 18 Sep 2025 12:02:53 -0400 Subject: [PATCH 34/58] update tests to show support for deprecation with no provided reason --- ...mcp_server__introspection__minify__tests__minify_schema.snap | 2 +- .../src/introspection/tools/testdata/schema.graphql | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 index f9c1f6e3..439853a6 100644 --- 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 @@ -42,7 +42,7 @@ Demographics: T:Demographics:ageGroups:[AgeGroup],locations:[LocationStats],devi 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!,language:s!,notifications:NotificationPreferences!,privacy:PrivacySettings! +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 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 a70e2660..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! From 58854db0d778959615e7f3bf63c8642679622ac1 Mon Sep 17 00:00:00 2001 From: Evan Silverman Date: Thu, 18 Sep 2025 12:04:14 -0400 Subject: [PATCH 35/58] update docs --- docs/source/define-tools.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/define-tools.mdx b/docs/source/define-tools.mdx index 3a87e360..246eb1d8 100644 --- a/docs/source/define-tools.mdx +++ b/docs/source/define-tools.mdx @@ -173,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: From 3e69db4805e5cda110b7ae5b98b75ad4ae23254d Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 15 Sep 2025 15:44:58 -0400 Subject: [PATCH 36/58] feat: add CORS support --- crates/apollo-mcp-server/src/cors.rs | 327 ++++++++++++++++++ crates/apollo-mcp-server/src/errors.rs | 3 + crates/apollo-mcp-server/src/lib.rs | 1 + crates/apollo-mcp-server/src/main.rs | 1 + crates/apollo-mcp-server/src/runtime.rs | 21 ++ .../apollo-mcp-server/src/runtime/config.rs | 5 +- crates/apollo-mcp-server/src/server.rs | 4 + crates/apollo-mcp-server/src/server/states.rs | 3 + .../src/server/states/starting.rs | 35 +- 9 files changed, 394 insertions(+), 6 deletions(-) create mode 100644 crates/apollo-mcp-server/src/cors.rs diff --git a/crates/apollo-mcp-server/src/cors.rs b/crates/apollo-mcp-server/src/cors.rs new file mode 100644 index 00000000..8d77ac23 --- /dev/null +++ b/crates/apollo-mcp-server/src/cors.rs @@ -0,0 +1,327 @@ +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: default_methods(), + allow_headers: default_headers(), + expose_headers: Vec::new(), + max_age: Some(default_max_age()), + } + } +} + +/// Default allowed HTTP methods +fn default_methods() -> Vec { + vec!["GET".to_string(), "POST".to_string(), "OPTIONS".to_string()] +} + +/// Default allowed headers +fn default_headers() -> Vec { + vec![ + "content-type".to_string(), + "authorization".to_string(), + "mcp-session-id".to_string(), + ] +} + +/// Default max age for preflight cache (2 hours) +fn default_max_age() -> u64 { + 7200 +} + +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" + .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::*; + + #[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, default_methods()); + assert_eq!(config.allow_headers, default_headers()); + assert_eq!(config.max_age, Some(default_max_age())); + } + + #[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![ + "https://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!["^https://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!["https://localhost:3000".to_string()], + allow_methods: vec!["invalid method with spaces".to_string()], + ..Default::default() + }; + assert!(config.build_cors_layer().is_err()); + } +} 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/lib.rs b/crates/apollo-mcp-server/src/lib.rs index 21b89ce8..5552e193 100644 --- a/crates/apollo-mcp-server/src/lib.rs +++ b/crates/apollo-mcp-server/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod cors; pub mod custom_scalar_map; pub mod errors; pub mod event; diff --git a/crates/apollo-mcp-server/src/main.rs b/crates/apollo-mcp-server/src/main.rs index 3f3d8738..9c6b0341 100644 --- a/crates/apollo-mcp-server/src/main.rs +++ b/crates/apollo-mcp-server/src/main.rs @@ -145,6 +145,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/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index bc63f7af..706ba800 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -144,6 +144,27 @@ 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", + "OPTIONS", + ], + allow_headers: [ + "content-type", + "authorization", + "mcp-session-id", + ], + expose_headers: [], + max_age: Some( + 7200, + ), + }, custom_scalars: None, endpoint: Endpoint( Url { diff --git a/crates/apollo-mcp-server/src/runtime/config.rs b/crates/apollo-mcp-server/src/runtime/config.rs index 8f64f518..991a21a5 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; @@ -15,6 +15,9 @@ use super::{ #[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, diff --git a/crates/apollo-mcp-server/src/server.rs b/crates/apollo-mcp-server/src/server.rs index d6b08186..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)] @@ -124,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(); @@ -151,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 540a3e0e..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, }, }); diff --git a/crates/apollo-mcp-server/src/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index eecd0191..88457ac7 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -164,6 +164,26 @@ 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, @@ -182,14 +202,19 @@ impl Starting { ..Default::default() }, ); - let mut router = - with_auth!(axum::Router::new().nest_service("/mcp", service), auth); + let mut router = with_cors!( + with_auth!(axum::Router::new().nest_service("/mcp", service), auth), + self.config.cors + ); // 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); } From bfb7d5be43ad0ae2209a8d4a4c8bc5dea188d6da Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Wed, 17 Sep 2025 23:15:10 -0400 Subject: [PATCH 37/58] docs: update config file reference --- docs/source/config-file.mdx | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 83c404d8..791eef37 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 | @@ -40,6 +41,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", "authorization"]` | List of allowed request headers | +| `expose_headers` | `List` | `[]` | List of response headers exposed to the browser (e.g., `["mcp-session-id"]`) | +| `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). @@ -154,7 +171,6 @@ The available fields depend on the value of the nested `type` key: | `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) | Option | Value | Value Type | Description | @@ -167,14 +183,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: @@ -242,4 +258,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 +``` From 87579bc3849297898616b6cd63fc3981c6f264af Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Wed, 17 Sep 2025 23:15:26 -0400 Subject: [PATCH 38/58] chore: add changeset --- .changesets/feat_cors.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changesets/feat_cors.md diff --git a/.changesets/feat_cors.md b/.changesets/feat_cors.md new file mode 100644 index 00000000..c3fdc5d7 --- /dev/null +++ b/.changesets/feat_cors.md @@ -0,0 +1,3 @@ +### 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. From d3f72cd34695165b980e32e65bea3010412c783e Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Fri, 19 Sep 2025 14:26:06 -0400 Subject: [PATCH 39/58] test: add integartion tests for cors --- Cargo.lock | 11 +- crates/apollo-mcp-server/Cargo.toml | 1 + crates/apollo-mcp-server/src/cors.rs | 548 ++++++++++++++++++++++-- crates/apollo-mcp-server/src/runtime.rs | 7 +- 4 files changed, 530 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5da0ac42..56dc3b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,7 @@ dependencies = [ "thiserror 2.0.14", "tokio", "tokio-util", + "tower", "tower-http", "tracing", "tracing-appender", @@ -645,7 +646,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]] @@ -1817,7 +1818,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3027,7 +3028,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3596,7 +3597,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4259,7 +4260,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index 3fe245b7..cb5903dc 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -55,6 +55,7 @@ insta.workspace = true mockito = "1.7.0" rstest.workspace = true tokio.workspace = true +tower = "0.5.2" tracing-test = "0.2.5" [lints] diff --git a/crates/apollo-mcp-server/src/cors.rs b/crates/apollo-mcp-server/src/cors.rs index 8d77ac23..244e3fc8 100644 --- a/crates/apollo-mcp-server/src/cors.rs +++ b/crates/apollo-mcp-server/src/cors.rs @@ -47,33 +47,18 @@ impl Default for CorsConfig { match_origins: Vec::new(), allow_any_origin: false, allow_credentials: false, - allow_methods: default_methods(), - allow_headers: default_headers(), - expose_headers: Vec::new(), - max_age: Some(default_max_age()), + allow_methods: vec!["GET".to_string(), "POST".to_string()], + 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 + ], + expose_headers: vec!["mcp-session-id".to_string()], // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management + max_age: Some(7200), // 2 hours } } } -/// Default allowed HTTP methods -fn default_methods() -> Vec { - vec!["GET".to_string(), "POST".to_string(), "OPTIONS".to_string()] -} - -/// Default allowed headers -fn default_headers() -> Vec { - vec![ - "content-type".to_string(), - "authorization".to_string(), - "mcp-session-id".to_string(), - ] -} - -/// Default max age for preflight cache (2 hours) -fn default_max_age() -> u64 { - 7200 -} - impl CorsConfig { /// Build a CorsLayer from this configuration pub fn build_cors_layer(&self) -> Result { @@ -220,6 +205,9 @@ impl CorsConfig { #[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() { @@ -227,9 +215,19 @@ mod tests { assert!(!config.enabled); assert!(!config.allow_any_origin); assert!(!config.allow_credentials); - assert_eq!(config.allow_methods, default_methods()); - assert_eq!(config.allow_headers, default_headers()); - assert_eq!(config.max_age, Some(default_max_age())); + assert_eq!( + config.allow_methods, + vec!["GET".to_string(), "POST".to_string()] + ); + assert_eq!( + config.allow_headers, + vec![ + "content-type".to_string(), + "mcp-protocol-version".to_string(), + "mcp-session-id".to_string(), + ] + ); + assert_eq!(config.max_age, Some(7200)); } #[test] @@ -253,7 +251,7 @@ mod tests { let config = CorsConfig { enabled: true, origins: vec![ - "https://localhost:3000".to_string(), + "http://localhost:3000".to_string(), "https://studio.apollographql.com".to_string(), ], ..Default::default() @@ -265,7 +263,7 @@ mod tests { fn test_regex_origins_build() { let config = CorsConfig { enabled: true, - match_origins: vec!["^https://localhost:[0-9]+$".to_string()], + match_origins: vec!["^http://localhost:[0-9]+$".to_string()], ..Default::default() }; assert!(config.build_cors_layer().is_ok()); @@ -318,10 +316,502 @@ mod tests { fn test_invalid_method_fails() { let config = CorsConfig { enabled: true, - origins: vec!["https://localhost:3000".to_string()], + 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/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index 706ba800..02c987c5 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -153,14 +153,15 @@ mod test { allow_methods: [ "GET", "POST", - "OPTIONS", ], allow_headers: [ "content-type", - "authorization", + "mcp-protocol-version", + "mcp-session-id", + ], + expose_headers: [ "mcp-session-id", ], - expose_headers: [], max_age: Some( 7200, ), From 5d6921dbbf07ef4f30b9c039ef776bdac6756cc1 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Mon, 22 Sep 2025 14:24:10 -0400 Subject: [PATCH 40/58] docs: add cors docs page --- docs/source/_sidebar.yaml | 2 + docs/source/cors.mdx | 243 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 docs/source/cors.mdx diff --git a/docs/source/_sidebar.yaml b/docs/source/_sidebar.yaml index 4b8bf4cc..f1e99e61 100644 --- a/docs/source/_sidebar.yaml +++ b/docs/source/_sidebar.yaml @@ -28,6 +28,8 @@ items: href: "./deploy" - label: "Health Checks" href: "./health-checks" + - label: "CORS Support" + href: "./cors" - label: "Authorization" href: "./auth" - label: "Best Practices" diff --git a/docs/source/cors.mdx b/docs/source/cors.mdx new file mode 100644 index 00000000..ba6afb6a --- /dev/null +++ b/docs/source/cors.mdx @@ -0,0 +1,243 @@ +--- +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 + - https://studio.apollographql.com # Keep this so GraphOS Studio can run queries against your server + + # 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 + - https://studio.apollographql.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 + allow_headers: + - accept + - content-type + - mcp-protocol-version + - mcp-session-id + + # 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. + expose_headers: + - mcp-session-id + + # 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 + - https://studio.apollographql.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 + - https://studio.apollographql.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(); +``` From 3ca42fca66202aa69f75a90021970c5556d6be78 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Tue, 23 Sep 2025 10:25:06 -0400 Subject: [PATCH 41/58] feat: include W3C trace context headers --- crates/apollo-mcp-server/src/cors.rs | 20 ++++++++++++++++++-- crates/apollo-mcp-server/src/runtime.rs | 4 ++++ docs/source/_sidebar.yaml | 2 +- docs/source/config-file.mdx | 22 +++++++++++----------- docs/source/cors.mdx | 7 ++++++- 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/crates/apollo-mcp-server/src/cors.rs b/crates/apollo-mcp-server/src/cors.rs index 244e3fc8..ca04a5bb 100644 --- a/crates/apollo-mcp-server/src/cors.rs +++ b/crates/apollo-mcp-server/src/cors.rs @@ -52,9 +52,15 @@ impl Default for CorsConfig { "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 - max_age: Some(7200), // 2 hours + 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 } } } @@ -225,6 +231,16 @@ mod tests { "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)); diff --git a/crates/apollo-mcp-server/src/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index 02c987c5..1d8ca2b5 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -158,9 +158,13 @@ mod test { "content-type", "mcp-protocol-version", "mcp-session-id", + "traceparent", + "tracestate", ], expose_headers: [ "mcp-session-id", + "traceparent", + "tracestate", ], max_age: Some( 7200, diff --git a/docs/source/_sidebar.yaml b/docs/source/_sidebar.yaml index f1e99e61..08cc6729 100644 --- a/docs/source/_sidebar.yaml +++ b/docs/source/_sidebar.yaml @@ -28,7 +28,7 @@ items: href: "./deploy" - label: "Health Checks" href: "./health-checks" - - label: "CORS Support" + - label: "CORS" href: "./cors" - label: "Authorization" href: "./auth" diff --git a/docs/source/config-file.mdx b/docs/source/config-file.mdx index 791eef37..9ef0d2f1 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -45,17 +45,17 @@ These fields are under the top-level `graphos` key and define your GraphOS graph 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", "authorization"]` | List of allowed request headers | -| `expose_headers` | `List` | `[]` | List of response headers exposed to the browser (e.g., `["mcp-session-id"]`) | -| `max_age` | `number` | `86400` | Maximum age (in seconds) for preflight cache | +| 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 diff --git a/docs/source/cors.mdx b/docs/source/cors.mdx index ba6afb6a..f94af7f6 100644 --- a/docs/source/cors.mdx +++ b/docs/source/cors.mdx @@ -113,18 +113,23 @@ cors: - POST # The headers to allow. - # These are the default headers required for MCP protocol + # 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 From 0ccc2510ffc3fef012b733bc5ad8c4c800af9f5b Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Tue, 23 Sep 2025 11:47:53 -0400 Subject: [PATCH 42/58] feat: include DELETE method for CORS --- crates/apollo-mcp-server/src/cors.rs | 8 ++++++-- crates/apollo-mcp-server/src/runtime.rs | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/apollo-mcp-server/src/cors.rs b/crates/apollo-mcp-server/src/cors.rs index ca04a5bb..1d9d2bf1 100644 --- a/crates/apollo-mcp-server/src/cors.rs +++ b/crates/apollo-mcp-server/src/cors.rs @@ -47,7 +47,11 @@ impl Default for CorsConfig { match_origins: Vec::new(), allow_any_origin: false, allow_credentials: false, - allow_methods: vec!["GET".to_string(), "POST".to_string()], + 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 @@ -223,7 +227,7 @@ mod tests { assert!(!config.allow_credentials); assert_eq!( config.allow_methods, - vec!["GET".to_string(), "POST".to_string()] + vec!["GET".to_string(), "POST".to_string(), "DELETE".to_string()] ); assert_eq!( config.allow_headers, diff --git a/crates/apollo-mcp-server/src/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index 1d8ca2b5..22a2afec 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -153,6 +153,7 @@ mod test { allow_methods: [ "GET", "POST", + "DELETE", ], allow_headers: [ "content-type", From d41c5cd7b04e32e19f0a0303207b17c6ebf6bc8f Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Tue, 23 Sep 2025 14:34:04 -0400 Subject: [PATCH 43/58] docs: remove studio domain from example config --- docs/source/cors.mdx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/source/cors.mdx b/docs/source/cors.mdx index f94af7f6..e2aa9710 100644 --- a/docs/source/cors.mdx +++ b/docs/source/cors.mdx @@ -43,7 +43,6 @@ cors: # It does not have any path section, so no trailing slash. origins: - https://www.your-app.example.com - - https://studio.apollographql.com # Keep this so GraphOS Studio can run queries against your server # List of origin patterns (regex matching) match_origins: @@ -72,7 +71,6 @@ cors: enabled: true origins: - https://www.your-app.example.com - - https://studio.apollographql.com allow_credentials: true ``` @@ -150,7 +148,6 @@ cors: origins: - http://localhost:3000 - https://myapp.example.com - - https://studio.apollographql.com ``` ### Pattern matching @@ -189,7 +186,6 @@ cors: enabled: true origins: - https://myapp.example.com - - https://studio.apollographql.com allow_credentials: true max_age: 86400 # 24 hours ``` From f2c95bf5403dfb19579dc56256821f1c01277903 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Tue, 23 Sep 2025 15:38:36 -0400 Subject: [PATCH 44/58] feat: add the MDN link for CORS with credentials --- .vscode/launch.json | 157 ++++++++++++++------------- crates/apollo-mcp-server/src/cors.rs | 2 +- docs/source/cors.mdx | 22 ++-- 3 files changed, 96 insertions(+), 85 deletions(-) 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/crates/apollo-mcp-server/src/cors.rs b/crates/apollo-mcp-server/src/cors.rs index 1d9d2bf1..8b8facd9 100644 --- a/crates/apollo-mcp-server/src/cors.rs +++ b/crates/apollo-mcp-server/src/cors.rs @@ -181,7 +181,7 @@ impl CorsConfig { // 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" + "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(), )); } diff --git a/docs/source/cors.mdx b/docs/source/cors.mdx index e2aa9710..aeaea24b 100644 --- a/docs/source/cors.mdx +++ b/docs/source/cors.mdx @@ -2,7 +2,7 @@ title: Configuring CORS --- -# Configuring CORS +## Configuring CORS Control browser access to your MCP server @@ -58,7 +58,7 @@ cors: If your MCP server serves exclusively _non_-browser-based clients, you probably don't need to enable CORS configuration. -## Passing credentials +### 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. @@ -76,7 +76,7 @@ cors: **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 +### All `cors` options The following snippet shows all CORS configuration defaults for Apollo MCP Server: @@ -134,11 +134,11 @@ cors: max_age: 7200 # 2 hours ``` -## Origin matching +### Origin matching Apollo MCP Server supports two types of origin matching: -### Exact origins +#### Exact origins Use the `origins` array for exact origin matches: @@ -150,7 +150,7 @@ cors: - https://myapp.example.com ``` -### Pattern matching +#### Pattern matching Use the `match_origins` array for regex pattern matching: @@ -163,9 +163,9 @@ cors: - "^https://.*\\.example\\.com$" # Any subdomain of example.com ``` -## Common configurations +### Common configurations -### Development setup +#### Development setup For local development with hot reloading and various ports: @@ -177,7 +177,7 @@ cors: allow_credentials: true ``` -### Production setup +#### Production setup For production with specific known origins: @@ -190,7 +190,7 @@ cors: max_age: 86400 # 24 hours ``` -### Public API setup +#### Public API setup For public APIs that don't require credentials: @@ -201,7 +201,7 @@ cors: allow_credentials: false # Cannot use credentials with any origin ``` -## Browser integration example +### Browser integration example Here's a simple example of connecting to Apollo MCP Server from a browser: From 48fcdfa4267253c9abf24247337c9b45aec362cb Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Mon, 25 Aug 2025 10:01:04 -0500 Subject: [PATCH 45/58] Prototype OpenTelemetry Traces in MCP Server (#274) * Create a prototype of otel emitting traces to local jaegar instance * Add tracing annotations * Combine logging and tracing * copilot feedback * Add changeset * Instrument other tools * Clippy fixes * PR feedback * Change default env name to development Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> * Remove some extraneous extensions --------- Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- .changesets/feat_otel_prototype.md | 9 + Cargo.lock | 974 ++++++++++++------ crates/apollo-mcp-server/Cargo.toml | 18 +- crates/apollo-mcp-server/src/graphql.rs | 11 +- .../src/introspection/tools/introspect.rs | 3 +- .../src/introspection/tools/search.rs | 3 +- .../src/introspection/tools/validate.rs | 3 +- crates/apollo-mcp-server/src/main.rs | 5 +- crates/apollo-mcp-server/src/runtime.rs | 1 + .../apollo-mcp-server/src/runtime/logging.rs | 48 +- crates/apollo-mcp-server/src/runtime/trace.rs | 111 ++ .../src/server/states/running.rs | 6 + .../src/server/states/starting.rs | 18 +- 13 files changed, 856 insertions(+), 354 deletions(-) create mode 100644 .changesets/feat_otel_prototype.md create mode 100644 crates/apollo-mcp-server/src/runtime/trace.rs diff --git a/.changesets/feat_otel_prototype.md b/.changesets/feat_otel_prototype.md new file mode 100644 index 00000000..3a530264 --- /dev/null +++ b/.changesets/feat_otel_prototype.md @@ -0,0 +1,9 @@ +### 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. + diff --git a/Cargo.lock b/Cargo.lock index 56dc3b75..5475f63e 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", @@ -212,6 +208,7 @@ dependencies = [ "apollo-schema-index", "axum", "axum-extra", + "axum-tracing-opentelemetry", "bon", "chrono", "clap", @@ -226,20 +223,31 @@ dependencies = [ "jwks", "lz-str", "mockito", + "opentelemetry", + "opentelemetry-appender-log", + "opentelemetry-otlp", + "opentelemetry-resource-detectors", + "opentelemetry-semantic-conventions", + "opentelemetry-stdout", + "opentelemetry_sdk", "regex", "reqwest", + "reqwest-middleware", + "reqwest-tracing", "rmcp", "rstest", "schemars", "serde", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tokio-util", "tower", "tower-http", "tracing", "tracing-appender", + "tracing-core", + "tracing-opentelemetry", "tracing-subscriber", "tracing-test", "url", @@ -266,7 +274,7 @@ dependencies = [ "itertools", "rstest", "tantivy", - "thiserror 2.0.14", + "thiserror 2.0.16", "tracing", ] @@ -299,13 +307,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", ] @@ -318,7 +326,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -419,6 +427,24 @@ dependencies = [ "tower-service", ] +[[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" @@ -463,9 +489,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" @@ -487,9 +513,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", @@ -497,17 +523,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]] @@ -558,10 +584,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", @@ -575,30 +602,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", @@ -606,9 +632,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", @@ -618,14 +644,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]] @@ -659,6 +685,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" @@ -716,6 +759,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" @@ -792,81 +844,47 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08440b3dd222c3d0433e63e097463969485f112baff337dfdaca043a0d760570" -dependencies = [ - "darling_core 0.21.2", - "darling_macro 0.21.2", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "syn 2.0.105", + "darling_core", + "darling_macro", ] [[package]] name = "darling_core" -version = "0.21.2" +version = "0.21.3" 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", ] @@ -879,12 +897,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]] @@ -905,7 +923,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "unicode-xid", ] @@ -927,14 +945,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" @@ -951,7 +969,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -975,6 +993,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" @@ -992,28 +1019,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]] @@ -1024,19 +1051,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", @@ -1071,6 +1098,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" @@ -1110,6 +1143,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" @@ -1127,9 +1166,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", ] @@ -1220,7 +1259,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -1291,7 +1330,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -1410,7 +1449,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]] @@ -1509,9 +1559,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" @@ -1525,13 +1575,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", @@ -1539,6 +1590,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1562,9 +1614,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", @@ -1595,9 +1647,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", @@ -1711,9 +1763,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", @@ -1732,13 +1784,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]] @@ -1753,7 +1806,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", ] @@ -1769,9 +1822,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", @@ -1785,11 +1838,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", ] @@ -1844,9 +1897,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", @@ -1854,9 +1907,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -1890,7 +1943,7 @@ dependencies = [ "idna", "itoa", "num-cmp", - "num-traits", + "num-traits 0.2.19", "once_cell", "percent-encoding", "referencing", @@ -1971,9 +2024,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" @@ -1995,9 +2048,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" @@ -2017,9 +2070,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" @@ -2074,9 +2127,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", ] @@ -2203,7 +2256,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", @@ -2241,7 +2294,7 @@ dependencies = [ "num-integer", "num-iter", "num-rational", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2251,7 +2304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2266,7 +2319,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]] @@ -2281,7 +2334,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]] @@ -2292,7 +2345,7 @@ checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", - "num-traits", + "num-traits 0.2.19", ] [[package]] @@ -2303,7 +2356,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]] @@ -2368,7 +2430,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", @@ -2385,7 +2447,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2416,6 +2478,116 @@ 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", + "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", +] + [[package]] name = "outref" version = "0.5.2" @@ -2480,7 +2652,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2495,26 +2667,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", @@ -2522,22 +2694,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", @@ -2573,7 +2745,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2596,9 +2768,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", ] @@ -2620,28 +2792,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", ] @@ -2654,11 +2826,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" @@ -2739,7 +2934,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", ] @@ -2769,7 +2964,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]] @@ -2789,7 +2984,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -2808,9 +3003,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", @@ -2820,9 +3015,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", @@ -2831,9 +3026,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" @@ -2881,6 +3076,37 @@ 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", + "reqwest", + "reqwest-middleware", + "tracing", +] + [[package]] name = "ring" version = "0.17.14" @@ -2917,7 +3143,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.14", + "thiserror 2.0.16", "tokio", "tokio-stream", "tokio-util", @@ -2932,11 +3158,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]] @@ -2977,7 +3203,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.105", + "syn 2.0.106", "unicode-ident", ] @@ -3024,7 +3250,7 @@ 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", @@ -3033,15 +3259,15 @@ dependencies = [ [[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]] @@ -3076,11 +3302,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]] @@ -3107,7 +3333,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3132,7 +3358,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", @@ -3141,9 +3367,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", @@ -3151,28 +3377,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]] @@ -3183,20 +3419,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]] @@ -3216,12 +3453,13 @@ 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]] @@ -3273,9 +3511,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", @@ -3320,8 +3558,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", ] @@ -3396,7 +3634,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3412,9 +3650,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", @@ -3438,7 +3676,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3487,7 +3725,7 @@ dependencies = [ "tantivy-stacker", "tantivy-tokenizer-api", "tempfile", - "thiserror 2.0.14", + "thiserror 2.0.16", "time", "uuid", "winapi", @@ -3589,15 +3827,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.59.0", + "rustix 1.1.2", + "windows-sys 0.61.0", ] [[package]] @@ -3618,7 +3856,7 @@ checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3638,11 +3876,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]] @@ -3653,18 +3891,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]] @@ -3678,9 +3916,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", @@ -3695,15 +3933,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", @@ -3747,7 +3985,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3786,21 +4024,55 @@ dependencies = [ [[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 = "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", + "percent-encoding", + "pin-project", + "prost", + "tokio-stream", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3823,7 +4095,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", @@ -3833,6 +4105,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3879,7 +4152,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -3915,6 +4188,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" @@ -3964,7 +4267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4012,9 +4315,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" @@ -4048,9 +4351,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", @@ -4078,9 +4381,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", @@ -4150,44 +4453,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.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" 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.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" dependencies = [ "cfg-if", "js-sys", @@ -4198,9 +4511,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4208,31 +4521,41 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" 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.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +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", @@ -4256,11 +4579,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.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -4271,13 +4594,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", ] @@ -4290,7 +4613,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4301,7 +4624,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4310,22 +4633,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]] @@ -4364,6 +4693,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" @@ -4401,7 +4739,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", @@ -4552,21 +4890,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", @@ -4584,13 +4921,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" @@ -4624,28 +4958,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]] @@ -4665,7 +4999,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", "synstructure", ] @@ -4705,7 +5039,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.105", + "syn 2.0.106", ] [[package]] @@ -4728,9 +5062,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/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index cb5903dc..c484b61f 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -17,6 +17,7 @@ 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-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 +29,16 @@ 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 = "0.30.0" +opentelemetry-resource-detectors = "0.9.0" +opentelemetry-semantic-conventions = "0.30.0" +opentelemetry-stdout = "0.30.0" +opentelemetry_sdk = "0.30.0" regex = "1.11.1" +reqwest-middleware = "0.4.2" +reqwest-tracing = "0.5.8" reqwest.workspace = true rmcp = { version = "0.6", features = [ "server", @@ -41,11 +51,13 @@ 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 [dev-dependencies] diff --git a/crates/apollo-mcp-server/src/graphql.rs b/crates/apollo-mcp-server/src/graphql.rs index d47e61e2..519aae58 100644 --- a/crates/apollo-mcp-server/src/graphql.rs +++ b/crates/apollo-mcp-server/src/graphql.rs @@ -2,10 +2,13 @@ use crate::errors::McpError; 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,6 +36,7 @@ pub trait Executable { fn headers(&self, default_headers: &HeaderMap) -> HeaderMap; /// Execute as a GraphQL operation using the endpoint and headers + #[tracing::instrument(skip(self))] async fn execute(&self, request: Request<'_>) -> Result { let client_metadata = serde_json::json!({ "name": "mcp", @@ -74,7 +78,12 @@ pub trait Executable { } } - reqwest::Client::new() + let client = ClientBuilder::new(reqwest::Client::new()) + .with_init(Extension(OtelName("mcp-graphql-client".into()))) + .with(TracingMiddleware::default()) + .build(); + + client .post(request.endpoint.as_str()) .headers(self.headers(&request.headers)) .body(Value::Object(request_body).to_string()) diff --git a/crates/apollo-mcp-server/src/introspection/tools/introspect.rs b/crates/apollo-mcp-server/src/introspection/tools/introspect.rs index 7d92996e..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(); diff --git a/crates/apollo-mcp-server/src/introspection/tools/search.rs b/crates/apollo-mcp-server/src/introspection/tools/search.rs index 29816bcc..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, @@ -87,6 +87,7 @@ impl Search { }) } + #[tracing::instrument(skip(self))] pub async fn execute(&self, input: Input) -> Result { let mut root_paths = self .index 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/main.rs b/crates/apollo-mcp-server/src/main.rs index 9c6b0341..551d64b1 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::trace::init_tracing_subscriber(&config)?; info!( "Apollo MCP Server v{} // (c) Apollo Graph, Inc. // Licensed under MIT", diff --git a/crates/apollo-mcp-server/src/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index 22a2afec..b96339df 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -12,6 +12,7 @@ mod operation_source; mod overrides; mod schema_source; mod schemas; +pub mod trace; use std::path::Path; 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/trace.rs b/crates/apollo-mcp-server/src/runtime/trace.rs new file mode 100644 index 00000000..b293b816 --- /dev/null +++ b/crates/apollo-mcp-server/src/runtime/trace.rs @@ -0,0 +1,111 @@ +use opentelemetry::{KeyValue, global, trace::TracerProvider as _}; +use opentelemetry_sdk::{ + Resource, + metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider}, + trace::{RandomIdGenerator, SdkTracerProvider}, +}; +use opentelemetry_semantic_conventions::{ + SCHEMA_URL, + attribute::{DEPLOYMENT_ENVIRONMENT_NAME, SERVICE_VERSION}, +}; +use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use super::{Config, logging::Logging}; + +/// Create a Resource that captures information about the entity for which telemetry is recorded. +fn resource() -> Resource { + Resource::builder() + .with_service_name(env!("CARGO_PKG_NAME")) + .with_schema_url( + [ + KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION")), + KeyValue::new( + DEPLOYMENT_ENVIRONMENT_NAME, + std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()), + ), + ], + SCHEMA_URL, + ) + .build() +} + +/// Construct MeterProvider for MetricsLayer +fn init_meter_provider() -> Result { + let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_temporality(opentelemetry_sdk::metrics::Temporality::default()) + .build()?; + + let reader = PeriodicReader::builder(exporter) + .with_interval(std::time::Duration::from_secs(30)) + .build(); + + let meter_provider = MeterProviderBuilder::default() + .with_resource(resource()) + .with_reader(reader) + .build(); + + global::set_meter_provider(meter_provider.clone()); + + Ok(meter_provider) +} + +/// Construct TracerProvider for OpenTelemetryLayer +fn init_tracer_provider() -> Result { + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_http() + .build()?; + + let trace_provider = SdkTracerProvider::builder() + // TODO: Should this use session information to group spans to a request? + .with_id_generator(RandomIdGenerator::default()) + .with_resource(resource()) + .with_batch_exporter(exporter) + .build(); + + global::set_tracer_provider(trace_provider.clone()); + + Ok(trace_provider) +} + +/// Initialize tracing-subscriber and return OtelGuard for opentelemetry-related termination processing +pub fn init_tracing_subscriber(config: &Config) -> Result { + let tracer_provider = init_tracer_provider()?; + let meter_provider = init_meter_provider()?; + let env_filter = Logging::env_filter(&config.logging)?; + let (logging_layer, logging_guard) = Logging::logging_layer(&config.logging)?; + + let tracer = tracer_provider.tracer("tracing-otel-subscriber"); + + tracing_subscriber::registry() + .with(logging_layer) + .with(env_filter) + .with(MetricsLayer::new(meter_provider.clone())) + .with(OpenTelemetryLayer::new(tracer)) + .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()); + } +} diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index b1cc4e90..064c6c46 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::Context; +use opentelemetry::trace::FutureExt; use reqwest::header::HeaderMap; use rmcp::model::Implementation; use rmcp::{ @@ -170,6 +172,7 @@ impl Running { } impl ServerHandler for Running { + #[tracing::instrument(skip(self))] async fn initialize( &self, _request: InitializeRequestParam, @@ -181,6 +184,7 @@ impl ServerHandler for Running { Ok(self.get_info()) } + #[tracing::instrument(skip(self, context), fields(tool_name = request.name.as_ref(), request_id = %context.id.clone()))] async fn call_tool( &self, request: CallToolRequestParam, @@ -269,6 +273,7 @@ impl ServerHandler for Running { .find(|op| op.as_ref().name == request.name) .ok_or(tool_not_found(&request.name))? .execute(graphql_request) + .with_context(Context::current()) .await } }; @@ -281,6 +286,7 @@ impl ServerHandler for Running { result } + #[tracing::instrument(skip_all)] async fn list_tools( &self, _request: Option, diff --git a/crates/apollo-mcp-server/src/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index 88457ac7..de331a99 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -2,6 +2,7 @@ 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 axum_tracing_opentelemetry::middleware::{OtelAxumLayer, OtelInResponseLayer}; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService}; use rmcp::{ @@ -11,6 +12,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::{ @@ -205,7 +207,21 @@ impl Starting { let mut router = with_cors!( with_auth!(axum::Router::new().nest_service("/mcp", service), auth), self.config.cors - ); + ) + // 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!( + "http_request", + method = %request.method(), + uri = %request.uri(), + ) + }, + )); // Add health check endpoint if configured if let Some(health_check) = health_check.filter(|h| h.config().enabled) { From c1cfddfea4619da9dc1dc187e436bc53ed2dbb9f Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Thu, 28 Aug 2025 12:26:55 -0500 Subject: [PATCH 46/58] Implement metrics for mcp tool and operation counts and durations (#297) * Implement metrics for mcp tool and operation counts and durations * Changeset * Unit test attribute setting in graphql.rs * Add axum_otel_metrics for emitting basic http metrics about requests * Lazy load singleton Meter for metrics * Alphabetize * Simplify result.is_error checking --- .changesets/feat_otel_metrics.md | 11 ++ Cargo.lock | 20 +++ crates/apollo-mcp-server/Cargo.toml | 2 + crates/apollo-mcp-server/src/graphql.rs | 116 +++++++++++++++++- crates/apollo-mcp-server/src/lib.rs | 1 + crates/apollo-mcp-server/src/meter.rs | 8 ++ crates/apollo-mcp-server/src/runtime/trace.rs | 1 + .../src/server/states/running.rs | 54 ++++++-- .../src/server/states/starting.rs | 2 + 9 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 .changesets/feat_otel_metrics.md create mode 100644 crates/apollo-mcp-server/src/meter.rs diff --git a/.changesets/feat_otel_metrics.md b/.changesets/feat_otel_metrics.md new file mode 100644 index 00000000..8d553052 --- /dev/null +++ b/.changesets/feat_otel_metrics.md @@ -0,0 +1,11 @@ +### 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 diff --git a/Cargo.lock b/Cargo.lock index 5475f63e..effe277e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,7 @@ dependencies = [ "apollo-schema-index", "axum", "axum-extra", + "axum-otel-metrics", "axum-tracing-opentelemetry", "bon", "chrono", @@ -427,6 +428,23 @@ 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" @@ -2586,6 +2604,8 @@ dependencies = [ "rand 0.9.2", "serde_json", "thiserror 2.0.16", + "tokio", + "tokio-stream", ] [[package]] diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index c484b61f..a76d9d44 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -17,6 +17,7 @@ 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"] } @@ -65,6 +66,7 @@ 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" diff --git a/crates/apollo-mcp-server/src/graphql.rs b/crates/apollo-mcp-server/src/graphql.rs index 519aae58..cbb8a5dc 100644 --- a/crates/apollo-mcp-server/src/graphql.rs +++ b/crates/apollo-mcp-server/src/graphql.rs @@ -1,6 +1,7 @@ //! Execute GraphQL operations from an MCP tool -use crate::errors::McpError; +use crate::{errors::McpError, meter::get_meter}; +use opentelemetry::KeyValue; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest_middleware::{ClientBuilder, Extension}; use reqwest_tracing::{OtelName, TracingMiddleware}; @@ -38,6 +39,9 @@ pub trait Executable { /// Execute as a GraphQL operation using the endpoint and headers #[tracing::instrument(skip(self))] async fn execute(&self, request: Request<'_>) -> Result { + let meter = get_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") @@ -59,6 +63,7 @@ pub trait Executable { "clientLibrary": client_metadata, }), ); + op_id = Some(id.to_string()); } else { let OperationDetails { query, @@ -74,6 +79,7 @@ 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)); } } @@ -83,7 +89,7 @@ pub trait Executable { .with(TracingMiddleware::default()) .build(); - client + let result = client .post(request.endpoint.as_str()) .headers(self.headers(&request.headers)) .body(Value::Object(request_body).to_string()) @@ -118,7 +124,34 @@ pub trait Executable { ), meta: None, structured_content: Some(json), - }) + }); + + // Record response metrics + let attributes = vec![ + KeyValue::new( + "success", + result.as_ref().is_ok_and(|r| r.is_error != Some(true)), + ), + KeyValue::new("operation.id", op_id.unwrap_or("unknown".to_string())), + KeyValue::new( + "operation.type", + if self.persisted_query_id().is_some() { + "persisted_query" + } else { + "operation" + }, + ), + ]; + meter + .f64_histogram("apollo.mcp.operation.duration") + .build() + .record(start.elapsed().as_millis() as f64, &attributes); + meter + .u64_counter("apollo.mcp.operation.count") + .build() + .add(1, &attributes); + + result } } @@ -127,6 +160,11 @@ mod test { use crate::errors::McpError; 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; @@ -366,4 +404,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() == "apollo.mcp.operation.count" { + if 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/lib.rs b/crates/apollo-mcp-server/src/lib.rs index 5552e193..abe4bbc2 100644 --- a/crates/apollo-mcp-server/src/lib.rs +++ b/crates/apollo-mcp-server/src/lib.rs @@ -8,6 +8,7 @@ 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; diff --git a/crates/apollo-mcp-server/src/meter.rs b/crates/apollo-mcp-server/src/meter.rs new file mode 100644 index 00000000..07204721 --- /dev/null +++ b/crates/apollo-mcp-server/src/meter.rs @@ -0,0 +1,8 @@ +use opentelemetry::{global, metrics::Meter}; +use std::sync::OnceLock; + +static METER: OnceLock = OnceLock::new(); + +pub fn get_meter() -> &'static Meter { + METER.get_or_init(|| global::meter(env!("CARGO_PKG_NAME"))) +} diff --git a/crates/apollo-mcp-server/src/runtime/trace.rs b/crates/apollo-mcp-server/src/runtime/trace.rs index b293b816..1bf56420 100644 --- a/crates/apollo-mcp-server/src/runtime/trace.rs +++ b/crates/apollo-mcp-server/src/runtime/trace.rs @@ -4,6 +4,7 @@ use opentelemetry_sdk::{ metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider}, trace::{RandomIdGenerator, SdkTracerProvider}, }; + use opentelemetry_semantic_conventions::{ SCHEMA_URL, attribute::{DEPLOYMENT_ENVIRONMENT_NAME, SERVICE_VERSION}, diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index 064c6c46..b7791b55 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use apollo_compiler::{Schema, validation::Valid}; use headers::HeaderMapExt as _; -use opentelemetry::Context; use opentelemetry::trace::FutureExt; +use opentelemetry::{Context, KeyValue}; use reqwest::header::HeaderMap; use rmcp::model::Implementation; use rmcp::{ @@ -34,6 +34,7 @@ use crate::{ search::{SEARCH_TOOL_NAME, Search}, validate::{VALIDATE_TOOL_NAME, Validate}, }, + meter::get_meter, operations::{MutationMode, Operation, RawOperation}, }; @@ -178,6 +179,11 @@ impl ServerHandler for Running { _request: InitializeRequestParam, context: RequestContext, ) -> Result { + let meter = get_meter(); + meter + .u64_counter("apollo.mcp.initialize.count") + .build() + .add(1, &[]); // TODO: how to remove these? let mut peers = self.peers.write().await; peers.push(context.peer); @@ -190,25 +196,28 @@ impl ServerHandler for Running { request: CallToolRequestParam, context: RequestContext, ) -> Result { - let result = match request.name.as_ref() { + let meter = get_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 } @@ -230,7 +239,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, @@ -241,7 +250,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 } @@ -270,8 +279,8 @@ 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 @@ -283,6 +292,23 @@ impl ServerHandler for Running { health_check.record_rejection(); } + let attributes = vec![ + KeyValue::new( + "success", + result.as_ref().is_ok_and(|r| r.is_error != Some(true)), + ), + KeyValue::new("tool_name", tool_name), + ]; + // Record response time and status + meter + .f64_histogram("apollo.mcp.tool.duration") + .build() + .record(start.elapsed().as_millis() as f64, &attributes); + meter + .u64_counter("apollo.mcp.tool.count") + .build() + .add(1, &attributes); + result } @@ -292,6 +318,11 @@ impl ServerHandler for Running { _request: Option, _context: RequestContext, ) -> Result { + let meter = get_meter(); + meter + .u64_counter("apollo.mcp.list_tools.count") + .build() + .add(1, &[]); Ok(ListToolsResult { next_cursor: None, tools: self @@ -310,6 +341,11 @@ impl ServerHandler for Running { } fn get_info(&self) -> ServerInfo { + let meter = get_meter(); + meter + .u64_counter("apollo.mcp.get_info.count") + .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 de331a99..3429c17f 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -2,6 +2,7 @@ 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 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}; @@ -208,6 +209,7 @@ impl Starting { 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 From b2759b76d540657dc8f48d0e9771e6cabed86363 Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Thu, 28 Aug 2025 14:05:35 -0500 Subject: [PATCH 47/58] fix: Include the cargo feature and TraceContextPropagator to send otel headers downstream (#307) * Fix sending OTLP trace headers downstream to GQL API * Changeset --- .changesets/fix_telemetry_fix_downstream_traces.md | 3 +++ Cargo.lock | 2 ++ crates/apollo-mcp-server/Cargo.toml | 2 +- crates/apollo-mcp-server/src/runtime/trace.rs | 3 +++ 4 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changesets/fix_telemetry_fix_downstream_traces.md diff --git a/.changesets/fix_telemetry_fix_downstream_traces.md b/.changesets/fix_telemetry_fix_downstream_traces.md new file mode 100644 index 00000000..d5a8febf --- /dev/null +++ b/.changesets/fix_telemetry_fix_downstream_traces.md @@ -0,0 +1,3 @@ +### fix: 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 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index effe277e..cbf7ab8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3122,9 +3122,11 @@ dependencies = [ "getrandom 0.2.16", "http", "matchit", + "opentelemetry", "reqwest", "reqwest-middleware", "tracing", + "tracing-opentelemetry", ] [[package]] diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index a76d9d44..75e1a771 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -39,7 +39,7 @@ opentelemetry-stdout = "0.30.0" opentelemetry_sdk = "0.30.0" regex = "1.11.1" reqwest-middleware = "0.4.2" -reqwest-tracing = "0.5.8" +reqwest-tracing = { version = "0.5.8", features = ["opentelemetry_0_30"] } reqwest.workspace = true rmcp = { version = "0.6", features = [ "server", diff --git a/crates/apollo-mcp-server/src/runtime/trace.rs b/crates/apollo-mcp-server/src/runtime/trace.rs index 1bf56420..0e50ab64 100644 --- a/crates/apollo-mcp-server/src/runtime/trace.rs +++ b/crates/apollo-mcp-server/src/runtime/trace.rs @@ -2,6 +2,7 @@ use opentelemetry::{KeyValue, global, trace::TracerProvider as _}; use opentelemetry_sdk::{ Resource, metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider}, + propagation::TraceContextPropagator, trace::{RandomIdGenerator, SdkTracerProvider}, }; @@ -65,6 +66,8 @@ fn init_tracer_provider() -> Result { .with_batch_exporter(exporter) .build(); + // Set the global propagator (usually early in main()) + global::set_text_map_propagator(TraceContextPropagator::new()); global::set_tracer_provider(trace_provider.clone()); Ok(trace_provider) From fcdab35860dc5d7dc8f2394bd22b5d4dd1b843ff Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Mon, 8 Sep 2025 12:24:02 -0500 Subject: [PATCH 48/58] Add basic config file options to otel telemetry (#330) * Add basic config file options to otel telemetry * Happy path unit test for telemetry config * Changeset * Taplo format * Refactor to add a couple more tests * Update unit test for clippy after rust upgrade * Rename unit tests --- .changesets/config_telemetry_config.md | 7 + Cargo.lock | 23 ++ crates/apollo-mcp-server/Cargo.toml | 8 +- crates/apollo-mcp-server/src/graphql.rs | 40 +-- crates/apollo-mcp-server/src/main.rs | 2 +- crates/apollo-mcp-server/src/runtime.rs | 7 +- .../apollo-mcp-server/src/runtime/config.rs | 5 +- .../src/runtime/telemetry.rs | 314 ++++++++++++++++++ crates/apollo-mcp-server/src/runtime/trace.rs | 115 ------- 9 files changed, 382 insertions(+), 139 deletions(-) create mode 100644 .changesets/config_telemetry_config.md create mode 100644 crates/apollo-mcp-server/src/runtime/telemetry.rs delete mode 100644 crates/apollo-mcp-server/src/runtime/trace.rs diff --git a/.changesets/config_telemetry_config.md b/.changesets/config_telemetry_config.md new file mode 100644 index 00000000..2f1132cc --- /dev/null +++ b/.changesets/config_telemetry_config.md @@ -0,0 +1,7 @@ +### 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 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index cbf7ab8a..f41ded1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1614,6 +1614,19 @@ dependencies = [ "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" @@ -2547,6 +2560,8 @@ dependencies = [ "prost", "reqwest", "thiserror 2.0.16", + "tokio", + "tonic", "tracing", ] @@ -4086,10 +4101,15 @@ dependencies = [ "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", @@ -4103,9 +4123,12 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index 75e1a771..ae5f0ebe 100644 --- a/crates/apollo-mcp-server/Cargo.toml +++ b/crates/apollo-mcp-server/Cargo.toml @@ -32,7 +32,13 @@ jwks = "0.4.0" lz-str = "0.2.1" opentelemetry = "0.30.0" opentelemetry-appender-log = "0.30.0" -opentelemetry-otlp = "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" diff --git a/crates/apollo-mcp-server/src/graphql.rs b/crates/apollo-mcp-server/src/graphql.rs index cbb8a5dc..5a706b28 100644 --- a/crates/apollo-mcp-server/src/graphql.rs +++ b/crates/apollo-mcp-server/src/graphql.rs @@ -449,27 +449,27 @@ mod test { .find(|scope_metrics| scope_metrics.scope().name() == "apollo.mcp") { for metric in scope_metrics.metrics() { - if metric.name() == "apollo.mcp.operation.count" { - if 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")) - ); + if metric.name() == "apollo.mcp.operation.count" + && 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/main.rs b/crates/apollo-mcp-server/src/main.rs index 551d64b1..0d80e937 100644 --- a/crates/apollo-mcp-server/src/main.rs +++ b/crates/apollo-mcp-server/src/main.rs @@ -41,7 +41,7 @@ async fn main() -> anyhow::Result<()> { None => runtime::read_config_from_env().unwrap_or_default(), }; - let _guard = runtime::trace::init_tracing_subscriber(&config)?; + let _guard = runtime::telemetry::init_tracing_subscriber(&config)?; info!( "Apollo MCP Server v{} // (c) Apollo Graph, Inc. // Licensed under MIT", diff --git a/crates/apollo-mcp-server/src/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index b96339df..567b0e11 100644 --- a/crates/apollo-mcp-server/src/runtime.rs +++ b/crates/apollo-mcp-server/src/runtime.rs @@ -12,7 +12,7 @@ mod operation_source; mod overrides; mod schema_source; mod schemas; -pub mod trace; +pub mod telemetry; use std::path::Path; @@ -270,6 +270,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 991a21a5..598462bd 100644 --- a/crates/apollo-mcp-server/src/runtime/config.rs +++ b/crates/apollo-mcp-server/src/runtime/config.rs @@ -8,7 +8,7 @@ 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 @@ -43,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/telemetry.rs b/crates/apollo-mcp-server/src/runtime/telemetry.rs new file mode 100644 index 00000000..ce518883 --- /dev/null +++ b/crates/apollo-mcp-server/src/runtime/telemetry.rs @@ -0,0 +1,314 @@ +use opentelemetry::{KeyValue, global, trace::TracerProvider as _}; +use opentelemetry_otlp::WithExportConfig; +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 tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::runtime::Config; +use crate::runtime::logging::Logging; + +/// 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, +} + +#[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, +} + +#[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 otlp = telemetry + .exporters + .as_ref() + .and_then(|exporters| exporters.metrics.as_ref()) + .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 reader = PeriodicReader::builder(exporter) + .with_interval(std::time::Duration::from_secs(30)) + .build(); + + let meter_provider = MeterProviderBuilder::default() + .with_resource(resource(telemetry)) + .with_reader(reader) + .build(); + + Ok(meter_provider) +} + +fn init_tracer_provider(telemetry: &Telemetry) -> Result { + let otlp = telemetry + .exporters + .as_ref() + .and_then(|exporters| exporters.tracing.as_ref()) + .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 tracer_provider = SdkTracerProvider::builder() + .with_id_generator(RandomIdGenerator::default()) + .with_resource(resource(telemetry)) + .with_batch_exporter(exporter) + .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_configued() { + let config = test_config( + Some("test-config"), + Some("1.0.0"), + Some(MetricsExporters { + otlp: Some(OTLPMetricExporter::default()), + }), + Some(TracingExporters { + otlp: Some(OTLPTracingExporter::default()), + }), + ); + // 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(), + }), + }), + 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 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(), + }), + }), + ); + let result = init_tracer_provider(&config.telemetry); + assert!( + result + .err() + .map(|e| e.to_string().contains("Unsupported OTLP protocol")) + .unwrap_or(false) + ); + } +} diff --git a/crates/apollo-mcp-server/src/runtime/trace.rs b/crates/apollo-mcp-server/src/runtime/trace.rs deleted file mode 100644 index 0e50ab64..00000000 --- a/crates/apollo-mcp-server/src/runtime/trace.rs +++ /dev/null @@ -1,115 +0,0 @@ -use opentelemetry::{KeyValue, global, trace::TracerProvider as _}; -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 tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -use super::{Config, logging::Logging}; - -/// Create a Resource that captures information about the entity for which telemetry is recorded. -fn resource() -> Resource { - Resource::builder() - .with_service_name(env!("CARGO_PKG_NAME")) - .with_schema_url( - [ - KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION")), - KeyValue::new( - DEPLOYMENT_ENVIRONMENT_NAME, - std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()), - ), - ], - SCHEMA_URL, - ) - .build() -} - -/// Construct MeterProvider for MetricsLayer -fn init_meter_provider() -> Result { - let exporter = opentelemetry_otlp::MetricExporter::builder() - .with_http() - .with_temporality(opentelemetry_sdk::metrics::Temporality::default()) - .build()?; - - let reader = PeriodicReader::builder(exporter) - .with_interval(std::time::Duration::from_secs(30)) - .build(); - - let meter_provider = MeterProviderBuilder::default() - .with_resource(resource()) - .with_reader(reader) - .build(); - - global::set_meter_provider(meter_provider.clone()); - - Ok(meter_provider) -} - -/// Construct TracerProvider for OpenTelemetryLayer -fn init_tracer_provider() -> Result { - let exporter = opentelemetry_otlp::SpanExporter::builder() - .with_http() - .build()?; - - let trace_provider = SdkTracerProvider::builder() - // TODO: Should this use session information to group spans to a request? - .with_id_generator(RandomIdGenerator::default()) - .with_resource(resource()) - .with_batch_exporter(exporter) - .build(); - - // Set the global propagator (usually early in main()) - global::set_text_map_propagator(TraceContextPropagator::new()); - global::set_tracer_provider(trace_provider.clone()); - - Ok(trace_provider) -} - -/// Initialize tracing-subscriber and return OtelGuard for opentelemetry-related termination processing -pub fn init_tracing_subscriber(config: &Config) -> Result { - let tracer_provider = init_tracer_provider()?; - let meter_provider = init_meter_provider()?; - let env_filter = Logging::env_filter(&config.logging)?; - let (logging_layer, logging_guard) = Logging::logging_layer(&config.logging)?; - - let tracer = tracer_provider.tracer("tracing-otel-subscriber"); - - tracing_subscriber::registry() - .with(logging_layer) - .with(env_filter) - .with(MetricsLayer::new(meter_provider.clone())) - .with(OpenTelemetryLayer::new(tracer)) - .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()); - } -} From 8f1be84b402fad481250fb5821be8a4cd641eed2 Mon Sep 17 00:00:00 2001 From: Armando Locay Date: Tue, 16 Sep 2025 11:51:06 -0400 Subject: [PATCH 49/58] feat: adding ability to omit attributes for traces and metrics (#358) * feat: adding ability to omit attributes for traces and metrics * chore: adding changeset * chore: updating changeset text * chore: removing unused file * chore: renaming exporter file * chore: renaming module * re-running checks * chore: auto-gen the as_str function using enums instead of constants * chore: updating changeset entry and fixing fields in instruemnt attribute * chore: adding description as a doc string * chore: updating changeset entry * chore: updating operation attribute descriptions * chore: updating operation_type attribute to operation_source * chore: skipping request from instrumentation * chore: fixing clippy issues * re-running nix build * updating unit test snapshot * chore: fixing toml formatting issues * test: removing asserts on constants * chore: format issues * test: adjusting unit test to not use local envar * revert: undoing accidental commit * test: removing unnecessary assert --- ...at_allow_attribute_configuration_alocay.md | 70 ++++++ Cargo.lock | 78 ++++-- crates/apollo-mcp-server/Cargo.toml | 14 +- crates/apollo-mcp-server/build.rs | 173 +++++++++++++ crates/apollo-mcp-server/src/graphql.rs | 16 +- crates/apollo-mcp-server/src/lib.rs | 7 + crates/apollo-mcp-server/src/runtime.rs | 6 + .../src/runtime/filtering_exporter.rs | 233 ++++++++++++++++++ .../src/runtime/telemetry.rs | 66 ++++- .../src/server/states/running.rs | 19 +- .../src/telemetry_attributes.rs | 33 +++ crates/apollo-mcp-server/telemetry.toml | 15 ++ 12 files changed, 687 insertions(+), 43 deletions(-) create mode 100644 .changesets/feat_allow_attribute_configuration_alocay.md create mode 100644 crates/apollo-mcp-server/build.rs create mode 100644 crates/apollo-mcp-server/src/runtime/filtering_exporter.rs create mode 100644 crates/apollo-mcp-server/src/telemetry_attributes.rs create mode 100644 crates/apollo-mcp-server/telemetry.toml diff --git a/.changesets/feat_allow_attribute_configuration_alocay.md b/.changesets/feat_allow_attribute_configuration_alocay.md new file mode 100644 index 00000000..29ddfdba --- /dev/null +++ b/.changesets/feat_allow_attribute_configuration_alocay.md @@ -0,0 +1,70 @@ +### feat: 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 +``` diff --git a/Cargo.lock b/Cargo.lock index f41ded1f..9773eb4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,7 @@ dependencies = [ "apollo-federation", "apollo-mcp-registry", "apollo-schema-index", + "async-trait", "axum", "axum-extra", "axum-otel-metrics", @@ -213,6 +214,7 @@ dependencies = [ "bon", "chrono", "clap", + "cruet", "figment", "futures", "headers", @@ -231,6 +233,8 @@ dependencies = [ "opentelemetry-semantic-conventions", "opentelemetry-stdout", "opentelemetry_sdk", + "prettyplease", + "quote", "regex", "reqwest", "reqwest-middleware", @@ -240,9 +244,11 @@ dependencies = [ "schemars", "serde", "serde_json", + "syn 2.0.106", "thiserror 2.0.16", "tokio", "tokio-util", + "toml", "tower", "tower-http", "tracing", @@ -844,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" @@ -1938,9 +1954,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -3499,6 +3515,15 @@ dependencies = [ "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]] name = "serde_urlencoded" version = "0.7.1" @@ -4059,6 +4084,21 @@ 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.7.2" @@ -4089,6 +4129,12 @@ 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" @@ -4516,9 +4562,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", @@ -4529,9 +4575,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", @@ -4543,9 +4589,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.53" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -4556,9 +4602,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4566,9 +4612,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", @@ -4579,18 +4625,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.80" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/crates/apollo-mcp-server/Cargo.toml b/crates/apollo-mcp-server/Cargo.toml index ae5f0ebe..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" @@ -42,7 +43,9 @@ opentelemetry-otlp = { version = "0.30.0", features = [ opentelemetry-resource-detectors = "0.9.0" opentelemetry-semantic-conventions = "0.30.0" opentelemetry-stdout = "0.30.0" -opentelemetry_sdk = "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"] } @@ -66,6 +69,7 @@ tracing-opentelemetry = "0.31.0" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing.workspace = true url.workspace = true +async-trait = "0.1.89" [dev-dependencies] chrono = { version = "0.4.41", default-features = false, features = ["now"] } @@ -78,6 +82,14 @@ 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..e8b5d44a --- /dev/null +++ b/crates/apollo-mcp-server/build.rs @@ -0,0 +1,173 @@ +#![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); + println!( + "a {:?} | m {:?}", + telemetry_attribute_data, telemetry_metrics_data + ); + + // 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/graphql.rs b/crates/apollo-mcp-server/src/graphql.rs index 5a706b28..72af139d 100644 --- a/crates/apollo-mcp-server/src/graphql.rs +++ b/crates/apollo-mcp-server/src/graphql.rs @@ -1,5 +1,6 @@ //! Execute GraphQL operations from an MCP tool +use crate::generated::telemetry::{TelemetryAttribute, TelemetryMetric}; use crate::{errors::McpError, meter::get_meter}; use opentelemetry::KeyValue; use reqwest::header::{HeaderMap, HeaderValue}; @@ -37,7 +38,7 @@ pub trait Executable { fn headers(&self, default_headers: &HeaderMap) -> HeaderMap; /// Execute as a GraphQL operation using the endpoint and headers - #[tracing::instrument(skip(self))] + #[tracing::instrument(skip(self, request))] async fn execute(&self, request: Request<'_>) -> Result { let meter = get_meter(); let start = std::time::Instant::now(); @@ -129,12 +130,15 @@ pub trait Executable { // Record response metrics let attributes = vec![ KeyValue::new( - "success", + TelemetryAttribute::Success.to_key(), result.as_ref().is_ok_and(|r| r.is_error != Some(true)), ), - KeyValue::new("operation.id", op_id.unwrap_or("unknown".to_string())), KeyValue::new( - "operation.type", + TelemetryAttribute::OperationId.to_key(), + op_id.unwrap_or("unknown".to_string()), + ), + KeyValue::new( + TelemetryAttribute::OperationSource.to_key(), if self.persisted_query_id().is_some() { "persisted_query" } else { @@ -143,11 +147,11 @@ pub trait Executable { ), ]; meter - .f64_histogram("apollo.mcp.operation.duration") + .f64_histogram(TelemetryMetric::OperationDuration.as_str()) .build() .record(start.elapsed().as_millis() as f64, &attributes); meter - .u64_counter("apollo.mcp.operation.count") + .u64_counter(TelemetryMetric::OperationCount.as_str()) .build() .add(1, &attributes); diff --git a/crates/apollo-mcp-server/src/lib.rs b/crates/apollo-mcp-server/src/lib.rs index abe4bbc2..f0349459 100644 --- a/crates/apollo-mcp-server/src/lib.rs +++ b/crates/apollo-mcp-server/src/lib.rs @@ -13,3 +13,10 @@ pub mod operations; pub mod sanitize; pub(crate) mod schema_tree_shake; pub mod server; +pub mod telemetry_attributes; + +pub mod generated { + pub mod telemetry { + include!(concat!(env!("OUT_DIR"), "/telemetry_attributes.rs")); + } +} diff --git a/crates/apollo-mcp-server/src/runtime.rs b/crates/apollo-mcp-server/src/runtime.rs index 567b0e11..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; @@ -131,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)?; 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..d698617d --- /dev/null +++ b/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs @@ -0,0 +1,233 @@ +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)); + + // TODO: while not strictly necessary for dealing with high-cardinality, do we want to + // filter out from span.events.events as well? + // for ev in &mut span.events.events { + // ev.attributes.retain(|kv| filter_omitted_apollo_attributes(kv, &self.allow)); + // } + } + + 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; + + 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 {} + + 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 {} + + 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 {} + + 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 {} + + 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/telemetry.rs b/crates/apollo-mcp-server/src/runtime/telemetry.rs index ce518883..11deee20 100644 --- a/crates/apollo-mcp-server/src/runtime/telemetry.rs +++ b/crates/apollo-mcp-server/src/runtime/telemetry.rs @@ -1,24 +1,26 @@ -use opentelemetry::{KeyValue, global, trace::TracerProvider as _}; +use crate::runtime::Config; +use crate::runtime::filtering_exporter::FilteringExporter; +use crate::runtime::logging::Logging; +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}; -use crate::runtime::Config; -use crate::runtime::logging::Logging; - /// Telemetry related options #[derive(Debug, Deserialize, JsonSchema, Default)] pub struct Telemetry { @@ -36,6 +38,7 @@ pub struct Exporters { #[derive(Debug, Deserialize, JsonSchema)] pub struct MetricsExporters { otlp: Option, + omitted_attributes: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -56,6 +59,7 @@ impl Default for OTLPMetricExporter { #[derive(Debug, Deserialize, JsonSchema)] pub struct TracingExporters { otlp: Option, + omitted_attributes: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -99,14 +103,17 @@ fn resource(telemetry: &Telemetry) -> Resource { } fn init_meter_provider(telemetry: &Telemetry) -> Result { - let otlp = telemetry + let metrics_exporters = telemetry .exporters .as_ref() - .and_then(|exporters| exporters.metrics.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() @@ -123,27 +130,50 @@ fn init_meter_provider(telemetry: &Telemetry) -> Result = 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 otlp = telemetry + let tracer_exporters = telemetry .exporters .as_ref() - .and_then(|exporters| exporters.tracing.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() @@ -160,10 +190,17 @@ fn init_tracer_provider(telemetry: &Telemetry) -> Result = 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(exporter) + .with_batch_exporter(filtering_exporter) .build(); Ok(tracer_provider) @@ -251,15 +288,20 @@ mod tests { } #[tokio::test] - async fn guard_is_provided_when_tracing_configued() { + 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()), + omitted_attributes: Some(ommitted), }), ); // init_tracing_subscriber can only be called once in the test suite to avoid @@ -278,6 +320,7 @@ mod tests { protocol: "bogus".to_string(), endpoint: "http://localhost:4317".to_string(), }), + omitted_attributes: None, }), None, ); @@ -301,6 +344,7 @@ mod tests { protocol: "bogus".to_string(), endpoint: "http://localhost:4317".to_string(), }), + omitted_attributes: None, }), ); let result = init_tracer_provider(&config.telemetry); diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index b7791b55..73c28a82 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -21,6 +21,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error}; use url::Url; +use crate::generated::telemetry::{TelemetryAttribute, TelemetryMetric}; use crate::{ auth::ValidToken, custom_scalar_map::CustomScalarMap, @@ -173,7 +174,7 @@ impl Running { } impl ServerHandler for Running { - #[tracing::instrument(skip(self))] + #[tracing::instrument(skip(self, _request))] async fn initialize( &self, _request: InitializeRequestParam, @@ -181,7 +182,7 @@ impl ServerHandler for Running { ) -> Result { let meter = get_meter(); meter - .u64_counter("apollo.mcp.initialize.count") + .u64_counter(TelemetryMetric::InitializeCount.as_str()) .build() .add(1, &[]); // TODO: how to remove these? @@ -190,7 +191,7 @@ impl ServerHandler for Running { Ok(self.get_info()) } - #[tracing::instrument(skip(self, context), fields(tool_name = request.name.as_ref(), request_id = %context.id.clone()))] + #[tracing::instrument(skip(self, context, request), fields(apollo.mcp.tool_name = request.name.as_ref(), apollo.mcp.request_id = %context.id.clone()))] async fn call_tool( &self, request: CallToolRequestParam, @@ -294,18 +295,18 @@ impl ServerHandler for Running { let attributes = vec![ KeyValue::new( - "success", + TelemetryAttribute::Success.to_key(), result.as_ref().is_ok_and(|r| r.is_error != Some(true)), ), - KeyValue::new("tool_name", tool_name), + KeyValue::new(TelemetryAttribute::ToolName.to_key(), tool_name), ]; // Record response time and status meter - .f64_histogram("apollo.mcp.tool.duration") + .f64_histogram(TelemetryMetric::ToolDuration.as_str()) .build() .record(start.elapsed().as_millis() as f64, &attributes); meter - .u64_counter("apollo.mcp.tool.count") + .u64_counter(TelemetryMetric::ToolCount.as_str()) .build() .add(1, &attributes); @@ -320,7 +321,7 @@ impl ServerHandler for Running { ) -> Result { let meter = get_meter(); meter - .u64_counter("apollo.mcp.list_tools.count") + .u64_counter(TelemetryMetric::ListToolsCount.as_str()) .build() .add(1, &[]); Ok(ListToolsResult { @@ -343,7 +344,7 @@ impl ServerHandler for Running { fn get_info(&self) -> ServerInfo { let meter = get_meter(); meter - .u64_counter("apollo.mcp.get_info.count") + .u64_counter(TelemetryMetric::GetInfoCount.as_str()) .build() .add(1, &[]); ServerInfo { 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..a21d9171 --- /dev/null +++ b/crates/apollo-mcp-server/src/telemetry_attributes.rs @@ -0,0 +1,33 @@ +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()) + } + } + } + + 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..368793fe --- /dev/null +++ b/crates/apollo-mcp-server/telemetry.toml @@ -0,0 +1,15 @@ +[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" + +[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" From 0ee22243483b160964a38107d7e0c0851c5dc6b9 Mon Sep 17 00:00:00 2001 From: Armando Locay Date: Wed, 17 Sep 2025 13:38:14 -0400 Subject: [PATCH 50/58] feat: adding config option for trace sampling (#370) * feat: adding config option for trace sampling * chore: adding changeset entry --- .../feat_trace_sampling_option_alocay.md | 8 ++ .../src/runtime/telemetry.rs | 13 +++ .../src/runtime/telemetry/sampler.rs | 98 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 .changesets/feat_trace_sampling_option_alocay.md create mode 100644 crates/apollo-mcp-server/src/runtime/telemetry/sampler.rs diff --git a/.changesets/feat_trace_sampling_option_alocay.md b/.changesets/feat_trace_sampling_option_alocay.md new file mode 100644 index 00000000..38bec085 --- /dev/null +++ b/.changesets/feat_trace_sampling_option_alocay.md @@ -0,0 +1,8 @@ +### feat: 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. diff --git a/crates/apollo-mcp-server/src/runtime/telemetry.rs b/crates/apollo-mcp-server/src/runtime/telemetry.rs index 11deee20..bd392702 100644 --- a/crates/apollo-mcp-server/src/runtime/telemetry.rs +++ b/crates/apollo-mcp-server/src/runtime/telemetry.rs @@ -1,6 +1,9 @@ +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; @@ -59,6 +62,7 @@ impl Default for OTLPMetricExporter { #[derive(Debug, Deserialize, JsonSchema)] pub struct TracingExporters { otlp: Option, + sampler: Option, omitted_attributes: Option>, } @@ -190,6 +194,12 @@ fn init_tracer_provider(telemetry: &Telemetry) -> Result = tracer_exporters .and_then(|exporters| exporters.omitted_attributes.clone()) .map(|set| set.iter().map(|a| a.to_key()).collect()) @@ -201,6 +211,7 @@ fn init_tracer_provider(telemetry: &Telemetry) -> Result= 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) + )); + } +} From 8681f20c54f93b7d731ed77f765b60bab92093c9 Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Thu, 18 Sep 2025 10:38:40 -0400 Subject: [PATCH 51/58] chore: exclude test-only code from coverage analysis re-run checks --- Cargo.toml | 3 +++ crates/apollo-mcp-server/src/lib.rs | 2 ++ crates/apollo-mcp-server/src/runtime/filtering_exporter.rs | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index c3c09c07..1eea9dba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ url = { version = "2.4", features = ["serde"] } [workspace.metadata] crane.name = "apollo-mcp" +[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-server/src/lib.rs b/crates/apollo-mcp-server/src/lib.rs index f0349459..5e872c42 100644 --- a/crates/apollo-mcp-server/src/lib.rs +++ b/crates/apollo-mcp-server/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] + pub mod auth; pub mod cors; pub mod custom_scalar_map; diff --git a/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs b/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs index d698617d..5ed259c5 100644 --- a/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs +++ b/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs @@ -67,6 +67,7 @@ mod tests { 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), @@ -102,6 +103,7 @@ mod tests { #[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| { @@ -145,6 +147,7 @@ mod tests { #[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( @@ -176,6 +179,7 @@ mod tests { #[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( @@ -207,6 +211,7 @@ mod tests { #[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( From 28f36d80d8423f54798a6df681af02fe346b4c56 Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Thu, 18 Sep 2025 15:07:41 -0500 Subject: [PATCH 52/58] Trace operation parsing and tool generation Add status code to the http trace --- .../collection_poller.rs | 2 +- .../src/operations/operation.rs | 6 ++++ .../src/operations/operation_source.rs | 4 ++- .../src/server/states/starting.rs | 30 +++++++++++++------ 4 files changed, 31 insertions(+), 11 deletions(-) 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-server/src/operations/operation.rs b/crates/apollo-mcp-server/src/operations/operation.rs index 6a0ac72e..33a230fc 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))] 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(source_text))] 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(source_text))] 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..c203a8a5 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] 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/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index 3429c17f..af08e0eb 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -215,15 +215,27 @@ impl Starting { //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!( - "http_request", - method = %request.method(), - uri = %request.uri(), - ) - }, - )); + .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_code", + tracing::field::display(response.status()), + ); + }, + ), + ); // Add health check endpoint if configured if let Some(health_check) = health_check.filter(|h| h.config().enabled) { From 4c699f9b5735ac2491ae7fe9aa80d1f7eaae5716 Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Mon, 22 Sep 2025 10:49:25 -0500 Subject: [PATCH 53/58] Trace Auth with status_code and reason Changeset Unit tests for auth.rs Add raw_operation as a telemetry attribute Unit test starting a streamable http server Rename self to operation_source --- ...feat_telemetry_operations_trace_metrics.md | 4 + crates/apollo-mcp-server/src/auth.rs | 81 +++++++++++++++++-- .../src/operations/operation_source.rs | 2 +- .../src/server/states/running.rs | 2 + .../src/server/states/starting.rs | 51 +++++++++++- .../src/telemetry_attributes.rs | 3 + crates/apollo-mcp-server/telemetry.toml | 1 + 7 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 .changesets/feat_telemetry_operations_trace_metrics.md diff --git a/.changesets/feat_telemetry_operations_trace_metrics.md b/.changesets/feat_telemetry_operations_trace_metrics.md new file mode 100644 index 00000000..56ed75fe --- /dev/null +++ b/.changesets/feat_telemetry_operations_trace_metrics.md @@ -0,0 +1,4 @@ +### 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 \ No newline at end of file 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/operations/operation_source.rs b/crates/apollo-mcp-server/src/operations/operation_source.rs index c203a8a5..65f49890 100644 --- a/crates/apollo-mcp-server/src/operations/operation_source.rs +++ b/crates/apollo-mcp-server/src/operations/operation_source.rs @@ -38,7 +38,7 @@ pub enum OperationSource { } impl OperationSource { - #[tracing::instrument] + #[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(), diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index 73c28a82..50d6d170 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -105,6 +105,7 @@ impl Running { Ok(self) } + #[tracing::instrument(skip_all)] pub(super) async fn update_operations( self, operations: Vec, @@ -146,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() { diff --git a/crates/apollo-mcp-server/src/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index af08e0eb..6bf6380c 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -229,10 +229,7 @@ impl Starting { |response: &axum::http::Response<_>, _latency: std::time::Duration, span: &tracing::Span| { - span.record( - "status_code", - tracing::field::display(response.status()), - ); + span.record("status", tracing::field::display(response.status())); }, ), ); @@ -334,3 +331,49 @@ 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::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 index a21d9171..49e6ab5f 100644 --- a/crates/apollo-mcp-server/src/telemetry_attributes.rs +++ b/crates/apollo-mcp-server/src/telemetry_attributes.rs @@ -20,6 +20,9 @@ impl TelemetryAttribute { TelemetryAttribute::RequestId => { Key::from_static_str(TelemetryAttribute::RequestId.as_str()) } + TelemetryAttribute::RawOperation => { + Key::from_static_str(TelemetryAttribute::RawOperation.as_str()) + } } } diff --git a/crates/apollo-mcp-server/telemetry.toml b/crates/apollo-mcp-server/telemetry.toml index 368793fe..8135456a 100644 --- a/crates/apollo-mcp-server/telemetry.toml +++ b/crates/apollo-mcp-server/telemetry.toml @@ -4,6 +4,7 @@ operation_id = "The operation id - either persisted query id, operation name, or 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" [metrics.apollo.mcp] "initialize.count" = "Number of times initialize has been called" From d759c39bc4008e18788cb8fad4a66f16baa1c6ef Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Mon, 8 Sep 2025 14:41:22 -0500 Subject: [PATCH 54/58] Draft documentation and user guide for mcp otel telemetry Add telemetry docs to sidebar Cleaning up Fix title on grafana guide Update the overview Update configuration section to be more complete Add a quick start guide and info about prod Update production section with more useful ino Apply suggestions from code review Style edits Co-authored-by: Joseph Caudle Remove custom from 'custom metrics' Remove Grafana how-to guide Move configuration reference to the config page Add a note about cardinality control using sampling and attribute filtering Fix typo: traaces -> traces --- docs/source/_sidebar.yaml | 2 + docs/source/config-file.mdx | 48 +++++++++++ docs/source/telemetry.mdx | 168 ++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 docs/source/telemetry.mdx diff --git a/docs/source/_sidebar.yaml b/docs/source/_sidebar.yaml index 08cc6729..531eaceb 100644 --- a/docs/source/_sidebar.yaml +++ b/docs/source/_sidebar.yaml @@ -32,6 +32,8 @@ items: 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 9ef0d2f1..54021eaa 100644 --- a/docs/source/config-file.mdx +++ b/docs/source/config-file.mdx @@ -29,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 @@ -224,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. diff --git a/docs/source/telemetry.mdx b/docs/source/telemetry.mdx new file mode 100644 index 00000000..693c9377 --- /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/tracing/setup_overview/open_standards/otlp_ingest_in_datadog/) - Native OTLP support +- [New Relic OpenTelemetry](https://docs.newrelic.com/docs/more-integrations/open-source-telemetry-integrations/opentelemetry/) - 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). From 97142d6f23a5c726a59523bda5f3c6393f8be6a5 Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Tue, 23 Sep 2025 13:53:30 -0500 Subject: [PATCH 55/58] Add more unit test coverage Fix links to external vendors OTLP docs pr feedback: LazyLock meter, debug and todo removal, comments --- Cargo.toml | 3 ++ crates/apollo-mcp-server/build.rs | 4 -- crates/apollo-mcp-server/src/graphql.rs | 17 +++++---- crates/apollo-mcp-server/src/lib.rs | 1 + crates/apollo-mcp-server/src/meter.rs | 8 +--- .../src/runtime/filtering_exporter.rs | 6 --- .../src/runtime/telemetry.rs | 37 +++++++++++++++++++ .../src/server/states/running.rs | 10 ++--- .../src/server/states/starting.rs | 6 ++- docs/source/telemetry.mdx | 4 +- 10 files changed, 64 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1eea9dba..6364840a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,9 @@ 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)'] } diff --git a/crates/apollo-mcp-server/build.rs b/crates/apollo-mcp-server/build.rs index e8b5d44a..f213cc35 100644 --- a/crates/apollo-mcp-server/build.rs +++ b/crates/apollo-mcp-server/build.rs @@ -100,10 +100,6 @@ fn main() { // Generate the keys let telemetry_attribute_data = flatten(telemetry.attributes); let telemetry_metrics_data = flatten(telemetry.metrics); - println!( - "a {:?} | m {:?}", - telemetry_attribute_data, telemetry_metrics_data - ); // Write out the generated keys let out_dir = std::env::var_os("OUT_DIR").expect("could not retrieve output directory"); diff --git a/crates/apollo-mcp-server/src/graphql.rs b/crates/apollo-mcp-server/src/graphql.rs index 72af139d..8ed86941 100644 --- a/crates/apollo-mcp-server/src/graphql.rs +++ b/crates/apollo-mcp-server/src/graphql.rs @@ -1,7 +1,8 @@ //! Execute GraphQL operations from an MCP tool +use crate::errors::McpError; use crate::generated::telemetry::{TelemetryAttribute, TelemetryMetric}; -use crate::{errors::McpError, meter::get_meter}; +use crate::meter; use opentelemetry::KeyValue; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest_middleware::{ClientBuilder, Extension}; @@ -40,7 +41,7 @@ pub trait Executable { /// Execute as a GraphQL operation using the endpoint and headers #[tracing::instrument(skip(self, request))] async fn execute(&self, request: Request<'_>) -> Result { - let meter = get_meter(); + let meter = &meter::METER; let start = std::time::Instant::now(); let mut op_id: Option = None; let client_metadata = serde_json::json!({ @@ -135,14 +136,13 @@ pub trait Executable { ), KeyValue::new( TelemetryAttribute::OperationId.to_key(), - op_id.unwrap_or("unknown".to_string()), + op_id.unwrap_or("".to_string()), ), KeyValue::new( TelemetryAttribute::OperationSource.to_key(), - if self.persisted_query_id().is_some() { - "persisted_query" - } else { - "operation" + match self.persisted_query_id() { + Some(_) => "persisted_query", + None => "operation", }, ), ]; @@ -162,6 +162,7 @@ pub trait Executable { #[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; @@ -453,7 +454,7 @@ mod test { .find(|scope_metrics| scope_metrics.scope().name() == "apollo.mcp") { for metric in scope_metrics.metrics() { - if metric.name() == "apollo.mcp.operation.count" + if metric.name() == TelemetryMetric::OperationCount.as_str() && let AggregatedMetrics::U64(MetricData::Sum(data)) = metric.data() { for point in data.data_points() { diff --git a/crates/apollo-mcp-server/src/lib.rs b/crates/apollo-mcp-server/src/lib.rs index 5e872c42..1737b4e1 100644 --- a/crates/apollo-mcp-server/src/lib.rs +++ b/crates/apollo-mcp-server/src/lib.rs @@ -17,6 +17,7 @@ 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/meter.rs b/crates/apollo-mcp-server/src/meter.rs index 07204721..a52f7447 100644 --- a/crates/apollo-mcp-server/src/meter.rs +++ b/crates/apollo-mcp-server/src/meter.rs @@ -1,8 +1,4 @@ use opentelemetry::{global, metrics::Meter}; -use std::sync::OnceLock; +use std::sync::LazyLock; -static METER: OnceLock = OnceLock::new(); - -pub fn get_meter() -> &'static Meter { - METER.get_or_init(|| global::meter(env!("CARGO_PKG_NAME"))) -} +pub static METER: LazyLock = LazyLock::new(|| global::meter(env!("CARGO_PKG_NAME"))); diff --git a/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs b/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs index 5ed259c5..e56fc3eb 100644 --- a/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs +++ b/crates/apollo-mcp-server/src/runtime/filtering_exporter.rs @@ -28,12 +28,6 @@ where for span in &mut batch { span.attributes .retain(|kv| filter_omitted_apollo_attributes(kv, &self.omitted)); - - // TODO: while not strictly necessary for dealing with high-cardinality, do we want to - // filter out from span.events.events as well? - // for ev in &mut span.events.events { - // ev.attributes.retain(|kv| filter_omitted_apollo_attributes(kv, &self.allow)); - // } } self.inner.export(batch) diff --git a/crates/apollo-mcp-server/src/runtime/telemetry.rs b/crates/apollo-mcp-server/src/runtime/telemetry.rs index bd392702..d5d0688b 100644 --- a/crates/apollo-mcp-server/src/runtime/telemetry.rs +++ b/crates/apollo-mcp-server/src/runtime/telemetry.rs @@ -345,6 +345,24 @@ mod tests { ); } + #[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( @@ -368,4 +386,23 @@ mod tests { .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/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index 50d6d170..2bdede77 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -22,6 +22,7 @@ use tracing::{debug, error}; use url::Url; use crate::generated::telemetry::{TelemetryAttribute, TelemetryMetric}; +use crate::meter; use crate::{ auth::ValidToken, custom_scalar_map::CustomScalarMap, @@ -35,7 +36,6 @@ use crate::{ search::{SEARCH_TOOL_NAME, Search}, validate::{VALIDATE_TOOL_NAME, Validate}, }, - meter::get_meter, operations::{MutationMode, Operation, RawOperation}, }; @@ -182,7 +182,7 @@ impl ServerHandler for Running { _request: InitializeRequestParam, context: RequestContext, ) -> Result { - let meter = get_meter(); + let meter = &meter::METER; meter .u64_counter(TelemetryMetric::InitializeCount.as_str()) .build() @@ -199,7 +199,7 @@ impl ServerHandler for Running { request: CallToolRequestParam, context: RequestContext, ) -> Result { - let meter = get_meter(); + let meter = &meter::METER; let start = std::time::Instant::now(); let tool_name = request.name.clone(); let result = match tool_name.as_ref() { @@ -321,7 +321,7 @@ impl ServerHandler for Running { _request: Option, _context: RequestContext, ) -> Result { - let meter = get_meter(); + let meter = &meter::METER; meter .u64_counter(TelemetryMetric::ListToolsCount.as_str()) .build() @@ -344,7 +344,7 @@ impl ServerHandler for Running { } fn get_info(&self) -> ServerInfo { - let meter = get_meter(); + let meter = &meter::METER; meter .u64_counter(TelemetryMetric::GetInfoCount.as_str()) .build() diff --git a/crates/apollo-mcp-server/src/server/states/starting.rs b/crates/apollo-mcp-server/src/server/states/starting.rs index 6bf6380c..c377da5a 100644 --- a/crates/apollo-mcp-server/src/server/states/starting.rs +++ b/crates/apollo-mcp-server/src/server/states/starting.rs @@ -367,7 +367,11 @@ mod tests { disable_auth_token_passthrough: false, search_leaf_depth: 5, index_memory_bytes: 1024 * 1024 * 1024, - health_check: HealthCheckConfig::default(), + health_check: HealthCheckConfig { + enabled: true, + ..Default::default() + }, + cors: Default::default(), }, schema: Schema::parse_and_validate("type Query { hello: String }", "test.graphql") .expect("Valid schema"), diff --git a/docs/source/telemetry.mdx b/docs/source/telemetry.mdx index 693c9377..9b02f395 100644 --- a/docs/source/telemetry.mdx +++ b/docs/source/telemetry.mdx @@ -66,8 +66,8 @@ telemetry: 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/tracing/setup_overview/open_standards/otlp_ingest_in_datadog/) - Native OTLP support -- [New Relic OpenTelemetry](https://docs.newrelic.com/docs/more-integrations/open-source-telemetry-integrations/opentelemetry/) - Direct OTLP ingestion +- [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 From a39eb0ec4fa5f76d630d966a79029274ebd69f3b Mon Sep 17 00:00:00 2001 From: Dale Seo Date: Wed, 24 Sep 2025 15:00:12 -0400 Subject: [PATCH 56/58] chore: bumping to version 0.9.0 --- .../config_fix_disable_stateful_sessions.md | 3 - .changesets/config_telemetry_config.md | 7 - ...at_allow_attribute_configuration_alocay.md | 70 ------- .../feat_auth_token_passthrough_disable.md | 3 - .changesets/feat_cors.md | 3 - .../feat_imporove_tool_descriptions.md | 3 - .changesets/feat_otel_metrics.md | 11 -- .changesets/feat_otel_prototype.md | 9 - ...feat_telemetry_operations_trace_metrics.md | 4 - .../feat_trace_sampling_option_alocay.md | 8 - ..._evan_minify_include_builtin_directives.md | 3 - .changesets/fix_lb_supergraph_new_specs.md | 3 - .../fix_telemetry_fix_downstream_traces.md | 3 - .changesets/maint_codecov.md | 3 - .changesets/maint_coverage.md | 3 - ...fix_version_on_mcp_server_tester_alocay.md | 3 - .changesets/maint_nc_rmcp_update.md | 3 - .changesets/maint_tests_for_server_event.md | 3 - CHANGELOG.md | 186 +++++++++++++++++- CHANGELOG_SECTION.md | 1 - scripts/nix/install.sh | 2 +- scripts/windows/install.ps1 | 2 +- 22 files changed, 186 insertions(+), 150 deletions(-) delete mode 100644 .changesets/config_fix_disable_stateful_sessions.md delete mode 100644 .changesets/config_telemetry_config.md delete mode 100644 .changesets/feat_allow_attribute_configuration_alocay.md delete mode 100644 .changesets/feat_auth_token_passthrough_disable.md delete mode 100644 .changesets/feat_cors.md delete mode 100644 .changesets/feat_imporove_tool_descriptions.md delete mode 100644 .changesets/feat_otel_metrics.md delete mode 100644 .changesets/feat_otel_prototype.md delete mode 100644 .changesets/feat_telemetry_operations_trace_metrics.md delete mode 100644 .changesets/feat_trace_sampling_option_alocay.md delete mode 100644 .changesets/fix_evan_minify_include_builtin_directives.md delete mode 100644 .changesets/fix_lb_supergraph_new_specs.md delete mode 100644 .changesets/fix_telemetry_fix_downstream_traces.md delete mode 100644 .changesets/maint_codecov.md delete mode 100644 .changesets/maint_coverage.md delete mode 100644 .changesets/maint_fix_version_on_mcp_server_tester_alocay.md delete mode 100644 .changesets/maint_nc_rmcp_update.md delete mode 100644 .changesets/maint_tests_for_server_event.md diff --git a/.changesets/config_fix_disable_stateful_sessions.md b/.changesets/config_fix_disable_stateful_sessions.md deleted file mode 100644 index cbb982a2..00000000 --- a/.changesets/config_fix_disable_stateful_sessions.md +++ /dev/null @@ -1,3 +0,0 @@ -### fix: 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. \ No newline at end of file diff --git a/.changesets/config_telemetry_config.md b/.changesets/config_telemetry_config.md deleted file mode 100644 index 2f1132cc..00000000 --- a/.changesets/config_telemetry_config.md +++ /dev/null @@ -1,7 +0,0 @@ -### 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 \ No newline at end of file diff --git a/.changesets/feat_allow_attribute_configuration_alocay.md b/.changesets/feat_allow_attribute_configuration_alocay.md deleted file mode 100644 index 29ddfdba..00000000 --- a/.changesets/feat_allow_attribute_configuration_alocay.md +++ /dev/null @@ -1,70 +0,0 @@ -### feat: 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 -``` diff --git a/.changesets/feat_auth_token_passthrough_disable.md b/.changesets/feat_auth_token_passthrough_disable.md deleted file mode 100644 index dd9ac487..00000000 --- a/.changesets/feat_auth_token_passthrough_disable.md +++ /dev/null @@ -1,3 +0,0 @@ -### feat: 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. \ No newline at end of file diff --git a/.changesets/feat_cors.md b/.changesets/feat_cors.md deleted file mode 100644 index c3fdc5d7..00000000 --- a/.changesets/feat_cors.md +++ /dev/null @@ -1,3 +0,0 @@ -### 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. diff --git a/.changesets/feat_imporove_tool_descriptions.md b/.changesets/feat_imporove_tool_descriptions.md deleted file mode 100644 index 980dcb53..00000000 --- a/.changesets/feat_imporove_tool_descriptions.md +++ /dev/null @@ -1,3 +0,0 @@ -### feat: 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. diff --git a/.changesets/feat_otel_metrics.md b/.changesets/feat_otel_metrics.md deleted file mode 100644 index 8d553052..00000000 --- a/.changesets/feat_otel_metrics.md +++ /dev/null @@ -1,11 +0,0 @@ -### 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 diff --git a/.changesets/feat_otel_prototype.md b/.changesets/feat_otel_prototype.md deleted file mode 100644 index 3a530264..00000000 --- a/.changesets/feat_otel_prototype.md +++ /dev/null @@ -1,9 +0,0 @@ -### 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. - diff --git a/.changesets/feat_telemetry_operations_trace_metrics.md b/.changesets/feat_telemetry_operations_trace_metrics.md deleted file mode 100644 index 56ed75fe..00000000 --- a/.changesets/feat_telemetry_operations_trace_metrics.md +++ /dev/null @@ -1,4 +0,0 @@ -### 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 \ No newline at end of file diff --git a/.changesets/feat_trace_sampling_option_alocay.md b/.changesets/feat_trace_sampling_option_alocay.md deleted file mode 100644 index 38bec085..00000000 --- a/.changesets/feat_trace_sampling_option_alocay.md +++ /dev/null @@ -1,8 +0,0 @@ -### feat: 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. diff --git a/.changesets/fix_evan_minify_include_builtin_directives.md b/.changesets/fix_evan_minify_include_builtin_directives.md deleted file mode 100644 index d5e08bda..00000000 --- a/.changesets/fix_evan_minify_include_builtin_directives.md +++ /dev/null @@ -1,3 +0,0 @@ -### Minify: 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. \ No newline at end of file diff --git a/.changesets/fix_lb_supergraph_new_specs.md b/.changesets/fix_lb_supergraph_new_specs.md deleted file mode 100644 index 27f0997e..00000000 --- a/.changesets/fix_lb_supergraph_new_specs.md +++ /dev/null @@ -1,3 +0,0 @@ -### 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. diff --git a/.changesets/fix_telemetry_fix_downstream_traces.md b/.changesets/fix_telemetry_fix_downstream_traces.md deleted file mode 100644 index d5a8febf..00000000 --- a/.changesets/fix_telemetry_fix_downstream_traces.md +++ /dev/null @@ -1,3 +0,0 @@ -### fix: 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 \ No newline at end of file diff --git a/.changesets/maint_codecov.md b/.changesets/maint_codecov.md deleted file mode 100644 index 9d0fb911..00000000 --- a/.changesets/maint_codecov.md +++ /dev/null @@ -1,3 +0,0 @@ -### 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`. \ No newline at end of file diff --git a/.changesets/maint_coverage.md b/.changesets/maint_coverage.md deleted file mode 100644 index bc2e4c60..00000000 --- a/.changesets/maint_coverage.md +++ /dev/null @@ -1,3 +0,0 @@ -### 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. diff --git a/.changesets/maint_fix_version_on_mcp_server_tester_alocay.md b/.changesets/maint_fix_version_on_mcp_server_tester_alocay.md deleted file mode 100644 index c7028a76..00000000 --- a/.changesets/maint_fix_version_on_mcp_server_tester_alocay.md +++ /dev/null @@ -1,3 +0,0 @@ -### 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. diff --git a/.changesets/maint_nc_rmcp_update.md b/.changesets/maint_nc_rmcp_update.md deleted file mode 100644 index 9df34c15..00000000 --- a/.changesets/maint_nc_rmcp_update.md +++ /dev/null @@ -1,3 +0,0 @@ -### chore: update RMCP dependency ([328](https://github.com/apollographql/apollo-mcp-server/issues/328)) - -Update the RMCP dependency to the latest version, pulling in newer specification changes. diff --git a/.changesets/maint_tests_for_server_event.md b/.changesets/maint_tests_for_server_event.md deleted file mode 100644 index f8adc4ab..00000000 --- a/.changesets/maint_tests_for_server_event.md +++ /dev/null @@ -1,3 +0,0 @@ -### test: 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. 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/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 From 61574d189a833de26d35aa5f34bf7ebb19acbfd0 Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Fri, 26 Sep 2025 14:36:08 -0500 Subject: [PATCH 57/58] Update operation tracing and client initialization attributes --- crates/apollo-mcp-server/src/operations/operation.rs | 6 +++--- .../apollo-mcp-server/src/server/states/running.rs | 12 ++++++++---- crates/apollo-mcp-server/src/telemetry_attributes.rs | 6 ++++++ crates/apollo-mcp-server/telemetry.toml | 2 ++ 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/apollo-mcp-server/src/operations/operation.rs b/crates/apollo-mcp-server/src/operations/operation.rs index 33a230fc..79ac5aa0 100644 --- a/crates/apollo-mcp-server/src/operations/operation.rs +++ b/crates/apollo-mcp-server/src/operations/operation.rs @@ -139,7 +139,7 @@ impl Operation { } /// Generate a description for an operation based on documentation in the schema - #[tracing::instrument(skip(comments, tree_shaker, graphql_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, @@ -427,7 +427,7 @@ pub fn operation_name( .to_string()) } -#[tracing::instrument(skip(source_text))] +#[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, @@ -459,7 +459,7 @@ pub fn variable_description_overrides( argument_overrides_map } -#[tracing::instrument(skip(source_text))] +#[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, diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index 2bdede77..3d035177 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -176,24 +176,28 @@ impl Running { } impl ServerHandler for Running { - #[tracing::instrument(skip(self, _request))] + #[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(), + )]; meter .u64_counter(TelemetryMetric::InitializeCount.as_str()) .build() - .add(1, &[]); + .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(self, context, request), fields(apollo.mcp.tool_name = request.name.as_ref(), apollo.mcp.request_id = %context.id.clone()))] + #[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, diff --git a/crates/apollo-mcp-server/src/telemetry_attributes.rs b/crates/apollo-mcp-server/src/telemetry_attributes.rs index 49e6ab5f..540cd443 100644 --- a/crates/apollo-mcp-server/src/telemetry_attributes.rs +++ b/crates/apollo-mcp-server/src/telemetry_attributes.rs @@ -23,6 +23,12 @@ impl TelemetryAttribute { 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()) + } } } diff --git a/crates/apollo-mcp-server/telemetry.toml b/crates/apollo-mcp-server/telemetry.toml index 8135456a..f25ea0d2 100644 --- a/crates/apollo-mcp-server/telemetry.toml +++ b/crates/apollo-mcp-server/telemetry.toml @@ -5,6 +5,8 @@ operation_source = "The operation source - either operation (local file/op colle 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" From d97a56f3b866868d64855a005bfd218ccd04cf82 Mon Sep 17 00:00:00 2001 From: Samuel Collard Date: Mon, 29 Sep 2025 10:13:54 -0500 Subject: [PATCH 58/58] Also add client version to metric --- .../apollo-mcp-server/src/server/states/running.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/apollo-mcp-server/src/server/states/running.rs b/crates/apollo-mcp-server/src/server/states/running.rs index 3d035177..6111e0ce 100644 --- a/crates/apollo-mcp-server/src/server/states/running.rs +++ b/crates/apollo-mcp-server/src/server/states/running.rs @@ -183,10 +183,16 @@ impl ServerHandler for Running { context: RequestContext, ) -> Result { let meter = &meter::METER; - let attributes = vec![KeyValue::new( - TelemetryAttribute::ClientName.to_key(), - request.client_info.name.clone(), - )]; + 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()