@imenam/mcp-http-gateway 2.0.1 → 2.0.4

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 CHANGED
@@ -196,6 +196,75 @@ L'agent découvre d'abord les MCPs disponibles, explore les outils d'un MCP si b
196
196
 
197
197
  ---
198
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
+
199
268
  ## Ajouter un MCP
200
269
 
201
270
  1. Ajouter l'entrée dans `config/mcp-servers.json` avec `MCP_SERVER_ROUTE` dans `env`
package/auth.js ADDED
@@ -0,0 +1,43 @@
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
+ let payload;
15
+ try {
16
+ const issuer = process.env.OAUTH_ISSUER?.replace(/\/*$/, '/');
17
+ ({ payload } = await jwtVerify(token, JWKS, {
18
+ issuer,
19
+ audience: process.env.MCP_PUBLIC_URL,
20
+ }));
21
+ } catch (err) {
22
+ console.error('[auth] jwtVerify FAILED:', err.code ?? err.constructor.name, err.message);
23
+ throw err;
24
+ }
25
+ return {
26
+ token,
27
+ clientId: payload.client_id ?? payload.sub,
28
+ sub: payload.sub,
29
+ scopes: typeof payload.scope === 'string' ? payload.scope.split(' ') : [],
30
+ expiresAt: payload.exp,
31
+ };
32
+ },
33
+ };
34
+ }
35
+
36
+ export function getProtectedResourceMetadata() {
37
+ return {
38
+ resource: process.env.MCP_PUBLIC_URL,
39
+ authorization_servers: [process.env.OAUTH_ISSUER],
40
+ scopes_supported: ['mcp:tools'],
41
+ bearer_methods_supported: ['header'],
42
+ };
43
+ }
@@ -0,0 +1,212 @@
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 {
5
+ ListToolsRequestSchema,
6
+ CallToolRequestSchema,
7
+ isInitializeRequest,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+ import { getProtectedResourceMetadata } from './auth.js';
10
+
11
+ const tools = [
12
+ {
13
+ name: 'list_mcps',
14
+ description: 'List all MCPs available on the gateway with their route and description.',
15
+ inputSchema: { type: 'object', properties: {} },
16
+ },
17
+ {
18
+ name: 'list_tools',
19
+ description: 'List all tools available for a given MCP route.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ route: { type: 'string', description: 'MCP route as returned by list_mcps (e.g. "/documentor")' },
24
+ },
25
+ required: ['route'],
26
+ },
27
+ },
28
+ {
29
+ name: 'call_tool',
30
+ description: 'Call a tool on a given MCP route.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ route: { type: 'string', description: 'MCP route (e.g. "/documentor")' },
35
+ tool_name: { type: 'string', description: 'Tool name to call' },
36
+ arguments: { type: 'object', description: 'Arguments to pass to the tool' },
37
+ },
38
+ required: ['route', 'tool_name'],
39
+ },
40
+ },
41
+ ];
42
+
43
+ function buildMcpServer({ registry }) {
44
+ const server = new Server(
45
+ { name: 'mcp-http-gateway-remote', version: '1.0.0' },
46
+ { capabilities: { tools: {} } }
47
+ );
48
+
49
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
50
+
51
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
52
+ const { name, arguments: args = {} } = request.params;
53
+
54
+ try {
55
+ if (name === 'list_mcps') {
56
+ const text = registry
57
+ .map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
58
+ .join('\n');
59
+ return { content: [{ type: 'text', text }] };
60
+ }
61
+
62
+ if (name === 'list_tools') {
63
+ const entry = registry.find(m => m.route === args.route);
64
+ if (!entry) {
65
+ return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
66
+ }
67
+ const text = JSON.stringify(entry.tools, null, 2);
68
+ return { content: [{ type: 'text', text }] };
69
+ }
70
+
71
+ if (name === 'call_tool') {
72
+ const entry = registry.find(m => m.route === args.route);
73
+ if (!entry) {
74
+ return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
75
+ }
76
+ const result = await entry.client.callTool({
77
+ name: args.tool_name,
78
+ arguments: args.arguments ?? {},
79
+ });
80
+ return result;
81
+ }
82
+
83
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
84
+ } catch (err) {
85
+ return { content: [{ type: 'text', text: `Request failed: ${err.message}` }], isError: true };
86
+ }
87
+ });
88
+
89
+ return server;
90
+ }
91
+
92
+ function buildDnsRebindingConfig() {
93
+ const port = process.env.GATEWAY_PORT ?? '3500';
94
+ const extraHosts = process.env.GATEWAY_ALLOWED_HOSTS
95
+ ? process.env.GATEWAY_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
96
+ : [];
97
+ const publicHost = process.env.MCP_PUBLIC_URL
98
+ ? (() => { try { return new URL(process.env.MCP_PUBLIC_URL).host; } catch { return null; } })()
99
+ : null;
100
+ const allowedHosts = [
101
+ `localhost:${port}`,
102
+ ...(publicHost ? [publicHost] : []),
103
+ ...extraHosts,
104
+ ];
105
+
106
+ const extraOrigins = process.env.GATEWAY_ALLOWED_ORIGINS
107
+ ? process.env.GATEWAY_ALLOWED_ORIGINS.split(',').map(s => s.trim()).filter(Boolean)
108
+ : [];
109
+ const allowedOrigins = ['https://claude.ai', ...extraOrigins];
110
+
111
+ return { allowedHosts, allowedOrigins };
112
+ }
113
+
114
+ export function registerMcpEndpoint(app, { clients, registry }) {
115
+ app.get('/.well-known/oauth-protected-resource', (req, res) => {
116
+ res.json(getProtectedResourceMetadata());
117
+ });
118
+
119
+ const { allowedHosts, allowedOrigins } = buildDnsRebindingConfig();
120
+ console.log('[gateway] DNS rebinding protection — allowedHosts:', allowedHosts);
121
+ console.log('[gateway] DNS rebinding protection — allowedOrigins:', allowedOrigins);
122
+
123
+ const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MINUTES ?? '30', 10) * 60_000;
124
+ const SESSION_MAX = parseInt(process.env.SESSION_MAX_COUNT ?? '100', 10);
125
+ console.log(`[gateway] Session limits — TTL: ${SESSION_TTL_MS / 60_000} min, max: ${SESSION_MAX}`);
126
+
127
+ const transports = {};
128
+
129
+ const cleanupExpiredSessions = () => {
130
+ const now = Date.now();
131
+ for (const [sid, entry] of Object.entries(transports)) {
132
+ if (now - entry.lastActivity > SESSION_TTL_MS) {
133
+ console.log(`[mcp-endpoint] Session ${sid} expired (TTL), closing.`);
134
+ entry.transport.close?.();
135
+ delete transports[sid];
136
+ }
137
+ }
138
+ };
139
+
140
+ const cleanupTimer = setInterval(cleanupExpiredSessions, 60_000);
141
+ cleanupTimer.unref();
142
+
143
+ const handleMcpRequest = async (req, res) => {
144
+ try {
145
+ const sessionId = req.headers['mcp-session-id'];
146
+ const entry = sessionId ? transports[sessionId] : undefined;
147
+
148
+ let transport;
149
+
150
+ if (!entry && isInitializeRequest(req.body)) {
151
+ const activeCount = Object.keys(transports).length;
152
+ if (activeCount >= SESSION_MAX) {
153
+ console.warn(`[mcp-endpoint] Session cap reached (${activeCount}/${SESSION_MAX}), rejecting initialize.`);
154
+ return res.status(429).json({
155
+ jsonrpc: '2.0',
156
+ error: { code: -32000, message: 'Too Many Requests: session limit reached' },
157
+ id: null,
158
+ });
159
+ }
160
+
161
+ const ownerSub = req.auth?.sub ?? null;
162
+ transport = new StreamableHTTPServerTransport({
163
+ sessionIdGenerator: () => randomUUID(),
164
+ enableDnsRebindingProtection: true,
165
+ allowedHosts,
166
+ allowedOrigins,
167
+ onsessioninitialized: (sid) => {
168
+ transports[sid] = { transport, ownerSub, lastActivity: Date.now() };
169
+ },
170
+ });
171
+ transport.onclose = () => {
172
+ if (transport.sessionId) delete transports[transport.sessionId];
173
+ };
174
+ await buildMcpServer({ registry }).connect(transport);
175
+ } else if (!entry) {
176
+ return res.status(400).json({
177
+ jsonrpc: '2.0',
178
+ error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
179
+ id: null,
180
+ });
181
+ } else {
182
+ if (entry.ownerSub !== null && req.auth?.sub !== entry.ownerSub) {
183
+ console.warn(`[mcp-endpoint] Session hijacking attempt: session owned by "${entry.ownerSub}", request by "${req.auth?.sub}"`);
184
+ return res.status(403).json({
185
+ jsonrpc: '2.0',
186
+ error: { code: -32000, message: 'Forbidden: session belongs to another client' },
187
+ id: null,
188
+ });
189
+ }
190
+ entry.lastActivity = Date.now();
191
+ transport = entry.transport;
192
+ }
193
+
194
+ await transport.handleRequest(req, res, req.body);
195
+ } catch (err) {
196
+ console.error('[mcp-endpoint] handleMcpRequest error:', err);
197
+ if (!res.headersSent) {
198
+ res.status(500).json({
199
+ jsonrpc: '2.0',
200
+ error: { code: -32603, message: 'Internal server error' },
201
+ id: null,
202
+ });
203
+ }
204
+ }
205
+ };
206
+
207
+ app.post('/mcp', handleMcpRequest);
208
+ app.get('/mcp', handleMcpRequest);
209
+ app.delete('/mcp', handleMcpRequest);
210
+
211
+ console.log('[gateway] MCP remote endpoint registered at /mcp');
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-http-gateway",
3
- "version": "2.0.1",
3
+ "version": "2.0.4",
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,10 @@ 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';
17
+ import { createMcpTokenVerifier } from './auth.js';
18
+ import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
19
+ import { rateLimit } from 'express-rate-limit';
16
20
 
17
21
  // Normalise les différents formats de config rencontrés dans la nature :
18
22
  // - `command` string + `args` array (format Cursor)
@@ -25,13 +29,33 @@ function normalizeCfg(cfg) {
25
29
  return { ...cfg, command, args, env };
26
30
  }
27
31
 
32
+ // Variables système minimales transmises aux MCPs enfants.
33
+ // On exclut délibérément toutes les variables propres au gateway
34
+ // (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
35
+ const CHILD_ENV_WHITELIST = new Set([
36
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
37
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
38
+ 'TEMP', 'TMP', 'TMPDIR',
39
+ 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
40
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
41
+ 'COMSPEC', 'PATHEXT', 'USERNAME',
42
+ 'PROGRAMFILES', 'NODE_PATH',
43
+ ]);
44
+
45
+ function buildChildEnv(cfgEnv = {}) {
46
+ const safeEnv = Object.fromEntries(
47
+ Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
48
+ );
49
+ return { ...safeEnv, ...cfgEnv };
50
+ }
51
+
28
52
  // --- MCP client ---
29
53
 
30
54
  async function connectMcp(name, cfg) {
31
55
  const transport = new StdioClientTransport({
32
56
  command: cfg.command,
33
57
  args: cfg.args ?? [],
34
- env: { ...process.env, ...(cfg.env ?? {}) },
58
+ env: buildChildEnv(cfg.env),
35
59
  });
36
60
  const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
37
61
  await client.connect(transport);
@@ -68,8 +92,11 @@ function registerMcpRoutes(app, route, client, name) {
68
92
 
69
93
  // --- Bootstrap ---
70
94
 
95
+ const LOCALHOST_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
96
+
71
97
  async function main() {
72
98
  const GATEWAY_PORT = parseInt(process.env.GATEWAY_PORT ?? '3500', 10);
99
+ const GATEWAY_HOST = process.env.GATEWAY_HOST ?? '127.0.0.1';
73
100
 
74
101
  // GATEWAY_CONFIG_DIR overrides the directory in which mcp-servers.json is looked up
75
102
  // (defaults to ./config relative to cwd).
@@ -83,6 +110,65 @@ async function main() {
83
110
  const app = express();
84
111
  app.use(express.json({ type: '*/*' }));
85
112
 
113
+ const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
114
+ const globalMax = parseInt(process.env.RATE_LIMIT_GLOBAL_MAX ?? '300', 10);
115
+ const searchWindowMin = parseInt(process.env.RATE_LIMIT_SEARCH_WINDOW_MINUTES ?? '1', 10);
116
+ const searchMax = parseInt(process.env.RATE_LIMIT_SEARCH_MAX ?? '30', 10);
117
+
118
+ app.use(rateLimit({
119
+ windowMs: globalWindowMin * 60_000,
120
+ max: globalMax,
121
+ standardHeaders: 'draft-8',
122
+ legacyHeaders: false,
123
+ message: { error: 'Too Many Requests' },
124
+ }));
125
+ console.log(`[gateway] Rate limit — global: ${globalMax} req/${globalWindowMin} min/IP`);
126
+
127
+ const searchLimiter = rateLimit({
128
+ windowMs: searchWindowMin * 60_000,
129
+ max: searchMax,
130
+ standardHeaders: 'draft-8',
131
+ legacyHeaders: false,
132
+ message: { error: 'Too Many Requests' },
133
+ });
134
+ console.log(`[gateway] Rate limit — /tools/search: ${searchMax} req/${searchWindowMin} min/IP`);
135
+
136
+ const verifier = createMcpTokenVerifier();
137
+ if (verifier) {
138
+ const bearerMw = requireBearerAuth({
139
+ verifier,
140
+ requiredScopes: ['mcp:tools'],
141
+ resourceMetadataUrl: `${process.env.MCP_PUBLIC_URL?.replace(/\/mcp$/, '')}/.well-known/oauth-protected-resource`,
142
+ });
143
+
144
+ const allowedSubs = process.env.OAUTH_ALLOWED_SUBS
145
+ ? new Set(process.env.OAUTH_ALLOWED_SUBS.split(',').map(s => s.trim()).filter(Boolean))
146
+ : null;
147
+
148
+ const subAllowListMw = allowedSubs
149
+ ? (req, res, next) => {
150
+ if (!allowedSubs.has(req.auth?.sub)) {
151
+ return res.status(403).json({ error: 'Forbidden' });
152
+ }
153
+ return next();
154
+ }
155
+ : null;
156
+
157
+ app.use((req, res, next) => {
158
+ if (req.path.startsWith('/.well-known/')) return next();
159
+ return bearerMw(req, res, subAllowListMw ? () => subAllowListMw(req, res, next) : next);
160
+ });
161
+ console.log('[gateway] OAuth protection enabled — required scope: mcp:tools');
162
+ if (allowedSubs) console.log(`[gateway] Sub allow-list active (${allowedSubs.size} entr${allowedSubs.size > 1 ? 'ies' : 'y'})`);
163
+ } else if (!LOCALHOST_HOSTS.has(GATEWAY_HOST)) {
164
+ console.error('[gateway] FATAL: OAuth is disabled but GATEWAY_HOST is not localhost.');
165
+ console.error(`[gateway] Refusing to expose an unauthenticated gateway on ${GATEWAY_HOST}:${GATEWAY_PORT}.`);
166
+ console.error('[gateway] → Set MCP_OAUTH_ENABLED=true, or set GATEWAY_HOST=127.0.0.1.');
167
+ process.exit(1);
168
+ } else {
169
+ console.warn('[gateway] WARNING: OAuth is disabled — gateway is open, bound to localhost only.');
170
+ }
171
+
86
172
  const dataDir = process.env.GATEWAY_DATA_DIR ?? path.join(process.cwd(), 'data');
87
173
  const dbPath = path.join(dataDir, 'embeddings', 'embeddings.db');
88
174
  initDb(dbPath);
@@ -101,7 +187,7 @@ async function main() {
101
187
  const client = await connectMcp(name, cfg);
102
188
  const { tools } = await client.listTools();
103
189
  clients.push(client);
104
- registry.push({ name, route, description: cfg.description ?? null, tools });
190
+ registry.push({ name, route, description: cfg.description ?? null, tools, client });
105
191
  registerMcpRoutes(app, route, client, name);
106
192
  for (const tool of tools) {
107
193
  if (!tool.description) continue;
@@ -114,7 +200,7 @@ async function main() {
114
200
  }
115
201
  }
116
202
 
117
- app.get('/tools/search', async (req, res) => {
203
+ app.get('/tools/search', searchLimiter, async (req, res) => {
118
204
  const q = req.query.q?.trim();
119
205
  const limit = Math.min(parseInt(req.query.limit ?? '5', 10), 20);
120
206
  if (!q) return res.status(400).json({ error: 'Missing query parameter: q' });
@@ -165,8 +251,15 @@ async function main() {
165
251
  res.type('text/plain').send(lines.join('\n'));
166
252
  });
167
253
 
168
- app.listen(GATEWAY_PORT, () => {
169
- console.log(`[gateway] Listening on http://localhost:${GATEWAY_PORT}`);
254
+ registerMcpEndpoint(app, { clients, registry });
255
+
256
+ const httpServer = app.listen(GATEWAY_PORT, GATEWAY_HOST, () => {
257
+ console.log(`[gateway] Listening on http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
258
+ });
259
+
260
+ httpServer.on('error', (err) => {
261
+ console.error('[gateway] HTTP server error:', err.message);
262
+ process.exit(1);
170
263
  });
171
264
 
172
265
  const shutdown = async () => {