Skip Navigation

Scott Spence

Revisiting LSP in Claude Code

• 20 min read

Back in March I wrote about enabling LSP in Claude Code. It turned into one of my most-read posts this year, which is a bit awkward, because a fair chunk of it is now out of date. I told people to set an undocumented flag, I framed LSP as go-to-definition, and I said I’d check back in a week. I didn’t.

So this is the check back, seven months late. I read the current docs, went through my own session history, and ran some tests locally to see what Claude Code actually does with a language server.

TlDr: the flag is gone, the first answer LSP gives in every session is wrong, that’s a known bug, and for TypeScript there’s a workaround that’s three lines of config. After all that, grep was better for finding where something is used.

What’s changed since March

The setup in the old post was a feature flag plus a plugin in the ~/.claude/settings.json file:

1{
2	"env": {
3		"ENABLE_LSP_TOOL": "1"
4	},
5	"enabledPlugins": {
6		"typescript-lsp@claude-plugins-official": true
7	}
8}

The flag isn’t needed any more. I removed ENABLE_LSP_TOOL from my ~/.claude/settings.json, restarted, and the LSP tool still loads and answers on Claude Code 2.1.296. LSP is now a documented feature: install the language server binary, install the code intelligence plugin for the language, done.

The other thing I got wrong was what LSP is for. The docs describe two separate things a language server gives Claude Code:

  1. Diagnostics after edits. Every time Claude edits or writes a file the server handles, type errors and warnings get pushed into context automatically. No tool call involved.
  2. Code navigation. An LSP tool Claude can call for definitions, references, hover types, symbols and call hierarchy.

My March post was entirely about the second one. The first one didn’t get a mention.

Does Claude Code actually use it?

I keep my session history in local archives, with ccrecall and now omnirecall, so I went back through it to see how much Claude Code had actually used LSP.

From when I switched it on at the end of February to April there’s about 650 sessions, and it made 32 LSP calls in 15 of them. 12 of those came back with nothing, mostly No LSP server available for file type: .svelte, and then it went to grep. It never called findReferences, not once.

After that I wasn’t really using Claude Code until a couple of weeks ago. Since then there’s 118 sessions, half of them subagents, and no LSP calls until I started writing this.

You’ll know when it has used LSP because you’ll see something like this in Claude’s output:

1ā— LSP(operation: "findReferences", symbol: "get_from_cache", in: "src/lib/cache/server-cache.ts")
2	ā””	Found 51 references across 20 files

So, I got Claude Code to test itself. I asked it to run headless sessions against this site’s repo with claude -p and save the output from each one, so the tool calls could be counted:

1claude -p "$prompt" --output-format stream-json --verbose

These are the four prompts it used, three runs of each. None of them mention LSP or grep:

1<!-- Prompt 1 -->
2
3In `src/lib/posts.test.ts`, `get_posts` is imported. Where is that
4function actually defined? Give me the file and line.
5
6<!-- Prompt 2 -->
7
8I want to change the signature of `get_from_cache`. List every call
9site (`file:line`) I would need to update. Do not change anything.
10
11<!-- Prompt 3 -->
12
13What exactly does `get_post_tags` return? Give me the fully resolved
14return type and where it is defined.
15
16<!-- Prompt 4 -->
17
18Change get_period_boundaries in
19`src/lib/analytics/period-stats.helpers.ts` to take a single options
20`object ({ period, now })` instead of positional arguments, and update
21every caller. Make sure it still type-checks.

Then the same again with the LSP rule taken out of my CLAUDE.md.

TestUsed LSP, with the ruleUsed LSP, without the rule
Definition3 of 32 of 3
Call sites3 of 31 of 3
Type3 of 33 of 3
Refactor3 of 30 of 3

With the rule, Claude Code reached for LSP every time. Without it, half the time, and never for the refactor. I ran the with-the-rule set both headless with claude -p and in real interactive sessions and got twelve out of twelve both ways.

So the rule from the March post does work when the prompt is about a symbol. Going by my history, in real sessions it mostly went to grep anyway.

Whether it should be using LSP for that is another thing, that’s further down in Just use grep?

The first answer is wrong

This is the bit I didn’t expect. The second prompt is the one asking for every call site of get_from_cache. Each time it ran, Claude Code made one findReferences call and got this back:

1Found 1 reference:
2  src/lib/cache/server-cache.ts:42:17

One reference, which is the definition itself. That function is used in 20 files. Each run then built its answer from git grep instead. The answers were right, but LSP contributed nothing to them.

So, I wanted to know how wrong, and for how long. I got Claude Code to run some fresh headless sessions, each one making the same set of calls one after another across three functions in three different files, and to time each call.

This is the prompt for one of them:

1This is a mechanical probe of the LSP tool. If the LSP tool is not
2loaded, load it first with ToolSearch query `"select:LSP"`. Then do
3these steps in order, ONE tool call per message, never in parallel,
4never repeating a call unless the step says so, and using no other
5tools:
6
71. Call the LSP tool: `findReferences` on
8   `src/lib/server/bluesky-posts.ts` line 140 character 14. If the
9   result is "Found 1 reference", make the identical call again, up to
10   6 calls in total, stopping as soon as it reports more than 1
11   reference.
122. Exactly once: `findReferences` on `src/lib/cache/server-cache.ts`
13   line 42 character 17.
143. Exactly once: `findReferences` on
15   `src/lib/utils/group-posts-by-tag.ts` line 14 character 14.
164. Exactly once: goToDefinition on
17   `src/lib/components/newsletter-signup/newsletter.remote.ts` line 60
18   character 30.
195. Exactly once: hover on `src/lib/seo/default.ts` line 20
20   character 20.
21
22When finished reply with the single word DONE.

And this is what came back, the time is how long since the session started:

TimeCallResult
4.7sfindReferences, function BFound 1 reference
6.6sfindReferences, function BFound 1 reference
7.8sfindReferences, function BFound 5 references across 3 files
9.1sfindReferences, function AFound 51 references across 20 files
10.6sfindReferences, function CFound 10 references across 3 files
11.9sgoToDefinition, a fourth symbolThe right file and line
13.2shover, a fifth symbolThe full signature

Same call three times, wrong, wrong, right. Then everything after that was right, different functions, different files, goToDefinition and hover as well.

I got it to run a load of these, changing the order each time. The first call of a fresh session was wrong in 18 of the first 19 sessions, and in all 13 of the ones I timed after that. Every wrong answer came back inside about two seconds of the first LSP call in the session.

A few other things that came out of it:

  • It’s the session, not the function. One wrong call on one function, wait a couple of seconds, and the first call on a completely different function in a different file is fine.
  • Waiting before asking doesn’t help. One session sat there for 30 seconds before making its first call and it was still wrong. The language server doesn’t get started until something asks it a question.
  • Asking about something else straight away doesn’t help either. In that same session the second call was on a different function, 1.7 seconds later, and that was wrong too.
  • goToDefinition does it as well. As the first call it sent me to the import line at the top of the file I was already in.
  • One retry isn’t always enough. With the retries about 1.3 seconds apart it took a third go in five sessions out of seven.

I updated typescript-language-server from 5.3.0 to 6.0.2 to see if that helped, it didn’t.

Funnily enough, ā€œfirst LSP call can missā€ was one of the gotchas in my March post. I called it a minor quirk. I don’t think it’s minor now, Claude Code made one call, got the wrong answer and went to grep.

Why’s it doing that?

Turns out it’s a known bug, there’s an open issue for it on the Claude Code repo. Other people are getting the same ā€œone reference, then all of themā€ with other language servers, Python ones as well, so it’s not only TypeScript.

Claude Code asks the language server the question before the server has finished loading the project.

With TypeScript there’s a bit more to it. typescript-language-server starts up two tsserver processes, the full one and a lightweight one it calls the syntax server. The docs say the syntax server is there to ā€œprovide IntelliSense while projects are loadingā€.

So, while the project is loading, anything asking for references or definitions gets its answer from the lightweight one, and that one only knows about the file it was asked about. That’s why I get one reference back, it’s the only one in that file.

In VS Code that makes sense, I get something straight away and the rest fills in. Claude Code asks once and takes the answer.

The workaround

typescript-language-server has an option to turn the syntax server off, it’s called useSyntaxServer. I got Claude Code to go looking and it found a discussion on the language server repo from April. Someone had this exact problem with Claude Code and the maintainer suggested trying it.

Claude Code starts a language server from a bit of config in a plugin, which server to run and which file extensions it’s for. That config can also pass options to the server when it starts up, that’s what initializationOptions is for.

So here’s the config from the official typescript-lsp plugin, with the option added to it on the highlighted lines:

1{
2	"typescript": {
3		"command": "typescript-language-server",
4		"args": ["--stdio"],
5		"extensionToLanguage": {
6			".ts": "typescript",
7			".tsx": "typescriptreact",
8			".js": "javascript",
9			".jsx": "javascriptreact",
10			".mts": "typescript",
11			".cts": "typescript",
12			".mjs": "javascript",
13			".cjs": "javascript"
14		},
15		"initializationOptions": {
16			"tsserver": { "useSyntaxServer": "never" }
17		}
18	}
19}

With that in place the first call of a session came back right in all twelve of the sessions Claude Code ran with it.

There’s two ways I know of to get that config in.

Option 1, edit the official plugin config

The official plugin doesn’t have a config file of its own, the config is in the marketplace file, here:

1~/.claude/plugins/marketplaces/claude-plugins-official/.claude-plugin/marketplace.json

Find typescript under lspServers in there and add the initializationOptions block to it. That’s what the person in the discussion did and it worked for them.

I’ve not done it this way, because you could change the file, the plugin could be updated and probably overwrite that file again.

Option 2, use my plugin

I didn’t want to be editing a file that might get overwritten, so I made a plugin with the config above in it. It’s the official one with the option added.

I’ve added it to my svelte-skills-kit marketplace as typescript-lsp. It’s not Svelte specific, that’s just where I keep my plugins, it works with any TypeScript or JavaScript project.

The plugin is only config, so the language server needs to be installed. If the official plugin is already working then it’s there already, if not:

1pnpm i -g typescript-language-server typescript
2# or npm or whatever your package manager of choice is

Then in Claude Code, add the marketplace:

1/plugin marketplace add spences10/svelte-skills-kit

I already had it added, so I updated it to get the new plugin showing up:

1/plugin marketplace update svelte-skills-kit

Install the plugin:

1/plugin install typescript-lsp@svelte-skills-kit

Disable the official one. Both of them are set up for the same file extensions so I only want one of them on:

1/plugin disable typescript-lsp@claude-plugins-official

If you’re using Svelte there’s a plugin for that in the same marketplace, see the Svelte section.

Then reload the plugins:

1/reload-plugins

I restarted Claude Code after that as well.

To check it’s working I start a new session and, as the very first thing, ask for the references to something I know is used in a few files. If more than one reference comes back it’s working.

Does it work?

Here’s the default config against the one with the syntax server turned off:

DefaultSyntax server off
First call of a sessionWrong, back in 0.5sRight, back in about 2s
Same call three times1, 1, 51 references51, 51, 51 references
goToDefinition as first callThe import lineThe definition
Memory once loadedAbout 935 MB, 4 processesAbout 725 MB, 3 processes

The first call is slower because it’s now waiting for the project to load before it answers. This is a small project, on a big one I’d expect that wait to be a lot longer than two seconds.

Type errors after an edit still show up with the option on, I got Claude Code to check that as well.

The maintainer did say it ā€œmight not solve this issue by itselfā€. For this project it did.

⚠ Note: this is a workaround until it’s fixed in Claude Code, the issue is the place to keep an eye on.

Once that’s sorted, switching back to the official plugin is:

1/plugin uninstall typescript-lsp@svelte-skills-kit
2/plugin enable typescript-lsp@claude-plugins-official

Changes from the shell get missed

That same issue has a second problem in it, so I got Claude Code to test that as well.

In a session that was already warmed up it changed files three different ways and asked for the references after each one:

How the file changedWhat findReferences did
The Edit toolIncluded the new line on the next call
A new file written from the shellMissed it, then found it seconds later
A shell append to a file LSP had already asked aboutNever saw it, across three calls

So edits Claude Code makes with its own tools get to the language server. A change made from the shell to a file the server already had open didn’t.

It only tested appending a line. The issue has the same thing happening with git checkout, formatters and anything else that gets run through Bash.

The workaround doesn’t do anything for this one.

Just use grep?

So with the first answer fixed, is LSP actually any better than grep for this? When the first answer was wrong Claude Code went to grep anyway, and the answers were right.

I got Claude Code to run the same questions three different ways, three runs of each, 54 sessions in all:

  1. Grep only, with the LSP tool turned off.
  2. LSP and grep, which is my normal setup with the rule in CLAUDE.md.
  3. LSP only, with Bash, Grep and Glob turned off.

There’s three functions called get_post_tags in this project, so I used that for most of them, I thought a text search would struggle with it. These are the prompts:

1<!-- References 1, the only function with that name -->
2
3I want to change the signature of `get_from_cache`. List every call
4site (`file:line`) I would need to update. Do not change anything.
5
6<!-- References 2, one of three functions with the same name -->
7
8I want to change the signature of `get_post_tags` in
9`src/lib/data/post-tags.remote.ts`. List every call site (`file:line`)
10I would need to update. Do not change anything.
11
12<!-- References 3, a function nothing uses -->
13
14Is `get_post_tags` in `src/lib/state/post-tags.svelte.ts` used
15anywhere, or can I delete it? Do not change anything.
16
17<!-- Types 1, all three functions -->
18
19What exactly does `get_post_tags` return? Give me the fully resolved
20return type and where it is defined.
21
22<!-- Types 2, no return type written down -->
23
24What exactly does `get_post_tags` in `src/lib/post-tags.ts` return? It
25has no return type annotation. Give me the fully resolved return type.
26
27<!-- Types 3, a type that comes from SvelteKit -->
28
29What is the exact TypeScript type of `get_post_tags` exported from
30`src/lib/data/post-tags.remote.ts`, and what type do I get when I
31await a call to it?

Here’s the three references questions:

Grep onlyLSP and grepLSP only
Right call sites9 of 99 of 99 of 9
Found the test mock that needs updating3 of 33 of 30 of 3
Straight answer on ā€œcan I delete itā€3 of 33 of 30 of 3
Turns per question2 to 34 to 67 to 12
Seconds per question9 to 1514 to 1916 to 29
Cost per question$0.04 to $0.08$0.06 to $0.09$0.07 to $0.12

And the three types questions:

Grep onlyLSP and grepLSP only
Right answer9 of 99 of 99 of 9
Turns per question3 to 66 to 156 to 16
Seconds per question12 to 2211 to 2911 to 29
Cost per question$0.05 to $0.10$0.06 to $0.14$0.06 to $0.16

Grep on its own didn’t get a single answer wrong, and for references it was the quickest and the cheapest of the three.

Some things that came out of it:

  • With both available Claude Code ran grep every time anyway. My rule tells it to check the LSP count against grep, so the LSP call was extra work on top.
  • The three functions with the same name weren’t a problem for grep. It searched for the name and the file it’s imported from, and split them up correctly every time.
  • findReferences gives back a file, a line and a column, not the code. With only LSP to go on Claude Code had to read three to six files to see what each reference was. Grep gives back the line.
  • There’s a vi.mock for that function in a test that would break if the signature changed. Grep found it, LSP can’t, it’s not a reference as far as TypeScript is concerned.
  • With only LSP, all three ā€œcan I delete itā€ answers came back with a ā€œbut I can’t check the .svelte filesā€.
  • Types is where LSP did alright. hover gives the exact type in one call, and on two of the three questions it was as quick as grep.
  • Grep still got the types right though. For the SvelteKit one it read the type declarations in node_modules, and in one run it wrote a script to ask the TypeScript compiler.

⚠ Note: this is a small project with function names that don’t clash with much, one model (Opus 5.5) and three runs of each. On a big codebase with thousands of matches for a name it could be a different story. I didn’t test a refactor either.

Diagnostics

This is the half of LSP I skipped in March, and the one that needs nothing from me to work. I had Claude Code append a line with an obvious type error to a TypeScript file and report back whatever feedback it was shown:

1export const lsp_probe: number = 'text';

In a longer session the errors arrived like this, with no tool call asking for them:

1<new-diagnostics>The following new diagnostic issues were detected:
2
3period-stats.helpers.ts:
4  ✘ [Line 293:14] Type 'string' is not assignable to type 'number'. [2322] (typescript)
5  ✘ [Line 294:14] Type 'number' is not assignable to type 'string'. [2322] (typescript)</new-diagnostics>

They aren’t instant though. In two short sessions where the edit was the last thing that happened, the type error went unreported. In the longer one both errors showed up about 30 seconds later. A fresh session’s language server needs time to load the project, and Claude Code doesn’t wait for it.

Svelte

The official plugin list has no Svelte entry, which is the gap I complained about in March. Without one, any LSP call on a component comes back with:

1No LSP server available for file type: .svelte

Same deal as the TypeScript plugin, it’s only config. This is all of it, in a file called .lsp.json in the plugin:

1{
2	"svelte": {
3		"command": "svelteserver",
4		"args": ["--stdio"],
5		"extensionToLanguage": {
6			".svelte": "svelte"
7		}
8	}
9}

I’ve added it to my svelte-skills-kit marketplace as svelte-lsp. The plugin doesn’t include the language server, so that goes on first:

1pnpm i -g svelte-language-server
2# or npm or whatever your package manager of choice is

Then in Claude Code, with the marketplace added:

1/plugin install svelte-lsp@svelte-skills-kit
2/reload-plugins

With that loaded, hover, go-to-definition, references and document symbols all work on .svelte files, and a type error I added inside a component’s <script> block came back as a diagnostic. Without the plugin the same edit got no feedback at all.

It also showed up another gap in the TypeScript server on a SvelteKit project. number_crunch is a utility on this site that’s mostly called from components. I asked for its references from both sides:

Asked fromReferences found
The .ts file where it’s defined23 across 3 files, none of them .svelte
A .svelte file that uses it89 across 18 files, 15 of them .svelte
grep90 lines across 18 files

The TypeScript server can’t see into components, so from a .ts file it misses every .svelte caller, however many times the call is repeated. The Svelte server gets the full list, and got it on the first call.

What I’ve changed

The rule from the March post told Claude Code to use LSP over grep for definitions and references. After the tests above I’ve turned that round. This is what’s in my CLAUDE.md now:

1## Code Navigation
2
3- To find where a symbol is defined or used, use grep. When more than
4  one symbol has the same name, search for the name and the module it
5  is imported from, and read the import lines to tell them apart.
6- Use LSP hover when the exact type of a symbol matters, such as an
7  inferred return type or a type that comes from a library.
8- Do not treat an LSP findReferences result as the full list. It
9  misses .svelte callers when run from a .ts file, mocks and other
10  string references, and files changed from the shell. Confirm with
11  grep before deleting or changing a signature.
12- LSP diagnostics arrive on a later turn after an edit, not
13  immediately. No diagnostics straight after an edit does not mean the
14  edit is clean; run the type check before reporting done.

Then I got Claude Code to run the same six questions again with the new rule in, three runs of each:

Old ruleNew rule
Right answers18 of 1818 of 18
Used LSP on the references questions9 of 90 of 9
Used hover on the types questions9 of 99 of 9
Turns per references question4 to 62 to 3
Seconds per references question14 to 1910 to 20

So it does what it says. Grep for where something is used, hover for types, and the references questions take about half the turns.

I’ve also swapped the official typescript-lsp plugin for mine, and added svelte-lsp alongside it.

Is it worth it?

Yes, but not for the reason I gave in March.

  1. Diagnostics are the reason to have it. Type errors come back after an edit without anything having to ask for them, and that’s the one thing here grep can’t do.
  2. For finding where something is used, grep was better. It was right every time, quicker and cheaper, and it found things LSP can’t see.
  3. For types it was a draw. hover gives the exact type in one call, grep got to the same answer.
  4. The first answer of a session is wrong. It’s a known bug. For TypeScript, turning the syntax server off fixed it in every session Claude Code ran.
  5. It doesn’t see everything. Files changed from the shell can be stale, and on a SvelteKit project the TypeScript server never sees the components.
  6. The flag is gone. ENABLE_LSP_TOOL can come out of settings.json.
  7. Svelte takes one small config file.

So the advice in the March post, tell Claude Code to use LSP in place of grep, I’d take back. On this project it made things slower without making them more right.

Going by my history Claude Code had worked that out before I did, it was using grep the whole time.

There's a reactions leaderboard you can check out too.

Sign up for the newsletter

Want to keep up to date with what I'm working on?

Join other developers and sign up for the newsletter.

I care about the protection of your data. Read the Privacy Policy for more info.

Copyright Ā© 2017 - 2026 - All rights reserved Scott Spence