ast-mcp-server 0.12.0 → 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 CHANGED
@@ -6,7 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [0.12.0] — local release candidate, pending release (2026-08-27)
9
+ ## [0.13.0] — local release candidate, pending release (2026-08-27)
10
+
11
+ ### Added
12
+
13
+ - DeepSeek Harness Developer Preview adapter (roadmap initiative 4, first slice): `cordis.patch.yml` mounts the packaged `ast-mcp-server` stdio command through `@deepseek-ai/dsh-mcp-client`, declared via the exact `"dsh": {"bundle": {"patch": "./cordis.patch.yml"}}` contract with the pinned identity recorded separately in `deepseekHarness` (revision `cd5ef814…`, `dsh-v0.1.2-alpha.1`). The first surface is reads + prepare + preview with a fail-closed apply guard (`ast_apply_operation` registers only under an explicit `AST_MCP_APPLY_GUARD=allow`; unset, `deny`, or invalid denies it). `yarn test:dsh-adapter` is a mandatory, never-skipping gate: tarball fixture and hashes, guard matrix, and — against the clean source-built pinned Harness — native-mode profile composition plus Harness-mediated read, dry-run rename, preview, apply absence, and rejected direct apply invocation.
14
+
15
+ ## [0.12.0] — published to `latest` and `next`, registry verified (2026-08-27)
10
16
 
11
17
  ### Added
12
18
 
@@ -19,7 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
19
25
 
20
26
  ### Release status
21
27
 
22
- - Version `0.12.0` remains pending its exact-tree two-runtime matrix, CI, and separately authorized delivery sequence. It has not been published, assigned to an npm dist-tag, tagged in Git, or released on GitHub.
28
+ - npm published these immutable bytes under `next` with provenance and the expected Git commit. Public-registry consumer and release-preflight verification passed, `latest` and `next` both resolve to `0.12.0`, and the annotated `v0.12.0` tag plus GitHub Release resolve to the same release commit.
23
29
 
24
30
  ## [0.11.2] — published to `latest` and `next`, registry verified (2026-08-25)
25
31
 
package/README.md CHANGED
@@ -55,12 +55,12 @@ Reads can start with a bounded file slice, a compact outline, or exact source on
55
55
 
56
56
  The server exposes evidence labels instead of collapsing every result into an unqualified confidence score:
57
57
 
58
- | Label | Meaning | Safe use |
59
- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
60
- | `provenance: "compiler"`, `confidence: "exact"`, `resolution: "resolved"`, `freshness.state: "fresh"` | A relationship resolved by the active TypeScript compiler snapshot. This is the only combination that sets `compiler_authoritative: true`. | May support bounded impact evidence and compiler-backed test candidates. |
61
- | `provenance: "syntax"` | Syntax or AST structure without semantic symbol resolution. | Navigation and structural context only; not proof that two symbols are related. |
62
- | `provenance: "heuristic"` | A convention or name-based suggestion. | Discovery hints only; never mutation authority or a compiler-backed test candidate. |
63
- | index evidence | A derived query accelerator, not compiler authority. The current production status reports the index as disabled; any future ready index still requires compiler selector validation and a compiler fallback. | Faster routing only; stale, missing or mismatched entries must fail closed or fall back to the compiler. |
58
+ | Label | Meaning | Safe use |
59
+ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
60
+ | `provenance: "compiler"`, `confidence: "exact"`, `resolution: "resolved"`, `freshness.state: "fresh"` | A relationship resolved by the active TypeScript compiler snapshot. This is the only combination that sets `compiler_authoritative: true`. | May support bounded impact evidence and compiler-backed test candidates. |
61
+ | `provenance: "syntax"` | Syntax or AST structure without semantic symbol resolution. | Navigation and structural context only; not proof that two symbols are related. |
62
+ | `provenance: "heuristic"` | A convention or name-based suggestion. | Discovery hints only; never mutation authority or a compiler-backed test candidate. |
63
+ | index evidence | A derived query accelerator, not compiler authority. The production default uses native SQLite when persistence is absent or `enabled`; every indexed selector still requires compiler validation and a compiler fallback. | Faster routing only; stale, missing or mismatched entries must fail closed or fall back to the compiler. |
64
64
 
65
65
  Freshness is orthogonal to TypeScript diagnostics. `fresh` means the evidence matches the synchronized snapshot; `pending`, `rebuilding`, `stale`, or `degraded` means the response must not be presented as current compiler evidence. Read tools expose the state, causes (`source_change`, `config_change`, `index_failure`, `watcher_failure`, or `compiler_rebuild`), and bounded `checked_at` timestamp. `ast_get_impact` refuses non-fresh compiler relationships. `ast_explore` returns the state together with `completeness`, `unresolved`, `budget`, and `truncation` metadata rather than silently dropping evidence.
66
66
 
@@ -84,7 +84,7 @@ The included batch benchmark records a 50% reduction in model round-trips and a
84
84
 
85
85
  ## Supported environment and trust boundary
86
86
 
87
- The local `0.12.0` release candidate requires Node.js `>=22.13.0`; its evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Published v0.11.2 retains its immutable historical Node.js 22.13.0/24 evidence. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
87
+ Published v0.12.0 requires Node.js `>=22.13.0`; its immutable evidence matrix targets exact Node.js 22.13.0 and the current Node.js 24 line. Managed setup-file publication additionally requires GNU coreutils 9.7 `mv` supporting `--update=none-fail`, `--exchange`, `--no-copy`, and `--no-target-directory`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, and `O_DIRECTORY`/`O_NOFOLLOW`. Other Linux architectures or systems without those filesystem primitives, macOS, and Windows remain unverified.
88
88
 
89
89
  This is a local stdio server. It runs with the invoking user's filesystem permissions, and clients may request any `project_root` that user can access. It does not provide HTTP authentication, sandboxing, tenant isolation, or a remote-service security boundary. Remote, untrusted, and multi-tenant operation is unsupported.
90
90
 
@@ -100,7 +100,7 @@ The parent waits for child readiness before replaying bounded initialization sta
100
100
 
101
101
  The scoped Linux canary passed on exact Node.js 22.13.0 and Node.js 24 with repeatable PSS reclamation, stable compiler fingerprints, unchanged SQLite reuse, bounded redacted diagnostics, and no orphan after parent death. This is one child per connection, not a shared daemon, pool, or new default. See [ADR 0014](docs/adr/0014-supervised-compiler-worker.md).
102
102
 
103
- In published v0.11.2 and the local `0.12.0` release candidate, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
103
+ In published v0.12.0, an absent `AST_SYMBOL_INDEX_PERSISTENCE` or explicit `enabled` selects the private SQLite symbol-index cache. `disabled` is the immediate memory-only rollback. `canary` requires an explicit absolute normalized `AST_SYMBOL_INDEX_CACHE_ROOT`. Invalid policy or storage fails closed to compiler-authoritative memory reads with bounded path-free status.
104
104
 
105
105
  The default cache root is selected from `AST_SYMBOL_INDEX_CACHE_ROOT`, then `XDG_CACHE_HOME`, then `HOME`. Inspect or clear only derived cache artifacts through the bounded CLI:
106
106
 
@@ -261,7 +261,7 @@ Claude Code supports local stdio MCP servers. After building this repository, re
261
261
 
262
262
  ```bash
263
263
  AST_MCP_DIR="$(pwd)"
264
- claude mcp add --scope user --transport stdio ast -- \
264
+ claude mcp add --scope user --env AST_MCP_APPLY_GUARD=allow --transport stdio ast -- \
265
265
  node "$AST_MCP_DIR/dist/index.js"
266
266
  claude mcp get ast
267
267
  ```
@@ -316,12 +316,44 @@ Use `/mcp` inside Claude Code to inspect server status and tools. Outside the se
316
316
  Hermes Agent:
317
317
 
318
318
  ```bash
319
- hermes mcp add ast --command node --args /absolute/path/to/ast-mcp-server/dist/index.js
319
+ hermes mcp add ast --command node --env AST_MCP_APPLY_GUARD=allow --args /absolute/path/to/ast-mcp-server/dist/index.js
320
320
  hermes mcp test ast
321
321
  ```
322
322
 
323
323
  Project-scoped tools accept `project_root`, either the project directory or an explicit `tsconfig.json` path. The server contains no repository-specific paths.
324
324
 
325
+ ### DeepSeek Harness (Developer Preview)
326
+
327
+ A thin adapter ships inside this package: `cordis.patch.yml` mounts the packaged
328
+ `ast-mcp-server` stdio command through the official
329
+ [`@deepseek-ai/dsh-mcp-client`](https://github.com/deepseek-ai/deepseek-harness/blob/cd5ef8148158c3a752a658978873241fdf8e2bbc/packages/mcp/mcp-client/README.md)
330
+ bridge, declared through exactly `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`.
331
+ This is a Developer Preview against a pinned, **source-built** Harness revision
332
+ (`dsh-v0.1.2-alpha.1` at `cd5ef8148158c3a752a658978873241fdf8e2bbc`); that revision
333
+ is validated from source, and `ast-mcp-server@0.13.0` is not published yet. Install a
334
+ locally packed tarball instead:
335
+
336
+ ```bash
337
+ yarn pack --out ast-mcp-server-%v.tgz
338
+ dsh plugin --profile web add ./ast-mcp-server-0.13.0.tgz
339
+ ```
340
+
341
+ The first supported surface is **reads + prepare + preview**. Every apply path is
342
+ denied by a fail-closed guard: `ast_apply_operation` is not registered unless
343
+ `AST_MCP_APPLY_GUARD=allow` is set explicitly (the shipped patch instead pins `deny`,
344
+ so the Harness surface stays deny-by-default; an unset or invalid value also denies). Known upstream
345
+ gaps apply and are never safety authority: the official bridge drops MCP tool
346
+ annotations (`readOnlyHint`/`destructiveHint`) and launches the stdio child outside
347
+ the Harness sandbox.
348
+
349
+ `yarn test:dsh-adapter` is the mandatory verification: it packs the exact tarball,
350
+ installs it into an isolated profile on the pinned Harness, proves `--dump-config`
351
+ composition including effective `tools.mode: native`, and — through the Harness registry
352
+ itself — invokes the server-qualified read, dry-run rename prepare, and preview tools.
353
+ It separately proves `mcp__ast__ast_apply_operation` is absent and that a bounded direct
354
+ registry invocation is rejected. The smoke fails (never skips) if the pinned Harness
355
+ revision, tag, CLI/client version, clean source identity, or a prerequisite is missing.
356
+
325
357
  ## MCP tools
326
358
 
327
359
  | Tool | Purpose | Mutates files |
package/SECURITY.md CHANGED
@@ -11,7 +11,7 @@ Security fixes are provided for the exact version currently identified by the np
11
11
  | Earlier npm versions | No |
12
12
  | Unpublished release candidates | No |
13
13
 
14
- This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. The local `0.12.0` release candidate requires Node.js `>=22.13.0`, with evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.11.2 retains its immutable Node.js 22.13.0/24 release matrix. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The candidate's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
14
+ This project supports trusted single-user local stdio use; the supported operating-system and architecture boundary remains Linux x64 only. Published v0.12.0 requires Node.js `>=22.13.0`, with immutable evidence targeted at exact Node.js 22.13.0 and the governed Node.js 24 major. Managed setup mutation requires GNU coreutils 9.7 `mv --update=none-fail --exchange --no-copy --no-target-directory`, GNU coreutils `ln -L -T`, and procfs descriptor-relative paths at `/proc/self/fd` with `O_DIRECTORY`/`O_NOFOLLOW`. The release's default SQLite cache also relies on Linux ownership, mode, no-follow, link-count and inode-identity checks; it does not defend against a malicious same-UID process. Other Linux architectures and systems without those primitives are unverified, as are macOS and Windows. Remote, untrusted, and multi-tenant use is outside the supported security boundary.
15
15
 
16
16
  ## Reporting a vulnerability
17
17
 
@@ -0,0 +1,31 @@
1
+ # DeepSeek Harness adapter patch for ast-mcp-server (roadmap initiative 4).
2
+ #
3
+ # Mounts the packaged `ast-mcp-server` stdio command through the official
4
+ # `@deepseek-ai/dsh-mcp-client` bridge. Harness does not resolve MCP binaries
5
+ # from PATH, so the entrypoint is resolved relative to the profile directory
6
+ # (`baseUrl`), where `dsh plugin --profile <name> add ./ast-mcp-server-0.13.0.tgz`
7
+ # installs a locally packed package as a dependency.
8
+ #
9
+ # First-slice surface: reads + prepare + preview. Every apply path is denied
10
+ # by the server-side guard activated through `AST_MCP_APPLY_GUARD=deny`.
11
+ #
12
+ # Known upstream gaps (documented, never safety authority):
13
+ # - the official bridge drops MCP tool.annotations, incl. readOnlyHint and
14
+ # destructiveHint, so the guard enumerates tool names instead of hints;
15
+ # - the stdio child runs outside the Harness sandbox.
16
+ - insert:
17
+ - id: mcp-ast
18
+ name: "@deepseek-ai/dsh-mcp-client"
19
+ config:
20
+ serverName: ast
21
+ transport: stdio
22
+ command: !!js process.execPath
23
+ args:
24
+ - !!js >-
25
+ process.getBuiltinModule('node:url').fileURLToPath(
26
+ new URL('node_modules/ast-mcp-server/dist/index.js', baseUrl)
27
+ )
28
+ env:
29
+ AST_MCP_APPLY_GUARD: "deny"
30
+ cwd: !!js process.cwd()
31
+ failOnStartupError: true
package/dist/server.js CHANGED
@@ -25,6 +25,11 @@ export function createServer(options = {}) {
25
25
  });
26
26
  if (options.runtimeActivity)
27
27
  installRuntimeActivityTracking(server, options.runtimeActivity);
28
- toolCatalog.registerAll(server);
28
+ if (options.denyApply === undefined) {
29
+ toolCatalog.registerAll(server);
30
+ }
31
+ else {
32
+ toolCatalog.registerAll(server, { denyApply: options.denyApply });
33
+ }
29
34
  return server;
30
35
  }
@@ -9,7 +9,7 @@ import { applySkillInstallationPlan, planBundledSkillInstallation, SkillConflict
9
9
  import { planManagedGuidance, } from "./managed-guidance.js";
10
10
  import { applyManagedFilePlan, createManagedFileApplyContext, ManagedFileApplyError, ManagedFileRollbackError, revalidateManagedFilePlan, verifyManagedFilePostimage, } from "./managed-file.js";
11
11
  import { AGENT_IDS, classifyAgentVersion, getAgentTarget, MCP_SERVER_NAME, } from "./agent-targets.js";
12
- import { applyOpenCodeConfigPlan, planOpenCodeConfig, resolveOpenCodeConfigPath, withIsolatedOpenCodeConfig, } from "./opencode-config.js";
12
+ import { applyOpenCodeConfigPlan, classifyOpenCodeAstRegistration, planOpenCodeConfig, resolveOpenCodeConfigPath, OpenCodeConfigRecoveryError, runOpenCodeConfigTransaction, withIsolatedOpenCodeConfig, } from "./opencode-config.js";
13
13
  const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
14
14
  const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
15
15
  export class AgentSetupError extends Error {
@@ -238,10 +238,12 @@ async function verifyOpenCode(detection, environment, timeoutMs, context) {
238
238
  throw new AgentSetupError("OpenCode resolved configuration output is unknown.", "AGENT_VERIFICATION_FAILED", { agent: "opencode" });
239
239
  }
240
240
  const command = config?.mcp?.ast?.command;
241
+ const guard = config?.mcp?.ast?.environment?.AST_MCP_APPLY_GUARD;
241
242
  if (!Array.isArray(command) ||
242
243
  command.length !== 2 ||
243
244
  command[0] !== context.nodeExecutable ||
244
- command[1] !== context.serverEntryPath) {
245
+ command[1] !== context.serverEntryPath ||
246
+ guard !== "allow") {
245
247
  throw new AgentSetupError("OpenCode resolved configuration does not contain the expected ast server.", "AGENT_VERIFICATION_FAILED", { agent: "opencode" });
246
248
  }
247
249
  const listed = await runtime.run(["mcp", "list", "--pure"]);
@@ -265,6 +267,63 @@ async function configureMcp(agent, detection, serverEntryPath, nodeExecutable, e
265
267
  throw new AgentSetupError(`${target.label} saved the MCP registration but verification did not match.`, "AGENT_VERIFICATION_FAILED", { agent, inspection: verified });
266
268
  }
267
269
  }
270
+ async function repairMcp(agent, detection, serverEntryPath, nodeExecutable, environment, timeoutMs) {
271
+ const target = getAgentTarget(agent);
272
+ const runtime = targetRuntime(detection, environment, timeoutMs);
273
+ const context = { serverEntryPath, nodeExecutable };
274
+ const authenticated = await target.mcp.inspect(runtime, context);
275
+ if (authenticated.status !== "repairable") {
276
+ throw new AgentSetupError(`${target.label} legacy registration changed before repair and was not removed.`, "AGENT_MCP_CONFLICT", { agent, inspection: authenticated });
277
+ }
278
+ const recover = async (cause) => {
279
+ const durable = await target.mcp.inspect(runtime, context);
280
+ if (durable.status === "current")
281
+ return;
282
+ if (durable.status === "repairable")
283
+ throw cause;
284
+ if (durable.status !== "missing") {
285
+ throw new AgentSetupError(`${target.label} migration outcome conflicts with the authenticated registration; it was preserved.`, "AGENT_MCP_CONFLICT", { agent, inspection: durable });
286
+ }
287
+ // The authenticated preimage is absent. Add it directly: never unregister
288
+ // here, because a concurrent replacement must make this add fail harmlessly.
289
+ const restored = await target.mcp.registerLegacy(runtime, context);
290
+ const afterRestore = await target.mcp.inspect(runtime, context);
291
+ if (afterRestore.status === "current")
292
+ return;
293
+ if (afterRestore.status === "repairable")
294
+ throw cause;
295
+ if (!target.mcp.registrationAccepted(restored) && afterRestore.status === "conflict") {
296
+ throw new AgentSetupError(`${target.label} changed concurrently during migration recovery; the replacement was preserved.`, "AGENT_MCP_CONFLICT", { agent, inspection: afterRestore });
297
+ }
298
+ throw new AgentSetupError(`${target.label} migration failed and its authenticated legacy registration is missing.`, "AGENT_VERIFICATION_FAILED", { agent, cause: cause instanceof Error ? cause.message : String(cause), afterRestore });
299
+ };
300
+ try {
301
+ let removalError;
302
+ try {
303
+ const removed = await target.mcp.unregister(runtime);
304
+ if (!target.mcp.removalAccepted(removed)) {
305
+ removalError = commandFailure(agent, "legacy MCP removal", removed);
306
+ }
307
+ }
308
+ catch (error) {
309
+ removalError = error;
310
+ }
311
+ if (removalError !== undefined) {
312
+ return recover(removalError);
313
+ }
314
+ const added = await target.mcp.register(runtime, context);
315
+ if (!target.mcp.registrationAccepted(added)) {
316
+ throw commandFailure(agent, "MCP registration repair", added);
317
+ }
318
+ const verified = await target.mcp.inspect(runtime, context);
319
+ if (verified.status !== "current") {
320
+ throw new AgentSetupError(`${target.label} saved the repaired MCP registration but verification did not match.`, "AGENT_VERIFICATION_FAILED", { agent, inspection: verified });
321
+ }
322
+ }
323
+ catch (error) {
324
+ return recover(error);
325
+ }
326
+ }
268
327
  async function assertRegularFile(filePath, label) {
269
328
  let fileStat;
270
329
  try {
@@ -367,16 +426,11 @@ export async function runAgentSetup(options) {
367
426
  catch {
368
427
  throw new AgentSetupError("OpenCode effective configuration output is unknown.", "AGENT_MCP_CONFLICT", { agent: "opencode", correlation_id: correlationId });
369
428
  }
370
- const effectiveAst = effectiveConfig?.mcp?.ast;
371
- const desiredAst = {
372
- type: "local",
373
- command: [nodeExecutable, options.serverEntryPath],
374
- enabled: true,
375
- };
376
- if (effectiveAst !== undefined && JSON.stringify(effectiveAst) !== JSON.stringify(desiredAst)) {
429
+ const effectiveAstStatus = classifyOpenCodeAstRegistration(effectiveConfig?.mcp?.ast, nodeExecutable, options.serverEntryPath);
430
+ if (effectiveAstStatus === "conflict") {
377
431
  throw new AgentSetupError("OpenCode effective configuration conflict at mcp.ast.", "AGENT_MCP_CONFLICT", { agent: "opencode", correlation_id: correlationId });
378
432
  }
379
- if (effectiveAst !== undefined && openCodePlan.status === "installed") {
433
+ if (effectiveAstStatus === "current" && openCodePlan.status === "installed") {
380
434
  openCodePlan = { ...openCodePlan, status: "unchanged" };
381
435
  }
382
436
  }
@@ -434,23 +488,35 @@ export async function runAgentSetup(options) {
434
488
  const inspection = inspections.find((item) => item.agent === agent);
435
489
  try {
436
490
  await verifyManagedAssetPlans(skillPlan, guidancePlan, applyContext);
437
- if (inspection.status === "missing") {
491
+ if (inspection.status === "missing" || inspection.status === "repairable") {
438
492
  if (agent === "opencode") {
439
- await applyOpenCodeConfigPlan(openCodePlan);
440
- if (openCodePlan.status === "installed") {
493
+ try {
494
+ await runOpenCodeConfigTransaction(openCodePlan, async () => {
495
+ await applyOpenCodeConfigPlan(openCodePlan);
496
+ await verifyOpenCode(detection, environment, timeoutMs, {
497
+ nodeExecutable,
498
+ serverEntryPath: options.serverEntryPath,
499
+ });
500
+ });
501
+ }
502
+ catch (error) {
503
+ if (error instanceof OpenCodeConfigRecoveryError)
504
+ throw new AgentSetupError("OpenCode configuration outcome could not be restored safely.", "AGENT_VERIFICATION_FAILED", { cause: error.message });
505
+ throw error;
506
+ }
507
+ if (openCodePlan.status === "installed")
441
508
  physicalWrites.push({
442
509
  path: openCodePlan.filePath,
443
510
  asset: "mcp_config",
444
511
  status: "installed",
445
512
  });
446
- }
447
- await verifyOpenCode(detection, environment, timeoutMs, {
448
- nodeExecutable,
449
- serverEntryPath: options.serverEntryPath,
450
- });
451
513
  }
452
- else
514
+ else if (inspection.status === "repairable") {
515
+ await repairMcp(agent, detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
516
+ }
517
+ else {
453
518
  await configureMcp(agent, detection, options.serverEntryPath, nodeExecutable, environment, timeoutMs);
519
+ }
454
520
  }
455
521
  else if (agent === "opencode") {
456
522
  await verifyOpenCode(detection, environment, timeoutMs, {
@@ -1,6 +1,9 @@
1
1
  import { toolCatalog } from "../tools/catalog.js";
2
2
  export const MCP_SERVER_NAME = "ast";
3
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";
4
7
  function semver(value) {
5
8
  const match = value.match(/(?:^|\s|v)(\d+)\.(\d+)\.(\d+)(?=\s|$|[-+()]|\.(?:\s|$))/);
6
9
  return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
@@ -28,6 +31,32 @@ function expected(context, command, args) {
28
31
  args.length === 1 &&
29
32
  args[0] === context.serverEntryPath);
30
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
+ }
31
60
  function parseJson(result) {
32
61
  try {
33
62
  return JSON.parse(result.stdout);
@@ -46,12 +75,7 @@ function structuredInspection(result, context) {
46
75
  if (!value || value.name !== MCP_SERVER_NAME)
47
76
  return { status: "error", operation: "MCP inspection", result };
48
77
  const transport = (value.transport ?? value);
49
- return expected(context, transport.command, transport.args)
50
- ? { status: "current" }
51
- : {
52
- status: "conflict",
53
- detail: "The existing 'ast' registration does not match this package.",
54
- };
78
+ return matchingRegistration(transport.type === "stdio" && expected(context, transport.command, transport.args), structuredGuardStatus(transport.env, transport.environment));
55
79
  }
56
80
  function copilotStructuredInspection(result, context) {
57
81
  if (result.exitCode !== 0) {
@@ -65,18 +89,35 @@ function copilotStructuredInspection(result, context) {
65
89
  return { status: "error", operation: "MCP inspection", result };
66
90
  }
67
91
  const configuration = server;
68
- return configuration.type === "local" &&
92
+ const matches = configuration.type === "local" &&
69
93
  configuration.source === "user" &&
70
94
  configuration.enabled === true &&
71
95
  Array.isArray(configuration.tools) &&
72
96
  configuration.tools.length === 1 &&
73
97
  configuration.tools[0] === "*" &&
74
- expected(context, configuration.command, configuration.args)
75
- ? { status: "current" }
76
- : {
77
- 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,
78
103
  detail: "The existing user-scoped 'ast' registration does not match this package.",
79
- };
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
+ ]);
80
121
  }
81
122
  const claudeMcp = {
82
123
  async inspect(runtime, context) {
@@ -88,87 +129,140 @@ const claudeMcp = {
88
129
  : { status: "error", operation: "MCP inspection", result };
89
130
  const fields = new Map();
90
131
  for (const line of result.stdout.split(/\r?\n/)) {
91
- const match = line.match(/^\s*(Status|Type|Command|Args):\s*(.*)$/i);
132
+ const match = line.match(/^\s*(Scope|Status|Type|Command|Args):\s*(.*)$/i);
92
133
  if (match)
93
134
  fields.set(match[1].toLowerCase(), match[2].trim());
94
135
  }
95
- 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") ?? "") &&
96
138
  fields.get("type")?.toLowerCase() === "stdio" &&
97
- expected(context, fields.get("command"), [fields.get("args")])
98
- ? { status: "current" }
99
- : {
100
- 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,
101
149
  detail: "The existing user-scoped 'ast' registration does not match this package.",
102
- };
150
+ }
151
+ : inspection;
103
152
  },
104
- 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([
105
161
  "mcp",
106
162
  "add",
107
- "--scope",
108
- "user",
109
- "--transport",
110
- "stdio",
111
163
  MCP_SERVER_NAME,
112
- "--",
164
+ "--command",
113
165
  context.nodeExecutable,
166
+ ...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
167
+ "--args",
114
168
  context.serverEntryPath,
115
- ]),
116
- registrationAccepted: (result) => result.exitCode === 0,
117
- };
169
+ ], "\n");
170
+ }
118
171
  const hermesMcp = {
119
- async inspect(runtime) {
172
+ async inspect(runtime, context) {
120
173
  const listed = await runtime.run(["mcp", "list"]);
121
174
  if (listed.exitCode !== 0)
122
175
  return { status: "error", operation: "MCP list", result: listed };
123
176
  if (!/^\s*ast(?:\s|$)/im.test(listed.stdout))
124
177
  return { status: "missing" };
125
- const tested = await runtime.run(["mcp", "test", MCP_SERVER_NAME]);
126
- return tested.exitCode === 0 && EXPECTED_TOOLS.every((tool) => tested.stdout.includes(tool))
127
- ? { status: "current" }
128
- : {
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 {
129
185
  status: "conflict",
130
- detail: "The existing 'ast' registration does not expose the expected structural tools.",
186
+ detail: "The existing 'ast' registration cannot be authenticated as this package.",
131
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
+ };
132
216
  },
133
- 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([
134
225
  "mcp",
135
226
  "add",
136
227
  MCP_SERVER_NAME,
137
- "--command",
228
+ ...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
229
+ "--",
138
230
  context.nodeExecutable,
139
- "--args",
140
231
  context.serverEntryPath,
141
- ], "\n"),
142
- registrationAccepted: (result) => result.exitCode === 0 && /saved\s+'ast'/i.test(result.stdout),
143
- };
232
+ ]);
233
+ }
144
234
  const codexMcp = {
145
235
  async inspect(runtime, context) {
146
236
  return structuredInspection(await runtime.run(["mcp", "get", MCP_SERVER_NAME, "--json"]), context);
147
237
  },
148
- register: (runtime, context) => runtime.run([
149
- "mcp",
150
- "add",
151
- MCP_SERVER_NAME,
152
- "--",
153
- context.nodeExecutable,
154
- context.serverEntryPath,
155
- ]),
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),
156
241
  registrationAccepted: (result) => result.exitCode === 0,
242
+ removalAccepted: (result) => result.exitCode === 0,
157
243
  };
158
244
  const copilotMcp = {
159
245
  async inspect(runtime, context) {
160
246
  return copilotStructuredInspection(await runtime.run(["mcp", "get", MCP_SERVER_NAME, "--json"]), context);
161
247
  },
162
- 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([
163
256
  "mcp",
164
257
  "add",
165
258
  MCP_SERVER_NAME,
166
- "--",
259
+ ...(guarded ? ["--env", APPLY_GUARD_ALLOW_ENV] : []),
167
260
  context.nodeExecutable,
168
261
  context.serverEntryPath,
169
- ]),
170
- registrationAccepted: (result) => result.exitCode === 0,
171
- };
262
+ "--scope",
263
+ "user",
264
+ ]);
265
+ }
172
266
  const geminiMcp = {
173
267
  async inspect(runtime, context) {
174
268
  const result = await runtime.run(["mcp", "list"]);
@@ -187,23 +281,24 @@ const geminiMcp = {
187
281
  const match = result.stdout.match(pattern);
188
282
  if (!match)
189
283
  return { status: "error", operation: "MCP inspection", result };
190
- return expected(context, match[1]?.trim(), match[2]?.split(",").map((item) => item.trim()))
191
- ? { status: "current" }
192
- : {
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
+ ? {
193
292
  status: "conflict",
194
293
  detail: "The existing Gemini 'ast' registration does not match this package.",
195
- };
294
+ }
295
+ : inspection;
196
296
  },
197
- register: (runtime, context) => runtime.run([
198
- "mcp",
199
- "add",
200
- MCP_SERVER_NAME,
201
- context.nodeExecutable,
202
- context.serverEntryPath,
203
- "--scope",
204
- "user",
205
- ]),
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),
206
300
  registrationAccepted: (result) => result.exitCode === 0,
301
+ removalAccepted: (result) => result.exitCode === 0,
207
302
  };
208
303
  const opencodeMcp = {
209
304
  async inspect() {
@@ -220,7 +315,14 @@ const opencodeMcp = {
220
315
  async register() {
221
316
  return { exitCode: 1, stdout: "", stderr: "OpenCode CLI registration is disabled." };
222
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
+ },
223
324
  registrationAccepted: () => false,
325
+ removalAccepted: () => false,
224
326
  };
225
327
  export const AGENT_TARGETS = [
226
328
  { id: "claude", label: "Claude Code", command: "claude", skillTarget: "claude", mcp: claudeMcp },
@@ -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,
@@ -54,9 +54,12 @@ export function defineToolCatalog(input) {
54
54
  apply: names((item) => item.effect === "apply"),
55
55
  }),
56
56
  directToon: names((item) => item.directOutputFormats.length === 2),
57
- registerAll(server) {
58
- for (const descriptor of descriptors)
57
+ registerAll(server, options = {}) {
58
+ for (const descriptor of descriptors) {
59
+ if (options.denyApply === true && descriptor.effect === "apply")
60
+ continue;
59
61
  descriptor.register(server);
62
+ }
60
63
  },
61
64
  });
62
65
  }
@@ -14,11 +14,11 @@ export async function runStdioServer(options = {}) {
14
14
  prepareProjectRuntimeForStartup();
15
15
  const runtimeActivity = options.runtimeActivity ?? new RuntimeActivityTracker();
16
16
  runtimeActivity.prepareForStartup();
17
- const server = options.server ?? createServer({ runtimeActivity });
17
+ const runtimePolicy = options.runtimePolicy ?? readRuntimePolicy();
18
+ const server = options.server ?? createServer({ runtimeActivity, denyApply: runtimePolicy.denyApply });
18
19
  const transport = options.transport ?? new StdioServerTransport();
19
20
  const send = transport.send.bind(transport);
20
21
  transport.send = (message, sendOptions) => runtimeActivity.trackSend(() => send(message, sendOptions));
21
- const runtimePolicy = options.runtimePolicy ?? readRuntimePolicy();
22
22
  const installProcessHandlers = options.installProcessHandlers ?? true;
23
23
  const exit = options.exit ?? ((code) => process.exit(code));
24
24
  const protocol = server.server;
package/docs/support.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # Support policy
2
2
 
3
- This document defines the supported platform, runtime, persistence, and operational boundary for published `ast-mcp-server` v0.11.2 and the local `0.12.0` release candidate.
3
+ This document defines the supported platform, runtime, persistence, and operational boundary for published `ast-mcp-server` v0.12.0.
4
4
 
5
5
  ## Supported release target
6
6
 
7
7
  | Environment | Status | Contract |
8
8
  | ----------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------- |
9
9
  | Linux x64 with required GNU coreutils and procfs, Node.js 22.5.0 | Historical | Immutable v0.8.1 release evidence; not the runtime floor of the published or candidate lines. |
10
- | Linux x64 with required GNU coreutils and procfs, exact Node.js 22.13.0 | Supported | Published v0.11.2 and local 0.12.0 matrices, each bound to its own package and tree bytes. |
11
- | Linux x64 with required GNU coreutils and procfs, current Node.js 24 line | Supported | Published v0.11.2 and local 0.12.0 matrices, each bound to its own package and tree bytes. |
10
+ | Linux x64 with required GNU coreutils and procfs, exact Node.js 22.13.0 | Supported | Published v0.12.0 matrix bound to its immutable package and tree bytes. |
11
+ | Linux x64 with required GNU coreutils and procfs, current Node.js 24 line | Supported | Published v0.12.0 matrix bound to its immutable package and tree bytes. |
12
12
  | Other Linux architectures or systems without the required filesystem features | Unverified | Not supported until equivalent architecture, mutation, and filesystem-publication gates pass. |
13
13
  | macOS | Unverified | Not supported until equivalent filesystem, process, package, mutation, and canary gates pass. |
14
14
  | Windows | Unverified | Not supported until equivalent filesystem, process, package, mutation, and canary gates pass. |
15
15
  | Remote, untrusted, or multi-tenant service use | Unsupported | No network authentication, sandbox, or tenant-isolation boundary is provided. |
16
16
 
17
- The local `0.12.0` release candidate requires Node.js `>=22.13.0`; its matrix targets exact Node.js 22.13.0 and the governed Node.js 24 major. Published v0.11.2 retains its immutable `>=22.13.0` metadata and exact Node.js 22.13.0/24 release evidence. The earlier v0.8.1 Node.js 22.5.0/24 matrix remains historical evidence for those bytes only. A future runtime satisfying an engine range is not automatically a verified release target.
17
+ Published v0.12.0 requires Node.js `>=22.13.0`; its immutable matrix targets exact Node.js 22.13.0 and the governed Node.js 24 major. The earlier v0.8.1 Node.js 22.5.0/24 matrix remains historical evidence for those bytes only. A future runtime satisfying an engine range is not automatically a verified release target.
18
18
 
19
19
  The checked-evidence freezer requires GNU coreutils 9.7 `mv` with `--update=none-fail`, `--no-copy`, and `--no-target-directory`. Managed setup-file mutation additionally requires that same `mv` with `--exchange`, GNU coreutils `ln -L -T`, procfs descriptor paths at `/proc/self/fd`, `O_DIRECTORY`, and `O_NOFOLLOW`. It fails closed when any verified Linux primitive is unavailable. Equivalent publication, exchange, descriptor-link, and descriptor-relative mutation semantics have not been verified on other operating systems or architectures.
20
20
 
@@ -50,7 +50,7 @@ The scoped Linux evidence passed on exact Node.js 22.13.0 and the governed Node.
50
50
 
51
51
  ## Symbol-index persistence
52
52
 
53
- Published v0.11.2 and the local `0.12.0` release candidate select native SQLite when the persistence setting is absent or explicitly `enabled`; operators requiring no persistent index state must set `disabled` explicitly:
53
+ Published v0.12.0 selects native SQLite when the persistence setting is absent or explicitly `enabled`; operators requiring no persistent index state must set `disabled` explicitly:
54
54
 
55
55
  ```bash
56
56
  AST_SYMBOL_INDEX_PERSISTENCE=disabled \
@@ -76,11 +76,11 @@ ast-tool cache inspect
76
76
  ast-tool cache clear --yes
77
77
  ```
78
78
 
79
- Both commands are bounded and path-free. Clear preflights the tree, refuses unsafe or active SQLite artifacts, rechecks identity before unlink and preserves unknown regular files/directories. There is no automatic garbage collection in this candidate.
79
+ Both commands are bounded and path-free. Clear preflights the tree, refuses unsafe or active SQLite artifacts, rechecks identity before unlink and preserves unknown regular files/directories. There is no automatic garbage collection in this release.
80
80
 
81
81
  ## Production-readiness acceptance
82
82
 
83
- The four retained Linux x64 production-readiness reports cover `ast-mcp-server` and `x-scraper` under Node.js 22.5.0 and Node.js 24 for package version 0.6.0, commit `2d0b21bbb80fae1acfca6a85d5891d87e68b59c1`, and tree `af931d49769fabdf06f623965a6cfe1f9afb8a81`. They are historical integration evidence only: they are not reattributed to v0.8.1, published-next v0.9.0 or v0.9.1, the local 0.9.2 candidate, published v0.10.0, published-next v0.11.0 or v0.11.1, published v0.11.2, or the local v0.12.0 release candidate. The exact 0.9.0 and 0.9.1 release matrices remain historical evidence for their immutable published-next bytes, the exact 0.9.2 matrix remains historical local-candidate evidence, the exact 0.10.0 matrix remains historical published-release evidence, the exact 0.11.0 and 0.11.1 matrices remain historical evidence for their immutable published-next bytes before public-registry verification exposed their distinct verifier defects, and the exact 0.11.2 matrix remains historical published-release evidence. A separate exact 0.12.0 release matrix is the release-candidate gate for current repository bytes under both supported runtime lines, including bounded diagnostic aggregates, catalog-derived tool capability projections, and their independent release oracles; none of these matrices rebinds the older retained reports. MCP-PROD-404 remains normative for admission:
83
+ The four retained Linux x64 production-readiness reports cover `ast-mcp-server` and `x-scraper` under Node.js 22.5.0 and Node.js 24 for package version 0.6.0, commit `2d0b21bbb80fae1acfca6a85d5891d87e68b59c1`, and tree `af931d49769fabdf06f623965a6cfe1f9afb8a81`. They are historical integration evidence only: they are not reattributed to v0.8.1, published-next v0.9.0 or v0.9.1, the local 0.9.2 candidate, published v0.10.0, published-next v0.11.0 or v0.11.1, published v0.11.2, or published v0.12.0. The exact 0.9.0 and 0.9.1 release matrices remain historical evidence for their immutable published-next bytes, the exact 0.9.2 matrix remains historical local-candidate evidence, the exact 0.10.0 matrix remains historical published-release evidence, the exact 0.11.0 and 0.11.1 matrices remain historical evidence for their immutable published-next bytes before public-registry verification exposed their distinct verifier defects, and the exact 0.11.2 matrix remains historical published-release evidence. The exact 0.12.0 matrix is current published-release evidence under both supported runtime lines, including bounded diagnostic aggregates, catalog-derived tool capability projections, and their independent release oracles; none of these matrices rebinds the older retained reports. MCP-PROD-404 remains normative for admission:
84
84
 
85
85
  - zero semantic mismatches across disabled, cold, warm, restart, and rollback reads;
86
86
  - byte-identical real-repository status and worktree trees;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ast-mcp-server",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Structural code MCP server and batch CLI for compact TypeScript/JavaScript reads and hash-bound AST edits.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -26,6 +26,16 @@
26
26
  },
27
27
  "type": "module",
28
28
  "packageManager": "yarn@4.15.0",
29
+ "dsh": {
30
+ "bundle": {
31
+ "patch": "./cordis.patch.yml"
32
+ }
33
+ },
34
+ "deepseekHarness": {
35
+ "revision": "cd5ef8148158c3a752a658978873241fdf8e2bbc",
36
+ "tag": "dsh-v0.1.2-alpha.1",
37
+ "mcpClientVersion": "0.1.2-alpha.1"
38
+ },
29
39
  "main": "dist/index.js",
30
40
  "bin": {
31
41
  "ast-mcp-server": "dist/index.js",
@@ -34,6 +44,7 @@
34
44
  "files": [
35
45
  "dist",
36
46
  "skills",
47
+ "cordis.patch.yml",
37
48
  "README.md",
38
49
  "CHANGELOG.md",
39
50
  "SECURITY.md",
@@ -56,6 +67,7 @@
56
67
  "test:cli": "yarn build && node scripts/cli-smoke.mjs",
57
68
  "test:errors": "yarn build && node scripts/public-error-smoke.mjs",
58
69
  "test:package": "node scripts/package-smoke.mjs",
70
+ "test:dsh-adapter": "node scripts/dsh-adapter-smoke.mjs",
59
71
  "test:local-registry": "node scripts/local-registry-consumer-smoke.mjs",
60
72
  "test:installed-agents": "yarn build && node scripts/installed-agent-guidance-smoke.mjs",
61
73
  "test:agent-fixtures": "node scripts/admit-agent-fixtures.mjs --check",
@@ -95,7 +107,8 @@
95
107
  "prettier": "^3.9.6",
96
108
  "typescript": "^5.6.0",
97
109
  "typescript-eslint": "^8.65.0",
98
- "vitest": "^4.1.10"
110
+ "vitest": "^4.1.10",
111
+ "yaml": "^2.9.0"
99
112
  },
100
- "gitHead": "3991d0a74e672c6c4d3fbd1c8bbb1a60009515ec"
113
+ "gitHead": "75302189733f40aba6a36a8379c5b1f65fc3bd84"
101
114
  }
@@ -18,7 +18,7 @@ Cargar este skill antes de navegar semánticamente, analizar impacto o referenci
18
18
 
19
19
  No forzar un roundtrip AST para Markdown, configuración, comentarios ni una edición textual trivial en un archivo ya conocido. Si las tools `ast_*` no están disponibles, declarar el fallback y no presentar búsquedas textuales como evidencia compiler-backed.
20
20
 
21
- The local 0.12.0 release candidate requires Node.js `>=22.13.0` and is verified with exact Node.js 22.13.0 plus the governed Node.js 24 line. Published v0.11.2 retains its immutable Node.js 22.13.0/24 release evidence.
21
+ Published v0.12.0 requires Node.js `>=22.13.0` and retains immutable release evidence from exact Node.js 22.13.0 plus the governed Node.js 24 line.
22
22
 
23
23
  ## Hard Rules
24
24
 
@@ -6,7 +6,7 @@
6
6
  "files": [
7
7
  {
8
8
  "path": "SKILL.md",
9
- "sha256": "d0048ffd9a585d00791540c6fa5e0bec91ff95b1448479678f85702e6b09be8a"
9
+ "sha256": "df2ebc3f3903cf5abf4eb6612da527cad97a92f29f1a9a475ea6e8b4cf47abfb"
10
10
  },
11
11
  {
12
12
  "path": "references/operations.md",
@@ -94,6 +94,11 @@
94
94
  "version": "4.5.0",
95
95
  "sha256": "18132b4b747d135e4ccfbca9e2190a9afb41e144cc06c31c5e6bc0f1571e11da",
96
96
  "npm_versions": ["0.11.2"]
97
+ },
98
+ {
99
+ "version": "4.6.0",
100
+ "sha256": "d0048ffd9a585d00791540c6fa5e0bec91ff95b1448479678f85702e6b09be8a",
101
+ "npm_versions": ["0.12.0"]
97
102
  }
98
103
  ]
99
104
  }