forked from 1b-agent/opencadstudio-upstream
Harden native MCP compatibility
This commit is contained in:
1 parent
cbb4015760
commit
28d3aa5be2
3 files changed
+216
-25
No files matched your search
@@ -17,7 +17,21 @@ The server provides four tools:
|
|||||||
- `ocs_execute` performs semantic commands and input against the real editor.
|
- `ocs_execute` performs semantic commands and input against the real editor.
|
||||||
- `ocs_capture` returns a PNG of the current window for visual verification.
|
- `ocs_capture` returns a PNG of the current window for visual verification.
|
||||||
|
|
||||||
Mutations carry a session ID, document ID, expected revision, selection, and idempotent request ID. 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 normal flow is to call `ocs_sessions`, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "SESSION_FROM_OCS_SESSIONS",
|
||||||
|
"request": {
|
||||||
|
"op": "undo",
|
||||||
|
"request_id": "undo-1",
|
||||||
|
"document_id": 1,
|
||||||
|
"revision": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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 uses only the Python standard library:
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ from pathlib import Path
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve()
|
TOOLS = {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}
|
||||||
|
MODERN = "2026-07-28"
|
||||||
|
|
||||||
|
|
||||||
|
def start(server: Path) -> subprocess.Popen[str]:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
[str(server), "--mcp"],
|
[str(server), "--mcp"],
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
@@ -14,13 +18,26 @@ def main() -> None:
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
assert process.stdin and process.stdout
|
assert process.stdin and process.stdout
|
||||||
|
return process
|
||||||
|
|
||||||
def request(payload: dict) -> dict:
|
|
||||||
process.stdin.write(json.dumps(payload) + "\n")
|
|
||||||
process.stdin.flush()
|
|
||||||
return json.loads(process.stdout.readline())
|
|
||||||
|
|
||||||
initialized = request({
|
def request(process: subprocess.Popen[str], payload: dict) -> dict:
|
||||||
|
assert process.stdin and process.stdout
|
||||||
|
process.stdin.write(json.dumps(payload) + "\n")
|
||||||
|
process.stdin.flush()
|
||||||
|
return json.loads(process.stdout.readline())
|
||||||
|
|
||||||
|
|
||||||
|
def close(process: subprocess.Popen[str]) -> None:
|
||||||
|
assert process.stdin
|
||||||
|
process.stdin.close()
|
||||||
|
assert process.wait(timeout=5) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def legacy(server: Path) -> None:
|
||||||
|
process = start(server)
|
||||||
|
|
||||||
|
initialized = request(process, {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"method": "initialize",
|
"method": "initialize",
|
||||||
@@ -30,18 +47,75 @@ def main() -> None:
|
|||||||
assert initialized["result"]["instructions"]
|
assert initialized["result"]["instructions"]
|
||||||
process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
||||||
process.stdin.flush()
|
process.stdin.flush()
|
||||||
tools = request({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
tools = request(process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
||||||
names = {tool["name"] for tool in tools["result"]["tools"]}
|
names = {tool["name"] for tool in tools["result"]["tools"]}
|
||||||
assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names
|
assert names == TOOLS, names
|
||||||
sessions = request({
|
assert "resultType" not in tools["result"]
|
||||||
|
sessions = request(process, {
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"method": "tools/call",
|
"method": "tools/call",
|
||||||
"params": {"name": "ocs_sessions", "arguments": {"launch_if_none": False}},
|
"params": {"name": "ocs_sessions", "arguments": {"launch_if_none": False}},
|
||||||
})
|
})
|
||||||
assert not sessions["result"].get("isError"), sessions
|
assert not sessions["result"].get("isError"), sessions
|
||||||
process.stdin.close()
|
close(process)
|
||||||
assert process.wait(timeout=5) == 0
|
|
||||||
|
|
||||||
|
def modern(server: Path) -> None:
|
||||||
|
process = start(server)
|
||||||
|
meta = {
|
||||||
|
"io.modelcontextprotocol/protocolVersion": MODERN,
|
||||||
|
"io.modelcontextprotocol/clientInfo": {"name": "smoke", "version": "1"},
|
||||||
|
"io.modelcontextprotocol/clientCapabilities": {},
|
||||||
|
}
|
||||||
|
discovered = request(process, {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "discover",
|
||||||
|
"method": "server/discover",
|
||||||
|
"params": {"_meta": meta},
|
||||||
|
})
|
||||||
|
assert discovered["result"]["resultType"] == "complete"
|
||||||
|
assert discovered["result"]["ttlMs"] >= 0
|
||||||
|
assert discovered["result"]["cacheScope"] in {"public", "private"}
|
||||||
|
|
||||||
|
tools = request(process, {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "tools",
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {"_meta": meta},
|
||||||
|
})
|
||||||
|
assert {tool["name"] for tool in tools["result"]["tools"]} == TOOLS
|
||||||
|
assert tools["result"]["resultType"] == "complete"
|
||||||
|
assert tools["result"]["ttlMs"] >= 0
|
||||||
|
assert tools["result"]["cacheScope"] in {"public", "private"}
|
||||||
|
|
||||||
|
invalid_mutation = request(process, {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "invalid-mutation",
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "ocs_execute",
|
||||||
|
"arguments": {"session_id": "missing", "request": {"op": "undo"}},
|
||||||
|
"_meta": meta,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert invalid_mutation["result"]["isError"] is True
|
||||||
|
assert "request_id" in invalid_mutation["result"]["structuredContent"]["error"]
|
||||||
|
|
||||||
|
rejected = request(process, {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "unsupported",
|
||||||
|
"method": "tools/list",
|
||||||
|
"params": {"_meta": {"io.modelcontextprotocol/protocolVersion": "2099-01-01"}},
|
||||||
|
})
|
||||||
|
assert rejected["error"]["code"] == -32022
|
||||||
|
close(process)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve()
|
||||||
|
legacy(server)
|
||||||
|
modern(server)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
+115
-12
@@ -21,6 +21,7 @@ const PROTOCOL_VERSION: &str = "2025-11-25";
|
|||||||
const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
|
const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
|
||||||
const MAX_REQUEST: usize = 1_048_576;
|
const MAX_REQUEST: usize = 1_048_576;
|
||||||
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
|
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 = "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 READ_OPS: &[&str] = &[
|
const READ_OPS: &[&str] = &[
|
||||||
"state",
|
"state",
|
||||||
@@ -36,6 +37,10 @@ const READ_OPS: &[&str] = &[
|
|||||||
"events",
|
"events",
|
||||||
"operation",
|
"operation",
|
||||||
];
|
];
|
||||||
|
const EXECUTE_OPS: &[&str] = &[
|
||||||
|
"new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select",
|
||||||
|
"property", "action", "save", "stop",
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Clone, Deserialize)]
|
#[derive(Clone, Deserialize)]
|
||||||
struct Descriptor {
|
struct Descriptor {
|
||||||
@@ -331,6 +336,14 @@ fn call_tool(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.map(Value::Object)
|
.map(Value::Object)
|
||||||
.ok_or_else(|| "Missing request object".to_string())?;
|
.ok_or_else(|| "Missing request object".to_string())?;
|
||||||
|
let op = required_string(&request, "op")?;
|
||||||
|
if !EXECUTE_OPS.contains(&op) {
|
||||||
|
return Err(format!("Unknown mutation operation: {op}"));
|
||||||
|
}
|
||||||
|
let request_id = required_string(&request, "request_id")?;
|
||||||
|
if request_id.len() > 128 {
|
||||||
|
return Err("request_id must not exceed 128 bytes".into());
|
||||||
|
}
|
||||||
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
|
let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0);
|
||||||
client(clients, session_id)?.request(request, wait)
|
client(clients, session_id)?.request(request, wait)
|
||||||
}
|
}
|
||||||
@@ -357,25 +370,25 @@ fn tool_definitions() -> Value {
|
|||||||
{
|
{
|
||||||
"name":"ocs_sessions",
|
"name":"ocs_sessions",
|
||||||
"description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.",
|
"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}},"additionalProperties":false},
|
"inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true,"description":"Launch OpenCADStudio when no live session exists."}},"additionalProperties":false},
|
||||||
"annotations":{"title":"List OCS sessions","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
"annotations":{"title":"List OCS sessions","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name":"ocs_read",
|
"name":"ocs_read",
|
||||||
"description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.",
|
"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"},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object"}},"required":["session_id"],"additionalProperties":false},
|
"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},
|
||||||
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name":"ocs_execute",
|
"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.",
|
"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"},"request":{"type":"object"},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false},
|
"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},
|
||||||
"annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false}
|
"annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name":"ocs_capture",
|
"name":"ocs_capture",
|
||||||
"description":"Capture the actual current OpenCADStudio window as PNG for visual verification.",
|
"description":"Capture the actual current OpenCADStudio window as PNG for visual verification.",
|
||||||
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string"}},"required":["session_id"],"additionalProperties":false},
|
"inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."}},"required":["session_id"],"additionalProperties":false},
|
||||||
"annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
"annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
@@ -392,12 +405,18 @@ fn tool_result(value: Value) -> Value {
|
|||||||
};
|
};
|
||||||
json!({
|
json!({
|
||||||
"content":[{"type":"text","text":value.to_string()}],
|
"content":[{"type":"text","text":value.to_string()}],
|
||||||
"structuredContent":structured
|
"structuredContent":structured,
|
||||||
|
"isError":value["ok"].as_bool() == Some(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error_result(message: impl ToString) -> Value {
|
fn error_result(message: impl ToString) -> Value {
|
||||||
json!({"content":[{"type":"text","text":message.to_string()}],"isError":true})
|
let message = message.to_string();
|
||||||
|
json!({
|
||||||
|
"content":[{"type":"text","text":message.clone()}],
|
||||||
|
"structuredContent":{"ok":false,"error":message},
|
||||||
|
"isError":true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn response(id: Value, result: Value) -> Value {
|
fn response(id: Value, result: Value) -> Value {
|
||||||
@@ -413,7 +432,7 @@ fn modern_request(params: &Value) -> bool {
|
|||||||
== Some(MODERN_PROTOCOL_VERSION)
|
== Some(MODERN_PROTOCOL_VERSION)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_result(mut result: Value, modern: bool) -> Value {
|
fn protocol_result(mut result: Value, modern: bool, cacheable: bool) -> Value {
|
||||||
if modern {
|
if modern {
|
||||||
let object = result
|
let object = result
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
@@ -425,6 +444,10 @@ fn protocol_result(mut result: Value, modern: bool) -> Value {
|
|||||||
"_meta".into(),
|
"_meta".into(),
|
||||||
json!({"io.modelcontextprotocol/serverInfo":server_info()}),
|
json!({"io.modelcontextprotocol/serverInfo":server_info()}),
|
||||||
);
|
);
|
||||||
|
if cacheable {
|
||||||
|
object.insert("ttlMs".into(), Value::from(CACHE_TTL_MS));
|
||||||
|
object.insert("cacheScope".into(), Value::String("public".into()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
@@ -433,6 +456,18 @@ fn rpc_error(id: Value, code: i64, message: impl ToString) -> Value {
|
|||||||
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.to_string()}})
|
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message.to_string()}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unsupported_protocol(id: Value, requested: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"jsonrpc":"2.0",
|
||||||
|
"id":id,
|
||||||
|
"error":{
|
||||||
|
"code":-32022,
|
||||||
|
"message":format!("Unsupported protocol version: {requested}"),
|
||||||
|
"data":{"requested":requested,"supported":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION]}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_message(message: Value, clients: &mut HashMap<String, GuiClient>) -> Option<Value> {
|
fn handle_message(message: Value, clients: &mut HashMap<String, GuiClient>) -> Option<Value> {
|
||||||
let id = message.get("id").cloned();
|
let id = message.get("id").cloned();
|
||||||
let method = message.get("method").and_then(Value::as_str)?;
|
let method = message.get("method").and_then(Value::as_str)?;
|
||||||
@@ -441,8 +476,14 @@ fn handle_message(message: Value, clients: &mut HashMap<String, GuiClient>) -> O
|
|||||||
}
|
}
|
||||||
let id = id.unwrap();
|
let id = id.unwrap();
|
||||||
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
|
let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
if let Some(requested) = params["_meta"]["io.modelcontextprotocol/protocolVersion"].as_str() {
|
||||||
|
if requested != MODERN_PROTOCOL_VERSION {
|
||||||
|
return Some(unsupported_protocol(id, requested));
|
||||||
|
}
|
||||||
|
}
|
||||||
let modern = modern_request(¶ms);
|
let modern = modern_request(¶ms);
|
||||||
Some(match method {
|
Some(match method {
|
||||||
|
"initialize" if modern => rpc_error(id, -32601, "Method not found: initialize"),
|
||||||
"initialize" => {
|
"initialize" => {
|
||||||
let requested = params["protocolVersion"]
|
let requested = params["protocolVersion"]
|
||||||
.as_str()
|
.as_str()
|
||||||
@@ -472,12 +513,13 @@ fn handle_message(message: Value, clients: &mut HashMap<String, GuiClient>) -> O
|
|||||||
"instructions":INSTRUCTIONS
|
"instructions":INSTRUCTIONS
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"ping" => response(id, protocol_result(json!({}), modern)),
|
"ping" => response(id, protocol_result(json!({}), modern, false)),
|
||||||
"tools/list" => response(
|
"tools/list" => response(
|
||||||
id,
|
id,
|
||||||
protocol_result(json!({"tools":tool_definitions()}), modern),
|
protocol_result(json!({"tools":tool_definitions()}), modern, true),
|
||||||
),
|
),
|
||||||
"tools/call" => {
|
"tools/call" => {
|
||||||
let Some(name) = params["name"].as_str() else {
|
let Some(name) = params["name"].as_str() else {
|
||||||
@@ -490,7 +532,7 @@ fn handle_message(message: Value, clients: &mut HashMap<String, GuiClient>) -> O
|
|||||||
let result = call_tool(name, &arguments, clients)
|
let result = call_tool(name, &arguments, clients)
|
||||||
.map(tool_result)
|
.map(tool_result)
|
||||||
.unwrap_or_else(error_result);
|
.unwrap_or_else(error_result);
|
||||||
response(id, protocol_result(result, modern))
|
response(id, protocol_result(result, modern, false))
|
||||||
}
|
}
|
||||||
_ => rpc_error(id, -32601, format!("Method not found: {method}")),
|
_ => rpc_error(id, -32601, format!("Method not found: {method}")),
|
||||||
})
|
})
|
||||||
@@ -542,6 +584,11 @@ mod tests {
|
|||||||
names,
|
names,
|
||||||
["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"]
|
["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"]
|
||||||
);
|
);
|
||||||
|
assert_eq!(tools[0]["annotations"]["readOnlyHint"], false);
|
||||||
|
assert_eq!(
|
||||||
|
tools[2]["inputSchema"]["properties"]["request"]["required"],
|
||||||
|
json!(["op", "request_id"])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -564,6 +611,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4);
|
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4);
|
||||||
|
assert!(listed["result"].get("ttlMs").is_none());
|
||||||
|
assert!(listed["result"].get("resultType").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -575,11 +624,22 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(discovered["result"]["resultType"], "complete");
|
assert_eq!(discovered["result"]["resultType"], "complete");
|
||||||
|
assert_eq!(discovered["result"]["ttlMs"], CACHE_TTL_MS);
|
||||||
|
assert_eq!(discovered["result"]["cacheScope"], "public");
|
||||||
assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28");
|
assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
|
discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
|
||||||
"OpenCADStudio"
|
"OpenCADStudio"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let listed = handle_message(
|
||||||
|
json!({"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}),
|
||||||
|
&mut clients,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(listed["result"]["resultType"], "complete");
|
||||||
|
assert_eq!(listed["result"]["ttlMs"], CACHE_TTL_MS);
|
||||||
|
assert_eq!(listed["result"]["cacheScope"], "public");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -592,4 +652,47 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(called["result"]["isError"], true);
|
assert_eq!(called["result"]["isError"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_modern_protocol_versions() {
|
||||||
|
let mut clients = HashMap::new();
|
||||||
|
let rejected = handle_message(
|
||||||
|
json!({"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}}),
|
||||||
|
&mut clients,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rejected["error"]["code"], -32022);
|
||||||
|
assert_eq!(rejected["error"]["data"]["requested"], "2099-01-01");
|
||||||
|
assert_eq!(
|
||||||
|
rejected["error"]["data"]["supported"][0],
|
||||||
|
MODERN_PROTOCOL_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_requires_a_visible_request_id() {
|
||||||
|
let mut clients = HashMap::new();
|
||||||
|
let called = handle_message(
|
||||||
|
json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"undo"}}}}),
|
||||||
|
&mut clients,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(called["result"]["isError"], true);
|
||||||
|
assert!(called["result"]["structuredContent"]["error"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("request_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gui_failures_are_mcp_errors() {
|
||||||
|
let result = tool_result(json!({
|
||||||
|
"ok":false,
|
||||||
|
"status":"failed",
|
||||||
|
"code":"stale_state",
|
||||||
|
"error":"Refresh state before editing"
|
||||||
|
}));
|
||||||
|
assert_eq!(result["isError"], true);
|
||||||
|
assert_eq!(result["structuredContent"]["code"], "stale_state");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user