diff --git a/apps/mcp-server/tests/integration/catalog_access.rs b/apps/mcp-server/tests/integration/catalog_access.rs index cc1e284..b7befe3 100644 --- a/apps/mcp-server/tests/integration/catalog_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access.rs @@ -19,9 +19,9 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{ Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, Operation, - OperationApprovalRiskLevel, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, - PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, - WorkspaceId, + OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel, + OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, + PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_registry::{ @@ -625,6 +625,102 @@ async fn approval_key_lists_and_decides_pending_requests() { assert!(pending_after["items"].as_array().unwrap().is_empty()); } +#[tokio::test] +async fn tool_call_with_approval_policy_creates_pending_request() { + let registry = test_registry().await; + let upstream_base_url = spawn_upstream_server().await; + let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval"); + operation.execution_config.approval_policy = Some(OperationApprovalPolicy { + required: true, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Подтвердите создание лида".to_owned(), + confirmation_body_template: "Проверьте email перед отправкой в CRM.".to_owned(), + ttl_seconds: 300, + show_payload_preview: true, + payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + }); + 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_for_operation(®istry, &operation, "sales-gated").await; + let api_key = create_platform_api_key( + ®istry, + "sales-gated", + "mcp-gated", + &[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write], + ) + .await; + let approval_key = + create_approval_platform_api_key(®istry, "sales-gated", "approval-gated").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, "sales-gated"); + 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": 9, + "method": "tools/call", + "params": { + "name": "crm_requires_human_approval", + "arguments": { + "email": "ada@example.com" + } + } + }), + ) + .await; + + assert_eq!( + tool_result["result"]["structuredContent"]["status"], + "approval_required" + ); + assert_eq!(tool_result["result"]["isError"], false); + let approval_id = tool_result["result"]["structuredContent"]["approval_id"] + .as_str() + .unwrap(); + assert!(approval_id.starts_with("approval_")); + + let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated")); + let pending = client + .get(&approvals_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(pending["items"].as_array().unwrap().len(), 1); + assert_eq!(pending["items"][0]["approval"]["id"], approval_id); + assert_eq!( + pending["items"][0]["approval"]["request_payload"]["email"], + "ada@example.com" + ); +} + #[tokio::test] async fn rejects_tool_call_with_read_only_platform_api_key() { let registry = test_registry().await; diff --git a/crates/crank-community-mcp/src/app.rs b/crates/crank-community-mcp/src/app.rs index b69c948..dbccff4 100644 --- a/crates/crank-community-mcp/src/app.rs +++ b/crates/crank-community-mcp/src/app.rs @@ -13,12 +13,13 @@ use axum::{ routing::{get, post}, }; use crank_core::{ - ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore, InvocationLevel, - InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, PlatformApiKeyScope, - SecretId, + ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore, + InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus, + PlatformApiKeyScope, SecretId, }; use crank_registry::{ - CreateInvocationLogRequest, DecideApprovalRequest, PostgresRegistry, PublishedAgentTool, + CreateApprovalRequest, CreateInvocationLogRequest, DecideApprovalRequest, PostgresRegistry, + PublishedAgentTool, }; use crank_runtime::{ RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor, @@ -736,6 +737,20 @@ async fn handle_base_tool_call( let tool = execution.tool; let arguments = execution.arguments; let operation = runtime_operation(&tool); + if let Some(response) = maybe_create_pending_approval( + &state, + session, + message, + response_mode, + &tool, + &arguments, + transport_request_id, + ) + .await + { + return response; + } + let mut runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id) .with_response_cache_scope( tool.workspace_id.as_str().to_owned(), @@ -819,6 +834,112 @@ async fn handle_base_tool_call( } } +async fn maybe_create_pending_approval( + state: &Arc, + session: &SessionState, + message: &Value, + response_mode: ResponseMode, + tool: &PublishedAgentTool, + arguments: &Value, + transport_request_id: &str, +) -> Option { + let policy = tool.operation.execution_config.approval_policy.as_ref()?; + if !policy.required { + return None; + } + + let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple())); + let now = OffsetDateTime::now_utc(); + let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds)); + let approval_url = approval_url_for(tool, &approval_id); + let response_payload = json!({ + "status": "approval_required", + "approval_id": approval_id.as_str(), + "approval_url": approval_url, + "approve": { + "method": "POST", + "url": format!("{approval_url}/approve"), + "body": { "approve": "yes" } + }, + "deny": { + "method": "POST", + "url": format!("{approval_url}/deny"), + "body": { "approve": "no" } + }, + "expires_at": expires_at, + "risk_level": policy.risk_level, + "confirmation_title": policy.confirmation_title, + "confirmation_body": policy.confirmation_body_template, + "payload_preview": if policy.show_payload_preview { + arguments.clone() + } else { + Value::Null + }, + }); + let approval = ApprovalRequest { + id: approval_id, + workspace_id: tool.workspace_id.clone(), + agent_id: tool.agent_id.clone(), + operation_id: tool.operation.id.clone(), + operation_version: tool.operation.version, + status: ApprovalRequestStatus::Pending, + risk_level: policy.risk_level, + confirmation_title: policy.confirmation_title.clone(), + confirmation_body: policy.confirmation_body_template.clone(), + request_payload: arguments.clone(), + response_payload: None, + created_at: now, + expires_at, + decided_at: None, + decided_by_key_id: None, + decision_note: None, + }; + + if let Err(error) = state + .registry + .create_approval_request(CreateApprovalRequest { + approval: &approval, + }) + .await + { + return Some(internal_jsonrpc_error(message, error)); + } + + let _ = persist_invocation( + state, + tool, + InvocationRecord { + request_id: Some(transport_request_id), + tool_name: &tool.tool_name, + status: InvocationStatus::Ok, + level: InvocationLevel::Info, + message: "agent tool call is waiting for human approval", + status_code: None, + error_kind: None, + duration: Duration::from_millis(0), + request_preview: arguments.clone(), + response_preview: response_payload.clone(), + }, + ) + .await; + + Some(success_tool_response( + message, + response_mode, + &session.protocol_version, + response_payload, + )) +} + +fn approval_url_for(tool: &PublishedAgentTool, approval_id: &ApprovalRequestId) -> String { + format!( + "/v1/{}/{}/approvals/{}", + tool.workspace_slug, + tool.agent_slug, + approval_id.as_str() + ) +} + async fn handle_initialize( state: Arc, path: &AgentRoutePath,