Skip to content
Engineering3 min read

Extension protocol

The L0 probe and the L1 MCP session: wire format, state machine, invariants and limits.

Updated Nov 6, 20253 min readEdit this page
On this page

This is the contract between an MCP host (Lumo) and any Chrome extension that serves tools. It has exactly two layers: one custom message that answers whether the provider is usable, and standard MCP for everything else.

Two layers#

An MCP initialize cannot describe "installed but disabled" or "not authorized" without failing the handshake, which leaves the host unable to tell the user what to fix. L0 exists to express those states; L1 stays completely standard.

L0 request over chrome.runtime.sendMessagejson
{  "channel": "lumo:probe",  "version": 1,  "id": "probe-42",  "client": { "id": "<lumo extension id>", "name": "Lumo", "version": "1.0.19" }}
L0 response — ok stays true for policy failuresjson
{  "ok": true,  "service": { "namespace": "userscripts", "name": "Lumo Userscripts", "version": "1.0.1" },  "status": {    "enabled": true,    "authorized": false,    "ready": true,    "pendingApproval": true  }}

Layer 1: MCP over a runtime port#

  • The port name is fixed: lumo:mcp. The probe channel is fixed: lumo:probe.
  • JSON-RPC, initialize, capability negotiation, tools/list and tools/call are handled by the official MCP SDK on both sides.
  • A port closing does not mean the service disappeared: MV3 may recycle the service worker. The host keeps the logical session and cached tool list, then reconnects lazily.
  • Reconnection performs a fresh initialize. The provider creates one McpServer per port and must not replay an old initialize result.

State machine#

Probe resultHost stateUser-visible outcome
sendMessage rejectserrorExtension not installed, or no agent endpoint.
Timeout at 1500mserrorThe original error message.
ok: falseerrorThe protocol error message.
Missing service or statuserrorProbe response is incomplete.
enabled: falseerrorAgent access is turned off in the provider.
authorized: false + pendingerrorWaiting for your approval in the provider.
authorized: falseerrorThe caller is not on the allowlist.
ready: falseerrorThe provider's own reason message.
All passconnectedTools appear, with counts.

Invariants#

  1. Fixed names and version: lumo:probe, lumo:mcp, probe version 1.
  2. isExtensionId() is the only ID validator: /^[a-p]{32}$/. Every input path must use it.
  3. A probe returns ok: true even for a policy failure; only a protocol failure returns ok: false.
  4. Authorization compares sender.id. client.name and service.name are display-only.
  5. L1 requires enabled && authorized. ready describes capability, not permission.
  6. The provider re-checks policy on every tool call, so a switch takes effect immediately.
  7. A closed port is not a deleted service; reconnect with a fresh initialize.
  8. L0 carries metadata only. Tools and business data move over L1 after authorization.

Tool namespacing and limits#

RuleValueReason
Maximum tools per provider64Keeps the model-visible catalogue bounded.
Namespace length≤ 16Leaves room for the tool name.
Tool name length≤ 40Provider-side constraint.
Combined length≤ 57Below OpenAI's 64-character function-name limit.
Reserved namespacesbrowser, page, file, network, devtools, webmcp, extPrevents collisions with built-ins.
Name collisionsFirst winsNever silently overwrite another server's tool.

Audit and rate limits#

  • Every write is audited with tool name, sender ID, result, target UUID and a hashed argument digest.
  • Script source code never enters the audit log.
  • Writes use a sliding window of 30 calls per 60 seconds per caller.
  • A business failure is returned as a successful MCP call with isError: true, so the model can read the reason instead of losing the turn.

Do not widen these limits casually

The 57-character total is derived from a hard provider limit. Raising the namespace or tool budget without checking every model integration breaks tool calling in ways that look like model failures.

Implementing a provider#

  1. 1

    Declare the boundary

    Add externally_connectable to the manifest. ids: ['*'] is required while the host is unpacked, because its ID changes on every install.

  2. 2

    Register both listeners synchronously

    Call chrome.runtime.onMessageExternal and onConnectExternal at the top level of the background script, not inside an async callback.

  3. 3

    Write a buffered transport

    Implement the MCP Transport interface over the port, buffering inbound messages until authorization completes.

  4. 4

    Create one server per port

    Register tools with the official SDK and shape the instructions with the current policy.

  5. 5

    Store and re-check policy

    Persist endpoint, allowlist, write and review switches; re-resolve them at the start of every tool call.

Lumo Userscripts is the reference implementation: lib/agent/protocol.ts, probe.ts, transport.ts, mcp-server.ts, tools.ts, policy.ts and audit.ts. The host side lives in lib/mcp/extension-*.ts.