@imenam/mcp-http-gateway 2.0.2 → 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/auth.js +43 -35
- package/mcp-endpoint.js +212 -148
- package/package.json +1 -1
- package/server.js +94 -4
package/auth.js
CHANGED
|
@@ -1,35 +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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
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
|
+
}
|
package/mcp-endpoint.js
CHANGED
|
@@ -1,148 +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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from '
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 {
|
|
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
package/server.js
CHANGED
|
@@ -14,6 +14,9 @@ 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
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';
|
|
17
20
|
|
|
18
21
|
// Normalise les différents formats de config rencontrés dans la nature :
|
|
19
22
|
// - `command` string + `args` array (format Cursor)
|
|
@@ -26,13 +29,33 @@ function normalizeCfg(cfg) {
|
|
|
26
29
|
return { ...cfg, command, args, env };
|
|
27
30
|
}
|
|
28
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
|
+
|
|
29
52
|
// --- MCP client ---
|
|
30
53
|
|
|
31
54
|
async function connectMcp(name, cfg) {
|
|
32
55
|
const transport = new StdioClientTransport({
|
|
33
56
|
command: cfg.command,
|
|
34
57
|
args: cfg.args ?? [],
|
|
35
|
-
env:
|
|
58
|
+
env: buildChildEnv(cfg.env),
|
|
36
59
|
});
|
|
37
60
|
const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
|
|
38
61
|
await client.connect(transport);
|
|
@@ -69,8 +92,11 @@ function registerMcpRoutes(app, route, client, name) {
|
|
|
69
92
|
|
|
70
93
|
// --- Bootstrap ---
|
|
71
94
|
|
|
95
|
+
const LOCALHOST_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
96
|
+
|
|
72
97
|
async function main() {
|
|
73
98
|
const GATEWAY_PORT = parseInt(process.env.GATEWAY_PORT ?? '3500', 10);
|
|
99
|
+
const GATEWAY_HOST = process.env.GATEWAY_HOST ?? '127.0.0.1';
|
|
74
100
|
|
|
75
101
|
// GATEWAY_CONFIG_DIR overrides the directory in which mcp-servers.json is looked up
|
|
76
102
|
// (defaults to ./config relative to cwd).
|
|
@@ -84,6 +110,65 @@ async function main() {
|
|
|
84
110
|
const app = express();
|
|
85
111
|
app.use(express.json({ type: '*/*' }));
|
|
86
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
|
+
|
|
87
172
|
const dataDir = process.env.GATEWAY_DATA_DIR ?? path.join(process.cwd(), 'data');
|
|
88
173
|
const dbPath = path.join(dataDir, 'embeddings', 'embeddings.db');
|
|
89
174
|
initDb(dbPath);
|
|
@@ -115,7 +200,7 @@ async function main() {
|
|
|
115
200
|
}
|
|
116
201
|
}
|
|
117
202
|
|
|
118
|
-
app.get('/tools/search', async (req, res) => {
|
|
203
|
+
app.get('/tools/search', searchLimiter, async (req, res) => {
|
|
119
204
|
const q = req.query.q?.trim();
|
|
120
205
|
const limit = Math.min(parseInt(req.query.limit ?? '5', 10), 20);
|
|
121
206
|
if (!q) return res.status(400).json({ error: 'Missing query parameter: q' });
|
|
@@ -168,8 +253,13 @@ async function main() {
|
|
|
168
253
|
|
|
169
254
|
registerMcpEndpoint(app, { clients, registry });
|
|
170
255
|
|
|
171
|
-
app.listen(GATEWAY_PORT, () => {
|
|
172
|
-
console.log(`[gateway] Listening on http
|
|
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);
|
|
173
263
|
});
|
|
174
264
|
|
|
175
265
|
const shutdown = async () => {
|