Skip to content
61 changes: 58 additions & 3 deletions packages/opencode/src/altimate/tools/datamate.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<string, unknown>

// .vscode/mcp.json uses either "servers" (VS Code 1.99+) or "mcpServers" key
const serversMap =
(parsed["servers"] as Record<string, Record<string, unknown>> | undefined) ??
(parsed["mcpServers"] as Record<string, Record<string, unknown>> | 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). " +
Expand Down Expand Up @@ -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)

Expand Down
20 changes: 19 additions & 1 deletion packages/opencode/src/altimate/tools/mcp-discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ function safeDetail(server: { type: string } & Record<string, any>): 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.",
Expand Down Expand Up @@ -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(", ")}`)
Expand Down
186 changes: 186 additions & 0 deletions packages/opencode/src/cli/cmd/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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<string, unknown>
try {
parsed = JSON.parse(text) as Record<string, unknown>
} catch {
return updated
}

const serversMap =
(parsed["servers"] as Record<string, Record<string, unknown>> | undefined) ??
(parsed["mcpServers"] as Record<string, Record<string, unknown>> | 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<string, unknown>
if (datamateVscode["type"] === "stdio") {
const env = datamateVscode["env"] as Record<string, string> | 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<typeof addMcpToConfig>[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<string, unknown> = {}
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<typeof addMcpToConfig>[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",
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading