@@ -3,6 +3,7 @@ import type { ToolDefinition, ToolCall } from "@intx/types/runtime";
33import { TOOL_NAMES } from "@intx/tools-posix" ;
44import { createPermissionGate } from "../../../src/permission/gate.js" ;
55import type { PermissionGate } from "../../../src/permission/gate.js" ;
6+ import { mcpServerFingerprint } from "../../../src/trust/project-trust.js" ;
67import { withMockedModule } from "../../helpers/mock-module.js" ;
78
89const mockDispose = mock ( async ( ) => { } ) ;
@@ -362,6 +363,136 @@ test("headless MCP connection does not wait for interactive OAuth", async () =>
362363 expect ( mockConnectMCPServer . mock . calls [ 0 ] ?. [ 1 ] ?. onAuthURL ) . toBeUndefined ( ) ;
363364} ) ;
364365
366+ const localStdioServer = { name : "evil" , command : "evil-bin" } ;
367+ const globalHttpServer = {
368+ name : "linear" ,
369+ type : "http" as const ,
370+ url : "https://mcp.example.test/mcp" ,
371+ } ;
372+
373+ test ( "late connect of an untrusted local-source server does not spawn" , async ( ) => {
374+ mockConnectMCPServer . mockClear ( ) ;
375+ const statuses : { name : string ; state : string ; error ?: string } [ ] = [ ] ;
376+ const toolset = await createAgentToolset ( {
377+ cwd : "/fake" ,
378+ permissionGate : fakePermissionGate ,
379+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
380+ mcpServers : [ localStdioServer ] ,
381+ mcpServersSource : "local" ,
382+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
383+ } ) ;
384+
385+ await toolset . connectMCPServer ( localStdioServer , {
386+ interactiveAuth : false ,
387+ onStatus : ( status ) => statuses . push ( status ) ,
388+ onToolsChanged : ( ) => { } ,
389+ } ) ;
390+
391+ expect ( mockConnectMCPServer ) . not . toHaveBeenCalled ( ) ;
392+ expect ( statuses ) . toHaveLength ( 1 ) ;
393+ expect ( statuses [ 0 ] ?. name ) . toBe ( "evil" ) ;
394+ expect ( statuses [ 0 ] ?. state ) . toBe ( "failed" ) ;
395+ expect ( statuses [ 0 ] ?. error ) . toMatch ( / N o t t r u s t e d f o r t h i s p r o j e c t / ) ;
396+ await toolset . dispose ( ) ;
397+ } ) ;
398+
399+ test ( "late connect of an untrusted local-source server fail-closes when requestMcpTrust denies" , async ( ) => {
400+ mockConnectMCPServer . mockClear ( ) ;
401+ let trustAsks = 0 ;
402+ const toolset = await createAgentToolset ( {
403+ cwd : "/fake" ,
404+ permissionGate : fakePermissionGate ,
405+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
406+ mcpServers : [ localStdioServer ] ,
407+ mcpServersSource : "local" ,
408+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
409+ requestMcpTrust : async ( ) => {
410+ trustAsks += 1 ;
411+ return false ;
412+ } ,
413+ } ) ;
414+
415+ await toolset . connectMCPServer ( localStdioServer , {
416+ interactiveAuth : false ,
417+ onStatus : ( ) => { } ,
418+ onToolsChanged : ( ) => { } ,
419+ } ) ;
420+
421+ expect ( trustAsks ) . toBe ( 1 ) ;
422+ expect ( mockConnectMCPServer ) . not . toHaveBeenCalled ( ) ;
423+ await toolset . dispose ( ) ;
424+ } ) ;
425+
426+ test ( "late connect of a trusted local-source server still connects" , async ( ) => {
427+ mockConnectMCPServer . mockClear ( ) ;
428+ const toolset = await createAgentToolset ( {
429+ cwd : "/fake" ,
430+ permissionGate : fakePermissionGate ,
431+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
432+ mcpServers : [ localStdioServer ] ,
433+ mcpServersSource : "local" ,
434+ projectTrust : {
435+ trustedPluginPaths : [ ] ,
436+ trustedMcpFingerprints : [ mcpServerFingerprint ( localStdioServer ) ] ,
437+ } ,
438+ } ) ;
439+
440+ await toolset . connectMCPServer ( localStdioServer , {
441+ interactiveAuth : false ,
442+ onStatus : ( ) => { } ,
443+ onToolsChanged : ( ) => { } ,
444+ } ) ;
445+
446+ expect ( mockConnectMCPServer ) . toHaveBeenCalledTimes ( 1 ) ;
447+ expect ( mockConnectMCPServer . mock . calls [ 0 ] ?. [ 0 ] ) . toEqual ( localStdioServer ) ;
448+ await toolset . dispose ( ) ;
449+ } ) ;
450+
451+ test ( "late connect of a global-source HTTP server does not require trust" , async ( ) => {
452+ mockConnectMCPServer . mockClear ( ) ;
453+ const toolset = await createAgentToolset ( {
454+ cwd : "/fake" ,
455+ permissionGate : fakePermissionGate ,
456+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
457+ mcpServers : [ globalHttpServer ] ,
458+ mcpServersSource : "global" ,
459+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
460+ } ) ;
461+
462+ await toolset . connectMCPServer ( globalHttpServer , {
463+ interactiveAuth : false ,
464+ onStatus : ( ) => { } ,
465+ onToolsChanged : ( ) => { } ,
466+ } ) ;
467+
468+ expect ( mockConnectMCPServer ) . toHaveBeenCalledTimes ( 1 ) ;
469+ expect ( mockConnectMCPServer . mock . calls [ 0 ] ?. [ 0 ] ) . toEqual ( globalHttpServer ) ;
470+ await toolset . dispose ( ) ;
471+ } ) ;
472+
473+ test ( "startup connectMCP still fail-closes untrusted local servers" , async ( ) => {
474+ mockConnectMCPServer . mockClear ( ) ;
475+ const statuses : { name : string ; state : string ; error ?: string } [ ] = [ ] ;
476+ const toolset = await createAgentToolset ( {
477+ cwd : "/fake" ,
478+ permissionGate : fakePermissionGate ,
479+ onOperatorGate : async ( ) => ( { kind : "cancel" } ) ,
480+ mcpServers : [ localStdioServer ] ,
481+ mcpServersSource : "local" ,
482+ projectTrust : { trustedPluginPaths : [ ] , trustedMcpFingerprints : [ ] } ,
483+ } ) ;
484+
485+ await toolset . connectMCP ( {
486+ interactiveAuth : false ,
487+ onStatus : ( status ) => statuses . push ( status ) ,
488+ onToolsChanged : ( ) => { } ,
489+ } ) ;
490+
491+ expect ( mockConnectMCPServer ) . not . toHaveBeenCalled ( ) ;
492+ expect ( statuses . some ( ( s ) => s . name === "evil" && s . state === "failed" ) ) . toBe ( true ) ;
493+ await toolset . dispose ( ) ;
494+ } ) ;
495+
365496test ( "dispose calls posixTools.dispose" , async ( ) => {
366497 mockDispose . mockClear ( ) ;
367498
0 commit comments