1- import { existsSync , mkdirSync } from "node:fs" ;
1+ import { existsSync , mkdirSync , readFileSync } from "node:fs" ;
22import { dirname } from "node:path" ;
33import * as p from "@clack/prompts" ;
4+ import { applyEdits , modify , parse } from "jsonc-parser" ;
5+ import type { ParseError } from "jsonc-parser" ;
46import type { ConnectAdapter , ConnectOptions , ConnectResult } from "./types.js" ;
57import {
68 AGENTMEMORY_MCP_BLOCK ,
79 backupFile ,
810 logAlreadyWired ,
911 logBackup ,
1012 logInstalled ,
11- readJsonSafe ,
13+ writeTextAtomic ,
1214 writeJsonAtomic ,
1315} from "./util.js" ;
1416
@@ -26,13 +28,69 @@ export type JsonMcpAdapterConfig = {
2628 // Wrapper key under which servers live. Default "mcpServers".
2729 // Zed uses "context_servers"; otherwise same shape.
2830 wrapperKey ?: string ;
31+ // Some hosts, including Zed, store settings as JSONC with comments and
32+ // trailing commas. Preserve those files with textual JSONC edits.
33+ jsonc ?: boolean ;
2934 // Extra fields merged into the agentmemory entry. Droid requires
3035 // type: "stdio"; other hosts ignore unknown fields.
3136 extraEntryFields ?: Record < string , unknown > ;
3237} ;
3338
3439type McpEntry = typeof AGENTMEMORY_MCP_BLOCK ;
3540type McpConfig = Record < string , unknown > ;
41+ type ReadConfigResult =
42+ | { kind : "missing" ; config : McpConfig }
43+ | { kind : "parsed" ; config : McpConfig ; raw : string }
44+ | { kind : "invalid" ; reason : string } ;
45+
46+ const formattingOptions = {
47+ insertSpaces : true ,
48+ tabSize : 2 ,
49+ eol : "\n" ,
50+ insertFinalNewline : true ,
51+ } ;
52+
53+ function isRecord ( value : unknown ) : value is Record < string , unknown > {
54+ return Boolean ( value ) && typeof value === "object" && ! Array . isArray ( value ) ;
55+ }
56+
57+ function readMcpConfig ( path : string , jsonc : boolean ) : ReadConfigResult {
58+ if ( ! existsSync ( path ) ) return { kind : "missing" , config : { } } ;
59+
60+ const raw = readFileSync ( path , "utf-8" ) ;
61+ try {
62+ const parsed = jsonc ? parseJsonc ( raw ) : JSON . parse ( raw ) ;
63+ if ( parsed === undefined && raw . trim ( ) === "" ) {
64+ return { kind : "parsed" , config : { } , raw } ;
65+ }
66+ if ( ! isRecord ( parsed ) ) {
67+ return { kind : "invalid" , reason : "top-level config is not an object" } ;
68+ }
69+ return { kind : "parsed" , config : parsed , raw } ;
70+ } catch ( error ) {
71+ const message = error instanceof Error ? error . message : String ( error ) ;
72+ return { kind : "invalid" , reason : message } ;
73+ }
74+ }
75+
76+ function parseJsonc ( raw : string ) : unknown {
77+ const errors : ParseError [ ] = [ ] ;
78+ const parsed = parse ( raw , errors , {
79+ allowTrailingComma : true ,
80+ allowEmptyContent : true ,
81+ } ) ;
82+ if ( errors . length > 0 ) {
83+ const first = errors [ 0 ] ;
84+ throw new Error (
85+ `JSONC parse error ${ first . error } at offset ${ first . offset } ` ,
86+ ) ;
87+ }
88+ return parsed ;
89+ }
90+
91+ function serverEntries ( value : unknown ) : Record < string , McpEntry > {
92+ return isRecord ( value ) ? { ...( value as Record < string , McpEntry > ) } : { } ;
93+ }
3694
3795function entryMatches ( entry : unknown ) : boolean {
3896 if ( ! entry || typeof entry !== "object" ) return false ;
@@ -60,11 +118,17 @@ export function createJsonMcpAdapter(
60118 } ,
61119
62120 async install ( opts : ConnectOptions ) : Promise < ConnectResult > {
63- const existing = readJsonSafe < McpConfig > ( config . configPath ) ;
64- const next : McpConfig = existing ? { ...existing } : { } ;
65- const servers : Record < string , McpEntry > = {
66- ...( ( next [ wrapperKey ] as Record < string , McpEntry > ) ?? { } ) ,
67- } ;
121+ const jsonc = config . jsonc ?? false ;
122+ const existing = readMcpConfig ( config . configPath , jsonc ) ;
123+ if ( existing . kind === "invalid" ) {
124+ p . log . error (
125+ `${ config . displayName } : ${ config . configPath } could not be parsed (${ existing . reason } ); leaving it unchanged.` ,
126+ ) ;
127+ return { kind : "skipped" , reason : "invalid-config" } ;
128+ }
129+
130+ const next : McpConfig = { ...existing . config } ;
131+ const servers = serverEntries ( next [ wrapperKey ] ) ;
68132
69133 const alreadyHas = entryMatches ( servers [ "agentmemory" ] ) ;
70134 if ( alreadyHas && ! opts . force ) {
@@ -92,12 +156,21 @@ export function createJsonMcpAdapter(
92156 ...( config . extraEntryFields ?? { } ) ,
93157 } ;
94158 next [ wrapperKey ] = servers ;
95- writeJsonAtomic ( config . configPath , next ) ;
159+ if ( jsonc && existing . kind === "parsed" ) {
160+ const edits = modify (
161+ existing . raw ,
162+ [ wrapperKey , "agentmemory" ] ,
163+ servers [ "agentmemory" ] ,
164+ { formattingOptions } ,
165+ ) ;
166+ writeTextAtomic ( config . configPath , applyEdits ( existing . raw , edits ) ) ;
167+ } else {
168+ writeJsonAtomic ( config . configPath , next ) ;
169+ }
96170
97- const verify = readJsonSafe < McpConfig > ( config . configPath ) ;
98- const verifyServers = verify ?. [ wrapperKey ] as
99- | Record < string , McpEntry >
100- | undefined ;
171+ const verify = readMcpConfig ( config . configPath , jsonc ) ;
172+ const verifyServers =
173+ verify . kind === "invalid" ? undefined : serverEntries ( verify . config [ wrapperKey ] ) ;
101174 if ( ! entryMatches ( verifyServers ?. [ "agentmemory" ] ) ) {
102175 p . log . error (
103176 `Verification failed: ${ config . configPath } did not contain ${ wrapperKey } .agentmemory after write.` ,
0 commit comments