feat: implement rest vertical slice
This commit is contained in:
+332
-4
@@ -1,10 +1,23 @@
|
||||
use std::{env, net::SocketAddr};
|
||||
use std::{env, net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::{Json, Router, routing::get};
|
||||
use serde_json::json;
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
};
|
||||
use mcpaas_registry::PostgresRegistry;
|
||||
use mcpaas_runtime::{RuntimeExecutor, RuntimeOperation};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
registry: PostgresRegistry,
|
||||
runtime: RuntimeExecutor,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -14,9 +27,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
)
|
||||
.init();
|
||||
|
||||
let database_url = env::var("MCPAAS_DATABASE_URL")?;
|
||||
let bind_addr = env::var("MCPAAS_MCP_BIND").unwrap_or_else(|_| "0.0.0.0:3002".into());
|
||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||
let app = Router::new().route("/health", get(health));
|
||||
let registry = PostgresRegistry::connect(&database_url).await?;
|
||||
let state = Arc::new(AppState {
|
||||
registry,
|
||||
runtime: RuntimeExecutor::new(),
|
||||
});
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
|
||||
info!("mcp-server listening on {}", socket_addr);
|
||||
@@ -26,9 +45,318 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_app(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/tools", get(list_tools))
|
||||
.route("/tools/{tool_name}", post(call_tool))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
Json(json!({
|
||||
"service": "mcp-server",
|
||||
"status": "ok"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let operations = state
|
||||
.registry
|
||||
.list_published_operations()
|
||||
.await
|
||||
.map_err(internal_error)?;
|
||||
|
||||
let tools = operations
|
||||
.into_iter()
|
||||
.map(|operation| {
|
||||
json!({
|
||||
"name": operation.name,
|
||||
"title": operation.tool_description.title,
|
||||
"description": operation.tool_description.description
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(json!({ "tools": tools })))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
Path(tool_name): Path<String>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(input): Json<Value>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let operation = state
|
||||
.registry
|
||||
.list_published_operations()
|
||||
.await
|
||||
.map_err(internal_error)?
|
||||
.into_iter()
|
||||
.find(|operation| operation.name == tool_name)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"code": "not_found",
|
||||
"message": format!("tool {tool_name} was not found")
|
||||
}
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let output = state
|
||||
.runtime
|
||||
.execute(&RuntimeOperation::from(operation), &input)
|
||||
.await
|
||||
.map_err(runtime_error)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"tool_name": tool_name,
|
||||
"result": output
|
||||
})))
|
||||
}
|
||||
|
||||
fn internal_error(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"code": "internal_error",
|
||||
"message": error.to_string()
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_error(error: impl std::fmt::Display) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"code": "runtime_error",
|
||||
"message": error.to_string()
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
sync::Arc,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{Json, Router, routing::post};
|
||||
use mcpaas_core::{
|
||||
ExecutionConfig, HttpMethod, Operation, OperationId, OperationStatus, Protocol, RestTarget,
|
||||
Target, ToolDescription,
|
||||
};
|
||||
use mcpaas_mapping::{MappingRule, MappingSet};
|
||||
use mcpaas_registry::{PostgresRegistry, PublishRequest};
|
||||
use mcpaas_runtime::RuntimeExecutor;
|
||||
use mcpaas_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Executor, postgres::PgPoolOptions};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{AppState, build_app};
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_and_calls_published_rest_tool() {
|
||||
let registry = test_registry().await;
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let operation = test_operation(&upstream_base_url);
|
||||
|
||||
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 state = Arc::new(AppState {
|
||||
registry,
|
||||
runtime: RuntimeExecutor::new(),
|
||||
});
|
||||
let base_url = spawn_mcp_server(build_app(state)).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let tools = client
|
||||
.get(format!("{base_url}/tools"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let call_result = client
|
||||
.post(format!("{base_url}/tools/crm_create_lead"))
|
||||
.json(&json!({ "email": "user@example.com" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tools["tools"][0]["name"], "crm_create_lead");
|
||||
assert_eq!(call_result["result"], json!({ "id": "lead_123" }));
|
||||
}
|
||||
|
||||
async fn spawn_upstream_server() -> String {
|
||||
let app = Router::new().route("/crm/leads", post(create_lead));
|
||||
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();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
async fn create_lead(Json(payload): Json<Value>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"id": "lead_123",
|
||||
"email": payload["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());
|
||||
let admin_pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.unwrap();
|
||||
let schema = format!(
|
||||
"test_mcp_server_{}_{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
|
||||
admin_pool
|
||||
.execute(sqlx::query(&format!("create schema {schema}")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}"))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn test_operation(base_url: &str) -> Operation<Schema, MappingSet> {
|
||||
Operation {
|
||||
id: OperationId::new("op_mcp_rest"),
|
||||
name: "crm_create_lead".to_owned(),
|
||||
display_name: "Create Lead".to_owned(),
|
||||
protocol: Protocol::Rest,
|
||||
status: OperationStatus::Published,
|
||||
version: 1,
|
||||
target: Target::Rest(RestTarget {
|
||||
base_url: base_url.to_owned(),
|
||||
method: HttpMethod::Post,
|
||||
path_template: "/crm/leads".to_owned(),
|
||||
static_headers: BTreeMap::new(),
|
||||
}),
|
||||
input_schema: object_schema("email"),
|
||||
output_schema: object_schema("id"),
|
||||
input_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.mcp.email".to_owned(),
|
||||
target: "$.request.body.email".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
}],
|
||||
},
|
||||
output_mapping: MappingSet {
|
||||
rules: vec![MappingRule {
|
||||
source: "$.response.body.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".to_owned(),
|
||||
description: "Creates CRM lead".to_owned(),
|
||||
tags: Vec::new(),
|
||||
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,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::from([(
|
||||
field_name.to_owned(),
|
||||
Schema {
|
||||
kind: SchemaKind::String,
|
||||
description: None,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
)]),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user