Your capabilities. Your agent. Your control.
🔗 Live demo: hitch.agent9.dev · MIT licensed · built for the WebMCP Challenge
Captured against the spec-faithful WebMCP implementation in
tests/webmcp-shim.js, the same one the test suite runs. The tools
shown are really registered; the calls in the activity log really executed.
Hitch is WebMCP in reverse. Instead of a website handing capabilities to your agent, you hand capabilities to the website.
WebMCP gives websites a structured way to expose capabilities to AI agents. Hitch asks the inverse question:
What if users could bring capabilities they already own to the web?
Hitch connects to MCP capability sources you control, discovers what they offer,
lets you grant only the capabilities you choose, registers those grants into the page
with document.modelContext.registerTool(), executes them back through MCP, and shows
every agent call in an audit trail you can revoke from at any moment.
The webpage never receives the credentials behind those capabilities. It receives a callable contract.
Today, every website that wants an agent to do something useful has to integrate the services itself: Gmail, GitHub, Notion, Slack, Drive, Calendar, your internal APIs, your automation infrastructure. Each integration is a separate OAuth flow, a separate token, a separate silo, rebuilt site by site.
Meanwhile the user usually already has all of that wired up somewhere, in n8n, in an MCP server, in a local agent, on their own hardware.
The capability exists. It just cannot travel.
USER'S EXISTING CAPABILITIES
│
│ MCP
▼
┌───────────────────┐
│ CAPABILITY LAYER │
├───────────────────┤
│ discover │
│ classify │
│ approve ← user │
│ project │
│ execute │
│ audit │
│ revoke ← user │
└───────────────────┘
│
│ WebMCP
▼
BROWSER AGENT
Three layers, and the middle one is the product:
| Layer | What it is | In this build |
|---|---|---|
| Source | Capabilities the user already owns | n8n, hosted MCP servers |
| Control | Discovery, risk classification, leases, audit, revocation | Hitch |
| Surface | The browser-native agent interface | WebMCP |
This is deliberately not a protocol proxy. A proxy says protocol A → protocol B. Hitch says discover → classify → approve → lease → project → observe → revoke. That control layer is the point.
Browser Agent (ChatGPT in-app browser / Chrome WebMCP)
│
│ WebMCP, document.modelContext.registerTool()
▼
Hitch page (no credentials, no MCP endpoints, no tokens)
│
│ same-origin HTTPS: POST /api/execute { source, tool, arguments }
▼
Capability Bridge (Cloudflare Worker, the only privileged code)
│
│ MCP Streamable HTTP (official @modelcontextprotocol/sdk client)
▼
┌───────────────────────┬────────────────────────────┐
▼ ▼
n8n (self-hosted) Hitch Cloud Source …any MCP server
bearer token model + task store
holds credentials holds credentials
Why the MCP client lives on the server. If the browser held the MCP endpoint, it would hold the token behind it, and "the page never sees your credentials" would be a slogan rather than a property. The bridge is the only code that knows a capability source has an address at all.
The whole projection is one file, deliberately: src/client/lib/webmcp/project.ts.
It maps an MCP tool contract onto a WebMCP tool contract with no library in between:
| MCP | → | WebMCP |
|---|---|---|
name |
→ | name |
title |
→ | title |
description |
→ | description |
inputSchema (JSON Schema) |
→ | inputSchema (passed through verbatim) |
annotations.readOnlyHint |
→ | annotations.readOnlyHint |
| server-side execution | → | execute() → POST /api/execute → MCP tools/call |
await modelContext.registerTool(
{
name: capability.name,
title: capability.title,
description: capability.description,
inputSchema: capability.inputSchema, // the source's schema, untouched
annotations: {
readOnlyHint: capability.risk === "read",
destructiveHint: capability.risk === "dangerous",
untrustedContentHint: true, // results are data, never instructions
},
execute, // audited, bridged, revocable
},
{ signal: controller.signal }, // ← the lease
);Grants are leases, not flags. Each grant is held by an AbortController. Revoking calls
controller.abort(), and the abort itself unregisters the tool. There is no second code path
that has to remember to clean up, and a page refresh ends every lease by construction.
Discovery is not permission. Nothing reaches registerTool until the user clicks Grant.
A capability can be visible, described, risk-labelled and still completely unavailable to the agent.
Real MCP, over the network, with the official TypeScript SDK and
StreamableHTTPClientTransport. See src/worker/mcp/client.ts.
GET /api/capabilities→initialize+tools/listagainst every configured sourcePOST /api/execute→tools/list(to re-check the allowlist) +tools/call
Every execution re-validates the tool name against a live tools/list from that source,
so the bridge can only ever invoke something the source is currently advertising. A name the
browser invents is rejected at the bridge, not at the source.
Not every MCP source publishes a clean contract. n8n advertises every HTTP Request Tool as a single opaque property, with the real schema buried in the description as prose:
An agent handed that has to guess at a stringified blob. A capability layer should not pass
that through, so src/worker/mcp/harmonize.ts recovers the
schema, cleans the description, and remembers that this source needs its arguments re-wrapped
at call time:
// what Hitch projects into WebMCP
{
"inputSchema": {
"type": "object",
"properties": {
"latitude": { "type": "number", "description": "Latitude in decimal degrees." },
"longitude": { "type": "number", "description": "Longitude in decimal degrees." }
},
"required": ["latitude", "longitude"],
"additionalProperties": false
}
}The agent calls check_air_quality({ latitude, longitude }). The bridge absorbs the source's
quirk on the way out. This is the clearest example of the layer earning its place: it is not
forwarding a protocol, it is presenting a better contract than the source published.
A self-hosted n8n instance exposing an MCP Server Trigger (Streamable HTTP), protected with bearer auth. The token lives in the Worker as a secret and is never serialised to the page.
n8n matters here because it is already the boundary between agents and real services: it holds the Gmail token, the GitHub key, the database password. Hitch asks it for a capability, and n8n keeps the credential. The website gets neither.
A separate MCP server (src/sources/cloud-source.ts) running as
its own Cloudflare Worker on its own origin, reached over the public internet exactly like any
third-party MCP server. It holds a Workers AI model binding and a KV task store, credentials
the page also never sees.
It exists so a judge always has a live capability source, without needing our hardware, our network, or our credentials.
| Capability | Risk | What actually happens |
|---|---|---|
research_company |
READ | Live Wikipedia search + summary fetch |
draft_launch_announcement |
GENERATIVE | Real inference on Workers AI (Llama 3.3 70B) |
create_project_task |
WRITE | Durable write to Cloudflare KV |
list_project_tasks |
READ | Reads those writes back |
| Boundary | Guarantee |
|---|---|
| Credential | MCP endpoints and tokens exist only in Worker environment bindings. The client bundle contains no endpoint, no token, no key. |
| Capability | Only tools returned by a live tools/list can be executed. Unknown names and unknown sources are rejected at the bridge. |
| Consent | Discovery never registers anything. A capability becomes agent-visible only on an explicit user grant. |
| Context | Tools are registered into this document's model context, not into the browser at large. |
| Time | A lease lasts for the page session. Refresh revokes everything. |
| Visibility | Every request, completion and failure is written to the audit log before and after the call. |
| Revocation | controller.abort() unregisters the tool immediately. |
| Content | Results carry untrustedContentHint: capability output is data to show the user, never instructions for the agent to follow. |
Risk classification is not done by a model. It reads MCP annotations, applies a reviewed
local mapping, and falls back to write for anything unrecognised, so a new tool appearing at a
source can never quietly present itself as harmless.
See src/worker/policy/risk.ts.
More detail: docs/SECURITY.md · docs/ARCHITECTURE.md
- Open hitch.agent9.dev in ChatGPT's in-app browser, or in Chrome with WebMCP enabled.
- Capabilities are discovered live over MCP. Grant the ones you want.
- Ask your agent something that needs them, for example:
Research Anthropic, draft a short launch announcement for developers, and create a follow-up task.
- Watch Agent activity record each call, its risk class and its duration.
- Hit Revoke all. The tools stop existing for the agent immediately.
Without a WebMCP browser the page still works: discovery and execution are real, and the
Local test call panel invokes the exact closure that was handed to registerTool. Grants
made without WebMCP present are labelled local only rather than pretending an agent can see them.
node scripts/inspect-mcp.mjs https://your-server.example.com/mcp
MCP_TOKEN=... node scripts/inspect-mcp.mjs https://your-server.example.com/mcpPrints what a source advertises and what Hitch would project after harmonisation. The fastest way to see whether a source publishes a contract an agent can actually use.
The WebMCP surface is the thing this project is built on, so it is the thing the tests
actually exercise. tests/webmcp-shim.js is a deliberately strict,
spec-faithful implementation of document.modelContext: it rejects descriptors the
specification does not describe as valid, and it validates every execute response against
the required content-block shape. The suite then drives the real page against it.
npm run test:unit # capability harmonisation, pure logic
npm run test:webmcp # the real page against a conforming WebMCP host
npm test # bothWhat the integration suite asserts:
| WebMCP is detected and reported honestly | ✅ |
| Discovery registers nothing until the user grants | ✅ |
A grant registers a real tool visible to getTools() |
✅ |
| The registered contract is the source's contract, unrewritten | ✅ |
execute() returns a spec-valid content-block response |
✅ |
| Every call is written to the audit log with a duration | ✅ |
| A failure surfaces the source's own message and is audited | ✅ |
| A failure does not revoke the capability or break the page | ✅ |
Aborting the lease removes the tool from getTools() |
✅ |
| A revoked capability cannot be invoked at all | ✅ |
Writing these caught three real conformance bugs that no amount of local clicking would have
found: execute was returning a bare string where the specification requires a content-block
response, getTools() was being treated as synchronous, and a failing capability was throwing
away the source's error message before it reached the agent.
npm install
cp .env.example .dev.vars # point MCP_* at your own capability sources
npm run build # build the client into dist/
npm run dev:api # bridge on :8787
npm run dev # UI on :5173, proxying /api to the bridgeDeploy your own:
npm run deploy # the app
npm run deploy:source # the bundled demo MCP capability sourceThe app needs one KV namespace (LEASES, for connect-your-own-MCP handles); the demo source
needs a KV namespace (TASKS) and the Workers AI binding. Set capability sources as secrets,
never as vars:
npx wrangler secret put MCP_N8N_URL
npx wrangler secret put MCP_N8N_TOKENYou do not need any of that to try the concept, though: run the app with no secrets at all and use Connect your own capability source in the page to point it at any MCP server you have.
- MCP → WebMCP capability projection
- Native
document.modelContext.registerTool()implementation, no wrapper library AbortController-based capability leases with immediate revocation- User-approval gate between discovery and registration
- Capability risk classification from MCP annotations, conservative by default
- Server-side MCP execution proxy with live-discovery allowlisting
- Agent activity auditing for every call
- n8n MCP Server Trigger integration as a user-owned capability source
- Capability harmonisation: recovering real JSON Schemas from opaque source contracts
- Connect-your-own-MCP with an expiring credential handle and an SSRF guard
- A standalone MCP server so the demo needs nothing of ours to run
Hitch does not inject tools into arbitrary websites. WebMCP scopes registered tools to a document and its origin, and that scoping is correct. What Hitch demonstrates is the capability-projection primitive inside a WebMCP-native page.
Making those user-owned capabilities portable across compatible web experiences is the natural next layer, and it needs a local capability runtime rather than a page.
Origin-scoped and time-scoped leases · argument constraints and spend limits · confirmation gates for destructive capabilities · signed capability manifests · many sources at once · a local capability daemon for hardware, local agents and private networks · portable capability profiles that follow the user between sites.
The agentic web should not only know what a website can do. It should also know what the user can bring.
MIT. See LICENSE.
