Skip to content

Commit 0b6cc26

Browse files
author
saravmajestic
committed
feat: datamate stdio local transport + extension single-gateway mode
- 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
1 parent c2019ba commit 0b6cc26

7 files changed

Lines changed: 435 additions & 22 deletions

File tree

packages/opencode/src/altimate/tools/datamate.ts

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import z from "zod"
2+
import { readFile } from "fs/promises"
3+
import path from "path"
24
import { Tool } from "../../tool/tool"
35
import { AltimateApi } from "../api/client"
46
import { MCP } from "../../mcp"
@@ -11,6 +13,9 @@ import {
1113
} from "../../mcp/config"
1214
import { Instance } from "../../project/instance"
1315
import { Global } from "../../global"
16+
import { Log } from "../../util/log"
17+
18+
const log = Log.create({ service: "datamate" })
1419

1520
/** Project root for config resolution — falls back to cwd when no git repo is detected. */
1621
function projectRoot() {
@@ -25,6 +30,46 @@ export function slugify(name: string): string {
2530
.replace(/^-|-$/g, "")
2631
}
2732

33+
// altimate_change start — read transport type from .vscode/mcp.json
34+
// Returns { type: "remote", url } if the datamate entry is an HTTP server,
35+
// { type: "local" } if it is a stdio server, or null if the file is missing
36+
// or no datamate entry is found. The caller uses this to pick the right
37+
// mcpConfig shape and falls back to the cloud config when null is returned.
38+
async function readVscodeMcpTransport(
39+
projectRootDir: string,
40+
): Promise<{ type: "remote"; url: string } | { type: "local" } | null> {
41+
try {
42+
const mcpJsonPath = path.join(projectRootDir, ".vscode", "mcp.json")
43+
const text = await readFile(mcpJsonPath, "utf-8")
44+
const parsed = JSON.parse(text) as Record<string, unknown>
45+
46+
// .vscode/mcp.json uses either "servers" (VS Code 1.99+) or "mcpServers" key
47+
const serversMap =
48+
(parsed["servers"] as Record<string, Record<string, unknown>> | undefined) ??
49+
(parsed["mcpServers"] as Record<string, Record<string, unknown>> | undefined) ??
50+
{}
51+
52+
for (const [key, entry] of Object.entries(serversMap)) {
53+
const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : []
54+
const isDatamate =
55+
key === "datamate" ||
56+
args.some((a) => a.includes("start-stdio") || a.includes("datamate-cli"))
57+
58+
if (!isDatamate) continue
59+
60+
if (typeof entry["url"] === "string") {
61+
return { type: "remote", url: entry["url"] }
62+
}
63+
return { type: "local" }
64+
}
65+
return null
66+
} catch {
67+
// File missing or unparseable — caller falls back to cloud config
68+
return null
69+
}
70+
}
71+
// altimate_change end
72+
2873
export const DatamateManagerTool = Tool.define("datamate_manager", {
2974
description:
3075
"Manage Altimate Datamates — AI teammates with integrations (Snowflake, Jira, dbt, etc). " +
@@ -39,7 +84,9 @@ export const DatamateManagerTool = Tool.define("datamate_manager", {
3984
"'list-config' shows all datamate entries saved in config files (project and global). " +
4085
"Config files: project config is at <project-root>/altimate-code.json, " +
4186
"global config is at ~/.config/altimate-code/altimate-code.json. " +
42-
"Datamate server names are prefixed with 'datamate-'. " +
87+
"When a VS Code extension datamate entry exists (.vscode/mcp.json has 'datamate' key), " +
88+
"'add' always uses the server name 'datamate' — tools are then prefixed 'datamate_'. " +
89+
"In standalone mode, server names follow 'datamate-<name>' pattern. " +
4390
"Do NOT use glob/grep/read to find config files — use 'list-config' instead.",
4491
parameters: z.object({
4592
operation: z.enum(["list", "list-integrations", "add", "create", "edit", "delete", "status", "remove", "list-config"]),
@@ -154,6 +201,10 @@ async function handleListIntegrations() {
154201
}
155202
}
156203

204+
// altimate_change start — server name used by the VS Code extension in .vscode/mcp.json
205+
const EXTENSION_DATAMATE_SERVER = "datamate"
206+
// altimate_change end
207+
157208
async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "project" | "global" }) {
158209
if (!args.datamate_id) {
159210
return {
@@ -163,17 +214,76 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
163214
}
164215
}
165216
try {
166-
const creds = await AltimateApi.getCredentials()
167217
const datamate = await AltimateApi.getDatamate(args.datamate_id)
168-
const serverName = args.name ?? `datamate-${slugify(datamate.name)}`
169-
const mcpConfig = AltimateApi.buildMcpConfig(creds, args.datamate_id)
218+
const transport = await readVscodeMcpTransport(projectRoot())
219+
220+
// altimate_change start — single-gateway mode when extension is present
221+
// If .vscode/mcp.json has a "datamate" entry (written by the VS Code extension),
222+
// always use "datamate" as the server name regardless of which specific datamate
223+
// the user selected. This prevents duplicate tool sets — the extension's gateway
224+
// already serves all datamate tools through a single MCP connection.
225+
// In standalone/CLI mode (no .vscode/mcp.json datamate entry), fall back to the
226+
// original per-datamate naming with cloud URL.
227+
const serverName = transport !== null
228+
? EXTENSION_DATAMATE_SERVER
229+
: (args.name ?? `datamate-${slugify(datamate.name)}`)
230+
231+
const creds = transport ? undefined : await AltimateApi.getCredentials()
232+
const mcpConfig =
233+
transport?.type === "remote"
234+
? { type: "remote" as const, url: transport.url }
235+
: transport?.type === "local"
236+
// Extension stdio: no --datamate id needed — active teammate is resolved
237+
// by the extension over the ALTIMATE_EXTENSION_RPC socket at runtime.
238+
? { type: "local" as const, command: ["datamate", "start-stdio"] }
239+
: AltimateApi.buildMcpConfig(creds!, args.datamate_id)
170240

171-
// Always save to config first so it persists for future sessions
172241
const isGlobal = args.scope === "global"
173242
const configPath = await resolveConfigPath(isGlobal ? Global.Path.config : projectRoot(), isGlobal)
174-
await addMcpToConfig(serverName, mcpConfig, configPath)
175243

176-
await MCP.add(serverName, mcpConfig)
244+
if (transport !== null) {
245+
// Extension mode: check if "datamate" is already wired up
246+
const existingNames = await listMcpInConfig(configPath)
247+
const staleEntries = existingNames.filter(
248+
(n) => n !== EXTENSION_DATAMATE_SERVER && n.startsWith("datamate-"),
249+
)
250+
if (staleEntries.length > 0) {
251+
log.info("handleAdd: stale per-datamate entries detected alongside extension gateway", {
252+
staleEntries,
253+
})
254+
}
255+
256+
if (existingNames.includes(EXTENSION_DATAMATE_SERVER)) {
257+
// Already in config — just ensure it is connected in this session
258+
const allStatus = await MCP.status()
259+
if (allStatus[EXTENSION_DATAMATE_SERVER]?.status === "connected") {
260+
const mcpTools = await MCP.tools()
261+
const toolCount = Object.keys(mcpTools).filter((k) =>
262+
k.startsWith(EXTENSION_DATAMATE_SERVER + "_"),
263+
).length
264+
const staleNote =
265+
staleEntries.length > 0
266+
? `\n\nNote: stale per-datamate entries found in config: ${staleEntries.join(", ")} — use operation 'remove' to clean them up.`
267+
: ""
268+
return {
269+
title: `Datamate '${datamate.name}': already connected via '${EXTENSION_DATAMATE_SERVER}'`,
270+
metadata: { serverName: EXTENSION_DATAMATE_SERVER, datamateId: args.datamate_id, toolCount },
271+
output: `Datamate tools are already available via the '${EXTENSION_DATAMATE_SERVER}' MCP server (${toolCount} tools active).${staleNote}`,
272+
}
273+
}
274+
// In config but not connected — reconnect
275+
await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig)
276+
} else {
277+
// Not in config yet — write then connect
278+
await addMcpToConfig(EXTENSION_DATAMATE_SERVER, { ...mcpConfig, enabled: true }, configPath)
279+
await MCP.add(EXTENSION_DATAMATE_SERVER, mcpConfig)
280+
}
281+
} else {
282+
// Standalone/CLI mode — original behaviour: per-datamate name + cloud URL
283+
await addMcpToConfig(serverName, { ...mcpConfig, enabled: true }, configPath)
284+
await MCP.add(serverName, mcpConfig)
285+
}
286+
// altimate_change end
177287

178288
// Check connection status
179289
const allStatus = await MCP.status()
@@ -197,7 +307,7 @@ async function handleAdd(args: { datamate_id?: string; name?: string; scope?: "p
197307
return {
198308
title: `Datamate '${datamate.name}': connected as '${serverName}'`,
199309
metadata: { serverName, datamateId: args.datamate_id, toolCount, configPath },
200-
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.`,
310+
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.`,
201311
}
202312
} catch (e) {
203313
return {

packages/opencode/src/altimate/tools/mcp-discover.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ function safeDetail(server: { type: string } & Record<string, any>): string {
3535
return `(${server.type})`
3636
}
3737

38+
// altimate_change start — strip session-specific env vars before persisting
39+
// discovered servers. ALTIMATE_EXTENSION_RPC is a Unix socket path that is
40+
// unique to the current VS Code extension host process. Writing it to disk
41+
// causes altimate-code on a future session (or a different VS Code window) to
42+
// spawn datamate processes that connect to the wrong bridge or a dead socket.
43+
// Stripping it forces runtime discovery via ~/.altimate/extension-rpc/ sidecars,
44+
// which always resolves the correct live bridge by matching process.cwd() against
45+
// each bridge's recorded workspaceFolders.
46+
function stripSessionEnv(cfg: import("../../config/config").Config.Mcp): import("../../config/config").Config.Mcp {
47+
if (cfg.type !== "local" || !cfg.environment) return cfg
48+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
49+
const { ALTIMATE_EXTENSION_RPC: _rpc, ...rest } = cfg.environment
50+
return { ...cfg, environment: Object.keys(rest).length > 0 ? rest : undefined }
51+
}
52+
// altimate_change end
53+
3854
export const McpDiscoverTool = Tool.define("mcp_discover", {
3955
description:
4056
"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", {
110126
)
111127

112128
for (const name of toAdd) {
113-
await addMcpToConfig(name, discovered[name], configPath)
129+
// altimate_change start — strip session-specific ALTIMATE_EXTENSION_RPC
130+
await addMcpToConfig(name, stripSessionEnv(discovered[name]), configPath)
131+
// altimate_change end
114132
}
115133

116134
lines.push(`\nAdded ${toAdd.length} server(s) to ${configPath}: ${toAdd.join(", ")}`)

0 commit comments

Comments
 (0)