LSP client answers client/registerCapability with -32601, wedging servers that use dynamic registration (and no per-request timeout)
Summary
Claude Code's LSP client answers a server's client/registerCapability request with error -32601 Unhandled method. Servers that use dynamic capability registration treat that as fatal and stop responding, so every subsequent LSP tool call hangs — with no timeout, no error and no diagnostic.
Refiling per the closing bot on #52693 ("please file a new issue and reference this one"). Prior reports: #32595 (not planned), #44113 (duplicate), #52693 (closed inactive, locked), #16360.
What is new here: a wire-level capture of the exact exchange, a measured 53-minute hang, and a working proxy workaround that confirms the diagnosis.
Wire capture
Captured by a logging proxy placed between Claude Code 2.1.267 and expert 0.1.10 (the Elixir language server). Verbatim, in order:
client -> server {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"processId":62438,
"clientInfo":{"name":"Claude Code","version":"2.1.267"}, ...
"capabilities":{"workspace":{"configuration":false,"workspaceFolders":false},
"textDocument":{ ... }}}}
client -> server {"jsonrpc":"2.0","method":"initialized","params":{}}
client -> server {"jsonrpc":"2.0","method":"textDocument/didOpen", ... }
client -> server {"jsonrpc":"2.0","id":1,"method":"textDocument/hover", ... }
server -> client {"id":0,"jsonrpc":"2.0","result":{"capabilities":{ ... }}}
server -> client {"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"Server initialized, registering capabilities","type":3}}
server -> client {"id":1,"jsonrpc":"2.0","method":"client/registerCapability","params":{
"registrations":[{"id":"-42","method":"workspace/didChangeWatchedFiles",
"registerOptions":{"watchers":[{"globPattern":"**/mix.lock"},
{"globPattern":"**/*.{ex,exs}"}]}}]}}
client -> server {"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Unhandled method client/registerCapability"}}
(server sends nothing further, ever)
The textDocument/hover request above was never answered. In the session where I first hit this, the LSP tool call sat unanswered for 53 minutes before I interrupted it — it blocks the whole turn, so the model cannot even proceed to another step.
Note the client capabilities: there is no workspace.didChangeWatchedFiles entry at all. The server should arguably not be registering dynamically against a client that never advertised it (filed separately as expert#904) — but the client half is what makes it unrecoverable.
Two requests
1. Answer the request. The LSP specification expects a response. Replying {"jsonrpc":"2.0","id":<id>,"result":null} — acknowledging without actually watching files — is what other clients do and is enough to unblock every affected server. client/unregisterCapability has the same problem.
2. Add a per-request timeout. This is separable and, I would argue, the more important of the two. Whatever the cause, a language server that stops responding should surface an error to the model after some bounded interval rather than blocking a tool call indefinitely. Right now there is no upper bound at all: startupTimeout covers process startup, but nothing covers an in-flight request. A 53-minute silent block is a bad failure mode regardless of which side is at fault.
Confirmation via workaround
I put a small stdio proxy between Claude Code and expert that intercepts client/registerCapability, answers it with result: null on the client's behalf, and does not forward it (forwarding would leave the server with two responses for one id). Everything else passes through byte-for-byte.
With the proxy, the same session works immediately:
Sending request 'textDocument/documentSymbol - (1)'.
Received response 'textDocument/documentSymbol - (1)' in 31ms.
Sending request 'textDocument/hover - (2)'.
Received response 'textDocument/hover - (2)' in 44ms.
Sending request 'shutdown - (3)'.
Received response 'shutdown - (3)' in 8ms.
Before the proxy: no response in 53 minutes. After: 44ms. The only change is who answers client/registerCapability.
That a ~150-line proxy fixes it end to end suggests the client-side change is small.
Reproducing without a real language server
<details>
<summary>Mock LSP server that isolates the behaviour (Elixir, no dependencies)</summary>
Save as mock_lsp.exs. It answers every client request immediately, and sends one client/registerCapability after initialized:
:io.setopts(:standard_io, encoding: :latin1)
defmodule MockLsp do
def run, do: loop()
defp loop do
case read_frame() do
:eof -> :ok
{:ok, msg} -> handle(msg); loop()
end
end
defp handle(%{"method" => "initialize", "id" => id}) do
reply(id, %{"capabilities" => %{"textDocumentSync" => 1, "hoverProvider" => true},
"serverInfo" => %{"name" => "mock-lsp", "version" => "1"}})
end
defp handle(%{"method" => "initialized"}) do
send_msg(%{"jsonrpc" => "2.0", "id" => 1, "method" => "client/registerCapability",
"params" => %{"registrations" => []}})
end
defp handle(%{"method" => "textDocument/hover", "id" => id}) do
reply(id, %{"contents" => %{"kind" => "plaintext", "value" => "MOCK HOVER OK"}})
end
defp handle(%{"method" => "shutdown", "id" => id}), do: reply(id, nil)
defp handle(%{"method" => "exit"}), do: System.halt(0)
defp handle(%{"method" => _, "id" => id}) when not is_nil(id), do: reply(id, nil)
defp handle(%{"id" => id, "error" => err}) do
IO.puts(:stderr, "[mock] client rejected our request id #{inspect(id)}: #{inspect(err)}")
end
defp handle(_), do: :ok
defp reply(id, result), do: send_msg(%{"jsonrpc" => "2.0", "id" => id, "result" => result})
defp send_msg(map) do
body = JSON.encode!(map)
IO.binwrite(:stdio, "Content-Length: #{byte_size(body)}\r\n\r\n" <> body)
end
defp read_frame do
case read_headers(%{}) do
:eof -> :eof
h ->
len = h |> Map.fetch!("content-length") |> String.to_integer()
case IO.binread(:stdio, len) do
:eof -> :eof
body -> {:ok, JSON.decode!(body)}
end
end
end
defp read_headers(acc) do
case IO.binread(:stdio, :line) do
:eof -> :eof
l when l in ["\r\n", "\n"] -> acc
l ->
case String.split(l, ":", parts: 2) do
[k, v] -> read_headers(Map.put(acc, k |> String.trim() |> String.downcase(), String.trim(v)))
_ -> read_headers(acc)
end
end
end
end
MockLsp.run()
Register it as a plugin (mock/.claude-plugin/plugin.json), pointing at a wrapper that runs elixir mock_lsp.exs:
{
"name": "mock-lsp",
"version": "1.0.0",
"description": "Mock LSP server",
"author": { "name": "repro" },
"lspServers": {
"mock": {
"command": "/absolute/path/to/run.sh",
"extensionToLanguage": { ".mock": "mock" }
}
}
}
Then claude --plugin-dir ./mock --debug -p 'Use the LSP tool: operation=hover, filePath=sample.mock, line=1, character=1.' and watch the mock's stderr — it prints the -32601 rejection it received.
</details>
The server side of this is filed at expert#904; a full reproduction of the server behaviour is at https://github.com/onnimonni/expert-registercapability-hang
Environment
- Claude Code 2.1.267, macOS 15 (arm64)
- expert 0.1.10, Elixir 1.20.4, Erlang/OTP 28
- Plugin declared via
lspServersin a project-scope.claude/skills/*/.claude-plugin/plugin.json