From 11e2f208207194c490163e28493bcb6d2721b31f Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Thu, 16 Apr 2026 20:53:50 -0400 Subject: [PATCH] mcp-server: accept JSON-RPC notifications (no id field) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON-RPC 2.0 notifications are fire-and-forget and omit the `id` field entirely. The server was requiring `id: Value`, so MCP clients sending `notifications/initialized` after the handshake got a `parse error: missing field \`id\`` response back. Claude Code then treated the handshake as broken and never called `tools/list`, so none of our memory/journal/graph/channel tools showed up in the client's tool list. Make `Request.id` an `Option` with `#[serde(default)]`: - Missing id → notification (None) → no response of any kind. - Explicit null id → still a request, null response id (legal). - Parse errors still respond with id:null per spec. Update respond() / respond_error() to no-op on None so a dispatched handler that tries to reply to a notification silently does nothing instead of violating the spec. Verified with a manual handshake: initialize → notification → tools/list now round-trips cleanly. Co-Authored-By: Proof of Concept --- src/mcp-server.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/mcp-server.rs b/src/mcp-server.rs index 91a67b0..43d7b33 100644 --- a/src/mcp-server.rs +++ b/src/mcp-server.rs @@ -23,7 +23,11 @@ struct Request { method: String, #[serde(default)] params: Value, - id: Value, + /// Absent for JSON-RPC notifications (fire-and-forget); present for + /// requests that expect a response. Note that `Some(Value::Null)` is + /// an explicit null id (still a request), distinct from `None`. + #[serde(default)] + id: Option, } #[derive(Serialize)] @@ -50,7 +54,10 @@ struct DaemonResponse { id: Value, } -fn respond(id: Value, result: Value) { +/// Write a successful response for a request. Silently no-ops for +/// notifications (id = None) — they are fire-and-forget. +fn respond(id: Option, result: Value) { + let Some(id) = id else { return; }; let resp = Response { jsonrpc: "2.0".into(), result, id }; let json = serde_json::to_string(&resp).unwrap(); let mut stdout = io::stdout().lock(); @@ -58,7 +65,11 @@ fn respond(id: Value, result: Value) { let _ = stdout.flush(); } -fn respond_error(id: Value, code: i64, message: &str) { +/// Write an error response for a request. Silently no-ops for +/// notifications (id = None) per JSON-RPC 2.0 — a server MUST NOT reply +/// to a notification, even with an error. +fn respond_error(id: Option, code: i64, message: &str) { + let Some(id) = id else { return; }; let resp = ErrorResponse { jsonrpc: "2.0".into(), error: json!({ "code": code, "message": message }), @@ -232,7 +243,8 @@ fn main() { Err(e) => { eprintln!("consciousness-mcp: bad json-rpc: {e}"); eprintln!("consciousness-mcp: input was: {line}"); - respond_error(Value::Null, -32700, &format!("parse error: {e}")); + // Per spec, parse errors get a response with id: null. + respond_error(Some(Value::Null), -32700, &format!("parse error: {e}")); continue; } };