From a066595bf65493af84cdeca7941ed8fcc2adc8a5 Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Tue, 18 Aug 2026 18:14:09 +0530 Subject: [PATCH 1/7] feat(integrations): add Cursor SDK integration and docs --- packages/docs/docs.json | 1 + packages/docs/images/integrations/cursor.svg | 12 + packages/docs/v4/integrations/cursor.mdx | 84 ++++++ packages/docs/v4/integrations/overview.mdx | 9 +- packages/integrations/cursor/README.md | 67 +++++ packages/integrations/cursor/package.json | 24 ++ packages/integrations/cursor/src/agent.ts | 153 +++++++++++ .../integrations/cursor/tests/agent.test.ts | 250 ++++++++++++++++++ packages/integrations/cursor/tsconfig.json | 14 + packages/integrations/cursor/vitest.config.ts | 9 + pnpm-lock.yaml | 150 +++++++++++ pnpm-workspace.yaml | 1 + turbo.json | 3 + 13 files changed, 774 insertions(+), 3 deletions(-) create mode 100644 packages/docs/images/integrations/cursor.svg create mode 100644 packages/docs/v4/integrations/cursor.mdx create mode 100644 packages/integrations/cursor/README.md create mode 100644 packages/integrations/cursor/package.json create mode 100644 packages/integrations/cursor/src/agent.ts create mode 100644 packages/integrations/cursor/tests/agent.test.ts create mode 100644 packages/integrations/cursor/tsconfig.json create mode 100644 packages/integrations/cursor/vitest.config.ts diff --git a/packages/docs/docs.json b/packages/docs/docs.json index f6e2d3a120..75c8072cdc 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -51,6 +51,7 @@ "v4/integrations/overview", "v4/integrations/claude-code", "v4/integrations/codex", + "v4/integrations/cursor", "v4/integrations/eve", "v4/integrations/deep-agents", "v4/integrations/crewai", diff --git a/packages/docs/images/integrations/cursor.svg b/packages/docs/images/integrations/cursor.svg new file mode 100644 index 0000000000..162e4cd009 --- /dev/null +++ b/packages/docs/images/integrations/cursor.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/docs/v4/integrations/cursor.mdx b/packages/docs/v4/integrations/cursor.mdx new file mode 100644 index 0000000000..c04a9f3e24 --- /dev/null +++ b/packages/docs/v4/integrations/cursor.mdx @@ -0,0 +1,84 @@ +--- +title: "Cursor" +description: "Give a local Cursor SDK agent persistent Stagehand browser tools over MCP/stdio." +--- + +The Cursor integration creates a local agent through `@cursor/sdk` and mounts the Stagehand facade as one MCP/stdio server. One Cursor agent and one facade process own the browser across `run`, `snapshot`, and `screenshot` calls, preserving page state and snapshot IDs. + + +Stagehand ships this experimental integration from the repository rather than publishing it as a standalone adapter. + + +## Prerequisites + +- Node.js 24 or newer +- pnpm 11.10.0 +- A Cursor user or service-account API key +- A current Google Chrome installation for local browser mode + +## Quickstart + + + +```bash +git clone https://github.com/browserbase/stagehand.git +cd stagehand +pnpm install --frozen-lockfile +pnpm exec turbo run build \ + --filter @browserbasehq/stagehand-integrations +``` + + +Create a user API key in the Cursor dashboard or a service-account key in your team settings, then export it: + +```bash +export CURSOR_API_KEY="your-cursor-api-key" +``` + +The example uses `composer-2.5` by default. Set `CURSOR_STAGEHAND_MODEL` to another model ID available to your Cursor account when needed. + + +The example defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise it uses local Chrome: + +```bash +export STAGEHAND_BROWSER="browserbase" +export BROWSERBASE_API_KEY="your-browserbase-api-key" +``` + + +```bash +pnpm --dir packages/integrations/cursor start -- \ + "Open https://example.com, snapshot it, and report the heading." +``` + + + +## Configuration + +| Variable | Purpose | +| --- | --- | +| `CURSOR_API_KEY` | Cursor user or service-account key. The example does not forward it to the MCP child. | +| `CURSOR_STAGEHAND_MODEL` | Optional Cursor model ID; defaults to `composer-2.5`. | +| `STAGEHAND_BROWSER` | Select `local` or `browserbase`. Defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise local. | +| `BROWSERBASE_API_KEY` | Required for Browserbase. | +| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project ID. | +| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. | +| `STAGEHAND_MODEL_API_KEY` | Required with `STAGEHAND_MODEL_NAME`. | + +## SDK and session lifecycle + +This V1 integration uses Cursor's local runtime. The agent loop and temporary workspace run locally, while model inference remains hosted by Cursor. Cursor Cloud requires a remotely reachable MCP server and is not part of this example. + +The example creates an isolated temporary workspace, disables ambient Cursor setting sources, and enables only Cursor's `mcp` capability group. Its only inline server is the Stagehand facade, so the model receives `run`, `snapshot`, and `screenshot` without shell, file-editing, user MCP, or project MCP tools. The canonical Stagehand tool instructions are prefixed to the task because the main Cursor agent API does not expose a separate system-prompt option. + +The facade process remains attached to one Cursor agent for the full run. On completion or failure, the example disposes the agent and deletes its temporary workspace. `SIGINT` and `SIGTERM` first cancel the active run so in-flight MCP calls stop cleanly. + +Only non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables cross into the MCP child. The Cursor API key and unrelated host secrets remain in the agent process. + + +`run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/overview#security-boundary). + + + + Read the isolated Cursor agent setup, MCP mount, lifecycle handling, and unit tests. + diff --git a/packages/docs/v4/integrations/overview.mdx b/packages/docs/v4/integrations/overview.mdx index 1073a73842..c4461342e4 100644 --- a/packages/docs/v4/integrations/overview.mdx +++ b/packages/docs/v4/integrations/overview.mdx @@ -1,7 +1,7 @@ --- title: "Integrations" sidebarTitle: "Overview" -description: "Connect Claude Code, Codex, CrewAI, Deep Agents, Eve, Mastra, Pi, or the Vercel AI SDK to a persistent Stagehand browser." +description: "Connect Claude Code, Codex, Cursor, CrewAI, Deep Agents, Eve, Mastra, Pi, or the Vercel AI SDK to a persistent Stagehand browser." --- Each integration gives your agent one persistent browser and three tools: `run`, `snapshot`, and `screenshot`. Your agent decides how to navigate and interact while Stagehand manages the browser session. @@ -19,6 +19,9 @@ Stagehand ships these experimental integrations from the monorepo and does not p Give a Codex agent persistent Stagehand browser tools over MCP/stdio. + + Give a local Cursor SDK agent persistent Stagehand browser tools over MCP/stdio. + Give Vercel's framework for building durable agents native Stagehand tools. @@ -75,7 +78,7 @@ Every integration exposes the same browser capabilities. The tools share a browser for the lifetime of the integration's client session. A navigation performed by `run` is visible to the next `snapshot`, and authentication and page state remain available across calls. -The Claude Code, Codex, CrewAI, Mastra, Vercel AI SDK, and local Deep Agents examples keep one MCP client session open so the stdio server and browser stay alive. Eve, Pi, and Managed Deep Agents bind equivalent tools in-process. +The Claude Code, Codex, Cursor, CrewAI, Mastra, Vercel AI SDK, and local Deep Agents examples keep one MCP client session open so the stdio server and browser stay alive. Eve, Pi, and Managed Deep Agents bind equivalent tools in-process. The private [`core/` workspace package](https://github.com/browserbase/stagehand/tree/main/packages/integrations/core) owns the shared TypeScript tool contract, browser runtime, native bindings, and stdio MCP server. The TypeScript integrations and CrewAI use this package. @@ -85,7 +88,7 @@ Do not create a new MCP process for every tool call. Doing so starts a new brows ## Run the integrations from source -Claude Code, Codex, CrewAI, Mastra, and the Vercel AI SDK use the shared TypeScript Stagehand facade MCP server. Eve and Pi use native in-process bindings from the same package. These integrations require Node.js 24 or newer and [pnpm](https://pnpm.io/installation) 11.10.0. CrewAI also requires Python 3.11–3.13 and [uv](https://docs.astral.sh/uv/). +Claude Code, Codex, Cursor, CrewAI, Mastra, and the Vercel AI SDK use the shared TypeScript Stagehand facade MCP server. Eve and Pi use native in-process bindings from the same package. These integrations require Node.js 24 or newer and [pnpm](https://pnpm.io/installation) 11.10.0. CrewAI also requires Python 3.11–3.13 and [uv](https://docs.astral.sh/uv/). diff --git a/packages/integrations/cursor/README.md b/packages/integrations/cursor/README.md new file mode 100644 index 0000000000..12092e3b4a --- /dev/null +++ b/packages/integrations/cursor/README.md @@ -0,0 +1,67 @@ +# Cursor SDK + Stagehand facade over MCP/stdio + +A runnable example embedding Cursor through `@cursor/sdk`, with the Stagehand facade +(`run` / `snapshot` / `screenshot`) mounted as the local agent's only MCP server. One Cursor +agent and one facade process stay alive for the full task, so browser state and snapshot IDs +survive across tool calls. + +## Setup + +Use Node.js 24 or later. From the repository root, install dependencies and build the shared +integration package: + +```bash +pnpm install +pnpm exec turbo run build --filter @browserbasehq/stagehand-integrations +``` + +Export a Cursor user or service-account API key. Browserbase is the recommended browser backend +for untrusted tasks: + +```bash +export CURSOR_API_KEY=key_... +export BROWSERBASE_API_KEY=bb_live_... +export BROWSERBASE_PROJECT_ID=... +``` + +## Run + +With local Chrome: + +```bash +STAGEHAND_BROWSER=local pnpm --dir packages/integrations/cursor start -- \ + "Open https://example.com, snapshot it, and report the heading." +``` + +With Browserbase: + +```bash +STAGEHAND_BROWSER=browserbase pnpm --dir packages/integrations/cursor start -- \ + "Open https://example.com, take a screenshot, and report the page title." +``` + +| Variable | Purpose | +| ------------------------- | -------------------------------------------------------------------------------------------- | +| `CURSOR_API_KEY` | Cursor user or service-account key. Never forwarded to the browser process. | +| `CURSOR_STAGEHAND_MODEL` | Optional Cursor model ID. Defaults to `composer-2.5`. | +| `STAGEHAND_BROWSER` | Browser backend. Defaults to Browserbase when `BROWSERBASE_API_KEY` is set, otherwise local. | +| `BROWSERBASE_API_KEY` | Browserbase credential for the browser session. | +| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project. | +| `STAGEHAND_MODEL_NAME` | Optional model for Stagehand AI methods called inside `run`. | +| `STAGEHAND_MODEL_API_KEY` | Credential for `STAGEHAND_MODEL_NAME`. | + +This example intentionally uses Cursor's local agent runtime. "Local" means the agent loop and +workspace run on your machine; model inference remains hosted by Cursor. The temporary workspace +loads no ambient Cursor settings, and the agent's built-in tool allowlist contains only the `mcp` +capability. Its one inline MCP server is the Stagehand facade, so shell and file-editing tools are +not offered to the model. + +The MCP child receives only non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables. The Cursor API +key and unrelated host secrets remain in the agent process. `SIGINT` and `SIGTERM` cancel the +active Cursor run before the agent, MCP process, and temporary workspace are cleaned up. + +## Security model + +The `run` tool executes model-authored JavaScript inside the Stagehand browser extension's service +worker, not in the Cursor agent process. Browserbase is the recommended isolation boundary: the +privileged execution environment is a disposable cloud browser. diff --git a/packages/integrations/cursor/package.json b/packages/integrations/cursor/package.json new file mode 100644 index 0000000000..d62ce27b58 --- /dev/null +++ b/packages/integrations/cursor/package.json @@ -0,0 +1,24 @@ +{ + "name": "@browserbasehq/stagehand-integrations-example-cursor-facade", + "version": "4.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "node src/agent.ts", + "test": "pnpm -w exec turbo run build --filter @browserbasehq/stagehand-integrations && vitest run", + "test:unit": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@browserbasehq/stagehand-integrations": "workspace:*", + "@cursor/sdk": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=24" + } +} diff --git a/packages/integrations/cursor/src/agent.ts b/packages/integrations/cursor/src/agent.ts new file mode 100644 index 0000000000..54f5ae8f61 --- /dev/null +++ b/packages/integrations/cursor/src/agent.ts @@ -0,0 +1,153 @@ +import { Agent, JsonlLocalAgentStore, type AgentOptions, type RunResult } from "@cursor/sdk"; +import { FACADE_AGENT_INSTRUCTIONS } from "@browserbasehq/stagehand-integrations/facade"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_CURSOR_MODEL = "composer-2.5"; + +export function buildAllowlistedEnv( + source: NodeJS.ProcessEnv = process.env, +): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (/^(STAGEHAND_|BROWSERBASE_)/u.test(key) && value) env[key] = value; + } + return env; +} + +export function buildCursorAgentOptions( + workspaceDirectory: string, + facadeServerPath: string, + source: NodeJS.ProcessEnv = process.env, +): AgentOptions { + const apiKey = source.CURSOR_API_KEY?.trim(); + const model = source.CURSOR_STAGEHAND_MODEL?.trim() || DEFAULT_CURSOR_MODEL; + return { + ...(apiKey ? { apiKey } : {}), + model: { id: model }, + name: "Stagehand browser task", + local: { + cwd: workspaceDirectory, + // Do not inherit project/user MCP servers, hooks, skills, or other + // ambient Cursor configuration into this isolated example run. + settingSources: [], + // Keep Cursor's durable local-agent records inside the disposable + // workspace instead of writing them to the user's default SDK store. + store: new JsonlLocalAgentStore(join(workspaceDirectory, ".cursor-sdk-store")), + }, + // Cursor treats "mcp" as the capability group for all MCP tools. Because + // the isolated agent has exactly one inline server, this leaves only the + // Stagehand facade's run, snapshot, and screenshot tools available. + tools: ["mcp"], + mcpServers: { + stagehand: { + type: "stdio", + command: process.execPath, + args: [facadeServerPath], + env: buildAllowlistedEnv(source), + }, + }, + }; +} + +export function buildCursorPrompt(instruction: string): string { + return `${FACADE_AGENT_INSTRUCTIONS}\n\nTask:\n${instruction}`; +} + +export function resolveInstruction(args: string[]): string { + return (args[0] === "--" ? args.slice(1) : args).join(" ").trim(); +} + +type CursorRunResult = Pick; + +export type CursorRuntimeRun = { + wait(): Promise; + cancel(): Promise; +}; + +export type CursorRuntimeAgent = { + send(message: string): Promise; + [Symbol.asyncDispose](): Promise; +}; + +export type RunCursorOptions = { + env?: NodeJS.ProcessEnv; + facadeServerPath?: string; + makeWorkspaceDirectory?: () => Promise; + createAgent?: (options: AgentOptions) => Promise; +}; + +export async function runCursor( + instruction: string, + options: RunCursorOptions = {}, +): Promise { + const env = options.env ?? process.env; + const facadeServerPath = + options.facadeServerPath ?? + fileURLToPath(import.meta.resolve("@browserbasehq/stagehand-integrations/facade/stdio-server")); + const workspaceDirectory = await (options.makeWorkspaceDirectory ?? createWorkspaceDirectory)(); + let agent: CursorRuntimeAgent | undefined; + let activeRun: CursorRuntimeRun | undefined; + let interrupted = false; + + const removeSignalHandlers = forwardTerminationSignals(() => { + interrupted = true; + if (activeRun) void activeRun.cancel().catch(() => undefined); + }); + + try { + const createAgent = options.createAgent ?? ((agentOptions) => Agent.create(agentOptions)); + agent = await createAgent(buildCursorAgentOptions(workspaceDirectory, facadeServerPath, env)); + if (interrupted) throw new Error("Cursor run interrupted."); + activeRun = await agent.send(buildCursorPrompt(instruction)); + + const result = await activeRun.wait(); + if (interrupted) throw new Error("Cursor run interrupted."); + if (result.status === "cancelled") throw new Error("Cursor run interrupted."); + if (result.status === "error") { + const detail = result.error?.message?.trim(); + throw new Error(detail ? `Cursor run failed: ${detail}` : "Cursor run failed."); + } + + const text = result.result?.trim(); + if (!text) throw new Error("Cursor returned no assistant text."); + return text; + } finally { + removeSignalHandlers(); + try { + if (agent) await agent[Symbol.asyncDispose](); + } finally { + await rm(workspaceDirectory, { recursive: true, force: true }); + } + } +} + +async function createWorkspaceDirectory(): Promise { + return mkdtemp(join(tmpdir(), "stagehand-cursor-")); +} + +function forwardTerminationSignals(onSignal: () => void): () => void { + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + return () => { + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + }; +} + +async function main(): Promise { + const instruction = resolveInstruction(process.argv.slice(2)); + if (!instruction) throw new Error('Usage: pnpm start "your instruction"'); + // oxlint-disable-next-line no-console -- CLI example prints the agent result. + console.log(await runCursor(instruction)); +} + +if (import.meta.main) { + main().catch((error: unknown) => { + // oxlint-disable-next-line no-console -- CLI example reports failures to stderr. + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/packages/integrations/cursor/tests/agent.test.ts b/packages/integrations/cursor/tests/agent.test.ts new file mode 100644 index 0000000000..d1b49765d7 --- /dev/null +++ b/packages/integrations/cursor/tests/agent.test.ts @@ -0,0 +1,250 @@ +import { access, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { JsonlLocalAgentStore } from "@cursor/sdk"; +import { FACADE_AGENT_INSTRUCTIONS } from "@browserbasehq/stagehand-integrations/facade"; + +import { + buildAllowlistedEnv, + buildCursorAgentOptions, + buildCursorPrompt, + resolveInstruction, + runCursor, + type CursorRuntimeAgent, +} from "../src/agent.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("cursor stagehand example", () => { + it("forwards only non-empty Stagehand and Browserbase variables", () => { + expect( + buildAllowlistedEnv({ + STAGEHAND_BROWSER: "browserbase", + BROWSERBASE_API_KEY: "bb-secret", + CURSOR_API_KEY: "cursor-secret", + HOST_SECRET: "host-secret", + STAGEHAND_EMPTY: "", + }), + ).toEqual({ + STAGEHAND_BROWSER: "browserbase", + BROWSERBASE_API_KEY: "bb-secret", + }); + }); + + it("configures one isolated MCP-only local agent", () => { + const options = buildCursorAgentOptions("/tmp/workspace", "/tmp/facade-server.mjs", { + CURSOR_API_KEY: "cursor-secret", + STAGEHAND_BROWSER: "local", + HOST_SECRET: "host-secret", + }); + + expect(options).toMatchObject({ + apiKey: "cursor-secret", + model: { id: "composer-2.5" }, + tools: ["mcp"], + local: { cwd: "/tmp/workspace", settingSources: [] }, + mcpServers: { + stagehand: { + type: "stdio", + command: process.execPath, + args: ["/tmp/facade-server.mjs"], + env: { STAGEHAND_BROWSER: "local" }, + }, + }, + }); + expect(JSON.stringify(options.mcpServers)).not.toContain("cursor-secret"); + expect(JSON.stringify(options.mcpServers)).not.toContain("host-secret"); + expect(options.local?.store).toBeInstanceOf(JsonlLocalAgentStore); + }); + + it("uses an optional model override without requiring an explicit API key", () => { + const options = buildCursorAgentOptions("/tmp/workspace", "/tmp/server.mjs", { + CURSOR_STAGEHAND_MODEL: "custom-model", + }); + expect(options.model).toEqual({ id: "custom-model" }); + expect(options).not.toHaveProperty("apiKey"); + }); + + it("prefixes the task with canonical Stagehand instructions", () => { + const prompt = buildCursorPrompt("browse example.com"); + expect(prompt).toBe(`${FACADE_AGENT_INSTRUCTIONS}\n\nTask:\nbrowse example.com`); + }); + + it("normalizes pnpm's optional argument separator", () => { + expect(resolveInstruction(["--", "open", "example.com"])).toBe("open example.com"); + expect(resolveInstruction(["open", "example.com"])).toBe("open example.com"); + }); + + it("returns final assistant text and cleans up the agent and workspace", async () => { + const directory = await makeTemporaryDirectory(); + const fake = fakeAgent({ status: "finished", result: " done " }); + const createAgent = vi.fn(async () => fake.agent); + + await expect( + runCursor("browse", { + env: { STAGEHAND_BROWSER: "local", CURSOR_API_KEY: "cursor-secret" }, + facadeServerPath: "/tmp/facade-server.mjs", + makeWorkspaceDirectory: async () => directory, + createAgent, + }), + ).resolves.toBe("done"); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + tools: ["mcp"], + local: expect.objectContaining({ cwd: directory, settingSources: [] }), + }), + ); + expect(fake.send).toHaveBeenCalledWith(buildCursorPrompt("browse")); + expect(fake.dispose).toHaveBeenCalledOnce(); + await expect(access(directory)).rejects.toThrow(); + }); + + it.each([ + [{ status: "finished" as const }, "Cursor returned no assistant text."], + [ + { status: "error" as const, error: { message: "backend unavailable" } }, + "Cursor run failed: backend unavailable", + ], + [{ status: "cancelled" as const }, "Cursor run interrupted."], + ])("reports terminal result failures and still cleans up", async (result, message) => { + const directory = await makeTemporaryDirectory(); + const fake = fakeAgent(result); + + await expect( + runCursor("browse", { + facadeServerPath: "/tmp/facade-server.mjs", + makeWorkspaceDirectory: async () => directory, + createAgent: async () => fake.agent, + }), + ).rejects.toThrow(message); + + expect(fake.dispose).toHaveBeenCalledOnce(); + await expect(access(directory)).rejects.toThrow(); + }); + + it("removes the workspace when agent creation fails", async () => { + const directory = await makeTemporaryDirectory(); + await expect( + runCursor("browse", { + facadeServerPath: "/tmp/facade-server.mjs", + makeWorkspaceDirectory: async () => directory, + createAgent: async () => { + throw new Error("creation failed"); + }, + }), + ).rejects.toThrow("creation failed"); + await expect(access(directory)).rejects.toThrow(); + }); + + it("disposes the agent and removes the workspace when send fails", async () => { + const directory = await makeTemporaryDirectory(); + const dispose = vi.fn(async () => undefined); + const agent: CursorRuntimeAgent = { + send: vi.fn(async () => { + throw new Error("send failed"); + }), + [Symbol.asyncDispose]: dispose, + }; + + await expect( + runCursor("browse", { + facadeServerPath: "/tmp/facade-server.mjs", + makeWorkspaceDirectory: async () => directory, + createAgent: async () => agent, + }), + ).rejects.toThrow("send failed"); + expect(dispose).toHaveBeenCalledOnce(); + await expect(access(directory)).rejects.toThrow(); + }); + + it("does not send a task when interrupted during agent creation", async () => { + const directory = await makeTemporaryDirectory(); + const fake = fakeAgent({ status: "finished", result: "unexpected" }); + let finishCreation!: () => void; + let notifyCreationStarted!: () => void; + const creationStarted = new Promise((resolve) => { + notifyCreationStarted = resolve; + }); + const createAgent = vi.fn( + () => + new Promise((resolve) => { + finishCreation = () => resolve(fake.agent); + notifyCreationStarted(); + }), + ); + const running = runCursor("browse", { + facadeServerPath: "/tmp/facade-server.mjs", + makeWorkspaceDirectory: async () => directory, + createAgent, + }); + + await creationStarted; + process.emit("SIGINT"); + finishCreation(); + + await expect(running).rejects.toThrow("Cursor run interrupted."); + expect(fake.send).not.toHaveBeenCalled(); + expect(fake.dispose).toHaveBeenCalledOnce(); + await expect(access(directory)).rejects.toThrow(); + }); + + it.each(["SIGINT", "SIGTERM"] as const)( + "cancels the active run on %s and still cleans up", + async (signal) => { + const directory = await makeTemporaryDirectory(); + let resolveWait: ((value: { status: "cancelled" }) => void) | undefined; + const wait = vi.fn( + () => + new Promise<{ status: "cancelled" }>((resolve) => { + resolveWait = resolve; + }), + ); + const cancel = vi.fn(async () => resolveWait?.({ status: "cancelled" })); + const dispose = vi.fn(async () => undefined); + const agent: CursorRuntimeAgent = { + send: vi.fn(async () => ({ wait, cancel })), + [Symbol.asyncDispose]: dispose, + }; + const running = runCursor("browse", { + facadeServerPath: "/tmp/facade-server.mjs", + makeWorkspaceDirectory: async () => directory, + createAgent: async () => agent, + }); + + await vi.waitFor(() => expect(wait).toHaveBeenCalledOnce()); + process.emit(signal); + await expect(running).rejects.toThrow("Cursor run interrupted."); + expect(cancel).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + await expect(access(directory)).rejects.toThrow(); + }, + ); +}); + +function fakeAgent(result: { + status: "finished" | "error" | "cancelled"; + result?: string; + error?: { message: string }; +}) { + const wait = vi.fn(async () => result); + const cancel = vi.fn(async () => undefined); + const send = vi.fn(async () => ({ wait, cancel })); + const dispose = vi.fn(async () => undefined); + const agent: CursorRuntimeAgent = { send, [Symbol.asyncDispose]: dispose }; + return { agent, send, wait, cancel, dispose }; +} + +async function makeTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "stagehand-cursor-test-")); + temporaryDirectories.push(directory); + return directory; +} diff --git a/packages/integrations/cursor/tsconfig.json b/packages/integrations/cursor/tsconfig.json new file mode 100644 index 0000000000..e9859394d1 --- /dev/null +++ b/packages/integrations/cursor/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "types": ["node"], + "rootDir": ".", + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/integrations/cursor/vitest.config.ts b/packages/integrations/cursor/vitest.config.ts new file mode 100644 index 0000000000..d82214c96a --- /dev/null +++ b/packages/integrations/cursor/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + hookTimeout: 20_000, + testTimeout: 20_000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ef422543..6fe69d9f32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ catalogs: '@changesets/cli': specifier: 2.31.1 version: 2.31.1 + '@cursor/sdk': + specifier: 1.0.28 + version: 1.0.28 '@earendil-works/pi-coding-agent': specifier: 0.84.2 version: 0.84.2 @@ -651,6 +654,25 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/integrations/cursor: + dependencies: + '@browserbasehq/stagehand-integrations': + specifier: workspace:* + version: link:../core + '@cursor/sdk': + specifier: 'catalog:' + version: 1.0.28 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/integrations/eve: dependencies: '@ai-sdk/openai': @@ -1441,6 +1463,9 @@ packages: puppeteer-core: optional: true + '@bufbuild/protobuf@1.10.0': + resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} + '@canvas/image-data@1.1.0': resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} @@ -1505,6 +1530,58 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@connectrpc/connect-node@1.7.0': + resolution: {integrity: sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@bufbuild/protobuf': ^1.10.0 + '@connectrpc/connect': 1.7.0 + + '@connectrpc/connect-web@1.7.0': + resolution: {integrity: sha512-qyP0YOnUPRWwCc/VfsoydMJvkb7EyUPr2q9sHgBuJzbADjiqck1gKH5V5ZPzPhTLBvmz5UvG+wiZ5sMRQHU1MQ==} + peerDependencies: + '@bufbuild/protobuf': ^1.10.0 + '@connectrpc/connect': 1.7.0 + + '@connectrpc/connect@1.7.0': + resolution: {integrity: sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==} + peerDependencies: + '@bufbuild/protobuf': ^1.10.0 + + '@cursor/sdk-darwin-arm64@1.0.28': + resolution: {integrity: sha512-P9k17dHzyYirjvMIkdxGtIWFEpkC1lB0TSbNE3AEusr5UwYTHFt13+MK5j9GK8Tg3r9ncg4IeSDQgx7HvA2VAw==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@cursor/sdk-darwin-x64@1.0.28': + resolution: {integrity: sha512-SFamp6b3a5/Og4RIYpxivA9TNqSam8+yeDwdu4+iV9GwZygtw2y1r8Qz2r7VpDj40gou2UP/p8Nu54nAdAl4Mg==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@cursor/sdk-linux-arm64@1.0.28': + resolution: {integrity: sha512-J0Pp2vZLApRm4+j35zNFaMkAuI7AoTzY7bt1UdRC/NSAcJsh74soCf/HqB59Ps94HaznF7PYl1ft6ltnn4to4g==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@cursor/sdk-linux-x64@1.0.28': + resolution: {integrity: sha512-YFBA2syvFJZE15d1MBtgqJJCuYGbZEU7GcE8EaGDU3A6iJguZg7PB97c3ux2qN5kJtdk0+EveevrIi2UJsr5Ag==} + cpu: [x64] + os: [linux] + hasBin: true + + '@cursor/sdk-win32-x64@1.0.28': + resolution: {integrity: sha512-+9G30RcR+cDkQxiyFBfrNWSBkZJc19G9L7+bTl9PdiBI7FPCu1vCCdP4BBvntdCkFS9LwEX3iJyWJqEreFjNQg==} + cpu: [x64] + os: [win32] + hasBin: true + + '@cursor/sdk@1.0.28': + resolution: {integrity: sha512-bO7Ld00xXV5kFB9WDeBiyADgon3r/BQXc9qZ4sHlXX9ZQpzIkTfHrYU7g278pcHTuz+l/vPcPcc7JByeRWHeqA==} + engines: {node: '>=22.13'} + '@earendil-works/pi-agent-core@0.84.2': resolution: {integrity: sha512-8Pn3wSCxj0cfo5I6jxQYVB/3uuQRmHhAlEclyjqpOuMEdQMIODHizRogv56FLdbU+dTiGnybeHQ2N+sV1/L2YA==} engines: {node: '>=22.19.0'} @@ -1856,6 +1933,10 @@ packages: cpu: [x64] os: [win32] + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -3274,6 +3355,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@statsig/client-core@3.31.0': + resolution: {integrity: sha512-SuxQD6TmVszPG7FoMKwTk/uyBuVFk7XnxI3T/E0uyb7PL7GNjONtfsoh+NqBBVUJVse0CUeSFfgJPoZy1ZOslQ==} + + '@statsig/js-client@3.31.0': + resolution: {integrity: sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g==} + '@stoplight/better-ajv-errors@1.0.3': resolution: {integrity: sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==} engines: {node: ^12.20 || >= 14.13} @@ -7738,6 +7825,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@7.29.0: resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} @@ -9014,6 +9105,8 @@ snapshots: - supports-color - utf-8-validate + '@bufbuild/protobuf@1.10.0': {} + '@canvas/image-data@1.1.0': {} '@changesets/apply-release-plan@7.1.1': @@ -9174,6 +9267,51 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@connectrpc/connect-node@1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0))': + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) + undici: 5.29.0 + + '@connectrpc/connect-web@1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0))': + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) + + '@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)': + dependencies: + '@bufbuild/protobuf': 1.10.0 + + '@cursor/sdk-darwin-arm64@1.0.28': + optional: true + + '@cursor/sdk-darwin-x64@1.0.28': + optional: true + + '@cursor/sdk-linux-arm64@1.0.28': + optional: true + + '@cursor/sdk-linux-x64@1.0.28': + optional: true + + '@cursor/sdk-win32-x64@1.0.28': + optional: true + + '@cursor/sdk@1.0.28': + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) + '@connectrpc/connect-node': 1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)) + '@connectrpc/connect-web': 1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)) + '@statsig/js-client': 3.31.0 + zod: 3.25.76 + optionalDependencies: + '@cursor/sdk-darwin-arm64': 1.0.28 + '@cursor/sdk-darwin-x64': 1.0.28 + '@cursor/sdk-linux-arm64': 1.0.28 + '@cursor/sdk-linux-x64': 1.0.28 + '@cursor/sdk-win32-x64': 1.0.28 + '@earendil-works/pi-agent-core@0.84.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.4.3)': dependencies: '@earendil-works/pi-ai': 0.84.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.4.3) @@ -9431,6 +9569,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@fastify/busboy@2.1.1': {} + '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -10912,6 +11052,12 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@statsig/client-core@3.31.0': {} + + '@statsig/js-client@3.31.0': + dependencies: + '@statsig/client-core': 3.31.0 + '@stoplight/better-ajv-errors@1.0.3(ajv@8.20.0)': dependencies: ajv: 8.20.0 @@ -16344,6 +16490,10 @@ snapshots: undici-types@7.24.6: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@7.29.0: {} undici@8.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 36a3d2ff29..313fbe63f9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,6 +32,7 @@ catalog: ai: ^7.0.16 "@openai/codex-sdk": 0.147.0 "@anthropic-ai/claude-agent-sdk": 0.3.224 + "@cursor/sdk": 1.0.28 typebox: 1.3.7 "@earendil-works/pi-coding-agent": 0.84.2 "@mastra/core": ^1.55.0 diff --git a/turbo.json b/turbo.json index 32b50787d1..cdcf66cb81 100644 --- a/turbo.json +++ b/turbo.json @@ -99,6 +99,9 @@ "@browserbasehq/stagehand-integrations-example-codex-facade#typecheck": { "dependsOn": ["^build"] }, + "@browserbasehq/stagehand-integrations-example-cursor-facade#typecheck": { + "dependsOn": ["^build"] + }, "@browserbasehq/stagehand-docs#typecheck": {}, "test:unit": { "dependsOn": ["^build"], From 1754fc877e3398ac45085c36dcda81c435f3a75b Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Tue, 18 Aug 2026 18:31:56 +0530 Subject: [PATCH 2/7] fix(integrations): improve cursor integration documentation and error handling --- packages/docs/v4/integrations/cursor.mdx | 4 +- packages/integrations/cursor/README.md | 4 +- packages/integrations/cursor/src/agent.ts | 4 ++ .../integrations/cursor/tests/agent.test.ts | 39 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/docs/v4/integrations/cursor.mdx b/packages/docs/v4/integrations/cursor.mdx index c04a9f3e24..5848cc9cac 100644 --- a/packages/docs/v4/integrations/cursor.mdx +++ b/packages/docs/v4/integrations/cursor.mdx @@ -67,9 +67,9 @@ pnpm --dir packages/integrations/cursor start -- \ ## SDK and session lifecycle -This V1 integration uses Cursor's local runtime. The agent loop and temporary workspace run locally, while model inference remains hosted by Cursor. Cursor Cloud requires a remotely reachable MCP server and is not part of this example. +This V1 integration uses Cursor's local runtime. The agent loop and temporary workspace run locally, while Cursor hosts model inference. Cursor Cloud requires a remotely reachable MCP server and is not part of this example. -The example creates an isolated temporary workspace, disables ambient Cursor setting sources, and enables only Cursor's `mcp` capability group. Its only inline server is the Stagehand facade, so the model receives `run`, `snapshot`, and `screenshot` without shell, file-editing, user MCP, or project MCP tools. The canonical Stagehand tool instructions are prefixed to the task because the main Cursor agent API does not expose a separate system-prompt option. +The example creates an isolated temporary workspace, disables ambient Cursor setting sources, and enables only Cursor's `mcp` capability group. Its only inline server is the Stagehand facade, so the model receives `run`, `snapshot`, and `screenshot` without shell, file-editing, user MCP, or project MCP tools. The example prefixes the canonical Stagehand tool instructions to the task because the main Cursor agent API does not expose a separate system-prompt option. The facade process remains attached to one Cursor agent for the full run. On completion or failure, the example disposes the agent and deletes its temporary workspace. `SIGINT` and `SIGTERM` first cancel the active run so in-flight MCP calls stop cleanly. diff --git a/packages/integrations/cursor/README.md b/packages/integrations/cursor/README.md index 12092e3b4a..cfab4ac5c4 100644 --- a/packages/integrations/cursor/README.md +++ b/packages/integrations/cursor/README.md @@ -53,8 +53,8 @@ STAGEHAND_BROWSER=browserbase pnpm --dir packages/integrations/cursor start -- \ This example intentionally uses Cursor's local agent runtime. "Local" means the agent loop and workspace run on your machine; model inference remains hosted by Cursor. The temporary workspace loads no ambient Cursor settings, and the agent's built-in tool allowlist contains only the `mcp` -capability. Its one inline MCP server is the Stagehand facade, so shell and file-editing tools are -not offered to the model. +capability. Its one inline MCP server is the Stagehand facade, so Cursor does not offer shell and +file-editing tools to the model. The MCP child receives only non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables. The Cursor API key and unrelated host secrets remain in the agent process. `SIGINT` and `SIGTERM` cancel the diff --git a/packages/integrations/cursor/src/agent.ts b/packages/integrations/cursor/src/agent.ts index 54f5ae8f61..71426c9e4a 100644 --- a/packages/integrations/cursor/src/agent.ts +++ b/packages/integrations/cursor/src/agent.ts @@ -102,6 +102,10 @@ export async function runCursor( agent = await createAgent(buildCursorAgentOptions(workspaceDirectory, facadeServerPath, env)); if (interrupted) throw new Error("Cursor run interrupted."); activeRun = await agent.send(buildCursorPrompt(instruction)); + if (interrupted) { + await activeRun.cancel().catch(() => undefined); + throw new Error("Cursor run interrupted."); + } const result = await activeRun.wait(); if (interrupted) throw new Error("Cursor run interrupted."); diff --git a/packages/integrations/cursor/tests/agent.test.ts b/packages/integrations/cursor/tests/agent.test.ts index d1b49765d7..56e9985ff4 100644 --- a/packages/integrations/cursor/tests/agent.test.ts +++ b/packages/integrations/cursor/tests/agent.test.ts @@ -13,6 +13,7 @@ import { resolveInstruction, runCursor, type CursorRuntimeAgent, + type CursorRuntimeRun, } from "../src/agent.ts"; const temporaryDirectories: string[] = []; @@ -197,6 +198,44 @@ describe("cursor stagehand example", () => { await expect(access(directory)).rejects.toThrow(); }); + it("cancels the new run when interrupted during send", async () => { + const directory = await makeTemporaryDirectory(); + let finishSend!: () => void; + let notifySendStarted!: () => void; + const sendStarted = new Promise((resolve) => { + notifySendStarted = resolve; + }); + const wait = vi.fn(() => new Promise(() => undefined)); + const cancel = vi.fn(async () => undefined); + const dispose = vi.fn(async () => undefined); + const pendingRun: CursorRuntimeRun = { wait, cancel }; + const agent: CursorRuntimeAgent = { + send: vi.fn( + () => + new Promise((resolve) => { + finishSend = () => resolve(pendingRun); + notifySendStarted(); + }), + ), + [Symbol.asyncDispose]: dispose, + }; + const running = runCursor("browse", { + facadeServerPath: "/tmp/facade-server.mjs", + makeWorkspaceDirectory: async () => directory, + createAgent: async () => agent, + }); + + await sendStarted; + process.emit("SIGINT"); + finishSend(); + + await expect(running).rejects.toThrow("Cursor run interrupted."); + expect(cancel).toHaveBeenCalledOnce(); + expect(wait).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + await expect(access(directory)).rejects.toThrow(); + }); + it.each(["SIGINT", "SIGTERM"] as const)( "cancels the active run on %s and still cleans up", async (signal) => { From 76592a52f2959dfc79ae3deab77c7e6228765c5f Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Wed, 19 Aug 2026 10:50:19 +0530 Subject: [PATCH 3/7] docs(integrations): add Cursor CLI guide --- packages/docs/v4/integrations/cursor.mdx | 17 ++++++++++++++++ packages/integrations/cursor/.cursor/mcp.json | 8 ++++++++ packages/integrations/cursor/README.md | 20 +++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 packages/integrations/cursor/.cursor/mcp.json diff --git a/packages/docs/v4/integrations/cursor.mdx b/packages/docs/v4/integrations/cursor.mdx index 5848cc9cac..6449824b4e 100644 --- a/packages/docs/v4/integrations/cursor.mdx +++ b/packages/docs/v4/integrations/cursor.mdx @@ -75,6 +75,23 @@ The facade process remains attached to one Cursor agent for the full run. On com Only non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables cross into the MCP child. The Cursor API key and unrelated host secrets remain in the agent process. +## Connect a running Cursor CLI + +The package ships a project-scoped `.cursor/mcp.json` that mounts the same facade server in the Cursor CLI. Its `args` path is relative to the package, so start the CLI from that directory: + +```bash +cd packages/integrations/cursor +cursor-agent mcp list +cursor-agent mcp list-tools stagehand +cursor-agent +``` + +Cursor inherits your shell environment, so the exports above are the only configuration. For a headless one-shot run, approve the configured MCP server so tool calls do not wait for an interactive prompt: + +```bash +cursor-agent -p --approve-mcps "your instruction" +``` + `run` executes model-authored JavaScript in the browser. Use Browserbase for untrusted tasks and review the [integration security boundary](/v4/integrations/overview#security-boundary). diff --git a/packages/integrations/cursor/.cursor/mcp.json b/packages/integrations/cursor/.cursor/mcp.json new file mode 100644 index 0000000000..df72d50f5d --- /dev/null +++ b/packages/integrations/cursor/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "stagehand": { + "command": "node", + "args": ["../core/dist/facade/stdio-server.mjs"] + } + } +} diff --git a/packages/integrations/cursor/README.md b/packages/integrations/cursor/README.md index cfab4ac5c4..c82ab77f62 100644 --- a/packages/integrations/cursor/README.md +++ b/packages/integrations/cursor/README.md @@ -60,6 +60,26 @@ The MCP child receives only non-empty `STAGEHAND_*` and `BROWSERBASE_*` variable key and unrelated host secrets remain in the agent process. `SIGINT` and `SIGTERM` cancel the active Cursor run before the agent, MCP process, and temporary workspace are cleaned up. +## Connecting a running Cursor CLI instead + +To use the facade from the interactive `cursor-agent` CLI rather than the SDK, the project-scoped +`.cursor/mcp.json` in this directory is all that's needed. Cursor automatically discovers it and +inherits the Stagehand and Browserbase exports above. Start the CLI from this directory: + +```bash +cd packages/integrations/cursor +cursor-agent mcp list +cursor-agent mcp list-tools stagehand +cursor-agent +``` + +For a headless one-shot run, approve the configured MCP server so tool calls do not wait for an +interactive prompt: + +```bash +cursor-agent -p --approve-mcps "your instruction" +``` + ## Security model The `run` tool executes model-authored JavaScript inside the Stagehand browser extension's service From 4eb88f8f3a485c0a29897288d901c64848b72d88 Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Wed, 19 Aug 2026 11:11:08 +0530 Subject: [PATCH 4/7] fix(integrations): preserve Cursor CLI signal exits --- packages/docs/v4/integrations/cursor.mdx | 2 +- packages/integrations/cursor/src/agent.ts | 42 +++++++++++++------ .../integrations/cursor/tests/agent.test.ts | 5 +++ 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/docs/v4/integrations/cursor.mdx b/packages/docs/v4/integrations/cursor.mdx index 6449824b4e..b87f63676e 100644 --- a/packages/docs/v4/integrations/cursor.mdx +++ b/packages/docs/v4/integrations/cursor.mdx @@ -77,7 +77,7 @@ Only non-empty `STAGEHAND_*` and `BROWSERBASE_*` variables cross into the MCP ch ## Connect a running Cursor CLI -The package ships a project-scoped `.cursor/mcp.json` that mounts the same facade server in the Cursor CLI. Its `args` path is relative to the package, so start the CLI from that directory: +The package ships a project-scoped `.cursor/mcp.json` that mounts the Stagehand facade MCP server in the Cursor CLI. Its `args` path is relative to the package, so start the CLI from that directory: ```bash cd packages/integrations/cursor diff --git a/packages/integrations/cursor/src/agent.ts b/packages/integrations/cursor/src/agent.ts index 71426c9e4a..a2ca18c576 100644 --- a/packages/integrations/cursor/src/agent.ts +++ b/packages/integrations/cursor/src/agent.ts @@ -72,6 +72,16 @@ export type CursorRuntimeAgent = { [Symbol.asyncDispose](): Promise; }; +export class CursorInterruptionError extends Error { + readonly signal: NodeJS.Signals | undefined; + + constructor(signal?: NodeJS.Signals) { + super("Cursor run interrupted."); + this.name = "CursorInterruptionError"; + this.signal = signal; + } +} + export type RunCursorOptions = { env?: NodeJS.ProcessEnv; facadeServerPath?: string; @@ -90,26 +100,26 @@ export async function runCursor( const workspaceDirectory = await (options.makeWorkspaceDirectory ?? createWorkspaceDirectory)(); let agent: CursorRuntimeAgent | undefined; let activeRun: CursorRuntimeRun | undefined; - let interrupted = false; + let interruptedBy: NodeJS.Signals | undefined; - const removeSignalHandlers = forwardTerminationSignals(() => { - interrupted = true; + const removeSignalHandlers = forwardTerminationSignals((signal) => { + interruptedBy = signal; if (activeRun) void activeRun.cancel().catch(() => undefined); }); try { const createAgent = options.createAgent ?? ((agentOptions) => Agent.create(agentOptions)); agent = await createAgent(buildCursorAgentOptions(workspaceDirectory, facadeServerPath, env)); - if (interrupted) throw new Error("Cursor run interrupted."); + if (interruptedBy) throw new CursorInterruptionError(interruptedBy); activeRun = await agent.send(buildCursorPrompt(instruction)); - if (interrupted) { + if (interruptedBy) { await activeRun.cancel().catch(() => undefined); - throw new Error("Cursor run interrupted."); + throw new CursorInterruptionError(interruptedBy); } const result = await activeRun.wait(); - if (interrupted) throw new Error("Cursor run interrupted."); - if (result.status === "cancelled") throw new Error("Cursor run interrupted."); + if (interruptedBy) throw new CursorInterruptionError(interruptedBy); + if (result.status === "cancelled") throw new CursorInterruptionError(); if (result.status === "error") { const detail = result.error?.message?.trim(); throw new Error(detail ? `Cursor run failed: ${detail}` : "Cursor run failed."); @@ -132,12 +142,14 @@ async function createWorkspaceDirectory(): Promise { return mkdtemp(join(tmpdir(), "stagehand-cursor-")); } -function forwardTerminationSignals(onSignal: () => void): () => void { - process.once("SIGINT", onSignal); - process.once("SIGTERM", onSignal); +function forwardTerminationSignals(onSignal: (signal: NodeJS.Signals) => void): () => void { + const onSigint = () => onSignal("SIGINT"); + const onSigterm = () => onSignal("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); return () => { - process.removeListener("SIGINT", onSignal); - process.removeListener("SIGTERM", onSignal); + process.removeListener("SIGINT", onSigint); + process.removeListener("SIGTERM", onSigterm); }; } @@ -150,6 +162,10 @@ async function main(): Promise { if (import.meta.main) { main().catch((error: unknown) => { + if (error instanceof CursorInterruptionError && error.signal) { + process.kill(process.pid, error.signal); + return; + } // oxlint-disable-next-line no-console -- CLI example reports failures to stderr. console.error(error instanceof Error ? error.message : error); process.exitCode = 1; diff --git a/packages/integrations/cursor/tests/agent.test.ts b/packages/integrations/cursor/tests/agent.test.ts index 56e9985ff4..6ebce20b7c 100644 --- a/packages/integrations/cursor/tests/agent.test.ts +++ b/packages/integrations/cursor/tests/agent.test.ts @@ -10,6 +10,7 @@ import { buildAllowlistedEnv, buildCursorAgentOptions, buildCursorPrompt, + CursorInterruptionError, resolveInstruction, runCursor, type CursorRuntimeAgent, @@ -262,6 +263,10 @@ describe("cursor stagehand example", () => { await vi.waitFor(() => expect(wait).toHaveBeenCalledOnce()); process.emit(signal); await expect(running).rejects.toThrow("Cursor run interrupted."); + await expect(running).rejects.toMatchObject({ + name: CursorInterruptionError.name, + signal, + }); expect(cancel).toHaveBeenCalledOnce(); expect(dispose).toHaveBeenCalledOnce(); await expect(access(directory)).rejects.toThrow(); From 58ca1095ff16cc190496de9044af010914d7c44d Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Wed, 19 Aug 2026 11:18:03 +0530 Subject: [PATCH 5/7] docs(integrations): use current Cursor agent CLI --- packages/docs/v4/integrations/cursor.mdx | 8 ++++---- packages/integrations/cursor/README.md | 10 +++++----- packages/integrations/cursor/src/agent.ts | 20 ++++++++++--------- .../integrations/cursor/tests/agent.test.ts | 17 ++++++++++++++++ 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/docs/v4/integrations/cursor.mdx b/packages/docs/v4/integrations/cursor.mdx index b87f63676e..cbb2669afe 100644 --- a/packages/docs/v4/integrations/cursor.mdx +++ b/packages/docs/v4/integrations/cursor.mdx @@ -81,15 +81,15 @@ The package ships a project-scoped `.cursor/mcp.json` that mounts the Stagehand ```bash cd packages/integrations/cursor -cursor-agent mcp list -cursor-agent mcp list-tools stagehand -cursor-agent +agent mcp list +agent mcp list-tools stagehand +agent ``` Cursor inherits your shell environment, so the exports above are the only configuration. For a headless one-shot run, approve the configured MCP server so tool calls do not wait for an interactive prompt: ```bash -cursor-agent -p --approve-mcps "your instruction" +agent -p --approve-mcps "your instruction" ``` diff --git a/packages/integrations/cursor/README.md b/packages/integrations/cursor/README.md index c82ab77f62..3f4a2bbbc4 100644 --- a/packages/integrations/cursor/README.md +++ b/packages/integrations/cursor/README.md @@ -62,22 +62,22 @@ active Cursor run before the agent, MCP process, and temporary workspace are cle ## Connecting a running Cursor CLI instead -To use the facade from the interactive `cursor-agent` CLI rather than the SDK, the project-scoped +To use the facade from the interactive `agent` CLI rather than the SDK, the project-scoped `.cursor/mcp.json` in this directory is all that's needed. Cursor automatically discovers it and inherits the Stagehand and Browserbase exports above. Start the CLI from this directory: ```bash cd packages/integrations/cursor -cursor-agent mcp list -cursor-agent mcp list-tools stagehand -cursor-agent +agent mcp list +agent mcp list-tools stagehand +agent ``` For a headless one-shot run, approve the configured MCP server so tool calls do not wait for an interactive prompt: ```bash -cursor-agent -p --approve-mcps "your instruction" +agent -p --approve-mcps "your instruction" ``` ## Security model diff --git a/packages/integrations/cursor/src/agent.ts b/packages/integrations/cursor/src/agent.ts index a2ca18c576..4da3478dad 100644 --- a/packages/integrations/cursor/src/agent.ts +++ b/packages/integrations/cursor/src/agent.ts @@ -161,13 +161,15 @@ async function main(): Promise { } if (import.meta.main) { - main().catch((error: unknown) => { - if (error instanceof CursorInterruptionError && error.signal) { - process.kill(process.pid, error.signal); - return; - } - // oxlint-disable-next-line no-console -- CLI example reports failures to stderr. - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - }); + main().catch(handleFailure); +} + +export function handleFailure(error: unknown): void { + if (error instanceof CursorInterruptionError && error.signal) { + process.kill(process.pid, error.signal); + return; + } + // oxlint-disable-next-line no-console -- CLI example reports failures to stderr. + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; } diff --git a/packages/integrations/cursor/tests/agent.test.ts b/packages/integrations/cursor/tests/agent.test.ts index 6ebce20b7c..22c9590a55 100644 --- a/packages/integrations/cursor/tests/agent.test.ts +++ b/packages/integrations/cursor/tests/agent.test.ts @@ -1,3 +1,5 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; import { access, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -272,6 +274,21 @@ describe("cursor stagehand example", () => { await expect(access(directory)).rejects.toThrow(); }, ); + + it.skipIf(process.platform === "win32").each(["SIGINT", "SIGTERM"] as const)( + "re-raises %s from the CLI failure handler", + async (signal) => { + const agentModule = new URL("../src/agent.ts", import.meta.url).href; + const script = `import { CursorInterruptionError, handleFailure } from ${JSON.stringify(agentModule)}; handleFailure(new CursorInterruptionError(${JSON.stringify(signal)}));`; + const child = spawn(process.execPath, ["--input-type=module", "--eval", script], { + stdio: "ignore", + }); + + const [exitCode, exitSignal] = await once(child, "exit"); + expect(exitCode).toBeNull(); + expect(exitSignal).toBe(signal); + }, + ); }); function fakeAgent(result: { From 0e9c705ead338421e0aa8f3dc43f1a144d52f266 Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Wed, 19 Aug 2026 11:27:05 +0530 Subject: [PATCH 6/7] test(integrations): cover Cursor CLI failures --- .../integrations/cursor/tests/agent.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/integrations/cursor/tests/agent.test.ts b/packages/integrations/cursor/tests/agent.test.ts index 22c9590a55..a543345c78 100644 --- a/packages/integrations/cursor/tests/agent.test.ts +++ b/packages/integrations/cursor/tests/agent.test.ts @@ -289,6 +289,24 @@ describe("cursor stagehand example", () => { expect(exitSignal).toBe(signal); }, ); + + it("reports an ordinary CLI failure to stderr with exit code 1", async () => { + const agentModule = new URL("../src/agent.ts", import.meta.url).href; + const script = `import { handleFailure } from ${JSON.stringify(agentModule)}; handleFailure(new Error("boom"));`; + const child = spawn(process.execPath, ["--input-type=module", "--eval", script], { + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr.setEncoding("utf8"); + let stderr = ""; + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + + const [exitCode, exitSignal] = await once(child, "close"); + expect(exitCode).toBe(1); + expect(exitSignal).toBeNull(); + expect(stderr).toBe("boom\n"); + }); }); function fakeAgent(result: { From cc809c05d1795ae5d9d6f6f0dcf19f403dfb126c Mon Sep 17 00:00:00 2001 From: Vishal Anton Date: Fri, 21 Aug 2026 16:03:51 +0530 Subject: [PATCH 7/7] docs(integrations): list cursor in the package overview --- packages/integrations/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/integrations/README.md b/packages/integrations/README.md index 00665ba3d0..829b970881 100644 --- a/packages/integrations/README.md +++ b/packages/integrations/README.md @@ -14,6 +14,7 @@ else, never restated. | `claude-code/` | Claude Agent SDK example (programmatic MCP mount) plus a `.mcp.json` for connecting a running Claude Code CLI. | | `codex/` | Codex SDK example (config-override MCP mount) plus a `config.toml` template for the codex CLI. | | `crewai/` | Python CrewAI example over MCP/stdio (uv project). | +| `cursor/` | Cursor SDK example over MCP/stdio via the Cursor Agent SDK. | | `deepagents/` | Python LangChain Deep Agents integrations: a local stdio MCP server and a Managed Deep Agents project with native tools. | | `eve/` | Eve example with the tools bound natively via `defineTool` (Eve has no external-process tool mounting). | | `fx/` | fx configuration templates and skill — fx consumes the facade via its user-global MCP config. |