8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

今更ながら LSP を学ぶ

Last updated at Posted at 2025-12-14

この記事は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」を最小セットで送るスクリプトです。
rootUriuri は自分の作業パスに置き換えてください。

#!/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-serverpyrightrust-analyzer など。言語固有のパース、型解析、シンボル管理を担当し、クライアントの問い合わせに対して解析結果を返します。

両者は stdio(標準入出力)や TCP ソケット経由で JSON-RPC を用いてメッセージをやり取りします。
この分離により、1つの言語サーバを複数のエディタで使い回せるようになり、「エディタ × 言語」ごとに実装するコストが大幅に削減されています。


Request と Notification

LSP の通信は、大きく RequestNotification の 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 通知でプッシュされます。didOpendidChange 後にサーバが再解析すると、最新の診断が送られてきます。

事前に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 的な知見を与える仕組みを、自前で組み立てるイメージも一気に具体化します。

私もこれらのツールをちゃんと使いこなしていけるように、環境を育てていこうと思います。

8
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?