From 0b6cc26f1d0f16ffb81929b3aace69159e92c175 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Tue, 9 Jun 2026 02:44:40 +0000 Subject: [PATCH 01/17] feat: datamate stdio local transport + extension single-gateway mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stdio transport support for datamate MCP (vs HTTP-only before) - Single-gateway mode: when .vscode/mcp.json has "datamate" key, always use it as server name — prevents duplicate tool sets from extension - syncDatamateUrlFromVscodeMcp: use updatedAt field as change signal for the "datamate" entry (works for both stdio and HTTP), URL comparison for all other remote entries - Strip ALTIMATE_EXTENSION_RPC from persisted mcp-discover configs to avoid stale socket paths across VS Code sessions - persistMcpEnabled: write enabled/disabled flag to disk on MCP connect/disconnect so it survives session restarts - Add /altimate/mcp/reload-datamate endpoint to re-sync and reconnect without full server restart - MCP.ToolsChanged subscription in prompt loop for traceability - Merge main: preserve trace consumer in serve.ts, restore exports on isAnthropicLikeModel and insertReminders --- .../opencode/src/altimate/tools/datamate.ts | 126 +++++++++++- .../src/altimate/tools/mcp-discover.ts | 20 +- packages/opencode/src/cli/cmd/serve.ts | 186 ++++++++++++++++++ packages/opencode/src/config/config.ts | 6 + packages/opencode/src/mcp/index.ts | 35 ++++ packages/opencode/src/server/server.ts | 48 +++++ packages/opencode/src/session/prompt.ts | 36 ++-- 7 files changed, 435 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index e15e5af450..955d4751e0 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -1,4 +1,6 @@ import z from "zod" +import { readFile } from "fs/promises" +import path from "path" import { Tool } from "../../tool/tool" import { AltimateApi } from "../api/client" import { MCP } from "../../mcp" @@ -11,6 +13,9 @@ import { } from "../../mcp/config" import { Instance } from "../../project/instance" import { Global } from "../../global" +import { Log } from "../../util/log" + +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 +30,46 @@ export function slugify(name: string): string { .replace(/^-|-$/g, "") } +// altimate_change start — read transport type from .vscode/mcp.json +// Returns { type: "remote", url } if the datamate entry is an HTTP server, +// { type: "local" } if it is a stdio server, or null if the file is missing +// or no datamate entry is found. The caller uses this to pick the right +// mcpConfig shape and falls back to the cloud config when null is returned. +async function readVscodeMcpTransport( + projectRootDir: string, +): Promise<{ type: "remote"; url: string } | { type: "local" } | null> { + try { + const mcpJsonPath = path.join(projectRootDir, ".vscode", "mcp.json") + const text = await readFile(mcpJsonPath, "utf-8") + const parsed = JSON.parse(text) as Record + + // .vscode/mcp.json uses either "servers" (VS Code 1.99+) or "mcpServers" key + const serversMap = + (parsed["servers"] as Record> | undefined) ?? + (parsed["mcpServers"] as Record> | undefined) ?? + {} + + for (const [key, entry] of Object.entries(serversMap)) { + const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : [] + const isDatamate = + key === "datamate" || + args.some((a) => a.includes("start-stdio") || a.includes("datamate-cli")) + + if (!isDatamate) continue + + if (typeof entry["url"] === "string") { + return { type: "remote", url: entry["url"] } + } + return { type: "local" } + } + return null + } catch { + // File missing or unparseable — caller falls back to cloud config + return null + } +} +// altimate_change end + export const DatamateManagerTool = Tool.define("datamate_manager", { description: "Manage Altimate Datamates — AI teammates with integrations (Snowflake, Jira, dbt, etc). " + @@ -39,7 +84,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 +201,10 @@ async function handleListIntegrations() { } } +// altimate_change start — server name used by the VS Code extension in .vscode/mcp.json +const EXTENSION_DATAMATE_SERVER = "datamate" +// altimate_change end + async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { if (!args.datamate_id) { return { @@ -163,17 +214,76 @@ 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) + const transport = await readVscodeMcpTransport(projectRoot()) + + // altimate_change start — single-gateway mode when extension is present + // If .vscode/mcp.json has a "datamate" entry (written by the VS Code extension), + // always use "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 .vscode/mcp.json datamate entry), fall back to the + // original per-datamate naming with cloud URL. + const serverName = transport !== null + ? EXTENSION_DATAMATE_SERVER + : (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" + // Extension stdio: no --datamate id needed — active teammate is resolved + // by the extension over the ALTIMATE_EXTENSION_RPC socket at runtime. + ? { type: "local" as const, command: ["datamate", "start-stdio"] } + : 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) { + // Extension mode: check if "datamate" is already wired up + const existingNames = await listMcpInConfig(configPath) + const staleEntries = existingNames.filter( + (n) => n !== EXTENSION_DATAMATE_SERVER && n.startsWith("datamate-"), + ) + if (staleEntries.length > 0) { + log.info("handleAdd: stale per-datamate entries detected alongside extension gateway", { + staleEntries, + }) + } + + if (existingNames.includes(EXTENSION_DATAMATE_SERVER)) { + // Already in config — just ensure it is connected in this session + const allStatus = await MCP.status() + if (allStatus[EXTENSION_DATAMATE_SERVER]?.status === "connected") { + const mcpTools = await MCP.tools() + const toolCount = Object.keys(mcpTools).filter((k) => + k.startsWith(EXTENSION_DATAMATE_SERVER + "_"), + ).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 '${EXTENSION_DATAMATE_SERVER}'`, + metadata: { serverName: EXTENSION_DATAMATE_SERVER, datamateId: args.datamate_id, toolCount }, + output: `Datamate tools are already available via the '${EXTENSION_DATAMATE_SERVER}' MCP server (${toolCount} tools active).${staleNote}`, + } + } + // In config but not connected — reconnect + await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig) + } else { + // Not in config yet — write then connect + await addMcpToConfig(EXTENSION_DATAMATE_SERVER, { ...mcpConfig, enabled: true }, configPath) + await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig) + } + } else { + // Standalone/CLI mode — original behaviour: per-datamate name + cloud URL + await addMcpToConfig(serverName, { ...mcpConfig, enabled: true }, configPath) + await MCP.add(serverName, mcpConfig) + } + // altimate_change end // Check connection status const allStatus = await MCP.status() @@ -197,7 +307,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 30af405ac6..1a2592bf96 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -35,6 +35,22 @@ function safeDetail(server: { type: string } & Record): string { return `(${server.type})` } +// altimate_change start — strip session-specific env vars before persisting +// 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 } +} +// altimate_change end + 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.", @@ -110,7 +126,9 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { ) for (const name of toAdd) { - await addMcpToConfig(name, discovered[name], configPath) + // altimate_change start — strip session-specific ALTIMATE_EXTENSION_RPC + await addMcpToConfig(name, stripSessionEnv(discovered[name]), configPath) + // altimate_change end } 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 5284988011..b4c96cb04d 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,10 +5,190 @@ import { Flag } from "../../flag/flag" import { Workspace } from "../../control-plane/workspace" import { Project } from "../../project/project" import { Installation } from "../../installation" +// altimate_change start — URL sync helpers +import { readFile } from "fs/promises" +import path from "path" +import { existsSync } from "fs" +import { resolveConfigPath, addMcpToConfig } from "../../mcp/config" +import { Filesystem } from "../../util/filesystem" +import { parseTree, findNodeAtLocation } from "jsonc-parser" +import { Log } from "../../util/log" +// altimate_change end // altimate_change start — trace: session tracing in headless serve import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer" // altimate_change end +// altimate_change start +const log = Log.create({ service: "serve" }) +// altimate_change end + +// altimate_change start — sync datamate from .vscode/mcp.json +// Keeps altimate-code.json in sync with what the VS Code extension writes to +// .vscode/mcp.json. For the extension-managed "datamate" entry, uses the +// updatedAt field as the change signal — works for both stdio and HTTP transport. +// All other remote MCP entries fall back to URL comparison (original behaviour). +// Fire-and-forget: errors are logged but never thrown. +// Returns the list of MCP server names whose config was updated. +const DATAMATE_KEY = "datamate" + +export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise { + const updated: string[] = [] + try { + const mcpJsonPath = path.join(cwd, ".vscode", "mcp.json") + if (!existsSync(mcpJsonPath)) return updated + + const text = await readFile(mcpJsonPath, "utf-8") + let parsed: Record + try { + parsed = JSON.parse(text) as Record + } catch { + return updated + } + + const serversMap = + (parsed["servers"] as Record> | undefined) ?? + (parsed["mcpServers"] as Record> | undefined) ?? + {} + + // ── "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) { + // Extract current updatedAt + enabled from altimate-code.json + let existingUpdatedAt: string | undefined + let existingEnabled: boolean | undefined + if (existingNode.type === "object" && existingNode.children) { + for (const prop of existingNode.children) { + if (prop.type !== "property" || !prop.children) continue + const k = prop.children[0]!.value as string + if (k === "updatedAt") existingUpdatedAt = prop.children[1]!.value as string + if (k === "enabled") existingEnabled = prop.children[1]!.value as boolean + } + } + + if (vscodeUpdatedAt !== existingUpdatedAt) { + // Build the new config entry in altimate-code.json format. + // .vscode/mcp.json uses "stdio"/"http"/"streamable-http"/"sse"; + // altimate-code.json uses "local"/"remote". + let newEntry: Record + if (datamateVscode["type"] === "stdio") { + const env = datamateVscode["env"] as Record | undefined + const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} + newEntry = { + type: "local", + command: [ + datamateVscode["command"] as string, + ...((datamateVscode["args"] as string[]) ?? []), + ], + ...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}), + updatedAt: vscodeUpdatedAt, + } + } else { + // http / streamable-http / sse → remote + newEntry = { + type: "remote", + url: datamateVscode["url"] as string, + updatedAt: vscodeUpdatedAt, + } + } + if (typeof existingEnabled === "boolean") newEntry["enabled"] = existingEnabled + + await addMcpToConfig( + DATAMATE_KEY, + newEntry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrl: 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 // already handled above + 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" || !entryNode.children) continue + const entry: Record = {} + for (const prop of entryNode.children) { + if (prop.type === "property" && prop.children) { + entry[prop.children[0]!.value as string] = prop.children[1]!.value + } + } + entry["url"] = match.url + entry["updatedAt"] = new Date().toISOString() + await addMcpToConfig( + remote.name, + entry as Parameters[1], + configPath, + ) + log.info("syncDatamateUrl: updating", { + name: remote.name, + oldUrl: remote.url, + newUrl: match.url, + }) + updated.push(remote.name) + } + } + } + } + } + + if (updated.length === 0) log.info("syncDatamateUrl: no changes") + } catch (err) { + console.warn(`[altimate-code] syncDatamateUrlFromVscodeMcp failed (non-fatal):`, err) + } + return updated +} +// altimate_change end + export const ServeCommand = cmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), @@ -19,6 +199,12 @@ 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 .vscode/mcp.json on serve startup + // When a VS Code window restarts, the extension picks a new local port and rewrites + // .vscode/mcp.json. Re-reading it here keeps altimate-code.json in sync without + // requiring any user action. + 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/config/config.ts b/packages/opencode/src/config/config.ts index 99295821c2..4e813883fb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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/index.ts b/packages/opencode/src/mcp/index.ts index b110467cec..df96478476 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 } 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,31 @@ export namespace MCP { return state().then((state) => state.clients) } + // altimate_change start — persist enabled/disabled to disk so it survives session restarts + async function persistMcpEnabled(name: string, enabled: boolean): Promise { + try { + const paths = await findAllConfigPaths(Instance.directory, Global.Path.config) + for (const p of paths) { + const names = await listMcpInConfig(p) + if (names.includes(name)) { + const cfg = await Config.get() + const entry = cfg.mcp?.[name] + if (entry) + await addMcpToConfig( + name, + { ...entry, enabled } as Parameters[1], + p, + ) + log.info("persistMcpEnabled", { name, enabled, path: p }) + break + } + } + } 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 +761,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 +786,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 35f3304472..7e83d3a8a3 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -29,6 +29,10 @@ 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 { syncDatamateUrlFromVscodeMcp } from "../cli/cmd/serve" +// altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" import { ExperimentalRoutes } from "./routes/experimental" @@ -561,6 +565,50 @@ export namespace Server { }) }, ) + // altimate_change start — POST /altimate/mcp/reload-datamate + // Updates the datamate MCP server URL from .vscode/mcp.json and reconnects the + // live MCP client so the new URL takes effect immediately without a server restart. + .post("/altimate/mcp/reload-datamate", async (c) => { + try { + const directory = Instance.directory + // altimate_change start + log.info("reload-datamate: syncing URL from .vscode/mcp.json", { directory }) + // altimate_change end + // Sync URL from .vscode/mcp.json → project config; returns updated server names. + const updatedNames = await syncDatamateUrlFromVscodeMcp(directory) + const updated = updatedNames.length > 0 + + if (updated) { + // altimate_change start + log.info("reload-datamate: URL updated, reconnecting MCP servers", { updatedNames }) + // altimate_change end + // Reconnect each updated server that is currently live so the new URL takes effect. + const currentStatus = await MCP.status() + for (const name of updatedNames) { + if (currentStatus[name]?.status === "connected") { + // altimate_change start + log.info("reload-datamate: reconnecting", { name }) + // altimate_change end + await MCP.disconnect(name) + await MCP.connect(name) + } + } + } else { + // altimate_change start + log.info("reload-datamate: no URL changes detected") + // altimate_change end + } + + return c.json({ ok: true, updated }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + // altimate_change start + log.error("reload-datamate: failed", { error }) + // altimate_change end + return c.json({ ok: false, error }) + } + }) + // 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 99aefe46b1..c0c3132af7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -416,7 +416,30 @@ 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. + let toolsNeedRefresh = false + const unsubscribeToolsChanged = Bus.subscribe(MCP.ToolsChanged, (event) => { + log.info("MCP.ToolsChanged received — tools will refresh on next turn", { + server: event.properties.server, + sessionID, + }) + toolsNeedRefresh = true + }) + using _unsubToolsChanged = defer(unsubscribeToolsChanged) + // altimate_change end while (true) { + // altimate_change start — log when a ToolsChanged event was received since last turn + if (toolsNeedRefresh) { + log.info("refreshing MCP tools after ToolsChanged event", { sessionID }) + toolsNeedRefresh = false + } + // altimate_change end // 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" }) // altimate_change end @@ -2062,16 +2085,6 @@ export namespace SessionPrompt { // 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 - // will classify as Anthropic-like and SKIP the hoist, which reintroduces the - // #887 refusal on that backend. This is a routing-trust input, not an - // escalation vector (whoever sets the model config already controls the - // prompt), but operators adding gateway models should set `family` correctly. - // - // Exported for testing — the hoist/classification contract is exercised - // behaviorally in test/session/plan-layer-e2e.test.ts. export function isAnthropicLikeModel(model: Provider.Model): boolean { if (model.providerID === "anthropic") return true if (model.providerID === "google-vertex-anthropic") return true @@ -2093,9 +2106,6 @@ export namespace SessionPrompt { // file content as synthetic text), so it is not safe to infer trust from `synthetic` // alone. See #888 review feedback. type InsertRemindersResult = { messages: MessageV2.WithParts[]; trustedReminderParts: MessageV2.TextPart[] } - // Exported for testing — the trust boundary (only self-injected reminders land - // in `trustedReminderParts`, never user/file/resource content) is verified - // behaviorally in test/session/plan-layer-e2e.test.ts. export async function insertReminders(input: { messages: MessageV2.WithParts[] agent: Agent.Info From d8c851fe0dcd0186c12062b27c0bdeb9dd16bf8d Mon Sep 17 00:00:00 2001 From: "altimate-harness-bot[bot]" <274995457+altimate-harness-bot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:01:40 +0530 Subject: [PATCH 02/17] feat: [AI-6948] register mcps slash command in server command list (#885) Co-authored-by: saravmajestic --- packages/opencode/src/command/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index d6149d199e..ee7818c13b 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 } From 520143fcbfa712af5cdf2a9d32930b196346dc26 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Thu, 11 Jun 2026 09:45:44 +0000 Subject: [PATCH 03/17] fix: address review comments + stale config cache in reload-datamate + improve logging --- .../src/altimate/datamate-transport.ts | 311 ++++++++++++++++++ .../opencode/src/altimate/tools/datamate.ts | 118 +++---- packages/opencode/src/cli/cmd/serve.ts | 193 +---------- packages/opencode/src/mcp/index.ts | 9 + packages/opencode/src/server/server.ts | 57 ++-- packages/opencode/src/session/prompt.ts | 13 + 6 files changed, 437 insertions(+), 264 deletions(-) create mode 100644 packages/opencode/src/altimate/datamate-transport.ts diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts new file mode 100644 index 0000000000..dd8529df00 --- /dev/null +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -0,0 +1,311 @@ +import { readFile } from "fs/promises" +import path from "path" +import { existsSync } from "fs" +import { parseTree, findNodeAtLocation } from "jsonc-parser" +import { resolveConfigPath, addMcpToConfig } from "../mcp/config" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" +import type { Config } from "../config/config" + +const log = Log.create({ service: "datamate-transport" }) + +// altimate_change start — shared constant used in datamate.ts, serve.ts, and server.ts +export const DATAMATE_KEY = "datamate" +// altimate_change end + +/** IDE config sources where the extension may write a "datamate" MCP entry. */ +const IDE_MCP_SOURCES = [ + // VS Code (1.99+: "servers", older: "mcpServers") + { file: ".vscode/mcp.json", keys: ["servers", "mcpServers"] }, + // Cursor + { file: ".cursor/mcp.json", keys: ["mcpServers", "servers"] }, + // GitHub Copilot + { file: ".github/copilot/mcp.json", keys: ["mcpServers", "servers"] }, +] + +export type DatamateTransport = + | { type: "remote"; url: string } + | { type: "local"; command: string[] } + +/** + * Scan across all IDE MCP config files in projectRootDir and return the + * transport type for the "datamate" server entry. + * + * Returns null if no IDE config has 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 { + for (const source of IDE_MCP_SOURCES) { + const mcpJsonPath = path.join(projectRootDir, source.file) + if (!existsSync(mcpJsonPath)) continue + + try { + const text = await readFile(mcpJsonPath, "utf-8") + const parsed = JSON.parse(text) as Record + + let serversMap: Record> = {} + for (const key of source.keys) { + const candidate = parsed[key] + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + serversMap = candidate as Record> + break + } + } + + const entry = serversMap[DATAMATE_KEY] + if (!entry) continue + + log.info("readDatamateTransportFromIde: found entry", { + source: source.file, + type: entry["type"] ?? "(no type)", + }) + + if (typeof entry["url"] === "string") { + return { type: "remote", url: entry["url"] } + } + + // stdio entry — extract command + args so we reuse the same process + // the extension already manages, rather than spawning a second one. + 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: source.file }) + // File missing or unparseable — try next source + } + } + + log.info("readDatamateTransportFromIde: no IDE entry found, falling back to cloud config") + return null +} + +/** + * Sync the "datamate" entry (and other remote MCP entries) from IDE MCP config + * files 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 }) + + // Try each IDE source in priority order; use the first one that exists. + let mcpJsonPath: string | undefined + let ideSource: (typeof IDE_MCP_SOURCES)[number] | undefined + for (const source of IDE_MCP_SOURCES) { + const candidate = path.join(cwd, source.file) + if (existsSync(candidate)) { + mcpJsonPath = candidate + ideSource = source + break + } + } + + if (!mcpJsonPath || !ideSource) { + log.info("syncDatamateUrlFromVscodeMcp: no IDE MCP config found, skipping sync") + return updated + } + + const text = await readFile(mcpJsonPath, "utf-8") + let parsed: Record + try { + parsed = JSON.parse(text) as Record + } catch { + return updated + } + + let serversMap: Record> = {} + for (const key of ideSource.keys) { + const candidate = parsed[key] + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + serversMap = candidate as Record> + break + } + } + + // ── "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) { + // Extract current updatedAt + enabled from altimate-code.json + let existingUpdatedAt: string | undefined + let existingEnabled: boolean | undefined + if (existingNode.type === "object" && existingNode.children) { + for (const prop of existingNode.children) { + if (prop.type !== "property" || !prop.children) continue + const k = prop.children[0]!.value as string + if (k === "updatedAt") existingUpdatedAt = prop.children[1]!.value as string + if (k === "enabled") existingEnabled = prop.children[1]!.value as boolean + } + } + + if (vscodeUpdatedAt === existingUpdatedAt) { + log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date, skipping", { + updatedAt: vscodeUpdatedAt, + }) + } else { + // Build the new config entry in altimate-code.json format. + // IDE config uses "stdio"/"http"/"streamable-http"/"sse"; + // altimate-code.json uses "local"/"remote". + let newEntry: Record + if (datamateVscode["type"] === "stdio") { + 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 = { + type: "local", + command: [ + cmd, + ...((datamateVscode["args"] as string[]) ?? []), + ], + ...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}), + updatedAt: vscodeUpdatedAt, + } + } else { + // http / streamable-http / sse → remote + newEntry = { + type: "remote", + url: datamateVscode["url"] as string, + updatedAt: vscodeUpdatedAt, + } + } + if (typeof existingEnabled === "boolean") newEntry["enabled"] = existingEnabled + + 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 // already handled above + 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" || !entryNode.children) continue + const entry: Record = {} + for (const prop of entryNode.children) { + if (prop.type === "property" && prop.children) { + entry[prop.children[0]!.value as string] = prop.children[1]!.value + } + } + 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 +} + +/** + * Read a single MCP entry directly from disk (bypasses the in-memory Config + * singleton) so callers can get the freshly-written config without busting the + * whole cache. Returns undefined if the entry is not found in any config 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" || !node.children) return undefined + + const entry: Record = {} + for (const prop of node.children) { + if (prop.type === "property" && prop.children) { + entry[prop.children[0]!.value as string] = prop.children[1]!.value + } + } + + return entry as Config.Mcp +} diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 955d4751e0..55775dd4a5 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -1,6 +1,4 @@ import z from "zod" -import { readFile } from "fs/promises" -import path from "path" import { Tool } from "../../tool/tool" import { AltimateApi } from "../api/client" import { MCP } from "../../mcp" @@ -14,6 +12,9 @@ import { import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "../../util/log" +// altimate_change start — shared datamate transport helpers (IDE scan, sync, constants) +import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" +// altimate_change end const log = Log.create({ service: "datamate" }) @@ -30,44 +31,11 @@ export function slugify(name: string): string { .replace(/^-|-$/g, "") } -// altimate_change start — read transport type from .vscode/mcp.json -// Returns { type: "remote", url } if the datamate entry is an HTTP server, -// { type: "local" } if it is a stdio server, or null if the file is missing -// or no datamate entry is found. The caller uses this to pick the right -// mcpConfig shape and falls back to the cloud config when null is returned. -async function readVscodeMcpTransport( - projectRootDir: string, -): Promise<{ type: "remote"; url: string } | { type: "local" } | null> { - try { - const mcpJsonPath = path.join(projectRootDir, ".vscode", "mcp.json") - const text = await readFile(mcpJsonPath, "utf-8") - const parsed = JSON.parse(text) as Record - - // .vscode/mcp.json uses either "servers" (VS Code 1.99+) or "mcpServers" key - const serversMap = - (parsed["servers"] as Record> | undefined) ?? - (parsed["mcpServers"] as Record> | undefined) ?? - {} - - for (const [key, entry] of Object.entries(serversMap)) { - const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : [] - const isDatamate = - key === "datamate" || - args.some((a) => a.includes("start-stdio") || a.includes("datamate-cli")) - - if (!isDatamate) continue - - if (typeof entry["url"] === "string") { - return { type: "remote", url: entry["url"] } - } - return { type: "local" } - } - return null - } catch { - // File missing or unparseable — caller falls back to cloud config - return null - } -} +// altimate_change start — readDatamateTransportFromIde (imported from altimate/datamate-transport.ts) +// 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. // altimate_change end export const DatamateManagerTool = Tool.define("datamate_manager", { @@ -201,8 +169,9 @@ async function handleListIntegrations() { } } -// altimate_change start — server name used by the VS Code extension in .vscode/mcp.json -const EXTENSION_DATAMATE_SERVER = "datamate" +// altimate_change start — server name used by IDEs (VS Code, Cursor, etc) in their MCP config +// DATAMATE_KEY is imported from altimate/datamate-transport.ts (shared constant). +const EXTENSION_DATAMATE_SERVER = DATAMATE_KEY // altimate_change end async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { @@ -215,15 +184,29 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p } try { const datamate = await AltimateApi.getDatamate(args.datamate_id) - const transport = await readVscodeMcpTransport(projectRoot()) - - // altimate_change start — single-gateway mode when extension is present - // If .vscode/mcp.json has a "datamate" entry (written by the VS Code extension), - // always use "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 .vscode/mcp.json datamate entry), fall back to the - // original per-datamate naming with cloud URL. + // altimate_change start — scan all IDE config locations (VS Code, Cursor, Copilot, etc.) + // 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: EXTENSION_DATAMATE_SERVER, + transportType: transport.type, + }) + } else { + log.info("handleAdd: no IDE transport found, using standalone cloud config") + } + // altimate_change end + + // altimate_change start — single-gateway mode when IDE extension is present + // If an IDE MCP config has a "datamate" entry (written by VS Code, Cursor, etc.), + // always use EXTENSION_DATAMATE_SERVER ("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 ? EXTENSION_DATAMATE_SERVER : (args.name ?? `datamate-${slugify(datamate.name)}`) @@ -233,16 +216,18 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p transport?.type === "remote" ? { type: "remote" as const, url: transport.url } : transport?.type === "local" - // Extension stdio: no --datamate id needed — active teammate is resolved - // by the extension over the ALTIMATE_EXTENSION_RPC socket at runtime. - ? { type: "local" as const, command: ["datamate", "start-stdio"] } + // 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) const isGlobal = args.scope === "global" const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal) if (transport !== null) { - // Extension mode: check if "datamate" is already wired up + // IDE/extension mode: check if EXTENSION_DATAMATE_SERVER is already wired up const existingNames = await listMcpInConfig(configPath) const staleEntries = existingNames.filter( (n) => n !== EXTENSION_DATAMATE_SERVER && n.startsWith("datamate-"), @@ -257,6 +242,9 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p // Already in config — just ensure it is connected in this session const allStatus = await MCP.status() if (allStatus[EXTENSION_DATAMATE_SERVER]?.status === "connected") { + log.info("handleAdd: already connected, skipping add", { + serverName: EXTENSION_DATAMATE_SERVER, + }) const mcpTools = await MCP.tools() const toolCount = Object.keys(mcpTools).filter((k) => k.startsWith(EXTENSION_DATAMATE_SERVER + "_"), @@ -271,15 +259,29 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p output: `Datamate tools are already available via the '${EXTENSION_DATAMATE_SERVER}' MCP server (${toolCount} tools active).${staleNote}`, } } - // In config but not connected — reconnect - await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig) + // 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: EXTENSION_DATAMATE_SERVER, + }) + await MCP.connect(EXTENSION_DATAMATE_SERVER) } else { - // Not in config yet — write then connect + // Not in config yet — write to disk then connect + log.info("handleAdd: adding new datamate entry", { + serverName: EXTENSION_DATAMATE_SERVER, + type: mcpConfig.type, + }) await addMcpToConfig(EXTENSION_DATAMATE_SERVER, { ...mcpConfig, enabled: true }, configPath) await MCP.add(EXTENSION_DATAMATE_SERVER, 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) } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index b4c96cb04d..7bde95ba34 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,188 +5,13 @@ import { Flag } from "../../flag/flag" import { Workspace } from "../../control-plane/workspace" import { Project } from "../../project/project" import { Installation } from "../../installation" -// altimate_change start — URL sync helpers -import { readFile } from "fs/promises" -import path from "path" -import { existsSync } from "fs" -import { resolveConfigPath, addMcpToConfig } from "../../mcp/config" -import { Filesystem } from "../../util/filesystem" -import { parseTree, findNodeAtLocation } from "jsonc-parser" -import { Log } from "../../util/log" -// altimate_change end // altimate_change start — trace: session tracing in headless serve import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer" // altimate_change end - -// altimate_change start -const log = Log.create({ service: "serve" }) -// altimate_change end - -// altimate_change start — sync datamate from .vscode/mcp.json -// Keeps altimate-code.json in sync with what the VS Code extension writes to -// .vscode/mcp.json. For the extension-managed "datamate" entry, uses the -// updatedAt field as the change signal — works for both stdio and HTTP transport. -// All other remote MCP entries fall back to URL comparison (original behaviour). -// Fire-and-forget: errors are logged but never thrown. -// Returns the list of MCP server names whose config was updated. -const DATAMATE_KEY = "datamate" - -export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise { - const updated: string[] = [] - try { - const mcpJsonPath = path.join(cwd, ".vscode", "mcp.json") - if (!existsSync(mcpJsonPath)) return updated - - const text = await readFile(mcpJsonPath, "utf-8") - let parsed: Record - try { - parsed = JSON.parse(text) as Record - } catch { - return updated - } - - const serversMap = - (parsed["servers"] as Record> | undefined) ?? - (parsed["mcpServers"] as Record> | undefined) ?? - {} - - // ── "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) { - // Extract current updatedAt + enabled from altimate-code.json - let existingUpdatedAt: string | undefined - let existingEnabled: boolean | undefined - if (existingNode.type === "object" && existingNode.children) { - for (const prop of existingNode.children) { - if (prop.type !== "property" || !prop.children) continue - const k = prop.children[0]!.value as string - if (k === "updatedAt") existingUpdatedAt = prop.children[1]!.value as string - if (k === "enabled") existingEnabled = prop.children[1]!.value as boolean - } - } - - if (vscodeUpdatedAt !== existingUpdatedAt) { - // Build the new config entry in altimate-code.json format. - // .vscode/mcp.json uses "stdio"/"http"/"streamable-http"/"sse"; - // altimate-code.json uses "local"/"remote". - let newEntry: Record - if (datamateVscode["type"] === "stdio") { - const env = datamateVscode["env"] as Record | undefined - const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} - newEntry = { - type: "local", - command: [ - datamateVscode["command"] as string, - ...((datamateVscode["args"] as string[]) ?? []), - ], - ...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}), - updatedAt: vscodeUpdatedAt, - } - } else { - // http / streamable-http / sse → remote - newEntry = { - type: "remote", - url: datamateVscode["url"] as string, - updatedAt: vscodeUpdatedAt, - } - } - if (typeof existingEnabled === "boolean") newEntry["enabled"] = existingEnabled - - await addMcpToConfig( - DATAMATE_KEY, - newEntry as Parameters[1], - configPath, - ) - log.info("syncDatamateUrl: 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 // already handled above - 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" || !entryNode.children) continue - const entry: Record = {} - for (const prop of entryNode.children) { - if (prop.type === "property" && prop.children) { - entry[prop.children[0]!.value as string] = prop.children[1]!.value - } - } - entry["url"] = match.url - entry["updatedAt"] = new Date().toISOString() - await addMcpToConfig( - remote.name, - entry as Parameters[1], - configPath, - ) - log.info("syncDatamateUrl: updating", { - name: remote.name, - oldUrl: remote.url, - newUrl: match.url, - }) - updated.push(remote.name) - } - } - } - } - } - - if (updated.length === 0) log.info("syncDatamateUrl: no changes") - } catch (err) { - console.warn(`[altimate-code] syncDatamateUrlFromVscodeMcp failed (non-fatal):`, err) - } - return updated -} +// altimate_change start — datamate IDE-sync helpers extracted to shared module +// Logic lives in altimate/datamate-transport.ts so serve.ts, server.ts, and +// datamate.ts all import from one place (no duplication, shared DATAMATE_KEY constant). +export { syncDatamateUrlFromVscodeMcp } from "../../altimate/datamate-transport" // altimate_change end export const ServeCommand = cmd({ @@ -199,15 +24,15 @@ 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 .vscode/mcp.json on serve startup - // When a VS Code window restarts, the extension picks a new local port and rewrites - // .vscode/mcp.json. Re-reading it here keeps altimate-code.json in sync without - // requiring any user action. + // 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 // altimate_change start — trace: session tracing in headless serve // Sessions driven over HTTP (e.g. the VS Code chat panel) have no TUI diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index df96478476..59dbd6d2d6 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -702,6 +702,7 @@ export namespace MCP { async function persistMcpEnabled(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)) { @@ -714,9 +715,17 @@ export namespace MCP { 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 }) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7e83d3a8a3..b38c1dc06a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -31,7 +31,10 @@ import { PtyRoutes } from "./routes/pty" import { McpRoutes } from "./routes/mcp" // altimate_change start — reload-datamate endpoint import { MCP } from "../mcp" -import { syncDatamateUrlFromVscodeMcp } from "../cli/cmd/serve" +// 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, readMcpEntryFromDisk } from "../altimate/datamate-transport" +import { resolveConfigPath } from "../mcp/config" // altimate_change end import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -566,45 +569,55 @@ export namespace Server { }, ) // altimate_change start — POST /altimate/mcp/reload-datamate - // Updates the datamate MCP server URL from .vscode/mcp.json and reconnects the - // live MCP client so the new URL takes effect immediately without a server restart. + // 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 - // altimate_change start - log.info("reload-datamate: syncing URL from .vscode/mcp.json", { directory }) - // altimate_change end - // Sync URL from .vscode/mcp.json → project config; returns updated server names. + 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) { - // altimate_change start - log.info("reload-datamate: URL updated, reconnecting MCP servers", { updatedNames }) - // altimate_change end - // Reconnect each updated server that is currently live so the new URL takes effect. + 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) { - if (currentStatus[name]?.status === "connected") { - // altimate_change start - log.info("reload-datamate: reconnecting", { name }) - // altimate_change end - await MCP.disconnect(name) - await MCP.connect(name) + 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 { - // altimate_change start - log.info("reload-datamate: no URL changes detected") - // altimate_change end + 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) - // altimate_change start log.error("reload-datamate: failed", { error }) - // altimate_change end return c.json({ ok: false, error }) } }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c0c3132af7..19b4d36aa5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2096,6 +2096,16 @@ export namespace SessionPrompt { if (/^anthropic[-_/]/.test(apiId)) return true return false } + // + // NOTE: `family` is a free-form, config-settable string on the model schema — + // a connection that declares `family: "claude-*"` on a non-Anthropic gateway + // will classify as Anthropic-like and SKIP the hoist, which reintroduces the + // #887 refusal on that backend. This is a routing-trust input, not an + // escalation vector (whoever sets the model config already controls the + // prompt), but operators adding gateway models should set `family` correctly. + // + // Exported for testing — the hoist/classification contract is exercised + // behaviorally in test/session/plan-layer-e2e.test.ts. // altimate_change end // altimate_change start — return the trusted reminder parts insertReminders just appended @@ -2106,6 +2116,9 @@ export namespace SessionPrompt { // file content as synthetic text), so it is not safe to infer trust from `synthetic` // alone. See #888 review feedback. type InsertRemindersResult = { messages: MessageV2.WithParts[]; trustedReminderParts: MessageV2.TextPart[] } + // Exported for testing — the trust boundary (only self-injected reminders land + // in `trustedReminderParts`, never user/file/resource content) is verified + // behaviorally in test/session/plan-layer-e2e.test.ts. export async function insertReminders(input: { messages: MessageV2.WithParts[] agent: Agent.Info From 599807ae4d46a28551eb363ba38ea3389be1540e Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Thu, 11 Jun 2026 10:26:42 +0000 Subject: [PATCH 04/17] refactor: replace hardcoded IDE paths with glob scan for mcp.json Instead of checking .vscode/mcp.json, .cursor/mcp.json, and .github/copilot/mcp.json separately, scan all mcp.json files under the project root and use the first one that contains a datamate entry. This is IDE-agnostic and will work with any editor that writes an mcp.json. - Remove IDE_MCP_SOURCES constant - Add findAllMcpJsonFiles() using Glob.scan(**/mcp.json) - Add extractServersMap() helper to try both "servers" and "mcpServers" keys - Update readDatamateTransportFromIde() and syncDatamateUrlFromVscodeMcp() to use the new scan approach --- .../src/altimate/datamate-transport.ts | 158 ++++++++++-------- 1 file changed, 89 insertions(+), 69 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index dd8529df00..8b4a0c4284 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -1,9 +1,9 @@ import { readFile } from "fs/promises" import path from "path" -import { existsSync } from "fs" import { parseTree, findNodeAtLocation } from "jsonc-parser" import { resolveConfigPath, addMcpToConfig } from "../mcp/config" import { Filesystem } from "../util/filesystem" +import { Glob } from "../util/glob" import { Log } from "../util/log" import type { Config } from "../config/config" @@ -13,25 +13,61 @@ const log = Log.create({ service: "datamate-transport" }) export const DATAMATE_KEY = "datamate" // altimate_change end -/** IDE config sources where the extension may write a "datamate" MCP entry. */ -const IDE_MCP_SOURCES = [ - // VS Code (1.99+: "servers", older: "mcpServers") - { file: ".vscode/mcp.json", keys: ["servers", "mcpServers"] }, - // Cursor - { file: ".cursor/mcp.json", keys: ["mcpServers", "servers"] }, - // GitHub Copilot - { file: ".github/copilot/mcp.json", keys: ["mcpServers", "servers"] }, -] +/** + * 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 + +/** Glob patterns to exclude from mcp.json scans (large, irrelevant trees). */ +const MCP_SCAN_EXCLUDE = ["/node_modules/", "/.git/", "/dist/", "/build/", "/.pnpm/"] export type DatamateTransport = | { type: "remote"; url: string } | { type: "local"; command: string[] } /** - * Scan across all IDE MCP config files in projectRootDir and return the - * transport type for the "datamate" server entry. + * 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, + }) + return paths + .filter((p) => !MCP_SCAN_EXCLUDE.some((ex) => p.includes(ex))) + .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 IDE config has a "datamate" entry — the caller should + * 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 @@ -40,28 +76,19 @@ export type DatamateTransport = export async function readDatamateTransportFromIde( projectRootDir: string, ): Promise { - for (const source of IDE_MCP_SOURCES) { - const mcpJsonPath = path.join(projectRootDir, source.file) - if (!existsSync(mcpJsonPath)) continue + 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 - - let serversMap: Record> = {} - for (const key of source.keys) { - const candidate = parsed[key] - if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { - serversMap = candidate as Record> - break - } - } - + const serversMap = extractServersMap(parsed) const entry = serversMap[DATAMATE_KEY] if (!entry) continue log.info("readDatamateTransportFromIde: found entry", { - source: source.file, + source: relPath, type: entry["type"] ?? "(no type)", }) @@ -69,8 +96,7 @@ export async function readDatamateTransportFromIde( return { type: "remote", url: entry["url"] } } - // stdio entry — extract command + args so we reuse the same process - // the extension already manages, rather than spawning a second one. + // 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) { @@ -80,8 +106,7 @@ export async function readDatamateTransportFromIde( // 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: source.file }) - // File missing or unparseable — try next source + log.warn("readDatamateTransportFromIde: failed to parse", { source: relPath }) } } @@ -90,10 +115,11 @@ export async function readDatamateTransportFromIde( } /** - * Sync the "datamate" entry (and other remote MCP entries) from IDE MCP config - * files 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. + * 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. @@ -103,39 +129,34 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise> = {} - if (!mcpJsonPath || !ideSource) { - log.info("syncDatamateUrlFromVscodeMcp: no IDE MCP config found, skipping sync") - return updated + 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 + } } - const text = await readFile(mcpJsonPath, "utf-8") - let parsed: Record - try { - parsed = JSON.parse(text) as Record - } catch { + if (!mcpJsonPath) { + log.info("syncDatamateUrlFromVscodeMcp: no mcp.json with datamate entry found, skipping sync") return updated } - let serversMap: Record> = {} - for (const key of ideSource.keys) { - const candidate = parsed[key] - if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { - serversMap = candidate as Record> - break - } - } + log.info("syncDatamateUrlFromVscodeMcp: using config", { + source: path.relative(cwd, mcpJsonPath), + }) // ── "datamate" entry: sync by updatedAt (works for stdio + HTTP) ──────── const datamateVscode = serversMap[DATAMATE_KEY] @@ -154,7 +175,6 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise | undefined const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} - const cmd = typeof datamateVscode["command"] === "string" ? datamateVscode["command"] as string : DATAMATE_KEY + const cmd = + typeof datamateVscode["command"] === "string" + ? (datamateVscode["command"] as string) + : DATAMATE_KEY newEntry = { type: "local", - command: [ - cmd, - ...((datamateVscode["args"] as string[]) ?? []), - ], + command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])], ...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}), updatedAt: vscodeUpdatedAt, } @@ -216,7 +236,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise = [] for (const [key, entry] of Object.entries(serversMap)) { - if (key === DATAMATE_KEY) continue // already handled above + if (key === DATAMATE_KEY) continue if (typeof entry["url"] === "string") { httpEntries.push({ key, url: entry["url"] }) } From e790e91ad55efe7db75e841bad9618a997cd873e Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Fri, 12 Jun 2026 02:35:40 +0000 Subject: [PATCH 05/17] fix(mcps): bypass LLM for /mcps list, enable, disable commands Add direct handler in SessionPrompt.command for the mcps slash command. Instead of routing through the AI template, the handler calls MCP.status(), MCP.connect(), and MCP.disconnect() directly and returns a structured MessageV2.WithParts response, matching the TUI HTTP API behavior. - /mcps (no args): queries MCP.status() and renders a markdown table - /mcps enable : calls MCP.connect(name) and reports result - /mcps disable : calls MCP.disconnect(name) and confirms --- packages/opencode/src/session/prompt.ts | 102 ++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 19b4d36aa5..ad49fa7f50 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2602,6 +2602,108 @@ NOTE: At any point in time through this workflow you should feel free to ask the } const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) + // altimate_change start — /mcps enable/disable: direct handler bypasses LLM + if (input.command === "mcps") { + 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 as any).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." + + const now = Date.now() + const assistantMsg: MessageV2.Assistant = { + id: MessageID.ascending(), role: "assistant", sessionID: input.sessionID, + parentID: userMsg.info.id, 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 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) + 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 { + responseText = `Attempted to enable MCP server **${name}**. Status: ${entry?.status ?? "unknown"}${(entry as any)?.error ? " — " + (entry as any).error : "."}.` + } + } else { + await MCP.disconnect(name) + responseText = `MCP server **${name}** disabled.` + } + + const now = Date.now() + const assistantMsg: MessageV2.Assistant = { + id: MessageID.ascending(), role: "assistant", sessionID: input.sessionID, + parentID: userMsg.info.id, 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 + } + } + // altimate_change end + + const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) From 1dbb097811f5381466cba9c14ff282ada2b14c0b Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 03:21:09 +0000 Subject: [PATCH 06/17] fix(mcps): move /mcps bypass handler before Command.get() check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /mcps direct handler was added after the Command.get() call and "Command not found" error, so it never fired — /mcps was not in the registered commands list and would throw before reaching the bypass. Move the handler to run before Command.get() so it short-circuits cleanly for the built-in /mcps, /mcps enable, and /mcps disable commands without requiring a registered command entry. --- packages/opencode/src/session/prompt.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ad49fa7f50..4360d731b5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2589,18 +2589,6 @@ 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) - const command = await Command.get(input.command) - if (!command) { - const all = await Command.list() - const names = all - .map((c: any) => c.name) - .filter(Boolean) - .sort() - throw new NamedError.Unknown({ - message: `Command not found: "${input.command}". Available: ${names.join(", ") || "(none)"}`, - }) - } - const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) // altimate_change start — /mcps enable/disable: direct handler bypasses LLM if (input.command === "mcps") { @@ -2703,6 +2691,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the } // altimate_change end + const command = await Command.get(input.command) + if (!command) { + const all = await Command.list() + const names = all + .map((c: any) => c.name) + .filter(Boolean) + .sort() + throw new NamedError.Unknown({ + message: `Command not found: "${input.command}". Available: ${names.join(", ") || "(none)"}`, + }) + } + const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] const args = raw.map((arg) => arg.replace(quoteTrimRegex, "")) From 794c2218c24047802f04be588e193afde70f854b Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 05:26:03 +0000 Subject: [PATCH 07/17] fix(mcp-discover): strip enabled:false when explicitly adding server to config --- packages/opencode/src/altimate/tools/mcp-discover.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index 1a2592bf96..aa6393c668 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -126,8 +126,12 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { ) for (const name of toAdd) { - // altimate_change start — strip session-specific ALTIMATE_EXTENSION_RPC - await addMcpToConfig(name, stripSessionEnv(discovered[name]), configPath) + // altimate_change start — strip session-specific ALTIMATE_EXTENSION_RPC and + // 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 as import('../../config/config').Config.Mcp, configPath) // altimate_change end } From 1290a631173fcf3c272c93c7cd51edad01e316f6 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 08:19:33 +0000 Subject: [PATCH 08/17] fix(mcp-discover): set enabled:true when user explicitly adds server to config When the user asks to add an MCP server via /discover-and-add-mcps, the config entry should have enabled:true so /mcps shows it as connected. Previously the strip of the auto-discovery enabled:false left no enabled field, which caused ambiguity in the UI even though mcp/index.ts treats missing as enabled=true at runtime. --- packages/opencode/src/altimate/tools/mcp-discover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index aa6393c668..4ebbde5e5f 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -131,7 +131,7 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { // 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 as import('../../config/config').Config.Mcp, configPath) + await addMcpToConfig(name, { ...cfgToWrite, enabled: true } as import('../../config/config').Config.Mcp, configPath) // altimate_change end } From f8414c914bb08c3fc0617865946b9f10de086fef Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 09:33:22 +0000 Subject: [PATCH 09/17] fix(mcp-discover): add updatedAt timestamp and connect MCP immediately after config write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add updatedAt: new Date().toISOString() when writing MCP server to config so clients can detect config changes without restarting - Call MCP.connect(name) immediately after addMcpToConfig so the server becomes active in the current session — /mcps now shows connected without restart --- packages/opencode/src/altimate/tools/mcp-discover.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index 4ebbde5e5f..bf631b2d40 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -4,6 +4,7 @@ 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 @@ -131,7 +132,10 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { // 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 } as import('../../config/config').Config.Mcp, configPath) + 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) // altimate_change end } From 0fbdc9b9f0063f054ce077ad501372eca6b1aae9 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 10:02:16 +0000 Subject: [PATCH 10/17] chore: move isAnthropicLikeModel NOTE docblock back above function --- packages/opencode/src/session/prompt.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 4360d731b5..6af3e92db7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2079,6 +2079,16 @@ export namespace SessionPrompt { } } + // + // NOTE: `family` is a free-form, config-settable string on the model schema — + // a connection that declares `family: "claude-*"` on a non-Anthropic gateway + // will classify as Anthropic-like and SKIP the hoist, which reintroduces the + // #887 refusal on that backend. This is a routing-trust input, not an + // escalation vector (whoever sets the model config already controls the + // prompt), but operators adding gateway models should set `family` correctly. + // + // 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"` @@ -2096,16 +2106,6 @@ export namespace SessionPrompt { if (/^anthropic[-_/]/.test(apiId)) return true return false } - // - // NOTE: `family` is a free-form, config-settable string on the model schema — - // a connection that declares `family: "claude-*"` on a non-Anthropic gateway - // will classify as Anthropic-like and SKIP the hoist, which reintroduces the - // #887 refusal on that backend. This is a routing-trust input, not an - // escalation vector (whoever sets the model config already controls the - // prompt), but operators adding gateway models should set `family` correctly. - // - // Exported for testing — the hoist/classification contract is exercised - // behaviorally in test/session/plan-layer-e2e.test.ts. // altimate_change end // altimate_change start — return the trusted reminder parts insertReminders just appended From c2a55417755ba09fb553b8a6124825bb7ca52a98 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 10:09:17 +0000 Subject: [PATCH 11/17] chore: strip altimate_change markers from altimate/ subtree, inline DATAMATE_KEY --- .../src/altimate/datamate-transport.ts | 2 - .../opencode/src/altimate/tools/datamate.ts | 45 +++++++------------ .../src/altimate/tools/mcp-discover.ts | 4 -- 3 files changed, 17 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 8b4a0c4284..0a6b1e36b8 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -9,9 +9,7 @@ import type { Config } from "../config/config" const log = Log.create({ service: "datamate-transport" }) -// altimate_change start — shared constant used in datamate.ts, serve.ts, and server.ts export const DATAMATE_KEY = "datamate" -// altimate_change end /** * Top-level keys that MCP config files use to map server name → entry. diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index 55775dd4a5..cbbfe1e443 100644 --- a/packages/opencode/src/altimate/tools/datamate.ts +++ b/packages/opencode/src/altimate/tools/datamate.ts @@ -12,9 +12,7 @@ import { import { Instance } from "../../project/instance" import { Global } from "../../global" import { Log } from "../../util/log" -// altimate_change start — shared datamate transport helpers (IDE scan, sync, constants) import { DATAMATE_KEY, readDatamateTransportFromIde } from "../datamate-transport" -// altimate_change end const log = Log.create({ service: "datamate" }) @@ -31,12 +29,10 @@ export function slugify(name: string): string { .replace(/^-|-$/g, "") } -// altimate_change start — readDatamateTransportFromIde (imported from altimate/datamate-transport.ts) // 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. -// altimate_change end export const DatamateManagerTool = Tool.define("datamate_manager", { description: @@ -169,10 +165,7 @@ async function handleListIntegrations() { } } -// altimate_change start — server name used by IDEs (VS Code, Cursor, etc) in their MCP config // DATAMATE_KEY is imported from altimate/datamate-transport.ts (shared constant). -const EXTENSION_DATAMATE_SERVER = DATAMATE_KEY -// altimate_change end async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) { if (!args.datamate_id) { @@ -184,31 +177,28 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p } try { const datamate = await AltimateApi.getDatamate(args.datamate_id) - // altimate_change start — scan all IDE config locations (VS Code, Cursor, Copilot, etc.) // 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: EXTENSION_DATAMATE_SERVER, + serverName: DATAMATE_KEY, transportType: transport.type, }) } else { log.info("handleAdd: no IDE transport found, using standalone cloud config") } - // altimate_change end - // altimate_change start — single-gateway mode when IDE extension is present // If an IDE MCP config has a "datamate" entry (written by VS Code, Cursor, etc.), - // always use EXTENSION_DATAMATE_SERVER ("datamate") as the server name regardless + // 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 - ? EXTENSION_DATAMATE_SERVER + ? DATAMATE_KEY : (args.name ?? `datamate-${slugify(datamate.name)}`) const creds = transport ? undefined : await AltimateApi.getCredentials() @@ -227,10 +217,10 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal) if (transport !== null) { - // IDE/extension mode: check if EXTENSION_DATAMATE_SERVER is already wired up + // IDE/extension mode: check if DATAMATE_KEY is already wired up const existingNames = await listMcpInConfig(configPath) const staleEntries = existingNames.filter( - (n) => n !== EXTENSION_DATAMATE_SERVER && n.startsWith("datamate-"), + (n) => n !== DATAMATE_KEY && n.startsWith("datamate-"), ) if (staleEntries.length > 0) { log.info("handleAdd: stale per-datamate entries detected alongside extension gateway", { @@ -238,25 +228,25 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p }) } - if (existingNames.includes(EXTENSION_DATAMATE_SERVER)) { + if (existingNames.includes(DATAMATE_KEY)) { // Already in config — just ensure it is connected in this session const allStatus = await MCP.status() - if (allStatus[EXTENSION_DATAMATE_SERVER]?.status === "connected") { + if (allStatus[DATAMATE_KEY]?.status === "connected") { log.info("handleAdd: already connected, skipping add", { - serverName: EXTENSION_DATAMATE_SERVER, + serverName: DATAMATE_KEY, }) const mcpTools = await MCP.tools() const toolCount = Object.keys(mcpTools).filter((k) => - k.startsWith(EXTENSION_DATAMATE_SERVER + "_"), + 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 '${EXTENSION_DATAMATE_SERVER}'`, - metadata: { serverName: EXTENSION_DATAMATE_SERVER, datamateId: args.datamate_id, toolCount }, - output: `Datamate tools are already available via the '${EXTENSION_DATAMATE_SERVER}' MCP server (${toolCount} tools active).${staleNote}`, + 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 @@ -264,17 +254,17 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p // 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: EXTENSION_DATAMATE_SERVER, + serverName: DATAMATE_KEY, }) - await MCP.connect(EXTENSION_DATAMATE_SERVER) + await MCP.connect(DATAMATE_KEY) } else { // Not in config yet — write to disk then connect log.info("handleAdd: adding new datamate entry", { - serverName: EXTENSION_DATAMATE_SERVER, + serverName: DATAMATE_KEY, type: mcpConfig.type, }) - await addMcpToConfig(EXTENSION_DATAMATE_SERVER, { ...mcpConfig, enabled: true }, configPath) - await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig) + 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 @@ -285,7 +275,6 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p await addMcpToConfig(serverName, { ...mcpConfig, enabled: true }, configPath) await MCP.add(serverName, mcpConfig) } - // altimate_change end // Check connection status const allStatus = await MCP.status() diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index bf631b2d40..7cc256f5b7 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -36,7 +36,6 @@ function safeDetail(server: { type: string } & Record): string { return `(${server.type})` } -// altimate_change start — strip session-specific env vars before persisting // 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 @@ -50,7 +49,6 @@ function stripSessionEnv(cfg: import("../../config/config").Config.Mcp): import( const { ALTIMATE_EXTENSION_RPC: _rpc, ...rest } = cfg.environment return { ...cfg, environment: Object.keys(rest).length > 0 ? rest : undefined } } -// altimate_change end export const McpDiscoverTool = Tool.define("mcp_discover", { description: @@ -127,7 +125,6 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { ) for (const name of toAdd) { - // altimate_change start — strip session-specific ALTIMATE_EXTENSION_RPC and // 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. @@ -136,7 +133,6 @@ export const McpDiscoverTool = Tool.define("mcp_discover", { // Connect immediately so /mcps reflects the server status in the current session // without requiring a restart. await MCP.connect(name) - // altimate_change end } lines.push(`\nAdded ${toAdd.length} server(s) to ${configPath}: ${toAdd.join(", ")}`) From 18da6b4c35a8fb44def56b431b68f7993d766cab Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Tue, 16 Jun 2026 06:55:22 +0000 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20glob=20ignore,=20type=20guard,=20persist=20from=20d?= =?UTF-8?q?isk,=20dedup=20respond()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/altimate/datamate-transport.ts | 38 +-------- packages/opencode/src/cli/cmd/serve.ts | 4 - packages/opencode/src/mcp/config.ts | 28 +++++++ packages/opencode/src/mcp/index.ts | 5 +- packages/opencode/src/server/server.ts | 3 +- packages/opencode/src/session/prompt.ts | 81 ++++++++----------- packages/opencode/src/util/glob.ts | 2 + 7 files changed, 70 insertions(+), 91 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 0a6b1e36b8..51985ea5bc 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -1,7 +1,7 @@ import { readFile } from "fs/promises" import path from "path" import { parseTree, findNodeAtLocation } from "jsonc-parser" -import { resolveConfigPath, addMcpToConfig } from "../mcp/config" +import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" import { Filesystem } from "../util/filesystem" import { Glob } from "../util/glob" import { Log } from "../util/log" @@ -18,8 +18,6 @@ export const DATAMATE_KEY = "datamate" */ const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const -/** Glob patterns to exclude from mcp.json scans (large, irrelevant trees). */ -const MCP_SCAN_EXCLUDE = ["/node_modules/", "/.git/", "/dist/", "/build/", "/.pnpm/"] export type DatamateTransport = | { type: "remote"; url: string } @@ -51,10 +49,9 @@ async function findAllMcpJsonFiles(projectRootDir: string): Promise { cwd: projectRootDir, absolute: true, dot: true, + ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.pnpm/**"], }) - return paths - .filter((p) => !MCP_SCAN_EXCLUDE.some((ex) => p.includes(ex))) - .sort() + return paths.sort() } catch { log.warn("findAllMcpJsonFiles: glob scan failed", { cwd: projectRootDir }) return [] @@ -193,7 +190,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise - if (datamateVscode["type"] === "stdio") { + if ("command" in datamateVscode) { const env = datamateVscode["env"] as Record | undefined const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {} const cmd = @@ -300,30 +297,3 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: 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" || !node.children) return undefined - - const entry: Record = {} - for (const prop of node.children) { - if (prop.type === "property" && prop.children) { - entry[prop.children[0]!.value as string] = prop.children[1]!.value - } - } - - return entry as Config.Mcp -} diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 91ce9c6e7a..3509ef2bb9 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -4,10 +4,6 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" // altimate_change start — trace: session tracing in headless serve import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer" -// datamate IDE-sync helpers extracted to shared module -// Logic lives in altimate/datamate-transport.ts so serve.ts, server.ts, and -// datamate.ts all import from one place (no duplication, shared DATAMATE_KEY constant). -export { syncDatamateUrlFromVscodeMcp } from "../../altimate/datamate-transport" // altimate_change start — self-update on headless serve startup import { scheduleStartupUpgradeCheck } from "./serve-upgrade-check" // altimate_change end diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index debe866517..2ea6b464d2 100644 --- a/packages/opencode/src/mcp/config.ts +++ b/packages/opencode/src/mcp/config.ts @@ -101,3 +101,31 @@ 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" || !node.children) return undefined + + const entry: Record = {} + for (const prop of node.children) { + if (prop.type === "property" && prop.children) { + entry[prop.children[0]!.value as string] = prop.children[1]!.value + } + } + + return entry as Config.Mcp +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 59dbd6d2d6..57b1d19ca4 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -16,7 +16,7 @@ import z from "zod/v4" import { Instance } from "../project/instance" import { Installation } from "../installation" // altimate_change start — persist enabled flag -import { findAllConfigPaths, listMcpInConfig, addMcpToConfig } from "./config" +import { findAllConfigPaths, listMcpInConfig, addMcpToConfig, readMcpEntryFromDisk } from "./config" import { Global } from "../global" // altimate_change end import { withTimeout } from "@/util/timeout" @@ -706,8 +706,7 @@ export namespace MCP { for (const p of paths) { const names = await listMcpInConfig(p) if (names.includes(name)) { - const cfg = await Config.get() - const entry = cfg.mcp?.[name] + const entry = await readMcpEntryFromDisk(name, p) if (entry) await addMcpToConfig( name, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b38c1dc06a..7849eda58f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -33,7 +33,8 @@ import { McpRoutes } from "./routes/mcp" 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, readMcpEntryFromDisk } from "../altimate/datamate-transport" +import { syncDatamateUrlFromVscodeMcp } from "../altimate/datamate-transport" +import { readMcpEntryFromDisk } from "../mcp/config" import { resolveConfigPath } from "../mcp/config" // altimate_change end import { FileRoutes } from "./routes/file" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6af3e92db7..ae910639f1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -423,22 +423,15 @@ export namespace SessionPrompt { // 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. - let toolsNeedRefresh = false const unsubscribeToolsChanged = Bus.subscribe(MCP.ToolsChanged, (event) => { log.info("MCP.ToolsChanged received — tools will refresh on next turn", { server: event.properties.server, sessionID, }) - toolsNeedRefresh = true }) using _unsubToolsChanged = defer(unsubscribeToolsChanged) // altimate_change end while (true) { - // altimate_change start — log when a ToolsChanged event was received since last turn - if (toolsNeedRefresh) { - log.info("refreshing MCP tools after ToolsChanged event", { sessionID }) - toolsNeedRefresh = false - } // altimate_change end // 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" }) @@ -2592,6 +2585,33 @@ NOTE: At any point in time through this workflow you should feel free to ask the // 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: string, + responseText: string, + ): 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) { @@ -2608,7 +2628,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const icon = s.status === "connected" ? "\u2713" : "\u25cb" const label = s.status === "failed" - ? icon + " " + s.status + " (" + (s as any).error + ")" + ? icon + " " + s.status + " (" + s.error + ")" : icon + " " + s.status return "| `" + srv + "` | " + label + " |" }) @@ -2617,26 +2637,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows : "No MCP servers configured." - const now = Date.now() - const assistantMsg: MessageV2.Assistant = { - id: MessageID.ascending(), role: "assistant", sessionID: input.sessionID, - parentID: userMsg.info.id, 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 + return respond(userMsg.info.id, responseText) } const subMatch = trimmed.match(/^(enable|disable)\s+(\S+)/) @@ -2660,33 +2661,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (entry?.status === "connected") { responseText = `MCP server **${name}** enabled. Status: connected.` } else { - responseText = `Attempted to enable MCP server **${name}**. Status: ${entry?.status ?? "unknown"}${(entry as any)?.error ? " — " + (entry as any).error : "."}.` + 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.` } - const now = Date.now() - const assistantMsg: MessageV2.Assistant = { - id: MessageID.ascending(), role: "assistant", sessionID: input.sessionID, - parentID: userMsg.info.id, 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 + return respond(userMsg.info.id, responseText) } } // altimate_change end diff --git a/packages/opencode/src/util/glob.ts b/packages/opencode/src/util/glob.ts index febf062daa..aedbbe85a4 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, } } From 0d78a0b935f7b56bbb572367e071bde077e60d64 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Tue, 16 Jun 2026 09:30:55 +0000 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20add=20model=20param=20to=20respond?= =?UTF-8?q?()=20helper=20=E2=80=94=20was=20undefined=20in=20closure=20caus?= =?UTF-8?q?ing=20no-reply=20on=20/mcps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/session/prompt.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ae910639f1..51526c84c0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2588,8 +2588,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the // Helper: build and persist an assistant reply for a command shortcut. async function respond( - parentID: string, + parentID: MessageID, responseText: string, + model: { modelID: ModelID; providerID: ProviderID }, ): Promise { const now = Date.now() const assistantMsg: MessageV2.Assistant = { @@ -2637,7 +2638,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows : "No MCP servers configured." - return respond(userMsg.info.id, responseText) + return respond(userMsg.info.id, responseText, model) } const subMatch = trimmed.match(/^(enable|disable)\s+(\S+)/) @@ -2669,7 +2670,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the responseText = `MCP server **${name}** disabled.` } - return respond(userMsg.info.id, responseText) + return respond(userMsg.info.id, responseText, model) } } // altimate_change end From b5d5df8e93040ef7d9f5f9e7e0aebf9077189081 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 17 Jun 2026 10:25:24 +0530 Subject: [PATCH 14/17] fix: [AI-6948] discover MCPs from project root instead of git worktree `/discover-and-add-mcps` found no servers (and auto-discovery added none) for non-git projects: `Instance.worktree` resolves to "/" when there is no `.git`, so the scan looked under the filesystem root and missed the workspace's `.vscode/mcp.json` (datamate). The `add` action also resolved its project-scoped config path against "/". - `mcp-discover.ts`: use `Instance.directory` (project root) instead of `Instance.worktree` for the discovery scan, persisted-name read, and the project-scoped config write target - `config.ts`: same `Instance.worktree` -> `Instance.directory` for the background auto-discovery caller - `discover.ts`: replace the hardcoded IDE paths with a recursive `**/mcp.json` glob scan rooted at the project directory (IDE-agnostic, trying both `servers` and `mcpServers` keys), keeping the non-`mcp.json` sources (`.mcp.json`, `.gemini/settings.json`, `~/.claude.json`) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/altimate/tools/mcp-discover.ts | 6 +- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/mcp/discover.ts | 76 +++++++++++++++---- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index 7cc256f5b7..cf5a311260 100644 --- a/packages/opencode/src/altimate/tools/mcp-discover.ts +++ b/packages/opencode/src/altimate/tools/mcp-discover.ts @@ -11,7 +11,7 @@ import { MCP } from "../../mcp" * (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)) { @@ -68,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) { @@ -120,7 +120,7 @@ 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, ) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4e813883fb..86ddc2ec29 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[] = [] diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index 916f10ac3f..206c671684 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,37 @@ 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. + // Sorted for deterministic first-source-wins ordering. + let mcpJsonFiles: string[] = [] + try { + mcpJsonFiles = ( + await Glob.scan("**/mcp.json", { + cwd: projectDir, + absolute: true, + dot: true, + ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.pnpm/**"], + }) + ).sort() + } 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 = path.relative(projectDir, 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 +296,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) { From 4a4a1751bd1cffcbf5eec0c26d3a7c4ef51e793b Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 17 Jun 2026 10:50:08 +0530 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20[AI-6948]=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20preserve=20MCP=20config=20fields=20on=20disk=20w?= =?UTF-8?q?rites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the review findings on the `/mcps` enable/disable persistence and datamate transport sync paths. - `mcp/config.ts`: `readMcpEntryFromDisk` now uses `getNodeValue` instead of a manual children walk. `jsonc-parser` only populates `Node.value` for primitives, so the old walk silently dropped `command` arrays and `environment`/`headers` objects — corrupting local stdio (and remote-with- headers) entries on the next write via `persistMcpEnabled`. - `altimate/datamate-transport.ts`: same `getNodeValue` fix for the two sibling walkers; preserve non-transport fields (`enabled`, `timeout`, `oauth`, …) when rebuilding the synced datamate entry; widen the `**/mcp.json` ignore list (`target`, `.next`, `out`, `vendor`, `coverage`, `.venv`, `.turbo`). - `mcp/index.ts`: serialize `persistMcpEnabled` (promise chain) so concurrent enable/disable can't interleave read-modify-write and clobber each other. - `server/server.ts`: `reload-datamate` returns HTTP 500 (not 200) on error so the extension can distinguish failure. - `session/prompt.ts`: `/mcps enable|disable` validates the server name against config and reports clearly when it's unknown (was silent). - `test/mcp/config.test.ts`: add `readMcpEntryFromDisk` round-trip tests, including the persist-enabled flow that previously corrupted local entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/altimate/datamate-transport.ts | 71 +++++++++++++------ packages/opencode/src/mcp/config.ts | 17 ++--- packages/opencode/src/mcp/index.ts | 11 ++- packages/opencode/src/server/server.ts | 2 +- packages/opencode/src/session/prompt.ts | 13 ++++ packages/opencode/test/mcp/config.test.ts | 63 ++++++++++++++++ 6 files changed, 143 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/altimate/datamate-transport.ts b/packages/opencode/src/altimate/datamate-transport.ts index 51985ea5bc..149381823d 100644 --- a/packages/opencode/src/altimate/datamate-transport.ts +++ b/packages/opencode/src/altimate/datamate-transport.ts @@ -1,6 +1,6 @@ import { readFile } from "fs/promises" import path from "path" -import { parseTree, findNodeAtLocation } from "jsonc-parser" +import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser" import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config" import { Filesystem } from "../util/filesystem" import { Glob } from "../util/glob" @@ -49,7 +49,23 @@ async function findAllMcpJsonFiles(projectRootDir: string): Promise { cwd: projectRootDir, absolute: true, dot: true, - ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.pnpm/**"], + // 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 { @@ -170,25 +186,38 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise) + : {} + const existingUpdatedAt = + typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined if (vscodeUpdatedAt === existingUpdatedAt) { log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", { updatedAt: vscodeUpdatedAt, }) } else { - // Build the new config entry in altimate-code.json format. - // IDE config uses "stdio"/"http"/"streamable-http"/"sse"; + // 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 @@ -198,6 +227,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise 0 ? { environment: restEnv } : {}), @@ -206,12 +236,12 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise e.key === remote.name) if (match && match.url !== remote.url) { const entryNode = findNodeAtLocation(tree, ["mcp", remote.name]) - if (!entryNode || entryNode.type !== "object" || !entryNode.children) continue - const entry: Record = {} - for (const prop of entryNode.children) { - if (prop.type === "property" && prop.children) { - entry[prop.children[0]!.value as string] = prop.children[1]!.value - } - } + 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( diff --git a/packages/opencode/src/mcp/config.ts b/packages/opencode/src/mcp/config.ts index 2ea6b464d2..2467ebf584 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" @@ -118,14 +118,11 @@ export async function readMcpEntryFromDisk( if (!tree) return undefined const node = findNodeAtLocation(tree, ["mcp", name]) - if (!node || node.type !== "object" || !node.children) return undefined + if (!node || node.type !== "object") return undefined - const entry: Record = {} - for (const prop of node.children) { - if (prop.type === "property" && prop.children) { - entry[prop.children[0]!.value as string] = prop.children[1]!.value - } - } - - return entry as Config.Mcp + // 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/index.ts b/packages/opencode/src/mcp/index.ts index 57b1d19ca4..1e4d72f372 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -699,7 +699,16 @@ export namespace MCP { } // altimate_change start — persist enabled/disabled to disk so it survives session restarts - async function persistMcpEnabled(name: string, enabled: boolean): Promise { + // 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 diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7849eda58f..cc876dd998 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -619,7 +619,7 @@ export namespace Server { } catch (err) { const error = err instanceof Error ? err.message : String(err) log.error("reload-datamate: failed", { error }) - return c.json({ ok: false, error }) + return c.json({ ok: false, error }, 500) } }) // altimate_change end diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 51526c84c0..50b65bf4ff 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2653,6 +2653,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) 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) { diff --git a/packages/opencode/test/mcp/config.test.ts b/packages/opencode/test/mcp/config.test.ts index 4a958c2b78..ba4ceb88ab 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() From cba1f2c0f5be67e710e8ddc99aa5d442cb7e61bc Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 17 Jun 2026 12:03:44 +0530 Subject: [PATCH 16/17] fix: [AI-6948] preserve IDE source precedence in mcp.json glob scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `**/mcp.json` glob scan sorted purely alphabetically, which let `.cursor/mcp.json` override `.vscode/mcp.json` for same-named servers — a regression vs the previous fixed-source order (.vscode > .cursor > .github/copilot). Caught by the `discover-adversarial` precedence test. Order globbed files by IDE precedence first, then alphabetically, restoring first-source-wins semantics while staying IDE-agnostic for any other mcp.json. Also align the ignore list with `datamate-transport.ts`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/mcp/discover.ts | 47 +++++++++++++++++++++------ 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index 206c671684..18affeb585 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -260,24 +260,51 @@ export async function discoverExternalMcp(projectDir: string): Promise<{ // 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. - // Sorted for deterministic first-source-wins ordering. + // 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 { - mcpJsonFiles = ( - await Glob.scan("**/mcp.json", { - cwd: projectDir, - absolute: true, - dot: true, - ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.pnpm/**"], - }) - ).sort() + 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 = path.relative(projectDir, file) || path.basename(file) + const label = toRel(file) || path.basename(file) addServersFromFile(mergeServerKeys(parsed), label, result, contributingSources, true) } From c8197cc9687dfba13d7b844f02b5b4fa5c588b81 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 17 Jun 2026 12:33:21 +0530 Subject: [PATCH 17/17] fix: [AI-6948] balance altimate_change markers (CI Marker Guard + integrity tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing marker imbalances on the branch failed the "Require-markers regression backstop" job and the `altimate_change marker integrity` tests (origin/main is balanced; these were introduced by earlier merge commits on this branch): - `cli/cmd/serve.ts` (6 start / 4 end): close the trace-import region after its import, and close the branding region after the rebranded log line (the sync-datamate region stays nested inside it). - `session/prompt.ts` (43 start / 44 end): remove a duplicate `altimate_change end` left by a merge — the MCP-ToolsChanged region is already closed before `while (true)`, and the SessionStatus region right after has its own pair. Comment-only changes; no behavior impact. Verified with `bun run script/upstream/analyze.ts --require-markers --strict` (38/38) and the marker-integrity suites (118/0). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/serve.ts | 2 ++ packages/opencode/src/session/prompt.ts | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 3509ef2bb9..962385da72 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -4,6 +4,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" // altimate_change start — trace: session tracing in headless serve import { subscribeTraceConsumer } from "../../altimate/observability/trace-consumer" +// altimate_change end // altimate_change start — self-update on headless serve startup import { scheduleStartupUpgradeCheck } from "./serve-upgrade-check" // altimate_change end @@ -27,6 +28,7 @@ export const ServeCommand = cmd({ // altimate_change end const server = await Server.listen(opts) console.log(`altimate-code server listening on http://${server.hostname}:${server.port}`) + // altimate_change end // altimate_change start — trace: session tracing in headless serve // Sessions driven over HTTP (e.g. the VS Code chat panel) have no TUI diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 50b65bf4ff..bb00f580d7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -432,7 +432,6 @@ export namespace SessionPrompt { using _unsubToolsChanged = defer(unsubscribeToolsChanged) // altimate_change end while (true) { - // altimate_change end // 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" }) // altimate_change end