typegraph-mcp 0.9.46 → 0.9.48

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/module-graph.ts CHANGED
@@ -55,8 +55,22 @@ const SKIP_FILES = new Set(["routeTree.gen.ts"]);
55
55
 
56
56
  // ─── File Discovery ──────────────────────────────────────────────────────────
57
57
 
58
- export function discoverFiles(rootDir: string): string[] {
58
+ function normalizeExcludedPaths(rootDir: string, excludedPaths: string[]): string[] {
59
+ return excludedPaths.map((excludedPath) =>
60
+ path.resolve(rootDir, excludedPath)
61
+ );
62
+ }
63
+
64
+ function isExcluded(filePath: string, excludedPaths: string[]): boolean {
65
+ return excludedPaths.some(
66
+ (excludedPath) =>
67
+ filePath === excludedPath || filePath.startsWith(excludedPath + path.sep)
68
+ );
69
+ }
70
+
71
+ export function discoverFiles(rootDir: string, excludedPaths: string[] = []): string[] {
59
72
  const files: string[] = [];
73
+ const normalizedExclusions = normalizeExcludedPaths(rootDir, excludedPaths);
60
74
 
61
75
  function walk(dir: string): void {
62
76
  let entries: fs.Dirent[];
@@ -71,7 +85,9 @@ export function discoverFiles(rootDir: string): string[] {
71
85
  if (SKIP_DIRS.has(entry.name)) continue;
72
86
  // Skip hidden directories (except the root)
73
87
  if (entry.name.startsWith(".") && dir !== rootDir) continue;
74
- walk(path.join(dir, entry.name));
88
+ const childDir = path.join(dir, entry.name);
89
+ if (isExcluded(childDir, normalizedExclusions)) continue;
90
+ walk(childDir);
75
91
  } else if (entry.isFile()) {
76
92
  const name = entry.name;
77
93
  if (SKIP_FILES.has(name)) continue;
@@ -240,11 +256,11 @@ export function resolveProjectImport(
240
256
  }
241
257
 
242
258
  export function createResolver(projectRoot: string, tsconfigPath: string): ResolverFactory {
259
+ const configFile = path.resolve(projectRoot, tsconfigPath);
243
260
  return new ResolverFactory({
244
- tsconfig: {
245
- configFile: path.resolve(projectRoot, tsconfigPath),
246
- references: "auto",
247
- },
261
+ ...(fs.existsSync(configFile)
262
+ ? { tsconfig: { configFile, references: "auto" as const } }
263
+ : {}),
248
264
  extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
249
265
  extensionAlias: {
250
266
  ".js": [".ts", ".tsx", ".js"],
@@ -328,12 +344,13 @@ function buildReverseMap(forward: Map<string, ImportEdge[]>): Map<string, Import
328
344
 
329
345
  export async function buildGraph(
330
346
  projectRoot: string,
331
- tsconfigPath: string
347
+ tsconfigPath: string,
348
+ excludedPaths: string[] = []
332
349
  ): Promise<BuildGraphResult> {
333
350
  const startTime = performance.now();
334
351
 
335
352
  const resolver = createResolver(projectRoot, tsconfigPath);
336
- const fileList = discoverFiles(projectRoot);
353
+ const fileList = discoverFiles(projectRoot, excludedPaths);
337
354
 
338
355
  log(`Discovered ${fileList.length} source files`);
339
356
 
@@ -464,9 +481,11 @@ export function startWatcher(
464
481
  hooks?: {
465
482
  onFileUpdated?: (filePath: string) => void | Promise<void>;
466
483
  onFileDeleted?: (filePath: string) => void | Promise<void>;
467
- }
484
+ },
485
+ excludedPaths: string[] = []
468
486
  ): void {
469
487
  try {
488
+ const normalizedExclusions = normalizeExcludedPaths(projectRoot, excludedPaths);
470
489
  const watcher = fs.watch(
471
490
  projectRoot,
472
491
  { recursive: true },
@@ -489,6 +508,7 @@ export function startWatcher(
489
508
  return;
490
509
 
491
510
  const absPath = path.resolve(projectRoot, filename);
511
+ if (isExcluded(absPath, normalizedExclusions)) return;
492
512
 
493
513
  if (fs.existsSync(absPath)) {
494
514
  // File created or modified
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typegraph-mcp",
3
- "version": "0.9.46",
3
+ "version": "0.9.48",
4
4
  "description": "Type-aware codebase navigation for AI coding agents — 14 MCP tools powered by tsserver + oxc",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,8 +33,9 @@
33
33
  ],
34
34
  "scripts": {
35
35
  "build": "tsup",
36
+ "setup:self": "tsx scripts/self-install.ts",
36
37
  "start": "tsx server.ts",
37
- "test": "tsx smoke-test.ts && tsx export-surface-test.ts && tsx engine-sync-test.ts && tsx install-oxlint-test.ts",
38
+ "test": "tsx tests/smoke-test-selection-test.ts && tsx tests/biome-config-test.ts && tsx tests/self-install-test.ts && tsx tests/tsserver-resolution-test.ts && tsx tests/export-surface-test.ts && tsx tests/engine-sync-test.ts && tsx tests/install-oxlint-test.ts",
38
39
  "check": "tsx check.ts"
39
40
  },
40
41
  "dependencies": {
@@ -42,12 +43,12 @@
42
43
  "@modelcontextprotocol/sdk": "^1.26.0",
43
44
  "oxc-parser": "^0.114.0",
44
45
  "oxc-resolver": "^11.17.1",
46
+ "typescript": "^5.9.3",
45
47
  "zod": "^4.3.6"
46
48
  },
47
49
  "devDependencies": {
48
50
  "@types/node": "^25.3.0",
49
51
  "tsup": "^8.5.0",
50
- "tsx": "^4.21.0",
51
- "typescript": "^5.8.0"
52
+ "tsx": "^4.21.0"
52
53
  }
53
54
  }
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env npx tsx
2
+
3
+ import { spawnSync } from "node:child_process";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ export const SELF_INSTALL_IGNORES = [
9
+ "/plugins/typegraph-mcp/",
10
+ "/.agents/",
11
+ "/.codex/",
12
+ "/AGENTS.md",
13
+ "/CLAUDE.md",
14
+ ] as const;
15
+
16
+ export function isGitWorkTree(projectRoot: string): boolean {
17
+ const result = spawnSync("git", ["-C", projectRoot, "rev-parse", "--is-inside-work-tree"], {
18
+ encoding: "utf-8",
19
+ stdio: ["ignore", "pipe", "ignore"],
20
+ });
21
+ return result.status === 0 && result.stdout.trim() === "true";
22
+ }
23
+
24
+ export function ensureSelfInstallIgnores(projectRoot: string): string[] {
25
+ if (!isGitWorkTree(projectRoot)) return [];
26
+
27
+ const gitignorePath = path.join(projectRoot, ".gitignore");
28
+ const existing = fs.existsSync(gitignorePath)
29
+ ? fs.readFileSync(gitignorePath, "utf-8")
30
+ : "";
31
+ const existingLines = new Set(existing.split(/\r?\n/));
32
+ const added = SELF_INSTALL_IGNORES.filter((pattern) => !existingLines.has(pattern));
33
+
34
+ if (added.length > 0) {
35
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
36
+ fs.writeFileSync(gitignorePath, `${existing}${separator}${added.join("\n")}\n`);
37
+ }
38
+
39
+ return [...added];
40
+ }
41
+
42
+ function ensureAgentMarkers(projectRoot: string): void {
43
+ for (const fileName of ["AGENTS.md", "CLAUDE.md"]) {
44
+ const filePath = path.join(projectRoot, fileName);
45
+ if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "");
46
+ }
47
+ }
48
+
49
+ function main(): void {
50
+ const projectRoot = path.resolve(import.meta.dirname, "..");
51
+ const addedIgnores = ensureSelfInstallIgnores(projectRoot);
52
+
53
+ if (isGitWorkTree(projectRoot)) {
54
+ console.log(
55
+ addedIgnores.length > 0
56
+ ? `Added ${addedIgnores.length} self-install entries to .gitignore`
57
+ : "Self-install artifacts are already ignored"
58
+ );
59
+ } else {
60
+ console.log("Git worktree not found; skipping .gitignore update");
61
+ }
62
+
63
+ ensureAgentMarkers(projectRoot);
64
+
65
+ const tsxPath = path.join(projectRoot, "node_modules/.bin/tsx");
66
+ const cliPath = path.join(projectRoot, "cli.ts");
67
+ const result = spawnSync(tsxPath, [cliPath, "setup", "--yes"], {
68
+ cwd: projectRoot,
69
+ stdio: "inherit",
70
+ });
71
+
72
+ if (result.error) throw result.error;
73
+ process.exit(result.status ?? 1);
74
+ }
75
+
76
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
77
+ main();
78
+ }
package/server.ts CHANGED
@@ -41,7 +41,8 @@ import { resolveConfig } from "./config.js";
41
41
 
42
42
  // ─── Configuration ───────────────────────────────────────────────────────────
43
43
 
44
- const { projectRoot, tsconfigPath } = resolveConfig(import.meta.dirname);
44
+ const { projectRoot, tsconfigPath, toolDir, toolIsEmbedded } = resolveConfig(import.meta.dirname);
45
+ const excludedPaths = toolIsEmbedded ? [toolDir] : [];
45
46
 
46
47
  const log = (...args: unknown[]) => console.error("[typegraph]", ...args);
47
48
 
@@ -1127,29 +1128,35 @@ async function main() {
1127
1128
  // Start tsserver and build module graph concurrently
1128
1129
  const [, graphResult] = await Promise.all([
1129
1130
  client.start(),
1130
- buildGraph(projectRoot, tsconfigPath),
1131
+ buildGraph(projectRoot, tsconfigPath, excludedPaths),
1131
1132
  ]);
1132
1133
 
1133
1134
  moduleGraph = graphResult.graph;
1134
1135
  moduleResolver = graphResult.resolver;
1135
- startWatcher(projectRoot, moduleGraph, graphResult.resolver, {
1136
- onFileUpdated: (filePath) =>
1137
- client.reloadOpenFile(filePath).then(
1138
- (wasOpen) => {
1139
- // Closed files: tsserver's own disk watching decays in long-lived
1140
- // instances; force a projects reload before the next query.
1141
- if (!wasOpen) client.markProjectsDirty();
1142
- },
1143
- (err) => {
1144
- client.markProjectsDirty();
1145
- log(`Failed to reload open file ${relPath(filePath)}:`, err);
1146
- }
1147
- ),
1148
- onFileDeleted: (filePath) => {
1149
- client.closeFile(filePath);
1150
- client.markProjectsDirty();
1136
+ startWatcher(
1137
+ projectRoot,
1138
+ moduleGraph,
1139
+ graphResult.resolver,
1140
+ {
1141
+ onFileUpdated: (filePath) =>
1142
+ client.reloadOpenFile(filePath).then(
1143
+ (wasOpen) => {
1144
+ // Closed files: tsserver's own disk watching decays in long-lived
1145
+ // instances; force a projects reload before the next query.
1146
+ if (!wasOpen) client.markProjectsDirty();
1147
+ },
1148
+ (err) => {
1149
+ client.markProjectsDirty();
1150
+ log(`Failed to reload open file ${relPath(filePath)}:`, err);
1151
+ }
1152
+ ),
1153
+ onFileDeleted: (filePath) => {
1154
+ client.closeFile(filePath);
1155
+ client.markProjectsDirty();
1156
+ },
1151
1157
  },
1152
- });
1158
+ excludedPaths
1159
+ );
1153
1160
 
1154
1161
  const transport = new StdioServerTransport();
1155
1162
  await mcpServer.connect(transport);
package/smoke-test.ts CHANGED
@@ -13,7 +13,11 @@
13
13
 
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
- import { TsServerClient, type NavBarItem } from "./tsserver-client.js";
16
+ import {
17
+ TsServerClient,
18
+ type NavBarItem,
19
+ type QuickInfoResult,
20
+ } from "./tsserver-client.js";
17
21
  import { buildGraph, type ModuleGraph } from "./module-graph.js";
18
22
  import {
19
23
  dependencyTree,
@@ -54,6 +58,81 @@ function findInNavBar(
54
58
  return null;
55
59
  }
56
60
 
61
+ function symbolPositions(
62
+ symbol: NavBarItem,
63
+ sourceLines: string[]
64
+ ): Array<{ line: number; offset: number }> {
65
+ const span = symbol.spans[0]!;
66
+ const positions: Array<{ line: number; offset: number }> = [];
67
+ const lineText = sourceLines[span.start.line - 1];
68
+
69
+ if (lineText) {
70
+ const nameIndex = lineText.indexOf(symbol.text, Math.max(0, span.start.offset - 1));
71
+ if (nameIndex >= 0) {
72
+ positions.push({ line: span.start.line, offset: nameIndex + 1 });
73
+ }
74
+ }
75
+
76
+ if (!positions.some((position) => position.offset === span.start.offset)) {
77
+ positions.push(span.start);
78
+ }
79
+
80
+ return positions;
81
+ }
82
+
83
+ async function probeQuickInfo(
84
+ client: Pick<TsServerClient, "quickinfo">,
85
+ file: string,
86
+ symbol: NavBarItem,
87
+ sourceLines: string[]
88
+ ): Promise<{
89
+ info: QuickInfoResult;
90
+ position: { line: number; offset: number };
91
+ } | null> {
92
+ for (const position of symbolPositions(symbol, sourceLines)) {
93
+ const info = await client.quickinfo(file, position.line, position.offset);
94
+ if (info) return { info, position };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ export async function selectQuickInfoSymbol(
100
+ client: Pick<TsServerClient, "quickinfo">,
101
+ file: string,
102
+ symbols: NavBarItem[],
103
+ sourceLines: string[]
104
+ ): Promise<{
105
+ symbol: NavBarItem;
106
+ info: QuickInfoResult;
107
+ position: { line: number; offset: number };
108
+ } | null> {
109
+ const kindPriority = new Map([
110
+ ["const", 0],
111
+ ["let", 1],
112
+ ["var", 2],
113
+ ["class", 3],
114
+ ["enum", 4],
115
+ ["function", 5],
116
+ ["method", 6],
117
+ ]);
118
+ const concreteSymbols = symbols
119
+ .map((symbol, index) => ({ symbol, index }))
120
+ .filter(({ symbol }) => kindPriority.has(symbol.kind))
121
+ .sort(
122
+ (a, b) =>
123
+ kindPriority.get(a.symbol.kind)! - kindPriority.get(b.symbol.kind)! ||
124
+ a.index - b.index
125
+ )
126
+ .map(({ symbol }) => symbol);
127
+
128
+ for (const symbol of concreteSymbols) {
129
+ const probe = await probeQuickInfo(client, file, symbol, sourceLines);
130
+ if (probe) return { symbol, ...probe };
131
+ }
132
+
133
+ return null;
134
+ }
135
+
57
136
  const SKIP_DIRS = new Set([
58
137
  "node_modules",
59
138
  "dist",
@@ -65,8 +144,9 @@ const SKIP_DIRS = new Set([
65
144
  ]);
66
145
 
67
146
  /** Find a file with imports and exported symbols (good test candidate) */
68
- function findTestFile(rootDir: string): string | null {
147
+ function findTestFile(rootDir: string, excludedPaths: string[] = []): string | null {
69
148
  const candidates: Array<{ file: string; size: number }> = [];
149
+ const normalizedExclusions = excludedPaths.map((excludedPath) => path.resolve(excludedPath));
70
150
 
71
151
  function walk(dir: string, depth: number): void {
72
152
  if (depth > 5 || candidates.length >= 30) return;
@@ -79,7 +159,14 @@ function findTestFile(rootDir: string): string | null {
79
159
  for (const entry of entries) {
80
160
  if (entry.isDirectory()) {
81
161
  if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
82
- walk(path.join(dir, entry.name), depth + 1);
162
+ const childDir = path.join(dir, entry.name);
163
+ if (
164
+ normalizedExclusions.some(
165
+ (excludedPath) =>
166
+ childDir === excludedPath || childDir.startsWith(excludedPath + path.sep)
167
+ )
168
+ ) continue;
169
+ walk(childDir, depth + 1);
83
170
  } else if (entry.isFile()) {
84
171
  const name = entry.name;
85
172
  if (name.endsWith(".d.ts") || name.endsWith(".test.ts") || name.endsWith(".spec.ts"))
@@ -87,7 +174,7 @@ function findTestFile(rootDir: string): string | null {
87
174
  if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
88
175
  try {
89
176
  const stat = fs.statSync(path.join(dir, name));
90
- if (stat.size > 200 && stat.size < 50000) {
177
+ if (stat.size > 0 && stat.size < 50000) {
91
178
  candidates.push({ file: path.join(dir, name), size: stat.size });
92
179
  }
93
180
  } catch {
@@ -120,8 +207,9 @@ function findImporter(graph: ModuleGraph, file: string): string | null {
120
207
  // ─── Main ────────────────────────────────────────────────────────────────────
121
208
 
122
209
  export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestResult> {
123
- const { projectRoot, tsconfigPath } =
210
+ const { projectRoot, tsconfigPath, toolDir, toolIsEmbedded } =
124
211
  configOverride ?? resolveConfig(import.meta.dirname);
212
+ const excludedPaths = toolIsEmbedded ? [toolDir] : [];
125
213
 
126
214
  let passed = 0;
127
215
  let failed = 0;
@@ -152,7 +240,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
152
240
 
153
241
  // ─── Discover a test file ───────────────────────────────────────────────
154
242
 
155
- const testFile = findTestFile(projectRoot);
243
+ const testFile = findTestFile(projectRoot, excludedPaths);
156
244
  if (!testFile) {
157
245
  console.log(" No suitable .ts file found in project. Cannot run smoke tests.");
158
246
  return { passed, failed: failed + 1, skipped };
@@ -171,7 +259,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
171
259
  // Graph build
172
260
  t0 = performance.now();
173
261
  try {
174
- const result = await buildGraph(projectRoot, tsconfigPath);
262
+ const result = await buildGraph(projectRoot, tsconfigPath, excludedPaths);
175
263
  graph = result.graph;
176
264
  const ms = performance.now() - t0;
177
265
  const edgeCount = [...graph.forward.values()].reduce((s, e) => s + e.length, 0);
@@ -309,10 +397,21 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
309
397
  fail("navbar", `No symbols found in ${testFileRel}`, navbarMs);
310
398
  }
311
399
 
312
- // Prefer concrete symbols (const, function, class) interfaces/types may not
313
- // return quickinfo at their span start position (points to the keyword, not the name)
314
- const concreteKinds = new Set(["const", "function", "class", "var", "let", "enum"]);
315
- const sym = allSymbols.find((s) => concreteKinds.has(s.kind)) ?? allSymbols[0];
400
+ // Prefer a concrete symbol that quickinfo can actually resolve. Navbar may
401
+ // include synthetic entries such as "Alpine.data(...) callback" that are
402
+ // useful for navigation but do not correspond to a hoverable source token.
403
+ const source = fs.readFileSync(testFile, "utf-8");
404
+ const sourceLines = source.split(/\r?\n/);
405
+ const quickInfoSelection = await selectQuickInfoSymbol(
406
+ client,
407
+ testFileRel,
408
+ allSymbols,
409
+ sourceLines
410
+ );
411
+ const sym = quickInfoSelection?.symbol ?? allSymbols[0];
412
+ const selectedQuickInfo = quickInfoSelection?.info ?? null;
413
+ const selectedPosition = quickInfoSelection?.position ?? sym?.spans[0]?.start;
414
+
316
415
  if (!sym) {
317
416
  const toolNames = [
318
417
  "find_symbol",
@@ -327,6 +426,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
327
426
  for (const name of toolNames) skip(name, "No symbol discovered");
328
427
  } else {
329
428
  const span = sym.spans[0]!;
429
+ const point = selectedPosition ?? span.start;
330
430
 
331
431
  // find_symbol
332
432
  t0 = performance.now();
@@ -343,7 +443,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
343
443
 
344
444
  // definition
345
445
  t0 = performance.now();
346
- const defs = await client.definition(testFileRel, span.start.line, span.start.offset);
446
+ const defs = await client.definition(testFileRel, point.line, point.offset);
347
447
  if (defs.length > 0) {
348
448
  const def = defs[0]!;
349
449
  pass("definition", `${sym.text} -> ${def.file}:${def.start.line}`, performance.now() - t0);
@@ -353,7 +453,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
353
453
 
354
454
  // references
355
455
  t0 = performance.now();
356
- const refs = await client.references(testFileRel, span.start.line, span.start.offset);
456
+ const refs = await client.references(testFileRel, point.line, point.offset);
357
457
  const refFiles = new Set(refs.map((r) => r.file));
358
458
  pass(
359
459
  "references",
@@ -363,7 +463,10 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
363
463
 
364
464
  // type_info
365
465
  t0 = performance.now();
366
- let info = await client.quickinfo(testFileRel, span.start.line, span.start.offset);
466
+ let info = selectedQuickInfo;
467
+ if (!info) {
468
+ info = await client.quickinfo(testFileRel, point.line, point.offset);
469
+ }
367
470
  // Span start may point to a keyword (class, function) — retry at the name position
368
471
  if (!info && defs.length > 0) {
369
472
  const def = defs[0]!;
@@ -376,7 +479,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
376
479
  : info.displayString;
377
480
  pass("type_info", typeStr, performance.now() - t0);
378
481
  } else {
379
- fail("type_info", `No type info for ${sym.text}`, performance.now() - t0);
482
+ skip("type_info", `No discovered symbol returned type info`);
380
483
  }
381
484
 
382
485
  // navigate_to
@@ -416,7 +519,6 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
416
519
 
417
520
  // trace_chain — follow an import to its source
418
521
  t0 = performance.now();
419
- const source = fs.readFileSync(testFile, "utf-8");
420
522
  const importMatch = source.match(/^import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/m);
421
523
  if (importMatch) {
422
524
  const firstName = importMatch[1]!
@@ -488,4 +590,3 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
488
590
 
489
591
  return { passed, failed, skipped };
490
592
  }
491
-
@@ -80,6 +80,43 @@ interface PendingRequest {
80
80
  command: string;
81
81
  }
82
82
 
83
+ export interface TsServerResolution {
84
+ path: string;
85
+ version: string;
86
+ source: "project" | "typegraph";
87
+ projectVersion?: string;
88
+ }
89
+
90
+ function readPackageVersion(packagePath: string): string {
91
+ const pkg = JSON.parse(fs.readFileSync(packagePath, "utf-8")) as { version?: string };
92
+ return pkg.version ?? "unknown";
93
+ }
94
+
95
+ /** Resolve legacy tsserver, falling back when the project uses TypeScript 7's LSP runtime. */
96
+ export function resolveTsServer(projectRoot: string): TsServerResolution {
97
+ const projectRequire = createRequire(path.resolve(projectRoot, "package.json"));
98
+ let projectVersion: string | undefined;
99
+
100
+ try {
101
+ const packagePath = projectRequire.resolve("typescript/package.json");
102
+ projectVersion = readPackageVersion(packagePath);
103
+ const serverPath = projectRequire.resolve("typescript/lib/tsserver.js");
104
+ return { path: serverPath, version: projectVersion, source: "project" };
105
+ } catch {
106
+ // TypeScript 7 is installed without the legacy tsserver entrypoint.
107
+ }
108
+
109
+ const toolRequire = createRequire(import.meta.url);
110
+ const packagePath = toolRequire.resolve("typescript/package.json");
111
+ const serverPath = toolRequire.resolve("typescript/lib/tsserver.js");
112
+ return {
113
+ path: serverPath,
114
+ version: readPackageVersion(packagePath),
115
+ source: "typegraph",
116
+ projectVersion,
117
+ };
118
+ }
119
+
83
120
  export class TsServerClient {
84
121
  private child: ChildProcess | null = null;
85
122
  private seq = 0;
@@ -124,11 +161,15 @@ export class TsServerClient {
124
161
  async start(): Promise<void> {
125
162
  if (this.child) return;
126
163
 
127
- // Resolve tsserver from the TARGET project's node_modules, not the MCP server's
128
- const require = createRequire(path.resolve(this.projectRoot, "package.json"));
129
- const tsserverPath = require.resolve("typescript/lib/tsserver.js");
164
+ const resolution = resolveTsServer(this.projectRoot);
165
+ const tsserverPath = resolution.path;
130
166
 
131
- log(`Spawning tsserver: ${tsserverPath}`);
167
+ const fallbackNote = resolution.projectVersion
168
+ ? `; project TypeScript ${resolution.projectVersion} has no legacy tsserver`
169
+ : "";
170
+ log(
171
+ `Spawning tsserver: ${tsserverPath} (TypeScript ${resolution.version}, ${resolution.source}${fallbackNote})`
172
+ );
132
173
  log(`Project root: ${this.projectRoot}`);
133
174
  log(`tsconfig: ${this.tsconfigPath}`);
134
175
 
@@ -164,14 +205,11 @@ export class TsServerClient {
164
205
  },
165
206
  });
166
207
 
167
- // Warm up by opening the tsconfig's root file
208
+ // Keep inferred projects useful when the target has no tsconfig or a file sits outside it.
168
209
  const warmStart = performance.now();
169
- const tsconfigAbs = this.resolvePath(this.tsconfigPath);
170
- if (fs.existsSync(tsconfigAbs)) {
171
- await this.sendRequest("compilerOptionsForInferredProjects", {
172
- options: { allowJs: true, checkJs: false },
173
- });
174
- }
210
+ await this.sendRequest("compilerOptionsForInferredProjects", {
211
+ options: { allowJs: true, checkJs: false },
212
+ });
175
213
  this.ready = true;
176
214
  log(`Ready [${(performance.now() - warmStart).toFixed(0)}ms configure]`);
177
215
  }