@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/test-e2e.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
process.env.NODE_ENV = 'test';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
dotenv.config();
|
|
4
|
+
import assert from 'assert';
|
|
5
|
+
import { Client } from 'pg';
|
|
6
|
+
import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { LocalAESVault } from './vault';
|
|
8
|
+
import { fetchPropertyAndTools } from './db';
|
|
9
|
+
|
|
10
|
+
// We use httpbin.org to echo back our request and verify mapping
|
|
11
|
+
async function runE2E() {
|
|
12
|
+
console.log('๐งช Starting E2E Integration Test...');
|
|
13
|
+
|
|
14
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
15
|
+
if (!dbUrl) {
|
|
16
|
+
console.error('โ ๏ธ Skipping E2E test: DATABASE_URL not set');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const kmsKey = 'e2e-kms-secret-master-key';
|
|
21
|
+
process.env.AKROPOLYS_KMS_MASTER_KEY = kmsKey;
|
|
22
|
+
process.env.AKROPOLYS_PROPERTY_ID = 'prop_e2e_test';
|
|
23
|
+
|
|
24
|
+
const client = new Client({
|
|
25
|
+
connectionString: dbUrl,
|
|
26
|
+
ssl: { rejectUnauthorized: false }
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await client.connect();
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// 1. Setup - encrypt a mock token
|
|
33
|
+
const vault = new LocalAESVault();
|
|
34
|
+
const mockPlaintextToken = 'my-super-secret-token-value';
|
|
35
|
+
const encryptedToken = await vault.encrypt(mockPlaintextToken);
|
|
36
|
+
|
|
37
|
+
// Clean any residue first
|
|
38
|
+
await client.query('DELETE FROM developer_tools WHERE property_id = $1', ['prop_e2e_test']);
|
|
39
|
+
await client.query('DELETE FROM developer_properties WHERE id = $1', ['prop_e2e_test']);
|
|
40
|
+
|
|
41
|
+
// Clean Redis cache key
|
|
42
|
+
const redisUrl = process.env.UPSTASH_REDIS_URL;
|
|
43
|
+
if (redisUrl) {
|
|
44
|
+
const { createClient } = await import('redis');
|
|
45
|
+
const redisClient = createClient({ url: redisUrl });
|
|
46
|
+
await redisClient.connect();
|
|
47
|
+
await redisClient.del('mcp:config:prop_e2e_test');
|
|
48
|
+
await redisClient.disconnect();
|
|
49
|
+
console.log('Cleared Redis cache for "prop_e2e_test"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Insert mock developer property (using the real sites table to link if needed)
|
|
53
|
+
// Let's get an existing site ID to avoid foreign key failure.
|
|
54
|
+
const siteRes = await client.query('SELECT id FROM sites LIMIT 1');
|
|
55
|
+
if (siteRes.rows.length === 0) {
|
|
56
|
+
throw new Error('No sites found in the database. Cannot link property.');
|
|
57
|
+
}
|
|
58
|
+
const siteId = siteRes.rows[0].id;
|
|
59
|
+
|
|
60
|
+
console.log(`Inserting mock property "prop_e2e_test" linked to site: ${siteId}`);
|
|
61
|
+
await client.query(`
|
|
62
|
+
INSERT INTO developer_properties (id, site_id, name, api_base, auth_type, auth_token)
|
|
63
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
64
|
+
`, ['prop_e2e_test', siteId, 'E2E Test Store', 'https://httpbin.org', 'bearer', encryptedToken]);
|
|
65
|
+
|
|
66
|
+
// 3. Insert mock tool config
|
|
67
|
+
// path: /anything/products/:sku
|
|
68
|
+
// response_mapping: { "sku_echoed": "json.sku", "header_echoed": "headers.X-Test-Header", "method_echoed": "method", "token_echoed": "headers.Authorization" }
|
|
69
|
+
const parameters = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
sku: { type: 'string', location: 'path', description: 'Product SKU' },
|
|
73
|
+
qty: { type: 'integer', location: 'query', description: 'Quantity' },
|
|
74
|
+
'x-test-header': { type: 'string', location: 'header', description: 'Custom Header' },
|
|
75
|
+
note: { type: 'string', location: 'body', description: 'Body note' }
|
|
76
|
+
},
|
|
77
|
+
required: ['sku']
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const responseMapping = {
|
|
81
|
+
url_echoed: 'url',
|
|
82
|
+
header_echoed: 'headers.X-Test-Header',
|
|
83
|
+
method_echoed: 'method',
|
|
84
|
+
token_echoed: 'headers.Authorization',
|
|
85
|
+
note_echoed: 'json.note'
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
console.log('Inserting mock tool "get_product_e2e"');
|
|
89
|
+
await client.query(`
|
|
90
|
+
INSERT INTO developer_tools (property_id, name, description, method, path, parameters, response_mapping)
|
|
91
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
92
|
+
`, [
|
|
93
|
+
'prop_e2e_test',
|
|
94
|
+
'get_product_e2e',
|
|
95
|
+
'Mock tool for testing E2E proxy parameters mapping',
|
|
96
|
+
'POST',
|
|
97
|
+
'/anything/products/:sku',
|
|
98
|
+
JSON.stringify(parameters),
|
|
99
|
+
JSON.stringify(responseMapping)
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const rawResp = await fetch('https://httpbin.org/anything/products/SKU-777-XYZ', {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ note: 'test' })
|
|
106
|
+
});
|
|
107
|
+
const rawJson = await rawResp.json();
|
|
108
|
+
console.log('Raw httpbin keys:', Object.keys(rawJson));
|
|
109
|
+
console.log('Raw httpbin url field is:', rawJson.url);
|
|
110
|
+
|
|
111
|
+
// 4. Import index dynamically and invoke call tool logic manually
|
|
112
|
+
const { server } = await import('./index');
|
|
113
|
+
|
|
114
|
+
// We can directly call the registered handler
|
|
115
|
+
// Find the handler for CallToolRequestSchema
|
|
116
|
+
const handlersMap: Map<any, any> = (server as any)._requestHandlers;
|
|
117
|
+
const callHandlerInfo = handlersMap.get('tools/call');
|
|
118
|
+
|
|
119
|
+
if (!callHandlerInfo) {
|
|
120
|
+
throw new Error('CallToolRequestSchema handler not registered on server');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const callHandler = callHandlerInfo;
|
|
124
|
+
|
|
125
|
+
console.log('Executing mock tool call with arguments...');
|
|
126
|
+
const result = await callHandler({
|
|
127
|
+
method: 'tools/call',
|
|
128
|
+
params: {
|
|
129
|
+
name: 'get_product_e2e',
|
|
130
|
+
arguments: {
|
|
131
|
+
sku: 'SKU-777-XYZ',
|
|
132
|
+
qty: 5,
|
|
133
|
+
'x-test-header': 'Akropolys-Integration-Test',
|
|
134
|
+
note: 'E2E Integration logic runs successfully!'
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
console.log('Call result received:', JSON.stringify(result, null, 2));
|
|
140
|
+
|
|
141
|
+
assert.ok(!result.isError, 'Call must succeed');
|
|
142
|
+
assert.ok(result.content && result.content[0] && result.content[0].type === 'text', 'Result content format incorrect');
|
|
143
|
+
|
|
144
|
+
const parsedPayload = JSON.parse(result.content[0].text);
|
|
145
|
+
|
|
146
|
+
// Verify mapped elements
|
|
147
|
+
assert.ok(parsedPayload.url_echoed.includes('/products/SKU-777-XYZ'), 'Path parameters mapping failed: ' + parsedPayload.url_echoed);
|
|
148
|
+
assert.strictEqual(parsedPayload.header_echoed, 'Akropolys-Integration-Test', 'Header mapping failed');
|
|
149
|
+
assert.strictEqual(parsedPayload.method_echoed, 'POST', 'HTTP method mapping failed');
|
|
150
|
+
assert.strictEqual(parsedPayload.token_echoed, `Bearer ${mockPlaintextToken}`, 'Encrypted Auth Token decryption or insertion failed');
|
|
151
|
+
assert.strictEqual(parsedPayload.note_echoed, 'E2E Integration logic runs successfully!', 'Body mapping failed');
|
|
152
|
+
|
|
153
|
+
console.log('โ
E2E Integration Test passed perfectly!');
|
|
154
|
+
|
|
155
|
+
} finally {
|
|
156
|
+
// 5. Cleanup database
|
|
157
|
+
console.log('Cleaning up mock database entities...');
|
|
158
|
+
await client.query('DELETE FROM developer_tools WHERE property_id = $1', ['prop_e2e_test']);
|
|
159
|
+
await client.query('DELETE FROM developer_properties WHERE id = $1', ['prop_e2e_test']);
|
|
160
|
+
await client.end();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
runE2E().catch(err => {
|
|
165
|
+
console.error('โ E2E Integration Test failed:', err);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
process.env.NODE_ENV = 'test';
|
|
2
|
+
import assert from 'assert';
|
|
3
|
+
import { LocalAESVault } from './vault';
|
|
4
|
+
|
|
5
|
+
// Test 1: KMS local vault AES-256-GCM cycle
|
|
6
|
+
async function testKMSVault() {
|
|
7
|
+
console.log('๐งช Testing KMS Vault...');
|
|
8
|
+
const key = 'test-kms-secret-master-key-longer-value';
|
|
9
|
+
process.env.AKROPOLYS_KMS_MASTER_KEY = key;
|
|
10
|
+
|
|
11
|
+
const vault = new LocalAESVault();
|
|
12
|
+
const plaintext = 'super-secret-api-token-12345';
|
|
13
|
+
|
|
14
|
+
const ciphertext = await vault.encrypt(plaintext);
|
|
15
|
+
assert.ok(ciphertext.includes(':'), 'Ciphertext must contain colon delimiters');
|
|
16
|
+
|
|
17
|
+
const decrypted = await vault.decrypt(ciphertext);
|
|
18
|
+
assert.strictEqual(decrypted, plaintext, 'Decrypted text must match plaintext');
|
|
19
|
+
console.log('โ
KMS Vault passed.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Test 2: Nested response mapping resolver
|
|
23
|
+
function testResponseMapping(getNestedValue: Function, applyResponseMapping: Function) {
|
|
24
|
+
console.log('๐งช Testing Response Mapping...');
|
|
25
|
+
|
|
26
|
+
const payload = {
|
|
27
|
+
data: {
|
|
28
|
+
product: {
|
|
29
|
+
id: '123',
|
|
30
|
+
title: 'Vivo X300',
|
|
31
|
+
pricing: { amount: 169999.00 },
|
|
32
|
+
tags: ['smartphone', 'android']
|
|
33
|
+
},
|
|
34
|
+
items: [
|
|
35
|
+
{ name: 'Item A', value: 10 },
|
|
36
|
+
{ name: 'Item B', value: 20 }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Test getNestedValue
|
|
42
|
+
assert.strictEqual(getNestedValue(payload, 'data.product.id'), '123');
|
|
43
|
+
assert.strictEqual(getNestedValue(payload, 'data.product.pricing.amount'), 169999.00);
|
|
44
|
+
assert.strictEqual(getNestedValue(payload, 'data.product.nonexistent'), undefined);
|
|
45
|
+
|
|
46
|
+
// Test array mapping inside getNestedValue
|
|
47
|
+
const itemNames = getNestedValue(payload, 'data.items.name');
|
|
48
|
+
assert.deepStrictEqual(itemNames, ['Item A', 'Item B']);
|
|
49
|
+
|
|
50
|
+
// Test applyResponseMapping
|
|
51
|
+
const mapping = {
|
|
52
|
+
productId: 'data.product.id',
|
|
53
|
+
name: 'data.product.title',
|
|
54
|
+
price: 'data.product.pricing.amount',
|
|
55
|
+
tagsList: 'data.product.tags',
|
|
56
|
+
itemNames: 'data.items.name'
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const normalized = applyResponseMapping(payload, mapping);
|
|
60
|
+
|
|
61
|
+
assert.deepStrictEqual(normalized, {
|
|
62
|
+
productId: '123',
|
|
63
|
+
name: 'Vivo X300',
|
|
64
|
+
price: 169999.00,
|
|
65
|
+
tagsList: ['smartphone', 'android'],
|
|
66
|
+
itemNames: ['Item A', 'Item B']
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
console.log('โ
Response Mapping passed.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Test 3: Parameter cleaning schema
|
|
73
|
+
function testParameterCleaning(cleanParameterSchema: Function) {
|
|
74
|
+
console.log('๐งช Testing Parameter Cleaning...');
|
|
75
|
+
|
|
76
|
+
const devParams = {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
sku: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
location: 'path',
|
|
82
|
+
description: 'Stock keeping unit identifier'
|
|
83
|
+
},
|
|
84
|
+
quantity: {
|
|
85
|
+
type: 'integer',
|
|
86
|
+
location: 'query',
|
|
87
|
+
description: 'Quantity to purchase'
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
required: ['sku']
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const cleaned = cleanParameterSchema(devParams);
|
|
94
|
+
|
|
95
|
+
assert.deepStrictEqual(cleaned, {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
sku: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'Stock keeping unit identifier'
|
|
101
|
+
},
|
|
102
|
+
quantity: {
|
|
103
|
+
type: 'integer',
|
|
104
|
+
description: 'Quantity to purchase'
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
required: ['sku']
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
console.log('โ
Parameter Cleaning passed.');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Test 4: SSRF prevention check
|
|
114
|
+
async function testSSRFProtection(isPrivateIp: Function, validateUrlForSSRF: Function) {
|
|
115
|
+
console.log('๐งช Testing SSRF Protection...');
|
|
116
|
+
|
|
117
|
+
// Private IPs should be blocked
|
|
118
|
+
assert.ok(isPrivateIp('127.0.0.1'), 'Localhost should be classified as private');
|
|
119
|
+
assert.ok(isPrivateIp('10.0.0.1'), 'RFC 1918 10.x should be classified as private');
|
|
120
|
+
assert.ok(isPrivateIp('172.16.0.1'), 'RFC 1918 172.16.x should be classified as private');
|
|
121
|
+
assert.ok(isPrivateIp('192.168.1.100'), 'RFC 1918 192.168.x should be classified as private');
|
|
122
|
+
assert.ok(isPrivateIp('169.254.169.254'), 'AWS Metadata service IP should be classified as private');
|
|
123
|
+
assert.ok(isPrivateIp('::1'), 'IPv6 loopback should be classified as private');
|
|
124
|
+
assert.ok(isPrivateIp('::'), 'IPv6 unspecified should be classified as private');
|
|
125
|
+
|
|
126
|
+
// Public IPs should be allowed
|
|
127
|
+
assert.ok(!isPrivateIp('8.8.8.8'), 'Google DNS IP should be public');
|
|
128
|
+
assert.ok(!isPrivateIp('1.1.1.1'), 'Cloudflare DNS IP should be public');
|
|
129
|
+
|
|
130
|
+
// URL check throws on loopback/private
|
|
131
|
+
try {
|
|
132
|
+
await validateUrlForSSRF('http://127.0.0.1:8080/admin');
|
|
133
|
+
assert.fail('Should block loopback URLs');
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
assert.ok(err.message.includes('SSRF Prevention'), 'Error message must mention SSRF Prevention');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await validateUrlForSSRF('http://169.254.169.254/latest/meta-data');
|
|
140
|
+
assert.fail('Should block AWS metadata service URLs');
|
|
141
|
+
} catch (err: any) {
|
|
142
|
+
assert.ok(err.message.includes('SSRF Prevention'), 'Error message must mention SSRF Prevention');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// URL check succeeds on public sites
|
|
146
|
+
await validateUrlForSSRF('https://httpbin.org/anything');
|
|
147
|
+
await validateUrlForSSRF('https://api.github.com');
|
|
148
|
+
|
|
149
|
+
console.log('โ
SSRF Protection passed.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function runAll() {
|
|
153
|
+
try {
|
|
154
|
+
await testKMSVault();
|
|
155
|
+
|
|
156
|
+
// Dynamically import helpers to prevent ESM/TS hoisting execution issues
|
|
157
|
+
const { getNestedValue, applyResponseMapping, cleanParameterSchema, isPrivateIp, validateUrlForSSRF } = await import('./index');
|
|
158
|
+
|
|
159
|
+
testResponseMapping(getNestedValue, applyResponseMapping);
|
|
160
|
+
testParameterCleaning(cleanParameterSchema);
|
|
161
|
+
await testSSRFProtection(isPrivateIp, validateUrlForSSRF);
|
|
162
|
+
|
|
163
|
+
console.log('๐ ALL TESTS PASSED SUCCESSFULLY!');
|
|
164
|
+
} catch (err: any) {
|
|
165
|
+
console.error('โ TEST FAILURE:', err);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
runAll();
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
export interface CredentialVault {
|
|
4
|
+
encrypt(plaintext: string): Promise<string>;
|
|
5
|
+
decrypt(ciphertext: string): Promise<string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class LocalAESVault implements CredentialVault {
|
|
9
|
+
private masterKey: Buffer;
|
|
10
|
+
|
|
11
|
+
constructor(masterKeyEnvVar = 'AKROPOLYS_KMS_MASTER_KEY') {
|
|
12
|
+
const keyStr = process.env[masterKeyEnvVar];
|
|
13
|
+
if (!keyStr) {
|
|
14
|
+
throw new Error(`Master key environment variable ${masterKeyEnvVar} is not defined`);
|
|
15
|
+
}
|
|
16
|
+
// Hash the master key to ensure it is exactly 32 bytes for AES-256
|
|
17
|
+
this.masterKey = crypto.createHash('sha256').update(keyStr).digest();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async encrypt(plaintext: string): Promise<string> {
|
|
21
|
+
const iv = crypto.randomBytes(12);
|
|
22
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', this.masterKey, iv);
|
|
23
|
+
|
|
24
|
+
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
|
|
25
|
+
encrypted += cipher.final('hex');
|
|
26
|
+
|
|
27
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
28
|
+
|
|
29
|
+
// Format: iv:authTag:encryptedData
|
|
30
|
+
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async decrypt(ciphertext: string): Promise<string> {
|
|
34
|
+
const parts = ciphertext.split(':');
|
|
35
|
+
if (parts.length !== 3) {
|
|
36
|
+
throw new Error('Invalid ciphertext format');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const iv = Buffer.from(parts[0], 'hex');
|
|
40
|
+
const authTag = Buffer.from(parts[1], 'hex');
|
|
41
|
+
const encryptedData = parts[2];
|
|
42
|
+
|
|
43
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', this.masterKey, iv);
|
|
44
|
+
decipher.setAuthTag(authTag);
|
|
45
|
+
|
|
46
|
+
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
|
47
|
+
decrypted += decipher.final('utf8');
|
|
48
|
+
|
|
49
|
+
return decrypted;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"forceConsistentCasingInFileNames": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "./dist"
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*"]
|
|
13
|
+
}
|
package/tsup.config.ts
ADDED