@mhingston5/conduit 1.1.5 → 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.
@@ -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
- return str.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '');
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
  /**
@@ -98,7 +100,18 @@ const tools = new Proxy(_tools, {
98
100
  if (prop in target) return target[prop];
99
101
  if (prop === 'then') return undefined;
100
102
  if (typeof prop === 'string') {
101
- throw new Error(\`Namespace '\${prop}' not found. It might be invalid, or all tools in it were disallowed.\`);
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.\`);
102
115
  }
103
116
  return undefined;
104
117
  }
@@ -131,35 +144,47 @@ const tools = new Proxy(_tools, {
131
144
  }
132
145
 
133
146
  lines.push('');
134
- lines.push('class _ToolNamespace:');
135
- lines.push(' def __init__(self, methods):');
136
- lines.push(' for name, fn in methods.items():');
137
- lines.push(' setattr(self, name, fn)');
138
- lines.push('');
139
- lines.push('class _Tools:');
140
- lines.push(' def __init__(self):');
141
147
 
148
+ // Generate namespace classes
142
149
  for (const [namespace, tools] of grouped.entries()) {
143
150
  const safeNamespace = this.toSnakeCase(namespace);
144
- const methodsDict: string[] = [];
151
+ lines.push(`class _${safeNamespace}_Namespace:`);
145
152
 
146
153
  for (const tool of tools) {
147
154
  const methodName = this.toSnakeCase(tool.methodName);
148
155
  const fullName = tool.name;
149
- // Use async lambda - Python doesn't have async lambdas natively,
150
- // so we define methods that return awaitable coroutines
151
- methodsDict.push(` "${methodName}": lambda args, n="${this.escapeString(fullName)}": _internal_call_tool(n, args)`);
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)`);
152
160
  }
161
+ lines.push('');
162
+ }
153
163
 
154
- lines.push(` self.${safeNamespace} = _ToolNamespace({`);
155
- lines.push(methodsDict.join(',\n'));
156
- lines.push(` })`);
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
+ }
157
174
  }
158
175
 
159
- // Add raw escape hatch with allowlist validation
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
+
160
185
  if (enableRawFallback) {
161
186
  lines.push('');
162
- lines.push(' async def raw(self, name, args):');
187
+ lines.push(' async def raw(self, name, args=None):');
163
188
  lines.push(' """Call a tool by its full name (escape hatch for dynamic/unknown tools)"""');
164
189
  lines.push(' normalized = name.replace(".", "__")');
165
190
  lines.push(' if _allowed_tools is not None:');
@@ -169,7 +194,7 @@ const tools = new Proxy(_tools, {
169
194
  lines.push(' )');
170
195
  lines.push(' if not allowed:');
171
196
  lines.push(' raise PermissionError(f"Tool {name} is not in the allowlist")');
172
- lines.push(' return await _internal_call_tool(normalized, args)');
197
+ lines.push(' return await _internal_call_tool(normalized, args or {})');
173
198
  }
174
199
 
175
200
  lines.push('');
@@ -224,23 +249,23 @@ const tools = new Proxy(_tools, {
224
249
  lines.push(` },`);
225
250
  }
226
251
 
227
- lines.push(` },`);
252
+ lines.push(' },');
228
253
  }
229
254
 
230
255
  // Add $raw escape hatch
231
256
  if (enableRawFallback) {
232
- lines.push(` async $raw(name, args) {`);
257
+ lines.push(' async $raw(name, args) {');
233
258
  lines.push(` const normalized = name.replace(/\\./g, '__');`);
234
- lines.push(` if (__allowedTools) {`);
259
+ lines.push(' if (__allowedTools) {');
235
260
  lines.push(` const allowed = __allowedTools.some(p => {`);
236
261
  lines.push(` if (p.endsWith('__*')) return normalized.startsWith(p.slice(0, -1));`);
237
- lines.push(` return normalized === p;`);
238
- lines.push(` });`);
239
- lines.push(` if (!allowed) throw new Error(\`Tool \${name} is not in the allowlist\`);`);
240
- lines.push(` }`);
241
- lines.push(` const resStr = await __callTool(normalized, JSON.stringify(args || {}));`);
242
- lines.push(` return JSON.parse(resStr);`);
243
- 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(' },');
244
269
  }
245
270
 
246
271
  lines.push('};');
@@ -250,7 +275,18 @@ const tools = new Proxy(_tools, {
250
275
  if (prop in target) return target[prop];
251
276
  if (prop === 'then') return undefined;
252
277
  if (typeof prop === 'string') {
253
- throw new Error(\`Namespace '\${prop}' not found. It might be invalid, or all tools in it were disallowed.\`);
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.\`);
254
290
  }
255
291
  return undefined;
256
292
  }
@@ -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 request: JSONRPCRequest;
80
+ let message: any;
52
81
  try {
53
- request = JSON.parse(line) as JSONRPCRequest;
82
+ message = JSON.parse(line);
54
83
  } catch (err) {
55
- this.logger.error({ err, line }, 'Failed to parse JSON-RPC request');
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',
@@ -35,7 +35,18 @@ const tools = new Proxy(_tools, {
35
35
  if (prop in target) return target[prop];
36
36
  if (prop === 'then') return undefined;
37
37
  if (typeof prop === 'string') {
38
- throw new Error(\`Namespace '\${prop}' not found. It might be invalid, or all tools in it were disallowed.\`);
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.\`);
39
50
  }
40
51
  return undefined;
41
52
  }
@@ -61,6 +72,13 @@ class _Tools:
61
72
  "create_issue": lambda args, n="github__create_issue": _internal_call_tool(n, args)
62
73
  })
63
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
+
64
82
  async def raw(self, name, args):
65
83
  """Call a tool by its full name (escape hatch for dynamic/unknown tools)"""
66
84
  normalized = name.replace(".", "__")
@@ -111,7 +129,18 @@ const tools = new Proxy(_tools, {
111
129
  if (prop in target) return target[prop];
112
130
  if (prop === 'then') return undefined;
113
131
  if (typeof prop === 'string') {
114
- throw new Error(\`Namespace '\${prop}' not found. It might be invalid, or all tools in it were disallowed.\`);
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.\`);
115
144
  }
116
145
  return undefined;
117
146
  }
@@ -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(1);
42
- expect(packages[0].id).toBe('mock-upstream');
43
- expect(packages[0].description).toContain('Upstream mock-upstream');
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
+ });
@@ -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 === 'mcp_execute_typescript')).toBeDefined();
36
- expect(tools.find(t => t.name === 'mcp_execute_python')).toBeDefined();
37
- expect(tools.find(t => t.name === 'mcp_execute_isolate')).toBeDefined();
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('mcp_execute_typescript', context);
43
+ const schema = await gateway.getToolSchema('conduit__mcp_execute_typescript', context);
44
44
  expect(schema).toBeDefined();
45
- expect(schema?.name).toBe('mcp_execute_typescript');
45
+ expect(schema?.name).toBe('conduit__mcp_execute_typescript');
46
46
  expect(schema?.inputSchema.required).toContain('code');
47
47
  });
48
48
 
@@ -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 }),
@@ -43,7 +43,7 @@ describe('SDKGenerator', () => {
43
43
 
44
44
  const code = generator.generateTypeScript(bindings, undefined, false);
45
45
 
46
- expect(code).not.toContain('$raw');
46
+ expect(code).not.toContain('async $raw(name, args)');
47
47
  });
48
48
 
49
49
  it('should include JSDoc comments from descriptions', () => {
@@ -74,10 +74,10 @@ describe('SDKGenerator', () => {
74
74
  const code = generator.generatePython(bindings);
75
75
 
76
76
  expect(code).toContain('class _Tools:');
77
- expect(code).toContain('self.github = _ToolNamespace');
78
- expect(code).toContain('"create_issue"'); // snake_case conversion
79
- expect(code).toContain('self.slack = _ToolNamespace');
80
- expect(code).toContain('"send_message"'); // snake_case conversion
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
81
81
  expect(code).toContain('tools = _Tools()');
82
82
  });
83
83
 
@@ -88,8 +88,8 @@ describe('SDKGenerator', () => {
88
88
 
89
89
  const code = generator.generatePython(bindings);
90
90
 
91
- expect(code).toContain('async def raw(self, name, args)');
92
- 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 {})');
93
93
  });
94
94
 
95
95
  it('should inject allowlist when provided', () => {