Skip to content

MCP Servers

The mcp module lets AI agents use your API’s operations as tools, through the Model Context Protocol (MCP). You create one MCP server per purpose and audience – an admin server for an internal agent doing admin work, a support server for a customer-support agent, one for a UI-building tool – each a JSON file in the project’s mcp/ directory. Any MCP client can connect: your own agents, hosted agents, coding and UI-building tools. A client sees only the tools its server lists; nothing is exposed by default.

Install:

Terminal window
pip install "kirak[mcp]"

Needs Python 3.10+ (the MCP Python SDK v2).

Enable in kirak.json:

{"modules": ["mcp"]}

There are no other kirak.json settings: everything about a server is in its file.


mcp/support.json:

{
"$schema": "../.kirak/mcp.schema.json",
"name": "support",
"description": "Order lookups for the support team",
"instructions": "Look up the order before changing anything.",
"access": "User",
"rate_limit_per_minute": 60,
"tools": {
"orders.fetch": {},
"orders.search": {"description": "Find orders by customer email or status"},
"orders.update": {"name": "update_order", "timeout": 10},
"payments.verify_payment": {}
}
}

Served at /mcp/support/ (with or without the trailing slash).

Field Required Meaning
name yes Lowercase letters, digits and _, starting with a letter. The URL (/mcp/<name>/), and how code and monitoring refer to the server. Unique across mcp/.
description yes What the server is for; sent to clients.
instructions no Guidance sent to the client when it connects, for the model using the tools. Not enforced.
access no Who may connect: Guest (anyone), User (any signed-in caller, the default), Admin (admin, system or superadmin), System.
rate_limit_per_minute no Requests per minute per caller (per client IP for guests). Default 60.
tools no The operations the server offers, keyed by ref (below).

Run kirak schema to export .kirak/mcp.schema.json for completion and checks in your editor; kirak new maps mcp/*.json to it. kirak validate mcp checks the files without starting the app, and kirak info lists the servers with their paths and tools.

Each key of tools names one Kirak operation:

  • <model>.<operation> – a CRUD operation on one of your models: fetch, search, count, exists, create, update, upsert, delete, destroy, restore. A model offers delete and restore only with soft_delete, and search only with a searchable field.
  • <module>.<operation> – a module operation, as kirak modules <name> --json lists them (payments.refund_payment, vector.search, notifications.send_email, …). The module must be enabled. Operations that are not called with JSON arguments (webhooks, file uploads, browser sign-in flows) cannot be tools.

Each value holds the tool’s options; {} takes every default:

Option Meaning
name The tool name clients see. Default: the ref with _ for ., e.g. payments_verify_payment, because the model APIs clients pass tools to refuse dots.
description What the tool does and when to use it, for the model. Default: the operation’s own description.
requires_confirmation A person approves each call first. See Confirmation.
timeout Seconds the tool may run; past it the call answers TOOL_TIMEOUT.
parameters Module operations only: a JSON Schema replacing the operation’s parameters, to hide one or add a provider-specific one.

Each operation is listed once; for a second variant of the same operation, or anything that spans several models, write a Python tool.

A server also offers the functions registered on it with @kirak.mcp("<name>").tool, besides the operations its file lists. Register them before startup, in on_kirak_ready:

async def on_kirak_ready(kirak):
@kirak.mcp("support").tool
async def order_summary(order_id: int, include_items: bool = False) -> dict:
"""One order with its customer and items, in one call."""
result = await kirak.graphql({"query": ORDER_SUMMARY, "variables": {"id": order_id}})
return result["data"]
@kirak.mcp("support").tool(requires_confirmation=True, destructive=True)
async def close_account(user_id: int) -> dict:
"""Close a customer's account."""
...
  • Arguments come from the signature: type hints give the types, defaults make an argument optional, unknown arguments are refused. *args / **kwargs are not allowed.
  • Description: the docstring’s first paragraph, or description=.
  • Options: name, description, requires_confirmation, timeout, read_only, destructive (the last two are hints for the client).
  • Caller: the function runs as the caller, so Kirak calls inside it (kirak.fetch, kirak.graphql, …) apply the caller’s access rules; get_user_context() (from kirak.core.context) returns them.
  • Result: a returned dict is the tool’s result; anything else is sent as {"result": value}. A sync function runs in a worker thread.

Startup stops if a Python tool names a server no file declares, or uses a name one of the file’s tools already has. kirak validate does not see Python tools (it never imports your code), so these two checks run at startup only.

  • Input schema. A model tool’s arguments come from models.json: its fields as filters (status, and <field>__<operator> such as total__gte), paging (limit, page, offset, order_by, order, select_fields), and data / where for writes – the same params kirak.fetch("orders", ...) takes. Password fields are never filters, and read-only fields (id, timestamps) cannot be written; any other argument is refused before anything runs. A module tool’s arguments are the operation’s parameters from the catalog (or the entry’s parameters); arguments it does not list are passed on, not refused. A Python tool’s come from its signature, and unknown ones are refused.
  • Hints. Read operations are marked read-only, delete / destroy and destructive module operations (refund_payment, …) destructive.
  • Results. The operation’s data (and pagination), without the REST envelope, as structured content and as JSON text. A Kirak error comes back as a tool error with its code and message (PERMISSION_DENIED, VALIDATION_ERROR, …), so the model can react to it.
  • Output schema. Where the shape is fixed, a tool also declares it: fetch and search (a list of records with the model’s field names, and pagination), exists, and module operations whose result the module builds the same way for every provider (the vector operations except delete, list_payment_methods, detach_payment_method, set_default_payment_method; see result in kirak modules <name> --json). Field types are left out of record schemas: how a database driver returns a value (a boolean as 0/1) would otherwise make a client reject a good result.

A tool with requires_confirmation: true runs only after a person approves that exact call. The first call answers with a confirmation form (the tool’s name, description and arguments); the client shows it and retries the call with the answer (MCP 2026-07-28 multi round-trip requests, so this works on a stateless server). Accepted: the tool runs. Declined: CONFIRMATION_DECLINED. Invalid arguments are refused before anyone is asked, and an answer is bound to the arguments it approved: approving one call never approves another.

Clients on an older MCP protocol cannot receive the form, so for them such a tool answers CONFIRMATION_UNAVAILABLE and does not run.

A tool that cannot run, or whose operation fails, answers a tool error (isError) with {"error": <code>, "message": ...}:

Code Meaning
INVALID_ARGUMENTS The arguments do not match the tool’s input schema. Nothing ran.
UNKNOWN_TOOL The server has no tool by that name.
TOOL_TIMEOUT The tool did not finish within its timeout.
CONFIRMATION_DECLINED The person did not approve the call. Nothing ran.
CONFIRMATION_UNAVAILABLE The tool needs confirmation and the client cannot ask. Nothing ran.
INTERNAL_ERROR An unexpected failure; details are in the server log, not the answer.
any Kirak error code The operation’s own error: PERMISSION_DENIED, VALIDATION_ERROR, NOT_FOUND, a module’s codes, …

Each request is authenticated before the server sees it:

  • Authorization: Bearer <access token> – a Kirak JWT.
  • Authorization: Bearer <API key> – a Kirak API key (kk_...). Hosted clients often can send only a bearer header, so an API key is accepted there too.
  • X-API-Key: <API key>.
Situation Answer
No credential, server access is not Guest 401 with WWW-Authenticate: Bearer
A credential that is invalid or expired 401 – never downgraded to guest
The caller’s role is below the server’s access 403
The caller’s account is deactivated 403
Over rate_limit_per_minute 429
/mcp/<name>/ names no server 404

These answers use Kirak’s error envelope ({"statusCode", "status", "error", "message"}) and come before the MCP protocol: the client sees an HTTP error, not a tool error.

Every tool call runs as the caller. Model access rules, row-level security and hooks apply exactly as for REST: a User caller fetching orders sees only the rows their rules allow, and a server’s tool list only narrows what is offered – it never grants more. A Guest server’s tools run as guest.


Streamable HTTP, per the MCP specification 2026-07-28, served by the MCP Python SDK v2. Every request stands alone (no session), so the app can run on several workers behind a load balancer without sticky routing. Clients on older protocol versions can connect too. Each answer is a single JSON response: a tool cannot stream progress or partial results.


Point the client at the server’s URL and give it a credential. For Claude Code:

Terminal window
claude mcp add --transport http support https://api.example.com/mcp/support/ \
--header "Authorization: Bearer kk_..."

A hosted client asks for the URL and an API key or bearer token.


With the monitoring module on as well, every tool call is recorded in the monitoring store (mcp_calls): server, tool, operation, caller, status (ok, error, asked when a confirmation form went out), error code, confirmation outcome and duration. GET /monitoring/mcp/summary and GET /monitoring/mcp/calls read them; see Monitoring.


Kirak reads every file in mcp/ at startup and refuses to start if one is wrong: a key the schema does not know, a JSON key written twice (which would silently drop a tool), two files with the same name, a tool ref that names no model or module operation of this app, or a Python tool on a server no file declares. kirak validate mcp runs the same file checks before you start the app, plus warnings for files without the module enabled and for a server with more than 30 tools. The problem codes are in Problem codes.

The running app’s GET /docs lists every server with its path, access level and tools.

create_kirak_app() starts and stops the servers with the app. An app that builds Kirak(app=...) itself calls await kirak.mcp.start() after mount_routers() and await kirak.mcp.stop() on shutdown – once per app: the servers cannot be started again after they stopped.


  • Confirmation needs a current client. Clients on MCP protocol versions before 2026-07-28 cannot answer a confirmation form; requires_confirmation tools refuse to run for them (CONFIRMATION_UNAVAILABLE).
  • OAuth. Clients authenticate with a JWT or an API key; the MCP OAuth 2.1 sign-in flow is not supported yet.
  • Model rate_limit blocks in models.json apply to REST routes, not to MCP tools; use the server’s rate_limit_per_minute.
  • The path prefix is fixed at /mcp; module_prefixes in create_kirak_app() does not move it.
  • No resources or prompts yet: servers offer tools only.