use aion_mcp::tools::service::{ToolCall, ToolFailure, ToolOutcome};
use aion_proto::WireError;
use serde_json::json;
use crate::authoring::AuthoringApiError;
use crate::awl::{self, documents::DocumentError, run_loop::RunLoopError};
use crate::mcp::args::{optional_str, required_str};
use crate::{CallerIdentity, ServerState};
use super::errors::{tool_failure, wire_code_label};
pub(crate) async fn list_documents(
state: &ServerState,
caller: &CallerIdentity,
_call: &ToolCall,
) -> Result<ToolOutcome, ToolFailure> {
require_authenticated(caller)?;
let root = workspace(state)?;
let entries = awl::documents::list(&root)
.await
.map_err(|error| document_failure(&error))?;
let count = entries.len();
let documents = serde_json::to_value(&entries).map_err(|error| {
tool_failure(&WireError::backend(format!(
"document entries could not be encoded: {error}"
)))
})?;
Ok(ToolOutcome {
summary: format!("{count} document(s) in the authoring workspace"),
structured: json!({ "documents": documents, "count": count }),
})
}
pub(crate) async fn read_document(
state: &ServerState,
caller: &CallerIdentity,
call: &ToolCall,
) -> Result<ToolOutcome, ToolFailure> {
require_authenticated(caller)?;
let path = required_str(call, "path")?;
let root = workspace(state)?;
let document = awl::documents::read(&root, &path)
.await
.map_err(|error| document_failure(&error))?;
Ok(ToolOutcome {
summary: format!("{path} at revision {}", document.content_hash),
structured: json!({
"path": path,
"source": document.source,
"content_hash": document.content_hash,
}),
})
}
pub(crate) async fn check_document(
state: &ServerState,
caller: &CallerIdentity,
call: &ToolCall,
) -> Result<ToolOutcome, ToolFailure> {
require_authenticated(caller)?;
let source = required_str(call, "source")?;
let path = optional_str(call, "path");
let root = workspace(state)?;
let checked = awl::check_source_in_workspace(&root, &awl::CheckRequest { source, path })
.await
.map_err(|error| document_failure(&error))?;
let summary = if checked.deploys_green {
match checked.steps {
Some(steps) => format!("check passed: {steps} step(s), deploys green"),
None => "check passed: step count unavailable, deploys green".to_owned(),
}
} else {
format!(
"check found {} diagnostic(s); the document will not deploy until they are fixed",
checked.diagnostics.len()
)
};
let diagnostics = serde_json::to_value(&checked.diagnostics).map_err(|error| {
tool_failure(&WireError::backend(format!(
"check diagnostics could not be encoded: {error}"
)))
})?;
Ok(ToolOutcome {
summary,
structured: json!({
"ok": checked.ok,
"deploys_green": checked.deploys_green,
"steps": checked.steps,
"diagnostics": diagnostics,
}),
})
}
pub(crate) async fn save_document(
state: &ServerState,
caller: &CallerIdentity,
call: &ToolCall,
) -> Result<ToolOutcome, ToolFailure> {
require_deploy_grant(state, caller)?;
let path = required_str(call, "path")?;
let source = required_str(call, "source")?;
let root = workspace(state)?;
let saved = awl::documents::write(&root, &path, awl::PutDocumentRequest { source })
.await
.map_err(|error| document_failure(&error))?;
Ok(ToolOutcome {
summary: format!("saved {path} at revision {}", saved.content_hash),
structured: json!({
"path": path,
"source": saved.source,
"content_hash": saved.content_hash,
}),
})
}
pub(crate) async fn deploy_document(
state: &ServerState,
caller: &CallerIdentity,
call: &ToolCall,
) -> Result<ToolOutcome, ToolFailure> {
require_deploy_grant(state, caller)?;
let path = required_str(call, "path")?;
let content_hash = required_str(call, "content_hash")?;
let root = workspace(state)?;
let deployed = awl::run_loop::deploy(
state,
caller,
&root,
"mcp",
awl::run_loop::DeployAuthoringRequest { path, content_hash },
)
.await
.map_err(|error| run_loop_failure(&error))?;
let summary = format!(
"deployed {} as workflow type {} (deployment {})",
deployed.deployment.document_path,
deployed.deployment.workflow_type,
deployed.deployment.deployment_id
);
let structured = serde_json::to_value(&deployed).map_err(|error| {
tool_failure(&WireError::backend(format!(
"deployment record could not be encoded: {error}"
)))
})?;
Ok(ToolOutcome {
summary,
structured,
})
}
fn require_authenticated(caller: &CallerIdentity) -> Result<(), ToolFailure> {
match caller.denial_reason() {
Some(reason) => Err(tool_failure(&WireError::namespace_denied(format!(
"AWL authoring requires an authenticated caller: {reason}"
)))),
None => Ok(()),
}
}
fn require_deploy_grant(state: &ServerState, caller: &CallerIdentity) -> Result<(), ToolFailure> {
state
.deploy_guard()
.authorize(caller)
.map_err(|error| tool_failure(&error.to_wire_error()))
}
fn workspace(state: &ServerState) -> Result<std::path::PathBuf, ToolFailure> {
awl::workspace::workspace_root(state).map_err(|error| document_failure(&error))
}
fn document_failure(error: &DocumentError) -> ToolFailure {
let wire = error.to_wire_error();
let message = match error {
DocumentError::NotFound(_) => format!(
"{} — document paths come from list_documents or a previous save; \
use list_documents to see what exists, do not guess",
wire.message
),
DocumentError::WorkspaceUnconfigured => format!(
"{} — the server was started without authoring.workspace_dir, so no \
authoring tool can work until the operator configures it",
wire.message
),
_ => wire.message.clone(),
};
ToolFailure::new(
message,
json!({
"code": wire_code_label(wire.code),
"message": wire.message,
"error_type": wire.error_type,
}),
)
}
fn run_loop_failure(error: &RunLoopError) -> ToolFailure {
match error {
RunLoopError::Authoring(
AuthoringApiError::Wire(wire) | AuthoringApiError::Unavailable(wire),
) => tool_failure(wire),
RunLoopError::Authoring(AuthoringApiError::TypeError(diagnostics)) => tool_failure(
&WireError::invalid_input(diagnostics.clone()).with_error_type("TypeError"),
),
RunLoopError::Document(document) => document_failure(document),
RunLoopError::RevisionMismatch { .. } => {
let mut failure = tool_failure(&awl::run_loop::wire_error(error));
failure.message = format!(
"{} — the saved document changed since you last held its hash. Re-read the \
document (or re-save your source) and deploy with the content_hash you are \
handed back; never construct one",
failure.message
);
failure
}
other => tool_failure(&awl::run_loop::wire_error(other)),
}
}
#[cfg(test)]
mod tests {
use aion_proto::WireError;
use super::{DocumentError, document_failure, run_loop_failure};
use crate::authoring::AuthoringApiError;
use crate::awl::run_loop::RunLoopError;
#[test]
fn a_missing_document_gets_document_guidance_not_run_guidance() {
let failure = document_failure(&DocumentError::NotFound("etl.awl".to_owned()));
assert_eq!(failure.detail["code"], "not_found");
assert_eq!(failure.detail["error_type"], "DocumentNotFound");
assert!(
failure.message.contains("list_documents"),
"{}",
failure.message
);
assert!(
!failure.message.contains("list_runs"),
"run guidance on a document refusal points the model at the wrong tool: {}",
failure.message
);
}
#[test]
fn an_unconfigured_workspace_names_the_operator_knob() {
let failure = document_failure(&DocumentError::WorkspaceUnconfigured);
assert_eq!(failure.detail["code"], "backend");
assert!(
failure.message.contains("authoring.workspace_dir"),
"{}",
failure.message
);
}
#[test]
fn a_deploy_denial_keeps_its_wire_code_through_the_run_loop_wrapper() {
let denied = RunLoopError::Authoring(AuthoringApiError::Wire(WireError::deploy_denied(
"subject `assistant` is not authorized to deploy; \
set x-aion-deploy: true for subject `assistant`",
)));
let failure = run_loop_failure(&denied);
assert_eq!(failure.detail["code"], "deploy_denied");
assert!(
failure.message.contains("deploy grant"),
"{}",
failure.message
);
}
#[test]
fn a_deploy_of_a_missing_document_is_a_document_refusal() {
let failure = run_loop_failure(&RunLoopError::Document(DocumentError::NotFound(
"ghost.awl".to_owned(),
)));
assert_eq!(failure.detail["code"], "not_found");
assert_eq!(failure.detail["error_type"], "DocumentNotFound");
assert!(
failure.message.contains("list_documents"),
"{}",
failure.message
);
}
#[test]
fn a_revision_mismatch_tells_the_model_how_to_recover() {
let mismatch = RunLoopError::RevisionMismatch {
requested: "a".repeat(64),
saved: "b".repeat(64),
};
let failure = run_loop_failure(&mismatch);
assert_eq!(failure.detail["code"], "invalid_input");
assert_eq!(failure.detail["error_type"], "RevisionMismatch");
assert!(failure.message.contains("Re-read"), "{}", failure.message);
}
}