@imenam/mcp-http-gateway 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -2
- package/auth.js +35 -0
- package/mcp-endpoint.js +148 -0
- package/package.json +4 -1
- package/server.js +10 -5
package/README.md
CHANGED
|
@@ -45,11 +45,17 @@ npm install @imenam/mcp-http-gateway
|
|
|
45
45
|
|
|
46
46
|
```env
|
|
47
47
|
GATEWAY_PORT=3500
|
|
48
|
+
GATEWAY_CONFIG_DIR=./config
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
| Variable | Défaut | Description |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `GATEWAY_PORT` | `3500` | Port HTTP du gateway |
|
|
54
|
+
| `GATEWAY_CONFIG_DIR` | `./config` (relatif au cwd) | Dossier contenant `mcp-servers.json` |
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
### `mcp-servers.json`
|
|
57
|
+
|
|
58
|
+
À créer dans le dossier de configuration (`./config` par défaut, ou le chemin pointé par `GATEWAY_CONFIG_DIR`). Un fichier `config/mcp-servers.example.json` est fourni comme point de départ.
|
|
53
59
|
|
|
54
60
|
Même format que la configuration `mcpServers` de Cursor. Ajouter `MCP_SERVER_ROUTE` dans `env` pour définir la route HTTP du MCP, et `description` pour la documentation.
|
|
55
61
|
|
|
@@ -190,6 +196,75 @@ L'agent découvre d'abord les MCPs disponibles, explore les outils d'un MCP si b
|
|
|
190
196
|
|
|
191
197
|
---
|
|
192
198
|
|
|
199
|
+
## Mode MCP distant (HTTPS / Claude.ai)
|
|
200
|
+
|
|
201
|
+
Le gateway expose également un **endpoint MCP natif** sur `/mcp` via le transport **Streamable HTTP** (sessions `mcp-session-id`). Ce mode permet à Claude.ai — et à tout client MCP distant compatible — de se connecter directement au gateway, sans intermédiaire.
|
|
202
|
+
|
|
203
|
+
### Flow
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
Claude.ai
|
|
207
|
+
│ HTTPS + Bearer JWT
|
|
208
|
+
▼
|
|
209
|
+
[mcp-http-gateway /mcp] ──── 401 + WWW-Authenticate si non authentifié
|
|
210
|
+
│
|
|
211
|
+
│ (découverte OAuth 2.1)
|
|
212
|
+
▼
|
|
213
|
+
[Authorization Server externe] (Auth0, Keycloak, Authentik, Zitadel, Logto…)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Le gateway agit uniquement comme **Resource Server** : il valide les JWT via JWKS et expose l'URL de l'AS via le Protected Resource Metadata (RFC 9728). Toute la logique OAuth (DCR, PKCE, `/authorize`, `/token`) est déléguée à l'AS externe.
|
|
217
|
+
|
|
218
|
+
### Variables d'environnement
|
|
219
|
+
|
|
220
|
+
| Variable | Description |
|
|
221
|
+
|---|---|
|
|
222
|
+
| `MCP_OAUTH_ENABLED` | `true` pour activer la protection OAuth (recommandé en production) |
|
|
223
|
+
| `MCP_PUBLIC_URL` | URL publique canonique du endpoint — ex. `https://gateway.example.com/mcp`. Utilisée comme `resource` (RFC 9728) et `aud` JWT. |
|
|
224
|
+
| `OAUTH_JWKS_URI` | URI du JWKS de l'Authorization Server — ex. `https://your-auth-server/.well-known/jwks.json` |
|
|
225
|
+
| `OAUTH_ISSUER` | Issuer attendu dans les JWT — ex. `https://your-auth-server` |
|
|
226
|
+
|
|
227
|
+
> Sans `MCP_OAUTH_ENABLED=true`, `/mcp` est ouvert (utile pour les tests locaux). Claude.ai refusera cependant de s'y connecter sans 401 + `WWW-Authenticate`.
|
|
228
|
+
|
|
229
|
+
### Configuration Claude.ai
|
|
230
|
+
|
|
231
|
+
Dans Claude.ai → **Paramètres → Connecteurs** → Ajouter un connecteur MCP :
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
URL : https://gateway.example.com/mcp
|
|
235
|
+
Authentification : OAuth (auto-détectée via /.well-known/oauth-protected-resource)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Prérequis côté Authorization Server
|
|
239
|
+
|
|
240
|
+
L'AS doit supporter :
|
|
241
|
+
- **DCR** (RFC 7591) — `registration_endpoint` exposé
|
|
242
|
+
- **PKCE S256** — `code_challenge_methods_supported: ["S256"]`
|
|
243
|
+
- `redirect_uri` autorisé : `https://claude.ai/api/mcp/auth_callback`
|
|
244
|
+
- JWT signés (RS256 ou ES256) avec claims `iss`, `aud=MCP_PUBLIC_URL`, `exp`
|
|
245
|
+
|
|
246
|
+
### Exposition HTTPS publique
|
|
247
|
+
|
|
248
|
+
Le endpoint `/mcp` doit être accessible depuis l'IP range `160.79.104.0/21` (Anthropic) en HTTPS. Options :
|
|
249
|
+
|
|
250
|
+
| Option | Commande / Notes |
|
|
251
|
+
|---|---|
|
|
252
|
+
| **Cloudflare Tunnel** | `cloudflared tunnel --url http://localhost:3500` |
|
|
253
|
+
| **ngrok** | `ngrok http 3500` — idéal pour les tests |
|
|
254
|
+
| **Reverse-proxy** | Caddy / Traefik en frontal avec cert Let's Encrypt |
|
|
255
|
+
|
|
256
|
+
### Outils exposés
|
|
257
|
+
|
|
258
|
+
Les mêmes 3 outils que le mode stdio — avec accès direct aux clients MCP internes (sans repasser par HTTP) :
|
|
259
|
+
|
|
260
|
+
| Outil | Description |
|
|
261
|
+
|---|---|
|
|
262
|
+
| `list_mcps` | Liste les MCPs disponibles avec leur route et description |
|
|
263
|
+
| `list_tools` | Liste les outils d'un MCP donné (`route` requis) |
|
|
264
|
+
| `call_tool` | Appelle un outil (`route`, `tool_name`, `arguments`) |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
193
268
|
## Ajouter un MCP
|
|
194
269
|
|
|
195
270
|
1. Ajouter l'entrée dans `config/mcp-servers.json` avec `MCP_SERVER_ROUTE` dans `env`
|
package/auth.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
+
|
|
3
|
+
const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED === 'true';
|
|
4
|
+
|
|
5
|
+
let JWKS;
|
|
6
|
+
if (OAUTH_ENABLED) {
|
|
7
|
+
JWKS = createRemoteJWKSet(new URL(process.env.OAUTH_JWKS_URI));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createMcpTokenVerifier() {
|
|
11
|
+
if (!OAUTH_ENABLED) return null;
|
|
12
|
+
return {
|
|
13
|
+
async verifyAccessToken(token) {
|
|
14
|
+
const { payload } = await jwtVerify(token, JWKS, {
|
|
15
|
+
issuer: process.env.OAUTH_ISSUER,
|
|
16
|
+
audience: process.env.MCP_PUBLIC_URL,
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
token,
|
|
20
|
+
clientId: payload.client_id ?? payload.sub,
|
|
21
|
+
scopes: typeof payload.scope === 'string' ? payload.scope.split(' ') : [],
|
|
22
|
+
expiresAt: payload.exp,
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getProtectedResourceMetadata() {
|
|
29
|
+
return {
|
|
30
|
+
resource: process.env.MCP_PUBLIC_URL,
|
|
31
|
+
authorization_servers: [process.env.OAUTH_ISSUER],
|
|
32
|
+
scopes_supported: ['mcp:tools'],
|
|
33
|
+
bearer_methods_supported: ['header'],
|
|
34
|
+
};
|
|
35
|
+
}
|
package/mcp-endpoint.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
4
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
5
|
+
import {
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
CallToolRequestSchema,
|
|
8
|
+
isInitializeRequest,
|
|
9
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { createMcpTokenVerifier, getProtectedResourceMetadata } from './auth.js';
|
|
11
|
+
|
|
12
|
+
const tools = [
|
|
13
|
+
{
|
|
14
|
+
name: 'list_mcps',
|
|
15
|
+
description: 'List all MCPs available on the gateway with their route and description.',
|
|
16
|
+
inputSchema: { type: 'object', properties: {} },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'list_tools',
|
|
20
|
+
description: 'List all tools available for a given MCP route.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
route: { type: 'string', description: 'MCP route as returned by list_mcps (e.g. "/documentor")' },
|
|
25
|
+
},
|
|
26
|
+
required: ['route'],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'call_tool',
|
|
31
|
+
description: 'Call a tool on a given MCP route.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
route: { type: 'string', description: 'MCP route (e.g. "/documentor")' },
|
|
36
|
+
tool_name: { type: 'string', description: 'Tool name to call' },
|
|
37
|
+
arguments: { type: 'object', description: 'Arguments to pass to the tool' },
|
|
38
|
+
},
|
|
39
|
+
required: ['route', 'tool_name'],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function buildMcpServer({ registry }) {
|
|
45
|
+
const server = new Server(
|
|
46
|
+
{ name: 'mcp-http-gateway-remote', version: '1.0.0' },
|
|
47
|
+
{ capabilities: { tools: {} } }
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
51
|
+
|
|
52
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
53
|
+
const { name, arguments: args = {} } = request.params;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (name === 'list_mcps') {
|
|
57
|
+
const text = registry
|
|
58
|
+
.map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
|
|
59
|
+
.join('\n');
|
|
60
|
+
return { content: [{ type: 'text', text }] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (name === 'list_tools') {
|
|
64
|
+
const entry = registry.find(m => m.route === args.route);
|
|
65
|
+
if (!entry) {
|
|
66
|
+
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
67
|
+
}
|
|
68
|
+
const text = JSON.stringify(entry.tools, null, 2);
|
|
69
|
+
return { content: [{ type: 'text', text }] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (name === 'call_tool') {
|
|
73
|
+
const entry = registry.find(m => m.route === args.route);
|
|
74
|
+
if (!entry) {
|
|
75
|
+
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
76
|
+
}
|
|
77
|
+
const result = await entry.client.callTool({
|
|
78
|
+
name: args.tool_name,
|
|
79
|
+
arguments: args.arguments ?? {},
|
|
80
|
+
});
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return { content: [{ type: 'text', text: `Request failed: ${err.message}` }], isError: true };
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return server;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function registerMcpEndpoint(app, { clients, registry }) {
|
|
94
|
+
app.get('/.well-known/oauth-protected-resource', (req, res) => {
|
|
95
|
+
res.json(getProtectedResourceMetadata());
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const verifier = createMcpTokenVerifier();
|
|
99
|
+
const authMw = verifier
|
|
100
|
+
? requireBearerAuth({
|
|
101
|
+
verifier,
|
|
102
|
+
requiredScopes: [],
|
|
103
|
+
resourceMetadataUrl: `${process.env.MCP_PUBLIC_URL?.replace(/\/mcp$/, '')}/.well-known/oauth-protected-resource`,
|
|
104
|
+
})
|
|
105
|
+
: (req, res, next) => next();
|
|
106
|
+
|
|
107
|
+
const transports = {};
|
|
108
|
+
|
|
109
|
+
const handleMcpRequest = async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
112
|
+
let transport = sessionId ? transports[sessionId] : undefined;
|
|
113
|
+
|
|
114
|
+
if (!transport && isInitializeRequest(req.body)) {
|
|
115
|
+
transport = new StreamableHTTPServerTransport({
|
|
116
|
+
sessionIdGenerator: () => randomUUID(),
|
|
117
|
+
onsessioninitialized: (sid) => { transports[sid] = transport; },
|
|
118
|
+
});
|
|
119
|
+
transport.onclose = () => {
|
|
120
|
+
if (transport.sessionId) delete transports[transport.sessionId];
|
|
121
|
+
};
|
|
122
|
+
await buildMcpServer({ registry }).connect(transport);
|
|
123
|
+
} else if (!transport) {
|
|
124
|
+
return res.status(400).json({
|
|
125
|
+
jsonrpc: '2.0',
|
|
126
|
+
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
|
|
127
|
+
id: null,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
await transport.handleRequest(req, res, req.body);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error('[mcp-endpoint] handleMcpRequest error:', err.message);
|
|
133
|
+
if (!res.headersSent) {
|
|
134
|
+
res.status(500).json({
|
|
135
|
+
jsonrpc: '2.0',
|
|
136
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
137
|
+
id: null,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
app.post('/mcp', authMw, handleMcpRequest);
|
|
144
|
+
app.get('/mcp', authMw, handleMcpRequest);
|
|
145
|
+
app.delete('/mcp', authMw, handleMcpRequest);
|
|
146
|
+
|
|
147
|
+
console.log('[gateway] MCP remote endpoint registered at /mcp');
|
|
148
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imenam/mcp-http-gateway",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "HTTP gateway that exposes MCP servers as REST routes — bridge between MCP and any HTTP-capable agent",
|
|
6
6
|
"main": "server.js",
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"server.js",
|
|
12
12
|
"mcp-mode.js",
|
|
13
|
+
"mcp-endpoint.js",
|
|
14
|
+
"auth.js",
|
|
13
15
|
"README.md",
|
|
14
16
|
"embeddings/"
|
|
15
17
|
],
|
|
@@ -45,6 +47,7 @@
|
|
|
45
47
|
"better-sqlite3": "^12.8.0",
|
|
46
48
|
"dotenv": "^17.3.1",
|
|
47
49
|
"express": "^5.2.1",
|
|
50
|
+
"jose": "^6.2.3",
|
|
48
51
|
"openai": "^6.31.0"
|
|
49
52
|
}
|
|
50
53
|
}
|
package/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
|
13
13
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
14
14
|
import { initDb, getOrComputeToolEmbedding, getAllToolEmbeddings } from './embeddings/db.js';
|
|
15
15
|
import { getEmbedding, cosineSimilarity } from './embeddings/vectorizer.js';
|
|
16
|
+
import { registerMcpEndpoint } from './mcp-endpoint.js';
|
|
16
17
|
|
|
17
18
|
// Normalise les différents formats de config rencontrés dans la nature :
|
|
18
19
|
// - `command` string + `args` array (format Cursor)
|
|
@@ -71,10 +72,12 @@ function registerMcpRoutes(app, route, client, name) {
|
|
|
71
72
|
async function main() {
|
|
72
73
|
const GATEWAY_PORT = parseInt(process.env.GATEWAY_PORT ?? '3500', 10);
|
|
73
74
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
// GATEWAY_CONFIG_DIR overrides the directory in which mcp-servers.json is looked up
|
|
76
|
+
// (defaults to ./config relative to cwd).
|
|
77
|
+
const configDir = process.env.GATEWAY_CONFIG_DIR
|
|
78
|
+
? path.resolve(process.env.GATEWAY_CONFIG_DIR)
|
|
79
|
+
: path.join(process.cwd(), 'config');
|
|
80
|
+
const configPath = path.join(configDir, 'mcp-servers.json');
|
|
78
81
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
79
82
|
const servers = config.mcpServers ?? {};
|
|
80
83
|
|
|
@@ -99,7 +102,7 @@ async function main() {
|
|
|
99
102
|
const client = await connectMcp(name, cfg);
|
|
100
103
|
const { tools } = await client.listTools();
|
|
101
104
|
clients.push(client);
|
|
102
|
-
registry.push({ name, route, description: cfg.description ?? null, tools });
|
|
105
|
+
registry.push({ name, route, description: cfg.description ?? null, tools, client });
|
|
103
106
|
registerMcpRoutes(app, route, client, name);
|
|
104
107
|
for (const tool of tools) {
|
|
105
108
|
if (!tool.description) continue;
|
|
@@ -163,6 +166,8 @@ async function main() {
|
|
|
163
166
|
res.type('text/plain').send(lines.join('\n'));
|
|
164
167
|
});
|
|
165
168
|
|
|
169
|
+
registerMcpEndpoint(app, { clients, registry });
|
|
170
|
+
|
|
166
171
|
app.listen(GATEWAY_PORT, () => {
|
|
167
172
|
console.log(`[gateway] Listening on http://localhost:${GATEWAY_PORT}`);
|
|
168
173
|
});
|