use std::{fmt, sync::Arc, time::Duration};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use stateknot_core::{
AttemptId, BoxFuture, Digest, EventId, Failure, FailureCategory, FailureCode, FailureId,
FailureMessage, FailureOrigin, InvocationId, RetryAdvice, RunId, ToolError, ToolErrorPhase,
ToolErrorProvenance, ToolExternalEffect, ToolInvocationRevision,
};
use stateknot_integrations::{
McpServerContent, McpServerPrincipal, McpServerToolCall, McpServerToolContext,
McpServerToolDefinition, McpServerToolDefinitionError, McpServerToolHandler,
McpServerToolHandlerError, McpServerToolOutcome, McpServerToolResult,
};
use stateknot_runtime::JsonSchemaRegistry;
use stateknot_store_postgres::PostgresStore;
use super::{
McpReconciliationError, McpReconciliationGrant, McpToolReconciler, ReconciledOutcome,
ReconciliationStore, ReconciliationTarget, digest_schema, id_schema,
};
const TOOL: &str = "stateknot_reconcile_tool_error_v1";
const SCOPE: &str = "stateknot:reconcile-error";
const EVENT: &str = "mcp-tool-error-reconciled";
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum McpKnownToolEffect {
NotApplied,
Applied,
}
impl McpKnownToolEffect {
const fn core(self) -> ToolExternalEffect {
match self {
Self::NotApplied => ToolExternalEffect::NotApplied,
Self::Applied => ToolExternalEffect::Applied,
}
}
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct McpErrorReconciliationRequest {
pub event_id: EventId,
pub run_id: RunId,
pub invocation_id: InvocationId,
pub attempt_id: AttemptId,
pub expected_revision: ToolInvocationRevision,
pub expected_digest: Digest,
pub failure_id: FailureId,
pub failure_category: FailureCategory,
pub failure_code: FailureCode,
pub failure_message: FailureMessage,
pub external_effect: McpKnownToolEffect,
}
impl fmt::Debug for McpErrorReconciliationRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("McpErrorReconciliationRequest")
.field("event_id", &self.event_id)
.field("invocation_id", &self.invocation_id)
.field("expected_revision", &self.expected_revision)
.finish_non_exhaustive()
}
}
pub trait McpErrorReconciliationAuthorizer: Send + Sync + 'static {
fn authorize(
&self,
principal: McpServerPrincipal,
request: McpErrorReconciliationRequest,
) -> BoxFuture<'_, Result<McpReconciliationGrant, McpReconciliationError>>;
}
#[derive(Clone)]
pub struct McpToolErrorReconciler {
backend: ReconciliationStore,
authorizer: Arc<dyn McpErrorReconciliationAuthorizer>,
}
impl McpToolErrorReconciler {
pub fn new(
store: PostgresStore,
schemas: JsonSchemaRegistry,
authorizer: Arc<dyn McpErrorReconciliationAuthorizer>,
) -> Result<Self, McpReconciliationError> {
Ok(Self {
backend: ReconciliationStore::new(store, schemas, &Self::audit_schema(), EVENT)?,
authorizer,
})
}
pub fn audit_schema() -> Value {
let mut schema = McpToolReconciler::audit_schema();
schema["$id"] =
json!("https://stknot.com/schemas/runtime/mcp-tool-error-reconciliation/1.0.0");
schema
}
pub fn definition() -> Result<McpServerToolDefinition, McpServerToolDefinitionError> {
McpServerToolDefinition::new(TOOL, json!({"type":"object", "additionalProperties":false,
"required":["event_id","run_id","invocation_id","attempt_id","expected_revision","expected_digest",
"failure_id","failure_category","failure_code","failure_message","external_effect"],
"properties": {
"event_id":id_schema(), "run_id":id_schema(), "invocation_id":id_schema(), "attempt_id":id_schema(),
"expected_revision":{"type":"string","pattern":"^(0|[1-9][0-9]{0,18})$"},
"expected_digest":digest_schema(), "failure_id":id_schema(),
"failure_category":{"type":"string","enum":["invalid_input","unauthenticated","permission_denied",
"policy_denied","not_found","conflict","unsupported","rate_limited","deadline_exceeded",
"cancelled","dependency_unavailable","data_corruption","internal"]},
"failure_code":{"type":"string","minLength":1,"maxLength":128,"pattern":"^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)*$"},
"failure_message":{"type":"string","minLength":1,"maxLength":FailureMessage::MAX_BYTES},
"external_effect":{"type":"string","enum":["not_applied","applied"]}
}}))?
.with_required_scopes([SCOPE])?
.with_output_schema(json!({"type":"object", "additionalProperties":false,
"required":["event_id","invocation_digest","revision"], "properties":{
"event_id":id_schema(), "invocation_digest":digest_schema(),
"revision":{"type":"string","pattern":"^(0|[1-9][0-9]{0,18})$"}
}}))
}
async fn reconcile(
&self,
principal: McpServerPrincipal,
request: McpErrorReconciliationRequest,
) -> Result<Value, McpReconciliationError> {
if !principal.has_scope(SCOPE) {
return Err(McpReconciliationError::Denied);
}
let grant = self
.authorizer
.authorize(principal.clone(), request.clone())
.await?;
let failure = Failure::new(
request.failure_id,
request.failure_category,
request.failure_code.clone(),
FailureOrigin::new("mcp.reconciliation")
.map_err(|_| McpReconciliationError::Invalid)?,
request.failure_message.clone(),
RetryAdvice::Never,
)
.map_err(|_| McpReconciliationError::Invalid)?;
let request_digest = Digest::sha256(
serde_json_canonicalizer::to_vec(&json!({
"tool":TOOL, "subject":principal.subject(), "tenant":grant.caller.tenant_id(),
"principal":grant.caller.principal(), "request":request
}))
.map_err(|_| McpReconciliationError::Invalid)?,
);
let target = ReconciliationTarget {
event_id: request.event_id,
run_id: request.run_id,
invocation_id: request.invocation_id,
attempt_id: request.attempt_id,
expected_revision: request.expected_revision,
expected_digest: request.expected_digest,
};
let expected = self.backend.load(&grant, &target).await?;
let error = ToolError::new(
failure,
ToolErrorPhase::Execution,
request.external_effect.core(),
ToolErrorProvenance::new(
request.invocation_id,
request.attempt_id,
expected.intent().descriptor().metadata().identity().clone(),
),
)
.map_err(|_| McpReconciliationError::Invalid)?;
expected
.validate_reconciliation_error(&error)
.map_err(|_| McpReconciliationError::Invalid)?;
self.backend
.apply(
&grant,
&target,
&expected,
ReconciledOutcome::Error(error),
request_digest,
)
.await
}
}
impl McpServerToolHandler for McpToolErrorReconciler {
fn call(
&self,
call: McpServerToolCall,
context: McpServerToolContext,
) -> BoxFuture<'_, Result<McpServerToolOutcome, McpServerToolHandlerError>> {
Box::pin(async move {
if context.is_cancelled() {
return Err(McpServerToolHandlerError::Cancelled);
}
let operation = async {
if call.name() != TOOL
|| !call.input_responses().is_empty()
|| call.request_state().is_some()
{
return Err(McpReconciliationError::Invalid);
}
let request = serde_json::from_value(Value::Object(call.arguments().clone()))
.map_err(|_| McpReconciliationError::Invalid)?;
self.reconcile(context.principal().clone(), request).await
};
let result = tokio::select! {
biased;
() = context.cancelled() => return Err(McpServerToolHandlerError::Cancelled),
result = tokio::time::timeout(Duration::from_secs(15), operation) => result.unwrap_or(Err(McpReconciliationError::Unavailable)),
};
let result = match result {
Ok(receipt) => McpServerToolResult::structured([], receipt),
Err(error) => {
McpServerToolResult::error([McpServerContent::text(error.to_string())
.map_err(|_| McpServerToolHandlerError::Internal)?])
}
}
.map_err(|_| McpServerToolHandlerError::Internal)?;
Ok(result.into())
})
}
}