Add structured MCP tool errors
This commit is contained in:
@@ -45,6 +45,10 @@ use crate::{
|
||||
},
|
||||
manifest::tool_definitions,
|
||||
session::{SessionState, SharedSessionStore},
|
||||
tool_error::{
|
||||
ToolErrorContract, generic_tool_error_contract, runtime_error_code,
|
||||
tool_error_contract_from_runtime, tool_error_text, tool_error_value,
|
||||
},
|
||||
};
|
||||
|
||||
const HEADER_MCP_SESSION_ID: &str = "MCP-Session-Id";
|
||||
@@ -460,18 +464,17 @@ async fn handle_tool_call(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
"machine_access_insufficient",
|
||||
format!(
|
||||
"machine access mode {} does not satisfy {} operation security",
|
||||
serialize_machine_access_mode(credential.machine_access_mode),
|
||||
serialize_security_level(resolved.tool.operation.security_level),
|
||||
generic_tool_error_contract(
|
||||
"machine_access_insufficient",
|
||||
format!(
|
||||
"machine access mode {} does not satisfy {} operation security",
|
||||
serialize_machine_access_mode(credential.machine_access_mode),
|
||||
serialize_security_level(resolved.tool.operation.security_level),
|
||||
),
|
||||
transport_request_id,
|
||||
false,
|
||||
Some("Используйте ключ агента с достаточным уровнем доступа."),
|
||||
),
|
||||
Some(json!({
|
||||
"machine_access_mode": credential.machine_access_mode,
|
||||
"credential_security_level": credential.max_security_level,
|
||||
"required_security_level": resolved.tool.operation.security_level,
|
||||
"upgrade_required": true,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -660,9 +663,7 @@ async fn handle_base_tool_call(
|
||||
message,
|
||||
response_mode,
|
||||
&session.protocol_version,
|
||||
runtime_error_code(&error),
|
||||
error.to_string(),
|
||||
runtime_error_context(&error),
|
||||
tool_error_contract_from_runtime(&error, transport_request_id),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1242,17 +1243,10 @@ fn tool_error_response(
|
||||
message: &Value,
|
||||
response_mode: ResponseMode,
|
||||
protocol_version: &str,
|
||||
code: &str,
|
||||
error_message: String,
|
||||
error_context: Option<Value>,
|
||||
error: ToolErrorContract,
|
||||
) -> Response {
|
||||
let mut error = json!({
|
||||
"code": code,
|
||||
"message": error_message
|
||||
});
|
||||
if let Some(context) = error_context {
|
||||
error["context"] = context;
|
||||
}
|
||||
let error_message = tool_error_text(&error);
|
||||
let error_value = tool_error_value(&error);
|
||||
|
||||
transport_response(
|
||||
StatusCode::OK,
|
||||
@@ -1266,7 +1260,7 @@ fn tool_error_response(
|
||||
}
|
||||
],
|
||||
"structuredContent": {
|
||||
"error": error
|
||||
"error": error_value
|
||||
},
|
||||
"isError": true
|
||||
}),
|
||||
@@ -1283,64 +1277,6 @@ fn add_millis(timestamp: OffsetDateTime, millis: u64) -> OffsetDateTime {
|
||||
timestamp + delta
|
||||
}
|
||||
|
||||
fn runtime_error_code(error: &RuntimeError) -> &'static str {
|
||||
match error {
|
||||
RuntimeError::Schema(_) => "schema_validation_error",
|
||||
RuntimeError::Mapping(_) => "mapping_error",
|
||||
RuntimeError::RestAdapter(_) => "adapter_execution_error",
|
||||
RuntimeError::ProtocolAdapter(_) => "adapter_execution_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "unsupported_protocol",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
RuntimeError::UnsupportedExecutionMode { .. } => "streaming_mode_error",
|
||||
RuntimeError::InvalidPreparedRequest { .. } => "runtime_error",
|
||||
RuntimeError::MissingAuthProfile { .. } => "auth_profile_not_found",
|
||||
RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => {
|
||||
"secret_not_found"
|
||||
}
|
||||
RuntimeError::InvalidAuthSecretValue { .. } => "secret_value_error",
|
||||
RuntimeError::SecretCrypto { .. } => "secret_crypto_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_error_context(error: &RuntimeError) -> Option<Value> {
|
||||
match error {
|
||||
RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({
|
||||
"field": field,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
"reason": reason,
|
||||
})),
|
||||
RuntimeError::SecretCrypto { operation, details } => Some(json!({
|
||||
"operation": operation,
|
||||
"details": details,
|
||||
})),
|
||||
RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({
|
||||
"auth_profile_id": auth_profile_id,
|
||||
})),
|
||||
RuntimeError::MissingSecret { secret_id } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
})),
|
||||
RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({
|
||||
"secret_id": secret_id,
|
||||
"version": version,
|
||||
})),
|
||||
RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({
|
||||
"operation_id": operation_id,
|
||||
"mode": mode,
|
||||
})),
|
||||
RuntimeError::UnsupportedProtocol { protocol } => Some(json!({
|
||||
"protocol": protocol,
|
||||
})),
|
||||
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
|
||||
"kind": kind,
|
||||
"limit": limit,
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_access_secret(secret: &str) -> String {
|
||||
let digest = Sha256::digest(secret.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
@@ -1511,43 +1447,9 @@ mod tests {
|
||||
use axum::body::to_bytes;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ResponseMode, runtime_error_context, tool_error_response};
|
||||
use super::{ResponseMode, tool_error_response};
|
||||
use crate::jsonrpc::CURRENT_PROTOCOL_VERSION;
|
||||
use crank_runtime::RuntimeError;
|
||||
|
||||
#[test]
|
||||
fn runtime_error_context_includes_secret_crypto_operation() {
|
||||
let context = runtime_error_context(&RuntimeError::SecretCrypto {
|
||||
operation: "decode secret envelope",
|
||||
details: "bad base64".to_owned(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context,
|
||||
json!({
|
||||
"operation": "decode secret envelope",
|
||||
"details": "bad base64"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_error_context_includes_runtime_overload_details() {
|
||||
let context = runtime_error_context(&RuntimeError::ConcurrencyLimitExceeded {
|
||||
kind: "async_job",
|
||||
limit: 8,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
context,
|
||||
json!({
|
||||
"kind": "async_job",
|
||||
"limit": 8
|
||||
})
|
||||
);
|
||||
}
|
||||
use crate::tool_error::generic_tool_error_contract;
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_error_response_includes_structured_context() {
|
||||
@@ -1555,12 +1457,13 @@ mod tests {
|
||||
&json!({"jsonrpc": "2.0", "id": "req-1"}),
|
||||
ResponseMode::Json,
|
||||
CURRENT_PROTOCOL_VERSION,
|
||||
"streaming_payload_error",
|
||||
"request root must be an object".to_owned(),
|
||||
Some(json!({
|
||||
"field": "request",
|
||||
"reason": "must be an object"
|
||||
})),
|
||||
generic_tool_error_contract(
|
||||
"streaming_payload_error",
|
||||
"request root must be an object",
|
||||
"req-1",
|
||||
false,
|
||||
Some("Проверьте параметры вызова инструмента."),
|
||||
),
|
||||
);
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
@@ -1570,11 +1473,11 @@ mod tests {
|
||||
payload["result"]["structuredContent"]["error"],
|
||||
json!({
|
||||
"code": "streaming_payload_error",
|
||||
"error_code": "streaming_payload_error",
|
||||
"message": "request root must be an object",
|
||||
"context": {
|
||||
"field": "request",
|
||||
"reason": "must be an object"
|
||||
}
|
||||
"recoverable": false,
|
||||
"request_id": "req-1",
|
||||
"suggested_action": "Проверьте параметры вызова инструмента."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user