Overview
The package serves its HTTP surface on your own web framework. It implements no authentication: you hand every router an auth check and a principals resolver, and the routes enforce access with what they return. Routers carry no path prefix, so you choose where they live. Every example on this page mounts at /context.
Documents and search
/documents, /search, /stats. Seven routes, identical on every framework.
Tools, approvals, MCP
/tools, /approvals, /mcp. Thirteen routes: a separate FastAPI router in Python, built into every TypeScript adapter.
For the in-process API behind these routes see the Python API and TypeScript API. The MCP server that exposes the knowledge tool to agents is a different mount, covered on the Knowledge tool page.
Mounting the routers
Python
Three adapters serve documents, search and stats, one per framework, with identical routes and behaviour. Tools, approvals and MCP come from a fourth factory, create_tools_router, which is FastAPI only.
| Factory | Arguments | Returns |
|---|
| create_router(engine, *, auth, principals) | auth and principals are FastAPI dependencies. auth runs on every route; principals is injected per handler. Needs the [fastapi] extra. | APIRouter |
| create_flask_blueprint(engine, *, auth, principals, name="context_engine") | auth(request) runs before every route and should raise or abort() to reject; principals(request) returns the groups. Views are async: the [flask] extra installs flask[async]. | Blueprint |
| create_django_urlpatterns(engine, *, auth, principals) | Same callables as Flask, taking the Django request. Views are async (Django 4.1+) and CSRF-exempt. A wrong method answers 405. Needs [django]. | list of path() |
| create_tools_router(engine, *, auth, principals, redirect_base_url, pending_store=None, config_template=False, approval_scope=None) | See the table below. | APIRouter |
| create_tools_router argument | Default | What it does |
|---|
| auth | required | FastAPI dependency, runs on every route except GET /mcp/oauth/callback. |
| principals | required | FastAPI dependency. Decides who may see, edit and run which tools. |
| redirect_base_url | required | The full external URL up to where this router is mounted, for example https://app.example.com/context. The OAuth redirect URI is this plus /mcp/oauth/callback, and the callback page posts only to this URL’s origin. |
| pending_store | InMemoryPendingStore() | Where OAuth state waits between start and callback. Right for one process. With several workers pass a PostgresPendingStore(session_factory) from context_engine.tools.mcp_oauth, since the callback may land on another worker. |
| config_template | False | When True, GET /tools and GET /tools/{id} add config_redacted. Only for a mount that fronts an editor, never the one agents call. |
| approval_scope | None | A FastAPI dependency returning the approval claim scope for POST /tools/execute (a run id is the usual shape). Resolved on the server only. Without it gated calls run unscoped, which is deprecated. |
from fastapi import FastAPI, HTTPException, Request
from context_engine import create_router, create_tools_router
app = FastAPI()
async def auth(request: Request) -> None:
# Your own check. Raise to reject; nothing here is provided by the package.
if not request.headers.get("authorization"):
raise HTTPException(status_code=401)
async def principals(request: Request) -> list[str]:
# The caller's groups, from your session or token. [] when anonymous.
return ["group:hr", "user:ana"]
async def run_scope(request: Request) -> str | None:
# Resolved on the server (for example the current run id), never by the client.
return getattr(request.state, "run_id", None)
# Documents, search, stats
app.include_router(create_router(engine, auth=auth, principals=principals), prefix="/context")
# Tools, approvals, MCP connect and OAuth (a separate FastAPI router)
app.include_router(
create_tools_router(
engine,
auth=auth,
principals=principals,
redirect_base_url="https://app.example.com/context", # where THIS router is mounted
approval_scope=run_scope,
),
prefix="/context",
)
from flask import Flask, abort
from context_engine import create_flask_blueprint
app = Flask(__name__)
def auth(request):
if not request.headers.get("Authorization"):
abort(401) # raise or abort to reject
def principals(request):
return ["group:hr"] # [] when anonymous
bp = create_flask_blueprint(engine, auth=auth, principals=principals)
app.register_blueprint(bp, url_prefix="/context")
# urls.py
from django.core.exceptions import PermissionDenied
from django.urls import include, path
from context_engine import create_django_urlpatterns
def auth(request):
if not request.headers.get("Authorization"):
raise PermissionDenied # raise to reject
def principals(request):
return ["group:hr"] # [] when anonymous
urlpatterns = [
path("context/", include(create_django_urlpatterns(engine, auth=auth, principals=principals))),
]
A Flask or Django app that wants the tool routes mounts create_tools_router in a FastAPI app beside it, or calls the engine methods from its own views.
TypeScript
Each adapter mounts all twenty routes from one factory. Import it from its own subpath so the framework stays an optional peer dependency.
| Factory | Import from | Returns |
|---|
| createHonoRouter(engine, opts) | @promptev/context-engine/hono | a Hono app (also exported as createHonoApp) |
| createExpressRouter(engine, opts) | @promptev/context-engine/express | an Express Router |
| createFastifyPlugin(engine, opts) | @promptev/context-engine/fastify | a Fastify plugin (wrapped with fastify-plugin when that package is installed) |
| Option | Default | What it does |
|---|
| auth | required | (ctx) => unknown | Promise, called with the Hono context, Express request or Fastify request. Throw to reject. Skipped for /mcp/oauth/callback. |
| principals | required | Same signature; returns a string[], [] or TRUSTED. |
| approvalScope | none | Same signature; returns the approval claim scope for POST /tools/execute. |
| redirectBaseUrl | none | As redirect_base_url in Python. Optional here, but POST /mcp/oauth/start answers 500 without it. |
A factory called without auth or principals throws a TypeError at startup.
import { Hono } from "hono";
import { createHonoRouter } from "@promptev/context-engine/hono";
const app = new Hono();
const ce = createHonoRouter(engine, {
auth: async (c) => {
// Your own check. Throw to reject.
},
principals: async (c) => ["group:hr"], // [] when anonymous
approvalScope: async (c) => null, // optional; resolved on the server
redirectBaseUrl: "https://app.example.com/context", // needed for MCP OAuth
});
app.route("/context", ce);
import express from "express";
import { createExpressRouter } from "@promptev/context-engine/express";
const app = express();
app.use(express.json()); // the router reads req.body; it does not parse JSON itself
// File uploads: a multer-style middleware in memory storage, so the handler
// finds req.file (or req.files.file[0]) with .buffer and .originalname.
app.use(
"/context",
createExpressRouter(engine, {
auth: async (req) => {},
principals: async (req) => ["group:hr"],
redirectBaseUrl: "https://app.example.com/context",
}),
);
import Fastify from "fastify";
import multipart from "@fastify/multipart";
import { createFastifyPlugin } from "@promptev/context-engine/fastify";
const app = Fastify();
await app.register(multipart); // needed for file uploads on POST /documents
await app.register(
createFastifyPlugin(engine, {
auth: async (req) => {},
principals: async (req) => ["group:hr"],
redirectBaseUrl: "https://app.example.com/context",
}),
);
Auth and principals
auth answers “may this request reach the engine at all”. It runs first on every route except the OAuth callback, and whatever it raises or throws is what the caller gets: the status is your framework’s.
principals answers “who is asking”. What it returns decides everything the request can see or change:
| Returns | Meaning |
|---|
| ["group:hr", "user:ana"] | A known caller. Sees unrestricted rows plus rows whose ACL shares a principal with this list. May only grant ACLs within it. |
| [] | Anonymous. Sees unrestricted rows only. |
| TRUSTED | A trusted mount, written down on purpose. No ACL filtering, may file documents and register tools for any group, and sees the raw ingest error. |
| None / null | Refused with a 500 on every route. On the in-process API principals=None is deprecated (it warns, and a later release refuses it); over HTTP it is already an error, so a resolver written as user.groups if user else None cannot open the corpus. |
| anything else | 500: it must be a list of strings. |
Principals and the approval scope never come from the request. No body model has a principals field, so a client that sends one is ignored.
from context_engine import TRUSTED
# An internal admin or ingestion service that files documents for any group:
router = create_router(engine, auth=service_auth, principals=lambda: TRUSTED)
# An open mount on purpose: every caller is anonymous and sees only unrestricted rows.
router = create_router(engine, auth=lambda: None, principals=lambda: [])
import { TRUSTED } from "@promptev/context-engine";
// An internal admin or ingestion service that files documents for any group:
createHonoRouter(engine, { auth: serviceAuth, principals: () => TRUSTED });
// An open mount on purpose: every caller is anonymous.
createHonoRouter(engine, { auth: () => {}, principals: () => [] });
The full access model, including how tool ACLs differ from document ACLs, is in the engine repository’s access-control notes.
Errors
Every error the package raises has the same body on every framework: {"detail": ...}. The detail is usually a sentence; for a 422 it is a list of validation issues.
HTTP/1.1 403 Forbidden
content-type: application/json
{"detail": "cannot grant access to principals you do not hold: ['group:finance']"}
| Status | Used for |
|---|
| 400 | A malformed request: an id that is not a UUID, invalid JSON, a bad cursor, a config the caller must fix, an address the egress policy refuses. |
| 403 | An ACL that names principals the caller does not hold, or a tool with no ACL from a non-trusted mount. |
| 404 | Not found. A row that exists but is invisible to the caller gets the identical 404, so existence never leaks. |
| 405 | Django only: a method the path does not serve. |
| 409 | An ambiguous tool name, or an approval that has expired. |
| 413 | An upload over ingest.max_file_bytes. |
| 415 | POST /documents with an unsupported Content-Type. |
| 422 | A body that fails validation. FastAPI renders its own validation list; the Flask, Django and TypeScript adapters send the list shown below. |
| 500 | A misconfigured mount: a principals or approval-scope resolver returning the wrong thing, or a missing or malformed CE_SECRET_KEY. |
| 502 | An MCP server that cannot be reached or answers badly, or on /tools/execute a tool that ran and failed. |
| 503 | POST /search when every retrieval leg failed and a custom retrieval leg was among them (RetrievalFailed). |
| 503 | Any route, when the database was never migrated (DatabaseNotMigrated). The detail is one fixed sentence: “The service’s database has not been migrated. Run the migrate command against it, then try again.” The MCP tools answer with the same sentence. The exception’s own message, which spells out the migrate command, goes to the log. |
{"detail": [{"loc": ["query"], "msg": "Invalid input: expected string, received undefined", "type": "invalid_type"}]}
Document routes
Mounted by create_router, create_flask_blueprint, create_django_urlpatterns and every TypeScript adapter. Responses on this page are the Python ones; the TypeScript server returns the same objects with camelCase keys (see differences).
A document parked by a deferred model call (see Batch mode) reads "status": "waiting_model" and carries a parked object beside meta_data: stage, parked_at, retry_at, rounds, request_keys, last_skip_reason and last_skip_at. Resuming is a host call, resume_documents() / resumeDocuments(); no route runs it.
POST/documents
Any caller that passes auth. An acl in the request may only name principals the caller holds, unless the mount resolves to TRUSTED. Ingest is synchronous: the response is the finished report.
JSON body (Content-Type: application/json)
| Field | Type | Default | Notes |
|---|
| text | string | required | The document text. |
| name | string | required | Display name. |
| source_id | string | null | null | The collection it belongs to. |
| external_id | string | null | null | Your own id. With source_id it is the document identity for re-ingest. |
| description | string | null | null | |
| meta_data | object | null | null | |
| acl | string[] | null | null | null is unrestricted, [] is nobody. Only principals you hold. |
| mode | "hybrid" | "graph" | null | null | Processing mode. null uses the engine default. |
| extract_structured | boolean | false | Extract structured fields. |
| mime_type | string | null | null | What the document is, for example text/plain or text/csv. It is believed over the name and over what the text looks like. null leaves the engine to decide: the name’s extension first, then the content, where text is read as CSV only on strong evidence. |
Multipart form (Content-Type: multipart/form-data)
| Field | Type | Default | Notes |
|---|
| file | file | required | The upload. |
| name | string | file name | |
| description, source_id, external_id, mode, mime_type | string | none | Same meaning as the JSON fields. |
| meta_data, acl | JSON string | none | JSON-encoded, for example acl=["group:hr"]. |
| extract_structured | string | false | True when "1", "true", "yes" or "on". A batch key in the JSON body or the form, whatever its value, is refused with 400. |
curl -X POST https://app.example.com/context/documents \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"text": "Annual leave is 25 days.", "name": "Leave policy",
"source_id": "hr-handbook", "acl": ["group:hr"]}'
curl -X POST https://app.example.com/context/documents \
-H "Authorization: Bearer $TOKEN" \
-F [email protected] -F source_id=hr-handbook -F 'acl=["group:hr"]'
{
"documents": [
{
"document_id": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c",
"name": "Leave policy",
"status": "completed",
"pages": null,
"chunks": 1,
"units": 1,
"graph_units": 0,
"provider_tokens": {"embedding_tokens": 9},
"error": null,
"redaction_failed": [],
"pages_source": null,
"unreadable_reason": null,
"unreadable_pages": 0,
"mode_used": "hybrid",
"mode_reason": null,
"skip_reason": null,
"failure_reason": null,
"failure_message": null
}
],
"totals": {"files": 1, "failed": 0, "units": 1, "graph_units": 0}
}
Status codes
| 400 | Multipart without a file field; invalid or missing JSON body; invalid JSON in a multipart field; an ingest argument the engine rejects, including a NUL character (U+0000) in name, source_id, external_id, acl or meta_data (the detail names the field). |
| 403 | acl names a principal the caller does not hold. |
| 413 | The declared Content-Length, the text, or the file is over ingest.max_file_bytes. The detail names the setting. |
| 415 | Content-Type is neither multipart/form-data nor application/json. Django does not return this: any non-multipart body is read as JSON. |
| 422 | The JSON body fails validation, or acl is not a list. |
| 500 | The principals dependency returned None or something that is not a list of strings. |
Each document in the report also carries graph_backend_stale (boolean): true when a configured Neo4j could not be given the document’s graph. The document still completed and search works from Postgres; the host replays it with resync_graph() / resyncGraph(). It is false in every other case. The size check runs on the declared Content-Length before the body is read on FastAPI and Hono. Flask, Django, Express and Fastify have already read the body by then, so set their own body limit to the same number (upload_limit_bytes(engine) / uploadLimitBytes(engine)) to stop large uploads earlier. A chunked request with no length is checked after it is read.
GET/documents
Any caller that passes auth. Lists only documents visible to the caller’s principals, newest first.
Query parameters
| Field | Type | Default | Notes |
|---|
| source_id | string | none | Only this collection. |
| limit | integer | 50 | Clamped to 1 to 200. |
| cursor | JSON string | none | The next_cursor object from the previous page, JSON-encoded and URL-encoded. |
curl -G https://app.example.com/context/documents \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "source_id=hr-handbook" \
--data-urlencode "limit=50" \
--data-urlencode 'cursor={"time": "2026-09-20T10:00:00+00:00", "id": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c"}'
{
"documents": [
{
"id": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c",
"source_id": "hr-handbook",
"external_id": null,
"name": "Leave policy",
"description": null,
"acl": ["group:hr"],
"mode": "hybrid",
"mime_type": "text/plain",
"lang": "en",
"status": "completed",
"failure_reason": null,
"document_type": null,
"created_at": "2026-09-20T10:00:00+00:00",
"updated_at": "2026-09-20T10:00:02+00:00",
"unreadable_reason": null,
"unreadable_pages": 0,
"embedding_progress": {"done": 1, "total": 1, "tokens": 9}
}
],
"count": 1,
"has_more": true,
"next_cursor": {"time": "2026-09-20T10:00:00+00:00", "id": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c"}
}
Status codes
| 400 | cursor is not valid JSON. |
| 500 | Misconfigured principals dependency. |
GET/documents/{document_id}
Any caller that passes auth and can see the document. A missing document and one the caller cannot see give the same 404.
curl https://app.example.com/context/documents/3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c \
-H "Authorization: Bearer $TOKEN"
{
"id": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c",
"source_id": "hr-handbook",
"external_id": null,
"name": "Leave policy",
"description": null,
"meta_data": {},
"acl": ["group:hr"],
"mode": "hybrid",
"mime_type": "text/plain",
"lang": "en",
"text": "Annual leave is 25 days.",
"status": "completed",
"failure_reason": null,
"chunks": 1,
"document_type": null,
"structured_data": null,
"created_at": "...",
"updated_at": "...",
"started_at": "...",
"completed_at": "...",
"unreadable_reason": null,
"unreadable_pages": 0,
"embedding_progress": {"done": 1, "total": 1, "tokens": 9},
"failure_message": null
}
Status codes
| 400 | document_id is not a UUID. |
| 404 | Not found, or not visible to the caller. |
The raw error string from a failed ingest is removed from the body unless the mount resolves to TRUSTED; every caller gets the classified failure_reason and its fixed failure_message instead. An output redaction policy masks text and structured_data.
PATCH/documents/{document_id}
Any caller that passes auth and can see the document. Attributes only: this never re-ingests.
JSON body (every field optional; any other key is rejected)
| Field | Type | Default | Notes |
|---|
| acl | string[] | null | unchanged | Replaced. An explicit null makes the document unrestricted. Only principals you hold. |
| name | string | null | unchanged | Replaced. |
| description | string | null | unchanged | Replaced. |
| meta_data | object | null | unchanged | Merged into the stored object, not replaced. null is a no-op. |
curl -X PATCH https://app.example.com/context/documents/3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"meta_data": {"reviewed": true}}'
{"changed": ["meta_data"]}
Status codes
| 400 | document_id is not a UUID; invalid or missing JSON body (Flask, Django); a NUL character (U+0000) in name, description, acl or meta_data (the detail names the field). |
| 403 | acl names a principal the caller does not hold. |
| 404 | Not found, or not visible to the caller. |
| 422 | An unknown key such as text, or a wrong type. |
DELETE/documents/{document_id}
Any caller that passes auth and can see the document.
curl -X DELETE https://app.example.com/context/documents/3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c \
-H "Authorization: Bearer $TOKEN"
{"deleted": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c"}
Status codes
| 400 | document_id is not a UUID. |
| 404 | Not found, or not visible to the caller. |
Search and stats
Mounted by the same factories as the document routes.
POST/search
Any caller that passes auth. Results are filtered by the caller’s principals. The body has no principals field: a client-sent one is ignored.
JSON body
| Field | Type | Default | Notes |
|---|
| query | string | required | |
| source_ids | string[] | null | null | null searches every collection. |
| document_ids | string[] | null | null | Narrows within the sources; each must be a UUID. |
| top_k | integer | 10 | |
| mode | "hybrid" | "graph" | "hybrid" | The route does not let the query decide. Send "graph" to ask for the graph leg. |
| compress_to_tokens | integer | null | null | Compress the hits to this token budget. |
curl -X POST https://app.example.com/context/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "how much annual leave", "source_ids": ["hr-handbook"], "top_k": 5}'
{
"hits": [
{
"document_id": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c",
"document_name": "Leave policy",
"description": null,
"source_id": "hr-handbook",
"chunk_text": "Annual leave is 25 days.",
"idx": 0,
"lang": "en",
"score": 0.0328,
"chunk_id": "...",
"meta": {}
}
],
"usage": {
"kind": "search",
"units": 1,
"mode": "hybrid",
"graph_leg": false,
"degraded": false,
"returned": 1,
"reranked": false,
"mode_used": "hybrid",
"...": "more counters"
}
}
Status codes
| 400 | A document_ids entry is not a UUID; a search the engine refuses on purpose, such as an empty query or an unknown mode, in the Python and the TypeScript routers alike; invalid or missing JSON body (Flask, Django). |
| 422 | The body fails validation (missing query, unknown mode). |
| 503 | Every retrieval leg failed and a custom retrieval leg was among them (RetrievalFailed). The detail is the generic "every retrieval leg failed". |
| 503 | The database was never migrated (DatabaseNotMigrated). The detail is one fixed sentence that says to run the migrate command. |
The list order is the ranking. score is the fusion score and is kept when a reranker reorders the hits, so do not re-sort by it. usage.mode is what you asked for, usage.mode_used is what ran.
GET/stats
Any caller that passes auth. Counts only what the caller may see, with the same rule as every read.
Query parameters
| Field | Type | Default | Notes |
|---|
| source_id | string | none | Count one collection only. |
curl "https://app.example.com/context/stats?source_id=hr-handbook" \
-H "Authorization: Bearer $TOKEN"
{
"source_id": "hr-handbook",
"documents": 412,
"chunks": 9310,
"by_status": {"completed": 410, "failed": 2},
"embedding": {"provider": "openai", "model": "text-embedding-3-small", "dim": 1536}
}
Status codes
| 200 | Once auth passes. |
| 500 | The principals dependency returned None or not a list: a misconfigured mount. |
Tool routes
Mounted by create_tools_router in Python and by every TypeScript adapter. config is accepted on the way in and never sent back, in any response. Registering a tool with a non-empty config needs CE_SECRET_KEY (a base64url-encoded 32-byte key) to encrypt it; see Configuration.
POST/tools
Any caller that passes auth. The acl must name only principals the caller holds, and a tool with no acl is refused unless the mount is TRUSTED: an unrestricted tool is visible to every agent.
JSON body: a ToolConfig
| Field | Type | Default | Notes |
|---|
| name | string | required | The call name is derived from it: http_<name>, db_<name>, mcp_<tool>. |
| kind | "http" | "db" | "mcp" | "function" | required | function cannot be registered over HTTP (400). |
| config | object | required (Python) | Kind-specific. Encrypted at rest; never returned. |
| description | string | "" | What the agent reads when choosing a tool. |
| source_id | string | null | null | Scopes tool lookup. |
| acl | string[] | null | null | Who may see and run it. Only a TRUSTED mount may leave it null (403 otherwise). |
| requires_approval | boolean | false | Every call waits for a human. |
| approval_policy | object | {} | {"condition": "amount > 1000", "timeout_minutes": 60} |
| enabled | boolean | true | |
| meta_data | object | {} | Stored and returned in clear. Put secrets in config. |
curl -X POST https://app.example.com/context/tools \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "get_weather", "kind": "http", "acl": ["group:ops"],
"description": "Current weather for a city",
"config": {"method": "GET", "url": "https://api.example.com/weather",
"llmQueryParameters": {"properties": {"city": {"type": "string"}},
"required": ["city"]}}}'
{"id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d"}
Status codes
| 400 | A NUL character (U+0000) in name, source_id, acl or approval_policy (the detail names the field); kind is function; config holds the "__redacted__" placeholder; an http config.response_mode that is not json or tsv; an mcp config whose protocol, redirects or elicitation setting is invalid, or whose url carries a user name or password. |
| 403 | No acl on a non-trusted mount, or an acl naming a principal the caller does not hold. |
| 422 | The body is not a tool config: not an object, an unknown kind, no name, a config that is not an object, or an acl that is not a list of strings. The Python and the TypeScript routers answer the same. |
| 500 | A non-empty config and no CE_SECRET_KEY configured, or a malformed one. |
POST/tools/test
Any caller that passes auth. Principals are not consulted: nothing is stored. A dry run of a config before you register it.
JSON body: a ToolConfig (as for POST /tools)
curl -X POST https://app.example.com/context/tools/test \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "sales", "kind": "db",
"config": {"engine": "postgresql", "host": "db.internal", "port": 5432,
"database": "sales", "username": "reporting", "password": "..."}}'
{"ok": true, "success": true, "version": "PostgreSQL 16.4 ...", "host": "db.internal",
"database": "sales", "engine": "postgresql"}
{"ok": false, "error": "unreachable"}
Status codes
| 200 | Always for a reachability result, including failures. |
| 400 | config holds the "__redacted__" placeholder. |
| 422 | The body is not a tool config: not an object, an unknown kind, no name, a config that is not an object, or an acl that is not a list of strings. |
What is probed: http sends a HEAD to config.url with its headers and reports {"ok": status < 500, "status_code"}; db connects and reads the server version; mcp connects and returns {"ok": true, "tools": [names]}. A failure carries a category only, never the driver text: timeout, unreachable, auth_failed, misconfigured, egress_denied, unsupported_transport (an HTTP+SSE or WebSocket MCP URL) or error.
POST/tools/execute
Any caller that passes auth and can see the tool. Principals and the approval scope come only from the server: a principals or approval_scope key in the body is ignored.
JSON body
| Field | Type | Default | Notes |
|---|
| call_name | string | required | The LLM-facing name, for example http_get_weather. |
| args | object | null | null | The arguments. Keys starting with _ are stripped before execution. |
| tool_id | string | null | null | Which tool, when two visible tools share a call_name. |
| source_id | string | null | null | Scopes the lookup. |
curl -X POST https://app.example.com/context/tools/execute \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"call_name": "http_get_weather", "args": {"city": "Lahore"}}'
{
"result": {"status_code": 200, "headers": {"...": "..."}, "data": {"temp_c": 31}},
"usage": {"units": 1, "kind": "tool", "tool_name": "http_get_weather",
"tool_id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d", "truncated": false}
}
{
"approval_required": {
"approval_id": "c4d5e6f7-0000-4000-8000-000000000001",
"tool_name": "http_get_weather",
"tool_id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d",
"args": {"city": "Lahore"},
"reason": "tool requires approval",
"expires_at": "2026-09-27T11:00:00+00:00"
}
}
Status codes
| 400 | An argument cannot be stored: broken Unicode (a lone surrogate), a non-finite number, nesting deeper than 64 levels, or a value that is not a JSON value. The detail names the path of the argument and never its value. Also a call_name that holds broken Unicode. Nothing ran, no approval was opened, and the body carries no tool_ran. A NUL character (U+0000) in an argument is removed, not refused. |
| 404 | Unknown call_name, or not visible to the caller. |
| 502 | The tool ran and failed. The detail starts "tool execution failed:", with driver text stripped and redacted. |
| 502 | The tool ran and no audit row could be written at all (the database is down), with tools.audit_on_failure at "fail", the default. The body is {"detail": ..., "tool_ran": true, "result": ..., "truncated": ..., "tool_error": ...}: result is what the call would have returned, and tool_error is the error of the tool itself when it failed. Do not send the call again: it would run the tool a second time. The call is metered, because the tool ran. With "warn" the route answers 200 with the result. |
| 409 | call_name matches more than one visible tool and no tool_id was sent. |
| 422 | Missing call_name. |
| 500 | The approval_scope dependency returned something other than a non-empty string or None. |
The HTTP route uses the default result budget (8,000 characters, 100 rows) and the tool’s own response mode. See Result sizing. Every call whose tool was started leaves one audit row. A result keeps its JSON form on this route, over MCP and in the audit row, and all three spell a value the same way: timestamps as ISO 8601 text, durations as ISO 8601, a model or a dataclass as an object, an enum as its value. Only a value JSON cannot carry is changed: a lone surrogate becomes U+FFFD, a non-finite number becomes the text "NaN", "Infinity" or "-Infinity", U+0000 is removed, and a value nested deeper than 64 levels is cut there. So a call that ran and was recorded never comes back as a server error. In Python, binary is returned as text when it is valid UTF-8 without a NUL byte, otherwise as base64 text, and the value alone does not say which, and a result that is an iterator is returned as a list. In TypeScript a bigint is its digits as text and a property whose value is undefined is sent as null. A db tool’s text preview renders a timestamp in the language’s own text form, while rows carry the ISO form. An approval is matched to its arguments exactly, by canonical JSON form: key order does not matter, 1, true and "1" are different arguments (in Python, 1.0 too), and two spellings of one number, such as 1e16 and 10000000000000000, are the same argument. Test tool_ran on a 502 before retrying: see the audit guarantee.
MCP routes
For connecting an mcp tool: discover what a server offers, and run its OAuth sign-in when it needs one. These routes dial out, so they follow the same egress policy as the tool executors: private, loopback, link-local and metadata addresses are refused unless allow_private_egress is on.
POST/mcp/connect-and-list
Any caller that passes auth. Principals are not consulted. Connects once, lists, disconnects; nothing is stored.
JSON body
| Field | Type | Default | Notes |
|---|
| server_name | string | required | A label for this connection. |
| url | string | required | The MCP server’s Streamable HTTP URL. |
| token | string | null | null | Bearer token. |
| headers | object | null | null | Extra headers, string values. |
| include_resources | boolean | false | Also list resources. |
| protocol | "auto" | "modern" | "legacy" | null | null | The protocol setting for this one connection. Omitted, the global tools.mcp.protocol applies. |
| redirects | "refuse" | "same_origin" | "any" | null | null | The redirect setting for this one connection. Omitted, the global tools.mcp.redirects applies. |
curl -X POST https://app.example.com/context/mcp/connect-and-list \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"server_name": "wiki", "url": "https://mcp.example.com/mcp"}'
{
"server": "wiki",
"url": "https://mcp.example.com/mcp",
"transport": "http",
"tools": [{"name": "search_pages", "description": "...", "inputSchema": {"type": "object"}}],
"resources": null,
"requires_oauth": false
}
{"server": "wiki", "url": "https://mcp.example.com/mcp", "transport": "pending_oauth",
"tools": [], "resources": null, "requires_oauth": true}
Status codes
| 400 | The URL (or the OAuth discovery on it) is a private, loopback, link-local or metadata address and allow_private_egress is off; an HTTP+SSE or WebSocket URL (sse+http://, sse+https://, ws://, wss://, or a path ending /sse or /events), with the text "MCP over SSE and WebSocket is no longer supported. Use the server's Streamable HTTP URL."; a URL with a user name or password in it; a URL that is not http(s) or does not parse; an invalid protocol or redirects value. |
| 502 | The server answered 401 or 403 and has no OAuth endpoint: "MCP server returned <status>. No OAuth endpoint found. Provide a Bearer token instead." Any other HTTP error status: "MCP server returned <status>". The connection fails: "Cannot reach the MCP server." A JSON-RPC error from the server: "The MCP server answered with a protocol error." The error's own text, which can quote the URL and its query string, goes to the log and is not in the response. |
A 401 or 403 from the server triggers OAuth discovery on the same URL. When it finds an authorization server the route answers 200 with requires_oauth: true: go on to POST /mcp/oauth/start. transport is always http (Streamable HTTP is the only transport), or pending_oauth on that OAuth answer. When protocol is auto and the server answers the protocol version question with a 5xx, the 502 text ends with the way out: Set protocol to "legacy" on this connection if it only speaks the initialize handshake. A redirect from the server is refused unless redirects allows it. See Connecting MCP servers.
POST/mcp/oauth/start
Any caller that passes auth. Principals are not consulted.
JSON body
| Field | Type | Default | Notes |
|---|
| server_url | string | required | The MCP server URL. |
| tool_id | string | null | null | Your tool id. Echoed back in the callback token payload so you know which tool to save it on. |
| client_id | string | null | null | Your OAuth client. When absent and the server offers dynamic client registration, the route registers one. |
| client_secret | string | null | null | |
| scopes | string | null | null | Space-separated scopes. |
curl -X POST https://app.example.com/context/mcp/oauth/start \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"server_url": "https://mcp.example.com/mcp", "tool_id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d"}'
{"authorize_url": "https://auth.example.com/authorize?response_type=code&state=...&code_challenge=...",
"state": "..."}
Status codes
| 400 | A private address refused by the egress policy (server, or its registration endpoint); metadata missing authorization_endpoint or token_endpoint. |
| 500 | TypeScript only: the adapter was built without redirectBaseUrl. |
| 502 | No OAuth authorization endpoint could be discovered. |
The state is valid for 10 minutes. PKCE (S256) is always used.
GET/mcp/oauth/callback
Public. The only route that skips auth: the identity provider redirects the browser popup here and cannot carry your auth scheme. The one-time state is what protects it.
Query parameters (sent by the identity provider)
| Field | Type | Default | Notes |
|---|
| code | string | | Authorization code. |
| state | string | | The state from /mcp/oauth/start. |
| error, error_description | string | | Set when the provider declined. |
# Not called by you: the provider redirects the popup to
# https://app.example.com/context/mcp/oauth/callback?code=...&state=...
// posted to window.opener, then the popup closes
{"type": "mcp-oauth-success", "message": "Connected successfully",
"correlation_id": "mcpoauth_0f1e2d3c4b5a69788796a5b4c3d2e1f0",
"token": {"access_token": "...", "refresh_token": "...", "token_type": "Bearer",
"expires_in": 3600, "scope": "...", "tool_id": "9b1f0d2c-..."}}
{"type": "mcp-oauth-error", "code": "state_invalid",
"message": "This connection attempt expired. Please start again.",
"correlation_id": "mcpoauth_..."}
Status codes
| 200 | Always an HTML page, success or failure. |
The message is posted only to the origin of the redirect base URL, never "*". Failure code is one of idp_denied, malformed_callback, state_invalid, token_endpoint_unreachable, token_exchange_failed, internal_error; branch on it, not on message, which is a fixed sentence for the person. The provider’s own error text only reaches your server log, under the correlation_id.
Approval routes
The queue behind gated tool calls. Both routes are scoped by the caller’s principals: a caller only sees and resolves approvals opened under principals it shares. An operator surface that must handle everyone’s approvals mounts with TRUSTED.
GET/approvals
Any caller that passes auth. Lists only approvals whose recorded principals are visible to the caller, newest first, up to 50.
Query parameters
| Field | Type | Default | Notes |
|---|
| status | string | none | pending, approved, rejected, expired or executed. The stored status: an overdue pending row still reads pending. |
| source_id | string | none | |
curl "https://app.example.com/context/approvals?status=pending" -H "Authorization: Bearer $TOKEN"
{
"approvals": [
{
"id": "c4d5e6f7-0000-4000-8000-000000000001",
"tool_name": "http_get_weather",
"tool_args_frozen": {"city": "Lahore"},
"source_id": null,
"approval_scope": "run-42",
"principals": ["group:ops"],
"status": "pending",
"approver": null,
"approver_meta": null,
"expires_at": "2026-09-27T11:00:00+00:00",
"resolved_at": null,
"created_at": "2026-09-27T10:00:00+00:00",
"tool_id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d"
}
]
}
Status codes
| 500 | Misconfigured principals dependency. |
POST/approvals/{approval_id}/resolve
Any caller that passes auth and can see the approval. Single use: of several concurrent calls on one id, exactly one succeeds.
JSON body
| Field | Type | Default | Notes |
|---|
| decision | "approved" | "rejected" | required | |
| approver | string | required | Recorded on the approval. |
| meta | object | null | null | Stored as approver_meta. |
curl -X POST https://app.example.com/context/approvals/c4d5e6f7-0000-4000-8000-000000000001/resolve \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"decision": "approved", "approver": "user:boss"}'
{"id": "c4d5e6f7-0000-4000-8000-000000000001", "status": "approved",
"approver": "user:boss", "resolved_at": "2026-09-27T10:05:00+00:00", "...": "the full record"}
Status codes
| 400 | approval_id is not a UUID; a NUL character (U+0000) in approver or its metadata. |
| 404 | Missing, already resolved, or not visible to the caller (the same answer for all three). |
| 409 | Still pending but past expires_at. It is marked expired as part of this answer. |
| 422 | decision is not approved or rejected, or approver is missing (Python). |
Tool kinds
Every tool is a ToolConfig (the fields under POST /tools) whose config depends on its kind. GET /tools/schema/{kind} returns the JSON Schema of that config, the same in both packages. Fields marked write-only are masked in config_redacted; any key the schema does not list is masked too.
http
One tool, call name http_<name>. The agent’s arguments are the merged llmParameters and llmQueryParameters. For GET and DELETE every value goes in the query string; for the other methods fixed parameters and llmQueryParameters go in the query string and llmParameters merge into the JSON body. A required parameter that is missing or empty fails the call. Redirects are followed by hand, each hop checked against the egress policy. The result is {status_code, headers, data}, with truncated: true when the body hit the size cap.
| config field | Type | Default | Notes |
|---|
| url | string | required | Endpoint. {{name}} placeholders are filled from the arguments, percent-encoded. |
| method | GET | POST | PUT | PATCH | DELETE | QUERY | GET (required) | |
| headers | object of strings | | Write-only: masked in config_redacted. |
| body | any | | Request body template. Top-level keys merge with the LLM parameters. |
| body_secret | boolean | absent = masked | false lets body round-trip in config_redacted; true or absent masks it. |
| parameters | object | | Fixed parameters sent on every call (query string). Write-only. |
| llmParameters | object | | JSON-Schema shaped (properties, required). Filled by the LLM; sent in the JSON body. |
| llmQueryParameters | object | | JSON-Schema shaped. Filled by the LLM; sent in the query string. |
| response_mode | "json" | "tsv" | "json" | How the response comes back. Any other value is refused on register and update (400). |
{
"name": "orders",
"kind": "http",
"description": "Look up an order by id",
"acl": ["group:ops"],
"config": {
"method": "GET",
"url": "https://api.example.com/orders/{{order_id}}",
"headers": {"Authorization": "Bearer YOUR_TOKEN"},
"parameters": {"api_version": "2"},
"llmQueryParameters": {
"properties": {"order_id": {"type": "string"}, "expand": {"type": "string"}},
"required": ["order_id"]
},
"response_mode": "json"
}
}
// registered as call_name "http_orders"
db
One tool, call name db_<name>, with a single argument query: either SQL or describe:table1,table2 for column details. A statement the access mode does not allow comes back as {"success": false, "error": ...} rather than running. A result is {success, columns, rows, row_count, truncated, text}.
| config field | Type | Default | Notes |
|---|
| engine | postgresql | mysql | mariadb | mssql | sqlite | oracle | clickhouse | snowflake | required | |
| database | string | required | |
| host, port, username | string, integer, string | | |
| password | string | | Write-only. |
| access_mode | readonly | readwrite | full | readonly | readonly runs exactly one read statement (SELECT, WITH that only reads, SHOW, EXPLAIN without ANALYZE, DESCRIBE). More than one statement, transaction control or a session change (COMMIT, BEGIN, SET, COPY, LOCK and similar), SELECT ... INTO and EXPLAIN ANALYZE are refused before the database is touched. readwrite refuses COPY. An unknown value fails closed. |
| query_timeout_s | number above 0 | null | tools.db.query_timeout_s (30) | Seconds a query may run on the database server before it is cancelled there. null lifts the bound for this tool. |
| max_rows | integer | 1000 | Rows fetched per query, before result sizing. |
| selected_tables | string[] | | The tables the agent may use. |
| service_name, schema_name, warehouse | string | | Dialect connection identifiers (Oracle, schema, Snowflake). |
{
"name": "sales",
"kind": "db",
"description": "Read-only access to the sales database",
"acl": ["group:finance"],
"config": {
"engine": "postgresql",
"host": "db.internal",
"port": 5432,
"database": "sales",
"username": "reporting",
"password": "...",
"access_mode": "readonly",
"max_rows": 1000,
"selected_tables": ["orders", "customers"]
}
}
// registered as call_name "db_sales"; the agent passes {"query": "SELECT ..."}
// or {"query": "describe:orders,customers"}
mcp
One registration per server, expanded into one tool per entry in config.discovered_tools (save the tools list from connect-and-list there). Each becomes mcp_<tool name>, takes the tool’s own inputSchema, and may carry its own enabled, requires_approval and approval_policy, which override the server-level ones. With no discovered tools the registration is a single mcp_<name>.
| config field | Type | Default | Notes |
|---|
| url | string | required | The Streamable HTTP URL of the server. An HTTP+SSE or WebSocket URL, and a URL with a user name or password in it, is refused. |
| transport | http | | Optional and unused: Streamable HTTP is the only transport. Kept so a form written for an earlier release still round-trips. |
| protocol | auto | modern | legacy | tools.mcp.protocol | How this connection picks its protocol version. |
| redirects | refuse | same_origin | any | tools.mcp.redirects | What this connection does with a redirect. |
| elicitation_url (or elicitationUrl) | boolean | tools.mcp.elicitation_url | Pass a URL-mode question to the elicitation hook for this connection. |
| elicitation_timeout_s (or elicitationTimeoutS) | number above 0 | tools.mcp.elicitation_timeout_s | Seconds the hook may take per question on this connection. |
| oauth | object | | Discovered OAuth config. Write-only. |
| headers | object of strings | | Custom headers, for example Basic auth or X-Api-Key. Write-only. |
Beyond the schema, the executor reads these config keys: discovered_tools; oauth_token, bearer or access_token (sent as Authorization: Bearer, in that order of preference); and api_key (also sent as a Bearer header when no token is set). A derived Authorization header overrides one in headers.
{
"name": "wiki",
"kind": "mcp",
"description": "Company wiki",
"acl": ["group:staff"],
"config": {
"url": "https://mcp.example.com/mcp",
"protocol": "auto",
"redirects": "refuse",
"oauth_token": "...",
"discovered_tools": [
{"name": "search_pages", "description": "Search the wiki",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}}},
{"name": "delete_page", "requires_approval": true},
{"name": "admin_reset", "enabled": false}
]
}
}
// each enabled discovered tool becomes its own call_name: "mcp_search_pages", "mcp_delete_page"
function
Host code the engine calls by name, call name fn_<name>. Its schema has no properties, and it cannot be registered over HTTP: POST /tools answers 400. Register it in code; it is then listed and executed like any other tool, including through POST /tools/execute.
# In code, not over HTTP: a function tool is host code the engine calls by name.
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
engine.register_function_tool(add) # call_name "fn_add"
How approvals work
A call needs approval when the tool has requires_approval: true, or when its approval_policy.condition holds for the arguments. The engine does not block or poll: it records a pending approval, returns approval_required, and waits for you to call again.
| approval_policy key | Meaning |
|---|
| condition | One comparison, field op value, with > >= < <= == !=, for example amount > 100000. The value is read as a number when it parses, else a string (quotes stripped). Never evaluated as code. It fails closed: a condition that does not parse, an argument that is missing, and an ordering that cannot be made all require approval. An ordering (>, >=, <, <=) is made only between two numbers or two strings, so "9,999,999", a null, a boolean, a list or NaN against a number requires approval. Both packages answer the same for every operator and argument type. |
| timeout_minutes | How long the approval stays valid. Default 60. |
# 1. The agent asks for a gated call. Nothing runs yet.
POST /tools/execute {"call_name": "http_refund", "args": {"amount": 5000}}
-> 200 {"approval_required": {"approval_id": "c4d5...", "reason": "approval policy condition met: amount > 1000", ...}}
# 2. A person sees it and decides.
GET /approvals?status=pending
POST /approvals/c4d5.../resolve {"decision": "approved", "approver": "user:boss"}
# 3. The same call, same args, same server-side approval scope, runs for real, once.
POST /tools/execute {"call_name": "http_refund", "args": {"amount": 5000}}
-> 200 {"result": {...}, "usage": {...}}
The rules
The arguments are frozen. The approver reviews tool_args_frozen, and only a call with those same arguments can use the approval.
An approval is used once. The approved call claims it and the record moves to executed.
Scope it. The approval scope (approval_scope / approvalScope on the mount, typically the run id) is stamped on the record, and only a call in the same scope can claim it. Within a scope, identical gated calls share one pending record. Without a scope the record is opened and matched unscoped, with a deprecation warning; a later release will refuse a gated call without one.
Only an approved record is claimed. A pending one past its expires_at answers 409 on resolve and is marked expired.
MCP OAuth flow
For an MCP server that signs users in with OAuth. The browser does the sign-in in a popup; your page receives the token and saves it on the tool.
- Discover. POST /mcp/connect-and-list answers requires_oauth: true when the server refuses with 401 or 403 and publishes OAuth metadata.
- Start. POST /mcp/oauth/start with the server URL and your tool_id. The route discovers the authorization server (RFC 8414, then OpenID discovery, then common paths), registers a client by dynamic registration when you sent no client_id and the server supports it, stores a PKCE verifier under a fresh state for 10 minutes, and returns authorize_url.
- Sign in. Open authorize_url in a popup. The provider redirects to <redirect base URL>/mcp/oauth/callback.
- Exchange. The public callback redeems the state once, exchanges the code for tokens, and renders a page that posts the result to window.opener on your origin, then closes.
- Save. The engine does not store the token. Write it into the tool’s config yourself, for example as oauth_token with PATCH /tools/{id}, together with the discovered_tools list.
The code exchange and the token refresh (exchangeCodeForToken and refreshAccessToken in TypeScript, the tools/mcp_oauth module in Python) fail with OAuthCallbackError, whose code is one of the popup’s codes. A token endpoint the tool plane may not connect to (a private address without allow_private_egress) is reported as token_endpoint_unreachable, with the egress refusal as its cause (.cause in TypeScript, __cause__ in Python); the address itself is logged, never rendered.
// In your editor UI (browser)
const r = await fetch("/context/mcp/connect-and-list", {method: "POST", headers, body: JSON.stringify({server_name: "wiki", url})});
if ((await r.json()).requires_oauth) {
const {authorize_url} = await (await fetch("/context/mcp/oauth/start", {
method: "POST", headers, body: JSON.stringify({server_url: url, tool_id}),
})).json();
window.open(authorize_url, "mcp-oauth", "width=600,height=700");
}
window.addEventListener("message", async (e) => {
if (e.origin !== location.origin) return;
if (e.data.type === "mcp-oauth-success") {
// Save the token on the tool yourself: the engine does not store it.
await fetch(`/context/tools/${e.data.token.tool_id}`, {
method: "PATCH", headers,
body: JSON.stringify({config: {url, transport: "http", oauth_token: e.data.token.access_token}}),
});
} else if (e.data.type === "mcp-oauth-error") {
showError(e.data.code, e.data.correlation_id);
}
});
Several workers? The pending state lives in the process that built the authorize URL by default. In Python, pass pending_store=PostgresPendingStore(session_factory) to create_tools_router so any worker can redeem it. The TypeScript adapters keep it in memory.
Result sizing
A tool result is sized before it is returned, so one call cannot flood the model’s context. Only the returned result is shaped: the audit row keeps its own cap, and a db tool’s max_rows applies first.
| Setting | Default | Effect |
|---|
| result_max_rows | 100 | A result with a rows list is cut to this many rows, with _result_shaping: {rows_returned, rows_omitted}. |
| result_max_chars | 8,000 | A result still larger when serialized is replaced by {_truncated, _original_size, _note}. |
| response_mode | "json" | "tsv" turns every array of objects into a TSV string and cuts at whole rows, reporting each cut under its path in _result_shaping. |
Over HTTP, POST /tools/execute takes none of these in its body: it always uses 8,000 characters and 100 rows, and the response mode set on an http tool’s config (config.response_mode), else JSON. usage.truncated says whether anything was cut. When your model needs a different budget, call the engine from your own route:
# Your own route, when the defaults do not fit your model's window
out = await engine.execute_tool(
"db_sales", {"query": "SELECT * FROM orders"},
principals=caller_principals,
approval_scope=run_id,
result_max_chars=200_000, # None = no character limit
result_max_rows=None, # None = no row limit (the db tool's max_rows still applies)
response_mode="tsv", # "json" | "tsv"
)
// Your own route, when the defaults do not fit your model's window
const out = await engine.executeTool("db_sales", { query: "SELECT * FROM orders" }, {
principals: callerPrincipals,
approvalScope: runId,
resultMaxChars: 200_000, // null = no character limit
resultMaxRows: null, // null = no row limit (the db tool's max_rows still applies)
responseMode: "tsv", // "json" | "tsv"
});
// db result over 100 rows: rows cut, and the cut is said
{"success": true, "columns": ["id", "name"], "rows": [[1, "a"], "... 100 rows"], "row_count": 812,
"_result_shaping": {"rows_returned": 100, "rows_omitted": 712}}
// any result over 8,000 characters when serialized
{"_truncated": "{\"items\": [...first 8000 characters",
"_original_size": 51234,
"_note": "tool result exceeded the context budget and was truncated"}
// response_mode "tsv": every array of objects becomes a TSV string, cut at whole rows
{"items": "id\tname\n1\ta\n2\tb", "_result_shaping": {"items": {"rows_returned": 2, "rows_omitted": 40}}}
Python and TypeScript differences
The routes, paths, rules and status codes match. These are the places they do not:
| Topic | Python | TypeScript |
|---|
| Where tool routes live | A separate FastAPI router, create_tools_router. Flask and Django adapters have none. | Built into the Hono, Express and Fastify adapters. |
| Document and search response keys | snake_case (document_id, chunk_text, failure_message). | camelCase (documentId, chunkText, failureMessage). Request bodies are snake_case in both, and tool and approval responses are snake_case in both. |
| redirect base URL | Required argument. | Optional; POST /mcp/oauth/start answers 500 without it. |
| config_template, pending_store | Arguments of create_tools_router. | Not options of the adapters: they serve no config_redacted and keep OAuth state in memory. |
| Approval records | Include approval_scope. | Omit approval_scope. |
| 422 detail | FastAPI’s own validation list on the FastAPI routers; the pydantic list on Flask and Django. | [{loc, msg, type}], with zod 4’s msg texts and type codes. A missing call_name on execute is the sentence "call_name is required". |
| A malformed ToolConfig on POST /tools or /tools/test | 422 from validation. | kind: "function" answers 400 on POST /tools; an unknown kind or a missing name is not mapped to a 4xx. |
| 415 on POST /documents | FastAPI and Flask: yes. Django: any non-multipart body is read as JSON. | All three adapters. |
| Body parsing | Done by the adapter. | Express needs express.json() and a multer-style upload middleware; Fastify needs @fastify/multipart for uploads. |