ast-mcp-server 0.12.0 → 0.13.1

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,26 @@ 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.1] - 2026-09-01
10
+
11
+ ### Fixed
12
+
13
+ - The DeepSeek Harness adapter now opts into a bounded canonical-JSON text projection when a successful MCP result has structured data but no ordinary text, preserving lossless `structuredContent` while making native Agent/Session results useful and durable. The mandatory pinned-host smoke freezes the public 0.13.0 RED baseline and verifies the corrected candidate across the next model request, persisted `tool/result`, cold replay, guarded catalog, and cleanup readback.
14
+ - `ast_explore` now publishes its complete object input schema while retaining all cross-field runtime rejection, and `ast_get_impact` no longer advertises one false output shape across JSON and TOON. The pinned Harness gate binds the public empty-schema RED to candidate registry/native parity and unchanged hashes for the other 14 model schemas.
15
+ - Timeout ownership is now explicit across the Harness, queue, and AST worker: the shipped outer timeout exceeds the complete queue/execution/margin budget, deterministic slow work returns bounded AST-owned operational errors, queued work never starts after its deadline, cancellation remains correlated across worker generations, and cleanup proves zero active work, held requests, listeners, or owned processes.
16
+ - Harness lifecycle handling now converges deterministically through MCP removal and reconnect, rejects retired-generation effects, preserves AST `REQUEST_CANCELLED` authority separately from bridge transport observations, and completes correlated shutdown without residual state. The pinned native and rendered-GUI gates verify the `15→0→15` catalog sequence, request/header identity, secret-safe diagnostics, and ordered cleanup that still runs every owner check after an earlier cleanup failure.
17
+
18
+ ### Release status
19
+
20
+ - This is the reviewed `0.13.1` release candidate. Publication to npm `next`, public-registry verification, promotion to `latest`, tagging, and GitHub Release creation remain pending and are not claimed here.
21
+
22
+ ## [0.13.0] — published to `latest` and `next`, registry verified (2026-08-29)
23
+
24
+ ### Added
25
+
26
+ - 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.
27
+
28
+ ## [0.12.0] — published to `latest` and `next`, registry verified (2026-08-27)
10
29
 
11
30
  ### Added
12
31
 
@@ -19,7 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
19
38
 
20
39
  ### Release status
21
40
 
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.
41
+ - 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
42
 
24
43
  ## [0.11.2] — published to `latest` and `next`, registry verified (2026-08-25)
25
44
 
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,61 @@ 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`). The published
333
+ `ast-mcp-server@0.13.0` package is the compatibility baseline:
334
+
335
+ ```bash
336
+ dsh plugin --profile web add ast-mcp-server@0.13.0
337
+ ```
338
+
339
+ For a local candidate, pack and install its tarball instead:
340
+
341
+ ```bash
342
+ yarn pack --out ast-mcp-server-%v.tgz
343
+ dsh plugin --profile web add ./ast-mcp-server-0.13.1.tgz
344
+ ```
345
+
346
+ The first supported surface is **reads + prepare + preview**. Every apply path is
347
+ denied by a fail-closed guard: `ast_apply_operation` is not registered unless
348
+ `AST_MCP_APPLY_GUARD=allow` is set explicitly (the shipped patch instead pins `deny`,
349
+ so the Harness surface stays deny-by-default; an unset or invalid value also denies).
350
+ The adapter also sets `AST_MCP_TEXT_PROJECTION=canonical_json`: successful structured
351
+ results keep their lossless `structuredContent` and gain canonical JSON text only when
352
+ ordinary MCP text is empty, because the pinned native presenter otherwise exposes only a
353
+ non-useful empty-result marker. The projection is adapter-specific, never replaces existing
354
+ text, and reports an explicit size-limit marker when the complete supervised frame has room.
355
+ If even the unchanged structured-only result exceeds the existing worker frame, supervised mode
356
+ fails closed rather than truncating or corrupting it. Known upstream gaps remain non-authoritative:
357
+ the official bridge drops MCP tool annotations
358
+ (`readOnlyHint`/`destructiveHint`) and launches the stdio child outside the Harness sandbox.
359
+
360
+ `yarn test:dsh-adapter` is the mandatory verification. It binds the public 0.13.0 npm
361
+ integrity, packs the candidate, builds the pinned Harness and bridge from source, and proves
362
+ `tools.mode: native`, the 15-tool scoped catalog, complete executable input schemas,
363
+ read/prepare/preview, all three invalid `ast_explore` combinations, apply absence, and rejected direct apply.
364
+ The schema gate freezes the public empty `ast_explore` contract as RED, requires the candidate
365
+ registry/native definitions to match, and hashes all 14 unaffected model schemas. A deterministic
366
+ two-step model then invokes
367
+ `mcp__ast__ast_get_project_status` through a real headless Agent/Session: the public baseline
368
+ must reproduce the empty-result marker, while the corrected candidate must deliver lossless
369
+ canonical JSON to the next model request, the durable `tool/result`, and a cold Agent
370
+ resume/replay reconstructed from persistence. The smoke removes and read-backs its disposable
371
+ profile/workspace state and fails
372
+ (never skips) on an identity mismatch, missing prerequisite, lifecycle leak, or evidence gap.
373
+
325
374
  ## MCP tools
326
375
 
327
376
  | 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,38 @@
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.1.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
+ # Harness native presentation reads text content, so this adapter explicitly
17
+ # requests a lossless canonical JSON projection while preserving structuredContent.
18
+ - insert:
19
+ - id: mcp-ast
20
+ name: "@deepseek-ai/dsh-mcp-client"
21
+ config:
22
+ serverName: ast
23
+ transport: stdio
24
+ command: !!js process.execPath
25
+ args:
26
+ - !!js >-
27
+ process.getBuiltinModule('node:url').fileURLToPath(
28
+ new URL('node_modules/ast-mcp-server/dist/index.js', baseUrl)
29
+ )
30
+ env:
31
+ AST_MCP_APPLY_GUARD: "deny"
32
+ AST_MCP_TEXT_PROJECTION: "canonical_json"
33
+ cwd: !!js process.cwd()
34
+ toolCallTimeoutMs: !!js >-
35
+ JSON.parse(process.getBuiltinModule('node:fs').readFileSync(
36
+ new URL('node_modules/ast-mcp-server/package.json', baseUrl), 'utf8'
37
+ )).deepseekHarness.timeoutBudget.outerToolCallMs
38
+ failOnStartupError: true
package/dist/server.js CHANGED
@@ -2,9 +2,20 @@ import { createRequire } from "node:module";
2
2
  import { McpServer, } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { PublicOperationalError } from "./services/public-errors.js";
4
4
  import { toolCatalog } from "./tools/catalog.js";
5
- import { createToolErrorContext, errorResult } from "./tools/result.js";
5
+ import { createToolErrorContext, errorResult, projectStructuredContentAsText, } from "./tools/result.js";
6
6
  const packageMetadata = createRequire(import.meta.url)("../package.json");
7
7
  export const PACKAGE_VERSION = packageMetadata.version;
8
+ function installStructuredContentTextProjection(server) {
9
+ const registerTool = server.registerTool.bind(server);
10
+ function projectedRegisterTool(name, config, callback) {
11
+ const projectedCallback = (async (...args) => {
12
+ const result = await Reflect.apply(callback, undefined, args);
13
+ return projectStructuredContentAsText(result, true);
14
+ });
15
+ return registerTool(name, config, projectedCallback);
16
+ }
17
+ server.registerTool = projectedRegisterTool;
18
+ }
8
19
  function installRuntimeActivityTracking(server, runtimeActivity) {
9
20
  const registerTool = server.registerTool.bind(server);
10
21
  function trackedRegisterTool(name, config, callback) {
@@ -25,6 +36,14 @@ export function createServer(options = {}) {
25
36
  });
26
37
  if (options.runtimeActivity)
27
38
  installRuntimeActivityTracking(server, options.runtimeActivity);
28
- toolCatalog.registerAll(server);
39
+ if (options.projectStructuredContentAsText === true) {
40
+ installStructuredContentTextProjection(server);
41
+ }
42
+ if (options.denyApply === undefined) {
43
+ toolCatalog.registerAll(server);
44
+ }
45
+ else {
46
+ toolCatalog.registerAll(server, { denyApply: options.denyApply });
47
+ }
29
48
  return server;
30
49
  }
@@ -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, {