meta-mcp-manager
README.md
# Meta MCP Manager
[](https://github.com/arthurkatcher/meta-mcp-manager/actions/workflows/ci.yml)
[](LICENSE)
[](https://www.python.org/downloads/)
**One MCP endpoint for every tool you own.**
Self-hosted control plane that federates stdio and remote MCP servers, keeps them warm, indexes tools for retrieval, and exposes a single Streamable HTTP surface to Cursor, Claude, ChatGPT, and other agents.
```
Cursor / Claude / ChatGPT
│ one connection
▼
Meta MCP Manager ──► GitHub · Logfire · Context7 · your stdio servers · …
│
search → schema → call
manage_servers · manage_auth (admin)
```
- **Scale past client tool caps** — agents see a small fixed meta-tool set, not 500 schemas.
- **Cursor-compatible config** — drop in an `mcp.json` you already use.
- **Inbound OAuth** — Claude/ChatGPT paste your URL; PKCE + DCR + consent page.
- **Outbound OAuth** — connect protected upstreams (e.g. Logfire) via `manage_auth`.
- **Open source, single binary-ish Docker image** — MIT, no phone-home.
---
## Table of contents
- [Why](#why)
- [Features](#features)
- [Quick start](#quick-start)
- [Docker](#docker)
- [Connect clients](#connect-clients)
- [Meta tools](#meta-tools)
- [Configuration](#configuration)
- [OAuth](#oauth)
- [Brand & connector packaging](#brand--connector-packaging)
- [Architecture](#architecture)
- [Development](#development)
- [Security notes](#security-notes)
- [Status & roadmap](#status--roadmap)
- [Contributing](#contributing)
- [Security](#security)
- [License](#license)
---
## Why
AI clients hard-cap tools and burn tokens on every schema. Loading dozens of MCPs directly:
1. Hits limits (e.g. ~40 tools in some IDEs).
2. Degrades tool selection accuracy.
3. Makes OAuth, restarts, and inventory unmanageable.
Meta sits in the middle: **one public MCP**, many upstreams, **search-first** discovery.
---
## Features
| Area | Capability |
|------|------------|
| **Transports** | Warm **stdio** pool; remote **Streamable HTTP** (+ SSE where configured) |
| **Discovery** | BM25 `search_tools`, paginated `list_servers` / `list_tools` |
| **Admin** | `manage_servers` CRUD; enable/disable/reload |
| **Outbound auth** | OAuth 2.1 + PKCE to upstreams; encrypted token vault; refresh |
| **Inbound auth** | OAuth AS + PRM for Claude/ChatGPT; API key for local clients |
| **Scopes** | `meta:read` (catalog), `meta:invoke` (call), `meta:admin` (manage_*) |
| **Ops** | Health JSON, audit log (arg keys only), Docker Compose |
| **Packaging** | Logo SVG, docs/legal pages, CSP + security headers |
---
## Quick start
### Requirements
- Python **3.11+**
- Optional: Node (`npx`) if your upstream MCPs need it
- Optional: [uv](https://github.com/astral-sh/uv)
### Install & run
```bash
git clone https://github.com/arthurkatcher/meta-mcp-manager.git
cd meta-mcp-manager
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
# or: uv pip install -e ".[dev]"
cp config/mcp.example.json mcp.json
# Edit paths / servers as needed
export META_API_KEY="$(openssl rand -hex 16)"
meta serve --config mcp.json --host 127.0.0.1 --port 8080 --data-dir ./data
```
| URL | Description |
|-----|-------------|
| `http://127.0.0.1:8080/mcp` | MCP endpoint |
| `http://127.0.0.1:8080/health` | Health + stats |
| `http://127.0.0.1:8080/docs` | Human docs |
| `http://127.0.0.1:8080/static/logo.svg` | Logo |
```bash
curl -s http://127.0.0.1:8080/health | jq .
```
---
## Docker
```bash
export META_API_KEY="$(openssl rand -hex 16)"
docker compose up --build
```
Compose mounts `config/mcp.docker.json` and persists `/data`. For public clients (Claude/ChatGPT) set:
```bash
export META_PUBLIC_URL=https://your.domain.example
export META_OAUTH_PASSWORD="$META_API_KEY" # or a separate consent password
docker compose up --build
```
---
## Connect clients
### Cursor / VS Code (API key)
```json
{
"mcpServers": {
"meta": {
"url": "http://127.0.0.1:8080/mcp",
"headers": {
"Authorization": "Bearer YOUR_META_API_KEY"
}
}
}
}
```
### Grok (`~/.grok/config.toml`)
```toml
[mcp_servers.meta]
url = "http://127.0.0.1:8080/mcp"
enabled = true
[mcp_servers.meta.headers]
Authorization = "Bearer YOUR_META_API_KEY"
```
Or: `grok mcp add --transport http meta http://127.0.0.1:8080/mcp --header "Authorization: Bearer YOUR_META_API_KEY"`
### Claude Desktop (stdio bridge + API key)
```json
{
"mcpServers": {
"meta": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"http://127.0.0.1:8080/mcp",
"--header",
"Authorization: Bearer YOUR_META_API_KEY"
]
}
}
}
```
### Claude / ChatGPT (remote + inbound OAuth)
1. Expose Meta with HTTPS (tunnel or reverse proxy):
```bash
ngrok http 8080
export META_PUBLIC_URL=https://YOUR_SUBDOMAIN.ngrok-free.dev
meta serve --config mcp.json --host 0.0.0.0 --port 8080 \
--public-url "$META_PUBLIC_URL" --api-key "$META_API_KEY"
```
2. In the client, add remote MCP URL:
`https://YOUR_SUBDOMAIN.ngrok-free.dev/mcp`
3. Complete browser consent with **`META_OAUTH_PASSWORD`** or **`META_API_KEY`**.
No static Bearer header required for that path.
---
## Meta tools
| Tool | Purpose |
|------|---------|
| `search_tools` | BM25 search over the catalog |
| `get_tool_schema` | Full JSON schema for `server/tool` |
| `call_tool` | Invoke upstream tool |
| `list_servers` | Paginated server inventory |
| `list_tools` | Paginated tool catalog (`server` filter optional) |
| `stats` | Counts / health |
| `manage_servers` | create · update · delete · get · list · enable · disable · reload |
| `manage_auth` | login · status · logout · refresh (no raw tokens to the model) |
**Agent path:** `search_tools` → `get_tool_schema` → `call_tool`.
### Add an upstream MCP (+ OAuth)
```text
manage_servers(action="create", server_id="logfire",
url="https://logfire-us.pydantic.dev/mcp")
# if runtime_status == "auth_required":
manage_auth(action="login", server_id="logfire")
# open authorization_url → Meta /oauth/callback completes
manage_auth(action="status", server_id="logfire")
search_tools(query="list projects")
call_tool(tool_id="logfire/project_list", arguments={})
```
Admin tools require scope **`meta:admin`**. API keys grant full admin.
---
## Configuration
### `mcp.json` (Cursor-compatible)
```json
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
},
"context7": {
"url": "https://mcp.context7.com/mcp"
},
"protected": {
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer ${env:SERVICE_TOKEN}"
},
"auth": {
"CLIENT_ID": "${env:OAUTH_CLIENT_ID}",
"CLIENT_SECRET": "${env:OAUTH_CLIENT_SECRET}",
"scopes": ["read", "write"]
}
}
}
}
```
`${env:NAME}` is interpolated at load time.
### Environment variables
| Variable | Description |
|----------|-------------|
| `META_API_KEY` | Bearer key for local clients; default consent password if unset |
| `META_OAUTH_PASSWORD` | Consent password for inbound OAuth (optional) |
| `META_PUBLIC_URL` | Public base URL (required for OAuth redirects / PRM) |
| `META_CONFIG` | Path to `mcp.json` |
| `META_DATA_DIR` | SQLite, vaults, logs |
| `META_HOST` / `META_PORT` | Bind address |
| `META_OAUTH_ENABLED` | Inbound OAuth AS (default `true`) |
| `META_ALLOW_API_KEY` | Accept API key on `/mcp` (default `true`) |
| `META_REQUIRE_API_KEY` | Require auth on `/mcp` (default `true`) |
| `META_STRICT_STDIO` | Require absolute stdio commands (default `false`) |
| `META_STDIO_ALLOW_PREFIXES` | `:`-separated path prefixes allowed when strict |
CLI:
```bash
meta serve --config mcp.json --host 127.0.0.1 --port 8080 \
--api-key "$META_API_KEY" --public-url "$META_PUBLIC_URL"
```
---
## OAuth
### Two directions
| Direction | Meaning |
|-----------|---------|
| **Outbound** | Meta → upstream MCP (Logfire, Linear, …) via `manage_auth` + `/oauth/callback` |
| **Inbound** | Claude/ChatGPT → Meta via PRM/AS + `/oauth/authorize` + `/oauth/token` |
### Inbound discovery endpoints
| Endpoint | Spec role |
|----------|-----------|
| `GET /.well-known/oauth-protected-resource` | RFC 9728 PRM |
| `GET /.well-known/oauth-authorization-server` | RFC 8414 AS metadata |
| `POST /oauth/register` | Dynamic client registration |
| `GET/POST /oauth/authorize` | Authorization code + consent |
| `POST /oauth/token` | Code exchange / refresh |
### Scopes
| Scope | Access |
|-------|--------|
| `meta:read` | search, list, schema, stats (catalog) |
| `meta:invoke` | `call_tool` when `META_REQUIRE_INVOKE_SCOPE=true` |
| `meta:admin` | `manage_servers`, `manage_auth` |
Default OAuth consent grants **`meta:read` + `meta:invoke`** (catalog + call tools).
**`meta:admin` is never default** — must be requested explicitly. API keys get all scopes.
Set `META_REQUIRE_INVOKE_SCOPE=true` to enforce that `call_tool` needs `meta:invoke` (recommended when you also issue read-only tokens).
### Rate limits (defaults, per principal / min)
| Surface | Default | Env |
|---------|---------|-----|
| `call_tool` | 60 | `META_RATE_CALL_TOOL` |
| `manage_*` | 30 | `META_RATE_MANAGE` |
| catalog (`search` / `list_*` / `stats`) | 180 | `META_RATE_CATALOG` |
| OAuth register / token / authorize | separate | (built-in) |
Window: `META_RATE_WINDOW_SEC` (default 60). Set a limit to `0` to disable that bucket. Exceeded tools return `error: rate_limited`.
### Product database (one SQLite file)
Everything durable lives in **`{META_DATA_DIR}/meta.db`** (WAL). Multiple tables, one file for backup/UI:
| Table(s) | Purpose |
|----------|---------|
| `servers`, `tools` | Catalog of upstream MCPs + tool schemas |
| `activity` | Meta-tool call log (Activity UI) |
| `oauth_tokens`, `oauth_pending` | Outbound OAuth (token blobs Fernet-encrypted) |
| `oauth_clients`, `auth_codes`, `access_tokens`, … | Inbound OAuth AS |
| `meta_schema` | Schema version |
Still separate on disk: `mcp.json` (config templates), `token.key` (Fernet key, mode 0600), optional JSONL mirror `logs/audit.jsonl`.
Legacy split files (`activity.db`, `oauth.db`, `inbound_oauth.db`) are **merged into `meta.db` once** on startup and renamed `*.migrated`.
**Activity columns:** `event_id`, `ts`, `duration_ms`, `meta_tool`, `actor_label` (`oauth · Claude`, …), `ok`, `error_code`, `args_keys` (names only), `target_tool_id`, …
OAuth actors use registered **client_name** from DCR when available.
Inbound bearer secrets (access/refresh/auth codes/client_secret) are stored as **SHA-256 digests** in SQLite (plaintext only at issuance).
### Production checklist
| Control | Env / action |
|---------|----------------|
| Strong API key | `META_API_KEY` ≥16 chars, unique |
| Consent password | `META_OAUTH_PASSWORD` (separate from API key preferred) |
| Public bind | Auto `strict_stdio`; refuse weak keys |
| DCR lock | `META_DCR_TOKEN=…` and/or `META_DCR_REDIRECT_ALLOWLIST=claude.ai,.openai.com` |
| Disable DCR | `META_DCR_ENABLED=false` |
| Catalog-only OAuth | `META_REQUIRE_INVOKE_SCOPE=true` + grant `meta:invoke` when needed |
| SSRF | `META_ALLOW_PRIVATE_URLS=false` when not needing localhost MCPs |
| Activity retention | `META_ACTIVITY_RETENTION_DAYS=30` (default) |
| Single worker | Do not set `WEB_CONCURRENCY>1` (refused at start) |
| Backup | `meta backup --out /safe/path` (copies `meta.db`, `token.key`, `mcp.json`) |
| TLS | Terminate at reverse proxy / tunnel |
---
## Brand & connector packaging
| Asset | Path |
|-------|------|
| Logo | `/static/logo.svg` |
| Favicon | `/static/favicon.svg` |
| Docs | `/docs` |
| Terms | `/legal/terms` |
| Privacy | `/legal/privacy` |
| Brand pack | `/brand` |
PRM includes `logo_uri`, `resource_documentation`, `resource_tos_uri`, `resource_policy_uri`.
HTML/OAuth responses send CSP and standard security headers.
See [docs/CONNECTOR.md](docs/CONNECTOR.md) for the full surface and production checklist.
---
## Architecture
```
src/meta_manager/
app.py # HTTP routes, lifespan
main.py # CLI: meta serve
config/ # mcp.json load/save, settings
runtime/ # stdio + HTTP session pool
catalog/ # SQLite catalog + BM25
edge/ # meta tools, auth middleware, CSP
oauth/ # outbound vault + inbound AS
admin/ # manage_servers / manage_auth
brand/ # docs/legal HTML
static/ # logo, favicon
```
Design notes: [ARCHITECTURE.md](ARCHITECTURE.md).
---
## Development
```bash
source .venv/bin/activate
pip install -e ".[dev]"
pytest -q --timeout=90
# optional real-MCP stress (Meta must be running):
# META_URL=http://127.0.0.1:8080/mcp META_API_KEY=... python scripts/hardcore_eval_real.py
```
Fixtures under `fixtures/` provide stdio math/echo/many-tools and an HTTP time server for CI without flaky public remotes.
---
## Security notes
- **Self-hosted:** you operate the instance; protect `META_API_KEY` / `META_OAUTH_PASSWORD` and network exposure.
- Prefer **HTTPS** and a **stable domain** for inbound OAuth (not long-term free tunnels).
- Upstream tokens are stored under `META_DATA_DIR` (Fernet-encrypted vault); back up and restrict that directory.
- Admin tools can add arbitrary commands (stdio) and URLs — treat `meta:admin` like root.
- Audit log records tool ids and argument **keys**, not secret values.
- Legal pages under `/legal/*` are **OSS self-host templates**, not formal counsel.
---
## Status & roadmap
**v0.1 — production-capable for single-operator self-host:** federation, BM25 search, warm stdio, remote HTTP, outbound OAuth, inbound OAuth (PRM/AS/DCR/PKCE), admin tools with scope gates, Docker, connector branding, unified SQLite, activity log, rate limits, resource-bound tokens.
**Still out of scope for multi-tenant SaaS:** external IdP, distributed rate limits (Redis), multi-replica HA, marketplace listings.
See [CHANGELOG.md](CHANGELOG.md) for release notes.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). PRs and issues welcome.
---
## Security
Please report vulnerabilities privately — [SECURITY.md](SECURITY.md).
---
## License
[MIT](LICENSE) — Copyright (c) 2026 Meta MCP Manager contributors
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues