@@ -15,13 +15,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
1515import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" ;
1616
1717import { scenario } from "../src/scenario" ;
18- import { Api , Mcp , Target } from "../src/services" ;
18+ import { Api , Browser , Mcp , Target } from "../src/services" ;
1919import { parseBrowserApproval } from "../src/surfaces/mcp" ;
2020import type { Identity } from "../src/target" ;
2121
2222const coreApi = composePluginApi ( [ ] as const ) ;
2323
2424const GATE_TOOL = "executor.coreTools.policies.list" ;
25+ const UNAVAILABLE_COPY = "This paused execution is no longer available" ;
2526
2627const GATED_CODE = `
2728const result = await tools.executor.coreTools.policies.list({});
@@ -63,6 +64,11 @@ const openBrowserApprovalSession = async (mcpUrl: string, bearer: string): Promi
6364const closeQuietly = ( connected : Connected ) : Effect . Effect < void > =>
6465 Effect . promise ( ( ) => connected . client . close ( ) . catch ( ( ) => undefined ) ) ;
6566
67+ const pathWithSearch = ( url : string ) : string => {
68+ const parsed = new URL ( url ) ;
69+ return `${ parsed . pathname } ${ parsed . search } ` ;
70+ } ;
71+
6672const authenticatedFetch = (
6773 identity : Identity ,
6874 input : URL ,
@@ -167,3 +173,97 @@ scenario(
167173 ) ;
168174 } ) ,
169175) ;
176+
177+ scenario (
178+ "MCP approval · browser resume page approves a paused execution through the UI" ,
179+ { timeout : 180_000 } ,
180+ Effect . gen ( function * ( ) {
181+ const target = yield * Target ;
182+ const { client : apiClient } = yield * Api ;
183+ const browser = yield * Browser ;
184+ const mcp = yield * Mcp ;
185+ const identity = yield * target . newIdentity ( ) ;
186+ const bearer = yield * mcp . mintBearer ( emailOf ( identity ) ) ;
187+ const api = yield * apiClient ( coreApi , identity ) ;
188+
189+ const policy = yield * api . policies . create ( {
190+ payload : { owner : "org" , pattern : GATE_TOOL , action : "require_approval" } ,
191+ } ) ;
192+
193+ yield * Effect . gen ( function * ( ) {
194+ const session = yield * Effect . promise ( ( ) =>
195+ openBrowserApprovalSession ( target . mcpUrl , bearer ) ,
196+ ) ;
197+ yield * Effect . gen ( function * ( ) {
198+ const paused = yield * Effect . promise ( ( ) =>
199+ session . client . callTool ( { name : "execute" , arguments : { code : GATED_CODE } } ) ,
200+ ) ;
201+ const approval = parseBrowserApproval ( {
202+ raw : paused ,
203+ text : textOf ( paused ) ,
204+ ok : paused . isError !== true ,
205+ } ) ;
206+
207+ const approvalUrl = new URL ( approval . approvalUrl ) ;
208+ const mcpSessionId = approvalUrl . searchParams . get ( "mcp_session_id" ) ;
209+ expect (
210+ mcpSessionId ,
211+ "approval URL carries the MCP session id that the browser page will query" ,
212+ ) . toEqual ( expect . any ( String ) ) ;
213+ expect ( mcpSessionId , "approval URL points at the session that paused" ) . toBe (
214+ session . transport . sessionId ,
215+ ) ;
216+
217+ const [ resumed ] = yield * Effect . all (
218+ [
219+ Effect . promise ( ( ) =>
220+ session . client . callTool ( {
221+ name : "resume" ,
222+ arguments : { executionId : approval . executionId } ,
223+ } ) ,
224+ ) ,
225+ browser . session ( identity , async ( { page, step } ) => {
226+ await step ( "Open the paused execution approval page" , async ( ) => {
227+ await page . goto ( pathWithSearch ( approval . approvalUrl ) , { waitUntil : "networkidle" } ) ;
228+ await page . getByText ( "User approval required" ) . waitFor ( ) ;
229+ } ) ;
230+
231+ await step ( "Review the paused tool call details" , async ( ) => {
232+ await page . getByText ( "Pending request" ) . waitFor ( ) ;
233+ await page . getByText ( / A p p r o v e e x e c u t o r \. c o r e T o o l s \. p o l i c i e s \. l i s t \? / ) . waitFor ( ) ;
234+
235+ const approve = page . getByRole ( "button" , { name : "Approve" } ) ;
236+ await approve . waitFor ( ) ;
237+ expect (
238+ await approve . isEnabled ( ) ,
239+ "the approve control is enabled for the paused execution" ,
240+ ) . toBe ( true ) ;
241+ expect (
242+ await page . getByText ( UNAVAILABLE_COPY ) . count ( ) ,
243+ "the resume page does not show the expired-session failure copy" ,
244+ ) . toBe ( 0 ) ;
245+ } ) ;
246+
247+ await step ( "Approve the paused tool call" , async ( ) => {
248+ await page . getByRole ( "button" , { name : "Approve" } ) . click ( ) ;
249+ await page . getByText ( "Approve sent" ) . waitFor ( ) ;
250+ } ) ;
251+ } ) ,
252+ ] ,
253+ { concurrency : "unbounded" } ,
254+ ) ;
255+
256+ expect ( resumed . isError , "browser-mode resume completed after the UI approval" ) . not . toBe (
257+ true ,
258+ ) ;
259+ expect ( textOf ( resumed ) , "the gated tool completed after approval" ) . toContain ( policy . id ) ;
260+ } ) . pipe ( Effect . ensuring ( closeQuietly ( session ) ) ) ;
261+ } ) . pipe (
262+ Effect . ensuring (
263+ api . policies
264+ . remove ( { params : { policyId : policy . id } , payload : { owner : "org" } } )
265+ . pipe ( Effect . ignore ) ,
266+ ) ,
267+ ) ;
268+ } ) ,
269+ ) ;
0 commit comments