-
Notifications
You must be signed in to change notification settings - Fork 138
fix: carry the IDE entry's env when wiring the datamate stdio MCP server #1081
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cbf4f65
80d4ad4
1cb8fad
7bcc9b6
37c3d2c
33b60d8
6625177
42311f2
7f68428
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" | ||
|
|
@@ -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 } | ||
|
|
||
| /** | ||
| * 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 | ||
|
|
@@ -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"]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
|
|
@@ -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. | ||
|
|
@@ -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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SUGGESTION: A throw on one config file aborts healing of the rest
Reply with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Global Datamate entries in supported Prompt for AI agents
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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
|
|
||
| // ── All other remote MCP entries: existing URL-comparison logic ────────── | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.