@mhingston5/conduit 1.1.4 → 1.1.6
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/README.md +13 -2
- package/dist/index.js +500 -131
- package/dist/index.js.map +1 -1
- package/dist/pyodide.worker.js.map +1 -0
- package/package.json +1 -1
- package/src/auth.cmd.ts +161 -14
- package/src/core/config.service.ts +5 -1
- package/src/core/execution.service.ts +5 -0
- package/src/core/policy.service.ts +5 -0
- package/src/core/request.controller.ts +80 -7
- package/src/executors/pyodide.executor.ts +9 -4
- package/src/gateway/auth.service.ts +17 -7
- package/src/gateway/gateway.service.ts +150 -73
- package/src/gateway/host.client.ts +65 -0
- package/src/gateway/upstream.client.ts +21 -14
- package/src/index.ts +33 -7
- package/src/sdk/sdk-generator.ts +90 -30
- package/src/transport/stdio.transport.ts +44 -3
- package/tests/__snapshots__/assets.test.ts.snap +56 -3
- package/tests/code-mode-lite-gateway.test.ts +4 -4
- package/tests/debug.fallback.test.ts +40 -0
- package/tests/debug_upstream.ts +69 -0
- package/tests/dynamic.tool.test.ts +3 -3
- package/tests/gateway.manifest.test.ts +1 -1
- package/tests/gateway.service.test.ts +6 -6
- package/tests/reference_mcp.ts +5 -3
- package/tests/routing.test.ts +8 -1
- package/tests/sdk/sdk-generator.test.ts +10 -9
- package/tsup.config.ts +1 -1
- package/dist/executors/pyodide.worker.js.map +0 -1
- /package/dist/{executors/pyodide.worker.d.ts → pyodide.worker.d.ts} +0 -0
- /package/dist/{executors/pyodide.worker.js → pyodide.worker.js} +0 -0
package/src/sdk/sdk-generator.ts
CHANGED
|
@@ -10,7 +10,9 @@ export class SDKGenerator {
|
|
|
10
10
|
* Convert camelCase to snake_case for Python
|
|
11
11
|
*/
|
|
12
12
|
private toSnakeCase(str: string): string {
|
|
13
|
-
|
|
13
|
+
const snake = str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '').replace(/[^a-z0-9_]/g, '_');
|
|
14
|
+
// Ensure it doesn't start with a number
|
|
15
|
+
return /^[0-9]/.test(snake) ? `_${snake}` : snake;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -41,7 +43,7 @@ export class SDKGenerator {
|
|
|
41
43
|
lines.push('const __allowedTools = null;');
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
lines.push('const
|
|
46
|
+
lines.push('const _tools = {');
|
|
45
47
|
|
|
46
48
|
for (const [namespace, tools] of grouped.entries()) {
|
|
47
49
|
// Validate namespace is a valid identifier
|
|
@@ -92,6 +94,29 @@ export class SDKGenerator {
|
|
|
92
94
|
}
|
|
93
95
|
|
|
94
96
|
lines.push('};');
|
|
97
|
+
lines.push(`
|
|
98
|
+
const tools = new Proxy(_tools, {
|
|
99
|
+
get: (target, prop) => {
|
|
100
|
+
if (prop in target) return target[prop];
|
|
101
|
+
if (prop === 'then') return undefined;
|
|
102
|
+
if (typeof prop === 'string') {
|
|
103
|
+
// Flat tool access fallback: search all namespaces for a matching tool
|
|
104
|
+
for (const nsName of Object.keys(target)) {
|
|
105
|
+
if (nsName === '$raw') continue;
|
|
106
|
+
const ns = target[nsName];
|
|
107
|
+
if (ns && typeof ns === 'object' && ns[prop]) {
|
|
108
|
+
return ns[prop];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const forbidden = ['\x24raw'];
|
|
113
|
+
const namespaces = Object.keys(target).filter(k => !forbidden.includes(k));
|
|
114
|
+
throw new Error(\`Namespace or Tool '\${prop}' not found. Available namespaces: \${namespaces.join(', ') || 'none'}. Use tools.$raw(name, args) for dynamic calls.\`);
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
`);
|
|
95
120
|
lines.push('(globalThis as any).tools = tools;');
|
|
96
121
|
|
|
97
122
|
return lines.join('\n');
|
|
@@ -119,35 +144,47 @@ export class SDKGenerator {
|
|
|
119
144
|
}
|
|
120
145
|
|
|
121
146
|
lines.push('');
|
|
122
|
-
lines.push('class _ToolNamespace:');
|
|
123
|
-
lines.push(' def __init__(self, methods):');
|
|
124
|
-
lines.push(' for name, fn in methods.items():');
|
|
125
|
-
lines.push(' setattr(self, name, fn)');
|
|
126
|
-
lines.push('');
|
|
127
|
-
lines.push('class _Tools:');
|
|
128
|
-
lines.push(' def __init__(self):');
|
|
129
147
|
|
|
148
|
+
// Generate namespace classes
|
|
130
149
|
for (const [namespace, tools] of grouped.entries()) {
|
|
131
150
|
const safeNamespace = this.toSnakeCase(namespace);
|
|
132
|
-
|
|
151
|
+
lines.push(`class _${safeNamespace}_Namespace:`);
|
|
133
152
|
|
|
134
153
|
for (const tool of tools) {
|
|
135
154
|
const methodName = this.toSnakeCase(tool.methodName);
|
|
136
155
|
const fullName = tool.name;
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
156
|
+
// Accept both dict as first arg OR kwargs for flexibility
|
|
157
|
+
lines.push(` async def ${methodName}(self, args=None, **kwargs):`);
|
|
158
|
+
lines.push(` params = args if args is not None else kwargs`);
|
|
159
|
+
lines.push(` return await _internal_call_tool("${this.escapeString(fullName)}", params)`);
|
|
140
160
|
}
|
|
161
|
+
lines.push('');
|
|
162
|
+
}
|
|
141
163
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
164
|
+
// Generate the main Tools class
|
|
165
|
+
lines.push('class _Tools:');
|
|
166
|
+
lines.push(' def __init__(self):');
|
|
167
|
+
if (grouped.size === 0) {
|
|
168
|
+
lines.push(' pass');
|
|
169
|
+
} else {
|
|
170
|
+
for (const [namespace] of grouped.entries()) {
|
|
171
|
+
const safeNamespace = this.toSnakeCase(namespace);
|
|
172
|
+
lines.push(` self.${safeNamespace} = _${safeNamespace}_Namespace()`);
|
|
173
|
+
}
|
|
145
174
|
}
|
|
146
175
|
|
|
147
|
-
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push(' def __getattr__(self, name):');
|
|
178
|
+
lines.push(' # Flat access fallback: search all namespaces');
|
|
179
|
+
lines.push(' for attr_name in dir(self):');
|
|
180
|
+
lines.push(' attr = getattr(self, attr_name, None)');
|
|
181
|
+
lines.push(' if attr and hasattr(attr, name):');
|
|
182
|
+
lines.push(' return getattr(attr, name)');
|
|
183
|
+
lines.push(' raise AttributeError(f"Namespace or Tool \'{name}\' not found")');
|
|
184
|
+
|
|
148
185
|
if (enableRawFallback) {
|
|
149
186
|
lines.push('');
|
|
150
|
-
lines.push(' async def raw(self, name, args):');
|
|
187
|
+
lines.push(' async def raw(self, name, args=None):');
|
|
151
188
|
lines.push(' """Call a tool by its full name (escape hatch for dynamic/unknown tools)"""');
|
|
152
189
|
lines.push(' normalized = name.replace(".", "__")');
|
|
153
190
|
lines.push(' if _allowed_tools is not None:');
|
|
@@ -157,7 +194,7 @@ export class SDKGenerator {
|
|
|
157
194
|
lines.push(' )');
|
|
158
195
|
lines.push(' if not allowed:');
|
|
159
196
|
lines.push(' raise PermissionError(f"Tool {name} is not in the allowlist")');
|
|
160
|
-
lines.push(' return await _internal_call_tool(normalized, args)');
|
|
197
|
+
lines.push(' return await _internal_call_tool(normalized, args or {})');
|
|
161
198
|
}
|
|
162
199
|
|
|
163
200
|
lines.push('');
|
|
@@ -187,7 +224,7 @@ export class SDKGenerator {
|
|
|
187
224
|
lines.push('const __allowedTools = null;');
|
|
188
225
|
}
|
|
189
226
|
|
|
190
|
-
lines.push('const
|
|
227
|
+
lines.push('const _tools = {');
|
|
191
228
|
|
|
192
229
|
for (const [namespace, tools] of grouped.entries()) {
|
|
193
230
|
const safeNamespace = this.isValidIdentifier(namespace) ? namespace : `["${this.escapeString(namespace)}"]`;
|
|
@@ -212,26 +249,49 @@ export class SDKGenerator {
|
|
|
212
249
|
lines.push(` },`);
|
|
213
250
|
}
|
|
214
251
|
|
|
215
|
-
lines.push(
|
|
252
|
+
lines.push(' },');
|
|
216
253
|
}
|
|
217
254
|
|
|
218
255
|
// Add $raw escape hatch
|
|
219
256
|
if (enableRawFallback) {
|
|
220
|
-
lines.push(
|
|
257
|
+
lines.push(' async $raw(name, args) {');
|
|
221
258
|
lines.push(` const normalized = name.replace(/\\./g, '__');`);
|
|
222
|
-
lines.push(
|
|
259
|
+
lines.push(' if (__allowedTools) {');
|
|
223
260
|
lines.push(` const allowed = __allowedTools.some(p => {`);
|
|
224
261
|
lines.push(` if (p.endsWith('__*')) return normalized.startsWith(p.slice(0, -1));`);
|
|
225
|
-
lines.push(
|
|
226
|
-
lines.push(
|
|
227
|
-
lines.push(
|
|
228
|
-
lines.push(
|
|
229
|
-
lines.push(
|
|
230
|
-
lines.push(
|
|
231
|
-
lines.push(
|
|
262
|
+
lines.push(' return normalized === p;');
|
|
263
|
+
lines.push(' });');
|
|
264
|
+
lines.push(' if (!allowed) throw new Error(`Tool ${name} is not in the allowlist`);');
|
|
265
|
+
lines.push(' }');
|
|
266
|
+
lines.push(' const resStr = await __callTool(normalized, JSON.stringify(args || {}));');
|
|
267
|
+
lines.push(' return JSON.parse(resStr);');
|
|
268
|
+
lines.push(' },');
|
|
232
269
|
}
|
|
233
270
|
|
|
234
271
|
lines.push('};');
|
|
272
|
+
lines.push(`
|
|
273
|
+
const tools = new Proxy(_tools, {
|
|
274
|
+
get: (target, prop) => {
|
|
275
|
+
if (prop in target) return target[prop];
|
|
276
|
+
if (prop === 'then') return undefined;
|
|
277
|
+
if (typeof prop === 'string') {
|
|
278
|
+
// Flat tool access fallback: search all namespaces for a matching tool
|
|
279
|
+
for (const nsName of Object.keys(target)) {
|
|
280
|
+
if (nsName === '$raw') continue;
|
|
281
|
+
const ns = target[nsName];
|
|
282
|
+
if (ns && typeof ns === 'object' && ns[prop]) {
|
|
283
|
+
return ns[prop];
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const forbidden = ['\x24raw'];
|
|
288
|
+
const namespaces = Object.keys(target).filter(k => !forbidden.includes(k));
|
|
289
|
+
throw new Error(\`Namespace or Tool '\${prop}' not found. Available namespaces: \${namespaces.join(', ') || 'none'}. Use tools.$raw(name, args) for dynamic calls.\`);
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
`);
|
|
235
295
|
|
|
236
296
|
return lines.join('\n');
|
|
237
297
|
}
|
|
@@ -10,6 +10,7 @@ export class StdioTransport {
|
|
|
10
10
|
private requestController: RequestController;
|
|
11
11
|
private concurrencyService: ConcurrencyService;
|
|
12
12
|
private buffer: string = '';
|
|
13
|
+
private pendingRequests = new Map<string | number, (response: any) => void>();
|
|
13
14
|
|
|
14
15
|
constructor(
|
|
15
16
|
logger: Logger,
|
|
@@ -33,6 +34,34 @@ export class StdioTransport {
|
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
async callHost(method: string, params: any): Promise<any> {
|
|
38
|
+
const id = Math.random().toString(36).substring(7);
|
|
39
|
+
const request = {
|
|
40
|
+
jsonrpc: '2.0',
|
|
41
|
+
id,
|
|
42
|
+
method,
|
|
43
|
+
params
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const timeout = setTimeout(() => {
|
|
48
|
+
this.pendingRequests.delete(id);
|
|
49
|
+
reject(new Error(`Timeout waiting for host response to ${method}`));
|
|
50
|
+
}, 30000);
|
|
51
|
+
|
|
52
|
+
this.pendingRequests.set(id, (response) => {
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
if (response.error) {
|
|
55
|
+
reject(new Error(response.error.message));
|
|
56
|
+
} else {
|
|
57
|
+
resolve(response.result);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.sendResponse(request);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
36
65
|
private handleData(chunk: string) {
|
|
37
66
|
this.buffer += chunk;
|
|
38
67
|
|
|
@@ -48,11 +77,11 @@ export class StdioTransport {
|
|
|
48
77
|
}
|
|
49
78
|
|
|
50
79
|
private async processLine(line: string) {
|
|
51
|
-
let
|
|
80
|
+
let message: any;
|
|
52
81
|
try {
|
|
53
|
-
|
|
82
|
+
message = JSON.parse(line);
|
|
54
83
|
} catch (err) {
|
|
55
|
-
this.logger.error({ err, line }, 'Failed to parse JSON-RPC
|
|
84
|
+
this.logger.error({ err, line }, 'Failed to parse JSON-RPC message');
|
|
56
85
|
const errorResponse = {
|
|
57
86
|
jsonrpc: '2.0',
|
|
58
87
|
id: null,
|
|
@@ -65,6 +94,18 @@ export class StdioTransport {
|
|
|
65
94
|
return;
|
|
66
95
|
}
|
|
67
96
|
|
|
97
|
+
// Handle Response
|
|
98
|
+
if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
|
|
99
|
+
const pending = this.pendingRequests.get(message.id);
|
|
100
|
+
if (pending) {
|
|
101
|
+
this.pendingRequests.delete(message.id);
|
|
102
|
+
pending(message);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle Request
|
|
108
|
+
const request = message as JSONRPCRequest;
|
|
68
109
|
const context = new ExecutionContext({
|
|
69
110
|
logger: this.logger,
|
|
70
111
|
remoteAddress: 'stdio',
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
exports[`Asset Integrity (Golden Tests) > should match Isolate SDK snapshot 1`] = `
|
|
4
4
|
"// Generated SDK for isolated-vm
|
|
5
5
|
const __allowedTools = ["test__*","github__*"];
|
|
6
|
-
const
|
|
6
|
+
const _tools = {
|
|
7
7
|
test: {
|
|
8
8
|
async hello(args) {
|
|
9
9
|
const resStr = await __callTool("test__hello", JSON.stringify(args || {}));
|
|
@@ -28,7 +28,30 @@ const tools = {
|
|
|
28
28
|
const resStr = await __callTool(normalized, JSON.stringify(args || {}));
|
|
29
29
|
return JSON.parse(resStr);
|
|
30
30
|
},
|
|
31
|
-
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const tools = new Proxy(_tools, {
|
|
34
|
+
get: (target, prop) => {
|
|
35
|
+
if (prop in target) return target[prop];
|
|
36
|
+
if (prop === 'then') return undefined;
|
|
37
|
+
if (typeof prop === 'string') {
|
|
38
|
+
// Flat tool access fallback: search all namespaces for a matching tool
|
|
39
|
+
for (const nsName of Object.keys(target)) {
|
|
40
|
+
if (nsName === '$raw') continue;
|
|
41
|
+
const ns = target[nsName];
|
|
42
|
+
if (ns && typeof ns === 'object' && ns[prop]) {
|
|
43
|
+
return ns[prop];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const forbidden = ['$raw'];
|
|
48
|
+
const namespaces = Object.keys(target).filter(k => !forbidden.includes(k));
|
|
49
|
+
throw new Error(\`Namespace or Tool '\${prop}' not found. Available namespaces: \${namespaces.join(', ') || 'none'}. Use tools.$raw(name, args) for dynamic calls.\`);
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
"
|
|
32
55
|
`;
|
|
33
56
|
|
|
34
57
|
exports[`Asset Integrity (Golden Tests) > should match Python SDK snapshot 1`] = `
|
|
@@ -49,6 +72,13 @@ class _Tools:
|
|
|
49
72
|
"create_issue": lambda args, n="github__create_issue": _internal_call_tool(n, args)
|
|
50
73
|
})
|
|
51
74
|
|
|
75
|
+
def __getattr__(self, name):
|
|
76
|
+
# Flat access fallback: search all namespaces
|
|
77
|
+
for ns in self.__dict__.values():
|
|
78
|
+
if isinstance(ns, _ToolNamespace) and hasattr(ns, name):
|
|
79
|
+
return getattr(ns, name)
|
|
80
|
+
raise AttributeError(f"Namespace or Tool '{name}' not found")
|
|
81
|
+
|
|
52
82
|
async def raw(self, name, args):
|
|
53
83
|
"""Call a tool by its full name (escape hatch for dynamic/unknown tools)"""
|
|
54
84
|
normalized = name.replace(".", "__")
|
|
@@ -67,7 +97,7 @@ tools = _Tools()"
|
|
|
67
97
|
exports[`Asset Integrity (Golden Tests) > should match TypeScript SDK snapshot 1`] = `
|
|
68
98
|
"// Generated SDK - Do not edit
|
|
69
99
|
const __allowedTools = ["test__*","github__*"];
|
|
70
|
-
const
|
|
100
|
+
const _tools = {
|
|
71
101
|
test: {
|
|
72
102
|
/** Returns a greeting */
|
|
73
103
|
async hello(args) {
|
|
@@ -93,5 +123,28 @@ const tools = {
|
|
|
93
123
|
return await __internalCallTool(normalized, args);
|
|
94
124
|
},
|
|
95
125
|
};
|
|
126
|
+
|
|
127
|
+
const tools = new Proxy(_tools, {
|
|
128
|
+
get: (target, prop) => {
|
|
129
|
+
if (prop in target) return target[prop];
|
|
130
|
+
if (prop === 'then') return undefined;
|
|
131
|
+
if (typeof prop === 'string') {
|
|
132
|
+
// Flat tool access fallback: search all namespaces for a matching tool
|
|
133
|
+
for (const nsName of Object.keys(target)) {
|
|
134
|
+
if (nsName === '$raw') continue;
|
|
135
|
+
const ns = target[nsName];
|
|
136
|
+
if (ns && typeof ns === 'object' && ns[prop]) {
|
|
137
|
+
return ns[prop];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const forbidden = ['$raw'];
|
|
142
|
+
const namespaces = Object.keys(target).filter(k => !forbidden.includes(k));
|
|
143
|
+
throw new Error(\`Namespace or Tool '\${prop}' not found. Available namespaces: \${namespaces.join(', ') || 'none'}. Use tools.$raw(name, args) for dynamic calls.\`);
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
96
149
|
(globalThis as any).tools = tools;"
|
|
97
150
|
`;
|
|
@@ -36,11 +36,11 @@ describe('GatewayService (Code Mode Lite)', () => {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
describe('listToolPackages', () => {
|
|
39
|
-
it('should return registered tool packages', async () => {
|
|
39
|
+
it('should return registered tool packages including built-ins', async () => {
|
|
40
40
|
const packages = await gateway.listToolPackages();
|
|
41
|
-
expect(packages).toHaveLength(
|
|
42
|
-
expect(packages
|
|
43
|
-
expect(packages
|
|
41
|
+
expect(packages).toHaveLength(2); // conduit + mock-upstream
|
|
42
|
+
expect(packages.find(p => p.id === 'conduit')).toBeDefined();
|
|
43
|
+
expect(packages.find(p => p.id === 'mock-upstream')).toBeDefined();
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -0,0 +1,40 @@
|
|
|
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 pino from 'pino';
|
|
5
|
+
|
|
6
|
+
const logger = pino({ level: 'silent' });
|
|
7
|
+
|
|
8
|
+
describe('GatewayService Namespace Fallback Debug', () => {
|
|
9
|
+
let gateway: GatewayService;
|
|
10
|
+
let context: ExecutionContext;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
const securityService = {
|
|
14
|
+
validateUrl: vi.fn().mockReturnValue({ valid: true }),
|
|
15
|
+
} as any;
|
|
16
|
+
gateway = new GatewayService(logger, securityService);
|
|
17
|
+
context = new ExecutionContext({ logger });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should show detailed error when upstream not found', async () => {
|
|
21
|
+
const response = await gateway.callTool('nonexistent__tool', {}, context);
|
|
22
|
+
expect(response.error?.message).toContain("Upstream not found: 'nonexistent'");
|
|
23
|
+
expect(response.error?.message).toContain("Available: none");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should hit fallback for namespaceless tool', async () => {
|
|
27
|
+
// Register an upstream with a tool
|
|
28
|
+
gateway.registerUpstream({ id: 'fs', url: 'http://fs' });
|
|
29
|
+
|
|
30
|
+
// Mock discovery
|
|
31
|
+
vi.spyOn(gateway, 'discoverTools').mockResolvedValue([
|
|
32
|
+
{ name: 'fs__list_directory', description: '', inputSchema: {} }
|
|
33
|
+
] as any);
|
|
34
|
+
|
|
35
|
+
const response = await gateway.callTool('list_directory', {}, context);
|
|
36
|
+
// It should NOT return "Upstream not found: ''"
|
|
37
|
+
expect(response.error?.message).not.toContain("Upstream not found");
|
|
38
|
+
// It should try to call fs__list_directory (which will fail due to lack of axios mock here, but that's fine)
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug script to verify the filesystem upstream is working correctly.
|
|
3
|
+
* Run with: npx tsx tests/debug_upstream.ts
|
|
4
|
+
*/
|
|
5
|
+
import { ConfigService } from '../src/core/config.service.js';
|
|
6
|
+
import { createLogger } from '../src/core/logger.js';
|
|
7
|
+
import { GatewayService } from '../src/gateway/gateway.service.js';
|
|
8
|
+
import { SecurityService } from '../src/core/security.service.js';
|
|
9
|
+
import { ExecutionContext } from '../src/core/execution.context.js';
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
console.log('=== Conduit Upstream Debug ===\n');
|
|
13
|
+
|
|
14
|
+
const configService = new ConfigService();
|
|
15
|
+
const logger = createLogger(configService);
|
|
16
|
+
|
|
17
|
+
console.log('Config loaded from:', process.cwd());
|
|
18
|
+
console.log('Upstreams configured:', JSON.stringify(configService.get('upstreams'), null, 2));
|
|
19
|
+
console.log();
|
|
20
|
+
|
|
21
|
+
const securityService = new SecurityService(logger, undefined);
|
|
22
|
+
const gatewayService = new GatewayService(logger, securityService);
|
|
23
|
+
|
|
24
|
+
// Register upstreams from config
|
|
25
|
+
const upstreams = configService.get('upstreams') || [];
|
|
26
|
+
for (const upstream of upstreams) {
|
|
27
|
+
console.log(`Registering upstream: ${upstream.id} (${upstream.type})`);
|
|
28
|
+
gatewayService.registerUpstream(upstream);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log('\n=== Testing Tool Discovery ===\n');
|
|
32
|
+
|
|
33
|
+
const context = new ExecutionContext({ logger });
|
|
34
|
+
|
|
35
|
+
// Give the filesystem server a moment to start
|
|
36
|
+
console.log('Waiting 3 seconds for upstream servers to initialize...');
|
|
37
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const tools = await gatewayService.discoverTools(context);
|
|
41
|
+
console.log(`\nDiscovered ${tools.length} tools:`);
|
|
42
|
+
for (const tool of tools) {
|
|
43
|
+
console.log(` - ${tool.name}: ${tool.description || '(no description)'}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try to find list_directory
|
|
47
|
+
const listDirTool = tools.find(t => t.name.includes('list_directory') || t.name.includes('list_dir'));
|
|
48
|
+
if (listDirTool) {
|
|
49
|
+
console.log(`\n✅ Found list_directory tool: ${listDirTool.name}`);
|
|
50
|
+
|
|
51
|
+
// Try calling it
|
|
52
|
+
console.log('\n=== Testing Tool Call ===\n');
|
|
53
|
+
const result = await gatewayService.callTool(listDirTool.name, { path: '/private/tmp' }, context);
|
|
54
|
+
console.log('Result:', JSON.stringify(result, null, 2));
|
|
55
|
+
} else {
|
|
56
|
+
console.log('\n❌ list_directory tool NOT found in discovered tools');
|
|
57
|
+
}
|
|
58
|
+
} catch (error: any) {
|
|
59
|
+
console.error('Error during discovery:', error.message);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log('\n=== Debug Complete ===');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch(err => {
|
|
67
|
+
console.error('Fatal error:', err);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
|
@@ -44,10 +44,10 @@ describe('Dynamic Tool Calling (E2E)', () => {
|
|
|
44
44
|
// Register a mock upstream tool
|
|
45
45
|
(gatewayService as any).clients.set('mock', {
|
|
46
46
|
call: vi.fn().mockImplementation((req) => {
|
|
47
|
-
if (req.method === '
|
|
47
|
+
if (req.method === 'tools/call' && req.params.name === 'hello') {
|
|
48
48
|
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: `Hello ${req.params.arguments.name}` }] } };
|
|
49
49
|
}
|
|
50
|
-
if (req.method === '
|
|
50
|
+
if (req.method === 'tools/list') {
|
|
51
51
|
return { jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 'hello', inputSchema: {} }] } };
|
|
52
52
|
}
|
|
53
53
|
return { jsonrpc: '2.0', id: req.id, result: {} };
|
|
@@ -227,7 +227,7 @@ print(f"RESULT:{result}")
|
|
|
227
227
|
const request = callArgs[0];
|
|
228
228
|
|
|
229
229
|
expect(request).toMatchObject({
|
|
230
|
-
method: '
|
|
230
|
+
method: 'tools/call',
|
|
231
231
|
params: {
|
|
232
232
|
name: 'hello',
|
|
233
233
|
arguments: { name: 'Isolate' }
|
|
@@ -56,7 +56,7 @@ describe('GatewayService (Manifests)', () => {
|
|
|
56
56
|
const stubs = await gateway.listToolStubs('test-upstream', context);
|
|
57
57
|
|
|
58
58
|
expect(mockClient.getManifest).toHaveBeenCalled();
|
|
59
|
-
expect(mockClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: '
|
|
59
|
+
expect(mockClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'tools/list' }), context);
|
|
60
60
|
expect(stubs).toHaveLength(1);
|
|
61
61
|
expect(stubs[0].id).toBe('test-upstream__tool_rpc');
|
|
62
62
|
});
|
|
@@ -32,17 +32,17 @@ describe('GatewayService', () => {
|
|
|
32
32
|
|
|
33
33
|
const tools = await gateway.discoverTools(context);
|
|
34
34
|
expect(tools.length).toBeGreaterThanOrEqual(3);
|
|
35
|
-
expect(tools.find(t => t.name === '
|
|
36
|
-
expect(tools.find(t => t.name === '
|
|
37
|
-
expect(tools.find(t => t.name === '
|
|
35
|
+
expect(tools.find(t => t.name === 'conduit__mcp_execute_typescript')).toBeDefined();
|
|
36
|
+
expect(tools.find(t => t.name === 'conduit__mcp_execute_python')).toBeDefined();
|
|
37
|
+
expect(tools.find(t => t.name === 'conduit__mcp_execute_isolate')).toBeDefined();
|
|
38
38
|
expect(tools.find(t => t.name === 'u1__t1')).toBeDefined();
|
|
39
39
|
expect(tools.find(t => t.name === 'u2__t2')).toBeDefined();
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it('should return schema for built-in tools', async () => {
|
|
43
|
-
const schema = await gateway.getToolSchema('
|
|
43
|
+
const schema = await gateway.getToolSchema('conduit__mcp_execute_typescript', context);
|
|
44
44
|
expect(schema).toBeDefined();
|
|
45
|
-
expect(schema?.name).toBe('
|
|
45
|
+
expect(schema?.name).toBe('conduit__mcp_execute_typescript');
|
|
46
46
|
expect(schema?.inputSchema.required).toContain('code');
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -58,7 +58,7 @@ describe('GatewayService', () => {
|
|
|
58
58
|
expect(axios.post).toHaveBeenCalledWith(
|
|
59
59
|
'http://u1',
|
|
60
60
|
expect.objectContaining({
|
|
61
|
-
method: '
|
|
61
|
+
method: 'tools/call',
|
|
62
62
|
params: expect.objectContaining({ name: 't1' })
|
|
63
63
|
}),
|
|
64
64
|
expect.anything()
|
package/tests/reference_mcp.ts
CHANGED
|
@@ -5,7 +5,7 @@ const server = Fastify();
|
|
|
5
5
|
server.post('/', async (request, reply) => {
|
|
6
6
|
const { method, params, id } = request.body as any;
|
|
7
7
|
|
|
8
|
-
if (method === 'list_tools') {
|
|
8
|
+
if (method === 'list_tools' || method === 'tools/list') {
|
|
9
9
|
return {
|
|
10
10
|
jsonrpc: '2.0',
|
|
11
11
|
id,
|
|
@@ -21,12 +21,14 @@ server.post('/', async (request, reply) => {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
if (method === 'call_tool') {
|
|
24
|
+
if (method === 'call_tool' || method === 'tools/call') {
|
|
25
|
+
const toolName = params.name || '';
|
|
26
|
+
const args = params.arguments || {};
|
|
25
27
|
return {
|
|
26
28
|
jsonrpc: '2.0',
|
|
27
29
|
id,
|
|
28
30
|
result: {
|
|
29
|
-
content: [{ type: 'text', text: `Echo: ${JSON.stringify(
|
|
31
|
+
content: [{ type: 'text', text: `Echo: ${JSON.stringify(args)}` }]
|
|
30
32
|
}
|
|
31
33
|
};
|
|
32
34
|
}
|
package/tests/routing.test.ts
CHANGED
|
@@ -21,6 +21,13 @@ describe('RequestController Routing', () => {
|
|
|
21
21
|
mockGatewayService = {
|
|
22
22
|
callTool: vi.fn(),
|
|
23
23
|
discoverTools: vi.fn().mockResolvedValue([]),
|
|
24
|
+
policyService: {
|
|
25
|
+
parseToolName: (name: string) => {
|
|
26
|
+
const idx = name.indexOf('__');
|
|
27
|
+
if (idx === -1) return { namespace: '', name };
|
|
28
|
+
return { namespace: name.substring(0, idx), name: name.substring(idx + 2) };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
24
31
|
};
|
|
25
32
|
mockDenoExecutor = {
|
|
26
33
|
execute: vi.fn().mockResolvedValue({ stdout: 'deno', stderr: '', exitCode: 0 }),
|
|
@@ -162,7 +169,7 @@ describe('RequestController Routing', () => {
|
|
|
162
169
|
}, mockContext);
|
|
163
170
|
|
|
164
171
|
expect(mockIsolateExecutor.execute).toHaveBeenCalled();
|
|
165
|
-
expect(result!.result.stdout).toBe('isolate');
|
|
172
|
+
expect(result!.result.structuredContent.stdout).toBe('isolate');
|
|
166
173
|
});
|
|
167
174
|
|
|
168
175
|
});
|
|
@@ -15,12 +15,13 @@ describe('SDKGenerator', () => {
|
|
|
15
15
|
|
|
16
16
|
const code = generator.generateTypeScript(bindings);
|
|
17
17
|
|
|
18
|
-
expect(code).toContain('const
|
|
18
|
+
expect(code).toContain('const _tools = {');
|
|
19
19
|
expect(code).toContain('github: {');
|
|
20
20
|
expect(code).toContain('async createIssue(args)');
|
|
21
21
|
expect(code).toContain('await __internalCallTool("github__createIssue", args)');
|
|
22
22
|
expect(code).toContain('slack: {');
|
|
23
23
|
expect(code).toContain('async sendMessage(args)');
|
|
24
|
+
expect(code).toContain('const tools = new Proxy(_tools');
|
|
24
25
|
expect(code).toContain('(globalThis as any).tools = tools');
|
|
25
26
|
});
|
|
26
27
|
|
|
@@ -42,7 +43,7 @@ describe('SDKGenerator', () => {
|
|
|
42
43
|
|
|
43
44
|
const code = generator.generateTypeScript(bindings, undefined, false);
|
|
44
45
|
|
|
45
|
-
expect(code).not.toContain('$raw');
|
|
46
|
+
expect(code).not.toContain('async $raw(name, args)');
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
it('should include JSDoc comments from descriptions', () => {
|
|
@@ -58,7 +59,7 @@ describe('SDKGenerator', () => {
|
|
|
58
59
|
it('should handle empty bindings', () => {
|
|
59
60
|
const code = generator.generateTypeScript([]);
|
|
60
61
|
|
|
61
|
-
expect(code).toContain('const
|
|
62
|
+
expect(code).toContain('const _tools = {');
|
|
62
63
|
expect(code).toContain('async $raw(name, args)');
|
|
63
64
|
});
|
|
64
65
|
});
|
|
@@ -73,10 +74,10 @@ describe('SDKGenerator', () => {
|
|
|
73
74
|
const code = generator.generatePython(bindings);
|
|
74
75
|
|
|
75
76
|
expect(code).toContain('class _Tools:');
|
|
76
|
-
expect(code).toContain('self.github =
|
|
77
|
-
expect(code).toContain('
|
|
78
|
-
expect(code).toContain('self.slack =
|
|
79
|
-
expect(code).toContain('
|
|
77
|
+
expect(code).toContain('self.github = _github_Namespace');
|
|
78
|
+
expect(code).toContain('async def create_issue(self, args=None, **kwargs)'); // accepts dict or kwargs
|
|
79
|
+
expect(code).toContain('self.slack = _slack_Namespace');
|
|
80
|
+
expect(code).toContain('async def send_message(self, args=None, **kwargs)'); // accepts dict or kwargs
|
|
80
81
|
expect(code).toContain('tools = _Tools()');
|
|
81
82
|
});
|
|
82
83
|
|
|
@@ -87,8 +88,8 @@ describe('SDKGenerator', () => {
|
|
|
87
88
|
|
|
88
89
|
const code = generator.generatePython(bindings);
|
|
89
90
|
|
|
90
|
-
expect(code).toContain('async def raw(self, name, args)');
|
|
91
|
-
expect(code).toContain('await _internal_call_tool(normalized, args)');
|
|
91
|
+
expect(code).toContain('async def raw(self, name, args=None)');
|
|
92
|
+
expect(code).toContain('await _internal_call_tool(normalized, args or {})');
|
|
92
93
|
});
|
|
93
94
|
|
|
94
95
|
it('should inject allowlist when provided', () => {
|