mod app; mod catalog; mod jsonrpc; mod session; use std::{env, net::SocketAddr, time::Duration}; use crank_registry::PostgresRegistry; use tokio::net::TcpListener; use tracing::info; use crate::app::build_app; #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter( env::var("CRANK_LOG_LEVEL") .unwrap_or_else(|_| "mcp_server=info,tower_http=info".into()), ) .init(); let database_url = env::var("CRANK_DATABASE_URL")?; let bind_addr = env::var("CRANK_MCP_BIND").unwrap_or_else(|_| "0.0.0.0:3002".into()); let public_base_url = env::var("CRANK_PUBLIC_BASE_URL").ok(); let refresh_interval = env::var("CRANK_MCP_REFRESH_MS") .ok() .and_then(|value| value.parse::().ok()) .map(Duration::from_millis) .unwrap_or_else(|| Duration::from_secs(5)); let socket_addr: SocketAddr = bind_addr.parse()?; let registry = PostgresRegistry::connect(&database_url).await?; let app = build_app(registry, refresh_interval, public_base_url); let listener = TcpListener::bind(socket_addr).await?; info!("mcp-server listening on {}", socket_addr); axum::serve(listener, app).await?; Ok(()) } #[cfg(test)] mod tests { use std::{ collections::BTreeMap, env, time::{Duration, SystemTime, UNIX_EPOCH}, }; use axum::{Json, Router, http::header, routing::post}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_adapter_grpc::test_support as grpc_test_support; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest, }; use crank_schema::{Schema, SchemaKind}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use sqlx::{Executor, postgres::PgPoolOptions}; use tokio::net::TcpListener; use crate::app::build_app; fn test_workspace_id() -> WorkspaceId { WorkspaceId::new("ws_default") } fn test_workspace_slug() -> &'static str { "default" } #[tokio::test] async fn initializes_lists_and_calls_published_tool_via_mcp() { let registry = test_registry().await; let upstream_base_url = spawn_upstream_server().await; let operation = test_operation(&upstream_base_url, "crm_create_lead"); registry .create_operation(&test_workspace_id(), &operation, Some("alice")) .await .unwrap(); registry .publish_operation(PublishRequest { workspace_id: &test_workspace_id(), operation_id: &operation.id, version: 1, published_at: "2026-03-26T10:00:00Z", published_by: Some("alice"), }) .await .unwrap(); publish_agent_for_operation(®istry, &operation, "sales-rest").await; let api_key = create_platform_api_key( ®istry, "mcp-rest", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; let base_url = spawn_mcp_server(build_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let mcp_url = agent_mcp_url(&base_url, "sales-rest"); let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; let tools = post_jsonrpc( &client, &mcp_url, &api_key, Some(&initialized_session), json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }), ) .await; let call_result = post_jsonrpc( &client, &mcp_url, &api_key, Some(&initialized_session), json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "crm_create_lead", "arguments": { "email": "user@example.com" } } }), ) .await; assert_eq!(tools["result"]["tools"][0]["name"], "crm_create_lead"); assert_eq!( call_result["result"]["structuredContent"], json!({ "id": "lead_123" }) ); assert_eq!(call_result["result"]["isError"], false); let logs = registry .list_invocation_logs(ListInvocationLogsQuery { workspace_id: &test_workspace_id(), level: None, search_text: None, source: Some(crank_core::InvocationSource::AgentToolCall), operation_id: Some(&operation.id), agent_id: None, created_after: None, limit: 10, }) .await .unwrap(); assert_eq!(logs.len(), 1); assert_eq!( logs[0].log.source, crank_core::InvocationSource::AgentToolCall ); assert_eq!(logs[0].log.status, crank_core::InvocationStatus::Ok); assert_eq!(logs[0].log.tool_name, "crm_create_lead"); let keys = registry .list_platform_api_keys(&test_workspace_id()) .await .unwrap(); assert!(keys[0].api_key.last_used_at.is_some()); } #[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(&test_workspace_id(), &operation, Some("alice")) .await .unwrap(); registry .publish_operation(PublishRequest { workspace_id: &test_workspace_id(), operation_id: &operation.id, version: 1, published_at: "2026-03-26T10:00:00Z", published_by: Some("alice"), }) .await .unwrap(); publish_agent_for_operation(®istry, &operation, "sales-graphql").await; let api_key = create_platform_api_key( ®istry, "mcp-graphql", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; let base_url = spawn_mcp_server(build_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let mcp_url = agent_mcp_url(&base_url, "sales-graphql"); let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; let call_result = post_jsonrpc( &client, &mcp_url, &api_key, 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 initializes_and_calls_published_grpc_tool_via_mcp() { let registry = test_registry().await; let server_addr = grpc_test_support::spawn_unary_echo_server().await; let operation = test_grpc_operation(&server_addr, "echo_unary_grpc"); registry .create_operation(&test_workspace_id(), &operation, Some("alice")) .await .unwrap(); registry .publish_operation(PublishRequest { workspace_id: &test_workspace_id(), operation_id: &operation.id, version: 1, published_at: "2026-03-26T10:00:00Z", published_by: Some("alice"), }) .await .unwrap(); publish_agent_for_operation(®istry, &operation, "sales-grpc").await; let api_key = create_platform_api_key( ®istry, "mcp-grpc", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; let base_url = spawn_mcp_server(build_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let mcp_url = agent_mcp_url(&base_url, "sales-grpc"); let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; let call_result = post_jsonrpc( &client, &mcp_url, &api_key, Some(&initialized_session), json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "echo_unary_grpc", "arguments": { "message": "hello" } } }), ) .await; assert_eq!( call_result["result"]["structuredContent"], json!({ "message": "hello" }) ); assert_eq!(call_result["result"]["isError"], false); } #[tokio::test] async fn requires_initialized_notification_before_tool_methods() { let registry = test_registry().await; publish_agent_with_bindings(®istry, "sales-init", vec![]).await; let api_key = create_platform_api_key(®istry, "mcp-init", &[PlatformApiKeyScope::Read]).await; let base_url = spawn_mcp_server(build_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let mcp_url = agent_mcp_url(&base_url, "sales-init"); let initialize_response = client .post(&mcp_url) .header(header::ACCEPT, "application/json, text/event-stream") .header(header::AUTHORIZATION, format!("Bearer {api_key}")) .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25" } })) .send() .await .unwrap(); let session_id = initialize_response .headers() .get("MCP-Session-Id") .unwrap() .to_str() .unwrap() .to_owned(); let tools_list = client .post(&mcp_url) .header(header::ACCEPT, "application/json, text/event-stream") .header(header::AUTHORIZATION, format!("Bearer {api_key}")) .header("MCP-Session-Id", session_id) .header("MCP-Protocol-Version", "2025-11-25") .json(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })) .send() .await .unwrap() .json::() .await .unwrap(); assert_eq!(tools_list["error"]["code"], -32002); } #[tokio::test] async fn refreshes_published_tools_without_restart() { let registry = test_registry().await; let upstream_base_url = spawn_upstream_server().await; let base_url = spawn_mcp_server(build_app( registry.clone(), Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let mcp_url = agent_mcp_url(&base_url, "sales-refresh"); let api_key = create_platform_api_key( ®istry, "mcp-refresh", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; let before_publish = post_jsonrpc( &client, &mcp_url, &api_key, Some(&initialized_session), json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }), ) .await; let operation = test_operation(&upstream_base_url, "crm_publish_later"); registry .create_operation(&test_workspace_id(), &operation, Some("alice")) .await .unwrap(); registry .publish_operation(PublishRequest { workspace_id: &test_workspace_id(), operation_id: &operation.id, version: 1, published_at: "2026-03-26T10:00:00Z", published_by: Some("alice"), }) .await .unwrap(); publish_agent_for_operation(®istry, &operation, "sales-refresh").await; let after_publish = post_jsonrpc( &client, &mcp_url, &api_key, Some(&initialized_session), json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/list", "params": {} }), ) .await; assert_eq!(before_publish["result"]["tools"], json!([])); assert_eq!( after_publish["result"]["tools"][0]["name"], "crm_publish_later" ); } #[tokio::test] async fn lists_only_bound_tools_for_agent_context() { let registry = test_registry().await; let upstream_base_url = spawn_upstream_server().await; let operation_a = test_operation(&upstream_base_url, "crm_create_lead"); let operation_b = test_operation(&upstream_base_url, "crm_update_lead"); for operation in [&operation_a, &operation_b] { registry .create_operation(&test_workspace_id(), operation, Some("alice")) .await .unwrap(); registry .publish_operation(PublishRequest { workspace_id: &test_workspace_id(), operation_id: &operation.id, version: 1, published_at: "2026-03-26T10:00:00Z", published_by: Some("alice"), }) .await .unwrap(); } publish_agent_with_bindings( ®istry, "sales-a", vec![binding_for_operation(&operation_a)], ) .await; publish_agent_with_bindings( ®istry, "sales-b", vec![binding_for_operation(&operation_b)], ) .await; let api_key = create_platform_api_key( ®istry, "mcp-agents", &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], ) .await; let base_url = spawn_mcp_server(build_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let agent_a_url = agent_mcp_url(&base_url, "sales-a"); let agent_b_url = agent_mcp_url(&base_url, "sales-b"); let session_a = initialize_session(&client, &agent_a_url, &api_key).await; let session_b = initialize_session(&client, &agent_b_url, &api_key).await; let tools_a = post_jsonrpc( &client, &agent_a_url, &api_key, Some(&session_a), json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }), ) .await; let tools_b = post_jsonrpc( &client, &agent_b_url, &api_key, Some(&session_b), json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }), ) .await; assert_eq!(tools_a["result"]["tools"][0]["name"], "crm_create_lead"); assert_eq!(tools_b["result"]["tools"][0]["name"], "crm_update_lead"); } #[tokio::test] async fn rejects_initialize_without_platform_api_key() { let registry = test_registry().await; publish_agent_with_bindings(®istry, "sales-auth", vec![]).await; let base_url = spawn_mcp_server(build_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let response = client .post(agent_mcp_url(&base_url, "sales-auth")) .header(header::ACCEPT, "application/json, text/event-stream") .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25" } })) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); } #[tokio::test] async fn rejects_tool_call_with_read_only_platform_api_key() { let registry = test_registry().await; let upstream_base_url = spawn_upstream_server().await; let operation = test_operation(&upstream_base_url, "crm_read_only"); registry .create_operation(&test_workspace_id(), &operation, Some("alice")) .await .unwrap(); registry .publish_operation(PublishRequest { workspace_id: &test_workspace_id(), operation_id: &operation.id, version: 1, published_at: "2026-03-26T10:00:00Z", published_by: Some("alice"), }) .await .unwrap(); publish_agent_for_operation(®istry, &operation, "sales-read-only").await; let api_key = create_platform_api_key(®istry, "mcp-read", &[PlatformApiKeyScope::Read]).await; let base_url = spawn_mcp_server(build_app( registry, Duration::from_millis(0), Some("https://crank.example.com".to_owned()), )) .await; let client = reqwest::Client::new(); let mcp_url = agent_mcp_url(&base_url, "sales-read-only"); let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; let response = client .post(&mcp_url) .header(header::ACCEPT, "application/json, text/event-stream") .header(header::AUTHORIZATION, format!("Bearer {api_key}")) .header("MCP-Session-Id", initialized_session) .header("MCP-Protocol-Version", "2025-11-25") .json(&json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "crm_read_only", "arguments": { "email": "user@example.com" } } })) .send() .await .unwrap(); assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); } async fn initialize_session(client: &reqwest::Client, mcp_url: &str, api_key: &str) -> String { let initialize_response = client .post(mcp_url) .header(header::ACCEPT, "application/json, text/event-stream") .header(header::AUTHORIZATION, format!("Bearer {api_key}")) .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25" } })) .send() .await .unwrap(); let session_id = initialize_response .headers() .get("MCP-Session-Id") .unwrap() .to_str() .unwrap() .to_owned(); let initialized_response = client .post(mcp_url) .header(header::ACCEPT, "application/json, text/event-stream") .header(header::AUTHORIZATION, format!("Bearer {api_key}")) .header("MCP-Session-Id", &session_id) .header("MCP-Protocol-Version", "2025-11-25") .json(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized", "params": {} })) .send() .await .unwrap(); assert_eq!(initialized_response.status(), reqwest::StatusCode::ACCEPTED); session_id } async fn post_jsonrpc( client: &reqwest::Client, mcp_url: &str, api_key: &str, session_id: Option<&str>, payload: Value, ) -> Value { let mut request = client .post(mcp_url) .header(header::ACCEPT, "application/json, text/event-stream") .header(header::AUTHORIZATION, format!("Bearer {api_key}")) .header("MCP-Protocol-Version", "2025-11-25"); if let Some(session_id) = session_id { request = request.header("MCP-Session-Id", session_id); } request .json(&payload) .send() .await .unwrap() .json::() .await .unwrap() } async fn create_platform_api_key( registry: &PostgresRegistry, name: &str, scopes: &[PlatformApiKeyScope], ) -> String { let secret = format!("crk_{}_{}", name, uuid::Uuid::now_v7().simple()); let api_key = PlatformApiKey { id: PlatformApiKeyId::new(format!("pk_{name}")), workspace_id: test_workspace_id(), name: name.to_owned(), prefix: secret.chars().take(16).collect(), scopes: scopes.to_vec(), status: PlatformApiKeyStatus::Active, created_at: "2026-03-26T10:00:00Z".to_owned(), last_used_at: None, }; registry .create_platform_api_key(CreatePlatformApiKeyRequest { api_key: &api_key, secret_hash: &hash_access_secret(&secret), }) .await .unwrap(); secret } fn hash_access_secret(secret: &str) -> String { let digest = Sha256::digest(secret.as_bytes()); URL_SAFE_NO_PAD.encode(digest) } 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_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(); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); format!("http://{}", address) } fn agent_mcp_url(base_url: &str, agent_slug: &str) -> String { format!("{base_url}/v1/{}/{}", test_workspace_slug(), agent_slug) } async fn publish_agent_for_operation( registry: &PostgresRegistry, operation: &Operation, agent_slug: &str, ) { publish_agent_with_bindings(registry, agent_slug, vec![binding_for_operation(operation)]) .await; } async fn publish_agent_with_bindings( registry: &PostgresRegistry, agent_slug: &str, bindings: Vec, ) { let agent_id = AgentId::new(format!("agent_{agent_slug}")); let agent = Agent { id: agent_id.clone(), workspace_id: test_workspace_id(), slug: agent_slug.to_owned(), display_name: format!("Agent {agent_slug}"), description: "Curated MCP toolset".to_owned(), status: AgentStatus::Draft, current_draft_version: 1, latest_published_version: None, created_at: "2026-03-26T10:00:00Z".to_owned(), updated_at: "2026-03-26T10:00:00Z".to_owned(), published_at: None, }; let version = AgentVersion { agent_id: agent_id.clone(), version: 1, status: AgentStatus::Draft, instructions: json!({}), tool_selection_policy: json!({}), created_at: "2026-03-26T10:00:00Z".to_owned(), }; registry .create_agent(CreateAgentRequest { agent: &agent, version: &version, bindings: &bindings, }) .await .unwrap(); registry .publish_agent(PublishAgentRequest { workspace_id: &test_workspace_id(), agent_id: &agent_id, version: 1, published_at: "2026-03-26T10:00:00Z", published_by: Some("alice"), }) .await .unwrap(); } fn binding_for_operation(operation: &Operation) -> AgentOperationBinding { AgentOperationBinding { operation_id: operation.id.clone(), operation_version: 1, tool_name: operation.name.clone(), tool_title: operation.tool_description.title.clone(), tool_description_override: None, enabled: true, } } async fn create_lead(Json(payload): Json) -> Json { Json(json!({ "id": "lead_123", "email": payload["email"] })) } 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://crank:crank@127.0.0.1:5432/crank".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, name: &str) -> Operation { Operation { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Create Lead".to_owned(), category: "sales".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 a 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 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(), category: "sales".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 test_grpc_operation(server_addr: &str, name: &str) -> Operation { Operation { id: OperationId::new(format!("op_{name}")), name: name.to_owned(), display_name: "Unary Echo gRPC".to_owned(), category: "support".to_owned(), protocol: Protocol::Grpc, status: OperationStatus::Published, version: 1, target: Target::Grpc(GrpcTarget { server_addr: server_addr.to_owned(), package: "echo".to_owned(), service: "EchoService".to_owned(), method: "UnaryEcho".to_owned(), descriptor_ref: DescriptorId::new("desc_echo"), descriptor_set_b64: grpc_test_support::descriptor_set_b64(), }), input_schema: object_schema("message"), output_schema: object_schema("message"), input_mapping: MappingSet { rules: vec![MappingRule { source: "$.mcp.message".to_owned(), target: "$.request.grpc.message".to_owned(), required: true, default_value: None, transform: None, condition: None, notes: None, }], }, output_mapping: MappingSet { rules: vec![MappingRule { source: "$.response.data.message".to_owned(), target: "$.output.message".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: "Unary Echo gRPC".to_owned(), description: "Echoes a unary gRPC payload".to_owned(), tags: vec!["grpc".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, 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(), } } }