11import { afterEach , beforeEach , expect , test } from 'bun:test'
22
3+ import {
4+ acquireSharedMutationLock ,
5+ releaseSharedMutationLock ,
6+ } from '../../test/sharedMutationLock.js'
37import {
48 getCurrentProjectConfig ,
59 getGlobalConfig ,
@@ -9,6 +13,7 @@ import {
913import {
1014 addMcpConfig ,
1115 getMcpConfigByName ,
16+ parseMcpConfig ,
1217 removeMcpConfig ,
1318} from './config.js'
1419
@@ -30,7 +35,12 @@ let savedProjectMcp: ReturnType<typeof getCurrentProjectConfig>['mcpServers']
3035
3136let savedNodeEnv : string | undefined
3237
33- beforeEach ( ( ) => {
38+ beforeEach ( async ( ) => {
39+ // This suite swaps NODE_ENV and the process-wide global/project MCP configs,
40+ // so it has to be serialized against the other state-mutating suites bun may
41+ // run alongside it -- otherwise they observe the injected fixtures, or their
42+ // updates are clobbered when this teardown restores a stale snapshot.
43+ await acquireSharedMutationLock ( 'services/mcp/config.protoName.test.ts' )
3444 savedNodeEnv = process . env . NODE_ENV
3545 process . env . NODE_ENV = 'test'
3646 savedGlobalMcp = getGlobalConfig ( ) . mcpServers
@@ -46,15 +56,19 @@ beforeEach(() => {
4656} )
4757
4858afterEach ( ( ) => {
49- saveGlobalConfig ( config => ( { ...config , mcpServers : savedGlobalMcp } ) )
50- saveCurrentProjectConfig ( config => ( {
51- ...config ,
52- mcpServers : savedProjectMcp ,
53- } ) )
54- if ( savedNodeEnv === undefined ) {
55- delete process . env . NODE_ENV
56- } else {
57- process . env . NODE_ENV = savedNodeEnv
59+ try {
60+ saveGlobalConfig ( config => ( { ...config , mcpServers : savedGlobalMcp } ) )
61+ saveCurrentProjectConfig ( config => ( {
62+ ...config ,
63+ mcpServers : savedProjectMcp ,
64+ } ) )
65+ if ( savedNodeEnv === undefined ) {
66+ delete process . env . NODE_ENV
67+ } else {
68+ process . env . NODE_ENV = savedNodeEnv
69+ }
70+ } finally {
71+ releaseSharedMutationLock ( )
5872 }
5973} )
6074
@@ -97,6 +111,32 @@ test('reports proto-name removal as not found instead of succeeding', async () =
97111 }
98112} )
99113
114+ test ( 'surfaces a __proto__ entry in file config instead of dropping it' , ( ) => {
115+ // A hand-authored .mcp.json can carry this name: the schema accepts it, but
116+ // copying it into a plain object hits the prototype setter, so the entry
117+ // vanished with no diagnostic and the user could not tell why their server
118+ // did not exist. It must be reported, not silently discarded.
119+ // Parsed from text, exactly as the real file is: JSON.parse creates a true
120+ // own "__proto__" key, which an object literal would not.
121+ const { config, errors } = parseMcpConfig ( {
122+ configObject : JSON . parse (
123+ '{"mcpServers":{"__proto__":{"command":"echo","args":[]},' +
124+ '"realone":{"command":"echo","args":[]}}}' ,
125+ ) ,
126+ expandVars : false ,
127+ scope : 'project' ,
128+ filePath : '/tmp/.mcp.json' ,
129+ } )
130+
131+ const protoError = errors . find ( e => e . path === 'mcpServers.__proto__' )
132+ expect ( protoError ) . toBeDefined ( )
133+ expect ( protoError ?. message ) . toContain ( 'reserved' )
134+ expect ( protoError ?. mcpErrorMetadata ?. severity ) . toBe ( 'fatal' )
135+ // The rest of the file still parses, and the bad name is not an own key.
136+ expect ( Object . hasOwn ( config ?. mcpServers ?? { } , 'realone' ) ) . toBe ( true )
137+ expect ( Object . hasOwn ( config ?. mcpServers ?? { } , '__proto__' ) ) . toBe ( false )
138+ } )
139+
100140test ( 'still allows adding and removing a real server name' , async ( ) => {
101141 await addMcpConfig ( 'addedserver' , { command : 'echo' , args : [ ] } , 'user' )
102142 expect ( getMcpConfigByName ( 'addedserver' ) ) . not . toBeNull ( )
0 commit comments