feat: add graphql support

This commit is contained in:
a.tolmachev
2026-03-25 20:21:07 +03:00
parent 6296b04105
commit 1ea75eb824
16 changed files with 824 additions and 30 deletions
Generated
+4
View File
@@ -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]]
+7 -7
View File
@@ -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
+141 -1
View File
@@ -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::<Value>()
.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::<Value>()
.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::<Value>()
.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<Value>) -> Json<Value> {
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,
+1
View File
@@ -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)
}))
}
+1
View File
@@ -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",
+148 -2
View File
@@ -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<Value>) -> Json<Value> {
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<Schema, MappingSet> {
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,
+5
View File
@@ -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
+264
View File
@@ -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<GraphqlResponse, GraphqlAdapterError> {
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<HeaderMap, GraphqlAdapterError> {
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<Value>) -> Result<Value, GraphqlAdapterError> {
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<Value, GraphqlAdapterError> {
let bytes = response.bytes().await?;
if bytes.is_empty() {
return Ok(Value::Null);
}
match serde_json::from_slice::<Value>(&bytes) {
Ok(value) => Ok(value),
Err(_) => Ok(Value::String(
String::from_utf8_lossy(&bytes).trim().to_owned(),
)),
}
}
fn normalize_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
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<Value>) -> Json<Value> {
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"
}
}
}))
}
}
@@ -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<Value> },
}
+7 -3
View File
@@ -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};
@@ -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<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variables: Option<Value>,
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<String, String>,
pub body: Value,
pub data: Value,
}
+1 -14
View File
@@ -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> {
+19
View File
@@ -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)> {
+3
View File
@@ -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 },
+174 -3
View File
@@ -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<Value>) -> Option<Value> {
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<Value>) -> (axum::http::StatusCode, Json<Value>) {
let should_fail = payload
.get("fail")
@@ -259,6 +332,30 @@ mod tests {
)
}
async fn graphql_handler(Json(payload): Json<Value>) -> Json<Value> {
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,
+3
View File
@@ -46,6 +46,8 @@ pub struct PreparedRequest {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub variables: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
@@ -55,4 +57,5 @@ pub struct AdapterResponse {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
pub body: Value,
pub data: Value,
}