Execute approved tool calls
This commit is contained in:
@@ -610,7 +610,33 @@ async fn approval_key_lists_and_decides_pending_requests() {
|
||||
let approved_body = approved.json::<Value>().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::<Value>().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
|
||||
|
||||
@@ -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<ApprovalRoutePath>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
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<ApprovalRoutePath>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -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<AppState>,
|
||||
path: &AgentRoutePath,
|
||||
approval: ApprovalRequestRecord,
|
||||
) -> Result<ApprovalRequestRecord, Response> {
|
||||
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<AgentRoutePath>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum ApprovalRequestStatus {
|
||||
Approved,
|
||||
Denied,
|
||||
Expired,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Value>,
|
||||
pub decision_note: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateSecretRequest<'a> {
|
||||
pub secret: &'a Secret,
|
||||
|
||||
@@ -187,6 +187,49 @@ impl PostgresRegistry {
|
||||
|
||||
row.map(map_approval_request_row).transpose()
|
||||
}
|
||||
|
||||
pub async fn finish_approval_request(
|
||||
&self,
|
||||
request: FinishApprovalRequest<'_>,
|
||||
) -> Result<Option<ApprovalRequestRecord>, 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<ApprovalRequestRecord, RegistryError> {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -181,6 +181,13 @@ curl https://crank.example.com/mcp/v1/default/sales/approvals \
|
||||
-H 'Authorization: Bearer <approval_api_key>'
|
||||
```
|
||||
|
||||
Статус конкретного запроса:
|
||||
|
||||
```bash
|
||||
curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id> \
|
||||
-H 'Authorization: Bearer <approval_api_key>'
|
||||
```
|
||||
|
||||
Подтверждение:
|
||||
|
||||
```bash
|
||||
@@ -190,6 +197,8 @@ curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id>/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/<approval_id>/deny
|
||||
|
||||
Ключ MCP-клиента не подходит для этих endpoints. Ключ подтверждения, наоборот, не подходит для `initialize`, `tools/list` и `tools/call`.
|
||||
|
||||
Текущая реализация возвращает `approval_required` сразу и не держит исходный `tools/call` открытым до решения пользователя. Поэтому внешний интерфейс может восстановить результат через `GET /approvals/<approval_id>`. Долгое ожидание через Streamable HTTP/SSE запланировано отдельно.
|
||||
|
||||
## Как формируется каталог инструментов
|
||||
|
||||
MCP-клиент видит только опубликованные операции, которые привязаны к опубликованному агенту.
|
||||
|
||||
Reference in New Issue
Block a user