Architecture
OpenTabs is three things working together: an MCP server that discovers plugins and dispatches tool calls, a Chrome extension that injects adapters into browser tabs, and plugins that define what your agent can actually do. All communication uses JSON-RPC 2.0. Let me walk you through how each piece works and why I made the choices I did.
MCP Server
The MCP server is a single localhost process — no cloud, no relay servers, nothing phoning home. It runs on localhost:9515 (configurable via the PORT env var). The opentabs CLI also accepts OPENTABS_PORT as a user-facing convenience — it reads the value and passes it to the server process as PORT when spawning. It is an HTTP + WebSocket server (node:http + ws) with two responsibilities:
- Plugin discovery — resolves plugins from npm and local paths, loads their metadata and adapter bundles, and builds an immutable registry
- Request dispatch — registers plugin tools as MCP capabilities, dispatches calls to the Chrome extension via WebSocket, and relays results back to MCP clients
HTTP Endpoints
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/mcp | POST/GET/DELETE | Bearer token | MCP Streamable HTTP transport. AI agents connect here. |
/ws | WebSocket | Sec-WebSocket-Protocol | Chrome extension connection. |
/ws-info | GET | Bearer token | Returns WebSocket URL for extension bootstrap. |
/health | GET | Optional Bearer token | Server status. Returns basic status without auth; includes full plugin details, audit summary, and file watcher state when authenticated. |
/audit | GET | Bearer token | Last 500 tool invocations with timing, status, and error metadata. |
/reload | POST | Bearer token | Trigger config and plugin rediscovery (rate limited). |
/extension/reload | POST | Bearer token | Send reload signal to the Chrome extension. |
Discovery Pipeline
Plugin discovery turned out to need more stages than you'd expect. It's a five-phase pipeline in platform/mcp-server/src/discovery.ts:
- Discover npm plugins — scans global
node_modulesfor packages matchingopentabs-plugin-*and@*/opentabs-plugin-* - Resolve local plugins — reads filesystem paths from
config.localPluginsand resolves them to absolute directories - Load all plugins — reads
package.json(withopentabsmetadata field),dist/adapter.iife.js, anddist/tools.jsonfor each plugin in parallel - Merge — local plugins override npm plugins of the same name
- Build registry — constructs an immutable
PluginRegistrywith O(1) lookup maps for tools
Errors in any phase are collected but non-fatal — other plugins still load. Failed plugins are tracked in the registry for diagnostic purposes (visible in /health).
Plugin Registry
The PluginRegistry (platform/mcp-server/src/registry.ts) is immutable (Object.freeze()). Mutations during hot reload are a nightmare to debug, and immutability makes the whole thing predictable. It maintains two internal Maps:
plugins— name →RegisteredPlugintoolLookup— prefixed tool name →ToolLookupEntry(with pre-compiled Ajv validators)
When plugins change, a new registry is built and atomically swapped on the server state. Ajv validators are compiled during registry construction, so tool dispatch has O(1) lookup with no schema compilation at call time.
Plugin Sources
Plugins are classified by source: npm (auto-discovered from global packages) or local (filesystem paths in config). The source is visible in opentabs status and the /health endpoint.
Hot Reload Architecture
Fast feedback during development matters a lot. In dev mode, the MCP server runs behind a thin dev proxy (src/dev-proxy.ts). The proxy holds all HTTP and WebSocket connections while watching dist/ for .js file changes. On change (debounced 300ms), the proxy kills the current worker process and forks a new one. The new worker starts on an ephemeral port, signals its actual port to the proxy via IPC, and the proxy resumes forwarding traffic.
On each hot reload cycle:
- The dev proxy detects changed
.jsfiles indist/ - The current worker process is killed and a new one is forked
- The new worker re-reads config, re-discovers plugins, and builds a new registry
- MCP handlers are registered on new sessions
- A
sync.fullis sent to the extension with the updated plugin list - MCP clients are notified via
tools/list_changed
Chrome Extension
The Chrome extension is the bridge between the server and your browser tabs. It's a Manifest V3 extension with three main parts: a background service worker, an offscreen document, and a side panel.
Persistent WebSocket
This part was tricky. MV3 service workers can be suspended at any time — Chrome just kills them to save memory. To maintain a persistent WebSocket, I use an offscreen document — a hidden HTML page with a stable execution context. The offscreen document:
- Connects to
ws://localhost:9515/ws - Authenticates via the
Sec-WebSocket-Protocolheader (keeps the secret out of URLs and logs) - Runs ping/pong keepalive every 15 seconds with a 5-second timeout
- Reconnects with exponential backoff (1s → 2s → 3s → 3s cap)
A Chrome alarm re-ensures the offscreen document exists under memory pressure.
Adapter Injection
Getting code into web pages sounds simple until you hit Content Security Policy. Plugin adapters are injected as IIFE bundles using chrome.scripting.executeScript() with files and world: 'MAIN':
- A tab loads and reaches
status === 'complete' - The background script queries matching tabs by URL pattern
- For each matching tab without an adapter, it injects the plugin's IIFE from
~/.opentabs/extension/adapters/<plugin>.js - The adapter registers itself at
globalThis.__openTabs.adapters[pluginName]
File-based injection bypasses page Content Security Policy (CSP). Because adapter files are bundled with the extension, Chrome treats them as extension resources exempt from page CSP.
Dispatch Flow
When an AI agent calls a tool:
- The MCP server strips the optional
tabIdfrom the input args (if present), validates the remaining input against the pre-compiled JSON Schema, and threadstabIdas a top-level field in thetool.dispatchparams - The server sends a
tool.dispatchJSON-RPC request over WebSocket - The extension routes to one of two dispatch paths based on whether
tabIdis present:- Targeted dispatch (tabId present): Dispatches directly to the specified tab after validating the tab exists and its URL matches the plugin's URL patterns. Returns an error if the tab doesn't exist, the URL doesn't match (security check), or the adapter isn't ready — no fallback
- Auto-select dispatch (tabId absent): Ranks all matching ready tabs (active tab in focused window > active tab in any window > any tab in focused window > other tabs) and tries each in order until one succeeds
- The extension calls
adapter.handle(toolName, input)in the MAIN world viachrome.scripting.executeScript() - The result flows back through WebSocket to the MCP server, which returns it to the AI agent
Tab State Machine
Each plugin has three tab states:
| State | Meaning |
|---|---|
closed | No tab matches the plugin's URL patterns |
unavailable | A matching tab exists but isReady() returns false (e.g., user not logged in) |
ready | A matching tab exists and isReady() returns true |
The extension probes all matching tabs for readiness and reports a tabs array per plugin (each entry: { tabId, url, title, ready }) via tab.stateChanged and tab.syncAll messages. The aggregate state is derived from all matching tabs: ready if any tab is ready, unavailable if tabs exist but none ready, closed if no tabs exist. Tool dispatch only attempts tabs in the ready state.
To discover which tabs are available for a plugin, use the plugin_list_tabs MCP tool. To target a specific tab, pass its tabId as an optional parameter to any plugin tool (all plugin tools have tabId injected into their input schema automatically).
Wire Protocol
The wire protocol is the glue between server and extension. All communication uses JSON-RPC 2.0 over WebSocket with a 10 MB message size limit.
Server → Extension:
| Method | Purpose |
|---|---|
sync.full | Full plugin list (sent on connect and after rediscovery) |
plugin.update | Single plugin changed (file watcher detected changes) |
plugin.uninstall | Plugin removed from discovery |
plugins.changed | Plugin registry changed (after install, update, or remove) — side panel refreshes its plugin list |
tool.dispatch | Execute a plugin tool on a matching tab |
browser.* | Built-in browser tool commands |
tool.invocationStart | Tool execution started (side panel animation) |
tool.invocationEnd | Tool execution completed (side panel animation) |
confirmation.request | Request user approval for a browser tool (id, tool, domain, tabId, paramsPreview, timeoutMs) |
extension.reload | Signal the extension to reload |
pong | Keepalive response |
Extension → Server:
| Method | Purpose |
|---|---|
tab.syncAll | Full tab state for all plugins (sent on connect/reconnect) |
tab.stateChanged | Single plugin's tab state changed |
tool.progress | Progress notification from a running tool |
plugin.log | Log entry from a plugin adapter |
config.getState | Fetch config state for the side panel |
config.setToolPermission | Set permission for a single tool |
config.setPluginPermission | Set permission for all tools in a plugin at once |
config.setSkipPermissions | Toggle global skip-permissions flag |
plugin.search | Search the npm registry for plugins matching a query |
plugin.install | Install a plugin from npm |
plugin.updateFromRegistry | Update an installed plugin to the latest registry version |
plugin.remove | Remove an installed plugin |
plugin.checkUpdates | Check for available updates to installed plugins |
confirmation.response | User's approval decision (id, decision, alwaysAllow) |
ping | Keepalive probe |
Timeouts
| Operation | Default | Maximum |
|---|---|---|
| Tool dispatch | 30 seconds | 5 minutes (with progress) |
| Extension script execution | 25 seconds | 295 seconds (with progress) |
Readiness probe (isReady()) | 5 seconds | — |
| Keepalive ping/pong | 15s interval, 5s timeout | — |
Tool dispatch timeout resets on each progress notification. The extension's script timeout (295s) is 5 seconds shorter than the server's absolute maximum (300s) to ensure the extension always responds before the server times out.
Security
This is the part I think about the most. Your browser sessions are the keys to your digital life — email, work tools, everything. Any system that touches them needs to be paranoid about security. I won't pretend the model is perfect, but here's every layer of protection I built in.
Authentication
/mcp: Bearer token in theAuthorizationheader — the 64-character hexsecretfrom~/.opentabs/extension/auth.json/ws: Auth viaSec-WebSocket-Protocol— the client sends['opentabs', '<secret>'], server echoes'opentabs'as the accepted subprotocol/reloadand/extension/reload: Bearer token required, plus rate limiting (10 requests/minute)/ws-infoand/audit: Bearer token required, no rate limiting
CORS Protection
The server rejects any HTTP request with an Origin header unless it matches chrome-extension://. Legitimate MCP clients do not send Origin headers, so this prevents DNS rebinding attacks without affecting normal usage.
Adapter Integrity
The opentabs-plugin build CLI computes a SHA-256 hash of the adapter IIFE and embeds it as __adapterHash. The extension verifies this hash after injection to detect partial file writes or tampering.
Output Sanitization
Tool outputs are sanitized to remove keys that could cause prototype pollution. Error messages have internal file paths, IP addresses, and URLs stripped before being returned to clients.
Tool Permissions
All plugin and browser tools default to 'off' (disabled). Permissions are configured per-plugin or per-tool to 'off', 'ask', or 'auto' via the side panel or config.json.
Permission evaluation order:
- Per-tool override in
permissions[plugin].tools[tool] - Plugin default in
permissions[plugin].permission 'off'— fallback if no permission is configuredskipPermissions(OPENTABS_DANGEROUSLY_SKIP_PERMISSIONSenv var) — applied last:'ask'becomes'auto','off'stays'off'
Confirmation System
When a tool evaluates to 'ask', the server pauses and asks you. The confirmation flows through the Chrome extension's side panel:
- Server sends
confirmation.requestto the extension with{ id, tool, plugin, params } - The side panel displays an approval dialog
- The user chooses: allow once, allow always, or deny
- The extension sends
confirmation.responseback with{ id, decision: 'allow' | 'deny', alwaysAllow: boolean } - Server proceeds with the tool call or returns a denial error
When the user chooses "allow always" (alwaysAllow: true), the tool's permission is permanently set to 'auto' in config.json. There are no session-scoped rules — permissions are either one-shot or permanent. Pending confirmations are rejected if the extension disconnects.
Key Source Files
If you want to go deeper, these are the files I'd start with:
platform/mcp-server/src/index.ts— server entry point, HTTP routes, WebSocket handlingplatform/mcp-server/src/discovery.ts— five-phase plugin discovery pipelineplatform/mcp-server/src/registry.ts— immutable plugin registry with O(1) lookupsplatform/mcp-server/src/extension-protocol.ts— JSON-RPC dispatch to the extension, confirmation handlingplatform/mcp-server/src/permissions.ts— permission evaluation and resolutionplatform/browser-extension/src/background.ts— extension service worker, adapter injection, message routingplatform/browser-extension/src/offscreen/index.ts— persistent WebSocket via offscreen documentplatform/plugin-sdk/src/index.ts—OpenTabsPluginbase class,defineTool
Last Updated: 10 Mar, 2026