openpencil/crates/op-cli/src/mcp_http_cli.rs
2026-08-06 21:15:39 +08:00

366 lines
15 KiB
Rust

use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::cli_error::CliError;
use op_rpc_transport::{JsonRpcRequest, TcpJsonRpc, PING_TIMEOUT, POST_TIMEOUT, SHUTDOWN_TIMEOUT};
/// OpenPencil MCP identity marker reported in the `ping` reply's `result`
/// (mirrors `op-host-desktop` `MCP_SERVER_NAME`). Used to confirm a port
/// is really our server before routing tool calls / killing a pid.
const MCP_SERVER_NAME: &str = "openpencil-mcp";
pub(crate) fn status_json(port: u16) -> String {
// Verify it's genuinely an OpenPencil MCP server (identity ping —
// `result.server == "openpencil-mcp"`), not merely any TCP listener on
// the port. Mirrors TS `op status`, which reports running only when the
// OpenPencil endpoint actually responds. A stale/foreign listener now
// correctly reports `running:false`.
let running = ping_result(port).is_some();
// TS `op status` returns {running, port, pid, url, uptime}. When the live
// discovery file is present AND advertises this port, surface pid +
// uptime (seconds since the host wrote `timestamp`). Otherwise fall back
// to the discovery-free shape.
if running {
if let Some((file_port, pid, timestamp)) = crate::app_control_cli::live_status_fields() {
if file_port == port {
let uptime = now_millis().saturating_sub(timestamp) / 1000;
let url = op_editor_core::local_daemon_origin(port);
return format!(
r#"{{"running":true,"port":{port},"pid":{pid},"url":"{url}","uptime":{uptime}}}"#
);
}
}
}
status_json_from_running(port, running)
}
pub(crate) fn status_json_from_running(port: u16, running: bool) -> String {
if running {
let url = op_editor_core::local_daemon_origin(port);
format!(r#"{{"running":true,"port":{port},"url":"{url}"}}"#)
} else {
r#"{"running":false}"#.to_string()
}
}
/// Wall-clock milliseconds since the Unix epoch (for `op status` uptime).
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
/// JSON-RPC body for `tools/list`.
pub(crate) fn tools_list_body() -> String {
serde_json::to_string(&JsonRpcRequest::new(1, "tools/list", Value::Null))
.expect("static JSON-RPC request serializes")
}
/// JSON-RPC body for a `tools/call` of `tool` with the already-built
/// `arguments` object JSON.
pub(crate) fn tool_call_body(tool: &str, args_json: &str) -> String {
format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"{}","arguments":{}}}}}"#,
json_escape(tool),
args_json
)
}
/// Build a JSON object from `key=value` pairs. MCP tool arguments are
/// scalar string-typed, so every value is emitted as a JSON string.
pub(crate) fn args_to_json(pairs: &[(String, String)]) -> String {
let mut out = String::from("{");
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push('"');
out.push_str(&json_escape(k));
out.push_str("\":\"");
out.push_str(&json_escape(v));
out.push('"');
}
out.push('}');
out
}
/// Escape a string for inclusion in a JSON string literal (no surrounding
/// quotes). Delegates to the canonical op-util escaper.
pub(crate) fn json_escape(s: &str) -> String {
op_util::json_escape::escape_json(s)
}
#[cfg(test)]
pub(crate) fn http_request(body: &str) -> String {
TcpJsonRpc::localhost(0, op_rpc_transport::MCP_PATH).http_post_request(body)
}
/// POST `body` to `127.0.0.1:port/mcp` with connect + read/write deadlines;
/// return `(http_status, body)`. `http_status` is 0 when no recognizable
/// status line was returned. The deadlines stop a stale port whose
/// service accepts but never replies from hanging the CLI indefinitely.
fn post_raw(
port: u16,
token: &str,
body: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), CliError> {
// `op_rpc_transport::HttpTransportError` is the transport's own typed
// failure domain; flatten it into the CLI's `Transport` variant, whose
// payload is the exact sentence `op:` prints.
TcpJsonRpc::local_mcp(port)
.with_token(token)
.post_raw(body, timeout)
.map(|reply| (reply.status, reply.body))
.map_err(|e| CliError::Transport(e.to_string()))
}
/// POST `body` to the HTTP MCP server on `127.0.0.1:port` and return the
/// tool result for display. A non-2xx HTTP status (e.g. a 404 from the wrong
/// service, or a 500 from a failed live UI request) is an error. The MCP
/// `tools/call` content envelope is unwrapped to the raw tool result, and an
/// `isError` tool result (or a JSON-RPC transport error) is surfaced as an
/// error — so `op` exits non-zero on a failed tool, like the TS CLI.
pub(crate) fn post(port: u16, token: &str, body: &str) -> Result<String, CliError> {
let (status, reply) = post_raw(port, token, body, POST_TIMEOUT)?;
if !(200..300).contains(&status) {
return Err(CliError::Transport(format!(
"MCP server on 127.0.0.1:{port} returned HTTP {status}: {reply}"
)));
}
unwrap_mcp_reply(&reply)
}
/// Unwrap an MCP JSON-RPC reply for human/agent-facing output, matching the
/// TS CLI (which prints the raw tool result, not the wire envelope):
/// - a `tools/call` content envelope → the inner `text` (the tool's data);
/// an `isError:true` result → `Err(text)`.
/// - a JSON-RPC transport `error` → `Err(message)`.
/// - anything else (e.g. a `tools/list` reply) → the raw reply unchanged.
fn unwrap_mcp_reply(reply: &str) -> Result<String, CliError> {
let Ok(value) = serde_json::from_str::<Value>(reply) else {
return Ok(reply.to_string());
};
if let Some(message) = value
.get("error")
.and_then(|err| err.get("message"))
.and_then(Value::as_str)
{
return Err(CliError::Tool(message.to_string()));
}
let Some(result) = value.get("result") else {
return Ok(reply.to_string());
};
if let Some(content) = result.get("content").and_then(Value::as_array) {
let text = content
.iter()
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
if result.get("isError").and_then(Value::as_bool) == Some(true) {
return Err(CliError::Tool(text));
}
return Ok(text);
}
Ok(reply.to_string())
}
/// Plain HTTP `GET` against `127.0.0.1:port{path}` with the same deadline
/// discipline as [`post_raw`]; returns `(http_status, body)`.
fn http_get_raw(
port: u16,
path: &str,
timeout: std::time::Duration,
) -> Result<(u16, String), CliError> {
TcpJsonRpc::local_mcp(port)
.get_raw(path, timeout)
.map(|reply| (reply.status, reply.body))
.map_err(|e| CliError::Transport(e.to_string()))
}
/// True when `127.0.0.1:port` is the `--serve-web` web-canvas daemon —
/// `GET /api/mcp/server` reports our identity AND `mode:"web-canvas"`. Used
/// by `op start --web` so it never "reuses" a plain `--mcp-http` server
/// (which answers the token ping but serves no browser editor).
pub(crate) fn web_daemon_running(port: u16) -> bool {
match http_get_raw(port, "/api/mcp/server", PING_TIMEOUT) {
Ok((status, body)) => (200..300).contains(&status) && is_web_canvas_health(&body),
Err(_) => false,
}
}
/// Pure predicate over the `/api/mcp/server` health body (testable without
/// a socket): the OpenPencil identity marker plus the web-canvas mode.
fn is_web_canvas_health(body: &str) -> bool {
serde_json::from_str::<Value>(body).is_ok_and(|v| {
v.get("server").and_then(Value::as_str) == Some(MCP_SERVER_NAME)
&& v.get("mode").and_then(Value::as_str) == Some("web-canvas")
})
}
/// Send a JSON-RPC `ping` and return the parsed `result` object when the
/// reply is an OpenPencil MCP server (`result.server == "openpencil-mcp"`).
/// This distinguishes our `/mcp` JSON-RPC server from a stale TCP listener,
/// a third-party JSON-RPC server, or a TS editor (which serves REST at
/// `/api/mcp/*`, not `/mcp`) — so discovery never mis-routes tool calls.
fn ping_result(port: u16) -> Option<Value> {
let body = serde_json::to_string(&JsonRpcRequest::new(0, "ping", Value::Null)).ok()?;
let (status, body) = post_raw(port, "", &body, PING_TIMEOUT).ok()?;
if !(200..300).contains(&status) {
return None;
}
ping_reply_identity(&body)
}
/// Extract the OpenPencil identity object from a raw `ping` reply, or
/// `None` for a non-OpenPencil responder. Current servers nest the
/// identity under `result._meta` (a spec-compliant ping result is empty
/// apart from `_meta` — strict clients reject top-level extras, issue
/// #199); servers from before that fix reported it at the `result` top
/// level, so fall back there to keep discovering a still-running editor.
fn ping_reply_identity(body: &str) -> Option<Value> {
let value = serde_json::from_str::<Value>(body).ok()?;
let result = value.get("result")?;
let identity = result.get("_meta").unwrap_or(result);
if identity.get("server").and_then(Value::as_str) == Some(MCP_SERVER_NAME) {
Some(identity.clone())
} else {
None
}
}
/// True when `127.0.0.1:port` is the *live* OpenPencil editor that
/// published `token` (`result.mode == "live"` and `result.token` matches).
/// Distinguishes the live canvas from a headless server squatting on a
/// reused port, and proves the server owns the discovery file we read.
pub(crate) fn mcp_ping_live(port: u16, token: &str) -> bool {
ping_result(port).is_some_and(|result| {
result.get("mode").and_then(Value::as_str) == Some("live")
&& result.get("token").and_then(Value::as_str) == Some(token)
})
}
/// True when `127.0.0.1:port` is the CLI-spawned *headless* server that
/// reported this exact `token` — proving the pid in our manager file owns
/// this port, so `op stop` / reuse never act on a recycled pid. An empty or
/// mismatched token is treated as UNVERIFIED (returns false): we'd rather
/// re-launch / report "not running" than risk killing an unrelated process.
pub(crate) fn mcp_ping_headless(port: u16, token: &str) -> bool {
if token.is_empty() {
return false;
}
ping_result(port)
.is_some_and(|result| result.get("token").and_then(Value::as_str) == Some(token))
}
/// Ask the server on `127.0.0.1:port` to shut itself down (`op stop`),
/// authenticated by `token`. Returns true when the server acked
/// `shuttingDown`. This avoids signalling a pid entirely — so there's no
/// race where a recycled pid is killed — and lets the live editor quit
/// cleanly (saving state). An empty token never authenticates.
pub(crate) fn request_shutdown(port: u16, token: &str) -> bool {
if token.is_empty() {
return false;
}
let body = serde_json::to_string(&JsonRpcRequest::new(
0,
"openpencil/shutdown",
serde_json::json!({ "token": token }),
))
.expect("shutdown JSON-RPC request serializes");
match post_raw(port, "", &body, SHUTDOWN_TIMEOUT) {
Ok((status, body)) if (200..300).contains(&status) => serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| {
v.get("result")
.and_then(|r| r.get("shuttingDown"))
.and_then(Value::as_bool)
})
.unwrap_or(false),
_ => false,
}
}
pub(crate) fn pretty_json(raw: &str) -> String {
serde_json::from_str::<Value>(raw)
.ok()
.and_then(|value| serde_json::to_string_pretty(&value).ok())
.unwrap_or_else(|| raw.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unwrap_extracts_tool_content_text() {
let reply = r#"{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"ok\":\"true\"}"}]}}"#;
assert_eq!(unwrap_mcp_reply(reply).unwrap(), r#"{"ok":"true"}"#);
}
#[test]
fn unwrap_surfaces_iserror_result_as_err() {
let reply = r#"{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"Error: boom"}],"isError":true}}"#;
assert_eq!(
unwrap_mcp_reply(reply).unwrap_err().to_string(),
"Error: boom"
);
}
#[test]
fn unwrap_surfaces_transport_error_as_err() {
let reply = r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"bad args"}}"#;
assert_eq!(unwrap_mcp_reply(reply).unwrap_err().to_string(), "bad args");
}
#[test]
fn unwrap_passes_through_non_content_reply() {
// A tools/list reply (result.tools, no content) is returned as-is.
let reply = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
assert_eq!(unwrap_mcp_reply(reply).unwrap(), reply);
}
#[test]
fn web_canvas_health_requires_identity_and_web_mode() {
// The `--serve-web` daemon's health shape (extra fields tolerated).
assert!(is_web_canvas_health(
r#"{"running":true,"port":3100,"localIp":"127.0.0.1","server":"openpencil-mcp","mode":"web-canvas"}"#
));
// A plain `--mcp-http` server (no health route → would 404 anyway),
// a foreign service, or a missing mode must all be rejected.
assert!(!is_web_canvas_health(
r#"{"running":true,"port":3100,"server":"openpencil-mcp"}"#
));
assert!(!is_web_canvas_health(
r#"{"server":"someone-else","mode":"web-canvas"}"#
));
assert!(!is_web_canvas_health("not json"));
}
#[test]
fn ping_identity_reads_meta_and_falls_back_to_legacy_top_level() {
// Current servers nest the identity under `result._meta` (spec-empty
// ping result, issue #199).
let meta = ping_reply_identity(
r#"{"jsonrpc":"2.0","id":0,"result":{"_meta":{"server":"openpencil-mcp","mode":"live","token":"t-1"}}}"#,
)
.expect("meta identity accepted");
assert_eq!(meta.get("token").and_then(Value::as_str), Some("t-1"));
// A still-running editor from before that fix reports identity at the
// result top level — discovery must keep working against it.
let legacy = ping_reply_identity(
r#"{"jsonrpc":"2.0","id":0,"result":{"server":"openpencil-mcp","mode":"headless","token":"t-2"}}"#,
)
.expect("legacy identity accepted");
assert_eq!(legacy.get("token").and_then(Value::as_str), Some("t-2"));
// A foreign server (spec-compliant empty result, or another
// product's marker) is never treated as OpenPencil.
assert!(ping_reply_identity(r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).is_none());
assert!(ping_reply_identity(
r#"{"jsonrpc":"2.0","id":0,"result":{"_meta":{"server":"someone-else"}}}"#
)
.is_none());
}
}