Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 174 additions & 77 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 @@ -97,22 +152,40 @@ export async function readDatamateTransportFromIde(
const parsed = JSON.parse(text) as Record<string, unknown>
const serversMap = extractServersMap(parsed)
const entry = serversMap[DATAMATE_KEY]
if (!entry) continue
// The extension blanks `datamate` to {} (not delete) in non-active-IDE
// mcp.json files, and the sorted scan can reach the blanked file first
// (`.cursor/` sorts before `.vscode/`). An empty entry is a tombstone,
// not a transport — skip it so the active IDE's real entry is found.
if (!entry || Object.keys(entry).length === 0) continue

log.info("readDatamateTransportFromIde: found entry", {
source: relPath,
type: entry["type"] ?? "(no type)",
})

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 +209,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 All @@ -151,7 +232,11 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
const text = await readFile(candidate, "utf-8")
const parsed = JSON.parse(text) as Record<string, unknown>
const map = extractServersMap(parsed)
if (map[DATAMATE_KEY]) {
// Same tombstone rule as readDatamateTransportFromIde: a blanked {}
// entry (non-active-IDE file) must not be selected as the sync source —
// it has no updatedAt, so the heal would silently skip while the real
// entry sits in the next file.
if (map[DATAMATE_KEY] && Object.keys(map[DATAMATE_KEY]).length > 0) {

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: The blank-tombstone predicate is duplicated at both scan sites

Object.keys(...).length === 0 (line 159) and Object.keys(...).length > 0 (here) encode the same rule — a blanked {} entry is a tombstone and must be skipped. This comment already calls them "the same tombstone rule," but the two expressions can still drift (one negated, one not). A small shared predicate such as isBlankedEntry = (e) => !e || Object.keys(e).length === 0 would name the rule once; both sites would call it — isBlankedEntry(entry) to skip (line 159) and !isBlankedEntry(map[KEY]) to select (here). This mirrors the TRANSPORT_IDENTITY_FIELDS consolidation done earlier in this PR and keeps the read/sync paths in lockstep if the tombstone shape ever changes.


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

mcpJsonPath = candidate
serversMap = map
break
Expand All @@ -178,85 +263,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
Loading
Loading