From 71ef4bfba549878215ae1d1fb696ff988eb6a659 Mon Sep 17 00:00:00 2001 From: nikhil Date: Tue, 6 Oct 2026 23:56:32 -0700 Subject: [PATCH 1/2] Add session WebMCP tools and invocation handles --- README.md | 34 ++++++ src/services/sessions.ts | 3 + src/services/webmcp.ts | 85 ++++++++++++++ src/types/index.ts | 1 + src/types/session.ts | 3 + src/types/webmcp.ts | 95 +++++++++++++++ tests/unit/webmcp.test.ts | 237 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 458 insertions(+) create mode 100644 src/services/webmcp.ts create mode 100644 src/types/webmcp.ts create mode 100644 tests/unit/webmcp.test.ts diff --git a/README.md b/README.md index dba5978..2b733d8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,40 @@ or yarn add @hyperbrowser/sdk ``` +## WebMCP page tools + +Create a session with `enableWebMcp: true` (default: false), then navigate to a page +that exposes WebMCP tools using Playwright or Puppeteer. With that session open: + +```typescript +const discovery = await client.sessions.webmcp.listTools(session.id); +// Select a tool using its name, source page/frame, and input schema. +const tool = discovery.tools.find((tool) => tool.name === "search"); +if (!tool) throw new Error("The page has no search tool"); +const result = await client.sessions.webmcp.invoke(session.id, { + toolRef: tool.toolRef, + input: { query: "flights to Tokyo" }, // Match the selected tool's schema. +}); +console.log(result.status, result.output); +``` + +For long-running tools or forms awaiting human submission, use +`sessions.webmcp.start(sessionId, params)`, then +`sessions.webmcp.getResult(sessionId, invocationId, { waitSeconds: 30 })` until the +status is terminal. `sessions.webmcp.cancel(sessionId, invocationId)` requests +best-effort cancellation. Blocking invocation defaults to 60 seconds (maximum +120); `start` defaults to 300 seconds (maximum 3,600) via `timeoutSeconds`. + +Discover tools again after navigation. Invocations never automatically retry; +losing a response can leave an unknown outcome. Handles are ephemeral and +terminal results expire after 10 minutes or earlier eviction. Check result +status and truncation metadata before using output. Public types, including +`WebMCPTool`, `WebMCPInvocation`, and the request types, are exported from +`@hyperbrowser/sdk/types`. + +See the [WebMCP guide](https://hyperbrowser.ai/docs/sessions/webmcp) for complete +examples, cancellation behavior, human submission, and limits. + ## Usage ### Playwright diff --git a/src/services/sessions.ts b/src/services/sessions.ts index 68c4b03..f5b0adc 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -28,6 +28,7 @@ import { SessionGetParams, } from "../types/session"; import { BaseService } from "./base"; +import { WebMCPService } from "./webmcp"; import { HyperbrowserError } from "../client"; const CAPTCHA_EVALUATION_REQUEST_TIMEOUT_MS = 185_000; @@ -65,10 +66,12 @@ class SessionEventLogsService extends BaseService { export class SessionsService extends BaseService { public readonly eventLogs: SessionEventLogsService; + public readonly webmcp: WebMCPService; constructor(apiKey: string, baseUrl: string, timeout: number) { super(apiKey, baseUrl, timeout); this.eventLogs = new SessionEventLogsService(apiKey, baseUrl, timeout); + this.webmcp = new WebMCPService(apiKey, baseUrl, timeout); } /** diff --git a/src/services/webmcp.ts b/src/services/webmcp.ts new file mode 100644 index 0000000..64ede48 --- /dev/null +++ b/src/services/webmcp.ts @@ -0,0 +1,85 @@ +import { BaseService } from "./base"; +import { + WebMCPInvocation, + WebMCPInvokeParams, + WebMCPInvokeResult, + WebMCPResultParams, + WebMCPStartParams, + WebMCPToolsResponse, +} from "../types/webmcp"; + +function seconds(value: number, min: number, max: number, name: string): number { + if (!Number.isInteger(value) || value < min || value > max) { + throw new RangeError(`${name} must be an integer between ${min} and ${max}`); + } + return value; +} + +function invocationBody(params: WebMCPInvokeParams): string { + if (typeof params.toolRef !== "string" || !params.toolRef.trim()) { + throw new TypeError("toolRef must be a non-empty string from listTools"); + } + if ( + params.input !== undefined && + (params.input === null || typeof params.input !== "object" || Array.isArray(params.input)) + ) { + throw new TypeError("input must be a JSON object"); + } + return JSON.stringify(params); +} + +/** Page tools for sessions created with enableWebMcp: true. */ +export class WebMCPService extends BaseService { + private path(sessionId: string): string { + return `/session/${encodeURIComponent(sessionId)}/webmcp`; + } + + /** Discover current tools across the session's tabs and frames. */ + async listTools(sessionId: string): Promise { + return this.request(`${this.path(sessionId)}/tools`, { + timeout: Math.max(this.timeout, 40_000), + }); + } + + /** Invoke once. Transport failures never cause an automatic retry. */ + async invoke(sessionId: string, params: WebMCPInvokeParams): Promise { + const timeout = seconds(params.timeoutSeconds ?? 60, 1, 120, "timeoutSeconds"); + return this.request(`${this.path(sessionId)}/invoke`, { + method: "POST", + body: invocationBody(params), + timeout: Math.max(this.timeout, (timeout + 35) * 1000), + }); + } + + /** Start once and return a handle. A lost response may mean the tool already ran. */ + async start(sessionId: string, params: WebMCPStartParams): Promise { + seconds(params.timeoutSeconds ?? 300, 1, 3600, "timeoutSeconds"); + return this.request(`${this.path(sessionId)}/invocations`, { + method: "POST", + body: invocationBody(params), + timeout: Math.max(this.timeout, 40_000), + }); + } + + /** Read or long-poll a handle. Pending responses do not cancel or restart the tool. */ + async getResult( + sessionId: string, + invocationId: string, + params: WebMCPResultParams = {} + ): Promise { + const wait = seconds(params.waitSeconds ?? 0, 0, 30, "waitSeconds"); + return this.request( + `${this.path(sessionId)}/invocations/${encodeURIComponent(invocationId)}`, + { timeout: Math.max(this.timeout, (wait + 20) * 1000) }, + { waitSeconds: params.waitSeconds } + ); + } + + /** Request cancellation; completion can win the race. Read getResult for the outcome. */ + async cancel(sessionId: string, invocationId: string): Promise { + return this.request( + `${this.path(sessionId)}/invocations/${encodeURIComponent(invocationId)}/cancel`, + { method: "POST", timeout: Math.max(this.timeout, 20_000) } + ); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index c121918..c8eff94 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export { HyperbrowserConfig } from "./config"; +export * from "./webmcp"; export { StartCrawlJobParams, StartCrawlJobResponse, diff --git a/src/types/session.ts b/src/types/session.ts index 0298c28..84c8ee4 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -24,6 +24,7 @@ export interface SessionProfile { } export interface SessionLaunchState { + enableWebMcp?: boolean; useUltraStealth?: boolean; useStealth?: boolean; useProxy?: boolean; @@ -165,6 +166,8 @@ export interface StartSessionFromSnapshotParams { } export interface CreateSessionParams { + /** Enable page-provided WebMCP tools. Defaults to false on the server. */ + enableWebMcp?: boolean; useUltraStealth?: boolean; useStealth?: boolean; useProxy?: boolean; diff --git a/src/types/webmcp.ts b/src/types/webmcp.ts new file mode 100644 index 0000000..e19e0a7 --- /dev/null +++ b/src/types/webmcp.ts @@ -0,0 +1,95 @@ +/** Page-provided hints; these are not browser-enforced guarantees. */ +export interface WebMCPAnnotations { + readOnly: boolean; + untrustedContent: boolean; + consequential: boolean; + autosubmit: boolean; + destructive?: boolean; + idempotent?: boolean; + openWorld?: boolean; +} + +export interface WebMCPFrame { + id: string; + url: string; + isMainFrame: boolean; +} + +export interface WebMCPSource { + provider: "native" | "polyfill"; + tabId: string; + pageUrl: string; + pageTitle: string; + frame: WebMCPFrame; +} + +export interface WebMCPTool { + /** Opaque reference to this document. Discover tools again after navigation. */ + toolRef: string; + name: string; + title?: string; + description: string; + inputSchema?: Record; + outputSchema?: Record; + annotations: WebMCPAnnotations; + declarative: boolean; + /** Native DOM node identifier, when available, in the source target/frame. */ + backendNodeId?: number; + source: WebMCPSource; +} + +export interface WebMCPToolsResponse { + tools: WebMCPTool[]; + nativeSupported: boolean; + truncated: boolean; +} + +export interface WebMCPInvokeParams { + toolRef: string; + /** Page-defined keys are passed through unchanged. Defaults to {}. */ + input?: Record; + /** Execution deadline in seconds: 1–120, default 60. */ + timeoutSeconds?: number; +} + +export interface WebMCPStartParams extends WebMCPInvokeParams { + /** Execution deadline including human submission: 1–3600 seconds, default 300. */ + timeoutSeconds?: number; +} + +export interface WebMCPResultParams { + /** Wait 0–30 seconds, default 0. A pending response does not cancel the tool. */ + waitSeconds?: number; +} + +export type WebMCPInvokeStatus = "completed" | "error" | "canceled" | "awaiting_submission"; +export type WebMCPInvocationStatus = WebMCPInvokeStatus | "running" | "outcome_unknown"; + +export interface WebMCPInvokeResult { + invocationId?: string; + status: WebMCPInvokeStatus; + output?: unknown; + errorText?: string; + outputBytes: number; + outputTruncated?: boolean; + outputPreview?: string; + untrustedContent: boolean; + durationMs: number; +} + +export interface WebMCPInvocationError { + code: string; + message: string; +} + +/** Session-local record; results expire and do not survive receiver restarts. */ +export interface WebMCPInvocation { + invocationId: string; + toolRef: string; + status: WebMCPInvocationStatus; + cancellationRequested: boolean; + createdAt: string; + expiresAt?: string; + result?: WebMCPInvokeResult; + error?: WebMCPInvocationError; +} diff --git a/tests/unit/webmcp.test.ts b/tests/unit/webmcp.test.ts new file mode 100644 index 0000000..253913e --- /dev/null +++ b/tests/unit/webmcp.test.ts @@ -0,0 +1,237 @@ +import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; +import { Response } from "node-fetch"; + +const fetchMock = vi.fn(); +vi.mock("node-fetch", async () => { + const actual = await vi.importActual("node-fetch"); + return { ...actual, default: (...args: unknown[]) => fetchMock(...args) }; +}); +vi.mock("../../src/retry", async () => { + const actual = await vi.importActual("../../src/retry"); + return { ...actual, retryDelay: vi.fn().mockResolvedValue(undefined) }; +}); + +import { HyperbrowserClient as Hyperbrowser } from "../../src/client"; +import type { CreateSessionParams, WebMCPInvocation, WebMCPTool } from "../../src/types"; + +const client = (timeout = 1000) => + new Hyperbrowser({ apiKey: "test-key", baseUrl: "https://example.test", timeout }); +const respond = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const id = "0123456789abcdef01234567"; +const handle = { + invocationId: id, + toolRef: "document-tool", + status: "running", + cancellationRequested: false, + createdAt: "2026-10-06T12:00:00Z", +}; +const result = { + status: "completed", + output: { snake_key: { CamelKey: [null, 42] } }, + outputBytes: 37, + untrustedContent: true, + durationMs: 12, +}; + +afterEach(() => fetchMock.mockReset()); + +test("session opt-in is optional and preserves explicit false", async () => { + fetchMock.mockImplementation(() => Promise.resolve(respond({ id: "session" }))); + const sdk = client(); + for (const params of [ + {}, + { enableWebMcp: false }, + { enableWebMcp: true }, + ] satisfies CreateSessionParams[]) { + await sdk.sessions.create(params); + expect(JSON.parse(fetchMock.mock.lastCall![1].body)).toEqual(params); + } + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + Awaited> + >().toEqualTypeOf(); +}); + +test("discovers full tool metadata without rewriting schema or source keys", async () => { + const discovery = { + nativeSupported: true, + truncated: true, + tools: [ + { + toolRef: "document-tool", + name: "search", + description: "Search", + declarative: true, + backendNodeId: 123, + inputSchema: { type: "object", properties: { user_query: { type: "string" } } }, + outputSchema: { type: "object" }, + annotations: { + readOnly: true, + untrustedContent: true, + consequential: false, + autosubmit: false, + openWorld: true, + }, + source: { + provider: "native", + tabId: "tab", + pageUrl: "https://page.test", + pageTitle: "Page", + frame: { id: "frame", url: "https://page.test/frame", isMainFrame: false }, + }, + }, + ], + }; + fetchMock.mockResolvedValueOnce(respond(discovery)); + expect(await client().sessions.webmcp.listTools("session /#")).toEqual(discovery); + expect(fetchMock).toHaveBeenCalledWith( + "https://example.test/api/session/session%20%2F%23/webmcp/tools", + expect.objectContaining({ timeout: 40_000 }) + ); +}); + +test("invokes once and preserves arbitrary JSON inputs and outputs", async () => { + fetchMock.mockResolvedValueOnce(respond(result)); + const params = { + toolRef: "document-tool", + input: { snake_key: { CamelKey: null } }, + timeoutSeconds: 120, + }; + expect(await client().sessions.webmcp.invoke("session", params)).toEqual(result); + expect(fetchMock).toHaveBeenCalledWith( + "https://example.test/api/session/session/webmcp/invoke", + expect.objectContaining({ method: "POST", body: JSON.stringify(params), timeout: 155_000 }) + ); +}); + +test("starts, polls through pending states, and cancels without invoking again", async () => { + const sdk = client(); + fetchMock + .mockResolvedValueOnce(respond(handle, 202)) + .mockResolvedValueOnce(respond({ ...handle, status: "awaiting_submission" })) + .mockResolvedValueOnce( + respond({ ...handle, status: "completed", result, cancellationRequested: true }) + ); + const started = await sdk.sessions.webmcp.start("session", { + toolRef: "document-tool", + timeoutSeconds: 3600, + }); + expect(started.status).toBe("running"); + expect(fetchMock.mock.lastCall![1].timeout).toBe(40_000); + const pending = await sdk.sessions.webmcp.getResult("session", started.invocationId, { + waitSeconds: 30, + }); + expect(pending.status).toBe("awaiting_submission"); + expect(fetchMock.mock.lastCall![0]).toBe( + `https://example.test/api/session/session/webmcp/invocations/${id}?waitSeconds=30` + ); + expect(fetchMock.mock.lastCall![1].timeout).toBe(50_000); + const canceled = await sdk.sessions.webmcp.cancel("session", started.invocationId); + expect(canceled.status).toBe("completed"); // Completion may win cancellation. + expect(canceled.result).toEqual(result); + expect(fetchMock.mock.lastCall![0]).toBe( + `https://example.test/api/session/session/webmcp/invocations/${id}/cancel` + ); + expect(fetchMock.mock.lastCall![1].method).toBe("POST"); + expect(fetchMock).toHaveBeenCalledTimes(3); +}); + +test.each(["invoke", "start", "cancel"] as const)( + "%s does not retry network or transient API errors", + async (method) => { + for (const failure of ["network", "api"]) { + fetchMock.mockReset(); + if (failure === "network") + fetchMock.mockRejectedValue( + Object.assign(new Error("socket lost"), { code: "ECONNRESET" }) + ); + else + fetchMock.mockImplementation(() => + Promise.resolve( + respond({ code: "outcome_unknown", message: "Unknown outcome", invocationId: id }, 504) + ) + ); + const sdk = client().sessions.webmcp; + const request = + method === "cancel" + ? sdk.cancel("session", id) + : sdk[method]("session", { toolRef: "document-tool" }); + await expect(request).rejects.toMatchObject( + failure === "api" + ? { + code: "outcome_unknown", + details: { invocationId: id }, + statusCode: 504, + } + : { retryable: true } + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + } + } +); + +test("result GET retries safely and exposes expired handles", async () => { + fetchMock + .mockResolvedValueOnce(respond({ message: "busy" }, 503)) + .mockResolvedValueOnce(respond(handle)); + expect(await client().sessions.webmcp.getResult("session", id)).toEqual(handle); + expect(fetchMock).toHaveBeenCalledTimes(2); + fetchMock.mockReset(); + fetchMock.mockResolvedValueOnce( + respond({ code: "invocation_not_found", message: "Expired" }, 404) + ); + await expect(client().sessions.webmcp.getResult("session", id)).rejects.toMatchObject({ + code: "invocation_not_found", + statusCode: 404, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); + +test("retains truncation and form handles without fabricating output", async () => { + for (const payload of [ + { ...result, output: null }, + { + ...result, + output: undefined, + outputTruncated: true, + outputBytes: 2_000_000, + outputPreview: "preview", + }, + { ...result, output: undefined, status: "awaiting_submission", invocationId: id }, + { ...result, output: undefined, status: "error", errorText: "Page error" }, + ]) { + fetchMock.mockResolvedValueOnce(respond(payload)); + expect(await client().sessions.webmcp.invoke("session", { toolRef: "document-tool" })).toEqual( + JSON.parse(JSON.stringify(payload)) + ); + } +}); + +test("respects a larger client timeout without mutating it", async () => { + fetchMock.mockImplementation(() => Promise.resolve(respond(handle))); + const sdk = client(200_000).sessions.webmcp; + await sdk.listTools("s"); + await sdk.invoke("s", { toolRef: "t" }); + await sdk.start("s", { toolRef: "t" }); + await sdk.getResult("s", id, { waitSeconds: 30 }); + await sdk.cancel("s", id); + expect(fetchMock.mock.calls.every(([, init]) => init.timeout === 200_000)).toBe(true); +}); + +test("rejects invalid time budgets and input before sending a request", async () => { + const sdk = client().sessions.webmcp; + for (const timeoutSeconds of [0, 121, 1.5, NaN]) + await expect(sdk.invoke("s", { toolRef: "t", timeoutSeconds })).rejects.toBeInstanceOf( + RangeError + ); + for (const timeoutSeconds of [0, 3601]) + await expect(sdk.start("s", { toolRef: "t", timeoutSeconds })).rejects.toBeInstanceOf( + RangeError + ); + for (const waitSeconds of [-1, 31, 0.1]) + await expect(sdk.getResult("s", id, { waitSeconds })).rejects.toBeInstanceOf(RangeError); + await expect(sdk.invoke("s", { toolRef: "" })).rejects.toBeInstanceOf(TypeError); + // @ts-expect-error Tool inputs must be objects, not arrays. + await expect(sdk.start("s", { toolRef: "t", input: [] })).rejects.toBeInstanceOf(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); +}); From ac6cef11e7f6d79e54737b0109c3c7d47ccd52df Mon Sep 17 00:00:00 2001 From: nikhil Date: Sat, 10 Oct 2026 14:30:17 -0700 Subject: [PATCH 2/2] Bump SDK version to 1.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cbf9770..05f9a5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "1.1.0", + "version": "1.2.0", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": {