merge: feat/graphql-support
This commit is contained in:
Generated
+4
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> },
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user