Unified · Split
| @@ -15,13 +15,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; · 14 unmodified lines | ||
| 15 | 15 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 16 | 16 | |
| 17 | 17 | import { scenario } from "../src/scenario"; |
| 18 | −import { Api, Mcp, Target } from "../src/services"; | |
| 18 | +import { Api, Browser, Mcp, Target } from "../src/services"; | |
| 19 | 19 | import { parseBrowserApproval } from "../src/surfaces/mcp"; |
| 20 | 20 | import type { Identity } from "../src/target"; |
| 21 | 21 | |
| 22 | 22 | const coreApi = composePluginApi([] as const); |
| 23 | 23 | |
| 24 | 24 | const GATE_TOOL = "executor.coreTools.policies.list"; |
| 25 | +const UNAVAILABLE_COPY = "This paused execution is no longer available"; | |
| 25 | 26 | |
| 26 | 27 | const GATED_CODE = ` |
| 27 | 28 | const result = await tools.executor.coreTools.policies.list({}); |
| @@ -63,6 +64,11 @@ const openBrowserApprovalSession = async (mcpUrl: string, bearer: string): Promi · 35 unmodified lines | ||
| 63 | 64 | const closeQuietly = (connected: Connected): Effect.Effect<void> => |
| 64 | 65 | Effect.promise(() => connected.client.close().catch(() => undefined)); |
| 65 | 66 | |
| 67 | +const pathWithSearch = (url: string): string => { | |
| 68 | + const parsed = new URL(url); | |
| 69 | + return `${parsed.pathname}${parsed.search}`; | |
| 70 | +}; | |
| 71 | + | |
| 66 | 72 | const authenticatedFetch = ( |
| 67 | 73 | identity: Identity, |
| 68 | 74 | input: URL, |
| @@ -167,3 +173,97 @@ scenario( · 98 unmodified lines | ||
| 167 | 173 | ); |
| 168 | 174 | }), |
| 169 | 175 | ); |
| 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(/Approve executor\.coreTools\.policies\.list\?/).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 | +); | |
Binary file not shown.