ast-mcp-server 0.11.2 → 0.13.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.
@@ -1,18 +1,9 @@
1
+ import { toolCatalog } from "../tools/catalog.js";
1
2
  export const MCP_SERVER_NAME = "ast";
2
- const EXPECTED_TOOLS = [
3
- "ast_list_files",
4
- "ast_get_outline",
5
- "ast_get_symbol_source",
6
- "ast_search_symbols",
7
- "ast_find_references",
8
- "ast_find_test_candidates",
9
- "ast_get_diagnostics",
10
- "ast_rename_symbol",
11
- "ast_replace_symbol_body",
12
- "ast_scaffold_class",
13
- "ast_get_operation_preview",
14
- "ast_apply_operation",
15
- ];
3
+ const EXPECTED_TOOLS = toolCatalog.compatibility.required;
4
+ const APPLY_TOOL = "ast_apply_operation";
5
+ /** Ambient agent surfaces opt into the apply-effect tool (fail-closed guard default is deny). */
6
+ const APPLY_GUARD_ALLOW_ENV = "AST_MCP_APPLY_GUARD=allow";
16
7
  function semver(value) {
17
8
  const match = value.match(/(?:^|\s|v)(\d+)\.(\d+)\.(\d+)(?=\s|$|[-+()]|\.(?:\s|$))/);
18
9
  return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
@@ -40,6 +31,32 @@ function expected(context, command, args) {
40
31
  args.length === 1 &&
41
32
  args[0] === context.serverEntryPath);
42
33
  }
34
+ function structuredGuardStatus(...values) {
35
+ const provided = values.filter((value) => value !== undefined);
36
+ if (provided.length === 0)
37
+ return "repairable";
38
+ const records = provided.filter((value) => value !== null && typeof value === "object" && !Array.isArray(value));
39
+ return records.length === provided.length &&
40
+ records.length === 1 &&
41
+ Object.keys(records[0]).length === 1 &&
42
+ records[0].AST_MCP_APPLY_GUARD === "allow"
43
+ ? "current"
44
+ : "conflict";
45
+ }
46
+ function matchingRegistration(hasExpectedCommand, guardStatus) {
47
+ if (!hasExpectedCommand) {
48
+ return {
49
+ status: "conflict",
50
+ detail: "The existing 'ast' registration does not match this package.",
51
+ };
52
+ }
53
+ return guardStatus === "conflict"
54
+ ? {
55
+ status: "conflict",
56
+ detail: "The existing 'ast' registration has unsupported environment settings.",
57
+ }
58
+ : { status: guardStatus };
59
+ }
43
60
  function parseJson(result) {
44
61
  try {
45
62
  return JSON.parse(result.stdout);
@@ -58,12 +75,7 @@ function structuredInspection(result, context) {
58
75
  if (!value || value.name !== MCP_SERVER_NAME)
59
76
  return { status: "error", operation: "MCP inspection", result };
60
77
  const transport = (value.transport ?? value);
61
- return expected(context, transport.command, transport.args)
62
- ? { status: "current" }
63
- : {
64
- status: "conflict",
65
- detail: "The existing 'ast' registration does not match this package.",
66
- };
78
+ return matchingRegistration(transport.type === "stdio" && expected(context, transport.command, transport.args), structuredGuardStatus(transport.env, transport.environment));
67
79
  }
68
80
  function copilotStructuredInspection(result, context) {
69
81
  if (result.exitCode !== 0) {
@@ -77,18 +89,35 @@ function copilotStructuredInspection(result, context) {
77
89
  return { status: "error", operation: "MCP inspection", result };
78
90
  }
79
91
  const configuration = server;
80
- return configuration.type === "local" &&
92
+ const matches = configuration.type === "local" &&
81
93
  configuration.source === "user" &&
82
94
  configuration.enabled === true &&
83
95
  Array.isArray(configuration.tools) &&
84
96
  configuration.tools.length === 1 &&
85
97
  configuration.tools[0] === "*" &&
86
- expected(context, configuration.command, configuration.args)
87
- ? { status: "current" }
88
- : {
89
- status: "conflict",
98
+ expected(context, configuration.command, configuration.args);
99
+ const inspection = matchingRegistration(matches, structuredGuardStatus(configuration.env, configuration.environment));
100
+ return inspection.status === "conflict"
101
+ ? {
102
+ ...inspection,
90
103
  detail: "The existing user-scoped 'ast' registration does not match this package.",
91
- };
104
+ }
105
+ : inspection;
106
+ }
107
+ function claudeRegister(runtime, context, guarded) {
108
+ return runtime.run([
109
+ "mcp",
110
+ "add",
111
+ "--scope",
112
+ "user",
113
+ ...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
114
+ "--transport",
115
+ "stdio",
116
+ MCP_SERVER_NAME,
117
+ "--",
118
+ context.nodeExecutable,
119
+ context.serverEntryPath,
120
+ ]);
92
121
  }
93
122
  const claudeMcp = {
94
123
  async inspect(runtime, context) {
@@ -100,87 +129,140 @@ const claudeMcp = {
100
129
  : { status: "error", operation: "MCP inspection", result };
101
130
  const fields = new Map();
102
131
  for (const line of result.stdout.split(/\r?\n/)) {
103
- const match = line.match(/^\s*(Status|Type|Command|Args):\s*(.*)$/i);
132
+ const match = line.match(/^\s*(Scope|Status|Type|Command|Args):\s*(.*)$/i);
104
133
  if (match)
105
134
  fields.set(match[1].toLowerCase(), match[2].trim());
106
135
  }
107
- return /connected/i.test(fields.get("status") ?? "") &&
136
+ const matches = /^user config\b/i.test(fields.get("scope") ?? "") &&
137
+ /connected/i.test(fields.get("status") ?? "") &&
108
138
  fields.get("type")?.toLowerCase() === "stdio" &&
109
- expected(context, fields.get("command"), [fields.get("args")])
110
- ? { status: "current" }
111
- : {
112
- status: "conflict",
139
+ expected(context, fields.get("command"), [fields.get("args")]);
140
+ const hasEnvironment = /^\s*Environment:\s*$/im.test(result.stdout);
141
+ const inspection = matchingRegistration(matches, /(?:^|\s)AST_MCP_APPLY_GUARD\s*=\s*allow(?:\s|$)/m.test(result.stdout)
142
+ ? "current"
143
+ : hasEnvironment
144
+ ? "conflict"
145
+ : "repairable");
146
+ return inspection.status === "conflict"
147
+ ? {
148
+ ...inspection,
113
149
  detail: "The existing user-scoped 'ast' registration does not match this package.",
114
- };
150
+ }
151
+ : inspection;
115
152
  },
116
- register: (runtime, context) => runtime.run([
153
+ register: (runtime, context) => claudeRegister(runtime, context, true),
154
+ unregister: (runtime) => runtime.run(["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]),
155
+ registerLegacy: (runtime, context) => claudeRegister(runtime, context, false),
156
+ registrationAccepted: (result) => result.exitCode === 0,
157
+ removalAccepted: (result) => result.exitCode === 0,
158
+ };
159
+ function hermesRegister(runtime, context, guarded) {
160
+ return runtime.run([
117
161
  "mcp",
118
162
  "add",
119
- "--scope",
120
- "user",
121
- "--transport",
122
- "stdio",
123
163
  MCP_SERVER_NAME,
124
- "--",
164
+ "--command",
125
165
  context.nodeExecutable,
166
+ ...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
167
+ "--args",
126
168
  context.serverEntryPath,
127
- ]),
128
- registrationAccepted: (result) => result.exitCode === 0,
129
- };
169
+ ], "\n");
170
+ }
130
171
  const hermesMcp = {
131
- async inspect(runtime) {
172
+ async inspect(runtime, context) {
132
173
  const listed = await runtime.run(["mcp", "list"]);
133
174
  if (listed.exitCode !== 0)
134
175
  return { status: "error", operation: "MCP list", result: listed };
135
176
  if (!/^\s*ast(?:\s|$)/im.test(listed.stdout))
136
177
  return { status: "missing" };
137
- const tested = await runtime.run(["mcp", "test", MCP_SERVER_NAME]);
138
- return tested.exitCode === 0 && EXPECTED_TOOLS.every((tool) => tested.stdout.includes(tool))
139
- ? { status: "current" }
140
- : {
178
+ const configured = await runtime.run(["config", "get", "mcp_servers.ast", "--json"]);
179
+ const registration = parseJson(configured);
180
+ if (configured.exitCode !== 0 ||
181
+ !registration ||
182
+ Array.isArray(registration) ||
183
+ !expected(context, registration.command, registration.args)) {
184
+ return {
141
185
  status: "conflict",
142
- detail: "The existing 'ast' registration does not expose the expected structural tools.",
186
+ detail: "The existing 'ast' registration cannot be authenticated as this package.",
143
187
  };
188
+ }
189
+ const guardStatus = structuredGuardStatus(registration.env);
190
+ const admittedKeys = guardStatus === "repairable" ? ["args", "command"] : ["args", "command", "env"];
191
+ if (guardStatus === "conflict" ||
192
+ Object.keys(registration).sort().join(",") !== admittedKeys.join(",")) {
193
+ return {
194
+ status: "conflict",
195
+ detail: "The existing 'ast' registration has unsupported configuration.",
196
+ };
197
+ }
198
+ const tested = await runtime.run(["mcp", "test", MCP_SERVER_NAME]);
199
+ const expectedWithoutApply = EXPECTED_TOOLS.filter((tool) => tool !== APPLY_TOOL);
200
+ if (tested.exitCode === 0 &&
201
+ expectedWithoutApply.every((tool) => tested.stdout.includes(tool))) {
202
+ const applyPresent = !EXPECTED_TOOLS.includes(APPLY_TOOL) || tested.stdout.includes(APPLY_TOOL);
203
+ return guardStatus === "current" && applyPresent
204
+ ? { status: "current" }
205
+ : guardStatus === "repairable" && !tested.stdout.includes(APPLY_TOOL)
206
+ ? { status: "repairable" }
207
+ : {
208
+ status: "conflict",
209
+ detail: "The existing 'ast' registration tool surface contradicts its configuration.",
210
+ };
211
+ }
212
+ return {
213
+ status: "conflict",
214
+ detail: "The existing 'ast' registration does not expose the expected structural tools.",
215
+ };
144
216
  },
145
- register: (runtime, context) => runtime.run([
217
+ register: (runtime, context) => hermesRegister(runtime, context, true),
218
+ unregister: (runtime) => runtime.run(["mcp", "remove", MCP_SERVER_NAME]),
219
+ registerLegacy: (runtime, context) => hermesRegister(runtime, context, false),
220
+ registrationAccepted: (result) => result.exitCode === 0 && /saved\s+'ast'/i.test(result.stdout),
221
+ removalAccepted: (result) => result.exitCode === 0,
222
+ };
223
+ function separatorRegister(runtime, context, guarded) {
224
+ return runtime.run([
146
225
  "mcp",
147
226
  "add",
148
227
  MCP_SERVER_NAME,
149
- "--command",
228
+ ...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
229
+ "--",
150
230
  context.nodeExecutable,
151
- "--args",
152
231
  context.serverEntryPath,
153
- ], "\n"),
154
- registrationAccepted: (result) => result.exitCode === 0 && /saved\s+'ast'/i.test(result.stdout),
155
- };
232
+ ]);
233
+ }
156
234
  const codexMcp = {
157
235
  async inspect(runtime, context) {
158
236
  return structuredInspection(await runtime.run(["mcp", "get", MCP_SERVER_NAME, "--json"]), context);
159
237
  },
160
- register: (runtime, context) => runtime.run([
161
- "mcp",
162
- "add",
163
- MCP_SERVER_NAME,
164
- "--",
165
- context.nodeExecutable,
166
- context.serverEntryPath,
167
- ]),
238
+ register: (runtime, context) => separatorRegister(runtime, context, true),
239
+ unregister: (runtime) => runtime.run(["mcp", "remove", MCP_SERVER_NAME]),
240
+ registerLegacy: (runtime, context) => separatorRegister(runtime, context, false),
168
241
  registrationAccepted: (result) => result.exitCode === 0,
242
+ removalAccepted: (result) => result.exitCode === 0,
169
243
  };
170
244
  const copilotMcp = {
171
245
  async inspect(runtime, context) {
172
246
  return copilotStructuredInspection(await runtime.run(["mcp", "get", MCP_SERVER_NAME, "--json"]), context);
173
247
  },
174
- register: (runtime, context) => runtime.run([
248
+ register: (runtime, context) => separatorRegister(runtime, context, true),
249
+ unregister: (runtime) => runtime.run(["mcp", "remove", MCP_SERVER_NAME]),
250
+ registerLegacy: (runtime, context) => separatorRegister(runtime, context, false),
251
+ registrationAccepted: (result) => result.exitCode === 0,
252
+ removalAccepted: (result) => result.exitCode === 0,
253
+ };
254
+ function geminiRegister(runtime, context, guarded) {
255
+ return runtime.run([
175
256
  "mcp",
176
257
  "add",
177
258
  MCP_SERVER_NAME,
178
- "--",
259
+ ...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
179
260
  context.nodeExecutable,
180
261
  context.serverEntryPath,
181
- ]),
182
- registrationAccepted: (result) => result.exitCode === 0,
183
- };
262
+ "--scope",
263
+ "user",
264
+ ]);
265
+ }
184
266
  const geminiMcp = {
185
267
  async inspect(runtime, context) {
186
268
  const result = await runtime.run(["mcp", "list"]);
@@ -199,23 +281,24 @@ const geminiMcp = {
199
281
  const match = result.stdout.match(pattern);
200
282
  if (!match)
201
283
  return { status: "error", operation: "MCP inspection", result };
202
- return expected(context, match[1]?.trim(), match[2]?.split(",").map((item) => item.trim()))
203
- ? { status: "current" }
204
- : {
284
+ const matches = expected(context, match[1]?.trim(), match[2]?.split(",").map((item) => item.trim()));
285
+ const inspection = matchingRegistration(matches, /(?:^|\s|,)AST_MCP_APPLY_GUARD\s*=\s*allow(?:\s|$|,)/m.test(result.stdout)
286
+ ? "current"
287
+ : /\benv(?:ironment)?\s*:/i.test(result.stdout)
288
+ ? "conflict"
289
+ : "repairable");
290
+ return inspection.status === "conflict"
291
+ ? {
205
292
  status: "conflict",
206
293
  detail: "The existing Gemini 'ast' registration does not match this package.",
207
- };
294
+ }
295
+ : inspection;
208
296
  },
209
- register: (runtime, context) => runtime.run([
210
- "mcp",
211
- "add",
212
- MCP_SERVER_NAME,
213
- context.nodeExecutable,
214
- context.serverEntryPath,
215
- "--scope",
216
- "user",
217
- ]),
297
+ register: (runtime, context) => geminiRegister(runtime, context, true),
298
+ unregister: (runtime) => runtime.run(["mcp", "remove", MCP_SERVER_NAME, "--scope", "user"]),
299
+ registerLegacy: (runtime, context) => geminiRegister(runtime, context, false),
218
300
  registrationAccepted: (result) => result.exitCode === 0,
301
+ removalAccepted: (result) => result.exitCode === 0,
219
302
  };
220
303
  const opencodeMcp = {
221
304
  async inspect() {
@@ -232,7 +315,14 @@ const opencodeMcp = {
232
315
  async register() {
233
316
  return { exitCode: 1, stdout: "", stderr: "OpenCode CLI registration is disabled." };
234
317
  },
318
+ async unregister() {
319
+ return { exitCode: 1, stdout: "", stderr: "OpenCode CLI removal is disabled." };
320
+ },
321
+ async registerLegacy() {
322
+ return { exitCode: 1, stdout: "", stderr: "OpenCode CLI registration is disabled." };
323
+ },
235
324
  registrationAccepted: () => false,
325
+ removalAccepted: () => false,
236
326
  };
237
327
  export const AGENT_TARGETS = [
238
328
  { id: "claude", label: "Claude Code", command: "claude", skillTarget: "claude", mcp: claudeMcp },
@@ -1,6 +1,61 @@
1
1
  import path from "node:path";
2
2
  import { DiagnosticCategory, ts } from "ts-morph";
3
3
  import { NO_REQUEST_CONTEXT } from "./request-context.js";
4
+ export const DIAGNOSTIC_AGGREGATE_GROUP_LIMIT = 20;
5
+ function safeCount(value) {
6
+ if (!Number.isSafeInteger(value) || value < 0) {
7
+ throw new RangeError("Diagnostic aggregate count is outside the safe integer range.");
8
+ }
9
+ return value;
10
+ }
11
+ function increment(counts, key) {
12
+ counts.set(key, safeCount((counts.get(key) ?? 0) + 1));
13
+ }
14
+ function rankGroups(counts, createGroup, compareKey, groupedDiagnosticCount, requestContext) {
15
+ const groups = [];
16
+ for (const [key, count] of counts) {
17
+ requestContext.checkpoint();
18
+ groups.push(createGroup(key, safeCount(count)));
19
+ }
20
+ groups.sort((left, right) => {
21
+ requestContext.checkpoint();
22
+ return right.count - left.count || compareKey(left, right);
23
+ });
24
+ const selected = groups.slice(0, DIAGNOSTIC_AGGREGATE_GROUP_LIMIT);
25
+ const covered = safeCount(selected.reduce((total, group) => safeCount(total + group.count), 0));
26
+ const omittedGroups = safeCount(groups.length - selected.length);
27
+ return {
28
+ groups: selected,
29
+ total_group_count: safeCount(groups.length),
30
+ omitted_group_count: omittedGroups,
31
+ covered_diagnostic_count: covered,
32
+ omitted_diagnostic_count: safeCount(groupedDiagnosticCount - covered),
33
+ truncated: omittedGroups > 0,
34
+ };
35
+ }
36
+ export function buildDiagnosticAggregates(diagnostics, requestContext = NO_REQUEST_CONTEXT) {
37
+ requestContext.checkpoint();
38
+ const codeCounts = new Map();
39
+ const fileCounts = new Map();
40
+ let unfiled = 0;
41
+ for (const diagnostic of diagnostics) {
42
+ requestContext.checkpoint();
43
+ increment(codeCounts, diagnostic.code);
44
+ if (diagnostic.file === null) {
45
+ unfiled = safeCount(unfiled + 1);
46
+ }
47
+ else {
48
+ increment(fileCounts, diagnostic.file);
49
+ }
50
+ }
51
+ const codes = rankGroups(codeCounts, (code, count) => ({ code, count }), (left, right) => left.code - right.code, safeCount(diagnostics.length), requestContext);
52
+ const files = rankGroups(fileCounts, (file, count) => ({ file, count }), (left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0), safeCount(diagnostics.length - unfiled), requestContext);
53
+ return {
54
+ group_limit: DIAGNOSTIC_AGGREGATE_GROUP_LIMIT,
55
+ codes,
56
+ files: { ...files, unfiled_diagnostic_count: unfiled },
57
+ };
58
+ }
4
59
  export function normalizeDiagnostic(diagnostic, projectRoot, requestContext = NO_REQUEST_CONTEXT) {
5
60
  requestContext.checkpoint();
6
61
  const sourceFile = diagnostic.getSourceFile();
@@ -3,6 +3,49 @@ import { link, mkdir, mkdtemp, open, readFile, rename, rm, stat, writeFile, } fr
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { applyEdits, modify, parse } from "jsonc-parser";
6
+ export class OpenCodeConfigRecoveryError extends Error {
7
+ constructor(message, options) {
8
+ super(message, options);
9
+ this.name = "OpenCodeConfigRecoveryError";
10
+ }
11
+ }
12
+ function desiredAst(nodeExecutable, serverEntryPath) {
13
+ return {
14
+ type: "local",
15
+ command: [nodeExecutable, serverEntryPath],
16
+ enabled: true,
17
+ environment: { AST_MCP_APPLY_GUARD: "allow" },
18
+ };
19
+ }
20
+ export function classifyOpenCodeAstRegistration(value, nodeExecutable, serverEntryPath) {
21
+ if (value === undefined)
22
+ return "missing";
23
+ if (value === null || typeof value !== "object" || Array.isArray(value))
24
+ return "conflict";
25
+ const registration = value;
26
+ const command = registration.command;
27
+ const baseMatches = registration.type === "local" &&
28
+ registration.enabled === true &&
29
+ Array.isArray(command) &&
30
+ command.length === 2 &&
31
+ command[0] === nodeExecutable &&
32
+ command[1] === serverEntryPath;
33
+ if (!baseMatches)
34
+ return "conflict";
35
+ const keys = Object.keys(registration).sort().join(",");
36
+ if (keys === "command,enabled,type")
37
+ return "repairable";
38
+ if (keys !== "command,enabled,environment,type")
39
+ return "conflict";
40
+ const environment = registration.environment;
41
+ return environment !== null &&
42
+ typeof environment === "object" &&
43
+ !Array.isArray(environment) &&
44
+ Object.keys(environment).length === 1 &&
45
+ environment.AST_MCP_APPLY_GUARD === "allow"
46
+ ? "current"
47
+ : "conflict";
48
+ }
6
49
  function hash(value) {
7
50
  return createHash("sha256").update(value).digest("hex");
8
51
  }
@@ -80,14 +123,11 @@ export async function planOpenCodeConfig(options) {
80
123
  }
81
124
  if (!parsed || typeof parsed !== "object")
82
125
  throw new Error("OpenCode configuration must contain an object.");
83
- const desired = {
84
- type: "local",
85
- command: [options.nodeExecutable, options.serverEntryPath],
86
- enabled: true,
87
- };
88
- if (parsed.mcp?.ast !== undefined && JSON.stringify(parsed.mcp.ast) !== JSON.stringify(desired))
126
+ const desired = desiredAst(options.nodeExecutable, options.serverEntryPath);
127
+ const registrationStatus = classifyOpenCodeAstRegistration(parsed.mcp?.ast, options.nodeExecutable, options.serverEntryPath);
128
+ if (registrationStatus === "conflict")
89
129
  throw new Error("OpenCode configuration conflict at mcp.ast.");
90
- const content = parsed.mcp?.ast === undefined
130
+ const content = registrationStatus === "missing" || registrationStatus === "repairable"
91
131
  ? applyEdits(before, modify(before, ["mcp", "ast"], desired, {
92
132
  formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
93
133
  }))
@@ -96,6 +136,7 @@ export async function planOpenCodeConfig(options) {
96
136
  filePath: options.filePath,
97
137
  beforeHash: hash(beforeExists ? before : ""),
98
138
  beforeExists,
139
+ beforeContent: beforeExists ? before : "",
99
140
  mode,
100
141
  content,
101
142
  status: content === before ? "unchanged" : "installed",
@@ -146,3 +187,39 @@ export async function applyOpenCodeConfigPlan(plan) {
146
187
  await rm(temporary, { force: true });
147
188
  }
148
189
  }
190
+ async function inspectOpenCodeConfigPlanState(plan) {
191
+ let content = "";
192
+ let exists = true;
193
+ let mode;
194
+ try {
195
+ content = await readFile(plan.filePath, "utf8");
196
+ mode = (await stat(plan.filePath)).mode & 0o777;
197
+ }
198
+ catch (error) {
199
+ if (error.code === "ENOENT")
200
+ exists = false;
201
+ else
202
+ throw error;
203
+ }
204
+ if (!plan.beforeExists && !exists)
205
+ return "preimage";
206
+ if (plan.beforeExists && exists && content === plan.beforeContent && mode === plan.mode)
207
+ return "preimage";
208
+ if (exists && content === plan.content && mode === plan.mode)
209
+ return "postimage";
210
+ return "conflict";
211
+ }
212
+ export async function runOpenCodeConfigTransaction(plan, operation) {
213
+ try {
214
+ return await operation();
215
+ }
216
+ catch (error) {
217
+ const state = await inspectOpenCodeConfigPlanState(plan);
218
+ if (state === "postimage")
219
+ return undefined;
220
+ if (state === "conflict") {
221
+ throw new OpenCodeConfigRecoveryError("OpenCode configuration changed concurrently after an ambiguous write outcome; the concurrent bytes were preserved.", { cause: error });
222
+ }
223
+ throw error;
224
+ }
225
+ }
@@ -7,6 +7,7 @@ export const RUNTIME_POLICY_ENV_KEYS = Object.freeze([
7
7
  "AST_SHUTDOWN_DRAIN_TIMEOUT_MS",
8
8
  "AST_COMPILER_WORKER_MODE",
9
9
  "AST_COMPILER_WORKER_IDLE_TTL_MS",
10
+ "AST_MCP_APPLY_GUARD",
10
11
  ]);
11
12
  function parseBoundedInteger(rawValue, defaultValue, minimum, maximum) {
12
13
  if (rawValue === undefined)
@@ -37,6 +38,15 @@ export function parseRuntimePolicy(environment) {
37
38
  ? "configured"
38
39
  : "invalid_mode";
39
40
  const compilerWorkerIdleTtlMs = parseBoundedInteger(environment.AST_COMPILER_WORKER_IDLE_TTL_MS, 60_000, 0, 86_400_000);
41
+ const rawApplyGuard = environment.AST_MCP_APPLY_GUARD;
42
+ // Fail-closed: only an explicit `allow` permits the apply-effect MCP tool on
43
+ // this surface. A missing value, `deny`, or any invalid value denies apply.
44
+ const denyApply = rawApplyGuard !== "allow";
45
+ const applyGuardReason = rawApplyGuard === undefined
46
+ ? "default"
47
+ : rawApplyGuard === "allow" || rawApplyGuard === "deny"
48
+ ? "configured"
49
+ : "invalid_mode";
40
50
  const reasons = Object.freeze({
41
51
  AST_MAX_PROJECT_SESSIONS: maxProjectSessions.reason,
42
52
  AST_MAX_QUEUED_OPERATIONS_PER_PROJECT: maxQueuedOperationsPerProject.reason,
@@ -45,6 +55,7 @@ export function parseRuntimePolicy(environment) {
45
55
  AST_SHUTDOWN_DRAIN_TIMEOUT_MS: shutdownDrainTimeoutMs.reason,
46
56
  AST_COMPILER_WORKER_MODE: compilerWorkerModeReason,
47
57
  AST_COMPILER_WORKER_IDLE_TTL_MS: compilerWorkerIdleTtlMs.reason,
58
+ AST_MCP_APPLY_GUARD: applyGuardReason,
48
59
  });
49
60
  return Object.freeze({
50
61
  maxProjectSessions: maxProjectSessions.value,
@@ -54,6 +65,7 @@ export function parseRuntimePolicy(environment) {
54
65
  shutdownDrainTimeoutMs: shutdownDrainTimeoutMs.value,
55
66
  compilerWorkerMode,
56
67
  compilerWorkerIdleTtlMs: compilerWorkerIdleTtlMs.value,
68
+ denyApply,
57
69
  reasons,
58
70
  });
59
71
  }
@@ -95,6 +107,9 @@ export function createCompilerWorkerSpawnSpec(workerEntryPath, environment) {
95
107
  AST_QUEUE_WAIT_TIMEOUT_MS: String(policy.queueWaitTimeoutMs),
96
108
  AST_OPERATION_DEADLINE_MS: String(policy.operationDeadlineMs),
97
109
  AST_SHUTDOWN_DRAIN_TIMEOUT_MS: String(policy.shutdownDrainTimeoutMs),
110
+ // Forward the parent's resolved apply-guard policy so the supervised worker
111
+ // registers the same tool surface (deny-by-default fail-closed, allow opt-in).
112
+ AST_MCP_APPLY_GUARD: policy.denyApply ? "deny" : "allow",
98
113
  });
99
114
  return {
100
115
  ok: true,
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { applyOperation } from "../services/operations.js";
3
3
  import { createRequestContext } from "../services/request-context.js";
4
4
  import { createToolErrorContext, errorResult, structuredResult } from "./result.js";
5
+ const TOOL_NAME = "ast_apply_operation";
5
6
  const AstApplyOperationInputSchema = z.object({
6
7
  operation_id: z.string().uuid().describe("Identifier returned by a prepare operation."),
7
8
  plan_hash: z.string().length(64).describe("Plan hash returned by the same prepare operation."),
@@ -15,7 +16,7 @@ const AstApplyOperationOutputSchema = z.object({
15
16
  idempotent_replay: z.boolean(),
16
17
  });
17
18
  export function registerApplyOperation(server) {
18
- server.registerTool("ast_apply_operation", {
19
+ server.registerTool(TOOL_NAME, {
19
20
  title: "Apply a prepared structural operation",
20
21
  description: "Applies exactly the reviewed plan after verifying its plan hash and the complete TypeScript workspace fingerprint. Conflicts abort before writes; retries after success are idempotent.",
21
22
  inputSchema: AstApplyOperationInputSchema,
@@ -34,7 +35,9 @@ export function registerApplyOperation(server) {
34
35
  });
35
36
  }
36
37
  catch (error) {
37
- return errorResult(error, createToolErrorContext("ast_apply_operation"));
38
+ return errorResult(error, createToolErrorContext(TOOL_NAME));
38
39
  }
39
40
  });
40
41
  }
42
+ // prettier-ignore
43
+ export const toolDescriptor = Object.freeze({ name: TOOL_NAME, register: registerApplyOperation, compatibility: "required", effect: "apply", batch: "none", directOutputFormats: Object.freeze(["json"]) });