@@ -29,7 +29,16 @@ vi.mock('@/lib/mcp/domain-check', () => ({
2929} ) )
3030vi . mock ( '@/lib/mcp/oauth' , ( ) => mcpOauthMock )
3131vi . mock ( '@/lib/mcp/service' , ( ) => mcpServiceMock )
32- vi . mock ( '@/lib/mcp/utils' , ( ) => ( { generateMcpServerId : mockGenerateMcpServerId } ) )
32+ vi . mock ( '@/lib/mcp/utils' , ( ) => ( {
33+ generateMcpServerId : mockGenerateMcpServerId ,
34+ isSameMcpServerDestination : ( a : string , b : string ) => {
35+ const destination = ( url : string ) => {
36+ const parsed = new URL ( url )
37+ return `${ parsed . origin } ${ parsed . pathname } `
38+ }
39+ return destination ( a ) === destination ( b )
40+ } ,
41+ } ) )
3342vi . mock ( '@/lib/posthog/server' , ( ) => posthogServerMock )
3443
3544import {
@@ -74,6 +83,7 @@ describe('MCP server lifecycle orchestration', () => {
7483 workspaceId : 'workspace-1' ,
7584 userId : 'user-1' ,
7685 serverId : 'server-1' ,
86+ allowDestinationChange : false ,
7787 oauthClientId : 'client-1' ,
7888 oauthClientIdProvided : true ,
7989 } )
@@ -116,6 +126,7 @@ describe('MCP server lifecycle orchestration', () => {
116126 workspaceId : 'workspace-1' ,
117127 userId : 'user-1' ,
118128 serverId : 'server-1' ,
129+ allowDestinationChange : false ,
119130 authType : 'headers' ,
120131 } )
121132
@@ -163,6 +174,7 @@ describe('MCP server lifecycle orchestration', () => {
163174 workspaceId : 'workspace-1' ,
164175 userId : 'user-1' ,
165176 serverId : 'server-1' ,
177+ allowDestinationChange : false ,
166178 headers : { authorization : 'Bearer rotated' } ,
167179 } )
168180
@@ -225,6 +237,90 @@ describe('MCP server lifecycle orchestration', () => {
225237 expect ( mockRevokeOauthTokens ) . toHaveBeenCalledWith ( 'server-1' , 'workspace-1' )
226238 } )
227239
240+ it ( 'refuses a non-admin pointing an existing server at a different host' , async ( ) => {
241+ dbChainMockFns . limit . mockResolvedValueOnce ( [
242+ {
243+ url : 'https://example.com/mcp' ,
244+ authType : 'headers' ,
245+ headers : { } ,
246+ oauthClientId : null ,
247+ oauthClientSecret : null ,
248+ } ,
249+ ] )
250+
251+ const result = await performUpdateMcpServer ( {
252+ workspaceId : 'workspace-1' ,
253+ userId : 'user-1' ,
254+ serverId : 'server-1' ,
255+ allowDestinationChange : false ,
256+ url : 'https://other-host.example.com/mcp' ,
257+ } )
258+
259+ expect ( result ) . toMatchObject ( { success : false , errorCode : 'forbidden' } )
260+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalled ( )
261+ expect ( mockRevokeOauthTokens ) . not . toHaveBeenCalled ( )
262+ } )
263+
264+ it ( 'lets an admin point an existing server at a different host' , async ( ) => {
265+ dbChainMockFns . limit . mockResolvedValueOnce ( [
266+ {
267+ url : 'https://example.com/mcp' ,
268+ authType : 'headers' ,
269+ headers : { } ,
270+ oauthClientId : null ,
271+ oauthClientSecret : null ,
272+ } ,
273+ ] )
274+ dbChainMockFns . returning . mockResolvedValueOnce ( [
275+ {
276+ id : 'server-1' ,
277+ workspaceId : 'workspace-1' ,
278+ name : 'Example' ,
279+ transport : 'streamable-http' ,
280+ url : 'https://new.example.com/mcp' ,
281+ authType : 'headers' ,
282+ } ,
283+ ] )
284+
285+ const result = await performUpdateMcpServer ( {
286+ workspaceId : 'workspace-1' ,
287+ userId : 'user-1' ,
288+ serverId : 'server-1' ,
289+ allowDestinationChange : true ,
290+ url : 'https://new.example.com/mcp' ,
291+ } )
292+
293+ expect ( result . success ) . toBe ( true )
294+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
295+ expect . objectContaining ( { url : 'https://new.example.com/mcp' } )
296+ )
297+ } )
298+
299+ it ( 'refuses a registration whose id collides with a server at a different host' , async ( ) => {
300+ mockGenerateMcpServerId . mockReturnValue ( 'server-1' )
301+ dbChainMockFns . limit . mockResolvedValueOnce ( [
302+ {
303+ id : 'server-1' ,
304+ deletedAt : null ,
305+ url : 'https://example.com/mcp' ,
306+ authType : 'headers' ,
307+ oauthClientId : null ,
308+ oauthClientSecret : null ,
309+ } ,
310+ ] )
311+
312+ const result = await performCreateMcpServer ( {
313+ workspaceId : 'workspace-1' ,
314+ userId : 'user-1' ,
315+ name : 'Example' ,
316+ url : 'https://other-host.example.com/collide' ,
317+ authType : 'headers' ,
318+ } )
319+
320+ expect ( result ) . toMatchObject ( { success : false , errorCode : 'conflict' } )
321+ expect ( dbChainMockFns . set ) . not . toHaveBeenCalled ( )
322+ } )
323+
228324 it ( 'registers a new server as disconnected rather than stamping a connection it never made' , async ( ) => {
229325 mockGenerateMcpServerId . mockReturnValue ( 'server-1' )
230326 dbChainMockFns . limit . mockResolvedValueOnce ( [ ] )
0 commit comments