@akropolys/mcp 1.5.3
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/.env +8 -0
- package/dist/index.js +493 -0
- package/dist/test-e2e.js +652 -0
- package/dist/test.js +656 -0
- package/package.json +27 -0
- package/src/cache.ts +44 -0
- package/src/db.ts +87 -0
- package/src/index.ts +332 -0
- package/src/rateLimiter.ts +32 -0
- package/src/ssrfValidator.ts +63 -0
- package/src/test-e2e.ts +167 -0
- package/src/test.ts +170 -0
- package/src/vault.ts +51 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +11 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createClient } from 'redis';
|
|
2
|
+
import { DBProperty, DBTool } from './db.js';
|
|
3
|
+
|
|
4
|
+
export interface CachedConfig {
|
|
5
|
+
property: DBProperty;
|
|
6
|
+
tools: DBTool[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const redisUrl = process.env.UPSTASH_REDIS_URL;
|
|
10
|
+
export const redisClient = redisUrl ? createClient({ url: redisUrl }) : null;
|
|
11
|
+
|
|
12
|
+
if (redisClient) {
|
|
13
|
+
redisClient.connect().catch(err => {
|
|
14
|
+
console.error('Redis connection error:', err);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function getCachedConfig(propertyId: string): Promise<CachedConfig | null> {
|
|
19
|
+
if (!redisClient) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const cached = await redisClient.get(`mcp:config:${propertyId}`);
|
|
24
|
+
if (cached) {
|
|
25
|
+
return JSON.parse(cached) as CachedConfig;
|
|
26
|
+
}
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error('Redis cache get error:', err);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function setCachedConfig(propertyId: string, config: CachedConfig): Promise<void> {
|
|
34
|
+
if (!redisClient) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
await redisClient.set(`mcp:config:${propertyId}`, JSON.stringify(config), {
|
|
39
|
+
EX: 3600 // 1 hour TTL
|
|
40
|
+
});
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('Redis cache set error:', err);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
|
|
3
|
+
const { Pool } = pg;
|
|
4
|
+
|
|
5
|
+
export interface DBProperty {
|
|
6
|
+
id: string;
|
|
7
|
+
site_id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
api_base: string;
|
|
10
|
+
auth_type: string;
|
|
11
|
+
auth_token: string | null;
|
|
12
|
+
allow_agent_access: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DBTool {
|
|
16
|
+
id: number;
|
|
17
|
+
property_id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
method: string;
|
|
21
|
+
path: string;
|
|
22
|
+
parameters: any;
|
|
23
|
+
response_schema: any | null;
|
|
24
|
+
response_mapping: any | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Module-level pool — created once, reused for the lifetime of the stdio process.
|
|
28
|
+
// Falls back to null until first use so startup errors don't crash before main() runs.
|
|
29
|
+
let _pool: pg.Pool | null = null;
|
|
30
|
+
|
|
31
|
+
function getPool(): pg.Pool {
|
|
32
|
+
if (_pool) return _pool;
|
|
33
|
+
|
|
34
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
35
|
+
if (!dbUrl) {
|
|
36
|
+
throw new Error('DATABASE_URL environment variable is not defined');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_pool = new Pool({
|
|
40
|
+
connectionString: dbUrl,
|
|
41
|
+
max: 5,
|
|
42
|
+
idleTimeoutMillis: 30_000,
|
|
43
|
+
connectionTimeoutMillis: 5_000,
|
|
44
|
+
ssl: {
|
|
45
|
+
// Enforce TLS certificate validation.
|
|
46
|
+
// Set PGSSLROOTCERT or NODE_EXTRA_CA_CERTS if using a self-signed cert.
|
|
47
|
+
rejectUnauthorized: true,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
_pool.on('error', (err) => {
|
|
52
|
+
console.error('[db] idle client error:', err.message);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return _pool;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function fetchPropertyAndTools(propertyId: string): Promise<{ property: DBProperty; tools: DBTool[] }> {
|
|
59
|
+
const pool = getPool();
|
|
60
|
+
|
|
61
|
+
const propRes = await pool.query(
|
|
62
|
+
'SELECT id, site_id, name, api_base, auth_type, auth_token, allow_agent_access FROM developer_properties WHERE id = $1',
|
|
63
|
+
[propertyId]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (propRes.rows.length === 0) {
|
|
67
|
+
throw new Error(`Property config with ID "${propertyId}" not found in database`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const toolsRes = await pool.query(
|
|
71
|
+
'SELECT id, property_id, name, description, method, path, parameters, response_schema, response_mapping FROM developer_tools WHERE property_id = $1',
|
|
72
|
+
[propertyId]
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
property: propRes.rows[0] as DBProperty,
|
|
77
|
+
tools: toolsRes.rows as DBTool[],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Gracefully drain the pool — call on process exit. */
|
|
82
|
+
export async function closePool(): Promise<void> {
|
|
83
|
+
if (_pool) {
|
|
84
|
+
await _pool.end();
|
|
85
|
+
_pool = null;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListToolsRequestSchema,
|
|
7
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
import { fetchPropertyAndTools, closePool } from './db.js';
|
|
9
|
+
import { getCachedConfig, setCachedConfig, redisClient } from './cache.js';
|
|
10
|
+
import { LocalAESVault } from './vault.js';
|
|
11
|
+
import { assertSafeUrl } from './ssrfValidator.js';
|
|
12
|
+
import { McpRateLimiter } from './rateLimiter.js';
|
|
13
|
+
|
|
14
|
+
export { isPrivateIp, assertSafeUrl as validateUrlForSSRF } from './ssrfValidator.js';
|
|
15
|
+
|
|
16
|
+
const rateLimiter = new McpRateLimiter(redisClient);
|
|
17
|
+
|
|
18
|
+
dotenv.config();
|
|
19
|
+
|
|
20
|
+
// Property ID resolved dynamically
|
|
21
|
+
|
|
22
|
+
// Instantiate vault
|
|
23
|
+
let vault: LocalAESVault | null = null;
|
|
24
|
+
try {
|
|
25
|
+
vault = new LocalAESVault();
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
console.warn(`⚠️ WARNING: KMS Vault failed to initialize: ${err.message}. Raw tokens will be used.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getConfig() {
|
|
31
|
+
const propertyId = process.env.AKROPOLYS_PROPERTY_ID;
|
|
32
|
+
if (!propertyId) {
|
|
33
|
+
throw new Error('AKROPOLYS_PROPERTY_ID environment variable is required');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Try Redis cache first
|
|
37
|
+
const cached = await getCachedConfig(propertyId);
|
|
38
|
+
if (cached) {
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fallback to database
|
|
43
|
+
const config = await fetchPropertyAndTools(propertyId);
|
|
44
|
+
|
|
45
|
+
// Save to cache
|
|
46
|
+
await setCachedConfig(propertyId, config);
|
|
47
|
+
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getDecryptedToken(token: string | null): Promise<string | null> {
|
|
52
|
+
if (!token) return null;
|
|
53
|
+
if (!vault) return token;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
return await vault.decrypt(token);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Fallback to raw string if it's not encrypted
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getNestedValue(obj: any, path: string): any {
|
|
64
|
+
if (!obj) return undefined;
|
|
65
|
+
const parts = path.split('.');
|
|
66
|
+
let current = obj;
|
|
67
|
+
for (const part of parts) {
|
|
68
|
+
if (current === null || current === undefined) return undefined;
|
|
69
|
+
|
|
70
|
+
// Array notation support: items[0]
|
|
71
|
+
const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/);
|
|
72
|
+
if (arrayMatch) {
|
|
73
|
+
const key = arrayMatch[1];
|
|
74
|
+
const index = parseInt(arrayMatch[2], 10);
|
|
75
|
+
current = current[key];
|
|
76
|
+
if (Array.isArray(current)) {
|
|
77
|
+
current = current[index];
|
|
78
|
+
} else {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
if (Array.isArray(current)) {
|
|
83
|
+
// Traverses the array and extracts the field from each element
|
|
84
|
+
current = current.map(item => (item ? item[part] : undefined));
|
|
85
|
+
} else {
|
|
86
|
+
current = current[part];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function applyResponseMapping(payload: any, mapping: Record<string, string>): any {
|
|
94
|
+
if (!payload) return payload;
|
|
95
|
+
|
|
96
|
+
if (Array.isArray(payload)) {
|
|
97
|
+
return payload.map(item => applyResponseMapping(item, mapping));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mappedResult: Record<string, any> = {};
|
|
101
|
+
let hasMappedAny = false;
|
|
102
|
+
for (const [targetKey, sourcePath] of Object.entries(mapping)) {
|
|
103
|
+
if (typeof sourcePath === 'string') {
|
|
104
|
+
const val = getNestedValue(payload, sourcePath);
|
|
105
|
+
if (val !== undefined) {
|
|
106
|
+
mappedResult[targetKey] = val;
|
|
107
|
+
hasMappedAny = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return hasMappedAny ? mappedResult : payload;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function cleanParameterSchema(params: any): any {
|
|
115
|
+
if (!params || typeof params !== 'object') {
|
|
116
|
+
return { type: 'object', properties: {} };
|
|
117
|
+
}
|
|
118
|
+
const cleaned = JSON.parse(JSON.stringify(params));
|
|
119
|
+
if (cleaned.properties && typeof cleaned.properties === 'object') {
|
|
120
|
+
for (const key of Object.keys(cleaned.properties)) {
|
|
121
|
+
const prop = cleaned.properties[key];
|
|
122
|
+
if (prop && typeof prop === 'object') {
|
|
123
|
+
delete prop.location;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return cleaned;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const server = new Server(
|
|
131
|
+
{
|
|
132
|
+
name: 'akropolys-mcp',
|
|
133
|
+
version: '1.2.3',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
capabilities: {
|
|
137
|
+
tools: {},
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Register list tools handler
|
|
143
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
144
|
+
try {
|
|
145
|
+
const { tools } = await getConfig();
|
|
146
|
+
return {
|
|
147
|
+
tools: tools.map(t => ({
|
|
148
|
+
name: t.name,
|
|
149
|
+
description: t.description,
|
|
150
|
+
inputSchema: cleanParameterSchema(t.parameters),
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
console.error('Error listing tools:', err);
|
|
155
|
+
return {
|
|
156
|
+
tools: [],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Register call tool handler
|
|
162
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
163
|
+
const { name, arguments: args } = request.params;
|
|
164
|
+
const argumentsObj = args || {};
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const { property, tools } = await getConfig();
|
|
168
|
+
if (!property.allow_agent_access) {
|
|
169
|
+
throw new Error(`Agent access is not enabled for property "${property.id}"`);
|
|
170
|
+
}
|
|
171
|
+
const tool = tools.find(t => t.name === name);
|
|
172
|
+
if (!tool) {
|
|
173
|
+
throw new Error(`Tool "${name}" not found`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Resolve credentials
|
|
177
|
+
const decryptedAuthToken = await getDecryptedToken(property.auth_token);
|
|
178
|
+
|
|
179
|
+
// Validate parameters & map to locations
|
|
180
|
+
let resolvedPath = tool.path;
|
|
181
|
+
const queryParams: Record<string, string> = {};
|
|
182
|
+
const headers: Record<string, string> = {};
|
|
183
|
+
const bodyParams: Record<string, any> = {};
|
|
184
|
+
|
|
185
|
+
// Apply authorization headers
|
|
186
|
+
if (property.auth_type === 'bearer' && decryptedAuthToken) {
|
|
187
|
+
headers['Authorization'] = `Bearer ${decryptedAuthToken}`;
|
|
188
|
+
} else if (property.auth_type === 'api_key' && decryptedAuthToken) {
|
|
189
|
+
headers['X-API-Key'] = decryptedAuthToken;
|
|
190
|
+
headers['X-Akropolys-Token'] = decryptedAuthToken;
|
|
191
|
+
headers['Authorization'] = decryptedAuthToken;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const paramDefs = tool.parameters?.properties || {};
|
|
195
|
+
const requiredParams = tool.parameters?.required || [];
|
|
196
|
+
|
|
197
|
+
// Check required parameters
|
|
198
|
+
for (const reqField of requiredParams) {
|
|
199
|
+
if (argumentsObj[reqField] === undefined) {
|
|
200
|
+
throw new Error(`Missing required parameter: "${reqField}"`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Distribute parameters to their locations
|
|
205
|
+
for (const [key, val] of Object.entries(argumentsObj)) {
|
|
206
|
+
const def = paramDefs[key] || {};
|
|
207
|
+
const location = def.location || 'body';
|
|
208
|
+
|
|
209
|
+
if (location === 'path') {
|
|
210
|
+
resolvedPath = resolvedPath
|
|
211
|
+
.replace(new RegExp(`:${key}`, 'g'), String(val))
|
|
212
|
+
.replace(new RegExp(`{${key}}`, 'g'), String(val));
|
|
213
|
+
} else if (location === 'query') {
|
|
214
|
+
queryParams[key] = String(val);
|
|
215
|
+
} else if (location === 'header') {
|
|
216
|
+
headers[key] = String(val);
|
|
217
|
+
} else if (location === 'body') {
|
|
218
|
+
bodyParams[key] = val;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Build URL
|
|
223
|
+
const baseUrl = property.api_base.replace(/\/+$/, '');
|
|
224
|
+
const urlPath = resolvedPath.replace(/^\/+/, '');
|
|
225
|
+
let fullUrl = `${baseUrl}/${urlPath}`;
|
|
226
|
+
|
|
227
|
+
if (Object.keys(queryParams).length > 0) {
|
|
228
|
+
const qs = new URLSearchParams(queryParams).toString();
|
|
229
|
+
fullUrl += `?${qs}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const requestOptions: RequestInit = {
|
|
233
|
+
method: tool.method.toUpperCase(),
|
|
234
|
+
headers,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (['POST', 'PUT', 'PATCH'].includes(tool.method.toUpperCase())) {
|
|
238
|
+
if (!headers['Content-Type']) {
|
|
239
|
+
headers['Content-Type'] = 'application/json';
|
|
240
|
+
}
|
|
241
|
+
requestOptions.body = JSON.stringify(bodyParams);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Assert rate limits
|
|
245
|
+
await rateLimiter.assertAllowed(property.id);
|
|
246
|
+
|
|
247
|
+
// Validate target URL for SSRF protection.
|
|
248
|
+
// assertSafeUrl returns the IP-substituted URL to use for the actual fetch,
|
|
249
|
+
// closing the DNS-rebinding window between validation and connection.
|
|
250
|
+
const { safeUrl, hostHeader } = await assertSafeUrl(fullUrl);
|
|
251
|
+
headers['Host'] = hostHeader;
|
|
252
|
+
|
|
253
|
+
// Execute request using the pre-validated IP URL
|
|
254
|
+
const response = await fetch(safeUrl, requestOptions);
|
|
255
|
+
const text = await response.text();
|
|
256
|
+
let jsonPayload: any;
|
|
257
|
+
try {
|
|
258
|
+
jsonPayload = JSON.parse(text);
|
|
259
|
+
} catch {
|
|
260
|
+
jsonPayload = { responseText: text };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
return {
|
|
265
|
+
content: [
|
|
266
|
+
{
|
|
267
|
+
type: 'text',
|
|
268
|
+
text: `API request failed with status ${response.status}: ${JSON.stringify(jsonPayload)}`,
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
isError: true,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Normalize response using mapping
|
|
276
|
+
let normalized = jsonPayload;
|
|
277
|
+
if (
|
|
278
|
+
tool.response_mapping &&
|
|
279
|
+
typeof tool.response_mapping === 'object' &&
|
|
280
|
+
Object.keys(tool.response_mapping).length > 0
|
|
281
|
+
) {
|
|
282
|
+
normalized = applyResponseMapping(jsonPayload, tool.response_mapping);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
content: [
|
|
287
|
+
{
|
|
288
|
+
type: 'text',
|
|
289
|
+
text: JSON.stringify(normalized, null, 2),
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
} catch (err: any) {
|
|
294
|
+
console.error(`Error executing tool "${name}":`, err);
|
|
295
|
+
return {
|
|
296
|
+
content: [
|
|
297
|
+
{
|
|
298
|
+
type: 'text',
|
|
299
|
+
text: `Error: ${err.message}`,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
isError: true,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
async function main() {
|
|
308
|
+
const propertyId = process.env.AKROPOLYS_PROPERTY_ID;
|
|
309
|
+
if (!propertyId) {
|
|
310
|
+
console.error('❌ ERROR: AKROPOLYS_PROPERTY_ID environment variable is required');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
const transport = new StdioServerTransport();
|
|
314
|
+
await server.connect(transport);
|
|
315
|
+
console.error(`✓ Akropolys MCP Proxy Server running for property: ${propertyId}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
319
|
+
// Gracefully drain the DB pool on exit signals
|
|
320
|
+
const shutdown = async (signal: string) => {
|
|
321
|
+
console.error(`[mcp] ${signal} received — shutting down`);
|
|
322
|
+
await closePool();
|
|
323
|
+
process.exit(0);
|
|
324
|
+
};
|
|
325
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
326
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
327
|
+
|
|
328
|
+
main().catch(err => {
|
|
329
|
+
console.error('Fatal error in MCP server:', err);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class McpRateLimiter {
|
|
2
|
+
private client: any;
|
|
3
|
+
private limit: number;
|
|
4
|
+
private windowSeconds: number;
|
|
5
|
+
|
|
6
|
+
constructor(redisClient: any, limit = 100, windowSeconds = 60) {
|
|
7
|
+
this.client = redisClient;
|
|
8
|
+
this.limit = limit;
|
|
9
|
+
this.windowSeconds = windowSeconds;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async assertAllowed(propertyId: string): Promise<void> {
|
|
13
|
+
if (!this.client) {
|
|
14
|
+
// Redis not configured — fail closed: deny the request.
|
|
15
|
+
// Without rate limiting we cannot safely allow agent traffic.
|
|
16
|
+
throw new Error('Rate limiting is not configured (Redis unavailable). Request denied.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const key = `mcp:ratelimit:${propertyId}`;
|
|
20
|
+
|
|
21
|
+
// Propagate ALL errors — never silently bypass.
|
|
22
|
+
const current = await this.client.incr(key);
|
|
23
|
+
if (current === 1) {
|
|
24
|
+
await this.client.expire(key, this.windowSeconds);
|
|
25
|
+
}
|
|
26
|
+
if (current > this.limit) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Rate limit exceeded: property "${propertyId}" has exceeded the limit of ${this.limit} requests per ${this.windowSeconds} seconds.`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import dns from 'dns';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const lookup = promisify(dns.lookup);
|
|
5
|
+
|
|
6
|
+
export function isPrivateIp(ip: string): boolean {
|
|
7
|
+
const ipv4MappedMatch = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
8
|
+
const normalizedIp = ipv4MappedMatch ? ipv4MappedMatch[1] : ip;
|
|
9
|
+
|
|
10
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(normalizedIp)) {
|
|
11
|
+
const parts = normalizedIp.split('.').map(Number);
|
|
12
|
+
if (parts.some(isNaN) || parts.length !== 4) return true;
|
|
13
|
+
|
|
14
|
+
if (parts[0] === 127) return true;
|
|
15
|
+
if (parts[0] === 10) return true;
|
|
16
|
+
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
|
|
17
|
+
if (parts[0] === 192 && parts[1] === 168) return true;
|
|
18
|
+
if (parts[0] === 169 && parts[1] === 254) return true; // link-local
|
|
19
|
+
if (parts[0] === 0) return true;
|
|
20
|
+
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// IPv6 checks
|
|
25
|
+
if (normalizedIp === '::1' || normalizedIp === '::') return true;
|
|
26
|
+
if (/^fe[89ab]/i.test(normalizedIp)) return true; // link-local (fe80::/10)
|
|
27
|
+
if (/^f[cd]/i.test(normalizedIp)) return true; // ULA (fc00::/7)
|
|
28
|
+
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validates that the target URL does not resolve to a private/loopback address
|
|
34
|
+
* (SSRF protection) and returns a **safe fetch URL** where the hostname has been
|
|
35
|
+
* replaced by the pre-validated IP address.
|
|
36
|
+
*
|
|
37
|
+
* Using the returned safeUrl for the actual fetch() call closes the DNS-rebinding
|
|
38
|
+
* window: the hostname cannot re-resolve to a different (private) IP between the
|
|
39
|
+
* validation check and the connection.
|
|
40
|
+
*
|
|
41
|
+
* @returns An object with `safeUrl` (connect to this) and `hostHeader` (send as Host:).
|
|
42
|
+
*/
|
|
43
|
+
export async function assertSafeUrl(urlStr: string): Promise<{ safeUrl: string; hostHeader: string }> {
|
|
44
|
+
const url = new URL(urlStr);
|
|
45
|
+
const hostname = url.hostname;
|
|
46
|
+
|
|
47
|
+
// Resolve once — we use this resolved IP for the actual connection.
|
|
48
|
+
const res = await lookup(hostname);
|
|
49
|
+
|
|
50
|
+
if (isPrivateIp(res.address)) {
|
|
51
|
+
throw new Error(`SSRF Prevention: outbound requests to private IP address ${res.address} (resolved from "${hostname}") are prohibited`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Build a URL that connects directly to the resolved IP so DNS cannot rebind.
|
|
55
|
+
const safeUrl = new URL(urlStr);
|
|
56
|
+
safeUrl.hostname = res.address;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
safeUrl: safeUrl.toString(),
|
|
60
|
+
hostHeader: hostname, // caller must forward this as the Host header
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|