This is page 4 of 8. Use http://codebase.md/apollographql/apollo-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cargo
│   └── config.toml
├── .changesets
│   └── README.md
├── .envrc
├── .github
│   ├── CODEOWNERS
│   ├── renovate.json5
│   └── workflows
│       ├── canary-release.yml
│       ├── ci.yml
│       ├── prep-release.yml
│       ├── release-bins.yml
│       ├── release-container.yml
│       ├── sync-develop.yml
│       └── verify-changeset.yml
├── .gitignore
├── .idea
│   └── runConfigurations
│       ├── clippy.xml
│       ├── format___test___clippy.xml
│       ├── format.xml
│       ├── Run_spacedevs.xml
│       └── Test_apollo_mcp_server.xml
├── .vscode
│   ├── extensions.json
│   ├── launch.json
│   ├── settings.json
│   └── tasks.json
├── apollo.config.json
├── Cargo.lock
├── Cargo.toml
├── CHANGELOG_SECTION.md
├── CHANGELOG.md
├── clippy.toml
├── codecov.yml
├── CONTRIBUTING.md
├── crates
│   ├── apollo-mcp-registry
│   │   ├── Cargo.toml
│   │   └── src
│   │       ├── files.rs
│   │       ├── lib.rs
│   │       ├── logging.rs
│   │       ├── platform_api
│   │       │   ├── operation_collections
│   │       │   │   ├── collection_poller.rs
│   │       │   │   ├── error.rs
│   │       │   │   ├── event.rs
│   │       │   │   └── operation_collections.graphql
│   │       │   ├── operation_collections.rs
│   │       │   └── platform-api.graphql
│   │       ├── platform_api.rs
│   │       ├── testdata
│   │       │   ├── minimal_supergraph.graphql
│   │       │   └── supergraph.graphql
│   │       ├── uplink
│   │       │   ├── persisted_queries
│   │       │   │   ├── event.rs
│   │       │   │   ├── manifest_poller.rs
│   │       │   │   ├── manifest.rs
│   │       │   │   └── persisted_queries_manifest_query.graphql
│   │       │   ├── persisted_queries.rs
│   │       │   ├── schema
│   │       │   │   ├── event.rs
│   │       │   │   ├── schema_query.graphql
│   │       │   │   └── schema_stream.rs
│   │       │   ├── schema.rs
│   │       │   ├── snapshots
│   │       │   │   ├── apollo_mcp_registry__uplink__schema__tests__schema_by_url_all_fail@logs.snap
│   │       │   │   ├── apollo_mcp_registry__uplink__schema__tests__schema_by_url_fallback@logs.snap
│   │       │   │   └── apollo_mcp_registry__uplink__schema__tests__schema_by_url@logs.snap
│   │       │   └── uplink.graphql
│   │       └── uplink.rs
│   ├── apollo-mcp-server
│   │   ├── build.rs
│   │   ├── Cargo.toml
│   │   ├── src
│   │   │   ├── auth
│   │   │   │   ├── networked_token_validator.rs
│   │   │   │   ├── protected_resource.rs
│   │   │   │   ├── valid_token.rs
│   │   │   │   └── www_authenticate.rs
│   │   │   ├── auth.rs
│   │   │   ├── config_schema.rs
│   │   │   ├── cors.rs
│   │   │   ├── custom_scalar_map.rs
│   │   │   ├── errors.rs
│   │   │   ├── event.rs
│   │   │   ├── explorer.rs
│   │   │   ├── graphql.rs
│   │   │   ├── headers.rs
│   │   │   ├── health.rs
│   │   │   ├── introspection
│   │   │   │   ├── minify.rs
│   │   │   │   ├── snapshots
│   │   │   │   │   └── apollo_mcp_server__introspection__minify__tests__minify_schema.snap
│   │   │   │   ├── tools
│   │   │   │   │   ├── execute.rs
│   │   │   │   │   ├── introspect.rs
│   │   │   │   │   ├── search.rs
│   │   │   │   │   ├── snapshots
│   │   │   │   │   │   └── apollo_mcp_server__introspection__tools__search__tests__search_tool.snap
│   │   │   │   │   ├── testdata
│   │   │   │   │   │   └── schema.graphql
│   │   │   │   │   └── validate.rs
│   │   │   │   └── tools.rs
│   │   │   ├── introspection.rs
│   │   │   ├── json_schema.rs
│   │   │   ├── lib.rs
│   │   │   ├── main.rs
│   │   │   ├── meter.rs
│   │   │   ├── operations
│   │   │   │   ├── mutation_mode.rs
│   │   │   │   ├── operation_source.rs
│   │   │   │   ├── operation.rs
│   │   │   │   ├── raw_operation.rs
│   │   │   │   ├── schema_walker
│   │   │   │   │   ├── name.rs
│   │   │   │   │   └── type.rs
│   │   │   │   └── schema_walker.rs
│   │   │   ├── operations.rs
│   │   │   ├── runtime
│   │   │   │   ├── config.rs
│   │   │   │   ├── endpoint.rs
│   │   │   │   ├── filtering_exporter.rs
│   │   │   │   ├── graphos.rs
│   │   │   │   ├── introspection.rs
│   │   │   │   ├── logging
│   │   │   │   │   ├── defaults.rs
│   │   │   │   │   ├── log_rotation_kind.rs
│   │   │   │   │   └── parsers.rs
│   │   │   │   ├── logging.rs
│   │   │   │   ├── operation_source.rs
│   │   │   │   ├── overrides.rs
│   │   │   │   ├── schema_source.rs
│   │   │   │   ├── schemas.rs
│   │   │   │   ├── telemetry
│   │   │   │   │   └── sampler.rs
│   │   │   │   └── telemetry.rs
│   │   │   ├── runtime.rs
│   │   │   ├── sanitize.rs
│   │   │   ├── schema_tree_shake.rs
│   │   │   ├── server
│   │   │   │   ├── states
│   │   │   │   │   ├── configuring.rs
│   │   │   │   │   ├── operations_configured.rs
│   │   │   │   │   ├── running.rs
│   │   │   │   │   ├── schema_configured.rs
│   │   │   │   │   └── starting.rs
│   │   │   │   └── states.rs
│   │   │   ├── server.rs
│   │   │   └── telemetry_attributes.rs
│   │   └── telemetry.toml
│   └── apollo-schema-index
│       ├── Cargo.toml
│       └── src
│           ├── error.rs
│           ├── lib.rs
│           ├── path.rs
│           ├── snapshots
│           │   ├── apollo_schema_index__tests__search.snap
│           │   └── apollo_schema_index__traverse__tests__schema_traverse.snap
│           ├── testdata
│           │   └── schema.graphql
│           └── traverse.rs
├── docs
│   └── source
│       ├── _sidebar.yaml
│       ├── auth.mdx
│       ├── best-practices.mdx
│       ├── config-file.mdx
│       ├── cors.mdx
│       ├── custom-scalars.mdx
│       ├── debugging.mdx
│       ├── define-tools.mdx
│       ├── deploy.mdx
│       ├── guides
│       │   └── auth-auth0.mdx
│       ├── health-checks.mdx
│       ├── images
│       │   ├── auth0-permissions-enable.png
│       │   ├── mcp-getstarted-inspector-http.jpg
│       │   └── mcp-getstarted-inspector-stdio.jpg
│       ├── index.mdx
│       ├── licensing.mdx
│       ├── limitations.mdx
│       ├── quickstart.mdx
│       ├── run.mdx
│       └── telemetry.mdx
├── e2e
│   └── mcp-server-tester
│       ├── local-operations
│       │   ├── api.graphql
│       │   ├── config.yaml
│       │   ├── operations
│       │   │   ├── ExploreCelestialBodies.graphql
│       │   │   ├── GetAstronautDetails.graphql
│       │   │   ├── GetAstronautsCurrentlyInSpace.graphql
│       │   │   └── SearchUpcomingLaunches.graphql
│       │   └── tool-tests.yaml
│       ├── pq-manifest
│       │   ├── api.graphql
│       │   ├── apollo.json
│       │   ├── config.yaml
│       │   └── tool-tests.yaml
│       ├── run_tests.sh
│       └── server-config.template.json
├── flake.lock
├── flake.nix
├── graphql
│   ├── TheSpaceDevs
│   │   ├── .vscode
│   │   │   ├── extensions.json
│   │   │   └── tasks.json
│   │   ├── api.graphql
│   │   ├── apollo.config.json
│   │   ├── config.yaml
│   │   ├── operations
│   │   │   ├── ExploreCelestialBodies.graphql
│   │   │   ├── GetAstronautDetails.graphql
│   │   │   ├── GetAstronautsCurrentlyInSpace.graphql
│   │   │   └── SearchUpcomingLaunches.graphql
│   │   ├── persisted_queries
│   │   │   └── apollo.json
│   │   ├── persisted_queries.config.json
│   │   ├── README.md
│   │   └── supergraph.yaml
│   └── weather
│       ├── api.graphql
│       ├── config.yaml
│       ├── operations
│       │   ├── alerts.graphql
│       │   ├── all.graphql
│       │   └── forecast.graphql
│       ├── persisted_queries
│       │   └── apollo.json
│       ├── supergraph.graphql
│       ├── supergraph.yaml
│       └── weather.graphql
├── LICENSE
├── macos-entitlements.plist
├── nix
│   ├── apollo-mcp.nix
│   ├── cargo-zigbuild.patch
│   ├── mcp-server-tools
│   │   ├── default.nix
│   │   ├── node-generated
│   │   │   ├── default.nix
│   │   │   ├── node-env.nix
│   │   │   └── node-packages.nix
│   │   ├── node-mcp-servers.json
│   │   └── README.md
│   └── mcphost.nix
├── README.md
├── rust-toolchain.toml
├── scripts
│   ├── nix
│   │   └── install.sh
│   └── windows
│       └── install.ps1
└── xtask
    ├── Cargo.lock
    ├── Cargo.toml
    └── src
        ├── commands
        │   ├── changeset
        │   │   ├── matching_pull_request.graphql
        │   │   ├── matching_pull_request.rs
        │   │   ├── mod.rs
        │   │   ├── scalars.rs
        │   │   └── snapshots
        │   │       ├── xtask__commands__changeset__tests__it_templatizes_with_multiple_issues_in_title_and_multiple_prs_in_footer.snap
        │   │       ├── xtask__commands__changeset__tests__it_templatizes_with_multiple_issues_in_title.snap
        │   │       ├── xtask__commands__changeset__tests__it_templatizes_with_multiple_prs_in_footer.snap
        │   │       ├── xtask__commands__changeset__tests__it_templatizes_with_neither_issues_or_prs.snap
        │   │       ├── xtask__commands__changeset__tests__it_templatizes_with_prs_in_title_when_empty_issues.snap
        │   │       └── xtask__commands__changeset__tests__it_templatizes_without_prs_in_title_when_issues_present.snap
        │   └── mod.rs
        ├── lib.rs
        └── main.rs
```
# Files
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/server/states.rs:
--------------------------------------------------------------------------------
```rust
  1 | use apollo_compiler::{Schema, validation::Valid};
  2 | use apollo_federation::{ApiSchemaOptions, Supergraph};
  3 | use apollo_mcp_registry::uplink::schema::{SchemaState, event::Event as SchemaEvent};
  4 | use futures::{FutureExt as _, Stream, StreamExt as _, stream};
  5 | use reqwest::header::HeaderMap;
  6 | use url::Url;
  7 | 
  8 | use crate::{
  9 |     cors::CorsConfig,
 10 |     custom_scalar_map::CustomScalarMap,
 11 |     errors::{OperationError, ServerError},
 12 |     headers::ForwardHeaders,
 13 |     health::HealthCheckConfig,
 14 |     operations::MutationMode,
 15 | };
 16 | 
 17 | use super::{Server, ServerEvent, Transport};
 18 | 
 19 | mod configuring;
 20 | mod operations_configured;
 21 | mod running;
 22 | mod schema_configured;
 23 | mod starting;
 24 | 
 25 | use configuring::Configuring;
 26 | use operations_configured::OperationsConfigured;
 27 | use running::Running;
 28 | use schema_configured::SchemaConfigured;
 29 | use starting::Starting;
 30 | 
 31 | pub(super) struct StateMachine {}
 32 | 
 33 | /// Common configuration options for the states
 34 | struct Config {
 35 |     transport: Transport,
 36 |     endpoint: Url,
 37 |     headers: HeaderMap,
 38 |     forward_headers: ForwardHeaders,
 39 |     execute_introspection: bool,
 40 |     validate_introspection: bool,
 41 |     introspect_introspection: bool,
 42 |     search_introspection: bool,
 43 |     introspect_minify: bool,
 44 |     search_minify: bool,
 45 |     explorer_graph_ref: Option<String>,
 46 |     custom_scalar_map: Option<CustomScalarMap>,
 47 |     mutation_mode: MutationMode,
 48 |     disable_type_description: bool,
 49 |     disable_schema_description: bool,
 50 |     disable_auth_token_passthrough: bool,
 51 |     search_leaf_depth: usize,
 52 |     index_memory_bytes: usize,
 53 |     health_check: HealthCheckConfig,
 54 |     cors: CorsConfig,
 55 | }
 56 | 
 57 | impl StateMachine {
 58 |     pub(crate) async fn start(self, server: Server) -> Result<(), ServerError> {
 59 |         let schema_stream = server
 60 |             .schema_source
 61 |             .into_stream()
 62 |             .map(ServerEvent::SchemaUpdated)
 63 |             .boxed();
 64 |         let operation_stream = server.operation_source.into_stream().await.boxed();
 65 |         let ctrl_c_stream = Self::ctrl_c_stream().boxed();
 66 |         let mut stream = stream::select_all(vec![schema_stream, operation_stream, ctrl_c_stream]);
 67 | 
 68 |         let mut state = State::Configuring(Configuring {
 69 |             config: Config {
 70 |                 transport: server.transport,
 71 |                 endpoint: server.endpoint,
 72 |                 headers: server.headers,
 73 |                 forward_headers: server.forward_headers,
 74 |                 execute_introspection: server.execute_introspection,
 75 |                 validate_introspection: server.validate_introspection,
 76 |                 introspect_introspection: server.introspect_introspection,
 77 |                 search_introspection: server.search_introspection,
 78 |                 introspect_minify: server.introspect_minify,
 79 |                 search_minify: server.search_minify,
 80 |                 explorer_graph_ref: server.explorer_graph_ref,
 81 |                 custom_scalar_map: server.custom_scalar_map,
 82 |                 mutation_mode: server.mutation_mode,
 83 |                 disable_type_description: server.disable_type_description,
 84 |                 disable_schema_description: server.disable_schema_description,
 85 |                 disable_auth_token_passthrough: server.disable_auth_token_passthrough,
 86 |                 search_leaf_depth: server.search_leaf_depth,
 87 |                 index_memory_bytes: server.index_memory_bytes,
 88 |                 health_check: server.health_check,
 89 |                 cors: server.cors,
 90 |             },
 91 |         });
 92 | 
 93 |         while let Some(event) = stream.next().await {
 94 |             state = match event {
 95 |                 ServerEvent::SchemaUpdated(registry_event) => match registry_event {
 96 |                     SchemaEvent::UpdateSchema(schema_state) => {
 97 |                         let schema = Self::sdl_to_api_schema(schema_state)?;
 98 |                         match state {
 99 |                             State::Configuring(configuring) => {
100 |                                 configuring.set_schema(schema).await.into()
101 |                             }
102 |                             State::SchemaConfigured(schema_configured) => {
103 |                                 schema_configured.set_schema(schema).await.into()
104 |                             }
105 |                             State::OperationsConfigured(operations_configured) => {
106 |                                 operations_configured.set_schema(schema).await.into()
107 |                             }
108 |                             State::Running(running) => running.update_schema(schema).await.into(),
109 |                             other => other,
110 |                         }
111 |                     }
112 |                     SchemaEvent::NoMoreSchema => match state {
113 |                         State::Configuring(_) | State::OperationsConfigured(_) => {
114 |                             State::Error(ServerError::NoSchema)
115 |                         }
116 |                         _ => state,
117 |                     },
118 |                 },
119 |                 ServerEvent::OperationsUpdated(operations) => match state {
120 |                     State::Configuring(configuring) => {
121 |                         configuring.set_operations(operations).await.into()
122 |                     }
123 |                     State::SchemaConfigured(schema_configured) => {
124 |                         schema_configured.set_operations(operations).await.into()
125 |                     }
126 |                     State::OperationsConfigured(operations_configured) => operations_configured
127 |                         .set_operations(operations)
128 |                         .await
129 |                         .into(),
130 |                     State::Running(running) => running.update_operations(operations).await.into(),
131 |                     other => other,
132 |                 },
133 |                 ServerEvent::OperationError(e, _) => {
134 |                     State::Error(ServerError::Operation(OperationError::File(e)))
135 |                 }
136 |                 ServerEvent::CollectionError(e) => {
137 |                     State::Error(ServerError::Operation(OperationError::Collection(e)))
138 |                 }
139 |                 ServerEvent::Shutdown => match state {
140 |                     State::Running(running) => {
141 |                         running.cancellation_token.cancel();
142 |                         State::Stopping
143 |                     }
144 |                     _ => State::Stopping,
145 |                 },
146 |             };
147 |             if let State::Starting(starting) = state {
148 |                 state = starting.start().await.into();
149 |             }
150 |             if matches!(&state, State::Error(_) | State::Stopping) {
151 |                 break;
152 |             }
153 |         }
154 |         match state {
155 |             State::Error(e) => Err(e),
156 |             _ => Ok(()),
157 |         }
158 |     }
159 | 
160 |     #[allow(clippy::result_large_err)]
161 |     fn sdl_to_api_schema(schema_state: SchemaState) -> Result<Valid<Schema>, ServerError> {
162 |         match Supergraph::new_with_router_specs(&schema_state.sdl) {
163 |             Ok(supergraph) => Ok(supergraph
164 |                 .to_api_schema(ApiSchemaOptions::default())
165 |                 .map_err(|e| ServerError::Federation(Box::new(e)))?
166 |                 .schema()
167 |                 .clone()),
168 |             Err(_) => Schema::parse_and_validate(schema_state.sdl, "schema.graphql")
169 |                 .map_err(|e| ServerError::GraphQLSchema(e.into())),
170 |         }
171 |     }
172 | 
173 |     fn ctrl_c_stream() -> impl Stream<Item = ServerEvent> {
174 |         shutdown_signal()
175 |             .map(|_| ServerEvent::Shutdown)
176 |             .into_stream()
177 |             .boxed()
178 |     }
179 | }
180 | 
181 | #[allow(clippy::expect_used)]
182 | async fn shutdown_signal() {
183 |     let ctrl_c = async {
184 |         tokio::signal::ctrl_c()
185 |             .await
186 |             .expect("Failed to install CTRL+C signal handler");
187 |     };
188 | 
189 |     #[cfg(unix)]
190 |     let terminate = async {
191 |         tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
192 |             .expect("Failed to install SIGTERM signal handler")
193 |             .recv()
194 |             .await;
195 |     };
196 | 
197 |     #[cfg(not(unix))]
198 |     let terminate = std::future::pending::<()>();
199 | 
200 |     tokio::select! {
201 |         _ = ctrl_c => {},
202 |         _ = terminate => {},
203 |     }
204 | }
205 | 
206 | #[allow(clippy::large_enum_variant)]
207 | enum State {
208 |     Configuring(Configuring),
209 |     SchemaConfigured(SchemaConfigured),
210 |     OperationsConfigured(OperationsConfigured),
211 |     Starting(Starting),
212 |     Running(Running),
213 |     Error(ServerError),
214 |     Stopping,
215 | }
216 | 
217 | impl From<Configuring> for State {
218 |     fn from(starting: Configuring) -> Self {
219 |         State::Configuring(starting)
220 |     }
221 | }
222 | 
223 | impl From<SchemaConfigured> for State {
224 |     fn from(schema_configured: SchemaConfigured) -> Self {
225 |         State::SchemaConfigured(schema_configured)
226 |     }
227 | }
228 | 
229 | impl From<Result<SchemaConfigured, ServerError>> for State {
230 |     fn from(result: Result<SchemaConfigured, ServerError>) -> Self {
231 |         match result {
232 |             Ok(schema_configured) => State::SchemaConfigured(schema_configured),
233 |             Err(error) => State::Error(error),
234 |         }
235 |     }
236 | }
237 | 
238 | impl From<OperationsConfigured> for State {
239 |     fn from(operations_configured: OperationsConfigured) -> Self {
240 |         State::OperationsConfigured(operations_configured)
241 |     }
242 | }
243 | 
244 | impl From<Result<OperationsConfigured, ServerError>> for State {
245 |     fn from(result: Result<OperationsConfigured, ServerError>) -> Self {
246 |         match result {
247 |             Ok(operations_configured) => State::OperationsConfigured(operations_configured),
248 |             Err(error) => State::Error(error),
249 |         }
250 |     }
251 | }
252 | 
253 | impl From<Starting> for State {
254 |     fn from(starting: Starting) -> Self {
255 |         State::Starting(starting)
256 |     }
257 | }
258 | 
259 | impl From<Result<Starting, ServerError>> for State {
260 |     fn from(result: Result<Starting, ServerError>) -> Self {
261 |         match result {
262 |             Ok(starting) => State::Starting(starting),
263 |             Err(error) => State::Error(error),
264 |         }
265 |     }
266 | }
267 | 
268 | impl From<Running> for State {
269 |     fn from(running: Running) -> Self {
270 |         State::Running(running)
271 |     }
272 | }
273 | 
274 | impl From<Result<Running, ServerError>> for State {
275 |     fn from(result: Result<Running, ServerError>) -> Self {
276 |         match result {
277 |             Ok(running) => State::Running(running),
278 |             Err(error) => State::Error(error),
279 |         }
280 |     }
281 | }
282 | 
283 | impl From<ServerError> for State {
284 |     fn from(error: ServerError) -> Self {
285 |         State::Error(error)
286 |     }
287 | }
288 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/introspection/tools/search.rs:
--------------------------------------------------------------------------------
```rust
  1 | //! MCP tool to search a GraphQL schema.
  2 | 
  3 | use crate::errors::McpError;
  4 | use crate::introspection::minify::MinifyExt as _;
  5 | use crate::schema_from_type;
  6 | use crate::schema_tree_shake::{DepthLimit, SchemaTreeShaker};
  7 | use apollo_compiler::ast::{Field, OperationType as AstOperationType, Selection};
  8 | use apollo_compiler::validation::Valid;
  9 | use apollo_compiler::{Name, Node, Schema};
 10 | use apollo_schema_index::{OperationType, Options, SchemaIndex};
 11 | use rmcp::model::{CallToolResult, Content, ErrorCode, Tool};
 12 | use rmcp::schemars::JsonSchema;
 13 | use rmcp::serde_json::Value;
 14 | use rmcp::{schemars, serde_json};
 15 | use serde::Deserialize;
 16 | use std::fmt::Debug;
 17 | use std::sync::Arc;
 18 | use tokio::sync::Mutex;
 19 | use tracing::debug;
 20 | 
 21 | /// The name of the tool to search a GraphQL schema.
 22 | pub const SEARCH_TOOL_NAME: &str = "search";
 23 | 
 24 | /// The maximum number of search results to consider.
 25 | const MAX_SEARCH_RESULTS: usize = 5;
 26 | 
 27 | /// A tool to search a GraphQL schema.
 28 | #[derive(Clone)]
 29 | pub struct Search {
 30 |     schema: Arc<Mutex<Valid<Schema>>>,
 31 |     index: SchemaIndex,
 32 |     allow_mutations: bool,
 33 |     leaf_depth: usize,
 34 |     minify: bool,
 35 |     pub tool: Tool,
 36 | }
 37 | 
 38 | /// Input for the search tool.
 39 | #[derive(JsonSchema, Deserialize, Debug)]
 40 | pub struct Input {
 41 |     /// The search terms
 42 |     terms: Vec<String>,
 43 | }
 44 | 
 45 | /// An error while indexing the GraphQL schema.
 46 | #[derive(Debug, thiserror::Error)]
 47 | pub enum IndexingError {
 48 |     #[error("Unable to index schema: {0}")]
 49 |     IndexingError(#[from] apollo_schema_index::error::IndexingError),
 50 | 
 51 |     #[error("Unable to lock schema: {0}")]
 52 |     TryLockError(#[from] tokio::sync::TryLockError),
 53 | }
 54 | 
 55 | impl Search {
 56 |     pub fn new(
 57 |         schema: Arc<Mutex<Valid<Schema>>>,
 58 |         allow_mutations: bool,
 59 |         leaf_depth: usize,
 60 |         index_memory_bytes: usize,
 61 |         minify: bool,
 62 |     ) -> Result<Self, IndexingError> {
 63 |         let root_types = if allow_mutations {
 64 |             OperationType::Query | OperationType::Mutation
 65 |         } else {
 66 |             OperationType::Query.into()
 67 |         };
 68 |         let locked = &schema.try_lock()?;
 69 |         Ok(Self {
 70 |             schema: schema.clone(),
 71 |             index: SchemaIndex::new(locked, root_types, index_memory_bytes)?,
 72 |             allow_mutations,
 73 |             leaf_depth,
 74 |             minify,
 75 |             tool: Tool::new(
 76 |                 SEARCH_TOOL_NAME,
 77 |                 format!(
 78 |                     "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.{}",
 79 |                     if minify {
 80 |                         " - 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"
 81 |                     } else {
 82 |                         ""
 83 |                     }
 84 |                 ),
 85 |                 schema_from_type!(Input),
 86 |             ),
 87 |         })
 88 |     }
 89 | 
 90 |     #[tracing::instrument(skip(self))]
 91 |     pub async fn execute(&self, input: Input) -> Result<CallToolResult, McpError> {
 92 |         let mut root_paths = self
 93 |             .index
 94 |             .search(input.terms.clone(), Options::default())
 95 |             .map_err(|e| {
 96 |                 McpError::new(
 97 |                     ErrorCode::INTERNAL_ERROR,
 98 |                     format!("Failed to search index: {e}"),
 99 |                     None,
100 |                 )
101 |             })?;
102 | 
103 |         root_paths.truncate(MAX_SEARCH_RESULTS);
104 |         debug!(
105 |             "Root paths for search terms: {}\n{}",
106 |             input.terms.join(", "),
107 |             root_paths
108 |                 .iter()
109 |                 .map(ToString::to_string)
110 |                 .collect::<Vec<String>>()
111 |                 .join("\n"),
112 |         );
113 | 
114 |         let schema = self.schema.lock().await;
115 |         let mut tree_shaker = SchemaTreeShaker::new(&schema);
116 |         for root_path in root_paths {
117 |             let path_len = root_path.inner.len();
118 |             for (i, path_node) in root_path.inner.into_iter().enumerate() {
119 |                 if let Some(extended_type) = schema.types.get(path_node.node_type.as_str()) {
120 |                     let (selection_set, depth) = if i == path_len - 1 {
121 |                         (None, DepthLimit::Limited(self.leaf_depth))
122 |                     } else {
123 |                         (
124 |                             path_node.field_name.as_ref().map(|field_name| {
125 |                                 vec![Selection::Field(Node::from(Field {
126 |                                     alias: Default::default(),
127 |                                     name: Name::new_unchecked(field_name),
128 |                                     arguments: Default::default(),
129 |                                     selection_set: Default::default(),
130 |                                     directives: Default::default(),
131 |                                 }))]
132 |                             }),
133 |                             DepthLimit::Limited(1),
134 |                         )
135 |                     };
136 |                     tree_shaker.retain_type(extended_type, selection_set.as_ref(), depth)
137 |                 }
138 |                 for field_arg in path_node.field_args {
139 |                     if let Some(extended_type) = schema.types.get(field_arg.as_str()) {
140 |                         // Retain input types with unlimited depth because all input must be given
141 |                         tree_shaker.retain_type(extended_type, None, DepthLimit::Unlimited);
142 |                     }
143 |                 }
144 |             }
145 |         }
146 | 
147 |         let shaken = tree_shaker.shaken().unwrap_or_else(|schema| schema.partial);
148 | 
149 |         Ok(CallToolResult {
150 |             content: shaken
151 |                 .types
152 |                 .iter()
153 |                 .filter(|(_name, extended_type)| {
154 |                     !extended_type.is_built_in()
155 |                         && schema
156 |                             .root_operation(AstOperationType::Mutation)
157 |                             .is_none_or(|root_name| {
158 |                                 extended_type.name() != root_name || self.allow_mutations
159 |                             })
160 |                 })
161 |                 .map(|(_, extended_type)| {
162 |                     if self.minify {
163 |                         extended_type.minify()
164 |                     } else {
165 |                         extended_type.serialize().to_string()
166 |                     }
167 |                 })
168 |                 .map(Content::text)
169 |                 .collect(),
170 |             is_error: None,
171 |             meta: None,
172 | 
173 |             // Note: The returned content is treated as text, so no need to structure its output
174 |             structured_content: None,
175 |         })
176 |     }
177 | }
178 | 
179 | #[cfg(test)]
180 | mod tests {
181 |     use super::*;
182 |     use rmcp::model::RawContent;
183 |     use rstest::{fixture, rstest};
184 |     use std::ops::Deref;
185 | 
186 |     const TEST_SCHEMA: &str = include_str!("testdata/schema.graphql");
187 | 
188 |     fn content_to_snapshot(result: CallToolResult) -> String {
189 |         result
190 |             .content
191 |             .into_iter()
192 |             .filter_map(|c| {
193 |                 let c = c.deref();
194 |                 match c {
195 |                     RawContent::Text(text) => Some(text.text.clone()),
196 |                     _ => None,
197 |                 }
198 |             })
199 |             .collect::<Vec<String>>()
200 |             .join("\n")
201 |     }
202 | 
203 |     #[fixture]
204 |     fn schema() -> Valid<Schema> {
205 |         Schema::parse(TEST_SCHEMA, "schema.graphql")
206 |             .expect("Failed to parse test schema")
207 |             .validate()
208 |             .expect("Failed to validate test schema")
209 |     }
210 | 
211 |     #[rstest]
212 |     #[tokio::test]
213 |     async fn test_search_tool(schema: Valid<Schema>) {
214 |         let schema = Arc::new(Mutex::new(schema));
215 |         let search = Search::new(schema.clone(), false, 1, 15_000_000, false)
216 |             .expect("Failed to create search tool");
217 | 
218 |         let result = search
219 |             .execute(Input {
220 |                 terms: vec!["User".to_string()],
221 |             })
222 |             .await
223 |             .expect("Search execution failed");
224 | 
225 |         assert!(!result.is_error.unwrap_or(false));
226 |         insta::assert_snapshot!(content_to_snapshot(result));
227 |     }
228 | 
229 |     #[rstest]
230 |     #[tokio::test]
231 |     async fn test_referencing_types_are_collected(schema: Valid<Schema>) {
232 |         let schema = Arc::new(Mutex::new(schema));
233 |         let search = Search::new(schema.clone(), true, 1, 15_000_000, false)
234 |             .expect("Failed to create search tool");
235 | 
236 |         // Search for a type that should have references
237 |         let result = search
238 |             .execute(Input {
239 |                 terms: vec!["User".to_string()],
240 |             })
241 |             .await
242 |             .expect("Search execution failed");
243 | 
244 |         assert!(!result.is_error.unwrap_or(false));
245 |         assert!(
246 |             content_to_snapshot(result).contains("createUser"),
247 |             "Expected to find the createUser mutation in search results"
248 |         );
249 |     }
250 | 
251 |     #[rstest]
252 |     #[tokio::test]
253 |     async fn test_search_tool_description_is_not_minified(schema: Valid<Schema>) {
254 |         let schema = Arc::new(Mutex::new(schema));
255 |         let search = Search::new(schema.clone(), false, 1, 15_000_000, false)
256 |             .expect("Failed to create search tool");
257 | 
258 |         let description = search.tool.description.unwrap();
259 | 
260 |         assert!(
261 |             description
262 |                 .contains("Search a GraphQL schema for types matching the provided search terms")
263 |         );
264 |         assert!(description.contains("Instructions: If the introspect tool is also available"));
265 |         assert!(description.contains("Avoid reusing previously searched terms"));
266 |         // Should not contain minification legend
267 |         assert!(!description.contains("T=type,I=input"));
268 |     }
269 | 
270 |     #[rstest]
271 |     #[tokio::test]
272 |     async fn test_tool_description_minified(schema: Valid<Schema>) {
273 |         let schema = Arc::new(Mutex::new(schema));
274 |         let search = Search::new(schema.clone(), false, 1, 15_000_000, true)
275 |             .expect("Failed to create search tool");
276 | 
277 |         let description = search.tool.description.unwrap();
278 | 
279 |         // Should contain minification legend
280 |         assert!(description.contains("T=type,I=input,E=enum,U=union,F=interface"));
281 |         assert!(description.contains("s=String,i=Int,f=Float,b=Boolean,d=ID"));
282 |     }
283 | }
284 | 
```
--------------------------------------------------------------------------------
/docs/source/quickstart.mdx:
--------------------------------------------------------------------------------
```markdown
  1 | ---
  2 | title: Apollo MCP Server Quickstart
  3 | subtitle: Create and run an MCP server in minutes with Apollo
  4 | ---
  5 | 
  6 | Apollo MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes your GraphQL API operations as MCP tools.
  7 | 
  8 | This guide walks you through the process of creating, running and configuring an MCP server with Apollo.
  9 | 
 10 | ## Prerequisites
 11 | 
 12 | - [Rover CLI](/rover/getting-started) v0.36 or later. We'll use Rover to initialize a project and run the MCP server. Follow the instructions for [installing](/rover/getting-started) and [authenticating](/rover/getting-started#connecting-to-graphos) Rover with a GraphOS account.
 13 | - [Node.js](https://nodejs.org/) v18 or later (for `mcp-remote`)
 14 | - [Claude Desktop](https://claude.ai/download) or another MCP-compatible client
 15 | 
 16 | ## Step 1: Create an MCP server
 17 | 
 18 | Run the interactive initialization command:
 19 | 
 20 | ```bash showLineNumbers=false
 21 | rover init --mcp
 22 | ```
 23 | 
 24 | The CLI wizard guides you through several prompts. 
 25 | 
 26 | Select **Create MCP tools from a new Apollo GraphOS project** and **Apollo graph with Connectors (connect to REST services)** as your starting point.
 27 | 
 28 | You'll also need to select your organization and give your project a name and ID.
 29 | 
 30 | The wizard shows all files that will be created, including:
 31 | 
 32 | - MCP server configuration files
 33 | - GraphQL schema and operations
 34 | - Docker setup for (optional) deployment
 35 | 
 36 | Type `Y` to confirm and create your project files.
 37 | 
 38 | ## Step 2: Run your MCP Server
 39 | 
 40 | You can start your MCP server locally with `rover dev`.
 41 | 
 42 | 1. Choose the environment-specific command to load environment variables from the provided `.env` file and start the MCP server.
 43 | 
 44 |     <Tabs>
 45 | 
 46 |         <Tab label="Linux / MacOS">
 47 |         
 48 |         ```terminal showLineNumbers=false
 49 |         set -a && source .env && set +a && rover dev --supergraph-config supergraph.yaml --mcp .apollo/mcp.local.yaml
 50 |         ```
 51 | 
 52 |         </Tab>
 53 | 
 54 |         <Tab label="Windows Powershell">
 55 | 
 56 |         ```terminal showLineNumbers=false
 57 |         Get-Content .env | ForEach-Object { $name, $value = $_.split('=',2); [System.Environment]::SetEnvironmentVariable($name, $value) }
 58 |         rover dev --supergraph-config supergraph.yaml --mcp .apollo/mcp.local.yaml
 59 |         ```
 60 |         </Tab>
 61 |         
 62 |     </Tabs>
 63 | 
 64 | 1. You should see some output indicating that the GraphQL server is running at `http://localhost:4000` and the MCP server is running at `http://127.0.0.1:8000`.
 65 | 
 66 | 1. In a new terminal window, run the MCP Inspector to verify the server is running:
 67 | 
 68 |     ```terminal showLineNumbers=false
 69 |     npx @modelcontextprotocol/inspector http://127.0.0.1:8000/mcp --transport http
 70 |     ```
 71 | 
 72 | 1. This will automatically open your browser to `http://127.0.0.1:6274`.
 73 | 
 74 | 1. Click **Connect**, then **List Tools** to see the available tools.
 75 | 
 76 | ## Step 3: Connect to an MCP client
 77 | 
 78 | Apollo MCP Server works with any MCP-compatible client. Choose your favorite client and follow the instructions to connect.
 79 | 
 80 | <ExpansionPanel title="Claude Desktop (recommended)">
 81 | 
 82 | Open the `claude_desktop_config.json` file in one of the following paths:
 83 | 
 84 | - Mac OS: `~/Library/Application\ Support/Claude/claude_desktop_config.json`
 85 | - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
 86 | - Linux: `~/.config/Claude/claude_desktop_config.json`
 87 | 
 88 | Copy the configuration:
 89 | 
 90 | ```json
 91 | {
 92 |   "mcpServers": {
 93 |     "mcp-My API": {
 94 |       "command": "npx",
 95 |       "args": [
 96 |         "mcp-remote",
 97 |         "http://127.0.0.1:8000/mcp"
 98 |       ]
 99 |     }
100 |   }
101 | }
102 | ```
103 | 
104 | </ExpansionPanel>
105 | 
106 | <ExpansionPanel title="Claude Code">
107 | Install using the CLI:
108 | 
109 | ```bash
110 | claude mcp add apollo-mcp npx mcp-remote http://127.0.0.1:8000/mcp
111 | ```
112 | </ExpansionPanel>
113 | 
114 | <ExpansionPanel title="Cursor">
115 | Click the button to quick install:
116 | 
117 | <a href="cursor://anysphere.cursor-deeplink/mcp/install?name=apollo-mcp&config=eyJjb21tYW5kIjoibnB4IG1jcC1yZW1vdGUgaHR0cDovLzEyNy4wLjAuMTo1MDUwL21jcCJ9">
118 |   <img
119 |     src="https://cursor.com/deeplink/mcp-install-dark.svg"
120 |     alt="Install Apollo MCP Server"
121 |     width="200"
122 |   />
123 | </a>
124 | 
125 | Or install manually:
126 | 
127 | 1. Go to **Cursor Settings** → **MCP** → **Add new MCP Server**
128 | 2. Name: `Apollo MCP` (choose a title)
129 | 3. Command: `npx`
130 | 4. Arguments: `["mcp-remote", "http://127.0.0.1:8000/mcp"]`
131 | </ExpansionPanel>
132 | 
133 | <ExpansionPanel title="Goose">
134 | Add Apollo MCP Server to your Goose configuration. Edit your `~/.config/goose/profiles.yaml`:
135 | 
136 | ```yaml
137 | default:
138 |   provider: openai
139 |   processor: gpt-4
140 |   accelerator: gpt-4o-mini
141 |   moderator: passive
142 |   toolkits:
143 |     - name: developer
144 |     - name: mcp
145 |       requires:
146 |         apollo-mcp:
147 |           command: npx
148 |           args:
149 |             - mcp-remote
150 |             - http://127.0.0.1:8000/mcp
151 | ```
152 | 
153 | Or use the Goose CLI to add the MCP server:
154 | 
155 | ```bash
156 | goose mcp add apollo-mcp npx mcp-remote http://127.0.0.1:8000/mcp
157 | ```
158 | </ExpansionPanel>
159 | 
160 | <ExpansionPanel title="Cline (VS Code Extension)">
161 | 1. Go to **Advanced settings** → **Extensions** → **Add custom extension**
162 | 2. Name: `Apollo MCP`
163 | 3. Type: **STDIO**
164 | 4. Command: `npx mcp-remote http://127.0.0.1:8000/mcp`
165 | </ExpansionPanel>
166 | 
167 | <ExpansionPanel title="OpenCode">
168 | Edit `~/.config/opencode/opencode.json`:
169 | 
170 | ```json
171 | {
172 |   "$schema": "https://opencode.ai/config.json",
173 |   "mcp": {
174 |     "apollo-mcp": {
175 |       "type": "local", 
176 |       "command": [
177 |         "npx",
178 |         "mcp-remote",
179 |         "http://127.0.0.1:8000/mcp"
180 |       ],
181 |       "enabled": true
182 |     }
183 |   }
184 | }
185 | ```
186 | </ExpansionPanel>
187 | 
188 | <ExpansionPanel title="Windsurf">
189 | 1. Go to **Windsurf Settings → MCP → Add new MCP Server**
190 | 2. Name: `Apollo MCP`
191 | 3. Command: `npx`
192 | 4. Arguments: `["mcp-remote", "http://127.0.0.1:8000/mcp"]`
193 | 
194 | Alternatively, edit your Windsurf configuration file directly:
195 | 
196 | ```json
197 | {
198 |   "mcpServers": {
199 |     "apollo-mcp": {
200 |       "command": "npx",
201 |       "args": [
202 |         "mcp-remote",
203 |         "http://127.0.0.1:8000/mcp"
204 |       ]
205 |     }
206 |   }
207 | }
208 | ```
209 | </ExpansionPanel>
210 | 
211 | 1. Restart your MCP client.
212 | 1. Test the connection by asking: "What MCP tools do you have available?". 
213 | 1. Verify GraphQL operations are listed as available tools.
214 | 1. Test a query using one of your configured operations.
215 | 
216 | ## Step 4: Define MCP tools
217 | 
218 | MCP tools are defined as GraphQL operations. The project template currently uses operation collections as the source of its tools.
219 | 
220 | <Note>
221 | 
222 | See [Define MCP Tools](/apollo-mcp-server/define-tools) for other ways to define MCP tools.
223 | 
224 | </Note>
225 | 
226 | 1. Navigate to Sandbox at [http://localhost:4000](http://localhost:4000).
227 | 1. Click the Bookmark icon to open Operation Collections.
228 | 1. Click  **Sandbox** beside "Showing saved operations for your Sandbox, across all endpoints" and select your graph. This represents the graph name and ID you used when creating your project.
229 | 1. You'll see an operation collection called "Default MCP Tools".
230 | 1. Create a new operation in the middle panel:
231 | 
232 |     ```graphql
233 |     # Retrieves product information
234 |     query GetProducts {
235 |       products {
236 |         id
237 |         name
238 |         description
239 |       }
240 |     }
241 |     ```
242 | 
243 | 1. Click the **Save** button and give it the name `GetProducts`.
244 | 1. Select the `Default MCP Tools` collection and click **Save**.
245 | 1. Restart your MCP client and test the connection by asking: "What MCP tools do you have available?". You should see the `GetProducts` tool listed. You can also test this with MCP Inspector.
246 | 
247 | ## Step 5: Deploy your MCP server
248 | 
249 | Apollo MCP Server can run in any container environment.
250 | 
251 | ### Using the Apollo Runtime Container
252 | 
253 | Your project includes a pre-configured `mcp.Dockerfile` for easy deployment. This container includes:
254 | 
255 | - Apollo Router for serving your GraphQL API
256 | - Apollo MCP Server for MCP protocol support
257 | - All necessary dependencies
258 | 
259 | 1. Build the container:
260 | 
261 |     ```bash
262 |     docker build -f mcp.Dockerfile -t my-mcp-server .
263 |     ```
264 | 
265 | 1. Run locally:
266 | 
267 |     ```bash
268 |     docker run -p 4000:4000 -p 8000:8000 \
269 |       -e APOLLO_KEY=$APOLLO_KEY \
270 |       -e APOLLO_GRAPH_REF=$APOLLO_GRAPH_REF \
271 |       -e MCP_ENABLE=1 \
272 |       my-mcp-server
273 |     ```
274 | 
275 | 1. Deploy to your platform. The container can be deployed to any platform supporting Docker, such as: AWS ECS/Fargate, Google Cloud Run, Azure Container Instances, Kubernetes, Fly.io, Railway, Render.
276 | 
277 | 1. Ensure these variables are set in your deployment environment:
278 | 
279 | | Variable                     | Description                     | Required |
280 | | ---------------------------- | ------------------------------- | -------- |
281 | | `APOLLO_KEY`                 | Your graph's API key            | Yes      |
282 | | `APOLLO_GRAPH_REF`           | Your graph reference            | Yes      |
283 | | `APOLLO_MCP_TRANSPORT__PORT` | MCP server port (default: 8000) | No       |
284 | | `APOLLO_ROUTER_PORT`         | Router port (default: 4000)     | No       |
285 | 
286 | For more deployment options, see the [Deploy the MCP Server](/apollo-mcp-server/deploy) page.
287 | 
288 | ### Update client configuration
289 | 
290 | After deploying, update your MCP client configuration to use the deployed URL:
291 | 
292 | ```json
293 | {
294 |   "mcpServers": {
295 |     "my-api": {
296 |       "command": "npx",
297 |       "args": [
298 |         "mcp-remote",
299 |         "https://your-deployed-server.com/mcp"
300 |       ]
301 |     }
302 |   }
303 | }
304 | ```
305 | 
306 | ## Troubleshooting
307 | 
308 | **Client doesn't see tools:**
309 | - Ensure you restarted your MCP client after configuration
310 | - Verify the Apollo MCP Server is running (`rover dev` command)
311 | - Check port numbers match between server and client config
312 | 
313 | **Connection refused errors:**
314 | - Confirm the server is running on the correct port
315 | - Verify firewall settings allow connections to localhost:8000
316 | - For remote connections, ensure the host is set to `0.0.0.0` in your config
317 | 
318 | **Authentication issues:**
319 | - Verify environment variables are properly set
320 | - Check that your GraphQL endpoint accepts the provided headers
321 | - When using `rover dev` you can test your GraphQL endpoint using Sandbox at [http://localhost:4000](http://localhost:4000)
322 | 
323 | ## Additional resources
324 | 
325 | - [Tutorial: Getting started with MCP and GraphQL](https://www.apollographql.com/tutorials/intro-mcp-graphql)
326 | - [Tutorial: Agentic GraphQL: MCP for the Enterprise](https://www.apollographql.com/tutorials/enterprise-mcp-graphql)
327 | - [Blog: Getting Started with Apollo MCP Server](https://www.apollographql.com/blog/getting-started-with-apollo-mcp-server-for-any-graphql-api)
328 | 
329 | ### Getting help
330 | 
331 | If you're still having issues:
332 | 
333 | - Check [Apollo MCP Server GitHub issues](https://github.com/apollographql/apollo-mcp-server/issues)
334 | - Join the [Apollo community forums](https://community.apollographql.com/c/mcp-server/41)
335 | - Contact your Apollo representative for direct support
336 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/headers.rs:
--------------------------------------------------------------------------------
```rust
  1 | use std::ops::Deref;
  2 | use std::str::FromStr;
  3 | 
  4 | use headers::HeaderMapExt;
  5 | use http::Extensions;
  6 | use reqwest::header::{HeaderMap, HeaderName};
  7 | 
  8 | use crate::auth::ValidToken;
  9 | 
 10 | /// List of header names to forward from MCP clients to GraphQL API
 11 | pub type ForwardHeaders = Vec<String>;
 12 | 
 13 | /// Build headers for a GraphQL request by combining static headers with forwarded headers
 14 | pub fn build_request_headers(
 15 |     static_headers: &HeaderMap,
 16 |     forward_header_names: &ForwardHeaders,
 17 |     incoming_headers: &HeaderMap,
 18 |     extensions: &Extensions,
 19 |     disable_auth_token_passthrough: bool,
 20 | ) -> HeaderMap {
 21 |     // Starts with static headers
 22 |     let mut headers = static_headers.clone();
 23 | 
 24 |     // Forward headers dynamically
 25 |     forward_headers(forward_header_names, incoming_headers, &mut headers);
 26 | 
 27 |     // Optionally extract the validated token and propagate it to upstream servers if present
 28 |     if !disable_auth_token_passthrough && let Some(token) = extensions.get::<ValidToken>() {
 29 |         headers.typed_insert(token.deref().clone());
 30 |     }
 31 | 
 32 |     // Forward the mcp-session-id header if present
 33 |     if let Some(session_id) = incoming_headers.get("mcp-session-id") {
 34 |         headers.insert("mcp-session-id", session_id.clone());
 35 |     }
 36 | 
 37 |     headers
 38 | }
 39 | 
 40 | /// Forward matching headers from incoming headers to outgoing headers
 41 | fn forward_headers(names: &[String], incoming: &HeaderMap, outgoing: &mut HeaderMap) {
 42 |     for header in names {
 43 |         if let Ok(header_name) = HeaderName::from_str(header)
 44 |             && let Some(value) = incoming.get(&header_name)
 45 |             // Hop-by-hop headers are blocked per RFC 7230: https://datatracker.ietf.org/doc/html/rfc7230#section-6.1
 46 |             && !matches!(
 47 |                 header_name.as_str().to_lowercase().as_str(),
 48 |                 "connection"
 49 |                     | "keep-alive"
 50 |                     | "proxy-authenticate"
 51 |                     | "proxy-authorization"
 52 |                     | "te"
 53 |                     | "trailers"
 54 |                     | "transfer-encoding"
 55 |                     | "upgrade"
 56 |                     | "content-length"
 57 |             )
 58 |         {
 59 |             outgoing.insert(header_name, value.clone());
 60 |         }
 61 |     }
 62 | }
 63 | 
 64 | #[cfg(test)]
 65 | mod tests {
 66 |     use super::*;
 67 |     use headers::Authorization;
 68 |     use http::Extensions;
 69 |     use reqwest::header::HeaderValue;
 70 | 
 71 |     use crate::auth::ValidToken;
 72 | 
 73 |     #[test]
 74 |     fn test_build_request_headers_includes_static_headers() {
 75 |         let mut static_headers = HeaderMap::new();
 76 |         static_headers.insert("x-api-key", HeaderValue::from_static("static-key"));
 77 |         static_headers.insert("user-agent", HeaderValue::from_static("mcp-server"));
 78 | 
 79 |         let forward_header_names = vec![];
 80 |         let incoming_headers = HeaderMap::new();
 81 |         let extensions = Extensions::new();
 82 | 
 83 |         let result = build_request_headers(
 84 |             &static_headers,
 85 |             &forward_header_names,
 86 |             &incoming_headers,
 87 |             &extensions,
 88 |             false,
 89 |         );
 90 | 
 91 |         assert_eq!(result.get("x-api-key").unwrap(), "static-key");
 92 |         assert_eq!(result.get("user-agent").unwrap(), "mcp-server");
 93 |     }
 94 | 
 95 |     #[test]
 96 |     fn test_build_request_headers_forwards_configured_headers() {
 97 |         let static_headers = HeaderMap::new();
 98 |         let forward_header_names = vec!["x-tenant-id".to_string(), "x-trace-id".to_string()];
 99 | 
100 |         let mut incoming_headers = HeaderMap::new();
101 |         incoming_headers.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
102 |         incoming_headers.insert("x-trace-id", HeaderValue::from_static("trace-456"));
103 |         incoming_headers.insert("other-header", HeaderValue::from_static("ignored"));
104 | 
105 |         let extensions = Extensions::new();
106 | 
107 |         let result = build_request_headers(
108 |             &static_headers,
109 |             &forward_header_names,
110 |             &incoming_headers,
111 |             &extensions,
112 |             false,
113 |         );
114 | 
115 |         assert_eq!(result.get("x-tenant-id").unwrap(), "tenant-123");
116 |         assert_eq!(result.get("x-trace-id").unwrap(), "trace-456");
117 |         assert!(result.get("other-header").is_none());
118 |     }
119 | 
120 |     #[test]
121 |     fn test_build_request_headers_adds_oauth_token_when_enabled() {
122 |         let static_headers = HeaderMap::new();
123 |         let forward_header_names = vec![];
124 |         let incoming_headers = HeaderMap::new();
125 | 
126 |         let mut extensions = Extensions::new();
127 |         let token = ValidToken(Authorization::bearer("test-token").unwrap());
128 |         extensions.insert(token);
129 | 
130 |         let result = build_request_headers(
131 |             &static_headers,
132 |             &forward_header_names,
133 |             &incoming_headers,
134 |             &extensions,
135 |             false,
136 |         );
137 | 
138 |         assert!(result.get("authorization").is_some());
139 |         assert_eq!(result.get("authorization").unwrap(), "Bearer test-token");
140 |     }
141 | 
142 |     #[test]
143 |     fn test_build_request_headers_skips_oauth_token_when_disabled() {
144 |         let static_headers = HeaderMap::new();
145 |         let forward_header_names = vec![];
146 |         let incoming_headers = HeaderMap::new();
147 | 
148 |         let mut extensions = Extensions::new();
149 |         let token = ValidToken(Authorization::bearer("test-token").unwrap());
150 |         extensions.insert(token);
151 | 
152 |         let result = build_request_headers(
153 |             &static_headers,
154 |             &forward_header_names,
155 |             &incoming_headers,
156 |             &extensions,
157 |             true,
158 |         );
159 | 
160 |         assert!(result.get("authorization").is_none());
161 |     }
162 | 
163 |     #[test]
164 |     fn test_build_request_headers_forwards_mcp_session_id() {
165 |         let static_headers = HeaderMap::new();
166 |         let forward_header_names = vec![];
167 | 
168 |         let mut incoming_headers = HeaderMap::new();
169 |         incoming_headers.insert("mcp-session-id", HeaderValue::from_static("session-123"));
170 | 
171 |         let extensions = Extensions::new();
172 | 
173 |         let result = build_request_headers(
174 |             &static_headers,
175 |             &forward_header_names,
176 |             &incoming_headers,
177 |             &extensions,
178 |             false,
179 |         );
180 | 
181 |         assert_eq!(result.get("mcp-session-id").unwrap(), "session-123");
182 |     }
183 | 
184 |     #[test]
185 |     fn test_build_request_headers_combined_scenario() {
186 |         // Static headers
187 |         let mut static_headers = HeaderMap::new();
188 |         static_headers.insert("x-api-key", HeaderValue::from_static("static-key"));
189 | 
190 |         // Forward specific headers
191 |         let forward_header_names = vec!["x-tenant-id".to_string()];
192 | 
193 |         // Incoming headers
194 |         let mut incoming_headers = HeaderMap::new();
195 |         incoming_headers.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
196 |         incoming_headers.insert("mcp-session-id", HeaderValue::from_static("session-456"));
197 |         incoming_headers.insert(
198 |             "ignored-header",
199 |             HeaderValue::from_static("should-not-appear"),
200 |         );
201 | 
202 |         // OAuth token
203 |         let mut extensions = Extensions::new();
204 |         let token = ValidToken(Authorization::bearer("oauth-token").unwrap());
205 |         extensions.insert(token);
206 | 
207 |         let result = build_request_headers(
208 |             &static_headers,
209 |             &forward_header_names,
210 |             &incoming_headers,
211 |             &extensions,
212 |             false,
213 |         );
214 | 
215 |         // Verify all parts combined correctly
216 |         assert_eq!(result.get("x-api-key").unwrap(), "static-key");
217 |         assert_eq!(result.get("x-tenant-id").unwrap(), "tenant-123");
218 |         assert_eq!(result.get("mcp-session-id").unwrap(), "session-456");
219 |         assert_eq!(result.get("authorization").unwrap(), "Bearer oauth-token");
220 |         assert!(result.get("ignored-header").is_none());
221 |     }
222 | 
223 |     #[test]
224 |     fn test_forward_headers_no_headers_by_default() {
225 |         let names: Vec<String> = vec![];
226 | 
227 |         let mut incoming = HeaderMap::new();
228 |         incoming.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
229 | 
230 |         let mut outgoing = HeaderMap::new();
231 | 
232 |         forward_headers(&names, &incoming, &mut outgoing);
233 | 
234 |         assert!(outgoing.is_empty());
235 |     }
236 | 
237 |     #[test]
238 |     fn test_forward_headers_only_specific_headers() {
239 |         let names = vec![
240 |             "x-tenant-id".to_string(),     // Multi-tenancy
241 |             "x-trace-id".to_string(),      // Distributed tracing
242 |             "x-geo-country".to_string(),   // Geo information from CDN
243 |             "x-experiment-id".to_string(), // A/B testing
244 |             "ai-client-name".to_string(),  // Client identification
245 |         ];
246 | 
247 |         let mut incoming = HeaderMap::new();
248 |         incoming.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
249 |         incoming.insert("x-trace-id", HeaderValue::from_static("trace-456"));
250 |         incoming.insert("x-geo-country", HeaderValue::from_static("US"));
251 |         incoming.insert("x-experiment-id", HeaderValue::from_static("exp-789"));
252 |         incoming.insert("ai-client-name", HeaderValue::from_static("claude"));
253 |         incoming.insert("other-header", HeaderValue::from_static("ignored"));
254 | 
255 |         let mut outgoing = HeaderMap::new();
256 | 
257 |         forward_headers(&names, &incoming, &mut outgoing);
258 | 
259 |         assert_eq!(outgoing.get("x-tenant-id").unwrap(), "tenant-123");
260 |         assert_eq!(outgoing.get("x-trace-id").unwrap(), "trace-456");
261 |         assert_eq!(outgoing.get("x-geo-country").unwrap(), "US");
262 |         assert_eq!(outgoing.get("x-experiment-id").unwrap(), "exp-789");
263 |         assert_eq!(outgoing.get("ai-client-name").unwrap(), "claude");
264 | 
265 |         assert!(outgoing.get("other-header").is_none());
266 |     }
267 | 
268 |     #[test]
269 |     fn test_forward_headers_blocks_hop_by_hop_headers() {
270 |         let names = vec!["connection".to_string(), "content-length".to_string()];
271 | 
272 |         let mut incoming = HeaderMap::new();
273 |         incoming.insert("connection", HeaderValue::from_static("keep-alive"));
274 |         incoming.insert("content-length", HeaderValue::from_static("1234"));
275 | 
276 |         let mut outgoing = HeaderMap::new();
277 | 
278 |         forward_headers(&names, &incoming, &mut outgoing);
279 | 
280 |         assert!(outgoing.get("connection").is_none());
281 |         assert!(outgoing.get("content-length").is_none());
282 |     }
283 | 
284 |     #[test]
285 |     fn test_forward_headers_case_insensitive_matching() {
286 |         let names = vec!["X-Tenant-ID".to_string()];
287 | 
288 |         let mut incoming = HeaderMap::new();
289 |         incoming.insert("x-tenant-id", HeaderValue::from_static("tenant-123"));
290 | 
291 |         let mut outgoing = HeaderMap::new();
292 |         forward_headers(&names, &incoming, &mut outgoing);
293 | 
294 |         assert_eq!(outgoing.get("x-tenant-id").unwrap(), "tenant-123");
295 |     }
296 | }
297 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/introspection/tools/introspect.rs:
--------------------------------------------------------------------------------
```rust
  1 | use crate::errors::McpError;
  2 | use crate::introspection::minify::MinifyExt as _;
  3 | use crate::schema_from_type;
  4 | use crate::schema_tree_shake::{DepthLimit, SchemaTreeShaker};
  5 | use apollo_compiler::Schema;
  6 | use apollo_compiler::ast::OperationType;
  7 | use apollo_compiler::schema::ExtendedType;
  8 | use apollo_compiler::validation::Valid;
  9 | use rmcp::model::{CallToolResult, Content, Tool};
 10 | use rmcp::schemars::JsonSchema;
 11 | use rmcp::serde_json::Value;
 12 | use rmcp::{schemars, serde_json};
 13 | use serde::Deserialize;
 14 | use std::sync::Arc;
 15 | use tokio::sync::Mutex;
 16 | 
 17 | /// The name of the tool to get GraphQL schema type information
 18 | pub const INTROSPECT_TOOL_NAME: &str = "introspect";
 19 | 
 20 | /// A tool to get detailed information about specific types from the GraphQL schema.
 21 | #[derive(Clone)]
 22 | pub struct Introspect {
 23 |     schema: Arc<Mutex<Valid<Schema>>>,
 24 |     allow_mutations: bool,
 25 |     minify: bool,
 26 |     pub tool: Tool,
 27 | }
 28 | 
 29 | /// Input for the introspect tool.
 30 | #[derive(JsonSchema, Deserialize, Debug)]
 31 | pub struct Input {
 32 |     /// The name of the type to get information about.
 33 |     type_name: String,
 34 |     /// How far to recurse the type hierarchy. Use 0 for no limit. Defaults to 1.
 35 |     #[serde(default = "default_depth")]
 36 |     depth: usize,
 37 | }
 38 | 
 39 | impl Introspect {
 40 |     pub fn new(
 41 |         schema: Arc<Mutex<Valid<Schema>>>,
 42 |         root_query_type: Option<String>,
 43 |         root_mutation_type: Option<String>,
 44 |         minify: bool,
 45 |     ) -> Self {
 46 |         Self {
 47 |             schema,
 48 |             allow_mutations: root_mutation_type.is_some(),
 49 |             minify,
 50 |             tool: Tool::new(
 51 |                 INTROSPECT_TOOL_NAME,
 52 |                 tool_description(root_query_type, root_mutation_type, minify),
 53 |                 schema_from_type!(Input),
 54 |             ),
 55 |         }
 56 |     }
 57 | 
 58 |     #[tracing::instrument(skip(self))]
 59 |     pub async fn execute(&self, input: Input) -> Result<CallToolResult, McpError> {
 60 |         let schema = self.schema.lock().await;
 61 |         let type_name = input.type_name.as_str();
 62 |         let mut tree_shaker = SchemaTreeShaker::new(&schema);
 63 |         match schema.types.get(type_name) {
 64 |             Some(extended_type) => tree_shaker.retain_type(
 65 |                 extended_type,
 66 |                 None,
 67 |                 if input.depth > 0 {
 68 |                     DepthLimit::Limited(input.depth)
 69 |                 } else {
 70 |                     DepthLimit::Unlimited
 71 |                 },
 72 |             ),
 73 |             None => {
 74 |                 return Ok(CallToolResult {
 75 |                     content: vec![],
 76 |                     is_error: None,
 77 |                     meta: None,
 78 |                     structured_content: None,
 79 |                 });
 80 |             }
 81 |         }
 82 |         let shaken = tree_shaker.shaken().unwrap_or_else(|schema| schema.partial);
 83 | 
 84 |         Ok(CallToolResult {
 85 |             content: shaken
 86 |                 .types
 87 |                 .iter()
 88 |                 .filter(|(_name, extended_type)| {
 89 |                     !extended_type.is_built_in()
 90 |                         && schema
 91 |                             .root_operation(OperationType::Mutation)
 92 |                             .is_none_or(|root_name| {
 93 |                                 // Allow introspection of the mutation type itself even when mutations are disabled
 94 |                                 extended_type.name() != root_name
 95 |                                     || type_name == root_name.as_str()
 96 |                                     || self.allow_mutations
 97 |                             })
 98 |                         && schema
 99 |                             .root_operation(OperationType::Subscription)
100 |                             .is_none_or(|root_name| extended_type.name() != root_name)
101 |                 })
102 |                 .map(|(_, extended_type)| extended_type)
103 |                 .map(|extended_type| self.serialize(extended_type))
104 |                 .map(Content::text)
105 |                 .collect(),
106 |             is_error: None,
107 |             meta: None,
108 |             // The content being returned is a raw string, so no need to create structured content for it
109 |             structured_content: None,
110 |         })
111 |     }
112 | 
113 |     fn serialize(&self, extended_type: &ExtendedType) -> String {
114 |         if self.minify {
115 |             extended_type.minify()
116 |         } else {
117 |             extended_type.serialize().to_string()
118 |         }
119 |     }
120 | }
121 | 
122 | fn tool_description(
123 |     root_query_type: Option<String>,
124 |     root_mutation_type: Option<String>,
125 |     minify: bool,
126 | ) -> String {
127 |     if minify {
128 |         "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()
129 |     } else {
130 |         format!(
131 |             "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.",
132 |             root_query_type.as_deref().unwrap_or("Query"),
133 |             root_mutation_type.as_deref().unwrap_or("Mutation")
134 |         )
135 |     }
136 | }
137 | 
138 | /// The default depth to recurse the type hierarchy.
139 | fn default_depth() -> usize {
140 |     1
141 | }
142 | 
143 | #[cfg(test)]
144 | mod tests {
145 |     use super::*;
146 |     use apollo_compiler::Schema;
147 |     use apollo_compiler::validation::Valid;
148 |     use rstest::{fixture, rstest};
149 |     use std::sync::Arc;
150 |     use tokio::sync::Mutex;
151 | 
152 |     const TEST_SCHEMA: &str = include_str!("testdata/schema.graphql");
153 | 
154 |     #[fixture]
155 |     fn schema() -> Valid<Schema> {
156 |         Schema::parse(TEST_SCHEMA, "schema.graphql")
157 |             .expect("Failed to parse test schema")
158 |             .validate()
159 |             .expect("Failed to validate test schema")
160 |     }
161 | 
162 |     #[rstest]
163 |     #[tokio::test]
164 |     async fn test_introspect_tool_description_is_not_minified(schema: Valid<Schema>) {
165 |         let introspect = Introspect::new(Arc::new(Mutex::new(schema)), None, None, false);
166 | 
167 |         let description = introspect.tool.description.unwrap();
168 | 
169 |         assert!(
170 |             description
171 |                 .contains("Get information about a given GraphQL type defined in the schema")
172 |         );
173 |         assert!(description.contains("Instructions: Use this tool to explore the schema"));
174 |         // Should not contain minification legend
175 |         assert!(!description.contains("T=type,I=input"));
176 |         // Should mention conditional search tool usage
177 |         assert!(description.contains("If the search tool is also available"));
178 |     }
179 | 
180 |     #[rstest]
181 |     #[tokio::test]
182 |     async fn test_introspect_tool_description_is_minified_with_an_appropriate_legend(
183 |         schema: Valid<Schema>,
184 |     ) {
185 |         let introspect = Introspect::new(Arc::new(Mutex::new(schema)), None, None, true);
186 | 
187 |         let description = introspect.tool.description.unwrap();
188 | 
189 |         // Should contain minification legend
190 |         assert!(description.contains("T=type,I=input,E=enum,U=union,F=interface"));
191 |         assert!(description.contains("s=String,i=Int,f=Float,b=Boolean,d=ID"));
192 |     }
193 | 
194 |     #[rstest]
195 |     #[tokio::test]
196 |     async fn test_introspect_query_depth_1_returns_fields(schema: Valid<Schema>) {
197 |         let introspect = Introspect::new(
198 |             Arc::new(Mutex::new(schema)),
199 |             Some("Query".to_string()),
200 |             Some("Mutation".to_string()),
201 |             false,
202 |         );
203 | 
204 |         let result = introspect
205 |             .execute(Input {
206 |                 type_name: "Query".to_string(),
207 |                 depth: 1,
208 |             })
209 |             .await
210 |             .expect("Introspect execution failed");
211 | 
212 |         let content = result
213 |             .content
214 |             .iter()
215 |             .filter_map(|c| {
216 |                 use rmcp::model::RawContent;
217 |                 use std::ops::Deref;
218 |                 let c = c.deref();
219 |                 match c {
220 |                     RawContent::Text(text) => Some(text.text.clone()),
221 |                     _ => None,
222 |                 }
223 |             })
224 |             .collect::<Vec<String>>()
225 |             .join("\n");
226 | 
227 |         // Query with depth 1 should return the Query type with its fields
228 |         assert!(!result.content.is_empty());
229 |         assert!(content.contains("type Query"));
230 |     }
231 | 
232 |     #[rstest]
233 |     #[tokio::test]
234 |     async fn test_introspect_mutation_depth_1_returns_fields(schema: Valid<Schema>) {
235 |         let introspect = Introspect::new(
236 |             Arc::new(Mutex::new(schema)),
237 |             Some("Query".to_string()),
238 |             Some("Mutation".to_string()),
239 |             false,
240 |         );
241 | 
242 |         let result = introspect
243 |             .execute(Input {
244 |                 type_name: "Mutation".to_string(),
245 |                 depth: 1,
246 |             })
247 |             .await
248 |             .expect("Introspect execution failed");
249 | 
250 |         let content = result
251 |             .content
252 |             .iter()
253 |             .filter_map(|c| {
254 |                 use rmcp::model::RawContent;
255 |                 use std::ops::Deref;
256 |                 let c = c.deref();
257 |                 match c {
258 |                     RawContent::Text(text) => Some(text.text.clone()),
259 |                     _ => None,
260 |                 }
261 |             })
262 |             .collect::<Vec<String>>()
263 |             .join("\n");
264 | 
265 |         // Mutation with depth 1 should return the Mutation type with its fields, just like Query
266 |         assert!(
267 |             !result.content.is_empty(),
268 |             "Mutation introspection should return content"
269 |         );
270 |         assert!(
271 |             content.contains("type Mutation"),
272 |             "Should contain Mutation type definition"
273 |         );
274 |     }
275 | 
276 |     #[rstest]
277 |     #[tokio::test]
278 |     async fn test_introspect_mutation_depth_1_with_mutations_disabled(schema: Valid<Schema>) {
279 |         // This test verifies the fix: when mutations are not allowed, mutation introspection should still work
280 |         let introspect = Introspect::new(
281 |             Arc::new(Mutex::new(schema)),
282 |             Some("Query".to_string()),
283 |             None,
284 |             false,
285 |         );
286 | 
287 |         let result = introspect
288 |             .execute(Input {
289 |                 type_name: "Mutation".to_string(),
290 |                 depth: 1,
291 |             })
292 |             .await
293 |             .expect("Introspect execution failed");
294 | 
295 |         let content = result
296 |             .content
297 |             .iter()
298 |             .filter_map(|c| {
299 |                 use rmcp::model::RawContent;
300 |                 use std::ops::Deref;
301 |                 let c = c.deref();
302 |                 match c {
303 |                     RawContent::Text(text) => Some(text.text.clone()),
304 |                     _ => None,
305 |                 }
306 |             })
307 |             .collect::<Vec<String>>()
308 |             .join("\n");
309 | 
310 |         // After the fix: mutation introspection should work even when mutations are disabled
311 |         assert!(
312 |             !result.content.is_empty(),
313 |             "Mutation introspection should return content even when mutations are disabled"
314 |         );
315 |         assert!(
316 |             content.contains("type Mutation"),
317 |             "Should contain Mutation type definition"
318 |         );
319 |     }
320 | }
321 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-registry/src/uplink/schema.rs:
--------------------------------------------------------------------------------
```rust
  1 | pub mod event;
  2 | mod schema_stream;
  3 | 
  4 | use std::convert::Infallible;
  5 | use std::str::FromStr;
  6 | 
  7 | use std::path::PathBuf;
  8 | use std::pin::Pin;
  9 | use std::time::Duration;
 10 | 
 11 | use crate::uplink::UplinkConfig;
 12 | use crate::uplink::stream_from_uplink;
 13 | use derive_more::Display;
 14 | use derive_more::From;
 15 | use educe::Educe;
 16 | use event::Event;
 17 | use event::Event::{NoMoreSchema, UpdateSchema};
 18 | use futures::prelude::*;
 19 | pub(crate) use schema_stream::SupergraphSdlQuery;
 20 | use url::Url;
 21 | 
 22 | /// Represents the new state of a schema after an update.
 23 | #[derive(Eq, PartialEq, Debug)]
 24 | pub struct SchemaState {
 25 |     pub sdl: String,
 26 |     pub(crate) launch_id: Option<String>,
 27 | }
 28 | 
 29 | impl FromStr for SchemaState {
 30 |     type Err = Infallible;
 31 | 
 32 |     fn from_str(s: &str) -> Result<Self, Self::Err> {
 33 |         Ok(Self {
 34 |             sdl: s.to_string(),
 35 |             launch_id: None,
 36 |         })
 37 |     }
 38 | }
 39 | 
 40 | type SchemaStream = Pin<Box<dyn Stream<Item = String> + Send>>;
 41 | 
 42 | /// The user supplied schema. Either a static string or a stream for hot reloading.
 43 | #[derive(From, Display, Educe)]
 44 | #[educe(Debug)]
 45 | #[non_exhaustive]
 46 | pub enum SchemaSource {
 47 |     /// A static schema.
 48 |     #[display("String")]
 49 |     Static { schema_sdl: String },
 50 | 
 51 |     /// A stream of schema.
 52 |     #[display("Stream")]
 53 |     Stream(#[educe(Debug(ignore))] SchemaStream),
 54 | 
 55 |     /// A YAML file that may be watched for changes.
 56 |     #[display("File")]
 57 |     File {
 58 |         /// The path of the schema file.
 59 |         path: PathBuf,
 60 | 
 61 |         /// `true` to watch the file for changes and hot apply them.
 62 |         watch: bool,
 63 |     },
 64 | 
 65 |     /// Apollo managed federation.
 66 |     #[display("Registry")]
 67 |     Registry(UplinkConfig),
 68 | 
 69 |     /// A list of URLs to fetch the schema from.
 70 |     #[display("URLs")]
 71 |     URLs {
 72 |         /// The URLs to fetch the schema from.
 73 |         urls: Vec<Url>,
 74 |     },
 75 | }
 76 | 
 77 | impl From<&'_ str> for SchemaSource {
 78 |     fn from(s: &'_ str) -> Self {
 79 |         Self::Static {
 80 |             schema_sdl: s.to_owned(),
 81 |         }
 82 |     }
 83 | }
 84 | 
 85 | impl SchemaSource {
 86 |     /// Convert this schema into a stream regardless of if is static or not. Allows for unified handling later.
 87 |     pub fn into_stream(self) -> impl Stream<Item = Event> {
 88 |         match self {
 89 |             SchemaSource::Static { schema_sdl: schema } => {
 90 |                 let update_schema = UpdateSchema(SchemaState {
 91 |                     sdl: schema,
 92 |                     launch_id: None,
 93 |                 });
 94 |                 stream::once(future::ready(update_schema)).boxed()
 95 |             }
 96 |             SchemaSource::Stream(stream) => stream
 97 |                 .map(|sdl| {
 98 |                     UpdateSchema(SchemaState {
 99 |                         sdl,
100 |                         launch_id: None,
101 |                     })
102 |                 })
103 |                 .boxed(),
104 |             SchemaSource::File {
105 |                 path,
106 |                 watch,
107 |             } => {
108 |                 // Sanity check, does the schema file exists, if it doesn't then bail.
109 |                 if !path.exists() {
110 |                     tracing::error!(
111 |                         "Supergraph schema at path '{}' does not exist.",
112 |                         path.to_string_lossy()
113 |                     );
114 |                     stream::empty().boxed()
115 |                 } else {
116 |                     //The schema file exists try and load it
117 |                     match std::fs::read_to_string(&path) {
118 |                         Ok(schema) => {
119 |                             if watch {
120 |                                 crate::files::watch(&path)
121 |                                     .filter_map(move |_| {
122 |                                         let path = path.clone();
123 |                                         async move {
124 |                                             match tokio::fs::read_to_string(&path).await {
125 |                                                 Ok(schema) => {
126 |                                                     let update_schema = UpdateSchema(SchemaState {
127 |                                                         sdl: schema,
128 |                                                         launch_id: None,
129 |                                                     });
130 |                                                     Some(update_schema)
131 |                                                 }
132 |                                                 Err(err) => {
133 |                                                     tracing::error!(reason = %err, "failed to read supergraph schema");
134 |                                                     None
135 |                                                 }
136 |                                             }
137 |                                         }
138 |                                     })
139 |                                     .boxed()
140 |                             } else {
141 |                                 let update_schema = UpdateSchema(SchemaState {
142 |                                     sdl: schema,
143 |                                     launch_id: None,
144 |                                 });
145 |                                 stream::once(future::ready(update_schema)).boxed()
146 |                             }
147 |                         }
148 |                         Err(err) => {
149 |                             tracing::error!(reason = %err, "failed to read supergraph schema");
150 |                             stream::empty().boxed()
151 |                         }
152 |                     }
153 |                 }
154 |             }
155 |             SchemaSource::Registry(uplink_config) => {
156 |                 stream_from_uplink::<SupergraphSdlQuery, SchemaState>(uplink_config)
157 |                     .filter_map(|res| {
158 |                         future::ready(match res {
159 |                             Ok(schema) => {
160 |                                 let update_schema = UpdateSchema(schema);
161 |                                 Some(update_schema)
162 |                             }
163 |                             Err(e) => {
164 |                                 tracing::error!("{}", e);
165 |                                 None
166 |                             }
167 |                         })
168 |                     })
169 |                     .boxed()
170 |             }
171 |             SchemaSource::URLs { urls } => {
172 |                 futures::stream::once(async move {
173 |                     fetch_supergraph_from_first_viable_url(&urls).await
174 |                 })
175 |                     .filter_map(|s| async move { s.map(Event::UpdateSchema) })
176 |                     .boxed()
177 |             }
178 |         }
179 |             .chain(stream::iter(vec![NoMoreSchema]))
180 |             .boxed()
181 |     }
182 | }
183 | 
184 | // Encapsulates fetching the schema from the first viable url.
185 | // It will try each url in order until it finds one that works.
186 | #[allow(clippy::unwrap_used)] // TODO - existing unwrap from router code
187 | async fn fetch_supergraph_from_first_viable_url(urls: &[Url]) -> Option<SchemaState> {
188 |     let Ok(client) = reqwest::Client::builder()
189 |         .no_gzip()
190 |         .timeout(Duration::from_secs(10))
191 |         .build()
192 |     else {
193 |         tracing::error!("failed to create HTTP client to fetch supergraph schema");
194 |         return None;
195 |     };
196 |     for url in urls {
197 |         match client.get(Url::parse(url.as_ref()).unwrap()).send().await {
198 |             Ok(res) if res.status().is_success() => match res.text().await {
199 |                 Ok(schema) => {
200 |                     return Some(SchemaState {
201 |                         sdl: schema,
202 |                         launch_id: None,
203 |                     });
204 |                 }
205 |                 Err(err) => {
206 |                     tracing::warn!(
207 |                         url.full = %url,
208 |                         reason = %err,
209 |                         "failed to fetch supergraph schema"
210 |                     )
211 |                 }
212 |             },
213 |             Ok(res) => tracing::warn!(
214 |                 http.response.status_code = res.status().as_u16(),
215 |                 url.full = %url,
216 |                 "failed to fetch supergraph schema"
217 |             ),
218 |             Err(err) => tracing::warn!(
219 |                 url.full = %url,
220 |                 reason = %err,
221 |                 "failed to fetch supergraph schema"
222 |             ),
223 |         }
224 |     }
225 |     tracing::error!("failed to fetch supergraph schema from all urls");
226 |     None
227 | }
228 | 
229 | #[cfg(test)]
230 | mod tests {
231 |     use std::env::temp_dir;
232 | 
233 |     use test_log::test;
234 |     use tracing_futures::WithSubscriber;
235 |     use wiremock::Mock;
236 |     use wiremock::MockServer;
237 |     use wiremock::ResponseTemplate;
238 |     use wiremock::matchers::method;
239 |     use wiremock::matchers::path;
240 | 
241 |     use super::*;
242 |     use crate::assert_snapshot_subscriber;
243 |     use crate::files::tests::create_temp_file;
244 |     use crate::files::tests::write_and_flush;
245 | 
246 |     #[test(tokio::test)]
247 |     async fn schema_by_file_watching() {
248 |         let (path, mut file) = create_temp_file();
249 |         let schema = include_str!("../testdata/supergraph.graphql");
250 |         write_and_flush(&mut file, schema).await;
251 |         let mut stream = SchemaSource::File { path, watch: true }
252 |             .into_stream()
253 |             .boxed();
254 | 
255 |         // First update is guaranteed
256 |         assert!(matches!(stream.next().await.unwrap(), UpdateSchema(_)));
257 | 
258 |         // Need different contents, since we won't get an event if content is the same
259 |         let schema_minimal = include_str!("../testdata/minimal_supergraph.graphql");
260 |         // Modify the file and try again
261 |         write_and_flush(&mut file, schema_minimal).await;
262 |         assert!(matches!(stream.next().await.unwrap(), UpdateSchema(_)));
263 |     }
264 | 
265 |     #[test(tokio::test)]
266 |     async fn schema_by_file_no_watch() {
267 |         let (path, mut file) = create_temp_file();
268 |         let schema = include_str!("../testdata/supergraph.graphql");
269 |         write_and_flush(&mut file, schema).await;
270 | 
271 |         let mut stream = SchemaSource::File { path, watch: false }.into_stream();
272 |         assert!(matches!(stream.next().await.unwrap(), UpdateSchema(_)));
273 |         assert!(matches!(stream.next().await.unwrap(), NoMoreSchema));
274 |     }
275 | 
276 |     #[test(tokio::test)]
277 |     async fn schema_by_file_missing() {
278 |         let mut stream = SchemaSource::File {
279 |             path: temp_dir().join("does_not_exist"),
280 |             watch: true,
281 |         }
282 |         .into_stream();
283 | 
284 |         // First update fails because the file is invalid.
285 |         assert!(matches!(stream.next().await.unwrap(), NoMoreSchema));
286 |     }
287 | 
288 |     const SCHEMA_1: &str = "schema1";
289 |     const SCHEMA_2: &str = "schema2";
290 |     #[test(tokio::test)]
291 |     async fn schema_by_url() {
292 |         async {
293 |             let mock_server = MockServer::start().await;
294 |             Mock::given(method("GET"))
295 |                 .and(path("/schema1"))
296 |                 .respond_with(ResponseTemplate::new(200).set_body_string(SCHEMA_1))
297 |                 .mount(&mock_server)
298 |                 .await;
299 |             Mock::given(method("GET"))
300 |                 .and(path("/schema2"))
301 |                 .respond_with(ResponseTemplate::new(200).set_body_string(SCHEMA_2))
302 |                 .mount(&mock_server)
303 |                 .await;
304 | 
305 |             let mut stream = SchemaSource::URLs {
306 |                 urls: vec![
307 |                     Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(),
308 |                     Url::parse(&format!("http://{}/schema2", mock_server.address())).unwrap(),
309 |                 ],
310 |             }
311 |                 .into_stream();
312 | 
313 |             assert!(
314 |                 matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema.sdl == SCHEMA_1)
315 |             );
316 |             assert!(matches!(stream.next().await.unwrap(), NoMoreSchema));
317 |         }
318 |             .with_subscriber(assert_snapshot_subscriber!())
319 |             .await;
320 |     }
321 | 
322 |     #[test(tokio::test)]
323 |     async fn schema_by_url_fallback() {
324 |         async {
325 |             let mock_server = MockServer::start().await;
326 |             Mock::given(method("GET"))
327 |                 .and(path("/schema1"))
328 |                 .respond_with(ResponseTemplate::new(400))
329 |                 .mount(&mock_server)
330 |                 .await;
331 |             Mock::given(method("GET"))
332 |                 .and(path("/schema2"))
333 |                 .respond_with(ResponseTemplate::new(200).set_body_string(SCHEMA_2))
334 |                 .mount(&mock_server)
335 |                 .await;
336 | 
337 |             let mut stream = SchemaSource::URLs {
338 |                 urls: vec![
339 |                     Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(),
340 |                     Url::parse(&format!("http://{}/schema2", mock_server.address())).unwrap(),
341 |                 ],
342 |             }
343 |                 .into_stream();
344 | 
345 |             assert!(
346 |                 matches!(stream.next().await.unwrap(), UpdateSchema(schema) if schema.sdl == SCHEMA_2)
347 |             );
348 |             assert!(matches!(stream.next().await.unwrap(), NoMoreSchema));
349 |         }
350 |             .with_subscriber(assert_snapshot_subscriber!({
351 |             "[].fields[\"url.full\"]" => "[url.full]"
352 |         }))
353 |             .await;
354 |     }
355 | 
356 |     #[test(tokio::test)]
357 |     async fn schema_by_url_all_fail() {
358 |         async {
359 |             let mock_server = MockServer::start().await;
360 |             Mock::given(method("GET"))
361 |                 .and(path("/schema1"))
362 |                 .respond_with(ResponseTemplate::new(400))
363 |                 .mount(&mock_server)
364 |                 .await;
365 |             Mock::given(method("GET"))
366 |                 .and(path("/schema2"))
367 |                 .respond_with(ResponseTemplate::new(400))
368 |                 .mount(&mock_server)
369 |                 .await;
370 | 
371 |             let mut stream = SchemaSource::URLs {
372 |                 urls: vec![
373 |                     Url::parse(&format!("http://{}/schema1", mock_server.address())).unwrap(),
374 |                     Url::parse(&format!("http://{}/schema2", mock_server.address())).unwrap(),
375 |                 ],
376 |             }
377 |             .into_stream();
378 | 
379 |             assert!(matches!(stream.next().await.unwrap(), NoMoreSchema));
380 |         }
381 |         .with_subscriber(assert_snapshot_subscriber!({
382 |             "[].fields[\"url.full\"]" => "[url.full]"
383 |         }))
384 |         .await;
385 |     }
386 | }
387 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/runtime/telemetry.rs:
--------------------------------------------------------------------------------
```rust
  1 | mod sampler;
  2 | 
  3 | use crate::runtime::Config;
  4 | use crate::runtime::filtering_exporter::FilteringExporter;
  5 | use crate::runtime::logging::Logging;
  6 | use crate::runtime::telemetry::sampler::SamplerOption;
  7 | use apollo_mcp_server::generated::telemetry::TelemetryAttribute;
  8 | use opentelemetry::{Key, KeyValue, global, trace::TracerProvider as _};
  9 | use opentelemetry_otlp::WithExportConfig;
 10 | use opentelemetry_sdk::metrics::{Instrument, Stream, Temporality};
 11 | use opentelemetry_sdk::{
 12 |     Resource,
 13 |     metrics::{MeterProviderBuilder, PeriodicReader, SdkMeterProvider},
 14 |     propagation::TraceContextPropagator,
 15 |     trace::{RandomIdGenerator, SdkTracerProvider},
 16 | };
 17 | use opentelemetry_semantic_conventions::{
 18 |     SCHEMA_URL,
 19 |     attribute::{DEPLOYMENT_ENVIRONMENT_NAME, SERVICE_VERSION},
 20 | };
 21 | use schemars::JsonSchema;
 22 | use serde::Deserialize;
 23 | use std::collections::HashSet;
 24 | use tracing_opentelemetry::{MetricsLayer, OpenTelemetryLayer};
 25 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
 26 | 
 27 | /// Telemetry related options
 28 | #[derive(Debug, Deserialize, JsonSchema, Default)]
 29 | pub struct Telemetry {
 30 |     exporters: Option<Exporters>,
 31 |     service_name: Option<String>,
 32 |     version: Option<String>,
 33 | }
 34 | 
 35 | #[derive(Debug, Deserialize, JsonSchema)]
 36 | pub struct Exporters {
 37 |     metrics: Option<MetricsExporters>,
 38 |     tracing: Option<TracingExporters>,
 39 | }
 40 | 
 41 | #[derive(Debug, Deserialize, JsonSchema)]
 42 | pub struct MetricsExporters {
 43 |     otlp: Option<OTLPMetricExporter>,
 44 |     omitted_attributes: Option<HashSet<TelemetryAttribute>>,
 45 | }
 46 | 
 47 | #[derive(Debug, Deserialize, JsonSchema)]
 48 | pub enum MetricTemporality {
 49 |     Cumulative,
 50 |     Delta,
 51 | }
 52 | 
 53 | impl OTLPMetricExporter {
 54 |     pub fn to_temporality(&self) -> Temporality {
 55 |         match self
 56 |             .temporality
 57 |             .as_ref()
 58 |             .unwrap_or(&MetricTemporality::Cumulative)
 59 |         {
 60 |             MetricTemporality::Cumulative => Temporality::Cumulative,
 61 |             MetricTemporality::Delta => Temporality::Delta,
 62 |         }
 63 |     }
 64 | }
 65 | 
 66 | #[derive(Debug, Deserialize, JsonSchema)]
 67 | pub struct OTLPMetricExporter {
 68 |     endpoint: String,
 69 |     protocol: String,
 70 |     temporality: Option<MetricTemporality>,
 71 | }
 72 | 
 73 | impl Default for OTLPMetricExporter {
 74 |     fn default() -> Self {
 75 |         Self {
 76 |             endpoint: "http://localhost:4317".into(),
 77 |             protocol: "grpc".into(),
 78 |             temporality: Some(MetricTemporality::Cumulative),
 79 |         }
 80 |     }
 81 | }
 82 | 
 83 | #[derive(Debug, Deserialize, JsonSchema)]
 84 | pub struct TracingExporters {
 85 |     otlp: Option<OTLPTracingExporter>,
 86 |     sampler: Option<SamplerOption>,
 87 |     omitted_attributes: Option<HashSet<TelemetryAttribute>>,
 88 | }
 89 | 
 90 | #[derive(Debug, Deserialize, JsonSchema)]
 91 | pub struct OTLPTracingExporter {
 92 |     endpoint: String,
 93 |     protocol: String,
 94 | }
 95 | 
 96 | impl Default for OTLPTracingExporter {
 97 |     fn default() -> Self {
 98 |         Self {
 99 |             endpoint: "http://localhost:4317".into(),
100 |             protocol: "grpc".into(),
101 |         }
102 |     }
103 | }
104 | 
105 | fn resource(telemetry: &Telemetry) -> Resource {
106 |     let service_name = telemetry
107 |         .service_name
108 |         .clone()
109 |         .unwrap_or_else(|| env!("CARGO_PKG_NAME").to_string());
110 | 
111 |     let service_version = telemetry
112 |         .version
113 |         .clone()
114 |         .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
115 | 
116 |     let deployment_env = std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string());
117 | 
118 |     Resource::builder()
119 |         .with_service_name(service_name)
120 |         .with_schema_url(
121 |             [
122 |                 KeyValue::new(SERVICE_VERSION, service_version),
123 |                 KeyValue::new(DEPLOYMENT_ENVIRONMENT_NAME, deployment_env),
124 |             ],
125 |             SCHEMA_URL,
126 |         )
127 |         .build()
128 | }
129 | 
130 | fn init_meter_provider(telemetry: &Telemetry) -> Result<SdkMeterProvider, anyhow::Error> {
131 |     let metrics_exporters = telemetry
132 |         .exporters
133 |         .as_ref()
134 |         .and_then(|exporters| exporters.metrics.as_ref());
135 | 
136 |     let otlp = metrics_exporters
137 |         .and_then(|metrics_exporters| metrics_exporters.otlp.as_ref())
138 |         .ok_or_else(|| {
139 |             anyhow::anyhow!("No metrics exporters configured, at least one is required")
140 |         })?;
141 | 
142 |     let exporter = match otlp.protocol.as_str() {
143 |         "grpc" => opentelemetry_otlp::MetricExporter::builder()
144 |             .with_tonic()
145 |             .with_endpoint(otlp.endpoint.clone())
146 |             .with_temporality(otlp.to_temporality())
147 |             .build()?,
148 |         "http/protobuf" => opentelemetry_otlp::MetricExporter::builder()
149 |             .with_http()
150 |             .with_endpoint(otlp.endpoint.clone())
151 |             .with_temporality(otlp.to_temporality())
152 |             .build()?,
153 |         other => {
154 |             return Err(anyhow::anyhow!(
155 |                 "Unsupported OTLP protocol: {other}. Supported protocols are: grpc, http/protobuf"
156 |             ));
157 |         }
158 |     };
159 | 
160 |     let omitted_attributes: HashSet<TelemetryAttribute> = metrics_exporters
161 |         .and_then(|exporters| exporters.omitted_attributes.clone())
162 |         .unwrap_or_default();
163 |     let included_attributes: Vec<Key> = TelemetryAttribute::included_attributes(omitted_attributes)
164 |         .iter()
165 |         .map(|a| a.to_key())
166 |         .collect();
167 | 
168 |     let reader = PeriodicReader::builder(exporter)
169 |         .with_interval(std::time::Duration::from_secs(30))
170 |         .build();
171 | 
172 |     let filtered_view = move |i: &Instrument| {
173 |         if i.name().starts_with("apollo.") {
174 |             Stream::builder()
175 |                 .with_allowed_attribute_keys(included_attributes.clone()) // if available in your version
176 |                 .build()
177 |                 .ok()
178 |         } else {
179 |             None
180 |         }
181 |     };
182 | 
183 |     let meter_provider = MeterProviderBuilder::default()
184 |         .with_resource(resource(telemetry))
185 |         .with_reader(reader)
186 |         .with_view(filtered_view)
187 |         .build();
188 | 
189 |     Ok(meter_provider)
190 | }
191 | 
192 | fn init_tracer_provider(telemetry: &Telemetry) -> Result<SdkTracerProvider, anyhow::Error> {
193 |     let tracer_exporters = telemetry
194 |         .exporters
195 |         .as_ref()
196 |         .and_then(|exporters| exporters.tracing.as_ref());
197 | 
198 |     let otlp = tracer_exporters
199 |         .and_then(|tracing_exporters| tracing_exporters.otlp.as_ref())
200 |         .ok_or_else(|| {
201 |             anyhow::anyhow!("No tracing exporters configured, at least one is required")
202 |         })?;
203 | 
204 |     let exporter = match otlp.protocol.as_str() {
205 |         "grpc" => opentelemetry_otlp::SpanExporter::builder()
206 |             .with_tonic()
207 |             .with_endpoint(otlp.endpoint.clone())
208 |             .build()?,
209 |         "http/protobuf" => opentelemetry_otlp::SpanExporter::builder()
210 |             .with_http()
211 |             .with_endpoint(otlp.endpoint.clone())
212 |             .build()?,
213 |         other => {
214 |             return Err(anyhow::anyhow!(
215 |                 "Unsupported OTLP protocol: {other}. Supported protocols are: grpc, http/protobuf"
216 |             ));
217 |         }
218 |     };
219 | 
220 |     let sampler: opentelemetry_sdk::trace::Sampler = tracer_exporters
221 |         .as_ref()
222 |         .and_then(|e| e.sampler.clone())
223 |         .unwrap_or_default()
224 |         .into();
225 | 
226 |     let omitted_attributes: HashSet<Key> = tracer_exporters
227 |         .and_then(|exporters| exporters.omitted_attributes.clone())
228 |         .map(|set| set.iter().map(|a| a.to_key()).collect())
229 |         .unwrap_or_default();
230 | 
231 |     let filtering_exporter = FilteringExporter::new(exporter, omitted_attributes);
232 | 
233 |     let tracer_provider = SdkTracerProvider::builder()
234 |         .with_id_generator(RandomIdGenerator::default())
235 |         .with_resource(resource(telemetry))
236 |         .with_batch_exporter(filtering_exporter)
237 |         .with_sampler(sampler)
238 |         .build();
239 | 
240 |     Ok(tracer_provider)
241 | }
242 | 
243 | /// Initialize tracing-subscriber and return TelemetryGuard for logging and opentelemetry-related termination processing
244 | pub fn init_tracing_subscriber(config: &Config) -> Result<TelemetryGuard, anyhow::Error> {
245 |     let tracer_provider = if let Some(exporters) = &config.telemetry.exporters {
246 |         if let Some(_tracing_exporters) = &exporters.tracing {
247 |             init_tracer_provider(&config.telemetry)?
248 |         } else {
249 |             SdkTracerProvider::builder().build()
250 |         }
251 |     } else {
252 |         SdkTracerProvider::builder().build()
253 |     };
254 |     let meter_provider = if let Some(exporters) = &config.telemetry.exporters {
255 |         if let Some(_metrics_exporters) = &exporters.metrics {
256 |             init_meter_provider(&config.telemetry)?
257 |         } else {
258 |             SdkMeterProvider::builder().build()
259 |         }
260 |     } else {
261 |         SdkMeterProvider::builder().build()
262 |     };
263 |     let env_filter = Logging::env_filter(&config.logging)?;
264 |     let (logging_layer, logging_guard) = Logging::logging_layer(&config.logging)?;
265 | 
266 |     let tracer = tracer_provider.tracer("apollo-mcp-trace");
267 | 
268 |     global::set_meter_provider(meter_provider.clone());
269 |     global::set_text_map_propagator(TraceContextPropagator::new());
270 |     global::set_tracer_provider(tracer_provider.clone());
271 | 
272 |     tracing_subscriber::registry()
273 |         .with(logging_layer)
274 |         .with(env_filter)
275 |         .with(MetricsLayer::new(meter_provider.clone()))
276 |         .with(OpenTelemetryLayer::new(tracer))
277 |         .try_init()?;
278 | 
279 |     Ok(TelemetryGuard {
280 |         tracer_provider,
281 |         meter_provider,
282 |         logging_guard,
283 |     })
284 | }
285 | 
286 | pub struct TelemetryGuard {
287 |     tracer_provider: SdkTracerProvider,
288 |     meter_provider: SdkMeterProvider,
289 |     logging_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
290 | }
291 | 
292 | impl Drop for TelemetryGuard {
293 |     fn drop(&mut self) {
294 |         if let Err(err) = self.tracer_provider.shutdown() {
295 |             tracing::error!("{err:?}");
296 |         }
297 |         if let Err(err) = self.meter_provider.shutdown() {
298 |             tracing::error!("{err:?}");
299 |         }
300 |         drop(self.logging_guard.take());
301 |     }
302 | }
303 | 
304 | #[cfg(test)]
305 | mod tests {
306 |     use super::*;
307 | 
308 |     fn test_config(
309 |         service_name: Option<&str>,
310 |         version: Option<&str>,
311 |         metrics: Option<MetricsExporters>,
312 |         tracing: Option<TracingExporters>,
313 |     ) -> Config {
314 |         Config {
315 |             telemetry: Telemetry {
316 |                 exporters: Some(Exporters { metrics, tracing }),
317 |                 service_name: service_name.map(|s| s.to_string()),
318 |                 version: version.map(|v| v.to_string()),
319 |             },
320 |             ..Default::default()
321 |         }
322 |     }
323 | 
324 |     #[tokio::test]
325 |     async fn guard_is_provided_when_tracing_configured() {
326 |         let mut ommitted = HashSet::new();
327 |         ommitted.insert(TelemetryAttribute::RequestId);
328 | 
329 |         let config = test_config(
330 |             Some("test-config"),
331 |             Some("1.0.0"),
332 |             Some(MetricsExporters {
333 |                 otlp: Some(OTLPMetricExporter::default()),
334 |                 omitted_attributes: None,
335 |             }),
336 |             Some(TracingExporters {
337 |                 otlp: Some(OTLPTracingExporter::default()),
338 |                 sampler: Default::default(),
339 |                 omitted_attributes: Some(ommitted),
340 |             }),
341 |         );
342 |         // init_tracing_subscriber can only be called once in the test suite to avoid
343 |         // panic when calling global::set_tracer_provider multiple times
344 |         let guard = init_tracing_subscriber(&config);
345 |         assert!(guard.is_ok());
346 |     }
347 | 
348 |     #[tokio::test]
349 |     async fn unknown_protocol_raises_meter_provider_error() {
350 |         let config = test_config(
351 |             None,
352 |             None,
353 |             Some(MetricsExporters {
354 |                 otlp: Some(OTLPMetricExporter {
355 |                     protocol: "bogus".to_string(),
356 |                     endpoint: "http://localhost:4317".to_string(),
357 |                     temporality: None,
358 |                 }),
359 |                 omitted_attributes: None,
360 |             }),
361 |             None,
362 |         );
363 |         let result = init_meter_provider(&config.telemetry);
364 |         assert!(
365 |             result
366 |                 .err()
367 |                 .map(|e| e.to_string().contains("Unsupported OTLP protocol"))
368 |                 .unwrap_or(false)
369 |         );
370 |     }
371 | 
372 |     #[tokio::test]
373 |     async fn http_protocol_returns_valid_meter_provider() {
374 |         let config = test_config(
375 |             None,
376 |             None,
377 |             Some(MetricsExporters {
378 |                 otlp: Some(OTLPMetricExporter {
379 |                     protocol: "http/protobuf".to_string(),
380 |                     endpoint: "http://localhost:4318/v1/metrics".to_string(),
381 |                     temporality: Some(MetricTemporality::Delta),
382 |                 }),
383 |                 omitted_attributes: None,
384 |             }),
385 |             None,
386 |         );
387 |         let result = init_meter_provider(&config.telemetry);
388 |         assert!(result.is_ok());
389 |     }
390 | 
391 |     #[tokio::test]
392 |     async fn unknown_protocol_raises_tracer_provider_error() {
393 |         let config = test_config(
394 |             None,
395 |             None,
396 |             None,
397 |             Some(TracingExporters {
398 |                 otlp: Some(OTLPTracingExporter {
399 |                     protocol: "bogus".to_string(),
400 |                     endpoint: "http://localhost:4317".to_string(),
401 |                 }),
402 |                 sampler: Default::default(),
403 |                 omitted_attributes: None,
404 |             }),
405 |         );
406 |         let result = init_tracer_provider(&config.telemetry);
407 |         assert!(
408 |             result
409 |                 .err()
410 |                 .map(|e| e.to_string().contains("Unsupported OTLP protocol"))
411 |                 .unwrap_or(false)
412 |         );
413 |     }
414 | 
415 |     #[tokio::test]
416 |     async fn http_protocol_returns_valid_tracer_provider() {
417 |         let config = test_config(
418 |             None,
419 |             None,
420 |             None,
421 |             Some(TracingExporters {
422 |                 otlp: Some(OTLPTracingExporter {
423 |                     protocol: "http/protobuf".to_string(),
424 |                     endpoint: "http://localhost:4318/v1/traces".to_string(),
425 |                 }),
426 |                 sampler: Default::default(),
427 |                 omitted_attributes: None,
428 |             }),
429 |         );
430 |         let result = init_tracer_provider(&config.telemetry);
431 |         assert!(result.is_ok());
432 |     }
433 | }
434 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/auth/valid_token.rs:
--------------------------------------------------------------------------------
```rust
  1 | use std::ops::Deref;
  2 | 
  3 | use headers::{Authorization, authorization::Bearer};
  4 | use jsonwebtoken::{Algorithm, Validation, decode, decode_header, jwk};
  5 | use jwks::Jwk;
  6 | use serde::{Deserialize, Serialize};
  7 | use tracing::{info, warn};
  8 | use url::Url;
  9 | 
 10 | /// A validated authentication token
 11 | ///
 12 | /// Note: This is used as a marker to ensure that we have validated this
 13 | /// separately from just reading the header itself.
 14 | #[derive(Clone, Debug, PartialEq)]
 15 | pub(crate) struct ValidToken(pub(crate) Authorization<Bearer>);
 16 | 
 17 | impl Deref for ValidToken {
 18 |     type Target = Authorization<Bearer>;
 19 | 
 20 |     fn deref(&self) -> &Self::Target {
 21 |         &self.0
 22 |     }
 23 | }
 24 | 
 25 | /// Trait to handle validation of tokens
 26 | pub(super) trait ValidateToken {
 27 |     /// Get the intended audiences
 28 |     fn get_audiences(&self) -> &Vec<String>;
 29 | 
 30 |     /// Get the available upstream servers
 31 |     fn get_servers(&self) -> &Vec<Url>;
 32 | 
 33 |     /// Fetch the key by its ID
 34 |     async fn get_key(&self, server: &Url, key_id: &str) -> Option<Jwk>;
 35 | 
 36 |     /// Attempt to validate a token against the validator
 37 |     async fn validate(&self, token: Authorization<Bearer>) -> Option<ValidToken> {
 38 |         /// Claims which must be present in the JWT (and must match validation)
 39 |         /// in order for a JWT to be considered valid.
 40 |         ///
 41 |         /// See: https://auth0.com/docs/secure/tokens/json-web-tokens/json-web-token-claims#registered-claims
 42 |         #[derive(Clone, Debug, Serialize, Deserialize)]
 43 |         pub struct Claims {
 44 |             /// The intended audience of this token.
 45 |             /// Can be either a single string or an array of strings per JWT spec. (https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3)
 46 |             #[serde(deserialize_with = "deserialize_audience")]
 47 |             pub aud: Vec<String>,
 48 | 
 49 |             /// The user who owns this token
 50 |             pub sub: String,
 51 |         }
 52 | 
 53 |         fn deserialize_audience<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
 54 |         where
 55 |             D: serde::Deserializer<'de>,
 56 |         {
 57 |             #[derive(Deserialize)]
 58 |             #[serde(untagged)]
 59 |             enum Audience {
 60 |                 Single(String),
 61 |                 Multiple(Vec<String>),
 62 |             }
 63 | 
 64 |             Ok(match Audience::deserialize(deserializer)? {
 65 |                 Audience::Single(s) => vec![s],
 66 |                 Audience::Multiple(v) => v,
 67 |             })
 68 |         }
 69 | 
 70 |         let jwt = token.token();
 71 |         let header = decode_header(jwt).ok()?;
 72 |         let key_id = header.kid.as_ref()?;
 73 | 
 74 |         for server in self.get_servers() {
 75 |             let Some(jwk) = self.get_key(server, key_id).await else {
 76 |                 continue;
 77 |             };
 78 | 
 79 |             let validation = {
 80 |                 let mut val = Validation::new(match jwk.alg {
 81 |                     jwk::KeyAlgorithm::HS256 => Algorithm::HS256,
 82 |                     jwk::KeyAlgorithm::HS384 => Algorithm::HS384,
 83 |                     jwk::KeyAlgorithm::HS512 => Algorithm::HS512,
 84 |                     jwk::KeyAlgorithm::ES256 => Algorithm::ES256,
 85 |                     jwk::KeyAlgorithm::ES384 => Algorithm::ES384,
 86 |                     jwk::KeyAlgorithm::RS256 => Algorithm::RS256,
 87 |                     jwk::KeyAlgorithm::RS384 => Algorithm::RS384,
 88 |                     jwk::KeyAlgorithm::RS512 => Algorithm::RS512,
 89 |                     jwk::KeyAlgorithm::PS256 => Algorithm::PS256,
 90 |                     jwk::KeyAlgorithm::PS384 => Algorithm::PS384,
 91 |                     jwk::KeyAlgorithm::PS512 => Algorithm::PS512,
 92 |                     jwk::KeyAlgorithm::EdDSA => Algorithm::EdDSA,
 93 | 
 94 |                     // No other validation key type is supported by this library, so we
 95 |                     // warn and fail if we encounter one.
 96 |                     other => {
 97 |                         warn!("Skipping JWT signed by unsupported algorithm: {other}");
 98 |                         continue;
 99 |                     }
100 |                 });
101 |                 val.set_audience(self.get_audiences());
102 | 
103 |                 val
104 |             };
105 | 
106 |             match decode::<Claims>(jwt, &jwk.decoding_key, &validation) {
107 |                 Ok(_) => {
108 |                     return Some(ValidToken(token));
109 |                 }
110 |                 Err(e) => warn!("Token failed validation with error: {e}"),
111 |             };
112 |         }
113 | 
114 |         info!("Token did not pass validation");
115 |         None
116 |     }
117 | }
118 | 
119 | #[cfg(test)]
120 | mod test {
121 |     use std::str::FromStr;
122 | 
123 |     use headers::{Authorization, authorization::Bearer};
124 |     use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, encode, jwk::KeyAlgorithm};
125 |     use jwks::Jwk;
126 |     use serde::Serialize;
127 |     use tracing_test::traced_test;
128 |     use url::Url;
129 | 
130 |     use super::ValidateToken;
131 | 
132 |     struct TestTokenValidator {
133 |         audiences: Vec<String>,
134 |         key_pair: (String, Jwk),
135 |         servers: Vec<Url>,
136 |     }
137 | 
138 |     impl ValidateToken for TestTokenValidator {
139 |         fn get_audiences(&self) -> &Vec<String> {
140 |             &self.audiences
141 |         }
142 | 
143 |         fn get_servers(&self) -> &Vec<url::Url> {
144 |             &self.servers
145 |         }
146 | 
147 |         async fn get_key(&self, server: &url::Url, key_id: &str) -> Option<jwks::Jwk> {
148 |             // Return nothing if the server is not known to us
149 |             if !self.get_servers().contains(server) {
150 |                 return None;
151 |             }
152 | 
153 |             // Only return the key if it is the one we know
154 |             self.key_pair
155 |                 .0
156 |                 .eq(key_id)
157 |                 .then_some(self.key_pair.1.clone())
158 |         }
159 |     }
160 | 
161 |     /// Creates a key for signing / verifying JWTs
162 |     fn create_key(base64_secret: &str) -> (EncodingKey, DecodingKey) {
163 |         let encode =
164 |             EncodingKey::from_base64_secret(base64_secret).expect("create valid encoding key");
165 |         let decode =
166 |             DecodingKey::from_base64_secret(base64_secret).expect("create valid decoding key");
167 | 
168 |         (encode, decode)
169 |     }
170 | 
171 |     fn create_jwt(
172 |         key_id: String,
173 |         key: EncodingKey,
174 |         audience: String,
175 |         expires_at: i64,
176 |     ) -> Authorization<Bearer> {
177 |         #[derive(Serialize)]
178 |         struct Claims {
179 |             aud: String,
180 |             exp: i64,
181 |             sub: String,
182 |         }
183 | 
184 |         let header = {
185 |             let mut h = Header::new(Algorithm::HS512);
186 |             h.kid = Some(key_id);
187 | 
188 |             h
189 |         };
190 |         let token = encode(
191 |             &header,
192 |             &Claims {
193 |                 aud: audience,
194 |                 exp: expires_at,
195 |                 sub: "test user".to_string(),
196 |             },
197 |             &key,
198 |         )
199 |         .expect("encode JWT");
200 | 
201 |         Authorization::bearer(&token).expect("create bearer token")
202 |     }
203 | 
204 |     #[tokio::test]
205 |     async fn it_validates_jwt() {
206 |         let key_id = "some-example-id".to_string();
207 |         let (encode_key, decode_key) = create_key("DEADBEEF");
208 |         let jwk = Jwk {
209 |             alg: KeyAlgorithm::HS512,
210 |             decoding_key: decode_key,
211 |         };
212 | 
213 |         let audience = "test-audience".to_string();
214 |         let in_the_future = chrono::Utc::now().timestamp() + 1000;
215 |         let jwt = create_jwt(key_id.clone(), encode_key, audience.clone(), in_the_future);
216 | 
217 |         let server =
218 |             Url::from_str("https://auth.example.com").expect("should parse a valid example server");
219 | 
220 |         let test_validator = TestTokenValidator {
221 |             audiences: vec![audience],
222 |             key_pair: (key_id, jwk),
223 |             servers: vec![server],
224 |         };
225 | 
226 |         let token = jwt.token().to_string();
227 |         assert_eq!(
228 |             test_validator
229 |                 .validate(jwt)
230 |                 .await
231 |                 .expect("valid token")
232 |                 .0
233 |                 .token(),
234 |             token
235 |         );
236 |     }
237 | 
238 |     #[traced_test]
239 |     #[tokio::test]
240 |     async fn it_rejects_different_key() {
241 |         let key_id = "some-example-id".to_string();
242 |         let (_, decode_key) = create_key("CAFED00D");
243 |         let (bad_encode_key, _) = create_key("DEADC0DE");
244 |         let jwk = Jwk {
245 |             alg: KeyAlgorithm::HS512,
246 |             decoding_key: decode_key,
247 |         };
248 | 
249 |         let audience = "test-audience".to_string();
250 |         let in_the_future = chrono::Utc::now().timestamp() + 1000;
251 |         let jwt = create_jwt(
252 |             key_id.clone(),
253 |             bad_encode_key,
254 |             audience.clone(),
255 |             in_the_future,
256 |         );
257 | 
258 |         let server =
259 |             Url::from_str("https://auth.example.com").expect("should parse a valid example server");
260 | 
261 |         let test_validator = TestTokenValidator {
262 |             audiences: vec![audience],
263 |             key_pair: (key_id, jwk),
264 |             servers: vec![server],
265 |         };
266 | 
267 |         assert_eq!(test_validator.validate(jwt).await, None);
268 | 
269 |         logs_assert(|lines: &[&str]| {
270 |             lines
271 |                 .iter()
272 |                 .filter(|line| line.contains("WARN"))
273 |                 .any(|line| line.contains("InvalidSignature"))
274 |                 .then_some(())
275 |                 .ok_or("Expected warning for validation failure".to_string())
276 |         });
277 |     }
278 | 
279 |     #[traced_test]
280 |     #[tokio::test]
281 |     async fn it_rejects_expired() {
282 |         let key_id = "some-example-id".to_string();
283 |         let (encode_key, decode_key) = create_key("F0CACC1A");
284 |         let jwk = Jwk {
285 |             alg: KeyAlgorithm::HS512,
286 |             decoding_key: decode_key,
287 |         };
288 | 
289 |         let audience = "test-audience".to_string();
290 |         let in_the_past = chrono::Utc::now().timestamp() - 1000;
291 |         let jwt = create_jwt(key_id.clone(), encode_key, audience.clone(), in_the_past);
292 | 
293 |         let server =
294 |             Url::from_str("https://auth.example.com").expect("should parse a valid example server");
295 | 
296 |         let test_validator = TestTokenValidator {
297 |             audiences: vec![audience],
298 |             key_pair: (key_id, jwk),
299 |             servers: vec![server],
300 |         };
301 | 
302 |         assert_eq!(test_validator.validate(jwt).await, None);
303 | 
304 |         logs_assert(|lines: &[&str]| {
305 |             lines
306 |                 .iter()
307 |                 .filter(|line| line.contains("WARN"))
308 |                 .any(|line| line.contains("ExpiredSignature"))
309 |                 .then_some(())
310 |                 .ok_or("Expected warning for validation failure".to_string())
311 |         });
312 |     }
313 | 
314 |     #[traced_test]
315 |     #[tokio::test]
316 |     async fn it_rejects_different_audience() {
317 |         let key_id = "some-example-id".to_string();
318 |         let (encode_key, decode_key) = create_key("F0CACC1A");
319 |         let jwk = Jwk {
320 |             alg: KeyAlgorithm::HS512,
321 |             decoding_key: decode_key,
322 |         };
323 | 
324 |         let audience = "test-audience".to_string();
325 |         let bad_audience = "not-test-audience".to_string();
326 |         let in_the_future = chrono::Utc::now().timestamp() + 1000;
327 |         let jwt = create_jwt(key_id.clone(), encode_key, bad_audience, in_the_future);
328 | 
329 |         let server =
330 |             Url::from_str("https://auth.example.com").expect("should parse a valid example server");
331 | 
332 |         let test_validator = TestTokenValidator {
333 |             audiences: vec![audience],
334 |             key_pair: (key_id, jwk),
335 |             servers: vec![server],
336 |         };
337 | 
338 |         assert_eq!(test_validator.validate(jwt).await, None);
339 | 
340 |         logs_assert(|lines: &[&str]| {
341 |             lines
342 |                 .iter()
343 |                 .filter(|line| line.contains("WARN"))
344 |                 .any(|line| line.contains("InvalidAudience"))
345 |                 .then_some(())
346 |                 .ok_or("Expected warning for validation failure".to_string())
347 |         });
348 |     }
349 | 
350 |     #[tokio::test]
351 |     async fn it_validates_jwt_with_array_audience() {
352 |         use serde_json::json;
353 | 
354 |         let key_id = "some-example-id".to_string();
355 |         let (encode_key, decode_key) = create_key("DEADBEEF");
356 |         let jwk = Jwk {
357 |             alg: KeyAlgorithm::HS512,
358 |             decoding_key: decode_key,
359 |         };
360 | 
361 |         let audience = "test-audience".to_string();
362 |         let in_the_future = chrono::Utc::now().timestamp() + 1000;
363 | 
364 |         let header = {
365 |             let mut h = Header::new(Algorithm::HS512);
366 |             h.kid = Some(key_id.clone());
367 |             h
368 |         };
369 | 
370 |         let claims = json!({
371 |             "aud": ["test-audience", "another-audience"],
372 |             "exp": in_the_future,
373 |             "sub": "test user"
374 |         });
375 | 
376 |         let token = encode(&header, &claims, &encode_key).expect("encode JWT");
377 |         let jwt = Authorization::bearer(&token).expect("create bearer token");
378 | 
379 |         let server =
380 |             Url::from_str("https://auth.example.com").expect("should parse a valid example server");
381 | 
382 |         let test_validator = TestTokenValidator {
383 |             audiences: vec![audience],
384 |             key_pair: (key_id, jwk),
385 |             servers: vec![server],
386 |         };
387 | 
388 |         assert_eq!(
389 |             test_validator
390 |                 .validate(jwt)
391 |                 .await
392 |                 .expect("valid token")
393 |                 .0
394 |                 .token(),
395 |             token
396 |         );
397 |     }
398 | 
399 |     #[traced_test]
400 |     #[tokio::test]
401 |     async fn it_rejects_array_audience_with_no_matches() {
402 |         use serde_json::json;
403 | 
404 |         let key_id = "some-example-id".to_string();
405 |         let (encode_key, decode_key) = create_key("DEADBEEF");
406 |         let jwk = Jwk {
407 |             alg: KeyAlgorithm::HS512,
408 |             decoding_key: decode_key,
409 |         };
410 | 
411 |         let expected_audience = "expected-audience".to_string();
412 |         let in_the_future = chrono::Utc::now().timestamp() + 1000;
413 | 
414 |         let header = {
415 |             let mut h = Header::new(Algorithm::HS512);
416 |             h.kid = Some(key_id.clone());
417 |             h
418 |         };
419 | 
420 |         let claims = json!({
421 |             "aud": ["wrong-audience-1", "wrong-audience-2"],
422 |             "exp": in_the_future,
423 |             "sub": "test user"
424 |         });
425 | 
426 |         let token = encode(&header, &claims, &encode_key).expect("encode JWT");
427 |         let jwt = Authorization::bearer(&token).expect("create bearer token");
428 | 
429 |         let server =
430 |             Url::from_str("https://auth.example.com").expect("should parse a valid example server");
431 | 
432 |         let test_validator = TestTokenValidator {
433 |             audiences: vec![expected_audience],
434 |             key_pair: (key_id, jwk),
435 |             servers: vec![server],
436 |         };
437 | 
438 |         assert_eq!(test_validator.validate(jwt).await, None);
439 | 
440 |         logs_assert(|lines: &[&str]| {
441 |             lines
442 |                 .iter()
443 |                 .filter(|line| line.contains("WARN"))
444 |                 .any(|line| line.contains("InvalidAudience"))
445 |                 .then_some(())
446 |                 .ok_or("Expected warning for validation failure".to_string())
447 |         });
448 |     }
449 | }
450 | 
```
--------------------------------------------------------------------------------
/docs/source/guides/auth-auth0.mdx:
--------------------------------------------------------------------------------
```markdown
  1 | ---
  2 | title: Authorization with Auth0
  3 | ---
  4 | 
  5 | ## Example: Auth0
  6 | 
  7 | This guide uses [Auth0](https://auth0.com/) as the Identity Provider.
  8 | 
  9 | ### Pre-requisites
 10 | 
 11 | 1. [Create an Apollo account](https://studio.apollographql.com/signup?referrer=docs-content).
 12 | 
 13 | 1. Clone the repo for the example project.
 14 | 
 15 |    ```sh showLineNumbers=false
 16 |    git clone [email protected]:apollographql/apollo-mcp-server.git
 17 |    ```
 18 | 
 19 | 1. Install or update the Rover CLI. You need at least v0.35 or later.
 20 | 
 21 |    ```sh showLineNumbers=false
 22 |    curl -sSL https://rover.apollo.dev/nix/latest | sh
 23 |    ```
 24 | 
 25 | ### Step 1: Set up the Auth0 Identity Provider
 26 | 
 27 | [Create an Auth0 account](https://auth0.com/).
 28 | 
 29 | #### Create the Auth0 API
 30 | 
 31 | 1. In your dashboard, navigate to **Applications** -> **APIs**.
 32 | 1. Click **Create API**.
 33 | 1. Give it a friendly name like `MCP Auth API`.
 34 | 1. For the **Identifier** field, Auth0 recommends using a URL. This identifier is used in the MCP server configuration later as the `audience` property. For this guide, use `http://localhost:8000/mcp-example`.
 35 | 1. Leave the defaults for the rest of the fields and click **Create**.
 36 | 1. Navigate to your dashboard **Settings**.
 37 |    1. Under **General** -> **API Authorization Settings**, set the **Default Audience** to the `Identifier` you chose.
 38 |    1. Navigate to the **Advanced** tab.
 39 |    1. Toggle on **OIDC Dynamic Application Registration** to enable [dynamic client registration](https://auth0.com/docs/get-started/applications/dynamic-client-registration#enable-dynamic-client-registration).
 40 |    1. Toggle on **Enable Application Connections**.
 41 |    1. Save your changes.
 42 | 
 43 | #### Create the Auth0 Connection
 44 | 
 45 | The Auth0 Connection is the method clients use to authenticate. This guide uses the default **Username-Password-Authentication** connection.
 46 | 
 47 | 1. In your Auth0 dashboard, navigate to **Authentication** -> **Database**.
 48 | 1. Create the default **Username-Password-Authentication** connection. Click the **Try Connection** button to test it and set up a username and password for later.
 49 | 1. Back on your Auth0 dashboard, note the **Connection Identifier** at the top of the page. It should start with something like `con_`. Copy it into a temporary location. This guide refers to it as `<CONNECTION ID>`.
 50 | 1. Navigate to **Applications** -> **APIs** -> **Auth0 Management API**.
 51 | 1. Copy the **Identifier** for the Auth0 Management API to a temporary location. It should look something like `dev-123456.us.auth0.com`, where `dev-123456` is your Auth0 tenant ID. This guide refers to it as `<AUTH0 DOMAIN>`.
 52 | 1. Click the **API Explorer** tab. Copy the token value to a temporary location. This guide refers to it as `<MGMT API ACCESS TOKEN>`.
 53 | 1. Run the following `curl` command to promote the connection to domain level, replacing `<CONNECTION ID>`, `<AUTH0 DOMAIN>`, and `<MGMT API ACCESS TOKEN>` with the values you copied in the previous steps:
 54 | 
 55 |    ```sh
 56 |    curl --request PATCH \
 57 |      --url 'https://<AUTH0 DOMAIN>/api/v2/connections/<CONNECTION ID>' \
 58 |      --header 'authorization: Bearer <MGMT API ACCESS TOKEN>' \
 59 |      --header 'cache-control: no-cache' \
 60 |      --header 'content-type: application/json' \
 61 |      --data '{ "is_domain_connection": true }'
 62 |    ```
 63 | 
 64 | Your Auth0 setup is now complete. You have an API with an audience and a connection for authentication.
 65 | 
 66 | <ExpansionPanel title="Something went wrong? Try these troubleshooting steps">
 67 |   - Check that the `curl` command is correct and that you have the correct values for `<CONNECTION ID>`, `<AUTH0 DOMAIN>`, and `<MGMT API ACCESS TOKEN>`.
 68 |   - Check that you have the correct permissions to promote the connection to domain level. 
 69 |     - In your Auth0 dashboard, navigate to **Applications** -> **API Explorer Application** -> **APIs**. Ensure that the **Auth0 Management API** is authorized.
 70 |     - Expand the **Auth0 Management API** item and enable the `update:connections` permission.
 71 |     
 72 |     - Click **Update** to save your changes.
 73 | </ExpansionPanel>
 74 | 
 75 | ### Step 2: Configure the MCP Server for authorization
 76 | 
 77 | Configure the MCP server to use the Auth0 instance for authentication.
 78 | 
 79 | 1. Open the example repo you cloned earlier.
 80 | 1. In the `graphql/TheSpaceDevs` directory, open the `config.yaml` file.
 81 | 1. Add the following `auth` configuration under the `transport` key:
 82 | 
 83 |    ```yaml title="graphql/TheSpaceDevs/config.yaml"
 84 |    transport:
 85 |      type: streamable_http
 86 | 
 87 |      auth:
 88 |        servers:
 89 |          - https://<AUTH0 DOMAIN> # Fill in your Auth0 domain
 90 |        audiences:
 91 |          - <AUTH0 DEFAULT AUDIENCE> # Fill in your Auth0 Identifier
 92 |        resource: http://127.0.0.1:8000/mcp
 93 |        scopes:
 94 |          - read:users # Adjust scopes as needed
 95 |    ```
 96 | 
 97 | 1. Replace the `<AUTH0 DOMAIN>` with your own Auth0 domain from earlier.
 98 | 
 99 | 1. Replace the `<AUTH0 DEFAULT AUDIENCE>` with the matching `Identifier` you set when creating the Auth0 API. In this guide, you used `http://localhost:8000/mcp-example`.
100 | 
101 | Your MCP server is now configured to use Auth0 for authentication.
102 | 
103 | ### Step 3: Configure the router for JWT authentication
104 | 
105 | Configure your GraphOS Router to validate JWTs issued by Auth0. This involves setting up the JWKS endpoint and defining the authorization rules.
106 | 
107 | #### Define authorization and authentication rules in the router
108 | 
109 | 1. In the `graphql/TheSpaceDevs` directory, create a new file called `router.yaml`.
110 | 1. Paste the following configuration, replacing `<AUTH0 DOMAIN>` with your Auth0 domain:
111 | 
112 |    ```yaml title="graphql/TheSpaceDevs/router.yaml"
113 |    authorization:
114 |      require_authentication: true # Enforces authentication on all requests
115 |    authentication:
116 |      router:
117 |        jwt:
118 |          jwks:
119 |            - url: https://<AUTH0 DOMAIN>/.well-known/jwks.json
120 |    homepage:
121 |      enabled: false
122 |    sandbox:
123 |      enabled: true
124 |    supergraph:
125 |      introspection: true
126 |    ```
127 | 
128 |    With this configuration, the router requires authentication for all requests. If a request doesn't include an Authorization token, the router returns an `UNAUTHENTICATED` error.
129 | 
130 | #### Retrieve your GraphOS license credentials for auth
131 | 
132 | You need a graph's credentials and a valid GraphOS plan to use the router's authentication features.
133 | 
134 | 1. Navigate to [GraphOS Studio](https://studio.apollographql.com/) and log in.
135 | 1. Click **Add graph** and **Connect an existing graph**.
136 | 1. Give it a name and click **Next**.
137 | 1. In the next modal, find the command that looks something like this:
138 | 
139 |    ```sh showLineNumbers=false {2}
140 |    APOLLO_KEY=<YOUR_APOLLO_KEY> \
141 |    rover subgraph publish <YOUR_APOLLO_GRAPH_REF> \
142 |    --schema ./products-schema.graphql \
143 |    --name your-subgraph-name \
144 |    --routing-url http://products.prod.svc.cluster.local:4001/graphql
145 |    ```
146 | 
147 |    Note: You don't need to run this command.
148 | 
149 | 1. Retrieve the values for `YOUR_APOLLO_KEY` and `YOUR_APOLLO_GRAPH_REF` from the modal and click **Finish later**.
150 | 
151 | #### Run the MCP Server and router
152 | 
153 | 1. Back in your terminal, in the root of the project directory, replace and run the following command to start the MCP Server and the router together:
154 | 
155 |    ```sh
156 |    APOLLO_GRAPH_REF=<YOUR_APOLLO_GRAPH_REF> APOLLO_KEY=<YOUR_APOLLO_KEY> \
157 |    rover dev --supergraph-config ./graphql/TheSpaceDevs/supergraph.yaml \
158 |    --router-config ./graphql/TheSpaceDevs/router.yaml \
159 |    --mcp ./graphql/TheSpaceDevs/config.yaml
160 |    ```
161 | 
162 | 1. Test the router by navigating to `http://localhost:4000` in your browser. You should see the Explorer, where you can run GraphQL queries against the router.
163 | 
164 | 1. Remember, the router is configured to require authentication on all requests. Any operations without a valid Authorization token returns an `UNAUTHENTICATED` error. Run the operation:
165 | 
166 |    ```graphql
167 |    query GetAstronautsCurrentlyInSpace {
168 |      astronauts(filters: { inSpace: true, search: "" }) {
169 |        results {
170 |          id
171 |          name
172 |          timeInSpace
173 |          lastFlight
174 |        }
175 |      }
176 |    }
177 |    ```
178 | 
179 | 1. You should see an `UNAUTHENTICATED` error, which means the router is correctly enforcing authentication.
180 | 
181 | ### Step 4: Make requests with MCP Inspector
182 | 
183 | 1. In a new terminal window, run the MCP Inspector:
184 | 
185 |    ```sh
186 |    npx @modelcontextprotocol/inspector
187 |    ```
188 | 
189 |    The browser should open automatically with a proxy auth token.
190 | 
191 | 1. In the MCP Inspector, select `Streamable HTTP` as the Transport Type and enter `http://127.0.0.1:8000/mcp` as the URL.
192 | 1. Click **Connect**. This triggers the OAuth flow, and you are redirected to the Auth0 login page.
193 | 1. Log in with the credentials you set up in the Auth0 connection and allow MCP Inspector access.
194 | 1. After you connect, the browser redirects back to MCP Inspector.
195 | 1. Click **List Tools** to see the available tools.
196 | 1. Select the `GetAstronautsCurrentlyInSpace` tool listed and click **Run Tool**.
197 | 1. You should see the results of the query, which means the authentication is working correctly.
198 | 
199 | You can select the **Auth** tab in MCP Inspector to see the details of the authenticated user and the scopes granted.
200 | 
201 | <ExpansionPanel title="Alternative: Guided OAuth flow in MCP Inspector">
202 | 
203 | You can also use the guided OAuth flow in MCP Inspector to test authentication. This gives you a detailed look into each step the client does to connect to the server.
204 | 
205 | 1. Click **Open Auth Settings**.
206 | 1. In the **OAuth Flow Progress** section, click **Continue** to start the **Metadata Discovery** step.
207 | 1. Click **Continue** to start the **Client Registration** step. Expand the **Registered Client Information** step to note the `client_id` value.
208 | 1. Click **Continue** to start the **Preparing Authorization** step. Click the link to open up a new tab to authorize MCP Inspector.
209 | 1. Copy the authorization code and return to MCP Inspector.
210 | 1. Paste the code in the next step **Request Authorization and acquire authorization code** then click **Continue**.
211 | 1. Click **Continue** to start the **Token Request** step. This completes the authentication flow.
212 | 
213 | Before continuing, you need to set up the Auth0 client to accept an additional callback URL.
214 | 
215 | 1. In your Auth0 dashboard, navigate to **Applications**.
216 | 1. Select the client for **MCP Inspector**. If you have multiple entries, find the `client_id` value from the MCP Inspector.
217 | 1. In the client's **Settings** -> **Application URIs**, copy and paste the existing callback URL. Then, remove the `/debug` suffix. Make sure the URLs are comma-separated. It should look something like this:
218 | 
219 |    ```txt
220 |    http://localhost:6274/oauth/callback/debug,
221 |    http://localhost:6274/oauth/callback
222 |    ```
223 | 
224 | 1. Back in MCP Inspector, click **Connect**. You are now authenticated and can run tools as usual.
225 | 
226 | </ExpansionPanel>
227 | 
228 | ### Step 5: Make requests with an MCP Client (Goose)
229 | 
230 | We'll use [Goose](https://block.github.io/goose/) as our MCP Client. Goose allows you to choose between many different LLMs and provides some built-in functionality for connecting to MCP servers, called [Extensions](https://block.github.io/goose/docs/getting-started/using-extensions).
231 | 
232 | [Install the Goose CLI](https://block.github.io/goose/docs/getting-started/installation), following the instructions for your operating system. Set up the LLM provider of your choice with `goose configure` --> **Configure Providers**. Each provider has its own set of instructions, rate limiting and pricing.
233 | 
234 | Then, continue with the following steps:
235 | 
236 | 1. In your terminal, run `goose configure`.
237 | 1. Select or enter the following answers at the prompts:
238 | 
239 | | Prompt                                                      | Answer                                     |
240 | | ----------------------------------------------------------- | ------------------------------------------ |
241 | | "What would you like to configure?"                         | "Add Extension"                            |
242 | | "What type of extension would you like to add?"             | "Command Line Extension"                   |
243 | | "What's the name of this extension?"                        | "mcp-auth-quickstart"                      |
244 | | "What command should be run?"                               | `npx mcp-remote http://127.0.0.1:8000/mcp` |
245 | | Other prompts (timeout, description, environment variables) | Use the default values                     |
246 | 
247 | 1. To start Goose, type `goose`. This will open a browser window and send you through the auth flow.
248 | 1. Log in to your Auth0 instance and authorize your MCP server to gain access to your tools.
249 | 1. In Goose, ask "What astronauts are in space right now?". This question is similar to the `GetAstronautsCurrentlyInSpace` operation from earlier, which fails as unauthenticated without the proper token.
250 | 1. Goose will select the `GetAstronautsCurrentlyInSpace` tool and respond with information about the astronauts found in TheSpaceDevs.
251 | 
252 | ## Troubleshooting
253 | 
254 | ### Common Issues
255 | 
256 | #### MCP Server Won't Start
257 | 
258 | - **Error**: "Port 8000 is already in use"
259 |   - Solution: Kill any existing processes using port 8000 or specify a different port with the `transport.port` option or `APOLLO_MCP_TRANSPORT__PORT` env variable
260 | - **Error**: "Failed to load supergraph configuration"
261 |   - Solution: Verify you're running the command from the repo root directory
262 |   - Solution: Check that the path to `supergraph.yaml` is correct
263 | - **Error**: "License violation"
264 |   - Solution: Ensure that the `rover dev` command includes valid `APOLLO_KEY` and `APOLLO_GRAPH_REF` values and that your plan supports authentication features.
265 | - **Error**: "What URL is your subgraph running on?" question in terminal
266 |   - Solution: Verify that the file path for your config files is correct. You should run the `rover dev` command from the root of the example project directory and the file paths should be relative to that root.
267 | 
268 | #### MCP Inspector Connection Issues
269 | 
270 | - **Error**: "Failed to connect to server"
271 |   - Solution: Ensure the MCP server is running (check terminal output)
272 |   - Solution: Verify you're using the correct URL (`http://127.0.0.1:8000/mcp`)
273 |   - Solution: Check if your firewall is blocking the connection
274 | 
275 | ### Infinite loop during OAuth flow
276 | 
277 | - **Issue**: After logging in to Auth0, MCP Inspector keeps refreshing and doesn't complete the OAuth flow
278 |   - Solution: In MCP Inspector, open the **Authentication** panel in the sidebar. Clear out any values in the **Header Name** and **Bearer Token** fields. Then try connecting again.
279 |   - Solution: In MCP Inspector, select **Clear OAuth State** and try connecting again.
280 | 
281 | ### Getting Help
282 | 
283 | If you're still having issues:
284 | 
285 | 1. Check the [Apollo MCP Server GitHub issues](https://github.com/apollographql/apollo-mcp-server/issues).
286 | 2. Join the [Apollo Community MCP Server Category](https://community.apollographql.com/c/mcp-server/41).
287 | 3. Contact your Apollo representative for direct support.
288 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/server/states/starting.rs:
--------------------------------------------------------------------------------
```rust
  1 | use std::{net::SocketAddr, sync::Arc};
  2 | 
  3 | use apollo_compiler::{Name, Schema, ast::OperationType, validation::Valid};
  4 | use axum::{Router, extract::Query, http::StatusCode, response::Json, routing::get};
  5 | use axum_otel_metrics::HttpMetricsLayerBuilder;
  6 | use axum_tracing_opentelemetry::middleware::{OtelAxumLayer, OtelInResponseLayer};
  7 | use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
  8 | use rmcp::transport::{StreamableHttpServerConfig, StreamableHttpService};
  9 | use rmcp::{
 10 |     ServiceExt as _,
 11 |     transport::{SseServer, sse_server::SseServerConfig, stdio},
 12 | };
 13 | use serde_json::json;
 14 | use tokio::sync::{Mutex, RwLock};
 15 | use tokio_util::sync::CancellationToken;
 16 | use tower_http::trace::TraceLayer;
 17 | use tracing::{Instrument as _, debug, error, info, trace};
 18 | 
 19 | use crate::{
 20 |     errors::ServerError,
 21 |     explorer::Explorer,
 22 |     health::HealthCheck,
 23 |     introspection::tools::{
 24 |         execute::Execute, introspect::Introspect, search::Search, validate::Validate,
 25 |     },
 26 |     operations::{MutationMode, RawOperation},
 27 |     server::Transport,
 28 | };
 29 | 
 30 | use super::{Config, Running, shutdown_signal};
 31 | 
 32 | pub(super) struct Starting {
 33 |     pub(super) config: Config,
 34 |     pub(super) schema: Valid<Schema>,
 35 |     pub(super) operations: Vec<RawOperation>,
 36 | }
 37 | 
 38 | impl Starting {
 39 |     pub(super) async fn start(self) -> Result<Running, ServerError> {
 40 |         let peers = Arc::new(RwLock::new(Vec::new()));
 41 | 
 42 |         let operations: Vec<_> = self
 43 |             .operations
 44 |             .into_iter()
 45 |             .filter_map(|operation| {
 46 |                 operation
 47 |                     .into_operation(
 48 |                         &self.schema,
 49 |                         self.config.custom_scalar_map.as_ref(),
 50 |                         self.config.mutation_mode,
 51 |                         self.config.disable_type_description,
 52 |                         self.config.disable_schema_description,
 53 |                     )
 54 |                     .unwrap_or_else(|error| {
 55 |                         error!("Invalid operation: {}", error);
 56 |                         None
 57 |                     })
 58 |             })
 59 |             .collect();
 60 | 
 61 |         debug!(
 62 |             "Loaded {} operations:\n{}",
 63 |             operations.len(),
 64 |             serde_json::to_string_pretty(&operations)?
 65 |         );
 66 | 
 67 |         let execute_tool = self
 68 |             .config
 69 |             .execute_introspection
 70 |             .then(|| Execute::new(self.config.mutation_mode));
 71 | 
 72 |         let root_query_type = self
 73 |             .config
 74 |             .introspect_introspection
 75 |             .then(|| {
 76 |                 self.schema
 77 |                     .root_operation(OperationType::Query)
 78 |                     .map(Name::as_str)
 79 |                     .map(|s| s.to_string())
 80 |             })
 81 |             .flatten();
 82 |         let root_mutation_type = self
 83 |             .config
 84 |             .introspect_introspection
 85 |             .then(|| {
 86 |                 matches!(self.config.mutation_mode, MutationMode::All)
 87 |                     .then(|| {
 88 |                         self.schema
 89 |                             .root_operation(OperationType::Mutation)
 90 |                             .map(Name::as_str)
 91 |                             .map(|s| s.to_string())
 92 |                     })
 93 |                     .flatten()
 94 |             })
 95 |             .flatten();
 96 |         let schema = Arc::new(Mutex::new(self.schema));
 97 |         let introspect_tool = self.config.introspect_introspection.then(|| {
 98 |             Introspect::new(
 99 |                 schema.clone(),
100 |                 root_query_type,
101 |                 root_mutation_type,
102 |                 self.config.introspect_minify,
103 |             )
104 |         });
105 |         let validate_tool = self
106 |             .config
107 |             .validate_introspection
108 |             .then(|| Validate::new(schema.clone()));
109 |         let search_tool = if self.config.search_introspection {
110 |             Some(Search::new(
111 |                 schema.clone(),
112 |                 matches!(self.config.mutation_mode, MutationMode::All),
113 |                 self.config.search_leaf_depth,
114 |                 self.config.index_memory_bytes,
115 |                 self.config.search_minify,
116 |             )?)
117 |         } else {
118 |             None
119 |         };
120 | 
121 |         let explorer_tool = self.config.explorer_graph_ref.map(Explorer::new);
122 | 
123 |         let cancellation_token = CancellationToken::new();
124 | 
125 |         // Create health check if enabled (only for StreamableHttp transport)
126 |         let health_check = match (&self.config.transport, self.config.health_check.enabled) {
127 |             (
128 |                 Transport::StreamableHttp {
129 |                     auth: _,
130 |                     address: _,
131 |                     port: _,
132 |                     stateful_mode: _,
133 |                 },
134 |                 true,
135 |             ) => Some(HealthCheck::new(self.config.health_check.clone())),
136 |             _ => None, // No health check for SSE, Stdio, or when disabled
137 |         };
138 | 
139 |         let running = Running {
140 |             schema,
141 |             operations: Arc::new(Mutex::new(operations)),
142 |             headers: self.config.headers,
143 |             forward_headers: self.config.forward_headers.clone(),
144 |             endpoint: self.config.endpoint,
145 |             execute_tool,
146 |             introspect_tool,
147 |             search_tool,
148 |             explorer_tool,
149 |             validate_tool,
150 |             custom_scalar_map: self.config.custom_scalar_map,
151 |             peers,
152 |             cancellation_token: cancellation_token.clone(),
153 |             mutation_mode: self.config.mutation_mode,
154 |             disable_type_description: self.config.disable_type_description,
155 |             disable_schema_description: self.config.disable_schema_description,
156 |             disable_auth_token_passthrough: self.config.disable_auth_token_passthrough,
157 |             health_check: health_check.clone(),
158 |         };
159 | 
160 |         // Helper to enable auth
161 |         macro_rules! with_auth {
162 |             ($router:expr, $auth:ident) => {{
163 |                 let mut router = $router;
164 |                 if let Some(auth) = $auth {
165 |                     router = auth.enable_middleware(router);
166 |                 }
167 | 
168 |                 router
169 |             }};
170 |         }
171 | 
172 |         // Helper to enable CORS
173 |         macro_rules! with_cors {
174 |             ($router:expr, $config:expr) => {{
175 |                 let mut router = $router;
176 |                 if $config.enabled {
177 |                     match $config.build_cors_layer() {
178 |                         Ok(cors_layer) => {
179 |                             router = router.layer(cors_layer);
180 |                         }
181 |                         Err(e) => {
182 |                             error!("Failed to build CORS layer: {}", e);
183 |                             return Err(e);
184 |                         }
185 |                     }
186 |                 }
187 |                 router
188 |             }};
189 |         }
190 | 
191 |         match self.config.transport {
192 |             Transport::StreamableHttp {
193 |                 auth,
194 |                 address,
195 |                 port,
196 |                 stateful_mode,
197 |             } => {
198 |                 info!(port = ?port, address = ?address, "Starting MCP server in Streamable HTTP mode");
199 |                 let running = running.clone();
200 |                 let listen_address = SocketAddr::new(address, port);
201 |                 let service = StreamableHttpService::new(
202 |                     move || Ok(running.clone()),
203 |                     LocalSessionManager::default().into(),
204 |                     StreamableHttpServerConfig {
205 |                         stateful_mode,
206 |                         ..Default::default()
207 |                     },
208 |                 );
209 |                 let mut router = with_cors!(
210 |                     with_auth!(axum::Router::new().nest_service("/mcp", service), auth),
211 |                     self.config.cors
212 |                 )
213 |                 .layer(HttpMetricsLayerBuilder::new().build())
214 |                 // include trace context as header into the response
215 |                 .layer(OtelInResponseLayer)
216 |                 //start OpenTelemetry trace on incoming request
217 |                 .layer(OtelAxumLayer::default())
218 |                 // Add tower-http tracing layer for additional HTTP-level tracing
219 |                 .layer(
220 |                     TraceLayer::new_for_http()
221 |                         .make_span_with(|request: &axum::http::Request<_>| {
222 |                             tracing::info_span!(
223 |                                 "mcp_server",
224 |                                 method = %request.method(),
225 |                                 uri = %request.uri(),
226 |                                 session_id = tracing::field::Empty,
227 |                                 status_code = tracing::field::Empty,
228 |                             )
229 |                         })
230 |                         .on_response(
231 |                             |response: &axum::http::Response<_>,
232 |                              _latency: std::time::Duration,
233 |                              span: &tracing::Span| {
234 |                                 span.record(
235 |                                     "status_code",
236 |                                     tracing::field::display(response.status()),
237 |                                 );
238 |                                 if let Some(session_id) = response
239 |                                     .headers()
240 |                                     .get("mcp-session-id")
241 |                                     .and_then(|v| v.to_str().ok())
242 |                                 {
243 |                                     span.record("session_id", tracing::field::display(session_id));
244 |                                 }
245 |                             },
246 |                         ),
247 |                 );
248 | 
249 |                 // Add health check endpoint if configured
250 |                 if let Some(health_check) = health_check.filter(|h| h.config().enabled) {
251 |                     let health_router = with_cors!(
252 |                         Router::new()
253 |                             .route(&health_check.config().path, get(health_endpoint))
254 |                             .with_state(health_check.clone()),
255 |                         self.config.cors
256 |                     );
257 |                     router = router.merge(health_router);
258 |                 }
259 | 
260 |                 let tcp_listener = tokio::net::TcpListener::bind(listen_address).await?;
261 |                 tokio::spawn(async move {
262 |                     // Health check is already active from creation
263 |                     if let Err(e) = axum::serve(tcp_listener, router)
264 |                         .with_graceful_shutdown(shutdown_signal())
265 |                         .await
266 |                     {
267 |                         // This can never really happen
268 |                         error!("Failed to start MCP server: {e:?}");
269 |                     }
270 |                 });
271 |             }
272 |             Transport::SSE {
273 |                 auth,
274 |                 address,
275 |                 port,
276 |             } => {
277 |                 info!(port = ?port, address = ?address, "Starting MCP server in SSE mode");
278 |                 let running = running.clone();
279 |                 let listen_address = SocketAddr::new(address, port);
280 | 
281 |                 let (server, router) = SseServer::new(SseServerConfig {
282 |                     bind: listen_address,
283 |                     sse_path: "/sse".to_string(),
284 |                     post_path: "/message".to_string(),
285 |                     ct: cancellation_token,
286 |                     sse_keep_alive: None,
287 |                 });
288 | 
289 |                 // Optionally wrap the router with auth, if enabled
290 |                 let router = with_auth!(router, auth);
291 | 
292 |                 // Start up the SSE server
293 |                 // Note: Until RMCP consolidates SSE with the same tower system as StreamableHTTP,
294 |                 // we need to basically copy the implementation of `SseServer::serve_with_config` here.
295 |                 let listener = tokio::net::TcpListener::bind(server.config.bind).await?;
296 |                 let ct = server.config.ct.child_token();
297 |                 let axum_server =
298 |                     axum::serve(listener, router).with_graceful_shutdown(async move {
299 |                         ct.cancelled().await;
300 |                         tracing::info!("mcp server cancelled");
301 |                     });
302 | 
303 |                 tokio::spawn(
304 |                     async move {
305 |                         if let Err(e) = axum_server.await {
306 |                             tracing::error!(error = %e, "mcp shutdown with error");
307 |                         }
308 |                     }
309 |                     .instrument(
310 |                         tracing::info_span!("mcp-server", bind_address = %server.config.bind),
311 |                     ),
312 |                 );
313 | 
314 |                 server.with_service(move || running.clone());
315 |             }
316 |             Transport::Stdio => {
317 |                 info!("Starting MCP server in stdio mode");
318 |                 let service = running
319 |                     .clone()
320 |                     .serve(stdio())
321 |                     .await
322 |                     .inspect_err(|e| {
323 |                         error!("serving error: {:?}", e);
324 |                     })
325 |                     .map_err(Box::new)?;
326 |                 service.waiting().await.map_err(ServerError::StartupError)?;
327 |             }
328 |         }
329 | 
330 |         Ok(running)
331 |     }
332 | }
333 | 
334 | /// Health check endpoint handler
335 | async fn health_endpoint(
336 |     axum::extract::State(health_check): axum::extract::State<HealthCheck>,
337 |     Query(params): Query<std::collections::HashMap<String, String>>,
338 | ) -> Result<(StatusCode, Json<serde_json::Value>), StatusCode> {
339 |     let query = params.keys().next().map(|k| k.as_str());
340 |     let (health, status_code) = health_check.get_health_state(query);
341 | 
342 |     trace!(?health, query = ?query, "health check");
343 | 
344 |     Ok((status_code, Json(json!(health))))
345 | }
346 | 
347 | #[cfg(test)]
348 | mod tests {
349 |     use http::HeaderMap;
350 |     use url::Url;
351 | 
352 |     use crate::health::HealthCheckConfig;
353 | 
354 |     use super::*;
355 | 
356 |     #[tokio::test]
357 |     async fn start_basic_server() {
358 |         let starting = Starting {
359 |             config: Config {
360 |                 transport: Transport::StreamableHttp {
361 |                     auth: None,
362 |                     address: "127.0.0.1".parse().unwrap(),
363 |                     port: 7799,
364 |                     stateful_mode: false,
365 |                 },
366 |                 endpoint: Url::parse("http://localhost:4000").expect("valid url"),
367 |                 mutation_mode: MutationMode::All,
368 |                 execute_introspection: true,
369 |                 headers: HeaderMap::new(),
370 |                 forward_headers: vec![],
371 |                 validate_introspection: true,
372 |                 introspect_introspection: true,
373 |                 search_introspection: true,
374 |                 introspect_minify: false,
375 |                 search_minify: false,
376 |                 explorer_graph_ref: None,
377 |                 custom_scalar_map: None,
378 |                 disable_type_description: false,
379 |                 disable_schema_description: false,
380 |                 disable_auth_token_passthrough: false,
381 |                 search_leaf_depth: 5,
382 |                 index_memory_bytes: 1024 * 1024 * 1024,
383 |                 health_check: HealthCheckConfig {
384 |                     enabled: true,
385 |                     ..Default::default()
386 |                 },
387 |                 cors: Default::default(),
388 |             },
389 |             schema: Schema::parse_and_validate("type Query { hello: String }", "test.graphql")
390 |                 .expect("Valid schema"),
391 |             operations: vec![],
392 |         };
393 |         let running = starting.start();
394 |         assert!(running.await.is_ok());
395 |     }
396 | }
397 | 
```
--------------------------------------------------------------------------------
/crates/apollo-mcp-server/src/server/states/running.rs:
--------------------------------------------------------------------------------
```rust
  1 | use std::sync::Arc;
  2 | 
  3 | use apollo_compiler::{Schema, validation::Valid};
  4 | use opentelemetry::trace::FutureExt;
  5 | use opentelemetry::{Context, KeyValue};
  6 | use reqwest::header::HeaderMap;
  7 | use rmcp::model::Implementation;
  8 | use rmcp::{
  9 |     Peer, RoleServer, ServerHandler, ServiceError,
 10 |     model::{
 11 |         CallToolRequestParam, CallToolResult, ErrorCode, InitializeRequestParam, InitializeResult,
 12 |         ListToolsResult, PaginatedRequestParam, ServerCapabilities, ServerInfo,
 13 |     },
 14 |     service::RequestContext,
 15 | };
 16 | use serde_json::Value;
 17 | use tokio::sync::{Mutex, RwLock};
 18 | use tokio_util::sync::CancellationToken;
 19 | use tracing::{debug, error};
 20 | use url::Url;
 21 | 
 22 | use crate::generated::telemetry::{TelemetryAttribute, TelemetryMetric};
 23 | use crate::meter;
 24 | use crate::{
 25 |     custom_scalar_map::CustomScalarMap,
 26 |     errors::{McpError, ServerError},
 27 |     explorer::{EXPLORER_TOOL_NAME, Explorer},
 28 |     graphql::{self, Executable as _},
 29 |     headers::{ForwardHeaders, build_request_headers},
 30 |     health::HealthCheck,
 31 |     introspection::tools::{
 32 |         execute::{EXECUTE_TOOL_NAME, Execute},
 33 |         introspect::{INTROSPECT_TOOL_NAME, Introspect},
 34 |         search::{SEARCH_TOOL_NAME, Search},
 35 |         validate::{VALIDATE_TOOL_NAME, Validate},
 36 |     },
 37 |     operations::{MutationMode, Operation, RawOperation},
 38 | };
 39 | 
 40 | #[derive(Clone)]
 41 | pub(super) struct Running {
 42 |     pub(super) schema: Arc<Mutex<Valid<Schema>>>,
 43 |     pub(super) operations: Arc<Mutex<Vec<Operation>>>,
 44 |     pub(super) headers: HeaderMap,
 45 |     pub(super) forward_headers: ForwardHeaders,
 46 |     pub(super) endpoint: Url,
 47 |     pub(super) execute_tool: Option<Execute>,
 48 |     pub(super) introspect_tool: Option<Introspect>,
 49 |     pub(super) search_tool: Option<Search>,
 50 |     pub(super) explorer_tool: Option<Explorer>,
 51 |     pub(super) validate_tool: Option<Validate>,
 52 |     pub(super) custom_scalar_map: Option<CustomScalarMap>,
 53 |     pub(super) peers: Arc<RwLock<Vec<Peer<RoleServer>>>>,
 54 |     pub(super) cancellation_token: CancellationToken,
 55 |     pub(super) mutation_mode: MutationMode,
 56 |     pub(super) disable_type_description: bool,
 57 |     pub(super) disable_schema_description: bool,
 58 |     pub(super) disable_auth_token_passthrough: bool,
 59 |     pub(super) health_check: Option<HealthCheck>,
 60 | }
 61 | 
 62 | impl Running {
 63 |     /// Update a running server with a new schema.
 64 |     pub(super) async fn update_schema(self, schema: Valid<Schema>) -> Result<Running, ServerError> {
 65 |         debug!("Schema updated:\n{}", schema);
 66 | 
 67 |         // Update the operations based on the new schema. This is necessary because the MCP tool
 68 |         // input schemas and description are derived from the schema.
 69 |         let operations: Vec<Operation> = self
 70 |             .operations
 71 |             .lock()
 72 |             .await
 73 |             .iter()
 74 |             .cloned()
 75 |             .map(|operation| operation.into_inner())
 76 |             .filter_map(|operation| {
 77 |                 operation
 78 |                     .into_operation(
 79 |                         &schema,
 80 |                         self.custom_scalar_map.as_ref(),
 81 |                         self.mutation_mode,
 82 |                         self.disable_type_description,
 83 |                         self.disable_schema_description,
 84 |                     )
 85 |                     .unwrap_or_else(|error| {
 86 |                         error!("Invalid operation: {}", error);
 87 |                         None
 88 |                     })
 89 |             })
 90 |             .collect();
 91 | 
 92 |         debug!(
 93 |             "Updated {} operations:\n{}",
 94 |             operations.len(),
 95 |             serde_json::to_string_pretty(&operations)?
 96 |         );
 97 |         *self.operations.lock().await = operations;
 98 | 
 99 |         // Update the schema itself
100 |         *self.schema.lock().await = schema;
101 | 
102 |         // Notify MCP clients that tools have changed
103 |         Self::notify_tool_list_changed(self.peers.clone()).await;
104 |         Ok(self)
105 |     }
106 | 
107 |     #[tracing::instrument(skip_all)]
108 |     pub(super) async fn update_operations(
109 |         self,
110 |         operations: Vec<RawOperation>,
111 |     ) -> Result<Running, ServerError> {
112 |         debug!("Operations updated:\n{:?}", operations);
113 | 
114 |         // Update the operations based on the current schema
115 |         {
116 |             let schema = &*self.schema.lock().await;
117 |             let updated_operations: Vec<Operation> = operations
118 |                 .into_iter()
119 |                 .filter_map(|operation| {
120 |                     operation
121 |                         .into_operation(
122 |                             schema,
123 |                             self.custom_scalar_map.as_ref(),
124 |                             self.mutation_mode,
125 |                             self.disable_type_description,
126 |                             self.disable_schema_description,
127 |                         )
128 |                         .unwrap_or_else(|error| {
129 |                             error!("Invalid operation: {}", error);
130 |                             None
131 |                         })
132 |                 })
133 |                 .collect();
134 | 
135 |             debug!(
136 |                 "Loaded {} operations:\n{}",
137 |                 updated_operations.len(),
138 |                 serde_json::to_string_pretty(&updated_operations)?
139 |             );
140 |             *self.operations.lock().await = updated_operations;
141 |         }
142 | 
143 |         // Notify MCP clients that tools have changed
144 |         Self::notify_tool_list_changed(self.peers.clone()).await;
145 |         Ok(self)
146 |     }
147 | 
148 |     /// Notify any peers that tools have changed. Drops unreachable peers from the list.
149 |     #[tracing::instrument(skip_all)]
150 |     async fn notify_tool_list_changed(peers: Arc<RwLock<Vec<Peer<RoleServer>>>>) {
151 |         let mut peers = peers.write().await;
152 |         if !peers.is_empty() {
153 |             debug!(
154 |                 "Operations changed, notifying {} peers of tool change",
155 |                 peers.len()
156 |             );
157 |         }
158 |         let mut retained_peers = Vec::new();
159 |         for peer in peers.iter() {
160 |             if !peer.is_transport_closed() {
161 |                 match peer.notify_tool_list_changed().await {
162 |                     Ok(_) => retained_peers.push(peer.clone()),
163 |                     Err(ServiceError::TransportSend(_) | ServiceError::TransportClosed) => {
164 |                         error!("Failed to notify peer of tool list change - dropping peer",);
165 |                     }
166 |                     Err(e) => {
167 |                         error!("Failed to notify peer of tool list change {:?}", e);
168 |                         retained_peers.push(peer.clone());
169 |                     }
170 |                 }
171 |             }
172 |         }
173 |         *peers = retained_peers;
174 |     }
175 | }
176 | 
177 | impl ServerHandler for Running {
178 |     #[tracing::instrument(skip_all, fields(apollo.mcp.client_name = request.client_info.name, apollo.mcp.client_version = request.client_info.version))]
179 |     async fn initialize(
180 |         &self,
181 |         request: InitializeRequestParam,
182 |         context: RequestContext<RoleServer>,
183 |     ) -> Result<InitializeResult, McpError> {
184 |         let meter = &meter::METER;
185 |         let attributes = vec![
186 |             KeyValue::new(
187 |                 TelemetryAttribute::ClientName.to_key(),
188 |                 request.client_info.name.clone(),
189 |             ),
190 |             KeyValue::new(
191 |                 TelemetryAttribute::ClientVersion.to_key(),
192 |                 request.client_info.version.clone(),
193 |             ),
194 |         ];
195 |         meter
196 |             .u64_counter(TelemetryMetric::InitializeCount.as_str())
197 |             .build()
198 |             .add(1, &attributes);
199 |         // TODO: how to remove these?
200 |         let mut peers = self.peers.write().await;
201 |         peers.push(context.peer);
202 |         Ok(self.get_info())
203 |     }
204 | 
205 |     #[tracing::instrument(skip_all, fields(apollo.mcp.tool_name = request.name.as_ref(), apollo.mcp.request_id = %context.id.clone()))]
206 |     async fn call_tool(
207 |         &self,
208 |         request: CallToolRequestParam,
209 |         context: RequestContext<RoleServer>,
210 |     ) -> Result<CallToolResult, McpError> {
211 |         let meter = &meter::METER;
212 |         let start = std::time::Instant::now();
213 |         let tool_name = request.name.clone();
214 |         let result = match tool_name.as_ref() {
215 |             INTROSPECT_TOOL_NAME => {
216 |                 self.introspect_tool
217 |                     .as_ref()
218 |                     .ok_or(tool_not_found(&tool_name))?
219 |                     .execute(convert_arguments(request)?)
220 |                     .await
221 |             }
222 |             SEARCH_TOOL_NAME => {
223 |                 self.search_tool
224 |                     .as_ref()
225 |                     .ok_or(tool_not_found(&tool_name))?
226 |                     .execute(convert_arguments(request)?)
227 |                     .await
228 |             }
229 |             EXPLORER_TOOL_NAME => {
230 |                 self.explorer_tool
231 |                     .as_ref()
232 |                     .ok_or(tool_not_found(&tool_name))?
233 |                     .execute(convert_arguments(request)?)
234 |                     .await
235 |             }
236 |             EXECUTE_TOOL_NAME => {
237 |                 let headers = if let Some(axum_parts) =
238 |                     context.extensions.get::<axum::http::request::Parts>()
239 |                 {
240 |                     build_request_headers(
241 |                         &self.headers,
242 |                         &self.forward_headers,
243 |                         &axum_parts.headers,
244 |                         &axum_parts.extensions,
245 |                         self.disable_auth_token_passthrough,
246 |                     )
247 |                 } else {
248 |                     self.headers.clone()
249 |                 };
250 | 
251 |                 self.execute_tool
252 |                     .as_ref()
253 |                     .ok_or(tool_not_found(&tool_name))?
254 |                     .execute(graphql::Request {
255 |                         input: Value::from(request.arguments.clone()),
256 |                         endpoint: &self.endpoint,
257 |                         headers,
258 |                     })
259 |                     .await
260 |             }
261 |             VALIDATE_TOOL_NAME => {
262 |                 self.validate_tool
263 |                     .as_ref()
264 |                     .ok_or(tool_not_found(&tool_name))?
265 |                     .execute(convert_arguments(request)?)
266 |                     .await
267 |             }
268 |             _ => {
269 |                 let headers = if let Some(axum_parts) =
270 |                     context.extensions.get::<axum::http::request::Parts>()
271 |                 {
272 |                     build_request_headers(
273 |                         &self.headers,
274 |                         &self.forward_headers,
275 |                         &axum_parts.headers,
276 |                         &axum_parts.extensions,
277 |                         self.disable_auth_token_passthrough,
278 |                     )
279 |                 } else {
280 |                     self.headers.clone()
281 |                 };
282 | 
283 |                 let graphql_request = graphql::Request {
284 |                     input: Value::from(request.arguments.clone()),
285 |                     endpoint: &self.endpoint,
286 |                     headers,
287 |                 };
288 |                 self.operations
289 |                     .lock()
290 |                     .await
291 |                     .iter()
292 |                     .find(|op| op.as_ref().name == tool_name)
293 |                     .ok_or(tool_not_found(&tool_name))?
294 |                     .execute(graphql_request)
295 |                     .with_context(Context::current())
296 |                     .await
297 |             }
298 |         };
299 | 
300 |         // Track errors for health check
301 |         if let (Err(_), Some(health_check)) = (&result, &self.health_check) {
302 |             health_check.record_rejection();
303 |         }
304 | 
305 |         let attributes = vec![
306 |             KeyValue::new(
307 |                 TelemetryAttribute::Success.to_key(),
308 |                 result.as_ref().is_ok_and(|r| r.is_error != Some(true)),
309 |             ),
310 |             KeyValue::new(TelemetryAttribute::ToolName.to_key(), tool_name),
311 |         ];
312 |         // Record response time and status
313 |         meter
314 |             .f64_histogram(TelemetryMetric::ToolDuration.as_str())
315 |             .build()
316 |             .record(start.elapsed().as_millis() as f64, &attributes);
317 |         meter
318 |             .u64_counter(TelemetryMetric::ToolCount.as_str())
319 |             .build()
320 |             .add(1, &attributes);
321 | 
322 |         result
323 |     }
324 | 
325 |     #[tracing::instrument(skip_all)]
326 |     async fn list_tools(
327 |         &self,
328 |         _request: Option<PaginatedRequestParam>,
329 |         _context: RequestContext<RoleServer>,
330 |     ) -> Result<ListToolsResult, McpError> {
331 |         let meter = &meter::METER;
332 |         meter
333 |             .u64_counter(TelemetryMetric::ListToolsCount.as_str())
334 |             .build()
335 |             .add(1, &[]);
336 |         Ok(ListToolsResult {
337 |             next_cursor: None,
338 |             tools: self
339 |                 .operations
340 |                 .lock()
341 |                 .await
342 |                 .iter()
343 |                 .map(|op| op.as_ref().clone())
344 |                 .chain(self.execute_tool.as_ref().iter().map(|e| e.tool.clone()))
345 |                 .chain(self.introspect_tool.as_ref().iter().map(|e| e.tool.clone()))
346 |                 .chain(self.search_tool.as_ref().iter().map(|e| e.tool.clone()))
347 |                 .chain(self.explorer_tool.as_ref().iter().map(|e| e.tool.clone()))
348 |                 .chain(self.validate_tool.as_ref().iter().map(|e| e.tool.clone()))
349 |                 .collect(),
350 |         })
351 |     }
352 | 
353 |     fn get_info(&self) -> ServerInfo {
354 |         let meter = &meter::METER;
355 |         meter
356 |             .u64_counter(TelemetryMetric::GetInfoCount.as_str())
357 |             .build()
358 |             .add(1, &[]);
359 |         ServerInfo {
360 |             server_info: Implementation {
361 |                 name: "Apollo MCP Server".to_string(),
362 |                 icons: None,
363 |                 title: Some("Apollo MCP Server".to_string()),
364 |                 version: env!("CARGO_PKG_VERSION").to_string(),
365 |                 website_url: Some(
366 |                     "https://www.apollographql.com/docs/apollo-mcp-server".to_string(),
367 |                 ),
368 |             },
369 |             capabilities: ServerCapabilities::builder()
370 |                 .enable_tools()
371 |                 .enable_tool_list_changed()
372 |                 .build(),
373 |             ..Default::default()
374 |         }
375 |     }
376 | }
377 | 
378 | fn tool_not_found(name: &str) -> McpError {
379 |     McpError::new(
380 |         ErrorCode::METHOD_NOT_FOUND,
381 |         format!("Tool {name} not found"),
382 |         None,
383 |     )
384 | }
385 | 
386 | fn convert_arguments<T: serde::de::DeserializeOwned>(
387 |     arguments: CallToolRequestParam,
388 | ) -> Result<T, McpError> {
389 |     serde_json::from_value(Value::from(arguments.arguments))
390 |         .map_err(|_| McpError::new(ErrorCode::INVALID_PARAMS, "Invalid input".to_string(), None))
391 | }
392 | 
393 | #[cfg(test)]
394 | mod tests {
395 |     use super::*;
396 | 
397 |     #[tokio::test]
398 |     async fn invalid_operations_should_not_crash_server() {
399 |         let schema = Schema::parse("type Query { id: String }", "schema.graphql")
400 |             .unwrap()
401 |             .validate()
402 |             .unwrap();
403 | 
404 |         let running = Running {
405 |             schema: Arc::new(Mutex::new(schema)),
406 |             operations: Arc::new(Mutex::new(vec![])),
407 |             headers: HeaderMap::new(),
408 |             forward_headers: vec![],
409 |             endpoint: "http://localhost:4000".parse().unwrap(),
410 |             execute_tool: None,
411 |             introspect_tool: None,
412 |             search_tool: None,
413 |             explorer_tool: None,
414 |             validate_tool: None,
415 |             custom_scalar_map: None,
416 |             peers: Arc::new(RwLock::new(vec![])),
417 |             cancellation_token: CancellationToken::new(),
418 |             mutation_mode: MutationMode::None,
419 |             disable_type_description: false,
420 |             disable_schema_description: false,
421 |             disable_auth_token_passthrough: false,
422 |             health_check: None,
423 |         };
424 | 
425 |         let operations = vec![
426 |             RawOperation::from((
427 |                 "query Valid { id }".to_string(),
428 |                 Some("valid.graphql".to_string()),
429 |             )),
430 |             RawOperation::from((
431 |                 "query Invalid {{ id }".to_string(),
432 |                 Some("invalid.graphql".to_string()),
433 |             )),
434 |             RawOperation::from((
435 |                 "query { id }".to_string(),
436 |                 Some("unnamed.graphql".to_string()),
437 |             )),
438 |         ];
439 | 
440 |         let updated_running = running.update_operations(operations).await.unwrap();
441 |         let updated_operations = updated_running.operations.lock().await;
442 | 
443 |         assert_eq!(updated_operations.len(), 1);
444 |         assert_eq!(updated_operations.first().unwrap().as_ref().name, "Valid");
445 |     }
446 | }
447 | 
```
--------------------------------------------------------------------------------
/crates/apollo-schema-index/src/path.rs:
--------------------------------------------------------------------------------
```rust
  1 | //! Defines a path from a root type in a GraphQL schema (Query, Mutation, or Subscription) to
  2 | //! another type.
  3 | 
  4 | use apollo_compiler::Name;
  5 | use apollo_compiler::ast::NamedType;
  6 | use std::collections::HashSet;
  7 | use std::fmt;
  8 | use std::fmt::Display;
  9 | use std::hash::Hash;
 10 | 
 11 | /// Iterator over references to PathNode elements
 12 | pub struct PathNodeIter<'a> {
 13 |     current: Option<&'a PathNode>,
 14 | }
 15 | 
 16 | impl<'a> Iterator for PathNodeIter<'a> {
 17 |     type Item = &'a PathNode;
 18 | 
 19 |     fn next(&mut self) -> Option<Self::Item> {
 20 |         let current = self.current?;
 21 |         self.current = current.child.as_deref();
 22 |         Some(current)
 23 |     }
 24 | }
 25 | 
 26 | /// Iterator over mutable references to PathNode elements
 27 | pub struct PathNodeIterMut<'a> {
 28 |     current: Option<&'a mut PathNode>,
 29 | }
 30 | 
 31 | impl<'a> Iterator for PathNodeIterMut<'a> {
 32 |     type Item = &'a mut PathNode;
 33 | 
 34 |     fn next(&mut self) -> Option<Self::Item> {
 35 |         let current = self.current.take()?;
 36 |         let child_ptr = current
 37 |             .child
 38 |             .as_deref_mut()
 39 |             .map(|child| child as *mut PathNode);
 40 |         self.current = child_ptr.map(|ptr| unsafe { &mut *ptr });
 41 |         Some(current)
 42 |     }
 43 | }
 44 | 
 45 | /// Iterator over owned PathNode elements
 46 | pub struct PathNodeIntoIter {
 47 |     current: Option<PathNode>,
 48 | }
 49 | 
 50 | impl Iterator for PathNodeIntoIter {
 51 |     type Item = PathNode;
 52 | 
 53 |     fn next(&mut self) -> Option<Self::Item> {
 54 |         let mut current = self.current.take()?;
 55 |         self.current = current.child.map(|boxed| *boxed);
 56 |         current.child = None; // Remove child to avoid double ownership
 57 |         Some(current)
 58 |     }
 59 | }
 60 | 
 61 | #[derive(Clone, PartialEq, Eq, Hash)]
 62 | pub struct PathNode {
 63 |     /// The schema type of this node
 64 |     pub node_type: NamedType,
 65 | 
 66 |     /// The name of the field referencing the child type, if the child is a field type
 67 |     pub field_name: Option<Name>,
 68 | 
 69 |     /// The arguments of the field referencing the child type, if the child is a field type
 70 |     pub field_args: Vec<NamedType>,
 71 | 
 72 |     /// The child type
 73 |     child: Option<Box<PathNode>>,
 74 | }
 75 | 
 76 | impl PathNode {
 77 |     /// Create a new path containing just one type
 78 |     pub fn new(node_type: NamedType) -> Self {
 79 |         Self {
 80 |             node_type,
 81 |             field_name: None,
 82 |             field_args: Vec::default(),
 83 |             child: None,
 84 |         }
 85 |     }
 86 | 
 87 |     /// Add a child to the end of a path. Allows building up a path from the root down.
 88 |     pub fn add_child(
 89 |         self,
 90 |         field_name: Option<Name>,
 91 |         field_args: Vec<NamedType>,
 92 |         child_type: NamedType,
 93 |     ) -> Self {
 94 |         if let Some(child) = self.child {
 95 |             Self {
 96 |                 node_type: self.node_type,
 97 |                 field_name: self.field_name,
 98 |                 field_args: self.field_args,
 99 |                 child: Some(Box::new(
100 |                     child.add_child(field_name, field_args, child_type),
101 |                 )),
102 |             }
103 |         } else {
104 |             Self {
105 |                 node_type: self.node_type,
106 |                 field_name,
107 |                 field_args,
108 |                 child: Some(Box::new(PathNode::new(child_type))),
109 |             }
110 |         }
111 |     }
112 | 
113 |     /// Add a parent to the beginning of a path. Allows building up a path from the bottom up.
114 |     pub fn add_parent(
115 |         self,
116 |         field_name: Option<Name>,
117 |         field_args: Vec<NamedType>,
118 |         parent_type: NamedType,
119 |     ) -> Self {
120 |         Self {
121 |             node_type: parent_type,
122 |             field_name,
123 |             field_args,
124 |             child: Some(Box::new(self)),
125 |         }
126 |     }
127 | 
128 |     /// Gets the penultimate node in a path
129 |     pub fn referencing_type(&self) -> Option<(&NamedType, Option<&Name>, Vec<&NamedType>)> {
130 |         if let Some(child) = &self.child {
131 |             child.referencing_type_inner(self)
132 |         } else {
133 |             None
134 |         }
135 |     }
136 | 
137 |     fn referencing_type_inner<'a>(
138 |         &'a self,
139 |         referencing_node: &'a PathNode,
140 |     ) -> Option<(&'a NamedType, Option<&'a Name>, Vec<&'a NamedType>)> {
141 |         if let Some(child) = &self.child {
142 |             child.referencing_type_inner(self)
143 |         } else {
144 |             Some((
145 |                 &referencing_node.node_type,
146 |                 referencing_node.field_name.as_ref(),
147 |                 referencing_node.field_args.iter().collect(),
148 |             ))
149 |         }
150 |     }
151 | 
152 |     /// Determines if a path contains a cycle
153 |     pub(crate) fn has_cycle(&self) -> bool {
154 |         self.has_cycle_inner(HashSet::new())
155 |     }
156 | 
157 |     fn has_cycle_inner(&self, mut visited: HashSet<NamedType>) -> bool {
158 |         if visited.contains(&self.node_type) {
159 |             return true;
160 |         }
161 | 
162 |         visited.insert(self.node_type.clone());
163 | 
164 |         if let Some(child) = &self.child {
165 |             child.has_cycle_inner(visited)
166 |         } else {
167 |             false
168 |         }
169 |     }
170 | 
171 |     /// Gets the length of the path
172 |     pub fn len(&self) -> usize {
173 |         if let Some(child) = &self.child {
174 |             child.len() + 1
175 |         } else {
176 |             1
177 |         }
178 |     }
179 | 
180 |     /// Get an iterator over references to all nodes in this path
181 |     pub fn iter(&self) -> PathNodeIter<'_> {
182 |         PathNodeIter {
183 |             current: Some(self),
184 |         }
185 |     }
186 | 
187 |     /// Get an iterator over mutable references to all nodes in this path
188 |     pub fn iter_mut(&mut self) -> PathNodeIterMut<'_> {
189 |         PathNodeIterMut {
190 |             current: Some(self),
191 |         }
192 |     }
193 | }
194 | 
195 | impl<'a> IntoIterator for &'a PathNode {
196 |     type Item = &'a PathNode;
197 |     type IntoIter = PathNodeIter<'a>;
198 | 
199 |     fn into_iter(self) -> Self::IntoIter {
200 |         self.iter()
201 |     }
202 | }
203 | 
204 | impl<'a> IntoIterator for &'a mut PathNode {
205 |     type Item = &'a mut PathNode;
206 |     type IntoIter = PathNodeIterMut<'a>;
207 | 
208 |     fn into_iter(self) -> Self::IntoIter {
209 |         self.iter_mut()
210 |     }
211 | }
212 | 
213 | impl IntoIterator for PathNode {
214 |     type Item = PathNode;
215 |     type IntoIter = PathNodeIntoIter;
216 | 
217 |     fn into_iter(self) -> Self::IntoIter {
218 |         PathNodeIntoIter {
219 |             current: Some(self),
220 |         }
221 |     }
222 | }
223 | 
224 | impl Display for PathNode {
225 |     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 |         if let Some(child) = &self.child {
227 |             if let Some(field_name) = &self.field_name {
228 |                 if !self.field_args.is_empty() {
229 |                     write!(
230 |                         f,
231 |                         "{} -> {}({}) -> {}",
232 |                         self.node_type.as_str(),
233 |                         field_name.as_str(),
234 |                         self.field_args
235 |                             .iter()
236 |                             .map(|arg| arg.as_str())
237 |                             .collect::<Vec<_>>()
238 |                             .join(","),
239 |                         child
240 |                     )
241 |                 } else {
242 |                     write!(
243 |                         f,
244 |                         "{} -> {} -> {}",
245 |                         self.node_type.as_str(),
246 |                         field_name.as_str(),
247 |                         child
248 |                     )
249 |                 }
250 |             } else {
251 |                 write!(f, "{} -> {}", self.node_type.as_str(), child)
252 |             }
253 |         } else {
254 |             write!(f, "{}", self.node_type.as_str())
255 |         }
256 |     }
257 | }
258 | 
259 | /// An item with a score
260 | pub struct Scored<T: Eq + Hash + Display> {
261 |     pub inner: T,
262 |     score: f32,
263 | }
264 | 
265 | impl<T: Eq + Hash + Display> Scored<T> {
266 |     /// Create a new scored item
267 |     pub fn new(inner: T, score: f32) -> Self {
268 |         Self { inner, score }
269 |     }
270 | 
271 |     /// Get the score associated with this item
272 |     pub fn score(&self) -> f32 {
273 |         self.score
274 |     }
275 | }
276 | 
277 | impl<T: Eq + Hash + Display> PartialEq for Scored<T> {
278 |     fn eq(&self, other: &Self) -> bool {
279 |         self.inner == other.inner && self.score() == other.score()
280 |     }
281 | }
282 | 
283 | impl<T: Eq + Hash + Display> Eq for Scored<T> {}
284 | 
285 | impl<T: Eq + Hash + Display> PartialOrd for Scored<T> {
286 |     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
287 |         Some(self.cmp(other))
288 |     }
289 | }
290 | 
291 | impl<T: Eq + Hash + Display> Ord for Scored<T> {
292 |     fn cmp(&self, other: &Self) -> std::cmp::Ordering {
293 |         self.score().total_cmp(&other.score())
294 |     }
295 | }
296 | 
297 | impl<T: Eq + Hash + Display> Hash for Scored<T> {
298 |     fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
299 |         self.inner.hash(state);
300 |     }
301 | }
302 | 
303 | impl<T: Eq + Hash + Display> Display for Scored<T> {
304 |     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305 |         write!(f, "{} ({})", self.inner, self.score)
306 |     }
307 | }
308 | 
309 | #[cfg(test)]
310 | mod test {
311 |     use super::*;
312 |     use apollo_compiler::name;
313 |     use insta::assert_snapshot;
314 | 
315 |     #[test]
316 |     fn test_add_child() {
317 |         let path = PathNode::new(NamedType::new("Root").unwrap());
318 |         let path = path.add_child(
319 |             Some(name!("child")),
320 |             vec![],
321 |             NamedType::new("Child").unwrap(),
322 |         );
323 |         assert_eq!(path.to_string(), "Root -> child -> Child");
324 |     }
325 | 
326 |     #[test]
327 |     fn test_add_parent() {
328 |         let path = PathNode::new(NamedType::new("Child").unwrap());
329 |         let path = path.add_parent(
330 |             Some(name!("child")),
331 |             vec![],
332 |             NamedType::new("Root").unwrap(),
333 |         );
334 |         assert_eq!(path.to_string(), "Root -> child -> Child");
335 |     }
336 | 
337 |     #[test]
338 |     fn test_len() {
339 |         // Test path with no children
340 |         let path = PathNode::new(NamedType::new("Root").unwrap());
341 |         assert_eq!(path.len(), 1);
342 | 
343 |         // Test path with one child
344 |         let path = path.add_child(
345 |             Some(name!("child")),
346 |             vec![],
347 |             NamedType::new("Child").unwrap(),
348 |         );
349 |         assert_eq!(path.len(), 2);
350 | 
351 |         // Test path with two children
352 |         let path = path.add_child(
353 |             Some(name!("grandchild")),
354 |             vec![],
355 |             NamedType::new("GrandChild").unwrap(),
356 |         );
357 |         assert_eq!(path.len(), 3);
358 | 
359 |         // Test path with a non-field child
360 |         let path = path.add_child(None, vec![], NamedType::new("GreatGrandChild").unwrap());
361 |         assert_eq!(path.len(), 4);
362 |     }
363 | 
364 |     #[test]
365 |     fn test_display() {
366 |         let path = PathNode::new(NamedType::new("Root").unwrap());
367 |         let path = path.add_child(
368 |             Some(name!("child")),
369 |             vec![
370 |                 NamedType::new("Arg1").unwrap(),
371 |                 NamedType::new("Arg2").unwrap(),
372 |             ],
373 |             NamedType::new("Child").unwrap(),
374 |         );
375 |         let path = path.add_child(
376 |             Some(name!("grandchild")),
377 |             vec![],
378 |             NamedType::new("GrandChild").unwrap(),
379 |         );
380 |         assert_snapshot!(
381 |             path.to_string(),
382 |             @"Root -> child(Arg1,Arg2) -> Child -> grandchild -> GrandChild"
383 |         );
384 |     }
385 | 
386 |     #[test]
387 |     fn test_has_cycle() {
388 |         // Test path without cycle
389 |         let path = PathNode::new(NamedType::new("Root").unwrap());
390 |         let path = path.add_child(
391 |             Some(name!("child")),
392 |             vec![],
393 |             NamedType::new("Child").unwrap(),
394 |         );
395 |         assert!(!path.has_cycle());
396 | 
397 |         // Test path with cycle (Root -> Child -> Root)
398 |         let root_type = NamedType::new("Root").unwrap();
399 |         let path = PathNode::new(root_type.clone());
400 |         let path = path.add_child(
401 |             Some(name!("child")),
402 |             vec![],
403 |             NamedType::new("Child").unwrap(),
404 |         );
405 |         let path = path.add_child(Some(name!("back_to_root")), vec![], root_type);
406 |         assert!(path.has_cycle());
407 |     }
408 | 
409 |     #[test]
410 |     fn test_referencing_type() {
411 |         // Test single level path
412 |         let path = PathNode::new(NamedType::new("Root").unwrap());
413 |         assert_eq!(path.referencing_type(), None);
414 | 
415 |         // Test two level path: Root -> child -> Child
416 |         let root_type = NamedType::new("Root").unwrap();
417 |         let child_type = NamedType::new("Child").unwrap();
418 |         let path = PathNode::new(root_type.clone());
419 |         let path = path.add_child(Some(name!("child")), vec![], child_type.clone());
420 |         assert_eq!(
421 |             path.referencing_type(),
422 |             Some((&root_type, Some(&name!("child")), vec![])),
423 |         );
424 | 
425 |         // Test three level path: Root -> child -> Child -> grandchild -> GrandChild
426 |         let path = path.add_child(
427 |             Some(name!("grandchild")),
428 |             vec![],
429 |             NamedType::new("GrandChild").unwrap(),
430 |         );
431 |         assert_eq!(
432 |             path.referencing_type(),
433 |             Some((&child_type, Some(&name!("grandchild")), vec![]))
434 |         );
435 |     }
436 | 
437 |     #[test]
438 |     fn test_iteration() {
439 |         // Test single node
440 |         let path = PathNode::new(NamedType::new("Root").unwrap());
441 |         let nodes: Vec<_> = path.iter().collect();
442 |         assert_eq!(nodes.len(), 1);
443 |         assert_eq!(nodes[0].node_type.as_str(), "Root");
444 | 
445 |         // Test two level path: Root -> child -> Child
446 |         let path = path.add_child(
447 |             Some(name!("child")),
448 |             vec![],
449 |             NamedType::new("Child").unwrap(),
450 |         );
451 |         let nodes: Vec<_> = path.iter().collect();
452 |         assert_eq!(nodes.len(), 2);
453 |         assert_eq!(nodes[0].node_type.as_str(), "Root");
454 |         assert_eq!(nodes[1].node_type.as_str(), "Child");
455 |         assert_eq!(nodes[0].field_name.as_ref().unwrap().as_str(), "child");
456 |         assert_eq!(nodes[1].field_name, None);
457 | 
458 |         // Test three level path: Root -> child -> Child -> grandchild -> GrandChild
459 |         let path = path.add_child(
460 |             Some(name!("grandchild")),
461 |             vec![],
462 |             NamedType::new("GrandChild").unwrap(),
463 |         );
464 |         let nodes: Vec<_> = path.iter().collect();
465 |         assert_eq!(nodes.len(), 3);
466 |         assert_eq!(nodes[0].node_type.as_str(), "Root");
467 |         assert_eq!(nodes[1].node_type.as_str(), "Child");
468 |         assert_eq!(nodes[2].node_type.as_str(), "GrandChild");
469 |         assert_eq!(nodes[0].field_name.as_ref().unwrap().as_str(), "child");
470 |         assert_eq!(nodes[1].field_name.as_ref().unwrap().as_str(), "grandchild");
471 |         assert_eq!(nodes[2].field_name, None);
472 |     }
473 | 
474 |     #[test]
475 |     fn test_iteration_mut() {
476 |         // Test mutable iteration
477 |         let path = PathNode::new(NamedType::new("Root").unwrap());
478 |         let path = path.add_child(
479 |             Some(name!("child")),
480 |             vec![],
481 |             NamedType::new("Child").unwrap(),
482 |         );
483 |         let path = path.add_child(
484 |             Some(name!("grandchild")),
485 |             vec![],
486 |             NamedType::new("GrandChild").unwrap(),
487 |         );
488 | 
489 |         let mut path = path;
490 |         let nodes: Vec<_> = path.iter_mut().collect();
491 |         assert_eq!(nodes.len(), 3);
492 | 
493 |         // Verify we can access the nodes mutably
494 |         for node in nodes {
495 |             assert!(!node.node_type.as_str().is_empty());
496 |         }
497 |     }
498 | 
499 |     #[test]
500 |     fn test_into_iter() {
501 |         // Test owned iteration
502 |         let path = PathNode::new(NamedType::new("Root").unwrap());
503 |         let path = path.add_child(
504 |             Some(name!("child")),
505 |             vec![],
506 |             NamedType::new("Child").unwrap(),
507 |         );
508 |         let path = path.add_child(
509 |             Some(name!("grandchild")),
510 |             vec![],
511 |             NamedType::new("GrandChild").unwrap(),
512 |         );
513 | 
514 |         let nodes: Vec<_> = path.into_iter().collect();
515 |         assert_eq!(nodes.len(), 3);
516 |         assert_eq!(nodes[0].node_type.as_str(), "Root");
517 |         assert_eq!(nodes[1].node_type.as_str(), "Child");
518 |         assert_eq!(nodes[2].node_type.as_str(), "GrandChild");
519 |     }
520 | 
521 |     #[test]
522 |     fn test_iteration_with_into_iter() {
523 |         // Test using IntoIterator trait
524 |         let path = PathNode::new(NamedType::new("Root").unwrap());
525 |         let path = path.add_child(
526 |             Some(name!("child")),
527 |             vec![],
528 |             NamedType::new("Child").unwrap(),
529 |         );
530 |         let path = path.add_child(
531 |             Some(name!("grandchild")),
532 |             vec![],
533 |             NamedType::new("GrandChild").unwrap(),
534 |         );
535 | 
536 |         // Test reference iteration
537 |         let nodes: Vec<_> = (&path).into_iter().collect();
538 |         assert_eq!(nodes.len(), 3);
539 |         assert_eq!(nodes[0].node_type.as_str(), "Root");
540 |         assert_eq!(nodes[1].node_type.as_str(), "Child");
541 |         assert_eq!(nodes[2].node_type.as_str(), "GrandChild");
542 | 
543 |         // Test mutable reference iteration
544 |         let mut path = path;
545 |         let nodes: Vec<_> = (&mut path).into_iter().collect();
546 |         assert_eq!(nodes.len(), 3);
547 | 
548 |         // Test owned iteration
549 |         let path = PathNode::new(NamedType::new("Root").unwrap());
550 |         let path = path.add_child(
551 |             Some(name!("child")),
552 |             vec![],
553 |             NamedType::new("Child").unwrap(),
554 |         );
555 |         let nodes: Vec<_> = path.into_iter().collect();
556 |         assert_eq!(nodes.len(), 2);
557 |     }
558 | 
559 |     #[test]
560 |     fn test_iteration_empty_path() {
561 |         // Test iteration on a path with no children
562 |         let path = PathNode::new(NamedType::new("Root").unwrap());
563 |         let nodes: Vec<_> = path.iter().collect();
564 |         assert_eq!(nodes.len(), 1);
565 |         assert_eq!(nodes[0].node_type.as_str(), "Root");
566 |     }
567 | }
568 | 
```