Skip to content

Commit 9049026

Browse files
authored
feat: Add MCP tool for verifying graphql queries before executing them called 'validate' (#203)
* feat: Validate tool for verifying graphql queries before executing them * Include changeset * Update tool descriptions * Also include validation tool in list of any introspection tools being enabled
1 parent 3c10c1e commit 9049026

File tree

12 files changed

+162
-4
lines changed

12 files changed

+162
-4
lines changed

.changesets/feat_validate_tool.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### feat: Validate tool for verifying graphql queries before executing them - @swcollard PR #203
2+
3+
The introspection options in the mcp server provide introspect, execute, and search tools. The LLM often tries to validate its queries by just executing them. This may not be desired (there might be side effects, for example). This feature adds a `validate` tool so the LLM can validate the operation without actually hitting the GraphQL endpoint. It first validates the syntax of the operation, and then checks it against the introspected schema for validation.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
pub(crate) mod execute;
44
pub(crate) mod introspect;
55
pub(crate) mod search;
6+
pub(crate) mod validate;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ impl Execute {
3636
mutation_mode,
3737
tool: Tool::new(
3838
EXECUTE_TOOL_NAME,
39-
"Execute a GraphQL operation. Use the `introspect` tool to get information about the GraphQL schema. Always use the schema to create operations - do not try arbitrary operations. DO NOT try to execute introspection queries.",
39+
"Execute a GraphQL operation. Use the `introspect` tool to get information about the GraphQL schema. Always use the schema to create operations - do not try arbitrary operations. If available, first use the `validate` tool to validate operations. DO NOT try to execute introspection queries.",
4040
schema_from_type!(Input),
4141
),
4242
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
use crate::errors::McpError;
2+
use crate::operations::operation_defs;
3+
use crate::schema_from_type;
4+
use apollo_compiler::Schema;
5+
use apollo_compiler::parser::Parser;
6+
use apollo_compiler::validation::Valid;
7+
use rmcp::model::CallToolResult;
8+
use rmcp::model::Content;
9+
use rmcp::model::{ErrorCode, 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::default::Default;
15+
use std::sync::Arc;
16+
use tokio::sync::Mutex;
17+
18+
/// The name of the tool to validate an ad hoc GraphQL operation
19+
pub const VALIDATE_TOOL_NAME: &str = "validate";
20+
21+
#[derive(Clone)]
22+
pub struct Validate {
23+
pub tool: Tool,
24+
schema: Arc<Mutex<Valid<Schema>>>,
25+
}
26+
27+
/// Input for the validate tool
28+
#[derive(JsonSchema, Deserialize)]
29+
pub struct Input {
30+
/// The GraphQL operation
31+
operation: String,
32+
}
33+
34+
impl Validate {
35+
pub fn new(schema: Arc<Mutex<Valid<Schema>>>) -> Self {
36+
Self {
37+
schema,
38+
tool: Tool::new(
39+
VALIDATE_TOOL_NAME,
40+
"Validates a GraphQL operation against the schema. \
41+
Use the `introspect` tool first to get information about the GraphQL schema. \
42+
Operations should be validated prior to calling the `execute` tool.",
43+
schema_from_type!(Input),
44+
),
45+
}
46+
}
47+
48+
/// Validates the provided GraphQL query
49+
pub async fn execute(&self, input: Value) -> Result<CallToolResult, McpError> {
50+
let input = serde_json::from_value::<Input>(input).map_err(|_| {
51+
McpError::new(ErrorCode::INVALID_PARAMS, "Invalid input".to_string(), None)
52+
})?;
53+
54+
operation_defs(&input.operation, true, None)
55+
.map_err(|e| McpError::new(ErrorCode::INVALID_PARAMS, e.to_string(), None))?
56+
.ok_or_else(|| {
57+
McpError::new(
58+
ErrorCode::INVALID_PARAMS,
59+
"Invalid operation type".to_string(),
60+
None,
61+
)
62+
})?;
63+
64+
let schema_guard = self.schema.lock().await;
65+
Parser::new()
66+
.parse_executable(&schema_guard, input.operation.as_str(), "operation.graphql")
67+
.map_err(|e| McpError::new(ErrorCode::INVALID_PARAMS, e.to_string(), None))?;
68+
Ok(CallToolResult {
69+
content: vec![Content::text("Operation is valid")],
70+
is_error: None,
71+
})
72+
}
73+
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use serde_json::json;
78+
79+
use super::*;
80+
static SCHEMA: std::sync::LazyLock<Arc<Mutex<Valid<Schema>>>> =
81+
std::sync::LazyLock::new(|| {
82+
Arc::new(Mutex::new(
83+
Schema::parse_and_validate("type Query { id: ID! }", "schema.graphql").unwrap(),
84+
))
85+
});
86+
87+
#[tokio::test]
88+
async fn validate_valid_query() {
89+
let validate = Validate::new(SCHEMA.clone());
90+
let input = json!({
91+
"operation": "query Test { id }"
92+
});
93+
assert!(validate.execute(input).await.is_ok());
94+
}
95+
96+
#[tokio::test]
97+
async fn validate_invalid_graphql_query() {
98+
let validate = Validate::new(SCHEMA.clone());
99+
let input = json!({
100+
"operation": "query {"
101+
});
102+
assert!(validate.execute(input).await.is_err());
103+
}
104+
105+
#[tokio::test]
106+
async fn validate_invalid_query_field() {
107+
let validate = Validate::new(SCHEMA.clone());
108+
let input = json!({
109+
"operation": "query { invalidField }"
110+
});
111+
assert!(validate.execute(input).await.is_err());
112+
}
113+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ async fn main() -> anyhow::Result<()> {
138138
.maybe_explorer_graph_ref(explorer_graph_ref)
139139
.headers(config.headers)
140140
.execute_introspection(config.introspection.execute.enabled)
141+
.validate_introspection(config.introspection.validate.enabled)
141142
.introspect_introspection(config.introspection.introspect.enabled)
142143
.introspect_minify(config.introspection.introspect.minify)
143144
.search_minify(config.introspection.search.minify)

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,12 @@ pub fn operation_defs(
370370
allow_mutations: bool,
371371
source_path: Option<String>,
372372
) -> Result<Option<(Document, Node<OperationDefinition>, Option<String>)>, OperationError> {
373+
let source_path_clone = source_path.clone();
373374
let document = Parser::new()
374-
.parse_ast(source_text, "operation.graphql")
375+
.parse_ast(
376+
source_text,
377+
source_path_clone.unwrap_or_else(|| "operation.graphql".to_string()),
378+
)
375379
.map_err(|e| OperationError::GraphQLDocument(Box::new(e)))?;
376380
let mut last_offset: Option<usize> = Some(0);
377381
let mut operation_defs = document.definitions.clone().into_iter().filter_map(|def| {

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ pub struct Introspection {
1313

1414
/// Search tool configuration
1515
pub search: SearchConfig,
16+
17+
/// Validate configuration for checking operations before execution
18+
pub validate: ValidateConfig,
1619
}
1720

1821
/// Execution-specific introspection configuration
@@ -64,9 +67,17 @@ impl Default for SearchConfig {
6467
}
6568
}
6669

70+
/// Validation tool configuration
71+
#[derive(Debug, Default, Deserialize, JsonSchema)]
72+
#[serde(default)]
73+
pub struct ValidateConfig {
74+
/// Enable validation tool
75+
pub enabled: bool,
76+
}
77+
6778
impl Introspection {
6879
/// Check if any introspection tools are enabled
6980
pub fn any_enabled(&self) -> bool {
70-
self.execute.enabled | self.introspect.enabled | self.search.enabled
81+
self.execute.enabled | self.introspect.enabled | self.search.enabled | self.validate.enabled
7182
}
7283
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub struct Server {
2424
endpoint: Url,
2525
headers: HeaderMap,
2626
execute_introspection: bool,
27+
validate_introspection: bool,
2728
introspect_introspection: bool,
2829
introspect_minify: bool,
2930
search_minify: bool,
@@ -90,6 +91,7 @@ impl Server {
9091
endpoint: Url,
9192
headers: HeaderMap,
9293
execute_introspection: bool,
94+
validate_introspection: bool,
9395
introspect_introspection: bool,
9496
search_introspection: bool,
9597
introspect_minify: bool,
@@ -114,6 +116,7 @@ impl Server {
114116
endpoint,
115117
headers,
116118
execute_introspection,
119+
validate_introspection,
117120
introspect_introspection,
118121
search_introspection,
119122
introspect_minify,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ struct Config {
3333
endpoint: Url,
3434
headers: HeaderMap,
3535
execute_introspection: bool,
36+
validate_introspection: bool,
3637
introspect_introspection: bool,
3738
search_introspection: bool,
3839
introspect_minify: bool,
@@ -63,6 +64,7 @@ impl StateMachine {
6364
endpoint: server.endpoint,
6465
headers: server.headers,
6566
execute_introspection: server.execute_introspection,
67+
validate_introspection: server.validate_introspection,
6668
introspect_introspection: server.introspect_introspection,
6769
search_introspection: server.search_introspection,
6870
introspect_minify: server.introspect_minify,

crates/apollo-mcp-server/src/server/states/running.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use crate::{
2626
execute::{EXECUTE_TOOL_NAME, Execute},
2727
introspect::{INTROSPECT_TOOL_NAME, Introspect},
2828
search::{SEARCH_TOOL_NAME, Search},
29+
validate::{VALIDATE_TOOL_NAME, Validate},
2930
},
3031
operations::{MutationMode, Operation, RawOperation},
3132
};
@@ -40,6 +41,7 @@ pub(super) struct Running {
4041
pub(super) introspect_tool: Option<Introspect>,
4142
pub(super) search_tool: Option<Search>,
4243
pub(super) explorer_tool: Option<Explorer>,
44+
pub(super) validate_tool: Option<Validate>,
4345
pub(super) custom_scalar_map: Option<CustomScalarMap>,
4446
pub(super) peers: Arc<RwLock<Vec<Peer<RoleServer>>>>,
4547
pub(super) cancellation_token: CancellationToken,
@@ -211,6 +213,13 @@ impl ServerHandler for Running {
211213
})
212214
.await
213215
}
216+
VALIDATE_TOOL_NAME => {
217+
self.validate_tool
218+
.as_ref()
219+
.ok_or(tool_not_found(&request.name))?
220+
.execute(convert_arguments(request)?)
221+
.await
222+
}
214223
_ => {
215224
let graphql_request = graphql::Request {
216225
input: Value::from(request.arguments.clone()),
@@ -246,6 +255,7 @@ impl ServerHandler for Running {
246255
.chain(self.introspect_tool.as_ref().iter().map(|e| e.tool.clone()))
247256
.chain(self.search_tool.as_ref().iter().map(|e| e.tool.clone()))
248257
.chain(self.explorer_tool.as_ref().iter().map(|e| e.tool.clone()))
258+
.chain(self.validate_tool.as_ref().iter().map(|e| e.tool.clone()))
249259
.collect(),
250260
})
251261
}
@@ -300,6 +310,7 @@ mod tests {
300310
introspect_tool: None,
301311
search_tool: None,
302312
explorer_tool: None,
313+
validate_tool: None,
303314
custom_scalar_map: None,
304315
peers: Arc::new(RwLock::new(vec![])),
305316
cancellation_token: CancellationToken::new(),

0 commit comments

Comments
 (0)