Skip to content

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

nuvel-mcp serve --transport http --skills-dir ./skills

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:

nuvel-mcp serve --transport http --no-auth --skills-dir ./skills

--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:

claude mcp add --transport http nuvel-skills https://mcp.example.com/mcp

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:

nuvel-mcp client create --name alice --url-key

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 OAuth client_id). The client has no secret and no grant types: it cannot get tokens at /token.
  • Scopes. A key gets skills:read and skills:write, the same as a client_credentials token. 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 includes expires_at (UTC), and an expired key gets the same 401 as 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-in if you want), send them the new connector_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 old client_id and are not visible through the new one.
  • Transport. /mcp/k/<key> is Streamable HTTP only (POST, GET stream and DELETE, like /mcp). The legacy /sse transport has no key form. A request on a key path that also sends an Authorization header 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:

mcp.example.com {
    reverse_proxy 127.0.0.1:8080
}

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.

  1. Install the IaC SDK once with npm install --prefix .railway, link the repository to your project with railway link, then review and apply the definition with railway config plan and railway config apply. To set it up by hand instead, point the service at the Folken2/nuvel-mcp repository (Root Directory /) with the Dockerfile builder.
  2. Under Settings → Networking, generate a domain on port 8080.
  3. Create a Neon project and copy its pooled connection string into the NUVEL_MCP_DATABASE_URL variable (step 5). That replaces the volume. Without a database, mount a volume at /data instead: otherwise every redeploy loses issued tokens, registered clients, URL keys and run traces. Moving an existing volume deploy over: run nuvel-mcp db import (see above) from railway ssh with the URL set, then add the variable and redeploy; the volume can go once nuvel-mcp db status shows the rows.
  4. Pick how people get in:
  5. URL keys only (beta testers connecting with no sign-in): no OAuth app is needed. Set NUVEL_OAUTH_LOGIN=none.
  6. GitHub login (interactive clients that run the OAuth flow): create a GitHub OAuth app whose callback URL is https://<your-domain>/oauth/callback.
  7. 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_LOGIN included): the definition is the whole truth, so railway config apply deletes 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_feedback and propose_improvement accept free text, which can carry user content a hosted endpoint should never receive. propose_improvement can also file GitHub issues with the server's GITHUB_TOKEN on behalf of any caller.
  • install_skill writes 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 old client_id tenant, and the same users now see only what they record under their new per-user tenant. client_credentials and URL-key tenants are unchanged. No migration is run; a deployment with NUVEL_OAUTH_LOGIN=none is 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.