Make native MCP self-describing

This commit is contained in:
Hakan Seven committed 2026-09-05 18:22:05 +03:00
1 parent 200ed541f5
commit 0e4f755e6f
5 files changed
+407 -17

No files matched your search

+20 -1
View File
@@ -31,9 +31,28 @@ The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_rea
}
```
To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance.
A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token:
```json
{
"session_id": "SESSION_FROM_OCS_SESSIONS",
"request": {
"op": "run",
"request_id": "polyline-1",
"document_id": 1,
"revision": 12,
"cmd": "PLINE 0,0 10,0 10,10 C"
}
}
```
For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step.
Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel.
The source-tree protocol smoke test uses only the Python standard library:
The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface:
```sh
cargo build
+28 -2
View File
@@ -44,12 +44,21 @@ def legacy(server: Path) -> None:
"params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}},
})
assert initialized["result"]["serverInfo"]["name"] == "OpenCADStudio"
assert initialized["result"]["instructions"]
assert "state.command.accepts" in initialized["result"]["instructions"]
process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
process.stdin.flush()
tools = request(process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
names = {tool["name"] for tool in tools["result"]["tools"]}
definitions = {tool["name"]: tool for tool in tools["result"]["tools"]}
names = set(definitions)
assert names == TOOLS, names
execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"]
assert len(execute_request["oneOf"]) == 14
assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10"
assert execute_request["properties"]["kind"]["enum"] == [
"text", "token", "point", "entity", "structure", "selection", "enter"
]
for name in {"ocs_sessions", "ocs_read", "ocs_execute"}:
assert definitions[name]["outputSchema"]["type"] == "object"
assert "resultType" not in tools["result"]
sessions = request(process, {
"jsonrpc": "2.0",
@@ -89,6 +98,23 @@ def modern(server: Path) -> None:
assert tools["result"]["ttlMs"] >= 0
assert tools["result"]["cacheScope"] in {"public", "private"}
missing_cmd = request(process, {
"jsonrpc": "2.0",
"id": "missing-cmd",
"method": "tools/call",
"params": {
"name": "ocs_execute",
"arguments": {
"session_id": "missing",
"request": {"op": "run", "request_id": "run-1"},
},
"_meta": meta,
},
})
error = missing_cmd["result"]["structuredContent"]
assert error["code"] == "invalid_arguments"
assert "LINE 0,0 10,10" in error["error"]
invalid_mutation = request(process, {
"jsonrpc": "2.0",
"id": "invalid-mutation",
+141 -8
View File
@@ -1,6 +1,6 @@
//! Semantic control shared by the GUI, headless client and web adapter.
use super::{Message, OpenCADStudio};
use crate::command::StepInput;
use crate::command::{InputKind, StepInput};
use iced::Task;
use serde_json::{json, Value};
use std::{collections::VecDeque, sync::OnceLock};
@@ -166,6 +166,78 @@ fn point(req: &Value) -> Result<glam::DVec3, Value> {
Ok(glam::DVec3::from_array(p))
}
fn command_examples(name: &str) -> &'static [&'static str] {
match name {
"LINE" => &["LINE 0,0 10,10"],
"CIRCLE" => &["CIRCLE 5,5 3"],
"PLINE" => &["PLINE 0,0 10,0 10,10 C"],
"ZOOM" => &["ZOOM EXTENTS"],
_ => &[],
}
}
fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value {
let options = command.options();
let mut accepts = Vec::new();
if command.is_selection_gathering() {
accepts.push("selection");
}
if command.needs_structure_point_pick() {
accepts.push("structure");
}
if command.needs_entity_pick() {
accepts.push("entity");
}
if accepts.is_empty() && !command.needs_tangent_pick() {
accepts.push(match command.input_kind() {
InputKind::Point => "point",
InputKind::SingleToken => "token",
InputKind::FreeText => "text",
});
}
if options.iter().any(|option| !option.keyword.is_empty()) && !accepts.contains(&"token") {
accepts.push("token");
}
accepts.push("enter");
let input_example = if accepts.contains(&"point") {
json!({"op":"input","kind":"point","point":[0,0,0],"space":"wcs"})
} else if accepts.contains(&"entity") {
json!({"op":"input","kind":"entity","handle":"HANDLE","point":[0,0,0]})
} else if accepts.contains(&"structure") {
json!({"op":"input","kind":"structure","handle":"HANDLE","point":[0,0,0]})
} else if accepts.contains(&"selection") {
json!({"op":"input","kind":"selection"})
} else if accepts.contains(&"text") {
json!({"op":"input","kind":"text","text":"value"})
} else if accepts.contains(&"token") {
let token = options
.iter()
.find(|option| !option.keyword.is_empty())
.map_or("VALUE", |option| option.keyword.as_str());
json!({"op":"input","kind":"token","text":token})
} else {
json!({"op":"input","kind":"enter"})
};
json!({
"name":command.name(),
"prompt":command.prompt(),
"accepts":accepts,
"input_example":input_example,
"options":options.iter().map(|option| json!({
"label":option.label,
"keyword":option.keyword,
"kind":if option.keyword.is_empty(){"enter"}else{"token"}
})).collect::<Vec<_>>(),
"entity_pick":command.needs_entity_pick(),
"structure_pick":command.needs_structure_point_pick(),
"selection":command.is_selection_gathering(),
"tangent_pick":command.needs_tangent_pick(),
"free_text":command.is_free_text_step()
})
}
impl OpenCADStudio {
pub(super) fn control_busy(&self) -> bool {
self.control.pending.is_some()
@@ -173,10 +245,7 @@ impl OpenCADStudio {
pub(super) fn control_state(&self) -> Value {
let tab = &self.tabs[self.active_tab];
let command = tab.active_cmd.as_ref().map(|c| json!({
"name":c.name(),"prompt":c.prompt(),"options":c.options().iter().map(|o|json!({"label":o.label,"keyword":o.keyword})).collect::<Vec<_>>(),
"entity_pick":c.needs_entity_pick(),"structure_pick":c.needs_structure_point_pick(),"selection":c.is_selection_gathering(),"tangent_pick":c.needs_tangent_pick(),"free_text":c.is_free_text_step()
}));
let command = tab.active_cmd.as_deref().map(active_command_metadata);
json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"),
"mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled,
"document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation,
@@ -272,8 +341,45 @@ impl OpenCADStudio {
names.extend(self.command_line.dynamic_commands.iter().cloned());
names.sort_unstable();
names.dedup();
if let Some(requested) = req["name"].as_str() {
let Some(name) = names
.iter()
.find(|name| name.eq_ignore_ascii_case(requested))
else {
return (
failure(
"unknown_command",
format!("Unknown command {requested}; call commands without name to list commands"),
),
Task::none(),
);
};
return (
json!({
"ok":true,
"command":{
"name":name,
"batch":{
"op":"run",
"syntax":"Command name followed by prompt answers separated by spaces. Points use x,y or x,y,z; options use their token.",
"examples":command_examples(name)
},
"interactive":{
"op":"start",
"request":{"op":"start","cmd":name},
"next":"Follow state.command.accepts, options and input_example after every step."
}
}
}),
Task::none(),
);
}
return (
json!({"ok":true,"commands":names,"actions":actions::NAMES}),
json!({
"ok":true,"commands":names,"actions":actions::NAMES,
"detail_parameters":{"name":"LINE"},
"guidance":"Request one command by name for batch examples and interactive input guidance."
}),
Task::none(),
);
}
@@ -787,6 +893,9 @@ impl OpenCADStudio {
}
}
mod actions;
pub(super) fn action_names() -> &'static [&'static str] {
actions::NAMES
}
#[cfg(test)]
mod tests {
@@ -816,9 +925,15 @@ mod tests {
request(&mut app, json!({"op":"new"}))["status"],
"completed"
);
let started = request(&mut app, json!({"op":"start","cmd":"LINE"}));
assert_eq!(started["status"], "waiting_input");
assert!(started["state"]["command"]["accepts"]
.as_array()
.unwrap()
.contains(&json!("point")));
assert_eq!(
request(&mut app, json!({"op":"start","cmd":"LINE"}))["status"],
"waiting_input"
started["state"]["command"]["input_example"]["kind"],
"point"
);
assert_eq!(
request(
@@ -864,6 +979,24 @@ mod tests {
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0);
}
#[test]
fn command_discovery_explains_batch_and_interactive_use() {
let mut app = OpenCADStudio::new_for_test();
let detail = app
.control_request(json!({"op":"commands","name":"pline"}))
.0;
assert_eq!(detail["command"]["name"], "PLINE");
assert_eq!(
detail["command"]["batch"]["examples"][0],
"PLINE 0,0 10,0 10,10 C"
);
assert_eq!(detail["command"]["interactive"]["op"], "start");
assert_eq!(
app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"}))
.0["code"],
"unknown_command"
);
}
#[test]
fn control_retry_is_idempotent_and_stale_state_rejected() {
let mut app = OpenCADStudio::new_for_test();
request(&mut app, json!({"op":"new"}));
+3
View File
@@ -1,6 +1,9 @@
mod alias;
mod automation;
mod control;
pub(crate) fn automation_action_names() -> &'static [&'static str] {
control::action_names()
}
pub(crate) mod config;
#[cfg(not(target_arch = "wasm32"))]
pub use automation::{export_headless, serve};
+215 -6
View File
@@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;
const INSTRUCTIONS: &str = "Use ocs_sessions first; it opens OpenCADStudio when needed. Read state and commands before editing. Preserve session_id, document_id, revision, selection and request_id. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path.";
const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for command help. For interactive work, call start and follow state.command.accepts, options and input_example. For batch work, run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path.";
const READ_OPS: &[&str] = &[
"state",
"hello",
@@ -306,6 +306,87 @@ fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, Strin
.ok_or_else(|| format!("Missing {key}"))
}
fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> {
let missing = |field: &str, example: &str| {
Err(format!(
"Missing {field} for {op}. Example request: {example}"
))
};
match op {
"open" if request["path"].as_str().is_none_or(str::is_empty) => {
missing("path", r#"{"op":"open","path":"/path/drawing.dxf"}"#)
}
"activate" if request["document_id"].as_u64().is_none() => {
missing("document_id", r#"{"op":"activate","document_id":2}"#)
}
"run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => missing(
"cmd",
r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#,
),
"input" => match request["kind"].as_str() {
Some("text") => Ok(()),
Some("token") if request["text"].as_str().is_some_and(|value| !value.is_empty()) => {
Ok(())
}
Some("token") => missing(
"text",
r#"{"op":"input","kind":"token","text":"C"}"#,
),
Some("point")
if request["point"]
.as_array()
.is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) =>
{
Ok(())
}
Some("point") => missing(
"point",
r#"{"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}"#,
),
Some("entity" | "structure")
if request["handle"].as_str().is_some_and(|value| !value.is_empty())
&& request["point"]
.as_array()
.is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) =>
{
Ok(())
}
Some("entity" | "structure") => missing(
"handle and point",
r#"{"op":"input","kind":"entity","handle":"2A","point":[0,0,0]}"#,
),
Some("selection" | "enter") => Ok(()),
Some(kind) => Err(format!(
"Unknown input kind {kind}. Use text, token, point, entity, structure, selection or enter"
)),
None => missing(
"kind",
r#"{"op":"input","kind":"point","point":[0,0,0]}"#,
),
},
"property"
if request["field"].as_str().is_none_or(str::is_empty)
|| request.get("value").is_none() =>
{
missing(
"field or value",
r#"{"op":"property","field":"color","value":1}"#,
)
}
"action" => match request["name"].as_str() {
Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()),
Some(name) => Err(format!(
"Unknown action {name}. Call ocs_read commands to list actions"
)),
None => missing(
"name",
r#"{"op":"action","name":"zoom_extents"}"#,
),
},
_ => Ok(()),
}
}
fn call_tool(
name: &str,
arguments: &Value,
@@ -344,6 +425,7 @@ fn call_tool(
if request_id.len() > 128 {
return Err("request_id must not exceed 128 bytes".into());
}
validate_execute_request(&request, op)?;
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
client(clients, session_id)?.request(request, wait)
}
@@ -365,24 +447,117 @@ fn call_tool(
}
}
fn execute_request_schema() -> Value {
let handle = json!({
"type":"string",
"pattern":"^(0[xX])?[0-9A-Fa-f]+$",
"description":"Entity handle returned by ocs_read entities or state selection."
});
let point = json!({
"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3,
"description":"Finite [x,y,z] coordinates."
});
json!({
"type":"object",
"properties":{
"op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."},
"request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."},
"document_id":{"type":"integer","minimum":0,"description":"Target document from current state."},
"revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."},
"geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."},
"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},
"selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."},
"cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]},
"path":{"type":"string","minLength":1,"description":"Absolute drawing path for open or save."},
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."},
"text":{"type":"string","description":"Free text or one option/value token."},
"point":point,
"space":{"type":"string","enum":["wcs","ucs","relative"],"default":"wcs","description":"Coordinate space for point input."},
"handle":handle.clone(),
"handles":{"type":"array","items":handle,"description":"Entity handles to select."},
"type":{"type":"string","description":"Entity type filter for select."},
"layer":{"type":"string","description":"Layer filter for select."},
"clear":{"type":"boolean","description":"Clear the current selection before applying select filters."},
"field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."},
"value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]},
"name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."}
},
"required":["op","request_id"],
"additionalProperties":false,
"oneOf":[
{"properties":{"op":{"const":"new"}}},
{"properties":{"op":{"const":"open"}},"required":["path"]},
{"properties":{"op":{"const":"activate"}},"required":["document_id"]},
{"properties":{"op":{"const":"run"}},"required":["cmd"]},
{"properties":{"op":{"const":"start"}},"required":["cmd"]},
{"properties":{"op":{"const":"input"}},"required":["kind"],"oneOf":[
{"properties":{"kind":{"const":"text"}}},
{"properties":{"kind":{"const":"token"}},"required":["text"]},
{"properties":{"kind":{"const":"point"}},"required":["point"]},
{"properties":{"kind":{"const":"entity"}},"required":["handle","point"]},
{"properties":{"kind":{"const":"structure"}},"required":["handle","point"]},
{"properties":{"kind":{"const":"selection"}}},
{"properties":{"kind":{"const":"enter"}}}
]},
{"properties":{"op":{"const":"cancel"}}},
{"properties":{"op":{"const":"undo"}}},
{"properties":{"op":{"const":"redo"}}},
{"properties":{"op":{"const":"select"}}},
{"properties":{"op":{"const":"property"}},"required":["field","value"]},
{"properties":{"op":{"const":"action"}},"required":["name"]},
{"properties":{"op":{"const":"save"}}},
{"properties":{"op":{"const":"stop"}}}
]
})
}
fn read_output_schema() -> Value {
json!({
"type":"object",
"properties":{
"ok":{"type":"boolean"},"status":{"type":"string"},"code":{"type":"string"},
"error":{"anyOf":[{"type":"string"},{"type":"null"}]},"document_id":{"type":"integer"},
"revision":{"type":"integer"},"geometry_revision":{"type":"integer"},
"camera_revision":{"type":"integer"}
},
"required":["ok"],"additionalProperties":true
})
}
fn execute_output_schema() -> Value {
json!({
"type":"object",
"properties":{
"ok":{"type":"boolean"},
"status":{"type":"string","enum":["accepted","running","waiting_input","completed","cancelled","failed"]},
"request_id":{"type":"string"},"code":{"type":"string"},"error":{"anyOf":[{"type":"string"},{"type":"null"}]},
"result":{"type":"object"},"changes":{"anyOf":[{"type":"array"},{"type":"null"}]},"state":{"type":"object"}
},
"required":["ok"],"additionalProperties":true
})
}
fn tool_definitions() -> Value {
json!([
{
"name":"ocs_sessions",
"description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.",
"inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true,"description":"Launch OpenCADStudio when no live session exists."}},"additionalProperties":false},
"outputSchema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"ok":{"const":true},"session_id":{"type":"string"},"document_id":{"type":"integer"},"revision":{"type":"integer"},"selection":{"type":"array","items":{"type":"string"}},"documents":{"type":"array"}},"required":["ok","session_id","document_id","revision","selection","documents"],"additionalProperties":true}}},"required":["result"],"additionalProperties":false},
"annotations":{"title":"List OCS sessions","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
{
"name":"ocs_read",
"description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.",
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters such as document_id, handles, after, or request_id."}},"required":["session_id"],"additionalProperties":false},
"description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session. For command help use op commands with parameters.name.",
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"}},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1}},"additionalProperties":true}},"required":["session_id"],"additionalProperties":false},
"outputSchema":read_output_schema(),
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
{
"name":"ocs_execute",
"description":"Execute one semantic OCS action. Use state document_id and revision. request_id is the idempotency key. Accepted, running and waiting_input are not completion.",
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":{"type":"object","properties":{"op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."},"request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."},"document_id":{"type":"integer","minimum":0,"description":"Target document from current state."},"revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."},"geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."},"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},"selection":{"type":"array","items":{"type":"string"},"description":"Expected selected handles from current state."}},"required":["op","request_id"]},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false},
"description":"Execute one semantic OCS action. Use current state fields and a unique request_id. Use run for a complete command line or start plus input for guided steps. accepted, running and waiting_input are not completion.",
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Time to wait for asynchronous completion before returning."}},"required":["session_id","request"],"additionalProperties":false},
"outputSchema":execute_output_schema(),
"annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false}
},
{
@@ -414,7 +589,7 @@ fn error_result(message: impl ToString) -> Value {
let message = message.to_string();
json!({
"content":[{"type":"text","text":message.clone()}],
"structuredContent":{"ok":false,"error":message},
"structuredContent":{"ok":false,"status":"failed","code":"invalid_arguments","error":message,"retryable":false},
"isError":true
})
}
@@ -589,6 +764,20 @@ mod tests {
tools[2]["inputSchema"]["properties"]["request"]["required"],
json!(["op", "request_id"])
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["oneOf"]
.as_array()
.unwrap()
.len(),
EXECUTE_OPS.len()
);
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0],
"LINE 0,0 10,10"
);
assert!(tools[0].get("outputSchema").is_some());
assert!(tools[1].get("outputSchema").is_some());
assert!(tools[2].get("outputSchema").is_some());
}
#[test]
@@ -684,6 +873,26 @@ mod tests {
.contains("request_id"));
}
#[test]
fn execute_errors_explain_missing_operation_fields() {
let mut clients = HashMap::new();
let called = handle_message(
json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}),
&mut clients,
)
.unwrap();
assert_eq!(called["result"]["isError"], true);
assert_eq!(
called["result"]["structuredContent"]["code"],
"invalid_arguments"
);
let error = called["result"]["structuredContent"]["error"]
.as_str()
.unwrap();
assert!(error.contains("Missing cmd"), "{error}");
assert!(error.contains("LINE 0,0 10,10"), "{error}");
}
#[test]
fn gui_failures_are_mcp_errors() {
let result = tool_result(json!({