Skip to content
Open
239 changes: 164 additions & 75 deletions packages/opencode/src/altimate/datamate-transport.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -20,8 +21,62 @@ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const


export type DatamateTransport =
| { type: "remote"; url: string }
| { type: "local"; command: string[] }
| { type: "remote"; url: string; updatedAt?: string }
| { type: "local"; command: string[]; environment?: Record<string, string>; updatedAt?: string }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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<string, string> | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (key === "ALTIMATE_EXTENSION_RPC") continue
if (typeof value === "string") env[key] = value
}
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<string> {
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
}

/**
* Entry fields re-derived from the IDE transport on every sync/refresh — as
* opposed to user-managed fields (enabled, timeout, oauth, …), which are
* carried forward from the existing entry. Shared with `datamate_manager add`'s
* refresh path so the two never disagree on what counts as transport identity.
*/
export const TRANSPORT_IDENTITY_FIELDS: ReadonlySet<string> = new Set([
"type",
"command",
"args",
"environment",
"url",
"updatedAt",
])

/**
* Parse a single mcp.json file and return the servers map, trying each of the
Expand Down Expand Up @@ -105,14 +160,28 @@ 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 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"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Datamate entries using supported MCP env references are launched with the literal ${VAR} value instead of the resolved environment value. The IDE-specific read and sync paths should apply the shared ConfigPaths.resolveEnvVarsInString handling before returning or persisting the environment, while preserving the existing single-pass escape semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/datamate-transport.ts, line 137:

<comment>Datamate entries using supported MCP env references are launched with the literal `${VAR}` value instead of the resolved environment value. The IDE-specific read and sync paths should apply the shared `ConfigPaths.resolveEnvVarsInString` handling before returning or persisting the environment, while preserving the existing single-pass escape semantics.</comment>

<file context>
@@ -108,11 +127,21 @@ export async function readDatamateTransportFromIde(
       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 {
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged but deliberately not changed here: this is a pre-existing parity gap shared with syncDatamateUrlFromVscodeMcp, which has persisted the env block verbatim since the transport layer landed — this PR's read path just mirrors it (the shared extractSpawnEnvironment keeps them in lockstep). In practice the extension writes only literal values (ELECTRON_RUN_AS_NODE, the RPC socket path), never ${VAR} references, and persisted entries still go through config-load substitution. Unifying with resolveServerEnvVars would change the sync path's semantics too, so it belongs in its own change — parked as a follow-up.

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
Expand All @@ -136,9 +205,17 @@ 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<string[]> {
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<string[]> {
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.
Expand Down Expand Up @@ -178,85 +255,97 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
: undefined

if (datamateVscode && vscodeUpdatedAt) {
const configPath = await resolveConfigPath(cwd)
if (await Filesystem.exists(configPath)) {
// The entry may live in the project config OR the global one
// (`datamate_manager add` supports scope: "global") — a stale global entry
// is spawned at session start just the same, so heal every config file
// that carries a datamate entry, not only the project's.
const healEntryInFile = async (configPath: string): Promise<boolean> => {
const configText = await Filesystem.readText(configPath)
const existingTree = parseTree(configText)
const existingNode = existingTree
? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY])
: undefined
if (!existingNode) 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<string, unknown>)
: {}
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 TRANSPORT_FIELDS = new Set([
"type",
"command",
"args",
"environment",
"url",
"updatedAt",
])
const preserved: Record<string, unknown> = {}
for (const [k, v] of Object.entries(existingEntry)) {
if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v
}
// 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<string, unknown>)
: {}
const existingUpdatedAt =
typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined

let newEntry: Record<string, unknown>
if ("command" in datamateVscode) {
const env = datamateVscode["env"] as Record<string, string> | undefined
const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {}
const cmd =
typeof datamateVscode["command"] === "string"
? (datamateVscode["command"] as string)
: DATAMATE_KEY
newEntry = {
...preserved,
type: "local",
command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])],
...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}),
updatedAt: vscodeUpdatedAt,
}
} else {
// http / streamable-http / sse → remote
newEntry = {
...preserved,
type: "remote",
url: datamateVscode["url"] as string,
updatedAt: vscodeUpdatedAt,
}
}
if (vscodeUpdatedAt === existingUpdatedAt) {
log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", {
configPath,
updatedAt: vscodeUpdatedAt,
})
return false
}

await addMcpToConfig(
DATAMATE_KEY,
newEntry as Parameters<typeof addMcpToConfig>[1],
configPath,
)
log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", {
type: datamateVscode["type"],
updatedAt: vscodeUpdatedAt,
})
updated.push(DATAMATE_KEY)
// 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<string, unknown> = {}
for (const [k, v] of Object.entries(existingEntry)) {
if (!TRANSPORT_IDENTITY_FIELDS.has(k)) preserved[k] = v
}

let newEntry: Record<string, unknown>
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<typeof addMcpToConfig>[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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: A throw on one config file aborts healing of the rest

healEntryInFile can throw mid-loop: addMcpToConfig rejects a malformed-JSON config (it throws in config.ts:46-52), and readText would throw if the file is removed between findAllConfigPaths' existence check and the read. Because the whole function shares a single outer try/catch, a failure on the project config (iterated first) also skips healing the global entry and skips the remote-entry URL refresh below. The sibling persistMcpEnabledUnlocked (mcp/index.ts:974) wraps its own findAllConfigPaths loop in a try/catch for this reason — isolating each iteration would let one bad file fail without defeating the rest of the heal.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Global Datamate entries in supported altimate-code.jsonc or legacy config.json files are skipped by this new healing pass, so those sessions remain stale despite the global-config fix. Including every filename used by global config loading in findAllConfigPaths would make the repair cover all active global entries.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/datamate-transport.ts, line 331:

<comment>Global Datamate entries in supported `altimate-code.jsonc` or legacy `config.json` files are skipped by this new healing pass, so those sessions remain stale despite the global-config fix. Including every filename used by global config loading in `findAllConfigPaths` would make the repair cover all active global entries.</comment>

<file context>
@@ -246,76 +251,87 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
+      }
+
+      let datamateHealed = false
+      for (const configPath of await findAllConfigPaths(cwd, globalConfigDir)) {
+        if (await healEntryInFile(configPath)) datamateHealed = true
       }
</file context>

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Reloading a global-only Datamate entry reports success but leaves the running MCP client on the stale transport, because the new global repair result is reduced to a name while the reload path only rereads the project config. Returning the repaired config path(s), or updating the reload handler to read the global file too, would reconnect the repaired global entry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/datamate-transport.ts, line 334:

<comment>Reloading a global-only Datamate entry reports success but leaves the running MCP client on the stale transport, because the new global repair result is reduced to a name while the reload path only rereads the project config. Returning the repaired config path(s), or updating the reload handler to read the global file too, would reconnect the repaired global entry.</comment>

<file context>
@@ -246,76 +251,87 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
+      for (const configPath of await findAllConfigPaths(cwd, globalConfigDir)) {
+        if (await healEntryInFile(configPath)) datamateHealed = true
       }
+      if (datamateHealed) updated.push(DATAMATE_KEY)
     }
 
</file context>

}

// ── All other remote MCP entries: existing URL-comparison logic ──────────
Expand Down
70 changes: 55 additions & 15 deletions packages/opencode/src/altimate/tools/datamate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import {
listMcpInConfig,
resolveConfigPath,
findAllConfigPaths,
readMcpEntryFromDisk,
} from "../../mcp/config"
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" })

Expand Down Expand Up @@ -206,18 +207,28 @@ 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 } : {}),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
: AltimateApi.buildMcpConfig(creds!, args.datamate_id)

const isGlobal = args.scope === "global"
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-"),
Expand Down Expand Up @@ -249,21 +260,50 @@ 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)
// 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<string, unknown> = {}
for (const [k, v] of Object.entries(existing ?? {})) {
if (!replacedFields.has(k)) preserved[k] = v
}
const refreshed = {
...preserved,
...mcpConfig,
enabled: true,
...updatedAtField,
}
await addMcpToConfig(DATAMATE_KEY, refreshed as Parameters<typeof addMcpToConfig>[1], configPath)
// 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<typeof MCP.add>[1])
} else {
// Not in config yet — write to disk then connect
// Not in config yet — write to disk then connect.
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,
...updatedAtField,
}
await addMcpToConfig(DATAMATE_KEY, diskEntry as Parameters<typeof addMcpToConfig>[1], configPath)
await MCP.add(DATAMATE_KEY, mcpConfig)
}
} else {
Expand Down
Loading
Loading