Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0b6cc26
feat: datamate stdio local transport + extension single-gateway mode
Jun 9, 2026
d8c851f
feat: [AI-6948] register mcps slash command in server command list (#…
altimate-harness-bot[bot] Jun 11, 2026
520143f
fix: address review comments + stale config cache in reload-datamate …
Jun 11, 2026
599807a
refactor: replace hardcoded IDE paths with glob scan for mcp.json
Jun 11, 2026
e790e91
fix(mcps): bypass LLM for /mcps list, enable, disable commands
Jun 12, 2026
1dbb097
fix(mcps): move /mcps bypass handler before Command.get() check
Jun 15, 2026
794c221
fix(mcp-discover): strip enabled:false when explicitly adding server …
Jun 15, 2026
1290a63
fix(mcp-discover): set enabled:true when user explicitly adds server …
Jun 15, 2026
f8414c9
fix(mcp-discover): add updatedAt timestamp and connect MCP immediatel…
Jun 15, 2026
0fbdc9b
chore: move isAnthropicLikeModel NOTE docblock back above function
Jun 15, 2026
c2a5541
chore: strip altimate_change markers from altimate/ subtree, inline D…
Jun 15, 2026
2a1c5ac
Merge branch 'main' into fix/datamate-stdio-local-transport
saravmajestic Jun 15, 2026
18da6b4
fix: address review comments — glob ignore, type guard, persist from …
Jun 16, 2026
e7aa5ef
Merge branch main into fix/datamate-stdio-local-transport
Jun 16, 2026
0d78a0b
fix: add model param to respond() helper — was undefined in closure c…
Jun 16, 2026
b5d5df8
fix: [AI-6948] discover MCPs from project root instead of git worktree
saravmajestic Jun 17, 2026
4a4a175
fix: [AI-6948] address PR review — preserve MCP config fields on disk…
saravmajestic Jun 17, 2026
cba1f2c
fix: [AI-6948] preserve IDE source precedence in mcp.json glob scan
saravmajestic Jun 17, 2026
c8197cc
fix: [AI-6948] balance altimate_change markers (CI Marker Guard + int…
saravmajestic Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions packages/opencode/src/altimate/datamate-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
import { readFile } from "fs/promises"
import path from "path"
import { parseTree, findNodeAtLocation, getNodeValue } from "jsonc-parser"
import { resolveConfigPath, addMcpToConfig, readMcpEntryFromDisk } from "../mcp/config"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
import { Log } from "../util/log"
import type { Config } from "../config/config"

const log = Log.create({ service: "datamate-transport" })

export const DATAMATE_KEY = "datamate"

/**
* Top-level keys that MCP config files use to map server name → entry.
* VS Code 1.99+ uses "servers"; older VS Code and Cursor use "mcpServers".
* We try both so the scan works regardless of which IDE wrote the file.
*/
const MCP_SERVERS_KEYS = ["servers", "mcpServers"] as const


export type DatamateTransport =
| { type: "remote"; url: string }
| { type: "local"; command: string[] }

/**
* Parse a single mcp.json file and return the servers map, trying each of the
* known top-level key names in order.
*/
function extractServersMap(
parsed: Record<string, unknown>,
): Record<string, Record<string, unknown>> {
for (const key of MCP_SERVERS_KEYS) {
const candidate = parsed[key]
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
return candidate as Record<string, Record<string, unknown>>
}
}
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<string[]> {
try {
const paths = await Glob.scan("**/mcp.json", {
cwd: projectRootDir,
absolute: true,
dot: true,
// Exclude build/dependency/output trees. command + args from a discovered
// mcp.json are passed to StdioClientTransport, so keep the scan to source the
// user actually authors and out of vendored/generated directories.
ignore: [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/.pnpm/**",
"**/target/**",
"**/.next/**",
"**/out/**",
"**/vendor/**",
"**/coverage/**",
"**/.venv/**",
"**/.turbo/**",
],
})
return paths.sort()
} catch {
log.warn("findAllMcpJsonFiles: glob scan failed", { cwd: projectRootDir })
return []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concern: Glob.scan("**/mcp.json", { dot: true }) descends into node_modules/.git/dist and only filters them after the walk (the Glob.Options wrapper never forwards ignore to prune). This runs on every handleAdd, serve startup, and every /altimate/mcp/reload-datamate POST — a real repeated latency hit on big monorepos. The sibling mcp/discover.ts already uses a curated SOURCES list of exact relative paths; reuse that, or add ignore forwarding to toGlobOptions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: added ignore field to Glob.Options and wired it through to toGlobOptions in util/glob.ts. Updated findAllMcpJsonFiles to pass the exclude patterns directly to the glob walk — no more post-filter.

}
}

/**
* Scan all mcp.json files under projectRootDir and return the transport type
* for the first "datamate" server entry found.
*
* Returns null if no mcp.json contains a "datamate" entry — the caller should
* fall back to the cloud config.
*
* Reuses the exact command from the IDE config so altimate-code spawns the
* same process the extension already started, rather than a second one.
*/
export async function readDatamateTransportFromIde(
projectRootDir: string,
): Promise<DatamateTransport | null> {
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<string, unknown>
const serversMap = extractServersMap(parsed)
const entry = serversMap[DATAMATE_KEY]
if (!entry) continue

log.info("readDatamateTransportFromIde: found entry", {
source: relPath,
type: entry["type"] ?? "(no type)",
})

if (typeof entry["url"] === "string") {
return { type: "remote", url: entry["url"] }
}

// stdio entry — reuse the exact command + args the extension registered
const cmd = typeof entry["command"] === "string" ? entry["command"] : undefined
const args = Array.isArray(entry["args"]) ? (entry["args"] as string[]) : []
if (cmd) {
return { type: "local", command: [cmd, ...args] }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defense in depth — narrow the glob to explicit IDE config paths.

readDatamateTransportFromIde reads command and args from any mcp.json found via the **/mcp.json glob (only node_modules, .git, dist, build, .pnpm are excluded) and passes them straight to StdioClientTransport at mcp/index.ts:527. The ignore list also misses target/, .next/, out/, vendor/, coverage/, .venv/, .turbo/, .cache/, __pycache__/.

This widens both the attack surface (an mcp.json in a third-party fixture or vendored package gets loaded) and the performance footprint (every handleAdd / serve startup / reload-datamate POST walks the tree).

Fix: reuse the curated SOURCES list already used by mcp/discover.ts — only IDEs we explicitly support ever write a datamate entry, so the glob is overkill. Kills two birds.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4a4a1751b. Widened the ignore list for the **/mcp.json scan to also exclude target, .next, out, vendor, coverage, .venv, and .turbo, keeping the scan to source the user actually authors rather than vendored/generated trees. Kept the IDE-agnostic glob (per #599807ae4) rather than hardcoding paths; open to tightening to explicit IDE dirs if you'd prefer.

}

// Entry exists but has no usable command — treat as local marker
return { type: "local", command: [DATAMATE_KEY, "start-stdio"] }
} catch {
log.warn("readDatamateTransportFromIde: failed to parse", { source: relPath })
}
}

log.info("readDatamateTransportFromIde: no IDE entry found, falling back to cloud config")
return null
}

/**
* Sync the "datamate" entry (and other remote MCP entries) from the first
* mcp.json that contains a "datamate" key to altimate-code.json.
*
* Uses `updatedAt` as the change signal for the datamate entry (covers both
* stdio and HTTP transport), and URL comparison for all other remote entries.
*
* Fire-and-forget friendly: errors are logged but never thrown.
* Returns the list of MCP server names whose config was updated on disk.
*/
export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[]> {
const updated: string[] = []
try {
log.info("syncDatamateUrlFromVscodeMcp: start", { cwd })

// Find the first mcp.json that contains a "datamate" entry.
const mcpJsonPaths = await findAllMcpJsonFiles(cwd)
let mcpJsonPath: string | undefined
let serversMap: Record<string, Record<string, unknown>> = {}

for (const candidate of mcpJsonPaths) {
try {
const text = await readFile(candidate, "utf-8")
const parsed = JSON.parse(text) as Record<string, unknown>
const map = extractServersMap(parsed)
if (map[DATAMATE_KEY]) {
mcpJsonPath = candidate
serversMap = map
break
}
} catch {
// Unparseable — skip
}
}

if (!mcpJsonPath) {
log.info("syncDatamateUrlFromVscodeMcp: no mcp.json with datamate entry found, skipping sync")
return updated
}

log.info("syncDatamateUrlFromVscodeMcp: using config", {
source: path.relative(cwd, mcpJsonPath),
})

// ── "datamate" entry: sync by updatedAt (works for stdio + HTTP) ────────
const datamateVscode = serversMap[DATAMATE_KEY]
const vscodeUpdatedAt =
datamateVscode && typeof datamateVscode["updatedAt"] === "string"
? (datamateVscode["updatedAt"] as string)
: undefined

if (datamateVscode && vscodeUpdatedAt) {
const configPath = await resolveConfigPath(cwd)
if (await Filesystem.exists(configPath)) {
const configText = await Filesystem.readText(configPath)
const existingTree = parseTree(configText)
const existingNode = existingTree
? findNodeAtLocation(existingTree, ["mcp", DATAMATE_KEY])
: undefined

if (existingNode) {
// getNodeValue reconstructs the full entry (a manual children walk reading
// `prop.children[1].value` drops array/object fields — jsonc-parser only
// populates `Node.value` for primitives).
const existingEntry =
existingNode.type === "object"
? (getNodeValue(existingNode) as Record<string, unknown>)
: {}
const existingUpdatedAt =
typeof existingEntry["updatedAt"] === "string" ? existingEntry["updatedAt"] : undefined

if (vscodeUpdatedAt === existingUpdatedAt) {
log.info("syncDatamateUrlFromVscodeMcp: datamate entry already up to date", {
updatedAt: vscodeUpdatedAt,
})
} else {
// Preserve fields the IDE doesn't manage (enabled, timeout, oauth, …) by
// carrying forward everything except the transport-identity fields, which
// we re-derive below. IDE config uses "stdio"/"http"/"streamable-http"/"sse";
// altimate-code.json uses "local"/"remote".
const TRANSPORT_FIELDS = new Set([
"type",
"command",
"args",
"environment",
"url",
"updatedAt",
])
const preserved: Record<string, unknown> = {}
for (const [k, v] of Object.entries(existingEntry)) {
if (!TRANSPORT_FIELDS.has(k)) preserved[k] = v
}

let newEntry: Record<string, unknown>
if ("command" in datamateVscode) {
const env = datamateVscode["env"] as Record<string, string> | undefined
const { ALTIMATE_EXTENSION_RPC: _rpc, ...restEnv } = env ?? {}
const cmd =
typeof datamateVscode["command"] === "string"
? (datamateVscode["command"] as string)
: DATAMATE_KEY
newEntry = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syncDatamateUrlFromVscodeMcp overwrites the entry, losing timeout, oauth, and other fields.

This constructs newEntry from scratch using only type, command, args, env, updatedAt, and (separately) preserved enabled. Anything else on the existing altimate-code.json datamate entry — timeout, oauth, future fields — is silently dropped because addMcpToConfig does modify(path, value), which is a set, not a merge.

Fix: read the existing entry (via the corrected readMcpEntryFromDisk from the blocker fix) and spread it before overlaying new fields:

const existing = (existingNode ? getNodeValue(existingNode) : {}) as Record<string, unknown>
newEntry = {
  ...existing,
  type: "local",
  command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])],
  ...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}),
  updatedAt: vscodeUpdatedAt,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a4a175. The rebuild now carries forward the existing entry's non-transport fields: it spreads a preserved object (everything except type/command/args/environment/url/updatedAt) onto the new entry, so enabled, timeout, oauth, etc. survive the sync.

...preserved,
type: "local",
command: [cmd, ...((datamateVscode["args"] as string[]) ?? [])],
...(Object.keys(restEnv).length > 0 ? { environment: restEnv } : {}),
updatedAt: vscodeUpdatedAt,
}
} else {
// http / streamable-http / sse → remote
newEntry = {
...preserved,
type: "remote",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concern (latent): the stdio branch is gated on type === "stdio" exactly; any datamate entry without that exact type falls into the else and writes { type: "remote", url: undefined } — corrupting a Cursor/mcpServers-shaped { command, args } entry. readDatamateTransportFromIde classifies the opposite (correct) way: command present → stdio. Latent today because the only updatedAt writer always also writes type: "stdio", but the two functions disagree. Suggest mirroring the reader and guarding the remote branch with typeof url === "string".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: changed datamateVscode["type"] === "stdio" to "command" in datamateVscode. Now correctly identifies local/stdio transports by the presence of a command key rather than relying on an explicit type field (which VS Code omits when it defaults to stdio).

url: datamateVscode["url"] as string,
updatedAt: vscodeUpdatedAt,
}
}

await addMcpToConfig(
DATAMATE_KEY,
newEntry as Parameters<typeof addMcpToConfig>[1],
configPath,
)
log.info("syncDatamateUrlFromVscodeMcp: datamate entry synced", {
type: datamateVscode["type"],
updatedAt: vscodeUpdatedAt,
})
updated.push(DATAMATE_KEY)
}
}
}
}

// ── All other remote MCP entries: existing URL-comparison logic ──────────
const httpEntries: Array<{ key: string; url: string }> = []
for (const [key, entry] of Object.entries(serversMap)) {
if (key === DATAMATE_KEY) continue
if (typeof entry["url"] === "string") {
httpEntries.push({ key, url: entry["url"] })
}
}

if (httpEntries.length > 0) {
const configPath = await resolveConfigPath(cwd)
if (await Filesystem.exists(configPath)) {
const configText = await Filesystem.readText(configPath)
const tree = parseTree(configText)
const mcpNode = tree ? findNodeAtLocation(tree, ["mcp"]) : undefined

if (tree && mcpNode && mcpNode.type === "object" && mcpNode.children) {
const remoteMcpEntries: Array<{ name: string; url: string }> = []
for (const child of mcpNode.children) {
if (child.type !== "property" || !child.children) continue
const nameNode = child.children[0]
const valueNode = child.children[1]
if (!nameNode || !valueNode || valueNode.type !== "object" || !valueNode.children) continue
const typeNode = findNodeAtLocation(valueNode, ["type"])
const urlNode = findNodeAtLocation(valueNode, ["url"])
if (typeNode?.value === "remote" && typeof urlNode?.value === "string") {
remoteMcpEntries.push({ name: nameNode.value as string, url: urlNode.value })
}
}

for (const remote of remoteMcpEntries) {
const match = httpEntries.find((e) => e.key === remote.name)
if (match && match.url !== remote.url) {
const entryNode = findNodeAtLocation(tree, ["mcp", remote.name])
if (!entryNode || entryNode.type !== "object") continue
// getNodeValue preserves headers/oauth/timeout; a children walk reading
// `prop.children[1].value` would strip them (object/array nodes).
const entry = getNodeValue(entryNode) as Record<string, unknown>
entry["url"] = match.url
entry["updatedAt"] = new Date().toISOString()
await addMcpToConfig(
remote.name,
entry as Parameters<typeof addMcpToConfig>[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
}

Loading
Loading