@@ -3139,19 +3139,74 @@ describe("CloudTaskService MCP relay", () => {
31393139 requestId : string ;
31403140 server : string ;
31413141 expiresAt : string ;
3142+ payload : Record < string , unknown > ;
31423143 } > = { } ,
31433144 ) : string {
31443145 const event = {
31453146 type : "mcp_request" ,
31463147 requestId : overrides . requestId ?? "req-1" ,
31473148 server : overrides . server ?? "slack" ,
3148- payload : { jsonrpc : "2.0" , id : 1 , method : "initialize" } ,
3149+ payload : overrides . payload ?? {
3150+ jsonrpc : "2.0" ,
3151+ id : 1 ,
3152+ method : "initialize" ,
3153+ } ,
31493154 expiresAt :
31503155 overrides . expiresAt ?? new Date ( Date . now ( ) + 60_000 ) . toISOString ( ) ,
31513156 } ;
31523157 return `data: ${ JSON . stringify ( event ) } \n\n` ;
31533158 }
31543159
3160+ function toolsCallPayload (
3161+ args : Record < string , unknown > = { channel : "#general" } ,
3162+ ) : Record < string , unknown > {
3163+ return {
3164+ jsonrpc : "2.0" ,
3165+ id : 2 ,
3166+ method : "tools/call" ,
3167+ params : { name : "send_message" , arguments : args } ,
3168+ } ;
3169+ }
3170+
3171+ function createControllableSseResponse ( ) : {
3172+ response : Response ;
3173+ push : ( chunk : string ) => void ;
3174+ } {
3175+ const encoder = new TextEncoder ( ) ;
3176+ let streamController : ReadableStreamDefaultController < Uint8Array > ;
3177+ const stream = new ReadableStream < Uint8Array > ( {
3178+ start ( controller ) {
3179+ streamController = controller ;
3180+ } ,
3181+ } ) ;
3182+ return {
3183+ response : new Response ( stream , {
3184+ status : 200 ,
3185+ headers : { "Content-Type" : "text/event-stream" } ,
3186+ } ) ,
3187+ push : ( chunk : string ) => streamController . enqueue ( encoder . encode ( chunk ) ) ,
3188+ } ;
3189+ }
3190+
3191+ function lastPermissionRequestUpdate (
3192+ updates : unknown [ ] ,
3193+ ) : { requestId : string } | undefined {
3194+ return [ ...updates ]
3195+ . reverse ( )
3196+ . find (
3197+ ( u ) : u is { kind : string ; requestId : string } =>
3198+ typeof u === "object" &&
3199+ u !== null &&
3200+ ( u as { kind ?: string } ) . kind === "permission_request" ,
3201+ ) ;
3202+ }
3203+
3204+ function commandPosts ( ) : unknown [ ] {
3205+ return mockNetFetch . mock . calls
3206+ . filter ( ( [ url ] ) => ( url as string ) . includes ( "/command/" ) )
3207+ . map ( ( [ , init ] ) => JSON . parse ( ( init as RequestInit ) . body as string ) ) ;
3208+ }
3209+
31553210 function watchRun ( runId : string ) : void {
31563211 mockNetFetch . mockResolvedValueOnce (
31573212 createJsonResponse ( {
@@ -3328,4 +3383,241 @@ describe("CloudTaskService MCP relay", () => {
33283383
33293384 expect ( mcpRelayExecutor . closeRun ) . toHaveBeenCalledWith ( "run-1" ) ;
33303385 } ) ;
3386+
3387+ describe ( "relayed tools/call approval" , ( ) => {
3388+ it ( "prompts on the desktop and executes after the user allows" , async ( ) => {
3389+ const updates : unknown [ ] = [ ] ;
3390+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3391+ updates . push ( payload ) ;
3392+ } ) ;
3393+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3394+ mockStreamFetch . mockResolvedValueOnce (
3395+ createOpenSseResponse (
3396+ mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ,
3397+ ) ,
3398+ ) ;
3399+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3400+ watchRun ( "run-1" ) ;
3401+
3402+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3403+ expect ( mcpRelayExecutor . execute ) . not . toHaveBeenCalled ( ) ;
3404+
3405+ const prompt = lastPermissionRequestUpdate ( updates ) ;
3406+ await relayService . sendCommand ( {
3407+ taskId : "task-1" ,
3408+ runId : "run-1" ,
3409+ apiHost : "https://app.example.com" ,
3410+ teamId : 2 ,
3411+ method : "permission_response" ,
3412+ params : { requestId : prompt ?. requestId , optionId : "allow" } ,
3413+ } ) ;
3414+
3415+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length > 0 ) ;
3416+ expect ( mcpRelayExecutor . execute ) . toHaveBeenCalledWith (
3417+ "run-1" ,
3418+ "slack" ,
3419+ expect . objectContaining ( { method : "tools/call" } ) ,
3420+ ) ;
3421+ await waitFor ( ( ) =>
3422+ commandPosts ( ) . some (
3423+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3424+ ) ,
3425+ ) ;
3426+ } ) ;
3427+
3428+ it ( "answers a denial to the sandbox without executing, carrying the user's feedback" , async ( ) => {
3429+ const updates : unknown [ ] = [ ] ;
3430+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3431+ updates . push ( payload ) ;
3432+ } ) ;
3433+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3434+ mockStreamFetch . mockResolvedValueOnce (
3435+ createOpenSseResponse (
3436+ mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ,
3437+ ) ,
3438+ ) ;
3439+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3440+ watchRun ( "run-1" ) ;
3441+
3442+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3443+ const prompt = lastPermissionRequestUpdate ( updates ) ;
3444+ await relayService . sendCommand ( {
3445+ taskId : "task-1" ,
3446+ runId : "run-1" ,
3447+ apiHost : "https://app.example.com" ,
3448+ teamId : 2 ,
3449+ method : "permission_response" ,
3450+ params : {
3451+ requestId : prompt ?. requestId ,
3452+ optionId : "reject" ,
3453+ customInput : "use the announcements channel instead" ,
3454+ } ,
3455+ } ) ;
3456+
3457+ await waitFor ( ( ) =>
3458+ commandPosts ( ) . some (
3459+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3460+ ) ,
3461+ ) ;
3462+ expect ( mcpRelayExecutor . execute ) . not . toHaveBeenCalled ( ) ;
3463+ const response = commandPosts ( ) . find (
3464+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3465+ ) as { params : { error ?: { message ?: string } } } ;
3466+ expect ( response . params . error ?. message ) . toContain (
3467+ "use the announcements channel instead" ,
3468+ ) ;
3469+ } ) ;
3470+
3471+ it ( "drops an unanswered prompt at the request's expiry without executing" , async ( ) => {
3472+ const updates : unknown [ ] = [ ] ;
3473+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3474+ updates . push ( payload ) ;
3475+ } ) ;
3476+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3477+ mockStreamFetch . mockResolvedValueOnce (
3478+ createOpenSseResponse (
3479+ mcpRequestSseLine ( {
3480+ payload : toolsCallPayload ( ) ,
3481+ expiresAt : new Date ( Date . now ( ) + 150 ) . toISOString ( ) ,
3482+ } ) ,
3483+ ) ,
3484+ ) ;
3485+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3486+ watchRun ( "run-1" ) ;
3487+
3488+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3489+ await new Promise ( ( resolve ) => setTimeout ( resolve , 300 ) ) ;
3490+
3491+ expect ( mcpRelayExecutor . execute ) . not . toHaveBeenCalled ( ) ;
3492+ expect (
3493+ commandPosts ( ) . some (
3494+ ( body ) => ( body as { method ?: string } ) . method === "mcp_response" ,
3495+ ) ,
3496+ ) . toBe ( false ) ;
3497+ } ) ;
3498+
3499+ it ( "consumes a harness prompt approval instead of prompting a second time" , async ( ) => {
3500+ const updates : unknown [ ] = [ ] ;
3501+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3502+ updates . push ( payload ) ;
3503+ } ) ;
3504+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3505+ const { response, push } = createControllableSseResponse ( ) ;
3506+ mockStreamFetch . mockResolvedValueOnce ( response ) ;
3507+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3508+ watchRun ( "run-1" ) ;
3509+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length > 0 ) ;
3510+
3511+ // The harness (claude always-ask) prompt for the same call arrives first.
3512+ push (
3513+ `data: ${ JSON . stringify ( {
3514+ type : "permission_request" ,
3515+ requestId : "harness-req-1" ,
3516+ toolCall : {
3517+ toolCallId : "tc-1" ,
3518+ title : "The agent wants to call send_message (slack)" ,
3519+ kind : "other" ,
3520+ rawInput : {
3521+ channel : "#general" ,
3522+ toolName : "mcp__slack__send_message" ,
3523+ } ,
3524+ } ,
3525+ options : [
3526+ { kind : "allow_once" , name : "Yes" , optionId : "allow" } ,
3527+ {
3528+ kind : "allow_always" ,
3529+ name : "Yes, always allow" ,
3530+ optionId : "allow_always" ,
3531+ } ,
3532+ { kind : "reject_once" , name : "No" , optionId : "reject" } ,
3533+ ] ,
3534+ } ) } \n\n`,
3535+ ) ;
3536+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3537+
3538+ // The user answers it in the task view; the response routes through
3539+ // sendCommand, granting a consume-once pass for the identical call.
3540+ await relayService . sendCommand ( {
3541+ taskId : "task-1" ,
3542+ runId : "run-1" ,
3543+ apiHost : "https://app.example.com" ,
3544+ teamId : 2 ,
3545+ method : "permission_response" ,
3546+ params : { requestId : "harness-req-1" , optionId : "allow" } ,
3547+ } ) ;
3548+
3549+ const updatesBeforeCall = updates . length ;
3550+ push ( mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ) ;
3551+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length > 0 ) ;
3552+
3553+ // No desktop prompt was raised for the relayed call itself.
3554+ expect (
3555+ updates
3556+ . slice ( updatesBeforeCall )
3557+ . filter (
3558+ ( u ) => ( u as { kind ?: string } ) . kind === "permission_request" ,
3559+ ) ,
3560+ ) . toEqual ( [ ] ) ;
3561+
3562+ // The pass was consume-once: an identical unattested call prompts again.
3563+ push (
3564+ mcpRequestSseLine ( {
3565+ requestId : "req-2" ,
3566+ payload : toolsCallPayload ( ) ,
3567+ } ) ,
3568+ ) ;
3569+ await waitFor (
3570+ ( ) =>
3571+ updates
3572+ . slice ( updatesBeforeCall )
3573+ . filter (
3574+ ( u ) => ( u as { kind ?: string } ) . kind === "permission_request" ,
3575+ ) . length > 0 ,
3576+ ) ;
3577+ expect ( mcpRelayExecutor . execute ) . toHaveBeenCalledOnce ( ) ;
3578+ } ) ;
3579+
3580+ it ( "an always-allow answer covers subsequent calls to the same tool" , async ( ) => {
3581+ const updates : unknown [ ] = [ ] ;
3582+ relayService . on ( CloudTaskEvent . Update , ( payload ) => {
3583+ updates . push ( payload ) ;
3584+ } ) ;
3585+ mockNetFetch . mockResolvedValue ( createJsonResponse ( { result : { } } ) ) ;
3586+ const { response, push } = createControllableSseResponse ( ) ;
3587+ mockStreamFetch . mockResolvedValueOnce ( response ) ;
3588+ relayService . designateRelayedMcpServers ( "run-1" , [ "slack" ] ) ;
3589+ watchRun ( "run-1" ) ;
3590+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length > 0 ) ;
3591+
3592+ push ( mcpRequestSseLine ( { payload : toolsCallPayload ( ) } ) ) ;
3593+ await waitFor ( ( ) => lastPermissionRequestUpdate ( updates ) !== undefined ) ;
3594+ const prompt = lastPermissionRequestUpdate ( updates ) ;
3595+ await relayService . sendCommand ( {
3596+ taskId : "task-1" ,
3597+ runId : "run-1" ,
3598+ apiHost : "https://app.example.com" ,
3599+ teamId : 2 ,
3600+ method : "permission_response" ,
3601+ params : { requestId : prompt ?. requestId , optionId : "allow_always" } ,
3602+ } ) ;
3603+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length === 1 ) ;
3604+
3605+ const updatesAfterFirst = updates . length ;
3606+ // Different arguments — always-allow is per tool, not per exact call.
3607+ push (
3608+ mcpRequestSseLine ( {
3609+ requestId : "req-2" ,
3610+ payload : toolsCallPayload ( { channel : "#random" } ) ,
3611+ } ) ,
3612+ ) ;
3613+ await waitFor ( ( ) => mcpRelayExecutor . execute . mock . calls . length === 2 ) ;
3614+ expect (
3615+ updates
3616+ . slice ( updatesAfterFirst )
3617+ . filter (
3618+ ( u ) => ( u as { kind ?: string } ) . kind === "permission_request" ,
3619+ ) ,
3620+ ) . toEqual ( [ ] ) ;
3621+ } ) ;
3622+ } ) ;
33313623} ) ;
0 commit comments