typegraph-mcp 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +17 -0
- package/.cursor-plugin/plugin.json +17 -0
- package/.mcp.json +10 -0
- package/LICENSE +21 -0
- package/README.md +451 -0
- package/benchmark.ts +735 -0
- package/check.ts +459 -0
- package/cli.ts +778 -0
- package/commands/check.md +23 -0
- package/commands/test.md +23 -0
- package/config.ts +50 -0
- package/gemini-extension.json +16 -0
- package/graph-queries.ts +462 -0
- package/hooks/hooks.json +15 -0
- package/module-graph.ts +507 -0
- package/package.json +39 -0
- package/scripts/ensure-deps.sh +34 -0
- package/server.ts +837 -0
- package/skills/code-exploration/SKILL.md +55 -0
- package/skills/dependency-audit/SKILL.md +50 -0
- package/skills/impact-analysis/SKILL.md +52 -0
- package/skills/refactor-safety/SKILL.md +50 -0
- package/skills/tool-selection/SKILL.md +79 -0
- package/smoke-test.ts +500 -0
- package/tsserver-client.ts +413 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TsServerClient — TypeScript Server Protocol Bridge
|
|
3
|
+
*
|
|
4
|
+
* Spawns tsserver as a child process and provides a typed async API
|
|
5
|
+
* for sending commands and receiving responses.
|
|
6
|
+
*
|
|
7
|
+
* Protocol: tsserver uses Content-Length framed JSON over stdin/stdout.
|
|
8
|
+
* Requests are newline-terminated JSON. Responses are matched by request_seq.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
|
|
16
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface Location {
|
|
19
|
+
line: number;
|
|
20
|
+
offset: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DefinitionResult {
|
|
24
|
+
file: string;
|
|
25
|
+
start: Location;
|
|
26
|
+
end: Location;
|
|
27
|
+
contextStart?: Location;
|
|
28
|
+
contextEnd?: Location;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ReferenceEntry {
|
|
32
|
+
file: string;
|
|
33
|
+
start: Location;
|
|
34
|
+
end: Location;
|
|
35
|
+
isDefinition: boolean;
|
|
36
|
+
isWriteAccess: boolean;
|
|
37
|
+
lineText: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface QuickInfoResult {
|
|
41
|
+
displayString: string;
|
|
42
|
+
documentation: string;
|
|
43
|
+
kind: string;
|
|
44
|
+
kindModifiers: string;
|
|
45
|
+
start: Location;
|
|
46
|
+
end: Location;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface NavToItem {
|
|
50
|
+
name: string;
|
|
51
|
+
kind: string;
|
|
52
|
+
file: string;
|
|
53
|
+
start: Location;
|
|
54
|
+
end: Location;
|
|
55
|
+
containerName: string;
|
|
56
|
+
containerKind: string;
|
|
57
|
+
matchKind: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface NavBarItem {
|
|
61
|
+
text: string;
|
|
62
|
+
kind: string;
|
|
63
|
+
kindModifiers: string;
|
|
64
|
+
spans: Array<{ start: Location; end: Location }>;
|
|
65
|
+
childItems: NavBarItem[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const log = (...args: unknown[]) => console.error("[typegraph/tsserver]", ...args);
|
|
71
|
+
|
|
72
|
+
// ─── TsServerClient ─────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
75
|
+
|
|
76
|
+
interface PendingRequest {
|
|
77
|
+
resolve: (value: unknown) => void;
|
|
78
|
+
reject: (reason: Error) => void;
|
|
79
|
+
timer: ReturnType<typeof setTimeout>;
|
|
80
|
+
command: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class TsServerClient {
|
|
84
|
+
private child: ChildProcess | null = null;
|
|
85
|
+
private seq = 0;
|
|
86
|
+
private pending = new Map<number, PendingRequest>();
|
|
87
|
+
private openFiles = new Set<string>();
|
|
88
|
+
private buffer = Buffer.alloc(0);
|
|
89
|
+
private ready = false;
|
|
90
|
+
private shuttingDown = false;
|
|
91
|
+
private restartCount = 0;
|
|
92
|
+
private readonly maxRestarts = 3;
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private readonly projectRoot: string,
|
|
96
|
+
private readonly tsconfigPath: string = "./tsconfig.json"
|
|
97
|
+
) {}
|
|
98
|
+
|
|
99
|
+
// ─── Path Resolution ────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
resolvePath(file: string): string {
|
|
102
|
+
return path.isAbsolute(file) ? file : path.resolve(this.projectRoot, file);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
relativePath(file: string): string {
|
|
106
|
+
return path.relative(this.projectRoot, file);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read a line from a file (1-based line number). Returns trimmed content. */
|
|
110
|
+
readLine(file: string, line: number): string {
|
|
111
|
+
try {
|
|
112
|
+
const absPath = this.resolvePath(file);
|
|
113
|
+
const content = fs.readFileSync(absPath, "utf-8");
|
|
114
|
+
const lines = content.split("\n");
|
|
115
|
+
return lines[line - 1]?.trim() ?? "";
|
|
116
|
+
} catch {
|
|
117
|
+
return "";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
async start(): Promise<void> {
|
|
124
|
+
if (this.child) return;
|
|
125
|
+
|
|
126
|
+
// Resolve tsserver from the TARGET project's node_modules, not the MCP server's
|
|
127
|
+
const require = createRequire(path.resolve(this.projectRoot, "package.json"));
|
|
128
|
+
const tsserverPath = require.resolve("typescript/lib/tsserver.js");
|
|
129
|
+
|
|
130
|
+
log(`Spawning tsserver: ${tsserverPath}`);
|
|
131
|
+
log(`Project root: ${this.projectRoot}`);
|
|
132
|
+
log(`tsconfig: ${this.tsconfigPath}`);
|
|
133
|
+
|
|
134
|
+
this.child = spawn("node", [tsserverPath, "--disableAutomaticTypingAcquisition"], {
|
|
135
|
+
cwd: this.projectRoot,
|
|
136
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
137
|
+
env: { ...process.env, TSS_LOG: undefined },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this.child.stdout!.on("data", (chunk: Buffer) => this.onData(chunk));
|
|
141
|
+
this.child.stderr!.on("data", (chunk: Buffer) => {
|
|
142
|
+
// tsserver stderr is diagnostic info — log it
|
|
143
|
+
const text = chunk.toString().trim();
|
|
144
|
+
if (text) log(`[stderr] ${text}`);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
this.child.on("close", (code) => {
|
|
148
|
+
log(`tsserver exited with code ${code}`);
|
|
149
|
+
this.child = null;
|
|
150
|
+
this.rejectAllPending(new Error(`tsserver exited with code ${code}`));
|
|
151
|
+
this.tryRestart();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.child.on("error", (err) => {
|
|
155
|
+
log(`tsserver error: ${err.message}`);
|
|
156
|
+
this.rejectAllPending(err);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Send configure request to set the project
|
|
160
|
+
await this.sendRequest("configure", {
|
|
161
|
+
preferences: {
|
|
162
|
+
disableSuggestions: true,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Warm up by opening the tsconfig's root file
|
|
167
|
+
const warmStart = performance.now();
|
|
168
|
+
const tsconfigAbs = this.resolvePath(this.tsconfigPath);
|
|
169
|
+
if (fs.existsSync(tsconfigAbs)) {
|
|
170
|
+
await this.sendRequest("compilerOptionsForInferredProjects", {
|
|
171
|
+
options: { allowJs: true, checkJs: false },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
this.ready = true;
|
|
175
|
+
log(`Ready [${(performance.now() - warmStart).toFixed(0)}ms configure]`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
shutdown(): void {
|
|
179
|
+
this.shuttingDown = true;
|
|
180
|
+
if (this.child) {
|
|
181
|
+
this.child.kill("SIGTERM");
|
|
182
|
+
this.child = null;
|
|
183
|
+
}
|
|
184
|
+
this.rejectAllPending(new Error("Client shutdown"));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private tryRestart(): void {
|
|
188
|
+
if (this.shuttingDown) return;
|
|
189
|
+
if (this.restartCount >= this.maxRestarts) {
|
|
190
|
+
log(`Max restarts (${this.maxRestarts}) reached, not restarting`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.restartCount++;
|
|
194
|
+
log(`Restarting tsserver (attempt ${this.restartCount})...`);
|
|
195
|
+
this.buffer = Buffer.alloc(0);
|
|
196
|
+
|
|
197
|
+
// Re-open previously tracked files after restart
|
|
198
|
+
const filesToReopen = [...this.openFiles];
|
|
199
|
+
this.openFiles.clear();
|
|
200
|
+
this.start().then(async () => {
|
|
201
|
+
for (const file of filesToReopen) {
|
|
202
|
+
await this.ensureOpen(file).catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private rejectAllPending(err: Error): void {
|
|
208
|
+
for (const [seq, pending] of this.pending) {
|
|
209
|
+
clearTimeout(pending.timer);
|
|
210
|
+
pending.reject(err);
|
|
211
|
+
}
|
|
212
|
+
this.pending.clear();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Protocol: Parsing ──────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
private onData(chunk: Buffer): void {
|
|
218
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
219
|
+
this.processBuffer();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private processBuffer(): void {
|
|
223
|
+
while (true) {
|
|
224
|
+
// Look for Content-Length header
|
|
225
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
226
|
+
if (headerEnd === -1) return; // Need more data
|
|
227
|
+
|
|
228
|
+
const header = this.buffer.subarray(0, headerEnd).toString("utf-8");
|
|
229
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
230
|
+
if (!match) {
|
|
231
|
+
// Skip malformed data — advance past the header
|
|
232
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const contentLength = parseInt(match[1]!, 10);
|
|
237
|
+
const bodyStart = headerEnd + 4;
|
|
238
|
+
|
|
239
|
+
if (this.buffer.length < bodyStart + contentLength) {
|
|
240
|
+
return; // Need more data for the body
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const bodyBytes = this.buffer.subarray(bodyStart, bodyStart + contentLength);
|
|
244
|
+
this.buffer = this.buffer.subarray(bodyStart + contentLength);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const message = JSON.parse(bodyBytes.toString("utf-8"));
|
|
248
|
+
this.onMessage(message);
|
|
249
|
+
} catch {
|
|
250
|
+
log("Failed to parse tsserver message");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private onMessage(message: {
|
|
256
|
+
type: string;
|
|
257
|
+
request_seq?: number;
|
|
258
|
+
success?: boolean;
|
|
259
|
+
body?: unknown;
|
|
260
|
+
message?: string;
|
|
261
|
+
command?: string;
|
|
262
|
+
}): void {
|
|
263
|
+
if (message.type === "response" && message.request_seq !== undefined) {
|
|
264
|
+
const pending = this.pending.get(message.request_seq);
|
|
265
|
+
if (pending) {
|
|
266
|
+
clearTimeout(pending.timer);
|
|
267
|
+
this.pending.delete(message.request_seq);
|
|
268
|
+
if (message.success) {
|
|
269
|
+
pending.resolve(message.body);
|
|
270
|
+
} else {
|
|
271
|
+
pending.reject(
|
|
272
|
+
new Error(`tsserver ${pending.command} failed: ${message.message ?? "unknown error"}`)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Ignore events (type: "event") — tsserver sends diagnostics, etc.
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ─── Protocol: Sending ──────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
private sendRequest(command: string, args?: object): Promise<unknown> {
|
|
283
|
+
if (!this.child?.stdin?.writable) {
|
|
284
|
+
return Promise.reject(new Error("tsserver not running"));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const seq = ++this.seq;
|
|
288
|
+
const request = {
|
|
289
|
+
seq,
|
|
290
|
+
type: "request",
|
|
291
|
+
command,
|
|
292
|
+
arguments: args,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const timer = setTimeout(() => {
|
|
297
|
+
this.pending.delete(seq);
|
|
298
|
+
reject(new Error(`tsserver ${command} timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
|
299
|
+
}, REQUEST_TIMEOUT_MS);
|
|
300
|
+
|
|
301
|
+
this.pending.set(seq, { resolve, reject, timer, command });
|
|
302
|
+
this.child!.stdin!.write(JSON.stringify(request) + "\n");
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Fire-and-forget — for commands like `open` that may not send a response
|
|
307
|
+
private sendNotification(command: string, args?: object): void {
|
|
308
|
+
if (!this.child?.stdin?.writable) return;
|
|
309
|
+
const seq = ++this.seq;
|
|
310
|
+
const request = { seq, type: "request", command, arguments: args };
|
|
311
|
+
this.child.stdin.write(JSON.stringify(request) + "\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── File Management ───────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
async ensureOpen(file: string): Promise<void> {
|
|
317
|
+
const absPath = this.resolvePath(file);
|
|
318
|
+
if (this.openFiles.has(absPath)) return;
|
|
319
|
+
this.openFiles.add(absPath);
|
|
320
|
+
// `open` is fire-and-forget — tsserver doesn't reliably respond
|
|
321
|
+
this.sendNotification("open", { file: absPath });
|
|
322
|
+
// Small delay to let tsserver process the open before we query
|
|
323
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── Public API ────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
async definition(file: string, line: number, offset: number): Promise<DefinitionResult[]> {
|
|
329
|
+
const absPath = this.resolvePath(file);
|
|
330
|
+
await this.ensureOpen(absPath);
|
|
331
|
+
|
|
332
|
+
const body = (await this.sendRequest("definition", {
|
|
333
|
+
file: absPath,
|
|
334
|
+
line,
|
|
335
|
+
offset,
|
|
336
|
+
})) as DefinitionResult[] | undefined;
|
|
337
|
+
|
|
338
|
+
if (!body || !Array.isArray(body)) return [];
|
|
339
|
+
|
|
340
|
+
return body.map((d) => ({
|
|
341
|
+
...d,
|
|
342
|
+
file: this.relativePath(d.file),
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async references(file: string, line: number, offset: number): Promise<ReferenceEntry[]> {
|
|
347
|
+
const absPath = this.resolvePath(file);
|
|
348
|
+
await this.ensureOpen(absPath);
|
|
349
|
+
|
|
350
|
+
const body = (await this.sendRequest("references", {
|
|
351
|
+
file: absPath,
|
|
352
|
+
line,
|
|
353
|
+
offset,
|
|
354
|
+
})) as { refs?: ReferenceEntry[] } | undefined;
|
|
355
|
+
|
|
356
|
+
if (!body?.refs) return [];
|
|
357
|
+
|
|
358
|
+
return body.refs.map((r) => ({
|
|
359
|
+
...r,
|
|
360
|
+
file: this.relativePath(r.file),
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async quickinfo(file: string, line: number, offset: number): Promise<QuickInfoResult | null> {
|
|
365
|
+
const absPath = this.resolvePath(file);
|
|
366
|
+
await this.ensureOpen(absPath);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const body = (await this.sendRequest("quickinfo", {
|
|
370
|
+
file: absPath,
|
|
371
|
+
line,
|
|
372
|
+
offset,
|
|
373
|
+
})) as QuickInfoResult | undefined;
|
|
374
|
+
|
|
375
|
+
return body ?? null;
|
|
376
|
+
} catch {
|
|
377
|
+
// tsserver returns success:false with "No content available" for positions
|
|
378
|
+
// without type info (e.g., keywords, whitespace). This is expected, not an error.
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async navto(searchValue: string, maxResults = 10, file?: string): Promise<NavToItem[]> {
|
|
384
|
+
// If a file is specified, open it first so tsserver knows about it
|
|
385
|
+
if (file) await this.ensureOpen(file);
|
|
386
|
+
|
|
387
|
+
const args: Record<string, unknown> = {
|
|
388
|
+
searchValue,
|
|
389
|
+
maxResultCount: maxResults,
|
|
390
|
+
};
|
|
391
|
+
if (file) args["file"] = this.resolvePath(file);
|
|
392
|
+
|
|
393
|
+
const body = (await this.sendRequest("navto", args)) as NavToItem[] | undefined;
|
|
394
|
+
|
|
395
|
+
if (!body || !Array.isArray(body)) return [];
|
|
396
|
+
|
|
397
|
+
return body.map((item) => ({
|
|
398
|
+
...item,
|
|
399
|
+
file: this.relativePath(item.file),
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async navbar(file: string): Promise<NavBarItem[]> {
|
|
404
|
+
const absPath = this.resolvePath(file);
|
|
405
|
+
await this.ensureOpen(absPath);
|
|
406
|
+
|
|
407
|
+
const body = (await this.sendRequest("navbar", {
|
|
408
|
+
file: absPath,
|
|
409
|
+
})) as NavBarItem[] | undefined;
|
|
410
|
+
|
|
411
|
+
return body ?? [];
|
|
412
|
+
}
|
|
413
|
+
}
|