11import { readFile } from "fs/promises"
22import path from "path"
3- import { existsSync } from "fs"
43import { parseTree , findNodeAtLocation } from "jsonc-parser"
54import { resolveConfigPath , addMcpToConfig } from "../mcp/config"
65import { Filesystem } from "../util/filesystem"
6+ import { Glob } from "../util/glob"
77import { Log } from "../util/log"
88import type { Config } from "../config/config"
99
@@ -13,25 +13,61 @@ const log = Log.create({ service: "datamate-transport" })
1313export const DATAMATE_KEY = "datamate"
1414// altimate_change end
1515
16- /** IDE config sources where the extension may write a "datamate" MCP entry. */
17- const IDE_MCP_SOURCES = [
18- // VS Code ( 1.99+: "servers", older: "mcpServers")
19- { file : ".vscode/mcp.json" , keys : [ "servers" , "mcpServers" ] } ,
20- // Cursor
21- { file : ".cursor/mcp.json ", keys : [ "mcpServers" , "servers" ] } ,
22- // GitHub Copilot
23- { file : ".github/copilot/ mcp.json" , keys : [ "mcpServers" , "servers" ] } ,
24- ]
16+ /**
17+ * Top-level keys that MCP config files use to map server name → entry.
18+ * VS Code 1.99+ uses "servers"; older VS Code and Cursor use "mcpServers".
19+ * We try both so the scan works regardless of which IDE wrote the file.
20+ */
21+ const MCP_SERVERS_KEYS = [ "servers ", "mcpServers" ] as const
22+
23+ /** Glob patterns to exclude from mcp.json scans (large, irrelevant trees). */
24+ const MCP_SCAN_EXCLUDE = [ "/node_modules/" , "/.git/" , "/dist/" , "/build/" , "/.pnpm/" ]
2525
2626export type DatamateTransport =
2727 | { type : "remote" ; url : string }
2828 | { type : "local" ; command : string [ ] }
2929
3030/**
31- * Scan across all IDE MCP config files in projectRootDir and return the
32- * transport type for the "datamate" server entry.
31+ * Parse a single mcp.json file and return the servers map, trying each of the
32+ * known top-level key names in order.
33+ */
34+ function extractServersMap (
35+ parsed : Record < string , unknown > ,
36+ ) : Record < string , Record < string , unknown > > {
37+ for ( const key of MCP_SERVERS_KEYS ) {
38+ const candidate = parsed [ key ]
39+ if ( candidate && typeof candidate === "object" && ! Array . isArray ( candidate ) ) {
40+ return candidate as Record < string , Record < string , unknown > >
41+ }
42+ }
43+ return { }
44+ }
45+
46+ /**
47+ * Find all mcp.json files under projectRootDir (excluding noise directories)
48+ * and return the paths sorted for deterministic ordering.
49+ */
50+ async function findAllMcpJsonFiles ( projectRootDir : string ) : Promise < string [ ] > {
51+ try {
52+ const paths = await Glob . scan ( "**/mcp.json" , {
53+ cwd : projectRootDir ,
54+ absolute : true ,
55+ dot : true ,
56+ } )
57+ return paths
58+ . filter ( ( p ) => ! MCP_SCAN_EXCLUDE . some ( ( ex ) => p . includes ( ex ) ) )
59+ . sort ( )
60+ } catch {
61+ log . warn ( "findAllMcpJsonFiles: glob scan failed" , { cwd : projectRootDir } )
62+ return [ ]
63+ }
64+ }
65+
66+ /**
67+ * Scan all mcp.json files under projectRootDir and return the transport type
68+ * for the first "datamate" server entry found.
3369 *
34- * Returns null if no IDE config has a "datamate" entry — the caller should
70+ * Returns null if no mcp.json contains a "datamate" entry — the caller should
3571 * fall back to the cloud config.
3672 *
3773 * Reuses the exact command from the IDE config so altimate-code spawns the
@@ -40,37 +76,27 @@ export type DatamateTransport =
4076export async function readDatamateTransportFromIde (
4177 projectRootDir : string ,
4278) : Promise < DatamateTransport | null > {
43- for ( const source of IDE_MCP_SOURCES ) {
44- const mcpJsonPath = path . join ( projectRootDir , source . file )
45- if ( ! existsSync ( mcpJsonPath ) ) continue
79+ const mcpJsonPaths = await findAllMcpJsonFiles ( projectRootDir )
4680
81+ for ( const mcpJsonPath of mcpJsonPaths ) {
82+ const relPath = path . relative ( projectRootDir , mcpJsonPath )
4783 try {
4884 const text = await readFile ( mcpJsonPath , "utf-8" )
4985 const parsed = JSON . parse ( text ) as Record < string , unknown >
50-
51- let serversMap : Record < string , Record < string , unknown > > = { }
52- for ( const key of source . keys ) {
53- const candidate = parsed [ key ]
54- if ( candidate && typeof candidate === "object" && ! Array . isArray ( candidate ) ) {
55- serversMap = candidate as Record < string , Record < string , unknown > >
56- break
57- }
58- }
59-
86+ const serversMap = extractServersMap ( parsed )
6087 const entry = serversMap [ DATAMATE_KEY ]
6188 if ( ! entry ) continue
6289
6390 log . info ( "readDatamateTransportFromIde: found entry" , {
64- source : source . file ,
91+ source : relPath ,
6592 type : entry [ "type" ] ?? "(no type)" ,
6693 } )
6794
6895 if ( typeof entry [ "url" ] === "string" ) {
6996 return { type : "remote" , url : entry [ "url" ] }
7097 }
7198
72- // stdio entry — extract command + args so we reuse the same process
73- // the extension already manages, rather than spawning a second one.
99+ // stdio entry — reuse the exact command + args the extension registered
74100 const cmd = typeof entry [ "command" ] === "string" ? entry [ "command" ] : undefined
75101 const args = Array . isArray ( entry [ "args" ] ) ? ( entry [ "args" ] as string [ ] ) : [ ]
76102 if ( cmd ) {
@@ -80,8 +106,7 @@ export async function readDatamateTransportFromIde(
80106 // Entry exists but has no usable command — treat as local marker
81107 return { type : "local" , command : [ DATAMATE_KEY , "start-stdio" ] }
82108 } catch {
83- log . warn ( "readDatamateTransportFromIde: failed to parse" , { source : source . file } )
84- // File missing or unparseable — try next source
109+ log . warn ( "readDatamateTransportFromIde: failed to parse" , { source : relPath } )
85110 }
86111 }
87112
@@ -90,10 +115,11 @@ export async function readDatamateTransportFromIde(
90115}
91116
92117/**
93- * Sync the "datamate" entry (and other remote MCP entries) from IDE MCP config
94- * files to altimate-code.json. Uses `updatedAt` as the change signal for the
95- * datamate entry (covers both stdio and HTTP transport), and URL comparison for
96- * all other remote entries.
118+ * Sync the "datamate" entry (and other remote MCP entries) from the first
119+ * mcp.json that contains a "datamate" key to altimate-code.json.
120+ *
121+ * Uses `updatedAt` as the change signal for the datamate entry (covers both
122+ * stdio and HTTP transport), and URL comparison for all other remote entries.
97123 *
98124 * Fire-and-forget friendly: errors are logged but never thrown.
99125 * Returns the list of MCP server names whose config was updated on disk.
@@ -103,39 +129,34 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
103129 try {
104130 log . info ( "syncDatamateUrlFromVscodeMcp: start" , { cwd } )
105131
106- // Try each IDE source in priority order; use the first one that exists.
132+ // Find the first mcp.json that contains a "datamate" entry.
133+ const mcpJsonPaths = await findAllMcpJsonFiles ( cwd )
107134 let mcpJsonPath : string | undefined
108- let ideSource : ( typeof IDE_MCP_SOURCES ) [ number ] | undefined
109- for ( const source of IDE_MCP_SOURCES ) {
110- const candidate = path . join ( cwd , source . file )
111- if ( existsSync ( candidate ) ) {
112- mcpJsonPath = candidate
113- ideSource = source
114- break
115- }
116- }
135+ let serversMap : Record < string , Record < string , unknown > > = { }
117136
118- if ( ! mcpJsonPath || ! ideSource ) {
119- log . info ( "syncDatamateUrlFromVscodeMcp: no IDE MCP config found, skipping sync" )
120- return updated
137+ for ( const candidate of mcpJsonPaths ) {
138+ try {
139+ const text = await readFile ( candidate , "utf-8" )
140+ const parsed = JSON . parse ( text ) as Record < string , unknown >
141+ const map = extractServersMap ( parsed )
142+ if ( map [ DATAMATE_KEY ] ) {
143+ mcpJsonPath = candidate
144+ serversMap = map
145+ break
146+ }
147+ } catch {
148+ // Unparseable — skip
149+ }
121150 }
122151
123- const text = await readFile ( mcpJsonPath , "utf-8" )
124- let parsed : Record < string , unknown >
125- try {
126- parsed = JSON . parse ( text ) as Record < string , unknown >
127- } catch {
152+ if ( ! mcpJsonPath ) {
153+ log . info ( "syncDatamateUrlFromVscodeMcp: no mcp.json with datamate entry found, skipping sync" )
128154 return updated
129155 }
130156
131- let serversMap : Record < string , Record < string , unknown > > = { }
132- for ( const key of ideSource . keys ) {
133- const candidate = parsed [ key ]
134- if ( candidate && typeof candidate === "object" && ! Array . isArray ( candidate ) ) {
135- serversMap = candidate as Record < string , Record < string , unknown > >
136- break
137- }
138- }
157+ log . info ( "syncDatamateUrlFromVscodeMcp: using config" , {
158+ source : path . relative ( cwd , mcpJsonPath ) ,
159+ } )
139160
140161 // ── "datamate" entry: sync by updatedAt (works for stdio + HTTP) ────────
141162 const datamateVscode = serversMap [ DATAMATE_KEY ]
@@ -154,7 +175,6 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
154175 : undefined
155176
156177 if ( existingNode ) {
157- // Extract current updatedAt + enabled from altimate-code.json
158178 let existingUpdatedAt : string | undefined
159179 let existingEnabled : boolean | undefined
160180 if ( existingNode . type === "object" && existingNode . children ) {
@@ -167,7 +187,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
167187 }
168188
169189 if ( vscodeUpdatedAt === existingUpdatedAt ) {
170- log . info ( "syncDatamateUrlFromVscodeMcp: datamate entry already up to date, skipping " , {
190+ log . info ( "syncDatamateUrlFromVscodeMcp: datamate entry already up to date" , {
171191 updatedAt : vscodeUpdatedAt ,
172192 } )
173193 } else {
@@ -178,13 +198,13 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
178198 if ( datamateVscode [ "type" ] === "stdio" ) {
179199 const env = datamateVscode [ "env" ] as Record < string , string > | undefined
180200 const { ALTIMATE_EXTENSION_RPC : _rpc , ...restEnv } = env ?? { }
181- const cmd = typeof datamateVscode [ "command" ] === "string" ? datamateVscode [ "command" ] as string : DATAMATE_KEY
201+ const cmd =
202+ typeof datamateVscode [ "command" ] === "string"
203+ ? ( datamateVscode [ "command" ] as string )
204+ : DATAMATE_KEY
182205 newEntry = {
183206 type : "local" ,
184- command : [
185- cmd ,
186- ...( ( datamateVscode [ "args" ] as string [ ] ) ?? [ ] ) ,
187- ] ,
207+ command : [ cmd , ...( ( datamateVscode [ "args" ] as string [ ] ) ?? [ ] ) ] ,
188208 ...( Object . keys ( restEnv ) . length > 0 ? { environment : restEnv } : { } ) ,
189209 updatedAt : vscodeUpdatedAt ,
190210 }
@@ -216,7 +236,7 @@ export async function syncDatamateUrlFromVscodeMcp(cwd: string): Promise<string[
216236 // ── All other remote MCP entries: existing URL-comparison logic ──────────
217237 const httpEntries : Array < { key : string ; url : string } > = [ ]
218238 for ( const [ key , entry ] of Object . entries ( serversMap ) ) {
219- if ( key === DATAMATE_KEY ) continue // already handled above
239+ if ( key === DATAMATE_KEY ) continue
220240 if ( typeof entry [ "url" ] === "string" ) {
221241 httpEntries . push ( { key, url : entry [ "url" ] } )
222242 }
0 commit comments