memstead_mcp/error_envelope.rs
1//! Tool-error envelope helpers used by the full `ServerHandler` to
2//! construct `CallToolResult` responses with the engine's typed wire
3//! shape: text channel prefixed `ERROR [<CODE>]: <message>`,
4//! `structured_content` carrying the full payload (or absent on the
5//! prose-only path), `is_error` set so MCP clients render the response
6//! as a failure. Both helpers live in `memstead-mcp` because the
7//! full server is their only consumer; the lean filesystem server
8//! builds its envelopes through the parallel helpers inline in
9//! `memstead-mcp::filesystem_server`.
10//!
11//! Wire-shape byte-identity for these envelopes is pinned by
12//! `memstead-mcp/tests/wire_shape.rs`; any change here that alters the
13//! text-channel prefix or the structured payload would trip those
14//! tests.
15
16use rmcp::model::{CallToolResult, ContentBlock};
17
18/// Build a typed-code tool-error response without a structured details
19/// payload. Text channel reads `"ERROR [<CODE>]: <msg>"` and
20/// `structured_content` carries `{code, message}` (no `details`); the
21/// `code` field matches the wire envelope agents see from
22/// `tool_error_with_payload`, so consumers that branch on the
23/// `UPPER_SNAKE_CASE` token get the same shape on either form. Use
24/// this helper when the failure has no structured recovery payload
25/// beyond the message body; reach for [`tool_error_with_payload`]
26/// otherwise.
27///
28/// Pre-fix the text channel emitted `"ERROR: <msg>"` without the
29/// typed code, leaving the prefix-form contract a half-delivered
30/// promise on the simple form.
31pub fn tool_error(code: &str, msg: &str) -> CallToolResult {
32 let payload = serde_json::json!({ "code": code, "message": msg });
33 let mut r = CallToolResult::success(vec![ContentBlock::text(format!("ERROR [{code}]: {msg}"))]);
34 r.is_error = Some(true);
35 r.structured_content = Some(payload);
36 r
37}
38
39/// Build a structured tool-error response with the typed envelope on
40/// `structured_content`. The text channel mirrors the same `code`
41/// inline as `"ERROR [<CODE>]: <msg>"` so a consumer that only reads
42/// `result.content[0].text` (Claude Code's default rendering, log
43/// scrapes, terminal dumps) still recovers the UPPER_SNAKE_CASE code
44/// with a one-line regex (`^ERROR \[([A-Z_]+)\]: `).
45///
46/// The `code` argument is explicit (and non-optional). An earlier
47/// shape extracted the code from `payload["code"]` and defaulted to
48/// `"INTERNAL"` when the key was absent — the explicit parameter makes
49/// a missing-code regression compile-impossible instead of
50/// review-impossible. Callsites that
51/// build a payload via `memstead_base::ops::envelope(code, message, details)`
52/// spell the same `code` twice — once in the outer call, once inside
53/// `envelope(...)` — by design: the outer code is what the text
54/// channel emits; the inner code is what `structured_content` carries.
55pub fn tool_error_with_payload(
56 code: &str,
57 msg: &str,
58 payload: serde_json::Value,
59) -> CallToolResult {
60 let mut r = CallToolResult::success(vec![ContentBlock::text(format!("ERROR [{code}]: {msg}"))]);
61 r.is_error = Some(true);
62 r.structured_content = Some(payload);
63 r
64}