Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ MINTLIFY_DOMAIN=<x>

# Optional MCP toolset gating. Comma-separated values.
# Example: api_keys hides manage_api_keys so deployments can opt out of key management.
# Supported: apps, api_keys, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, shell
# Supported: apps, api_keys, browser_pools, browsers, computer, docs, extensions, playwright, profiles, projects, proxies, replays, shell
# KERNEL_MCP_DISABLED_TOOLSETS=api_keys

# Redis Configuration
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The Kernel MCP Server bridges AI assistants (like Claude, Cursor, or other MCP-c
- 📊 Monitor deployments and track invocations
- 🔍 Search Kernel documentation and inject context
- 💻 Execute arbitrary Playwright code against live browsers
- 🎥 Automatically record video replays of browser automation
- 🎥 Record MP4 video replays of browser automation

**Open-source & fully-managed** — the complete codebase is available here, and we run the production instance so you don't need to deploy anything.

Expand Down Expand Up @@ -269,6 +269,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_
- `manage_api_keys` - Create, list, get, update, and delete org-wide or project-scoped API keys. Create returns the plaintext key once.
- `manage_browser_pools` - Create, list, get, delete, and flush pools of pre-warmed browsers. Acquire and release browsers from pools.
- `manage_proxies` - Create, list, get, check, and delete proxy configurations (datacenter, ISP, residential, mobile, custom).
- `manage_replays` - Start, stop, and list MP4 video replay recordings for a browser session. Session-scoped: start once, run your automation, then stop. Requires a paid Kernel plan.
- `manage_extensions` - List and delete uploaded browser extensions.
- `manage_apps` - List/search apps, invoke actions, get/list/delete deployments, and get invocation results.
- `manage_auth_connections` - Create, list, get, delete managed auth connections; start login flows (returns a hosted URL and live view); submit MFA codes or SSO selections.
Expand All @@ -279,7 +280,7 @@ Self-hosted deployments can hide sensitive tool families by setting `KERNEL_MCP_

- `computer_action` - Mouse, keyboard, clipboard, and screenshot controls for browser sessions (click, type, press_key, scroll, move, get_position, read_clipboard, write_clipboard, screenshot).
- `browser_curl` - Send HTTP requests through an existing browser session's Chrome network stack.
- `execute_playwright_code` - Execute Playwright/TypeScript code against a browser with automatic video replay and cleanup.
- `execute_playwright_code` - Execute Playwright/TypeScript code against an existing browser session. Does not create or delete browsers - use `manage_browsers` for session lifecycle.
- `exec_command` - Run shell commands inside a browser VM. Returns decoded stdout/stderr.
- `search_docs` - Search Kernel platform documentation and guides.

Expand Down Expand Up @@ -320,9 +321,10 @@ Assistant: I'll execute your web-scraper action with reddit.com as the target.

```
Human: Go to example.com and get me the page title
Assistant: I'll execute Playwright code to navigate to the site and retrieve the title.
[Uses execute_playwright_code tool with code: "await page.goto('https://example.com'); return await page.title();"]
Returns: { success: true, result: "Example Domain", replay_url: "https://..." }
Assistant: I'll create a browser session, then execute Playwright code against it to navigate to the site and retrieve the title.
[Uses manage_browsers tool with action: "create" to get a session_id]
[Uses execute_playwright_code tool with session_id and code: "await page.goto('https://example.com'); return await page.title();"]
Returns: { success: true, result: "Example Domain" }
```

### Set up browser profiles for authentication
Expand Down
2 changes: 2 additions & 0 deletions src/lib/mcp/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { registerPlaywrightTool } from "@/lib/mcp/tools/playwright";
import { registerProfileCapabilities } from "@/lib/mcp/tools/profiles";
import { registerProjectCapabilities } from "@/lib/mcp/tools/projects";
import { registerProxyTools } from "@/lib/mcp/tools/proxies";
import { registerReplayTools } from "@/lib/mcp/tools/replays";
import { registerShellTool } from "@/lib/mcp/tools/shell";

type RegisterMcpToolset = (server: McpServer) => void;
Expand All @@ -33,6 +34,7 @@ const mcpToolRegistrations = [
["computer", registerComputerActionTool],
["shell", registerShellTool],
["playwright", registerPlaywrightTool],
["replays", registerReplayTools],
["auth_connections", registerAuthConnectionTools],
["credentials", registerCredentialTools],
["credential_providers", registerCredentialProviderTools],
Expand Down
65 changes: 5 additions & 60 deletions src/lib/mcp/tools/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function registerPlaywrightTool(server: McpServer) {
// execute_playwright_code -- Run Playwright/TypeScript code against a browser
server.tool(
"execute_playwright_code",
'Execute Playwright/TypeScript automation code against a Kernel browser session. If session_id is provided, uses that existing browser; otherwise creates a new one. Returns the result with a video replay URL. Auto-cleans up browsers it creates. Use computer_action with action "screenshot" instead of page.screenshot() in code.',
"Execute Playwright/TypeScript automation code against an existing Kernel browser session. Does not create or delete browsers -- use manage_browsers to manage session lifecycle.",
{
code: z
.string()
Expand All @@ -15,10 +15,7 @@ export function registerPlaywrightTool(server: McpServer) {
),
session_id: z
.string()
.describe(
"Existing browser session ID. If omitted, a new browser is created and cleaned up after execution.",
)
.optional(),
.describe("Browser session ID to execute the code against."),
},
{
title: "Execute Playwright code",
Expand All @@ -30,50 +27,14 @@ export function registerPlaywrightTool(server: McpServer) {
async ({ code, session_id }, extra) => {
if (!extra.authInfo) throw new Error("Authentication required");
const client = createKernelClient(extra.authInfo.token);
let kernelBrowser;
let replay;
const shouldCleanup = !session_id;

try {
if (!code || typeof code !== "string")
throw new Error("code is required and must be a string");

if (session_id) {
kernelBrowser = await client.browsers.retrieve(session_id);
if (!kernelBrowser)
throw new Error(`Browser session "${session_id}" not found`);
} else {
kernelBrowser = await client.browsers.create({ stealth: true });
if (!kernelBrowser?.session_id)
throw new Error("Failed to create browser session");
}

try {
replay = await client.browsers.replays.start(
kernelBrowser.session_id,
);
} catch {
replay = null;
}

const response = await client.browsers.playwright.execute(
kernelBrowser.session_id,
{ code },
);

let replayUrl = null;
if (replay && kernelBrowser?.session_id) {
try {
await client.browsers.replays.stop(replay.replay_id, {
id: kernelBrowser.session_id,
});
replayUrl = replay.replay_view_url;
} catch {}
}

if (shouldCleanup && kernelBrowser?.session_id) {
await client.browsers.deleteByID(kernelBrowser.session_id);
}
const response = await client.browsers.playwright.execute(session_id, {
code,
});

return {
content: [
Expand All @@ -86,7 +47,6 @@ export function registerPlaywrightTool(server: McpServer) {
error: response.error,
stdout: response.stdout,
stderr: response.stderr,
replay_url: replayUrl,
},
null,
2,
Expand All @@ -95,20 +55,6 @@ export function registerPlaywrightTool(server: McpServer) {
],
};
} catch (error) {
let replayUrl = null;
if (replay && kernelBrowser?.session_id) {
try {
await client.browsers.replays.stop(replay.replay_id, {
id: kernelBrowser.session_id,
});
replayUrl = replay.replay_view_url;
} catch {}
}
try {
if (shouldCleanup && kernelBrowser?.session_id)
await client.browsers.deleteByID(kernelBrowser.session_id);
} catch {}

return {
content: [
{
Expand All @@ -117,7 +63,6 @@ export function registerPlaywrightTool(server: McpServer) {
{
success: false,
error: error instanceof Error ? error.message : String(error),
replay_url: replayUrl,
},
null,
2,
Expand Down
97 changes: 97 additions & 0 deletions src/lib/mcp/tools/replays.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createKernelClient } from "@/lib/mcp/kernel-client";
import {
errorResponse,
itemsJsonResponse,
jsonResponse,
textResponse,
toolErrorResponse,
} from "@/lib/mcp/responses";

export function registerReplayTools(server: McpServer) {
// manage_replays -- Start, stop, and list video replay recordings for a session
server.tool(
"manage_replays",
'Manage video replay recordings for a browser session. Use "start" to begin recording a session (returns a replay_id and a viewable URL), "stop" to end a recording and persist the video, or "list" to see all replays for a session with their view URLs. Recording is session-scoped: start once, run your automation, then stop -- rather than recording each action separately. Requires a paid Kernel plan; not available on the free tier.',
{
action: z
.enum(["start", "stop", "list"])
.describe("Operation to perform."),
session_id: z.string().describe("Browser session ID."),
replay_id: z.string().describe("(stop) Replay ID to stop.").optional(),
framerate: z
.number()
.int()
.min(1)
.describe(
"(start) Recording framerate in fps. Values above 20 require GPU to be enabled on the session.",
)
.optional(),
max_duration_in_seconds: z
.number()
.int()
.min(1)
.describe("(start) Maximum recording duration in seconds.")
.optional(),
record_audio: z
.boolean()
.describe(
"(start) Record audio in addition to video. Defaults to video-only.",
)
.optional(),
},
{
title: "Manage browser session replays",
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async (params, extra) => {
if (!extra.authInfo) throw new Error("Authentication required");
const client = createKernelClient(extra.authInfo.token);

try {
switch (params.action) {
case "start": {
const body = {
...(params.framerate !== undefined && {
framerate: params.framerate,
}),
...(params.max_duration_in_seconds !== undefined && {
max_duration_in_seconds: params.max_duration_in_seconds,
}),
...(params.record_audio !== undefined && {
record_audio: params.record_audio,
}),
};
const replay = await client.browsers.replays.start(
params.session_id,
Object.keys(body).length > 0 ? body : undefined,
);
return jsonResponse(replay);
}
case "stop": {
if (!params.replay_id)
return errorResponse("Error: replay_id is required for stop.");
await client.browsers.replays.stop(params.replay_id, {
id: params.session_id,
});
return textResponse("Replay stopped successfully");
}
case "list": {
const replays = await client.browsers.replays.list(
params.session_id,
);
return itemsJsonResponse(replays, {
emptyText: "No replays found for this session",
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List skips pagination contract

Medium Severity

manage_replays list calls browsers.replays.list with only session_id and returns via itemsJsonResponse, without paginationParams or paginatedJsonResponse. Other MCP tool list actions expose limit/offset and return { items, has_more, next_offset }, so this path breaks the shared list contract agents rely on.

Fix in Cursor Fix in Web

Triggered by learned rule: MCP resources iterate all pages; tool list actions return single pages

Reviewed by Cursor Bugbot for commit 1265a2d. Configure here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applicable here — the SDK's browsers.replays.list(id) returns a plain Array (ReplayListResponse), not a paginated page. There's no limit/offset/cursor to honor (unlike extensions.list/proxies.list, which return a page object), so it returns every replay for the session in one call. itemsJsonResponse still emits the shared { items, has_more, next_offset } envelope, so agents get the same shape. Adding paginationParams would advertise pagination the endpoint doesn't support. Leaving as-is.

}
}
} catch (error) {
return toolErrorResponse("manage_replays", params.action, error);
}
},
);
}
Loading