@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.
- package/README.md +29 -1
- package/dist/index.js +413 -127
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.cmd.ts +26 -14
- package/src/core/config.service.ts +27 -2
- package/src/core/execution.service.ts +5 -0
- package/src/core/policy.service.ts +5 -0
- package/src/core/request.controller.ts +32 -7
- package/src/gateway/auth.service.ts +55 -13
- package/src/gateway/gateway.service.ts +150 -65
- package/src/gateway/host.client.ts +65 -0
- package/src/gateway/upstream.client.ts +94 -26
- package/src/index.ts +13 -4
- package/src/sdk/sdk-generator.ts +66 -30
- package/src/transport/stdio.transport.ts +44 -3
- package/tests/__snapshots__/assets.test.ts.snap +45 -14
- package/tests/auth.service.test.ts +57 -0
- package/tests/code-mode-lite-gateway.test.ts +4 -4
- package/tests/config.service.test.ts +29 -1
- package/tests/gateway.service.test.ts +5 -5
- package/tests/routing.test.ts +7 -0
- package/tests/sdk/sdk-generator.test.ts +7 -7
- package/tests/upstream.transports.test.ts +117 -0
|
@@ -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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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 (
|
|
78
|
-
return this.
|
|
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
|
|
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: '
|
|
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 }, '
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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(
|
|
257
|
+
lines.push(' async $raw(name, args) {');
|
|
233
258
|
lines.push(` const normalized = name.replace(/\\./g, '__');`);
|
|
234
|
-
lines.push(
|
|
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(
|
|
238
|
-
lines.push(
|
|
239
|
-
lines.push(
|
|
240
|
-
lines.push(
|
|
241
|
-
lines.push(
|
|
242
|
-
lines.push(
|
|
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
|
-
|
|
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
|
|
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',
|
|
@@ -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
|
-
|
|
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
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|