Deploy the Skills MCP server over HTTP¶
nuvel-mcp speaks stdio by default, which is right for an MCP client on your own
machine. To serve a skills hub, or to collect
skill run traces from agents running
elsewhere, serve it over HTTP. The transport is pure standard library: there is
nothing extra to install, and nuvel-mcp has no dependencies and no extras.
It speaks Streamable HTTP on /mcp, legacy HTTP+SSE on /sse and /messages,
and answers GET /healthz. It is its own OAuth 2.1 authorization server (PKCE,
dynamic client registration, refresh-token rotation, GitHub as the login
provider), so every MCP request needs a bearer access token, or an
operator-issued URL key for clients that can only be
given a URL.
Run it locally¶
This binds 127.0.0.1:8080 (change with --host / --port) and needs a login
provider. For local tests only, NUVEL_OAUTH_DEV_LOGIN=1 enables a built-in dev
login that is refused on any non-loopback bind. To skip OAuth entirely on your
own machine:
--no-auth is loopback-only (refused on any other --host) and prints a
warning: every method and tool, writes included, is open to anything that can
reach the port. With --no-auth there is no caller identity, so run traces are
stored without a tenant, the same as stdio.
--skills-dir is optional. Without it the server runs feedback-only: just the
trace and health tools.
Deploy it for other people¶
Binding a non-loopback address takes a deliberate --allow-remote, plus a public
URL and either a GitHub OAuth app (below) or NUVEL_OAUTH_LOGIN=none, which
turns off the interactive login and leaves
URL keys and client_credentials:
export NUVEL_MCP_PUBLIC_URL="https://mcp.example.com"
export NUVEL_OAUTH_GITHUB_CLIENT_ID="$GITHUB_OAUTH_CLIENT_ID"
export NUVEL_OAUTH_GITHUB_CLIENT_SECRET="$GITHUB_OAUTH_CLIENT_SECRET"
export NUVEL_OAUTH_ALLOWED_USERS="alice,bob"
nuvel-mcp serve --transport http --allow-remote --host 0.0.0.0 --skills-dir ./skills
| Setting | What it does |
|---|---|
--allow-remote |
Required to bind a non-loopback --host. |
NUVEL_MCP_PUBLIC_URL |
The origin clients reach the server at (for example https://mcp.example.com). It is the OAuth issuer and the audience of every token. Required on a non-loopback bind. |
NUVEL_OAUTH_GITHUB_CLIENT_ID / NUVEL_OAUTH_GITHUB_CLIENT_SECRET |
A GitHub OAuth app whose callback URL is <NUVEL_MCP_PUBLIC_URL>/oauth/callback. This is how people log in when they connect an interactive client. |
NUVEL_OAUTH_ALLOWED_USERS |
Comma-separated GitHub users allowed to log in, each either id:<number> (GitHub's numeric user id, from https://api.github.com/users/<login>) or a login. Prefer ids: a login can be renamed and later registered by someone else. A login entry still works (existing lists need no change), but the first account it admits is pinned by id until the server restarts, and any other account under that login is refused. Unset, any GitHub user may, and the server warns at startup. Set it. |
NUVEL_OAUTH_LOGIN |
none turns the interactive login off: /authorize, /register and the login callbacks answer 404, and clients connect with a URL key or client_credentials. GitHub credentials and NUVEL_OAUTH_DEV_LOGIN are then ignored, with a warning. Unset, the login is GitHub when its credentials are set, else the dev login, else the server refuses to start. Any other value stops the server with exit code 2. |
NUVEL_OAUTH_REDIRECT_ALLOWLIST |
Extra redirect URIs accepted at client registration, beyond https:// and loopback ones. |
NUVEL_MCP_DATABASE_URL |
A Postgres connection string. Set, OAuth, run traces, recipes and usage all live in that database (below); this is the production setup. |
NUVEL_MCP_OAUTH_STORE |
The token and client file when no database is configured (default ~/.nuvel/oauth.json, mode 0600). Tokens and secrets are kept only as hashes. Put it on a persistent volume. Ignored when NUVEL_MCP_DATABASE_URL is set. |
NUVEL_MCP_ALLOWED_ORIGINS, NUVEL_MCP_MAX_BODY_BYTES, NUVEL_MCP_MAX_SESSIONS, NUVEL_MCP_SESSION_TTL |
Optional tuning of the origin allow-list and the request and session caps. |
NUVEL_MCP_REQUEST_DEADLINE |
Wall-clock seconds a client gets to send one whole request (request line, headers, body), however slowly it trickles. Default 30. |
NUVEL_MCP_READ_TIMEOUT |
Seconds a single socket read or write may stall, and how long an idle keep-alive connection is kept. Default 30. The idle keep-alive limit is the smaller of this and NUVEL_MCP_REQUEST_DEADLINE. |
NUVEL_MCP_MAX_CONNECTIONS |
Connections (handler threads, open SSE streams included) served at once. Default 128. Slots are taken before authentication, and long-lived GET streams hold them, so a public deployment should sit behind a buffering reverse proxy with per-client connection limits. |
NUVEL_MCP_MAX_SESSIONS_PER_PRINCIPAL |
MCP sessions one caller may hold at once. A signed-in user counts across every client they use (registering more clients buys no more); a client_credentials client or URL key counts as its client. Default 8. A new session past it evicts that caller's least recently used session (as DELETE would, closing its stream), so a connector that re-initializes on every reconnect and never deletes keeps working. The global NUVEL_MCP_MAX_SESSIONS still applies. Not applied with --no-auth. |
NUVEL_MCP_MAX_CONNECTIONS_PER_IP |
Connections one caller IP may hold at once. Default 16. Over it is 429, sent from the accept loop without a handler thread. Behind a trusted proxy (NUVEL_MCP_TRUST_FORWARDED_FOR=1) it counts in-flight requests and open streams per X-Forwarded-For address instead, since every TCP peer is then the proxy. That only covers requests whose headers have arrived: behind a proxy, connection-level flood protection (slow or half-open connections, connection rate per client) belongs to the proxy. |
NUVEL_MCP_HEADER_DEADLINE |
Behind a trusted proxy only: on a new connection, the seconds from accept until its first request's headers are in, idle time included, so a connection that sends nothing is closed after it. On a keep-alive connection that has already served a request, idle time is not counted (it lasts NUVEL_MCP_READ_TIMEOUT, because the proxy pools upstream connections and closing them early causes intermittent 502s); the window starts at the next request's first byte. Default 5, must be greater than 0. Until its headers arrive a connection has no caller IP to count against, so this bounds how long it can hold a global connection slot; the body then gets the rest of NUVEL_MCP_REQUEST_DEADLINE. |
NUVEL_MCP_TRUST_FORWARDED_FOR |
1 when the server sits behind a reverse proxy that appends the caller's address to X-Forwarded-For (Railway, nginx, Caddy). The right-most entry is then the caller's IP for the per-IP cap and the /register rate limit. Leave it unset when clients reach the server directly, or they could pick their own IP. |
NUVEL_MCP_MAX_STREAMS_PER_PRINCIPAL |
Open SSE streams (GET /mcp and GET /sse together) one caller may hold at once. Default 4. A new stream past it closes that caller's oldest stream (a half-open stream left behind a proxy must not lock the client out of its reconnect); the session behind it stays usable. A displaced stream's connection is shut down and it counts until its handler has exited; with as many displaced streams still exiting as the cap, a new stream gets 429 with Retry-After: 1. A stream whose client stops reading is dropped after NUVEL_MCP_READ_TIMEOUT. Not applied with --no-auth. |
The two timeouts must be greater than 0 and NUVEL_MCP_MAX_CONNECTIONS and the per-caller caps at least 1 (0 does not disable them); any other value stops the server at startup with exit code 2.
Keep the GitHub secret and every other credential in your secret manager and inject it as an environment variable; never put one on a command line or in a file you commit.
Clients for agents and services¶
Interactive clients register themselves dynamically and log in through GitHub. For an agent or service that has no browser, issue a client from the server's host. It uses the same store, so the running server sees it at once:
nuvel-mcp client create --name acme-agent # prints client_id and client_secret once
nuvel-mcp client list # no secrets
nuvel-mcp client delete "$CLIENT_ID" # also revokes its tokens
nuvel-mcp token revoke "$TOKEN_OR_CLIENT_ID" # one token, or all of a client's
Add --person and --company to client create to record who uses the client
in the same step (with a database; clients list and report show them). Add
--redirect-uri for a client that also uses the
authorization-code flow. The client_secret is shown only at creation. A service
exchanges its client_id and client_secret for an access token with the
client_credentials grant at POST <NUVEL_MCP_PUBLIC_URL>/token (HTTP Basic
authentication), then sends Authorization: Bearer <access_token> to /mcp.
Tokens carry the scopes skills:read (every MCP method and the read tools) and
skills:write (the tools that write). Point an MCP client at the server, for
example Claude Code, which runs the OAuth flow itself:
Beta testers: URL keys¶
Claude's custom connectors have a No sign-in option where the only thing a person enters is the server URL. A URL key puts the credential in that URL, one key per tester:
It prints the client's client_id and a connector_url of the form
$NUVEL_MCP_PUBLIC_URL/mcp/k/<key>, once; the store keeps only the key's hash.
Send the tester that URL; they add it as a custom connector with no sign-in.
Without NUVEL_MCP_PUBLIC_URL set, the command prints just the
/mcp/k/<key> path and tells you to prefix the public origin.
- Tenant. Each key belongs to its own client, so the tester's tenant is that
client_id, exactly as for any other client (see Tenant is the OAuthclient_id). The client has no secret and no grant types: it cannot get tokens at/token. - Scopes. A key gets
skills:readandskills:write, the same as aclient_credentialstoken. What a tester can call is still the default HTTP tool set below. - Revoking.
nuvel-mcp token revoke "$CLIENT_ID"revokes the client's key (and any tokens);nuvel-mcp token revoke "$CONNECTOR_URL"revokes one key by its URL;nuvel-mcp client delete "$CLIENT_ID"removes the client and its key. The running server refuses the key on the next request and closes an open stream at its next ping. - Expiry.
--expires-in DAYS(fractions allowed) makes a key stop working after that many days:nuvel-mcp client create --name alice --url-key --expires-in 30. The output then includesexpires_at(UTC), and an expired key gets the same401as a revoked one; an open stream closes at its next ping. Keys created without it, including every key issued before this option existed, last until revoked. - Rotating. A key cannot be changed in place, because it is the client's
only credential. To rotate: create a new key for the tester
(
client create --name alice --url-key, with--expires-inif you want), send them the newconnector_url, and once they have switched, delete the old client (nuvel-mcp client delete "$OLD_CLIENT_ID"). The new key is a new client, so it is a new tenant: run traces recorded under the old key stay with the oldclient_idand are not visible through the new one. - Transport.
/mcp/k/<key>is Streamable HTTP only (POST,GETstream andDELETE, like/mcp). The legacy/ssetransport has no key form. A request on a key path that also sends anAuthorizationheader is refused. - Where keys can leak. The server redacts keys from its own logs, but the key is part of the URL: a reverse proxy or CDN in front of it must not log full request paths, and a tester should treat the URL like a password. The key travels in the path rather than a query string because MCP clients keep the path of a configured endpoint on every request but may drop or rewrite a query.
Keys work in every gated mode. With NUVEL_OAUTH_LOGIN=none they (and
client_credentials for services) are the only way in.
Behind a reverse proxy¶
nuvel-mcp does not terminate TLS. Run it behind something that does, and tell
it its public address with NUVEL_MCP_PUBLIC_URL; without that setting it would
derive the OAuth origin from the request's Host header.
export NUVEL_MCP_PUBLIC_URL="https://mcp.example.com"
nuvel-mcp serve --transport http --skills-dir ./skills # still on 127.0.0.1:8080
Caddy:
nginx (SSE needs buffering off):
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
Because the proxy and the server share a machine, the server can stay on
loopback and you do not need --allow-remote. On a container platform (Railway,
Fly, Cloud Run, Kubernetes) bind --allow-remote --host 0.0.0.0 --port $PORT
instead, set NUVEL_MCP_PUBLIC_URL to the platform's HTTPS URL, and expose the
service only through it.
A browser request whose Origin is not loopback, your public origin or one in
NUVEL_MCP_ALLOWED_ORIGINS is refused with 403, with or without --no-auth.
That stops a web page the user visits from reaching a loopback server through DNS
rebinding.
Store everything in Postgres (Neon)¶
Without a database the server keeps its state in files: the OAuth store
(NUVEL_MCP_OAUTH_STORE) and the feedback store (NUVEL_FEEDBACK_DIR: run
traces, recipe signals and recipes). That needs a persistent disk, ties you to a
single replica, and leaves usage unrecorded. Set NUVEL_MCP_DATABASE_URL and all
of it moves to Postgres, plus a usage log of every request:
export NUVEL_MCP_DATABASE_URL="$NEON_DATABASE_URL" # Neon's *pooled* string
nuvel-mcp serve --transport http --allow-remote --host 0.0.0.0
nuvel-mcp itself has no dependencies; the Postgres store needs
pip install "psycopg[binary,pool]" (the Dockerfile installs it). The server
creates its schema and applies migrations at startup, so there is nothing to run
first. On Neon, use the pooled connection string (host with -pooler): the
server works behind PgBouncer's transaction mode, and several replicas can share
one database.
| Setting | What it does |
|---|---|
NUVEL_MCP_DATABASE_URL |
The connection string. Set, the database stores OAuth clients, codes, tokens and URL keys; run traces; recipe signals and recipes; and usage. It is never logged: errors print it with the password masked. |
NUVEL_MCP_DATABASE_SCHEMA |
The schema the server owns. Default nuvel_mcp. |
NUVEL_MCP_DATABASE_POOL_MAX |
Connections per server process. Default 10. |
NUVEL_MCP_DATABASE_AUTO_MIGRATE |
0 stops migrations at startup; run nuvel-mcp db migrate from your release step instead. Default 1. |
NUVEL_MCP_USAGE |
0 turns the usage log off. Default 1. |
NUVEL_MCP_GEOIP_CSV |
Optional path to a country-level IP-range CSV (DB-IP "IP to Country Lite", plain or .gz). Set, each usage row gets the caller's two-letter country; the address itself is never stored. Unset (the default), country stays empty. See Usage insights. |
NUVEL_MCP_RECORD_COUNTRY |
0 turns the country lookup off even with NUVEL_MCP_GEOIP_CSV set. Default 1. |
NUVEL_MCP_TRUST_FORWARDED_FOR |
1 behind a reverse proxy you run (Railway's edge, Caddy, nginx): the caller's address is the right-most X-Forwarded-For entry instead of the proxy's. Leave it unset when clients reach the server directly, or they could forge it. Default off. |
--trace-store and --recipe-store default to auto: Postgres when a URL is
set, files otherwise. postgres and local force one; postgres without a URL
stops the server. The client and token commands use the database too, so run
them anywhere the URL is set: no shell on the server is needed.
GET /healthz reports "storage": "postgres" and answers 503 with
"database unavailable" when the database stops answering. If the database is
unreachable, OAuth fails closed: requests get an error, never a free pass.
Usage insights: what is collected¶
Usage is recorded only with a database; on files nothing is. Every MCP request
with an id becomes one row in usage_events. Rows are written by a background
thread off the request path, so a slow database delays the log, not the request.
Collected, per request:
| Column | What it is |
|---|---|
at, duration_ms |
When, and how long the server took. |
tenant |
The OAuth client_id of the caller (empty for stdio and --no-auth). |
transport |
http or stdio. |
method, tool |
The JSON-RPC method and, for tools/call, the tool. Only names the server knows; anything else is stored as unknown. |
outcome, error_code |
ok, tool_error or error, and the JSON-RPC error code. |
client_name, client_version, protocol_version |
The client app of the session, from its initialize request (clientInfo.name, clientInfo.version, protocolVersion), for example claude-ai or Cursor. These are the only caller-supplied text in the table: stripped, cut to 64 characters, and stored as unknown unless they are only letters, digits, spaces and ._:/@+()-. Empty when the client sent none. |
country |
Optional: the caller's two-letter ISO country, when NUVEL_MCP_GEOIP_CSV is set. |
Never collected: IP addresses (not even hashed: the address is looked up in memory and dropped), tool arguments or results, skill content, request bodies, headers, user agents, or GitHub logins.
Country, never IP. Download the free DB-IP IP to Country
Lite CSV (IPv4 and IPv6,
updated monthly; .csv.gz works as is), put it where the server can read it, and
point NUVEL_MCP_GEOIP_CSV at it. nuvel-mcp never downloads or ships it. The
data is licensed CC BY 4.0:
anywhere you show the countries (a dashboard, a report you share), credit it as
"IP Geolocation by DB-IP" with a link to https://db-ip.com. It is loaded into
memory at startup (tens of MB for the full table). Behind a proxy, also set
NUVEL_MCP_TRUST_FORWARDED_FOR=1, or every caller looks like the proxy. Traffic
from claude.ai and Claude Cowork connectors likely reaches the server from
Anthropic's infrastructure, so its country reflects where that runs, not where
the user is; desktop and CLI clients that connect directly show the user's
country.
Who is behind a client. client_profiles holds what you, the operator, know
about a client id: a person, a company and notes. Nothing a caller sends ends up
there.
nuvel-mcp clients label nvcl_abc --person "Ana García" --company "Acme SL" --notes "pilot"
nuvel-mcp clients label nvcl_abc --notes "" # an empty value clears a field
nuvel-mcp clients list # label, last seen, events in 30 days
The weekly report. nuvel-mcp report (or nuvel mcp report) prints, for
the last 7 days (--days N): active clients with their labels, calls per day,
top methods and tools, error rate, top error codes and p50/p95 latency per tool,
client apps and versions, countries, skill runs per skill with outcome mix and
correction rate, the tools nobody called, and recipe adoption. --json prints
the same as JSON. Text output strips control characters, so a client name
cannot rewrite your terminal.
Two views summarise usage in SQL too:
-- calls, errors and p50/p95 latency per day, client, client app and tool
SELECT * FROM nuvel_mcp.usage_daily ORDER BY day DESC, calls DESC;
-- skill runs per day by outcome
SELECT * FROM nuvel_mcp.skill_runs_daily ORDER BY day DESC;
Retention. The server never deletes usage rows; pick a retention period (90 days is plenty for the report) and prune on a schedule, for example from a cron job or Neon's SQL editor:
DELETE FROM nuvel_mcp.usage_events WHERE at < now() - interval '90 days';
-- forget a person: drop their label (usage keeps only the client id)
DELETE FROM nuvel_mcp.client_profiles WHERE client_id = 'nvcl_abc';
To erase a client entirely, also delete its rows from usage_events,
skill_runs and recipe_signals (WHERE tenant = '<client_id>') and revoke it
with nuvel-mcp client delete <client_id>.
Moving off the volume. On a server that has been running on files, copy them in once, then point the server at the database:
NUVEL_MCP_DATABASE_URL="$NEON_DATABASE_URL" nuvel-mcp db import \
--oauth-store /data/oauth.json --feedback-dir /data/feedback
Traces and recipes are deduped, so re-running the import inserts nothing new.
OAuth is a one-time move: once the database has held OAuth state it is the
authority, and later imports leave OAuth alone, so a client or key you revoked
in the database cannot come back from the old file (--force-oauth merges it
anyway). Issued URL keys and tokens keep working, because only their hashes move. nuvel-mcp db status shows applied
migrations and row counts; nuvel-mcp db migrate applies pending migrations.
The same commands exist as nuvel mcp db ….
Deploy on Railway¶
The nuvel-mcp repository ships a Dockerfile at its root for this. The image binds
0.0.0.0:$PORT with --allow-remote. With NUVEL_MCP_DATABASE_URL set, all
state is in Postgres; without it, it falls back to files under /data. The
server runs as the unprivileged system user nuvel (uid 10001), which owns
/data in the image; a volume mounted there must be writable by that uid. The Railway project itself (service, Dockerfile build, /data
volume) is defined in .railway/railway.ts, Railway's Infrastructure as Code;
its config-as-code railway.json is deprecated.
- Install the IaC SDK once with
npm install --prefix .railway, link the repository to your project withrailway link, then review and apply the definition withrailway config planandrailway config apply. To set it up by hand instead, point the service at theFolken2/nuvel-mcprepository (Root Directory/) with the Dockerfile builder. - Under Settings → Networking, generate a domain on port
8080. - Create a Neon project and copy its pooled connection string into the
NUVEL_MCP_DATABASE_URLvariable (step 5). That replaces the volume. Without a database, mount a volume at/datainstead: otherwise every redeploy loses issued tokens, registered clients, URL keys and run traces. Moving an existing volume deploy over: runnuvel-mcp db import(see above) fromrailway sshwith the URL set, then add the variable and redeploy; the volume can go oncenuvel-mcp db statusshows the rows. - Pick how people get in:
- URL keys only (beta testers connecting with no sign-in): no OAuth app
is needed. Set
NUVEL_OAUTH_LOGIN=none. - GitHub login (interactive clients that run the OAuth flow): create a
GitHub OAuth app whose callback URL is
https://<your-domain>/oauth/callback. - Set the service variables. Keep any secret in Railway's variables, never in
the start command. Every variable you set in Railway must also be listed with
preserve()in.railway/railway.ts(NUVEL_OAUTH_LOGINincluded): the definition is the whole truth, sorailway config applydeletes a variable it does not list:
| Variable | Value |
|---|---|
NUVEL_MCP_PUBLIC_URL |
https://<your-domain> |
NUVEL_MCP_DATABASE_URL |
Neon's pooled connection string (a secret) |
NUVEL_OAUTH_LOGIN |
none, for URL keys only |
NUVEL_MCP_TRUST_FORWARDED_FOR |
1: Railway's edge proxy appends the caller's address to X-Forwarded-For, so the per-IP cap and the /register limit see real callers instead of the proxy. The image does not set it, since a container exposed with no proxy in front must not trust the header. |
NUVEL_OAUTH_GITHUB_CLIENT_ID / NUVEL_OAUTH_GITHUB_CLIENT_SECRET |
For GitHub login: from the OAuth app |
NUVEL_OAUTH_ALLOWED_USERS |
For GitHub login: the GitHub users allowed to log in, as id:<number> (preferred) or logins |
NUVEL_MCP_SKILLS_DIR |
Optional: a skills hub inside the container, for example one cloned onto the volume. Unset, the server runs feedback-only. |
The server refuses to start without the public URL and without either
NUVEL_OAUTH_LOGIN=none or the GitHub credentials, so a misconfigured
deploy fails its health check rather than running open. A deploy that loses
its GitHub variables does not fall back to key mode on its own.
6. Issue clients with the database URL set, from your machine
(railway run nuvel-mcp client create …) or a shell on the service
(railway ssh); without a database, only the shell reaches the store on
/data. Either way the server sees new clients at once:
nuvel-mcp client create --name alice --url-key for a tester (send them the
printed connector_url), or nuvel-mcp client create --name acme-agent for
a headless agent. The server's own access log shows key paths as
/mcp/k/<redacted>, but Railway's HTTP logs record request paths, so anyone
who can read the project's logs can see testers' keys. Keep project access
to the people who could issue keys anyway.
What is exposed over HTTP, and why¶
OAuth decides who may call the server; the defaults below decide what a caller can
do. They apply to every HTTP server, whether or not you use --no-auth.
Default tool set. Without --tools, HTTP exposes:
| Tool | Why it is exposed |
|---|---|
search_skills, list_skills, list_themes, get_skill, fence_value |
The hub read tools: serving the hub is what a remote server is for. |
record_run_trace |
Takes a closed-schema nuvel.skill_run/1 trace with no free-text fields; anything else is rejected without being echoed. |
check_skill_health |
Read-only; see remote mode below. |
record_recipe_signal |
Takes a closed-schema nuvel.recipe_signal/2 weekly snapshot (status and usage counters) with identifiers and counters only, no free text. See Skill run traces. |
share_recipe |
Takes a nuvel.recipe/3 recipe: closed structure (actions, tool kinds, placeholder slots, usage stats) and, only when the user opted in, free text under text that the server masks with a per-language allowlist before anything is stored, relayed or logged. Spanish, English and Catalan are masked; other languages are shared as structure only. A format problem is rejected with field paths only. See Skill run traces. |
Off unless you name them in --tools:
record_feedbackandpropose_improvementaccept free text, which can carry user content a hosted endpoint should never receive.propose_improvementcan also file GitHub issues with the server'sGITHUB_TOKENon behalf of any caller.install_skillwrites to the server's filesystem.
stdio keeps the full tool set: it runs on the user's own machine, where free text never leaves.
Remote mode. Every HTTP request runs in remote mode: no tool returns data
derived from the local feedback store (outcome counts, queued proposals and their
free text). get_skill leaves out its health report, and check_skill_health
requires plugin and returns only {"skill", "plugin", "runs"}: the run-trace
summary for the caller's own tenant.
Tenant is the OAuth client_id. For each authenticated request, the caller
has a tenant: it is stamped on every trace it records and scopes what
check_skill_health returns, so two tenants never see each other's runs. For a
client acting as itself (client_credentials, URL keys) the tenant is its
client_id. For a person who signed in interactively it is the client_id
followed by : and 16 hex characters derived from who they are (their GitHub
numeric id, or their dev-login name), so two people using the same public
client never share a tenant, even though anyone allowed to log in can use any
registered public client. It never contains the subject (a person's GitHub
login) itself. Use one client per tenant, which is what client create is for.
Changed in this release: interactive-login tenants used to be the bare
client_id. Run traces that GitHub (or dev-login) users recorded before upgrading stay under that oldclient_idtenant, and the same users now see only what they record under their new per-user tenant.client_credentialsand URL-key tenants are unchanged. No migration is run; a deployment withNUVEL_OAUTH_LOGIN=noneis not affected.
Request limits¶
| Condition | Response |
|---|---|
Missing, invalid or expired access token, or more than one Authorization header |
401 |
Unknown, revoked or deleted-client URL key on /mcp/k/<key>, or a key path that also sends Authorization |
401 with WWW-Authenticate: Bearer error="invalid_token" and no resource_metadata |
/mcp/k/ with an empty or multi-segment key, or /sse/k/… / /messages/k/… |
404 |
/register, /authorize or a login callback with NUVEL_OAUTH_LOGIN=none |
404 |
| Valid token without the scope the method needs | 403 |
Origin not allowed |
403 |
Body over the cap (NUVEL_MCP_MAX_BODY_BYTES, default 1 MiB) |
413 |
Body sent with Transfer-Encoding: chunked |
411 |
Request not fully received within NUVEL_MCP_REQUEST_DEADLINE seconds |
408 if it stalled in the body; a slow request line or headers just closes the connection |
A second GET /mcp stream on a session that already has one open |
409; the open stream keeps working |
More than NUVEL_MCP_MAX_CONNECTIONS connections open |
503 with Retry-After: 1, sent without starting a handler thread |
More than NUVEL_MCP_MAX_CONNECTIONS_PER_IP connections (or, behind a trusted proxy, in-flight requests) from one caller IP |
429 with Retry-After: 1 |
More than NUVEL_MCP_MAX_SESSIONS sessions in all |
503 |
One caller past NUVEL_MCP_MAX_SESSIONS_PER_PRINCIPAL sessions or NUVEL_MCP_MAX_STREAMS_PER_PRINCIPAL open streams |
Not refused: the caller's own least recently used session, or oldest stream, is closed to make room; other callers are unaffected |
| Body that is not valid JSON | 400 |
Error responses do not echo the request body or path, and a failing trace store
reports a generic error to the caller; the detail goes to the server log only.
The access log shows the request line with query strings and URL keys replaced
by <redacted>.
A request with Expect: 100-continue gets the interim 100 Continue only once
it has passed the framing checks, Origin and authentication and its body is
about to be read; a request refused by any of those gets its one final status
and no 100.