mcp-server: accept JSON-RPC notifications (no id field)

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<Value>` 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 <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-04-16 20:53:50 -04:00
commit 11e2f20820

View file

@ -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<Value>,
}
#[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<Value>, 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<Value>, 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;
}
};