From 1ea75eb824737e2571baf762135af42162a89930 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Wed, 25 Mar 2026 20:21:07 +0300 Subject: [PATCH] feat: add graphql support --- Cargo.lock | 4 + TASKS.md | 14 +- apps/admin-api/src/app.rs | 142 ++++++++++- apps/admin-api/src/service.rs | 1 + apps/mcp-server/src/app.rs | 1 + apps/mcp-server/src/main.rs | 150 ++++++++++- crates/mcpaas-adapter-graphql/Cargo.toml | 5 + crates/mcpaas-adapter-graphql/src/client.rs | 264 ++++++++++++++++++++ crates/mcpaas-adapter-graphql/src/error.rs | 24 ++ crates/mcpaas-adapter-graphql/src/lib.rs | 10 +- crates/mcpaas-adapter-graphql/src/model.rs | 22 ++ crates/mcpaas-mapping/src/execute.rs | 15 +- crates/mcpaas-mapping/src/jsonpath.rs | 19 ++ crates/mcpaas-runtime/src/error.rs | 3 + crates/mcpaas-runtime/src/executor.rs | 177 ++++++++++++- crates/mcpaas-runtime/src/model.rs | 3 + 16 files changed, 824 insertions(+), 30 deletions(-) create mode 100644 crates/mcpaas-adapter-graphql/src/client.rs create mode 100644 crates/mcpaas-adapter-graphql/src/error.rs create mode 100644 crates/mcpaas-adapter-graphql/src/model.rs diff --git a/Cargo.lock b/Cargo.lock index 81c9b88..2f674e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,10 +863,14 @@ dependencies = [ name = "mcpaas-adapter-graphql" version = "0.1.0" dependencies = [ + "axum", "mcpaas-core", + "mcpaas-mapping", + "reqwest", "serde", "serde_json", "thiserror", + "tokio", ] [[package]] diff --git a/TASKS.md b/TASKS.md index 5910afa..a05dcf2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,21 +2,21 @@ ## Current -### `feat/mcp-server` +### `feat/graphql-support` Status: completed DoD: -- `mcp-server` exposes a single MCP HTTP endpoint -- initialize and initialized lifecycle work with session handling -- tools are listed and called through MCP JSON-RPC methods -- published tool catalog refreshes without service restart -- MCP integration tests cover initialize, list, call and refresh flow +- GraphQL operation can be created, tested and published +- variables mapping and `response_path` work in runtime +- GraphQL `errors` are translated into runtime failures +- published GraphQL tool is callable through MCP +- integration tests cover admin-api, runtime and MCP GraphQL scenarios ## Next -- `feat/graphql-support` +- `feat/grpc-support` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index 4ff5634..e3e747e 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -67,7 +67,10 @@ mod tests { }; use axum::{Json, Router, routing::post}; - use mcpaas_core::{ExecutionConfig, HttpMethod, Protocol, RestTarget, Target, ToolDescription}; + use mcpaas_core::{ + ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, Protocol, RestTarget, + Target, ToolDescription, + }; use mcpaas_mapping::{MappingRule, MappingSet}; use mcpaas_registry::PostgresRegistry; use mcpaas_schema::{Schema, SchemaKind}; @@ -143,6 +146,59 @@ mod tests { assert_eq!(test_run["response_preview"]["id"], "lead_123"); } + #[tokio::test] + async fn creates_publishes_and_tests_graphql_operation() { + let registry = test_registry().await; + let storage_root = test_storage_root("graphql"); + let upstream_base_url = spawn_graphql_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = reqwest::Client::new(); + + let created = client + .post(format!("{base_url}/operations")) + .json(&test_graphql_operation_payload( + &upstream_base_url, + "crm_create_lead_graphql", + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let operation_id = created["operation_id"].as_str().unwrap().to_owned(); + + let published = client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({ "version": 1 })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let test_run = client + .post(format!("{base_url}/operations/{operation_id}/test-runs")) + .json(&json!({ + "version": 1, + "input": { "email": "user@example.com" } + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!(published["published_version"], 1); + assert_eq!(test_run["ok"], true); + assert_eq!( + test_run["request_preview"]["variables"]["email"], + "user@example.com" + ); + assert_eq!(test_run["response_preview"]["id"], "lead_123"); + } + #[tokio::test] async fn manages_auth_profiles_and_yaml_upsert() { let registry = test_registry().await; @@ -288,6 +344,18 @@ mod tests { format!("http://{}", address) } + async fn spawn_graphql_server() -> String { + let app = Router::new().route("/", post(graphql_handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + async fn spawn_admin_api(app: Router) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -307,6 +375,24 @@ mod tests { })) } + async fn graphql_handler(Json(payload): Json) -> Json { + let email = payload + .get("variables") + .and_then(|variables| variables.get("email")) + .and_then(Value::as_str) + .unwrap_or_default(); + + Json(json!({ + "data": { + "createLead": { + "id": "lead_123", + "status": "created", + "email": email + } + } + })) + } + async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned()); @@ -396,6 +482,60 @@ mod tests { } } + fn test_graphql_operation_payload(endpoint: &str, name: &str) -> OperationPayload { + OperationPayload { + name: name.to_owned(), + display_name: "Create Lead GraphQL".to_owned(), + protocol: Protocol::Graphql, + target: Target::Graphql(GraphqlTarget { + endpoint: endpoint.to_owned(), + operation_type: GraphqlOperationType::Mutation, + operation_name: "CreateLead".to_owned(), + query_template: + "mutation CreateLead($email: String!) { createLead(email: $email) { id status email } }" + .to_owned(), + response_path: "$.response.body.data.createLead".to_owned(), + }), + input_schema: object_schema("email"), + output_schema: object_schema("id"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.variables.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.data.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + }, + tool_description: ToolDescription { + title: "Create Lead GraphQL".to_owned(), + description: "Creates a CRM lead through GraphQL".to_owned(), + tags: vec!["crm".to_owned(), "graphql".to_owned()], + examples: Vec::new(), + }, + } + } + fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index cb9b819..b093dc3 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -612,6 +612,7 @@ fn build_request_preview( "path": prepared.path_params, "query": prepared.query_params, "headers": prepared.headers, + "variables": prepared.variables.unwrap_or(Value::Null), "body": prepared.body.unwrap_or(Value::Null) })) } diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 4df37a8..9e39f12 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -436,6 +436,7 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "schema_validation_error", RuntimeError::Mapping(_) => "mapping_error", + RuntimeError::GraphqlAdapter(_) => "adapter_execution_error", RuntimeError::RestAdapter(_) => "adapter_execution_error", RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol", RuntimeError::InvalidPreparedRequest { .. } => "runtime_error", diff --git a/apps/mcp-server/src/main.rs b/apps/mcp-server/src/main.rs index e9fb263..24bdec4 100644 --- a/apps/mcp-server/src/main.rs +++ b/apps/mcp-server/src/main.rs @@ -50,8 +50,8 @@ mod tests { use axum::{Json, Router, http::header, routing::post}; use mcpaas_core::{ - ExecutionConfig, HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget, - Target, ToolDescription, + ExecutionConfig, GraphqlOperationType, GraphqlTarget, HttpMethod, Operation, OperationId, + OperationStatus, Protocol, RestTarget, Target, ToolDescription, }; use mcpaas_mapping::{MappingRule, MappingSet}; use mcpaas_registry::{PostgresRegistry, PublishRequest}; @@ -129,6 +129,60 @@ mod tests { assert_eq!(call_result["result"]["isError"], false); } + #[tokio::test] + async fn initializes_and_calls_published_graphql_tool_via_mcp() { + let registry = test_registry().await; + let endpoint = spawn_graphql_server().await; + let operation = test_graphql_operation(&endpoint, "crm_create_lead_graphql"); + + registry + .create_operation(&operation, Some("alice")) + .await + .unwrap(); + registry + .publish_operation(PublishRequest { + operation_id: &operation.id, + version: 1, + published_at: "2026-03-26T10:00:00Z", + published_by: Some("alice"), + }) + .await + .unwrap(); + + let base_url = spawn_mcp_server(build_app( + registry, + Duration::from_millis(0), + Some("https://rmcp.example.com".to_owned()), + )) + .await; + let client = reqwest::Client::new(); + let initialized_session = initialize_session(&client, &base_url).await; + + let call_result = post_jsonrpc( + &client, + &base_url, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "crm_create_lead_graphql", + "arguments": { + "email": "user@example.com" + } + } + }), + ) + .await; + + assert_eq!( + call_result["result"]["structuredContent"], + json!({ "id": "lead_123" }) + ); + assert_eq!(call_result["result"]["isError"], false); + } + #[tokio::test] async fn requires_initialized_notification_before_tool_methods() { let registry = test_registry().await; @@ -321,6 +375,18 @@ mod tests { format!("http://{}", address) } + async fn spawn_graphql_server() -> String { + let app = Router::new().route("/", post(graphql_handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + async fn spawn_mcp_server(app: Router) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -339,6 +405,23 @@ mod tests { })) } + async fn graphql_handler(Json(payload): Json) -> Json { + let email = payload + .get("variables") + .and_then(|variables| variables.get("email")) + .and_then(Value::as_str) + .unwrap_or_default(); + + Json(json!({ + "data": { + "createLead": { + "id": "lead_123", + "email": email + } + } + })) + } + async fn test_registry() -> PostgresRegistry { let database_url = env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://rmcp:rmcp@127.0.0.1:5432/rmcp".to_owned()); @@ -426,6 +509,69 @@ mod tests { } } + fn test_graphql_operation(endpoint: &str, name: &str) -> Operation { + Operation { + id: OperationId::new(format!("op_{name}")), + name: name.to_owned(), + display_name: "Create Lead GraphQL".to_owned(), + protocol: Protocol::Graphql, + status: OperationStatus::Published, + version: 1, + target: Target::Graphql(GraphqlTarget { + endpoint: endpoint.to_owned(), + operation_type: GraphqlOperationType::Mutation, + operation_name: "CreateLead".to_owned(), + query_template: + "mutation CreateLead($email: String!) { createLead(email: $email) { id email } }" + .to_owned(), + response_path: "$.response.body.data.createLead".to_owned(), + }), + input_schema: object_schema("email"), + output_schema: object_schema("id"), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.variables.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.data.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + }, + tool_description: ToolDescription { + title: "Create Lead GraphQL".to_owned(), + description: "Creates a CRM lead through GraphQL".to_owned(), + tags: vec!["graphql".to_owned()], + examples: Vec::new(), + }, + samples: None, + generated_draft: None, + config_export: None, + created_at: "2026-03-26T10:00:00Z".to_owned(), + updated_at: "2026-03-26T10:00:00Z".to_owned(), + published_at: Some("2026-03-26T10:00:00Z".to_owned()), + } + } + fn object_schema(field_name: &str) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/crates/mcpaas-adapter-graphql/Cargo.toml b/crates/mcpaas-adapter-graphql/Cargo.toml index 9fc9f90..6d84231 100644 --- a/crates/mcpaas-adapter-graphql/Cargo.toml +++ b/crates/mcpaas-adapter-graphql/Cargo.toml @@ -7,7 +7,12 @@ version.workspace = true [dependencies] mcpaas-core = { path = "../mcpaas-core" } +mcpaas-mapping = { path = "../mcpaas-mapping" } +reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +[dev-dependencies] +axum.workspace = true +tokio.workspace = true diff --git a/crates/mcpaas-adapter-graphql/src/client.rs b/crates/mcpaas-adapter-graphql/src/client.rs new file mode 100644 index 0000000..416f841 --- /dev/null +++ b/crates/mcpaas-adapter-graphql/src/client.rs @@ -0,0 +1,264 @@ +use std::{collections::BTreeMap, time::Duration}; + +use mcpaas_core::GraphqlTarget; +use mcpaas_mapping::JsonPath; +use reqwest::{ + Client, + header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}, +}; +use serde_json::{Map, Value, json}; + +use crate::{GraphqlAdapterError, GraphqlRequest, GraphqlResponse}; + +#[derive(Clone, Debug)] +pub struct GraphqlAdapter { + client: Client, +} + +impl Default for GraphqlAdapter { + fn default() -> Self { + Self::new() + } +} + +impl GraphqlAdapter { + pub fn new() -> Self { + Self { + client: Client::new(), + } + } + + pub async fn execute( + &self, + target: &GraphqlTarget, + request: &GraphqlRequest, + ) -> Result { + let endpoint = reqwest::Url::parse(&target.endpoint).map_err(|_| { + GraphqlAdapterError::InvalidEndpoint { + url: target.endpoint.clone(), + } + })?; + let headers = build_headers(request)?; + let variables = normalize_variables(request.variables.clone())?; + let payload = json!({ + "query": target.query_template, + "operationName": target.operation_name, + "variables": variables + }); + + let response = self + .client + .post(endpoint) + .headers(headers) + .timeout(Duration::from_millis(request.timeout_ms)) + .json(&payload) + .send() + .await?; + let status = response.status(); + let headers = normalize_headers(response.headers()); + let body = decode_body(response).await?; + + if !status.is_success() { + return Err(GraphqlAdapterError::UnexpectedStatus { + status: status.as_u16(), + body, + }); + } + + if let Some(errors) = body.get("errors").cloned() { + return Err(GraphqlAdapterError::OperationErrors { + data: body.get("data").cloned(), + errors, + }); + } + + let response_path = JsonPath::parse(&target.response_path).map_err(|_| { + GraphqlAdapterError::InvalidResponsePath { + path: target.response_path.clone(), + } + })?; + let response_context = json!({ + "response": { + "body": body + } + }); + let data = response_path + .read(&response_context) + .cloned() + .ok_or_else(|| GraphqlAdapterError::ResponsePathNotFound { + path: target.response_path.clone(), + })?; + let body = response_context["response"]["body"].clone(); + + Ok(GraphqlResponse { + status_code: status.as_u16(), + headers, + body, + data, + }) + } +} + +fn build_headers(request: &GraphqlRequest) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + for (name, value) in &request.headers { + let header_name = HeaderName::try_from(name.as_str()).map_err(|_| { + GraphqlAdapterError::InvalidHeaderName { + header: name.clone(), + } + })?; + let header_value = HeaderValue::try_from(value.as_str()).map_err(|_| { + GraphqlAdapterError::InvalidHeaderValue { + header: name.clone(), + } + })?; + + headers.insert(header_name, header_value); + } + + Ok(headers) +} + +fn normalize_variables(variables: Option) -> Result { + match variables { + None | Some(Value::Null) => Ok(Value::Object(Map::new())), + Some(Value::Object(values)) => Ok(Value::Object(values)), + Some(_) => Err(GraphqlAdapterError::InvalidVariablesShape), + } +} + +async fn decode_body(response: reqwest::Response) -> Result { + let bytes = response.bytes().await?; + + if bytes.is_empty() { + return Ok(Value::Null); + } + + match serde_json::from_slice::(&bytes) { + Ok(value) => Ok(value), + Err(_) => Ok(Value::String( + String::from_utf8_lossy(&bytes).trim().to_owned(), + )), + } +} + +fn normalize_headers(headers: &HeaderMap) -> BTreeMap { + headers + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use axum::{Json, Router, routing::post}; + use mcpaas_core::{GraphqlOperationType, GraphqlTarget}; + use serde_json::{Value, json}; + use tokio::net::TcpListener; + + use crate::{GraphqlAdapter, GraphqlAdapterError, GraphqlRequest}; + + #[tokio::test] + async fn executes_graphql_request_and_extracts_response_path() { + let endpoint = spawn_graphql_server().await; + let adapter = GraphqlAdapter::new(); + let target = GraphqlTarget { + endpoint, + operation_type: GraphqlOperationType::Mutation, + operation_name: "CreateLead".to_owned(), + query_template: + "mutation CreateLead($email: String!) { createLead(email: $email) { id status } }" + .to_owned(), + response_path: "$.response.body.data.createLead".to_owned(), + }; + let request = GraphqlRequest { + headers: BTreeMap::from([("x-trace-id".to_owned(), "trace-123".to_owned())]), + variables: Some(json!({ "email": "user@example.com" })), + timeout_ms: 1_000, + }; + + let response = adapter.execute(&target, &request).await.unwrap(); + + assert_eq!(response.status_code, 200); + assert_eq!( + response.data, + json!({ "id": "lead_123", "status": "created" }) + ); + } + + #[tokio::test] + async fn returns_operation_errors_from_graphql_body() { + let endpoint = spawn_graphql_server().await; + let adapter = GraphqlAdapter::new(); + let target = GraphqlTarget { + endpoint, + operation_type: GraphqlOperationType::Mutation, + operation_name: "CreateLead".to_owned(), + query_template: + "mutation CreateLead($email: String!) { createLead(email: $email) { id status } }" + .to_owned(), + response_path: "$.response.body.data.createLead".to_owned(), + }; + let request = GraphqlRequest { + headers: BTreeMap::new(), + variables: Some(json!({ "email": "fail@example.com" })), + timeout_ms: 1_000, + }; + + let error = adapter.execute(&target, &request).await.unwrap_err(); + + assert!(matches!( + error, + GraphqlAdapterError::OperationErrors { + errors: Value::Array(_), + .. + } + )); + } + + async fn spawn_graphql_server() -> String { + let app = Router::new().route("/", post(graphql_handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + + async fn graphql_handler(Json(payload): Json) -> Json { + let email = payload + .get("variables") + .and_then(Value::as_object) + .and_then(|variables| variables.get("email")) + .and_then(Value::as_str) + .unwrap_or_default(); + + if email == "fail@example.com" { + return Json(json!({ + "data": { "createLead": null }, + "errors": [{ "message": "lead creation failed" }] + })); + } + + Json(json!({ + "data": { + "createLead": { + "id": "lead_123", + "status": "created" + } + } + })) + } +} diff --git a/crates/mcpaas-adapter-graphql/src/error.rs b/crates/mcpaas-adapter-graphql/src/error.rs new file mode 100644 index 0000000..1a97f3b --- /dev/null +++ b/crates/mcpaas-adapter-graphql/src/error.rs @@ -0,0 +1,24 @@ +use serde_json::Value; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GraphqlAdapterError { + #[error("invalid graphql endpoint: {url}")] + InvalidEndpoint { url: String }, + #[error("invalid header name {header}")] + InvalidHeaderName { header: String }, + #[error("invalid header value for {header}")] + InvalidHeaderValue { header: String }, + #[error("graphql variables must be a JSON object")] + InvalidVariablesShape, + #[error("invalid graphql response path: {path}")] + InvalidResponsePath { path: String }, + #[error("graphql response path did not match any value: {path}")] + ResponsePathNotFound { path: String }, + #[error("request failed")] + Transport(#[from] reqwest::Error), + #[error("graphql endpoint returned status {status}")] + UnexpectedStatus { status: u16, body: Value }, + #[error("graphql operation returned errors")] + OperationErrors { errors: Value, data: Option }, +} diff --git a/crates/mcpaas-adapter-graphql/src/lib.rs b/crates/mcpaas-adapter-graphql/src/lib.rs index 33c9ca1..27e2986 100644 --- a/crates/mcpaas-adapter-graphql/src/lib.rs +++ b/crates/mcpaas-adapter-graphql/src/lib.rs @@ -1,3 +1,7 @@ -pub fn crate_name() -> &'static str { - "mcpaas-adapter-graphql" -} +mod client; +mod error; +mod model; + +pub use client::GraphqlAdapter; +pub use error::GraphqlAdapterError; +pub use model::{GraphqlRequest, GraphqlResponse}; diff --git a/crates/mcpaas-adapter-graphql/src/model.rs b/crates/mcpaas-adapter-graphql/src/model.rs new file mode 100644 index 0000000..f1785df --- /dev/null +++ b/crates/mcpaas-adapter-graphql/src/model.rs @@ -0,0 +1,22 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +pub struct GraphqlRequest { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option, + pub timeout_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GraphqlResponse { + pub status_code: u16, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub body: Value, + pub data: Value, +} diff --git a/crates/mcpaas-mapping/src/execute.rs b/crates/mcpaas-mapping/src/execute.rs index 0a91aaf..ccad9e5 100644 --- a/crates/mcpaas-mapping/src/execute.rs +++ b/crates/mcpaas-mapping/src/execute.rs @@ -69,20 +69,7 @@ fn empty_target_context(context: MappingTargetContext) -> Value { } fn read_path<'a>(value: &'a Value, path: &JsonPath) -> Option<&'a Value> { - let mut current = value; - - for field in path.root.root_fields() { - current = current.get(*field)?; - } - - for segment in &path.segments { - current = match segment { - JsonPathSegment::Field(field) => current.get(field)?, - JsonPathSegment::Index(index) => current.get(*index)?, - }; - } - - Some(current) + path.read(value) } fn write_path(target: &mut Value, path: &JsonPath, value: Value) -> Result<(), MappingError> { diff --git a/crates/mcpaas-mapping/src/jsonpath.rs b/crates/mcpaas-mapping/src/jsonpath.rs index 91a40f4..4872b75 100644 --- a/crates/mcpaas-mapping/src/jsonpath.rs +++ b/crates/mcpaas-mapping/src/jsonpath.rs @@ -1,3 +1,5 @@ +use serde_json::Value; + use crate::MappingError; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -103,6 +105,23 @@ impl JsonPath { path } + + pub fn read<'a>(&self, value: &'a Value) -> Option<&'a Value> { + let mut current = value; + + for field in self.root.root_fields() { + current = current.get(*field)?; + } + + for segment in &self.segments { + current = match segment { + JsonPathSegment::Field(field) => current.get(field)?, + JsonPathSegment::Index(index) => current.get(*index)?, + }; + } + + Some(current) + } } fn match_root(input: &str) -> Option<(JsonPathRoot, &str)> { diff --git a/crates/mcpaas-runtime/src/error.rs b/crates/mcpaas-runtime/src/error.rs index e691b25..62cc92b 100644 --- a/crates/mcpaas-runtime/src/error.rs +++ b/crates/mcpaas-runtime/src/error.rs @@ -1,3 +1,4 @@ +use mcpaas_adapter_graphql::GraphqlAdapterError; use mcpaas_adapter_rest::RestAdapterError; use mcpaas_core::Protocol; use mcpaas_mapping::MappingError; @@ -11,6 +12,8 @@ pub enum RuntimeError { #[error(transparent)] Mapping(#[from] MappingError), #[error(transparent)] + GraphqlAdapter(#[from] GraphqlAdapterError), + #[error(transparent)] RestAdapter(#[from] RestAdapterError), #[error("protocol {protocol:?} is not supported by runtime")] UnsupportedProtocol { protocol: Protocol }, diff --git a/crates/mcpaas-runtime/src/executor.rs b/crates/mcpaas-runtime/src/executor.rs index f7aa678..f6c922e 100644 --- a/crates/mcpaas-runtime/src/executor.rs +++ b/crates/mcpaas-runtime/src/executor.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use mcpaas_adapter_graphql::{GraphqlAdapter, GraphqlRequest}; use mcpaas_adapter_rest::{RestAdapter, RestRequest}; use mcpaas_core::Target; use serde_json::{Map, Value, json}; @@ -8,6 +9,7 @@ use crate::{AdapterResponse, PreparedRequest, RuntimeError, RuntimeOperation}; #[derive(Clone, Debug)] pub struct RuntimeExecutor { + graphql_adapter: GraphqlAdapter, rest_adapter: RestAdapter, } @@ -20,6 +22,7 @@ impl Default for RuntimeExecutor { impl RuntimeExecutor { pub fn new() -> Self { Self { + graphql_adapter: GraphqlAdapter::new(), rest_adapter: RestAdapter::new(), } } @@ -35,6 +38,25 @@ impl RuntimeExecutor { let prepared_request = PreparedRequest::from_mapping_output(&mapped_input)?; let adapter_response = match &operation.target { + Target::Graphql(target) => { + let request = GraphqlRequest { + headers: merge_headers( + &BTreeMap::new(), + &operation.execution_config.headers, + &prepared_request.headers, + ), + variables: prepared_request.variables.clone(), + timeout_ms: operation.execution_config.timeout_ms, + }; + let response = self.graphql_adapter.execute(target, &request).await?; + + AdapterResponse { + status_code: response.status_code, + headers: response.headers, + body: response.body, + data: response.data, + } + } Target::Rest(target) => { let request = RestRequest { path_params: prepared_request.path_params.clone(), @@ -53,6 +75,7 @@ impl RuntimeExecutor { status_code: response.status_code, headers: response.headers, body: response.body, + data: Value::Null, } } _ => { @@ -83,6 +106,7 @@ impl PreparedRequest { path_params: read_string_map(request.get("path"), "request.path")?, query_params: read_string_map(request.get("query"), "request.query")?, headers: read_string_map(request.get("headers"), "request.headers")?, + variables: non_empty_payload(request.get("variables").cloned()), body: request.get("body").and_then(non_empty_body).cloned(), }) } @@ -95,7 +119,7 @@ fn finalize_output( let mapped = operation.output_mapping.apply(&json!({ "response": { "body": response.body, - "data": response.body, + "data": response.data, "headers": response.headers, "status": response.status_code } @@ -159,14 +183,23 @@ fn non_empty_body(value: &Value) -> Option<&Value> { } } +fn non_empty_payload(value: Option) -> Option { + match value { + None | Some(Value::Null) => None, + Some(Value::Object(object)) if object.is_empty() => None, + other => other, + } +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; use axum::{Json, Router, routing::post}; use mcpaas_core::{ - ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, HttpMethod, Operation, OperationId, - OperationStatus, Protocol, RestTarget, Samples, Target, ToolDescription, ToolExample, + ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, GraphqlOperationType, GraphqlTarget, + HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget, Samples, Target, + ToolDescription, ToolExample, }; use mcpaas_mapping::{MappingRule, MappingSet}; use mcpaas_schema::{Schema, SchemaKind}; @@ -189,6 +222,20 @@ mod tests { assert_eq!(output, json!({ "id": "lead_123" })); } + #[tokio::test] + async fn executes_graphql_operation_end_to_end() { + let endpoint = spawn_graphql_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_graphql_operation(&endpoint, false); + + let output = executor + .execute(&operation, &json!({ "email": "user@example.com" })) + .await + .unwrap(); + + assert_eq!(output, json!({ "id": "lead_123" })); + } + #[tokio::test] async fn rejects_invalid_input_shape() { let base_url = spawn_runtime_server().await; @@ -228,6 +275,20 @@ mod tests { assert!(matches!(error, RuntimeError::Mapping(_))); } + #[tokio::test] + async fn propagates_graphql_operation_errors() { + let endpoint = spawn_graphql_server().await; + let executor = RuntimeExecutor::new(); + let operation = test_graphql_operation(&endpoint, true); + + let error = executor + .execute(&operation, &json!({ "email": "fail@example.com" })) + .await + .unwrap_err(); + + assert!(matches!(error, RuntimeError::GraphqlAdapter(_))); + } + async fn spawn_runtime_server() -> String { let app = Router::new().route("/leads", post(create_lead)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -240,6 +301,18 @@ mod tests { format!("http://{}", address) } + async fn spawn_graphql_server() -> String { + let app = Router::new().route("/", post(graphql_handler)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + format!("http://{}", address) + } + async fn create_lead(Json(payload): Json) -> (axum::http::StatusCode, Json) { let should_fail = payload .get("fail") @@ -259,6 +332,30 @@ mod tests { ) } + async fn graphql_handler(Json(payload): Json) -> Json { + let email = payload + .get("variables") + .and_then(|variables| variables.get("email")) + .and_then(Value::as_str) + .unwrap_or_default(); + + if email == "fail@example.com" { + return Json(json!({ + "data": { "createLead": null }, + "errors": [{ "message": "lead creation failed" }] + })); + } + + Json(json!({ + "data": { + "createLead": { + "id": "lead_123", + "status": "created" + } + } + })) + } + fn test_rest_operation( base_url: &str, should_fail: bool, @@ -352,6 +449,80 @@ mod tests { }) } + fn test_graphql_operation(endpoint: &str, _should_fail: bool) -> RuntimeOperation { + RuntimeOperation::from(Operation { + id: OperationId::new("op_graphql_runtime"), + name: "crm_create_lead_graphql".to_owned(), + display_name: "Create Lead GraphQL".to_owned(), + protocol: Protocol::Graphql, + status: OperationStatus::Published, + version: 1, + target: Target::Graphql(GraphqlTarget { + endpoint: endpoint.to_owned(), + operation_type: GraphqlOperationType::Mutation, + operation_name: "CreateLead".to_owned(), + query_template: + "mutation CreateLead($email: String!) { createLead(email: $email) { id status } }" + .to_owned(), + response_path: "$.response.body.data.createLead".to_owned(), + }), + input_schema: object_schema("email", SchemaKind::String), + output_schema: object_schema("id", SchemaKind::String), + input_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.mcp.email".to_owned(), + target: "$.request.variables.email".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + output_mapping: MappingSet { + rules: vec![MappingRule { + source: "$.response.data.id".to_owned(), + target: "$.output.id".to_owned(), + required: true, + default_value: None, + transform: None, + condition: None, + notes: None, + }], + }, + execution_config: ExecutionConfig { + timeout_ms: 1_000, + retry_policy: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + protocol_options: None, + }, + tool_description: ToolDescription { + title: "Create Lead GraphQL".to_owned(), + description: "Creates a CRM lead through GraphQL".to_owned(), + tags: vec!["crm".to_owned(), "graphql".to_owned()], + examples: vec![ToolExample { + input: json!({ "email": "user@example.com" }), + }], + }, + samples: Some(Samples::default()), + generated_draft: Some(GeneratedDraft { + status: GeneratedDraftStatus::Available, + source_types: vec!["input_json".to_owned(), "output_json".to_owned()], + generated_at: Some("2026-03-25T20:00:00Z".to_owned()), + input_schema_generated: true, + output_schema_generated: true, + input_mapping_generated: true, + output_mapping_generated: true, + warnings: Vec::new(), + }), + config_export: None, + created_at: "2026-03-25T20:00:00Z".to_owned(), + updated_at: "2026-03-25T20:00:00Z".to_owned(), + published_at: Some("2026-03-25T20:00:00Z".to_owned()), + }) + } + fn object_schema(field_name: &str, kind: SchemaKind) -> Schema { Schema { kind: SchemaKind::Object, diff --git a/crates/mcpaas-runtime/src/model.rs b/crates/mcpaas-runtime/src/model.rs index 532fee3..f48abea 100644 --- a/crates/mcpaas-runtime/src/model.rs +++ b/crates/mcpaas-runtime/src/model.rs @@ -46,6 +46,8 @@ pub struct PreparedRequest { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub body: Option, } @@ -55,4 +57,5 @@ pub struct AdapterResponse { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, pub body: Value, + pub data: Value, }