use aion_mcp::tools::descriptor::{
Destructiveness, Idempotence, Tool, ToolAnnotations, WorldScope,
};
use aion_mcp::tools::service::{CatalogError, ToolCatalog};
use serde_json::json;
pub const ASSISTANT_CONTEXT_TOOL: &str = "assistant_context";
pub const ASSISTANT_DOCUMENT_EDIT_TOOL: &str = "assistant_document_edit";
pub const ASSISTANT_DOCUMENT_CHECK_TOOL: &str = "assistant_document_check";
pub(crate) const SESSION_TOOL_NAMES: [&str; 3] = [
ASSISTANT_CONTEXT_TOOL,
ASSISTANT_DOCUMENT_EDIT_TOOL,
ASSISTANT_DOCUMENT_CHECK_TOOL,
];
pub(crate) fn assistant_tool_catalog() -> Result<ToolCatalog, CatalogError> {
ToolCatalog::new(vec![
assistant_context(),
assistant_document_edit(),
assistant_document_check(),
])
}
fn assistant_context() -> Tool {
Tool {
name: ASSISTANT_CONTEXT_TOOL.to_owned(),
title: Some("Read what the operator is looking at".to_owned()),
description: Some(
"Read what the operator has on screen in the Aion console right now: the page they \
are on, the concepts that page explains, and the document their editor is showing \
with any selection inside it and where their caret is. Call this BEFORE asking the operator where anything \
is — the document's path comes back with it, so you never need to ask which \
directory a repository is in or which file is open. It answers for THIS \
conversation only and takes no arguments: there is no session to name, because the \
credential you called with already names one."
.to_owned(),
),
input_schema: json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false,
}),
output_schema: Some(assistant_context_output_schema()),
annotations: ToolAnnotations::read_only(WorldScope::Closed),
}
}
fn assistant_context_output_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": {
"shared": {
"type": "boolean",
"description":
"False when the operator has shared nothing yet, in which case every \
other field is absent. Not an error and not an empty screen: it means \
nobody has told this server what is on it.",
},
"url": {
"type": ["string", "null"],
"description": "The console route the operator is on, or null.",
},
"concepts": {
"type": "array",
"items": { "type": "string" },
"description":
"The titles of the explain concepts that screen declares — what the \
surface itself says it is about.",
},
"document": {
"type": ["object", "null"],
"description":
"The document the operator's editor is showing, or null when they are \
not in an editor.",
"properties": {
"path": {
"type": "string",
"description":
"The document's path, as the console holds it. This is the \
answer to `which file` — do not ask.",
},
"text": {
"type": "string",
"description":
"The document's current text, INCLUDING unsaved edits. It may \
differ from what is on disk; this is what the operator is \
looking at.",
},
"selection": {
"type": ["object", "null"],
"description":
"The selected range, or null when nothing is selected and the \
whole document is offered. Lines and columns are ONE-based \
here, matching what the operator reads off the gutter.",
"properties": {
"from_line": { "type": "integer", "minimum": 1 },
"from_column": { "type": "integer", "minimum": 1 },
"to_line": { "type": "integer", "minimum": 1 },
"to_column": { "type": "integer", "minimum": 1 },
},
"required": ["from_line", "from_column", "to_line", "to_column"],
"additionalProperties": false,
},
"cursor": {
"type": ["object", "null"],
"description":
"Where the operator's caret is, ONE-based like the selection, or \
null when the document did not come from a live editor. With no \
selection this is the line the operator means by `here`.",
"properties": {
"line": { "type": "integer", "minimum": 1 },
"column": { "type": "integer", "minimum": 1 },
},
"required": ["line", "column"],
"additionalProperties": false,
},
},
"required": ["path", "text"],
"additionalProperties": false,
},
"revision": {
"type": "integer",
"description":
"The shared document's revision: how many edit batches have been \
recorded for this conversation. Compare it with the revision your \
last `assistant_document_edit` returned — a larger jump than your own \
edits explain means the document moved under you, so re-read before \
quoting bytes from an older read.",
},
},
"required": ["shared"],
"additionalProperties": false,
})
}
fn assistant_document_edit() -> Tool {
Tool {
name: ASSISTANT_DOCUMENT_EDIT_TOOL.to_owned(),
title: Some("Edit the operator's document".to_owned()),
description: Some(
"Edit the document the operator is editing, in place. Submit one or more edits, each \
quoting the EXACT bytes to replace (`old_string`, which must occur exactly once in \
the document as it now stands) and what replaces them (`new_string`). The batch is \
atomic: if any edit does not apply, none of it does, and the failure says which edit \
and why. Applied edits appear in the operator's editor immediately — they watch each \
change land and choose to keep or revert it, so make SMALL, named changes one \
concept at a time rather than rewriting the whole document. Read the document with \
`assistant_context` first and quote from what it returned; after editing, run \
`assistant_document_check` and fix what it reports before telling the operator what \
you changed. This edits the operator's BUFFER — never read or write files to reach \
this document, and never save it: saving stays the operator's act. Edit only when \
the operator asked for a change: a question gets an answer in a sentence or two and \
no edit. When you have changed something, say what in a sentence or two — never \
paste the document back into your reply; the operator is looking at it."
.to_owned(),
),
input_schema: json!({
"type": "object",
"properties": {
"edits": {
"type": "array",
"minItems": 1,
"description": "The operations, applied in order, each against the text the \
previous one produced.",
"items": {
"type": "object",
"properties": {
"old_string": {
"type": "string",
"description": "The exact bytes to replace. Must occur exactly \
once; quote more surrounding text to make an \
ambiguous match unique.",
},
"new_string": {
"type": "string",
"description": "What replaces them.",
},
},
"required": ["old_string", "new_string"],
"additionalProperties": false,
},
},
},
"required": ["edits"],
"additionalProperties": false,
}),
output_schema: Some(json!({
"type": "object",
"properties": {
"applied": {
"type": "integer",
"description": "How many edits the batch carried — all of them applied.",
},
"revision": {
"type": "integer",
"description": "The shared document's revision after this batch.",
},
"path": {
"type": "string",
"description": "The document that was edited, by the path the console shared.",
},
},
"required": ["applied", "revision", "path"],
"additionalProperties": false,
})),
annotations: ToolAnnotations::mutating(
Destructiveness::Destructive,
Idempotence::Repeating,
WorldScope::Closed,
),
}
}
fn assistant_document_check() -> Tool {
Tool {
name: ASSISTANT_DOCUMENT_CHECK_TOOL.to_owned(),
title: Some("Check the operator's document".to_owned()),
description: Some(
"Run the AWL checker over the document the operator is editing, as it now stands — \
your own edits included. This is the SAME check the operator's editor runs, with \
the same workspace — verdicts differ only when the operator's own typing has \
diverged their buffer from the shared document, and their bytes win. It takes no \
arguments: \
the document is the one your credential's conversation shares. Check after every \
edit and fix what it reports with `assistant_document_edit` BEFORE telling the \
operator what you changed — an edit you have not checked is work you have not \
finished."
.to_owned(),
),
input_schema: json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false,
}),
output_schema: Some(json!({
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"description": "True when the document would deploy: no diagnostics.",
},
"diagnostics": {
"type": "array",
"description": "Every diagnostic, in document order. Lines and columns are \
the numbers the operator reads off their gutter.",
"items": {
"type": "object",
"properties": {
"line": { "type": "integer" },
"column": { "type": "integer" },
"message": { "type": "string" },
},
"required": ["line", "column", "message"],
"additionalProperties": false,
},
},
},
"required": ["ok", "diagnostics"],
"additionalProperties": false,
})),
annotations: ToolAnnotations::read_only(WorldScope::Closed),
}
}
#[cfg(test)]
mod tests {
use aion_mcp::tools::descriptor::Modification;
use super::{
ASSISTANT_CONTEXT_TOOL, ASSISTANT_DOCUMENT_CHECK_TOOL, ASSISTANT_DOCUMENT_EDIT_TOOL,
SESSION_TOOL_NAMES, assistant_tool_catalog,
};
use crate::mcp::catalog::aion_tool_catalog;
#[test]
fn the_assistant_catalogue_publishes_exactly_the_editing_loop()
-> Result<(), Box<dyn std::error::Error>> {
let catalog = assistant_tool_catalog()?;
let names: Vec<&str> = catalog
.tools()
.iter()
.map(|tool| tool.name.as_str())
.collect();
assert_eq!(names, SESSION_TOOL_NAMES);
Ok(())
}
#[test]
fn the_general_catalogue_does_not_publish_the_session_tools()
-> Result<(), Box<dyn std::error::Error>> {
let general = aion_tool_catalog()?;
for name in SESSION_TOOL_NAMES {
assert!(
general.find(name).is_none(),
"`{name}` must not be in the general catalogue: it is addressed by session, and \
the general surface is addressed by namespace"
);
}
assert!(
general.find("describe_run").is_some(),
"the general catalogue is empty, so the assertion above measures nothing"
);
Ok(())
}
#[test]
fn the_context_tool_is_read_only_and_names_no_session() -> Result<(), Box<dyn std::error::Error>>
{
let catalog = assistant_tool_catalog()?;
let tool = catalog
.find(ASSISTANT_CONTEXT_TOOL)
.ok_or("the catalogue must publish the context tool")?;
assert_eq!(tool.annotations.modification, Modification::ReadOnly);
assert_eq!(
tool.input_schema["properties"],
serde_json::json!({}),
"an argument here would be a way to ask about another session"
);
assert_eq!(tool.input_schema["additionalProperties"], false);
assert!(tool.output_schema.is_some());
assert!(tool.description.is_some());
Ok(())
}
#[test]
fn the_edit_tool_is_mutating_and_admits_only_edit_pairs()
-> Result<(), Box<dyn std::error::Error>> {
let catalog = assistant_tool_catalog()?;
let tool = catalog
.find(ASSISTANT_DOCUMENT_EDIT_TOOL)
.ok_or("the catalogue must publish the edit tool")?;
assert_eq!(tool.annotations.modification, Modification::Mutating);
assert_eq!(tool.input_schema["required"], serde_json::json!(["edits"]));
assert_eq!(tool.input_schema["additionalProperties"], false);
let items = &tool.input_schema["properties"]["edits"]["items"];
assert_eq!(
items["required"],
serde_json::json!(["old_string", "new_string"])
);
assert_eq!(items["additionalProperties"], false);
assert!(tool.output_schema.is_some());
Ok(())
}
#[test]
fn the_check_tool_is_read_only_and_names_no_document() -> Result<(), Box<dyn std::error::Error>>
{
let catalog = assistant_tool_catalog()?;
let tool = catalog
.find(ASSISTANT_DOCUMENT_CHECK_TOOL)
.ok_or("the catalogue must publish the check tool")?;
assert_eq!(tool.annotations.modification, Modification::ReadOnly);
assert_eq!(tool.input_schema["properties"], serde_json::json!({}));
assert_eq!(tool.input_schema["additionalProperties"], false);
assert!(tool.output_schema.is_some());
assert!(tool.description.is_some());
Ok(())
}
}