use serde_json::{Value, json};
use tracing::{debug, info};
use crate::error::{ProxyError, ProxyResult};
#[cfg(feature = "graphql")]
use crate::introspection::ServerSpec;
#[cfg(feature = "graphql")]
use crate::proxy::BackendConnector;
#[cfg(feature = "graphql")]
use axum::{
Json, Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
#[cfg(feature = "graphql")]
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct GraphQLAdapterConfig {
pub bind: String,
pub playground: bool,
}
impl GraphQLAdapterConfig {
pub fn new(bind: impl Into<String>, playground: bool) -> Self {
Self {
bind: bind.into(),
playground,
}
}
}
#[cfg(feature = "graphql")]
#[derive(Clone)]
struct GraphQLAdapterState {
backend: BackendConnector, spec: Arc<ServerSpec>, }
#[cfg(feature = "graphql")]
pub struct GraphQLAdapter {
config: GraphQLAdapterConfig,
backend: BackendConnector,
spec: ServerSpec,
}
#[cfg(feature = "graphql")]
impl GraphQLAdapter {
#[must_use]
pub fn new(config: GraphQLAdapterConfig, backend: BackendConnector, spec: ServerSpec) -> Self {
Self {
config,
backend,
spec,
}
}
pub async fn run(self) -> ProxyResult<()> {
info!("Starting GraphQL adapter on {}", self.config.bind);
let state = GraphQLAdapterState {
backend: self.backend,
spec: Arc::new(self.spec),
};
let router = Router::new()
.route("/graphql", post(graphql_endpoint))
.route("/schema", get(graphql_schema))
.route("/health", get(health_check))
.with_state(state);
if self.config.playground {
info!("GraphQL schema available at /schema");
info!("Full GraphQL Playground requires async-graphql crate integration");
}
let listener = tokio::net::TcpListener::bind(&self.config.bind)
.await
.map_err(|e| {
ProxyError::backend_connection(format!(
"Failed to bind GraphQL adapter to {}: {}",
self.config.bind, e
))
})?;
info!("GraphQL adapter listening on {}", self.config.bind);
axum::serve(listener, router)
.await
.map_err(|e| ProxyError::backend(format!("GraphQL adapter server error: {e}")))?;
Ok(())
}
}
#[cfg(feature = "graphql")]
async fn graphql_endpoint(
State(state): State<GraphQLAdapterState>,
Json(payload): Json<Value>,
) -> impl IntoResponse {
debug!("GraphQL request received: {:?}", payload);
let Some(query) = payload.get("query").and_then(|v| v.as_str()) else {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"errors": [{
"message": "Missing 'query' field in request body"
}]
})),
);
};
let response = if query.contains("tools") && query.contains('{') {
match state.backend.list_tools().await {
Ok(tools) => {
let tool_data: Vec<Value> = tools
.into_iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
})
})
.collect();
json!({ "data": { "tools": tool_data } })
}
Err(e) => json!({
"errors": [{
"message": format!("Failed to list tools: {e}")
}]
}),
}
} else if query.contains("resources") && query.contains('{') {
match state.backend.list_resources().await {
Ok(resources) => {
let resource_data: Vec<Value> = resources
.into_iter()
.map(|r| {
json!({
"uri": r.uri,
"name": r.name,
"description": r.description,
"mimeType": r.mime_type,
})
})
.collect();
json!({ "data": { "resources": resource_data } })
}
Err(e) => json!({
"errors": [{
"message": format!("Failed to list resources: {e}")
}]
}),
}
} else if query.contains("prompts") && query.contains('{') {
match state.backend.list_prompts().await {
Ok(prompts) => {
let prompt_data: Vec<Value> = prompts
.into_iter()
.map(|p| {
json!({
"name": p.name,
"description": p.description,
})
})
.collect();
json!({ "data": { "prompts": prompt_data } })
}
Err(e) => json!({
"errors": [{
"message": format!("Failed to list prompts: {e}")
}]
}),
}
} else {
json!({
"errors": [{
"message": "Unsupported query. Supported: tools, resources, prompts. For full GraphQL support, integrate async-graphql crate."
}]
})
};
(StatusCode::OK, Json(response))
}
#[cfg(feature = "graphql")]
async fn graphql_schema(State(state): State<GraphQLAdapterState>) -> impl IntoResponse {
debug!("GraphQL schema request");
let mut schema = String::from("type Query {\n");
schema.push_str(" tools: [Tool!]!\n");
schema.push_str(" resources: [Resource!]!\n");
schema.push_str(" prompts: [Prompt!]!\n");
schema.push_str("}\n\n");
schema.push_str("type Tool {\n");
schema.push_str(" name: String!\n");
schema.push_str(" description: String\n");
schema.push_str("}\n\n");
schema.push_str("type Resource {\n");
schema.push_str(" uri: String!\n");
schema.push_str(" name: String\n");
schema.push_str(" description: String\n");
schema.push_str(" mimeType: String\n");
schema.push_str("}\n\n");
schema.push_str("type Prompt {\n");
schema.push_str(" name: String!\n");
schema.push_str(" description: String\n");
schema.push_str("}\n");
info!(
"Generated GraphQL schema for {} tools, {} resources, {} prompts",
state.spec.tools.len(),
state.spec.resources.len(),
state.spec.prompts.len()
);
schema
}
#[cfg(feature = "graphql")]
async fn health_check() -> impl IntoResponse {
Json(json!({
"status": "ok",
"service": "turbomcp-graphql-adapter"
}))
}
#[cfg(not(feature = "graphql"))]
pub struct GraphQLAdapter;
#[cfg(not(feature = "graphql"))]
impl GraphQLAdapter {
pub fn new(
_config: GraphQLAdapterConfig,
_backend: crate::proxy::BackendConnector,
_spec: crate::introspection::ServerSpec,
) -> Self {
Self
}
pub async fn run(self) -> ProxyResult<()> {
Err(ProxyError::configuration(
"GraphQL adapter requires 'graphql' feature to be enabled",
))
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "graphql")]
use super::*;
#[test]
#[cfg(feature = "graphql")]
fn test_graphql_adapter_config() {
let config = GraphQLAdapterConfig::new("127.0.0.1:4000", true);
assert_eq!(config.bind, "127.0.0.1:4000");
assert!(config.playground);
}
}