Custom tools
Wrap your own REST API as agent tools. You define typed endpoints; m8tes exposes each as a named tool and calls it server-side (IP-pinned, secret never exposed to the agent).
Use this when the app isn't in the catalog and you already have a REST API.
One agent per customer in your product? See Your API as MCP tools. Single-tenant version: Take actions in your product.
Create a custom tool
from m8tes import M8tes
client = M8tes()
server = client.mcp_servers.create(
name="acme billing",
url="https://api.acme.com/v1",
auth_type="bearer",
secret="sk-live-...", # write-only, never returned
tool_defs=[
{"name": "get_invoice", "method": "GET", "path": "/invoices/{id}"},
{"name": "create_refund", "method": "POST", "path": "/refunds"},
],
user_id="customer_123",
)
print(server.slug) # "acme-billing"Each entry in tool_defs becomes a tool the agent sees by name (get_invoice, create_refund). Put {param} in a path to make it an argument (/invoices/{id}); any other arguments the agent passes go to the query string (GET/DELETE) or the JSON body (POST/PUT/PATCH). In the dashboard each tool_def shows as an Action ("Add action" adds one), but the SDK param name stays tool_defs.
Kinds
kind on create:
Remote MCP discovery refuses catalogs above 4 MiB per response, 64 pages, 256 tools, 256 KiB per input schema, or 2 MiB for the stored manifest. These limits apply during setup and later catalog refreshes, not to ordinary tool-call responses.
For unattended script tools (scheduled tasks, webhooks, agent-driven setup), pass auto_approve=True on create. Updating script_source, the origin URL, allowlist, or tool defs clears trust. Call client.mcp_servers.approve(server.id) (or update(..., auto_approve=True)) after you review the new source, or the run fail-closes with script_changed ("script or allowlist changed since approval").
server = client.mcp_servers.create(
name="aircall calls",
url="https://api.aircall.io", # origin only, no path
kind="script",
auth_type="bearer",
auto_approve=True, # required for headless / agent-driven runs
script_source=(
"def fetch_calls(from_date: str) -> dict:\n"
" r = http('GET', 'https://api.aircall.io/v1/calls')\n"
" return {'status': r['status'], 'body': r['body']}\n"
),
tool_defs=[{"name": "fetch_calls", "description": "List recent calls"}],
user_id="customer_123",
)
print(server.script_sha256) # source is never returned
# After you change the source:
# client.mcp_servers.update(server.id, script_source="...")
# client.mcp_servers.approve(server.id)The script runs in a sidecar jail with no network except the injected http() helper (and json). Caps: 20 calls and 1 MiB of response per job. Extra origins go in script_allowlist (max 8).
Attach to an agent
Reference the server's slug in the agent's tools list, alongside catalog tools:
client.agents.create(name="ops", tools=["gmail", "acme-billing"], user_id="customer_123")In the dashboard, open Apps → Custom tools → Add custom tool to create one, then enable it on an agent from its Configure → Custom tools section.
An agent can also create a REST tool mid-run without a setup approval. Remote MCP setup follows the normal approval flow because it contacts the supplied server. The agent cannot supply credentials or mark the tool trusted. You provide any required key; using the new tool follows the run's permission policy.
Every tool reports who made it and who can use it. created_by_teammate_id is the agent that set it up (null when a person did), and teammate_ids lists the agents it is attached to. The dashboard shows both as Made by and Used by.
Authentication
The secret is encrypted at rest and injected by m8tes when it makes the call. Pick the auth_type your API expects:
client.mcp_servers.create(
name="search service",
url="https://search.internal-acme.com",
auth_type="custom_header",
auth_config={"header_name": "X-API-Key"},
secret="key-...",
tool_defs=[{"name": "search", "method": "GET", "path": "/search"}],
user_id="customer_123",
)Sign-in (OAuth) MCP servers
Most hosted MCP servers want an OAuth sign-in, not a key. Create them with auth_type="oauth2" and no secret. m8tes finds the sign-in, registers itself with the vendor, and stores and refreshes the tokens.
srv = client.mcp_servers.create(
name="beehiiv",
url="https://mcp.beehiiv.com/mcp",
kind="mcp_http",
auth_type="oauth2",
user_id="customer_123",
)
link = client.mcp_servers.start_oauth(srv.id, user_id="customer_123")
print(link.authorization_url) # the owner signs in here, then lands on m8tes AppsThe owner returns to the m8tes Apps page, which completes the sign-in and discovers the tools. sign_in_host names where they sign in, and has_secret turns true once they have. Clear it with update(srv.id, secret=None) to sign out. An agent that creates one of these gets the sign-in detected automatically and asks the owner to sign in.
Permissions
Read-only tools never ask. A GET tool def runs immediately, in every permission mode. It can't mutate anything, so it is treated like any other read. Plan for this: if a lookup against your API is itself sensitive, don't model it as GET, and don't rely on an approval prompt to gate it.
Write tools ask before each use. For trusted unattended work, set Run without asking each time or pass auto_approve=true to client.mcp_servers.create/update.
An untrusted write pauses for approval. With nobody watching, the run is marked needs-approval.
Trust is per server, not per tool, so the usual shape is two servers against the same API: one holding the GET defs, one holding the writes you want gated.
Security model
- Egress is server-side and IP-pinned. The agent never connects to your service directly. m8tes resolves and pins the host to a validated public IP per call (no DNS-rebind), disables redirects, and caps the response. Private/metadata addresses are blocked.
- The secret never reaches the agent. It is injected into the outbound request by m8tes and scrubbed from any echoed response. API responses carry only
has_secret, never the value. - Strictly scoped. A custom tool is visible only to its owner (and, for multi-tenant setups, the
user_idend-user it was created for).
Manage
client.mcp_servers.list(user_id="customer_123")
client.mcp_servers.get(server.id, user_id="customer_123")
client.mcp_servers.update(server.id, status="disabled", user_id="customer_123") # stop using without deleting
client.mcp_servers.delete(server.id, user_id="customer_123")Next: Your API as MCP tools · Take actions in your product · Human-in-the-loop · Users