Management endpoints live under /api/v1/; invocation, MCP, metrics,
and OAuth discovery/authorization also expose the public paths called out
below. Management auth uses either:
- API key:
X-Orva-API-Key: orva_xxx...header, orAuthorization: Bearer orva_xxx...— both are accepted everywhere. Used by curl, CI, external callers. - Session cookie: set by
POST /api/v1/auth/login. Used by the dashboard.
A handful of endpoints are cookie-only and cannot be driven with a key,
because they act on the calling user rather than the instance:
/auth/me, /auth/refresh, /auth/change-password, /auth/sessions*, and
/oauth/connected-apps*.
API keys carry a permission set. The bootstrap admin key has all four:
invoke, read, write, admin. Operator-issued keys can be
narrowed.
Error envelope (every 4xx/5xx):
{
"error": {
"code": "POOL_AT_CAPACITY",
"message": "function pool at capacity for 019df200-7b00-7e00-9c00-aab1cd2e3f40",
"request_id": "019df210-7b00-7e00-9c00-aab1cd2e3f42",
"hint": "inspect pool limiting_reason; raise max_warm only for operator_max",
"retry_after_s": 5,
"details": {"function_id": "019df200-7b00-7e00-9c00-aab1cd2e3f40", "current": 16, "limit": 16}
}
}Retry-After HTTP header set in parallel when retry_after_s is
present. Full code catalog in ERRORS.md.
First-run only. Creates the admin user.
- 409
ALREADY_SETUP— a user already exists. - 401
UNAUTHORIZED— the instance is already in use (operator-minted API keys, or deployed functions) and the caller presented noadminkey. - 503
UNAVAILABLE— the setup state could not be read. It fails closed rather than assuming the instance is unclaimed.
A virgin instance onboards with no credentials, which is the documented
first-run flow: the auto-minted bootstrap-admin key does not count as use.
The gate exists because CreateUser is only ever called here, so an operator
who works exclusively through API keys never onboards — and unguarded, this
endpoint (exempt from the auth middleware, handing back a session cookie that
bypasses the permission model) would let anyone who can reach the port claim
a working instance.
// request
{"username": "admin", "password": "AdminPass123!Secure"}
// response 201
{"user": {"id": "u_xxx", "username": "admin"}, "expires_at": "..."}Sets the session cookie.
{"username": "admin", "password": "..."}Returns the current user (cookie-authed).
Reports whether any dashboard user exists. Fails closed: if the count
cannot be read it answers {"has_user": true}, so a transient database error
never advertises a claimable instance.
Returns {"has_user": bool} so the UI knows whether to route to
/onboarding or /login.
Mints a new session token, sets it as the cookie, and revokes the old
one — the previous cookie value stops working immediately. The lifetime is
ORVA_SESSION_DAYS (default 7).
Invalidates the session.
POST /api/v1/auth/change-password— change the current user's password.GET /api/v1/auth/sessions— list active login sessions.DELETE /api/v1/auth/sessions/{prefix}— revoke a session by token prefix.GET /api/v1/oauth/connected-apps— list authorized OAuth clients.DELETE /api/v1/oauth/connected-apps/{id}— revoke a connected client.DELETE /api/v1/oauth/clients/{client_id}— retire the application itself (instance-wide): revokes its grants, drops pending authorization codes, and blocks re-authorization without fresh consent.
Create a function record.
{
"name": "my-fn",
"runtime": "node", // node | python
"entrypoint": "handler.js", // optional, defaults match the runtime
"memory_mb": 128,
"cpus": 1,
"timeout_ms": 30000,
"env_vars": {"NODE_ENV": "production"},
"network_mode": "none" // none (default) | egress
}network_mode controls per-function network access:
none(default) — isolated net namespace, loopback only. DNS / TCP / UDP all blocked. Best for pure-compute handlers.egress— userspace TCP/UDP stack via nsjail--user_net. Function can call external HTTPS APIs. Host interfaces stay isolated. The instance-wide egress policy is compiled into every such sandbox; a blocked destination gives the handlerECONNREFUSED.
Toggling on an existing function via PUT /api/v1/functions/{id}
drains the warm pool so the next invocation picks up the new mode.
List all functions. Optional ?status=active|inactive, ?runtime=....
Single function record. Accepts a function id or name.
Partial update. Whitelisted fields: name, description, entrypoint,
timeout_ms, memory_mb, cpus, env_vars, network_mode,
max_concurrency, concurrency_policy, auth_mode, rate_limit_per_min,
status.
status accepts only active | inactive. Setting inactive causes
POST /fn/<id> to return 409 NOT_ACTIVE — and, since this release, also
blocks function-to-function orva.invoke() calls, which previously ran an
inactive function anyway.
entrypoint is the file you authored and the build pipeline never
rewrites it. When a runtime compiles — TypeScript through tsc — the build
output is recorded separately in run_entrypoint; empty means "same as
entrypoint". Send only entrypoint when creating or updating a function.
auth_mode accepts none | platform_key | signed and governs how
POST /fn/<id> is authorized. Under platform_key the caller must present a
key carrying the invoke permission (or a session cookie); a key scoped
to read/write only gets 403. concurrency_policy accepts reject | queue
and decides what happens once max_concurrency is reached (reject returns
429 FUNCTION_BUSY). rate_limit_per_min is a per-client-IP cap; exceeding it
returns 429 RATE_LIMITED with Retry-After: 60. The client IP is the TCP peer
address — X-Forwarded-For decides it only when ORVA_TRUSTED_PROXY=true, so
behind an un-declared proxy every caller shares one bucket.
filename defaults to the function's own entrypoint before falling back to
handler.js / handler.py. It used to hardcode those two, so a function
created with any other entrypoint deployed a file the builder then refused to
find. filename and every extras key must be a contained relative path;
traversal returns 400 VALIDATION.
extras is REST-only — the MCP deploy_function_inline tool does not accept
it.
Removes the row + the on-disk versions dir. Irreversible.
Deploy from JSON.
{
"code": "module.exports = async () => ({ok:true});",
"filename": "handler.js",
"dependencies": "lodash@^4.17.21", // optional, becomes package.json or requirements.txt
"extras": {"tsconfig.json": "{...}"} // optional support files written alongside the handler
}Returns 202 with the deployment record. Build runs asynchronously.
Deploy from a tarball (multipart upload).
Roll back to a prior version.
{"deployment_id": "019df210-1234-7000-8000-deadbeef0001"} // or {"code_hash": "abc..."}Rollback promotes an existing deployment — it does not append a new one,
so the version history does not grow and the deployment count is unchanged.
active_deployment_id moves to the target, the current symlink is
retargeted, and run_entrypoint is re-derived from the promoted version's own
directory rather than restored from its snapshot (a snapshot written before
that column existed carries no value, and applying that absence points a
compiled TypeScript version back at its .ts source). Returns 200 with the
promoted deployment record.
Returns 410 VERSION_GCD if the target version was pruned by the GC, and 400
if the target is not a succeeded deployment of this function, or if it is
already active.
Returns the function's current code + dependencies as JSON. Used by the Editor view.
Drops the function's persistent installer cache (npm / pip). The next deploy re-downloads its dependencies from scratch. Use when a cached dependency is suspect; it never touches deployed versions or the running function.
Compares the handler source + dependency manifest between two past
succeeded deployments. Both from and to must be deployment UUIDs
belonging to this function.
format=json(default — dashboardCompare versionsview) returns{from, to, files: [{path, kind:"handler"|"manifest", before, after, added, removed}]}.before/aftercarry the raw file bytes so the browser-side merge viewer can compute its own hunks.format=unifiedreturnstext/x-diffwith git-style hunks per file (--- a/path/+++ b/path/@@ …). Consumed byorva diff.
Errors:
- 400
VALIDATIONiffromandtoare equal, belong to different functions, or aren't in statussucceeded. - 404
VERSION_NOT_FOUNDif either deployment ID is unknown; details include the requested ID. - 410
VERSION_GCDif either version's source tree was pruned by the GC.details.available_hasheslists the surviving on-disk versions so the caller can retry against a still-archived target.
Deployment history for a function. Optional ?limit=N (default 50).
Calls the function. id is the function's UUID (the same value returned in the id field by GET /api/v1/functions).
(e.g. function 019df200-7b00-7e00-9c00-aab1cd2e3f40 → URL
/fn/019df200-7b00-7e00-9c00-aab1cd2e3f40). Method,
headers, body, query, and path (everything after /{id}) are all
passed to the handler as event.
Response is whatever the handler returns. HTTP status is 200 unless
the handler throws or returns an AWS-shape {statusCode, body}.
Custom routes (e.g. /webhooks/stripe) reach the same handler — see
the routes section below.
Response headers. Every invoke carries X-Orva-Execution-ID, including a
timeout or a sandbox failure, which answer from the invoke handler rather than
the proxy. Pass it to GET /api/v1/executions/{id}/logs to read what the
handler wrote — stderr for console.log/print, and log_entries for
orva.log.*. It used to be set only on the success path, so the runs whose
logs you most need — a crashed sandbox, a timeout — returned no id and their
stored stderr was unreachable.
A non-streaming response that the handler produced — whatever status it
returned — also carries X-Orva-Duration-MS. A streaming response carries
X-Orva-Ttfb-Ms instead, because its headers flush on the first chunk and a
duration written there would measure time-to-first-byte; total streaming
duration is on the execution row. Neither duration header is set when the
invoke handler answers for itself.
Single deployment record.
Build logs for that deployment.
Server-sent events stream of build progress. Live tail; closes when
the build reaches a terminal state (succeeded | failed).
List recent invocations. Optional ?function_id=..., ?limit=N (default 50,
capped at 1000), ?since=/?until= (RFC3339).
since/untilare compared as times, not as text. They used to be raw string comparisons against a differently-formatted stored value, so a "last 1 hour" query returned nothing at all andorva executions pruneover-deleted by up to a day at the boundary.
GET /api/v1/functions is likewise capped at 1000.
Single execution row (status, duration, cold_start flag).
The function's stderr from this invocation.
GET /api/v1/executions/{id}/request— return the captured invocation request.DELETE /api/v1/executions/{id}— delete one execution record. Returns 404 for an id that does not exist; it used to report success.POST /api/v1/executions/bulk-delete— delete matching execution records. Responds{deleted, not_found, failed}.deletedcounts rows that actually existed: it used to count attempts, so 1000 unknown ids reported{deleted: 1000, failed: 0}.POST /api/v1/executions/{id}/replay— replay a captured request.GET /api/v1/tracesandGET /api/v1/traces/{id}— list trace-wide summaries with opaque stable cursors and inspect the complete causal waterfall.GET /api/v1/functions/{id}/baseline— return the function's trace baseline.GET /api/v1/activity— list the operator activity feed. Filters:source,q,status_min(≥ n; use400for errors),status_max(≤ n; use399for successes),since/until(unix millis),limit. Paginate withcursorandcursor_idfrom the previous response'snext_cursor/next_cursor_id— the feed is ordered(ts, id)and a timestamp-only cursor silently skips every row sharing the last row's millisecond.
List secret keys for a function. Values are not returned (encrypted at rest; only injected into the sandbox at spawn time).
Upsert. Body: {"key": "STRIPE_KEY", "value": "sk_..."}. Triggers a
pool refresh so the next invocation sees the new value.
Remove. Triggers a pool refresh.
Operator-facing function resources use the normal API-key/session auth:
GET /api/v1/functions/{id}/kv—prefix,limit(default 200, max 1000) andcursor. Returnsnext_cursor;truncatedreflects whether one was issued. Page with it rather than narrowing the prefix.GET|PUT|DELETE /api/v1/functions/{id}/kv/{key}POST /api/v1/functions/{id}/kv/{key}/incrPOST /api/v1/functions/{id}/kv/{key}/casGET|POST /api/v1/functions/{id}/fixturesGET|PUT|DELETE /api/v1/functions/{id}/fixtures/{name}GET|POST /api/v1/functions/{id}/cronPUT|DELETE /api/v1/functions/{id}/cron/{schedule_id}GET /api/v1/cron— list schedules across all functions.
KV keys must be non-empty UTF-8 up to 256 characters and values must be valid
JSON up to 64 KiB. Internal SDK batches accept at most 100 operations and are
atomic. For put/increment/CAS, omitted ttl_seconds preserves an existing
expiry (new keys remain persistent), zero clears expiry, a positive value sets
or refreshes it, and a negative value returns 400 VALIDATION.
The /_kv and /_internal route families are sandbox-SDK transport
endpoints authenticated with a process-signed, function-scoped credential.
The verified claim supplies caller identity; caller headers are ignored. A
credential expires when orvad restarts, KV access is restricted to its own
namespace, and user spans must
name an active execution owned by the credential's function. Invokes and job
enqueue may target another function while retaining signed caller attribution.
Cron upsert additionally requires a live execution: it must be called from
inside your handler and awaited. Calling it at module scope, or after the
handler has returned without awaiting it, returns 403 SDK_SCOPE_VIOLATION. A
function may declare at most 25 schedules of its own; ones you create in the
dashboard are not capped and do not count against it. A schedule is the only
thing an SDK credential can create that outlives the credential, which is why
this surface is gated where KV is not.
These routes are not an operator API and do not accept long-lived API keys.
POST /api/v1/jobs— enqueue a background job.GET /api/v1/jobsandGET /api/v1/jobs/{id}— list or inspect jobs.POST /api/v1/jobs/{id}/retry— retry a failed job.DELETE /api/v1/jobs/{id}— delete a job.
POST /webhook/<id> is the public endpoint the inbound-webhook rows below
configure, and it is not under /api/v1/, so the API-key middleware never
sees it. Authentication is the signature scheme chosen per hook (GitHub,
Stripe, Slack, generic HMAC, or none), verified before the function is invoked.
A handler reads the outcome with orva.webhook.parse(event); verified is set
from a server-controlled header that inbound requests cannot forge.
Inbound function endpoints:
GET|POST /api/v1/functions/{id}/inbound-webhooksGET|PUT|DELETE /api/v1/functions/{id}/inbound-webhooks/{webhook_id}
Outbound event delivery:
GET|POST /api/v1/webhooksGET|PUT|DELETE /api/v1/webhooks/{id}POST /api/v1/webhooks/{id}/testGET /api/v1/webhooks/{id}/deliveries— accepts?limit=(default 100, max 500). It used to be hardcoded to 100 with no parameter.POST /api/v1/webhooks/deliveries/{id}/retry
Map a custom URL to a function so external callers don't need the function ID.
List custom routes.
{"path": "/webhooks/stripe", "function_id": "019df200-7b00-7e00-9c00-aab1cd2e3f40", "methods": "POST"}methods accepts * for all methods or comma-separated (GET,POST).
Reserved prefixes (/api/, /auth/, /web/, /_orva/, /fn/, /mcp/,
/webhook/) are rejected — Orva serves those itself, so a route under one
could only shadow the platform or never fire.
Remove a route.
Per-function autoscaler tuning.
Read the row.
{
"function_id": "019df200-7b00-7e00-9c00-aab1cd2e3f40",
"min_warm": 2,
"max_warm": 32,
"idle_ttl_seconds": 600,
"scale_to_zero": false
}Fields are partial — unspecified ones keep the prior value (or default
for new rows). Defaults are min 1, max 50, idle TTL 600 seconds, and
scale-to-zero off. Pool Controller v2 derives desired capacity from demand;
the removed target_concurrency field returns 400 VALIDATION.
List keys. Returns prefixes, names, last_used_at, expires_at. Never returns the plaintext key.
{
"name": "ci-deployer",
"permissions": ["invoke", "read", "write"], // optional, defaults to all 4
// expires_in_days is capped at 36500. Above that, time.Duration overflows
// and used to mint a key whose expires_at was already in the past.
"expires_in_days": 90 // or expires_at: "ISO timestamp"
}Returns the plaintext key once. Save it immediately — it's not recoverable.
Revoke a key.
A channel bundles N deployed functions under a name and a static bearer
token. Presenting that token at /mcp exposes ONE MCP tool per
bundled function (invoke-only) and nothing else — no Orva-management
surface. Token format: orva_chn_<32 hex>. Channel tokens are
explicitly rejected at every /api/v1/* endpoint (401); they're
MCP-only.
Auth header at /mcp — channel tokens accept either form, same as
operator API keys:
Authorization: Bearer orva_chn_<token> # spec-standard, recommended
X-Orva-API-Key: orva_chn_<token> # parity with the REST API
The REST endpoints below (CRUD on /api/v1/channels) are operator-managed
and require an API key carrying the admin permission, or a session
cookie — channel tokens themselves cannot manage channels.
Changed: these used to accept
read/write. A channel token is a long-lived bearer credential whose tools bypass the target function'sauth_mode, so a write-scoped key could mint itself a credential that outranked it. Minting one is now gated like minting an API key.
List channels. Returns {channels: [...]} with name, description,
prefix, function_count, last_used_at, expires_at, created_at.
{
"name": "support-bot",
"description": "Support workflow toolkit", // optional
"function_ids": ["<uuid>", "<uuid>"],
"expires_in_days": 30 // optional; or expires_at: "ISO timestamp"
}Returns the plaintext token once in the token field. Save it
immediately — it's not recoverable. Two functions whose names
snake_case to the same MCP tool name are rejected with 400 / TOOL_NAME_COLLISION.
Detail with the bundled function set + per-function description overrides.
Update name / description / expires_at. Function set is unchanged.
{
"function_ids": ["<uuid>", ...],
"descriptions": {"<uuid>": "tool description override"} // optional
}Replaces the function set wholesale. Junction descriptions on overlapping function IDs are preserved unless explicitly overridden.
Re-issues the bearer token. Returns {token: "orva_chn_..."} once;
the previous token stops working immediately.
Cascade — removes the channel and every junction row.
Returns 200 and {"status": "healthy", ...} when orvad is up, alongside
version, commit, build_time, image, uptime_seconds, and database,
sandbox, host and writer sub-objects. image echoes the ORVA_IMAGE
stamp — the published container image sets it; a bare-metal install has no
image and reports it empty. Returns 503 with
{"status": "degraded"} when the 2-second database ping fails. Used by Docker
HEALTHCHECK and load balancers — match on healthy, not ok.
Prometheus text format.
Same data, JSON shape, used by the dashboard.
Prometheus also scrapes the unauthenticated GET /metrics path.
Everything in this section requires the admin permission — including
the firewall reads (GET /firewall/rules, /firewall/status,
/firewall/dns).
Changed: the
/api/v1/firewallsurface used to beread/write. Egress blocklists and the DNS every sandbox resolves through are instance-wide security state, so a deploy-scoped key could repoint every sandbox's resolver. Breaking for write-scoped automation touching these endpoints.
GET /api/v1/backupandPOST /api/v1/restore— download or restore an instance backup.GET /api/v1/system/storage— inspect disk/database usage.POST /api/v1/system/vacuum— compact the SQLite database.GET|POST /api/v1/firewall/rulesPUT|DELETE /api/v1/firewall/rules/{rule_id}POST /api/v1/firewall/resolveGET|PUT /api/v1/firewall/dns
GET /api/v1/firewall/rules returns {"rules": [...], "status": {...}}, and
POST /api/v1/firewall/resolve returns the same status object. It describes
the compiled sandbox egress policy (see
SECURITY.md):
{
"ipv4": ["169.254.0.0/16", "93.184.216.34/32"],
"ipv6": ["fd00:ec2::254/128"],
"hostname_map": {"example.com": ["93.184.216.34"]},
"backend": "nstun",
"enforced": true,
"policy_generation": "3f9a1c0d4b7e2851",
"policy_rule_counts": {"v4": 8, "v6": 2, "allow": 7, "reject": 3},
"policy_stale": false,
"last_success_at": "2026-08-09T11:02:14Z",
"control_plane_allow": {"addrs": ["172.17.0.1"], "port": 8443},
"unenforced_rules": [
{"id": 12, "value": "*.corp.com",
"reason": "wildcard hostnames are not enforceable: egress policy matches IP/CIDR, not DNS names. Use a CIDR or an exact hostname."}
]
}| Field | Meaning |
|---|---|
ipv4 / ipv6 |
The REJECT prefixes actually present in the compiled policy — derived from what is enforced, not from the raw table |
hostname_map |
hostname rule → the addresses currently resolved for it |
backend |
Always "nstun". Enforcement is nsjail NSTUN rules loaded per sandbox |
enforced |
A policy has compiled and is in use. false means egress functions refuse to spawn, since NSTUN's no-match default is ALLOW |
policy_generation |
16 hex chars — a hash of the exact bytes handed to nsjail. Changes only when enforcement changes |
policy_rule_counts |
Compiled rule counts: v4, v6, allow (carve-outs), reject (blocklist) |
policy_stale |
A recompile failed and the last known-good generation is still in force. Read with last_compile_error |
pending_recycle |
Warm workers are still running the previous generation. Enforcement is live for new spawns but not yet universal — without this field the status reads "enforcing" while old workers keep the old policy |
last_compile_error |
Present only on failure |
last_success_at |
RFC 3339 timestamp of the last successful compile |
control_plane_allow |
The narrow ALLOW that keeps orvad's internal SDK (orva.kv / orva.jobs / orva.invoke) reachable: exact addresses, exact port, TCP only |
unenforced_rules |
Stored rules deliberately not compiled, each with a reason. Wildcard rules land here. Surfaced so the UI never implies a rule is in force when it isn't |
nftables_available was removed, with no compatibility alias. The field
described a host-firewall mechanism that no longer exists, and reporting it as
permanently true would be a lie in the API. Clients that read it should read
enforced (is a policy in force) and backend instead.
Creating or enabling a wildcard rule now fails with 400 VALIDATION;
wildcards cannot be expressed as packet rules. Existing wildcard rows are left
untouched and reported in unenforced_rules.
Server-sent events stream of:
event: metrics— periodic 5-second snapshotsevent: execution— every new invocationevent: deployment— every status / phase change
Browser EventSource automatically reconnects. Cookie auth (API-key auth not supported on EventSource — browsers can't set custom headers).
List supported runtimes.
The seccomp policy catalog. Useful for the dashboard's "what is this function allowed to do" tooltip.
The in-product agentic chat (the dashboard's AI section). Requires
a configured provider (BYO key). Streaming endpoints emit
text/event-stream; everything else is JSON. All paths require admin.
Send a user message and stream the assistant turn. Body carries
conversation_id (or omit to start one), content, and the selected
provider/model/thinking level. Response is SSE: message_start,
delta (text), thinking, tool_call, tool_result,
awaiting_approval, message_end, error. Long pre-token gaps are
kept alive with : ping comment frames.
List conversations (most-recently-updated first).
Create an empty conversation.
Delete every conversation and cascade-delete their messages and tool calls in
one operation. Returns {"deleted": N}. Responds with 409 CONVERSATION_BUSY without deleting anything when any conversation has a turn
in progress.
Fetch one conversation with its full message + tool-call timeline.
Rename ({"title": "..."}) or archive ({"archived": true}).
Delete a conversation and all its messages + tool calls (cascade).
List messages, optionally ?since_seq=N for incremental loads.
Truncate the last assistant turn and re-run it. SSE, same frames as
/chat.
Replace a user message's content, truncate everything after it, and re-run the turn. SSE. (There is no branching history — the tail is discarded.)
Delete a message and every message + tool call after it (truncate by
seq).
Resolve a tool call that is awaiting_approval and resume the stream
(approve) or skip it (reject). SSE.
List, upsert, and remove provider configs (provider, label, base URL, API key). Keys are encrypted at rest with the same cipher as function secrets and never returned in responses.
List the models the configured provider/endpoint reports.
Read/update assistant settings: default provider/model, thinking level,
and approval policy (all_writes / destructive_only / auto). The
max_tool_iterations response field is retained for API compatibility but is
an internal runaway-work guard fixed at 25; values supplied by PUT are
ignored and normalized to 25.
Persist the dashboard's active provider/model/thinking selection.
POST /mcp is the Streamable HTTP MCP endpoint. Operator API keys expose
the management tools; channel tokens expose only the functions bundled into
that channel.
The transport is stateless and speaks MCP 2026-07-28, negotiating down
for older clients (server/discover reports every supported version). Three
things follow that are visible on the wire:
- No
initializehandshake is required. A 2026-07-28 request carriesprotocolVersionandclientCapabilitiesinparams._meta(clientInfois optional), plusMcp-Protocol-VersionandMcp-Methodheaders — and, fortools/call/resources/read/prompts/get, anMcp-Nameheader equal to the name in the body. The headers let a proxy route on the operation without parsing the body, so one that disagrees with the body is rejected (-32020), not ignored. Declaring anyprotocolVersionin_metacommits the request to that validated path; the legacy handshake is reached by omitting it, and still returns200. - No
Mcp-Session-Idis issued, and none is read. Any request may be served by any instance. GET /mcpandDELETE /mcpreturn405. There is no session to resume or terminate, and no long-lived server→client stream to attach to.
List results carry the new ttlMs and cacheScope hints. Orva returns
cacheScope: "private" — the tool catalog is scoped to the caller's permissions
and to their channel, so it is never safe for an intermediary to share — and
ttlMs: 0, because the catalog changes on any deploy, channel edit, or
permission change.
OAuth-capable MCP clients discover and authorize through:
GET /.well-known/oauth-protected-resource[/mcp]GET /.well-known/oauth-authorization-server[/mcp]GET /.well-known/openid-configuration[/mcp]POST /registerGET|POST /oauth/authorizePOST /oauth/tokenPOST /oauth/revoke