@imenam/mcp-http-gateway 2.0.21 → 2.0.23
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 +11 -0
- package/auth.js +3 -2
- package/mcp-endpoint.js +53 -42
- package/mcp-mode.js +4 -8
- package/mcp-registry.js +2 -2
- package/package.json +7 -3
- package/server.js +1 -1
package/README.md
CHANGED
|
@@ -612,6 +612,15 @@ Chaque `POST /mcp` est traité de façon autonome : aucun `mcp-session-id` n'est
|
|
|
612
612
|
|
|
613
613
|
`GET /mcp` et `DELETE /mcp` répondent `405 Method Not Allowed` : sans session, il n'y a ni flux SSE autonome ni terminaison de session à gérer (la spec Streamable HTTP l'autorise explicitement).
|
|
614
614
|
|
|
615
|
+
L'endpoint sert deux générations du protocole, toujours en réponse JSON :
|
|
616
|
+
|
|
617
|
+
- **2026-07-28** : le client se présente par `server/discover`, sans `initialize` ;
|
|
618
|
+
- **2025-11-25 et antérieures** : poignée de main par `initialize`.
|
|
619
|
+
|
|
620
|
+
La génération est reconnue requête par requête : un client 2026-07-28 se connecte directement, sans essuyer de refus.
|
|
621
|
+
|
|
622
|
+
Les requêtes dont l'en-tête `Host` ou `Origin` n'est pas autorisé sont refusées en `403` (protection anti-DNS-rebinding). Sont autorisés les hôtes `localhost`, celui de `MCP_PUBLIC_URL` et ceux de `GATEWAY_ALLOWED_HOSTS`, ainsi que les origines `https://claude.ai` et celles de `GATEWAY_ALLOWED_ORIGINS`. La comparaison porte sur le nom d'hôte seul, sans le port. Une requête sans en-tête `Origin` est acceptée.
|
|
623
|
+
|
|
615
624
|
### Flow
|
|
616
625
|
|
|
617
626
|
```
|
|
@@ -635,6 +644,8 @@ Le gateway agit uniquement comme **Resource Server** : il valide les JWT via JWK
|
|
|
635
644
|
| `MCP_PUBLIC_URL` | URL publique canonique du endpoint — ex. `https://gateway.example.com/mcp`. Utilisée comme `resource` (RFC 9728) et `aud` JWT. |
|
|
636
645
|
| `OAUTH_JWKS_URI` | URI du JWKS de l'Authorization Server — ex. `https://your-auth-server/.well-known/jwks.json` |
|
|
637
646
|
| `OAUTH_ISSUER` | Issuer attendu dans les JWT — ex. `https://your-auth-server` |
|
|
647
|
+
| `GATEWAY_ALLOWED_HOSTS` | Hôtes supplémentaires admis sur `/mcp`, séparés par des virgules — ex. `mon-autre-host.example.com` |
|
|
648
|
+
| `GATEWAY_ALLOWED_ORIGINS` | Origines supplémentaires admises sur `/mcp`, séparées par des virgules — ex. `https://mon-client.example.com` |
|
|
638
649
|
|
|
639
650
|
> 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`.
|
|
640
651
|
|
package/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
-
import {
|
|
2
|
+
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
|
3
3
|
|
|
4
4
|
const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED === 'true';
|
|
5
5
|
|
|
@@ -22,7 +22,8 @@ export function createMcpTokenVerifier() {
|
|
|
22
22
|
} catch (err) {
|
|
23
23
|
const ts = new Date().toISOString();
|
|
24
24
|
console.error(`[auth] ${ts} jwtVerify FAILED:`, err.code ?? err.constructor.name, err.message);
|
|
25
|
-
throw new
|
|
25
|
+
throw new OAuthError(
|
|
26
|
+
OAuthErrorCode.InvalidToken,
|
|
26
27
|
err.code === 'ERR_JWT_EXPIRED' ? 'Token has expired' : 'Invalid token'
|
|
27
28
|
);
|
|
28
29
|
}
|
package/mcp-endpoint.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { Server } from '@modelcontextprotocol/
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
ListToolsRequestSchema,
|
|
5
|
-
CallToolRequestSchema,
|
|
6
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
1
|
+
import { Server, createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server';
|
|
2
|
+
import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node';
|
|
3
|
+
import { hostHeaderValidation, originValidation } from '@modelcontextprotocol/express';
|
|
7
4
|
import { getProtectedResourceMetadata } from './auth.js';
|
|
8
5
|
import { toolDefinition } from './startup-exposition.js';
|
|
9
6
|
import { allowsMcp, allowsTool, filterTools } from './scope.js';
|
|
@@ -61,9 +58,9 @@ function buildMcpServer(registry, scope) {
|
|
|
61
58
|
return entry && allowsMcp(scope, entry.name) ? entry : null;
|
|
62
59
|
};
|
|
63
60
|
|
|
64
|
-
server.setRequestHandler(
|
|
61
|
+
server.setRequestHandler('tools/list', async () => ({ tools: allTools }));
|
|
65
62
|
|
|
66
|
-
server.setRequestHandler(
|
|
63
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
67
64
|
const { name, arguments: args = {} } = request.params;
|
|
68
65
|
|
|
69
66
|
try {
|
|
@@ -117,24 +114,29 @@ function buildMcpServer(registry, scope) {
|
|
|
117
114
|
return server;
|
|
118
115
|
}
|
|
119
116
|
|
|
117
|
+
/** Nom d'hôte, sans port, d'un hôte (`exemple.com:8443`) ou d'une origine (`https://exemple.com`). */
|
|
118
|
+
function hostnameOf(value) {
|
|
119
|
+
try {
|
|
120
|
+
return new URL(value.includes('://') ? value : `http://${value}`).hostname;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const envList = (name) => (process.env[name] ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Noms d'hôte acceptés pour la protection DNS rebinding : la vérification du SDK ignore le port.
|
|
130
|
+
*/
|
|
120
131
|
function buildDnsRebindingConfig() {
|
|
121
|
-
const port = process.env.GATEWAY_PORT ?? '3500';
|
|
122
|
-
const extraHosts = process.env.GATEWAY_ALLOWED_HOSTS
|
|
123
|
-
? process.env.GATEWAY_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
|
|
124
|
-
: [];
|
|
125
|
-
const publicHost = process.env.MCP_PUBLIC_URL
|
|
126
|
-
? (() => { try { return new URL(process.env.MCP_PUBLIC_URL).host; } catch { return null; } })()
|
|
127
|
-
: null;
|
|
128
132
|
const allowedHosts = [
|
|
129
|
-
|
|
130
|
-
...(
|
|
131
|
-
...
|
|
132
|
-
];
|
|
133
|
+
'localhost',
|
|
134
|
+
...(process.env.MCP_PUBLIC_URL ? [process.env.MCP_PUBLIC_URL] : []),
|
|
135
|
+
...envList('GATEWAY_ALLOWED_HOSTS'),
|
|
136
|
+
].map(hostnameOf).filter(Boolean);
|
|
133
137
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
: [];
|
|
137
|
-
const allowedOrigins = ['https://claude.ai', ...extraOrigins];
|
|
138
|
+
const allowedOrigins = ['https://claude.ai', ...envList('GATEWAY_ALLOWED_ORIGINS')]
|
|
139
|
+
.map(hostnameOf).filter(Boolean);
|
|
138
140
|
|
|
139
141
|
return { allowedHosts, allowedOrigins };
|
|
140
142
|
}
|
|
@@ -148,31 +150,40 @@ export function registerMcpEndpoint(app, registry) {
|
|
|
148
150
|
console.log('[gateway] DNS rebinding protection — allowedHosts:', allowedHosts);
|
|
149
151
|
console.log('[gateway] DNS rebinding protection — allowedOrigins:', allowedOrigins);
|
|
150
152
|
|
|
151
|
-
|
|
153
|
+
const logError = (err) => console.error('[mcp-endpoint] handleMcpRequest error:', err);
|
|
154
|
+
|
|
155
|
+
// Mode stateless : un Server éphémère par requête, aucun état inter-requêtes.
|
|
152
156
|
// Les trois outils exposés sont du pur requête/réponse (aucune notification serveur→client,
|
|
153
157
|
// ni sampling, ni subscription), donc aucune session n'est nécessaire.
|
|
154
|
-
// Conséquence :
|
|
158
|
+
// Conséquence : pas de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
|
|
155
159
|
// et l'endpoint supporte le scale-out multi-instances.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
enableDnsRebindingProtection: true,
|
|
162
|
-
allowedHosts,
|
|
163
|
-
allowedOrigins,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
res.on('close', () => {
|
|
167
|
-
transport.close().catch(() => {});
|
|
168
|
-
server.close().catch(() => {});
|
|
169
|
-
});
|
|
160
|
+
// Deux générations du protocole sur le même endpoint, toutes deux en réponse JSON :
|
|
161
|
+
// - révisions 2025 (qui commencent par `initialize`) : transport Streamable HTTP sans session ;
|
|
162
|
+
// - révision 2026-07-28 (dont `server/discover`) : handler par requête du SDK.
|
|
163
|
+
app.post('/mcp', hostHeaderValidation(allowedHosts), originValidation(allowedOrigins), async (req, res) => {
|
|
164
|
+
const factory = () => buildMcpServer(registry, req.scope);
|
|
170
165
|
|
|
171
166
|
try {
|
|
172
|
-
await
|
|
173
|
-
|
|
167
|
+
if (await isLegacyRequest(await toWebRequest(req, req.body), req.body)) {
|
|
168
|
+
const server = factory();
|
|
169
|
+
const transport = new NodeStreamableHTTPServerTransport({
|
|
170
|
+
sessionIdGenerator: undefined,
|
|
171
|
+
enableJsonResponse: true,
|
|
172
|
+
});
|
|
173
|
+
res.on('close', () => {
|
|
174
|
+
transport.close().catch(() => {});
|
|
175
|
+
server.close().catch(() => {});
|
|
176
|
+
});
|
|
177
|
+
await server.connect(transport);
|
|
178
|
+
await transport.handleRequest(req, res, req.body);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handler = createMcpHandler(factory, { legacy: 'reject', responseMode: 'json', onerror: logError });
|
|
183
|
+
res.on('close', () => { handler.close().catch(() => {}); });
|
|
184
|
+
await toNodeHandler(handler, { onerror: logError })(req, res, req.body);
|
|
174
185
|
} catch (err) {
|
|
175
|
-
|
|
186
|
+
logError(err);
|
|
176
187
|
if (!res.headersSent) {
|
|
177
188
|
res.status(500).json({
|
|
178
189
|
jsonrpc: '2.0',
|
package/mcp-mode.js
CHANGED
|
@@ -2,12 +2,8 @@
|
|
|
2
2
|
import 'dotenv/config';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { Server } from '@modelcontextprotocol/
|
|
6
|
-
import { StdioServerTransport } from '@modelcontextprotocol/
|
|
7
|
-
import {
|
|
8
|
-
ListToolsRequestSchema,
|
|
9
|
-
CallToolRequestSchema,
|
|
10
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { Server } from '@modelcontextprotocol/server';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
11
7
|
import {
|
|
12
8
|
EXEC_ARG, MAX_EXEC_ROUNDS, parseExecRoutes, normalizeRoute, execAnnouncement, execRequestOf, runExecRequest,
|
|
13
9
|
} from './gateway-exec.js';
|
|
@@ -705,9 +701,9 @@ if (initialProxyTools.length) {
|
|
|
705
701
|
console.error(`[mcp-mode] ${initialProxyTools.length} proxy tool(s) exposed directly from gateway`);
|
|
706
702
|
}
|
|
707
703
|
|
|
708
|
-
server.setRequestHandler(
|
|
704
|
+
server.setRequestHandler('tools/list', async () => ({ tools: allTools }));
|
|
709
705
|
|
|
710
|
-
server.setRequestHandler(
|
|
706
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
711
707
|
const { name, arguments: args = {} } = request.params;
|
|
712
708
|
|
|
713
709
|
try {
|
package/mcp-registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
import readline from 'readline';
|
|
3
|
-
import { Client } from '@modelcontextprotocol/
|
|
4
|
-
import { StdioClientTransport } from '@modelcontextprotocol/
|
|
3
|
+
import { Client } from '@modelcontextprotocol/client';
|
|
4
|
+
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
|
5
5
|
import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
|
|
6
6
|
import { HTTP_RELAY_PORT_KEY, parseRelayPort, relayPrefix } from './http-relay.js';
|
|
7
7
|
|
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.23",
|
|
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",
|
|
@@ -46,13 +46,17 @@
|
|
|
46
46
|
"homepage": "https://github.com/Matthieu-Pesnot-Pin/mcp-http-gateway#readme",
|
|
47
47
|
"license": "ISC",
|
|
48
48
|
"engines": {
|
|
49
|
-
"node": ">=
|
|
49
|
+
"node": ">=20"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@modelcontextprotocol/
|
|
52
|
+
"@modelcontextprotocol/client": "^2.4.0",
|
|
53
|
+
"@modelcontextprotocol/express": "^2.0.2",
|
|
54
|
+
"@modelcontextprotocol/node": "^2.1.2",
|
|
55
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
53
56
|
"better-sqlite3": "^12.8.0",
|
|
54
57
|
"dotenv": "^17.3.1",
|
|
55
58
|
"express": "^5.2.1",
|
|
59
|
+
"express-rate-limit": "^8.7.1",
|
|
56
60
|
"jose": "^6.2.3",
|
|
57
61
|
"openai": "^6.31.0"
|
|
58
62
|
}
|
package/server.js
CHANGED
|
@@ -26,7 +26,7 @@ import { McpRegistry, REVISION_HEADER } from './mcp-registry.js';
|
|
|
26
26
|
import {
|
|
27
27
|
FULL_SCOPE, SCOPE_HEADER, allowsMcp, allowsTool, filterTools, readScope, scopedRevision,
|
|
28
28
|
} from './scope.js';
|
|
29
|
-
import { requireBearerAuth } from '@modelcontextprotocol/
|
|
29
|
+
import { requireBearerAuth } from '@modelcontextprotocol/express';
|
|
30
30
|
import { rateLimit } from 'express-rate-limit';
|
|
31
31
|
|
|
32
32
|
// Normalise les différents formats de config rencontrés dans la nature :
|