A single Go binary that aggregates several MCP servers into one MCP endpoint, with a built-in admin UI.
Inspired by metatool-ai/metamcp, rewritten with only the parts that carry the value: group servers into namespaces, filter and rename their tools, and serve each namespace over Streamable HTTP, SSE or stdio. No database, no accounts, no build step — the UI is plain HTML/CSS/JS embedded in the binary.
License: this project is not open source. Copying, forking or reusing it is not allowed — see LICENSE.
go build -o metamcp ./cmd/metamcplisten: :12008
api_key: ${METAMCP_API_KEY} # optional; ${VAR} reads the environment
ui_password: "" # optional; set it from the ui so it is stored hashed
servers:
files: # stdio: command is set
command: npx
args: [-y, "@modelcontextprotocol/server-filesystem", /tmp]
github: # streamable http: url is set
url: https://api.githubcopilot.com/mcp/
headers:
Authorization: Bearer ${GITHUB_TOKEN}
legacy:
type: sse # sse needs to be explicit
url: https://example.com/sse
namespaces:
dev:
servers:
- files # everything this server exposes
- name: github
allow: [search_issues] # whitelist; omit to allow all
deny: [delete_repo] # blacklist, wins over allow
overrides:
search_issues:
name: gh_search
description: Search GitHub issuesValidate it with metamcp -check -config metamcp.yaml.
metamcp -config metamcp.yamlEach namespace gets two endpoints:
| Transport | URL |
|---|---|
| Streamable HTTP | http://host:12008/mcp/dev |
| SSE | http://host:12008/mcp/dev/sse |
GET /healthz is unauthenticated and always open.
http://host:12008/ serves an admin UI on the same port:
- Overview — every server with its live connection status and tool count, every namespace with copyable endpoint URLs. Add, edit, delete and test servers and namespaces.
- Namespace view — every tool the downstream servers expose, with a checkbox to include it and inline fields to rename or re-describe it. Saving rewrites the config and takes effect immediately: namespaces you did not touch keep their live client sessions.
- Tool tester — call any tool with JSON arguments and see the result, so you do not need a separate MCP inspector.
Everything the UI does goes through a JSON API under /api/, so it is scriptable too:
| Route | Does |
|---|---|
GET /api/state |
servers, namespaces, statuses, version, config path |
PUT|DELETE /api/servers/{name} |
add, edit or remove a server (409 if still used) |
POST /api/servers/{name}/test |
connect now and report the status |
PUT|DELETE /api/namespaces/{name} |
add, edit or remove a namespace |
GET|POST /api/namespaces/{name}/tools |
list every downstream tool / save filters and overrides |
GET /api/namespaces/{name}/prompts |
aggregated prompts |
GET /api/namespaces/{name}/resources |
aggregated resources and templates |
POST /api/namespaces/{name}/call |
run a tool |
POST /api/namespaces/{name}/prompt |
get a prompt |
POST /api/namespaces/{name}/resource |
read a resource |
PUT /api/settings |
change listen or api_key (listen needs a restart) |
Two things worth knowing:
- Saving from the UI rewrites the config file, so comments and formatting are lost. The previous
version is kept as
<config>.bak.${VAR}references are preserved, never resolved into the file. - The UI and the API sit behind the same
api_keyas the endpoints when one is set. There is no separate login, so bind to localhost unless you set a key.
For stdio-only clients (Claude Desktop) skip the HTTP server entirely:
{
"mcpServers": {
"dev": {
"command": "metamcp",
"args": ["-config", "/etc/metamcp/metamcp.yaml", "-stdio", "dev"]
}
}
}Two independent things, because a browser cannot send a header on a plain navigation:
api_key — for MCP clients and scripts. Set it globally or per namespace and every request to
that namespace needs it, from any of X-API-Key, Authorization: Bearer <key>, ?api_key= or
?apikey=. It also guards the admin API. Opening the UI once with ?api_key= starts a browser
session so the rest of the page works.
ui_password — for the admin UI only. Set it and the UI redirects to a sign in page; the
correct password sets an HttpOnly cookie that lasts a week, or until the process restarts. It does
nothing for MCP clients. Set it from Settings → UI password so it is written salted and hashed; a
plain value written by hand also works.
Neither set means no auth at all, so bind to localhost.
The config credentials above are the admin's. For giving different clients different access to the
same namespaces, create issued keys — from the UI (API keys page) or the JSON API. They live
in a SQLite database (-db flag, default metamcp.db beside the config), because they are
created and revoked at runtime, unlike the YAML topology.
- Each key is named, optional expiry, and a list of grants: a namespace, and optionally a
whitelist of canonical tool names (
<server>__<tool>— stable across renames). An empty tool list means every tool in that namespace. - Deny by default: a key with no grants can reach nothing. Creating the first key closes the endpoints to anonymous clients; deleting all keys reopens them.
- The secret is shown once at creation and stored only as a hash. Disabled or expired keys stop resolving immediately; keys are replaced, never read back.
- Scoping is an intersection: a granted tool is further filtered by the namespace's own
allow/deny. A browser session never unlocks/mcp/*; MCP clients must present a key (Authorization: Bearer <key>,X-API-Key, or?api_key=). - The admin
api_keyis never scoped and always sees everything.
sqlite3 ~/.config/metamcp/metamcp.db "SELECT name, prefix, expires_at, disabled FROM api_keys;"
Tools and prompts are exposed as <server>__<name>, where <server> is the config key with
everything outside [a-zA-Z0-9_-] removed. Calls are routed by that prefix, so no state is
needed between tools/list and tools/call. Prefix collisions inside a namespace are rejected
at startup. Resources keep their own URIs; the owning server is remembered from resources/list
and looked up on demand if a client reads without listing.
- Downstream servers are connected lazily on first use and shared by all clients.
- A dead session is reconnected once, transparently, on the failing request.
- A downstream server that is broken or slow to fail is logged and skipped: listings degrade instead of failing.
- A tool blocked by
allow/denyreturns a tool error, not a protocol error. - Listings are not paginated outwards; every downstream page is fetched and merged.
-config path to the config file (default metamcp.yaml)
-listen override the listen address
-stdio serve one namespace over stdio instead of http
-db sqlite file for api keys (default: metamcp.db beside the config)
-check validate the config and exit
-debug debug logging, including downstream stderr
docker build -t metamcp .
docker run -p 12008:12008 -v $PWD/metamcp.yaml:/etc/metamcp/metamcp.yaml metamcpThe image ships node and uv so npx/uvx servers work out of the box.