diff --git a/packages/opencode/src/altimate/tools/datamate.ts b/packages/opencode/src/altimate/tools/datamate.ts index e15e5af45..db312c45e 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" @@ -25,6 +27,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). " + @@ -163,15 +205,28 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p } } try { - const creds = await AltimateApi.getCredentials() + // altimate_change start — detect transport from .vscode/mcp.json; fall back to cloud + // Reads .vscode/mcp.json in the project root to determine whether the extension + // registered an HTTP or stdio transport for the datamate MCP server. This lets + // altimate-code honour whatever the VS Code extension wrote (HTTP when the local + // mcp-engine is running, stdio when it is not), and falls back to the cloud MCP + // config when no .vscode/mcp.json entry is found. 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()) + const creds = transport ? undefined : await AltimateApi.getCredentials() + const mcpConfig = + transport?.type === "remote" + ? { type: "remote" as const, url: transport.url } + : transport?.type === "local" + ? { type: "local" as const, command: ["datamate", "start-stdio", "--datamate", args.datamate_id] } + : AltimateApi.buildMcpConfig(creds!, args.datamate_id) + // altimate_change end // 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 addMcpToConfig(serverName, { ...mcpConfig, enabled: true }, configPath) await MCP.add(serverName, mcpConfig) diff --git a/packages/opencode/src/altimate/tools/mcp-discover.ts b/packages/opencode/src/altimate/tools/mcp-discover.ts index 30af405ac..1a2592bf9 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 cc7cb3c3c..0524b4a31 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -5,6 +5,186 @@ 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 +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", @@ -16,6 +196,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 99295821c..4e813883f 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 b110467ce..df9647847 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 35f330447..7e83d3a8a 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 b078d746a..d41a3c1dd 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