@mhingston5/conduit 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ import { Logger } from 'pino';
2
+ import { JSONRPCRequest, JSONRPCResponse } from '../core/types.js';
3
+ import { ExecutionContext } from '../core/execution.context.js';
4
+ import { StdioTransport } from '../transport/stdio.transport.js';
5
+
6
+ /**
7
+ * HostClient - Proxies tool calls back to the MCP client (e.g. VS Code)
8
+ * that is hosting this Conduit process.
9
+ */
10
+ export class HostClient {
11
+ constructor(
12
+ private logger: Logger,
13
+ private transport: StdioTransport
14
+ ) { }
15
+
16
+ async call(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
17
+ try {
18
+ this.logger.debug({ method: request.method }, 'Forwarding request to host');
19
+
20
+ let method = request.method;
21
+ let params = request.params;
22
+
23
+ // Bridge mcp_* calls to standard MCP calls for the host
24
+ if (method === 'mcp_call_tool' || method === 'call_tool') {
25
+ method = 'tools/call';
26
+ } else if (method === 'mcp_discover_tools' || method === 'discover_tools') {
27
+ method = 'tools/list';
28
+ params = {};
29
+ }
30
+
31
+ const result = await this.transport.callHost(method, params);
32
+
33
+ return {
34
+ jsonrpc: '2.0',
35
+ id: request.id,
36
+ result
37
+ };
38
+ } catch (error: any) {
39
+ this.logger.error({ err: error.message }, 'Host call failed');
40
+ return {
41
+ jsonrpc: '2.0',
42
+ id: request.id,
43
+ error: {
44
+ code: -32008,
45
+ message: `Host error: ${error.message}`,
46
+ },
47
+ };
48
+ }
49
+ }
50
+
51
+ async listTools(): Promise<any[]> {
52
+ try {
53
+ this.logger.debug('Fetching tool list from host');
54
+ const result = await this.transport.callHost('tools/list', {});
55
+ return result.tools || [];
56
+ } catch (error: any) {
57
+ this.logger.warn({ err: error.message }, 'Failed to fetch tools from host');
58
+ return [];
59
+ }
60
+ }
61
+
62
+ async getManifest() {
63
+ return null;
64
+ }
65
+ }
@@ -7,6 +7,8 @@ import { IUrlValidator } from '../core/interfaces/url.validator.interface.js';
7
7
 
8
8
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9
9
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
10
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
11
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
10
12
  import { z } from 'zod';
11
13
 
12
14
  export type UpstreamInfo = {
@@ -14,6 +16,8 @@ export type UpstreamInfo = {
14
16
  credentials?: UpstreamCredentials;
15
17
  } & (
16
18
  | { type?: 'http'; url: string }
19
+ | { type: 'streamableHttp'; url: string }
20
+ | { type: 'sse'; url: string }
17
21
  | { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
18
22
  );
19
23
 
@@ -23,7 +27,8 @@ export class UpstreamClient {
23
27
  private authService: AuthService;
24
28
  private urlValidator: IUrlValidator;
25
29
  private mcpClient?: Client;
26
- private transport?: StdioClientTransport;
30
+ private transport?: StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport;
31
+ private connected: boolean = false;
27
32
 
28
33
  constructor(logger: Logger, info: UpstreamInfo, authService: AuthService, urlValidator: IUrlValidator) {
29
34
  this.logger = logger.child({ upstreamId: info.id });
@@ -50,40 +55,101 @@ export class UpstreamClient {
50
55
  }, {
51
56
  capabilities: {},
52
57
  });
58
+ return;
59
+ }
60
+
61
+ if (this.info.type === 'streamableHttp') {
62
+ this.transport = new StreamableHTTPClientTransport(new URL(this.info.url), {
63
+ fetch: this.createAuthedFetch(),
64
+ });
65
+ this.mcpClient = new Client({
66
+ name: 'conduit-gateway',
67
+ version: '1.0.0',
68
+ }, {
69
+ capabilities: {},
70
+ });
71
+ return;
72
+ }
73
+
74
+ if (this.info.type === 'sse') {
75
+ this.mcpClient = new Client({
76
+ name: 'conduit-gateway',
77
+ version: '1.0.0',
78
+ }, {
79
+ capabilities: {},
80
+ });
53
81
  }
54
82
  }
55
83
 
56
- private async ensureConnected() {
57
- if (!this.mcpClient || !this.transport) return;
58
- // There isn't a public isConnected property easily accessible,
59
- // usually we just connect once.
60
- // We can track connected state or just try/catch connect.
61
- // For simplicity, we connect once and existing sdk handles reconnection or errors usually kill it.
62
- // Actually SDK Client.connect() is for the transport.
63
- try {
64
- // @ts-ignore - Check internal state or just attempt connect if we haven't
65
- if (!this.transport.connection) {
66
- await this.mcpClient.connect(this.transport);
84
+ private createAuthedFetch() {
85
+ const creds = this.info.credentials;
86
+ if (!creds) return fetch;
87
+
88
+ return async (input: any, init: any = {}) => {
89
+ const headers = new Headers(init.headers || {});
90
+ const authHeaders = await this.authService.getAuthHeaders(creds);
91
+ for (const [k, v] of Object.entries(authHeaders)) {
92
+ headers.set(k, v);
67
93
  }
68
- } catch (e) {
69
- // connection might already be active
94
+ return fetch(input, { ...init, headers });
95
+ };
96
+ }
97
+
98
+ private async ensureConnected() {
99
+ if (!this.mcpClient) return;
100
+
101
+ if (!this.transport && this.info.type === 'sse') {
102
+ const authHeaders = this.info.credentials
103
+ ? await this.authService.getAuthHeaders(this.info.credentials)
104
+ : {};
105
+
106
+ this.transport = new SSEClientTransport(new URL(this.info.url), {
107
+ fetch: this.createAuthedFetch(),
108
+ eventSourceInit: { headers: authHeaders } as any,
109
+ requestInit: { headers: authHeaders },
110
+ });
111
+ }
112
+
113
+ if (!this.transport) return;
114
+ if (this.connected) return;
115
+
116
+ if (this.info.type === 'streamableHttp' || this.info.type === 'sse') {
117
+ const securityResult = await this.urlValidator.validateUrl(this.info.url);
118
+ if (!securityResult.valid) {
119
+ this.logger.error({ url: this.info.url }, 'Blocked upstream URL (SSRF)');
120
+ throw new Error(securityResult.message || 'Forbidden URL');
121
+ }
122
+ }
123
+
124
+ try {
125
+ this.logger.debug('Connecting to upstream transport...');
126
+ await this.mcpClient.connect(this.transport);
127
+ this.connected = true;
128
+ this.logger.info('Connected to upstream MCP');
129
+ } catch (e: any) {
130
+ this.logger.error({ err: e.message }, 'Failed to connect to upstream');
131
+ throw e;
70
132
  }
71
133
  }
72
134
 
73
135
  async call(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
74
- // Helper to determine type safely
75
- const isStdio = (info: UpstreamInfo): info is { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string>; id: string; credentials?: UpstreamCredentials } => info.type === 'stdio';
136
+ const usesMcpClientTransport = (info: UpstreamInfo): info is (
137
+ | { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
138
+ | { type: 'streamableHttp'; url: string }
139
+ | { type: 'sse'; url: string }
140
+ ) & { id: string; credentials?: UpstreamCredentials } =>
141
+ info.type === 'stdio' || info.type === 'streamableHttp' || info.type === 'sse';
76
142
 
77
- if (isStdio(this.info)) {
78
- return this.callStdio(request);
79
- } else {
80
- return this.callHttp(request, context as ExecutionContext);
143
+ if (usesMcpClientTransport(this.info)) {
144
+ return this.callMcpClient(request);
81
145
  }
146
+
147
+ return this.callHttp(request, context as ExecutionContext);
82
148
  }
83
149
 
84
- private async callStdio(request: JSONRPCRequest): Promise<JSONRPCResponse> {
150
+ private async callMcpClient(request: JSONRPCRequest): Promise<JSONRPCResponse> {
85
151
  if (!this.mcpClient) {
86
- return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Stdio client not initialized' } };
152
+ return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'MCP client not initialized' } };
87
153
  }
88
154
 
89
155
  try {
@@ -129,13 +195,13 @@ export class UpstreamClient {
129
195
  };
130
196
  }
131
197
  } catch (error: any) {
132
- this.logger.error({ err: error }, 'Stdio call failed');
198
+ this.logger.error({ err: error }, 'MCP call failed');
133
199
  return {
134
200
  jsonrpc: '2.0',
135
201
  id: request.id,
136
202
  error: {
137
203
  code: error.code || -32603,
138
- message: error.message || 'Internal error in stdio transport'
204
+ message: error.message || 'Internal error in MCP transport'
139
205
  }
140
206
  };
141
207
  }
@@ -143,7 +209,9 @@ export class UpstreamClient {
143
209
 
144
210
  private async callHttp(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
145
211
  // Narrowing for TS
146
- if (this.info.type === 'stdio') throw new Error('Unreachable');
212
+ if (this.info.type === 'stdio' || this.info.type === 'streamableHttp' || this.info.type === 'sse') {
213
+ throw new Error('Unreachable');
214
+ }
147
215
  const url = this.info.url;
148
216
 
149
217
  const headers: Record<string, string> = {
@@ -205,7 +273,7 @@ export class UpstreamClient {
205
273
  }
206
274
  }
207
275
  async getManifest(context: ExecutionContext): Promise<ToolManifest | null> {
208
- if (this.info.type !== 'http') return null;
276
+ if (this.info.type && this.info.type !== 'http') return null;
209
277
 
210
278
  try {
211
279
  const baseUrl = this.info.url.replace(/\/$/, ''); // Remove trailing slash
package/src/index.ts CHANGED
@@ -29,9 +29,10 @@ program
29
29
  .command('serve', { isDefault: true })
30
30
  .description('Start the Conduit server')
31
31
  .option('--stdio', 'Use stdio transport')
32
+ .option('--config <path>', 'Path to config file')
32
33
  .action(async (options) => {
33
34
  try {
34
- await startServer();
35
+ await startServer(options);
35
36
  } catch (err) {
36
37
  console.error('Failed to start Conduit:', err);
37
38
  process.exit(1);
@@ -68,8 +69,13 @@ program
68
69
  }
69
70
  });
70
71
 
71
- async function startServer() {
72
- const configService = new ConfigService();
72
+ async function startServer(options: any = {}) {
73
+ // Merge command line options into config overrides
74
+ const overrides: any = {};
75
+ if (options.stdio) overrides.transport = 'stdio';
76
+ if (options.config) process.env.CONFIG_FILE = options.config;
77
+
78
+ const configService = new ConfigService(overrides);
73
79
  const logger = createLogger(configService);
74
80
 
75
81
  const otelService = new OtelService(logger);
@@ -84,6 +90,7 @@ async function startServer() {
84
90
 
85
91
  const gatewayService = new GatewayService(logger, securityService);
86
92
  const upstreams = configService.get('upstreams') || [];
93
+ logger.info({ upstreamCount: upstreams.length, upstreamIds: upstreams.map((u: any) => u.id) }, 'Registering upstreams from config');
87
94
  for (const upstream of upstreams) {
88
95
  gatewayService.registerUpstream(upstream);
89
96
  }
@@ -122,8 +129,10 @@ async function startServer() {
122
129
  let address: string;
123
130
 
124
131
  if (configService.get('transport') === 'stdio') {
125
- transport = new StdioTransport(logger, requestController, concurrencyService);
132
+ const stdioTransport = new StdioTransport(logger, requestController, concurrencyService);
133
+ transport = stdioTransport;
126
134
  await transport.start();
135
+ gatewayService.registerHost(stdioTransport);
127
136
  address = 'stdio';
128
137
 
129
138
  // IMPORTANT: Even in stdio mode, we need a local socket for sandboxes to talk to
@@ -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
  }
@@ -47,21 +58,30 @@ exports[`Asset Integrity (Golden Tests) > should match Python SDK snapshot 1`] =
47
58
  "# Generated SDK - Do not edit
48
59
  _allowed_tools = ["test__*","github__*"]
49
60
 
50
- class _ToolNamespace:
51
- def __init__(self, methods):
52
- for name, fn in methods.items():
53
- setattr(self, name, fn)
61
+ class _test_Namespace:
62
+ async def hello(self, args=None, **kwargs):
63
+ params = args if args is not None else kwargs
64
+ return await _internal_call_tool("test__hello", params)
65
+
66
+ class _github_Namespace:
67
+ async def create_issue(self, args=None, **kwargs):
68
+ params = args if args is not None else kwargs
69
+ return await _internal_call_tool("github__create_issue", params)
54
70
 
55
71
  class _Tools:
56
72
  def __init__(self):
57
- self.test = _ToolNamespace({
58
- "hello": lambda args, n="test__hello": _internal_call_tool(n, args)
59
- })
60
- self.github = _ToolNamespace({
61
- "create_issue": lambda args, n="github__create_issue": _internal_call_tool(n, args)
62
- })
73
+ self.test = _test_Namespace()
74
+ self.github = _github_Namespace()
63
75
 
64
- async def raw(self, name, args):
76
+ def __getattr__(self, name):
77
+ # Flat access fallback: search all namespaces
78
+ for attr_name in dir(self):
79
+ attr = getattr(self, attr_name, None)
80
+ if attr and hasattr(attr, name):
81
+ return getattr(attr, name)
82
+ raise AttributeError(f"Namespace or Tool '{name}' not found")
83
+
84
+ async def raw(self, name, args=None):
65
85
  """Call a tool by its full name (escape hatch for dynamic/unknown tools)"""
66
86
  normalized = name.replace(".", "__")
67
87
  if _allowed_tools is not None:
@@ -71,7 +91,7 @@ class _Tools:
71
91
  )
72
92
  if not allowed:
73
93
  raise PermissionError(f"Tool {name} is not in the allowlist")
74
- return await _internal_call_tool(normalized, args)
94
+ return await _internal_call_tool(normalized, args or {})
75
95
 
76
96
  tools = _Tools()"
77
97
  `;
@@ -111,7 +131,18 @@ const tools = new Proxy(_tools, {
111
131
  if (prop in target) return target[prop];
112
132
  if (prop === 'then') return undefined;
113
133
  if (typeof prop === 'string') {
114
- throw new Error(\`Namespace '\${prop}' not found. It might be invalid, or all tools in it were disallowed.\`);
134
+ // Flat tool access fallback: search all namespaces for a matching tool
135
+ for (const nsName of Object.keys(target)) {
136
+ if (nsName === '$raw') continue;
137
+ const ns = target[nsName];
138
+ if (ns && typeof ns === 'object' && ns[prop]) {
139
+ return ns[prop];
140
+ }
141
+ }
142
+
143
+ const forbidden = ['$raw'];
144
+ const namespaces = Object.keys(target).filter(k => !forbidden.includes(k));
145
+ throw new Error(\`Namespace or Tool '\${prop}' not found. Available namespaces: \${namespaces.join(', ') || 'none'}. Use tools.$raw(name, args) for dynamic calls.\`);
115
146
  }
116
147
  return undefined;
117
148
  }