mcp-registry
The gateway can front a Gitea MCP server, making its tools available through the unified MCP endpoint and calling them with the person's credentials or a shared server secret.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-registryfind a tool to create an issue in Gitea and call it"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-registry
A self-hosted MCP gateway: one MCP endpoint for your AI agents (Claude Code and other MCP clients) in front of all the MCP servers your team uses.
Instead of loading hundreds of upstream tools into the agent's context, the gateway exposes four meta-tools. The agent searches for the tool it needs, reads its schema, and calls it through the gateway. Each call is made on behalf of the person: with their own OAuth token or personal access token for that service, or with a shared server secret that never leaves the gateway.
Who it is for. Individuals and small teams who run their own services (Gitea, YouTrack, internal tools) inside their own network and want one place to manage MCP access. It is a single container plus Postgres. It is not an enterprise product: no multi-tenancy, no HA clustering, no fine-grained RBAC beyond "member / admin / server visible to groups".
How it works
flowchart LR
A[Claude Code<br/>Bearer sk_...] -->|/mcp| G[mcp-registry]
B[Browser] -->|OIDC login| G
G -->|person's OAuth token / PAT<br/>or shared secret| U1[Gitea MCP]
G --> U2[YouTrack MCP]
G --> U3[Other MCP servers]
G --- DB[(Postgres + pgvector)]The agent sees exactly four tools, whatever the catalog contains:
Tool | What it does |
| Servers the person may use, and whether they still need to connect an account (with a link). |
| Hybrid search (full-text + vector) over tool descriptions; returns |
| Full description and |
| Calls the upstream tool with the person's credentials; the upstream result is returned as is. |
Search returns an empty list when nothing is relevant enough, rather than padding results with near misses.
Related MCP server: mcp-nexus
Features
UI login through any OIDC provider that can put the person's groups or roles into a claim of the ID token or UserInfo; no passwords stored.
Personal API tokens (
sk_...) for/mcp; the UI shows a ready-to-pasteclaude mcp addcommand.Server catalog: add upstream MCP servers, restrict them to groups, hide individual tools, set per-call timeouts and per-person daily limits.
Per-user credentials: OAuth 2.0 (Authorization Code + PKCE, automatic refresh) or personal access tokens; or one shared secret per server.
Secrets encrypted at rest with AES-256-GCM; key rotation without downtime.
Catalog synchronisation with upstream every 6 hours (configurable) and on demand.
Local embeddings (
multilingual-e5-small, baked into the image; no external service, works offline). An OpenAI-compatible embeddings API can be used instead.Call log (who, which tool, outcome, duration; never arguments or results), audit log of admin actions.
Strict CSP web UI (
default-src 'self', no inline scripts or styles).
Quick start (Docker Compose)
Requirements: Docker with Compose, an OIDC provider, and (for anything beyond localhost) a TLS-terminating reverse proxy in front of the gateway.
git clone https://github.com/ai-pipes/mcp-registry.git && cd mcp-registry
cp .env.example .envEdit .env:
PUBLIC_URL=https://mcp.example.com
TRUST_PROXY=true # behind a reverse proxy
POSTGRES_PASSWORD=$(openssl rand -base64 32)
SECRETS_KEY=$(openssl rand -base64 32) # back this up: without it stored secrets are lost
OIDC_ISSUER=https://id.example.com
OIDC_CLIENT_ID=...
OIDC_CLIENT_SECRET=...(Generate the values with openssl rand -base64 32 and paste them in; .env does not run shell commands.)
Start it:
docker compose up -d --build
curl http://localhost:3000/healthz # okCompose runs the app and pgvector/pgvector:pg17; Postgres is not published. Migrations run on startup. A prebuilt image is published to ghcr.io/ai-pipes/mcp-registry for tagged releases.
First admin
Put yourself in the admin group of your OIDC provider (OIDC_ADMIN_GROUP, default mcp-admins) and sign in at PUBLIC_URL. Role and groups are taken from the provider on every login.
Without a working provider (emergency access, scripted setups) you can create a person with the CLI inside the container. <sub> is the person's subject ID at the provider:
docker compose exec app node dist/cli.js create-user --sub <sub> --name "Jane" --adminConnect an agent
In the UI, open Tokens, create a token and copy the command it shows. Or from the CLI:
docker compose exec app node dist/cli.js create-token <sub> --name laptopThen:
claude mcp add --transport http mcp-registry https://mcp.example.com/mcp \
--header "Authorization: Bearer sk_..."Configuration
All settings are environment variables (see .env.example). An empty value (KEY=) counts as unset.
Variable | Default | Meaning |
| required | External URL of the gateway (scheme + host + port). Used for OIDC/OAuth redirect URIs, the |
| required (Compose sets it) | Postgres connection string. Compose defaults to |
| required by Compose | Password of the bundled Postgres. Set it before the first start; it is stored in the volume afterwards. |
|
| Listening port (with Compose: the host port; the container always listens on 3000). |
| empty | Extra accepted |
|
| Take the client IP from |
|
| Number of trusted proxies in front of the gateway (1-5). |
|
|
|
| required | AES-256-GCM key, base64 of exactly 32 bytes ( |
| unset | Previous key, only during key rotation. Must differ from |
| required | Issuer URL of the OIDC provider. Discovery happens on the first login, not at startup. |
| required | Client ID registered for the gateway. |
| required | Client secret registered for the gateway. |
|
| Members of this group may sign in. |
|
| Members of this group may sign in and are admins. |
|
| Scopes of the login request, space- or comma-separated; must include |
|
| Claim with the person's groups: a list of names, or an object whose keys are the names. |
|
| Catalog synchronisation period, hours (1-8760). |
|
| How long call log entries are kept. |
|
| Larger upstream responses are refused (memory protection). |
|
| Request body limit of |
|
|
|
| unset | Base URL of the embeddings API, only for |
| unset | Model name for |
|
| Directory of the local model. The image already contains it. |
|
| Cosine-similarity floor (0-1) for vector search candidates. |
|
|
|
OIDC setup
Any OIDC provider works if it can put the person's groups (or roles) into one claim of the ID token or the UserInfo response: a flat list of names, or an object whose keys are the names. The claim name is OIDC_GROUPS_CLAIM (default groups), the requested scopes are OIDC_SCOPES (default openid profile email groups).
Create two groups: the login group (
mcp-users) and the admin group (mcp-admins). Membership in either lets a person in; the admin group also makes them an admin.Create a confidential OIDC client:
redirect URI:
https://<host>/api/auth/callback;PKCE: allowed (the gateway always sends S256);
make sure the groups claim is emitted (see the table below).
Put the issuer, client ID and client secret into
OIDC_ISSUER,OIDC_CLIENT_ID,OIDC_CLIENT_SECRET, and setOIDC_SCOPES/OIDC_GROUPS_CLAIMif your provider needs it.
Provider | Settings |
Pocket ID, Authentik, Authelia, Kanidm | defaults |
Keycloak | defaults; add a client scope |
Zitadel |
|
Microsoft Entra ID |
|
Auth0 |
|
These are starting points, not tested recipes; check the claims your provider actually issues (for example on its token preview page) if login answers /?loginError=not_allowed.
Check the issuer with curl -s https://id.example.com/.well-known/openid-configuration. If login redirects to /?loginError=idp_unavailable, the gateway cannot reach OIDC_ISSUER; /mcp and existing tokens keep working.
Disabling a person in the UI revokes all their sessions and tokens.
Adding upstream servers
Upstreams must speak Streamable HTTP. Each server has a slug (kebab-case, the prefix of its tool refs, cannot be changed later), a URL, an auth mode, access (everyone or a list of groups), a call timeout and an optional daily limit per person.
Auth mode | Credentials |
| No header is sent. |
| One secret for the server, set by an admin, sent by the gateway for everyone. |
| Each person stores their own personal access token in Connections. |
| Each person connects their account via OAuth 2.0 (Authorization Code + PKCE); tokens are refreshed automatically. |
For all modes except none you set a header name and a template containing {secret}, e.g. Authorization / Bearer {secret}. Secrets in the form are write-only.
Order matters: add the server, then connect your own account in Connections, then press Sync. For oauth_user and pat_user servers the first sync right after creation reports that an admin connection is needed; that is expected. Scheduled syncs use the connection of the last admin who synced the server successfully; if that person loses the admin role, another admin with a connection must press Sync once.
Gitea (oauth_user)
Run
gitea-mcpin HTTP mode, with no global token (each request carries the person's token), reachable from the gateway and not published:gitea-mcp: image: docker.gitea.com/gitea-mcp-server command: ['/app/gitea-mcp', '-t', 'http', '--port', '8080', '-H', 'https://gitea.example.com']In Gitea: Site Administration -> Applications -> OAuth2 Applications -> create an app, Confidential Client on, redirect URI
https://<host>/api/connections/callback. Save the client ID and secret.Add the server in the gateway:
Field
Value
URL
http://gitea-mcp:8080/mcpAuth mode
oauth_userAuthorize URL
https://gitea.example.com/login/oauth/authorizeToken URL
https://gitea.example.com/login/oauth/access_tokenRevoke URL
empty (Gitea has no RFC 7009 endpoint)
Client ID, secret
from step 2
Scopes
empty (Gitea then grants the user's full access)
Header / template
Authorization/Bearer {secret}
A request with an invalid token to gitea-mcp returns HTTP 401; a connection error instead means the containers are not on the same network.
YouTrack (oauth_user)
YouTrack has a built-in MCP endpoint at /mcp. OAuth goes through Hub (built into YouTrack at <youtrack>/hub, or standalone).
Hub -> Services -> New service: redirect URI
https://<host>/api/connections/callback, set a secret. The service ID is the client ID.Note the ID of the YouTrack service in Hub: it is the scope.
Add the server:
Field
Value
URL
https://youtrack.example.com/mcpAuth mode
oauth_userAuthorize URL
<hub>/api/rest/oauth2/authToken URL
<hub>/api/rest/oauth2/tokenClient ID, secret
from step 1
Scopes
the YouTrack service ID from step 2
Extra authorize parameters
access_type=offline(required: without it Hub issues no refresh token and the connection expires after an hour)Header / template
Authorization/Bearer {secret}
A server with a shared key (shared_secret)
For an MCP server protected by one API key: auth mode shared_secret, header e.g. Authorization with template Bearer {secret} (or X-API-Key / {secret}), and the key as the server secret. Every permitted person calls it with the same key; the call log still records who called what.
Servers can also be created from a JSON file (ServerInput in packages/contracts/src/servers.ts); the secret is read from stdin or an environment variable, never from the command line:
docker compose exec -T app node dist/cli.js create-server --json /path/in/container/server.json \
--as <admin-sub> --secret-env UPSTREAM_KEYOperations
CLI
Inside the container: node dist/cli.js <command> (same environment as the server). Exit codes: 0 success, 1 operation failed, 2 bad invocation.
Command | Purpose |
| Create or update a person as a first login would. Idempotent by |
| Issue an |
| Create a server and sync it. The secret comes from |
| Sync a server's tools (without |
| Rebuild the search index (if |
| Re-encrypt every stored secret from |
Rotating SECRETS_KEY
Generate a new key and keep the old one until rotation is done.
Set
SECRETS_KEY_OLD=<current key>,SECRETS_KEY=<new key>, restart. Both keys open secrets meanwhile, so calls keep working; a warning is logged on each start.Run
node dist/cli.js rotate-secrets-key. It reports how many server secrets and connections were re-sealed. Exit code 1 lists records that neither key opens (wrong old key?).Run it again: it should report
0 re-sealed. RemoveSECRETS_KEY_OLDand restart.
Back up SECRETS_KEY together with the database: a database restored without its key has no usable secrets or connections.
Things to know
OAuth providers must be on HTTPS and on the same registrable domain as the gateway (e.g.
git.example.comandmcp.example.com). The session cookie isSameSite=Strict, so the browser does not send it on the redirect back from a provider on another site, and the connection fails witherror=unauthenticated.A wrong OAuth client secret does not break people's connections, but once their access tokens expire every call to that server fails as "unavailable" (
transport_errorin the call log) until the secret is fixed. Nobody has to reconnect. When rotating a client secret, update it in the gateway first, then revoke the old one at the provider.Search threshold.
SEARCH_MIN_SIMILARITY(default0.83, calibrated for the bundled model) is the floor for vector matches. Lower it (e.g.0.80) if the agent misses obvious tools, raise it if it finds unrelated ones. A restart is enough; no reindex. Recalibrate if you switch to another embedding model.Large tool outputs. Claude Code truncates MCP tool output at about 25k tokens (
MAX_MCP_OUTPUT_TOKENS); raise it on the client (MAX_MCP_OUTPUT_TOKENS=100000 claude) for large results. The gateway's 20 MiBUPSTREAM_MAX_RESPONSE_BYTESonly protects server memory.Scope. Streamable HTTP upstreams only (no stdio, no legacy SSE). Tools only: MCP resources and prompts are not proxied.
Call arguments and results are never logged. Daily limits reset at midnight UTC.
Development
Requires Node 22, pnpm (version pinned in package.json) and Docker (integration tests start Postgres with Testcontainers).
pnpm install
pnpm verify # lint, format, types, architecture rules, knip, unit + integration tests, UI build
bash scripts/smoke.sh # end-to-end check of the built image: CLI -> MCP search -> describe -> call
bash scripts/e2e-image.sh # Playwright against the built image (pnpm exec playwright install chromium)
pnpm dev:web # UI dev server, proxies /api and /mcp to localhost:3000Architecture, layer rules and conventions: CONTRIBUTING.md and CLAUDE.md. Security reports: SECURITY.md.
License
This server cannot be deployed
Maintenance
Related MCP Connectors
One MCP endpoint for your company's tools, with per-person sign-in, policy and audit.
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
Zero-setup MCP gateway securely connecting AI to your tools with authentication and workflows
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Related MCP Servers
- AlicenseAqualityDmaintenanceAggregates tools from multiple MCP servers, acting as a proxy to provide unified access to various AI agents and tools.1018 npm3MIT
- AlicenseAqualityAmaintenanceEnables AI harnesses to connect to a single MCP endpoint that routes to multiple downstream MCP servers, discovering and executing capabilities on demand while keeping tool schemas out of context.413 npmApache 2.0
- AlicenseNot gradedqualityCmaintenanceProvides a unified MCP gateway enabling AI agents to securely access multiple tool servers (knowledge base, database, repo, etc.) with dynamic tool selection and OAuth2/RBAC security.1MIT
- AlicenseNot gradedqualityAmaintenanceProvides a single MCP endpoint that dynamically discovers, routes, policy-checks, and executes hundreds of MCP tools from any agent, with optional LLM-based routing.10 npm1Apache 2.0