Base URL: http://localhost:3001 (dev). Interactive reference (Scalar) at
/docs; machine-readable /openapi.json
generated from the shared zod contracts.
Session cookie — Better Auth session (dashboard). The active project is selected
via the x-memoturn-project header (defaults to the user’s first project).
Write endpoints require a non-VIEWER role (viewers get 403).
Batched events (trace-create, span/generation-create/update, event-create, score-create). Returns 207 with per-event results: schema-invalid events are rejected individually in errors (id, index, reason) while valid events are accepted — inspect errors to catch silent data loss. Per-event input/output/metadata JSON capped at 1 MB (400 on oversize). Returns 429 when the per-project event rate limit (INGEST_EVENTS_PER_MINUTE) is exceeded; Retry-After header indicates when to retry. The body also accepts an optional sdk: { name, version } identifying the client build — the official SDKs send it, and it feeds GET /v1/usage/sdks. Per-event limits: INGEST_MAX_EVENT_BYTES (1 MB) and INGEST_MAX_JSON_DEPTH (32) — oversize/over-deep events are per-event 400s in the 207 body. Optional Idempotency-Key header (≤128 chars): a retry within 24 h replays the original 207 (Idempotent-Replayed: true) instead of accepting a second batch; 409 while the first is still in flight.
OTLP/HTTP logs receiver (JSON + protobuf); log records become EVENT observations (e.g. Claude Code prompt/response text).
POST
/v1/otel/v1/metrics
Always 501 with a JSON explanation — OTLP metrics are not ingested. Exists so a collector configured with the base endpoint gets a clear answer instead of a 404.
GET
/v1/ingest/health
Ingest-pipeline health for the ops console: DLQ depth, insert latency, error counters, recent failed batches. OWNER/ADMIN only. Project-scoped: DLQ depth and recent failures cover the caller’s project only.
POST
/v1/ingest/dlq/replay
Re-enqueue dead-lettered batches from blob onto the ingest queue. Body: { limit? }. OWNER/ADMIN only; audited.
Which auth methods are enabled (public, unauthenticated) — password/social/magic-link/email-OTP flags the console reads to render the sign-in surfaces the server accepts.
GET
/metrics
In-process API metrics (request counts, status classes, per-route latency percentiles, in-flight). Token-gated: returns 404 unless API_METRICS_TOKEN is set, then requires Authorization: Bearer <token>.
Paginated list { data, total, scores } (per-trace score map); paging: page, pageSize (or legacy limit); filters: userId, sessionId, environment, search (matches trace name OR observation input/output content), tag, promptId, scoreName, level, days, plus filter — a JSON-encoded structured filter set (see below).
GET
/v1/traces/facets
Distinct filter facet values + counts (environment, name, tags, scores, levels) over the range; params: days, limit, plus active filters (environment, search, userId, tag, scoreName, level) for facet-excluding counts.
GET
/v1/traces/histogram
Trace volume { interval, buckets } bucketed by hour (ranges ≤ 2 days) or day; honors the trace-list filters (environment, search, userId, tag, scoreName, level, days).
POST
/v1/traces/batch
Bulk action on selected traces: delete, add-to-dataset, or review.
GET
/v1/traces/{id}
Assembled trace: observations + scores.
GET
/v1/traces/{id}/similar
Traces semantically similar to this one (cosine over stored embeddings), most-similar first. Params: limit (≤ 50), days. Returns { data } of trace summaries + similarity.
POST
/v1/traces/{id}/replay
Re-run a stored trace’s input through the LLM gateway and record the result as a new trace. Body: { provider?, model? }. Audited.
POST
/v1/traces/{id}/annotate
Add a manual ANNOTATION score to a trace. Body: { name, dataType, value?, stringValue?, comment? }. Audited.
Paginated spans { data, total } — observations as first-class rows, filtered on the observation itself (the span explorer). Paging: page, pageSize (or legacy limit); filters: search (span name OR inline input/output), traceId, type, level, model, environment, days, plus filter (structured filter set over observation columns).
GET
/v1/observations/facets
Distinct facet values + counts (names, types, levels, models, environments) for the span explorer; same filters as above, counts facet-excluding.
GET
/v1/sessions
Paginated sessions { data, total } (traces grouped by sessionId); paging: page, pageSize (or legacy limit); scoped by days; search filters by sessionId substring.
GET
/v1/sessions/{id}/messages
The session’s traces as a conversation (Memory Explorer): { session_id, messages }, one turn per trace (oldest-first) with input/output and token/cost roll-ups.
GET
/v1/live/traces
Live tail (SSE): streams a trace event per trace as it’s ingested, plus ping heartbeats. Best-effort read-side (Redis pub/sub); EventSource clients pass ?project= since they can’t set the switcher header.
GET
/v1/users
Paginated end users { data, total } (traces grouped by userId); paging: page, pageSize (or legacy limit); scoped by days; search filters by userId substring.
GET
/v1/metrics
Cost/token/latency rollups by day and model (days query).
GET
/v1/metrics/tools
Per-tool analytics — call volume, error rate, and p50/p95/avg latency by tool name (named SPAN observations) over days. The top agent-debugging view.
GET
/v1/metrics/cost-breakdown
Top spenders: cost rolled up by end user, session, or prompt, ranked by spend. Query: by (user|session|prompt, default user), days, limit.
GET
/v1/usage
Volume-based usage metering — bytes / events / traces ingested per UTC day, measured on the raw batch before sampling (the GB-ingested billing signal). Returns { total_bytes, total_events, total_traces, byDay }. Query: days (1–365, default 30).
GET
/v1/usage/sdks
Which SDK builds this project has ingested from over the window (name, version, events, batches, first/last seen), busiest first. Populated from the optional sdk field on the ingest body — senders that don’t identify themselves are absent, which means unknown, not zero. Query: days (1–365, default 30).
POST
/v1/metrics/query
Run a dashboard/widget analytics query (view × metrics × dimensions × time × filters) from a JSON body; returns result rows. Read-only.
Structured trace filters (filter)./v1/traces, /v1/traces/facets, /v1/traces/histogram, and
/v1/exports/traces also accept filter — a URL-encoded JSON array of
{ column, type, operator, value } predicates, ANDed together, alongside the quick filters above.
Columns: name, environment, type, level, tags, userId, sessionId, version, release,
timestamp, tokens, cost, latencyMs, metadata, scores, scoreCategories. The last three
are key/value columns and carry a key: for metadata the key is a JSON key; for scores
(numeric value) and scoreCategories (categorical stringValue) the key is the score name.
Score predicates resolve level-agnostically — a trace matches when the score is attached to the
trace itself or to any of its observations.
Create a new version; labels point channels at it.
GET
/v1/prompts/{name}/detail
All versions + channels (incl. A/B split state per channel).
GET
/v1/prompts/{name}/costs
Spend attributed to each version (observations grouped by prompt_version), ranked by cost. Param: days.
GET
/v1/prompts/{name}/arm-scores
Per-A/B-arm score means (scores grouped by the prompt version that produced them). Param: days.
POST
/v1/prompts/{name}/experiment
Start a weighted A/B split on a channel. Body: { channel, splitVersion, splitWeight } (1–99%). Audited.
POST
/v1/prompts/{name}/experiment/stop
Stop the experiment on a channel; { channel, promote? } (promote makes the challenger live). Audited.
POST
/v1/prompts/{name}/compile
Resolve a prompt and fill it: composition expanded, {{variables}} substituted, and placeholder slots replaced with the caller’s message lists. Body: { channel?, bucketKey?, variables?, placeholders? }. Writes nothing, so it is not read-only gated. 422 when references can’t be resolved.
GET
/v1/prompts/{name}?channel=&bucketKey=
Resolve a deployed prompt (SDK path). bucketKey (session/user id) sticks a caller to one A/B arm. Prompt references (`@@@memoturnPrompt:name=X
Compare a dataset’s runs side by side (per-item output + scores). Optional version scopes to runs of one dataset version.
GET / PUT
/v1/datasets/{name}/schema
The dataset’s item contract — {input?, expectedOutput?, metadata?} object schemas (a subset of JSON Schema: type, required, enum). PUT rejects a schema it can’t honor (unknown type, empty enum, a required field not declared in properties) with the specific problems. Existing items are not re-validated. Audited.
GET
/v1/datasets/{name}/schema/check
How many existing items would fail the current schema, with the first 50 reasons — so tightening a contract is an informed decision rather than a surprise.
POST
/v1/datasets/{name}/items/csv
Import items from CSV text with a column→field mapping: { csv, mapping: { input: string | string[], expectedOutput?, metadata? } }. Quoted commas, embedded newlines, escaped quotes and a BOM are handled. A mapping naming a column that isn’t in the file fails the whole import (400); per-row problems come back in errors alongside the rows that did import. Audited.
POST
/v1/datasets/{name}/items
Append items.
POST
/v1/datasets/{name}/runs
Record an experiment run (link items → traces). Optional version pins the run to a dataset version (defaults to current).
POST
/v1/datasets/{name}/runs/{runId}/gate
CI quality gate: aggregate a run’s scores and check them against thresholds ({ scoreName: { min?, max?, maxRegression? } }; optional baselineRun for regression). Returns { passed, failures[], scores[] } for a CI exit code. Read-only.
GET
/v1/datasets/{name}/versions
List a dataset’s immutable version snapshots.
POST
/v1/datasets/{name}/versions
Cut a new version (freeze the current items). Body: { label?, description? }. Audited.
GET
/v1/datasets/{name}/versions/{version}
A version’s frozen items.
GET
/v1/datasets/{name}/export
JSONL download. format=items (backup dump), format=oai-chat (OpenAI fine-tuning chat lines), or format=anthropic-messages (Anthropic fine-tuning; the system prompt is hoisted to a top-level system field). Items without expectedOutput are skipped in both fine-tuning formats; count in X-Memoturn-Skipped. Optional version=N exports a frozen version. Offloaded payloads are rehydrated.
One-shot completion. trace:true (default) records it as a trace. Write-gated (spends the project’s provider key — VIEWERs get 403); maxTokens is capped at PLAYGROUND_MAX_TOKENS (default 32768).
POST
/v1/assistant/chat
In-app assistant: a bounded agentic loop over the project’s read-only MCP tools. Body: provider, model, messages[], optional context (organization/project/page/rangeDays); returns {content, steps[]}. Never mutates project data, but write-gated because each turn spends the provider key (VIEWERs get 403).
POST
/v1/assistant/stream
Streaming assistant (SSE): same loop and body as /chat, but tool steps are emitted as they execute and answer text arrives incrementally (data: {"step":...} | {"delta":...} then [DONE]). Write-gated like /chat.
POST
/v1/playground/stream
Streaming completion (SSE: data: {"delta":...} then [DONE]). Same body, validation, and write gate as /chat.
List / create (supports online, samplingRate, filterName, scope, variableMapping). kind: "CODE" + expression creates a deterministic check instead of an LLM judge; an expression that doesn’t compile is a 400. scope is trace (default), observation (score individual spans; filterName then matches the SPAN name), or thread. variableMapping binds judge-prompt variables to sources — [{ variable, source, observationName?, jsonPath? }], where source is one of `trace.input
The prebuilt evaluator library (RAG/quality judge templates).
GET
/v1/evaluators/presets
The prebuilt code-evaluator checks (regex, JSON shape, length, exact match).
POST
/v1/evaluators/test-expression
Dry-run a code-evaluator expression against a sample item. Persists nothing, so it is not read-only gated. Body: { expression, input?, output?, expectedOutput?, metadata? }.
POST
/v1/evaluators/from-template
Instantiate a template into a project evaluator. Body: { key, name?, provider?, model?, ... }. Audited.
GET
/v1/evaluators/{name}/versions
Immutable judge-config version history (newest first). A version bumps when the prompt/model/provider changes, so online score drift is attributable to a config change.
GET
/v1/evaluators/backfills
Recent evaluator backfill runs with progress counters (name query scopes to one evaluator).
GET
/v1/evaluators/backfills/preview
How many traces a backfill would score: { matches }. Query: days, filter (the same JSON-encoded structured trace filter set the traces list uses).
POST
/v1/evaluators/{name}/backfill
Queue a run of this evaluator over ALREADY-ingested traces. Body: { days?, filters? }. Capped at 5000 traces per run (the cap is recorded on the run, never silent). Thread-scope evaluators are a 400. Audited.
POST
/v1/evaluators/{name}/run
Run over a trace’s input/output → writes an EVAL score.
Server-executed experiments run a prompt/model across a dataset and auto-score each item (a BullMQ job on the worker); results surface through the dataset comparison grid.
Cross-trace RAG diagnostics: similarity histogram, weakest retrievals (worst top-score first), and per-document hit stats. Query: days (1–365), limit (1–200).
GET
/v1/embeddings/projection
3D UMAP projection of observation embeddings (seeded/deterministic; PCA fallback for small sets or EMBEDDING_PROJECTION_METHOD=pca), with clusters + optional colorBy score. Computed by the daily worker cron. Params: runId?, colorBy?, limit?.
POST
/v1/embeddings/projection/run
Recompute the projection on demand (instead of waiting for the daily cron). Audited.
Assign an item to a user (empty assigneeId unassigns; defaults to self).
POST
/v1/review-queues/{name}/items/{itemId}/score
Submit a human ANNOTATION score.
POST
/v1/review-queues/{name}/items/{itemId}/skip
Skip an item without scoring it (marks it SKIPPED).
| GET / PUT / DELETE | /v1/datasets/{name}/runner | The dataset’s remote runner — a URL we POST a signed trigger to so an external eval harness can execute the run. PUT registers or replaces it and returns the HMAC signing secretonce (re-registering rotates it); the secret is never read back. Audited. |
| POST | /v1/datasets/{name}/remote-runs | Ask the registered runner to execute this dataset. Body: { runName, version? }. Returns 202 with { accepted, status, error, itemCount } — whether the runner accepted the trigger, not whether the run finished. The run row is created before the trigger fires, so a run that never reports back is visibly empty rather than absent. Audited. |
| POST | /v1/dataset-run-items | Attach a trace to a dataset item within a run — how an external harness reports results. Body: { datasetName, runName, datasetItemId, traceId }. Creates the run on demand and upserts the link, so a retried report overwrites rather than duplicating. |
List (with computed data series; ?dashboardId= scopes to one dashboard, omitted = the Default) / create. Widget config: metric (cost|tokens|generations|latency_p95|error_rate|score), breakdown (by_day|by_model|by_user|by_session), days, filters ({environment?, model?, tag?}), dashboardId?.
GET / POST
/v1/widgets/query
List / create query-engine widgets (built in Explore). Create body: title, query (an analytics query), chartType (line|bar|horizontal_bar|big_number|pie|table), dashboardId?, gridW?/gridH?.
PATCH
/v1/widgets/{id}/grid
Persist a widget’s 12-col grid placement (gridX/gridY/gridW/gridH, all optional).
DELETE
/v1/widgets/{id}
Delete a dashboard widget (legacy or query-engine).
GET / POST
/v1/dashboards
List the project’s named dashboards / create one ({ name }). The “Default” dashboard is implicit (widgets with a null dashboardId).
DELETE
/v1/dashboards/{id}
Delete a dashboard (its widgets are removed too).
GET / POST
/v1/score-configs
List / create-update a score config.
DELETE
/v1/score-configs/{id}
Delete a score config.
GET
/v1/scores/names
Score names observed in the window with their data type, source, and count — the analytics picker. Query: days (default 30).
GET
/v1/scores/analytics
One score’s distribution: summary statistics, a 10-bucket histogram (numeric) or label counts (categorical), and a daily timeline. Query: name (required), days.
GET
/v1/scores/agreement
Agreement between two score sources over the traces carrying both. Numeric pairs return correlation + MAE/RMSE; label pairs return agreement rate, Cohen’s Kappa, and per-label F1. Always returns a confusion matrix (numeric values are bucketed). Query: a, b (required), days. Scans at most 20 000 pairs; past that sampled is true.
PATCH
/v1/scores/{id}
Correct a score’s value/stringValue/comment (inserts a replacement row; audited).
DELETE
/v1/scores/{id}
Hard-delete a score (Doris DELETE, project-scoped).
DELETE
/v1/traces/{id}
Delete one trace completely: every telemetry table, the Postgres state mirror, and the offloaded payload objects it references. Audited. (Raw event batches are multi-trace and governed by retention — follow an erasure with a retention cutoff.)
DELETE
/v1/users/{userId}/data
Right to erasure for an END USER of the traced app: every trace recorded under that userId, with the same completeness as DELETE /v1/traces/{id}. Admin-only; audited; returns { traces }.
GET / POST
/v1/saved-views
List / save a table view (named set of filters).
PATCH / DELETE
/v1/saved-views/{id}
Update a saved view’s name/state / delete it.
GET / POST
/v1/comments
List comments on an object (trace/observation/session/prompt) / add one.
GET
/v1/comments/mentions
Comments in this project that @mention the signed-in user.
DELETE
/v1/comments/{id}
Delete a comment.
GET / PUT
/v1/notification-preferences
Read / update the signed-in user’s notification preferences (per-user, not per-project).
Webhook and automation target URLs are SSRF-validated on write: private IP ranges, loopback addresses, and cloud metadata endpoints are rejected with 400 (override with ALLOW_PRIVATE_WEBHOOK_TARGETS=1 for dev/LAN). The same check runs again at dispatch time to guard against DNS rebinding. Webhook deliveries carry X-Memoturn-Signature: sha256=<hmac> (HMAC-SHA256 of timestamp.body using the webhook secret) and X-Memoturn-Timestamp; the secret is returned once on creation and never again.
Method
Path
Description
GET / POST
/v1/webhooks
List (includes lastStatus/lastError/lastAttemptAt/failureCount delivery tracking) / create a webhook (POSTs on an event; score.created supports a low-score threshold). secret returned once on 201.
List / create a trigger→action automation. Triggers: score.created, trace.created, eval.completed, prompt.created, prompt.updated, prompt.label.moved. Actions: webhook, slack, pagerduty, email, github (a repository_dispatch — target is owner/repo and secret is a PAT with repo scope, stored encrypted and never returned). Optional threshold (score triggers) and filter (substring on the entity name). Audited.
DELETE
/v1/automations/{id}
Delete an automation.
GET / POST
/v1/alerts
List / create a stateful alert rule. A worker cron evaluates metric (error_rate/latency_p95/cost_per_day/ingest_volume/dlq_depth) over a trailing window (minutes) against threshold per comparator (gt/gte/lt/lte), notifying channels ([{ type, target }]; type = slack/webhook (URL, SSRF-validated), pagerduty (Events-API routing key; auto-resolves), or email (address; needs an email transport configured)) once on firing and once on resolve.
Get / set / remove the project’s monthly cost budget (monthlyUsd + thresholds steps, default 50/80/100%). Notifies channels as month-to-date spend crosses each step. Soft only — no hard caps.
Multimodal attachments (images, audio, files). Inline base64 data URIs in trace/observation input/output are offloaded to blob storage at ingest time and replaced with a memoturn-media://<key> reference, so large payloads never bloat Doris; the console fetches them back through the GET route. Both routes require auth and are project-scoped.
Method
Path
Description
POST
/v1/media
Store a base64 data URI ({ "dataUri": "data:<mime>;base64,…" }). Returns 201 with { key, mimeType, url }.
GET
/v1/media/{key}
Fetch raw bytes back with the stored content-type (immutable, long-cache). 404 if the key isn’t in the caller’s project.
GET
/v1/payloads/{key}
Fetch a large input/output payload that was offloaded to blob at ingest (> 256 KB). The trace stores a { "_truncated": true, "ref": "memoturn-blob://<key>", "preview": … } marker; this returns the full serialized value. Project-scoped — 404 if the key isn’t payloads/<projectId>/….
Create a project in the caller’s active organization. OWNER/ADMIN only; audited.
PATCH
/v1/projects/{id}
Rename a project. {id} must be the active project. OWNER/ADMIN only; audited.
DELETE
/v1/projects/{id}
Delete a project and its data (relational rows cascade; telemetry purged best-effort). The last project in an organization can’t be deleted. {id} must be the active project. OWNER/ADMIN only; audited on the organization.
GET
/v1/projects/{id}/members
Project-level RBAC: the project’s org members, each with any per-project role override. {id} must be the active project.
PUT
/v1/projects/{id}/members/{userId}
Assign/update a user’s role on this project (overrides their org role). OWNER/ADMIN only; audited.
DELETE
/v1/projects/{id}/members/{userId}
Remove a user’s per-project role override (revert to org role). OWNER/ADMIN only; audited.
GET
/v1/audit-logs
Recent audit entries.
GET / POST
/v1/retention
Get / set retention (days; 0 = keep forever).
POST
/v1/retention/apply
Apply retention now.
GET / POST
/v1/sampling
Get / set ingest sampling. Head: rate (0–100 = percent of traces kept in the query store; 100 = all, stable per-trace). Tail keep-rules (kept regardless of the head dice below 100): keepOnError, keepLatencyMs, keepMinCostUsd (null = off). Dropped traces stay in blob for replay. Audited.
GET / POST
/v1/model-prices
List / create-update custom model price overrides (matched by name pattern, override built-ins).
DELETE
/v1/model-prices/{id}
Delete a model price override.
GET
/v1/exports/traces
Download traces as NDJSON (application/x-ndjson, default), CSV (?format=csv), or Parquet (?format=parquet, flat one-row-per-trace for BI); honors the trace-list filters: limit, environment, search, userId, tag, scoreName, level, days, filter.
GET
/v1/exports/traces/{traceId}
Download ONE trace as a self-contained JSON document — header, observations (with retrieved documents), and scores, wrapped in a versioned memoturn_export envelope. 404 if the trace is not in this project.
GET / POST
/v1/scheduled-exports
Get / configure the recurring daily NDJSON export of traces to blob storage.
POST
/v1/scheduled-exports/run
Run the export now and write the NDJSON to blob storage.
GET / POST
/v1/masking
Get / configure the PII redaction policy (built-in + custom patterns) applied to trace input/output at ingest.
POST
/v1/guardrails/check
Runtime guardrails: scan { text } for PII / prompt injection / SQL injection / blocked terms / required-match / JSON shape, plus opt-in LLM guards — evaluator (judge) guards and built-in restricted-topic + toxicity model guards; returns { verdict: allow|redact|block, findings, redactedText? }. Read-only compute (the LLM guard calls write nothing and fail open on timeout/error). SDK: checkGuardrails / check_guardrails.
GET / POST
/v1/guardrails
Get / configure the project’s guardrail policy (PII action, prompt-injection/SQL-injection detection, blocked terms, requireMatch, requireValidJson/requiredJsonKeys, evaluator-backed evaluatorGuards, and model guards restrictedTopics/toxicity+toxicityThreshold judged by judgeProvider/judgeModel).
GET / POST
/v1/analytics-sink
Get / configure the event sink — forwarding trace/score events to a product-analytics/CDP endpoint (PostHog-compatible capture API). POST host URL is SSRF-validated (400 on private/loopback targets).
GET / POST
/v1/api-keys
List project API keys (public key + hint) / mint a new pair (secret returned once). Admin-only (OWNER/ADMIN). Scopes: read, write, ingest (the default set) and admin. A key acts as MEMBER unless it carries admin, in which case it acts as OWNER on admin-only routes — never granted by default.
DELETE
/v1/api-keys/{id}
Revoke an API key. Admin-only.
GET
/v1/account/mcp-connections
List the OAuth clients (remote MCP IDEs/agents) the signed-in user has authorized. Empty for API-key callers (no user).
DELETE
/v1/account/mcp-connections/{consentId}
Disconnect an OAuth client: deletes the consent and revokes its refresh tokens (access ends when the last ≤1 h JWT expires).
A remote Model Context Protocol endpoint exposing the project’s prompts, datasets, and review queues as tools for agent IDEs — the same tool registry (packages/server/src/mcp-tools.ts) the local stdio server serves (see MCP), over Streamable HTTP. Each project is its own MCP resource, so clients connect per-project. RBAC is per-tool (not per-method — every call is a POST): a tool’s mutating flag maps to a read/write permission, and write tools are audited.
The endpoint speaks the stateless 2026-07-28 MCP protocol (no initialize handshake; every request is self-contained, so any replica can serve any call) and remains compatible with clients on earlier MCP revisions, which are served through the same per-request path.
Two auth paths resolve to the same per-project authorization:
API-key Basic (pk-mt-…:sk-mt-…, self-host / headless) — the key must belong to the {projectId} in the URL; the tool’s permission is checked against the key’s read/write scope.
OAuth 2.1 bearer (Memoturn cloud, IDE click-through) — the Better Auth @better-auth/oauth-provider plugin issues a JWT access token (authorization-code flow with mandatory PKCE S256, rotating refresh tokens, dynamic client registration per RFC 7591); the API verifies it statelessly (signature via /auth/jwks, issuer, audience) and resolves its sub to a user, who is then authorized against {projectId} (org membership → role). Any member may run read tools; only non-VIEWER roles may run write tools. Clients discover the flow via the .well-known documents below; an unauthenticated request returns 401 with WWW-Authenticate: Bearer resource_metadata="…".
Method
Path
Description
GET / POST / DELETE
/v1/mcp/{projectId}
Streamable-HTTP MCP endpoint scoped to {projectId}. 401 (advertising Bearer + Basic) when auth is missing/invalid or the caller isn’t authorized for the project.
OAuth protected-resource metadata (RFC 9728) — advertises the API origin as the canonical resource for all per-project MCP URLs.
Behind Caddy (single-VM prod), the three .well-known paths are routed to the API (they’re served at the domain root, not the console) — see infra/Caddyfile. The OAuth authorize flow bounces unauthenticated users to the console sign-in page (MCP_LOGIN_PAGE, default <first AUTH_TRUSTED_ORIGINS>/login) and scope approval to the console consent page (MCP_CONSENT_PAGE, default <first AUTH_TRUSTED_ORIGINS>/consent).