Skip to content

Commit 96829e8

Browse files
authored
Add an MCP tool to open Apollo Explorer (#32)
1 parent ea3f676 commit 96829e8

File tree

8 files changed

+238
-18
lines changed

8 files changed

+238
-18
lines changed

Cargo.lock

+106-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/mcp-apollo-server/Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ apollo-compiler.workspace = true
1010
buildstructor = "0.6.0"
1111
clap = { version = "4.5.36", features = ["derive"] }
1212
derive-new = "0.7.0"
13+
lz-str = "0.2.1"
1314
mcp-apollo-registry = { path = "../mcp-apollo-registry" }
1415
reqwest = "0.12.15"
1516
rmcp = { version = "0.1", features = [
@@ -32,6 +33,8 @@ tokio = { version = "1.44.2", features = [
3233
tracing.workspace = true
3334
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
3435
regex = "1.11.1"
36+
webbrowser = "1.0.4"
37+
base64 = "0.22.1"
3538

3639
[dev-dependencies]
3740
insta = "1.42.2"
+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
use crate::errors::McpError;
2+
use crate::schema_from_type;
3+
use base64::Engine;
4+
use rmcp::model::{CallToolResult, Content, ErrorCode, Tool};
5+
use rmcp::schemars::JsonSchema;
6+
use rmcp::serde_json::Value;
7+
use rmcp::{schemars, serde_json};
8+
use serde::Deserialize;
9+
10+
pub(crate) const EXPLORER_TOOL_NAME: &str = "explorer";
11+
12+
#[derive(Clone)]
13+
pub struct Explorer {
14+
graph_id: String,
15+
variant: String,
16+
pub tool: Tool,
17+
}
18+
19+
#[derive(JsonSchema, Deserialize)]
20+
#[allow(dead_code)] // This is only used to generate the JSON schema
21+
pub struct Input {
22+
/// The GraphQL document
23+
document: String,
24+
variables: String,
25+
headers: String,
26+
}
27+
28+
impl Explorer {
29+
pub fn new(graph_ref: String) -> Self {
30+
let (graph_id, variant) = match graph_ref.split_once('@') {
31+
Some((graph_id, variant)) => (graph_id.to_string(), variant.to_string()),
32+
None => (graph_ref, String::from("current")),
33+
};
34+
Self {
35+
graph_id,
36+
variant,
37+
tool: Tool::new(
38+
EXPLORER_TOOL_NAME,
39+
"Open a GraphQL operation in Apollo Explorer",
40+
schema_from_type!(Input),
41+
),
42+
}
43+
}
44+
45+
fn create_explorer_url(&self, input: &Value) -> String {
46+
let compressed = lz_str::compress_to_uint8_array(input.to_string().as_str());
47+
let encoded = base64::engine::general_purpose::STANDARD.encode(compressed);
48+
format!(
49+
"https://studio.apollographql.com/graph/{graph_id}/variant/{variant}/explorer?explorerURLState={encoded}",
50+
graph_id = self.graph_id,
51+
variant = self.variant
52+
)
53+
}
54+
55+
pub async fn execute(&self, input: Value) -> Result<CallToolResult, McpError> {
56+
webbrowser::open(self.create_explorer_url(&input).as_str())
57+
.map(|_| CallToolResult {
58+
content: vec![Content::text("success")],
59+
is_error: None,
60+
})
61+
.map_err(|_| McpError::new(ErrorCode::INTERNAL_ERROR, "Unable to open browser", None))
62+
}
63+
}
64+
65+
#[cfg(test)]
66+
mod tests {
67+
use super::*;
68+
use rmcp::serde_json::json;
69+
70+
#[test]
71+
fn test_create_explorer_url() {
72+
let explorer = Explorer::new(String::from("mcp-example@mcp"));
73+
let input = json!({
74+
"document": "query GetWeatherAlerts($state: String!) {\n alerts(state: $state) {\n severity\n description\n instruction\n }\n}",
75+
"headers": "{}",
76+
"variables": "{\"state\": \"CA\"}"
77+
});
78+
79+
let url = explorer.create_explorer_url(&input);
80+
assert_eq!(
81+
url,
82+
"https://studio.apollographql.com/graph/mcp-example/variant/mcp/explorer?explorerURLState=N4IgJg9gxgrgtgUwHYBcQC4QEcYIE4CeABAOIIoDqCAhigBb4CCANvigM4AUAJOyrQnREAyijwBLJAHMAhAEoiwADpIiRaqzwdOfAUN78UCBctVqi7BADd84lARXmiYBOygSADinEQkj85J8eDBQ3r7+AL4qESAANCAM1C547BggwDHxVtQS1ABGrKmYyiC6RkoYRBUAwowVMRFAAAA="
83+
);
84+
}
85+
}

crates/mcp-apollo-server/src/introspection.rs

+1-9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use crate::errors::McpError;
44
use crate::graphql;
5+
use crate::schema_from_type;
56
use apollo_compiler::Schema;
67
use apollo_compiler::validation::Valid;
78
use rmcp::model::{ErrorCode, Tool};
@@ -13,15 +14,6 @@ use serde::Deserialize;
1314
pub(crate) const GET_SCHEMA_TOOL_NAME: &str = "schema";
1415
pub(crate) const EXECUTE_TOOL_NAME: &str = "execute";
1516

16-
macro_rules! schema_from_type {
17-
($type:ty) => {{
18-
match serde_json::to_value(schemars::schema_for!($type)) {
19-
Ok(Value::Object(schema)) => schema,
20-
_ => panic!("Failed to generate schema for {}", stringify!($type)),
21-
}
22-
}};
23-
}
24-
2517
#[derive(Clone)]
2618
pub struct GetSchema {
2719
pub schema: Valid<Schema>,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/// Macro to generate a JSON schema from a type
2+
#[macro_export]
3+
macro_rules! schema_from_type {
4+
($type:ty) => {{
5+
match serde_json::to_value(schemars::schema_for!($type)) {
6+
Ok(Value::Object(schema)) => schema,
7+
_ => panic!("Failed to generate schema for {}", stringify!($type)),
8+
}
9+
}};
10+
}

crates/mcp-apollo-server/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
pub mod custom_scalar_map;
22
pub mod errors;
3+
mod explorer;
34
mod graphql;
45
mod introspection;
6+
pub mod json_schema;
57
pub mod operations;
68
pub mod sanitize;
79
pub mod server;

crates/mcp-apollo-server/src/main.rs

+6-1
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,14 @@ struct Args {
5454
#[clap(long, short = 'i')]
5555
introspection: bool,
5656

57-
/// Enable use of uplink to get the schema and persisted queries
57+
/// Enable use of uplink to get the schema and persisted queries (requires APOLLO_KEY and APOLLO_GRAPH_REF)
5858
#[clap(long, short = 'u')]
5959
uplink: bool,
6060

61+
/// Expose a tool to open queries in Apollo Explorer (requires APOLLO_KEY and APOLLO_GRAPH_REF)
62+
#[clap(long, short = 'x')]
63+
explorer: bool,
64+
6165
/// Operation files to expose as MCP tools
6266
#[arg(long = "operations", short = 'o', num_args=0..)]
6367
operations: Vec<PathBuf>,
@@ -96,6 +100,7 @@ async fn main() -> anyhow::Result<()> {
96100
.headers(args.headers)
97101
.introspection(args.introspection)
98102
.uplink(args.uplink)
103+
.explorer(args.explorer)
99104
.manifests(args.manifest.into_iter().collect())
100105
.and_custom_scalar_map(
101106
args.custom_scalars_config

0 commit comments

Comments
 (0)