Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .changeset/wrap-a-config-without-rewriting-it.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@memnox/core': patch
'@memnox/proxy': patch
'@memnox/interceptors': patch
'memnox': patch
---

`mcp wrap` governs a config with comments in it, and rewrites only the entries it wraps. It read configs with `JSON.parse`, so a VS Code or Cline file carrying a single `//` comment was skipped and its servers were never put behind the proxy. And it wrote the whole file back at two spaces, so a four-space file came back with every line changed. Configs are now read as JSONC and edited by span, the way `agents onboard` already edits them: comments, indentation and every untouched line stay exactly as they were. `mcp unwrap` puts back the entry's own original text where the backup still holds it, so a wrap and an unwrap return the bytes they started from, an inline `args` array included.
2 changes: 1 addition & 1 deletion packages/cli/src/agents/managed-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { MANAGED_SERVER, type ManagedServer, type RewriteResult } from './manage
*/

/** The indentation this file already uses, so an insert matches what surrounds it. */
function formattingOf(raw: string): FormattingOptions {
export function formattingOf(raw: string): FormattingOptions {
const indented = /^([ \t]+)\S/m.exec(raw);
const found = indented?.[1] ?? ' ';
const insertSpaces = !found.startsWith('\t');
Expand Down
83 changes: 83 additions & 0 deletions packages/cli/src/mcp/json-span.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* A JSON config's server entries rewritten by span, so the rest of the file — its comments,
* its indentation, every line nobody asked to change — comes back exactly as it was.
*/
import {
applyEdits,
findNodeAtLocation,
getNodeValue,
modify,
parse,
parseTree,
type ParseError,
} from 'jsonc-parser';
import { canonicalJson } from '@memnox/core';

import { formattingOf } from '../agents/managed-json';

/** One entry's new value, by server name. */
export interface EntryChange {
name: string;
after: unknown;
}

/**
* The document, tolerant of the comments and trailing commas VS Code and Cline write, or
* null where it will not parse, which is a refusal to touch it.
*/
export function parseJsonc(raw: string): Record<string, unknown> | null {
const errors: ParseError[] = [];
const parsed: unknown = parse(raw, errors, {
allowTrailingComma: true,
disallowComments: false,
});
if (errors.length > 0 || parsed === null || typeof parsed !== 'object') return null;
// Narrowed to an object on the line above.
return parsed as Record<string, unknown>;
}

/**
* `raw` with each entry under `key` set to its new value. `previous` is the file as it was
* before the last rewrite: where it still holds an entry equal to the new value, that
* entry's own text is put back, so a wrap and an unwrap return the bytes they started from.
*/
export function withEntries(
raw: string,
key: string,
changes: readonly EntryChange[],
previous: string | null,
): string {
let next = raw;
for (const change of changes) {
const kept = previous === null ? null : entryText(previous, key, change);
const restored = kept === null ? null : spliced(next, key, change.name, kept);
next = restored ?? edited(next, key, change);
}
return next;
}

function edited(raw: string, key: string, change: EntryChange): string {
const edits = modify(raw, [key, change.name], change.after, {
formattingOptions: formattingOf(raw),
});
return applyEdits(raw, edits);
}

/** The entry's text as `previous` wrote it, when its value is the one being set. */
function entryText(previous: string, key: string, change: EntryChange): string | null {
const tree = parseTree(previous);
const node =
tree === undefined ? undefined : findNodeAtLocation(tree, [key, change.name]);
if (node === undefined) return null;
// Compared canonically, since key order is the writer's and not part of the value.
if (canonicalJson(getNodeValue(node)) !== canonicalJson(change.after)) return null;
return previous.slice(node.offset, node.offset + node.length);
}

/** Null where the entry is not in `raw`, so the caller edits instead of guessing. */
function spliced(raw: string, key: string, name: string, text: string): string | null {
const tree = parseTree(raw);
const node = tree === undefined ? undefined : findNodeAtLocation(tree, [key, name]);
if (node === undefined) return null;
return `${raw.slice(0, node.offset)}${text}${raw.slice(node.offset + node.length)}`;
}
26 changes: 13 additions & 13 deletions packages/cli/src/mcp/server-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type ServerLaunch,
} from '@memnox/core';
import { backupPathFor } from '../memnox-paths';
import { parseJsonc, withEntries } from './json-span';
import { onPath } from '../on-path';

export interface ConfigFile {
Expand Down Expand Up @@ -41,8 +42,6 @@ interface Relaunch {
/** Answers whether a binary can be found, injected so a test needs no PATH. */
export type BinaryResolver = (binary: string) => boolean;

const JSON_INDENT = 2;

/**
* Whether the proxy can be started, checked before a config is touched, because an agent
* pointed at a missing binary starts nothing at all, which is worse than ungoverned.
Expand Down Expand Up @@ -74,14 +73,10 @@ async function readConfig(path: string): Promise<ConfigFile | null> {
if (Object.keys(servers).length === 0 && urlOnly.length === 0) return null;
return { path, raw, format, servers, urlOnly };
}
let config: Record<string, unknown>;
try {
// Cast is checked by serversKeyOf below, which finds no servers in anything else.
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
// Never rewrite a file we could not parse: we would lose what it held.
return null;
}
// Read as JSONC: VS Code and Cline configs carry comments, and one `//` used to mean the
// file was skipped and its servers never wrapped. Never rewrite a file that will not parse.
const config = parseJsonc(raw);
if (config === null) return null;
const key = serversKeyOf(config);
if (key === null) return null;
// Cast is safe: serversKeyOf only names a key holding the servers table.
Expand All @@ -102,7 +97,7 @@ async function readIfPresent(path: string): Promise<string | null> {

/**
* Writes the new launch lines, backing the file up first because the failure that matters
* is an editor that will not start. JSON is written whole; TOML and YAML keep every other byte.
* is an editor that will not start. Every format keeps every byte it was not asked to change.
*/
export async function writeRelaunches(
home: string,
Expand All @@ -113,6 +108,8 @@ export async function writeRelaunches(
for (const each of relaunches) changed[each.name] = each.after;

const backup = backupPathFor(home, file.path);
// Read before it is replaced: it holds the entries as they were written before the wrap.
const previous = await readIfPresent(backup);
await mkdir(dirname(backup), { recursive: true, mode: 0o700 });
await copyFile(file.path, backup);

Expand All @@ -124,6 +121,9 @@ export async function writeRelaunches(
);
return;
}
const next = { ...file.config, [file.key]: { ...file.servers, ...changed } };
await writeFile(file.path, `${JSON.stringify(next, null, JSON_INDENT)}\n`, 'utf8');
await writeFile(
file.path,
withEntries(file.raw, file.key, relaunches, previous),
'utf8',
);
}
100 changes: 100 additions & 0 deletions packages/cli/test/mcp-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,103 @@ describe('before it touches a single config', () => {
expect(out.text).toContain('Nothing was changed.');
});
});

/* `mcp wrap` read configs with JSON.parse, so a VS Code or Cline file with a single `//`
comment was skipped and its servers never governed; and it wrote with
JSON.stringify(_, null, 2), so a four-space file came back two-space, every line changed. */
describe('a config written by hand', () => {
// Four spaces, comments, a trailing comma and an inline array: what an editor leaves.
const HANDWRITTEN = `{
// the servers this project trusts
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "gh-mcp"], // pinned on purpose
},
"docs": { "command": "docs-mcp" }
},
/* not ours */
"theme": "dark"
}
`;

// Strict JSON, which the old reader did parse — and then rewrote at two spaces.
const PLAIN = `{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "gh-mcp"]
},
"docs": { "command": "docs-mcp" }
},
"theme": "dark"
}
`;

async function handwritten(content = HANDWRITTEN): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'memnox-wrap-'));
await writeFile(join(home, '.claude.json'), content);
return home;
}

const text = (home: string): Promise<string> =>
readFile(join(home, '.claude.json'), 'utf8');

it('wraps a file with comments, and the comments survive', async () => {
const home = await handwritten();

const out = await run(['mcp', 'wrap'], home);
const after = await text(home);

expect(out.text).toContain('github npx → proxy');
expect(after).toContain('memnox-mcp-proxy');
for (const comment of ['// the servers this project trusts', '/* not ours */'])
expect(after).toContain(comment);
});

it.each([
['with comments', HANDWRITTEN],
['in plain JSON', PLAIN],
])(
'keeps four spaces four %s, changing only the wrapped entry',
async (_how, content) => {
const home = await handwritten(content);

await run(['mcp', 'wrap'], home);
const before = content.split('\n');
const after = (await text(home)).split('\n');

expect(after).not.toEqual(before);
// Everything up to the wrapped entry, and everything after it, line for line.
const at = before.findIndex((line) => line.includes('"github"'));
expect(after.slice(0, at + 1)).toEqual(before.slice(0, at + 1));
const docs = before.findIndex((line) => line.includes('"docs"'));
expect(after.slice(after.findIndex((line) => line.includes('"docs"')))).toEqual(
before.slice(docs),
);
expect(after.filter((line) => /^ {2}"/.test(line))).toEqual([]);
},
);

it.each([
['with comments', HANDWRITTEN],
['in plain JSON', PLAIN],
])('unwraps %s to the very bytes it started from', async (_how, content) => {
const home = await handwritten(content);

await run(['mcp', 'wrap'], home);
await run(['mcp', 'unwrap'], home);

expect(await text(home)).toBe(content);
});

it('leaves a file that will not parse even as JSONC exactly alone', async () => {
const home = await mkdtemp(join(tmpdir(), 'memnox-wrap-'));
const broken = '{ "mcpServers": { "github": { "command": "npx" ';
await writeFile(join(home, '.claude.json'), broken);

await run(['mcp', 'wrap'], home);

expect(await text(home)).toBe(broken);
});
});
Loading