Skip to content

Commit cc91c5c

Browse files
Harden MCP config diagnostics in CLI parser
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent d4f17e2 commit cc91c5c

2 files changed

Lines changed: 74 additions & 12 deletions

File tree

src/cli/mcp-config.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,30 @@ describe("parseMCPServersConfig", () => {
5353
);
5454
});
5555

56+
it("sanitizes and truncates oversized JSON parse diagnostics", () => {
57+
const parseSpy = jest.spyOn(JSON, "parse").mockImplementation(() => {
58+
throw new Error(`parse\u0000\n${"x".repeat(10_000)}`);
59+
});
60+
61+
try {
62+
expect(() => parseMCPServersConfig('{"servers":[{"command":"npx"}]}')).toThrow(
63+
"[truncated"
64+
);
65+
expect(() => parseMCPServersConfig('{"servers":[{"command":"npx"}]}')).toThrow(
66+
/Invalid MCP config JSON:/
67+
);
68+
try {
69+
parseMCPServersConfig('{"servers":[{"command":"npx"}]}');
70+
} catch (error) {
71+
const message = String(error instanceof Error ? error.message : error);
72+
expect(message).not.toContain("\u0000");
73+
expect(message).not.toContain("\n");
74+
}
75+
} finally {
76+
parseSpy.mockRestore();
77+
}
78+
});
79+
5680
it("throws clear message when config input is not a string", () => {
5781
expect(() => parseMCPServersConfig(42 as unknown as string)).toThrow(
5882
"Invalid MCP config JSON: config must be a string."
@@ -253,7 +277,7 @@ describe("parseMCPServersConfig", () => {
253277
expect(() =>
254278
parseMCPServersConfig('[{"connectionType":"sse\\u0007","command":"npx"}]')
255279
).toThrow(
256-
'MCP server entry at index 0 has unsupported connectionType "sse\u0007". Supported values are "stdio" and "sse".'
280+
'MCP server entry at index 0 has unsupported connectionType "sse". Supported values are "stdio" and "sse".'
257281
);
258282

259283
expect(() =>
@@ -611,7 +635,7 @@ describe("parseMCPServersConfig", () => {
611635
'[{"connectionType":"sse","sseUrl":"https://example.com/sse\\u0007"}]'
612636
)
613637
).toThrow(
614-
'MCP server entry at index 0 has invalid "sseUrl" value "https://example.com/sse\u0007".'
638+
'MCP server entry at index 0 has invalid "sseUrl" value "https://example.com/sse".'
615639
);
616640

617641
expect(() =>
@@ -749,6 +773,33 @@ describe("loadMCPServersFromFile", () => {
749773
);
750774
});
751775

776+
it("sanitizes and truncates oversized config read diagnostics", async () => {
777+
const statSpy = jest.spyOn(fs.promises, "stat").mockResolvedValue({
778+
isFile: () => true,
779+
size: 1,
780+
} as unknown as fs.Stats);
781+
const readFileSpy = jest
782+
.spyOn(fs.promises, "readFile")
783+
.mockRejectedValue(new Error(`read\u0000\n${"x".repeat(10_000)}`));
784+
785+
try {
786+
await loadMCPServersFromFile("/tmp/mcp-config-test.json")
787+
.then(() => {
788+
throw new Error("expected loadMCPServersFromFile to reject");
789+
})
790+
.catch((error) => {
791+
const message = String(error instanceof Error ? error.message : error);
792+
expect(message).toContain("[truncated");
793+
expect(message).not.toContain("\u0000");
794+
expect(message).not.toContain("\n");
795+
expect(message.length).toBeLessThan(700);
796+
});
797+
} finally {
798+
statSpy.mockRestore();
799+
readFileSpy.mockRestore();
800+
}
801+
});
802+
752803
it("throws readable error when config path is not a regular file", async () => {
753804
const tempDir = await fs.promises.mkdtemp(
754805
path.join(os.tmpdir(), "hyperagent-mcp-config-")

src/cli/mcp-config.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,20 @@ function hasAnyControlChars(value: string): boolean {
3737
}
3838

3939
function formatMCPConfigDiagnostic(value: unknown): string {
40-
const normalized =
41-
typeof value === "string" ? value : formatUnknownError(value);
42-
if (normalized.length <= MAX_MCP_CONFIG_DIAGNOSTIC_CHARS) {
43-
return normalized;
44-
}
45-
const omitted = normalized.length - MAX_MCP_CONFIG_DIAGNOSTIC_CHARS;
46-
return `${normalized.slice(0, MAX_MCP_CONFIG_DIAGNOSTIC_CHARS)}... [truncated ${omitted} chars]`;
40+
const raw = typeof value === "string" ? value : formatUnknownError(value);
41+
const normalized = Array.from(raw, (char) => {
42+
const code = char.charCodeAt(0);
43+
return (code >= 0 && code < 32) || code === 127 ? " " : char;
44+
})
45+
.join("")
46+
.replace(/\s+/g, " ")
47+
.trim();
48+
const fallback = normalized.length > 0 ? normalized : "unknown error";
49+
if (fallback.length <= MAX_MCP_CONFIG_DIAGNOSTIC_CHARS) {
50+
return fallback;
51+
}
52+
const omitted = fallback.length - MAX_MCP_CONFIG_DIAGNOSTIC_CHARS;
53+
return `${fallback.slice(0, MAX_MCP_CONFIG_DIAGNOSTIC_CHARS)}... [truncated ${omitted} chars]`;
4754
}
4855

4956
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -389,7 +396,7 @@ export function parseMCPServersConfig(rawConfig: string): MCPServerConfig[] {
389396
parsed = JSON.parse(normalizedConfig);
390397
} catch (error) {
391398
throw new Error(
392-
`Invalid MCP config JSON: ${formatUnknownError(error)}`
399+
`Invalid MCP config JSON: ${formatMCPConfigDiagnostic(error)}`
393400
);
394401
}
395402

@@ -618,7 +625,9 @@ export async function loadMCPServersFromFile(
618625
fileContent = await fs.promises.readFile(normalizedFilePath, "utf-8");
619626
} catch (error) {
620627
throw new Error(
621-
`Failed to read MCP config file "${normalizedFilePath}": ${formatUnknownError(error)}`
628+
`Failed to read MCP config file "${normalizedFilePath}": ${formatMCPConfigDiagnostic(
629+
error
630+
)}`
622631
);
623632
}
624633

@@ -632,7 +641,9 @@ export async function loadMCPServersFromFile(
632641
return parseMCPServersConfig(fileContent);
633642
} catch (error) {
634643
throw new Error(
635-
`Invalid MCP config file "${normalizedFilePath}": ${formatUnknownError(error)}`
644+
`Invalid MCP config file "${normalizedFilePath}": ${formatMCPConfigDiagnostic(
645+
error
646+
)}`
636647
);
637648
}
638649
}

0 commit comments

Comments
 (0)