Skip to content

Commit 8eebf08

Browse files
committed
fix(mcp): cover the remaining prototype-name mutation and doctor paths
The own-property gate only covered unscoped removal and getMcpConfigByName. The scoped paths still used bare lookups: - removeMcpConfig's project/user/local existence checks accepted inherited members, so 'mcp remove constructor -s user' reported a successful removal while leaving the configuration untouched; - addMcpConfig's already-exists checks rejected valid 'mcp add constructor'; - doctor's servers[name] and activeServers[name] fabricated definitions for prototype names. Gate all of them on Object.hasOwn. Also reject the name '__proto__' at add time: it passes the character check but assigning it on a plain object hits the prototype setter instead of creating an own property, so the server would be reported as added and silently vanish. Restore NODE_ENV in the test teardown alongside the config state.
1 parent 328f621 commit 8eebf08

3 files changed

Lines changed: 60 additions & 9 deletions

File tree

src/services/mcp/config.protoName.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
saveCurrentProjectConfig,
77
saveGlobalConfig,
88
} from '../../utils/config.js'
9-
import { getMcpConfigByName } from './config.js'
9+
import {
10+
addMcpConfig,
11+
getMcpConfigByName,
12+
removeMcpConfig,
13+
} from './config.js'
1014

1115
// The MCP `servers` maps are plain objects built from JSON config, so a bare
1216
// `servers[name]` lookup exposes inherited Object.prototype members. A user
@@ -24,7 +28,10 @@ const PROTO_NAMES = [
2428
let savedGlobalMcp: ReturnType<typeof getGlobalConfig>['mcpServers']
2529
let savedProjectMcp: ReturnType<typeof getCurrentProjectConfig>['mcpServers']
2630

31+
let savedNodeEnv: string | undefined
32+
2733
beforeEach(() => {
34+
savedNodeEnv = process.env.NODE_ENV
2835
process.env.NODE_ENV = 'test'
2936
savedGlobalMcp = getGlobalConfig().mcpServers
3037
savedProjectMcp = getCurrentProjectConfig().mcpServers
@@ -44,6 +51,11 @@ afterEach(() => {
4451
...config,
4552
mcpServers: savedProjectMcp,
4653
}))
54+
if (savedNodeEnv === undefined) {
55+
delete process.env.NODE_ENV
56+
} else {
57+
process.env.NODE_ENV = savedNodeEnv
58+
}
4759
})
4860

4961
test('resolves a real server by name', () => {
@@ -64,3 +76,30 @@ test('returns null for Object.prototype member names', () => {
6476
expect(getMcpConfigByName(name)).toBeNull()
6577
}
6678
})
79+
80+
test('rejects adding a server named __proto__', async () => {
81+
// "__proto__" passes the character check but assigning it on a plain object
82+
// hits the prototype setter, so the server would be reported as added and
83+
// silently vanish rather than becoming an own property.
84+
await expect(
85+
addMcpConfig('__proto__', { command: 'echo', args: [] }, 'user'),
86+
).rejects.toThrow('reserved')
87+
})
88+
89+
test('reports proto-name removal as not found instead of succeeding', async () => {
90+
// Before the fix the scoped-removal existence checks accepted inherited
91+
// members, so `mcp remove constructor -s user` claimed success while leaving
92+
// the real configuration untouched.
93+
for (const name of PROTO_NAMES) {
94+
await expect(removeMcpConfig(name, 'user')).rejects.toThrow(
95+
'No user-scoped MCP server found',
96+
)
97+
}
98+
})
99+
100+
test('still allows adding and removing a real server name', async () => {
101+
await addMcpConfig('addedserver', { command: 'echo', args: [] }, 'user')
102+
expect(getMcpConfigByName('addedserver')).not.toBeNull()
103+
await removeMcpConfig('addedserver', 'user')
104+
expect(getMcpConfigByName('addedserver')).toBeNull()
105+
})

src/services/mcp/config.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,13 @@ export async function addMcpConfig(
633633
)
634634
}
635635

636+
// "__proto__" passes the character check but cannot be stored: assigning it
637+
// on a plain object invokes the prototype setter instead of creating an own
638+
// property, so the server would be reported as added and silently vanish.
639+
if (name === '__proto__') {
640+
throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
641+
}
642+
636643
// Block reserved server name "claude-in-chrome"
637644
if (isClaudeInChromeMCPServer(name)) {
638645
throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
@@ -682,21 +689,21 @@ export async function addMcpConfig(
682689
switch (scope) {
683690
case 'project': {
684691
const { servers } = getProjectMcpConfigsFromCwd()
685-
if (servers[name]) {
692+
if (Object.hasOwn(servers, name)) {
686693
throw new Error(`MCP server ${name} already exists in .mcp.json`)
687694
}
688695
break
689696
}
690697
case 'user': {
691698
const globalConfig = getGlobalConfig()
692-
if (globalConfig.mcpServers?.[name]) {
699+
if (Object.hasOwn(globalConfig.mcpServers ?? {}, name)) {
693700
throw new Error(`MCP server ${name} already exists in user config`)
694701
}
695702
break
696703
}
697704
case 'local': {
698705
const projectConfig = getCurrentProjectConfig()
699-
if (projectConfig.mcpServers?.[name]) {
706+
if (Object.hasOwn(projectConfig.mcpServers ?? {}, name)) {
700707
throw new Error(`MCP server ${name} already exists in local config`)
701708
}
702709
break
@@ -774,7 +781,7 @@ export async function removeMcpConfig(
774781
case 'project': {
775782
const { servers: existingServers } = getProjectMcpConfigsFromCwd()
776783

777-
if (!existingServers[name]) {
784+
if (!Object.hasOwn(existingServers, name)) {
778785
throw new Error(`No MCP server found with name: ${name} in .mcp.json`)
779786
}
780787

@@ -799,7 +806,7 @@ export async function removeMcpConfig(
799806

800807
case 'user': {
801808
const config = getGlobalConfig()
802-
if (!config.mcpServers?.[name]) {
809+
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
803810
throw new Error(`No user-scoped MCP server found with name: ${name}`)
804811
}
805812
saveGlobalConfig(current => {
@@ -815,7 +822,7 @@ export async function removeMcpConfig(
815822
case 'local': {
816823
// Check if server exists before updating
817824
const config = getCurrentProjectConfig()
818-
if (!config.mcpServers?.[name]) {
825+
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
819826
throw new Error(`No project-local MCP server found with name: ${name}`)
820827
}
821828
saveCurrentProjectConfig(current => {

src/services/mcp/doctor.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,10 @@ function buildScopeDefinitions(
240240
activeConfig: ScopedMcpServerConfig | undefined,
241241
deps: McpDoctorDependencies,
242242
): McpDoctorDefinition[] {
243-
const config = servers[name]
243+
// Own-property lookup: these maps are plain objects from JSON config, so a
244+
// bare servers[name] resolves inherited Object.prototype members and would
245+
// fabricate a definition for a name like 'constructor'.
246+
const config = Object.hasOwn(servers, name) ? servers[name] : undefined
244247
if (!config) {
245248
return []
246249
}
@@ -540,7 +543,9 @@ async function buildServerReport(
540543
}
541544
const { servers: activeServers } = await deps.getAllMcpConfigs()
542545
const serverDisabled = deps.isMcpServerDisabled(name)
543-
const runtimeConfig = activeServers[name] ?? undefined
546+
const runtimeConfig = Object.hasOwn(activeServers, name)
547+
? activeServers[name]
548+
: undefined
544549
const activeConfig = serverDisabled ? undefined : runtimeConfig
545550

546551
const definitions = [

0 commit comments

Comments
 (0)