diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 4647661..6cb622d 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -231,10 +231,14 @@ impl From for ApiError { } pub fn runtime_test_failure(error: &RuntimeError) -> Value { - json!({ + let mut payload = json!({ "code": runtime_test_failure_code(error), "message": error.to_string() - }) + }); + if let Some(context) = runtime_error_context(error) { + payload["context"] = context; + } + payload } fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { @@ -259,3 +263,87 @@ fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error", } } + +pub fn runtime_error_context(error: &RuntimeError) -> Option { + match error { + RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({ + "field": field, + "reason": reason, + })), + RuntimeError::InvalidStreamingPayload { 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::MissingStreamingConfig { operation_id } => Some(json!({ + "operation_id": operation_id, + })), + RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({ + "operation_id": operation_id, + "mode": mode, + })), + RuntimeError::UnsupportedProtocol { protocol } => Some(json!({ + "protocol": protocol, + })), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{runtime_error_context, runtime_test_failure}; + use crank_runtime::RuntimeError; + + #[test] + fn runtime_test_failure_includes_structured_context() { + let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest { + field: "request.headers".to_owned(), + reason: "must be an object".to_owned(), + }); + + assert_eq!(payload["code"], "runtime_request_error"); + assert_eq!( + payload["context"], + json!({ + "field": "request.headers", + "reason": "must be an object" + }) + ); + } + + #[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" + }) + ); + } +} diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index fdad692..42ae260 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -693,6 +693,7 @@ async fn handle_base_tool_call( &session.protocol_version, runtime_error_code(&error), error.to_string(), + runtime_error_context(&error), ) } } @@ -716,6 +717,7 @@ async fn handle_session_start_call( &session.protocol_version, "streaming_config_error", "streaming config is required for session tools".to_owned(), + None, ); }; @@ -841,6 +843,7 @@ async fn handle_session_start_call( &session.protocol_version, runtime_error_code(&error), error.to_string(), + runtime_error_context(&error), ) } } @@ -874,6 +877,7 @@ async fn handle_session_poll_call( &session.protocol_version, "streaming_config_error", "streaming config is required for session tools".to_owned(), + None, ); }; @@ -891,6 +895,7 @@ async fn handle_session_poll_call( &session.protocol_version, "stream_session_not_found", format!("stream session {} was not found", control.session_id), + None, ); } Err(error) => return internal_jsonrpc_error(message, error), @@ -903,6 +908,7 @@ async fn handle_session_poll_call( &session.protocol_version, "stream_session_not_found", format!("stream session {} was not found", control.session_id), + None, ); } @@ -927,6 +933,7 @@ async fn handle_session_poll_call( &session.protocol_version, "stream_session_expired", format!("stream session {} has expired", control.session_id), + None, ); } @@ -937,6 +944,7 @@ async fn handle_session_poll_call( &session.protocol_version, "stream_session_not_found", format!("stream session {} is not running", control.session_id), + None, ); } @@ -949,6 +957,7 @@ async fn handle_session_poll_call( &session.protocol_version, "streaming_payload_error", error.to_string(), + None, ); } }; @@ -1054,6 +1063,7 @@ async fn handle_session_stop_call( &session.protocol_version, "stream_session_not_found", format!("stream session {} was not found", control.session_id), + None, ); } Err(error) => return internal_jsonrpc_error(message, error), @@ -1066,6 +1076,7 @@ async fn handle_session_stop_call( &session.protocol_version, "stream_session_not_found", format!("stream session {} was not found", control.session_id), + None, ); } @@ -1107,6 +1118,7 @@ async fn handle_async_job_start_call( &session.protocol_version, "streaming_config_error", "streaming config is required for async job tools".to_owned(), + None, ); }; @@ -1188,7 +1200,8 @@ async fn handle_async_job_start_call( result: None, error: Some(&json!({ "code": runtime_error_code(&error), - "message": error.to_string() + "message": error.to_string(), + "context": runtime_error_context(&error) })), expires_at: None, updated_at: &finished_at, @@ -1265,6 +1278,7 @@ async fn handle_async_job_status_call( &session.protocol_version, "async_job_not_found", format!("async job {} was not found", control.job_id), + None, ); } Err(error) => return internal_jsonrpc_error(message, error), @@ -1277,6 +1291,7 @@ async fn handle_async_job_status_call( &session.protocol_version, "async_job_not_found", format!("async job {} was not found", control.job_id), + None, ); } @@ -1328,6 +1343,7 @@ async fn handle_async_job_result_call( &session.protocol_version, "async_job_not_found", format!("async job {} was not found", control.job_id), + None, ); } Err(error) => return internal_jsonrpc_error(message, error), @@ -1340,6 +1356,7 @@ async fn handle_async_job_result_call( &session.protocol_version, "async_job_not_found", format!("async job {} was not found", control.job_id), + None, ); } @@ -1356,9 +1373,16 @@ async fn handle_async_job_result_call( &session.protocol_version, "async_job_failed", job.error - .and_then(|value| value.get("message").cloned()) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .as_ref() + .and_then(|value| value.get("message")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) .unwrap_or_else(|| "async job failed".to_owned()), + job.error + .as_ref() + .and_then(|value| value.get("context")) + .filter(|value| !value.is_null()) + .cloned(), ), JobStatus::Cancelled => tool_error_response( message, @@ -1366,6 +1390,7 @@ async fn handle_async_job_result_call( &session.protocol_version, "async_job_cancelled", "async job was cancelled".to_owned(), + None, ), _ => tool_error_response( message, @@ -1373,6 +1398,7 @@ async fn handle_async_job_result_call( &session.protocol_version, "async_job_not_ready", "async job result is not ready".to_owned(), + None, ), } } @@ -1411,6 +1437,7 @@ async fn handle_async_job_cancel_call( &session.protocol_version, "async_job_not_found", format!("async job {} was not found", control.job_id), + None, ); } } @@ -1421,6 +1448,7 @@ async fn handle_async_job_cancel_call( &session.protocol_version, "async_job_not_found", format!("async job {} was not found", control.job_id), + None, ); } Err(error) => return internal_jsonrpc_error(message, error), @@ -1885,7 +1913,16 @@ fn tool_error_response( protocol_version: &str, code: &str, error_message: String, + error_context: Option, ) -> Response { + let mut error = json!({ + "code": code, + "message": error_message + }); + if let Some(context) = error_context { + error["context"] = context; + } + transport_response( StatusCode::OK, jsonrpc_result( @@ -1898,10 +1935,7 @@ fn tool_error_response( } ], "structuredContent": { - "error": { - "code": code, - "message": error_message - } + "error": error }, "isError": true }), @@ -1968,6 +2002,48 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str { } } +fn runtime_error_context(error: &RuntimeError) -> Option { + match error { + RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({ + "field": field, + "reason": reason, + })), + RuntimeError::InvalidStreamingPayload { 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::MissingStreamingConfig { operation_id } => Some(json!({ + "operation_id": operation_id, + })), + RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({ + "operation_id": operation_id, + "mode": mode, + })), + RuntimeError::UnsupportedProtocol { protocol } => Some(json!({ + "protocol": protocol, + })), + _ => None, + } +} + fn hash_access_secret(secret: &str) -> String { let digest = Sha256::digest(secret.as_bytes()); URL_SAFE_NO_PAD.encode(digest) @@ -2320,3 +2396,60 @@ fn extract_origin(url: &str) -> Option { None } + +#[cfg(test)] +mod tests { + use axum::body::to_bytes; + use serde_json::{Value, json}; + + use super::{ResponseMode, runtime_error_context, 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" + }) + ); + } + + #[tokio::test] + async fn tool_error_response_includes_structured_context() { + let response = tool_error_response( + &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" + })), + ); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!( + payload["result"]["structuredContent"]["error"], + json!({ + "code": "streaming_payload_error", + "message": "request root must be an object", + "context": { + "field": "request", + "reason": "must be an object" + } + }) + ); + } +}