LSP client answers client/registerCapability with -32601, wedging servers that use dynamic registration (and no per-request timeout)

Status Fixed / completed
Reported on v2.1.267
Maintainer reply None cached
Activity 0 comments · opened Sep 22, 2026 · closed Oct 1, 2026

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 lspServers in a project-scope .claude/skills/*/.claude-plugin/plugin.json

View original on GitHub ↗