-
Notifications
You must be signed in to change notification settings - Fork 134
feat: IDE-aware datamate transport, enabled-state persistence, and /mcps command #893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 12 commits
0b6cc26
d8c851f
520143f
599807a
e790e91
1dbb097
794c221
1290a63
f8414c9
0fbdc9b
c2a5541
2a1c5ac
18da6b4
e7aa5ef
0d78a0b
b5d5df8
4a4a175
cba1f2c
c8197cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,329 @@ | ||
| import { readFile } from "fs/promises" | ||
| import path from "path" | ||
| 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" | ||
|
|
||
| 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 | ||
|
|
||
| /** 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[] } | ||
|
|
||
| /** | ||
| * 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, | ||
| }) | ||
| 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 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] } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defense in depth — narrow the glob to explicit IDE config paths.
This widens both the attack surface (an Fix: reuse the curated
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 4a4a175. Widened the ignore list for the |
||
| } | ||
|
|
||
| // 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) { | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same Today it's safe by accident — only the scalar fields Use
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 4a4a175. This walker now reads the existing datamate entry via |
||
| if (k === "enabled") existingEnabled = prop.children[1]!.value as boolean | ||
| } | ||
| } | ||
|
|
||
| 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"; | ||
| // 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 ?? {} | ||
| const cmd = | ||
| typeof datamateVscode["command"] === "string" | ||
| ? (datamateVscode["command"] as string) | ||
| : DATAMATE_KEY | ||
| newEntry = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This constructs Fix: read the existing entry (via the corrected 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,
}
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Concern (latent): the stdio branch is gated on
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: changed |
||
| 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("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" || !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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same root cause as the This walks an existing remote MCP entry to rehydrate it before rewriting Fix: use const entry = getNodeValue(entryNode) as Record<string, unknown>
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 4a4a175. Replaced the children walk with |
||
| } | ||
| } | ||
| 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 | ||
| } | ||
|
|
||
| /** | ||
| * 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<Config.Mcp | undefined> { | ||
| 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<string, unknown> = {} | ||
| 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 | ||
| } | ||
There was a problem hiding this comment.
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 (theGlob.Optionswrapper never forwardsignoreto prune). This runs on everyhandleAdd, serve startup, and every/altimate/mcp/reload-datamatePOST — a real repeated latency hit on big monorepos. The siblingmcp/discover.tsalready uses a curatedSOURCESlist of exact relative paths; reuse that, or addignoreforwarding totoGlobOptions.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: added
ignorefield toGlob.Optionsand wired it through totoGlobOptionsinutil/glob.ts. UpdatedfindAllMcpJsonFilesto pass the exclude patterns directly to the glob walk — no more post-filter.