diff --git a/bun.lock b/bun.lock index 22f0584..d6fcb57 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "@onkernel/managed-auth-react": "0.5.5", - "@onkernel/sdk": "0.141.0", + "@onkernel/sdk": "0.148.0", "@posthog/mcp": "0.17.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", @@ -160,7 +160,7 @@ "@onkernel/managed-auth-react": ["@onkernel/managed-auth-react@0.5.5", "", { "dependencies": { "clsx": "^2.1.1", "tldts": "7.4.15" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-uoeHAJ0RnLoEJe7TdpT0pA3RgPWEpJLf9F532aLEcahFNrMIqyAdMp94LNIwVuMlxkkfx4+3S/fqzHubbnzjJg=="], - "@onkernel/sdk": ["@onkernel/sdk@0.141.0", "", {}, "sha512-NAsynTkRK3N7udBgitPBd5OBbo5j+NzAAsX8eIbHWR4Ly9CNAVuhJQ2uljnuZaXrCQwZhQ/29L6f2X2Vd4gf1w=="], + "@onkernel/sdk": ["@onkernel/sdk@0.148.0", "", {}, "sha512-e58SYGkmQoKMTKYv8SNpzbO4uE9Bhz/7dv6iyW8V1dq/m9V7CHNa8L3o9peP2arbaQdyqWCt5kljxKrK5XnlOw=="], "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eJopQrUk0WR7jViYDC29+Rp50xGvs4GtWOXBeqCoFMzutkkO3CZvHehA4JqnjfWMTSS8toqvRhCSOpOz62Wf9w=="], diff --git a/package.json b/package.json index 08df7ad..5bdb220 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "@onkernel/managed-auth-react": "0.5.5", - "@onkernel/sdk": "0.141.0", + "@onkernel/sdk": "0.148.0", "@posthog/mcp": "0.17.0", "@types/jsonwebtoken": "^9.0.10", "@types/redis": "^4.0.11", diff --git a/src/lib/mcp/tools/browsers.test.ts b/src/lib/mcp/tools/browsers.test.ts index 83662be..0fa178b 100644 --- a/src/lib/mcp/tools/browsers.test.ts +++ b/src/lib/mcp/tools/browsers.test.ts @@ -1385,6 +1385,133 @@ describe("manage_browsers next actions", () => { }); }); +describe("manage_browsers create_live_view", () => { + type LiveViewCall = { id: string; body: unknown; options: unknown }; + + function liveViewClient(calls: LiveViewCall[]) { + return { + browsers: { + liveView: { + create: async (id: string, body: unknown, options: unknown) => { + calls.push({ id, body, options }); + return { + url: "https://live.example/view", + scope: "control", + expires_at: "2026-01-01T00:10:00Z", + }; + }, + }, + }, + }; + } + + test("passes scope and ttl and returns the url", async () => { + const calls: LiveViewCall[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + liveViewClient(calls), + ); + + try { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "create_live_view", + session_id: "my-browser", + live_view_scope: "control", + live_view_ttl_seconds: 600, + }, + }); + + expect(result.isError).toBeFalsy(); + expect(calls).toEqual([ + { + id: "my-browser", + body: { scope: "control", ttl_seconds: 600 }, + options: { maxRetries: 0 }, + }, + ]); + expect(toolResultJSON(result)).toMatchObject({ + url: "https://live.example/view", + scope: "control", + expires_at: "2026-01-01T00:10:00Z", + }); + } finally { + await close(); + } + }); + + test("leaves scope and ttl to the api defaults when omitted", async () => { + const calls: LiveViewCall[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + liveViewClient(calls), + ); + + try { + await client.callTool({ + name: "manage_browsers", + arguments: { action: "create_live_view", session_id: "brr_123" }, + }); + + expect(calls).toEqual([ + { id: "brr_123", body: {}, options: { maxRetries: 0 } }, + ]); + } finally { + await close(); + } + }); + + test("requires session_id", async () => { + const calls: LiveViewCall[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + liveViewClient(calls), + ); + + try { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { action: "create_live_view" }, + }); + + expect(result.isError).toBeTrue(); + expect(toolResultText(result)).toContain( + "session_id is required for create_live_view", + ); + expect(calls).toEqual([]); + } finally { + await close(); + } + }); + + test("rejects live view params on other actions", async () => { + const calls: LiveViewCall[] = []; + const { client, close } = await connectTestMcp( + registerBrowserCapabilities, + liveViewClient(calls), + ); + + try { + const result = await client.callTool({ + name: "manage_browsers", + arguments: { + action: "get", + session_id: "brr_123", + live_view_scope: "read", + }, + }); + + expect(result.isError).toBeTrue(); + expect(toolResultText(result)).toContain( + "apply only to the create_live_view action", + ); + } finally { + await close(); + } + }); +}); + describe("browser resources", () => { test("uses injected dependencies", async () => { const kernelClient = { diff --git a/src/lib/mcp/tools/browsers.ts b/src/lib/mcp/tools/browsers.ts index bff61fc..42f1d8a 100644 --- a/src/lib/mcp/tools/browsers.ts +++ b/src/lib/mcp/tools/browsers.ts @@ -498,7 +498,7 @@ export function registerBrowserCapabilities( "manage_browsers", { description: - 'manage the browser sessions that every web task runs in, and their archived telemetry. once a session exists, drive it with webmcp, browser_repl, execute_playwright_code, or computer_action, preferring the first that can carry the current step. use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, and "delete" when finished. live sessions can be addressed by id or by the name given at creation or set on update; deleted sessions only by id. get_telemetry compacts events by default; set compact=false with explicit categories and a limit of at most 5 when raw headers, request data, response bodies, or other omitted fields are needed.', + 'manage the browser sessions that every web task runs in, and their archived telemetry. once a session exists, drive it with webmcp, browser_repl, execute_playwright_code, or computer_action, preferring the first that can carry the current step. use "list" to choose an existing session, "create" before browser control, "update" to change supported session settings, "get" for full details, "get_telemetry" to diagnose active or deleted sessions, "create_live_view" to get a shareable live view url limited to read-only or control access, and "delete" when finished. live sessions can be addressed by id or by the name given at creation or set on update; deleted sessions only by id. get_telemetry compacts events by default; set compact=false with explicit categories and a limit of at most 5 when raw headers, request data, response bodies, or other omitted fields are needed.', inputSchema: z.strictObject( { ...projectSelectionInputSchema(), @@ -509,6 +509,7 @@ export function registerBrowserCapabilities( "list", "get", "get_telemetry", + "create_live_view", "delete", ]) .describe("operation to perform."), @@ -519,7 +520,7 @@ export function registerBrowserCapabilities( session_id: z .string() .describe( - "browser session id or name. required for update, get, get_telemetry, and delete actions. a name resolves only a live session; for a deleted session (get_telemetry) pass its id.", + "browser session id or name. required for update, get, get_telemetry, create_live_view, and delete actions. a name resolves only a live session; for a deleted session (get_telemetry) pass its id.", ) .optional(), name: z @@ -844,6 +845,21 @@ export function registerBrowserCapabilities( "(create, update) enable or disable user interaction telemetry (clicks, keys, scrolls). off by default; enable for debugging.", ) .optional(), + live_view_scope: z + .enum(["read", "control"]) + .describe( + "(create_live_view) read shows the browser without accepting input; control also lets the viewer drive it. defaults to read.", + ) + .optional(), + live_view_ttl_seconds: z + .number() + .int() + .min(1) + .max(31_536_000) + .describe( + "(create_live_view) seconds until the url expires. omit to keep it valid until the browser is deleted.", + ) + .optional(), }, { error: unknownBrowserKeyMessage }, ), @@ -887,6 +903,15 @@ export function registerBrowserCapabilities( "proxy routes and private hosts are creation-only; only network.allowed_hosts can change on an existing browser.", ); } + if ( + params.action !== "create_live_view" && + (params.live_view_scope !== undefined || + params.live_view_ttl_seconds !== undefined) + ) { + return errorResponse( + "error: live_view_scope and live_view_ttl_seconds apply only to the create_live_view action.", + ); + } if (params.proxy !== undefined) { const error = proxyConfigError("proxy", params.proxy) ?? @@ -1136,6 +1161,30 @@ export function registerBrowserCapabilities( compact: params.compact, }); } + case "create_live_view": { + if (!params.session_id) + return errorResponse( + "error: session_id is required for create_live_view action.", + ); + const liveView = await client.browsers.liveView.create( + params.session_id, + { + ...(params.live_view_scope !== undefined && { + scope: params.live_view_scope, + }), + ...(params.live_view_ttl_seconds !== undefined && { + ttl_seconds: params.live_view_ttl_seconds, + }), + }, + // each call mints a url that cannot be revoked, so a retry after a + // lost response would leave an extra live url behind. + { maxRetries: 0 }, + ); + return jsonResponse({ + ...liveView, + note: "the url opens only the live view (not cdp or other browser endpoints) and cannot be revoked before it expires. share it only with the intended viewer.", + }); + } case "delete": { if (!params.session_id) return errorResponse(