Skip to content

🐛 fix: answer server-initiated requests and frame by byte length (unblocks TypeScript 7's native LSP) - #52

Open
sudhirj wants to merge 2 commits into
ktnyt:mainfrom
Common-Pattern:fix/typescript-7-native-lsp
Open

🐛 fix: answer server-initiated requests and frame by byte length (unblocks TypeScript 7's native LSP)#52
sudhirj wants to merge 2 commits into
ktnyt:mainfrom
Common-Pattern:fix/typescript-7-native-lsp

Conversation

@sudhirj

@sudhirj sudhirj commented Aug 19, 2026

Copy link
Copy Markdown

Summary

Two protocol bugs that make cclsp unusable with TypeScript 7's native LSP (tsc --lsp --stdio, the Go port — same server as tsgo --lsp). Both are general correctness fixes rather than TS-specific workarounds; TS 7 just happens to trip both immediately.

Symptom: every tool call fails with LSP request timeout: … (30000ms), and once past that, a run of Failed to parse LSP message.

The bugs

1. Server → client requests were never answered (src/lsp/server-manager.ts)

LSP requires a response to every request, and a server may block until it arrives. Server-initiated messages went to handleMessage, which only understood notifications, so any request no adapter claimed was silently dropped.

TS 7 sends client/registerCapability immediately after initialized and waits for the reply. Nothing answers, so every subsequent request — hover, definition, references — sits until the 30s timeout. typescript-language-server never sends one, which is why this has gone unnoticed.

2. Incoming frames were sliced by string length, not byte length (src/lsp/json-rpc.ts)

Content-Length counts bytes, but stdout was accumulated into a JS string and sliced by that count. The first message carrying a multi-byte character reads short, the next frame is then parsed from the wrong offset, and the parser never resynchronises.

Reachable with any server whose output is not pure ASCII. TS 7 hits it at once — its own timing logs read handled method 'textDocument/hover' (11) in 680.392µs.

Note sendMessage already used Buffer.byteLength, so outgoing framing was correct; only the receive path was wrong.

Approach — existing setups are unaffected

I deliberately split "always safe" from "changes behaviour":

  • Always on: the four capability requests cclsp can answer meaningfully — client/registerCapability, client/unregisterCapability, window/workDoneProgress/create, workspace/configuration. A server that sends one of these is already blocked waiting for the reply, so answering can only unblock it. There is no configuration in which silence was the better outcome.
  • Opt-in: replying MethodNotFound to an unrecognised request is the one genuine behaviour change, so it sits behind a new per-server rejectUnhandledRequests option, default false. Without it, unrecognised requests are ignored exactly as today.
  • The framing fix is byte-identical for pure-ASCII traffic, so servers that work today are unchanged.

Also in the second commit: LSPMessage.id widened to number | string (servers pick their own request ids — TS 7 uses "ts1"), and response correlation now checks for a number explicitly rather than relying on truthiness, so an id of 0 is no longer treated as absent.

Testing

src/lsp/client-requests.test.ts (new, 9 cases) and 4 new cases in src/lsp/json-rpc.test.ts, including a frame split mid-character across two chunks.

I checked the framing tests fail against the unpatched transport — all 4 fail before, pass after — so they genuinely pin the regression.

bun test        215 → 217 pass, 19 fail
bun run lint    Checked 52 files. No fixes applied.
bun run typecheck  clean

The 19 failures are pre-existing on main at 93414a1 (verified by running the suite on a clean checkout: 203 pass / 5 skip / 19 fail before, same 19 after). Happy to look at them separately if useful.

Verified against a real server

Driven end-to-end through cclsp over MCP against TypeScript 7.0.2's tsc --lsp --stdio on a 14-package monorepo: find_definition, find_references and rename_symbol all return correct results where they previously timed out.

Possibly related

#18 reports vue-language-server timing out on documentSymbol after 30s. That is the same shape as bug 1 — a request the client never answers — though the Vue adapter covers tsserver/request specifically, so I would not claim this fixes it without a Vue project to test on.

Content-Length counts bytes, but the transport accumulated stdout into a
JS string and sliced it by that count. The first message carrying a
multi-byte character reads short, so the next frame is parsed from the
wrong offset and every message after it fails — the parser never
resynchronises, and the session dies with a run of "Failed to parse LSP
message" errors.

This is reachable with any server whose output is not pure ASCII.
TypeScript 7's native LSP hits it immediately: its own timing logs are
of the form "handled method 'textDocument/hover' (11) in 680.392µs".

Buffer the raw bytes and index into them, so declared and consumed
lengths agree. Also accept a lowercase/spaced Content-Length header, and
stop treating a chunk boundary that falls inside a multi-byte character
as a parse error.
LSP requires a response to every request, and a server is entitled to
block until it arrives. cclsp routed server-initiated messages to
handleMessage, which only understood notifications, so a request that no
adapter claimed was silently dropped.

TypeScript 7's native LSP (`tsc --lsp --stdio`) sends
client/registerCapability immediately after `initialized` and waits for
the reply. Nothing answers it, so every later request — hover,
definition, references — sits until cclsp's 30s timeout.
typescript-language-server never sends one, which is why this went
unnoticed.

Answer the capability requests cclsp understands
(client/registerCapability, client/unregisterCapability,
window/workDoneProgress/create, workspace/configuration). These are
always answered: a server that sends one is already waiting, so a reply
can only unblock it.

Unrecognised requests keep today's behaviour and are ignored, unless the
new per-server `rejectUnhandledRequests` option asks for a MethodNotFound
reply. Existing configurations are therefore unaffected.

Also widen LSPMessage.id to `number | string`, since servers pick their
own request ids (TS 7 uses "ts1"), and correlate responses on an explicit
number check so that id 0 is not mistaken for absent.
Copilot AI lite review requested due to automatic review settings August 19, 2026 09:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes two LSP/JSON-RPC protocol correctness issues that prevented cclsp from working with servers that (a) send server→client requests that must be answered and/or (b) emit non-ASCII content (notably TypeScript 7’s native LSP). This keeps existing behavior stable by always answering a small set of safe capability/configuration requests, while leaving “reject unknown requests” as an opt-in per server.

Changes:

  • Add server→client request handling (with an opt-in MethodNotFound rejection mode for otherwise-unhandled requests).
  • Fix incoming JSON-RPC framing to slice by byte length (Buffer-based), not JS string length.
  • Expand request id support to number | string, and add targeted tests for multi-byte framing and server-initiated request replies.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/types.ts Adds rejectUnhandledRequests config option to control opt-in request rejection behavior.
src/lsp/types.ts Widens LSPMessage.id to number | string for server-generated request ids.
src/lsp/server-manager.ts Routes server-initiated requests to a responder (buildClientResponse) and fixes id=0 truthiness handling.
src/lsp/json-rpc.ts Reworks receive-side framing to buffer bytes and parse by Content-Length byte counts; fixes response correlation for id=0.
src/lsp/json-rpc.test.ts Adds regression tests for multi-byte payload framing and split-chunk mid-character parsing.
src/lsp/client-requests.ts Implements “client-side” responses for key server-initiated LSP requests + optional MethodNotFound rejection.
src/lsp/client-requests.test.ts Adds unit tests covering known request handling, opt-in rejection, and string/id=0 cases.
README.md Documents the new rejectUnhandledRequests configuration option.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants