typegraph-mcp 0.9.47 → 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.
@@ -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
@@ -144,8 +144,9 @@ const SKIP_DIRS = new Set([
144
144
  ]);
145
145
 
146
146
  /** Find a file with imports and exported symbols (good test candidate) */
147
- function findTestFile(rootDir: string): string | null {
147
+ function findTestFile(rootDir: string, excludedPaths: string[] = []): string | null {
148
148
  const candidates: Array<{ file: string; size: number }> = [];
149
+ const normalizedExclusions = excludedPaths.map((excludedPath) => path.resolve(excludedPath));
149
150
 
150
151
  function walk(dir: string, depth: number): void {
151
152
  if (depth > 5 || candidates.length >= 30) return;
@@ -158,7 +159,14 @@ function findTestFile(rootDir: string): string | null {
158
159
  for (const entry of entries) {
159
160
  if (entry.isDirectory()) {
160
161
  if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
161
- 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);
162
170
  } else if (entry.isFile()) {
163
171
  const name = entry.name;
164
172
  if (name.endsWith(".d.ts") || name.endsWith(".test.ts") || name.endsWith(".spec.ts"))
@@ -166,7 +174,7 @@ function findTestFile(rootDir: string): string | null {
166
174
  if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
167
175
  try {
168
176
  const stat = fs.statSync(path.join(dir, name));
169
- if (stat.size > 200 && stat.size < 50000) {
177
+ if (stat.size > 0 && stat.size < 50000) {
170
178
  candidates.push({ file: path.join(dir, name), size: stat.size });
171
179
  }
172
180
  } catch {
@@ -199,8 +207,9 @@ function findImporter(graph: ModuleGraph, file: string): string | null {
199
207
  // ─── Main ────────────────────────────────────────────────────────────────────
200
208
 
201
209
  export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestResult> {
202
- const { projectRoot, tsconfigPath } =
210
+ const { projectRoot, tsconfigPath, toolDir, toolIsEmbedded } =
203
211
  configOverride ?? resolveConfig(import.meta.dirname);
212
+ const excludedPaths = toolIsEmbedded ? [toolDir] : [];
204
213
 
205
214
  let passed = 0;
206
215
  let failed = 0;
@@ -231,7 +240,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
231
240
 
232
241
  // ─── Discover a test file ───────────────────────────────────────────────
233
242
 
234
- const testFile = findTestFile(projectRoot);
243
+ const testFile = findTestFile(projectRoot, excludedPaths);
235
244
  if (!testFile) {
236
245
  console.log(" No suitable .ts file found in project. Cannot run smoke tests.");
237
246
  return { passed, failed: failed + 1, skipped };
@@ -250,7 +259,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
250
259
  // Graph build
251
260
  t0 = performance.now();
252
261
  try {
253
- const result = await buildGraph(projectRoot, tsconfigPath);
262
+ const result = await buildGraph(projectRoot, tsconfigPath, excludedPaths);
254
263
  graph = result.graph;
255
264
  const ms = performance.now() - t0;
256
265
  const edgeCount = [...graph.forward.values()].reduce((s, e) => s + e.length, 0);
@@ -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
  }
@@ -1,262 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
-
3
- import * as assert from "node:assert/strict";
4
- import * as fs from "node:fs";
5
- import * as os from "node:os";
6
- import * as path from "node:path";
7
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
9
- import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
10
-
11
- type DependencyTreeResult = {
12
- root: string;
13
- nodes: number;
14
- files: string[];
15
- };
16
-
17
- type TypeInfoResult = {
18
- type: string | null;
19
- documentation: string | null;
20
- kind?: string;
21
- source?: string;
22
- };
23
-
24
- type ModuleExportsResult = {
25
- file: string;
26
- exports: Array<{
27
- symbol: string;
28
- type: string | null;
29
- }>;
30
- count: number;
31
- };
32
-
33
- function normalize(file: string): string {
34
- return file.replaceAll("\\", "/");
35
- }
36
-
37
- function sleep(ms: number): Promise<void> {
38
- return new Promise((resolve) => setTimeout(resolve, ms));
39
- }
40
-
41
- async function waitFor(
42
- description: string,
43
- fn: () => Promise<void>,
44
- timeoutMs = 5_000,
45
- intervalMs = 50
46
- ): Promise<void> {
47
- const start = Date.now();
48
- let lastError: unknown;
49
-
50
- while (Date.now() - start < timeoutMs) {
51
- try {
52
- await fn();
53
- return;
54
- } catch (err) {
55
- lastError = err;
56
- await sleep(intervalMs);
57
- }
58
- }
59
-
60
- throw new Error(
61
- `${description} did not stabilize within ${timeoutMs}ms: ${String(lastError)}`
62
- );
63
- }
64
-
65
- function writeFile(root: string, relativePath: string, content: string): void {
66
- const absPath = path.join(root, relativePath);
67
- fs.mkdirSync(path.dirname(absPath), { recursive: true });
68
- fs.writeFileSync(absPath, content);
69
- }
70
-
71
- async function main(): Promise<void> {
72
- const repoRoot = import.meta.dirname;
73
- const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "typegraph-engine-sync-"));
74
- const projectRoot = path.join(tempRoot, "project");
75
-
76
- fs.mkdirSync(projectRoot, { recursive: true });
77
- writeFile(
78
- projectRoot,
79
- "package.json",
80
- JSON.stringify(
81
- {
82
- name: "typegraph-engine-sync-fixture",
83
- private: true,
84
- type: "module",
85
- },
86
- null,
87
- 2
88
- ) + "\n"
89
- );
90
- writeFile(
91
- projectRoot,
92
- "tsconfig.json",
93
- JSON.stringify(
94
- {
95
- compilerOptions: {
96
- target: "ES2022",
97
- module: "ESNext",
98
- moduleResolution: "Bundler",
99
- strict: true,
100
- },
101
- include: ["src/**/*.ts"],
102
- },
103
- null,
104
- 2
105
- ) + "\n"
106
- );
107
-
108
- writeFile(projectRoot, "src/a.ts", 'export const current = "a" as const;\n');
109
- writeFile(projectRoot, "src/b.ts", 'export const current = "b" as const;\n');
110
- writeFile(
111
- projectRoot,
112
- "src/main.ts",
113
- 'import { current } from "./a";\nexport const value = current;\n'
114
- );
115
- writeFile(projectRoot, "src/test.ts", "export const oldName = 1 as const;\n");
116
- writeFile(projectRoot, "src/util.ts", "export const helper = 1 as const;\n");
117
- writeFile(projectRoot, "src/closed.ts", "export const closedValue = 1 as const;\n");
118
- writeFile(
119
- projectRoot,
120
- "src/closed-consumer.ts",
121
- 'import { closedValue } from "./closed";\nexport const observed = closedValue;\n'
122
- );
123
-
124
- fs.mkdirSync(path.join(projectRoot, "node_modules"), { recursive: true });
125
- fs.symlinkSync(
126
- path.join(repoRoot, "node_modules/typescript"),
127
- path.join(projectRoot, "node_modules/typescript"),
128
- "dir"
129
- );
130
-
131
- const client = new Client({ name: "engine-sync-test", version: "1.0.0" });
132
- const transport = new StdioClientTransport({
133
- command: path.join(repoRoot, "node_modules/.bin/tsx"),
134
- args: [path.join(repoRoot, "server.ts")],
135
- cwd: projectRoot,
136
- env: {
137
- TYPEGRAPH_PROJECT_ROOT: projectRoot,
138
- TYPEGRAPH_TSCONFIG: path.join(projectRoot, "tsconfig.json"),
139
- },
140
- });
141
-
142
- async function callTool<T>(name: string, args: Record<string, unknown>): Promise<T> {
143
- const result = await client.request(
144
- {
145
- method: "tools/call",
146
- params: {
147
- name,
148
- arguments: args,
149
- },
150
- },
151
- CallToolResultSchema
152
- );
153
-
154
- const content = result.content[0];
155
- assert.ok(content?.type === "text", `Expected text response from ${name}`);
156
- return JSON.parse(content.text) as T;
157
- }
158
-
159
- try {
160
- await client.connect(transport);
161
-
162
- const initialType = await callTool<TypeInfoResult>("ts_type_info", {
163
- file: "src/main.ts",
164
- line: 2,
165
- column: 14,
166
- });
167
- assert.match(initialType.type ?? "", /"a"/);
168
-
169
- writeFile(projectRoot, "src/main.ts", 'import { current } from "./b";\nexport const value = current;\n');
170
-
171
- await waitFor("import swap to synchronize graph and tsserver", async () => {
172
- const deps = await callTool<DependencyTreeResult>("ts_dependency_tree", {
173
- file: "src/main.ts",
174
- });
175
- const normalizedDeps = deps.files.map(normalize);
176
- assert.ok(normalizedDeps.includes("src/b.ts"), `Expected src/b.ts in ${normalizedDeps}`);
177
- assert.ok(!normalizedDeps.includes("src/a.ts"), `Expected src/a.ts to be removed from ${normalizedDeps}`);
178
-
179
- const typeInfo = await callTool<TypeInfoResult>("ts_type_info", {
180
- file: "src/main.ts",
181
- line: 2,
182
- column: 14,
183
- });
184
- assert.match(typeInfo.type ?? "", /"b"/);
185
- });
186
-
187
- // Open test.ts through ts_module_exports, then rename the export on disk.
188
- const initialExports = await callTool<ModuleExportsResult>("ts_module_exports", {
189
- file: "src/test.ts",
190
- });
191
- assert.ok(initialExports.exports.some((item) => item.symbol === "oldName"));
192
-
193
- writeFile(projectRoot, "src/test.ts", "export const newName = 1 as const;\n");
194
-
195
- await waitFor("symbol rename to refresh mixed export metadata", async () => {
196
- const exportsResult = await callTool<ModuleExportsResult>("ts_module_exports", {
197
- file: "src/test.ts",
198
- });
199
- const next = exportsResult.exports.find((item) => item.symbol === "newName");
200
- assert.ok(next, `Expected newName in ${JSON.stringify(exportsResult.exports)}`);
201
- assert.match(next.type ?? "", /\bnewName\b/);
202
- assert.ok(!exportsResult.exports.some((item) => item.symbol === "oldName"));
203
- });
204
-
205
- // Change a file that tsserver has not opened. The watcher should mark
206
- // projects dirty so the next semantic query reloads closed-file contents.
207
- writeFile(projectRoot, "src/closed.ts", "export const closedValue = 2 as const;\n");
208
-
209
- await waitFor("closed file update to refresh semantic project state", async () => {
210
- const deps = await callTool<DependencyTreeResult>("ts_dependency_tree", {
211
- file: "src/closed-consumer.ts",
212
- });
213
- const normalizedDeps = deps.files.map(normalize);
214
- assert.ok(normalizedDeps.includes("src/closed.ts"), `Expected src/closed.ts in ${normalizedDeps}`);
215
-
216
- const typeInfo = await callTool<TypeInfoResult>("ts_type_info", {
217
- file: "src/closed-consumer.ts",
218
- line: 2,
219
- column: 14,
220
- });
221
- assert.match(typeInfo.type ?? "", /\b2\b/);
222
- });
223
-
224
- // Open util.ts directly so tsserver tracks it, then delete it from disk.
225
- const utilInfo = await callTool<TypeInfoResult>("ts_type_info", {
226
- file: "src/util.ts",
227
- line: 1,
228
- column: 14,
229
- });
230
- assert.match(utilInfo.type ?? "", /\bhelper: 1\b/);
231
- fs.rmSync(path.join(projectRoot, "src/util.ts"));
232
-
233
- await waitFor("deleted file to disappear from semantic answers", async () => {
234
- const deletedInfo = await callTool<TypeInfoResult>("ts_type_info", {
235
- file: "src/util.ts",
236
- line: 1,
237
- column: 14,
238
- });
239
- assert.equal(
240
- deletedInfo.type,
241
- null,
242
- `Expected deleted file to have no type info, got ${JSON.stringify(deletedInfo)}`
243
- );
244
- });
245
-
246
- console.log("");
247
- console.log("typegraph-mcp Engine Sync Test");
248
- console.log("==============================");
249
- console.log(" ✓ import swaps keep dependency_tree and type_info aligned");
250
- console.log(" ✓ export renames refresh ts_module_exports semantic metadata");
251
- console.log(" ✓ closed-file edits refresh tsserver project state");
252
- console.log(" ✓ deleted open files do not survive as tsserver ghost snapshots");
253
- } finally {
254
- await transport.close().catch(() => {});
255
- fs.rmSync(tempRoot, { recursive: true, force: true });
256
- }
257
- }
258
-
259
- main().catch((err) => {
260
- console.error(err);
261
- process.exit(1);
262
- });