-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: scaffold Stagehand code-mode MCP host #2597
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
2cfa7b0
feat: scaffold Stagehand code-mode MCP host
shrey150 ead4034
Merge origin/v4-spike into code-mode MCP stack
shrey150 1fc9040
fix: harden code-mode host lifecycle and CI
shrey150 dcf2a2e
fix: close code-mode host review gaps
shrey150 9449d79
Merge latest v4-spike into code-mode MCP stack
shrey150 a62a8ba
ci: fold integrations tests into sdk-ts gating, drop dedicated job
miguelg719 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # Stagehand integrations | ||
|
|
||
| Private workspace package for Stagehand integration adapters. | ||
|
|
||
| The code-mode stdio entrypoint currently provides the MCP host and process lifecycle used by later code-mode capabilities. It intentionally advertises no tools yet. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| { | ||
| "name": "@browserbasehq/stagehand-integrations", | ||
| "version": "4.0.0", | ||
| "private": true, | ||
| "description": "Shared integration surfaces for Stagehand V4", | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "type": "module", | ||
| "exports": { | ||
| "./codemode/stdio-server": { | ||
| "import": "./dist/codemode/stdio-server.mjs" | ||
| } | ||
| }, | ||
| "scripts": { | ||
| "build": "tsdown", | ||
| "test": "pnpm run build && vitest run --root ../.. packages/integrations/tests", | ||
| "test:unit": "vitest run --root ../.. packages/integrations/tests", | ||
|
shrey150 marked this conversation as resolved.
|
||
| "typecheck": "tsc --noEmit -p tsconfig.json" | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "catalog:" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "catalog:", | ||
| "tsdown": "catalog:", | ||
| "typescript": "catalog:", | ||
| "vitest": "catalog:" | ||
| }, | ||
| "engines": { | ||
| "node": ">=22.18.0" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
|
|
||
| export function createCodeModeMcpHost(): McpServer { | ||
| return new McpServer({ | ||
| name: "stagehand-codemode", | ||
| version: "4.0.0", | ||
| }); | ||
| } | ||
|
|
||
| export async function connectCodeModeStdio(server: McpServer): Promise<void> { | ||
| await server.connect(new StdioServerTransport()); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| export type AsyncCloser = { | ||
| close(): Promise<unknown>; | ||
| }; | ||
|
|
||
| export const STDIO_SHUTDOWN_GRACE_MS = 5_000; | ||
|
|
||
| export async function closeCodeModeStdio( | ||
| resources: readonly AsyncCloser[], | ||
| timeoutMs = STDIO_SHUTDOWN_GRACE_MS, | ||
| ): Promise<boolean> { | ||
| let timeout: NodeJS.Timeout | undefined; | ||
| const cleanup = Promise.allSettled( | ||
| resources.map((resource) => Promise.resolve().then(() => resource.close())), | ||
| ).then((results) => results.every((result) => result.status === "fulfilled")); | ||
| const deadline = new Promise<boolean>((resolve) => { | ||
| timeout = setTimeout(() => resolve(false), timeoutMs); | ||
| timeout.unref(); | ||
| }); | ||
|
|
||
| try { | ||
| return await Promise.race([cleanup, deadline]); | ||
| } finally { | ||
| if (timeout) clearTimeout(timeout); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { connectCodeModeStdio, createCodeModeMcpHost } from "./mcp-runtime.js"; | ||
| import { closeCodeModeStdio } from "./stdio-lifecycle.js"; | ||
|
|
||
| const server = createCodeModeMcpHost(); | ||
| let closing = false; | ||
|
|
||
| async function shutdown(code: number): Promise<void> { | ||
| if (closing) return; | ||
| closing = true; | ||
| const clean = await closeCodeModeStdio([server]); | ||
| if (!clean) { | ||
| process.stderr.write("Failed to close Stagehand code mode cleanly.\n"); | ||
| } | ||
| process.exit(code === 0 && !clean ? 1 : code); | ||
| } | ||
|
|
||
| process.once("SIGINT", () => void shutdown(130)); | ||
| process.once("SIGTERM", () => void shutdown(143)); | ||
| process.stdin.once("end", () => void shutdown(0)); | ||
| process.stdin.once("close", () => void shutdown(0)); | ||
|
|
||
| await connectCodeModeStdio(server); | ||
| process.stderr.write("Stagehand code-mode MCP host listening on stdio\n"); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | ||
| import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
| import { createCodeModeMcpHost } from "../src/codemode/mcp-runtime.js"; | ||
|
|
||
| describe("code-mode MCP host", () => { | ||
| let client: Client; | ||
| let server: ReturnType<typeof createCodeModeMcpHost>; | ||
|
|
||
| beforeEach(async () => { | ||
| server = createCodeModeMcpHost(); | ||
| client = new Client({ name: "stagehand-codemode-host-test", version: "1.0.0" }); | ||
| const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | ||
| await server.connect(serverTransport); | ||
| await client.connect(clientTransport); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await client.close(); | ||
| await server.close(); | ||
| }); | ||
|
|
||
| it("initializes without advertising the tools capability", () => { | ||
| expect(client.getServerCapabilities()).not.toHaveProperty("tools"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { closeCodeModeStdio } from "../src/codemode/stdio-lifecycle.js"; | ||
|
|
||
| describe("closeCodeModeStdio", () => { | ||
| afterEach(() => vi.useRealTimers()); | ||
|
|
||
| it("closes every resource concurrently", async () => { | ||
| const first = { close: vi.fn(async () => undefined) }; | ||
| const second = { close: vi.fn(async () => undefined) }; | ||
|
|
||
| await expect(closeCodeModeStdio([first, second], 50)).resolves.toBe(true); | ||
| expect(first.close).toHaveBeenCalledOnce(); | ||
| expect(second.close).toHaveBeenCalledOnce(); | ||
| }); | ||
|
|
||
| it("reports cleanup failures without exposing their messages", async () => { | ||
| const healthy = { close: vi.fn(async () => undefined) }; | ||
| const failing = { close: vi.fn(async () => Promise.reject(new Error("secret detail"))) }; | ||
|
|
||
| await expect(closeCodeModeStdio([healthy, failing], 50)).resolves.toBe(false); | ||
| }); | ||
|
|
||
| it("contains synchronous cleanup failures", async () => { | ||
| const failing = { | ||
| close: vi.fn(() => { | ||
| throw new Error("secret detail"); | ||
| }), | ||
| }; | ||
|
|
||
| await expect(closeCodeModeStdio([failing], 50)).resolves.toBe(false); | ||
| }); | ||
|
|
||
| it("bounds cleanup when a resource never settles", async () => { | ||
| vi.useFakeTimers(); | ||
| const stuck = { close: vi.fn(() => new Promise<void>(() => undefined)) }; | ||
| const result = closeCodeModeStdio([stuck], 5_000); | ||
|
|
||
| await vi.advanceTimersByTimeAsync(5_000); | ||
|
|
||
| await expect(result).resolves.toBe(false); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; | ||
| import { PassThrough, type Stream } from "node:stream"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| const entrypoint = fileURLToPath(new URL("../dist/codemode/stdio-server.mjs", import.meta.url)); | ||
| const baseEnv = { PATH: process.env.PATH ?? "" }; | ||
| const readyMessage = "Stagehand code-mode MCP host listening on stdio"; | ||
|
|
||
| function startServer(): ChildProcessWithoutNullStreams { | ||
| return spawn(process.execPath, [entrypoint], { | ||
| env: baseEnv, | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| }); | ||
| } | ||
|
|
||
| async function waitForReady(child: ChildProcessWithoutNullStreams): Promise<string> { | ||
| let stderr = ""; | ||
| return await new Promise<string>((resolve, reject) => { | ||
| const timeout = setTimeout( | ||
| () => reject(new Error(`stdio host did not start: ${stderr}`)), | ||
| 10_000, | ||
| ); | ||
| const onData = (chunk: Buffer) => { | ||
| stderr += chunk.toString(); | ||
| if (!stderr.includes(readyMessage)) return; | ||
| clearTimeout(timeout); | ||
| child.stderr.off("data", onData); | ||
| resolve(stderr); | ||
| }; | ||
| child.stderr.on("data", onData); | ||
| child.once("exit", (code, signal) => { | ||
| clearTimeout(timeout); | ||
| reject( | ||
| new Error(`stdio host exited before ready (code=${code}, signal=${signal}): ${stderr}`), | ||
| ); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| function waitForOutput(stream: Stream, expected: string): Promise<string> { | ||
| let output = ""; | ||
| return new Promise<string>((resolve, reject) => { | ||
| const cleanup = () => { | ||
| clearTimeout(timeout); | ||
| stream.off("data", onData); | ||
| stream.off("error", onError); | ||
| stream.off("end", onEnd); | ||
| stream.off("close", onClose); | ||
| }; | ||
| const succeed = () => { | ||
| cleanup(); | ||
| resolve(output); | ||
| }; | ||
| const fail = (message: string) => { | ||
| cleanup(); | ||
| reject(new Error(message)); | ||
| }; | ||
| const onData = (chunk: Buffer) => { | ||
| output += chunk.toString(); | ||
| if (output.includes(expected)) succeed(); | ||
| }; | ||
| const onError = () => fail(`stdio output stream failed before ${JSON.stringify(expected)}`); | ||
| const onEnd = () => fail(`stdio output stream ended before ${JSON.stringify(expected)}`); | ||
| const onClose = () => fail(`stdio output stream closed before ${JSON.stringify(expected)}`); | ||
| const timeout = setTimeout( | ||
| () => fail(`stdio host did not emit ${JSON.stringify(expected)}: ${output}`), | ||
| 10_000, | ||
| ); | ||
| stream.on("data", onData); | ||
| stream.once("error", onError); | ||
| stream.once("end", onEnd); | ||
| stream.once("close", onClose); | ||
| }); | ||
| } | ||
|
|
||
| function waitForExit( | ||
| child: ChildProcessWithoutNullStreams, | ||
| ): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { | ||
| return new Promise((resolve, reject) => { | ||
| const timeout = setTimeout(() => { | ||
| child.kill("SIGKILL"); | ||
| reject(new Error("stdio host did not exit within 10 seconds")); | ||
| }, 10_000); | ||
| child.once("error", reject); | ||
| child.once("exit", (code, signal) => { | ||
| clearTimeout(timeout); | ||
| resolve({ code, signal }); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| describe("built code-mode stdio host", () => { | ||
| it("cleans up output waiters when the stream closes before the expected output", async () => { | ||
| const stream = new PassThrough(); | ||
| const output = waitForOutput(stream, readyMessage); | ||
|
|
||
| stream.destroy(); | ||
|
|
||
| await expect(output).rejects.toThrow(`closed before ${JSON.stringify(readyMessage)}`); | ||
| expect(stream.listenerCount("data")).toBe(0); | ||
| expect(stream.listenerCount("error")).toBe(0); | ||
| expect(stream.listenerCount("end")).toBe(0); | ||
| expect(stream.listenerCount("close")).toBe(0); | ||
| }); | ||
|
|
||
| it("starts and exits successfully on stdin EOF", async () => { | ||
| const child = startServer(); | ||
| try { | ||
| await waitForReady(child); | ||
| const exit = waitForExit(child); | ||
| child.stdin.end(); | ||
| await expect(exit).resolves.toStrictEqual({ code: 0, signal: null }); | ||
| } finally { | ||
| if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); | ||
| } | ||
| }); | ||
|
|
||
| it.skipIf(process.platform === "win32")( | ||
| "preserves SIGINT and SIGTERM exit semantics", | ||
| async () => { | ||
| for (const [signal, expectedCode] of [ | ||
| ["SIGINT", 130], | ||
| ["SIGTERM", 143], | ||
| ] as const) { | ||
| const child = startServer(); | ||
| try { | ||
| await waitForReady(child); | ||
| const exit = waitForExit(child); | ||
| child.kill(signal); | ||
| await expect(exit).resolves.toStrictEqual({ code: expectedCode, signal: null }); | ||
| } finally { | ||
| if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); | ||
| } | ||
| } | ||
| }, | ||
| 30_000, | ||
| ); | ||
|
|
||
| it("initializes without advertising tools through the compiled child", async () => { | ||
| const transport = new StdioClientTransport({ | ||
| command: process.execPath, | ||
| args: [entrypoint], | ||
| env: baseEnv, | ||
| stderr: "pipe", | ||
| }); | ||
| if (!transport.stderr) throw new Error("stdio transport did not expose stderr"); | ||
| const ready = waitForOutput(transport.stderr, readyMessage); | ||
| const client = new Client({ name: "stagehand-codemode-stdio-test", version: "1.0.0" }); | ||
|
|
||
| try { | ||
| await Promise.all([client.connect(transport), ready]); | ||
| expect(client.getServerCapabilities()).not.toHaveProperty("tools"); | ||
| } finally { | ||
| await client.close(); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "extends": "../../tsconfig.json", | ||
| "compilerOptions": { | ||
| "module": "NodeNext", | ||
| "moduleResolution": "NodeNext", | ||
| "target": "ES2022", | ||
| "types": ["node"], | ||
| "rootDir": ".", | ||
| "noEmit": true | ||
| }, | ||
| "include": ["src/**/*.ts", "tests/**/*.ts"], | ||
| "exclude": ["dist", "node_modules"] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { defineConfig } from "tsdown"; | ||
|
|
||
| export default defineConfig({ | ||
| entry: { | ||
| "codemode/stdio-server": "src/codemode/stdio-server.ts", | ||
| }, | ||
| format: ["esm"], | ||
| platform: "node", | ||
| target: "node22", | ||
| dts: { | ||
| sourcemap: true, | ||
| }, | ||
| sourcemap: true, | ||
| outDir: "dist", | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.