@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.
@@ -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
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts', 'src/test.ts', 'src/test-e2e.ts'],
5
+ format: ['cjs'],
6
+ clean: true,
7
+ dts: false,
8
+ banner: {
9
+ js: '#!/usr/bin/env node',
10
+ },
11
+ });