From 99bd05c145738d51e1d8da987963df3ac837516e Mon Sep 17 00:00:00 2001 From: bsodfather Date: Tue, 21 Jul 2026 13:12:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D1=8B:=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-api/src/app.rs | 3 +- apps/admin-api/src/dto.rs | 42 ++- apps/admin-api/src/routes/agents.rs | 18 +- apps/admin-api/src/service.rs | 1 + apps/admin-api/src/service/agents.rs | 171 ++++++++++- apps/admin-api/src/service/demo.rs | 7 +- .../integration/community_access_usage.rs | 5 +- .../tests/integration/operations_agents.rs | 110 +++++++ apps/mcp-server/tests/integration.rs | 1 + apps/mcp-server/tests/integration/common.rs | 23 +- .../tests/integration/tool_search.rs | 154 ++++++++++ .../tests/integration/transport_protocol.rs | 3 +- apps/ui/css/pages.css | 171 +++++++++++ apps/ui/html/agents.html | 104 ++++++- apps/ui/js/agents.js | 212 ++++++++++++-- apps/ui/js/api.js | 3 + apps/ui/js/i18n.js | 64 +++- apps/ui/tests/e2e/agents.spec.js | 23 ++ crates/crank-community-mcp/src/app.rs | 63 ++-- crates/crank-community-mcp/src/catalog.rs | 55 ++-- crates/crank-community-mcp/src/lib.rs | 1 + crates/crank-community-mcp/src/manifest.rs | 106 ++++++- crates/crank-community-mcp/src/tool_search.rs | 276 ++++++++++++++++++ crates/crank-core/src/agent.rs | 169 ++++++++++- crates/crank-core/src/lib.rs | 13 +- crates/crank-core/src/tool_search.rs | 237 +++++++++++++++ crates/crank-registry/src/lib.rs | 29 +- crates/crank-registry/src/model.rs | 20 +- crates/crank-registry/src/postgres/agent.rs | 78 +++++ crates/crank-registry/src/postgres/mod.rs | 19 +- .../tests/integration/common.rs | 5 +- docs/admin-api.md | 31 ++ docs/mcp-interface.md | 15 + docs/tool-design.md | 2 +- docs/ui.md | 9 + 35 files changed, 2088 insertions(+), 155 deletions(-) create mode 100644 apps/mcp-server/tests/integration/tool_search.rs create mode 100644 crates/crank-community-mcp/src/tool_search.rs create mode 100644 crates/crank-core/src/tool_search.rs diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index c655859..9ff95ae 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -12,7 +12,7 @@ use crate::{ agents::{ archive_agent, create_agent, create_agent_platform_api_key, delete_agent, delete_agent_platform_api_key, get_agent, get_agent_version, - list_agent_platform_api_keys, list_agents, publish_agent, + list_agent_platform_api_keys, list_agents, preview_tool_search, publish_agent, revoke_agent_platform_api_key, save_agent_bindings, unpublish_agent, update_agent, }, auth::{change_password, get_profile, get_session, login, logout, update_profile}, @@ -83,6 +83,7 @@ pub fn build_app(state: AppState) -> Router { ) .route("/operations/{operation_id}/export", get(export_operation)) .route("/agents", get(list_agents).post(create_agent)) + .route("/agents/tool-search/preview", post(preview_tool_search)) .route( "/agents/{agent_id}", get(get_agent).patch(update_agent).delete(delete_agent), diff --git a/apps/admin-api/src/dto.rs b/apps/admin-api/src/dto.rs index da46c81..139b4ea 100644 --- a/apps/admin-api/src/dto.rs +++ b/apps/admin-api/src/dto.rs @@ -2,7 +2,7 @@ use crank_core::{ AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, - UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus, + ToolSelectionPolicy, UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus, }; use crank_mapping::MappingSet; use crank_registry::{ @@ -144,7 +144,7 @@ pub struct AgentPayload { #[serde(default)] pub instructions: Value, #[serde(default)] - pub tool_selection_policy: Value, + pub tool_selection_policy: ToolSelectionPolicy, } #[derive(Clone, Debug, Deserialize)] @@ -165,6 +165,43 @@ pub struct AgentBindingPayload { pub enabled: bool, } +#[derive(Clone, Debug, Deserialize)] +pub struct ToolSearchPreviewPayload { + pub query: String, + #[serde(default)] + pub group_ids: Vec, + pub bindings: Vec, + pub tool_selection_policy: ToolSelectionPolicy, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +pub enum AgentCatalogPayload { + Bindings(Vec), + Config { + bindings: Vec, + tool_selection_policy: ToolSelectionPolicy, + }, +} + +impl AgentCatalogPayload { + pub fn into_parts(self) -> (Vec, Option) { + match self { + Self::Bindings(bindings) => (bindings, None), + Self::Config { + bindings, + tool_selection_policy, + } => (bindings, Some(tool_selection_policy)), + } + } +} + +impl From> for AgentCatalogPayload { + fn from(bindings: Vec) -> Self { + Self::Bindings(bindings) + } +} + #[derive(Clone, Debug, Serialize)] pub struct CreatedAgentResponse { pub agent_id: String, @@ -196,6 +233,7 @@ pub struct AgentSummaryView { pub published_at: Option, pub operation_count: usize, pub operation_ids: Vec, + pub tool_selection_policy: ToolSelectionPolicy, pub key_count: usize, pub calls_today: u64, pub mcp_endpoint: String, diff --git a/apps/admin-api/src/routes/agents.rs b/apps/admin-api/src/routes/agents.rs index faa7f69..660294d 100644 --- a/apps/admin-api/src/routes/agents.rs +++ b/apps/admin-api/src/routes/agents.rs @@ -8,8 +8,8 @@ use serde_json::{Value, json}; use crate::{ error::ApiError, service::{ - AgentBindingPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload, - UpdateAgentPayload, + AgentCatalogPayload, AgentPayload, PlatformApiKeyPayload, PublishPayload, + ToolSearchPreviewPayload, UpdateAgentPayload, }, state::AppState, }; @@ -50,6 +50,18 @@ pub async fn list_agents( Ok(Json(json!({ "items": items }))) } +pub async fn preview_tool_search( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let items = state + .service + .preview_tool_search(&path.workspace_id.as_str().into(), payload) + .await?; + Ok(Json(json!({"items": items}))) +} + pub async fn create_agent( Path(path): Path, State(state): State, @@ -124,7 +136,7 @@ pub async fn get_agent_version( pub async fn save_agent_bindings( Path(path): Path, State(state): State, - Json(payload): Json>, + Json(payload): Json, ) -> Result, ApiError> { let record = state .service diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 466786e..fd7f3e5 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -698,6 +698,7 @@ fn map_agent_summary_view(summary: AgentSummary) -> AgentSummaryView { published_at: summary.published_at.map(format_timestamp), operation_count: 0, operation_ids: Vec::new(), + tool_selection_policy: Default::default(), key_count: 0, calls_today: 0, mcp_endpoint: String::new(), diff --git a/apps/admin-api/src/service/agents.rs b/apps/admin-api/src/service/agents.rs index d2f1374..e056ab4 100644 --- a/apps/admin-api/src/service/agents.rs +++ b/apps/admin-api/src/service/agents.rs @@ -1,12 +1,12 @@ use std::collections::BTreeMap; use crank_core::{ - Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, UsagePeriod, - WorkspaceId, + Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, OperationId, SearchableTool, + ToolAccessMode, ToolSelectionPolicy, UsagePeriod, WorkspaceId, search_tool_catalog, }; use crank_registry::{ AgentVersionRecord, CreateAgentDraftVersionRequest, CreateAgentRequest, PublishAgentRequest, - SaveAgentBindingsRequest, UsageBucket, UsageQuery, + SaveAgentBindingsRequest, SaveAgentCatalogConfigRequest, UsageBucket, UsageQuery, }; use serde_json::json; use time::OffsetDateTime; @@ -15,13 +15,93 @@ use tracing::{info, instrument}; use crate::{ error::ApiError, service::{ - AdminService, AgentBindingPayload, AgentMutationResult, AgentPayload, AgentSummaryView, - CreatedAgentResponse, PublishAgentResponse, UpdateAgentPayload, agent_mcp_endpoint, - format_timestamp, map_agent_summary_view, new_prefixed_id, today_start_utc, + AdminService, AgentCatalogPayload, AgentMutationResult, AgentPayload, AgentSummaryView, + CreatedAgentResponse, PublishAgentResponse, ToolSearchPreviewPayload, UpdateAgentPayload, + agent_mcp_endpoint, format_timestamp, map_agent_summary_view, new_prefixed_id, + today_start_utc, }, }; impl AdminService { + #[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))] + pub async fn preview_tool_search( + &self, + workspace_id: &WorkspaceId, + payload: ToolSearchPreviewPayload, + ) -> Result, ApiError> { + self.ensure_workspace_exists(workspace_id).await?; + if payload.tool_selection_policy.mode != ToolAccessMode::Search { + return Err(ApiError::validation_with_context( + "tool search preview requires search mode", + json!({"field": "tool_selection_policy.mode"}), + )); + } + let bindings = payload + .bindings + .iter() + .map(|binding| AgentOperationBinding { + operation_id: OperationId::new(binding.operation_id.clone()), + operation_version: binding.operation_version, + tool_name: binding.tool_name.clone(), + tool_title: binding.tool_title.clone(), + tool_description_override: binding.tool_description_override.clone(), + enabled: binding.enabled, + }) + .collect::>(); + validate_tool_selection_policy(&payload.tool_selection_policy, &bindings)?; + + let mut tools = Vec::new(); + for binding in bindings.iter().filter(|binding| binding.enabled) { + let version = self + .registry + .get_operation_version( + workspace_id, + &binding.operation_id, + binding.operation_version, + ) + .await? + .ok_or_else(|| { + ApiError::not_found_with_context( + format!("operation {} was not found", binding.operation_id.as_str()), + json!({"operation_id": binding.operation_id.as_str()}), + ) + })?; + let groups = payload + .tool_selection_policy + .groups + .iter() + .filter(|group| { + group + .tool_names + .iter() + .any(|name| name == &binding.tool_name) + }) + .collect::>(); + tools.push(SearchableTool { + name: binding.tool_name.clone(), + title: binding.tool_title.clone(), + description: binding + .tool_description_override + .clone() + .unwrap_or(version.snapshot.tool_description.description), + input_schema: serde_json::Value::Null, + group_ids: groups.iter().map(|group| group.id.clone()).collect(), + group_context: groups + .iter() + .map(|group| format!("{} {}", group.name, group.description)) + .collect::>() + .join(" "), + }); + } + let max_results = payload.tool_selection_policy.search.max_results; + Ok(search_tool_catalog( + &tools, + &payload.query, + &payload.group_ids, + max_results, + )) + } + #[instrument(skip(self))] pub async fn list_agents( &self, @@ -69,6 +149,7 @@ impl AdminService { items.push(AgentSummaryView { operation_count: operation_ids.len(), operation_ids, + tool_selection_policy: version.snapshot.tool_selection_policy, key_count: key_counts.get(summary.id.as_str()).copied().unwrap_or(0), calls_today: calls_today.get(summary.id.as_str()).copied().unwrap_or(0), mcp_endpoint: agent_mcp_endpoint( @@ -130,6 +211,7 @@ impl AdminService { Ok(AgentSummaryView { operation_count: operation_ids.len(), operation_ids, + tool_selection_policy: version.snapshot.tool_selection_policy, key_count, calls_today: usage.map(|item| item.rollup.calls_total).unwrap_or(0), mcp_endpoint: agent_mcp_endpoint( @@ -304,9 +386,12 @@ impl AdminService { &self, workspace_id: &WorkspaceId, agent_id: &AgentId, - payload: Vec, + payload: AgentCatalogPayload, ) -> Result { - let agent = self.get_agent(workspace_id, agent_id).await?; + let current_version = self + .ensure_editable_agent_version(workspace_id, agent_id) + .await?; + let (payload, requested_policy) = payload.into_parts(); let bindings = payload .into_iter() .map(|binding| AgentOperationBinding { @@ -318,23 +403,62 @@ impl AdminService { enabled: binding.enabled, }) .collect::>(); + let tool_selection_policy = requested_policy + .unwrap_or_else(|| current_version.snapshot.tool_selection_policy.clone()); + validate_tool_selection_policy(&tool_selection_policy, &bindings)?; self.registry - .save_agent_bindings(SaveAgentBindingsRequest { + .save_agent_catalog_config(SaveAgentCatalogConfigRequest { workspace_id, agent_id, - agent_version: agent.current_draft_version, + agent_version: current_version.version, bindings: &bindings, + tool_selection_policy: &tool_selection_policy, }) .await?; info!( agent_id = %agent_id.as_str(), - version = agent.current_draft_version, + version = current_version.version, binding_count = bindings.len(), "agent bindings saved" ); - self.get_agent_version(workspace_id, agent_id, agent.current_draft_version) + self.get_agent_version(workspace_id, agent_id, current_version.version) + .await + } + + async fn ensure_editable_agent_version( + &self, + workspace_id: &WorkspaceId, + agent_id: &AgentId, + ) -> Result { + let agent = self.get_agent(workspace_id, agent_id).await?; + let current = self + .get_agent_version(workspace_id, agent_id, agent.current_draft_version) + .await?; + if agent.latest_published_version != Some(agent.current_draft_version) { + return Ok(current); + } + + let now = OffsetDateTime::now_utc(); + let draft = AgentVersion { + agent_id: agent_id.clone(), + version: current.version + 1, + status: AgentStatus::Draft, + instructions: current.snapshot.instructions.clone(), + tool_selection_policy: current.snapshot.tool_selection_policy.clone(), + created_at: now, + }; + self.registry + .create_agent_draft_version(CreateAgentDraftVersionRequest { + workspace_id, + agent_id, + version: &draft, + bindings: ¤t.bindings, + updated_at: &now, + }) + .await?; + self.get_agent_version(workspace_id, agent_id, draft.version) .await } @@ -351,6 +475,10 @@ impl AdminService { let published_bindings = self .published_agent_bindings(workspace_id, &agent_version.bindings) .await?; + validate_tool_selection_policy( + &agent_version.snapshot.tool_selection_policy, + &published_bindings, + )?; if published_bindings.is_empty() { return Err(ApiError::conflict_with_context( @@ -490,3 +618,22 @@ impl AdminService { }) } } + +fn validate_tool_selection_policy( + policy: &ToolSelectionPolicy, + bindings: &[AgentOperationBinding], +) -> Result<(), ApiError> { + policy + .validate_for_tools( + bindings + .iter() + .filter(|binding| binding.enabled) + .map(|binding| binding.tool_name.as_str()), + ) + .map_err(|error| { + ApiError::validation_with_context( + error.to_string(), + json!({"field": "tool_selection_policy"}), + ) + }) +} diff --git a/apps/admin-api/src/service/demo.rs b/apps/admin-api/src/service/demo.rs index 9d9e219..04a8e7f 100644 --- a/apps/admin-api/src/service/demo.rs +++ b/apps/admin-api/src/service/demo.rs @@ -272,7 +272,7 @@ impl AdminService { publish: bool, ) -> Result<(), ApiError> { let summary = self.get_agent(workspace_id, agent_id).await?; - self.save_agent_bindings(workspace_id, agent_id, bindings) + self.save_agent_bindings(workspace_id, agent_id, bindings.into()) .await?; if publish && summary.latest_published_version.is_none() { self.publish_agent(workspace_id, agent_id, summary.current_draft_version) @@ -348,10 +348,7 @@ fn demo_currency_agent_payload() -> AgentPayload { instructions: json!({ "system": "Используй инструменты Frankfurter только для запросов о курсах валют." }), - tool_selection_policy: json!({ - "max_tools": 4, - "prefer_tag": ["currency", "exchange-rate"] - }), + tool_selection_policy: Default::default(), } } diff --git a/apps/admin-api/tests/integration/community_access_usage.rs b/apps/admin-api/tests/integration/community_access_usage.rs index 9a0ac3b..c01b50a 100644 --- a/apps/admin-api/tests/integration/community_access_usage.rs +++ b/apps/admin-api/tests/integration/community_access_usage.rs @@ -464,7 +464,7 @@ async fn seeds_demo_assets_for_live_ui() { display_name: "Legacy Smoke Agent".to_owned(), description: "Keeps a legacy smoke operation published".to_owned(), instructions: json!({}), - tool_selection_policy: json!({}), + tool_selection_policy: Default::default(), }, ) .await @@ -481,7 +481,8 @@ async fn seeds_demo_assets_for_live_ui() { tool_title: "Legacy health smoke".to_owned(), tool_description_override: None, enabled: true, - }], + }] + .into(), ) .await .unwrap(); diff --git a/apps/admin-api/tests/integration/operations_agents.rs b/apps/admin-api/tests/integration/operations_agents.rs index fd7fd79..01b0629 100644 --- a/apps/admin-api/tests/integration/operations_agents.rs +++ b/apps/admin-api/tests/integration/operations_agents.rs @@ -398,6 +398,116 @@ async fn creates_binds_and_publishes_agent() { assert_eq!(published["published_version"], 1); } +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn saves_and_previews_versioned_agent_tool_search_policy() { + let registry = test_registry().await; + let storage_root = test_storage_root("agent_tool_search"); + let upstream_base_url = spawn_upstream_server().await; + let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await; + let client = authorized_client(&base_url).await; + + let operation = assert_success_json( + client + .post(format!("{base_url}/operations")) + .json(&test_operation_payload( + &upstream_base_url, + "finance_create_invoice", + )) + .send() + .await + .unwrap(), + ) + .await; + let operation_id = operation["operation_id"].as_str().unwrap().to_owned(); + assert_success_json( + client + .post(format!("{base_url}/operations/{operation_id}/publish")) + .json(&json!({"version": 1})) + .send() + .await + .unwrap(), + ) + .await; + + let agent = assert_success_json( + client + .post(format!("{base_url}/agents")) + .json(&json!({ + "slug": "finance-agent", + "display_name": "Finance Agent", + "description": "Finance workflows", + "instructions": {}, + "tool_selection_policy": {} + })) + .send() + .await + .unwrap(), + ) + .await; + let agent_id = agent["agent_id"].as_str().unwrap().to_owned(); + let catalog = json!({ + "bindings": [{ + "operation_id": operation_id, + "operation_version": 1, + "tool_name": "finance_create_invoice", + "tool_title": "Create Lead", + "tool_description_override": "Creates an invoice for a customer", + "enabled": true + }], + "tool_selection_policy": { + "mode": "search", + "groups": [{ + "id": "finance", + "name": "Finance", + "description": "Invoices and payments", + "tool_names": ["finance_create_invoice"] + }], + "search": {"max_results": 5} + } + }); + let saved = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/bindings")) + .json(&catalog) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(saved["snapshot"]["tool_selection_policy"]["mode"], "search"); + + let preview = assert_success_json( + client + .post(format!("{base_url}/agents/tool-search/preview")) + .json(&json!({ + "query": "create invoice", + "group_ids": ["finance"], + "bindings": catalog["bindings"], + "tool_selection_policy": catalog["tool_selection_policy"] + })) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!( + preview["items"][0]["tool"]["name"], + "finance_create_invoice" + ); + + let published = assert_success_json( + client + .post(format!("{base_url}/agents/{agent_id}/publish")) + .json(&json!({"version": 1})) + .send() + .await + .unwrap(), + ) + .await; + assert_eq!(published["published_version"], 1); +} + #[tokio::test(flavor = "multi_thread")] #[serial] async fn agent_publish_skips_draft_operation_bindings_and_preserves_draft() { diff --git a/apps/mcp-server/tests/integration.rs b/apps/mcp-server/tests/integration.rs index 0c3d0e1..feffabd 100644 --- a/apps/mcp-server/tests/integration.rs +++ b/apps/mcp-server/tests/integration.rs @@ -1,5 +1,6 @@ mod integration { mod catalog_access; mod common; + mod tool_search; mod transport_protocol; } diff --git a/apps/mcp-server/tests/integration/common.rs b/apps/mcp-server/tests/integration/common.rs index c1dd2fd..835847b 100644 --- a/apps/mcp-server/tests/integration/common.rs +++ b/apps/mcp-server/tests/integration/common.rs @@ -18,7 +18,7 @@ use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, - WorkspaceId, + ToolSelectionPolicy, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ @@ -45,7 +45,7 @@ use crank_community_mcp::{ session::{InMemorySessionStore, SharedSessionStore, TransportSessionStore}, }; -fn test_workspace_id() -> WorkspaceId { +pub(super) fn test_workspace_id() -> WorkspaceId { WorkspaceId::new("ws_default") } @@ -93,7 +93,7 @@ fn test_agent_id(agent_slug: &str) -> AgentId { AgentId::new(format!("agent_{agent_slug}")) } -fn build_test_app( +pub(super) fn build_test_app( registry: PostgresRegistry, refresh_interval: Duration, public_base_url: Option, @@ -363,6 +363,21 @@ pub(super) async fn publish_agent_with_bindings( registry: &PostgresRegistry, agent_slug: &str, bindings: Vec, +) { + publish_agent_with_policy( + registry, + agent_slug, + bindings, + ToolSelectionPolicy::default(), + ) + .await; +} + +pub(super) async fn publish_agent_with_policy( + registry: &PostgresRegistry, + agent_slug: &str, + bindings: Vec, + tool_selection_policy: ToolSelectionPolicy, ) { let agent_id = AgentId::new(format!("agent_{agent_slug}")); let agent = Agent { @@ -383,7 +398,7 @@ pub(super) async fn publish_agent_with_bindings( version: 1, status: AgentStatus::Draft, instructions: json!({}), - tool_selection_policy: json!({}), + tool_selection_policy, created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), }; diff --git a/apps/mcp-server/tests/integration/tool_search.rs b/apps/mcp-server/tests/integration/tool_search.rs new file mode 100644 index 0000000..c561b60 --- /dev/null +++ b/apps/mcp-server/tests/integration/tool_search.rs @@ -0,0 +1,154 @@ +use super::common::*; + +use std::time::Duration; + +use crank_core::{ + PlatformApiKeyScope, ToolAccessMode, ToolGroup, ToolSearchSettings, ToolSelectionPolicy, +}; +use crank_registry::PublishRequest; +use serde_json::json; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + +#[tokio::test] +async fn search_mode_discovers_and_calls_tools_through_meta_tools() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let invoice = test_operation(&upstream_base_url, "create_invoice"); + let ticket = test_operation(&upstream_base_url, "create_support_ticket"); + for operation in [&invoice, &ticket] { + 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: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(), + published_by: Some("alice"), + }) + .await + .unwrap(); + } + publish_agent_with_policy( + ®istry, + "business-search", + vec![ + binding_for_operation(&invoice), + binding_for_operation(&ticket), + ], + ToolSelectionPolicy { + mode: ToolAccessMode::Search, + groups: vec![ + ToolGroup { + id: "finance".to_owned(), + name: "Finance".to_owned(), + description: "Invoices and payments".to_owned(), + tool_names: vec![invoice.name.clone()], + }, + ToolGroup { + id: "support".to_owned(), + name: "Support".to_owned(), + description: "Customer support tickets".to_owned(), + tool_names: vec![ticket.name.clone()], + }, + ], + search: ToolSearchSettings { max_results: 5 }, + }, + ) + .await; + let api_key = create_platform_api_key( + ®istry, + "business-search", + "mcp-search", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_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, "business-search"); + let session = initialize_session(&client, &mcp_url, &api_key).await; + + let listed = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&session), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}), + ) + .await; + assert_eq!( + listed["result"]["tools"] + .as_array() + .unwrap() + .iter() + .map(|tool| tool["name"].as_str().unwrap()) + .collect::>(), + vec!["search_tools", "call_tool"] + ); + + let search = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&session), + json!({ + "jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"search_tools","arguments":{"query":"invoice","group_ids":["finance"]}} + }), + ) + .await; + assert_eq!( + search["result"]["structuredContent"]["tools"][0]["name"], + "create_invoice" + ); + assert_eq!( + search["result"]["structuredContent"]["catalog_revision"], + "agent-version-1" + ); + + let stale_call = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&session), + json!({ + "jsonrpc":"2.0","id":4,"method":"tools/call", + "params":{"name":"call_tool","arguments":{ + "name":"create_invoice", + "arguments":{"email":"user@example.com"}, + "catalog_revision":"agent-version-0" + }} + }), + ) + .await; + assert_eq!(stale_call["result"]["isError"], true); + assert_eq!( + stale_call["result"]["structuredContent"]["error"]["code"], + "catalog_revision_changed" + ); + + let call = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&session), + json!({ + "jsonrpc":"2.0","id":5,"method":"tools/call", + "params":{"name":"call_tool","arguments":{ + "name":"create_invoice", + "arguments":{"email":"user@example.com"}, + "catalog_revision":"agent-version-1" + }} + }), + ) + .await; + assert_eq!(call["result"]["isError"], false); + assert_eq!(call["result"]["structuredContent"]["id"], "lead_123"); +} diff --git a/apps/mcp-server/tests/integration/transport_protocol.rs b/apps/mcp-server/tests/integration/transport_protocol.rs index 8004e3e..ad335f4 100644 --- a/apps/mcp-server/tests/integration/transport_protocol.rs +++ b/apps/mcp-server/tests/integration/transport_protocol.rs @@ -19,7 +19,8 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod, Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, - PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, + PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolAccessMode, ToolDescription, ToolGroup, + ToolSearchSettings, ToolSelectionPolicy, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css index 872517c..0966a31 100644 --- a/apps/ui/css/pages.css +++ b/apps/ui/css/pages.css @@ -1403,6 +1403,177 @@ .agents-rec-callout svg { flex-shrink: 0; color: #d2991f; margin-top: 1px; } .agents-rec-callout strong { color: var(--text-primary); } +.tool-access-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.tool-access-option { + min-height: 112px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg-canvas); + color: var(--text-secondary); + text-align: left; + cursor: pointer; +} +.tool-access-option:hover { border-color: var(--border-muted, #444c56); } +.tool-access-option.active { + border-color: var(--accent); + background: rgba(45, 212, 191, 0.07); +} +.tool-access-option-title { + display: block; + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + line-height: 1.35; +} +.tool-access-option.active .tool-access-option-title { color: var(--accent); } +.tool-access-option-body { + display: block; + margin-top: 7px; + font-size: 11px; + line-height: 1.5; +} +.tool-search-config { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border-subtle); +} +.tool-search-config-header, +.tool-group-card-header, +.tool-search-preview-controls, +.tool-search-result, +.tool-group-assignment-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.tool-search-config-title { + color: var(--text-primary); + font-size: 12px; + font-weight: 600; +} +.tool-search-config-header .drawer-section-sub { margin: 3px 0 0; } +.tool-search-config-header .btn-ghost-sm { white-space: nowrap; } +.tool-group-empty { + padding: 12px; + border: 1px dashed var(--border); + border-radius: 7px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.5; +} +.tool-group-card, +.tool-search-preview { + padding: 12px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg-canvas); +} +.tool-group-card-header { margin-bottom: 10px; } +.tool-group-card-header strong { font-size: 12px; color: var(--text-primary); } +.tool-group-card-header .agent-action-btn img { width: 13px; height: 13px; } +.tool-group-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; +} +.tool-group-fields label, +.tool-group-description, +.tool-search-limit { + display: flex; + flex-direction: column; + gap: 5px; + color: var(--text-secondary); + font-size: 11px; +} +.tool-group-description { margin-top: 9px; } +.tool-group-assignments { + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; +} +.tool-group-assignments > .tool-search-config-title { + padding: 10px 12px; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-canvas); +} +.tool-group-assignment-row { + align-items: flex-start; + padding: 10px 12px; + border-bottom: 1px solid var(--border-subtle); +} +.tool-group-assignment-row:last-child { border-bottom: 0; } +.tool-group-assignment-tool { min-width: 130px; } +.tool-group-assignment-tool strong, +.tool-group-assignment-tool code, +.tool-search-result strong, +.tool-search-result code { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tool-group-assignment-tool strong, +.tool-search-result strong { color: var(--text-primary); font-size: 11px; } +.tool-group-assignment-tool code, +.tool-search-result code { margin-top: 3px; color: var(--text-muted); font-size: 10px; } +.tool-group-chips { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; +} +.tool-group-chip { + max-width: 150px; + padding: 4px 7px; + border: 1px solid var(--border); + border-radius: 5px; + background: transparent; + color: var(--text-muted); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} +.tool-group-chip.active { + border-color: var(--accent); + background: rgba(45, 212, 191, 0.08); + color: var(--accent); +} +.tool-search-limit { max-width: 190px; } +.tool-search-preview .drawer-section-sub { margin-top: 4px; } +.tool-search-preview-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) 132px auto; +} +.tool-search-results { + margin-top: 10px; + border-top: 1px solid var(--border-subtle); +} +.tool-search-result { padding: 9px 0; border-bottom: 1px solid var(--border-subtle); } +.tool-search-result:last-child { border-bottom: 0; } +.tool-search-result > div { min-width: 0; } +.tool-search-result > span { color: var(--text-muted); font-size: 10px; } +.tool-search-preview > .tool-group-empty { margin-top: 10px; } + +@media (max-width: 540px) { + .tool-access-options, + .tool-group-fields, + .tool-search-preview-controls { grid-template-columns: 1fr; } + .tool-group-assignment-row { flex-direction: column; } + .tool-group-chips { justify-content: flex-start; } + .tool-search-limit { max-width: none; } +} + /* ═══════════════════════════════════════════════════ Settings — Members enhanced diff --git a/apps/ui/html/agents.html b/apps/ui/html/agents.html index e9e0a85..86dec63 100644 --- a/apps/ui/html/agents.html +++ b/apps/ui/html/agents.html @@ -359,24 +359,122 @@ -
+
You've selected tools.
+
+
Tool access
+
Choose how the model receives this agent's tool catalog.
+ +
+ + +
+ + +
+