# Bundling LSP Servers in a Claude Code Plugin, and Its Cost

> A plugin can declare language servers, which changes what the agent knows about your code: it can ask for a definition instead of guessing from a grep. Here is the declaration, which servers are worth bundling, and what four of them actually cost.

URL: https://www.matthewswong.com/en/blog/claude-code-plugin-lsp-bundling-guide/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-09-10
- Updated: 2026-09-10
- Category: Software Development

> **Key takeaway:** A Claude Code plugin can declare language servers in an .lsp.json file at its root, so a team installs one plugin instead of configuring gopls or pyright by hand. The plugin ships the configuration, never the binary, and every bundled server still costs a process, an index and memory on each machine.

Someone on the team asked why Claude kept editing the wrong normalise. There are three functions with that name in our packages, one for money, one for addresses and one for strings, and text search cannot tell them apart. A language server can. The reason nobody had one running was not difficulty: it was that configuring it is a step every person has to remember on every machine, and nobody remembers a step that produces no error when skipped.

A plugin removes half of that step. It can declare language servers in a file at its root, so installing the plugin the team already installs also configures them. This post covers the declaration and its fields as the Claude Code plugins reference defines them, which servers are worth bundling for a given stack, what four of them cost and who pays, how to tell whether the agent is using LSP results or has quietly gone back to text search, and the questions no language server answers.

## What a plugin can ship, and the one thing it cannot

The plugin ships the configuration, not the language server. That sentence is the whole cost model, and the docs state it plainly: an LSP plugin configures the connection and does not include the server itself, so anyone installing your plugin must already have the binary on their machine. Bundling therefore distributes a decision and an expectation. It does not distribute an installation.

There is a bin directory at the plugin root whose executables are added to the Bash tool's PATH while the plugin is enabled, which looks like the loophole. It is not one for every audience: a plugin distributed through claude.ai organisation settings cannot include a top-level bin directory at all. So treat the missing binary as a permanent part of the design and make it loud, which is the subject of a later section.

## The declaration, field by field

Two fields are required and everything else is optional. The command is the executable to run and must be on the PATH; extensionToLanguage maps file extensions to the language identifiers the server expects. The optional fields exist because a server you hand to other people needs more than the happy path, and three of them accept plugin-root, plugin-data and project-directory substitutions, which is what lets a path baked into your plugin resolve on a machine you have never seen.

| Field | What it is for | Why it matters when you are shipping it |
| --- | --- | --- |
| command, extensionToLanguage | The binary to execute, and the file extensions this server claims | The only two required fields. The command must resolve on the installer's PATH, not on yours |
| args, env, workspaceFolder | Arguments, environment variables, and the folder the server treats as the workspace | All three support the plugin-root, plugin-data and project-directory substitutions, so a bundled wrapper or a subfolder path stays correct wherever the plugin lands |
| transport | stdio by default, or socket | Leave it at the default unless the server you are wrapping genuinely speaks over a socket |
| initializationOptions, settings | Options passed at initialisation, and settings pushed afterwards through the workspace configuration notification | Their shape is defined by the language server, not by Claude Code. Copy them from that server's own documentation rather than guessing |
| startupTimeout, shutdownTimeout | How long to wait, in milliseconds, for the server to come up and to close down | The first of the two decides whether a cold index on a large repo finishes in time to be useful at all |
| restartOnCrash, maxRestarts, diagnostics | Whether to restart a crashed server, how many times, and whether errors are pushed into Claude's context after edits | restartOnCrash and diagnostics both default to true. Turning diagnostics off keeps navigation and drops the per-edit context cost |

> If you point command at a wrapper script inside your plugin, keep its stdout clean. Language servers must send log output to stderr, because stdout carries protocol messages and nothing else. One echo line at the top of a wrapper corrupts the first JSON-RPC frame, and what you see afterwards is a server that starts and then behaves as if it had never initialised.

## Where a language server beats text search

The gain is not accuracy, it is a different operation. Text search returns candidate strings and leaves the ranking to whoever reads them; a language server resolves the identifier at a position and returns the one declaration it binds to. That distinction stops mattering in a small single-package repo and starts mattering badly in four specific places:

- An overloaded or repeated name. Three functions called normalise, one call site, and grep offers all three with equal confidence.
- A re-exported symbol. The import names a barrel package, the declaration lives two hops away, and the barrel re-exports a same-named symbol from somewhere else under an alias.
- A generated type. The declaration exists in a file nobody wrote and nobody greps, and the name in the source may not appear literally in the generator's input.
- A monorepo where the same identifier exists in six packages. Search scope and resolution scope are different things, and only one of them knows which tsconfig or go.mod applies.

None of this is exotic. It is the ordinary shape of a codebase after two years, which is why the argument for bundling gets stronger as the repo gets older rather than weaker.

*The middle panel is the one worth remembering: a language server is not a search engine with better manners, and there is a class of question neither tool resolves because the answer is not in the repository at all.*

## Which servers are worth bundling

Bundle for the stack the repository is, not the stack somebody might one day open in it. Each extra entry is another binary the installer must already have, another process started, another index built and another slice of memory held for the session. The docs also point the other way for anything mainstream: install the pre-built LSP plugins from the official marketplace for languages like TypeScript, Python and Rust, and write your own only for a language nobody has covered.

For a team plugin that means the honest layout is usually a dependency rather than a copy. Let your plugin depend on the official LSP plugin for your main language, and reserve your own .lsp.json for the odd one out: the in-house DSL, the template language, the server your platform team maintains. That way you are not shipping a second, slightly stale copy of a configuration somebody else keeps up to date.

> Two failures here are quiet by design. If two enabled plugins both claim the same extension, the first server registered handles it and the second is not reported as a conflict. And an entry with an invalid configuration is skipped rather than surfaced, while other servers start normally, so a trailing comma in your .lsp.json produces a plugin that installs cleanly and provides nothing.

## What it costs, and who pays

State the cost as four separate bills, because they land on different people at different times. The binary is paid once per machine by the person installing. Process startup is paid every session. Indexing is paid once per repository while cold, and it is paid precisely when the agent is asking its first questions. Resident memory is paid for the whole session, per server, per workspace folder, which is why four bundled servers is four times a cost you only measured once.

The startupTimeout field is where this turns from an annoyance into a wrong answer. A server that misses its window does not claim its extensions, so Claude keeps working with its built-in search tools and the session looks normal. Nothing is broken and nothing is reported; the agent is simply back to guessing from strings, which is the exact state you installed the server to leave.

> Reach for the diagnostics field before you reach for uninstalling. Setting it to false stops errors and warnings being pushed into the context window after every edit while leaving jump-to-definition and find-references intact. On a noisy workspace that is the better trade: you keep the half of LSP that answers structural questions and drop the half that was filling the context with warnings you had already learned to ignore.

## Telling whether the agent is actually using LSP

Three checks, in the order that finds the problem fastest. The Errors tab in the plugin manager lists a language server that failed to start, typically with a message that the executable was not found on the PATH. An entry with an invalid configuration appears nowhere at all, so if nothing happened and nothing was reported, run Claude Code with the debug flag and read the reason. After editing the file, reloading plugins picks up the change without a restart, and it does reload plugin language servers along with everything else.

The fourth check is the one I would actually build into a bundled plugin, because the failure mode above is silent and a silent fall back to text search is worse than a loud failure. A session-start hook that tests each declared binary for presence on the PATH, and prints the project's own install command when one is missing, converts an hour of subtly worse answers into one line at the top of the session.

*Notice that only the first band is paid by the plugin author's decision and the rest are paid by whoever runs it, which is the reason a fourth bundled server needs an argument rather than a shrug.*

## The questions a language server does not answer

LSP answers structural questions. Where is this declared, what references it, what type is this, what does the compiler complain about. It has nothing to say about intent, and a clean diagnostics channel is the most misleading signal in the set, because it reports that the code compiles and is routinely read as reporting that the change was right. Four things still need doing by someone who is not the protocol:

1. Run the tests. A file with no diagnostics can still be wrong in every way that compiles, and that is most of the ways.
2. Read the diff for intent. Find-references tells you where a symbol is used, never whether the use belongs there.
3. Confirm the server claimed the extension before you trust its silence. A server that never initialised reports nothing, which looks identical on the page to a file with no errors.
4. Keep the dynamic edges in your own head. Dispatch through a string key, a handler registered at runtime and a name assembled by concatenation are invisible to every request in the protocol.

Put the language server declaration in the plugin your team already installs, because a configuration step nobody has to remember is the only kind that gets done. Declare one server for the language the repository actually is, depend on the official plugin where one exists, and add a session-start check that names a missing binary out loud. Then hold on to the part that is easy to lose once navigation starts working: the server tells you where a symbol is, and a diagnostic tells you the code compiles. Neither of them is a review.

## Sources & further reading

- Claude Code docs — Plugins reference, LSP servers
- Claude Code docs — Create plugins, adding LSP servers
- Microsoft — Language Server Protocol
- Microsoft — LSP specification 3.17
- Claude Code docs — Plugin dependencies

## Source links

- https://code.claude.com/docs/en/plugins-reference
- https://code.claude.com/docs/en/plugins
- https://microsoft.github.io/language-server-protocol/
- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
- https://code.claude.com/docs/en/plugin-dependencies

## FAQ

### Can a Claude Code plugin bundle the language server binary itself?

No. An LSP plugin only configures the connection, so anyone who installs it must already have the binary such as gopls or pyright on their PATH. There is a bin directory at the plugin root whose executables join the Bash tool's PATH while the plugin is enabled, but a plugin distributed through claude.ai organisation settings cannot include a top-level bin directory. Plan for the missing binary rather than around it.

### Where do I declare an LSP server in a Claude Code plugin?

In an .lsp.json file at the plugin root, or inline in plugin.json under the lspServers field. Only two fields are required per server: command, the executable to run, and extensionToLanguage, which maps file extensions to the language identifiers the server expects. Everything else, including args, env, transport and the timeouts, is optional.

### Why is my bundled language server doing nothing without any error?

An entry with an invalid configuration is skipped rather than reported, and other servers still start, so a trailing comma produces a plugin that installs cleanly and provides nothing. Run Claude Code with the debug flag to see the reason. A server that starts and then fails does appear in the plugin manager's Errors tab, usually saying the executable was not found on the PATH.

### How many language servers should one plugin bundle?

As few as the repository genuinely needs, because each entry is another binary the installer must have, another process started, another index built and more memory held for the session. For mainstream languages the docs recommend the pre-built LSP plugins from the official marketplace, so a team plugin is usually better off depending on those and reserving its own .lsp.json for an in-house or uncovered language.

### Can I keep LSP navigation but stop the diagnostics filling my context?

Yes. Set the diagnostics field on that server to false and errors and warnings stop being pushed into Claude's context after every edit, while jump-to-definition and find-references keep working. On a noisy or badly configured workspace this is usually a better trade than disabling the plugin, because you keep the half of LSP that answers structural questions.

---

- HTML version: https://www.matthewswong.com/en/blog/claude-code-plugin-lsp-bundling-guide/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/claude-code-plugin-lsp-bundling-guide/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
