この記事はLIFULL Advent Calendar 2025の記事です。
最近 mcp をちゃんと活用したくなってきており、その周辺について学び直しているので、備忘録を残しておきます。
はじめに
最近、LLM がコードベースを理解する際に LSP(Language Server Protocol) を活用する事例が増えてきました。LSP は「エディタ(クライアント)」と「言語解析エンジン(サーバ)」を JSON-RPC でつなぐ共通プロトコルで、補完・定義ジャンプなどを標準化します。
日々 VSCode を使う中でふと疑問に思いました。「そもそも LSP って、裏側で何をしているんだろう?」と。エディタ操作のたびにどんなメッセージが飛び、どんな解析が行われているのかを意識する機会は多くありません。
この記事のゴール
- initialize → didOpen/didChange → definition の基本フローを自前で送れるようになる
- LSP サーバから返るメッセージ構造を実例で把握する
- LLM から LSP を「ツール」として使うときの設計の勘所を知る
TypeScript を例に、手を動かしながら確認していきます。
ざっくり:LSP とは?
エディタ(クライアント)と、言語ごとの解析エンジン(サーバ)が、JSON-RPC でメッセージをやり取りする仕組みです。
クライアントは「どのファイルのどの位置か」「何を知りたいか」を送り、サーバは定義位置や補完候補、診断結果を返します。
(詳細な役割分担や歴史的な背景は後半で改めて整理します。)
考える前にとりあえず動かしてみる
細かい仕様を読む前に、まず手元で LSP の応答を体感してみます。作業前提は以下です。
- Node.js 18 以上(
npxが使えること) - 任意の作業ディレクトリ(ここではプロジェクトルートを想定)
この上で、LSP に通知とリクエストを順に投げてみましょう。
1. sample.ts を用意
export function greet(name: string) {
return `Hello ${name}`;
}
console.log(greet("world"));
2. 依存をインストール
npm install --save-dev typescript typescript-language-server vscode-jsonrpc
3. Node.js から LSP を直接叩く
以下は「initialize → didOpen → didChange → definition」を最小セットで送るスクリプトです。
rootUri と uri は自分の作業パスに置き換えてください。
#!/usr/bin/env node
import { spawn } from "child_process";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter
} from "vscode-jsonrpc/node.js";
const server = spawn("npx", ["typescript-language-server", "--stdio"]);
const conn = createMessageConnection(
new StreamMessageReader(server.stdout),
new StreamMessageWriter(server.stdin)
);
conn.listen();
let response = await conn.sendRequest("initialize", {
rootUri: "file:///path/to/your/project",
capabilities: {}
});
console.log("=== initialize response ===");
console.log(JSON.stringify(response, null, 2));
/**
=== initialize response ===
{
"capabilities": {
"textDocumentSync": 2,
"completionProvider": {
"triggerCharacters": [...],
"resolveProvider": true
},
"codeActionProvider": true,
"codeLensProvider": {
"resolveProvider": true
},
"definitionProvider": true,
"documentFormattingProvider": true,
"documentRangeFormattingProvider": true,
"documentHighlightProvider": true,
"documentSymbolProvider": true,
"executeCommandProvider": {
"commands": [...]
},
"hoverProvider": true,
"inlayHintProvider": true,
"linkedEditingRangeProvider": false,
"renameProvider": true,
"referencesProvider": true,
"selectionRangeProvider": true,
"signatureHelpProvider": {
"triggerCharacters": [
"(",
",",
"<"
],
"retriggerCharacters": [
")"
]
},
"workspaceSymbolProvider": true,
"implementationProvider": true,
"typeDefinitionProvider": true,
"foldingRangeProvider": true,
"semanticTokensProvider": {
"documentSelector": null,
"legend": {
"tokenTypes": [...],
"tokenModifiers": [...]
},
"full": true,
"range": true
},
"workspace": {
"fileOperations": {
"willRename": {
"filters": [
{
"scheme": "file",
"pattern": {...}
},
{
"scheme": "file",
"pattern": {...}
}
]
}
}
}
}
}
*/
conn.sendNotification("initialized");
conn.sendNotification("textDocument/didOpen", {
textDocument: {
uri: "file:///Users/uetash/tmp/20251214/sample.ts",
languageId: "typescript",
version: 1,
text: `export function greet(name: string) { return \`Hello \${name}\`; }
console.log(greet("world"));`
}
});
// 2行目の "world" を "LSP" に差し替える差分を送る例
conn.sendNotification("textDocument/didChange", {
textDocument: {
uri: "file:///path/to/your/project/sample.ts",
version: 2
},
contentChanges: [
{
range: {
start: { line: 1, character: 18 },
end: { line: 1, character: 23 }
},
text: "LSP"
}
]
});
response = await conn.sendRequest("textDocument/definition", {
textDocument: { uri: "file:///Users/uetash/tmp/20251214/sample.ts" },
position: { line: 1, character: 15 }
});
console.log("=== textDocument/definition response ===");
console.log(JSON.stringify(response, null, 2));
/**
=== textDocument/definition response ===
[
{
"uri": "file:///Users/uetash/tmp/20251214/sample.ts",
"range": {
"start": {
"line": 0,
"character": 16
},
"end": {
"line": 0,
"character": 21
}
}
}
]
*/
conn.dispose();
server.kill();
以下、補足
-
greetの定義位置がresultとして返ってきます。 -
vscode-jsonrpcを使うことで、JSON-RPC のヘッダ処理を意識せずに LSP と通信できます。 - ファイル名を
run-lsp.jsなどで保存し、node run-lsp.jsを実行すると標準出力にレスポンスが表示されます。 - ここでは単発の
didChangeを送っていますが、実際には編集のたびにversionをインクリメントし、contentChangesに差分または全文を載せて送ります。 - ファイル内容は
didOpen時点のテキストを「保存済み状態」として扱っています。
動くものがあると楽しいですね。
LSP とは?(もう少し詳しく)
LSP(Language Server Protocol)とは、
Microsoft が 2016 年に提案した仕様で、エディタとコンパイラ系ツールがバラバラに実装されていた補完・定義ジャンプの API を共通化するために生まれました。標準化された JSON-RPC メッセージセットを定義し、VSCode 以外のエディタでも同じ言語サーバを使い回せるようにしたのが特徴です。
クライアントとサーバの役割分担
LSP はクライアント・サーバモデルで動作します。
-
クライアント(エディタ側)
VSCode、Vim、Emacs など。ファイルの編集状態を管理し、ユーザー操作に応じてサーバへリクエストを送ります。定義ジャンプや補完候補の取得など、「何が知りたいか」を伝える役割です。 -
サーバ(言語解析エンジン)
typescript-language-server、pyright、rust-analyzerなど。言語固有のパース、型解析、シンボル管理を担当し、クライアントの問い合わせに対して解析結果を返します。
両者は stdio(標準入出力)や TCP ソケット経由で JSON-RPC を用いてメッセージをやり取りします。
この分離により、1つの言語サーバを複数のエディタで使い回せるようになり、「エディタ × 言語」ごとに実装するコストが大幅に削減されています。
Request と Notification
LSP の通信は、大きく Request と Notification の 2 種類に分かれます。
Request
レスポンスを期待するメッセージです。id フィールドを持ち、サーバは必ず応答を返します。
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/definition",
"params": {
"textDocument": { "uri": "file:///path/to/your/project/sample.ts" },
"position": { "line": 1, "character": 15 }
}
}
定義位置やホバー情報など、「結果が欲しい」操作に使われます。
Notification
レスポンスを期待しないメッセージです。id を持たず、状態通知のみを行います。
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": "file:///path/to/your/project/sample.ts",
"languageId": "typescript",
"version": 1,
"text": "export function greet(name: string) { return `Hello ${name}`; }"
}
}
}
ファイルが開かれた、編集された、初期化が完了した、などの通知があります。
textDocument/didChange では差分または全文を送り、サーバ側のドキュメントスナップショットを最新に保ちます(多くのサーバはインクリメンタル変更を推奨)。
この使い分けにより、必要な情報だけ同期的に取得し、状態更新は非同期で効率的に伝えられます。
よく使うメソッド早見表
-
textDocument/definition(Request): シンボルの定義位置を取得。 -
textDocument/hover(Request): 型やコメントをホバー表示用に取得。 -
textDocument/completion(Request): 補完候補を取得。completionItem/resolveで詳細を後追いすることも。 -
textDocument/references(Request): 参照箇所を列挙。 -
textDocument/didOpen/textDocument/didChange(Notification): ドキュメント内容の同期。 -
textDocument/publishDiagnostics(Server→Client Notification): 型エラーや lint 結果などの診断情報をプッシュ。
基本フローをなぞる
ハンズオンで送ったメッセージを、プロトコル仕様の流れとして整理します。
initialize と initialized
LSP サーバとの通信の始め方です。まず初期化が必要です。
initialize
クライアントが最初に送信するリクエストです。
プロジェクトのルートやクライアントの capabilities を伝えます。
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"rootUri": "file:///path/to/project",
"capabilities": {}
}
}
capabilities とは、クライアント側が「補完は snippetSupport 付きで受け取れる」「ワークスペース内のシンボル検索に対応している」など、サーバに対して自分の機能レベルを宣言するフィールド群です。サーバはこれを見て、返却するレスポンスの粒度やオプションを調整します。
initialized
initialize のレスポンスを受け取った後に送る通知です。
「初期化が終わったので、これから通常リクエストを送ります」という合図になります。
{
"jsonrpc": "2.0",
"method": "initialized",
"params": {}
}
この 2 ステップが終わらないと、textDocument/definition などのリクエストは受け付けてもらえません。
didOpen / didChange
ファイル内容をサーバ側に届けるための通知です。didOpen で「今この内容を開いたよ」と全文を渡し、以降の編集は didChange で差分または全文を送ります。サーバは受け取った内容を元に、定義解決や補完のための AST を保持し続けます。
didOpen
クライアントがドキュメントを開いた直後に送る通知。text にファイル全文を含める必要があります。ここで渡した内容がサーバ側の「保存済みスナップショット」になります。
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": "file:///path/to/your/project/sample.ts",
"languageId": "typescript",
"version": 1,
"text": "export function greet(name: string) { return `Hello ${name}`; }"
}
}
}
didChange
開いているファイルの内容が変わるたびに送る通知。version をインクリメントしつつ、contentChanges に差分(範囲指定)または全文を載せます。インクリメンタル送信に対応していないサーバの場合は全文送信にフォールバックします。
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": { "uri": "file:///.../sample.ts", "version": 2 },
"contentChanges": [
{
"range": { "start": { "line": 1, "character": 18 }, "end": { "line": 1, "character": 23 } },
"text": "LSP"
}
]
}
}
didOpen で送ったテキストとの差分を累積させるイメージで、サーバ側のドキュメント状態を常に最新に保ちます。
他、LSP のリクエスト / レスポンス例
definition
カーソル位置のシンボル定義を返すリクエスト。IDE の「Go to Definition」に相当します。返ってくるのは URI と範囲(行・桁)で、複数候補が返るケースもあります(オーバーロードや型定義ファイルなど)。
{
"jsonrpc": "2.0",
"id": 3,
"method": "textDocument/definition",
"params": {
"textDocument": { "uri": "file:///.../sample.ts" },
"position": { "line": 1, "character": 15 }
}
}
レスポンス例(配列)は、IDE 側でジャンプ候補として扱われます。
references
あるシンボルを参照している箇所を列挙するリクエスト。includeDeclaration を true にすると定義側も含められます。大量に返る場合があるので、LLM 連携では結果件数の上限やフィルタをかけると扱いやすいです。
{
"jsonrpc": "2.0",
"id": 4,
"method": "textDocument/references",
"params": {
"textDocument": { "uri": "file:///.../sample.ts" },
"position": { "line": 0, "character": 16 },
"context": { "includeDeclaration": true }
}
}
publishDiagnostics
エラーや警告はクライアントがポーリングするのではなく、サーバから textDocument/publishDiagnostics 通知でプッシュされます。didOpen や didChange 後にサーバが再解析すると、最新の診断が送られてきます。
事前にdidChangeでエラーになるような変更を送っておくと、以下のような出力が得られます。
{
"uri": "file:///Users/uetash/tmp/20251214/sample.ts",
"diagnostics": [
{
"range": {
"start": {
"line": 4,
"character": 12
},
"end": {
"line": 4,
"character": 17
}
},
"message": "Cannot find name 'greet'. Did you mean 'grete'?",
"severity": 1,
"code": 2552,
"source": "typescript",
"tags": []
}
]
}
severity は 1=Error, 2=Warning, 3=Information, 4=Hint。クライアント側でアンダーラインや Problems パネルに反映されます。
LLM・ツールから LSP を呼ぶには
「LLM からコードベースを探索させたいが、巨大ファイルは読み込ませたくない」という場面で、LSP は軽量な窓口になります。定義ジャンプや参照取得をツール経由で呼び出せば、LLM は最小限のテキストだけを取得できます。
実装の勘所は次のとおりです。
- HTTP や MCP で JSON-RPC を包み、LLM のツール呼び出し形式に合わせる
- LLM 側には
definition/referencesなど抽象化したツール名だけ見せ、裏で LSP に変換する - 複数言語を扱う場合は拡張子ごとに LSP サーバを切り替える
- 未対応メソッドやサーバ固有の制限があるので、
initializeの capabilities を見てフォールバックを持つ
たとえば「カーソル位置の型を教えて」といったツールを用意し、textDocument/hover を呼ぶだけでも、LLM のコンテキスト消費を大きく抑えられます。
※実はこれに関連してMCPを自作していたのですが、長くなりそうだったのと、
先人が書いたものの方が優秀だったりするので、それについては別記事に譲ろうと思います。
おわりに
CLI から typescript-language-server を直接操作し、JSON-RPC を送受信してみると、LSP が「IDE の魔法」ではなく、かなり素朴なプロトコルであることが見えてきます。
この理解があると、LLM に IDE 的な知見を与える仕組みを、自前で組み立てるイメージも一気に具体化します。
私もこれらのツールをちゃんと使いこなしていけるように、環境を育てていこうと思います。