Open protocol v1.0 · MIT / Apache-2.0

UI for humans.
MCP for agents.

ClientMCP exposes a live browser, desktop or mobile application as a standard MCP server. Agents call the same actions as its UI, on the same state. When an agent adds a todo, the user sees it appear.

  • No port in the app
  • Any MCP client
  • Docker or Cloudflare Workers
Illustration: an agent adds “Oat milk” to a todo app through ClientMCP and it appears in the app; the user checks off “Buy eggs” and the agent sees it; the agent asks to clear finished items and the app asks the user to confirm.
Your app connecting…

Groceries

  • Buy eggs
  • Book flights
Agent POST /mcp

01 The idea

One application.
Two interfaces.
Shared state.

Agents usually reach an application from the outside: screenshots and simulated clicks, or a separate backend API that cannot see what is open in the app. ClientMCP works from the inside. The tool you expose is the function your button already calls.

  • Nothing to keep in sync. There is one implementation of each action.
  • No UI automation. No screenshots, DOM scripting or simulated input.
  • Changes show up on both sides. The agent sees what the user did; the user sees what the agent did.
app.ts
// One implementation of each action…
const addTodo = (title: string) => todos.add(title);

// …called by the UI, for people,
form.onsubmit = () => addTodo(input.value);

// …and by agents, through ClientMCP.
mcp.expose("add_todo", {
  description: "Add a todo",
  inputSchema: {
    type: "object",
    properties: { title: { type: "string" } },
    required: ["title"],
  },
  handler: ({ title }) => addTodo(title),
});

02 How it works

Both sides dial out. The bridge meets them.

The application opens one WebSocket to a bridge and declares its tools. Agents talk to the same bridge over standard MCP Streamable HTTP. Neither needs to be reachable, and the bridge stores nothing.

Client SDK

Declares tools, receives calls and runs them in the app. Pings, reconnects with backoff and re-registers on its own. No dependencies.

Bridge

Translates between the WebSocket and MCP. One Rust core with two runtimes: a 1.2 MB static binary for any Linux host, or Cloudflare Workers with Durable Objects.

Agent

Needs nothing beyond MCP and a bearer token. tools/list returns what the app declared; tools/call runs inside the app.

03 Quick start

From a running app to a working agent in four steps.

  1. 01

    Run a bridge

    A static binary in a scratch image. It stores nothing; the secret signs every token.

    git clone https://github.com/atshelchin/clientmcp && cd clientmcp
    docker build -t clientmcp .
    export CLIENTMCP_SECRET=$(openssl rand -hex 32)
    docker run -d -p 8080:8080 -e CLIENTMCP_SECRET clientmcp
  2. 02

    Mint tokens

    A client token lets the application connect as my-app; an agent token lets an agent call it.

    docker run --rm -e CLIENTMCP_SECRET clientmcp token client my-app
    docker run --rm -e CLIENTMCP_SECRET clientmcp token agent my-app --ttl 30d
  3. 03

    Expose functions from the application

    The SDK in sdk/js has no dependencies and runs in browsers, Node.js 22+, Deno and Bun.

    import { ClientMCP } from "clientmcp";
    
    const mcp = new ClientMCP({ url: "ws://localhost:8080/connect", token: CLIENT_TOKEN });
    mcp.expose("add_todo", {
      description: "Add a todo",
      inputSchema: { type: "object", properties: { title: { type: "string" } }, required: ["title"] },
      handler: ({ title }) => todos.add(title), // the same action the UI calls
    });
    await mcp.connect();
  4. 04

    Connect an agent

    Any MCP client works. Hosts that cannot send headers can put the token in the URL: /mcp/<agent token>.

    claude mcp add --transport http my-app http://localhost:8080/mcp \
      --header "Authorization: Bearer $AGENT_TOKEN"

Read the full guide for production tokens, TLS and configuration, or open the browser example, a complete shared todo list.

04 The details

Built around what goes wrong in practice.

No server in the app

The application opens no port and runs no HTTP server. One outbound WebSocket, so a browser tab behind NAT works as well as a desktop app.

Both MCP eras, one endpoint

The bridge serves MCP 2026-07-28 and the 2025 revisions side by side, decided per request, keeping no per-agent state in either.

Offline is a state, not an error

A closed app simply has no tools. Agents stay connected and get tools/list_changed the moment it comes back.

Calls can wait for a person

A call still running after a second streams as SSE with keep-alives, so a user can take a minute to confirm without a proxy cutting it off.

Stateless tokens

HS256 JWTs with a client ID and a role. Any backend can mint them and any bridge verifies them without a database. Secrets rotate without downtime.

Declarative tools

Each register declares the complete tool set and the bridge mirrors it. There are no incremental updates to lose or reorder.

05 Control

The application stays in charge.

Arguments come from a language model, and prompt injection can steer it. So the app decides which tools exist, validates every argument and asks its user before anything consequential. Two optional conventions let every app say so in the same words.

Risk
observeoperatemanagetransact

Published as _meta with the MCP annotations each level implies.

Outcome
completedacceptedrejected

The change held, it started and goes on, or nothing changed and why.

app.ts
import { completed, rejected } from "clientmcp";

mcp.expose("clear_done", {
  description: "Remove completed todos",
  risk: "manage", // observe | operate | manage | transact
  handler: async () => {
    if (!(await askTheUser("Remove completed items?")))
      return rejected("denied", "the user said no");
    todos.clearDone();
    return completed(); // the change holds — the app has seen it
  },
});

06 Protocol

Seven JSON messages. That is the whole client side.

The bridge maps them onto MCP: tools/list returns the declared tools, tools/call becomes a call, and every change is pushed as notifications/tools/list_changed. Tool definitions, arguments and results pass through verbatim, so new MCP fields need no bridge release.

↑ app → bridge ↓ bridge → app
  • ↑ register Declare the complete tool set {"type":"register","tools":[{"name":"add_todo","inputSchema":{…}}]}
  • ↓ registered Token and tools accepted {"type":"registered"}
  • ↓ call Execute a tool {"type":"call","id":"8f3a","name":"add_todo","arguments":{"title":"Oat milk"}}
  • ↑ result Finish a call with a CallToolResult {"type":"result","id":"8f3a","result":{"content":[…]}}
  • ↓ cancel Stop a call; its result is not wanted {"type":"cancel","id":"8f3a"}
  • ↑ ping Liveness, every 25 s when idle {"type":"ping"}
  • ↓ pong Answered at the edge on Workers {"type":"pong"}

Read the specification: tokens, close codes, both MCP eras, limits and security, in one page.

07 Deploy

Two runtimes. One Rust core. The same tests.

Linux & Docker

A 1.2 MB static binary on scratch, running as an unprivileged user. Put TLS in front; the repository has a Compose file with Caddy that gets certificates automatically.

docker run -d -p 8080:8080 -e CLIENTMCP_SECRET clientmcp
# or, without Docker
cargo build --release -p clientmcp-server

Cloudflare Workers

One Durable Object per client ID holds its WebSocket through the Hibernation API. Pings are answered at the edge, so a connected but idle app keeps nothing awake.

cd crates/worker
npx wrangler secret put CLIENTMCP_SECRET
npx wrangler deploy

08 Measured

Small enough to forget it is there.

bridge CPU per tools/call round trip
~11 µs ~14 µs on MCP 2026-07-28
calls per second, 64 agents → 1 client
86–93k p99 ≈ 1.9 ms on 2 shared vCPUs
memory per idle connected client
~11 KB 1.2 MB for the idle process
CPU while 10,000 clients sit idle
0 each pinging every 25 s

Static binary on Linux aarch64, loopback, with the load generator on the same machine, so the numbers understate the bridge alone. Full table.

Verified against the real thing

The conformance suite drives a bridge with the official MCP SDKs as agents (v1 for the 2025 era, v2 for 2026-07-28) and passes unchanged against the native server, the Docker image and the Worker running in workerd. Beyond the suite, the browser example was run end to end in Chrome, and Claude Code, as a real agent, listed and added todos through the bridge.

  • authentication
  • origin checks & CORS
  • both MCP eras
  • header validation
  • x-mcp-header
  • notification streams
  • streamed slow calls
  • concurrency
  • timeouts
  • cancellation
  • disconnection
  • replacement
  • size limits
  • tool validation

09 Limits

What v1 does not do.

The bridge is trusted

It terminates TLS and sees every argument and result. There is no end-to-end encryption between agent and app: run a bridge you operate or trust.

Revocation means rotation

Before exp, only rotating the secret revokes a token. Several secrets can be active at once, so rotating needs no downtime.

One server instance per client ID

The single-process bridge holds tens of thousands of clients, but several instances need requests for a client routed to one of them. Workers scale by construction.

Tools only, for now

Resources, prompts, progress notifications and multi round-trip requests are not in v1. They fit later as new message types.

Give your app a second interface.