@@ -3097,19 +3097,74 @@ describe("CloudTaskService MCP relay", () => {
30973097 requestId : string ;
30983098 server : string ;
30993099 expiresAt : string ;
3100+ payload : Record < string , unknown > ;
31003101 } > = { } ,
31013102 ) : string {
31023103 const event = {
31033104 type : "mcp_request" ,
31043105 requestId : overrides . requestId ?? "req-1" ,
31053106 server : overrides . server ?? "slack" ,
3106- payload : { jsonrpc : "2.0" , id : 1 , method : "initialize" } ,
3107+ payload : overrides . payload ?? {
3108+ jsonrpc : "2.0" ,
3109+ id : 1 ,
3110+ method : "initialize" ,
3111+ } ,
31073112 expiresAt :
31083113 overrides . expiresAt ?? new Date ( Date . now ( ) + 60_000 ) . toISOString ( ) ,
31093114 } ;
31103115 return `data: ${ JSON . stringify ( event ) } \n\n` ;
31113116 }
31123117
3118+ function toolsCallPayload (
3119+ args : Record < string , unknown > = { channel : "#general" } ,
3120+ ) : Record < string , unknown > {
3121+ return {
3122+ jsonrpc : "2.0" ,
3123+ id : 2 ,
3124+ method : "tools/call" ,
3125+ params : { name : "send_message" , arguments : args } ,
3126+ } ;
3127+ }
3128+
3129+ function createControllableSseResponse ( ) : {
3130+ response : Response ;
3131+ push : ( chunk : string ) => void ;
3132+ } {
3133+ const encoder = new TextEncoder ( ) ;
3134+ let streamController : ReadableStreamDefaultController < Uint8Array > ;
3135+ const stream = new ReadableStream < Uint8Array > ( {
3136+ start ( controller ) {
3137+ streamController = controller ;
3138+ } ,
3139+ } ) ;
3140+ return {
3141+ response : new Response ( stream , {
3142+ status : 200 ,
3143+ headers : { "Content-Type" : "text/event-stream" } ,
3144+ } ) ,
3145+ push : ( chunk : string ) => streamController . enqueue ( encoder . encode ( chunk ) ) ,
3146+ } ;
3147+ }
3148+
3149+ function lastPermissionRequestUpdate (
3150+ updates : unknown [ ] ,
3151+ ) : { requestId : string } | undefined {
3152+ return [ ...updates ]
3153+ . reverse ( )
3154+ . find (
3155+ ( u ) : u is { kind : string ; requestId : string } =>
3156+ typeof u === "object" &&
3157+ u !== null &&
3158+ ( u as { kind ?: string } ) . kind === "permission_request" ,
3159+ ) ;
3160+ }
3161+
3162+ function commandPosts ( ) : unknown [ ] {
3163+ return mockNetFetch . mock . calls
3164+ . filter ( ( [ url ] ) => ( url as string ) . includes ( "/command/" ) )
3165+ . map ( ( [ , init ] ) => JSON . parse ( ( init as RequestInit ) . body as string ) ) ;
3166+ }
3167+
31133168 function watchRun ( runId : string ) : void {
31143169 mockNetFetch . mockResolvedValueOnce (
31153170 createJsonResponse ( {
@@ -3286,4 +3341,241 @@ describe("CloudTaskService MCP relay", () => {
32863341
32873342 expect ( mcpRelayExecutor . closeRun ) . toHaveBeenCalledWith ( "run-1" ) ;
32883343 } ) ;
3344+
3345+ describe ( "relayed tools/call approval" , ( ) => {
3346+ it ( "prompts on the desktop and executes after the user allows" , async ( ) => {
3347+ const updates : unknown [ ] = [ ] ;
3348+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3349+ updates . push ( payload ) ;
3350+ } ) ;
3351+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3352+ mockStreamFetch . mockResolvedValueOnce (
3353+ createOpenSseResponse (
3354+ mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ,
3355+ ) ,
3356+ ) ;
3357+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3358+ watchRun ( "run-1" ) ;
3359+
3360+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3361+ expect ( mcpRelayExecutor . execute ) . not . toHaveBeenCalled ( ) ;
3362+
3363+ const prompt = lastPermissionRequestUpdate ( updates ) ;
3364+ await relayService . sendCommand ( {
3365+ taskId : "task-1" ,
3366+ runId : "run-1" ,
3367+ apiHost : "https://app.example.com" ,
3368+ teamId : 2 ,
3369+ method : "permission_response" ,
3370+ params : { requestId : prompt ?. requestId , optionId : "allow" } ,
3371+ } ) ;
3372+
3373+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length > 0 ) ;
3374+ expect ( mcpRelayExecutor . execute ) . toHaveBeenCalledWith (
3375+ "run-1" ,
3376+ "slack" ,
3377+ expect . objectContaining ( { method : "tools/call" } ) ,
3378+ ) ;
3379+ await waitFor ( ( ) =>
3380+ commandPosts ( ) . some (
3381+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3382+ ) ,
3383+ ) ;
3384+ } ) ;
3385+
3386+ it ( "answers a denial to the sandbox without executing, carrying the user's feedback" , async ( ) => {
3387+ const updates : unknown [ ] = [ ] ;
3388+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3389+ updates . push ( payload ) ;
3390+ } ) ;
3391+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3392+ mockStreamFetch . mockResolvedValueOnce (
3393+ createOpenSseResponse (
3394+ mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ,
3395+ ) ,
3396+ ) ;
3397+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3398+ watchRun ( "run-1" ) ;
3399+
3400+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3401+ const prompt = lastPermissionRequestUpdate ( updates ) ;
3402+ await relayService . sendCommand ( {
3403+ taskId : "task-1" ,
3404+ runId : "run-1" ,
3405+ apiHost : "https://app.example.com" ,
3406+ teamId : 2 ,
3407+ method : "permission_response" ,
3408+ params : {
3409+ requestId : prompt ?. requestId ,
3410+ optionId : "reject" ,
3411+ customInput : "use the announcements channel instead" ,
3412+ } ,
3413+ } ) ;
3414+
3415+ await waitFor ( ( ) =>
3416+ commandPosts ( ) . some (
3417+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3418+ ) ,
3419+ ) ;
3420+ expect ( mcpRelayExecutor . execute ) . not . toHaveBeenCalled ( ) ;
3421+ const response = commandPosts ( ) . find (
3422+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3423+ ) as { params : { error ?: { message ?: string } } } ;
3424+ expect ( response . params . error ?. message ) . toContain (
3425+ "use the announcements channel instead" ,
3426+ ) ;
3427+ } ) ;
3428+
3429+ it ( "drops an unanswered prompt at the request's expiry without executing" , async ( ) => {
3430+ const updates : unknown [ ] = [ ] ;
3431+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3432+ updates . push ( payload ) ;
3433+ } ) ;
3434+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3435+ mockStreamFetch . mockResolvedValueOnce (
3436+ createOpenSseResponse (
3437+ mcpRequestSseLine ( {
3438+ payload : toolsCallPayload ( ) ,
3439+ expiresAt : new Date ( Date . now ( ) + 150 ) . toISOString ( ) ,
3440+ } ) ,
3441+ ) ,
3442+ ) ;
3443+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3444+ watchRun ( "run-1" ) ;
3445+
3446+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3447+ await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
3448+
3449+ expect ( mcpRelayExecutor . execute ) . not . toHaveBeenCalled ( ) ;
3450+ expect (
3451+ commandPosts ( ) . some (
3452+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3453+ ) ,
3454+ ) . toBe ( false ) ;
3455+ } ) ;
3456+
3457+ it ( "consumes a harness prompt approval instead of prompting a second time" , async ( ) => {
3458+ const updates : unknown [ ] = [ ] ;
3459+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3460+ updates . push ( payload ) ;
3461+ } ) ;
3462+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3463+ const { response, push } = createControllableSseResponse ( ) ;
3464+ mockStreamFetch . mockResolvedValueOnce ( response ) ;
3465+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3466+ watchRun ( "run-1" ) ;
3467+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length > 0 ) ;
3468+
3469+ // The harness (claude always-ask) prompt for the same call arrives first.
3470+ push (
3471+ `data: ${ JSON . stringify ( {
3472+ type : "permission_request" ,
3473+ requestId : "harness-req-1" ,
3474+ toolCall : {
3475+ toolCallId : "tc-1" ,
3476+ title : "The agent wants to call send_message (slack)" ,
3477+ kind : "other" ,
3478+ rawInput : {
3479+ channel : "#general" ,
3480+ toolName : "mcp__slack__send_message" ,
3481+ } ,
3482+ } ,
3483+ options : [
3484+ { kind : "allow_once" , name : "Yes" , optionId : "allow" } ,
3485+ {
3486+ kind : "allow_always" ,
3487+ name : "Yes, always allow" ,
3488+ optionId : "allow_always" ,
3489+ } ,
3490+ { kind : "reject_once" , name : "No" , optionId : "reject" } ,
3491+ ] ,
3492+ } ) } \n\n`,
3493+ ) ;
3494+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3495+
3496+ // The user answers it in the task view; the response routes through
3497+ // sendCommand, granting a consume-once pass for the identical call.
3498+ await relayService . sendCommand ( {
3499+ taskId : "task-1" ,
3500+ runId : "run-1" ,
3501+ apiHost : "https://app.example.com" ,
3502+ teamId : 2 ,
3503+ method : "permission_response" ,
3504+ params : { requestId : "harness-req-1" , optionId : "allow" } ,
3505+ } ) ;
3506+
3507+ const updatesBeforeCall = updates . length ;
3508+ push ( mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ) ;
3509+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length > 0 ) ;
3510+
3511+ // No desktop prompt was raised for the relayed call itself.
3512+ expect (
3513+ updates
3514+ . slice ( updatesBeforeCall )
3515+ . filter (
3516+ ( u ) => ( u as { kind ?: string } ) . kind === "permission_request" ,
3517+ ) ,
3518+ ) . toEqual ( [ ] ) ;
3519+
3520+ // The pass was consume-once: an identical unattested call prompts again.
3521+ push (
3522+ mcpRequestSseLine ( {
3523+ requestId : "req-2" ,
3524+ payload : toolsCallPayload ( ) ,
3525+ } ) ,
3526+ ) ;
3527+ await waitFor (
3528+ ( ) =>
3529+ updates
3530+ . slice ( updatesBeforeCall )
3531+ . filter (
3532+ ( u ) => ( u as { kind ?: string } ) . kind === "permission_request" ,
3533+ ) . length > 0 ,
3534+ ) ;
3535+ expect ( mcpRelayExecutor . execute ) . toHaveBeenCalledOnce ( ) ;
3536+ } ) ;
3537+
3538+ it ( "an always-allow answer covers subsequent calls to the same tool" , async ( ) => {
3539+ const updates : unknown [ ] = [ ] ;
3540+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3541+ updates . push ( payload ) ;
3542+ } ) ;
3543+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3544+ const { response, push } = createControllableSseResponse ( ) ;
3545+ mockStreamFetch . mockResolvedValueOnce ( response ) ;
3546+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3547+ watchRun ( "run-1" ) ;
3548+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length > 0 ) ;
3549+
3550+ push ( mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ) ;
3551+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3552+ const prompt = lastPermissionRequestUpdate ( updates ) ;
3553+ await relayService . sendCommand ( {
3554+ taskId : "task-1" ,
3555+ runId : "run-1" ,
3556+ apiHost : "https://app.example.com" ,
3557+ teamId : 2 ,
3558+ method : "permission_response" ,
3559+ params : { requestId : prompt ?. requestId , optionId : "allow_always" } ,
3560+ } ) ;
3561+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length === 1 ) ;
3562+
3563+ const updatesAfterFirst = updates . length ;
3564+ // Different arguments — always-allow is per tool, not per exact call.
3565+ push (
3566+ mcpRequestSseLine ( {
3567+ requestId : "req-2" ,
3568+ payload : toolsCallPayload ( { channel : "#random" } ) ,
3569+ } ) ,
3570+ ) ;
3571+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length === 2 ) ;
3572+ expect (
3573+ updates
3574+ . slice ( updatesAfterFirst )
3575+ . filter (
3576+ ( u ) => ( u as { kind ?: string } ) . kind === "permission_request" ,
3577+ ) ,
3578+ ) . toEqual ( [ ] ) ;
3579+ } ) ;
3580+ } ) ;
32893581} ) ;
0 commit comments