@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.
- package/dist/index.js +266 -96
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- 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 +32 -7
- package/src/gateway/gateway.service.ts +142 -65
- package/src/gateway/host.client.ts +65 -0
- package/src/gateway/upstream.client.ts +10 -11
- 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 +31 -2
- 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/gateway.service.test.ts +5 -5
- package/tests/routing.test.ts +7 -0
- package/tests/sdk/sdk-generator.test.ts +7 -7
package/package.json
CHANGED
|
@@ -134,10 +134,14 @@ export class ConfigService {
|
|
|
134
134
|
(fs.existsSync(path.resolve(process.cwd(), 'conduit.yaml')) ? 'conduit.yaml' :
|
|
135
135
|
(fs.existsSync(path.resolve(process.cwd(), 'conduit.json')) ? 'conduit.json' : null));
|
|
136
136
|
|
|
137
|
-
if (!configPath)
|
|
137
|
+
if (!configPath) {
|
|
138
|
+
console.warn(`[Conduit] No config file found in ${process.cwd()}. Running with default settings.`);
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
138
141
|
|
|
139
142
|
try {
|
|
140
143
|
const fullPath = path.resolve(process.cwd(), configPath);
|
|
144
|
+
console.error(`[Conduit] Loading config from ${fullPath}`);
|
|
141
145
|
let fileContent = fs.readFileSync(fullPath, 'utf-8');
|
|
142
146
|
|
|
143
147
|
// Env var substitution: ${VAR} or ${VAR:-default}
|
|
@@ -137,17 +137,22 @@ export class ExecutionService {
|
|
|
137
137
|
const packages = await this.gatewayService.listToolPackages();
|
|
138
138
|
const allBindings = [];
|
|
139
139
|
|
|
140
|
+
this.logger.debug({ packageCount: packages.length, packages: packages.map(p => p.id) }, 'Fetching tool bindings');
|
|
141
|
+
|
|
140
142
|
for (const pkg of packages) {
|
|
141
143
|
try {
|
|
142
144
|
// Determine if we need to fetch tools for this package
|
|
143
145
|
// Optimization: if allowedTools is strict, we could filter packages here
|
|
144
146
|
|
|
145
147
|
const stubs = await this.gatewayService.listToolStubs(pkg.id, context);
|
|
148
|
+
this.logger.debug({ packageId: pkg.id, stubCount: stubs.length }, 'Got stubs from package');
|
|
146
149
|
allBindings.push(...stubs.map(s => toToolBinding(s.id, undefined, s.description)));
|
|
147
150
|
} catch (err: any) {
|
|
148
151
|
this.logger.warn({ packageId: pkg.id, err: err.message }, 'Failed to list stubs for package');
|
|
149
152
|
}
|
|
150
153
|
}
|
|
154
|
+
|
|
155
|
+
this.logger.info({ totalBindings: allBindings.length }, 'Tool bindings ready for SDK generation');
|
|
151
156
|
return allBindings;
|
|
152
157
|
}
|
|
153
158
|
|
|
@@ -66,6 +66,11 @@ export class PolicyService {
|
|
|
66
66
|
return true;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Improved matching: if pattern has only one part, match it against the tool's name part
|
|
70
|
+
if (patternParts.length === 1 && toolParts.length > 1) {
|
|
71
|
+
return patternParts[0] === toolParts[toolParts.length - 1];
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
// Exact match: pattern parts must equal tool parts
|
|
70
75
|
if (patternParts.length !== toolParts.length) return false;
|
|
71
76
|
for (let i = 0; i < patternParts.length; i++) {
|
|
@@ -128,6 +128,8 @@ export class RequestController {
|
|
|
128
128
|
return this.handleInitialize(params, context, id);
|
|
129
129
|
case 'notifications/initialized':
|
|
130
130
|
return null; // Notifications don't get responses per MCP spec
|
|
131
|
+
case 'mcp_register_upstream':
|
|
132
|
+
return this.handleRegisterUpstream(params, context, id);
|
|
131
133
|
case 'ping':
|
|
132
134
|
return { jsonrpc: '2.0', id, result: {} };
|
|
133
135
|
default:
|
|
@@ -138,6 +140,23 @@ export class RequestController {
|
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
private async handleRegisterUpstream(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
144
|
+
if (!params || !params.id || !params.type || (!params.url && !params.command)) {
|
|
145
|
+
return this.errorResponse(id, -32602, 'Missing registration parameters (id, type, url/command)');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
this.gatewayService.registerUpstream(params);
|
|
150
|
+
return {
|
|
151
|
+
jsonrpc: '2.0',
|
|
152
|
+
id,
|
|
153
|
+
result: { success: true }
|
|
154
|
+
};
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
return this.errorResponse(id, -32001, err.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
private async handleDiscoverTools(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
142
161
|
const tools = await this.gatewayService.discoverTools(context);
|
|
143
162
|
|
|
@@ -215,14 +234,20 @@ export class RequestController {
|
|
|
215
234
|
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
216
235
|
const { name, arguments: toolArgs } = params;
|
|
217
236
|
|
|
237
|
+
const toolId = this.gatewayService.policyService.parseToolName(name);
|
|
238
|
+
const baseName = toolId.name;
|
|
239
|
+
const isConduit = toolId.namespace === 'conduit' || toolId.namespace === '';
|
|
240
|
+
|
|
218
241
|
// Route built-in tools to their specific handlers
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
242
|
+
if (isConduit) {
|
|
243
|
+
switch (baseName) {
|
|
244
|
+
case 'mcp_execute_typescript':
|
|
245
|
+
return this.handleExecuteToolCall('typescript', toolArgs, context, id);
|
|
246
|
+
case 'mcp_execute_python':
|
|
247
|
+
return this.handleExecuteToolCall('python', toolArgs, context, id);
|
|
248
|
+
case 'mcp_execute_isolate':
|
|
249
|
+
return this.handleExecuteToolCall('isolate', toolArgs, context, id);
|
|
250
|
+
}
|
|
226
251
|
}
|
|
227
252
|
|
|
228
253
|
const response = await this.gatewayService.callTool(name, toolArgs, context);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Logger } from 'pino';
|
|
2
|
+
import { HostClient } from './host.client.js';
|
|
3
|
+
import { StdioTransport } from '../transport/stdio.transport.js';
|
|
2
4
|
import { UpstreamClient, UpstreamInfo } from './upstream.client.js';
|
|
3
5
|
import { AuthService } from './auth.service.js';
|
|
4
6
|
import { SchemaCache, ToolSchema } from './schema.cache.js';
|
|
@@ -72,41 +74,69 @@ const BUILT_IN_TOOLS: ToolSchema[] = [
|
|
|
72
74
|
|
|
73
75
|
export class GatewayService {
|
|
74
76
|
private logger: Logger;
|
|
75
|
-
private clients: Map<string,
|
|
77
|
+
private clients: Map<string, any> = new Map();
|
|
76
78
|
private authService: AuthService;
|
|
77
79
|
private schemaCache: SchemaCache;
|
|
78
80
|
private urlValidator: IUrlValidator;
|
|
79
|
-
|
|
81
|
+
public policyService: PolicyService;
|
|
80
82
|
private ajv: Ajv;
|
|
81
83
|
// Cache compiled validators to avoid recompilation on every call
|
|
82
84
|
private validatorCache = new Map<string, any>();
|
|
83
85
|
|
|
84
86
|
constructor(logger: Logger, urlValidator: IUrlValidator, policyService?: PolicyService) {
|
|
85
|
-
this.logger = logger;
|
|
87
|
+
this.logger = logger.child({ component: 'GatewayService' });
|
|
88
|
+
this.logger.debug('GatewayService instance created');
|
|
86
89
|
this.urlValidator = urlValidator;
|
|
87
90
|
this.authService = new AuthService(logger);
|
|
88
91
|
this.schemaCache = new SchemaCache(logger);
|
|
89
92
|
this.policyService = policyService ?? new PolicyService();
|
|
90
|
-
this.ajv = new Ajv({ strict: false });
|
|
91
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
93
|
+
this.ajv = new Ajv({ strict: false });
|
|
92
94
|
(addFormats as any).default(this.ajv);
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
registerUpstream(info: UpstreamInfo) {
|
|
96
98
|
const client = new UpstreamClient(this.logger, info, this.authService, this.urlValidator);
|
|
97
99
|
this.clients.set(info.id, client);
|
|
98
|
-
this.logger.info({ upstreamId: info.id }, 'Registered upstream MCP');
|
|
100
|
+
this.logger.info({ upstreamId: info.id, totalRegistered: this.clients.size }, 'Registered upstream MCP');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
registerHost(transport: StdioTransport) {
|
|
104
|
+
// NOTE: The host (VS Code) cannot receive tools/call requests - it's the CLIENT.
|
|
105
|
+
// We only register it for potential future use (e.g., sampling requests).
|
|
106
|
+
// DO NOT use the host as a tool provider fallback.
|
|
107
|
+
this.logger.debug('Host transport available but not registered as tool upstream (protocol limitation)');
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
async listToolPackages(): Promise<ToolPackage[]> {
|
|
102
|
-
|
|
111
|
+
const upstreams = Array.from(this.clients.entries()).map(([id, client]) => ({
|
|
103
112
|
id,
|
|
104
|
-
description: `Upstream ${id}`,
|
|
113
|
+
description: `Upstream ${id}`,
|
|
105
114
|
version: '1.0.0'
|
|
106
115
|
}));
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
{ id: 'conduit', description: 'Conduit built-in execution tools', version: '1.0.0' },
|
|
119
|
+
...upstreams
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getBuiltInTools(): ToolSchema[] {
|
|
124
|
+
return BUILT_IN_TOOLS;
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
async listToolStubs(packageId: string, context: ExecutionContext): Promise<ToolStub[]> {
|
|
128
|
+
if (packageId === 'conduit') {
|
|
129
|
+
const stubs = BUILT_IN_TOOLS.map(t => ({
|
|
130
|
+
id: `conduit__${t.name}`,
|
|
131
|
+
name: t.name,
|
|
132
|
+
description: t.description
|
|
133
|
+
}));
|
|
134
|
+
if (context.allowedTools) {
|
|
135
|
+
return stubs.filter(t => this.policyService.isToolAllowed(t.id, context.allowedTools!));
|
|
136
|
+
}
|
|
137
|
+
return stubs;
|
|
138
|
+
}
|
|
139
|
+
|
|
110
140
|
const client = this.clients.get(packageId);
|
|
111
141
|
if (!client) {
|
|
112
142
|
throw new Error(`Upstream package not found: ${packageId}`);
|
|
@@ -117,40 +147,40 @@ export class GatewayService {
|
|
|
117
147
|
// Try manifest first if tools not cached
|
|
118
148
|
if (!tools) {
|
|
119
149
|
try {
|
|
120
|
-
// Try to
|
|
150
|
+
// Try to get manifest FIRST
|
|
121
151
|
const manifest = await client.getManifest(context);
|
|
122
|
-
if (manifest) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
if (manifest && manifest.tools) {
|
|
153
|
+
tools = manifest.tools as ToolSchema[];
|
|
154
|
+
} else {
|
|
155
|
+
// Fall back to RPC discovery
|
|
156
|
+
if (typeof (client as any).listTools === 'function') {
|
|
157
|
+
tools = await (client as any).listTools();
|
|
158
|
+
} else {
|
|
159
|
+
const response = await client.call({
|
|
160
|
+
jsonrpc: '2.0',
|
|
161
|
+
id: 'discovery',
|
|
162
|
+
method: 'tools/list',
|
|
163
|
+
}, context);
|
|
164
|
+
|
|
165
|
+
if (response.result?.tools) {
|
|
166
|
+
tools = response.result.tools as ToolSchema[];
|
|
167
|
+
} else {
|
|
168
|
+
this.logger.warn({ upstreamId: packageId, error: response.error }, 'Failed to discover tools via RPC');
|
|
169
|
+
}
|
|
131
170
|
}
|
|
132
|
-
return stubs;
|
|
133
171
|
}
|
|
134
|
-
} catch (e) {
|
|
135
|
-
// Manifest fetch failed, fall back
|
|
136
|
-
this.logger.debug({ packageId, err: e }, 'Manifest fetch failed, falling back to RPC');
|
|
137
|
-
}
|
|
138
172
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (response.result?.tools) {
|
|
146
|
-
tools = response.result.tools as ToolSchema[];
|
|
147
|
-
this.schemaCache.set(packageId, tools);
|
|
148
|
-
} else {
|
|
149
|
-
this.logger.warn({ upstreamId: packageId, error: response.error }, 'Failed to discover tools from upstream');
|
|
150
|
-
tools = [];
|
|
173
|
+
if (tools && tools.length > 0) {
|
|
174
|
+
this.schemaCache.set(packageId, tools);
|
|
175
|
+
this.logger.info({ upstreamId: packageId, toolCount: tools.length }, 'Discovered tools from upstream');
|
|
176
|
+
}
|
|
177
|
+
} catch (e: any) {
|
|
178
|
+
this.logger.error({ upstreamId: packageId, err: e.message }, 'Error during tool discovery');
|
|
151
179
|
}
|
|
152
180
|
}
|
|
153
181
|
|
|
182
|
+
if (!tools) tools = [];
|
|
183
|
+
|
|
154
184
|
const stubs: ToolStub[] = tools.map(t => ({
|
|
155
185
|
id: `${packageId}__${t.name}`,
|
|
156
186
|
name: t.name,
|
|
@@ -170,17 +200,29 @@ export class GatewayService {
|
|
|
170
200
|
}
|
|
171
201
|
|
|
172
202
|
const parsed = this.policyService.parseToolName(toolId);
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (
|
|
203
|
+
const namespace = parsed.namespace;
|
|
204
|
+
const toolName = parsed.name;
|
|
205
|
+
|
|
206
|
+
// Check for built-in tools (now namespaced as conduit__*)
|
|
207
|
+
if (namespace === 'conduit' || namespace === '') {
|
|
208
|
+
const builtIn = BUILT_IN_TOOLS.find(t => t.name === toolName);
|
|
209
|
+
if (builtIn) {
|
|
210
|
+
return { ...builtIn, name: `conduit__${builtIn.name}` };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
178
213
|
|
|
179
|
-
const upstreamId =
|
|
214
|
+
const upstreamId = namespace;
|
|
215
|
+
if (!upstreamId) {
|
|
216
|
+
// Un-namespaced tool lookup: try all upstreams
|
|
217
|
+
for (const id of this.clients.keys()) {
|
|
218
|
+
const schema = await this.getToolSchema(`${id}__${toolName}`, context);
|
|
219
|
+
if (schema) return schema;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
180
223
|
|
|
181
224
|
// Ensure we have schemas for this upstream
|
|
182
225
|
if (!this.schemaCache.get(upstreamId)) {
|
|
183
|
-
// Force refresh if missing
|
|
184
226
|
await this.listToolStubs(upstreamId, context);
|
|
185
227
|
}
|
|
186
228
|
|
|
@@ -189,7 +231,6 @@ export class GatewayService {
|
|
|
189
231
|
|
|
190
232
|
if (!tool) return null;
|
|
191
233
|
|
|
192
|
-
// Return schema with namespaced name
|
|
193
234
|
return {
|
|
194
235
|
...tool,
|
|
195
236
|
name: toolId
|
|
@@ -197,41 +238,48 @@ export class GatewayService {
|
|
|
197
238
|
}
|
|
198
239
|
|
|
199
240
|
async discoverTools(context: ExecutionContext): Promise<ToolSchema[]> {
|
|
200
|
-
const allTools: ToolSchema[] =
|
|
241
|
+
const allTools: ToolSchema[] = BUILT_IN_TOOLS.map(t => ({
|
|
242
|
+
...t,
|
|
243
|
+
name: `conduit__${t.name}`
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
this.logger.debug({ clientCount: this.clients.size, clientIds: Array.from(this.clients.keys()) }, 'Starting tool discovery');
|
|
201
247
|
|
|
202
248
|
for (const [id, client] of this.clients.entries()) {
|
|
203
|
-
|
|
249
|
+
// Skip host - it's not a tool provider
|
|
250
|
+
if (id === 'host') {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
204
253
|
|
|
205
|
-
|
|
206
|
-
const response = await client.call({
|
|
207
|
-
jsonrpc: '2.0',
|
|
208
|
-
id: 'discovery',
|
|
209
|
-
method: 'tools/list', // Standard MCP method
|
|
210
|
-
}, context);
|
|
254
|
+
this.logger.debug({ upstreamId: id }, 'Discovering tools from upstream');
|
|
211
255
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
tools = [];
|
|
218
|
-
}
|
|
256
|
+
// reuse unified discovery logic
|
|
257
|
+
try {
|
|
258
|
+
await this.listToolStubs(id, context);
|
|
259
|
+
} catch (e: any) {
|
|
260
|
+
this.logger.error({ upstreamId: id, err: e.message }, 'Failed to list tool stubs');
|
|
219
261
|
}
|
|
262
|
+
const tools = this.schemaCache.get(id) || [];
|
|
220
263
|
|
|
221
|
-
|
|
264
|
+
this.logger.debug({ upstreamId: id, toolCount: tools.length }, 'Discovery result');
|
|
222
265
|
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
266
|
+
if (tools && tools.length > 0) {
|
|
267
|
+
const prefixedTools = tools.map(t => ({ ...t, name: `${id}__${t.name}` }));
|
|
268
|
+
if (context.allowedTools) {
|
|
269
|
+
allTools.push(...prefixedTools.filter(t => this.policyService.isToolAllowed(t.name, context.allowedTools!)));
|
|
270
|
+
} else {
|
|
271
|
+
allTools.push(...prefixedTools);
|
|
272
|
+
}
|
|
228
273
|
}
|
|
229
274
|
}
|
|
230
275
|
|
|
276
|
+
this.logger.info({ totalTools: allTools.length }, 'Tool discovery complete');
|
|
231
277
|
return allTools;
|
|
232
278
|
}
|
|
233
279
|
|
|
234
280
|
async callTool(name: string, params: any, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
281
|
+
this.logger.debug({ name, upstreamCount: this.clients.size }, 'GatewayService.callTool called');
|
|
282
|
+
|
|
235
283
|
if (context.allowedTools && !this.policyService.isToolAllowed(name, context.allowedTools)) {
|
|
236
284
|
this.logger.warn({ name, allowedTools: context.allowedTools }, 'Tool call blocked by allowlist');
|
|
237
285
|
return {
|
|
@@ -248,14 +296,43 @@ export class GatewayService {
|
|
|
248
296
|
const upstreamId = toolId.namespace;
|
|
249
297
|
const toolName = toolId.name;
|
|
250
298
|
|
|
299
|
+
this.logger.debug({ name, upstreamId, toolName }, 'Parsed tool name');
|
|
300
|
+
|
|
301
|
+
// Fallback for namespaceless calls: try to find the tool in any registered upstream
|
|
302
|
+
if (!upstreamId) {
|
|
303
|
+
this.logger.debug({ toolName }, 'Namespaceless call, attempting discovery across upstreams');
|
|
304
|
+
const allStubs = await this.discoverTools(context);
|
|
305
|
+
const found = allStubs.find(t => {
|
|
306
|
+
const parts = t.name.split('__');
|
|
307
|
+
return parts[parts.length - 1] === toolName;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (found) {
|
|
311
|
+
this.logger.debug({ original: name, resolved: found.name }, 'Resolved namespaceless tool');
|
|
312
|
+
return this.callTool(found.name, params, context);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// No fallback to host - it doesn't support server-to-client tool calls
|
|
316
|
+
const upstreamList = Array.from(this.clients.keys()).filter(k => k !== 'host');
|
|
317
|
+
return {
|
|
318
|
+
jsonrpc: '2.0',
|
|
319
|
+
id: 0,
|
|
320
|
+
error: {
|
|
321
|
+
code: -32601,
|
|
322
|
+
message: `Tool '${toolName}' not found. Discovered ${allStubs.length} tools from upstreams: [${upstreamList.join(', ') || 'none'}]. Available tools: ${allStubs.map(t => t.name).slice(0, 10).join(', ')}${allStubs.length > 10 ? '...' : ''}`,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
251
327
|
const client = this.clients.get(upstreamId);
|
|
252
328
|
if (!client) {
|
|
329
|
+
this.logger.error({ upstreamId, availableUpstreams: Array.from(this.clients.keys()) }, 'Upstream not found');
|
|
253
330
|
return {
|
|
254
331
|
jsonrpc: '2.0',
|
|
255
332
|
id: 0,
|
|
256
333
|
error: {
|
|
257
334
|
code: -32003,
|
|
258
|
-
message: `Upstream not found: ${upstreamId}`,
|
|
335
|
+
message: `Upstream not found: '${upstreamId}'. Available: ${Array.from(this.clients.keys()).join(', ') || 'none'}`,
|
|
259
336
|
},
|
|
260
337
|
};
|
|
261
338
|
}
|
|
@@ -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
|
+
}
|
|
@@ -24,6 +24,7 @@ export class UpstreamClient {
|
|
|
24
24
|
private urlValidator: IUrlValidator;
|
|
25
25
|
private mcpClient?: Client;
|
|
26
26
|
private transport?: StdioClientTransport;
|
|
27
|
+
private connected: boolean = false;
|
|
27
28
|
|
|
28
29
|
constructor(logger: Logger, info: UpstreamInfo, authService: AuthService, urlValidator: IUrlValidator) {
|
|
29
30
|
this.logger = logger.child({ upstreamId: info.id });
|
|
@@ -55,18 +56,16 @@ export class UpstreamClient {
|
|
|
55
56
|
|
|
56
57
|
private async ensureConnected() {
|
|
57
58
|
if (!this.mcpClient || !this.transport) return;
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
59
|
+
if (this.connected) return;
|
|
60
|
+
|
|
63
61
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} catch (e) {
|
|
69
|
-
|
|
62
|
+
this.logger.debug('Connecting to upstream transport...');
|
|
63
|
+
await this.mcpClient.connect(this.transport);
|
|
64
|
+
this.connected = true;
|
|
65
|
+
this.logger.info('Connected to upstream MCP');
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
this.logger.error({ err: e.message }, 'Failed to connect to upstream');
|
|
68
|
+
throw e;
|
|
70
69
|
}
|
|
71
70
|
}
|
|
72
71
|
|
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
|