feat: implement rest vertical slice

This commit is contained in:
a.tolmachev
2026-03-25 18:28:44 +03:00
parent 649a2ede0d
commit 8a7e8dbd64
15 changed files with 1547 additions and 23 deletions
+332 -4
View File
@@ -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(),
}
}
}