NEWPromptev MCP Server: one URL for every tool, every agent and your team’s knowledge. Connect Claude Code, Cursor, or any MCP client.→

Context Engine HTTP API reference

Every route the Python and TypeScript packages mount: documents, search, stats, tools, approvals and MCP connections. How to mount each router, who may call each route, what it takes, what it returns and which status codes it answers with. Then the tool kinds behind /tools.

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.

FactoryArgumentsReturns
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 argumentDefaultWhat it does
authrequiredFastAPI dependency, runs on every route except GET /mcp/oauth/callback.
principalsrequiredFastAPI dependency. Decides who may see, edit and run which tools.
redirect_base_urlrequiredThe 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_storeInMemoryPendingStore()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_templateFalseWhen True, GET /tools and GET /tools/{id} add config_redacted. Only for a mount that fronts an editor, never the one agents call.
approval_scopeNoneA 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.
FastAPI
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",
)
Flask
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")
Django
# 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.

FactoryImport fromReturns
createHonoRouter(engine, opts)@promptev/context-engine/honoa Hono app (also exported as createHonoApp)
createExpressRouter(engine, opts)@promptev/context-engine/expressan Express Router
createFastifyPlugin(engine, opts)@promptev/context-engine/fastifya Fastify plugin (wrapped with fastify-plugin when that package is installed)
OptionDefaultWhat it does
authrequired(ctx) => unknown | Promise, called with the Hono context, Express request or Fastify request. Throw to reject. Skipped for /mcp/oauth/callback.
principalsrequiredSame signature; returns a string[], [] or TRUSTED.
approvalScopenoneSame signature; returns the approval claim scope for POST /tools/execute.
redirectBaseUrlnoneAs 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.

Hono
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);
Express
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",
  }),
);
Fastify
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:

ReturnsMeaning
["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.
TRUSTEDA 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 / nullRefused 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 else500: 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.

Python
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: [])
TypeScript
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
HTTP/1.1 403 Forbidden
content-type: application/json

{"detail": "cannot grant access to principals you do not hold: ['group:finance']"}
StatusUsed for
400A 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.
403An ACL that names principals the caller does not hold, or a tool with no ACL from a non-trusted mount.
404Not found. A row that exists but is invisible to the caller gets the identical 404, so existence never leaks.
405Django only: a method the path does not serve.
409An ambiguous tool name, or an approval that has expired.
413An upload over ingest.max_file_bytes.
415POST /documents with an unsupported Content-Type.
422A body that fails validation. FastAPI renders its own validation list; the Flask, Django and TypeScript adapters send the list shown below.
500A misconfigured mount: a principals or approval-scope resolver returning the wrong thing, or a missing or malformed CE_SECRET_KEY.
502An MCP server that cannot be reached or answers badly, or on /tools/execute a tool that ran and failed.
503POST /search when every retrieval leg failed and a custom retrieval leg was among them (RetrievalFailed).
503Any 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.
422 from the TypeScript adapters
{"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)
FieldTypeDefaultNotes
textstringrequiredThe document text.
namestringrequiredDisplay name.
source_idstring | nullnullThe collection it belongs to.
external_idstring | nullnullYour own id. With source_id it is the document identity for re-ingest.
descriptionstring | nullnull
meta_dataobject | nullnull
aclstring[] | nullnullnull is unrestricted, [] is nobody. Only principals you hold.
mode"hybrid" | "graph" | nullnullProcessing mode. null uses the engine default.
extract_structuredbooleanfalseExtract structured fields.
mime_typestring | nullnullWhat 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)
FieldTypeDefaultNotes
filefilerequiredThe upload.
namestringfile name
description, source_id, external_id, mode, mime_typestringnoneSame meaning as the JSON fields.
meta_data, aclJSON stringnoneJSON-encoded, for example acl=["group:hr"].
extract_structuredstringfalseTrue when "1", "true", "yes" or "on". A batch key in the JSON body or the form, whatever its value, is refused with 400.
curl
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"]'
Response 200 (Python; values illustrative)
{
  "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
400Multipart 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).
403acl names a principal the caller does not hold.
413The declared Content-Length, the text, or the file is over ingest.max_file_bytes. The detail names the setting.
415Content-Type is neither multipart/form-data nor application/json. Django does not return this: any non-multipart body is read as JSON.
422The JSON body fails validation, or acl is not a list.
500The 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
FieldTypeDefaultNotes
source_idstringnoneOnly this collection.
limitinteger50Clamped to 1 to 200.
cursorJSON stringnoneThe next_cursor object from the previous page, JSON-encoded and URL-encoded.
curl
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"}'
Response 200 (Python; next_cursor only when has_more)
{
  "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
400cursor is not valid JSON.
500Misconfigured 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
curl https://app.example.com/context/documents/3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c \
  -H "Authorization: Bearer $TOKEN"
Response 200 (Python)
{
  "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
400document_id is not a UUID.
404Not 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)
FieldTypeDefaultNotes
aclstring[] | nullunchangedReplaced. An explicit null makes the document unrestricted. Only principals you hold.
namestring | nullunchangedReplaced.
descriptionstring | nullunchangedReplaced.
meta_dataobject | nullunchangedMerged into the stored object, not replaced. null is a no-op.
curl
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}}'
Response 200: the fields that actually changed
{"changed": ["meta_data"]}
Status codes
400document_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).
403acl names a principal the caller does not hold.
404Not found, or not visible to the caller.
422An unknown key such as text, or a wrong type.

DELETE/documents/{document_id}

Any caller that passes auth and can see the document.

curl
curl -X DELETE https://app.example.com/context/documents/3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c \
  -H "Authorization: Bearer $TOKEN"
Response 200
{"deleted": "3f2a9c1e-8b7d-4e21-9a0c-5d6e7f8a9b0c"}
Status codes
400document_id is not a UUID.
404Not found, or not visible to the caller.

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
FieldTypeDefaultNotes
namestringrequiredThe call name is derived from it: http_<name>, db_<name>, mcp_<tool>.
kind"http" | "db" | "mcp" | "function"requiredfunction cannot be registered over HTTP (400).
configobjectrequired (Python)Kind-specific. Encrypted at rest; never returned.
descriptionstring""What the agent reads when choosing a tool.
source_idstring | nullnullScopes tool lookup.
aclstring[] | nullnullWho may see and run it. Only a TRUSTED mount may leave it null (403 otherwise).
requires_approvalbooleanfalseEvery call waits for a human.
approval_policyobject{}{"condition": "amount > 1000", "timeout_minutes": 60}
enabledbooleantrue
meta_dataobject{}Stored and returned in clear. Put secrets in config.
curl
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"]}}}'
Response 200
{"id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d"}
Status codes
400A 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.
403No acl on a non-trusted mount, or an acl naming a principal the caller does not hold.
422The 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.
500A non-empty config and no CE_SECRET_KEY configured, or a malformed one.

GET/tools

Any caller that passes auth. Returns the registered rows the caller can see (one row per registration, not per discovered MCP tool).

Query parameters
FieldTypeDefaultNotes
source_idstringnoneOnly tools with this source_id.
curl
curl https://app.example.com/context/tools -H "Authorization: Bearer $TOKEN"
Response 200
{"tools": [{
    "id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d",
    "name": "get_weather",
    "kind": "http",
    "description": "Current weather for a city",
    "source_id": null,
    "acl": ["group:ops"],
    "requires_approval": false,
    "approval_policy": {},
    "enabled": true,
    "meta_data": {}
  }]}
Status codes
500Misconfigured principals dependency.

No response carries config. On a Python mount built with config_template=True, each row also carries config_redacted: the stored config with every write-only value masked to "__redacted__", or config_error: "undecryptable" when it cannot be decrypted. Turn that on only for a mount that fronts an editor.

GET/tools/{tool_id}

Any caller that passes auth and can see the tool.

curl
curl https://app.example.com/context/tools/9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d",
  "name": "get_weather",
  "kind": "http",
  "description": "Current weather for a city",
  "source_id": null,
  "acl": ["group:ops"],
  "requires_approval": false,
  "approval_policy": {},
  "enabled": true,
  "meta_data": {}
}
Status codes
400tool_id is not a UUID.
404Not found, or not visible to the caller.

PATCH/tools/{tool_id}

Any caller that passes auth and can see the tool. Only the fields you send change.

JSON body: any ToolConfig field
FieldTypeDefaultNotes
name, kind, description, requires_approval, approval_policy, enabled, meta_dataas ToolConfigunchangedCannot be null (400). Clear description with "" and approval_policy with {}.
configobjectunchangedAny "__redacted__" value keeps what is stored at that path.
aclstring[] | nullunchangedSame rule as create: only principals you hold, and null only on a TRUSTED mount.
source_idstring | nullunchanged
curl
curl -X PATCH https://app.example.com/context/tools/9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"requires_approval": true, "approval_policy": {"timeout_minutes": 30}}'
Response 200: the updated row, without config
{
  "id": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d",
  "name": "get_weather",
  "kind": "http",
  "description": "Current weather for a city",
  "source_id": null,
  "acl": ["group:ops"],
  "requires_approval": true,
  "approval_policy": {"timeout_minutes": 30},
  "enabled": true,
  "meta_data": {}
}
Status codes
400tool_id is not a UUID; a NUL character (U+0000) in name, source_id, acl, meta_data or approval_policy (the detail names the field); a non-nullable field sent as null; a "__redacted__" value with nothing stored at that path; an mcp config whose protocol, redirects or elicitation setting is invalid, or whose url carries a user name or password.
403acl rule broken.
404Not found, or not visible to the caller.

DELETE/tools/{tool_id}

Any caller that passes auth and can see the tool.

curl
curl -X DELETE https://app.example.com/context/tools/9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d \
  -H "Authorization: Bearer $TOKEN"
Response 200
{"deleted": "9b1f0d2c-4a3e-4f5b-8c6d-7e8f9a0b1c2d"}
Status codes
400tool_id is not a UUID.
404Not found, or not visible to the caller.

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
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": "..."}}'
Response 200: success, or a failure category
{"ok": true, "success": true, "version": "PostgreSQL 16.4 ...", "host": "db.internal",
 "database": "sales", "engine": "postgresql"}

{"ok": false, "error": "unreachable"}
Status codes
200Always for a reachability result, including failures.
400config holds the "__redacted__" placeholder.
422The 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.

GET/tools/schema/{kind}

Any caller that passes auth. Returns the JSON Schema of config for one kind, for rendering a form. The package does not validate config against it.

curl
curl https://app.example.com/context/tools/schema/mcp -H "Authorization: Bearer $TOKEN"
Response 200
{
  "type": "object",
  "properties": {
    "url": {"type": "string", "description": "The server's Streamable HTTP URL"},
    "transport": {"type": "string", "enum": ["http"]},
    "oauth": {"type": "object", "description": "Discovered OAuth config, if any", "writeOnly": true},
    "headers": {"type": "object", "additionalProperties": {"type": "string"}, "writeOnly": true},
    "protocol": {"type": "string", "enum": ["auto", "modern", "legacy"]},
    "redirects": {"type": "string", "enum": ["refuse", "same_origin", "any"]},
    "elicitation_url": {"type": "boolean"},
    "elicitationUrl": {"type": "boolean"},
    "elicitation_timeout_s": {"type": "number", "exclusiveMinimum": 0},
    "elicitationTimeoutS": {"type": "number", "exclusiveMinimum": 0}
  },
  "required": ["url"]
}
Status codes
404kind is not http, db, mcp or function.

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
FieldTypeDefaultNotes
call_namestringrequiredThe LLM-facing name, for example http_get_weather.
argsobject | nullnullThe arguments. Keys starting with _ are stripped before execution.
tool_idstring | nullnullWhich tool, when two visible tools share a call_name.
source_idstring | nullnullScopes the lookup.
curl
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"}}'
Response 200: a result, or an approval to wait for (nothing ran)
{
  "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
400An 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.
404Unknown call_name, or not visible to the caller.
502The tool ran and failed. The detail starts "tool execution failed:", with driver text stripped and redacted.
502The 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.
409call_name matches more than one visible tool and no tool_id was sent.
422Missing call_name.
500The 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
FieldTypeDefaultNotes
server_namestringrequiredA label for this connection.
urlstringrequiredThe MCP server’s Streamable HTTP URL.
tokenstring | nullnullBearer token.
headersobject | nullnullExtra headers, string values.
include_resourcesbooleanfalseAlso list resources.
protocol"auto" | "modern" | "legacy" | nullnullThe protocol setting for this one connection. Omitted, the global tools.mcp.protocol applies.
redirects"refuse" | "same_origin" | "any" | nullnullThe redirect setting for this one connection. Omitted, the global tools.mcp.redirects applies.
curl
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"}'
Response 200: tools listed, or the server wants OAuth
{
  "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
400The 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.
502The 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
FieldTypeDefaultNotes
server_urlstringrequiredThe MCP server URL.
tool_idstring | nullnullYour tool id. Echoed back in the callback token payload so you know which tool to save it on.
client_idstring | nullnullYour OAuth client. When absent and the server offers dynamic client registration, the route registers one.
client_secretstring | nullnull
scopesstring | nullnullSpace-separated scopes.
curl
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"}'
Response 200
{"authorize_url": "https://auth.example.com/authorize?response_type=code&state=...&code_challenge=...",
 "state": "..."}
Status codes
400A private address refused by the egress policy (server, or its registration endpoint); metadata missing authorization_endpoint or token_endpoint.
500TypeScript only: the adapter was built without redirectBaseUrl.
502No 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)
FieldTypeDefaultNotes
codestringAuthorization code.
statestringThe state from /mcp/oauth/start.
error, error_descriptionstringSet when the provider declined.
curl
# Not called by you: the provider redirects the popup to
# https://app.example.com/context/mcp/oauth/callback?code=...&state=...
Response 200 text/html: a page that postMessages this and closes
// 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
200Always 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
FieldTypeDefaultNotes
statusstringnonepending, approved, rejected, expired or executed. The stored status: an overdue pending row still reads pending.
source_idstringnone
curl
curl "https://app.example.com/context/approvals?status=pending" -H "Authorization: Bearer $TOKEN"
Response 200 (Python; TypeScript omits approval_scope)
{
  "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
500Misconfigured 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
FieldTypeDefaultNotes
decision"approved" | "rejected"required
approverstringrequiredRecorded on the approval.
metaobject | nullnullStored as approver_meta.
curl
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"}'
Response 200: the updated record
{"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
400approval_id is not a UUID; a NUL character (U+0000) in approver or its metadata.
404Missing, already resolved, or not visible to the caller (the same answer for all three).
409Still pending but past expires_at. It is marked expired as part of this answer.
422decision 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 fieldTypeDefaultNotes
urlstringrequiredEndpoint. {{name}} placeholders are filled from the arguments, percent-encoded.
methodGET | POST | PUT | PATCH | DELETE | QUERYGET (required)
headersobject of stringsWrite-only: masked in config_redacted.
bodyanyRequest body template. Top-level keys merge with the LLM parameters.
body_secretbooleanabsent = maskedfalse lets body round-trip in config_redacted; true or absent masks it.
parametersobjectFixed parameters sent on every call (query string). Write-only.
llmParametersobjectJSON-Schema shaped (properties, required). Filled by the LLM; sent in the JSON body.
llmQueryParametersobjectJSON-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).
POST /tools body
{
  "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 fieldTypeDefaultNotes
enginepostgresql | mysql | mariadb | mssql | sqlite | oracle | clickhouse | snowflakerequired
databasestringrequired
host, port, usernamestring, integer, string
passwordstringWrite-only.
access_modereadonly | readwrite | fullreadonlyreadonly 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_snumber above 0 | nulltools.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_rowsinteger1000Rows fetched per query, before result sizing.
selected_tablesstring[]The tables the agent may use.
service_name, schema_name, warehousestringDialect connection identifiers (Oracle, schema, Snowflake).
POST /tools body
{
  "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 fieldTypeDefaultNotes
urlstringrequiredThe 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.
transporthttpOptional and unused: Streamable HTTP is the only transport. Kept so a form written for an earlier release still round-trips.
protocolauto | modern | legacytools.mcp.protocolHow this connection picks its protocol version.
redirectsrefuse | same_origin | anytools.mcp.redirectsWhat this connection does with a redirect.
elicitation_url (or elicitationUrl)booleantools.mcp.elicitation_urlPass a URL-mode question to the elicitation hook for this connection.
elicitation_timeout_s (or elicitationTimeoutS)number above 0tools.mcp.elicitation_timeout_sSeconds the hook may take per question on this connection.
oauthobjectDiscovered OAuth config. Write-only.
headersobject of stringsCustom 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.

POST /tools body
{
  "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.

Python
# 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 keyMeaning
conditionOne 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_minutesHow long the approval stays valid. Default 60.
The round trip
# 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.

  1. Discover. POST /mcp/connect-and-list answers requires_oauth: true when the server refuses with 401 or 403 and publishes OAuth metadata.
  2. 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.
  3. Sign in. Open authorize_url in a popup. The provider redirects to <redirect base URL>/mcp/oauth/callback.
  4. 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.
  5. 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.

Browser
// 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.

SettingDefaultEffect
result_max_rows100A result with a rows list is cut to this many rows, with _result_shaping: {rows_returned, rows_omitted}.
result_max_chars8,000A 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:

Python
# 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"
)
TypeScript
// 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"
});
What a cut looks like
// 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:

TopicPythonTypeScript
Where tool routes liveA separate FastAPI router, create_tools_router. Flask and Django adapters have none.Built into the Hono, Express and Fastify adapters.
Document and search response keyssnake_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 URLRequired argument.Optional; POST /mcp/oauth/start answers 500 without it.
config_template, pending_storeArguments of create_tools_router.Not options of the adapters: they serve no config_redacted and keep OAuth state in memory.
Approval recordsInclude approval_scope.Omit approval_scope.
422 detailFastAPI’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/test422 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 /documentsFastAPI and Flask: yes. Django: any non-multipart body is read as JSON.All three adapters.
Body parsingDone by the adapter.Express needs express.json() and a multer-style upload middleware; Fastify needs @fastify/multipart for uploads.

Next: the methods behind these routes are on the Python API and TypeScript API pages; the settings they read are on Configuration.

2,500 free credits · No card required · No subscription