From 700a684257a2f0f3cef1942733d995de9f7d8a0d Mon Sep 17 00:00:00 2001 From: github-ops Date: Sat, 27 Jun 2026 07:55:38 +0000 Subject: [PATCH] Add approval mode selection --- .../src/service/operation_validation.rs | 17 +- .../tests/integration/catalog_access.rs | 2 +- .../catalog_access/approval_access.rs | 170 ++++++++++++++++++ apps/mcp-server/tests/integration/common.rs | 12 +- .../tests/integration/transport_protocol.rs | 1 + apps/ui/html/wizard/step3-rest.html | 36 ++++ apps/ui/js/i18n.js | 20 +++ apps/ui/js/wizard-live.js | 15 ++ apps/ui/tests/e2e/wizard.spec.js | 3 + crates/crank-community-mcp/src/app.rs | 97 +++++++++- crates/crank-community-mcp/src/session.rs | 47 +++-- .../tests/integration/session.rs | 2 + .../crank-community-mcp/tests/unit/session.rs | 6 +- crates/crank-core/src/lib.rs | 16 +- crates/crank-core/src/operation.rs | 12 ++ 15 files changed, 425 insertions(+), 31 deletions(-) diff --git a/apps/admin-api/src/service/operation_validation.rs b/apps/admin-api/src/service/operation_validation.rs index 711771d..3c28a42 100644 --- a/apps/admin-api/src/service/operation_validation.rs +++ b/apps/admin-api/src/service/operation_validation.rs @@ -125,6 +125,17 @@ pub(super) fn validate_approval_policy( )); } + if let Some(message) = policy.elicitation_message.as_ref() { + if message.chars().count() > 240 { + return Err(ApiError::validation_with_context( + "approval elicitation message must be at most 240 characters".to_owned(), + json!({ + "field": "execution_config.approval_policy.elicitation_message", + }), + )); + } + } + Ok(()) } @@ -133,7 +144,7 @@ mod tests { use std::collections::BTreeMap; use crank_core::{ - ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, + ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, OperationApprovalMode, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel, ResponseCachePolicy, RestTarget, Target, }; @@ -272,10 +283,12 @@ mod tests { let mut config = cacheable_execution_config(); config.approval_policy = Some(OperationApprovalPolicy { required: true, + mode: OperationApprovalMode::Custom, risk_level: OperationApprovalRiskLevel::Dangerous, ttl_seconds: 300, show_payload_preview: true, payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + elicitation_message: None, }); validate_approval_policy(&config).unwrap(); @@ -286,10 +299,12 @@ mod tests { let mut config = cacheable_execution_config(); config.approval_policy = Some(OperationApprovalPolicy { required: true, + mode: OperationApprovalMode::Custom, risk_level: OperationApprovalRiskLevel::Dangerous, ttl_seconds: 0, show_payload_preview: true, payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + elicitation_message: None, }); let error = validate_approval_policy(&config).unwrap_err(); diff --git a/apps/mcp-server/tests/integration/catalog_access.rs b/apps/mcp-server/tests/integration/catalog_access.rs index e743c0b..63994ab 100644 --- a/apps/mcp-server/tests/integration/catalog_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access.rs @@ -21,7 +21,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, InvocationSource, - Operation, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, + Operation, OperationApprovalMode, OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, diff --git a/apps/mcp-server/tests/integration/catalog_access/approval_access.rs b/apps/mcp-server/tests/integration/catalog_access/approval_access.rs index fcf0307..51459a8 100644 --- a/apps/mcp-server/tests/integration/catalog_access/approval_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access/approval_access.rs @@ -359,10 +359,12 @@ async fn tool_call_with_approval_policy_creates_pending_request() { let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval"); operation.execution_config.approval_policy = Some(OperationApprovalPolicy { required: true, + mode: OperationApprovalMode::Custom, risk_level: OperationApprovalRiskLevel::Dangerous, ttl_seconds: 300, show_payload_preview: true, payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + elicitation_message: None, }); registry .create_operation(&test_workspace_id(), &operation, Some("alice")) @@ -445,3 +447,171 @@ async fn tool_call_with_approval_policy_creates_pending_request() { "ada@example.com" ); } + +#[tokio::test] +async fn elicitation_approval_requires_client_capability() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation"); + operation.execution_config.approval_policy = Some(OperationApprovalPolicy { + required: true, + mode: OperationApprovalMode::Elicitation, + risk_level: OperationApprovalRiskLevel::Normal, + ttl_seconds: 300, + show_payload_preview: true, + payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + elicitation_message: Some("Подтвердите создание лида.".to_owned()), + }); + 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::now_utc(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-elicitation-no-capability", + vec![binding_for_operation(&operation)], + ) + .await; + let api_key = create_platform_api_key( + ®istry, + "sales-elicitation-no-capability", + "mcp-elicitation-no-capability", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_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-elicitation-no-capability"); + let initialized_session = initialize_session(&client, &mcp_url, &api_key).await; + + let tool_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": { + "name": "crm_requires_elicitation", + "arguments": { + "email": "ada@example.com" + } + } + }), + ) + .await; + + assert_eq!(tool_result["result"]["isError"], true); + assert_eq!( + tool_result["result"]["structuredContent"]["error"]["code"], + "approval_elicitation_not_supported" + ); +} + +#[tokio::test] +async fn elicitation_approval_uses_session_capability_without_approval_key() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_requires_elicitation_supported"); + operation.execution_config.approval_policy = Some(OperationApprovalPolicy { + required: true, + mode: OperationApprovalMode::Elicitation, + risk_level: OperationApprovalRiskLevel::Normal, + ttl_seconds: 300, + show_payload_preview: true, + payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + elicitation_message: Some("Подтвердите создание лида.".to_owned()), + }); + 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::now_utc(), + published_by: Some("alice"), + }) + .await + .unwrap(); + publish_agent_with_bindings( + ®istry, + "sales-elicitation-supported", + vec![binding_for_operation(&operation)], + ) + .await; + let api_key = create_platform_api_key( + ®istry, + "sales-elicitation-supported", + "mcp-elicitation-supported", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let base_url = spawn_mcp_server(build_test_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-elicitation-supported"); + let initialized_session = initialize_session_with_capabilities( + &client, + &mcp_url, + &api_key, + json!({ "elicitation": {} }), + ) + .await; + + let tool_result = post_jsonrpc( + &client, + &mcp_url, + &api_key, + Some(&initialized_session), + json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "crm_requires_elicitation_supported", + "arguments": { + "email": "ada@example.com" + } + } + }), + ) + .await; + + assert_eq!(tool_result["result"]["isError"], false); + assert_eq!( + tool_result["result"]["structuredContent"]["status"], + "elicitation_required" + ); + assert_eq!( + tool_result["result"]["structuredContent"]["message"], + "Подтвердите создание лида." + ); + assert_eq!( + tool_result["result"]["structuredContent"]["payload_preview"]["email"], + "ada@example.com" + ); +} diff --git a/apps/mcp-server/tests/integration/common.rs b/apps/mcp-server/tests/integration/common.rs index c44de4c..193dd32 100644 --- a/apps/mcp-server/tests/integration/common.rs +++ b/apps/mcp-server/tests/integration/common.rs @@ -147,6 +147,15 @@ pub(super) async fn initialize_session( client: &reqwest::Client, mcp_url: &str, api_key: &str, +) -> String { + initialize_session_with_capabilities(client, mcp_url, api_key, json!({})).await +} + +pub(super) async fn initialize_session_with_capabilities( + client: &reqwest::Client, + mcp_url: &str, + api_key: &str, + capabilities: Value, ) -> String { let initialize_response = client .post(mcp_url) @@ -157,7 +166,8 @@ pub(super) async fn initialize_session( "id": 1, "method": "initialize", "params": { - "protocolVersion": "2025-11-25" + "protocolVersion": "2025-11-25", + "capabilities": capabilities } })) .send() diff --git a/apps/mcp-server/tests/integration/transport_protocol.rs b/apps/mcp-server/tests/integration/transport_protocol.rs index aac4760..466f5fb 100644 --- a/apps/mcp-server/tests/integration/transport_protocol.rs +++ b/apps/mcp-server/tests/integration/transport_protocol.rs @@ -689,6 +689,7 @@ async fn get_returns_not_found_for_expired_transport_session() { "2025-11-25", test_workspace_slug(), "sales-expired-session", + false, OffsetDateTime::parse("2026-05-01T10:00:00Z", &Rfc3339).unwrap(), Some(OffsetDateTime::parse("2026-05-01T10:00:01Z", &Rfc3339).unwrap()), ) diff --git a/apps/ui/html/wizard/step3-rest.html b/apps/ui/html/wizard/step3-rest.html index 877cd7b..09561b6 100644 --- a/apps/ui/html/wizard/step3-rest.html +++ b/apps/ui/html/wizard/step3-rest.html @@ -114,6 +114,42 @@