From cbf4f6554efc05aa37dbf39051f3ac23cc4c6a2a Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 06:51:20 +0800 Subject: [PATCH 1/9] fix: carry the IDE entry's env when wiring the datamate stdio MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datamate_manager add` reused the command + args from the IDE's `mcp.json` `datamate` entry but dropped its `env` block, both in the immediate spawn and in the entry persisted to `.altimate-code/altimate-code.json`. On desktop editors the command is the editor's Electron binary and `env` carries `ELECTRON_RUN_AS_NODE=1` — spawned without it, the editor GUI boots and opens `datamate-cli.js` as a document, the MCP client reports `-32000 Connection closed`, and the broken persisted entry re-pops the file on every subsequent session launch. - `readDatamateTransportFromIde` now returns the entry's env (minus `ALTIMATE_EXTENSION_RPC`, mirroring the sync path) and `updatedAt`; `handleAdd` carries the env into the runtime config and persists it as `environment`, plus `updatedAt` on disk so the sync recognizes the entry as current. - The sync path's inline env-strip is extracted into the shared `extractSpawnEnvironment` helper so both paths stay in lockstep. - The TUI worker and `run` now run `syncDatamateUrlFromVscodeMcp` before the first session (as `serve` already did), so entries already persisted without `environment` self-heal on the next launch. --- .../src/altimate/datamate-transport.ts | 40 +++++- .../opencode/src/altimate/tools/datamate.ts | 28 +++- packages/opencode/src/cli/cmd/run.ts | 9 ++ packages/opencode/src/cli/tui/worker.ts | 21 +++ .../mcp-datamate-stdio-env.test.ts | 133 ++++++++++++++++++ 5 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 8a5e967233..98543f7b19 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -21,7 +21,26 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const export type DatamateTransport = | { type: "remote"; url: string } - | { type: "local"; command: string[] } + | { type: "local"; command: string[]; environment?: Record; updatedAt?: string } + +/** + * Env block to carry over when spawning the datamate CLI from an IDE mcp.json + * entry, minus ALTIMATE_EXTENSION_RPC (the extension-private RPC socket path, + * which goes stale whenever the extension restarts and is re-resolved by the + * CLI itself). ELECTRON_RUN_AS_NODE must survive: on desktop editors the + * entry's command is the editor's Electron binary, and without the flag the + * spawn boots the editor GUI — which opens datamate-cli.js as a document in + * the IDE — instead of running it as a Node script. + */ +function extractSpawnEnvironment(raw: unknown): Record | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined + const env: Record = {} + for (const [key, value] of Object.entries(raw as Record)) { + if (key === "ALTIMATE_EXTENSION_RPC") continue + if (typeof value === "string") env[key] = value + } + return Object.keys(env).length > 0 ? env : undefined +} /** * Parse a single mcp.json file and return the servers map, trying each of the @@ -108,11 +127,21 @@ export async function readDatamateTransportFromIde( return { type: "remote", url: entry["url"] } } - // stdio entry — reuse the exact command + args the extension registered + // stdio entry — reuse the exact command + args + env the extension + // registered. Dropping env here regresses desktop editors: the entry's + // command is the editor's Electron binary and only runs as Node when + // ELECTRON_RUN_AS_NODE=1 is passed through. const cmd = typeof entry["command"] === "string" ? entry["command"] : undefined const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : [] if (cmd) { - return { type: "local", command: [cmd, ...args] } + const environment = extractSpawnEnvironment(entry["env"]) + const updatedAt = typeof entry["updatedAt"] === "string" ? entry["updatedAt"] : undefined + return { + type: "local", + command: [cmd, ...args], + ...(environment ? { environment } : {}), + ...(updatedAt ? { updatedAt } : {}), + } } // Entry exists but has no usable command — treat as local marker @@ -221,8 +250,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise if ("command" in datamateVscode) { - const env = datamateVscode["env"] as Record | undefined - const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} + const environment = extractSpawnEnvironment(datamateVscode["env"]) const cmd = typeof datamateVscode["command"] === "string" ? (datamateVscode["command"] as string) @@ -231,7 +259,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise 0 ? { environment: restEnv } : {}), + ...(environment ? { environment } : {}), updatedAt: vscodeUpdatedAt, } } else { diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 7e1bb6944d..562072abdf 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -206,11 +206,17 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p transport?.type === "remote" ? { type: "remote" as const, url: transport.url } : transport?.type === "local" - // Use the exact command from the IDE config so we reuse the process the - // extension manages rather than spawning a second one. The extension and - // altimate-code would otherwise maintain two separate stdio child processes - // connected to the same datamate binary, wasting resources. - ? { type: "local" as const, command: transport.command } + // Use the exact command + env from the IDE config so we reuse the process + // the extension manages rather than spawning a second one. The env block + // must be carried: on desktop editors the command is the editor's Electron + // binary, which only runs as Node when ELECTRON_RUN_AS_NODE=1 is set — + // spawned without it, the editor GUI boots and opens datamate-cli.js as a + // document instead. + ? { + type: "local" as const, + command: transport.command, + ...(transport.environment ? { environment: transport.environment } : {}), + } : AltimateApi.buildMcpConfig(creds!, args.datamate_id) const isGlobal = args.scope === "global" @@ -258,12 +264,20 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p }) await MCP.connect(DATAMATE_KEY) } else { - // Not in config yet — write to disk then connect + // Not in config yet — write to disk then connect. The persisted entry + // additionally carries the IDE entry's updatedAt (disk-only; the runtime + // config schema has no such field) so the mcp.json sync recognizes the + // entry as current instead of rewriting it on the next serve boot. log.info("handleAdd: adding new datamate entry", { serverName: DATAMATE_KEY, type: mcpConfig.type, }) - await addMcpToConfig(DATAMATE_KEY, { ...mcpConfig, enabled: true }, configPath) + const diskEntry = { + ...mcpConfig, + enabled: true, + ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + } + await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters[1], configPath) await MCP.add(DATAMATE_KEY, mcpConfig) } } else { diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 50638bcd14..93b9e73cff 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -942,6 +942,15 @@ You are speaking to a non-technical business executive. Follow these rules stric return await execute(sdk) } + // altimate_change start — heal the datamate MCP entry before the session starts, + // mirroring cli/cmd/serve.ts: an entry persisted without its env block (e.g. + // missing ELECTRON_RUN_AS_NODE for an Electron command) would otherwise be + // re-spawned broken on every run invocation with no path to self-repair. + { + const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport") + await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) + } + // altimate_change end await bootstrap(process.cwd(), async () => { const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 8d4dd58e45..47d995a889 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -27,6 +27,13 @@ import { Instance } from "@/project/instance" // altimate_change — onboarding telemetry: flush this thread's buffer in rpc.shutdown() import { Telemetry } from "@/altimate/telemetry" import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" +// altimate_change start — heal the datamate MCP entry at boot. `altimate serve` runs +// this sync before listening (cli/cmd/serve.ts), but the TUI worker never did, so an +// entry persisted without its env block (e.g. missing ELECTRON_RUN_AS_NODE for an +// Electron command) was re-spawned broken on every TUI session start with no path to +// self-repair. +import { syncDatamateUrlFromVscodeMcp } from "@/altimate/datamate-transport" +// altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling // is enforced by the compiler rather than by a comment. @@ -34,6 +41,12 @@ const SHUTDOWN_BUDGET_MS = Telemetry.TUI_SHUTDOWN_BUDGET_MS Heap.start() +// altimate_change start — datamate entry heal, awaited before the first in-process +// request (session start connects MCP servers from the config this sync repairs). +// Errors are swallowed: a failed sync must never block the TUI. +const datamateSyncReady: Promise = syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) +// altimate_change end + const traceConsumer = new TraceConsumer() // loadConfig() must complete before the first event: getOrCreateTrace caches, per session, a Trace // whose snapshot dir comes from loadConfig's FileExporter — an event handled before it finishes caches @@ -65,6 +78,10 @@ let server: Awaited> | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { + // altimate_change start — no request is served until the datamate entry heal + // completes (already-resolved after the first request; effectively free thereafter). + await datamateSyncReady + // altimate_change end const headers = { ...input.headers } const auth = ServerAuth.header() if (auth && !headers["authorization"] && !headers["Authorization"]) { @@ -90,6 +107,10 @@ export const rpc = { return result }, async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { + // altimate_change start — external-server mode bypasses rpc.fetch, so gate listen + // on the datamate entry heal the same way (mirrors cli/cmd/serve.ts ordering). + await datamateSyncReady + // altimate_change end if (server) await server.stop(true) server = await Server.listen(input) return { url: server.url.toString() } diff --git a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts new file mode 100644 index 0000000000..5c49d190ce --- /dev/null +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -0,0 +1,133 @@ +import { describe, test, expect } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { mkdir, writeFile, readFile } from "fs/promises" +import path from "path" +import { + readDatamateTransportFromIde, + syncDatamateUrlFromVscodeMcp, + DATAMATE_KEY, +} from "../../src/altimate/datamate-transport" + +// Regression tests for the stdio env carry-through. The IDE extension writes the +// datamate stdio entry with an env block — on desktop editors the entry's command +// is the editor's Electron binary and env carries ELECTRON_RUN_AS_NODE=1, without +// which the spawn boots the editor GUI and opens datamate-cli.js as a document +// instead of running it. readDatamateTransportFromIde used to drop env entirely, +// so `datamate_manager add` persisted a broken entry that re-popped the file on +// every session launch. + +async function seedIdeStdio(dir: string, entry: Record) { + await mkdir(path.join(dir, ".vscode"), { recursive: true }) + await writeFile( + path.join(dir, ".vscode", "mcp.json"), + JSON.stringify({ servers: { [DATAMATE_KEY]: entry } }, null, 2), + ) +} + +describe("readDatamateTransportFromIde stdio env carry-through", () => { + test("carries env minus ALTIMATE_EXTENSION_RPC, plus updatedAt", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { + ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock", + ELECTRON_RUN_AS_NODE: "1", + }, + updatedAt: "2026-08-06T00:00:00.000Z", + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ + type: "local", + command: ["/path/to/electron", "/ext/dist/datamate-cli.js", "start-stdio"], + environment: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "2026-08-06T00:00:00.000Z", + }) + }) + + test("env with only ALTIMATE_EXTENSION_RPC → environment omitted entirely", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/usr/lib/code-server/lib/node", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock" }, + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ + type: "local", + command: ["/usr/lib/code-server/lib/node", "/ext/dist/datamate-cli.js", "start-stdio"], + }) + }) + + test("entry without env keeps the bare local shape (back-compat)", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "datamate", + args: ["start-stdio"], + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ type: "local", command: ["datamate", "start-stdio"] }) + }) + + test("non-string env values are ignored, string values kept", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["start-stdio"], + env: { + ELECTRON_RUN_AS_NODE: "1", + BOGUS_NUMBER: 42, + BOGUS_OBJECT: { nested: true }, + }, + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t?.type).toBe("local") + if (t?.type === "local") { + expect(t.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + } + }) +}) + +describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { + test("synced local entry strips ALTIMATE_EXTENSION_RPC but keeps ELECTRON_RUN_AS_NODE", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "altimate-code.json") + await writeFile( + configPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["stale"], enabled: true, updatedAt: "T1" } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { + ALTIMATE_EXTENSION_RPC: "/tmp/altimate-mcp-1.sock", + ELECTRON_RUN_AS_NODE: "1", + }, + updatedAt: "T2", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + expect(updated).toContain(DATAMATE_KEY) + + const after = JSON.parse(await readFile(configPath, "utf-8")) + const entry = after.mcp[DATAMATE_KEY] + expect(entry.type).toBe("local") + expect(entry.command).toEqual(["/path/to/electron", "/ext/dist/datamate-cli.js", "start-stdio"]) + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + expect(entry.updatedAt).toBe("T2") + expect(entry.enabled).toBe(true) // non-transport field preserved + }) +}) From 80d4ad4cd2c7779ec8b36d9cff829cf3598c7dcc Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 13:36:57 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20heal?= =?UTF-8?q?=20ordering,=20existing-entry=20refresh,=20project-root=20sync?= =?UTF-8?q?=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TUI worker: the datamate heal is now sequenced strictly before `InstanceRuntime.load`/`Config.get()` (trace init awaits it), so the config read can neither race the non-atomic write nor cache the pre-heal entry — the first session connects with the healed config. - `datamate_manager add`: the in-config-but-not-connected branch refreshes the persisted entry from the current IDE transport (preserving user-managed fields) and connects via `MCP.add`, instead of `MCP.connect` which re-reads the stale in-memory entry. - Boot heals (`run`, TUI worker) scan from the containing git project root via the new `resolveDatamateSyncRoot`, not raw cwd — a session launched from a subdirectory now finds the root IDE config and persisted entry. --- .../src/altimate/datamate-transport.ts | 20 ++++++++++++ .../opencode/src/altimate/tools/datamate.ts | 32 +++++++++++++++---- packages/opencode/src/cli/cmd/run.ts | 9 ++++-- packages/opencode/src/cli/tui/worker.ts | 20 +++++++++--- .../mcp-datamate-stdio-env.test.ts | 20 ++++++++++++ 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 98543f7b19..ff2931326a 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -42,6 +42,26 @@ function extractSpawnEnvironment(raw: unknown): Record | undefin return Object.keys(env).length > 0 ? env : undefined } +/** + * Root directory the boot-time heal should scan from: the containing git + * project root when there is one, else the directory itself. Boot-time callers + * (TUI worker, `run`) fire the sync before an Instance exists, so they cannot + * use `Instance.worktree` — but MCP config is scoped to the project root, and + * a session launched from a subdirectory would otherwise scan the subtree and + * miss both the IDE config and the persisted entry it needs to repair. + */ +export async function resolveDatamateSyncRoot(directory: string): Promise { + try { + const matches = Filesystem.up({ targets: [".git"], start: directory }) + const dotgit = await matches.next().then((x) => x.value) + await matches.return() + if (dotgit) return path.dirname(dotgit) + } catch { + // fall through to the directory itself + } + return directory +} + /** * Parse a single mcp.json file and return the servers map, trying each of the * known top-level key names in order. diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 562072abdf..954431993b 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -8,6 +8,7 @@ import { listMcpInConfig, resolveConfigPath, findAllConfigPaths, + readMcpEntryFromDisk, } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" @@ -255,14 +256,33 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p output: `Datamate tools are already available via the '${DATAMATE_KEY}' MCP server (${toolCount} tools active).${staleNote}`, } } - // In config but not connected — reconnect via MCP.connect() so persistMcpEnabled - // is called and the enabled:true state survives the next session restart. - // Bug-fix: was previously MCP.add() which skips persistMcpEnabled, so a session - // that had the server disabled would not re-enable it on the next restart. - log.info("handleAdd: reconnecting existing datamate entry", { + // In config but not connected — refresh the persisted entry from the current + // IDE transport before connecting. MCP.connect() reads the in-memory Config + // singleton, so a stale entry (e.g. one persisted without its environment + // block) would be respawned broken no matter what the IDE entry says now. + // Same pattern as the reload-datamate endpoint: write the fresh entry to + // disk, then MCP.add() with the config directly. Writing enabled: true + // preserves the re-enable-on-restart behavior MCP.connect()'s + // persistMcpEnabled used to provide; other user-managed fields (timeout, + // oauth, …) are carried over from the existing entry. + log.info("handleAdd: refreshing and reconnecting existing datamate entry", { serverName: DATAMATE_KEY, + type: mcpConfig.type, }) - await MCP.connect(DATAMATE_KEY) + const existing = await readMcpEntryFromDisk(DATAMATE_KEY, configPath) + const TRANSPORT_FIELDS = new Set(["type", "command", "args", "environment", "url", "updatedAt", "enabled"]) + const preserved: Record = {} + for (const [k, v] of Object.entries(existing ?? {})) { + if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v + } + const refreshed = { + ...preserved, + ...mcpConfig, + enabled: true, + ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + } + await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) + await MCP.add(DATAMATE_KEY, mcpConfig) } else { // Not in config yet — write to disk then connect. The persisted entry // additionally carries the IDE entry's updatedAt (disk-only; the runtime diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 93b9e73cff..ae2d97d4e6 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -946,9 +946,14 @@ You are speaking to a non-technical business executive. Follow these rules stric // mirroring cli/cmd/serve.ts: an entry persisted without its env block (e.g. // missing ELECTRON_RUN_AS_NODE for an Electron command) would otherwise be // re-spawned broken on every run invocation with no path to self-repair. + // Scoped to the project root, not cwd — a run from a subdirectory must still + // find the root IDE config and the persisted entry it needs to repair. { - const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport") - await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) + const { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot } = await import( + "../../altimate/datamate-transport" + ) + const root = await resolveDatamateSyncRoot(process.cwd()).catch(() => process.cwd()) + await syncDatamateUrlFromVscodeMcp(root).catch(() => {}) } // altimate_change end await bootstrap(process.cwd(), async () => { diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 47d995a889..c00117a9a5 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -32,7 +32,7 @@ import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // entry persisted without its env block (e.g. missing ELECTRON_RUN_AS_NODE for an // Electron command) was re-spawned broken on every TUI session start with no path to // self-repair. -import { syncDatamateUrlFromVscodeMcp } from "@/altimate/datamate-transport" +import { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot } from "@/altimate/datamate-transport" // altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling @@ -41,10 +41,17 @@ const SHUTDOWN_BUDGET_MS = Telemetry.TUI_SHUTDOWN_BUDGET_MS Heap.start() -// altimate_change start — datamate entry heal, awaited before the first in-process -// request (session start connects MCP servers from the config this sync repairs). +// altimate_change start — datamate entry heal. Scoped to the project root (a session +// launched from a subdirectory must still find the root IDE config + persisted entry). +// Everything that reads the config is sequenced AFTER this promise — trace init below, +// the first in-process request, and Server.listen — because the heal writes +// altimate-code.json with a non-atomic write, and InstanceRuntime.load/Config.get() +// would otherwise race it (transiently truncated read) or cache the pre-heal entry, +// making the first session spawn the broken config anyway. // Errors are swallowed: a failed sync must never block the TUI. -const datamateSyncReady: Promise = syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) +const datamateSyncReady: Promise = resolveDatamateSyncRoot(process.cwd()) + .then((root) => syncDatamateUrlFromVscodeMcp(root)) + .catch(() => {}) // altimate_change end const traceConsumer = new TraceConsumer() @@ -54,6 +61,11 @@ const traceConsumer = new TraceConsumer() // Config.get() (a facade needing an Instance on the canonical ALS the bare worker lacks at init), so // load the project instance for the worker's cwd first; best-effort fallback otherwise. const traceReady: Promise = (async () => { + // altimate_change start — the datamate heal writes altimate-code.json; let it finish + // before InstanceRuntime.load/Config.get() read (and cache) the config, so the first + // session connects with the healed entry instead of a stale or half-written one. + await datamateSyncReady + // altimate_change end try { const ctx = await InstanceRuntime.load({ directory: process.cwd() }) await Instance.restore(ctx, () => traceConsumer.loadConfig()) diff --git a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts index 5c49d190ce..e704c2527c 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -5,6 +5,7 @@ import path from "path" import { readDatamateTransportFromIde, syncDatamateUrlFromVscodeMcp, + resolveDatamateSyncRoot, DATAMATE_KEY, } from "../../src/altimate/datamate-transport" @@ -96,6 +97,25 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { }) }) +describe("resolveDatamateSyncRoot", () => { + test("resolves the containing git project root from a subdirectory", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, ".git"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "deep"), { recursive: true }) + + const root = await resolveDatamateSyncRoot(path.join(tmp.path, "packages", "deep")) + expect(root).toBe(tmp.path) + }) + + test("falls back to the directory itself outside a git project", async () => { + await using tmp = await tmpdir() + await mkdir(path.join(tmp.path, "plain"), { recursive: true }) + + const root = await resolveDatamateSyncRoot(path.join(tmp.path, "plain")) + expect(root).toBe(path.join(tmp.path, "plain")) + }) +}) + describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { test("synced local entry strips ALTIMATE_EXTENSION_RPC but keeps ELECTRON_RUN_AS_NODE", async () => { await using tmp = await tmpdir() From 1cb8fad7960879b630eea9e76b280de4852b8e1c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 13:48:12 +0800 Subject: [PATCH 3/9] refactor: share TRANSPORT_IDENTITY_FIELDS between sync and datamate add refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paths encode the same idea — entry fields re-derived from the IDE transport versus user-managed fields carried forward. A single exported set keeps them from silently diverging when a new transport field is added; the add-refresh path layers `enabled` on top since it re-derives that too. --- .../src/altimate/datamate-transport.ts | 25 ++++++++++++------- .../opencode/src/altimate/tools/datamate.ts | 8 +++--- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index ff2931326a..0c40270fc6 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -62,6 +62,21 @@ export async function resolveDatamateSyncRoot(directory: string): Promise = new Set([ + "type", + "command", + "args", + "environment", + "url", + "updatedAt", +]) + /** * Parse a single mcp.json file and return the servers map, trying each of the * known top-level key names in order. @@ -255,17 +270,9 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise = {} for (const [k, v] of Object.entries(existingEntry)) { - if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v + if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v } let newEntry: Record diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 954431993b..4077b25ec1 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -13,7 +13,7 @@ import { import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "@/altimate/util/log" -import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" +import { DATAMATE_KEY, readDatamateTransportFromIde, TRANSPORT_IDENTITY_FIELDS } from "../datamate-transport" const log = Log.create({ service: "datamate" }) @@ -270,10 +270,12 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p type: mcpConfig.type, }) const existing = await readMcpEntryFromDisk(DATAMATE_KEY, configPath) - const TRANSPORT_FIELDS = new Set(["type", "command", "args", "environment", "url", "updatedAt", "enabled"]) + // enabled joins the shared transport-identity set here because this path + // re-derives it too (always written as true below). + const replacedFields = new Set([...TRANSPORT_IDENTITY_FIELDS, "enabled"]) const preserved: Record = {} for (const [k, v] of Object.entries(existing ?? {})) { - if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v + if (!replacedFields.has(k)) preserved[k] = v } const refreshed = { ...preserved, From 7bcc9b6405167eb8b91908bc5b9ccbfe6a753d0e Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 13:52:09 +0800 Subject: [PATCH 4/9] fix: connect refreshed datamate entry with its preserved settings; carry updatedAt for remote - The add-refresh path wrote the merged entry (preserved headers/oauth/timeout + fresh transport) to disk but connected the live client with the bare transport config, dropping authentication and connection settings for the session being connected. MCP.add now receives the same merged entry as the disk write, matching the reload-datamate endpoint. - The remote transport variant now carries updatedAt like the local one, so a remote datamate added via datamate_manager is not rewritten once by the next boot's sync purely for the missing change signal. --- .../opencode/src/altimate/datamate-transport.ts | 8 ++++++-- packages/opencode/src/altimate/tools/datamate.ts | 9 ++++++--- .../mcp-datamate-stdio-env.test.ts | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 0c40270fc6..8deb45b1ab 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -20,7 +20,7 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const export type DatamateTransport = - | { type: "remote"; url: string } + | { type: "remote"; url: string; updatedAt?: string } | { type: "local"; command: string[]; environment?: Record; updatedAt?: string } /** @@ -159,7 +159,11 @@ export async function readDatamateTransportFromIde( }) if (typeof entry["url"] === "string") { - return { type: "remote", url: entry["url"] } + // updatedAt carried for parity with the local branch: the boot-time sync + // uses it as its change signal regardless of transport type, and an entry + // persisted without it gets one redundant rewrite on the next boot. + const updatedAt = typeof entry["updatedAt"] === "string" ? entry["updatedAt"] : undefined + return { type: "remote", url: entry["url"], ...(updatedAt ? { updatedAt } : {}) } } // stdio entry — reuse the exact command + args + env the extension diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 4077b25ec1..9e90af77a9 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -281,10 +281,13 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ...preserved, ...mcpConfig, enabled: true, - ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), } await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) - await MCP.add(DATAMATE_KEY, mcpConfig) + // The live client must get the same merged entry as the disk write — the + // bare transport config would drop preserved auth/connection settings + // (headers, oauth, timeout) for the session being connected right now. + await MCP.add(DATAMATE_KEY, refreshed as Parameters[1]) } else { // Not in config yet — write to disk then connect. The persisted entry // additionally carries the IDE entry's updatedAt (disk-only; the runtime @@ -297,7 +300,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p const diskEntry = { ...mcpConfig, enabled: true, - ...(transport?.type === "local" && transport.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), } await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters[1], configPath) await MCP.add(DATAMATE_KEY, mcpConfig) diff --git a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts index e704c2527c..f5bf721897 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -64,6 +64,22 @@ describe("readDatamateTransportFromIde stdio env carry-through", () => { }) }) + test("remote entry carries updatedAt for sync parity, bare shape without it", async () => { + await using tmp = await tmpdir() + await seedIdeStdio(tmp.path, { + type: "http", + url: "http://localhost:7801/mcp", + updatedAt: "2026-08-06T00:00:00.000Z", + }) + + const t = await readDatamateTransportFromIde(tmp.path) + expect(t).toEqual({ + type: "remote", + url: "http://localhost:7801/mcp", + updatedAt: "2026-08-06T00:00:00.000Z", + }) + }) + test("entry without env keeps the bare local shape (back-compat)", async () => { await using tmp = await tmpdir() await seedIdeStdio(tmp.path, { From 37c3d2c8e523663da3b9467e5ac718e555732970 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Fri, 7 Aug 2026 14:01:58 +0800 Subject: [PATCH 5/9] refactor: hoist shared updatedAt spread in handleAdd Both the refresh and new-entry branches persisted the transport's updatedAt with the same conditional spread; a single `updatedAtField` above the branch keeps them from drifting, and the disk-only rationale is documented once. --- packages/opencode/src/altimate/tools/datamate.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 9e90af77a9..95e1656257 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -224,7 +224,11 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal) if (transport !== null) { - // IDE/extension mode: check if DATAMATE_KEY is already wired up + // IDE/extension mode: check if DATAMATE_KEY is already wired up. + // updatedAt is disk-only (the runtime config schema has no such field); the + // mcp.json sync uses it to recognize the entry as current instead of + // rewriting it on the next boot. + const updatedAtField = transport.updatedAt ? { updatedAt: transport.updatedAt } : {} const existingNames = await listMcpInConfig(configPath) const staleEntries = existingNames.filter( (n) => n !== DATAMATE_KEY && n.startsWith("datamate-"), @@ -281,7 +285,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p ...preserved, ...mcpConfig, enabled: true, - ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...updatedAtField, } await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters[1], configPath) // The live client must get the same merged entry as the disk write — the @@ -289,10 +293,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p // (headers, oauth, timeout) for the session being connected right now. await MCP.add(DATAMATE_KEY, refreshed as Parameters[1]) } else { - // Not in config yet — write to disk then connect. The persisted entry - // additionally carries the IDE entry's updatedAt (disk-only; the runtime - // config schema has no such field) so the mcp.json sync recognizes the - // entry as current instead of rewriting it on the next serve boot. + // Not in config yet — write to disk then connect. log.info("handleAdd: adding new datamate entry", { serverName: DATAMATE_KEY, type: mcpConfig.type, @@ -300,7 +301,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p const diskEntry = { ...mcpConfig, enabled: true, - ...(transport?.updatedAt ? { updatedAt: transport.updatedAt } : {}), + ...updatedAtField, } await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters[1], configPath) await MCP.add(DATAMATE_KEY, mcpConfig) From 33b60d8b969ceb013f9bfd3d7b27daadc82064a0 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 05:44:39 +0800 Subject: [PATCH 6/9] fix: heal the datamate entry in the global config too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit datamate_manager add supports scope "global", so a broken (env-less) datamate entry can live in the global altimate-code.json. It is spawned at session start like any merged config entry — reproducing the editor-tab pop — but the boot heal only rewrote the project config, so the entry never repaired (found by the bug reporter testing the fix: no environment block appeared). syncDatamateUrlFromVscodeMcp now heals every config file carrying a datamate entry via findAllConfigPaths (project, project subdirs, global), reporting the entry once. Sync tests pass an isolated global dir so test runs never touch the developer's real config. --- .../src/altimate/datamate-transport.ts | 140 ++++++++++-------- .../mcp-datamate-893.test.ts | 6 +- .../mcp-datamate-stdio-env.test.ts | 60 +++++++- 3 files changed, 140 insertions(+), 66 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 8deb45b1ab..d71383b324 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -1,7 +1,8 @@ import { readFile } from "fs/promises" import path from "path" import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" -import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" +import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk, findAllConfigPaths } from "../mcp/config" +import { Global } from "../global" import { Filesystem } from "../util/filesystem" import { Glob } from "@opencode-ai/core/util/glob" import { Log } from "@/altimate/util/log" @@ -204,7 +205,11 @@ export async function readDatamateTransportFromIde( * Fire-and-forget friendly: errors are logged but never thrown. * Returns the list of MCP server names whose config was updated on disk. */ -export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise { +export async function syncDatamateUrlFromVscodeMcp( + cwd: string, + // Overridable for tests only — the real global config dir is a static xdg path. + globalConfigDir: string = Global.Path.config, +): Promise { const updated: string[] = [] try { log.info("syncDatamateUrlFromVscodeMcp: start", { cwd }) @@ -246,76 +251,87 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise => { const configText = await Filesystem.readText(configPath) const existingTree = parseTree(configText) const existingNode = existingTree ? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY]) : undefined + if (!existingNode) return false + + // getNodeValue reconstructs the full entry (a manual children walk reading + // `prop.children[1].value` drops array/object fields — jsonc-parser only + // populates `Node.value` for primitives). + const existingEntry = + existingNode.type === "object" + ? (getNodeValue(existingNode) as Record) + : {} + const existingUpdatedAt = + typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined + + if (vscodeUpdatedAt === existingUpdatedAt) { + log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { + configPath, + updatedAt: vscodeUpdatedAt, + }) + return false + } - if (existingNode) { - // getNodeValue reconstructs the full entry (a manual children walk reading - // `prop.children[1].value` drops array/object fields — jsonc-parser only - // populates `Node.value` for primitives). - const existingEntry = - existingNode.type === "object" - ? (getNodeValue(existingNode) as Record) - : {} - const existingUpdatedAt = - typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined - - if (vscodeUpdatedAt === existingUpdatedAt) { - log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { - updatedAt: vscodeUpdatedAt, - }) - } else { - // Preserve fields the IDE doesn't manage (enabled, timeout, oauth, …) by - // carrying forward everything except the transport-identity fields, which - // we re-derive below. IDE config uses "stdio"/"http"/"streamable-http"/"sse"; - // altimate-code.json uses "local"/"remote". - const preserved: Record = {} - for (const [k, v] of Object.entries(existingEntry)) { - if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v - } - - let newEntry: Record - if ("command" in datamateVscode) { - const environment = extractSpawnEnvironment(datamateVscode["env"]) - const cmd = - typeof datamateVscode["command"] === "string" - ? (datamateVscode["command"] as string) - : DATAMATE_KEY - newEntry = { - ...preserved, - type: "local", - command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])], - ...(environment ? { environment } : {}), - updatedAt: vscodeUpdatedAt, - } - } else { - // http / streamable-http / sse → remote - newEntry = { - ...preserved, - type: "remote", - url: datamateVscode["url"] as string, - updatedAt: vscodeUpdatedAt, - } - } + // Preserve fields the IDE doesn't manage (enabled, timeout, oauth, …) by + // carrying forward everything except the transport-identity fields, which + // we re-derive below. IDE config uses "stdio"/"http"/"streamable-http"/"sse"; + // altimate-code.json uses "local"/"remote". + const preserved: Record = {} + for (const [k, v] of Object.entries(existingEntry)) { + if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v + } - await addMcpToConfig( - DATAMATE_KEY, - newEntry as Parameters[1], - configPath, - ) - log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { - type: datamateVscode["type"], - updatedAt: vscodeUpdatedAt, - }) - updated.push(DATAMATE_KEY) + let newEntry: Record + if ("command" in datamateVscode) { + const environment = extractSpawnEnvironment(datamateVscode["env"]) + const cmd = + typeof datamateVscode["command"] === "string" + ? (datamateVscode["command"] as string) + : DATAMATE_KEY + newEntry = { + ...preserved, + type: "local", + command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])], + ...(environment ? { environment } : {}), + updatedAt: vscodeUpdatedAt, + } + } else { + // http / streamable-http / sse → remote + newEntry = { + ...preserved, + type: "remote", + url: datamateVscode["url"] as string, + updatedAt: vscodeUpdatedAt, } } + + await addMcpToConfig( + DATAMATE_KEY, + newEntry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { + configPath, + type: datamateVscode["type"], + updatedAt: vscodeUpdatedAt, + }) + return true + } + + let datamateHealed = false + for (const configPath of await findAllConfigPaths(cwd, globalConfigDir)) { + if (await healEntryInFile(configPath)) datamateHealed = true } + if (datamateHealed) updated.push(DATAMATE_KEY) } // ── All other remote MCP entries: existing URL-comparison logic ────────── diff --git a/packages/opencode/test/release-validation/mcp-datamate-893.test.ts b/packages/opencode/test/release-validation/mcp-datamate-893.test.ts index 50d21b3e78..fd0b03ef63 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-893.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-893.test.ts @@ -350,7 +350,7 @@ describe("PR893: syncDatamateUrlFromVscodeMcp updatedAt-based change detection", }) await seedIdeMcp(tmp.path, { url: "http://NEW" }) // no updatedAt - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).not.toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) @@ -368,7 +368,7 @@ describe("PR893: syncDatamateUrlFromVscodeMcp updatedAt-based change detection", }) await seedIdeMcp(tmp.path, { url: "http://NEW", updatedAt: "T1" }) - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).not.toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) @@ -386,7 +386,7 @@ describe("PR893: syncDatamateUrlFromVscodeMcp updatedAt-based change detection", }) await seedIdeMcp(tmp.path, { url: "http://NEW", updatedAt: "T2" }) - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) diff --git a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts index f5bf721897..d6d95427bc 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -155,7 +155,7 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { updatedAt: "T2", }) - const updated = await syncDatamateUrlFromVscodeMcp(tmp.path) + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, path.join(tmp.path, "isolated-global")) expect(updated).toContain(DATAMATE_KEY) const after = JSON.parse(await readFile(configPath, "utf-8")) @@ -166,4 +166,62 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { expect(entry.updatedAt).toBe("T2") expect(entry.enabled).toBe(true) // non-transport field preserved }) + + test("heals a datamate entry living only in the GLOBAL config", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const globalConfigPath = path.join(globalDir, "altimate-code.json") + await writeFile( + globalConfigPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T3", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const after = JSON.parse(await readFile(globalConfigPath, "utf-8")) + const entry = after.mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + expect(entry.updatedAt).toBe("T3") + expect(entry.enabled).toBe(true) + }) + + test("heals project and global entries in one pass", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const brokenEntry = { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } + const projectConfigPath = path.join(tmp.path, "altimate-code.json") + const globalConfigPath = path.join(globalDir, "altimate-code.json") + await writeFile(projectConfigPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) + await writeFile(globalConfigPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T4", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toEqual([DATAMATE_KEY]) // reported once, not per file + + for (const p of [projectConfigPath, globalConfigPath]) { + const entry = JSON.parse(await readFile(p, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + expect(entry.updatedAt).toBe("T4") + } + }) }) From 6625177f644c815b73cf653ffdcb2ed6ef41845c Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 12:51:38 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20harden=20the=20datamate=20heal=20?= =?UTF-8?q?=E2=80=94=20full=20config-filename=20coverage,=20internal=20roo?= =?UTF-8?q?t=20resolution,=20per-file=20isolation,=20global-aware=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONFIG_FILENAMES now mirrors every filename the config loader merges (adds altimate-code.jsonc and legacy config.json), so entries in those files are healed/removed/listed like the rest instead of loading as live config that tooling cannot see. - syncDatamateUrlFromVscodeMcp resolves the git project root itself, so every caller (serve, reload endpoint, TUI worker, run) handles nested invocations; the worker/run callers drop their now-redundant resolution. - One malformed config file no longer aborts the multi-file heal — each file is healed independently with a logged skip on failure. - The reload-datamate endpoint reads the fresh entry from any config file the sync covers (project, subdirs, global) instead of only the project path, so a healed global-only entry actually reconnects. --- .../src/altimate/datamate-transport.ts | 16 +++- packages/opencode/src/cli/cmd/run.ts | 13 ++- packages/opencode/src/cli/tui/worker.ts | 22 +++-- packages/opencode/src/mcp/config.ts | 11 ++- packages/opencode/src/server/server.ts | 15 +++- .../mcp-datamate-stdio-env.test.ts | 89 +++++++++++++++++++ 6 files changed, 137 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index d71383b324..eb6786e591 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -212,6 +212,10 @@ export async function syncDatamateUrlFromVscodeMcp( ): Promise { const updated: string[] = [] try { + // Resolve the project root here rather than in each caller: an invocation + // from a nested subdirectory must still find the root .vscode/mcp.json and + // the root-level config files it needs to repair. + cwd = await resolveDatamateSyncRoot(cwd) log.info("syncDatamateUrlFromVscodeMcp: start", { cwd }) // Find the first mcp.json that contains a "datamate" entry. @@ -329,7 +333,17 @@ export async function syncDatamateUrlFromVscodeMcp( let datamateHealed = false for (const configPath of await findAllConfigPaths(cwd, globalConfigDir)) { - if (await healEntryInFile(configPath)) datamateHealed = true + // Per-file isolation: one malformed config (addMcpToConfig refuses to + // rewrite unparseable files by throwing) must not abort the heal for the + // remaining project/global files. + try { + if (await healEntryInFile(configPath)) datamateHealed = true + } catch (err) { + log.warn("syncDatamateUrlFromVscodeMcp: skipping unhealable config file", { + configPath, + error: err instanceof Error ? err.message : String(err), + }) + } } if (datamateHealed) updated.push(DATAMATE_KEY) } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index ae2d97d4e6..5c263512d3 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -945,15 +945,12 @@ You are speaking to a non-technical business executive. Follow these rules stric // altimate_change start — heal the datamate MCP entry before the session starts, // mirroring cli/cmd/serve.ts: an entry persisted without its env block (e.g. // missing ELECTRON_RUN_AS_NODE for an Electron command) would otherwise be - // re-spawned broken on every run invocation with no path to self-repair. - // Scoped to the project root, not cwd — a run from a subdirectory must still - // find the root IDE config and the persisted entry it needs to repair. + // re-spawned broken on every run invocation with no path to self-repair. The + // sync resolves the project root itself, so a run from a subdirectory still + // finds the root IDE config and the persisted entry it needs to repair. { - const { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot } = await import( - "../../altimate/datamate-transport" - ) - const root = await resolveDatamateSyncRoot(process.cwd()).catch(() => process.cwd()) - await syncDatamateUrlFromVscodeMcp(root).catch(() => {}) + const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport") + await syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) } // altimate_change end await bootstrap(process.cwd(), async () => { diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index c00117a9a5..736eacc8db 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -32,7 +32,7 @@ import * as OnboardingTelemetry from "@/altimate/telemetry/onboarding" // entry persisted without its env block (e.g. missing ELECTRON_RUN_AS_NODE for an // Electron command) was re-spawned broken on every TUI session start with no path to // self-repair. -import { syncDatamateUrlFromVscodeMcp, resolveDatamateSyncRoot } from "@/altimate/datamate-transport" +import { syncDatamateUrlFromVscodeMcp } from "@/altimate/datamate-transport" // altimate_change end // altimate_change — shared with the withTimeout budget in cli/cmd/tui.ts stop(), so the coupling @@ -41,17 +41,15 @@ const SHUTDOWN_BUDGET_MS = Telemetry.TUI_SHUTDOWN_BUDGET_MS Heap.start() -// altimate_change start — datamate entry heal. Scoped to the project root (a session -// launched from a subdirectory must still find the root IDE config + persisted entry). -// Everything that reads the config is sequenced AFTER this promise — trace init below, -// the first in-process request, and Server.listen — because the heal writes -// altimate-code.json with a non-atomic write, and InstanceRuntime.load/Config.get() -// would otherwise race it (transiently truncated read) or cache the pre-heal entry, -// making the first session spawn the broken config anyway. -// Errors are swallowed: a failed sync must never block the TUI. -const datamateSyncReady: Promise = resolveDatamateSyncRoot(process.cwd()) - .then((root) => syncDatamateUrlFromVscodeMcp(root)) - .catch(() => {}) +// altimate_change start — datamate entry heal (the sync resolves the project root +// itself, so a session launched from a subdirectory still finds the root IDE config +// + persisted entry). Everything that reads the config is sequenced AFTER this +// promise — trace init below, the first in-process request, and Server.listen — +// because the heal writes altimate-code.json with a non-atomic write, and +// InstanceRuntime.load/Config.get() would otherwise race it (transiently truncated +// read) or cache the pre-heal entry, making the first session spawn the broken +// config anyway. Errors are swallowed: a failed sync must never block the TUI. +const datamateSyncReady: Promise = syncDatamateUrlFromVscodeMcp(process.cwd()).catch(() => {}) // altimate_change end const traceConsumer = new TraceConsumer() diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index cccbc89f9d..10125fa7a9 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -3,10 +3,13 @@ import { modify, applyEdits, parse, parseTree, findNodeAtLocation, getNodeValue, import { Filesystem } from "../util/filesystem" import type { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" -// altimate_change start — primary config filename is altimate-code.json; opencode.json -// is fallback for users with pre-existing upstream installs. New writes land in -// altimate-code.json (first entry of the list). -const CONFIG_FILENAMES = ["altimate-code.json", "opencode.json", "opencode.jsonc"] +// altimate_change start — primary config filename is altimate-code.json; the rest are +// fallbacks for users with pre-existing installs. The list mirrors every filename the +// config loader merges (config/config.ts loadFile calls: altimate-code.json/.jsonc, +// opencode.json/.jsonc, legacy config.json) — an entry in any of them is live config, +// so lookups/removals/heals must see them all. New writes land in altimate-code.json +// (first entry of the list). +const CONFIG_FILENAMES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc", "config.json"] // altimate_change end export async function resolveConfigPath(baseDir: string, global = false) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5f548ddf8f..46f5d12c11 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -35,7 +35,7 @@ import { MCP } from "../mcp" // Using datamate-transport.ts instead of serve.ts avoids a dep on a cmd handler. import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport" import { readMcpEntryFromDisk } from "../mcp/config" -import { resolveConfigPath } from "../mcp/config" +import { findAllConfigPaths } from "../mcp/config" import { enhancePrompt, isAutoEnhanceEnabled } from "../altimate/enhance-prompt" // altimate_change end import { FileRoutes } from "./routes/file" @@ -688,12 +688,19 @@ export namespace Server { log.info("reload-datamate: config updated, reconnecting MCP servers", { updatedNames }) // Reconnect each updated server using the freshly-written disk entry. // Bypass Config.get() (stale singleton) by reading the file directly. - const configPath = await resolveConfigPath(directory) + // The healed entry may live in any config file the sync covers — + // project, project subdirs, or the global config (scope: "global" + // adds) — so scan them all instead of only the project path. + const configPaths = await findAllConfigPaths(directory, Global.Path.config) const currentStatus = await MCP.status() for (const name of updatedNames) { - const freshEntry = await readMcpEntryFromDisk(name, configPath) + let freshEntry: Awaited> + for (const configPath of configPaths) { + freshEntry = await readMcpEntryFromDisk(name, configPath) + if (freshEntry) break + } if (!freshEntry) { - log.warn("reload-datamate: fresh config entry not found on disk", { name, configPath }) + log.warn("reload-datamate: fresh config entry not found on disk", { name, configPaths }) continue } log.info("reload-datamate: reconnecting with fresh config", { diff --git a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts index d6d95427bc..348bcae775 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -198,6 +198,95 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { expect(entry.enabled).toBe(true) }) + test("invocation from a nested subdirectory heals root-level configs", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + await mkdir(path.join(tmp.path, ".git"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "deep"), { recursive: true }) + const projectConfigPath = path.join(tmp.path, "altimate-code.json") + await writeFile( + projectConfigPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T5", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(path.join(tmp.path, "packages", "deep"), globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const entry = JSON.parse(await readFile(projectConfigPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + + test("a malformed config file does not abort healing the remaining files", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + // Project config is truncated garbage — addMcpToConfig refuses to rewrite it. + const projectConfigPath = path.join(tmp.path, "altimate-code.json") + await writeFile(projectConfigPath, '{"mcp": {"datamate": {"type": "local", "command": ["x"') + const globalConfigPath = path.join(globalDir, "altimate-code.json") + await writeFile( + globalConfigPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T6", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const entry = JSON.parse(await readFile(globalConfigPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + + test("heals a global entry living in altimate-code.jsonc (loader-merged filename)", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const globalJsoncPath = path.join(globalDir, "altimate-code.jsonc") + await writeFile( + globalJsoncPath, + JSON.stringify( + { mcp: { [DATAMATE_KEY]: { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } } }, + null, + 2, + ), + ) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T7", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const entry = JSON.parse(await readFile(globalJsoncPath, "utf-8")).mcp[DATAMATE_KEY] + expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + }) + test("heals project and global entries in one pass", async () => { await using tmp = await tmpdir() const globalDir = path.join(tmp.path, "global-config") From 42311f234992bd50d10442be065369f42f57dc00 Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 13:00:35 +0800 Subject: [PATCH 8/9] fix: scope legacy config.json to global config candidates only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config loader merges config.json only from the global config dir; the project loader reads only altimate-code.json/.jsonc and opencode.json/.jsonc. Listing config.json in the shared filename set made project-side discovery treat any unrelated project config.json as live config — and resolveConfigPath could return it as the write target for a fresh add, persisting an entry the loader would never load. Split the sets: GLOBAL_CONFIG_FILENAMES carries config.json, project candidates do not. Regression test asserts the global legacy file heals while a project-level config.json is left byte-identical. --- packages/opencode/src/mcp/config.ts | 14 ++++++--- .../mcp-datamate-stdio-env.test.ts | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index 10125fa7a9..195c4da69d 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -9,7 +9,13 @@ import type { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" // opencode.json/.jsonc, legacy config.json) — an entry in any of them is live config, // so lookups/removals/heals must see them all. New writes land in altimate-code.json // (first entry of the list). -const CONFIG_FILENAMES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc", "config.json"] +const CONFIG_FILENAMES = ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"] +// The GLOBAL config dir additionally merges the legacy config.json +// (config/config.ts global load path). The project loader never reads +// config.json, so it must stay out of project-side candidates — otherwise an +// unrelated project file named config.json becomes a discovery hit and, worse, +// a write target for entries the loader would never load. +const GLOBAL_CONFIG_FILENAMES = [...CONFIG_FILENAMES, "config.json"] // altimate_change end export async function resolveConfigPath(baseDir: string, global = false) { @@ -23,8 +29,8 @@ export async function resolveConfigPath(baseDir: string, global = false) { ) } - // Then check root-level configs - candidates.push(...CONFIG_FILENAMES.map((f) => path.join(baseDir, f))) + // Then check root-level configs (the global dir also accepts legacy config.json) + candidates.push(...(global ? GLOBAL_CONFIG_FILENAMES : CONFIG_FILENAMES).map((f) => path.join(baseDir, f))) for (const candidate of candidates) { if (await Filesystem.exists(candidate)) { @@ -101,7 +107,7 @@ export async function listMcpInConfig(configPath: string): Promise { export async function findAllConfigPaths(projectDir: string, globalDir: string): Promise { const paths: string[] = [] for (const dir of [projectDir, globalDir]) { - for (const name of CONFIG_FILENAMES) { + for (const name of dir === globalDir ? GLOBAL_CONFIG_FILENAMES : CONFIG_FILENAMES) { const p = path.join(dir, name) if (await Filesystem.exists(p)) paths.push(p) } diff --git a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts index 348bcae775..21025ec951 100644 --- a/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts +++ b/packages/opencode/test/release-validation/mcp-datamate-stdio-env.test.ts @@ -287,6 +287,35 @@ describe("syncDatamateUrlFromVscodeMcp stdio env parity", () => { expect(entry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) }) + test("legacy config.json is healed in the GLOBAL dir but left alone at project level", async () => { + await using tmp = await tmpdir() + const globalDir = path.join(tmp.path, "global-config") + await mkdir(globalDir, { recursive: true }) + const brokenEntry = { type: "local", command: ["/path/to/electron", "cli.js"], enabled: true } + // Global legacy config.json IS merged by the config loader → must heal. + const globalLegacyPath = path.join(globalDir, "config.json") + await writeFile(globalLegacyPath, JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2)) + // Project config.json is NOT read by the loader → must not be touched. + const projectConfigJsonPath = path.join(tmp.path, "config.json") + const unrelated = JSON.stringify({ mcp: { [DATAMATE_KEY]: brokenEntry } }, null, 2) + await writeFile(projectConfigJsonPath, unrelated) + await seedIdeStdio(tmp.path, { + type: "stdio", + command: "/path/to/electron", + args: ["/ext/dist/datamate-cli.js", "start-stdio"], + env: { ELECTRON_RUN_AS_NODE: "1" }, + updatedAt: "T8", + }) + + const updated = await syncDatamateUrlFromVscodeMcp(tmp.path, globalDir) + expect(updated).toContain(DATAMATE_KEY) + + const globalEntry = JSON.parse(await readFile(globalLegacyPath, "utf-8")).mcp[DATAMATE_KEY] + expect(globalEntry.environment).toEqual({ ELECTRON_RUN_AS_NODE: "1" }) + // Byte-identical: the project-level config.json was never rewritten. + expect(await readFile(projectConfigJsonPath, "utf-8")).toBe(unrelated) + }) + test("heals project and global entries in one pass", async () => { await using tmp = await tmpdir() const globalDir = path.join(tmp.path, "global-config") From 7f684289134dd98cf3420cba1f9b47e5c348f3ce Mon Sep 17 00:00:00 2001 From: ralphstodomingo Date: Sat, 8 Aug 2026 13:17:00 +0800 Subject: [PATCH 9/9] test: update reload-endpoint source guard for the multi-path disk read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial guard asserted the single-path read line verbatim; the endpoint now scans every config file the heal covers. The guarded contract — stale-singleton bypass via readMcpEntryFromDisk + MCP.add — is unchanged and still asserted. --- .../test/upstream/adversarial/upi-config-mcp.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts b/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts index c0b3abaa7d..016664dfea 100644 --- a/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts +++ b/packages/opencode/test/upstream/adversarial/upi-config-mcp.test.ts @@ -197,7 +197,11 @@ describe("UPI-25 through UPI-27 MCP persistence, names, pagination, and resource expect(mcpSource).toContain("persistChain.then(() =>") expect(mcpSource).toContain("persistChain = run.catch(() => {})") expect(serverSource).toContain("Bypass Config.get() (stale singleton) by reading the file directly.") - expect(serverSource).toContain("const freshEntry = await readMcpEntryFromDisk(name, configPath)") + // The disk read scans every config file the heal covers (project, subdirs, + // global) — the healed entry may live in any of them; the stale-singleton + // bypass contract is unchanged (readMcpEntryFromDisk + MCP.add, no Config.get()). + expect(serverSource).toContain("const configPaths = await findAllConfigPaths(directory, Global.Path.config)") + expect(serverSource).toContain("freshEntry = await readMcpEntryFromDisk(name, configPath)") expect(serverSource).toContain("await MCP.add(name, freshEntry)") }) })