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.
- package/CHANGELOG.md +23 -2
- package/README.md +60 -10
- package/SECURITY.md +1 -1
- package/cordis.patch.yml +31 -0
- package/dist/batch/schema.js +7 -16
- package/dist/server.js +7 -32
- package/dist/services/agent-setup.js +85 -19
- package/dist/services/agent-targets.js +170 -80
- package/dist/services/diagnostics.js +55 -0
- package/dist/services/opencode-config.js +84 -7
- package/dist/services/runtime-policy.js +15 -0
- package/dist/tools/apply_operation.js +5 -2
- package/dist/tools/catalog.js +83 -0
- package/dist/tools/explore.js +5 -2
- package/dist/tools/find_references.js +5 -2
- package/dist/tools/find_test_candidates.js +5 -2
- package/dist/tools/get_diagnostics.js +36 -4
- package/dist/tools/get_file.js +5 -2
- package/dist/tools/get_impact.js +5 -2
- package/dist/tools/get_operation_preview.js +5 -2
- package/dist/tools/get_outline.js +5 -2
- package/dist/tools/get_project_status.js +5 -2
- package/dist/tools/get_symbol_source.js +5 -2
- package/dist/tools/list_files.js +5 -2
- package/dist/tools/rename_symbol.js +5 -2
- package/dist/tools/replace_symbol_body.js +5 -2
- package/dist/tools/scaffold_class.js +5 -2
- package/dist/tools/search_symbols.js +5 -2
- package/dist/worker-runtime.js +2 -2
- package/docs/support.md +7 -7
- package/package.json +16 -3
- package/skills/structural-code-editing/SKILL.md +14 -2
- package/skills/structural-code-editing/releases.json +22 -2
|
@@ -1,18 +1,9 @@
|
|
|
1
|
+
import { toolCatalog } from "../tools/catalog.js";
|
|
1
2
|
export const MCP_SERVER_NAME = "ast";
|
|
2
|
-
const EXPECTED_TOOLS =
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
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
|
-
"--
|
|
228
|
+
...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
|
|
229
|
+
"--",
|
|
150
230
|
context.nodeExecutable,
|
|
151
|
-
"--args",
|
|
152
231
|
context.serverEntryPath,
|
|
153
|
-
]
|
|
154
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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"]) });
|