@mhingston5/conduit 1.0.0
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.example +13 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/pr-checks.yml +90 -0
- package/.tool-versions +2 -0
- package/README.md +177 -0
- package/conduit.yaml.test +3 -0
- package/docs/ARCHITECTURE.md +35 -0
- package/docs/CODE_MODE.md +33 -0
- package/docs/SECURITY.md +52 -0
- package/logo.png +0 -0
- package/package.json +74 -0
- package/src/assets/deno-shim.ts +93 -0
- package/src/assets/python-shim.py +21 -0
- package/src/core/asset.utils.ts +42 -0
- package/src/core/concurrency.service.ts +70 -0
- package/src/core/config.service.ts +147 -0
- package/src/core/execution.context.ts +37 -0
- package/src/core/execution.service.ts +209 -0
- package/src/core/interfaces/app.config.ts +17 -0
- package/src/core/interfaces/executor.interface.ts +31 -0
- package/src/core/interfaces/middleware.interface.ts +12 -0
- package/src/core/interfaces/url.validator.interface.ts +3 -0
- package/src/core/logger.ts +64 -0
- package/src/core/metrics.service.ts +112 -0
- package/src/core/middleware/auth.middleware.ts +56 -0
- package/src/core/middleware/error.middleware.ts +21 -0
- package/src/core/middleware/logging.middleware.ts +25 -0
- package/src/core/middleware/middleware.builder.ts +24 -0
- package/src/core/middleware/ratelimit.middleware.ts +31 -0
- package/src/core/network.policy.service.ts +106 -0
- package/src/core/ops.server.ts +74 -0
- package/src/core/otel.service.ts +41 -0
- package/src/core/policy.service.ts +77 -0
- package/src/core/registries/executor.registry.ts +26 -0
- package/src/core/request.controller.ts +297 -0
- package/src/core/security.service.ts +68 -0
- package/src/core/session.manager.ts +44 -0
- package/src/core/types.ts +47 -0
- package/src/executors/deno.executor.ts +342 -0
- package/src/executors/isolate.executor.ts +281 -0
- package/src/executors/pyodide.executor.ts +327 -0
- package/src/executors/pyodide.worker.ts +195 -0
- package/src/gateway/auth.service.ts +104 -0
- package/src/gateway/gateway.service.ts +345 -0
- package/src/gateway/schema.cache.ts +46 -0
- package/src/gateway/upstream.client.ts +244 -0
- package/src/index.ts +92 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/sdk-generator.ts +245 -0
- package/src/sdk/tool-binding.ts +86 -0
- package/src/transport/socket.transport.ts +203 -0
- package/tests/__snapshots__/assets.test.ts.snap +97 -0
- package/tests/assets.test.ts +50 -0
- package/tests/auth.service.test.ts +78 -0
- package/tests/code-mode-lite-execution.test.ts +84 -0
- package/tests/code-mode-lite-gateway.test.ts +150 -0
- package/tests/concurrency.service.test.ts +50 -0
- package/tests/concurrency.test.ts +41 -0
- package/tests/config.service.test.ts +70 -0
- package/tests/contract.test.ts +43 -0
- package/tests/deno.executor.test.ts +68 -0
- package/tests/deno_hardening.test.ts +45 -0
- package/tests/dynamic.tool.test.ts +237 -0
- package/tests/e2e_stdio_upstream.test.ts +197 -0
- package/tests/fixtures/stdio-server.ts +42 -0
- package/tests/gateway.manifest.test.ts +82 -0
- package/tests/gateway.service.test.ts +58 -0
- package/tests/gateway.strict.unit.test.ts +74 -0
- package/tests/gateway.validation.unit.test.ts +89 -0
- package/tests/gateway_validation.test.ts +86 -0
- package/tests/hardening.test.ts +139 -0
- package/tests/hardening_v1.test.ts +72 -0
- package/tests/isolate.executor.test.ts +100 -0
- package/tests/log-limit.test.ts +55 -0
- package/tests/middleware.test.ts +106 -0
- package/tests/ops.server.test.ts +65 -0
- package/tests/policy.service.test.ts +90 -0
- package/tests/pyodide.executor.test.ts +101 -0
- package/tests/reference_mcp.ts +40 -0
- package/tests/remediation.test.ts +119 -0
- package/tests/routing.test.ts +148 -0
- package/tests/schema.cache.test.ts +27 -0
- package/tests/sdk/sdk-generator.test.ts +205 -0
- package/tests/socket.transport.test.ts +182 -0
- package/tests/stdio_upstream.test.ts +54 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +22 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { DenoExecutor } from '../src/executors/deno.executor.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import pino from 'pino';
|
|
5
|
+
|
|
6
|
+
const logger = pino({ level: 'silent' });
|
|
7
|
+
|
|
8
|
+
describe('DenoExecutor', () => {
|
|
9
|
+
let executor: DenoExecutor;
|
|
10
|
+
let context: ExecutionContext;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
executor = new DenoExecutor();
|
|
14
|
+
context = new ExecutionContext({ logger });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should execute simple typescript code', async () => {
|
|
18
|
+
const result = await executor.execute('console.log("hello")', {
|
|
19
|
+
timeoutMs: 5000,
|
|
20
|
+
memoryLimitMb: 128,
|
|
21
|
+
maxOutputBytes: 1024,
|
|
22
|
+
maxLogEntries: 100,
|
|
23
|
+
}, context);
|
|
24
|
+
|
|
25
|
+
expect(result.stdout).toContain('hello');
|
|
26
|
+
expect(result.exitCode).toBe(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should timeout on infinite loops', async () => {
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
const result = await executor.execute('while(true){}', {
|
|
32
|
+
timeoutMs: 500,
|
|
33
|
+
memoryLimitMb: 128,
|
|
34
|
+
maxOutputBytes: 1024,
|
|
35
|
+
maxLogEntries: 100,
|
|
36
|
+
}, context);
|
|
37
|
+
|
|
38
|
+
const duration = Date.now() - startTime;
|
|
39
|
+
expect(duration).toBeGreaterThanOrEqual(500);
|
|
40
|
+
expect(result.exitCode).toBe(null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should enforce output limits', async () => {
|
|
44
|
+
const result = await executor.execute('console.log("A".repeat(2000))', {
|
|
45
|
+
timeoutMs: 5000,
|
|
46
|
+
memoryLimitMb: 128,
|
|
47
|
+
maxOutputBytes: 100,
|
|
48
|
+
maxLogEntries: 100,
|
|
49
|
+
}, context);
|
|
50
|
+
|
|
51
|
+
expect(result.stdout.length).toBeLessThanOrEqual(100);
|
|
52
|
+
expect(result.error?.code).toBe(-32013);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should enforce memory limits', async () => {
|
|
56
|
+
// Grow heap until it hits the limit
|
|
57
|
+
const result = await executor.execute('const a = []; while(true) a.push(new Array(10000).fill(0));', {
|
|
58
|
+
timeoutMs: 5000,
|
|
59
|
+
memoryLimitMb: 64,
|
|
60
|
+
maxOutputBytes: 1024,
|
|
61
|
+
maxLogEntries: 100,
|
|
62
|
+
}, context);
|
|
63
|
+
|
|
64
|
+
expect(result.exitCode).not.toBe(0);
|
|
65
|
+
// V8 might throw Out of Memory error on stderr
|
|
66
|
+
expect(result.stderr).toMatch(/out of memory|exhausted/i);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { DenoExecutor } from '../src/executors/deno.executor.js'; // Adjust path if needed
|
|
4
|
+
import { ConduitError } from '../src/core/request.controller.js';
|
|
5
|
+
import { pino } from 'pino';
|
|
6
|
+
|
|
7
|
+
// Need to match the imports in deno.executor; it uses relative paths.
|
|
8
|
+
// Assuming tests are in `tests/` and src in `src/`.
|
|
9
|
+
// Imports in `deno.executor.ts` are `../core/...`
|
|
10
|
+
|
|
11
|
+
describe('DenoExecutor Hardening', () => {
|
|
12
|
+
let executor: DenoExecutor;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
executor = new DenoExecutor();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it.skip('should return LogLimitExceeded code when log limit is reached', async () => {
|
|
19
|
+
// Skipping because this requires `deno` to be installed and available in environment
|
|
20
|
+
// and we might not want to spawn processes in unit tests if avoidable.
|
|
21
|
+
// But for verification, we can run it once.
|
|
22
|
+
|
|
23
|
+
const context = {
|
|
24
|
+
logger: pino({ level: 'silent' }),
|
|
25
|
+
correlationId: 'test'
|
|
26
|
+
} as any;
|
|
27
|
+
|
|
28
|
+
const result = await executor.execute(
|
|
29
|
+
'for (let i = 0; i < 20; i++) console.log("line " + i);',
|
|
30
|
+
{
|
|
31
|
+
timeoutMs: 2000,
|
|
32
|
+
memoryLimitMb: 128,
|
|
33
|
+
maxOutputBytes: 1024,
|
|
34
|
+
maxLogEntries: 5 // Low limit
|
|
35
|
+
},
|
|
36
|
+
context
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(result.error).toBeDefined();
|
|
40
|
+
if (result.error) {
|
|
41
|
+
expect(result.error.code).toBe(ConduitError.LogLimitExceeded); // -32014
|
|
42
|
+
expect(result.error.message).toBe('Log entry limit exceeded');
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { SocketTransport } from '../src/transport/socket.transport.js';
|
|
3
|
+
import { RequestController } from '../src/core/request.controller.js';
|
|
4
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
5
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
6
|
+
import { ConcurrencyService } from '../src/core/concurrency.service.js';
|
|
7
|
+
import pino from 'pino';
|
|
8
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
9
|
+
import { ExecutionService } from '../src/core/execution.service.js';
|
|
10
|
+
import { ExecutorRegistry } from '../src/core/registries/executor.registry.js';
|
|
11
|
+
import { DenoExecutor } from '../src/executors/deno.executor.js';
|
|
12
|
+
import { PyodideExecutor } from '../src/executors/pyodide.executor.js';
|
|
13
|
+
import { IsolateExecutor } from '../src/executors/isolate.executor.js';
|
|
14
|
+
import { buildDefaultMiddleware } from '../src/core/middleware/middleware.builder.js';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
|
|
19
|
+
const logger = pino({ level: 'silent' });
|
|
20
|
+
const defaultLimits = {
|
|
21
|
+
timeoutMs: 10000,
|
|
22
|
+
memoryLimitMb: 128,
|
|
23
|
+
maxOutputBytes: 1024 * 1024,
|
|
24
|
+
maxLogEntries: 100,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const LOG_FILE = path.join(os.tmpdir(), `conduit-test-debug-${process.pid}.log`);
|
|
28
|
+
|
|
29
|
+
describe('Dynamic Tool Calling (E2E)', () => {
|
|
30
|
+
let transport: SocketTransport;
|
|
31
|
+
let requestController: RequestController;
|
|
32
|
+
let gatewayService: GatewayService;
|
|
33
|
+
let securityService: SecurityService;
|
|
34
|
+
let concurrencyService: ConcurrencyService;
|
|
35
|
+
const testToken = 'dynamic-test-token';
|
|
36
|
+
let ipcAddress: string;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
if (fs.existsSync(LOG_FILE)) fs.unlinkSync(LOG_FILE);
|
|
40
|
+
|
|
41
|
+
securityService = new SecurityService(logger, testToken);
|
|
42
|
+
gatewayService = new GatewayService(logger, securityService);
|
|
43
|
+
|
|
44
|
+
// Register a mock upstream tool
|
|
45
|
+
(gatewayService as any).clients.set('mock', {
|
|
46
|
+
call: vi.fn().mockImplementation((req) => {
|
|
47
|
+
if (req.method === 'call_tool' && req.params.name === 'hello') {
|
|
48
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: `Hello ${req.params.arguments.name}` }] } };
|
|
49
|
+
}
|
|
50
|
+
if (req.method === 'list_tools') {
|
|
51
|
+
return { jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 'hello', inputSchema: {} }] } };
|
|
52
|
+
}
|
|
53
|
+
return { jsonrpc: '2.0', id: req.id, result: {} };
|
|
54
|
+
})
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
concurrencyService = new ConcurrencyService(logger, { maxConcurrent: 10 });
|
|
58
|
+
|
|
59
|
+
const executorRegistry = new ExecutorRegistry();
|
|
60
|
+
executorRegistry.register('deno', new DenoExecutor());
|
|
61
|
+
executorRegistry.register('python', new PyodideExecutor());
|
|
62
|
+
// For isolate test, we need IsolateExecutor.
|
|
63
|
+
// It requires GatewayService.
|
|
64
|
+
executorRegistry.register('isolate', new IsolateExecutor(logger, gatewayService));
|
|
65
|
+
|
|
66
|
+
const executionService = new ExecutionService(
|
|
67
|
+
logger,
|
|
68
|
+
defaultLimits,
|
|
69
|
+
gatewayService,
|
|
70
|
+
securityService,
|
|
71
|
+
executorRegistry
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
requestController = new RequestController(logger, executionService, gatewayService, buildDefaultMiddleware(securityService));
|
|
75
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
76
|
+
|
|
77
|
+
ipcAddress = await transport.listen({ port: 0, host: '127.0.0.1' });
|
|
78
|
+
fs.appendFileSync(LOG_FILE, `IPC_ADDRESS: ${ipcAddress}\n`);
|
|
79
|
+
executionService.ipcAddress = ipcAddress;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await transport.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should allow Deno to discover and call tools via SDK', async () => {
|
|
87
|
+
const code = `
|
|
88
|
+
const toolList = await discoverMCPTools();
|
|
89
|
+
console.log('TOOLS:' + JSON.stringify(toolList));
|
|
90
|
+
// Use SDK to call tools - tools.mock.hello() or tools.$raw()
|
|
91
|
+
const result = await tools.$raw('mock__hello', { name: 'Deno' });
|
|
92
|
+
console.log('RESULT:' + JSON.stringify(result));
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
const context = new ExecutionContext({ logger });
|
|
96
|
+
const response = await requestController.handleRequest({
|
|
97
|
+
jsonrpc: '2.0',
|
|
98
|
+
id: 1,
|
|
99
|
+
method: 'mcp.executeTypeScript',
|
|
100
|
+
params: { code },
|
|
101
|
+
auth: { bearerToken: testToken }
|
|
102
|
+
}, context);
|
|
103
|
+
|
|
104
|
+
fs.appendFileSync(LOG_FILE, `Deno Stdout: ${response.result?.stdout}\n`);
|
|
105
|
+
fs.appendFileSync(LOG_FILE, `Deno Stderr: ${response.result?.stderr}\n`);
|
|
106
|
+
|
|
107
|
+
expect(response.error).toBeUndefined();
|
|
108
|
+
expect(response.result.stdout).toContain('mock__hello');
|
|
109
|
+
expect(response.result.stdout).toContain('Hello Deno');
|
|
110
|
+
}, 15000);
|
|
111
|
+
|
|
112
|
+
it('should allow Python to discover and call tools via SDK', async () => {
|
|
113
|
+
const code = `
|
|
114
|
+
tool_list = await discover_mcp_tools()
|
|
115
|
+
print(f"TOOLS:{tool_list}")
|
|
116
|
+
# Use SDK to call tools - must await async methods
|
|
117
|
+
result = await tools.raw('mock__hello', {'name': 'Python'})
|
|
118
|
+
print(f"RESULT:{result}")
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
const context = new ExecutionContext({ logger });
|
|
122
|
+
const response = await requestController.handleRequest({
|
|
123
|
+
jsonrpc: '2.0',
|
|
124
|
+
id: 2,
|
|
125
|
+
method: 'mcp.executePython',
|
|
126
|
+
params: { code },
|
|
127
|
+
auth: { bearerToken: testToken }
|
|
128
|
+
}, context);
|
|
129
|
+
|
|
130
|
+
fs.appendFileSync(LOG_FILE, `Python Stdout: ${response.result?.stdout}\n`);
|
|
131
|
+
fs.appendFileSync(LOG_FILE, `Python Stderr: ${response.result?.stderr}\n`);
|
|
132
|
+
if (response.error) fs.appendFileSync(LOG_FILE, `Python Error: ${JSON.stringify(response.error)}\n`);
|
|
133
|
+
|
|
134
|
+
expect(response.error).toBeUndefined();
|
|
135
|
+
expect(response.result.stdout).toContain('mock__hello');
|
|
136
|
+
expect(response.result.stdout).toContain('Hello Python');
|
|
137
|
+
}, 25000);
|
|
138
|
+
|
|
139
|
+
it('should reject tools not in allowlist via $raw()', async () => {
|
|
140
|
+
const code = `
|
|
141
|
+
try {
|
|
142
|
+
// Attempt to call a tool not in the allowlist
|
|
143
|
+
const result = await tools.$raw('other__forbidden', { arg: 'test' });
|
|
144
|
+
console.log('ERROR: Should have thrown');
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.log('REJECTED:' + e.message);
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const context = new ExecutionContext({ logger });
|
|
151
|
+
const response = await requestController.handleRequest({
|
|
152
|
+
jsonrpc: '2.0',
|
|
153
|
+
id: 3,
|
|
154
|
+
method: 'mcp.executeTypeScript',
|
|
155
|
+
params: {
|
|
156
|
+
code,
|
|
157
|
+
allowedTools: ['mock.hello'] // Only mock.hello allowed
|
|
158
|
+
},
|
|
159
|
+
auth: { bearerToken: testToken }
|
|
160
|
+
}, context);
|
|
161
|
+
|
|
162
|
+
fs.appendFileSync(LOG_FILE, `Allowlist Stdout: ${response.result?.stdout}\n`);
|
|
163
|
+
if (response.error) fs.appendFileSync(LOG_FILE, `Allowlist Error: ${JSON.stringify(response.error)}\n`);
|
|
164
|
+
|
|
165
|
+
expect(response.error).toBeUndefined();
|
|
166
|
+
expect(response.result.stdout).toContain('REJECTED');
|
|
167
|
+
expect(response.result.stdout).toContain('not in the allowlist');
|
|
168
|
+
}, 15000);
|
|
169
|
+
|
|
170
|
+
it('should allow tools matching wildcard pattern', async () => {
|
|
171
|
+
const code = `
|
|
172
|
+
// This should work because mock.* matches mock__hello
|
|
173
|
+
const result = await tools.$raw('mock.hello', { name: 'Wildcard' });
|
|
174
|
+
console.log('RESULT:' + JSON.stringify(result));
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
const context = new ExecutionContext({ logger });
|
|
178
|
+
const response = await requestController.handleRequest({
|
|
179
|
+
jsonrpc: '2.0',
|
|
180
|
+
id: 4,
|
|
181
|
+
method: 'mcp.executeTypeScript',
|
|
182
|
+
params: {
|
|
183
|
+
code,
|
|
184
|
+
allowedTools: ['mock.*'] // Wildcard allows all mock tools
|
|
185
|
+
},
|
|
186
|
+
auth: { bearerToken: testToken }
|
|
187
|
+
}, context);
|
|
188
|
+
|
|
189
|
+
fs.appendFileSync(LOG_FILE, `Wildcard Stdout: ${response.result?.stdout}\n`);
|
|
190
|
+
if (response.error) fs.appendFileSync(LOG_FILE, `Wildcard Error: ${JSON.stringify(response.error)}\n`);
|
|
191
|
+
|
|
192
|
+
expect(response.error).toBeUndefined();
|
|
193
|
+
expect(response.result.stdout).toContain('Hello Wildcard');
|
|
194
|
+
}, 15000);
|
|
195
|
+
|
|
196
|
+
it('should allow isolated-vm to discover and call tools via typed SDK', async () => {
|
|
197
|
+
const mockClient = (gatewayService as any).clients.get('mock');
|
|
198
|
+
mockClient.call.mockClear();
|
|
199
|
+
|
|
200
|
+
const code = `
|
|
201
|
+
await tools.mock.hello({ name: 'Isolate' });
|
|
202
|
+
console.log('Isolate call done');
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
const context = new ExecutionContext({ logger });
|
|
206
|
+
const response = await requestController.handleRequest({
|
|
207
|
+
jsonrpc: '2.0',
|
|
208
|
+
id: 5,
|
|
209
|
+
method: 'mcp.executeIsolate',
|
|
210
|
+
params: {
|
|
211
|
+
code,
|
|
212
|
+
allowedTools: ['mock.*'],
|
|
213
|
+
limits: { timeoutMs: 5000, memoryLimitMb: 128, maxOutputBytes: 1024, maxLogEntries: 100 }
|
|
214
|
+
},
|
|
215
|
+
auth: { bearerToken: testToken }
|
|
216
|
+
}, context);
|
|
217
|
+
|
|
218
|
+
fs.appendFileSync(LOG_FILE, `Isolate Stdout: ${response.result?.stdout}\n`);
|
|
219
|
+
if (response.error) fs.appendFileSync(LOG_FILE, `Isolate Error: ${JSON.stringify(response.error)}\n`);
|
|
220
|
+
|
|
221
|
+
expect(response.error).toBeUndefined();
|
|
222
|
+
expect(response.result.stdout).toContain('Isolate call done');
|
|
223
|
+
|
|
224
|
+
// Verify tool was called
|
|
225
|
+
expect(mockClient.call).toHaveBeenCalled();
|
|
226
|
+
const callArgs = mockClient.call.mock.calls[0];
|
|
227
|
+
const request = callArgs[0];
|
|
228
|
+
|
|
229
|
+
expect(request).toMatchObject({
|
|
230
|
+
method: 'call_tool',
|
|
231
|
+
params: {
|
|
232
|
+
name: 'hello',
|
|
233
|
+
arguments: { name: 'Isolate' }
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}, 15000);
|
|
237
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-End Test: Client -> Conduit -> Stdio Upstream
|
|
3
|
+
*
|
|
4
|
+
* This test verifies the full flow:
|
|
5
|
+
* 1. Conduit starts with a stdio upstream configured
|
|
6
|
+
* 2. A client connects to Conduit
|
|
7
|
+
* 3. Client can discover tools from the upstream
|
|
8
|
+
* 4. Client can call tools on the upstream
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
11
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
12
|
+
import { createLogger, loggerStorage } from '../src/core/logger.js';
|
|
13
|
+
import { SocketTransport } from '../src/transport/socket.transport.js';
|
|
14
|
+
import { OpsServer } from '../src/core/ops.server.js';
|
|
15
|
+
import { ConcurrencyService } from '../src/core/concurrency.service.js';
|
|
16
|
+
import { RequestController } from '../src/core/request.controller.js';
|
|
17
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
18
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
19
|
+
import { DenoExecutor } from '../src/executors/deno.executor.js';
|
|
20
|
+
import { PyodideExecutor } from '../src/executors/pyodide.executor.js';
|
|
21
|
+
import { IsolateExecutor } from '../src/executors/isolate.executor.js';
|
|
22
|
+
import { ExecutorRegistry } from '../src/core/registries/executor.registry.js';
|
|
23
|
+
import { ExecutionService } from '../src/core/execution.service.js';
|
|
24
|
+
import { buildDefaultMiddleware } from '../src/core/middleware/middleware.builder.js';
|
|
25
|
+
import net from 'net';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
|
|
28
|
+
describe('E2E: Stdio Upstream Integration', () => {
|
|
29
|
+
let transport: SocketTransport;
|
|
30
|
+
let opsServer: OpsServer;
|
|
31
|
+
let requestController: RequestController;
|
|
32
|
+
let serverAddress: string;
|
|
33
|
+
let ipcToken: string;
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
// Configure Conduit with our test stdio server
|
|
37
|
+
const stioServerPath = path.resolve(__dirname, 'fixtures/stdio-server.ts');
|
|
38
|
+
|
|
39
|
+
const configService = new ConfigService({
|
|
40
|
+
port: 0, // Random port
|
|
41
|
+
upstreams: [
|
|
42
|
+
{
|
|
43
|
+
id: 'test-stdio',
|
|
44
|
+
type: 'stdio',
|
|
45
|
+
command: 'npx',
|
|
46
|
+
args: ['tsx', stioServerPath],
|
|
47
|
+
} as any,
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
ipcToken = configService.get('ipcBearerToken');
|
|
52
|
+
const logger = createLogger(configService);
|
|
53
|
+
|
|
54
|
+
await loggerStorage.run({ correlationId: 'e2e-test' }, async () => {
|
|
55
|
+
const securityService = new SecurityService(logger, ipcToken);
|
|
56
|
+
const gatewayService = new GatewayService(logger, securityService);
|
|
57
|
+
|
|
58
|
+
const upstreams = configService.get('upstreams') || [];
|
|
59
|
+
for (const upstream of upstreams) {
|
|
60
|
+
gatewayService.registerUpstream(upstream);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const executorRegistry = new ExecutorRegistry();
|
|
64
|
+
executorRegistry.register('deno', new DenoExecutor());
|
|
65
|
+
executorRegistry.register('python', new PyodideExecutor());
|
|
66
|
+
const isolateExecutor = new IsolateExecutor(logger, gatewayService);
|
|
67
|
+
executorRegistry.register('isolate', isolateExecutor);
|
|
68
|
+
|
|
69
|
+
const executionService = new ExecutionService(
|
|
70
|
+
logger,
|
|
71
|
+
configService.get('resourceLimits'),
|
|
72
|
+
gatewayService,
|
|
73
|
+
securityService,
|
|
74
|
+
executorRegistry
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
requestController = new RequestController(
|
|
78
|
+
logger,
|
|
79
|
+
executionService,
|
|
80
|
+
gatewayService,
|
|
81
|
+
buildDefaultMiddleware(securityService)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
opsServer = new OpsServer(logger, configService.all, gatewayService, requestController);
|
|
85
|
+
await opsServer.listen();
|
|
86
|
+
|
|
87
|
+
const concurrencyService = new ConcurrencyService(logger, {
|
|
88
|
+
maxConcurrent: configService.get('maxConcurrent'),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
92
|
+
const address = await transport.listen({ port: 0 });
|
|
93
|
+
executionService.ipcAddress = address;
|
|
94
|
+
serverAddress = address;
|
|
95
|
+
|
|
96
|
+
await requestController.warmup();
|
|
97
|
+
});
|
|
98
|
+
}, 30000);
|
|
99
|
+
|
|
100
|
+
afterAll(async () => {
|
|
101
|
+
await transport?.close();
|
|
102
|
+
await opsServer?.close();
|
|
103
|
+
await requestController?.shutdown();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function sendRequest(request: object): Promise<any> {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
// Parse address - could be TCP or Unix socket
|
|
109
|
+
let connectOptions: net.NetConnectOpts;
|
|
110
|
+
if (serverAddress.startsWith('/') || serverAddress.startsWith('\\\\.\\pipe\\')) {
|
|
111
|
+
connectOptions = { path: serverAddress };
|
|
112
|
+
} else {
|
|
113
|
+
const [host, portStr] = serverAddress.split(':');
|
|
114
|
+
connectOptions = { host, port: parseInt(portStr, 10) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const client = net.createConnection(connectOptions, () => {
|
|
118
|
+
const payload = JSON.stringify(request) + '\n';
|
|
119
|
+
client.write(payload);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
let data = '';
|
|
123
|
+
client.on('data', (chunk) => {
|
|
124
|
+
data += chunk.toString();
|
|
125
|
+
// Check for newline delimiter
|
|
126
|
+
if (data.includes('\n')) {
|
|
127
|
+
const lines = data.split('\n').filter(Boolean);
|
|
128
|
+
if (lines.length > 0) {
|
|
129
|
+
try {
|
|
130
|
+
const response = JSON.parse(lines[0]);
|
|
131
|
+
client.end();
|
|
132
|
+
resolve(response);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// Keep waiting for more data
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
client.on('error', reject);
|
|
141
|
+
client.setTimeout(10000, () => {
|
|
142
|
+
client.destroy();
|
|
143
|
+
reject(new Error('Timeout waiting for response'));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('should discover tools from the stdio upstream', async () => {
|
|
149
|
+
const response = await sendRequest({
|
|
150
|
+
jsonrpc: '2.0',
|
|
151
|
+
id: '1',
|
|
152
|
+
method: 'mcp.discoverTools',
|
|
153
|
+
params: {},
|
|
154
|
+
auth: { bearerToken: ipcToken },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(response.error).toBeUndefined();
|
|
158
|
+
expect(response.result).toBeDefined();
|
|
159
|
+
expect(response.result.tools).toBeInstanceOf(Array);
|
|
160
|
+
|
|
161
|
+
// Find our echo tool from the stdio upstream
|
|
162
|
+
const echoTool = response.result.tools.find((t: any) => t.name.includes('echo'));
|
|
163
|
+
expect(echoTool).toBeDefined();
|
|
164
|
+
expect(echoTool.description).toBe('Echoes back the input');
|
|
165
|
+
}, 15000);
|
|
166
|
+
|
|
167
|
+
it('should call a tool on the stdio upstream', async () => {
|
|
168
|
+
// First discover tools to get the namespaced name
|
|
169
|
+
const discoverResponse = await sendRequest({
|
|
170
|
+
jsonrpc: '2.0',
|
|
171
|
+
id: '1',
|
|
172
|
+
method: 'mcp.discoverTools',
|
|
173
|
+
params: {},
|
|
174
|
+
auth: { bearerToken: ipcToken },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const echoTool = discoverResponse.result.tools.find((t: any) => t.name.includes('echo'));
|
|
178
|
+
expect(echoTool).toBeDefined();
|
|
179
|
+
|
|
180
|
+
// Call the tool
|
|
181
|
+
const callResponse = await sendRequest({
|
|
182
|
+
jsonrpc: '2.0',
|
|
183
|
+
id: '2',
|
|
184
|
+
method: 'mcp.callTool',
|
|
185
|
+
params: {
|
|
186
|
+
name: echoTool.name,
|
|
187
|
+
arguments: { message: 'Hello from E2E test!' },
|
|
188
|
+
},
|
|
189
|
+
auth: { bearerToken: ipcToken },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(callResponse.error).toBeUndefined();
|
|
193
|
+
expect(callResponse.result).toBeDefined();
|
|
194
|
+
expect(callResponse.result.content).toBeInstanceOf(Array);
|
|
195
|
+
expect(callResponse.result.content[0].text).toBe('Echo: Hello from E2E test!');
|
|
196
|
+
}, 15000);
|
|
197
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
|
|
6
|
+
const server = new Server({
|
|
7
|
+
name: 'test-stdio-server',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
}, {
|
|
10
|
+
capabilities: {
|
|
11
|
+
tools: {},
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
16
|
+
tools: [{
|
|
17
|
+
name: 'echo',
|
|
18
|
+
description: 'Echoes back the input',
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
message: { type: 'string' },
|
|
23
|
+
},
|
|
24
|
+
required: ['message'],
|
|
25
|
+
},
|
|
26
|
+
}],
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
30
|
+
if (request.params.name === 'echo') {
|
|
31
|
+
return {
|
|
32
|
+
content: [{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: `Echo: ${request.params.arguments?.message}`,
|
|
35
|
+
}],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
throw new Error('Tool not found');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const transport = new StdioServerTransport();
|
|
42
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
3
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
4
|
+
import { createLogger } from '../src/core/logger.js';
|
|
5
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
6
|
+
import { PolicyService } from '../src/core/policy.service.js';
|
|
7
|
+
|
|
8
|
+
describe('GatewayService (Manifests)', () => {
|
|
9
|
+
let gateway: GatewayService;
|
|
10
|
+
let logger: any;
|
|
11
|
+
let mockClient: any;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
logger = createLogger(new ConfigService());
|
|
15
|
+
gateway = new GatewayService(logger, { validateUrl: vi.fn().mockResolvedValue({ valid: true }) } as any, new PolicyService());
|
|
16
|
+
|
|
17
|
+
// Mock the clients map directly since it's private but we need to inject a specific client
|
|
18
|
+
mockClient = {
|
|
19
|
+
call: vi.fn(),
|
|
20
|
+
getManifest: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
(gateway as any).clients.set('test-upstream', mockClient);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should use manifest if available', async () => {
|
|
26
|
+
const context = new ExecutionContext({ logger });
|
|
27
|
+
|
|
28
|
+
mockClient.getManifest.mockResolvedValue({
|
|
29
|
+
version: '1.0.0',
|
|
30
|
+
tools: [
|
|
31
|
+
{ name: 'tool1', description: 'desc1' },
|
|
32
|
+
{ name: 'tool2', description: 'desc2' }
|
|
33
|
+
]
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const stubs = await gateway.listToolStubs('test-upstream', context);
|
|
37
|
+
|
|
38
|
+
expect(mockClient.getManifest).toHaveBeenCalled();
|
|
39
|
+
expect(mockClient.call).not.toHaveBeenCalled(); // Should NOT call RPC
|
|
40
|
+
expect(stubs).toHaveLength(2);
|
|
41
|
+
expect(stubs[0].id).toBe('test-upstream__tool1');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should fall back to RPC if manifest is null', async () => {
|
|
45
|
+
const context = new ExecutionContext({ logger });
|
|
46
|
+
|
|
47
|
+
mockClient.getManifest.mockResolvedValue(null);
|
|
48
|
+
mockClient.call.mockResolvedValue({
|
|
49
|
+
result: {
|
|
50
|
+
tools: [
|
|
51
|
+
{ name: 'tool_rpc', description: 'from rpc' }
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const stubs = await gateway.listToolStubs('test-upstream', context);
|
|
57
|
+
|
|
58
|
+
expect(mockClient.getManifest).toHaveBeenCalled();
|
|
59
|
+
expect(mockClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'list_tools' }), context);
|
|
60
|
+
expect(stubs).toHaveLength(1);
|
|
61
|
+
expect(stubs[0].id).toBe('test-upstream__tool_rpc');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should fall back to RPC if manifest fetch throws', async () => {
|
|
65
|
+
const context = new ExecutionContext({ logger });
|
|
66
|
+
|
|
67
|
+
mockClient.getManifest.mockRejectedValue(new Error('Network error'));
|
|
68
|
+
mockClient.call.mockResolvedValue({
|
|
69
|
+
result: {
|
|
70
|
+
tools: [
|
|
71
|
+
{ name: 'tool_rpc', description: 'from rpc' }
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const stubs = await gateway.listToolStubs('test-upstream', context);
|
|
77
|
+
|
|
78
|
+
expect(mockClient.getManifest).toHaveBeenCalled();
|
|
79
|
+
expect(mockClient.call).toHaveBeenCalled();
|
|
80
|
+
expect(stubs).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
});
|