Your API as MCP Tools
You give us REST endpoints; we run the MCP server. Your users' agents connect to it. Every call to your API is stamped with the end user it's acting for. No MCP server for you to build or host. No per-user credential for you to collect.
The shape, once:
- One service token. Your API already authorizes your users; it needs to know which one. m8tes calls your API with a token you issue us and stamps the end-user id on every request.
- One tool row per end-user, all carrying that same token. The row's scope becomes the identity we send.
- One mate per end-user, holding that row.
- The proxy pins the mate per request, so each user lands on their own.
Already host your own MCP server? Point us at it with
kind="mcp_http". We connect out and discover its tools. The end-user identity header below is not sent on that path, so a remote MCP server must carry its own per-customer scoping (one server per customer, or identity in its own URL/credential). This page covers the common case: a REST API and no MCP server.
kind="script"(a small Python tool) cannot takeuser_id. For one agent per customer, stay onrest_api. See Custom tools.
1. Wrap your API as a tool
A tool def is a method, a path, and a description. m8tes synthesizes the MCP tools for you.
from m8tes import M8tes
client = M8tes() # reads M8TES_API_KEY
TOOL_DEFS = [
{"name": "list_orders", "method": "GET", "path": "/orders",
"description": "List this customer's recent orders."},
{"name": "update_shipping", "method": "POST", "path": "/orders/{id}/shipping",
"description": "Change the shipping address on an order."},
]2. Provision per end-user, at signup
Run this once per customer, wherever you already create their account. Both calls take the same user_id. That pairing is the part that matters (see Scope pairing).
def provision(customer_id: str):
tool = client.mcp_servers.create(
name="acme api",
url="https://api.acme.com/v1",
auth_type="bearer",
secret=ACME_SERVICE_TOKEN, # the SAME token for every customer
tool_defs=TOOL_DEFS,
user_id=customer_id,
)
agent = client.agents.create(
name="acme assistant",
instructions="Help the customer with their orders. Confirm before changing anything.",
tools=[tool.slug],
user_id=customer_id,
)
return agent.id # store this against the customer in your DBSlugs and the per-account tool quota are scoped per end-user, so every customer can hold the same acme-api slug. The token is stored encrypted, once per row, and never reaches the agent or the sandbox.
3. Pin the mate per request
Requires
@m8tes/react@0.1.0-alpha.2or later. On an older build,resolveAgentIdis silently ignored, no mate is pinned, and the browser's ownteammate_idwins. That is the hole pinning exists to close. Runnpm i @m8tes/react@latestto upgrade, and checkM8TES_REACT_VERSIONif you're unsure what you have.
Each customer has their own mate, so a single static agentId no longer works. It would send everyone to one customer's mate, and the API would reject the mismatch. Resolve it per request instead:
// app/api/m8tes/[...path]/route.ts
import { createM8tesHandler } from "@m8tes/react/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
export const { GET, POST } = createM8tesHandler({
apiKey: process.env.M8TES_API_KEY!,
resolveUserId: async (req) => (await auth(req)).userId,
resolveAgentId: async ({ userId }) => db.agents.findByUser(userId),
});resolveAgentId is handed the already resolved userId. Key your lookup off that, never off a query param or a readable cookie. Otherwise the browser is choosing the mate again. It fails closed: throw or return nothing and the request 401s rather than starting an unpinned run. See Embed a UI for the rest of the proxy.
4. What your API receives
Every tool call arrives from m8tes' servers (never the browser, never the sandbox) as a plain HTTPS request:
GET /v1/orders HTTP/1.1
Host: api.acme.com
Authorization: Bearer <your service token>
X-M8tes-End-User-Id: cust_123Authorize it exactly as you would a logged-in session for cust_123:
@app.get("/v1/orders")
def list_orders(request):
assert_service_token(request.headers["Authorization"]) # it's us
customer = lookup(request.headers["X-M8tes-End-User-Id"]) # it's for them
return orders_for(customer) # your existing rulesTrust basis: the identity header is a claim; the service token makes it trustworthy (only m8tes holds it). Two rules, both on you:
- Authorize on the token, every request. Never expose an endpoint that honours
X-M8tes-End-User-Idwithout verifying the credential first. The header says who we're acting for, not that we're allowed to. - Treat a missing header as "no end-user", not as "any end-user". Reject or fall back to a safe default. Never to an admin scope.
We send the header only on servers configured with real auth (an auth_type other than none, with a secret). On an unauthenticated server it is omitted rather than left forgeable. It is also omitted, with a warning logged, when a server's own credential header is named X-M8tes-End-User-Id. The credential always wins that collision, and new servers can't be configured that way.
The value is percent-encoded, so any id transmits safely as a header. Ordinary ids (cust_123, a UUID) are unchanged; if yours can contain non-ASCII or spaces, URL-decode before you look it up.
Your API stays the authority. m8tes never assumes an id grants access. If cust_123 may not see an order, return your usual 403 and the agent will tell them so.
Scope pairing
A tool resolves only when the RUN's user_id matches the tool's. No fallback to account-level data in either direction:
A mate scoped to cust_123 only ever runs as cust_123, so pairing it with that customer's tool can't drift. An account-level mate has no fixed scope: its runs carry whatever user_id you pass, so an end-user tool attached to it still resolves on that end-user's runs only. That works, but every caller must pass the right user_id. One omission silently yields no tools.
A scope mismatch silently leaves the agent without tools. Check:
- The run's
user_idmatches the intended user. - A
GETon the mate lists the expected slug intools.
Account health checks flag attachments that can never resolve, such as another user's tool on a user-scoped mate. They allow the legitimate account-level case above.
Two more constraints:
- Custom slugs attach to an agent only. Passing one in a per-task or per-run
toolslist is rejected. GETtools never prompt for approval, in any permission mode. Writes do, unless the server is marked trusted. See Take Actions.
Limits
Calls are capped at 120 per minute per end-user, per server. The budget covers a server's whole tool set, not each tool separately. One runaway agent or abusive customer therefore can't burn the API quota everyone else shares through your service token. A throttled call returns a rate_limited error carrying retry_after to the agent, which backs off; it never fails the run.
Rotating the token
The token is stored per row, so rotation is a sweep:
for customer_id in all_customers():
for s in client.mcp_servers.list(user_id=customer_id):
client.mcp_servers.update(s.id, secret=NEW_TOKEN, user_id=customer_id)Issue the new token before revoking the old one so in-flight runs don't fail mid-call.
Next: Embed a UI · Multi-tenancy · Take Actions · Human-in-the-Loop