use serde_json::Value;
#[derive(Debug, Clone, serde::Serialize)]
pub struct GraphqlToolResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub fn validate_input(input: &Value) -> Result<(String, Value), String> {
let (query_str, variables) = if let Some(s) = input.as_str() {
(s.to_string(), Value::Null)
} else if let Some(obj) = input.as_object() {
let query = obj
.get("query")
.and_then(|q| q.as_str())
.ok_or("'query' must be a non-empty string")?;
if query.trim().is_empty() {
return Err("'query' must be a non-empty string".into());
}
let vars = obj.get("variables").cloned().unwrap_or(Value::Null);
if !vars.is_null() && !vars.is_object() {
return Err("'variables' must be a JSON object when present".into());
}
(query.to_string(), vars)
} else {
return Err("input must be an object with 'query' field or a raw query string".into());
};
if query_str.trim().is_empty() {
return Err("'query' must be a non-empty string".into());
}
if has_multiple_operations(&query_str) {
return Err("query must contain exactly one GraphQL operation".into());
}
Ok((query_str, variables))
}
fn has_multiple_operations(query: &str) -> bool {
let mut count = 0;
let mut chars = query.chars().peekable();
let mut in_string = false;
let mut in_comment = false;
while let Some(ch) = chars.next() {
if in_comment {
if ch == '\n' {
in_comment = false;
}
continue;
}
if ch == '#' {
in_comment = true;
continue;
}
if ch == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
if ch.is_alphabetic() {
let mut word = String::new();
word.push(ch);
while let Some(&next) = chars.peek() {
if next.is_alphanumeric() || next == '_' {
word.push(next);
chars.next();
} else {
break;
}
}
match word.as_str() {
"query" | "mutation" | "subscription" => {
count += 1;
if count > 1 {
return true;
}
}
_ => {}
}
}
}
false
}
pub async fn execute_graphql_tool(
endpoint: &str,
api_key: &str,
query: &str,
variables: Value,
) -> GraphqlToolResult {
let http = reqwest::Client::new();
let body = serde_json::json!({
"query": query,
"variables": if variables.is_null() { Value::Object(serde_json::Map::new()) } else { variables },
});
let response = match http
.post(endpoint)
.header("Authorization", api_key)
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => {
return GraphqlToolResult {
success: false,
data: None,
errors: None,
error: Some(format!("transport_failure: {e}")),
};
}
};
let status = response.status().as_u16();
if !(200..300).contains(&status) {
let body_text = response.text().await.unwrap_or_else(|_| "<unreadable>".into());
return GraphqlToolResult {
success: false,
data: None,
errors: None,
error: Some(format!("api_status_{status}: {body_text}")),
};
}
let json: Value = match response.json().await {
Ok(j) => j,
Err(e) => {
return GraphqlToolResult {
success: false,
data: None,
errors: None,
error: Some(format!("parse_failure: {e}")),
};
}
};
let has_errors = json
.get("errors")
.and_then(|e| e.as_array())
.is_some_and(|arr| !arr.is_empty());
if has_errors {
GraphqlToolResult {
success: false,
data: json.get("data").cloned(),
errors: json.get("errors").cloned(),
error: None,
}
} else {
GraphqlToolResult {
success: true,
data: json.get("data").cloned(),
errors: None,
error: None,
}
}
}
pub fn tool_spec() -> Value {
serde_json::json!({
"name": "linear_graphql",
"description": "Execute a GraphQL query or mutation against the Linear API.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A single GraphQL query or mutation document"
},
"variables": {
"type": "object",
"description": "Optional GraphQL variables"
}
},
"required": ["query"]
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_valid_query() {
let input = serde_json::json!({
"query": "query { viewer { id } }",
});
let (query, vars) = validate_input(&input).unwrap();
assert_eq!(query, "query { viewer { id } }");
assert!(vars.is_null());
}
#[test]
fn validate_query_with_variables() {
let input = serde_json::json!({
"query": "query($id: ID!) { issue(id: $id) { title } }",
"variables": { "id": "abc-123" }
});
let (query, vars) = validate_input(&input).unwrap();
assert!(query.contains("$id: ID!"));
assert!(vars.is_object());
assert_eq!(vars["id"], "abc-123");
}
#[test]
fn validate_raw_string_input() {
let input = Value::String("query { viewer { id } }".into());
let (query, _vars) = validate_input(&input).unwrap();
assert_eq!(query, "query { viewer { id } }");
}
#[test]
fn validate_empty_query_fails() {
let input = serde_json::json!({ "query": "" });
let err = validate_input(&input).unwrap_err();
assert!(err.contains("non-empty"));
}
#[test]
fn validate_missing_query_fails() {
let input = serde_json::json!({ "variables": {} });
let err = validate_input(&input).unwrap_err();
assert!(err.contains("non-empty"));
}
#[test]
fn validate_variables_must_be_object() {
let input = serde_json::json!({
"query": "query { viewer { id } }",
"variables": [1, 2, 3]
});
let err = validate_input(&input).unwrap_err();
assert!(err.contains("JSON object"));
}
#[test]
fn validate_multiple_operations_rejected() {
let input = serde_json::json!({
"query": "query A { viewer { id } } mutation B { updateIssue { id } }"
});
let err = validate_input(&input).unwrap_err();
assert!(err.contains("exactly one"));
}
#[test]
fn validate_single_mutation_accepted() {
let input = serde_json::json!({
"query": "mutation { updateIssue(id: \"123\", input: { title: \"New\" }) { success } }"
});
assert!(validate_input(&input).is_ok());
}
#[test]
fn has_multiple_operations_detects_two() {
assert!(has_multiple_operations(
"query A { a } mutation B { b }"
));
}
#[test]
fn has_multiple_operations_single_ok() {
assert!(!has_multiple_operations("query { viewer { id } }"));
}
#[test]
fn has_multiple_operations_ignores_comments() {
assert!(!has_multiple_operations(
"query { viewer { id } }\n# mutation { x }"
));
}
#[test]
fn tool_spec_has_required_fields() {
let spec = tool_spec();
assert_eq!(spec["name"], "linear_graphql");
assert!(spec.get("inputSchema").is_some());
}
#[test]
fn graphql_tool_result_serialization() {
let result = GraphqlToolResult {
success: true,
data: Some(serde_json::json!({"viewer": {"id": "user-1"}})),
errors: None,
error: None,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["success"], true);
assert!(json.get("data").is_some());
assert!(json.get("errors").is_none());
assert!(json.get("error").is_none());
}
#[test]
fn graphql_tool_result_failure() {
let result = GraphqlToolResult {
success: false,
data: Some(serde_json::json!(null)),
errors: Some(serde_json::json!([{"message": "Not found"}])),
error: None,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["success"], false);
assert!(json.get("errors").is_some());
}
#[tokio::test]
#[ignore] async fn real_graphql_tool_valid_query() {
let api_key = std::env::var("LINEAR_API_KEY")
.expect("LINEAR_API_KEY must be set for real integration tests");
let result = execute_graphql_tool(
"https://api.linear.app/graphql",
&api_key,
"query { viewer { id name } }",
Value::Null,
)
.await;
assert!(result.success, "valid query should succeed: {:?}", result);
assert!(result.data.is_some(), "data should be present");
assert!(result.error.is_none(), "error should be absent on success");
}
#[tokio::test]
#[ignore] async fn real_graphql_tool_invalid_auth() {
let result = execute_graphql_tool(
"https://api.linear.app/graphql",
"lin_api_invalid_key_12345",
"query { viewer { id } }",
Value::Null,
)
.await;
assert!(!result.success, "invalid auth should fail");
}
}