Skip to content
v0.3

MCP: connect an agent

Forge speaks MCP at /mcp. There is nothing to deploy and no proxy to configure: the tool list is the registry, and the gate behind it is the kernel’s own.

The thing to understand before anything else: the address decides the store. /mcp on a storefront’s hostname is that store — resolved from the host, the same way the storefront decides which shop to render and the certificate decides which name it answers for. Not an environment variable, not a setting on your key, not an argument on a tool.

Only two, and they are answered in this order:

You connected to…with no Bearerwith a tenant Bearer
a store’s hostnamethe public face — the purchase journey and the public readsthat key’s tools plus the journey (telesales, agent-on-behalf)
a hostname no store claims401that key’s tools; no journey

The Bearer does not change which store it is — only what you may do there.

This is the case worth reading twice, because it is the product promise: a shopper’s agent does not hold — and must never hold — a key belonging to the merchant.

Terminal window
claude mcp add --transport http shop https://shop.example/mcp

No header. No token. Speaking to it by hand:

Terminal window
curl -X POST https://shop.example/mcp \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"my-agent","version":"1"}}}'

2. Turn the address into a store id — this step is not optional

Section titled “2. Turn the address into a store id — this step is not optional”

There is an asymmetry here and you will trip on it if nobody says it out loud:

  • Commands take no store. The host already decided; a store is configuration of the server, never an argument of a tool.
  • Public reads do take store, because the same read serves every shop.

So an agent that was handed only a URL starts by converting one into the other:

{ "method": "tools/call", "params": {
"name": "read.store.by_host",
"arguments": { "host": "shop.example" } } }
{ "store_id": "sto_01ABCDEFGHJKMNPQRSTVWXYZ01" }

Now it can browse:

{ "name": "read.products",
"arguments": { "store": "sto_…", "limit": 5, "projection": "feed" } }

The journey, in order. None of these takes a store:

cart.create → cart.add_line → cart.set_buyer → cart.set_delivery
→ cart.set_payment_method → checkout.place_order

checkout.place_order returns the order. That is a real order, in a real store, placed by something holding no credential — exactly as a browser does it, because it is what a browser does, over a different transport.

payment.initiate is a tool and you can call it. It returns next_action: null.

That is not a bug and not an oversight: the command claims the intent and opens the attempt, but it does not call the provider. Talking to a payment provider is third-party I/O, isolated in an adapter outside the kernel’s transaction — so it is not a command, and what is not a command is not a tool.

The agent closes the order over MCP and pays over the public HTTP route /v1/payment/commands/payment.initiate, or follows the next_action that route returns.

This limit is measured, not assumed: a test asserts that null. The day a tool exists for the provider invoke, that test fails and forces whoever built it to update this page.

Add a Bearer and the store does not change — the powers do. The key’s own tools appear beside the journey, which is the telesales case: an agent acting for the merchant, building a cart and attaching it to a customer’s account with cart.assign_customer.

Terminal window
claude mcp add --transport http forge https://shop.example/mcp \
--header "Authorization: Bearer $FORGE_TOKEN"

The tool list is the key’s scopes. A key carrying three read scopes sees reads and nothing else — not a longer list it must be trusted not to call. Issue a key with write scopes and the write tools appear; revoke a scope and they leave. See issue & rotate API keys.

Every call lands in the audit trail under the key’s name, refusals included.

The stdio binary has no request to read a host from, so it takes the store id directly:

Terminal window
FORGE_MCP_STORE=sto_… forge-mcp

With or without FORGE_MCP_TOKEN — the same two questions, answered the same way. To convert your hostname into that id: forge read store.by_host --input '{"host": "shop.example"}'.

Nothing. The anonymous face answers exactly what /v1/cart/* and /v1/read/* already answer to any browser — same commands, same dispatcher in public mode, and the same per-IP caps on the endpoints that carry one, with a key and without one. What changed is the transport, and who has to hold a credential in order to shop: nobody.

⚠️ That last parity is wired, not inherited, and the distinction is worth your attention if you run your own instance. The obvious reasoning — “it is the same dispatcher, so it is the same everything” — is wrong, and it is wrong in the direction that costs you: a second transport over a face inherits the face’s commands, not its guards. The HTTP caps are middleware on a route; a tool call never passes through that route. They are on this face because they were deliberately carried onto it.