Bundling LSP Server di Plugin Claude Code dan Biayanya

Foto oleh Bidgee via Wikimedia Commons (CC BY-SA 2.5 au)
Tidak. Plugin LSP hanya mengonfigurasi koneksinya, jadi siapa pun yang memasangnya harus sudah punya binary seperti gopls atau pyright di PATH-nya. Ada direktori bin di root plugin yang isinya masuk ke PATH tool Bash selama plugin aktif, tetapi plugin yang didistribusikan lewat organisation settings claude.ai tidak boleh punya direktori bin di level teratas. Rencanakan kondisi binary yang belum terpasang, jangan diakali.
Di file .lsp.json pada root plugin, atau langsung di plugin.json lewat field lspServers. Hanya dua field yang wajib per server: command, yaitu executable yang dijalankan, dan extensionToLanguage, yang memetakan ekstensi file ke language identifier yang dipahami server. Sisanya, termasuk args, env, transport, dan timeout, bersifat opsional.
Entri dengan konfigurasi tidak valid akan dilewati, bukan dilaporkan, dan server lain tetap menyala, jadi satu koma berlebih menghasilkan plugin yang terpasang mulus dan tidak memberi apa pun. Jalankan Claude Code dengan flag debug untuk melihat alasannya. Server yang menyala lalu gagal justru muncul di tab Errors pada plugin manager, biasanya dengan pesan bahwa executable-nya tidak ditemukan di PATH.
Sesedikit mungkin, sebatas yang benar-benar dibutuhkan repository, karena tiap entri berarti satu binary lagi yang harus dimiliki pemasangnya, satu process lagi, satu index lagi, dan memori tambahan yang ditahan sepanjang sesi. Untuk bahasa mainstream dokumentasinya menyarankan plugin LSP siap pakai dari marketplace resmi, jadi plugin tim biasanya lebih baik bergantung pada plugin itu dan menyimpan .lsp.json sendiri untuk bahasa internal atau yang belum tercakup.
Bisa. Setel field diagnostics pada server itu ke false, maka error dan warning tidak lagi dikirim ke context Claude setiap kali ada edit, sementara jump-to-definition dan find-references tetap bekerja. Di workspace yang berisik atau salah konfigurasi, ini biasanya pertukaran yang lebih baik daripada mematikan plugin, karena Anda tetap menyimpan setengah bagian LSP yang menjawab pertanyaan struktural.

Foto oleh Bidgee via Wikimedia Commons (CC BY-SA 2.5 au)
Ringkasan Utama
Plugin Claude Code bisa mendeklarasikan language server lewat file .lsp.json di root plugin, sehingga satu tim cukup memasang satu plugin ketimbang mengatur gopls atau pyright satu per satu. Plugin mengirim konfigurasinya, bukan binary-nya, dan setiap server yang dibundel tetap memakan satu process, satu index, dan memori di tiap mesin.
Ada anggota tim yang bertanya kenapa Claude terus mengedit normalise yang salah. Di package kami ada tiga fungsi bernama sama, satu untuk uang, satu untuk alamat, satu untuk string, dan text search tidak bisa membedakannya. Language server bisa. Alasan belum ada yang menjalankannya bukan karena sulit: mengaturnya adalah langkah yang harus diingat setiap orang di setiap mesin, dan tidak ada yang ingat langkah yang kalau dilewatkan pun tidak memunculkan error.
Plugin menghapus setengah dari langkah itu. Plugin bisa mendeklarasikan language server di satu file di root-nya, jadi memasang plugin yang memang sudah dipasang tim sekaligus mengonfigurasi server tersebut. Tulisan ini membahas bentuk deklarasi beserta field-nya sesuai plugins reference Claude Code, server mana yang layak dibundel untuk sebuah stack, berapa biaya empat server dan siapa yang menanggungnya, cara memastikan agent benar-benar memakai hasil LSP atau sudah diam-diam kembali ke text search, dan pertanyaan yang tidak dijawab language server mana pun.
Plugin mengirim konfigurasinya, bukan language server-nya. Satu kalimat itu adalah seluruh model biayanya, dan dokumentasinya menyebutkannya terang-terangan: plugin LSP mengonfigurasi koneksi dan tidak menyertakan server-nya, sehingga siapa pun yang memasang plugin Anda harus sudah punya binary tersebut di mesinnya. Jadi bundling menyebarkan sebuah keputusan dan sebuah ekspektasi. Ia tidak menyebarkan proses instalasi.
# The plugin root. Only plugin.json goes inside .claude-plugin; every other
# component, .lsp.json included, sits at the root.
acme-toolkit/
├── .claude-plugin/
│ └── plugin.json
├── .lsp.json the language servers this plugin declares
├── skills/
└── hooks/
# .lsp.json — the whole file. The top-level keys are server names you choose.
{
"go": {
"command": "gopls",
"args": ["serve"],
"extensionToLanguage": { ".go": "go" }
}
}
# The same thing inline, if you would rather keep one file:
# .claude-plugin/plugin.json
{
"name": "acme-toolkit",
"lspServers": {
"go": {
"command": "gopls",
"args": ["serve"],
"extensionToLanguage": { ".go": "go" }
}
}
}
# What neither form does: install gopls. The docs are explicit that an LSP
# plugin configures the connection and does not include the server, so the
# binary is the installer's problem on every machine.Ada direktori bin di root plugin yang isinya otomatis masuk ke PATH milik tool Bash selama plugin aktif, dan itu terlihat seperti celah. Untuk sebagian audiens celah itu tertutup: plugin yang didistribusikan lewat organisation settings claude.ai tidak boleh menyertakan direktori bin di level teratas. Jadi anggap binary yang belum terpasang sebagai bagian permanen dari desain, lalu buat kondisi itu berisik, dan itu bahasan bagian berikutnya.
Dua field wajib, sisanya opsional. Field command adalah executable yang dijalankan dan harus ada di PATH; extensionToLanguage memetakan ekstensi file ke language identifier yang dipahami server. Field opsional ada karena server yang Anda serahkan ke orang lain butuh lebih dari jalur ideal, dan tiga di antaranya menerima substitusi plugin root, plugin data, dan project directory, yang membuat path bawaan plugin Anda tetap benar di mesin yang belum pernah Anda lihat.
# One server, with the optional fields that exist for other people's machines.
{
"python": {
# A wrapper inside the plugin, resolved wherever the plugin was installed.
"command": "${CLAUDE_PLUGIN_ROOT}/bin/pyright-wrapper.sh",
# Required, alongside command. Both extensions map to the same language id.
"extensionToLanguage": { ".py": "python", ".pyi": "python" },
"env": { "PYTHONPATH": "${CLAUDE_PROJECT_DIR}/services/api" },
# Point the server at the package being worked on rather than the whole
# monorepo. Substitutions work here too.
"workspaceFolder": "${CLAUDE_PROJECT_DIR}/services/api",
# Milliseconds, and a number YOU choose for YOUR repo. This is the field
# that decides whether a cold index finishes in time to be useful.
"startupTimeout": 30000,
"restartOnCrash": true,
"maxRestarts": 3,
"diagnostics": true
}
}
# initializationOptions and settings also exist, but their SHAPE is defined by
# the language server, not by Claude Code. Copy those from that server's own
# documentation; there is nothing to guess at and guessing produces a config
# that is skipped rather than rejected.Field-nya, dan apa yang berubah ketika orang lain memasang plugin Anda:
| Field | Fungsinya | Kenapa penting saat plugin dibagikan |
|---|---|---|
| command, extensionToLanguage | Binary yang dieksekusi, dan ekstensi file yang diklaim server ini | Dua-duanya wajib. Command harus bisa ditemukan di PATH orang yang memasang, bukan di PATH Anda |
| args, env, workspaceFolder | Argumen, environment variable, dan folder yang dianggap server sebagai workspace | Ketiganya mendukung substitusi plugin root, plugin data, dan project directory, jadi wrapper bawaan atau path ke subfolder tetap benar di mana pun plugin dipasang |
| transport | stdio sebagai default, atau socket | Biarkan di default kecuali server yang Anda bungkus memang berkomunikasi lewat socket |
| initializationOptions, settings | Opsi yang dikirim saat inisialisasi, dan setting yang dikirim setelahnya lewat notifikasi workspace configuration | Bentuknya ditentukan oleh language server, bukan oleh Claude Code. Salin dari dokumentasi server tersebut, jangan menebak |
| startupTimeout, shutdownTimeout | Berapa lama menunggu, dalam milidetik, server menyala dan server berhenti | Yang pertama menentukan apakah index dingin di repo besar selesai tepat waktu sehingga masih ada gunanya |
| restartOnCrash, maxRestarts, diagnostics | Apakah server yang crash direstart, berapa kali, dan apakah error dikirim ke context Claude setelah setiap edit | restartOnCrash dan diagnostics keduanya default true. Mematikan diagnostics tetap menyisakan navigasi dan menghapus biaya context per edit |
Kalau command Anda mengarah ke wrapper script di dalam plugin, jaga stdout-nya tetap bersih. Language server harus mengirim log ke stderr, karena stdout hanya untuk pesan protokol dan tidak untuk yang lain. Satu baris echo di awal wrapper merusak frame JSON-RPC pertama, dan yang Anda lihat setelah itu adalah server yang menyala lalu berperilaku seolah tidak pernah selesai inisialisasi.
Yang didapat bukan akurasi yang lebih tinggi, melainkan jenis operasi yang berbeda. Text search mengembalikan kandidat string dan menyerahkan urutannya ke pembacanya; language server memecahkan identifier pada satu posisi dan mengembalikan satu deklarasi yang benar-benar diikat. Bedanya tidak terasa di repo kecil satu package, dan mulai terasa parah di empat tempat berikut:
# One name, three declarations, in a monorepo of six packages.
$ rg -n "export function normalise" packages/
packages/billing/src/money.ts:14:export function normalise(v: Money): Money
packages/geo/src/address.ts:31:export function normalise(a: Address): Address
packages/core/src/text.ts:9:export function normalise(s: string): string
# Text search returns three answers and ranks none of them. And the call site
# you care about imports none of those files:
$ rg -n "normalise" packages/checkout/src/total.ts
packages/checkout/src/total.ts:3:import { normalise } from "@acme/core";
# ^ a barrel package, which re-exports
# text.ts AND, under an alias,
# billing's same-named function
# A language server does not search. It resolves the identifier at that
# position, under the tsconfig that actually applies, and answers once:
# textDocument/definition -> packages/core/src/text.ts:9
# textDocument/references -> only the call sites binding to that one
#
# Same protocol, same two requests, whichever language the server speaks.Tidak ada yang aneh di daftar itu. Itu bentuk normal sebuah codebase setelah dua tahun, dan justru karena itu alasan untuk membundel makin kuat seiring repo menua, bukan makin lemah.

Bundel sesuai stack yang benar-benar dipakai repository, bukan stack yang mungkin suatu hari dibuka di sana. Setiap entri tambahan berarti satu binary lagi yang harus sudah dimiliki pemasangnya, satu process lagi yang dijalankan, satu index lagi yang dibangun, dan satu potong memori lagi yang ditahan sepanjang sesi. Dokumentasinya sendiri juga mengarahkan sebaliknya untuk bahasa mainstream: pasang plugin LSP siap pakai dari marketplace resmi untuk TypeScript, Python, dan Rust, lalu tulis sendiri hanya untuk bahasa yang belum ada plugin-nya.
# The temptation, in a repo that has a bit of everything. Binary names and
# flags come from each server's own docs, not from Claude Code — check them.
{
"ts": { "command": "typescript-language-server", "args": ["--stdio"],
"extensionToLanguage": { ".ts": "typescript" } },
"go": { "command": "gopls", "args": ["serve"],
"extensionToLanguage": { ".go": "go" } },
"python": { "command": "pyright-langserver", "args": ["--stdio"],
"extensionToLanguage": { ".py": "python" } },
"rust": { "command": "rust-analyzer",
"extensionToLanguage": { ".rs": "rust" } }
}
# Four entries is four binaries every installer must already have, four
# processes, four indexes and four resident footprints. For TypeScript, Python
# and Rust the docs point at the pre-built LSP plugins in the official
# marketplace instead — so depend on those and keep this file for the language
# nobody has covered.
# Collision rule worth knowing before you add a second TypeScript entry: if
# two enabled plugins both claim ".ts", the FIRST server registered handles it.
# The loser is not an error and is not reported as a conflict.Untuk plugin tim, artinya susunan yang jujur biasanya berupa dependency, bukan salinan. Biarkan plugin Anda bergantung pada plugin LSP resmi untuk bahasa utama Anda, dan simpan .lsp.json Anda untuk yang tidak umum: DSL internal, bahasa template, atau server yang dirawat platform team Anda. Dengan begitu Anda tidak ikut mengirim salinan kedua dari konfigurasi yang sudah dirawat orang lain dan sedikit lebih basi.
Dua kegagalan di sini memang senyap secara desain. Kalau dua plugin aktif mengklaim ekstensi yang sama, server yang terdaftar lebih dulu yang menanganinya dan yang kedua tidak dilaporkan sebagai konflik. Lalu entri dengan konfigurasi tidak valid akan dilewati, bukan dimunculkan, sementara server lain tetap menyala, jadi satu koma berlebih di .lsp.json Anda menghasilkan plugin yang terpasang mulus dan tidak memberi apa pun.
Sebut biayanya sebagai empat tagihan terpisah, karena keempatnya jatuh ke orang yang berbeda pada waktu yang berbeda. Binary dibayar sekali per mesin oleh orang yang memasang. Startup process dibayar setiap sesi. Indexing dibayar sekali per repository selama masih dingin, dan dibayar tepat ketika agent mengajukan pertanyaan-pertanyaan pertamanya. Memori residen dibayar sepanjang sesi, per server, per workspace folder, dan itulah kenapa empat server yang dibundel berarti empat kali biaya yang hanya Anda ukur satu kali.
# Two fields carry almost all of the cost tuning on a large repo.
{
"rust": {
"command": "rust-analyzer",
"extensionToLanguage": { ".rs": "rust" },
# A cold index on a big workspace can outlast a short startup window. Pick
# the number by watching your own repo. The failure is the quiet one: a
# server that misses the window does not claim .rs, so Claude keeps using
# its built-in search tools and the session looks completely normal.
"startupTimeout": 60000,
# Navigation without the diagnostics push. Errors and warnings stop
# arriving in the context window after every edit; definition, references
# and hover still answer.
"diagnostics": false,
# One crate instead of the whole workspace — the cheapest of the three.
"workspaceFolder": "${CLAUDE_PROJECT_DIR}/crates/api"
}
}Field startupTimeout adalah titik di mana ini berubah dari gangguan kecil menjadi jawaban yang salah. Server yang lewat dari jendela waktunya tidak mengklaim ekstensinya, jadi Claude terus bekerja dengan search tool bawaannya dan sesi itu terlihat normal. Tidak ada yang rusak dan tidak ada yang dilaporkan; agent-nya sekadar kembali menebak dari string, yaitu keadaan yang justru ingin Anda tinggalkan saat memasang server itu.
Coba field diagnostics dulu sebelum memutuskan mencabut plugin. Menyetelnya ke false menghentikan error dan warning dikirim ke context window setiap kali ada edit, sementara jump-to-definition dan find-references tetap jalan. Di workspace yang berisik itu pertukaran yang lebih baik: Anda menyimpan setengah bagian LSP yang menjawab pertanyaan struktural dan membuang setengah yang mengisi context dengan warning yang sudah biasa Anda abaikan.
Tiga pemeriksaan, disusun dari yang paling cepat menemukan masalah. Tab Errors di plugin manager menampilkan language server yang gagal menyala, biasanya dengan pesan bahwa executable-nya tidak ditemukan di PATH. Entri dengan konfigurasi tidak valid tidak muncul di mana pun, jadi kalau tidak terjadi apa-apa dan tidak ada laporan apa pun, jalankan Claude Code dengan flag debug lalu baca alasannya. Setelah file itu diubah, me-reload plugin sudah cukup tanpa restart, dan proses itu juga me-reload language server milik plugin bersama komponen lainnya.
# 1. Did the server start? The /plugin manager's Errors tab lists one that
# failed, typically: Executable not found in $PATH
# An entry with an INVALID configuration is skipped and appears nowhere,
# so when nothing happened and nothing was reported:
$ claude --debug
# 2. After editing .lsp.json — no restart needed. This reloads plugin
# language servers along with skills, agents, hooks and MCP servers:
/reload-plugins
# 3. Before shipping it to anyone:
$ claude plugin validate ./acme-toolkit --strict
# 4. The check I add to a bundled plugin, because a silent fall back to text
# search is worse than a loud failure. hooks/hooks.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/bin/lsp-preflight.sh" }
]
}
]
}
}
# bin/lsp-preflight.sh — names the missing binary and the project's own
# install command, once, instead of letting an hour of answers come from grep.
for b in gopls pyright-langserver; do
command -v "$b" >/dev/null 2>&1 ||
echo "LSP: $b is not on PATH — run 'make dev-tools' to install it"
donePemeriksaan keempat adalah yang benar-benar akan saya tanam di plugin bundel, karena kegagalan di atas senyap dan kembali ke text search secara diam-diam lebih buruk daripada gagal dengan berisik. Sebuah hook saat sesi dimulai yang menguji keberadaan setiap binary yang dideklarasikan di PATH, lalu mencetak perintah instalasi milik proyek itu sendiri kalau ada yang hilang, mengubah satu jam jawaban yang sedikit lebih buruk menjadi satu baris di awal sesi.

LSP menjawab pertanyaan struktural. Di mana ini dideklarasikan, apa saja yang mereferensikannya, ini type apa, apa yang dikeluhkan compiler. Ia tidak punya pendapat soal maksud, dan kanal diagnostics yang bersih adalah sinyal paling menyesatkan di antara semuanya, karena ia melaporkan bahwa kode berhasil dikompilasi lalu rutin dibaca sebagai laporan bahwa perubahannya sudah benar. Empat hal berikut tetap harus dikerjakan oleh pihak yang bukan protokol:
Taruh deklarasi language server di plugin yang memang sudah dipasang tim Anda, karena langkah konfigurasi yang tidak perlu diingat siapa pun adalah satu-satunya jenis yang benar-benar dikerjakan. Deklarasikan satu server untuk bahasa yang sungguh dipakai repository, bergantung pada plugin resmi kalau memang sudah ada, dan tambahkan pemeriksaan di awal sesi yang menyebut binary yang hilang dengan lantang. Lalu pegang bagian yang paling mudah hilang begitu navigasi mulai bekerja: server memberi tahu di mana sebuah symbol berada, dan diagnostics memberi tahu bahwa kode berhasil dikompilasi. Keduanya bukan review.