typegraph-mcp 0.9.38 → 0.9.40

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
@@ -216,7 +216,7 @@ function distToSource(resolvedPath: string, projectRoot: string): string {
216
216
  return resolvedPath;
217
217
  }
218
218
 
219
- function resolveImport(
219
+ export function resolveProjectImport(
220
220
  resolver: ResolverFactory,
221
221
  fromDir: string,
222
222
  specifier: string,
@@ -287,7 +287,7 @@ function buildForwardEdges(
287
287
  const fromDir = path.dirname(filePath);
288
288
 
289
289
  for (const raw of rawImports) {
290
- const target = resolveImport(resolver, fromDir, raw.specifier, projectRoot);
290
+ const target = resolveProjectImport(resolver, fromDir, raw.specifier, projectRoot);
291
291
  if (target) {
292
292
  edges.push({
293
293
  target,
@@ -397,7 +397,7 @@ export function updateFile(
397
397
  const fromDir = path.dirname(filePath);
398
398
  const newEdges: ImportEdge[] = [];
399
399
  for (const raw of rawImports) {
400
- const target = resolveImport(resolver, fromDir, raw.specifier, projectRoot);
400
+ const target = resolveProjectImport(resolver, fromDir, raw.specifier, projectRoot);
401
401
  if (target) {
402
402
  newEdges.push({
403
403
  target,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typegraph-mcp",
3
- "version": "0.9.38",
3
+ "version": "0.9.40",
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",
@@ -34,7 +34,7 @@
34
34
  "scripts": {
35
35
  "build": "tsup",
36
36
  "start": "tsx server.ts",
37
- "test": "tsx smoke-test.ts",
37
+ "test": "tsx smoke-test.ts && tsx export-surface-test.ts && tsx install-oxlint-test.ts",
38
38
  "check": "tsx check.ts"
39
39
  },
40
40
  "dependencies": {
package/server.ts CHANGED
@@ -17,9 +17,16 @@
17
17
 
18
18
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
19
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { parseSync } from "oxc-parser";
21
+ import type { ResolverFactory } from "oxc-resolver";
20
22
  import { z } from "zod";
21
23
  import { TsServerClient, type NavBarItem } from "./tsserver-client.js";
22
- import { buildGraph, startWatcher, type ModuleGraph } from "./module-graph.js";
24
+ import {
25
+ buildGraph,
26
+ resolveProjectImport,
27
+ startWatcher,
28
+ type ModuleGraph,
29
+ } from "./module-graph.js";
23
30
  import {
24
31
  dependencyTree,
25
32
  dependents,
@@ -44,6 +51,7 @@ const client = new TsServerClient(projectRoot, tsconfigPath);
44
51
 
45
52
  // Module graph — initialized in main(), used by graph tools
46
53
  let moduleGraph: ModuleGraph;
54
+ let moduleResolver: ResolverFactory;
47
55
 
48
56
  const mcpServer = new McpServer({
49
57
  name: "typegraph",
@@ -167,6 +175,347 @@ async function resolveParams(params: {
167
175
  return { error: "Either line+column or symbol must be provided" };
168
176
  }
169
177
 
178
+ type ModuleExportRecord = {
179
+ symbol: string;
180
+ kind: string;
181
+ line: number;
182
+ type: string | null;
183
+ exportKind: "value" | "type";
184
+ isTypeOnly: boolean;
185
+ isNamespace: boolean;
186
+ source: "local" | "re-export" | "star-re-export";
187
+ from: string | null;
188
+ definedIn: string;
189
+ definedLine: number | null;
190
+ };
191
+
192
+ type StaticExportEntry = ReturnType<typeof parseSync>["module"]["staticExports"][number]["entries"][number];
193
+
194
+ const exportKinds = new Set([
195
+ "function",
196
+ "const",
197
+ "class",
198
+ "interface",
199
+ "type",
200
+ "enum",
201
+ "var",
202
+ "let",
203
+ "method",
204
+ ]);
205
+
206
+ function exportPriority(source: ModuleExportRecord["source"]): number {
207
+ switch (source) {
208
+ case "local":
209
+ return 3;
210
+ case "re-export":
211
+ return 2;
212
+ case "star-re-export":
213
+ return 1;
214
+ }
215
+ }
216
+
217
+ function exportKey(item: Pick<ModuleExportRecord, "symbol" | "exportKind">): string {
218
+ return `${item.symbol}:${item.exportKind}`;
219
+ }
220
+
221
+ function sameExportOrigin(a: ModuleExportRecord, b: ModuleExportRecord): boolean {
222
+ return (
223
+ a.symbol === b.symbol &&
224
+ a.exportKind === b.exportKind &&
225
+ a.from === b.from &&
226
+ a.definedIn === b.definedIn &&
227
+ a.definedLine === b.definedLine
228
+ );
229
+ }
230
+
231
+ function kindImpliesTypeOnly(kind: string): boolean {
232
+ return kind === "type" || kind === "interface";
233
+ }
234
+
235
+ function normalizeExportKindLabel(
236
+ kind: string,
237
+ exportKind: ModuleExportRecord["exportKind"]
238
+ ): string {
239
+ if (exportKind === "type" && !kindImpliesTypeOnly(kind)) {
240
+ return "type";
241
+ }
242
+ return kind;
243
+ }
244
+
245
+ function upsertExport(
246
+ map: Map<string, ModuleExportRecord>,
247
+ conflicts: Set<string>,
248
+ nextExport: ModuleExportRecord
249
+ ): void {
250
+ const key = exportKey(nextExport);
251
+ if (conflicts.has(key)) {
252
+ if (nextExport.source === "star-re-export") return;
253
+ conflicts.delete(key);
254
+ map.set(key, nextExport);
255
+ return;
256
+ }
257
+
258
+ const existing = map.get(key);
259
+ if (
260
+ existing &&
261
+ existing.source === "star-re-export" &&
262
+ nextExport.source === "star-re-export" &&
263
+ !sameExportOrigin(existing, nextExport)
264
+ ) {
265
+ map.delete(key);
266
+ conflicts.add(key);
267
+ return;
268
+ }
269
+
270
+ if (!existing || exportPriority(nextExport.source) > exportPriority(existing.source)) {
271
+ map.set(key, nextExport);
272
+ }
273
+ }
274
+
275
+ function offsetToLineColumn(source: string, offset: number | null | undefined): {
276
+ line: number;
277
+ column: number;
278
+ } {
279
+ const safeOffset = Math.max(0, Math.min(offset ?? 0, source.length));
280
+ const prefix = source.slice(0, safeOffset);
281
+ const lines = prefix.split("\n");
282
+ return {
283
+ line: lines.length,
284
+ column: (lines.at(-1)?.length ?? 0) + 1,
285
+ };
286
+ }
287
+
288
+ function normalizeExistingPath(filePath: string): string {
289
+ const resolved = path.resolve(filePath);
290
+ try {
291
+ return fs.realpathSync.native(resolved);
292
+ } catch {
293
+ return resolved;
294
+ }
295
+ }
296
+
297
+ const normalizedProjectRoot = normalizeExistingPath(projectRoot);
298
+
299
+ function projectPath(file: string): string {
300
+ return path.isAbsolute(file) ? relPath(file) : file;
301
+ }
302
+
303
+ function exportSymbol(entry: StaticExportEntry): string | null {
304
+ if (entry.exportName.kind === "Default") return "default";
305
+ return entry.exportName.name ?? entry.localName.name ?? entry.importName.name;
306
+ }
307
+
308
+ function exportLookupOffset(entry: StaticExportEntry): number | null | undefined {
309
+ if ((entry as { moduleRequest?: { value: string } }).moduleRequest) {
310
+ return entry.importName.start ?? entry.exportName.start ?? entry.start;
311
+ }
312
+ if (entry.exportName.kind === "Default") {
313
+ return entry.localName.start ?? entry.exportName.start ?? entry.start;
314
+ }
315
+ return entry.exportName.start ?? entry.localName.start ?? entry.start;
316
+ }
317
+
318
+ async function resolveExportMetadata(
319
+ file: string,
320
+ line: number,
321
+ column: number,
322
+ fallbackKind: string
323
+ ): Promise<{
324
+ kind: string;
325
+ type: string | null;
326
+ definedIn: string;
327
+ definedLine: number | null;
328
+ }> {
329
+ const defs = await client.definition(file, line, column);
330
+ const def = defs[0] ?? null;
331
+
332
+ let info = await client.quickinfo(file, line, column);
333
+ if ((!info || info.kind === "alias") && def) {
334
+ info = (await client.quickinfo(def.file, def.start.line, def.start.offset)) ?? info;
335
+ }
336
+
337
+ return {
338
+ kind: info?.kind ?? fallbackKind,
339
+ type: info?.displayString ?? null,
340
+ definedIn: projectPath(def?.file ?? file),
341
+ definedLine: def?.start.line ?? null,
342
+ };
343
+ }
344
+
345
+ async function getModuleExports(
346
+ file: string,
347
+ visited = new Set<string>()
348
+ ): Promise<ModuleExportRecord[]> {
349
+ const relFile = path.isAbsolute(file) ? relPath(file) : file;
350
+ const absFile = normalizeExistingPath(client.resolvePath(relFile));
351
+ if (visited.has(absFile)) return [];
352
+
353
+ const nextVisited = new Set(visited);
354
+ nextVisited.add(absFile);
355
+
356
+ const exportMap = new Map<string, ModuleExportRecord>();
357
+ const conflictingStarExports = new Set<string>();
358
+
359
+ let source: string;
360
+ try {
361
+ source = fs.readFileSync(absFile, "utf-8");
362
+ } catch {
363
+ return [...exportMap.values()];
364
+ }
365
+
366
+ let parsed: ReturnType<typeof parseSync>;
367
+ try {
368
+ parsed = parseSync(absFile, source);
369
+ } catch {
370
+ return [...exportMap.values()];
371
+ }
372
+
373
+ for (const exp of parsed.module.staticExports) {
374
+ for (const entry of exp.entries) {
375
+ const moduleRequest = (entry as { moduleRequest?: { value: string } }).moduleRequest;
376
+ if (!moduleRequest) continue;
377
+
378
+ const targetFile = resolveProjectImport(
379
+ moduleResolver,
380
+ path.dirname(absFile),
381
+ moduleRequest.value,
382
+ projectRoot
383
+ );
384
+
385
+ const exportLoc = offsetToLineColumn(
386
+ source,
387
+ entry.exportName.start ?? entry.localName.start ?? entry.importName.start ?? entry.start
388
+ );
389
+ const importKind = entry.importName.kind as string;
390
+ const exportKind = entry.exportName.kind as string;
391
+
392
+ if (importKind === "AllButDefault" && exportKind === "None") {
393
+ if (!targetFile) continue;
394
+ const nestedExports = await getModuleExports(targetFile, nextVisited);
395
+ for (const nested of nestedExports) {
396
+ if (nested.symbol === "default") continue;
397
+ const starExportKind: ModuleExportRecord["exportKind"] = entry.isType
398
+ ? "type"
399
+ : nested.exportKind;
400
+ upsertExport(exportMap, conflictingStarExports, {
401
+ ...nested,
402
+ line: exportLoc.line,
403
+ exportKind: starExportKind,
404
+ isTypeOnly: starExportKind === "type",
405
+ source: "star-re-export",
406
+ from: relPath(targetFile),
407
+ });
408
+ }
409
+ continue;
410
+ }
411
+
412
+ const symbol = exportSymbol(entry);
413
+ if (!symbol) continue;
414
+
415
+ const importedSymbol =
416
+ importKind === "Default"
417
+ ? "default"
418
+ : importKind === "Name"
419
+ ? entry.importName.name
420
+ : null;
421
+ const nestedMatch =
422
+ targetFile && importedSymbol
423
+ ? (await getModuleExports(targetFile, nextVisited)).find(
424
+ (item) => item.symbol === importedSymbol
425
+ ) ?? null
426
+ : null;
427
+
428
+ const lookupLoc = offsetToLineColumn(
429
+ source,
430
+ exportLookupOffset(entry)
431
+ );
432
+ const metadata = await resolveExportMetadata(
433
+ relFile,
434
+ lookupLoc.line,
435
+ lookupLoc.column,
436
+ importKind === "All" ? "namespace" : "alias"
437
+ );
438
+ const resolvedExportKind: ModuleExportRecord["exportKind"] =
439
+ entry.isType ||
440
+ nestedMatch?.exportKind === "type" ||
441
+ kindImpliesTypeOnly(nestedMatch?.kind ?? metadata.kind)
442
+ ? "type"
443
+ : "value";
444
+ const resolvedKind = normalizeExportKindLabel(
445
+ nestedMatch?.kind ?? metadata.kind,
446
+ resolvedExportKind
447
+ );
448
+
449
+ upsertExport(exportMap, conflictingStarExports, {
450
+ symbol,
451
+ kind: resolvedKind,
452
+ line: exportLoc.line,
453
+ type: nestedMatch?.type ?? metadata.type,
454
+ exportKind: resolvedExportKind,
455
+ isTypeOnly: resolvedExportKind === "type",
456
+ isNamespace: importKind === "All",
457
+ source: "re-export",
458
+ from: targetFile ? relPath(targetFile) : moduleRequest.value,
459
+ definedIn: nestedMatch?.definedIn ?? metadata.definedIn,
460
+ definedLine: nestedMatch?.definedLine ?? metadata.definedLine,
461
+ });
462
+ continue;
463
+ }
464
+
465
+ for (const entry of exp.entries) {
466
+ const moduleRequest = (entry as { moduleRequest?: { value: string } }).moduleRequest;
467
+ if (moduleRequest) continue;
468
+
469
+ const symbol = exportSymbol(entry);
470
+ if (!symbol) continue;
471
+
472
+ const exportLoc = offsetToLineColumn(
473
+ source,
474
+ entry.exportName.start ?? entry.localName.start ?? entry.start
475
+ );
476
+ const lookupLoc = offsetToLineColumn(source, exportLookupOffset(entry));
477
+ const metadata = await resolveExportMetadata(
478
+ relFile,
479
+ lookupLoc.line,
480
+ lookupLoc.column,
481
+ entry.isType ? "type" : "value"
482
+ );
483
+ const resolvedExportKind: ModuleExportRecord["exportKind"] =
484
+ entry.isType || kindImpliesTypeOnly(metadata.kind) ? "type" : "value";
485
+ const resolvedKind = normalizeExportKindLabel(metadata.kind, resolvedExportKind);
486
+
487
+ // Skip navbar/import alias noise — only keep actual exported declaration kinds.
488
+ if (
489
+ resolvedExportKind === "value" &&
490
+ symbol !== "default" &&
491
+ !exportKinds.has(resolvedKind) &&
492
+ resolvedKind !== "namespace" &&
493
+ resolvedKind !== "class"
494
+ ) {
495
+ continue;
496
+ }
497
+
498
+ upsertExport(exportMap, conflictingStarExports, {
499
+ symbol,
500
+ kind: resolvedKind,
501
+ line: exportLoc.line,
502
+ type: metadata.type,
503
+ exportKind: resolvedExportKind,
504
+ isTypeOnly: resolvedExportKind === "type",
505
+ isNamespace: false,
506
+ source: "local",
507
+ from: null,
508
+ definedIn: relFile,
509
+ definedLine: resolvedExportKind === "type" ? exportLoc.line : metadata.definedLine,
510
+ });
511
+ }
512
+ }
513
+
514
+ return [...exportMap.values()].sort(
515
+ (a, b) => a.line - b.line || a.symbol.localeCompare(b.symbol)
516
+ );
517
+ }
518
+
170
519
  // ─── Tool 1: ts_find_symbol ─────────────────────────────────────────────────
171
520
 
172
521
  mcpServer.tool(
@@ -499,68 +848,38 @@ mcpServer.tool(
499
848
 
500
849
  mcpServer.tool(
501
850
  "ts_module_exports",
502
- "List all exported symbols from a module with their resolved types. Gives an at-a-glance understanding of what a file provides.",
851
+ "List all exported symbols from a module with their resolved types, including re-exports when possible. Gives an at-a-glance understanding of what a file provides.",
503
852
  {
504
853
  file: z.string().describe("File to inspect"),
505
854
  },
506
855
  async ({ file }) => {
507
- const bar = await client.navbar(file);
508
- if (bar.length === 0) {
509
- return {
510
- content: [
511
- {
512
- type: "text" as const,
513
- text: JSON.stringify({ error: `No symbols found in ${file}` }),
514
- },
515
- ],
516
- };
517
- }
518
-
519
- // The top-level navbar item is the module itself — its children are exports
520
- const moduleItem = bar.find((item) => item.kind === "module");
521
- const topItems = moduleItem?.childItems ?? bar;
522
-
523
- // Filter to meaningful declarations (skip imports, local vars, etc.)
524
- const exportKinds = new Set([
525
- "function",
526
- "const",
527
- "class",
528
- "interface",
529
- "type",
530
- "enum",
531
- "var",
532
- "let",
533
- "method",
534
- ]);
535
- const candidates = topItems.filter((item) => exportKinds.has(item.kind));
536
-
537
- const exports: Array<{
538
- symbol: string;
539
- kind: string;
540
- line: number;
541
- type: string | null;
542
- }> = [];
543
-
544
- for (const item of candidates) {
545
- if (!item.spans[0]) continue;
546
- const span = item.spans[0];
547
-
548
- // Get type info for this symbol
549
- const info = await client.quickinfo(file, span.start.line, span.start.offset);
550
-
551
- exports.push({
552
- symbol: item.text,
553
- kind: item.kind,
554
- line: span.start.line,
555
- type: info?.displayString ?? null,
556
- });
557
- }
856
+ const exports = await getModuleExports(file);
857
+ const localCount = exports.filter((item) => item.source === "local").length;
858
+ const reExportCount = exports.length - localCount;
859
+ const typeOnlyCount = exports.filter((item) => item.isTypeOnly).length;
860
+ const valueCount = exports.length - typeOnlyCount;
861
+ const namespaceExportCount = exports.filter((item) => item.isNamespace).length;
862
+ const hasLocalRuntimeExports = exports.some(
863
+ (item) => item.source === "local" && !item.isTypeOnly
864
+ );
865
+ const isPrimarilyBarrel = exports.length > 0 && localCount < reExportCount;
558
866
 
559
867
  return {
560
868
  content: [
561
869
  {
562
870
  type: "text" as const,
563
- text: JSON.stringify({ file, exports, count: exports.length }),
871
+ text: JSON.stringify({
872
+ file,
873
+ exports,
874
+ count: exports.length,
875
+ localCount,
876
+ reExportCount,
877
+ typeOnlyCount,
878
+ valueCount,
879
+ namespaceExportCount,
880
+ hasLocalRuntimeExports,
881
+ isPrimarilyBarrel,
882
+ }),
564
883
  },
565
884
  ],
566
885
  };
@@ -571,7 +890,7 @@ mcpServer.tool(
571
890
 
572
891
  /** Convert an absolute path to project-relative */
573
892
  function relPath(absPath: string): string {
574
- return path.relative(projectRoot, absPath);
893
+ return path.relative(normalizedProjectRoot, normalizeExistingPath(absPath));
575
894
  }
576
895
 
577
896
  /** Convert a relative or absolute path to absolute */
@@ -812,6 +1131,7 @@ async function main() {
812
1131
  ]);
813
1132
 
814
1133
  moduleGraph = graphResult.graph;
1134
+ moduleResolver = graphResult.resolver;
815
1135
  startWatcher(projectRoot, moduleGraph, graphResult.resolver);
816
1136
 
817
1137
  const transport = new StdioServerTransport();