From 3ac00a57d0e25cb7cd4998120b8baea441482850 Mon Sep 17 00:00:00 2001 From: github-ops Date: Sat, 20 Jun 2026 20:22:03 +0000 Subject: [PATCH] Extract MCP tool manifest builder --- Cargo.lock | 1 + crates/crank-community-mcp/Cargo.toml | 3 + crates/crank-community-mcp/src/app.rs | 62 +------- crates/crank-community-mcp/src/lib.rs | 1 + crates/crank-community-mcp/src/manifest.rs | 63 ++++++++ crates/crank-community-mcp/tests/unit.rs | 2 + .../tests/unit/manifest.rs | 150 ++++++++++++++++++ 7 files changed, 221 insertions(+), 61 deletions(-) create mode 100644 crates/crank-community-mcp/src/manifest.rs create mode 100644 crates/crank-community-mcp/tests/unit.rs create mode 100644 crates/crank-community-mcp/tests/unit/manifest.rs diff --git a/Cargo.lock b/Cargo.lock index 4dc9fd3..41fb8c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,7 @@ dependencies = [ "axum", "base64", "crank-core", + "crank-mapping", "crank-registry", "crank-runtime", "crank-schema", diff --git a/crates/crank-community-mcp/Cargo.toml b/crates/crank-community-mcp/Cargo.toml index 7319dc2..e38a2b8 100644 --- a/crates/crank-community-mcp/Cargo.toml +++ b/crates/crank-community-mcp/Cargo.toml @@ -23,3 +23,6 @@ time.workspace = true tokio = { workspace = true, features = ["sync"] } tracing.workspace = true uuid.workspace = true + +[dev-dependencies] +crank-mapping = { path = "../crank-mapping" } diff --git a/crates/crank-community-mcp/src/app.rs b/crates/crank-community-mcp/src/app.rs index 570b31e..cfb3ab7 100644 --- a/crates/crank-community-mcp/src/app.rs +++ b/crates/crank-community-mcp/src/app.rs @@ -43,6 +43,7 @@ use crate::{ is_response, jsonrpc_error, jsonrpc_result, method_name, negotiated_protocol_version, params, request_id, }, + manifest::tool_definitions, session::{SessionState, SharedSessionStore}, }; @@ -1430,15 +1431,6 @@ where response } -fn tool_definitions(tool: &PublishedAgentTool) -> Vec { - vec![tool_definition( - &tool.tool_name, - &tool.tool_title, - &tool.tool_description, - schema_to_json_schema(&tool.operation.input_schema), - )] -} - fn resolve_request_id(headers: &HeaderMap) -> String { headers .get(&HEADER_X_REQUEST_ID) @@ -1457,15 +1449,6 @@ fn is_valid_request_id(value: &str) -> bool { .all(|byte| matches!(byte, 0x21..=0x7e) && byte != b',' && byte != b';') } -fn tool_definition(name: &str, title: &str, description: &str, input_schema: Value) -> Value { - json!({ - "name": name, - "title": title, - "description": description, - "inputSchema": input_schema - }) -} - fn resolve_generated_tool( tools: &[PublishedAgentTool], tool_name: &str, @@ -1487,49 +1470,6 @@ fn runtime_operation(tool: &PublishedAgentTool) -> RuntimeOperation { operation } -fn schema_to_json_schema(schema: &crank_schema::Schema) -> Value { - match schema.kind { - crank_schema::SchemaKind::Object => { - let mut properties = serde_json::Map::new(); - let mut required = Vec::new(); - - for (field_name, field_schema) in &schema.fields { - properties.insert(field_name.clone(), schema_to_json_schema(field_schema)); - - if field_schema.required { - required.push(Value::String(field_name.clone())); - } - } - - json!({ - "type": "object", - "properties": properties, - "required": required - }) - } - crank_schema::SchemaKind::Array => json!({ - "type": "array", - "items": schema - .items - .as_deref() - .map(schema_to_json_schema) - .unwrap_or_else(|| json!({})) - }), - crank_schema::SchemaKind::String => json!({ "type": "string" }), - crank_schema::SchemaKind::Integer => json!({ "type": "integer" }), - crank_schema::SchemaKind::Number => json!({ "type": "number" }), - crank_schema::SchemaKind::Boolean => json!({ "type": "boolean" }), - crank_schema::SchemaKind::Enum => json!({ - "type": "string", - "enum": schema.enum_values - }), - crank_schema::SchemaKind::Null => json!({ "type": "null" }), - crank_schema::SchemaKind::Oneof => json!({ - "anyOf": schema.variants.iter().map(schema_to_json_schema).collect::>() - }), - } -} - impl AllowedOrigins { fn new(public_base_url: Option) -> Self { Self { diff --git a/crates/crank-community-mcp/src/lib.rs b/crates/crank-community-mcp/src/lib.rs index 5f9f08b..faedbef 100644 --- a/crates/crank-community-mcp/src/lib.rs +++ b/crates/crank-community-mcp/src/lib.rs @@ -2,6 +2,7 @@ mod app; pub mod auth; pub mod catalog; pub mod jsonrpc; +pub mod manifest; pub mod session; pub use app::build_app; diff --git a/crates/crank-community-mcp/src/manifest.rs b/crates/crank-community-mcp/src/manifest.rs new file mode 100644 index 0000000..68c5638 --- /dev/null +++ b/crates/crank-community-mcp/src/manifest.rs @@ -0,0 +1,63 @@ +use crank_registry::PublishedAgentTool; +use serde_json::{Value, json}; + +pub fn tool_definitions(tool: &PublishedAgentTool) -> Vec { + vec![tool_definition( + &tool.tool_name, + &tool.tool_title, + &tool.tool_description, + schema_to_json_schema(&tool.operation.input_schema), + )] +} + +pub fn tool_definition(name: &str, title: &str, description: &str, input_schema: Value) -> Value { + json!({ + "name": name, + "title": title, + "description": description, + "inputSchema": input_schema + }) +} + +pub fn schema_to_json_schema(schema: &crank_schema::Schema) -> Value { + match schema.kind { + crank_schema::SchemaKind::Object => { + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + for (field_name, field_schema) in &schema.fields { + properties.insert(field_name.clone(), schema_to_json_schema(field_schema)); + + if field_schema.required { + required.push(Value::String(field_name.clone())); + } + } + + json!({ + "type": "object", + "properties": properties, + "required": required + }) + } + crank_schema::SchemaKind::Array => json!({ + "type": "array", + "items": schema + .items + .as_deref() + .map(schema_to_json_schema) + .unwrap_or_else(|| json!({})) + }), + crank_schema::SchemaKind::String => json!({ "type": "string" }), + crank_schema::SchemaKind::Integer => json!({ "type": "integer" }), + crank_schema::SchemaKind::Number => json!({ "type": "number" }), + crank_schema::SchemaKind::Boolean => json!({ "type": "boolean" }), + crank_schema::SchemaKind::Enum => json!({ + "type": "string", + "enum": schema.enum_values + }), + crank_schema::SchemaKind::Null => json!({ "type": "null" }), + crank_schema::SchemaKind::Oneof => json!({ + "anyOf": schema.variants.iter().map(schema_to_json_schema).collect::>() + }), + } +} diff --git a/crates/crank-community-mcp/tests/unit.rs b/crates/crank-community-mcp/tests/unit.rs new file mode 100644 index 0000000..cb9ba95 --- /dev/null +++ b/crates/crank-community-mcp/tests/unit.rs @@ -0,0 +1,2 @@ +#[path = "unit/manifest.rs"] +mod manifest; diff --git a/crates/crank-community-mcp/tests/unit/manifest.rs b/crates/crank-community-mcp/tests/unit/manifest.rs new file mode 100644 index 0000000..8ef3030 --- /dev/null +++ b/crates/crank-community-mcp/tests/unit/manifest.rs @@ -0,0 +1,150 @@ +use std::collections::BTreeMap; + +use crank_community_mcp::manifest::tool_definitions; +use crank_core::{ + ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel, OperationStatus, + Protocol, RestTarget, Target, ToolDescription, WorkspaceId, +}; +use crank_mapping::MappingSet; +use crank_registry::{PublishedAgentTool, RegistryOperation}; +use crank_schema::{Schema, SchemaKind}; +use serde_json::json; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +#[test] +fn builds_tools_list_manifest_from_published_agent_tool() { + let definitions = tool_definitions(&published_tool()); + + assert_eq!(definitions.len(), 1); + let definition = &definitions[0]; + assert_eq!(definition["name"], "frankfurter_latest_rate"); + assert_eq!(definition["title"], "Последний курс валюты"); + assert_eq!( + definition["description"], + "Возвращает последний курс валюты через Frankfurter." + ); + assert_eq!(definition["inputSchema"]["type"], "object"); + assert_eq!(definition["inputSchema"]["required"], json!(["base"])); + assert_eq!( + definition["inputSchema"]["properties"]["base"], + json!({ "type": "string" }) + ); + assert_eq!( + definition["inputSchema"]["properties"]["mode"], + json!({ "type": "string", "enum": ["latest", "historical"] }) + ); +} + +fn published_tool() -> PublishedAgentTool { + PublishedAgentTool { + workspace_id: WorkspaceId::new("ws_01"), + workspace_slug: "default".to_owned(), + agent_id: "agent_01".into(), + agent_slug: "fx-agent".to_owned(), + operation: operation(), + tool_name: "frankfurter_latest_rate".to_owned(), + tool_title: "Последний курс валюты".to_owned(), + tool_description: "Возвращает последний курс валюты через Frankfurter.".to_owned(), + } +} + +fn operation() -> RegistryOperation { + Operation { + id: OperationId::new("op_01"), + name: "frankfurter_latest_rate".to_owned(), + display_name: "Последний курс валюты".to_owned(), + category: "finance".to_owned(), + protocol: Protocol::Rest, + security_level: OperationSecurityLevel::Standard, + status: OperationStatus::Published, + version: 1, + target: Target::Rest(RestTarget { + base_url: "https://api.frankfurter.dev".to_owned(), + method: HttpMethod::Get, + path_template: "/v2/rates".to_owned(), + static_headers: BTreeMap::new(), + }), + input_schema: input_schema(), + output_schema: Schema { + kind: SchemaKind::Object, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + }, + input_mapping: MappingSet { rules: Vec::new() }, + output_mapping: MappingSet { rules: Vec::new() }, + execution_config: ExecutionConfig { + timeout_ms: 10_000, + retry_policy: None, + response_cache: None, + auth_profile_ref: None, + headers: BTreeMap::new(), + }, + tool_description: ToolDescription { + title: "Последний курс валюты".to_owned(), + description: "Возвращает последний курс валюты через Frankfurter.".to_owned(), + tags: Vec::new(), + examples: Vec::new(), + }, + samples: None, + generated_draft: None, + config_export: None, + wizard_state: None, + created_at: timestamp("2026-06-20T00:00:00Z"), + updated_at: timestamp("2026-06-20T00:00:00Z"), + published_at: Some(timestamp("2026-06-20T00:00:00Z")), + } +} + +fn input_schema() -> Schema { + let mut fields = BTreeMap::new(); + fields.insert( + "base".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(), + }, + ); + fields.insert( + "mode".to_owned(), + Schema { + kind: SchemaKind::Enum, + description: None, + required: false, + nullable: false, + default_value: None, + fields: BTreeMap::new(), + items: None, + enum_values: vec!["latest".to_owned(), "historical".to_owned()], + variants: Vec::new(), + }, + ); + + Schema { + kind: SchemaKind::Object, + description: None, + required: false, + nullable: false, + default_value: None, + fields, + items: None, + enum_values: Vec::new(), + variants: Vec::new(), + } +} + +fn timestamp(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() +}