cms-mcp-hub
by vengat-0100
README.md
# CMS MCP Hub
Multi-tenant MCP (Model Context Protocol) gateway. Proxies Claude.ai tool calls to one or more remote CMS MCP servers, with per-workspace SSO, connector management, and an admin dashboard.
```
Claude.ai ──MCP──▶ cms-mcp-hub ──HTTP──▶ Drupal MCP server
──HTTP──▶ WordPress MCP server
──SSE───▶ Any CMS MCP server
```
---
## Quick Start
```bash
# 1. Install dependencies
npm install
# 2. (Recommended) Set encryption key
export ENCRYPTION_KEY=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
# 3. Start dev server (auto-reloads on src/ changes)
npm run dev
# 4. Open browser
open http://localhost:3456
```
Default port: **3456**. Override with `PORT=4000 npm run dev`.
---
## Control Flow
### 1. Server Startup (`src/index.js`)
```
node src/index.js
└── new WorkspaceRegistry() reads data/workspaces/ from disk
└── new McpHubServer(registry)
└── _setupRoutes() registers all Express routes + app.param workspace validation
└── app.listen(PORT)
```
On startup, `app.param('workspace_id', ...)` runs for every route that has `:workspace_id`. It loads the workspace config from disk, attaches it to `req.workspace`, and returns 404 if the workspace doesn't exist.
---
### 2. Creating a Workspace
```
Browser → POST /api/workspaces { name }
└── WorkspaceRegistry.create()
├── generates workspace_id = "ws_" + 12 random bytes (hex)
├── generates admin_key = "ak_" + 24 random bytes (hex)
├── stores admin_key_hash = SHA-256(admin_key)
└── writes data/workspaces/{workspace_id}/config.json
Response → { workspace_id, admin_key, name }
⚠ admin_key shown ONCE — save it immediately
```
`config.json` structure:
```json
{
"workspace_id": "ws_abc123...",
"name": "My Org",
"admin_key_hash": "sha256hex...",
"idp": null,
"created_at": "2025-01-01T00:00:00.000Z"
}
```
---
### 3. Admin Login → Session Cookie
```
Browser → POST /ws/:id/auth/admin { admin_key }
└── WorkspaceRegistry.verifyAdminKey()
└── SHA-256(admin_key) == stored admin_key_hash (timing-safe compare)
└── TokenStore.signSession({ workspace_id, exp: now + 24h })
└── base64url(payload) + "." + HMAC-SHA256(payload, ENCRYPTION_KEY)
└── Set-Cookie: ws_session=<token>; HttpOnly; SameSite=Lax;
Path=/ws/{workspace_id}/; Max-Age=86400
Response → { ok: true }
Browser → redirects to /ws/:id/ui
```
Session cookie is **path-scoped** to `/ws/{workspace_id}/`. A session for workspace A is not sent to workspace B requests.
---
### 4. Dashboard Load (`/ws/:id/ui`)
Page is static HTML served from `src/web/ui.js`. On load, JavaScript calls 4 APIs in parallel:
```
Browser JS load()
├── GET /ws/:id/health (no auth — workspace health + tool count)
├── GET /ws/:id/api/connectors (session required)
├── GET /ws/:id/auth/sso/status (session required)
└── GET /ws/:id/api/idp (session required)
If any session-required endpoint returns 401:
└── showAuthWall() ← overlay with "Session expired" + link to login
If all succeed:
└── renders workspace name, stats bar, SSO panel, connector cards
```
---
### 5. Connector Connection Flow
```
Admin → POST /ws/:id/api/connectors { name, url, token?, transport?, sseUrl? }
└── ConnectorRegistry.connectOne()
└── transport auto-detection (if not specified):
1. Direct HTTP — JSON-RPC POST to url
2. Streamable HTTP — MCP SDK StreamableHTTPClientTransport
3. SSE — MCP SDK SSEClientTransport
└── calls tools/list on remote server
└── registers each tool as {safePrefix(name)}__{toolName}
e.g. drupal-prod__get_node
└── stores in memory (ConnectorRegistry.connectors Map)
└── saves to data/workspaces/{id}/connectors.json
On server restart:
└── ConnectorRegistry.initialize() reconnects all saved connectors in parallel
Non-blocking: registry returned immediately, connections run in background
```
**Tool prefix rule**: `safePrefix(name)` replaces characters outside `[a-zA-Z0-9_-]` with `_`. Required by Claude.ai tool naming rules.
**Fetch timeout**: `FETCH_TIMEOUT_MS = 120,000` (2 minutes) per tool call.
---
### 6. Claude.ai MCP Request Flow
```
Claude.ai → POST /ws/:id/mcp
└── checks Mcp-Session-Id header
├── existing session → route to cached StreamableHTTPServerTransport
└── new session → create MCP Server + StreamableHTTPServerTransport
store in httpSessions Map (keyed by session ID)
└── MCP Server handles:
├── tools/list → ConnectorRegistry.getAllTools()
│ all tools from all connected connectors (prefixed)
└── tools/call { name: "drupal-prod__get_node", arguments: {...} }
└── ConnectorRegistry.callTool()
├── strips prefix → finds connector by name
├── calls remote MCP server with original tool name
└── returns result to Claude.ai
Claude.ai GET /ws/:id/mcp ← SSE stream for streaming responses
Claude.ai DELETE /ws/:id/mcp ← close session
```
Connect Claude.ai to: `http://your-host/ws/{workspace_id}/mcp`
---
### 7. SSO / OAuth 2.0 Authorization Code Flow
```
Step 1 — Configure IdP
Admin → PATCH /ws/:id/api/idp
{ authorize_url, token_url, client_id, client_secret, scope }
└── saved to config.json idp field
Step 2 — Start SSO
Admin → GET /ws/:id/auth/sso/start
└── OAuthManager.buildStartUrl()
├── generates random state (20 bytes hex), stores with 10-min expiry
└── builds IdP redirect URL:
{authorize_url}?response_type=code
&client_id=...
&redirect_uri={origin}/ws/{id}/auth/callback
&scope=...
&state=...
└── 302 redirect → IdP login page
Step 3 — IdP Callback
IdP → GET /ws/:id/auth/callback?code=...&state=...
└── OAuthManager.handleCallback()
├── validates state (exists + not expired)
├── POST to token_url to exchange code → tokens
├── decodes JWT id_token to extract sub claim
└── WorkspaceRegistry.saveToken(workspace_id, '_service', {
access_token, refresh_token, expires_at, token_type, sub
})
└── AES-256-GCM encrypted → data/workspaces/{id}/tokens/_service.enc
└── 302 redirect → /ws/:id/ui?sso=ok
Step 4 — Token Auto-Refresh
On every tool call: WorkspaceRegistry.getToken()
└── if expires_at - 60s < now && has refresh_token
POST to idp.token_url with grant_type=refresh_token
saves updated token
└── returns current access_token
└── ConnectorRegistry injects Authorization: Bearer {access_token}
```
---
### 8. Session Verification (all protected routes)
```
_requireAdmin(req, res)
└── reads Cookie: ws_session=<token>
└── TokenStore.verifySession(token)
├── splits "data.sig"
├── recomputes HMAC-SHA256(data, key)
├── crypto.timingSafeEqual(sig, expected)
├── decodes base64url payload
└── checks payload.exp > Date.now()
└── verifies payload.workspace_id === req.params.workspace_id
└── on failure: sends { error: "..." } with status 401
```
---
### 9. Token Encryption (TokenStore)
- **Algorithm**: AES-256-GCM (authenticated encryption)
- **Key source**: `ENCRYPTION_KEY` env var (64 hex chars = 32 bytes). Falls back to `scrypt("cms-mcp-hub-dev-key", "cms-salt-v1", 32)` if unset — **not safe for production**
- **Key is cached** after first derivation — `scryptSync` only runs once per process
- **Format** stored in `.enc` files:
```json
{ "iv": "<12-byte hex>", "tag": "<16-byte hex>", "data": "<ciphertext hex>" }
```
---
## File Structure
```
cms-mcp-hub/
├── src/
│ ├── index.js entry point — creates WorkspaceRegistry + McpHubServer
│ ├── proxy/
│ │ └── McpHubServer.js Express app, all route handlers, MCP session factory
│ ├── connectors/
│ │ └── ConnectorRegistry.js connector lifecycle, transport detection, tool routing
│ ├── workspaces/
│ │ ├── WorkspaceRegistry.js workspace CRUD, per-workspace ConnectorRegistry factory
│ │ ├── OAuthManager.js OAuth 2.0 authorization code flow
│ │ └── TokenStore.js AES-256-GCM encrypt/decrypt, HMAC session sign/verify
│ └── web/
│ └── ui.js landing page + dashboard HTML (served as template strings)
└── data/
└── workspaces/
└── {workspace_id}/
├── config.json workspace name, admin_key_hash, idp config
├── connectors.json saved connector configs
└── tokens/
└── _service.enc encrypted SSO service token
```
---
## All API Endpoints
### Public (no auth required)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/` | Landing page — create or open a workspace |
| `POST` | `/api/workspaces` | Create a new workspace |
| `GET` | `/ws/:id/ui` | Workspace admin dashboard (HTML) |
| `GET` | `/ws/:id/health` | Workspace health — connectors + tool count |
**POST /api/workspaces**
```
Body: { "name": "My Org" }
Response: { "workspace_id": "ws_...", "admin_key": "ak_...", "name": "My Org" }
```
**GET /ws/:id/health**
```json
{
"status": "ok",
"workspace": "My Org",
"sso": { "connected": false },
"connectors": [{ "name": "drupal-prod", "status": "connected", "toolCount": 8 }],
"totalTools": 8
}
```
---
### Admin Auth
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/ws/:id/auth/admin` | Login with admin key — sets session cookie |
| `POST` | `/ws/:id/auth/admin/logout` | Logout — clears session cookie |
**POST /ws/:id/auth/admin**
```
Body: { "admin_key": "ak_..." }
Response: { "ok": true }
Set-Cookie: ws_session=...; HttpOnly; SameSite=Lax; Path=/ws/{id}/; Max-Age=86400
```
---
### SSO / OAuth (requires admin session)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/ws/:id/auth/sso/start` | Redirect to IdP authorization page |
| `GET` | `/ws/:id/auth/callback` | OAuth callback — handles code→token exchange |
| `GET` | `/ws/:id/auth/sso/status` | Current SSO token status |
| `POST` | `/ws/:id/auth/sso/disconnect` | Delete stored service token |
**GET /ws/:id/auth/sso/status**
```json
{ "connected": true, "expired": false, "expires_at": 1749123456789, "has_refresh": true }
{ "connected": false }
```
---
### Connector Management (requires admin session)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/ws/:id/api/connectors` | List all connectors, status, and tools |
| `POST` | `/ws/:id/api/connectors` | Add and connect a new connector |
| `PATCH` | `/ws/:id/api/connectors/:name` | Update connector config and reconnect |
| `DELETE` | `/ws/:id/api/connectors/:name` | Disconnect and remove a connector |
| `POST` | `/ws/:id/api/connectors/:name/refresh` | Re-fetch tool list (no reconnect) |
| `POST` | `/ws/:id/api/connectors/:name/reconnect` | Full disconnect + reconnect |
**POST /ws/:id/api/connectors — request body**
```json
{
"name": "drupal-prod",
"url": "https://cms.example.com/mcp",
"token": "Basic abc123",
"transport": "streamable-http",
"sseUrl": "https://cms.example.com/mcp/sse",
"description": "Production Drupal"
}
```
`token`, `transport`, `sseUrl`, `description` are all optional. If `transport` is omitted, auto-detection runs.
**GET /ws/:id/api/connectors — response item**
```json
{
"name": "drupal-prod",
"url": "https://...",
"status": "connected",
"transport": "direct-http",
"toolCount": 8,
"tools": [
{
"name": "get_node",
"title": "Get Node",
"description": "...",
"inputSchema": { "type": "object", "properties": {} }
}
],
"error": null
}
```
---
### IdP Configuration (requires admin session)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/ws/:id/api/idp` | Get current IdP config (client_secret masked) |
| `PATCH` | `/ws/:id/api/idp` | Create or update IdP config |
**PATCH /ws/:id/api/idp**
```json
{
"authorize_url": "https://idp.example.com/oauth/authorize",
"token_url": "https://idp.example.com/oauth/token",
"client_id": "your-client-id",
"client_secret": "your-secret",
"scope": "openid profile email"
}
```
Send `"client_secret": "••••••••"` (or omit it) to keep the existing secret.
---
### MCP Endpoint (Claude.ai connects here)
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/ws/:id/mcp` | Create new MCP session or send to existing |
| `GET` | `/ws/:id/mcp` | SSE stream for active session |
| `DELETE` | `/ws/:id/mcp` | Close MCP session |
New session: POST without `Mcp-Session-Id` header.
Existing session: POST/GET with `Mcp-Session-Id: <id>` header.
**In Claude.ai → Settings → Integrations**: enter `http://your-host/ws/{workspace_id}/mcp`
---
## Connector Transport Auto-Detection
When `transport` is not specified, tried in order:
| Order | Transport | Method | Works with |
|-------|-----------|--------|------------|
| 1 | **Direct HTTP** | JSON-RPC POST to `url` | Drupal `mcp_server`, simple REST MCP |
| 2 | **Streamable HTTP** | MCP SDK `StreamableHTTPClientTransport` | Modern MCP servers |
| 3 | **SSE** | MCP SDK `SSEClientTransport` (to `sseUrl ?? url`) | Legacy MCP servers |
Force a specific transport: `"transport": "streamable-http"` or `"transport": "sse"`.
---
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `PORT` | No | HTTP port (default: `3456`) |
| `ENCRYPTION_KEY` | Recommended | 64-char hex string (32 bytes) for AES-256-GCM + HMAC. Falls back to hardcoded dev key if unset |
Generate a key:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
In dev, source before starting:
```bash
export ENCRYPTION_KEY=your64hexchars
npm run dev
```
Or use a `.env` file with a loader (e.g. `dotenv`) — the `export ENCRYPTION_KEY=...` syntax in `.env.dev` only works after `source .env.dev`, not with dotenv.
---
## npm Scripts
| Script | Command | Use |
|--------|---------|-----|
| `npm start` | `node src/index.js` | Production |
| `npm run dev` | `nodemon --watch src ...` | Dev — auto-reloads on any `src/` JS/JSON change |
| `npm run sync` | `nodemon --watch connectors.json ...` | Only reloads on connector config JSON changes |
| `npm run cli` | `node src/cli/index.js` | CLI interface |
Use `npm run dev` for development. `npm run sync` does **not** watch JS files.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| Dashboard stuck on "Loading..." | JS not reloaded after code change | Hard refresh: `Ctrl+Shift+R` |
| "Session expired" overlay immediately | Cookie not set, or wrong workspace path | Log in at `POST /ws/:id/auth/admin` |
| `EADDRINUSE :::3456` | Another process on port 3456 | `kill $(lsof -ti:3456)` then restart |
| `[warn] ENCRYPTION_KEY not set` | Env var missing | Set `ENCRYPTION_KEY` before starting |
| Connector stuck in "connecting" | Remote MCP server unreachable | Check URL + network; see `/ws/:id/api/connectors` for error field |
| SSO button disabled | IdP not configured | Configure IdP first via "Configure IdP" button |
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues