了解、`planRenameSymbol` 前提で仕様をまとめ直すね。
このままコーディングエージェントに渡して実装に使えるレベルを目指します。
---
# ts-rename-helper-mcp 実装仕様書
## 1. 概要
### 1.1 目的
`@t09tanaka/ts-rename-helper-mcp` は、TypeScript プロジェクト向けの **リネーム/移動専用 MCP サーバ** です。
- TypeScript Language Service を使って、以下の「編集プラン(edit plan)」を返します:
- シンボルのリネーム
- ファイルの移動/リネーム
- ディレクトリの移動/リネーム
- MCP 経由でリクエストを受け取り、**ファイルシステムは一切変更せず**、
- テキスト編集(`textEdits`)
- ファイル移動提案(`fsMoves`)
だけを JSON で返します。
### 1.2 非目標(やらないこと)
- 実際のファイル書き換え・削除・移動
- git コミットやフォーマット実行
- TypeScript 以外の言語
- rename/move 以外のリファクタ機能(将来拡張は基本しない前提)
---
## 2. 技術要件
- Node.js: **v18 以上**
- 言語: TypeScript
- TypeScript コンパイラ: `typescript` v5 系
- MCP サーバ実装: `@modelcontextprotocol/sdk`(Node 用 MCP SDK)
- 対象プロジェクト:
- `projectRoot` 以下に有効な `tsconfig.json` が存在すること
- 主な対象拡張子: `.ts`, `.tsx`(必要なら後で `.mts`, `.cts` に対応)
---
## 3. パッケージ構成
### 3.1 `package.json`(イメージ)
```jsonc
{
"name": "@t09tanaka/ts-rename-helper-mcp",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"bin": {
"ts-rename-helper-mcp": "dist/index.js",
},
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
},
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.1.0", // 実際は最新版
"typescript": "^5.0.0",
},
"devDependencies": {
"typescript": "^5.0.0",
},
}
```
### 3.2 ディレクトリ構造
```text
src/
index.ts // MCP サーバのエントリポイント
tsService.ts // TypeScript Language Service の生成・管理
types.ts // 共通型定義 (Position, Range, TextEdit など)
tools/
planRenameSymbol.ts // planRenameSymbol ツール実装
planFileMove.ts // planFileMove ツール実装
planDirectoryMove.ts // planDirectoryMove ツール実装
```
ビルド後は `dist/` に出力。
---
## 4. 起動・CLI 仕様
### 4.1 MCP クライアントからの起動例
想定される設定例(JSON):
```jsonc
{
"mcpServers": {
"ts-rename-helper": {
"command": "npx",
"args": ["-y", "@t09tanaka/ts-rename-helper-mcp"],
},
},
}
```
もしくはローカルインストール後に:
```bash
npx -y @t09tanaka/ts-rename-helper-mcp
# or
pnpm ts-rename-helper-mcp
# or
node dist/index.js
```
### 4.2 I/O
- MCP プロトコルに従い、**標準入力/標準出力** で JSON メッセージをやり取り
- ログやデバッグ出力は必ず **`stderr` 側** に出すこと
---
## 5. 共通型定義(JSON 形状)
`src/types.ts` に以下のような型を定義する。
```ts
export type Position = {
line: number; // 0-based
character: number; // 0-based
};
export type Range = {
start: Position;
end: Position;
};
export type TextEdit = {
range: Range;
newText: string;
};
export type FileTextEdits = {
filePath: string; // absolute path
textEdits: TextEdit[];
};
export type FsMove = {
from: string; // absolute path
to: string; // absolute path
};
```
MCP のツール返却値は基本的にこれらの型を配列で返す。
---
## 6. TypeScript Language Service 管理
`src/tsService.ts` で TypeScript Language Service を生成する。
### 6.1 API
```ts
import ts from "typescript";
export function createTsService(projectRoot: string): {
service: ts.LanguageService;
projectRoot: string;
// 必要なら parsedConfig なども返してよい
};
```
### 6.2 処理フロー
1. `projectRoot` を絶対パスに正規化
2. `ts.findConfigFile` で `tsconfig.json` を探索
```ts
const configPath = ts.findConfigFile(
projectRoot,
ts.sys.fileExists,
"tsconfig.json"
);
if (!configPath) {
throw new Error("tsconfig.json not found");
}
```
3. 設定ファイル読み込み & パース
```ts
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(configPath)
);
const files = parsed.fileNames; // TypeScript による対象ファイル一覧
```
4. `LanguageServiceHost` 実装
```ts
const host: ts.LanguageServiceHost = {
getScriptFileNames: () => files,
getScriptVersion: () => "0",
getScriptSnapshot: (fileName) => {
if (!fs.existsSync(fileName)) return undefined;
const text = fs.readFileSync(fileName, "utf8");
return ts.ScriptSnapshot.fromString(text);
},
getCurrentDirectory: () => projectRoot,
getCompilationSettings: () => parsed.options,
getDefaultLibFileName: (opts) => ts.getDefaultLibFilePath(opts),
fileExists: ts.sys.fileExists,
readFile: ts.sys.readFile,
readDirectory: ts.sys.readDirectory,
};
```
5. Language Service 生成
```ts
const service = ts.createLanguageService(host);
return { service, projectRoot };
```
### 6.3 キャッシュ
初期実装では、**リクエストごとに `createTsService` を呼んでもよい**(実装をシンプルにするため)。
パフォーマンスが問題になった場合のみ、`projectRoot` を key にしたキャッシュを検討する。
---
## 7. MCP ツール一覧
この MCP サーバは、次の 3 つの tools を公開する:
1. `planRenameSymbol`
2. `planFileMove`
3. `planDirectoryMove`
共通仕様:
- すべて **pure(副作用なし)**
- ファイル書き込み・削除・移動は行わない
- 結果は JSON で `edits` と `fsMoves` を返す(`planRenameSymbol` は `fsMoves` なし)
### 7.1 共通ルール
- 入力 `projectRoot`:
- 受け取った文字列が相対パスなら、`process.cwd()` からの相対とみなし `path.resolve` で絶対パス化。
- 出力では常に絶対パスを使う。
- 入力 `filePath` / `oldPath` / `newPath` / `oldDir` / `newDir`:
- 絶対 or `projectRoot` からの相対パスを受け付ける。
- 内部では `path.isAbsolute` をチェックし、すべて絶対パスに変換。
- 出力の `filePath`, `FsMove.from`, `FsMove.to`:
- **必ず絶対パス**(MCP クライアント側が解釈しやすいように統一)。
- 位置情報:
- `line`, `character` は **0-based**。
- エラー時:
- 例: `tsconfig.json not found`, `file not found`, `cannot rename symbol`, など
- MCP SDK のエラー形式に従って返す(`ToolError` 的なもの)。
---
## 8. ツール別仕様
### 8.1 `planRenameSymbol`
#### 8.1.1 入力
```ts
type PlanRenameSymbolParams = {
projectRoot: string; // 絶対 or 相対
filePath: string; // 絶対 or projectRoot からの相対
line: number; // 0-based
character: number; // 0-based
newName: string;
findInStrings?: boolean; // デフォルト false
findInComments?: boolean; // デフォルト false
};
```
#### 8.1.2 出力
```ts
type PlanRenameSymbolResult =
| {
canRename: false;
reason: string;
}
| {
canRename: true;
edits: FileTextEdits[];
};
```
#### 8.1.3 処理フロー
1. `projectRoot` を絶対パスに正規化。
2. `createTsService(projectRoot)` で `service` を取得。
3. `filePath` を絶対パスに正規化。
4. 対象ファイルの内容を `fs.readFileSync` で取得。
5. `ts.getPositionOfLineAndCharacter` で `(line, character)` → `pos: number` に変換。
```ts
const pos = ts.getPositionOfLineAndCharacter(text, line, character);
```
6. `service.getRenameInfo(absFile, pos)` を呼び出す。
```ts
const info = service.getRenameInfo(absFile, pos);
if (!info.canRename) {
return {
canRename: false,
reason: info.localizedErrorMessage ?? "Cannot rename symbol",
};
}
```
7. `service.findRenameLocations` を呼び出す。
```ts
const locations =
service.findRenameLocations(
absFile,
pos,
params.findInStrings ?? false,
params.findInComments ?? false,
/*providePrefixAndSuffixTextForRename*/ false
) ?? [];
```
8. 各 location (`RenameLocation`) ごとに:
- `loc.fileName` のテキストを読み込む。
- `loc.textSpan` を `Range` に変換:
```ts
const fileText = fs.readFileSync(loc.fileName, "utf8");
const start = ts.getLineAndCharacterOfPosition(
fileText,
loc.textSpan.start
);
const end = ts.getLineAndCharacterOfPosition(
fileText,
loc.textSpan.start + loc.textSpan.length
);
```
- `TextEdit` を構築:
```ts
{
range: {
start: { line: start.line, character: start.character },
end: { line: end.line, character: end.character }
},
newText: newName
}
```
9. `fileName` ごとにまとめて `FileTextEdits[]` を構築し、`canRename: true` とともに返す。
---
### 8.2 `planFileMove`
#### 8.2.1 入力
```ts
type PlanFileMoveParams = {
projectRoot: string; // 絶対 or 相対
oldPath: string; // 元ファイルパス
newPath: string; // 移動先ファイルパス
};
```
#### 8.2.2 出力
```ts
type PlanFileMoveResult = {
edits: FileTextEdits[];
fsMoves: FsMove[]; // 通常は 1 件だけ
};
```
#### 8.2.3 処理フロー
1. `projectRoot` を絶対パスに正規化。
2. `oldPath` / `newPath` を `projectRoot` 基準で絶対パスに正規化。
3. `createTsService(projectRoot)` から `service` を取得。
4. `service.getEditsForFileRename(oldAbs, newAbs)` を呼ぶ。
```ts
const changes = service.getEditsForFileRename(oldAbs, newAbs);
```
5. `FileTextChanges[]` を `FileTextEdits[]` に変換:
- 各 `FileTextChanges`:
- `fileName` → 絶対パス
- `textChanges` の各 `TextChange` について:
- `span.start` / `span.length` を `Range` に変換
- `newText` をそのまま使う
6. `fsMoves` として 1 件だけ追加:
```ts
fsMoves: [
{
from: oldAbs,
to: newAbs,
},
];
```
7. すべてまとめて `PlanFileMoveResult` として返す。
---
### 8.3 `planDirectoryMove`
#### 8.3.1 入力
```ts
type PlanDirectoryMoveParams = {
projectRoot: string; // 絶対 or 相対
oldDir: string; // 元ディレクトリパス
newDir: string; // 移動先ディレクトリパス
};
```
#### 8.3.2 出力
```ts
type PlanDirectoryMoveResult = {
edits: FileTextEdits[];
fsMoves: FsMove[];
};
```
#### 8.3.3 処理フロー
1. `projectRoot`・`oldDir`・`newDir` を絶対パスに正規化。
2. `createTsService(projectRoot)` で `service` と `parsedConfig`(必要なら)を取得。
3. `parsed.fileNames` から `oldDir` 配下のファイルだけを抽出:
```ts
const targetFiles = parsed.fileNames.filter((file) =>
file.startsWith(oldDirAbs)
);
```
4. 各 `oldFile` に対して `newFile` を計算:
```ts
const rel = path.relative(oldDirAbs, oldFile);
const newFile = path.join(newDirAbs, rel);
```
5. 各 `oldFile` / `newFile` ペアについて `service.getEditsForFileRename(oldFile, newFile)` を呼ぶ。
6. 返ってきた `FileTextChanges[]` をすべてマージし、`FileTextEdits[]` に変換。
- 同じ `fileName` に対する `TextChange` は 1 つの `FileTextEdits` にまとめる。
7. `fsMoves` として、すべての `oldFile` / `newFile` ペアを列挙。
8. `PlanDirectoryMoveResult` を返す。
---
## 9. MCP サーバ実装(`src/index.ts`)
`@modelcontextprotocol/sdk` を使って MCP サーバを構築する。擬似コード:
```ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
import { Server } from "@modelcontextprotocol/sdk/server";
import { planRenameSymbolHandler } from "./tools/planRenameSymbol";
import { planFileMoveHandler } from "./tools/planFileMove";
import { planDirectoryMoveHandler } from "./tools/planDirectoryMove";
async function main() {
const server = new Server(
{
name: "ts-rename-helper-mcp",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
}
);
server.tool("planRenameSymbol", async (args) => {
// args を PlanRenameSymbolParams にパースして handler に渡す
return await planRenameSymbolHandler(args);
});
server.tool("planFileMove", async (args) => {
return await planFileMoveHandler(args);
});
server.tool("planDirectoryMove", async (args) => {
return await planDirectoryMoveHandler(args);
});
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
各 handler (`planRenameSymbolHandler` 等) の役割:
- 引数のバリデーション(`projectRoot` が存在するか、など最低限)
- 相対パス → 絶対パス変換
- `tsService` 呼び出し
- 結果を MCP tool のレスポンス JSON として返却
---
## 10. コーディングエージェントへの前提
- このサーバは **「実行」ではなく「プランを返す」** ためのもの。
- エージェント側でやるべきこと:
- `planRenameSymbol` / `planFileMove` / `planDirectoryMove` を呼ぶ
- 返ってきた `edits` を対象ファイルに適用して保存
- `fsMoves` を使ってファイルシステム上で rename/move を行う
- `line` / `character` は 0-based、`filePath` は絶対パスで返ることを前提に処理する。
---
以上が `@t09tanaka/ts-rename-helper-mcp` の実装仕様です。
この仕様通りに作れば、「LSP を直接触れないコーディングエージェントに、TS の rename/move だけを高速で肩代わりさせる」用途にそのまま使えるはずです。