Skip to content
mcp-data-platform composable mcp data platform
v1.x part of txn2 ↗

Endpoints

An endpoint is a managed script bound to an HTTP route. A request to /endpoints/{name}/... runs the script's handle(req) on the replica that received it and answers with what the handler returns. An endpoint answers HTTP with platform data: a lookup a partner system calls, a feed an automated client pulls with an API key, a page that sends a form.

sequenceDiagram
    participant C as Caller
    participant H as /endpoints/{name}
    participant B as Bot check
    participant R as Runner
    participant P as Platform tools
    C->>H: request (session, API key, or bot check answer)
    H->>H: binding, CORS, method, path, rate limit
    alt public
        H->>B: verify X-Bot-Check
    else signed_in
        H->>H: browser session or API key
    end
    H->>R: handle(req), inline
    R->>P: platform.query / platform.call as script:<name>
    P-->>R: results (authorized, audited)
    R-->>H: {status, headers, json | body}
    H-->>C: response

Binding a script

Binding a script to an endpoint is an administrator's. The script's owner and everyone else are refused, on every surface. A persona that can be granted the permission comes in a later release.

With manage_script:

{"command": "endpoint_set", "name": "order-lookup", "owner_email": "[email protected]",
 "endpoint": "orders", "methods": ["GET"], "path": "/{id}", "auth_mode": "signed_in",
 "timeout_seconds": 10}

endpoint_list reads a script's binding and its recent failed requests, or lists every binding the caller's scripts have. endpoint_enable and endpoint_disable turn an endpoint on and off without touching the script; a disabled endpoint answers 404. A script run may read a binding but not set or turn one on or off.

On the portal, the script's page under Automations shows the binding, and an administrator binds, changes and turns it on and off there. The routes are GET|PUT /api/v1/portal/scripts/{id}/endpoint and POST /api/v1/portal/scripts/{id}/endpoint/enable|disable.

A binding holds:

Field Default Meaning
endpoint (required) The name, /endpoints/{name}; lowercase letters, digits, hyphens and underscores, unique across the deployment
methods GET The HTTP methods answered; HEAD is answered wherever GET is
path empty The template under the name: /{id} takes one segment the handler reads as req["params"]["id"]
auth_mode (required) signed_in or public
open_reads false On a public endpoint, GET and HEAD need no bot check
timeout_seconds 10 (at most 60) How long one request may run
max_memory_bytes 64 MiB The memory one request may hold, measured as a run's is
max_body_bytes 1 MiB (at most 10 MiB) The largest request body
allowed_origins none Browser origins a cross-origin request may come from
rate_limit 600 The endpoint's requests a minute on each replica
caller_rate_limit 120 One signed-in caller's requests a minute on each replica

A script can be bound only when it defines handle(req), and a save of a bound script whose source no longer defines it is refused, naming the endpoint. A library cannot be bound. A script with handle and no main() is a script, not a library.

The handler

def handle(req):
    """Looks one order up."""
    rows = platform.query(
        "SELECT status FROM orders WHERE id = :id",
        params = {"id": req["params"]["id"]},
    )
    if not rows["rows"]:
        return {"status": 404, "json": {"error": "no such order"}}
    return {"json": rows["rows"][0]}

req is a frozen dict:

Key Value
method The HTTP method
path The request path
params The path template's values
query Each query parameter's first value
headers The request headers, names lower-cased, joined with ,. Authorization, Cookie, X-API-Key and Proxy-Authorization are left out: they are the caller's credentials, and caller is who the platform found them to be
body The body, as bytes
json The body parsed when its content type is JSON, else None. A body that says it is JSON and is not is answered 400 before the script runs
form Each field's first value when the body is application/x-www-form-urlencoded, else None
caller On a signed_in endpoint, {"user_id", "email", "auth_type"} of the session or API key; None on a public one. It never comes from the request body

The handler returns a dict: status (200 to 599, default 200), headers (names to strings) and one of json (any value) or body (a string or bytes). A handler may not set Set-Cookie, Content-Length, Transfer-Encoding, Connection, Content-Security-Policy, X-Content-Type-Options, X-Frame-Options or any Access-Control-* header. Every answer carries Content-Security-Policy: default-src 'none'; sandbox and X-Content-Type-Options: nosniff, so whatever a handler returns renders as an opaque document that runs no script against the platform's origin, and Cache-Control: no-store unless the handler sets its own.

platform.export and platform.publish_data fail in a handler: a request answers with what handle returns.

A draft (run_draft) runs main(), so a handler is drafted with a main() that calls it with a request built by hand, which is also how its recording gets the answers to the platform calls it makes:

def handle(req):
    """Looks one order up."""
    rows = platform.query(
        "SELECT status FROM orders WHERE id = :id",
        params = {"id": req["params"]["id"]},
    )
    return {"json": rows["rows"][0]}

def main():
    """Drafts one request."""
    platform.result(handle({
        "method": "GET",
        "path": "/endpoints/orders/42",
        "params": {"id": "42"},
        "caller": None,
    }))

def test_handle():
    """Answers order 42."""
    testing.replay("<the draft's run id>")
    main()
    assert.eq(testing.outputs().result["json"]["status"], "shipped")

A save runs the tests as it does for any script.

Identity and authority

A request runs as the script's own principal, script:<name>, presenting the roles the current version's author held when they saved it, exactly as a scheduled run does (see Managed Scripts security). Every platform call the handler makes goes through the in-memory MCP session a run uses, so the persona filter authorizes it, the rate limiter counts it and the audit log records it under the script principal, with the request's session id.

Each request writes one audit row of its own: source endpoint, event kind endpoint_request, tool name endpoint:<name>, and in its parameters the endpoint, script, version, method, status, auth mode and the caller. No script_runs row is written.

Auth modes

signed_in answers a browser session or an API key (Authorization: Bearer or X-API-Key). A GET handler should change nothing: a browser sends its session with a GET another site navigates it to, as it does for any page. An anonymous request is answered 401. A browser session's write (POST, PUT, PATCH, DELETE) carries its CSRF token, as every portal write does; a session refused its token is answered 403. A caller past their own caller_rate_limit is answered 429 while other callers are still answered.

public answers anyone who passes the deployment's bot check, carried in the X-Bot-Check header. A request without one, or with one refused (altered, expired, issued for another endpoint, or already used), is answered 403. With open_reads, GET and HEAD need no check.

Bot checks

The bot check is configured under Admin > Settings > Bot check, the platform_settings section bot_check (GET|PUT /api/v1/admin/settings/bot-check).

Provider What it is
pow (default) The platform's own proof-of-work challenge. It calls no third party
turnstile Cloudflare Turnstile, verified on the server
hcaptcha hCaptcha, verified on the server
none No check. A public endpoint is refused, and cannot be bound, until allow_public_without_check is set

A page fetches a challenge and answers it with the platform's script:

<script src="/botcheck/solver.js"></script>
<script>
  const answer = await PlatformBotCheck.answer("contact");
  await fetch("/endpoints/contact", {
    method: "POST",
    headers: {"Content-Type": "application/json", [PlatformBotCheck.header]: answer},
    body: JSON.stringify({email: "[email protected]"}),
  });
</script>

GET /botcheck/challenge?endpoint=<name> issues a challenge for a public endpoint. A proof-of-work challenge is signed, bound to the one endpoint and answerable for challenge_ttl_seconds (300 by default). Challenges are counted against the endpoint's rate_limit in a bucket of their own, so fetching one does not spend the request it guards. The answer is the challenge and a counter whose SHA-256 has the challenge's difficulty in leading zero bits; the browser script computes it in a fraction of a second at the default difficulty of 16. Every accepted answer is recorded in PostgreSQL as used, so it cannot be replayed on any replica. The difficulty rises with the endpoint's recent rate: past rate_threshold answers in the last minute (30 by default), each doubling of the rate adds a bit, up to max_difficulty (22 by default). A person filling out a form does not notice it, and a flood pays more for each request.

The challenge is signed with a key derived from the browser-session signing key (auth.browser_session.signing_key), which every replica holds. A deployment without one cannot issue a proof-of-work challenge, and the settings page says so.

Turnstile and hCaptcha take the provider's site_key, which the widget is rendered with, and secret_name, a stored secret (Admin > Secrets) holding the provider's secret key, whose allow_connections names bot_check and which lists no personas. The key is read on the server when a request is verified and never appears in a response or a log. PlatformBotCheck.answer(name, {container}) renders the provider's widget into container and resolves with the token it produces. A provider token is recorded as used like a proof-of-work answer; it is not bound to one endpoint beyond what the provider itself checks.

CORS

An endpoint answers its own cross-origin requests from its allowed_origins; the platform's CORS layer leaves /endpoints/ and /botcheck/ alone. A preflight from a listed origin is answered with the endpoint's methods and the Content-Type, Authorization, X-API-Key and X-Bot-Check headers; one from an origin that is not listed is refused with 403. Credentials are never allowed across origins, so a browser session is never sent from another site, and a cross-origin caller of a signed_in endpoint signs in with an API key.

Limits and failures

Answer When
404 No enabled endpoint has the name, or the path does not match the template
405 The endpoint does not answer the method (Allow lists the ones it does)
413 The body is past max_body_bytes; the script does not run
429 Past rate_limit or the caller's caller_rate_limit, with Retry-After
503 The script is disabled, deprecated or superseded; the handler held more than max_memory_bytes; the replica is already running 32 handlers (with Retry-After); or the bot check could not be made
504 The handler ran past timeout_seconds
500 The handler failed, or returned something that is not a response

The rate limits count requests on each replica: three replicas let up to three times rate_limit through between them. No limit depends on the client's address, which a request behind an ingress and a load balancer does not carry.

A request that fails is kept for seven days with its status, error and duration, and is listed on the script's page and by endpoint_list. The caller is told only which of the answers above it got, never the script's error.

The compiled module is kept per version on each replica. Each request reads when its binding and its script were last written, so the first request after a save runs the new version on every replica.

Observability

endpoint_requests_total{endpoint, status_class} and endpoint_request_duration_seconds{endpoint} measure every request a handler was run for, by the binding's name; a request refused before that (404, 405, 401, 403, 413, 429) is in http_server_request_duration_seconds under the route template. A 429 is counted in http_rate_limited_total{limiter="endpoint"}. The routes report their template (/endpoints/{name}/{rest...}) to http_server_request_duration_seconds, never the path a caller sent.