@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,27 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { SchemaCache } from '../src/gateway/schema.cache.js';
|
|
3
|
+
import pino from 'pino';
|
|
4
|
+
|
|
5
|
+
const logger = pino({ level: 'silent' });
|
|
6
|
+
|
|
7
|
+
describe('SchemaCache', () => {
|
|
8
|
+
it('should cache and retrieve schemas', () => {
|
|
9
|
+
const cache = new SchemaCache(logger);
|
|
10
|
+
const tools = [{ name: 'test', inputSchema: {} }];
|
|
11
|
+
|
|
12
|
+
cache.set('upstream1', tools);
|
|
13
|
+
expect(cache.get('upstream1')).toEqual(tools);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should return undefined for missing entries', () => {
|
|
17
|
+
const cache = new SchemaCache(logger);
|
|
18
|
+
expect(cache.get('missing')).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should invalidate entries', () => {
|
|
22
|
+
const cache = new SchemaCache(logger);
|
|
23
|
+
cache.set('upstream1', []);
|
|
24
|
+
cache.invalidate('upstream1');
|
|
25
|
+
expect(cache.get('upstream1')).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { SDKGenerator } from '../../src/sdk/sdk-generator.js';
|
|
3
|
+
import { ToolBinding, toToolBinding, groupByNamespace } from '../../src/sdk/tool-binding.js';
|
|
4
|
+
|
|
5
|
+
describe('SDKGenerator', () => {
|
|
6
|
+
const generator = new SDKGenerator();
|
|
7
|
+
|
|
8
|
+
describe('generateTypeScript', () => {
|
|
9
|
+
it('should generate SDK with nested namespace structure', () => {
|
|
10
|
+
const bindings: ToolBinding[] = [
|
|
11
|
+
{ name: 'github__createIssue', namespace: 'github', methodName: 'createIssue' },
|
|
12
|
+
{ name: 'github__listRepos', namespace: 'github', methodName: 'listRepos' },
|
|
13
|
+
{ name: 'slack__sendMessage', namespace: 'slack', methodName: 'sendMessage' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const code = generator.generateTypeScript(bindings);
|
|
17
|
+
|
|
18
|
+
expect(code).toContain('const tools = {');
|
|
19
|
+
expect(code).toContain('github: {');
|
|
20
|
+
expect(code).toContain('async createIssue(args)');
|
|
21
|
+
expect(code).toContain('await __internalCallTool("github__createIssue", args)');
|
|
22
|
+
expect(code).toContain('slack: {');
|
|
23
|
+
expect(code).toContain('async sendMessage(args)');
|
|
24
|
+
expect(code).toContain('(globalThis as any).tools = tools');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should include $raw escape hatch by default', () => {
|
|
28
|
+
const bindings: ToolBinding[] = [
|
|
29
|
+
{ name: 'test__method', namespace: 'test', methodName: 'method' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const code = generator.generateTypeScript(bindings);
|
|
33
|
+
|
|
34
|
+
expect(code).toContain('async $raw(name, args)');
|
|
35
|
+
expect(code).toContain('await __internalCallTool(normalized, args)');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should omit $raw when disabled', () => {
|
|
39
|
+
const bindings: ToolBinding[] = [
|
|
40
|
+
{ name: 'test__method', namespace: 'test', methodName: 'method' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const code = generator.generateTypeScript(bindings, undefined, false);
|
|
44
|
+
|
|
45
|
+
expect(code).not.toContain('$raw');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should include JSDoc comments from descriptions', () => {
|
|
49
|
+
const bindings: ToolBinding[] = [
|
|
50
|
+
{ name: 'github__createIssue', namespace: 'github', methodName: 'createIssue', description: 'Create a new GitHub issue' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const code = generator.generateTypeScript(bindings);
|
|
54
|
+
|
|
55
|
+
expect(code).toContain('/** Create a new GitHub issue */');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle empty bindings', () => {
|
|
59
|
+
const code = generator.generateTypeScript([]);
|
|
60
|
+
|
|
61
|
+
expect(code).toContain('const tools = {');
|
|
62
|
+
expect(code).toContain('async $raw(name, args)');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('generatePython', () => {
|
|
67
|
+
it('should generate SDK with nested namespace structure', () => {
|
|
68
|
+
const bindings: ToolBinding[] = [
|
|
69
|
+
{ name: 'github__createIssue', namespace: 'github', methodName: 'createIssue' },
|
|
70
|
+
{ name: 'slack__sendMessage', namespace: 'slack', methodName: 'sendMessage' },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const code = generator.generatePython(bindings);
|
|
74
|
+
|
|
75
|
+
expect(code).toContain('class _Tools:');
|
|
76
|
+
expect(code).toContain('self.github = _ToolNamespace');
|
|
77
|
+
expect(code).toContain('"create_issue"'); // snake_case conversion
|
|
78
|
+
expect(code).toContain('self.slack = _ToolNamespace');
|
|
79
|
+
expect(code).toContain('"send_message"'); // snake_case conversion
|
|
80
|
+
expect(code).toContain('tools = _Tools()');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should include raw escape hatch', () => {
|
|
84
|
+
const bindings: ToolBinding[] = [
|
|
85
|
+
{ name: 'test__method', namespace: 'test', methodName: 'method' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const code = generator.generatePython(bindings);
|
|
89
|
+
|
|
90
|
+
expect(code).toContain('async def raw(self, name, args)');
|
|
91
|
+
expect(code).toContain('await _internal_call_tool(normalized, args)');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should inject allowlist when provided', () => {
|
|
95
|
+
const bindings: ToolBinding[] = [
|
|
96
|
+
{ name: 'test__method', namespace: 'test', methodName: 'method' },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const code = generator.generatePython(bindings, ['test.method', 'other.*']);
|
|
100
|
+
|
|
101
|
+
expect(code).toContain('_allowed_tools = ["test__method","other__*"]');
|
|
102
|
+
expect(code).toContain('if _allowed_tools is not None');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('SDK Allowlist Enforcement', () => {
|
|
108
|
+
const generator = new SDKGenerator();
|
|
109
|
+
|
|
110
|
+
describe('TypeScript', () => {
|
|
111
|
+
it('should inject allowlist when provided', () => {
|
|
112
|
+
const bindings: ToolBinding[] = [
|
|
113
|
+
{ name: 'github__createIssue', namespace: 'github', methodName: 'createIssue' },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const code = generator.generateTypeScript(bindings, ['github.createIssue', 'slack.*']);
|
|
117
|
+
|
|
118
|
+
expect(code).toContain('const __allowedTools = ["github__createIssue","slack__*"]');
|
|
119
|
+
expect(code).toContain('if (__allowedTools)');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should set __allowedTools to null when no allowlist', () => {
|
|
123
|
+
const bindings: ToolBinding[] = [
|
|
124
|
+
{ name: 'test__method', namespace: 'test', methodName: 'method' },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const code = generator.generateTypeScript(bindings);
|
|
128
|
+
|
|
129
|
+
expect(code).toContain('const __allowedTools = null');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should include wildcard pattern matching', () => {
|
|
133
|
+
const bindings: ToolBinding[] = [];
|
|
134
|
+
const code = generator.generateTypeScript(bindings, ['github.*']);
|
|
135
|
+
|
|
136
|
+
expect(code).toContain("if (p.endsWith('__*'))");
|
|
137
|
+
expect(code).toContain('normalized.startsWith(p.slice(0, -1))');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('Python', () => {
|
|
142
|
+
it('should inject allowlist when provided', () => {
|
|
143
|
+
const bindings: ToolBinding[] = [];
|
|
144
|
+
const code = generator.generatePython(bindings, ['github.createIssue']);
|
|
145
|
+
|
|
146
|
+
expect(code).toContain('_allowed_tools = ["github__createIssue"]');
|
|
147
|
+
expect(code).toContain('if _allowed_tools is not None');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should set _allowed_tools to None when no allowlist', () => {
|
|
151
|
+
const bindings: ToolBinding[] = [];
|
|
152
|
+
const code = generator.generatePython(bindings);
|
|
153
|
+
|
|
154
|
+
expect(code).toContain('_allowed_tools = None');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should include wildcard pattern matching', () => {
|
|
158
|
+
const bindings: ToolBinding[] = [];
|
|
159
|
+
const code = generator.generatePython(bindings, ['github.*']);
|
|
160
|
+
|
|
161
|
+
expect(code).toContain('p.endswith("__*")');
|
|
162
|
+
expect(code).toContain('raise PermissionError');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('toToolBinding', () => {
|
|
168
|
+
it('should parse fully qualified tool name', () => {
|
|
169
|
+
const binding = toToolBinding('github__createIssue');
|
|
170
|
+
|
|
171
|
+
expect(binding.name).toBe('github__createIssue');
|
|
172
|
+
expect(binding.namespace).toBe('github');
|
|
173
|
+
expect(binding.methodName).toBe('createIssue');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should handle multi-part method names', () => {
|
|
177
|
+
const binding = toToolBinding('mcp__filesystem__read_file');
|
|
178
|
+
|
|
179
|
+
expect(binding.namespace).toBe('mcp');
|
|
180
|
+
expect(binding.methodName).toBe('filesystem__read_file');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should include optional fields', () => {
|
|
184
|
+
const schema = { type: 'object' };
|
|
185
|
+
const binding = toToolBinding('test__method', schema, 'Test description');
|
|
186
|
+
|
|
187
|
+
expect(binding.inputSchema).toEqual(schema);
|
|
188
|
+
expect(binding.description).toBe('Test description');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('groupByNamespace', () => {
|
|
193
|
+
it('should group bindings by namespace', () => {
|
|
194
|
+
const bindings: ToolBinding[] = [
|
|
195
|
+
{ name: 'a__one', namespace: 'a', methodName: 'one' },
|
|
196
|
+
{ name: 'a__two', namespace: 'a', methodName: 'two' },
|
|
197
|
+
{ name: 'b__one', namespace: 'b', methodName: 'one' },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const groups = groupByNamespace(bindings);
|
|
201
|
+
|
|
202
|
+
expect(groups.get('a')?.length).toBe(2);
|
|
203
|
+
expect(groups.get('b')?.length).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
2
|
+
import { SocketTransport } from '../src/transport/socket.transport.js';
|
|
3
|
+
import { RequestController } from '../src/core/request.controller.js';
|
|
4
|
+
import pino from 'pino';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
9
|
+
import { ExecutionService } from '../src/core/execution.service.js';
|
|
10
|
+
import { ExecutorRegistry } from '../src/core/registries/executor.registry.js';
|
|
11
|
+
import { buildDefaultMiddleware } from '../src/core/middleware/middleware.builder.js';
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
|
|
15
|
+
const logger = pino({ level: 'silent' });
|
|
16
|
+
const defaultLimits = {
|
|
17
|
+
timeoutMs: 5000,
|
|
18
|
+
memoryLimitMb: 128,
|
|
19
|
+
maxOutputBytes: 1024,
|
|
20
|
+
maxLogEntries: 100,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('SocketTransport', () => {
|
|
24
|
+
let transport: SocketTransport;
|
|
25
|
+
let requestController: RequestController;
|
|
26
|
+
let securityService: any;
|
|
27
|
+
let concurrencyService: any;
|
|
28
|
+
let gatewayService: any;
|
|
29
|
+
const testToken = 'test-token';
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
gatewayService = {
|
|
33
|
+
discoverTools: vi.fn().mockResolvedValue([]), // Return empty array for SDK generation
|
|
34
|
+
callTool: vi.fn(),
|
|
35
|
+
listToolPackages: vi.fn().mockResolvedValue([]),
|
|
36
|
+
listToolStubs: vi.fn().mockResolvedValue([])
|
|
37
|
+
} as any;
|
|
38
|
+
securityService = new SecurityService(logger, testToken);
|
|
39
|
+
concurrencyService = {
|
|
40
|
+
run: vi.fn().mockImplementation((fn) => fn()),
|
|
41
|
+
} as any;
|
|
42
|
+
|
|
43
|
+
const executorRegistry = new ExecutorRegistry();
|
|
44
|
+
// Use real DenoExecutor for this E2E-like test
|
|
45
|
+
// But DenoExecutor initialization might be heavy? Whatever, it creates temp dir.
|
|
46
|
+
// Or we can mock it to return expected stdout.
|
|
47
|
+
// Let's use real one to match previous behavior.
|
|
48
|
+
|
|
49
|
+
// Since we are mocking Deno call in test via 'executeTypeScript', maybe we should mock DenoExecutor execution?
|
|
50
|
+
// But the test sends 'console.log...'.
|
|
51
|
+
// If we mock DenoExecutor, we can make it return 'hello E2E'.
|
|
52
|
+
// That's faster and safer.
|
|
53
|
+
const mockDenoExecutor = {
|
|
54
|
+
execute: vi.fn().mockImplementation(async (code) => {
|
|
55
|
+
// Simple mock that echoes input logic if needed, or just returns static because test sends specific string
|
|
56
|
+
if (code.includes('hello E2E')) return { stdout: 'hello E2E', stderr: '', exitCode: 0 };
|
|
57
|
+
if (code.includes('hello')) return { stdout: 'hello', stderr: '', exitCode: 0 }; // for server busy test
|
|
58
|
+
return { stdout: '', stderr: '', exitCode: 0 };
|
|
59
|
+
}),
|
|
60
|
+
shutdown: vi.fn(),
|
|
61
|
+
healthCheck: vi.fn(),
|
|
62
|
+
warmup: vi.fn()
|
|
63
|
+
};
|
|
64
|
+
executorRegistry.register('deno', mockDenoExecutor as any);
|
|
65
|
+
|
|
66
|
+
const executionService = new ExecutionService(
|
|
67
|
+
logger,
|
|
68
|
+
defaultLimits,
|
|
69
|
+
gatewayService,
|
|
70
|
+
securityService,
|
|
71
|
+
executorRegistry
|
|
72
|
+
);
|
|
73
|
+
executionService.ipcAddress = '127.0.0.1:0'; // Dummy address for tests
|
|
74
|
+
|
|
75
|
+
requestController = new RequestController(logger, executionService, gatewayService, buildDefaultMiddleware(securityService));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(async () => {
|
|
79
|
+
if (transport) {
|
|
80
|
+
await transport.close();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should listen on a TCP port in development mode', async () => {
|
|
85
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
86
|
+
const address = await transport.listen({ port: 0 }); // Random port
|
|
87
|
+
expect(address).toMatch(/127\.0\.0\.1:\d+|:::\d+|0\.0\.0\.0:\d+/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (os.platform() !== 'win32') {
|
|
91
|
+
it('should listen on a Unix socket', async () => {
|
|
92
|
+
const socketPath = path.join(os.tmpdir(), `conduit-test-${Date.now()}.sock`);
|
|
93
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
94
|
+
const address = await transport.listen({ path: socketPath });
|
|
95
|
+
expect(address).toBe(socketPath);
|
|
96
|
+
expect(fs.existsSync(socketPath)).toBe(true);
|
|
97
|
+
|
|
98
|
+
// Cleanup
|
|
99
|
+
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it('should handle mcp.executeTypeScript request', async () => {
|
|
104
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
105
|
+
const address = await transport.listen({ port: 0 });
|
|
106
|
+
const portMatch = address.match(/:(\d+)$/);
|
|
107
|
+
const port = portMatch ? parseInt(portMatch[1]) : 0;
|
|
108
|
+
const host = address.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
|
|
109
|
+
|
|
110
|
+
return new Promise<void>((resolve, reject) => {
|
|
111
|
+
const client = net.createConnection({ host: host === '::' ? '::1' : host, port }, () => {
|
|
112
|
+
const request = {
|
|
113
|
+
jsonrpc: '2.0',
|
|
114
|
+
id: 1,
|
|
115
|
+
method: 'mcp.executeTypeScript',
|
|
116
|
+
params: { code: 'console.log("hello E2E")' },
|
|
117
|
+
auth: { bearerToken: testToken }
|
|
118
|
+
};
|
|
119
|
+
client.write(JSON.stringify(request) + '\n');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
client.on('data', (data) => {
|
|
123
|
+
const responseString = data.toString();
|
|
124
|
+
try {
|
|
125
|
+
const response = JSON.parse(responseString);
|
|
126
|
+
expect(response.id).toBe(1);
|
|
127
|
+
expect(response.result.stdout).toContain('hello E2E');
|
|
128
|
+
resolve();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
reject(err);
|
|
131
|
+
} finally {
|
|
132
|
+
client.end();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
client.on('error', reject);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should return server busy error with correct ID when queue is full', async () => {
|
|
141
|
+
const queueFullError = new Error('Queue full');
|
|
142
|
+
queueFullError.name = 'QueueFullError';
|
|
143
|
+
concurrencyService.run.mockRejectedValue(queueFullError);
|
|
144
|
+
|
|
145
|
+
transport = new SocketTransport(logger, requestController, concurrencyService);
|
|
146
|
+
const address = await transport.listen({ port: 0 });
|
|
147
|
+
const portMatch = address.match(/:(\d+)$/);
|
|
148
|
+
const port = portMatch ? parseInt(portMatch[1]) : 0;
|
|
149
|
+
const host = address.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
|
|
150
|
+
|
|
151
|
+
return new Promise<void>((resolve, reject) => {
|
|
152
|
+
const client = net.createConnection({ host: host === '::' ? '::1' : host, port }, () => {
|
|
153
|
+
const request = {
|
|
154
|
+
jsonrpc: '2.0',
|
|
155
|
+
id: 12345,
|
|
156
|
+
method: 'mcp.executeTypeScript',
|
|
157
|
+
params: { code: 'console.log("hello")' },
|
|
158
|
+
auth: { bearerToken: testToken }
|
|
159
|
+
};
|
|
160
|
+
client.write(JSON.stringify(request) + '\n');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
client.on('data', (data) => {
|
|
164
|
+
const responseString = data.toString();
|
|
165
|
+
try {
|
|
166
|
+
const response = JSON.parse(responseString);
|
|
167
|
+
expect(response.id).toBe(12345);
|
|
168
|
+
expect(response.error).toBeDefined();
|
|
169
|
+
expect(response.error.code).toBe(-32000); // ConduitError.ServerBusy
|
|
170
|
+
expect(response.error.message).toBe('Server busy');
|
|
171
|
+
resolve();
|
|
172
|
+
} catch (err) {
|
|
173
|
+
reject(err);
|
|
174
|
+
} finally {
|
|
175
|
+
client.end();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
client.on('error', reject);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { UpstreamClient } from '../src/gateway/upstream.client.js';
|
|
3
|
+
import { Logger } from 'pino';
|
|
4
|
+
import { mock } from 'vitest-mock-extended';
|
|
5
|
+
import { AuthService } from '../src/gateway/auth.service.js';
|
|
6
|
+
import { IUrlValidator } from '../src/core/interfaces/url.validator.interface.js';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
describe('UpstreamClient (Stdio)', () => {
|
|
10
|
+
const logger = mock<Logger>();
|
|
11
|
+
const authService = mock<AuthService>();
|
|
12
|
+
const urlValidator = mock<IUrlValidator>();
|
|
13
|
+
logger.child.mockReturnThis();
|
|
14
|
+
|
|
15
|
+
it('should connect to a local stdio server and call a tool', async () => {
|
|
16
|
+
const serverPath = path.resolve(__dirname, 'fixtures/stdio-server.ts');
|
|
17
|
+
|
|
18
|
+
// We use ts-node or just node with loader to run the ts file,
|
|
19
|
+
// or we compile it. For simplicity in this env, we assume we can run it via node loader
|
|
20
|
+
// OR we use a simple JS script if TS execution is complex in subprocess.
|
|
21
|
+
// Let's rely on tsx or similar if available, or just compile it?
|
|
22
|
+
// Actually, the project uses `vite-node` or `ts-node`.
|
|
23
|
+
// Let's try running it with `npx tsx`.
|
|
24
|
+
|
|
25
|
+
const client = new UpstreamClient(
|
|
26
|
+
logger,
|
|
27
|
+
{
|
|
28
|
+
id: 'stdio-test',
|
|
29
|
+
type: 'stdio',
|
|
30
|
+
command: 'npx',
|
|
31
|
+
args: ['tsx', serverPath],
|
|
32
|
+
},
|
|
33
|
+
authService,
|
|
34
|
+
urlValidator
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// We simulate a JSON-RPC request like Gateway would send
|
|
38
|
+
const request = {
|
|
39
|
+
jsonrpc: '2.0',
|
|
40
|
+
id: '1',
|
|
41
|
+
method: 'tools/call', // SDK uses 'tools/call'
|
|
42
|
+
params: {
|
|
43
|
+
name: 'echo',
|
|
44
|
+
arguments: { message: 'Hello Stdio' },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const response = await client.call(request as any, { correlationId: 'test-corr' } as any);
|
|
49
|
+
|
|
50
|
+
expect(response.error).toBeUndefined();
|
|
51
|
+
expect(response.result).toBeDefined();
|
|
52
|
+
expect((response.result as any).content[0].text).toBe('Echo: Hello Stdio');
|
|
53
|
+
}, 10000);
|
|
54
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": [
|
|
7
|
+
"ESNext"
|
|
8
|
+
],
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"outDir": "dist",
|
|
14
|
+
"rootDir": "src",
|
|
15
|
+
"declaration": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": [
|
|
19
|
+
"src/**/*"
|
|
20
|
+
],
|
|
21
|
+
"exclude": [
|
|
22
|
+
"node_modules",
|
|
23
|
+
"**/*.test.ts"
|
|
24
|
+
]
|
|
25
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: {
|
|
5
|
+
index: 'src/index.ts',
|
|
6
|
+
'executors/pyodide.worker': 'src/executors/pyodide.worker.ts'
|
|
7
|
+
},
|
|
8
|
+
format: ['esm'],
|
|
9
|
+
dts: true,
|
|
10
|
+
splitting: false,
|
|
11
|
+
sourcemap: true,
|
|
12
|
+
clean: true,
|
|
13
|
+
loader: {
|
|
14
|
+
'.py': 'text',
|
|
15
|
+
'.ts': 'text', // We want the shim source as text
|
|
16
|
+
},
|
|
17
|
+
// Ensure assets are included
|
|
18
|
+
// We can use the 'onSuccess' hook to copy them or just include them in the bundle
|
|
19
|
+
// But the spec says 'into dist/assets', which implies they should be separate files.
|
|
20
|
+
// tsup doesn't have a direct 'copy directory' but we can use a custom plugin or hook.
|
|
21
|
+
onSuccess: 'mkdir -p dist/assets && cp src/assets/* dist/assets/',
|
|
22
|
+
});
|