ast-mcp-server 0.6.0 → 0.7.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +47 -15
  3. package/SECURITY.md +31 -0
  4. package/dist/cli.js +0 -0
  5. package/dist/index.js +129 -10
  6. package/dist/server.js +21 -2
  7. package/dist/services/agent-setup.js +36 -117
  8. package/dist/services/agent-targets.js +122 -0
  9. package/dist/services/context-builder.js +52 -24
  10. package/dist/services/diagnostics.js +10 -5
  11. package/dist/services/file-fingerprints.js +104 -0
  12. package/dist/services/file-snapshot.js +8 -1
  13. package/dist/services/impact.js +334 -0
  14. package/dist/services/operations.js +343 -254
  15. package/dist/services/outline.js +94 -21
  16. package/dist/services/pagination.js +10 -4
  17. package/dist/services/project-operation-scheduler.js +475 -0
  18. package/dist/services/project-status.js +134 -52
  19. package/dist/services/project-watcher.js +230 -0
  20. package/dist/services/project.js +646 -66
  21. package/dist/services/public-errors.js +255 -0
  22. package/dist/services/references.js +7 -1
  23. package/dist/services/relationships.js +1198 -0
  24. package/dist/services/request-context.js +42 -0
  25. package/dist/services/runtime-activity.js +65 -0
  26. package/dist/services/runtime-logger.js +50 -0
  27. package/dist/services/runtime-policy.js +47 -0
  28. package/dist/services/setup-wizard.js +4 -7
  29. package/dist/services/shutdown.js +93 -0
  30. package/dist/services/symbol-index-limits.js +5 -0
  31. package/dist/services/symbol-index-order.js +13 -0
  32. package/dist/services/symbol-index-policy.js +91 -0
  33. package/dist/services/symbol-index-sqlite.js +850 -0
  34. package/dist/services/symbol-index.js +447 -0
  35. package/dist/services/symbols.js +403 -79
  36. package/dist/services/test-candidates.js +209 -0
  37. package/dist/services/workspace.js +18 -7
  38. package/dist/tools/apply_operation.js +8 -4
  39. package/dist/tools/explore.js +8 -6
  40. package/dist/tools/find_references.js +12 -13
  41. package/dist/tools/get_diagnostics.js +10 -7
  42. package/dist/tools/get_file.js +23 -11
  43. package/dist/tools/get_impact.js +134 -0
  44. package/dist/tools/get_operation_preview.js +7 -4
  45. package/dist/tools/get_outline.js +8 -6
  46. package/dist/tools/get_project_status.js +77 -8
  47. package/dist/tools/get_symbol_source.js +8 -6
  48. package/dist/tools/list_files.js +11 -6
  49. package/dist/tools/rename_symbol.js +6 -4
  50. package/dist/tools/replace_symbol_body.js +6 -4
  51. package/dist/tools/result.js +27 -17
  52. package/dist/tools/scaffold_class.js +6 -4
  53. package/dist/tools/search_symbols.js +15 -14
  54. package/docs/support.md +80 -0
  55. package/package.json +16 -5
  56. package/skills/structural-code-editing/SKILL.md +37 -10
@@ -0,0 +1,209 @@
1
+ import { assertExactImpactEvidence } from "./impact.js";
2
+ export const DEFAULT_TEST_FILE_PATTERNS = Object.freeze(["**/*.test.*", "**/*.spec.*"]);
3
+ export const DEFAULT_TEST_DIRECTORIES = Object.freeze(["test", "tests", "__tests__"]);
4
+ export const TEST_CANDIDATE_REASONS = Object.freeze([
5
+ "direct_compiler_reference",
6
+ "transitive_compiler_reference",
7
+ ]);
8
+ export const TEST_CANDIDATE_CONFIDENCES = Object.freeze(["exact", "high"]);
9
+ const MAX_CONVENTION_ITEMS = 32;
10
+ const MAX_CONVENTION_LENGTH = 256;
11
+ function normalizeConventionValues(values, defaults, label) {
12
+ const selected = values ?? defaults;
13
+ if (!Array.isArray(selected) || selected.length > MAX_CONVENTION_ITEMS) {
14
+ throw new Error(`${label} must contain at most ${MAX_CONVENTION_ITEMS} entries.`);
15
+ }
16
+ const normalized = selected.map((value) => {
17
+ if (typeof value !== "string" || value.trim().length === 0) {
18
+ throw new Error(`${label} entries must be non-empty strings.`);
19
+ }
20
+ if (value.length > MAX_CONVENTION_LENGTH) {
21
+ throw new Error(`${label} entries must not exceed ${MAX_CONVENTION_LENGTH} characters.`);
22
+ }
23
+ return value.trim().replaceAll("\\", "/");
24
+ });
25
+ return [...new Set(normalized)];
26
+ }
27
+ function assertSafePattern(pattern) {
28
+ if (pattern.startsWith("/") ||
29
+ /^[A-Za-z]:\//u.test(pattern) ||
30
+ pattern.split("/").some((segment) => segment === "..")) {
31
+ throw new Error("Test file patterns must be project-relative.");
32
+ }
33
+ }
34
+ function assertSafeDirectory(directory) {
35
+ if (directory.startsWith("/") ||
36
+ /^[A-Za-z]:\//u.test(directory) ||
37
+ directory.split("/").some((segment) => segment === "..")) {
38
+ throw new Error("Test directories must be project-relative.");
39
+ }
40
+ }
41
+ function normalizeConventions(conventions = {}) {
42
+ if (typeof conventions !== "object" || conventions === null) {
43
+ throw new Error("Test candidate conventions are invalid.");
44
+ }
45
+ const patterns = normalizeConventionValues(conventions.test_file_patterns, DEFAULT_TEST_FILE_PATTERNS, "test_file_patterns");
46
+ const directories = normalizeConventionValues(conventions.test_directories, DEFAULT_TEST_DIRECTORIES, "test_directories");
47
+ patterns.forEach(assertSafePattern);
48
+ directories.forEach(assertSafeDirectory);
49
+ return { patterns, directories };
50
+ }
51
+ function escapeRegexCharacter(character) {
52
+ return /[\\^$.*+?()[\]{}|]/u.test(character) ? `\\${character}` : character;
53
+ }
54
+ function globToRegExp(pattern) {
55
+ let source = "^";
56
+ for (let index = 0; index < pattern.length; index += 1) {
57
+ const character = pattern[index];
58
+ if (character === "*" && pattern[index + 1] === "*") {
59
+ index += 1;
60
+ if (pattern[index + 1] === "/") {
61
+ index += 1;
62
+ source += "(?:[^/]+/)*";
63
+ }
64
+ else {
65
+ source += ".*";
66
+ }
67
+ continue;
68
+ }
69
+ if (character === "*") {
70
+ source += "[^/]*";
71
+ continue;
72
+ }
73
+ if (character === "?") {
74
+ source += "[^/]";
75
+ continue;
76
+ }
77
+ source += escapeRegexCharacter(character);
78
+ }
79
+ return new RegExp(`${source}$`, "u");
80
+ }
81
+ function matchesFilePattern(file, pattern) {
82
+ const matcher = globToRegExp(pattern);
83
+ const basename = file.slice(file.lastIndexOf("/") + 1);
84
+ return matcher.test(file) || matcher.test(basename);
85
+ }
86
+ function pathContainsDirectory(file, directory) {
87
+ const fileSegments = file.split("/");
88
+ const directorySegments = directory.split("/").filter(Boolean);
89
+ if (directorySegments.length === 0)
90
+ return false;
91
+ return fileSegments.some((_, index) => directorySegments.every((segment, offset) => fileSegments[index + offset] === segment));
92
+ }
93
+ function isProjectRelativeFile(file) {
94
+ return (file.length > 0 &&
95
+ !file.startsWith("/") &&
96
+ !/^[A-Za-z]:[\\/]/u.test(file) &&
97
+ !file.split("/").some((segment) => segment === ".." || segment.length === 0));
98
+ }
99
+ function isTestFile(file, conventions) {
100
+ if (!isProjectRelativeFile(file))
101
+ return false;
102
+ return (conventions.patterns.some((pattern) => matchesFilePattern(file, pattern)) ||
103
+ conventions.directories.some((directory) => pathContainsDirectory(file, directory)));
104
+ }
105
+ function endpointKey(endpoint) {
106
+ return `${endpoint.file}\u0000${endpoint.symbol_path}\u0000${endpoint.selector}`;
107
+ }
108
+ function edgeOrder(left, right) {
109
+ return (left.relationship_id.localeCompare(right.relationship_id) ||
110
+ left.source.file.localeCompare(right.source.file) ||
111
+ left.source.selector.localeCompare(right.source.selector) ||
112
+ left.target.file.localeCompare(right.target.file) ||
113
+ left.target.selector.localeCompare(right.target.selector));
114
+ }
115
+ function candidateReason(depth) {
116
+ return depth === 1 ? "direct_compiler_reference" : "transitive_compiler_reference";
117
+ }
118
+ function candidateConfidence(depth) {
119
+ return depth === 1 ? "exact" : "high";
120
+ }
121
+ function findPathToRoot(startKeys, rootKey, edges) {
122
+ const queue = [
123
+ ...startKeys,
124
+ ]
125
+ .sort()
126
+ .map((key) => ({ key, path: [] }));
127
+ const visited = new Set(startKeys);
128
+ const orderedEdges = [...edges].sort(edgeOrder);
129
+ while (queue.length > 0) {
130
+ const current = queue.shift();
131
+ if (current.key === rootKey && current.path.length > 0)
132
+ return current.path;
133
+ const steps = [];
134
+ for (const edge of orderedEdges) {
135
+ const sourceKey = endpointKey(edge.source);
136
+ const targetKey = endpointKey(edge.target);
137
+ if (sourceKey === current.key)
138
+ steps.push({ edge, nextKey: targetKey });
139
+ if (targetKey === current.key)
140
+ steps.push({ edge, nextKey: sourceKey });
141
+ }
142
+ steps.sort((left, right) => edgeOrder(left.edge, right.edge) || left.nextKey.localeCompare(right.nextKey));
143
+ for (const step of steps) {
144
+ if (visited.has(step.nextKey))
145
+ continue;
146
+ visited.add(step.nextKey);
147
+ queue.push({ key: step.nextKey, path: [...current.path, step.edge] });
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ export function findTestCandidates(impact, conventions = {}) {
153
+ assertExactImpactEvidence(impact);
154
+ if (typeof impact.root !== "object" ||
155
+ impact.root === null ||
156
+ !Array.isArray(impact.nodes) ||
157
+ !impact.nodes.every((node) => typeof node === "object" && node !== null)) {
158
+ throw new Error("Impact nodes are invalid.");
159
+ }
160
+ const normalized = normalizeConventions(conventions);
161
+ const nodesByEndpoint = new Map(impact.nodes.map((node) => [endpointKey(node.endpoint), node]));
162
+ const rootFile = impact.root.file;
163
+ const rootKey = endpointKey(impact.root);
164
+ const candidatesByFile = new Map();
165
+ for (const node of impact.nodes) {
166
+ const file = node.endpoint.file;
167
+ if (file === rootFile || !isTestFile(file, normalized))
168
+ continue;
169
+ const candidate = candidatesByFile.get(file);
170
+ if (candidate) {
171
+ candidate.depth = Math.min(candidate.depth, node.depth);
172
+ candidate.endpointKeys.add(endpointKey(node.endpoint));
173
+ }
174
+ else {
175
+ candidatesByFile.set(file, {
176
+ depth: node.depth,
177
+ endpointKeys: new Set([endpointKey(node.endpoint)]),
178
+ });
179
+ }
180
+ }
181
+ for (const edge of impact.edges) {
182
+ const sourceNode = nodesByEndpoint.get(endpointKey(edge.source));
183
+ const targetNode = nodesByEndpoint.get(endpointKey(edge.target));
184
+ if (!sourceNode || !targetNode) {
185
+ throw new Error("Impact edge does not have matching bounded nodes.");
186
+ }
187
+ }
188
+ return [...candidatesByFile.entries()]
189
+ .map(([file, candidate]) => {
190
+ const relationships = findPathToRoot([...candidate.endpointKeys], rootKey, impact.edges);
191
+ if (!relationships) {
192
+ throw new Error(`Test candidate "${file}" has no exact impact path to the root.`);
193
+ }
194
+ const depth = candidate.depth;
195
+ return {
196
+ file,
197
+ reason: candidateReason(depth),
198
+ confidence: candidateConfidence(depth),
199
+ evidence: {
200
+ depth,
201
+ direct: depth === 1,
202
+ compiler_authoritative: true,
203
+ relationship_ids: relationships.map((edge) => edge.relationship_id),
204
+ relationships,
205
+ },
206
+ };
207
+ })
208
+ .sort((left, right) => left.evidence.depth - right.evidence.depth || left.file.localeCompare(right.file));
209
+ }
@@ -2,9 +2,8 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { ts } from "ts-morph";
5
- export function hashBytes(content) {
6
- return createHash("sha256").update(content).digest("hex");
7
- }
5
+ import { collectFileFingerprints, compareFileFingerprints, hashBytes, } from "./file-fingerprints.js";
6
+ export { hashBytes } from "./file-fingerprints.js";
8
7
  export function hashWorkspaceFiles(files) {
9
8
  const digest = createHash("sha256");
10
9
  for (const [filePath, fileHash] of [...files].sort(([left], [right]) => left.localeCompare(right))) {
@@ -62,19 +61,29 @@ export function createConfigSnapshot(rootConfigPath) {
62
61
  }
63
62
  return { digest: digest.digest("hex"), files };
64
63
  }
65
- export function createWorkspaceSnapshot(context) {
64
+ export function createWorkspaceSnapshot(context, options = {}) {
66
65
  const sourcePaths = context.project
67
66
  .getSourceFiles()
68
67
  .map((sourceFile) => canonicalPath(sourceFile.getFilePath()));
69
68
  const configSnapshot = createConfigSnapshot(context.tsConfigFilePath);
69
+ const allPaths = [...new Set([...sourcePaths, ...configSnapshot.files])];
70
+ const fingerprintCollection = collectFileFingerprints(allPaths, options.previousFingerprints, {
71
+ verifyContentHash: options.verifyContentHash,
72
+ });
73
+ const fingerprints = fingerprintCollection.files;
70
74
  const sourceFiles = new Map();
71
75
  for (const filePath of [...new Set(sourcePaths)].sort()) {
72
- const digest = hashBytes(fs.readFileSync(filePath));
73
- sourceFiles.set(filePath, digest);
76
+ const fingerprint = fingerprints.get(filePath);
77
+ if (fingerprint)
78
+ sourceFiles.set(filePath, fingerprint.content_hash);
74
79
  }
75
80
  const files = new Map(sourceFiles);
76
81
  for (const filePath of configSnapshot.files) {
77
- const digest = hashBytes(fs.readFileSync(filePath));
82
+ const fingerprint = fingerprints.get(filePath);
83
+ if (!fingerprint) {
84
+ throw new Error(`Configuration file disappeared while creating a workspace snapshot: ${filePath}`);
85
+ }
86
+ const digest = fingerprint.content_hash;
78
87
  const previous = files.get(filePath);
79
88
  if (previous && previous !== digest) {
80
89
  throw new Error(`Workspace identity collision for ${filePath}.`);
@@ -88,5 +97,7 @@ export function createWorkspaceSnapshot(context) {
88
97
  sourceFileCount: sourceFiles.size,
89
98
  fileCount: files.size,
90
99
  files,
100
+ fingerprints,
101
+ fingerprintChanges: compareFileFingerprints(options.previousFingerprints ?? new Map(), fingerprints),
91
102
  };
92
103
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { applyOperation } from "../services/operations.js";
3
- import { errorResult, structuredResult } from "./result.js";
3
+ import { createRequestContext } from "../services/request-context.js";
4
+ import { createToolErrorContext, errorResult, structuredResult } from "./result.js";
4
5
  const AstApplyOperationInputSchema = z.object({
5
6
  operation_id: z.string().uuid().describe("Identifier returned by a prepare operation."),
6
7
  plan_hash: z.string().length(64).describe("Plan hash returned by the same prepare operation."),
@@ -25,12 +26,15 @@ export function registerApplyOperation(server) {
25
26
  idempotentHint: true,
26
27
  openWorldHint: false,
27
28
  },
28
- }, async ({ operation_id, plan_hash }) => {
29
+ }, async ({ operation_id, plan_hash }, extra) => {
30
+ const requestContext = createRequestContext(extra.signal);
29
31
  try {
30
- return structuredResult({ ...(await applyOperation(operation_id, plan_hash)) });
32
+ return structuredResult({
33
+ ...(await applyOperation(operation_id, plan_hash, requestContext)),
34
+ });
31
35
  }
32
36
  catch (error) {
33
- return errorResult(error);
37
+ return errorResult(error, createToolErrorContext("ast_apply_operation"));
34
38
  }
35
39
  });
36
40
  }
@@ -2,8 +2,9 @@ import { z } from "zod";
2
2
  import { buildExploreContext, EXPLORE_DEFAULT_LIMIT, EXPLORE_DEFAULT_MAX_BYTES, EXPLORE_DEFAULT_REFERENCE_LIMIT, EXPLORE_MAX_BYTES, } from "../services/context-builder.js";
3
3
  import { createPaginationInputSchema } from "../services/pagination.js";
4
4
  import { withProject } from "../services/project.js";
5
+ import { createRequestContext } from "../services/request-context.js";
5
6
  import { FRESHNESS_CAUSES, SNAPSHOT_STATES, TRUNCATION_REASONS, } from "../services/read-contracts.js";
6
- import { errorResult, structuredResult } from "./result.js";
7
+ import { createToolErrorContext, errorResult, structuredResult } from "./result.js";
7
8
  const ExploreDetailSchema = z.enum(["selectors", "summary", "context", "full"]).default("summary");
8
9
  const ReferenceDetailSchema = z.enum(["locations", "context"]).default("locations");
9
10
  const ExploreInputSchema = z
@@ -139,7 +140,7 @@ const ExploreOutputSchema = z.object({
139
140
  export function registerExplore(server) {
140
141
  server.registerTool("ast_explore", {
141
142
  title: "Explore project context",
142
- description: "Composes bounded structural search, exact selectors, source evidence and compiler references without weakening the primitive AST tools.",
143
+ description: "Composes bounded structural search, exact selectors, source evidence and compiler references without weakening the primitive AST tools. Returns bounded session freshness metadata for fresh, pending, stale, rebuilding or degraded state.",
143
144
  inputSchema: ExploreInputSchema,
144
145
  outputSchema: ExploreOutputSchema,
145
146
  annotations: {
@@ -148,9 +149,10 @@ export function registerExplore(server) {
148
149
  idempotentHint: true,
149
150
  openWorldHint: false,
150
151
  },
151
- }, async ({ project_root, query, file_path, symbol_path, kinds, file_filter, detail, include_source, include_references, reference_detail, reference_limit, offset, limit, max_bytes, }) => {
152
+ }, async ({ project_root, query, file_path, symbol_path, kinds, file_filter, detail, include_source, include_references, reference_detail, reference_limit, offset, limit, max_bytes, }, extra) => {
153
+ const requestContext = createRequestContext(extra.signal);
152
154
  try {
153
- const output = await withProject(project_root, (context) => buildExploreContext(context, {
155
+ const output = await withProject(project_root, (context, operationContext) => buildExploreContext(context, {
154
156
  query,
155
157
  filePath: file_path,
156
158
  symbolPath: symbol_path,
@@ -164,11 +166,11 @@ export function registerExplore(server) {
164
166
  limit,
165
167
  referenceLimit: reference_limit,
166
168
  maxBytes: max_bytes,
167
- }));
169
+ }, operationContext), requestContext);
168
170
  return structuredResult(ExploreOutputSchema.parse(output));
169
171
  }
170
172
  catch (error) {
171
- return errorResult(error);
173
+ return errorResult(error, createToolErrorContext("ast_explore", project_root));
172
174
  }
173
175
  });
174
176
  }
@@ -2,7 +2,8 @@ import { z } from "zod";
2
2
  import { PaginationInputSchema, PaginationOutputSchema } from "../services/pagination.js";
3
3
  import { findDeclarationByName, getSourceFileOrThrow, withProject } from "../services/project.js";
4
4
  import { collectSymbolReferences } from "../services/references.js";
5
- import { errorResult, formattedResult, ToolOutputFormatInputSchema } from "./result.js";
5
+ import { createRequestContext } from "../services/request-context.js";
6
+ import { createToolErrorContext, errorResult, formattedResult, ToolOutputFormatInputSchema, } from "./result.js";
6
7
  const ReferenceDetailSchema = z.enum(["locations", "context"]).default("locations");
7
8
  const AstFindReferencesInputSchema = z.object({
8
9
  project_root: z
@@ -53,31 +54,29 @@ export function registerFindReferences(server) {
53
54
  idempotentHint: true,
54
55
  openWorldHint: false,
55
56
  },
56
- }, async ({ project_root, file_path, symbol_path, include_declaration, detail, output_format, offset, limit, }) => {
57
+ }, async ({ project_root, file_path, symbol_path, include_declaration, detail, output_format, offset, limit, }, extra) => {
58
+ const requestContext = createRequestContext(extra.signal);
57
59
  try {
58
- const structuredContent = await withProject(project_root, ({ project, projectRoot }) => {
60
+ const structuredContent = await withProject(project_root, ({ project, projectRoot }, operationContext) => {
59
61
  const sourceFile = getSourceFileOrThrow(project, file_path);
60
62
  const node = findDeclarationByName(sourceFile, symbol_path);
61
- const collected = collectSymbolReferences(node, projectRoot, symbol_path, include_declaration, detail, offset, limit);
63
+ const collected = collectSymbolReferences(node, projectRoot, symbol_path, include_declaration, detail, offset, limit, operationContext);
62
64
  const { references, ...metadata } = collected;
63
65
  return {
64
66
  ...metadata,
65
67
  references: detail === "context"
66
68
  ? references
67
- : references.map(({ file, line, column, kind, is_declaration }) => ({
68
- file,
69
- line,
70
- column,
71
- kind,
72
- is_declaration,
73
- })),
69
+ : references.map(({ file, line, column, kind, is_declaration }) => {
70
+ operationContext.checkpoint();
71
+ return { file, line, column, kind, is_declaration };
72
+ }),
74
73
  };
75
- });
74
+ }, requestContext);
76
75
  const outputSchema = ReferenceOutputSchemas[detail];
77
76
  return formattedResult(outputSchema, structuredContent, output_format);
78
77
  }
79
78
  catch (error) {
80
- return errorResult(error);
79
+ return errorResult(error, createToolErrorContext("ast_find_references", project_root));
81
80
  }
82
81
  });
83
82
  }
@@ -2,7 +2,8 @@ import { z } from "zod";
2
2
  import { normalizeDiagnostic } from "../services/diagnostics.js";
3
3
  import { PaginationInputSchema, PaginationOutputSchema, paginate } from "../services/pagination.js";
4
4
  import { getSourceFileOrThrow, withProject } from "../services/project.js";
5
- import { errorResult, formattedResult, ToolOutputFormatInputSchema } from "./result.js";
5
+ import { createRequestContext } from "../services/request-context.js";
6
+ import { createToolErrorContext, errorResult, formattedResult, ToolOutputFormatInputSchema, } from "./result.js";
6
7
  const DiagnosticSchema = z.object({
7
8
  code: z.number().int(),
8
9
  category: z.string(),
@@ -40,31 +41,33 @@ export function registerGetDiagnostics(server) {
40
41
  idempotentHint: true,
41
42
  openWorldHint: false,
42
43
  },
43
- }, async ({ project_root, file_path, output_format, offset, limit }) => {
44
+ }, async ({ project_root, file_path, output_format, offset, limit }, extra) => {
45
+ const requestContext = createRequestContext(extra.signal);
44
46
  try {
45
- const structuredContent = await withProject(project_root, ({ project, projectRoot }) => {
47
+ const structuredContent = await withProject(project_root, ({ project, projectRoot }, operationContext) => {
46
48
  const startedAt = performance.now();
47
49
  const diagnostics = file_path
48
50
  ? getSourceFileOrThrow(project, file_path).getPreEmitDiagnostics()
49
51
  : [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()];
50
52
  const normalized = diagnostics
51
- .map((diagnostic) => normalizeDiagnostic(diagnostic, projectRoot))
53
+ .map((diagnostic) => normalizeDiagnostic(diagnostic, projectRoot, operationContext))
52
54
  .sort((left, right) => `${left.file ?? ""}:${left.line ?? 0}:${left.column ?? 0}:${left.code}`.localeCompare(`${right.file ?? ""}:${right.line ?? 0}:${right.column ?? 0}:${right.code}`));
53
55
  const page = paginate(normalized, offset, limit);
54
56
  const { items, ...metadata } = page;
55
57
  return {
56
58
  diagnostics: items,
57
- error_count: normalized.filter((diagnostic) => diagnostic.category === "Error").length,
59
+ error_count: normalized.filter((diagnostic) => diagnostic.category === "Error")
60
+ .length,
58
61
  warning_count: normalized.filter((diagnostic) => diagnostic.category === "Warning")
59
62
  .length,
60
63
  duration_ms: performance.now() - startedAt,
61
64
  ...metadata,
62
65
  };
63
- });
66
+ }, requestContext);
64
67
  return formattedResult(AstGetDiagnosticsOutputSchema, structuredContent, output_format);
65
68
  }
66
69
  catch (error) {
67
- return errorResult(error);
70
+ return errorResult(error, createToolErrorContext("ast_get_diagnostics", project_root));
68
71
  }
69
72
  });
70
73
  }
@@ -2,7 +2,9 @@ import { z } from "zod";
2
2
  import { DEFAULT_FILE_LINE_LIMIT, MAX_FILE_LINE_LIMIT, readFileSnapshot, } from "../services/file-snapshot.js";
3
3
  import { buildFileOutline } from "../services/outline.js";
4
4
  import { getSourceFileOrThrow, withProject } from "../services/project.js";
5
- import { errorResult, structuredResult } from "./result.js";
5
+ import { createRequestContext } from "../services/request-context.js";
6
+ import { FRESHNESS_CAUSES, SNAPSHOT_STATES } from "../services/read-contracts.js";
7
+ import { createToolErrorContext, errorResult, structuredResult } from "./result.js";
6
8
  const AstGetFileInputSchema = z.object({
7
9
  project_root: z
8
10
  .string()
@@ -44,6 +46,11 @@ const FileSnapshotBaseSchema = {
44
46
  file: z.string(),
45
47
  file_hash: z.string().regex(/^[0-9a-f]{64}$/),
46
48
  snapshot_state: z.enum(["fresh", "stale"]),
49
+ freshness: z.object({
50
+ state: z.enum(SNAPSHOT_STATES),
51
+ causes: z.array(z.enum(FRESHNESS_CAUSES)),
52
+ checked_at: z.string().nullable(),
53
+ }),
47
54
  };
48
55
  const AstGetFileOutputSchema = z.object({
49
56
  mode: z.enum(["source", "symbols_only"]),
@@ -55,7 +62,7 @@ const AstGetFileOutputSchema = z.object({
55
62
  export function registerGetFile(server) {
56
63
  server.registerTool("ast_get_file", {
57
64
  title: "Get bounded source file",
58
- description: "Returns exact bounded source lines for one TypeScript/JavaScript file, or body-free symbols when symbols_only is true. Read-only; freshness is reported separately from compiler diagnostics.",
65
+ description: "Returns exact bounded source lines for one TypeScript/JavaScript file, or body-free symbols when symbols_only is true. Read-only; file snapshot freshness and project freshness are reported separately from compiler diagnostics.",
59
66
  inputSchema: AstGetFileInputSchema,
60
67
  outputSchema: AstGetFileOutputSchema,
61
68
  annotations: {
@@ -64,17 +71,21 @@ export function registerGetFile(server) {
64
71
  idempotentHint: true,
65
72
  openWorldHint: false,
66
73
  },
67
- }, async ({ project_root, file_path, offset, limit, symbols_only }) => {
74
+ }, async ({ project_root, file_path, offset, limit, symbols_only }, extra) => {
75
+ const requestContext = createRequestContext(extra.signal);
68
76
  try {
69
- const structuredContent = await withProject(project_root, async ({ project, projectRoot }) => {
70
- const snapshot = await readFileSnapshot(project, projectRoot, file_path, {
71
- offset,
72
- limit,
73
- });
77
+ const structuredContent = await withProject(project_root, async ({ project, projectRoot, status }, operationContext) => {
78
+ const snapshot = await readFileSnapshot(project, projectRoot, file_path, { offset, limit }, operationContext);
79
+ const freshness = {
80
+ state: status.state,
81
+ causes: status.causes,
82
+ checked_at: status.lastSuccessfulSyncAt,
83
+ };
74
84
  if (!symbols_only) {
75
85
  return {
76
86
  mode: "source",
77
87
  ...snapshot,
88
+ freshness,
78
89
  };
79
90
  }
80
91
  const sourceFile = getSourceFileOrThrow(project, file_path);
@@ -83,13 +94,14 @@ export function registerGetFile(server) {
83
94
  file: snapshot.file,
84
95
  file_hash: snapshot.file_hash,
85
96
  snapshot_state: snapshot.snapshot_state,
86
- symbols: buildFileOutline(sourceFile).symbols,
97
+ freshness,
98
+ symbols: buildFileOutline(sourceFile, operationContext).symbols,
87
99
  };
88
- });
100
+ }, requestContext);
89
101
  return structuredResult(structuredContent);
90
102
  }
91
103
  catch (error) {
92
- return errorResult(error);
104
+ return errorResult(error, createToolErrorContext("ast_get_file", project_root));
93
105
  }
94
106
  });
95
107
  }
@@ -0,0 +1,134 @@
1
+ import { z } from "zod";
2
+ import { DEFAULT_IMPACT_MAX_DEPTH, DEFAULT_IMPACT_MAX_EDGES, DEFAULT_IMPACT_MAX_NODES, IMPACT_DIRECTIONS, MAX_IMPACT_DEPTH, MAX_IMPACT_EDGES, MAX_IMPACT_NODES, resolveImpactRoot, traverseCompilerImpact, } from "../services/impact.js";
3
+ import { FRESHNESS_CAUSES, SNAPSHOT_STATES, TRUNCATION_REASONS, } from "../services/read-contracts.js";
4
+ import { withProject } from "../services/project.js";
5
+ import { createRequestContext } from "../services/request-context.js";
6
+ import { RELATIONSHIP_CONFIDENCES, RELATIONSHIP_EDGE_KINDS, RELATIONSHIP_PROVENANCES, RELATIONSHIP_RESOLUTIONS, } from "../services/relationships.js";
7
+ import { createToolErrorContext, errorResult, formattedResult, ToolOutputFormatInputSchema, } from "./result.js";
8
+ const AstGetImpactInputSchema = z.object({
9
+ project_root: z
10
+ .string()
11
+ .describe("Absolute project directory containing tsconfig.json, or the config path."),
12
+ file_path: z.string().describe("Project-relative file containing the exact impact root."),
13
+ symbol_path: z.string().describe("Exact symbol path returned by an outline or symbol search."),
14
+ direction: z
15
+ .enum(IMPACT_DIRECTIONS)
16
+ .default("both")
17
+ .describe("Traversal direction relative to the root symbol."),
18
+ max_depth: z
19
+ .number()
20
+ .int()
21
+ .min(0)
22
+ .max(MAX_IMPACT_DEPTH)
23
+ .default(DEFAULT_IMPACT_MAX_DEPTH)
24
+ .describe("Maximum relationship depth, including zero for the root only."),
25
+ max_nodes: z
26
+ .number()
27
+ .int()
28
+ .min(1)
29
+ .max(MAX_IMPACT_NODES)
30
+ .default(DEFAULT_IMPACT_MAX_NODES)
31
+ .describe("Maximum returned impact nodes, including the root."),
32
+ max_edges: z
33
+ .number()
34
+ .int()
35
+ .min(1)
36
+ .max(MAX_IMPACT_EDGES)
37
+ .default(DEFAULT_IMPACT_MAX_EDGES)
38
+ .describe("Maximum returned relationship edges."),
39
+ relationship_kinds: z
40
+ .array(z.enum(RELATIONSHIP_EDGE_KINDS))
41
+ .optional()
42
+ .describe("Optional relationship kinds to traverse; defaults to all normalized kinds."),
43
+ ...ToolOutputFormatInputSchema,
44
+ });
45
+ const SourceRangeSchema = z.object({
46
+ start_line: z.number().int().positive(),
47
+ start_column: z.number().int().positive().optional(),
48
+ end_line: z.number().int().positive().optional(),
49
+ end_column: z.number().int().positive().optional(),
50
+ });
51
+ const RelationshipEndpointSchema = z.object({
52
+ file: z.string(),
53
+ symbol_path: z.string(),
54
+ selector: z.string(),
55
+ range: SourceRangeSchema.optional(),
56
+ });
57
+ const FreshnessSchema = z.object({
58
+ state: z.enum(SNAPSHOT_STATES),
59
+ causes: z.array(z.enum(FRESHNESS_CAUSES)),
60
+ checked_at: z.string().nullable(),
61
+ });
62
+ const RelationshipEdgeSchema = z.object({
63
+ relationship_id: z.string(),
64
+ source: RelationshipEndpointSchema,
65
+ target: RelationshipEndpointSchema,
66
+ kind: z.enum(RELATIONSHIP_EDGE_KINDS),
67
+ provenance: z.enum(RELATIONSHIP_PROVENANCES),
68
+ confidence: z.enum(RELATIONSHIP_CONFIDENCES),
69
+ resolution: z.enum(RELATIONSHIP_RESOLUTIONS),
70
+ compiler_authoritative: z.boolean(),
71
+ freshness: FreshnessSchema,
72
+ });
73
+ const ImpactOutputSchema = z.object({
74
+ root: RelationshipEndpointSchema,
75
+ direction: z.enum(IMPACT_DIRECTIONS),
76
+ relationship_kinds: z.array(z.enum(RELATIONSHIP_EDGE_KINDS)),
77
+ nodes: z.array(z.object({
78
+ endpoint: RelationshipEndpointSchema,
79
+ depth: z.number().int().min(0),
80
+ direct: z.boolean(),
81
+ })),
82
+ edges: z.array(RelationshipEdgeSchema),
83
+ visited_nodes: z.number().int().min(0),
84
+ visited_edges: z.number().int().min(0),
85
+ max_depth_reached: z.number().int().min(0),
86
+ max_depth: z.number().int().min(0),
87
+ max_nodes: z.number().int().positive(),
88
+ max_edges: z.number().int().positive(),
89
+ incomplete: z.boolean(),
90
+ truncation: z.object({
91
+ truncated: z.boolean(),
92
+ reason: z.enum(TRUNCATION_REASONS).nullable(),
93
+ }),
94
+ truncation_reasons: z.array(z.enum(TRUNCATION_REASONS)),
95
+ freshness: FreshnessSchema,
96
+ });
97
+ export function registerGetImpact(server) {
98
+ server.registerTool("ast_get_impact", {
99
+ title: "Get bounded compiler-backed impact",
100
+ description: "Traverses exact compiler-backed relationships around a symbol with explicit direction, depth, node and edge budgets. Returns bounded session freshness metadata and fails closed when exact relationships are not fresh. Read-only evidence only; it never prepares or applies mutations.",
101
+ inputSchema: AstGetImpactInputSchema,
102
+ outputSchema: ImpactOutputSchema,
103
+ annotations: {
104
+ readOnlyHint: true,
105
+ destructiveHint: false,
106
+ idempotentHint: true,
107
+ openWorldHint: false,
108
+ },
109
+ }, async ({ project_root, file_path, symbol_path, direction, max_depth, max_nodes, max_edges, relationship_kinds, output_format, }, extra) => {
110
+ const requestContext = createRequestContext(extra.signal);
111
+ try {
112
+ const structuredContent = await withProject(project_root, (context, operationContext) => {
113
+ if (context.status.state !== "fresh") {
114
+ throw new Error("Compiler-backed impact evidence is not fresh. Retry the read.");
115
+ }
116
+ const freshness = {
117
+ state: context.status.state,
118
+ causes: context.status.causes,
119
+ checked_at: context.status.lastSuccessfulSyncAt,
120
+ };
121
+ const root = resolveImpactRoot(context.project, context.projectRoot, { file_path, symbol_path }, operationContext);
122
+ const impact = traverseCompilerImpact(context.project, context.projectRoot, root, freshness, { direction, max_depth, max_nodes, max_edges, relationship_kinds }, operationContext);
123
+ return {
124
+ ...impact,
125
+ freshness,
126
+ };
127
+ }, requestContext);
128
+ return formattedResult(ImpactOutputSchema, structuredContent, output_format);
129
+ }
130
+ catch (error) {
131
+ return errorResult(error, createToolErrorContext("ast_get_impact", project_root));
132
+ }
133
+ });
134
+ }