diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts new file mode 100644 index 000000000..149381823 --- /dev/null +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -0,0 +1,326 @@ +import { readFile } from "fs/promises" +import path from "path" +import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" +import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" +import { Filesystem } from "../util/filesystem" +import { Glob } from "../util/glob" +import { Log } from "../util/log" +import type { Config } from "../config/config" + +const log = Log.create({ service: "datamate-transport" }) + +export const DATAMATE_KEY = "datamate" + +/** + * Top-level keys that MCP config files use to map server name → entry. + * VS Code 1.99+ uses "servers"; older VS Code and Cursor use "mcpServers". + * We try both so the scan works regardless of which IDE wrote the file. + */ +const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const + + +export type DatamateTransport = + | { type: "remote"; url: string } + | { type: "local"; command: string[] } + +/** + * Parse a single mcp.json file and return the servers map, trying each of the + * known top-level key names in order. + */ +function extractServersMap( + parsed: Record, +): Record> { + for (const key of MCP_SERVERS_KEYS) { + const candidate = parsed[key] + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + return candidate as Record> + } + } + return {} +} + +/** + * Find all mcp.json files under projectRootDir (excluding noise directories) + * and return the paths sorted for deterministic ordering. + */ +async function findAllMcpJsonFiles(projectRootDir: string): Promise { + try { + const paths = await Glob.scan("**/mcp.json", { + cwd: projectRootDir, + absolute: true, + dot: true, + // Exclude build/dependency/output trees. command + args from a discovered + // mcp.json are passed to StdioClientTransport, so keep the scan to source the + // user actually authors and out of vendored/generated directories. + ignore: [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/.pnpm/**", + "**/target/**", + "**/.next/**", + "**/out/**", + "**/vendor/**", + "**/coverage/**", + "**/.venv/**", + "**/.turbo/**", + ], + }) + return paths.sort() + } catch { + log.warn("findAllMcpJsonFiles: glob scan failed", { cwd: projectRootDir }) + return [] + } +} + +/** + * Scan all mcp.json files under projectRootDir and return the transport type + * for the first "datamate" server entry found. + * + * Returns null if no mcp.json contains a "datamate" entry — the caller should + * fall back to the cloud config. + * + * Reuses the exact command from the IDE config so altimate-code spawns the + * same process the extension already started, rather than a second one. + */ +export async function readDatamateTransportFromIde( + projectRootDir: string, +): Promise { + const mcpJsonPaths = await findAllMcpJsonFiles(projectRootDir) + + for (const mcpJsonPath of mcpJsonPaths) { + const relPath = path.relative(projectRootDir, mcpJsonPath) + try { + const text = await readFile(mcpJsonPath, "utf-8") + const parsed = JSON.parse(text) as Record + const serversMap = extractServersMap(parsed) + const entry = serversMap[DATAMATE_KEY] + if (!entry) continue + + log.info("readDatamateTransportFromIde: found entry", { + source: relPath, + type: entry["type"] ?? "(no type)", + }) + + if (typeof entry["url"] === "string") { + return { type: "remote", url: entry["url"] } + } + + // stdio entry — reuse the exact command + args the extension registered + 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] } + } + + // Entry exists but has no usable command — treat as local marker + return { type: "local", command: [DATAMATE_KEY, "start-stdio"] } + } catch { + log.warn("readDatamateTransportFromIde: failed to parse", { source: relPath }) + } + } + + log.info("readDatamateTransportFromIde: no IDE entry found, falling back to cloud config") + return null +} + +/** + * Sync the "datamate" entry (and other remote MCP entries) from the first + * mcp.json that contains a "datamate" key to altimate-code.json. + * + * Uses `updatedAt` as the change signal for the datamate entry (covers both + * stdio and HTTP transport), and URL comparison for all other remote entries. + * + * 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 { + const updated: string[] = [] + try { + log.info("syncDatamateUrlFromVscodeMcp: start", { cwd }) + + // Find the first mcp.json that contains a "datamate" entry. + const mcpJsonPaths = await findAllMcpJsonFiles(cwd) + let mcpJsonPath: string | undefined + let serversMap: Record> = {} + + for (const candidate of mcpJsonPaths) { + try { + const text = await readFile(candidate, "utf-8") + const parsed = JSON.parse(text) as Record + const map = extractServersMap(parsed) + if (map[DATAMATE_KEY]) { + mcpJsonPath = candidate + serversMap = map + break + } + } catch { + // Unparseable — skip + } + } + + if (!mcpJsonPath) { + log.info("syncDatamateUrlFromVscodeMcp: no mcp.json with datamate entry found, skipping sync") + return updated + } + + log.info("syncDatamateUrlFromVscodeMcp: using config", { + source: path.relative(cwd, mcpJsonPath), + }) + + // ── "datamate" entry: sync by updatedAt (works for stdio + HTTP) ──────── + const datamateVscode = serversMap[DATAMATE_KEY] + const vscodeUpdatedAt = + datamateVscode && typeof datamateVscode["updatedAt"] === "string" + ? (datamateVscode["updatedAt"] as string) + : undefined + + if (datamateVscode && vscodeUpdatedAt) { + const configPath = await resolveConfigPath(cwd) + if (await Filesystem.exists(configPath)) { + const configText = await Filesystem.readText(configPath) + const existingTree = parseTree(configText) + const existingNode = existingTree + ? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY]) + : undefined + + 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 TRANSPORT_FIELDS = new Set([ + "type", + "command", + "args", + "environment", + "url", + "updatedAt", + ]) + const preserved: Record = {} + for (const [k, v] of Object.entries(existingEntry)) { + if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v + } + + let newEntry: Record + if ("command" in datamateVscode) { + const env = datamateVscode["env"] as Record | 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, + } + } + + await addMcpToConfig( + DATAMATE_KEY, + newEntry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", { + type: datamateVscode["type"], + updatedAt: vscodeUpdatedAt, + }) + updated.push(DATAMATE_KEY) + } + } + } + } + + // ── All other remote MCP entries: existing URL-comparison logic ────────── + const httpEntries: Array<{ key: string; url: string }> = [] + for (const [key, entry] of Object.entries(serversMap)) { + if (key === DATAMATE_KEY) continue + if (typeof entry["url"] === "string") { + httpEntries.push({ key, url: entry["url"] }) + } + } + + if (httpEntries.length > 0) { + const configPath = await resolveConfigPath(cwd) + if (await Filesystem.exists(configPath)) { + const configText = await Filesystem.readText(configPath) + const tree = parseTree(configText) + const mcpNode = tree ? findNodeAtLocation(tree, ["mcp"]) : undefined + + if (tree && mcpNode && mcpNode.type === "object" && mcpNode.children) { + const remoteMcpEntries: Array<{ name: string; url: string }> = [] + for (const child of mcpNode.children) { + if (child.type !== "property" || !child.children) continue + const nameNode = child.children[0] + const valueNode = child.children[1] + if (!nameNode || !valueNode || valueNode.type !== "object" || !valueNode.children) continue + const typeNode = findNodeAtLocation(valueNode, ["type"]) + const urlNode = findNodeAtLocation(valueNode, ["url"]) + if (typeNode?.value === "remote" && typeof urlNode?.value === "string") { + remoteMcpEntries.push({ name: nameNode.value as string, url: urlNode.value }) + } + } + + for (const remote of remoteMcpEntries) { + const match = httpEntries.find((e) => e.key === remote.name) + if (match && match.url !== remote.url) { + const entryNode = findNodeAtLocation(tree, ["mcp", remote.name]) + if (!entryNode || entryNode.type !== "object") continue + // getNodeValue preserves headers/oauth/timeout; a children walk reading + // `prop.children[1].value` would strip them (object/array nodes). + const entry = getNodeValue(entryNode) as Record + entry["url"] = match.url + entry["updatedAt"] = new Date().toISOString() + await addMcpToConfig( + remote.name, + entry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrlFromVscodeMcp: remote entry updated", { + name: remote.name, + oldUrl: remote.url, + newUrl: match.url, + }) + updated.push(remote.name) + } + } + } + } + } + + if (updated.length === 0) log.info("syncDatamateUrlFromVscodeMcp: no changes detected") + } catch (err) { + log.warn("syncDatamateUrlFromVscodeMcp: failed (non-fatal)", { + error: err instanceof Error ? err.message : String(err), + }) + } + return updated +} + diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index e15e5af45..cbbfe1e44 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -11,6 +11,10 @@ import { } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" +import { Log } from "../../util/log" +import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" + +const log = Log.create({ service: "datamate" }) /** Project root for config resolution — falls back to cwd when no git repo is detected. */ function projectRoot() { @@ -25,6 +29,11 @@ export function slugify(name: string): string { .replace(/^-|-$/g, "") } +// Scans .vscode/mcp.json, .cursor/mcp.json, .github/copilot/mcp.json in projectRootDir +// so this works in Cursor, Copilot, and other IDEs that write their own MCP config file. +// Returns the exact command from the IDE config so altimate-code reuses the same process +// the extension already manages rather than spawning a second one. + export const DatamateManagerTool = Tool.define("datamate_manager", { description: "Manage Altimate Datamates — AI teammates with integrations (Snowflake, Jira, dbt, etc). " + @@ -39,7 +48,9 @@ export const DatamateManagerTool = Tool.define("datamate_manager", { "'list-config' shows all datamate entries saved in config files (project and global). " + "Config files: project config is at /altimate-code.json, " + "global config is at ~/.config/altimate-code/altimate-code.json. " + - "Datamate server names are prefixed with 'datamate-'. " + + "When a VS Code extension datamate entry exists (.vscode/mcp.json has 'datamate' key), " + + "'add' always uses the server name 'datamate' — tools are then prefixed 'datamate_'. " + + "In standalone mode, server names follow 'datamate-' pattern. " + "Do NOT use glob/grep/read to find config files — use 'list-config' instead.", parameters: z.object({ operation: z.enum(["list", "list-integrations", "add", "create", "edit", "delete", "status", "remove", "list-config"]), @@ -154,6 +165,8 @@ async function handleListIntegrations() { } } +// DATAMATE_KEY is imported from altimate/datamate-transport.ts (shared constant). + async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { if (!args.datamate_id) { return { @@ -163,17 +176,105 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p } } try { - const creds = await AltimateApi.getCredentials() const datamate = await AltimateApi.getDatamate(args.datamate_id) - const serverName = args.name ?? `datamate-${slugify(datamate.name)}` - const mcpConfig = AltimateApi.buildMcpConfig(creds, args.datamate_id) + // readDatamateTransportFromIde returns the exact command from the IDE config so we + // reuse the same process the extension already manages, not a second one. + const transport = await readDatamateTransportFromIde(projectRoot()) + + if (transport !== null) { + log.info("handleAdd: IDE transport detected, entering single-gateway mode", { + serverName: DATAMATE_KEY, + transportType: transport.type, + }) + } else { + log.info("handleAdd: no IDE transport found, using standalone cloud config") + } + + // If an IDE MCP config has a "datamate" entry (written by VS Code, Cursor, etc.), + // always use DATAMATE_KEY ("datamate") as the server name regardless + // of which specific datamate the user selected. This prevents duplicate tool sets + // — the extension's gateway already serves all datamate tools through a single + // MCP connection. + // In standalone/CLI mode (no IDE datamate entry), fall back to per-datamate naming + // with cloud URL. + const serverName = transport !== null + ? DATAMATE_KEY + : (args.name ?? `datamate-${slugify(datamate.name)}`) + + const creds = transport ? undefined : await AltimateApi.getCredentials() + const mcpConfig = + 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 } + : AltimateApi.buildMcpConfig(creds!, args.datamate_id) - // Always save to config first so it persists for future sessions const isGlobal = args.scope === "global" const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal) - await addMcpToConfig(serverName, mcpConfig, configPath) - await MCP.add(serverName, mcpConfig) + if (transport !== null) { + // IDE/extension mode: check if DATAMATE_KEY is already wired up + const existingNames = await listMcpInConfig(configPath) + const staleEntries = existingNames.filter( + (n) => n !== DATAMATE_KEY && n.startsWith("datamate-"), + ) + if (staleEntries.length > 0) { + log.info("handleAdd: stale per-datamate entries detected alongside extension gateway", { + staleEntries, + }) + } + + if (existingNames.includes(DATAMATE_KEY)) { + // Already in config — just ensure it is connected in this session + const allStatus = await MCP.status() + if (allStatus[DATAMATE_KEY]?.status === "connected") { + log.info("handleAdd: already connected, skipping add", { + serverName: DATAMATE_KEY, + }) + const mcpTools = await MCP.tools() + const toolCount = Object.keys(mcpTools).filter((k) => + k.startsWith(DATAMATE_KEY + "_"), + ).length + const staleNote = + staleEntries.length > 0 + ? `\n\nNote: stale per-datamate entries found in config: ${staleEntries.join(", ")} — use operation 'remove' to clean them up.` + : "" + return { + title: `Datamate '${datamate.name}': already connected via '${DATAMATE_KEY}'`, + metadata: { serverName: DATAMATE_KEY, datamateId: args.datamate_id, toolCount }, + 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", { + serverName: DATAMATE_KEY, + }) + await MCP.connect(DATAMATE_KEY) + } else { + // 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) + await MCP.add(DATAMATE_KEY, mcpConfig) + } + } else { + // Standalone/CLI mode — original behaviour: per-datamate name + cloud URL + log.info("handleAdd: standalone mode, adding per-datamate entry", { + serverName, + type: mcpConfig.type, + }) + await addMcpToConfig(serverName, { ...mcpConfig, enabled: true }, configPath) + await MCP.add(serverName, mcpConfig) + } // Check connection status const allStatus = await MCP.status() @@ -197,7 +298,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p return { title: `Datamate '${datamate.name}': connected as '${serverName}'`, metadata: { serverName, datamateId: args.datamate_id, toolCount, configPath }, - output: `Connected datamate '${datamate.name}' (ID: ${args.datamate_id}) as MCP server '${serverName}'.\n\n${toolCount} tools are now available from this datamate. They will be usable in the next message.\n\nConfiguration saved to ${configPath} for future sessions.`, + output: `Connected datamate '${datamate.name}' (ID: ${args.datamate_id}) as MCP server '${serverName}'.\n\n${toolCount} tools are now available. They will be usable in the next message.\n\nConfiguration saved to ${configPath} for future sessions.`, } } catch (e) { return { diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index 30af405ac..cf5a31126 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -4,13 +4,14 @@ import { discoverExternalMcp } from "../../mcp/discover" import { resolveConfigPath, addMcpToConfig, findAllConfigPaths, listMcpInConfig } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" +import { MCP } from "../../mcp" /** * Check which MCP server names are permanently configured on disk * (as opposed to ephemeral auto-discovered servers in memory). */ async function getPersistedMcpNames(): Promise> { - const configPaths = await findAllConfigPaths(Instance.worktree, Global.Path.config) + const configPaths = await findAllConfigPaths(Instance.directory, Global.Path.config) const names = new Set() for (const p of configPaths) { for (const name of await listMcpInConfig(p)) { @@ -35,6 +36,20 @@ function safeDetail(server: { type: string } & Record): string { return `(${server.type})` } +// discovered servers. ALTIMATE_EXTENSION_RPC is a Unix socket path that is +// unique to the current VS Code extension host process. Writing it to disk +// causes altimate-code on a future session (or a different VS Code window) to +// spawn datamate processes that connect to the wrong bridge or a dead socket. +// Stripping it forces runtime discovery via ~/.altimate/extension-rpc/ sidecars, +// which always resolves the correct live bridge by matching process.cwd() against +// each bridge's recorded workspaceFolders. +function stripSessionEnv(cfg: import("../../config/config").Config.Mcp): import("../../config/config").Config.Mcp { + if (cfg.type !== "local" || !cfg.environment) return cfg + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ALTIMATE_EXTENSION_RPC: _rpc, ...rest } = cfg.environment + return { ...cfg, environment: Object.keys(rest).length > 0 ? rest : undefined } +} + export const McpDiscoverTool = Tool.define("mcp_discover", { description: "Discover MCP servers from external AI tool configs (VS Code, Cursor, Claude Code, Copilot, Gemini) and optionally add them to altimate-code config permanently.", @@ -53,7 +68,7 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { .describe('Server names to add. If omitted with action "add", adds all new servers.'), }), async execute(args, ctx) { - const { servers: discovered } = await discoverExternalMcp(Instance.worktree) + const { servers: discovered } = await discoverExternalMcp(Instance.directory) const discoveredNames = Object.keys(discovered) if (discoveredNames.length === 0) { @@ -105,12 +120,19 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { const useGlobal = args.scope === "global" const configPath = await resolveConfigPath( - useGlobal ? Global.Path.config : Instance.worktree, + useGlobal ? Global.Path.config : Instance.directory, useGlobal, ) for (const name of toAdd) { - await addMcpToConfig(name, discovered[name], configPath) + // strip the discovery-time flag. Project-scoped discovery sets + // as a security default (no auto-connect until user approves). + // When the user explicitly adds a server via this tool, it should be enabled. + const { enabled: _discardEnabled, ...cfgToWrite } = stripSessionEnv(discovered[name]) as any + await addMcpToConfig(name, { ...cfgToWrite, enabled: true, updatedAt: new Date().toISOString() } as import('../../config/config').Config.Mcp, configPath) + // Connect immediately so /mcps reflects the server status in the current session + // without requiring a restart. + await MCP.connect(name) } lines.push(`\nAdded ${toAdd.length} server(s) to ${configPath}: ${toAdd.join(", ")}`) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 05c46f156..962385da7 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -19,6 +19,13 @@ export const ServeCommand = cmd({ console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) + // altimate_change start — sync datamate URL from IDE MCP config on serve startup. + // When a VS Code/Cursor window restarts, the extension picks a new local port and + // rewrites its MCP config. Re-reading it here keeps altimate-code.json in sync + // without requiring any user action. + const { syncDatamateUrlFromVscodeMcp } = await import("../../altimate/datamate-transport") + await syncDatamateUrlFromVscodeMcp(process.cwd()) + // altimate_change end const server = await Server.listen(opts) console.log(`altimate-code server listening on http://${server.hostname}:${server.port}`) // altimate_change end diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index d6149d199..ee7818c13 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -72,6 +72,7 @@ export namespace Command { CONFIGURE_CLAUDE: "configure-claude", CONFIGURE_CODEX: "configure-codex", DISCOVER_MCPS: "discover-and-add-mcps", + MCPS: "mcps", // altimate_change end } as const @@ -144,6 +145,15 @@ export namespace Command { }, hints: hints(PROMPT_DISCOVER_MCPS), }, + [Default.MCPS]: { + name: Default.MCPS, + description: "list added MCP servers and their connection status", + source: "command", + get template() { + return "List all configured MCP servers and their current connection status." + }, + hints: [], + }, // altimate_change end } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 99295821c..86ddc2ec2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -269,7 +269,7 @@ export namespace Config { // altimate_change start — auto-discover MCP servers from external AI tool configs if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG && result.experimental?.auto_mcp_discovery !== false) { const { discoverExternalMcp, setDiscoveryResult } = await import("../mcp/discover") - const { servers: externalMcp, sources } = await discoverExternalMcp(Instance.worktree) + const { servers: externalMcp, sources } = await discoverExternalMcp(Instance.directory) if (Object.keys(externalMcp).length > 0) { result.mcp ??= {} const added: string[] = [] @@ -622,6 +622,9 @@ export namespace Config { .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + // altimate_change start — sync timestamp written by syncDatamateUrlFromVscodeMcp + updatedAt: z.string().optional().describe("ISO timestamp of last URL sync; used to detect reconnect need."), + // altimate_change end }) .strict() .meta({ @@ -661,6 +664,9 @@ export namespace Config { .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), + // altimate_change start — sync timestamp written by syncDatamateUrlFromVscodeMcp + updatedAt: z.string().optional().describe("ISO timestamp of last URL sync; used to detect reconnect need."), + // altimate_change end }) .strict() .meta({ diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index debe86651..2467ebf58 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -1,5 +1,5 @@ import path from "path" -import { modify, applyEdits, parseTree, findNodeAtLocation } from "jsonc-parser" +import { modify, applyEdits, parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" import { Filesystem } from "../util/filesystem" import type { Config } from "../config/config" @@ -101,3 +101,28 @@ export async function findAllConfigPaths(projectDir: string, globalDir: string): } return paths } + +/** + * Read a single MCP entry directly from a config file, bypassing the Config + * singleton so callers can get the freshly-written config without busting the + * whole cache. Returns undefined if the entry is not found in the file. + */ +export async function readMcpEntryFromDisk( + name: string, + configPath: string, +): Promise { + if (!(await Filesystem.exists(configPath))) return undefined + + const text = await Filesystem.readText(configPath) + const tree = parseTree(text) + if (!tree) return undefined + + const node = findNodeAtLocation(tree, ["mcp", name]) + if (!node || node.type !== "object") return undefined + + // getNodeValue reconstructs the full value tree. A manual children walk reading + // `prop.children[1].value` would silently drop array/object fields (command, + // environment, headers, oauth) — jsonc-parser only populates `Node.value` for + // primitives — corrupting the entry on the next disk write. + return getNodeValue(node) as Config.Mcp +} diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index 916f10ac3..18affeb58 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -3,6 +3,7 @@ import path from "path" import { parse as parseJsonc } from "jsonc-parser" import { Log } from "../util/log" import { Filesystem } from "../util/filesystem" +import { Glob } from "../util/glob" import { ConfigPaths } from "../config/paths" import type { Config } from "../config/config" @@ -47,13 +48,14 @@ interface ExternalMcpSource { scope: "project" | "home" | "both" } -/** Standard sources checked relative to project root and/or home directory */ +/** + * Config files whose basename is NOT "mcp.json" — the `**​/mcp.json` glob scan + * in `discoverExternalMcp` does not match these, so they are still checked by + * exact relative path. (`.vscode/mcp.json`, `.cursor/mcp.json`, + * `.github/copilot/mcp.json` and any other tool's `mcp.json` are covered by the + * glob scan and intentionally omitted here.) + */ const SOURCES: ExternalMcpSource[] = [ - // Project-only sources - { file: ".vscode/mcp.json", key: "servers", scope: "project" }, - { file: ".cursor/mcp.json", key: "mcpServers", scope: "project" }, - { file: ".github/copilot/mcp.json", key: "mcpServers", scope: "project" }, - // Both project and home { file: ".mcp.json", key: "mcpServers", scope: "both" }, { file: ".gemini/settings.json", key: "mcpServers", scope: "both" }, @@ -211,20 +213,42 @@ async function discoverClaudeCode( } } +/** + * Merge the server maps from every recognized top-level key in a parsed config. + * VS Code 1.99+ uses "servers"; older VS Code, Cursor, and Copilot use + * "mcpServers". A single file normally uses one or the other, but we merge both + * so the scan is IDE-agnostic regardless of which key the writer chose. + */ +function mergeServerKeys(parsed: Record): Record { + const out: Record = {} + for (const key of ["servers", "mcpServers"]) { + const candidate = parsed[key] + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + Object.assign(out, candidate) + } + } + return out +} + /** * Discover MCP servers configured in external AI tool configs - * (VS Code, GitHub Copilot, Claude Code, Gemini CLI). + * (VS Code, Cursor, GitHub Copilot, Claude Code, Gemini CLI). * * Security model: Project-scoped servers (.vscode/mcp.json, .mcp.json, etc.) are * discovered with enabled=false so they don't auto-connect. Users must explicitly * approve them via /discover-and-add-mcps. Home-directory configs (~/.claude.json, * ~/.gemini/settings.json) are auto-enabled since they're user-owned. * - * Searches both the project directory and the home directory. + * `projectDir` is the project root (the opened workspace folder) — NOT the git + * worktree, which resolves to "/" for non-git projects and would scan the whole + * filesystem / miss the project's configs entirely. + * + * Recursively scans every `mcp.json` under `projectDir` (IDE-agnostic) plus the + * non-"mcp.json" config files in the project and home directories. * Returns servers and contributing source labels. * First-discovered-wins per server name across sources. */ -export async function discoverExternalMcp(worktree: string): Promise<{ +export async function discoverExternalMcp(projectDir: string): Promise<{ servers: Record sources: string[] }> { @@ -233,13 +257,64 @@ export async function discoverExternalMcp(worktree: string): Promise<{ const contributingSources: string[] = [] const homedir = os.homedir() - // Scan standard sources in project and/or home directories + // Recursively scan every mcp.json under the project root — covers + // .vscode/mcp.json (VS Code), .cursor/mcp.json (Cursor), + // .github/copilot/mcp.json (Copilot), and any other tool's mcp.json. + // Ordered by IDE precedence first, then alphabetically, so first-source-wins + // dedup is deterministic and keeps the historical .vscode > .cursor > copilot order + // (a plain alphabetical sort would let .cursor override .vscode). + const IDE_PRECEDENCE = [".vscode/mcp.json", ".cursor/mcp.json", ".github/copilot/mcp.json"] + const toRel = (abs: string) => path.relative(projectDir, abs).split(path.sep).join("/") + let mcpJsonFiles: string[] = [] + try { + const scanned = await Glob.scan("**/mcp.json", { + cwd: projectDir, + absolute: true, + dot: true, + ignore: [ + "**/node_modules/**", + "**/.git/**", + "**/dist/**", + "**/build/**", + "**/.pnpm/**", + "**/target/**", + "**/.next/**", + "**/out/**", + "**/vendor/**", + "**/coverage/**", + "**/.venv/**", + "**/.turbo/**", + ], + }) + const rank = (abs: string) => { + const i = IDE_PRECEDENCE.indexOf(toRel(abs)) + return i === -1 ? IDE_PRECEDENCE.length : i + } + mcpJsonFiles = scanned.sort((a, b) => { + const ra = rank(a) + const rb = rank(b) + if (ra !== rb) return ra - rb + const relA = toRel(a) + const relB = toRel(b) + return relA < relB ? -1 : relA > relB ? 1 : 0 + }) + } catch { + log.warn("mcp.json glob scan failed", { cwd: projectDir }) + } + for (const file of mcpJsonFiles) { + const parsed = await readJsonSafe(file) + if (!parsed || typeof parsed !== "object") continue + const label = toRel(file) || path.basename(file) + addServersFromFile(mergeServerKeys(parsed), label, result, contributingSources, true) + } + + // Non-"mcp.json" config files (not matched by the glob above), in project and/or home. for (const source of SOURCES) { const dirs: Array<{ dir: string; label: string }> = [] if (source.scope === "project" || source.scope === "both") { - dirs.push({ dir: worktree, label: source.file }) + dirs.push({ dir: projectDir, label: source.file }) } - if ((source.scope === "home" || source.scope === "both") && worktree !== homedir) { + if ((source.scope === "home" || source.scope === "both") && projectDir !== homedir) { dirs.push({ dir: homedir, label: `~/${source.file}` }) } @@ -248,14 +323,14 @@ export async function discoverExternalMcp(worktree: string): Promise<{ const parsed = await readJsonSafe(filePath) if (!parsed || typeof parsed !== "object") continue - const isProjectScoped = dir === worktree + const isProjectScoped = dir === projectDir const servers = parsed[source.key] addServersFromFile(servers, label, result, contributingSources, isProjectScoped) } } // Claude Code has a unique config structure — handle separately - await discoverClaudeCode(worktree, result, contributingSources) + await discoverClaudeCode(projectDir, result, contributingSources) const serverNames = Object.keys(result) if (serverNames.length > 0) { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index b110467ce..1e4d72f37 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -15,6 +15,10 @@ import { NamedError } from "@opencode-ai/util/error" import z from "zod/v4" import { Instance } from "../project/instance" import { Installation } from "../installation" +// altimate_change start — persist enabled flag +import { findAllConfigPaths, listMcpInConfig, addMcpToConfig, readMcpEntryFromDisk } from "./config" +import { Global } from "../global" +// altimate_change end import { withTimeout } from "@/util/timeout" import { McpOAuthProvider } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" @@ -694,6 +698,48 @@ export namespace MCP { return state().then((state) => state.clients) } + // altimate_change start — persist enabled/disabled to disk so it survives session restarts + // Serialize all writes: persistMcpEnabled is read-modify-write on a shared config + // file, so concurrent callers (e.g. rapid /mcps enable then disable) could otherwise + // interleave and clobber each other's changes. + let persistChain: Promise = Promise.resolve() + function persistMcpEnabled(name: string, enabled: boolean): Promise { + const run = persistChain.then(() => persistMcpEnabledUnlocked(name, enabled)) + persistChain = run.catch(() => {}) + return run + } + async function persistMcpEnabledUnlocked(name: string, enabled: boolean): Promise { + try { + const paths = await findAllConfigPaths(Instance.directory, Global.Path.config) + let found = false + for (const p of paths) { + const names = await listMcpInConfig(p) + if (names.includes(name)) { + const entry = await readMcpEntryFromDisk(name, p) + if (entry) + await addMcpToConfig( + name, + { ...entry, enabled } as Parameters[1], + p, + ) + log.info("persistMcpEnabled", { name, enabled, path: p }) + found = true + break + } + } + if (!found) { + log.warn("persistMcpEnabled: entry not found in any config file — enabled flag not persisted", { + name, + enabled, + searchedPaths: paths, + }) + } + } catch (err) { + log.error("Failed to persist MCP enabled flag", { name, enabled, error: err }) + } + } + // altimate_change end + export async function connect(name: string) { const cfg = await Config.get() const config = cfg.mcp ?? {} @@ -732,6 +778,9 @@ export namespace MCP { s.clients[name] = result.mcpClient if (result.transport) s.transports[name] = result.transport } + // altimate_change start — persist enabled:true so it survives session restarts + await persistMcpEnabled(name, true) + // altimate_change end } export async function disconnect(name: string) { @@ -754,6 +803,9 @@ export namespace MCP { }) delete s.transports[name] s.status[name] = { status: "disabled" } + // altimate_change start — persist enabled:false so disable survives session restarts + await persistMcpEnabled(name, false) + // altimate_change end } /** Fully remove a dynamically-added MCP server — disconnects, and purges from runtime state. */ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 35f330447..cc876dd99 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -29,6 +29,14 @@ import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" import { PtyRoutes } from "./routes/pty" import { McpRoutes } from "./routes/mcp" +// altimate_change start — reload-datamate endpoint +import { MCP } from "../mcp" +// Import sync + fresh-read helpers directly from the shared transport module. +// 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" +// altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" @@ -561,6 +569,60 @@ export namespace Server { }) }, ) + // altimate_change start — POST /altimate/mcp/reload-datamate + // Updates the datamate MCP server config from IDE MCP config files and reconnects + // the live MCP client so the new transport takes effect without a server restart. + // + // Bug-fix: the previous implementation called MCP.disconnect(name) + MCP.connect(name). + // MCP.disconnect calls persistMcpEnabled(name, false), which reads the stale in-memory + // Config singleton (not yet updated by syncDatamateUrlFromVscodeMcp) and writes + // { ...stale_entry, enabled: false } back to disk, overwriting the fresh config. + // MCP.connect then re-reads the same stale singleton and reconnects with the old transport. + // + // Fix: read the freshly-written entry directly from disk via readMcpEntryFromDisk + // (bypasses Config singleton), then call MCP.add(name, freshEntry) which takes a + // config directly and never calls persistMcpEnabled. + .post("/altimate/mcp/reload-datamate", async (c) => { + try { + const directory = Instance.directory + log.info("reload-datamate: syncing IDE MCP config", { directory }) + + // Sync IDE MCP config → altimate-code.json; returns updated server names. + const updatedNames = await syncDatamateUrlFromVscodeMcp(directory) + const updated = updatedNames.length > 0 + + if (updated) { + 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) + const currentStatus = await MCP.status() + for (const name of updatedNames) { + const freshEntry = await readMcpEntryFromDisk(name, configPath) + if (!freshEntry) { + log.warn("reload-datamate: fresh config entry not found on disk", { name, configPath }) + continue + } + log.info("reload-datamate: reconnecting with fresh config", { + name, + type: freshEntry.type, + wasConnected: currentStatus[name]?.status === "connected", + }) + // MCP.add takes a config directly — no Config.get() call, no persistMcpEnabled. + await MCP.add(name, freshEntry) + } + } else { + log.info("reload-datamate: no config changes detected") + } + + return c.json({ ok: true, updated }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("reload-datamate: failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) + // altimate_change end .all("/*", async (c) => { const path = c.req.path diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 99aefe46b..bb00f580d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -416,6 +416,21 @@ export namespace SessionPrompt { process.once("beforeExit", emergencySessionEnd) process.once("exit", emergencySessionEnd) // altimate_change end + // altimate_change start — refresh MCP tools on ToolsChanged event + // When a datamate MCP server reconnects (transport change, window restart), + // MCP.ToolsChanged is published. MCP.tools() already uses a per-client cache + // that is invalidated by the notification handler that publishes this event, + // so the next resolveTools() call (once per LLM turn) naturally picks up fresh + // tools without any extra work here. This subscription makes the session layer + // explicitly aware of the reconnect and logs it so it is traceable in prod. + const unsubscribeToolsChanged = Bus.subscribe(MCP.ToolsChanged, (event) => { + log.info("MCP.ToolsChanged received — tools will refresh on next turn", { + server: event.properties.server, + sessionID, + }) + }) + using _unsubToolsChanged = defer(unsubscribeToolsChanged) + // altimate_change end while (true) { // altimate_change start — SessionStatus.set became async in v1.4.0; await so busy state flushes before LLM call await SessionStatus.set(sessionID, { type: "busy" }) @@ -2056,12 +2071,6 @@ export namespace SessionPrompt { } } - // altimate_change start — model-family helper used by the trust-aware hoist below. - // Uses `familyVendor` so specific family values (`claude-sonnet`, `claude-haiku`, - // `gemini-pro`, etc.) classify correctly — an exact `family === "anthropic"` - // check would miss the gateway-emitted specific names (#888 J1). The api.id - // checks are lowercased and tightened to a `claude-` / `anthropic-` / - // `anthropic/...` shape so a model named `foo-claude-bench` doesn't false-match. // // NOTE: `family` is a free-form, config-settable string on the model schema — // a connection that declares `family: "claude-*"` on a non-Anthropic gateway @@ -2072,6 +2081,12 @@ export namespace SessionPrompt { // // Exported for testing — the hoist/classification contract is exercised // behaviorally in test/session/plan-layer-e2e.test.ts. + // altimate_change start — model-family helper used by the trust-aware hoist below. + // Uses `familyVendor` so specific family values (`claude-sonnet`, `claude-haiku`, + // `gemini-pro`, etc.) classify correctly — an exact `family === "anthropic"` + // check would miss the gateway-emitted specific names (#888 J1). The api.id + // checks are lowercased and tightened to a `claude-` / `anthropic-` / + // `anthropic/...` shape so a model named `foo-claude-bench` doesn't false-match. export function isAnthropicLikeModel(model: Provider.Model): boolean { if (model.providerID === "anthropic") return true if (model.providerID === "google-vertex-anthropic") return true @@ -2566,6 +2581,112 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) + + // altimate_change start — /mcps enable/disable: direct handler bypasses LLM + if (input.command === "mcps") { + + // Helper: build and persist an assistant reply for a command shortcut. + async function respond( + parentID: MessageID, + responseText: string, + model: { modelID: ModelID; providerID: ProviderID }, + ): Promise { + const now = Date.now() + const assistantMsg: MessageV2.Assistant = { + id: MessageID.ascending(), role: "assistant", sessionID: input.sessionID, + parentID, modelID: model.modelID, providerID: model.providerID, + mode: "builder", agent: "builder", + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", time: { created: now, completed: now }, + } + await Session.updateMessage(assistantMsg) + const textPart: MessageV2.TextPart = { + id: PartID.ascending(), sessionID: input.sessionID, messageID: assistantMsg.id, + type: "text", text: responseText, time: { start: now, end: now }, + } + await Session.updatePart(textPart) + Bus.publish(Command.Event.Executed, { + name: input.command, sessionID: input.sessionID, + arguments: input.arguments, messageID: assistantMsg.id, + }) + return { info: assistantMsg, parts: [textPart] } as MessageV2.WithParts + } + const trimmed = input.arguments.trim() + + if (!trimmed) { + // /mcps (no args): return actual runtime status directly + const userMsg = await createUserMessage({ + sessionID: input.sessionID, + messageID: input.messageID, + parts: [{ type: "text", text: "/mcps" }], + }) + const model = await lastModel(input.sessionID) + const statusMap = await MCP.status() + const rows = Object.entries(statusMap) + .map(([srv, s]) => { + const icon = s.status === "connected" ? "\u2713" : "\u25cb" + const label = + s.status === "failed" + ? icon + " " + s.status + " (" + s.error + ")" + : icon + " " + s.status + return "| `" + srv + "` | " + label + " |" + }) + .join("\n") + const responseText = rows + ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows + : "No MCP servers configured." + + return respond(userMsg.info.id, responseText, model) + } + + const subMatch = trimmed.match(/^(enable|disable)\s+(\S+)/) + if (subMatch) { + const [, subCmd, name] = subMatch + const isEnable = subCmd === "enable" + + const userMsg = await createUserMessage({ + sessionID: input.sessionID, + messageID: input.messageID, + parts: [{ type: "text", text: `/mcps ${subCmd} ${name}` }], + }) + + const model = await lastModel(input.sessionID) + // MCP.connect/disconnect on an unknown name logs and returns silently, so + // validate against config first and give the user a clear signal on a typo. + const cfg = await Config.get() + if (!cfg.mcp?.[name]) { + const known = Object.keys(cfg.mcp ?? {}) + const suffix = known.length ? ` Known servers: ${known.join(", ")}.` : "" + return respond( + userMsg.info.id, + `MCP server **${name}** not found in config.${suffix}`, + model, + ) + } + + let responseText: string + + if (isEnable) { + await MCP.connect(name) + const statusMap = await MCP.status() + const entry = statusMap[name] + if (entry?.status === "connected") { + responseText = `MCP server **${name}** enabled. Status: connected.` + } else { + const errSuffix = entry?.status === "failed" ? " — " + entry.error : "" + responseText = `Attempted to enable MCP server **${name}**. Status: ${entry?.status ?? "unknown"}${errSuffix}.` + } + } else { + await MCP.disconnect(name) + responseText = `MCP server **${name}** disabled.` + } + + return respond(userMsg.info.id, responseText, model) + } + } + // altimate_change end + const command = await Command.get(input.command) if (!command) { const all = await Command.list() diff --git a/packages/opencode/src/util/glob.ts b/packages/opencode/src/util/glob.ts index febf062da..aedbbe85a 100644 --- a/packages/opencode/src/util/glob.ts +++ b/packages/opencode/src/util/glob.ts @@ -8,6 +8,7 @@ export namespace Glob { include?: "file" | "all" dot?: boolean symlink?: boolean + ignore?: string | string[] } function toGlobOptions(options: Options): GlobOptions { @@ -17,6 +18,7 @@ export namespace Glob { dot: options.dot, follow: options.symlink ?? false, nodir: options.include !== "all", + ignore: options.ignore, } } diff --git a/packages/opencode/test/mcp/config.test.ts b/packages/opencode/test/mcp/config.test.ts index 4a958c2b7..ba4ceb88a 100644 --- a/packages/opencode/test/mcp/config.test.ts +++ b/packages/opencode/test/mcp/config.test.ts @@ -8,6 +8,7 @@ import { removeMcpFromConfig, listMcpInConfig, findAllConfigPaths, + readMcpEntryFromDisk, } from "../../src/mcp/config" describe("MCP config: resolveConfigPath", () => { @@ -99,6 +100,68 @@ describe("MCP config: addMcpToConfig + removeMcpFromConfig round-trip", () => { }) }) +describe("MCP config: readMcpEntryFromDisk round-trip", () => { + test("preserves command array and environment object for local entries", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + const local = { + type: "local", + command: ["node", "/path/to/datamate", "start-stdio"], + environment: { DATAMATE_KEY: "abc" }, + enabled: false, + } + await addMcpToConfig("datamate", local as any, configPath) + const entry = await readMcpEntryFromDisk("datamate", configPath) + expect(entry).toEqual(local as any) + }) + + test("preserves headers object for remote entries", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + const remote = { + type: "remote", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer xyz" }, + enabled: true, + } + await addMcpToConfig("knowledge", remote as any, configPath) + const entry = await readMcpEntryFromDisk("knowledge", configPath) + expect(entry).toEqual(remote as any) + }) + + test("persist-enabled flow does not corrupt local command/environment", async () => { + // Mirrors persistMcpEnabled: read entry from disk, then write back {...entry, enabled}. + // A node-walker that drops array/object fields would strip command + environment here. + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + await addMcpToConfig( + "datamate", + { type: "local", command: ["node", "/p"], environment: { K: "v" }, enabled: false } as any, + configPath, + ) + + const entry = await readMcpEntryFromDisk("datamate", configPath) + expect(entry).toBeDefined() + await addMcpToConfig("datamate", { ...(entry as any), enabled: true }, configPath) + + const after = await readMcpEntryFromDisk("datamate", configPath) + expect(after).toEqual({ + type: "local", + command: ["node", "/p"], + environment: { K: "v" }, + enabled: true, + } as any) + }) + + test("returns undefined for missing file or unknown name", async () => { + await using tmp = await tmpdir() + const configPath = path.join(tmp.path, "opencode.json") + expect(await readMcpEntryFromDisk("x", path.join(tmp.path, "missing.json"))).toBeUndefined() + await writeFile(configPath, JSON.stringify({ mcp: { a: { type: "local", command: ["x"] } } })) + expect(await readMcpEntryFromDisk("nope", configPath)).toBeUndefined() + }) +}) + describe("MCP config: listMcpInConfig", () => { test("returns empty array for missing file", async () => { await using tmp = await tmpdir()