From 267061e22699b242599d943b93df7ab4f0be29e5 Mon Sep 17 00:00:00 2001 From: github-ops Date: Wed, 24 Jun 2026 12:49:57 +0000 Subject: [PATCH] Execute approved tool calls --- .../tests/integration/catalog_access.rs | 28 +++- crates/crank-community-mcp/src/app.rs | 147 +++++++++++++++++- crates/crank-core/src/approval.rs | 2 + crates/crank-registry/src/lib.rs | 18 +-- crates/crank-registry/src/model.rs | 10 ++ .../crank-registry/src/postgres/approval.rs | 43 +++++ crates/crank-registry/src/postgres/mod.rs | 21 +-- .../tests/integration/workspace_access.rs | 28 +++- docs/mcp-interface.md | 11 ++ 9 files changed, 282 insertions(+), 26 deletions(-) diff --git a/apps/mcp-server/tests/integration/catalog_access.rs b/apps/mcp-server/tests/integration/catalog_access.rs index b7befe3..9c3be59 100644 --- a/apps/mcp-server/tests/integration/catalog_access.rs +++ b/apps/mcp-server/tests/integration/catalog_access.rs @@ -610,7 +610,33 @@ async fn approval_key_lists_and_decides_pending_requests() { let approved_body = approved.json::().await.unwrap(); assert_eq!( approved_body["approval"]["status"], - Value::String("approved".to_owned()) + Value::String("completed".to_owned()) + ); + assert_eq!( + approved_body["approval"]["response_payload"], + json!({ "id": "lead_123" }) + ); + + let status_url = format!( + "{}/approvals/{}", + agent_mcp_url(&base_url, "sales-human-approval"), + approval.id + ); + let current = client + .get(&status_url) + .header(header::AUTHORIZATION, format!("Bearer {approval_key}")) + .send() + .await + .unwrap(); + assert_eq!(current.status(), reqwest::StatusCode::OK); + let current_body = current.json::().await.unwrap(); + assert_eq!( + current_body["approval"]["status"], + Value::String("completed".to_owned()) + ); + assert_eq!( + current_body["approval"]["response_payload"], + json!({ "id": "lead_123" }) ); let pending_after = client diff --git a/crates/crank-community-mcp/src/app.rs b/crates/crank-community-mcp/src/app.rs index dbccff4..2321989 100644 --- a/crates/crank-community-mcp/src/app.rs +++ b/crates/crank-community-mcp/src/app.rs @@ -18,8 +18,8 @@ use crank_core::{ PlatformApiKeyScope, SecretId, }; use crank_registry::{ - CreateApprovalRequest, CreateInvocationLogRequest, DecideApprovalRequest, PostgresRegistry, - PublishedAgentTool, + ApprovalRequestRecord, CreateApprovalRequest, CreateInvocationLogRequest, + DecideApprovalRequest, FinishApprovalRequest, PostgresRegistry, PublishedAgentTool, }; use crank_runtime::{ RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor, @@ -155,6 +155,10 @@ pub fn build_app( "/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve", post(approve_request), ) + .route( + "/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}", + get(get_approval_request), + ) .route( "/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny", post(deny_request), @@ -213,6 +217,42 @@ async fn approve_request( .await } +async fn get_approval_request( + Path(path): Path, + State(state): State>, + headers: HeaderMap, +) -> Response { + let agent_path = AgentRoutePath { + workspace_slug: path.workspace_slug, + agent_slug: path.agent_slug, + }; + let key = match require_approval_access( + &state, + &agent_path, + &headers, + PlatformApiKeyScope::ReadPending, + ) + .await + { + Ok(key) => key, + Err(status) => return status.into_response(), + }; + let Some(agent_id) = key.api_key.agent_id.as_ref() else { + return StatusCode::FORBIDDEN.into_response(); + }; + let approval_id = ApprovalRequestId::new(path.approval_id); + + match state + .registry + .get_approval_request_for_agent(&key.api_key.workspace_id, agent_id, &approval_id) + .await + { + Ok(Some(record)) => Json(json!(record)).into_response(), + Ok(None) => StatusCode::NOT_FOUND.into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + async fn deny_request( Path(path): Path, State(state): State>, @@ -279,12 +319,115 @@ async fn decide_approval_request( }) .await { + Ok(Some(record)) if status == ApprovalRequestStatus::Approved => { + match execute_approved_request(&state, &agent_path, record).await { + Ok(record) => Json(json!(record)).into_response(), + Err(response) => response, + } + } Ok(Some(record)) => Json(json!(record)).into_response(), Ok(None) => StatusCode::CONFLICT.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } +async fn execute_approved_request( + state: &Arc, + path: &AgentRoutePath, + approval: ApprovalRequestRecord, +) -> Result { + let tools = state + .catalog + .list_tools(&path.workspace_slug, &path.agent_slug) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?; + let Some(tool) = tools.into_iter().find(|tool| { + tool.operation.id == approval.approval.operation_id + && tool.operation.version == approval.approval.operation_version + }) else { + return Err(StatusCode::NOT_FOUND.into_response()); + }; + + let operation = runtime_operation(&tool); + let request_preview = build_request_preview( + &state.runtime, + &operation, + &approval.approval.request_payload, + ); + let started_at = Instant::now(); + let resolved_auth = + resolve_operation_auth(state, &tool.workspace_id, &operation.execution_config).await; + let result = match resolved_auth { + Ok(resolved_auth) => { + state + .runtime + .execute_request( + RuntimeExecutionRequest::new(&operation, &approval.approval.request_payload) + .with_optional_auth(resolved_auth.as_ref()), + ) + .await + } + Err(error) => Err(error), + }; + + let (status, response_payload, invocation_status, invocation_level, message, error_kind) = + match result { + Ok(output) => ( + ApprovalRequestStatus::Completed, + output, + InvocationStatus::Ok, + InvocationLevel::Info, + "approved tool call completed", + None, + ), + Err(error) => ( + ApprovalRequestStatus::Failed, + json!({ + "error": { + "code": runtime_error_code(&error), + "message": error.to_string(), + } + }), + InvocationStatus::Error, + InvocationLevel::Error, + "approved tool call failed", + Some(runtime_error_code(&error)), + ), + }; + + let _ = persist_invocation( + state, + &tool, + InvocationRecord { + request_id: Some(approval.approval.id.as_str()), + tool_name: &tool.tool_name, + status: invocation_status, + level: invocation_level, + message, + status_code: None, + error_kind, + duration: started_at.elapsed(), + request_preview, + response_preview: response_payload.clone(), + }, + ) + .await; + + state + .registry + .finish_approval_request(FinishApprovalRequest { + workspace_id: &approval.approval.workspace_id, + agent_id: &approval.approval.agent_id, + approval_id: &approval.approval.id, + status, + response_payload: Some(response_payload), + decision_note: None, + }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())? + .ok_or_else(|| StatusCode::CONFLICT.into_response()) +} + async fn mcp_get( Path(path): Path, State(state): State>, diff --git a/crates/crank-core/src/approval.rs b/crates/crank-core/src/approval.rs index 7bc7701..e651b44 100644 --- a/crates/crank-core/src/approval.rs +++ b/crates/crank-core/src/approval.rs @@ -13,6 +13,8 @@ pub enum ApprovalRequestStatus { Approved, Denied, Expired, + Completed, + Failed, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/crank-registry/src/lib.rs b/crates/crank-registry/src/lib.rs index 8b7727b..91a8f9a 100644 --- a/crates/crank-registry/src/lib.rs +++ b/crates/crank-registry/src/lib.rs @@ -27,10 +27,10 @@ pub mod requests { CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, - FinishImportJobRequest, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest, - RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, - SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, - UpdateWorkspaceRequest, UsageQuery, + FinishApprovalRequest, FinishImportJobRequest, ListInvocationLogsQuery, + PublishAgentRequest, PublishRequest, RotateSecretRequest, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery, }; } @@ -45,11 +45,11 @@ pub use model::{ CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata, - FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, - InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, - OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, - OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, - PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, + FinishApprovalRequest, FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind, + ImportJobStatus, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, + MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary, + OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, + PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index 4b92375..c94cabf 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -536,6 +536,16 @@ pub struct DecideApprovalRequest<'a> { pub decision_note: Option<&'a str>, } +#[derive(Clone, Debug)] +pub struct FinishApprovalRequest<'a> { + pub workspace_id: &'a WorkspaceId, + pub agent_id: &'a AgentId, + pub approval_id: &'a ApprovalRequestId, + pub status: ApprovalRequestStatus, + pub response_payload: Option, + pub decision_note: Option<&'a str>, +} + #[derive(Clone, Debug, PartialEq)] pub struct CreateSecretRequest<'a> { pub secret: &'a Secret, diff --git a/crates/crank-registry/src/postgres/approval.rs b/crates/crank-registry/src/postgres/approval.rs index 3113ba1..f2e6755 100644 --- a/crates/crank-registry/src/postgres/approval.rs +++ b/crates/crank-registry/src/postgres/approval.rs @@ -187,6 +187,49 @@ impl PostgresRegistry { row.map(map_approval_request_row).transpose() } + + pub async fn finish_approval_request( + &self, + request: FinishApprovalRequest<'_>, + ) -> Result, RegistryError> { + let row = sqlx::query( + "update approval_requests + set status = $1, + response_payload_json = $2, + decision_note = coalesce($3, decision_note) + where workspace_id = $4 + and agent_id = $5 + and id = $6 + and status = 'approved' + returning + id, + workspace_id, + agent_id, + operation_id, + operation_version, + status, + risk_level, + confirmation_title, + confirmation_body, + request_payload_json, + response_payload_json, + created_at, + expires_at, + decided_at, + decided_by_key_id, + decision_note", + ) + .bind(serialize_enum_text(&request.status, "approval_status")?) + .bind(request.response_payload.as_ref().map(Json)) + .bind(request.decision_note) + .bind(request.workspace_id.as_str()) + .bind(request.agent_id.as_str()) + .bind(request.approval_id.as_str()) + .fetch_optional(&self.pool) + .await?; + + row.map(map_approval_request_row).transpose() + } } fn map_approval_request_row(row: PgRow) -> Result { diff --git a/crates/crank-registry/src/postgres/mod.rs b/crates/crank-registry/src/postgres/mod.rs index 2b6a8f4..4a0fcf1 100644 --- a/crates/crank-registry/src/postgres/mod.rs +++ b/crates/crank-registry/src/postgres/mod.rs @@ -35,16 +35,17 @@ use crate::{ CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, - DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord, - InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, - OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, - PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool, - RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest, - SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, - SecretRecord, SecretVersionRecord, SessionRecord, UpdateWorkspaceRequest, - UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, - UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, - YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + DescriptorMetadata, FinishApprovalRequest, FinishImportJobRequest, ImportJob, ImportJobId, + InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, + OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary, + OperationVersionRecord, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, + PublishedAgentTool, RegistryOperation, RotateSecretRequest, SaveAgentBindingsRequest, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord, + UpdateWorkspaceRequest, UsageAgentBreakdown, UsageOperationBreakdown, UsageQuery, + UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, + WorkspaceRecord, WorkspaceUpstream, YamlImportJob, YamlImportJobCompletion, + YamlImportJobId, YamlImportJobStatus, }, }; diff --git a/crates/crank-registry/tests/integration/workspace_access.rs b/crates/crank-registry/tests/integration/workspace_access.rs index 69f7fea..b881389 100644 --- a/crates/crank-registry/tests/integration/workspace_access.rs +++ b/crates/crank-registry/tests/integration/workspace_access.rs @@ -23,10 +23,11 @@ use crank_registry::{ CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata, - OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest, - PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest, - SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest, - WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus, + FinishApprovalRequest, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, + PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind, + SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, + UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, + YamlImportJobStatus, }; fn test_workspace_id() -> WorkspaceId { @@ -555,6 +556,25 @@ async fn manages_approval_request_lifecycle() { Some(json!({"approve": "yes"})) ); + let completed = registry + .finish_approval_request(FinishApprovalRequest { + workspace_id: &workspace_id, + agent_id: &agent.id, + approval_id: &approval.id, + status: ApprovalRequestStatus::Completed, + response_payload: Some(json!({"id": "lead_123"})), + decision_note: None, + }) + .await + .unwrap() + .unwrap(); + + assert_eq!(completed.approval.status, ApprovalRequestStatus::Completed); + assert_eq!( + completed.approval.response_payload, + Some(json!({"id": "lead_123"})) + ); + let pending_after_decision = registry .list_pending_approval_requests_for_agent(&workspace_id, &agent.id) .await diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index c244dab..9e7ece2 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -181,6 +181,13 @@ curl https://crank.example.com/mcp/v1/default/sales/approvals \ -H 'Authorization: Bearer ' ``` +Статус конкретного запроса: + +```bash +curl https://crank.example.com/mcp/v1/default/sales/approvals/ \ + -H 'Authorization: Bearer ' +``` + Подтверждение: ```bash @@ -190,6 +197,8 @@ curl https://crank.example.com/mcp/v1/default/sales/approvals//appr --data '{ "approve": "yes", "note": "Пользователь подтвердил действие" }' ``` +После подтверждения Crank выполняет исходный REST-запрос с тем payload, который был сохранен при первом `tools/call`. Если запрос прошел успешно, заявка получает статус `completed`, а результат сохраняется в `response_payload`. Если upstream вернул ошибку, заявка получает статус `failed`, а в `response_payload` сохраняется код и текст ошибки. + Отклонение: ```bash @@ -201,6 +210,8 @@ curl https://crank.example.com/mcp/v1/default/sales/approvals//deny Ключ MCP-клиента не подходит для этих endpoints. Ключ подтверждения, наоборот, не подходит для `initialize`, `tools/list` и `tools/call`. +Текущая реализация возвращает `approval_required` сразу и не держит исходный `tools/call` открытым до решения пользователя. Поэтому внешний интерфейс может восстановить результат через `GET /approvals/`. Долгое ожидание через Streamable HTTP/SSE запланировано отдельно. + ## Как формируется каталог инструментов MCP-клиент видит только опубликованные операции, которые привязаны к опубликованному агенту.