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:

  1. 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.
  2. One tool row per end-user, all carrying that same token. The row's scope becomes the identity we send.
  3. One mate per end-user, holding that row.
  4. 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 take user_id. For one agent per customer, stay on rest_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.

Python
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).

Python
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 DB

Slugs 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.2 or later. On an older build, resolveAgentId is silently ignored, no mate is pinned, and the browser's own teammate_id wins. That is the hole pinning exists to close. Run npm i @m8tes/react@latest to upgrade, and check M8TES_REACT_VERSION if 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:

TypeScript
// 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:

Http
GET /v1/orders HTTP/1.1
Host: api.acme.com
Authorization: Bearer <your service token>
X-M8tes-End-User-Id: cust_123

Authorize it exactly as you would a logged-in session for cust_123:

Python
@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 rules

Trust basis: the identity header is a claim; the service token makes it trustworthy (only m8tes holds it). Two rules, both on you:

  1. Authorize on the token, every request. Never expose an endpoint that honours X-M8tes-End-User-Id without verifying the credential first. The header says who we're acting for, not that we're allowed to.
  2. 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:

Run's user_idTool scopeResult
"cust_123"user_id="cust_123"Works
"cust_123"account-level (no user_id)No tools
none (account-level run)user_id="cust_123"No tools

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:

  1. The run's user_id matches the intended user.
  2. A GET on the mate lists the expected slug in tools.

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 tools list is rejected.
  • GET tools 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:

Python
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

Was this page helpful?