Skip to main content

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:

  1. Plugin discovery — resolves plugins from npm and local paths, loads their metadata and adapter bundles, and builds an immutable registry
  2. 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

EndpointMethodAuthDescription
/mcpPOST/GET/DELETEBearer tokenMCP Streamable HTTP transport. AI agents connect here.
/wsWebSocketSec-WebSocket-ProtocolChrome extension connection.
/ws-infoGETBearer tokenReturns WebSocket URL for extension bootstrap.
/healthGETOptional Bearer tokenServer status. Returns basic status without auth; includes full plugin details, audit summary, and file watcher state when authenticated.
/auditGETBearer tokenLast 500 tool invocations with timing, status, and error metadata.
/reloadPOSTBearer tokenTrigger config and plugin rediscovery (rate limited).
/extension/reloadPOSTBearer tokenSend 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:

  1. Discover npm plugins — scans global node_modules for packages matching opentabs-plugin-* and @*/opentabs-plugin-*
  2. Resolve local plugins — reads filesystem paths from config.localPlugins and resolves them to absolute directories
  3. Load all plugins — reads package.json (with opentabs metadata field), dist/adapter.iife.js, and dist/tools.json for each plugin in parallel
  4. Merge — local plugins override npm plugins of the same name
  5. Build registry — constructs an immutable PluginRegistry with 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 → RegisteredPlugin
  • toolLookup — 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:

  1. The dev proxy detects changed .js files in dist/
  2. The current worker process is killed and a new one is forked
  3. The new worker re-reads config, re-discovers plugins, and builds a new registry
  4. MCP handlers are registered on new sessions
  5. A sync.full is sent to the extension with the updated plugin list
  6. 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-Protocol header (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':

  1. A tab loads and reaches status === 'complete'
  2. The background script queries matching tabs by URL pattern
  3. For each matching tab without an adapter, it injects the plugin's IIFE from ~/.opentabs/extension/adapters/<plugin>.js
  4. 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:

  1. The MCP server strips the optional tabId from the input args (if present), validates the remaining input against the pre-compiled JSON Schema, and threads tabId as a top-level field in the tool.dispatch params
  2. The server sends a tool.dispatch JSON-RPC request over WebSocket
  3. The extension routes to one of two dispatch paths based on whether tabId is 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
  4. The extension calls adapter.handle(toolName, input) in the MAIN world via chrome.scripting.executeScript()
  5. 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:

StateMeaning
closedNo tab matches the plugin's URL patterns
unavailableA matching tab exists but isReady() returns false (e.g., user not logged in)
readyA 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:

MethodPurpose
sync.fullFull plugin list (sent on connect and after rediscovery)
plugin.updateSingle plugin changed (file watcher detected changes)
plugin.uninstallPlugin removed from discovery
plugins.changedPlugin registry changed (after install, update, or remove) — side panel refreshes its plugin list
tool.dispatchExecute a plugin tool on a matching tab
browser.*Built-in browser tool commands
tool.invocationStartTool execution started (side panel animation)
tool.invocationEndTool execution completed (side panel animation)
confirmation.requestRequest user approval for a browser tool (id, tool, domain, tabId, paramsPreview, timeoutMs)
extension.reloadSignal the extension to reload
pongKeepalive response

Extension → Server:

MethodPurpose
tab.syncAllFull tab state for all plugins (sent on connect/reconnect)
tab.stateChangedSingle plugin's tab state changed
tool.progressProgress notification from a running tool
plugin.logLog entry from a plugin adapter
config.getStateFetch config state for the side panel
config.setToolPermissionSet permission for a single tool
config.setPluginPermissionSet permission for all tools in a plugin at once
config.setSkipPermissionsToggle global skip-permissions flag
plugin.searchSearch the npm registry for plugins matching a query
plugin.installInstall a plugin from npm
plugin.updateFromRegistryUpdate an installed plugin to the latest registry version
plugin.removeRemove an installed plugin
plugin.checkUpdatesCheck for available updates to installed plugins
confirmation.responseUser's approval decision (id, decision, alwaysAllow)
pingKeepalive probe

Timeouts

OperationDefaultMaximum
Tool dispatch30 seconds5 minutes (with progress)
Extension script execution25 seconds295 seconds (with progress)
Readiness probe (isReady())5 seconds
Keepalive ping/pong15s 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 the Authorization header — the 64-character hex secret from ~/.opentabs/extension/auth.json
  • /ws: Auth via Sec-WebSocket-Protocol — the client sends ['opentabs', '<secret>'], server echoes 'opentabs' as the accepted subprotocol
  • /reload and /extension/reload: Bearer token required, plus rate limiting (10 requests/minute)
  • /ws-info and /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:

  1. Per-tool override in permissions[plugin].tools[tool]
  2. Plugin default in permissions[plugin].permission
  3. 'off' — fallback if no permission is configured
  4. skipPermissions (OPENTABS_DANGEROUSLY_SKIP_PERMISSIONS env 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:

  1. Server sends confirmation.request to the extension with { id, tool, plugin, params }
  2. The side panel displays an approval dialog
  3. The user chooses: allow once, allow always, or deny
  4. The extension sends confirmation.response back with { id, decision: 'allow' | 'deny', alwaysAllow: boolean }
  5. 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 handling
  • platform/mcp-server/src/discovery.ts — five-phase plugin discovery pipeline
  • platform/mcp-server/src/registry.ts — immutable plugin registry with O(1) lookups
  • platform/mcp-server/src/extension-protocol.ts — JSON-RPC dispatch to the extension, confirmation handling
  • platform/mcp-server/src/permissions.ts — permission evaluation and resolution
  • platform/browser-extension/src/background.ts — extension service worker, adapter injection, message routing
  • platform/browser-extension/src/offscreen/index.ts — persistent WebSocket via offscreen document
  • platform/plugin-sdk/src/index.tsOpenTabsPlugin base class, defineTool

Last Updated: 10 Mar, 2026