From 63f8ee333f3319697b2124ecc530113c4fd2ab23 Mon Sep 17 00:00:00 2001 From: bsodfather Date: Tue, 21 Jul 2026 01:59:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4=D0=B0?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE=D1=81=D1=82=D1=8C:=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=B1=D1=8E=D0=B4=D0=B6=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/crank-community-mcp/src/catalog.rs | 42 ++++++++- crates/crank-community-mcp/src/manifest.rs | 36 +++++++- .../tests/unit/manifest.rs | 16 +++- crates/crank-core/src/lib.rs | 6 ++ crates/crank-core/src/tool_catalog.rs | 89 +++++++++++++++++++ crates/crank-core/tests/unit.rs | 1 + crates/crank-core/tests/unit/tool_catalog.rs | 79 ++++++++++++++++ docs/mcp-interface.md | 11 +++ docs/observability.md | 3 +- docs/tool-design.md | 5 ++ 10 files changed, 284 insertions(+), 4 deletions(-) create mode 100644 crates/crank-core/src/tool_catalog.rs create mode 100644 crates/crank-core/tests/unit/tool_catalog.rs diff --git a/crates/crank-community-mcp/src/catalog.rs b/crates/crank-community-mcp/src/catalog.rs index 73d58e6..9c4f1d6 100644 --- a/crates/crank-community-mcp/src/catalog.rs +++ b/crates/crank-community-mcp/src/catalog.rs @@ -8,7 +8,9 @@ use crank_core::{CacheScope, CoordinationStateStore, CoordinationStateValue}; use crank_registry::{PostgresRegistry, PublishedAgentTool, RegistryError}; use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, RwLock}; -use tracing::info; +use tracing::{info, warn}; + +use crate::manifest::analyze_published_tool_catalog; #[derive(Clone)] pub struct PublishedToolCatalog { @@ -105,6 +107,7 @@ impl PublishedToolCatalog { } if let Some((tools, age)) = self.load_shared_snapshot(workspace_slug, agent_slug).await { + log_catalog_analysis(workspace_slug, agent_slug, "shared_cache", &tools); let mut guard = self.cached.write().await; guard.insert( key, @@ -125,6 +128,7 @@ impl PublishedToolCatalog { Err(RegistryError::PublishedAgentNotFound { .. }) => Vec::new(), Err(error) => return Err(error), }; + log_catalog_analysis(workspace_slug, agent_slug, "postgres", &tools); self.store_shared_snapshot(workspace_slug, agent_slug, &tools) .await; let mut guard = self.cached.write().await; @@ -208,6 +212,42 @@ impl PublishedToolCatalog { } } +fn log_catalog_analysis( + workspace_slug: &str, + agent_slug: &str, + source: &str, + tools: &[PublishedAgentTool], +) { + let analysis = match analyze_published_tool_catalog(tools) { + Ok(analysis) => analysis, + Err(error) => { + warn!(workspace_slug, agent_slug, source, %error, "published catalog analysis failed"); + return; + } + }; + let warning_count = analysis + .quality + .findings + .iter() + .filter(|finding| finding.severity == crank_core::ToolQualitySeverity::Warning) + .count(); + + info!( + workspace_slug, + agent_slug, + source, + tool_count = analysis.budget.tool_count, + serialized_bytes = analysis.budget.serialized_bytes, + estimated_context_tokens = analysis.budget.estimated_context_tokens, + largest_tool_estimated_context_tokens = + analysis.budget.largest_tool_estimated_context_tokens, + recommended_context_tokens = analysis.budget.recommended_context_tokens, + exceeds_recommended_budget = analysis.budget.exceeds_recommended_budget, + catalog_quality_warning_count = warning_count, + "published agent catalog analyzed" + ); +} + fn now_unix_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/crank-community-mcp/src/manifest.rs b/crates/crank-community-mcp/src/manifest.rs index e9fd51a..a759d81 100644 --- a/crates/crank-community-mcp/src/manifest.rs +++ b/crates/crank-community-mcp/src/manifest.rs @@ -1,4 +1,7 @@ -use crank_core::{HttpMethod, OperationSafetyClass, OperationSafetyPolicy, Target}; +use crank_core::{ + HttpMethod, OperationSafetyClass, OperationSafetyPolicy, Target, ToolCatalogAnalysis, + ToolCatalogAnalysisError, ToolQualityCatalogTool, analyze_tool_catalog, +}; use crank_registry::PublishedAgentTool; use serde_json::{Value, json}; @@ -27,6 +30,37 @@ pub fn tool_definitions(tool: &PublishedAgentTool) -> Vec { )] } +pub fn analyze_published_tool_catalog( + tools: &[PublishedAgentTool], +) -> Result { + let mut quality_tools = Vec::new(); + let mut definitions = Vec::new(); + + for tool in tools { + for definition in tool_definitions(tool) { + quality_tools.push(ToolQualityCatalogTool { + name: definition_string(&definition, "name")?, + display_name: definition_string(&definition, "title")?, + description: definition_string(&definition, "description")?, + }); + definitions.push(definition); + } + } + + analyze_tool_catalog(&quality_tools, &definitions) +} + +fn definition_string( + definition: &Value, + field: &'static str, +) -> Result { + definition + .get(field) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or(ToolCatalogAnalysisError::DefinitionFieldMissing(field)) +} + pub fn tool_definition(name: &str, title: &str, description: &str, input_schema: Value) -> Value { json!({ "name": name, diff --git a/crates/crank-community-mcp/tests/unit/manifest.rs b/crates/crank-community-mcp/tests/unit/manifest.rs index ef98e0b..aa44efe 100644 --- a/crates/crank-community-mcp/tests/unit/manifest.rs +++ b/crates/crank-community-mcp/tests/unit/manifest.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use crank_community_mcp::manifest::tool_definitions; +use crank_community_mcp::manifest::{analyze_published_tool_catalog, tool_definitions}; use crank_core::{ ExecutionConfig, HttpMethod, Operation, OperationId, OperationSecurityLevel, OperationStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, @@ -57,6 +57,20 @@ fn marks_destructive_tools_as_two_step_confirmation_calls() { assert_eq!(definition["inputSchema"]["required"], json!(["base"])); } +#[test] +fn catalog_budget_uses_the_same_definitions_as_tools_list() { + let tool = published_tool(); + let definitions = tool_definitions(&tool); + let analysis = analyze_published_tool_catalog(&[tool]).unwrap(); + + assert_eq!(analysis.budget.tool_count, definitions.len()); + assert_eq!( + analysis.budget.serialized_bytes, + serde_json::to_vec(&definitions[0]).unwrap().len() + ); + assert!(!analysis.budget.exceeds_recommended_budget); +} + fn published_tool() -> PublishedAgentTool { PublishedAgentTool { workspace_id: WorkspaceId::new("ws_01"), diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index f831e5b..29cadc3 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod observability; pub mod operation; pub mod protocol; pub mod secret; +pub mod tool_catalog; pub mod tool_quality; pub mod workspace; @@ -50,6 +51,7 @@ pub mod domain { }; pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol}; pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion}; + pub use crate::tool_catalog::{ToolCatalogAnalysis, ToolCatalogBudget}; pub use crate::tool_quality::{ ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity, @@ -141,6 +143,10 @@ pub use operation::{ }; pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol}; pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion}; +pub use tool_catalog::{ + RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS, ToolCatalogAnalysis, ToolCatalogAnalysisError, + ToolCatalogBudget, analyze_tool_catalog, +}; pub use tool_quality::{ ToolQualityCatalogTool, ToolQualityFinding, ToolQualityMappingRule, ToolQualityMappingSet, ToolQualityReport, ToolQualitySchemaKind, ToolQualitySchemaNode, ToolQualitySeverity, diff --git a/crates/crank-core/src/tool_catalog.rs b/crates/crank-core/src/tool_catalog.rs new file mode 100644 index 0000000..dce4e43 --- /dev/null +++ b/crates/crank-core/src/tool_catalog.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::tool_quality::{ + ToolQualityCatalogTool, ToolQualityFinding, ToolQualityReport, ToolQualitySeverity, + analyze_agent_tool_catalog_quality, +}; + +pub const RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS: usize = 4_096; +const ESTIMATED_TOKEN_UTF8_BYTES: usize = 3; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolCatalogBudget { + pub tool_count: usize, + pub serialized_bytes: usize, + pub estimated_context_tokens: usize, + pub largest_tool_estimated_context_tokens: usize, + pub recommended_context_tokens: usize, + pub exceeds_recommended_budget: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolCatalogAnalysis { + pub budget: ToolCatalogBudget, + pub quality: ToolQualityReport, +} + +#[derive(Debug, Error)] +pub enum ToolCatalogAnalysisError { + #[error("tool catalog and definitions length mismatch")] + DefinitionCountMismatch, + #[error("tool catalog definition is missing string field: {0}")] + DefinitionFieldMissing(&'static str), + #[error("tool catalog definition serialization failed: {0}")] + Serialization(#[from] serde_json::Error), +} + +pub fn analyze_tool_catalog( + tools: &[ToolQualityCatalogTool], + definitions: &[Value], +) -> Result { + if tools.len() != definitions.len() { + return Err(ToolCatalogAnalysisError::DefinitionCountMismatch); + } + + let mut serialized_bytes = 0usize; + let mut estimated_context_tokens = 0usize; + let mut largest_tool_estimated_context_tokens = 0usize; + for definition in definitions { + let definition_bytes = serde_json::to_vec(definition)?.len(); + let definition_tokens = estimate_context_tokens(definition_bytes); + serialized_bytes = serialized_bytes.saturating_add(definition_bytes); + estimated_context_tokens = estimated_context_tokens.saturating_add(definition_tokens); + largest_tool_estimated_context_tokens = + largest_tool_estimated_context_tokens.max(definition_tokens); + } + + let exceeds_recommended_budget = + estimated_context_tokens > RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS; + let budget = ToolCatalogBudget { + tool_count: tools.len(), + serialized_bytes, + estimated_context_tokens, + largest_tool_estimated_context_tokens, + recommended_context_tokens: RECOMMENDED_TOOL_CATALOG_CONTEXT_TOKENS, + exceeds_recommended_budget, + }; + let mut quality = analyze_agent_tool_catalog_quality(tools); + if exceeds_recommended_budget { + quality.findings.push(ToolQualityFinding { + severity: ToolQualitySeverity::Warning, + code: "agent_catalog_context_budget_high".to_owned(), + message: "Каталог инструментов занимает слишком много контекста модели.".to_owned(), + suggested_action: Some( + "Сократите описания и схемы либо разделите инструменты между специализированными агентами." + .to_owned(), + ), + field_path: Some("agent.operations".to_owned()), + }); + quality = ToolQualityReport::new(quality.findings); + } + + Ok(ToolCatalogAnalysis { budget, quality }) +} + +fn estimate_context_tokens(serialized_bytes: usize) -> usize { + serialized_bytes.div_ceil(ESTIMATED_TOKEN_UTF8_BYTES) +} diff --git a/crates/crank-core/tests/unit.rs b/crates/crank-core/tests/unit.rs index 56fbfe8..a7cc072 100644 --- a/crates/crank-core/tests/unit.rs +++ b/crates/crank-core/tests/unit.rs @@ -1,3 +1,4 @@ mod unit { + mod tool_catalog; mod tool_quality; } diff --git a/crates/crank-core/tests/unit/tool_catalog.rs b/crates/crank-core/tests/unit/tool_catalog.rs new file mode 100644 index 0000000..34b7810 --- /dev/null +++ b/crates/crank-core/tests/unit/tool_catalog.rs @@ -0,0 +1,79 @@ +use crank_core::{ + ToolCatalogAnalysis, ToolQualityCatalogTool, ToolQualitySeverity, analyze_tool_catalog, +}; +use serde_json::json; + +#[test] +fn measures_the_actual_serialized_catalog_definition() { + let definitions = vec![json!({ + "name": "get_exchange_rate", + "title": "Получить курс", + "description": "Возвращает актуальный курс выбранной валютной пары.", + "inputSchema": { + "type": "object", + "properties": { + "base": {"type": "string"}, + "quote": {"type": "string"} + }, + "required": ["base", "quote"] + } + })]; + + let analysis = analyze_tool_catalog(&[tool("get_exchange_rate")], &definitions).unwrap(); + let expected_bytes = serde_json::to_vec(&definitions[0]).unwrap().len(); + + assert_eq!(analysis.budget.tool_count, 1); + assert_eq!(analysis.budget.serialized_bytes, expected_bytes); + assert!(analysis.budget.estimated_context_tokens > 0); + assert_eq!( + analysis.budget.largest_tool_estimated_context_tokens, + analysis.budget.estimated_context_tokens + ); +} + +#[test] +fn warns_when_actual_catalog_exceeds_context_budget() { + let tools = (0..9) + .map(|index| tool(&format!("large_tool_{index}"))) + .collect::>(); + let definitions = (0..9) + .map(|index| { + json!({ + "name": format!("large_tool_{index}"), + "description": "x".repeat(1_500), + "inputSchema": {"type": "object", "properties": {}} + }) + }) + .collect::>(); + + let analysis = analyze_tool_catalog(&tools, &definitions).unwrap(); + + assert!(analysis.budget.exceeds_recommended_budget); + assert!(has_warning(&analysis, "agent_catalog_context_budget_high")); +} + +#[test] +fn rejects_mismatch_between_catalog_tools_and_definitions() { + let error = analyze_tool_catalog(&[tool("one")], &[]).unwrap_err(); + + assert_eq!( + error.to_string(), + "tool catalog and definitions length mismatch" + ); +} + +fn tool(name: &str) -> ToolQualityCatalogTool { + ToolQualityCatalogTool { + name: name.to_owned(), + display_name: name.to_owned(), + description: format!("Инструмент {name} выполняет одну конкретную операцию."), + } +} + +fn has_warning(analysis: &ToolCatalogAnalysis, code: &str) -> bool { + analysis + .quality + .findings + .iter() + .any(|finding| finding.code == code && finding.severity == ToolQualitySeverity::Warning) +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index f58a4d9..6afb975 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -228,6 +228,17 @@ MCP-клиент видит только опубликованные опера Черновики операций не попадают в MCP-каталог. Если два пользователя работают в одном workspace, один может редактировать черновик, а второй публиковать агента. В опубликованный каталог попадут только опубликованные версии операций. +При каждом обновлении каталога Crank анализирует фактические определения `tools/list` и записывает в структурированный журнал: + +- число инструментов; +- размер компактного JSON в байтах; +- оценочный объём контекста в токенах; +- размер крупнейшего инструмента; +- рекомендуемый предел и признак его превышения; +- число предупреждений качества каталога. + +Оценка токенов равна округлённому вверх отношению размера UTF-8 к трём. Она нужна для стабильного сравнения ревизий каталога и не заменяет точный токенизатор конкретной модели. + ## Обновление каталога `mcp-server` периодически обновляет опубликованный каталог. Интервал задается: diff --git a/docs/observability.md b/docs/observability.md index 22f4d31..0cfec0e 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -15,6 +15,8 @@ Crank сохраняет данные о тестовых запусках и в - краткий preview запроса и ответа; - категория ошибки, если вызов завершился ошибкой. +При обновлении опубликованного MCP-каталога отдельное событие `published agent catalog analyzed` содержит `tool_count`, `serialized_bytes`, `estimated_context_tokens`, `largest_tool_estimated_context_tokens`, `recommended_context_tokens`, `exceeds_recommended_budget` и число предупреждений качества. По этим полям можно заметить рост цены `tools/list` до того, как он ухудшит выбор инструментов моделью. + ## Использование Раздел использования агрегирует: @@ -31,4 +33,3 @@ Crank сохраняет данные о тестовых запусках и в - увидеть ошибки маппинга или внешнего API; - найти медленные endpoint-ы; - понять, какие инструменты реально используются. - diff --git a/docs/tool-design.md b/docs/tool-design.md index 0ed0a4b..c201110 100644 --- a/docs/tool-design.md +++ b/docs/tool-design.md @@ -154,6 +154,10 @@ Crank возвращает структурированные ошибки. MCP- Если у агента слишком много похожих инструментов, модель чаще ошибается при выборе. +Crank измеряет опубликованный каталог по тому же компактному JSON, который возвращается в `tools/list`. В расчёт входят имя, заголовок, описание, входная JSON-схема и добавляемое Crank описание подтверждения опасной операции. Для сравнения используется независимая от конкретной модели консервативная оценка: один токен на три байта UTF-8. Это не счётчик токенов конкретного поставщика, а стабильная инженерная метрика для поиска регрессий. + +Рекомендуемый бюджет одного агентского каталога — не более 4096 оценочных токенов. Превышение не блокирует публикацию, потому что допустимый объём зависит от модели, но создаёт предупреждение. Сначала сокращайте лишние описания и схемы. Если инструменты решают разные задачи, разделяйте их между специализированными агентами. Выбор нужного агента и постепенное раскрытие каталогов выполняет оркестратор Drivetrain, а не Crank. + ## Проверочный список - Имя инструмента конкретное и не похоже на `call_api`. @@ -164,3 +168,4 @@ Crank возвращает структурированные ошибки. MCP- - Для POST/PATCH задан idempotency key, если повторный вызов может создать дубль. - Для DELETE пользователь видит двухшаговое подтверждение. - Агенту привязаны только инструменты, нужные для его задачи. +- Опубликованный каталог укладывается в выбранный бюджет контекста модели.