@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
package/package.json
CHANGED
package/src/auth.cmd.ts
CHANGED
|
@@ -157,27 +157,39 @@ export async function handleAuth(options: AuthOptions) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
const payload: Record<string, string> = {
|
|
161
|
+
grant_type: 'authorization_code',
|
|
162
|
+
code,
|
|
163
|
+
redirect_uri: redirectUri,
|
|
164
|
+
client_id: options.clientId,
|
|
165
|
+
};
|
|
166
|
+
|
|
165
167
|
if (options.clientSecret) {
|
|
166
|
-
|
|
168
|
+
payload.client_secret = options.clientSecret;
|
|
167
169
|
}
|
|
168
170
|
if (codeVerifier) {
|
|
169
|
-
|
|
171
|
+
payload.code_verifier = codeVerifier;
|
|
170
172
|
}
|
|
171
173
|
if (resolvedResource) {
|
|
172
|
-
|
|
174
|
+
payload.resource = resolvedResource;
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
177
|
+
const tokenHostname = new URL(resolvedTokenUrl).hostname;
|
|
178
|
+
const useJson = tokenHostname === 'auth.atlassian.com';
|
|
179
|
+
|
|
180
|
+
const response = useJson
|
|
181
|
+
? await axios.post(resolvedTokenUrl, payload, {
|
|
182
|
+
headers: {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
'Accept': 'application/json',
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
: await axios.post(resolvedTokenUrl, new URLSearchParams(payload), {
|
|
188
|
+
headers: {
|
|
189
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
190
|
+
'Accept': 'application/json',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
181
193
|
|
|
182
194
|
const { refresh_token, access_token } = response.data;
|
|
183
195
|
|
|
@@ -29,6 +29,8 @@ export const UpstreamCredentialsSchema = z.object({
|
|
|
29
29
|
tokenUrl: z.string().optional(),
|
|
30
30
|
refreshToken: z.string().optional(),
|
|
31
31
|
scopes: z.array(z.string()).optional(),
|
|
32
|
+
tokenRequestFormat: z.enum(['form', 'json']).optional(),
|
|
33
|
+
tokenParams: z.record(z.string(), z.string()).optional(),
|
|
32
34
|
apiKey: z.string().optional(),
|
|
33
35
|
bearerToken: z.string().optional(),
|
|
34
36
|
headerName: z.string().optional(),
|
|
@@ -41,6 +43,20 @@ export const HttpUpstreamSchema = z.object({
|
|
|
41
43
|
credentials: UpstreamCredentialsSchema.optional(),
|
|
42
44
|
});
|
|
43
45
|
|
|
46
|
+
export const StreamableHttpUpstreamSchema = z.object({
|
|
47
|
+
id: z.string(),
|
|
48
|
+
type: z.literal('streamableHttp'),
|
|
49
|
+
url: z.string(),
|
|
50
|
+
credentials: UpstreamCredentialsSchema.optional(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const SseUpstreamSchema = z.object({
|
|
54
|
+
id: z.string(),
|
|
55
|
+
type: z.literal('sse'),
|
|
56
|
+
url: z.string(),
|
|
57
|
+
credentials: UpstreamCredentialsSchema.optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
44
60
|
export const StdioUpstreamSchema = z.object({
|
|
45
61
|
id: z.string(),
|
|
46
62
|
type: z.literal('stdio'),
|
|
@@ -49,7 +65,12 @@ export const StdioUpstreamSchema = z.object({
|
|
|
49
65
|
env: z.record(z.string(), z.string()).optional(),
|
|
50
66
|
});
|
|
51
67
|
|
|
52
|
-
export const UpstreamInfoSchema = z.union([
|
|
68
|
+
export const UpstreamInfoSchema = z.union([
|
|
69
|
+
HttpUpstreamSchema,
|
|
70
|
+
StreamableHttpUpstreamSchema,
|
|
71
|
+
SseUpstreamSchema,
|
|
72
|
+
StdioUpstreamSchema,
|
|
73
|
+
]);
|
|
53
74
|
|
|
54
75
|
export type ResourceLimits = z.infer<typeof ResourceLimitsSchema>;
|
|
55
76
|
|
|
@@ -134,10 +155,14 @@ export class ConfigService {
|
|
|
134
155
|
(fs.existsSync(path.resolve(process.cwd(), 'conduit.yaml')) ? 'conduit.yaml' :
|
|
135
156
|
(fs.existsSync(path.resolve(process.cwd(), 'conduit.json')) ? 'conduit.json' : null));
|
|
136
157
|
|
|
137
|
-
if (!configPath)
|
|
158
|
+
if (!configPath) {
|
|
159
|
+
console.warn(`[Conduit] No config file found in ${process.cwd()}. Running with default settings.`);
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
138
162
|
|
|
139
163
|
try {
|
|
140
164
|
const fullPath = path.resolve(process.cwd(), configPath);
|
|
165
|
+
console.error(`[Conduit] Loading config from ${fullPath}`);
|
|
141
166
|
let fileContent = fs.readFileSync(fullPath, 'utf-8');
|
|
142
167
|
|
|
143
168
|
// 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);
|
|
@@ -12,6 +12,8 @@ export interface UpstreamCredentials {
|
|
|
12
12
|
tokenUrl?: string;
|
|
13
13
|
refreshToken?: string;
|
|
14
14
|
scopes?: string[];
|
|
15
|
+
tokenRequestFormat?: 'form' | 'json';
|
|
16
|
+
tokenParams?: Record<string, string>;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
interface CachedToken {
|
|
@@ -23,6 +25,8 @@ export class AuthService {
|
|
|
23
25
|
private logger: Logger;
|
|
24
26
|
// Cache tokens separately from credentials to avoid mutation
|
|
25
27
|
private tokenCache = new Map<string, CachedToken>();
|
|
28
|
+
// Keep the latest refresh token in-memory (rotating tokens)
|
|
29
|
+
private refreshTokenCache = new Map<string, string>();
|
|
26
30
|
// Prevent concurrent refresh requests for the same client
|
|
27
31
|
private refreshLocks = new Map<string, Promise<string>>();
|
|
28
32
|
|
|
@@ -81,23 +85,56 @@ export class AuthService {
|
|
|
81
85
|
this.logger.info({ tokenUrl: creds.tokenUrl, clientId: creds.clientId }, 'Refreshing OAuth2 token');
|
|
82
86
|
|
|
83
87
|
try {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
const tokenUrl = creds.tokenUrl;
|
|
89
|
+
const cachedRefreshToken = this.refreshTokenCache.get(cacheKey);
|
|
90
|
+
const refreshToken = cachedRefreshToken || creds.refreshToken;
|
|
91
|
+
|
|
92
|
+
if (!refreshToken) {
|
|
93
|
+
throw new Error('OAuth2 credentials missing required fields for refresh');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payload: Record<string, string> = {
|
|
97
|
+
grant_type: 'refresh_token',
|
|
98
|
+
refresh_token: refreshToken,
|
|
99
|
+
client_id: creds.clientId,
|
|
100
|
+
};
|
|
101
|
+
|
|
88
102
|
if (creds.clientSecret) {
|
|
89
|
-
|
|
103
|
+
payload.client_secret = creds.clientSecret;
|
|
90
104
|
}
|
|
91
105
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
'Accept': 'application/json',
|
|
96
|
-
},
|
|
97
|
-
});
|
|
106
|
+
if (creds.tokenParams) {
|
|
107
|
+
Object.assign(payload, creds.tokenParams);
|
|
108
|
+
}
|
|
98
109
|
|
|
99
|
-
const
|
|
100
|
-
|
|
110
|
+
const requestFormat = (() => {
|
|
111
|
+
if (creds.tokenRequestFormat) return creds.tokenRequestFormat;
|
|
112
|
+
try {
|
|
113
|
+
const hostname = new URL(tokenUrl).hostname;
|
|
114
|
+
if (hostname === 'auth.atlassian.com') return 'json';
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore
|
|
117
|
+
}
|
|
118
|
+
return 'form';
|
|
119
|
+
})();
|
|
120
|
+
|
|
121
|
+
const response = requestFormat === 'json'
|
|
122
|
+
? await axios.post(tokenUrl, payload, {
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Accept': 'application/json',
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
: await axios.post(tokenUrl, new URLSearchParams(payload), {
|
|
129
|
+
headers: {
|
|
130
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
131
|
+
'Accept': 'application/json',
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const { access_token, expires_in, refresh_token } = response.data;
|
|
136
|
+
const expiresInRaw = Number(expires_in);
|
|
137
|
+
const expiresInSeconds = Number.isFinite(expiresInRaw) ? expiresInRaw : 3600;
|
|
101
138
|
|
|
102
139
|
// Cache the token (don't mutate the input credentials)
|
|
103
140
|
this.tokenCache.set(cacheKey, {
|
|
@@ -105,6 +142,11 @@ export class AuthService {
|
|
|
105
142
|
expiresAt: Date.now() + (expiresInSeconds * 1000),
|
|
106
143
|
});
|
|
107
144
|
|
|
145
|
+
// Some providers (e.g. Atlassian) rotate refresh tokens
|
|
146
|
+
if (typeof refresh_token === 'string' && refresh_token.length > 0) {
|
|
147
|
+
this.refreshTokenCache.set(cacheKey, refresh_token);
|
|
148
|
+
}
|
|
149
|
+
|
|
108
150
|
return `Bearer ${access_token}`;
|
|
109
151
|
} catch (err: any) {
|
|
110
152
|
const errorMsg = err.response?.data?.error_description || err.response?.data?.error || err.message;
|
|
@@ -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}`);
|
|
@@ -114,43 +144,51 @@ export class GatewayService {
|
|
|
114
144
|
|
|
115
145
|
let tools = this.schemaCache.get(packageId);
|
|
116
146
|
|
|
117
|
-
//
|
|
147
|
+
// Discover tools if not cached
|
|
118
148
|
if (!tools) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
name: t.name,
|
|
126
|
-
description: t.description
|
|
127
|
-
}));
|
|
128
|
-
|
|
129
|
-
if (context.allowedTools) {
|
|
130
|
-
return stubs.filter(t => this.policyService.isToolAllowed(t.id, context.allowedTools!));
|
|
149
|
+
// 1) Try to get manifest (if supported)
|
|
150
|
+
if (typeof (client as any).getManifest === 'function') {
|
|
151
|
+
try {
|
|
152
|
+
const manifest = await (client as any).getManifest(context);
|
|
153
|
+
if (manifest && manifest.tools) {
|
|
154
|
+
tools = manifest.tools as ToolSchema[];
|
|
131
155
|
}
|
|
132
|
-
|
|
156
|
+
} catch (e: any) {
|
|
157
|
+
this.logger.debug({ upstreamId: packageId, err: e.message }, 'Manifest fetch failed (will fallback)');
|
|
133
158
|
}
|
|
134
|
-
} catch (e) {
|
|
135
|
-
// Manifest fetch failed, fall back
|
|
136
|
-
this.logger.debug({ packageId, err: e }, 'Manifest fetch failed, falling back to RPC');
|
|
137
159
|
}
|
|
138
160
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
161
|
+
// 2) Fall back to RPC discovery
|
|
162
|
+
if (!tools) {
|
|
163
|
+
try {
|
|
164
|
+
if (typeof (client as any).listTools === 'function') {
|
|
165
|
+
tools = await (client as any).listTools();
|
|
166
|
+
} else {
|
|
167
|
+
const response = await client.call({
|
|
168
|
+
jsonrpc: '2.0',
|
|
169
|
+
id: 'discovery',
|
|
170
|
+
method: 'tools/list',
|
|
171
|
+
}, context);
|
|
172
|
+
|
|
173
|
+
if (response.result?.tools) {
|
|
174
|
+
tools = response.result.tools as ToolSchema[];
|
|
175
|
+
} else {
|
|
176
|
+
this.logger.warn({ upstreamId: packageId, error: response.error }, 'Failed to discover tools via RPC');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (e: any) {
|
|
180
|
+
this.logger.error({ upstreamId: packageId, err: e.message }, 'Error during tool discovery');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
144
183
|
|
|
145
|
-
if (
|
|
146
|
-
tools = response.result.tools as ToolSchema[];
|
|
184
|
+
if (tools && tools.length > 0) {
|
|
147
185
|
this.schemaCache.set(packageId, tools);
|
|
148
|
-
|
|
149
|
-
this.logger.warn({ upstreamId: packageId, error: response.error }, 'Failed to discover tools from upstream');
|
|
150
|
-
tools = [];
|
|
186
|
+
this.logger.info({ upstreamId: packageId, toolCount: tools.length }, 'Discovered tools from upstream');
|
|
151
187
|
}
|
|
152
188
|
}
|
|
153
189
|
|
|
190
|
+
if (!tools) tools = [];
|
|
191
|
+
|
|
154
192
|
const stubs: ToolStub[] = tools.map(t => ({
|
|
155
193
|
id: `${packageId}__${t.name}`,
|
|
156
194
|
name: t.name,
|
|
@@ -170,17 +208,29 @@ export class GatewayService {
|
|
|
170
208
|
}
|
|
171
209
|
|
|
172
210
|
const parsed = this.policyService.parseToolName(toolId);
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (
|
|
211
|
+
const namespace = parsed.namespace;
|
|
212
|
+
const toolName = parsed.name;
|
|
213
|
+
|
|
214
|
+
// Check for built-in tools (now namespaced as conduit__*)
|
|
215
|
+
if (namespace === 'conduit' || namespace === '') {
|
|
216
|
+
const builtIn = BUILT_IN_TOOLS.find(t => t.name === toolName);
|
|
217
|
+
if (builtIn) {
|
|
218
|
+
return { ...builtIn, name: `conduit__${builtIn.name}` };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
178
221
|
|
|
179
|
-
const upstreamId =
|
|
222
|
+
const upstreamId = namespace;
|
|
223
|
+
if (!upstreamId) {
|
|
224
|
+
// Un-namespaced tool lookup: try all upstreams
|
|
225
|
+
for (const id of this.clients.keys()) {
|
|
226
|
+
const schema = await this.getToolSchema(`${id}__${toolName}`, context);
|
|
227
|
+
if (schema) return schema;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
180
231
|
|
|
181
232
|
// Ensure we have schemas for this upstream
|
|
182
233
|
if (!this.schemaCache.get(upstreamId)) {
|
|
183
|
-
// Force refresh if missing
|
|
184
234
|
await this.listToolStubs(upstreamId, context);
|
|
185
235
|
}
|
|
186
236
|
|
|
@@ -189,7 +239,6 @@ export class GatewayService {
|
|
|
189
239
|
|
|
190
240
|
if (!tool) return null;
|
|
191
241
|
|
|
192
|
-
// Return schema with namespaced name
|
|
193
242
|
return {
|
|
194
243
|
...tool,
|
|
195
244
|
name: toolId
|
|
@@ -197,41 +246,48 @@ export class GatewayService {
|
|
|
197
246
|
}
|
|
198
247
|
|
|
199
248
|
async discoverTools(context: ExecutionContext): Promise<ToolSchema[]> {
|
|
200
|
-
const allTools: ToolSchema[] =
|
|
249
|
+
const allTools: ToolSchema[] = BUILT_IN_TOOLS.map(t => ({
|
|
250
|
+
...t,
|
|
251
|
+
name: `conduit__${t.name}`
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
this.logger.debug({ clientCount: this.clients.size, clientIds: Array.from(this.clients.keys()) }, 'Starting tool discovery');
|
|
201
255
|
|
|
202
256
|
for (const [id, client] of this.clients.entries()) {
|
|
203
|
-
|
|
257
|
+
// Skip host - it's not a tool provider
|
|
258
|
+
if (id === 'host') {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
204
261
|
|
|
205
|
-
|
|
206
|
-
const response = await client.call({
|
|
207
|
-
jsonrpc: '2.0',
|
|
208
|
-
id: 'discovery',
|
|
209
|
-
method: 'tools/list', // Standard MCP method
|
|
210
|
-
}, context);
|
|
262
|
+
this.logger.debug({ upstreamId: id }, 'Discovering tools from upstream');
|
|
211
263
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
tools = [];
|
|
218
|
-
}
|
|
264
|
+
// reuse unified discovery logic
|
|
265
|
+
try {
|
|
266
|
+
await this.listToolStubs(id, context);
|
|
267
|
+
} catch (e: any) {
|
|
268
|
+
this.logger.error({ upstreamId: id, err: e.message }, 'Failed to list tool stubs');
|
|
219
269
|
}
|
|
270
|
+
const tools = this.schemaCache.get(id) || [];
|
|
220
271
|
|
|
221
|
-
|
|
272
|
+
this.logger.debug({ upstreamId: id, toolCount: tools.length }, 'Discovery result');
|
|
222
273
|
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
274
|
+
if (tools && tools.length > 0) {
|
|
275
|
+
const prefixedTools = tools.map(t => ({ ...t, name: `${id}__${t.name}` }));
|
|
276
|
+
if (context.allowedTools) {
|
|
277
|
+
allTools.push(...prefixedTools.filter(t => this.policyService.isToolAllowed(t.name, context.allowedTools!)));
|
|
278
|
+
} else {
|
|
279
|
+
allTools.push(...prefixedTools);
|
|
280
|
+
}
|
|
228
281
|
}
|
|
229
282
|
}
|
|
230
283
|
|
|
284
|
+
this.logger.info({ totalTools: allTools.length }, 'Tool discovery complete');
|
|
231
285
|
return allTools;
|
|
232
286
|
}
|
|
233
287
|
|
|
234
288
|
async callTool(name: string, params: any, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
289
|
+
this.logger.debug({ name, upstreamCount: this.clients.size }, 'GatewayService.callTool called');
|
|
290
|
+
|
|
235
291
|
if (context.allowedTools && !this.policyService.isToolAllowed(name, context.allowedTools)) {
|
|
236
292
|
this.logger.warn({ name, allowedTools: context.allowedTools }, 'Tool call blocked by allowlist');
|
|
237
293
|
return {
|
|
@@ -248,14 +304,43 @@ export class GatewayService {
|
|
|
248
304
|
const upstreamId = toolId.namespace;
|
|
249
305
|
const toolName = toolId.name;
|
|
250
306
|
|
|
307
|
+
this.logger.debug({ name, upstreamId, toolName }, 'Parsed tool name');
|
|
308
|
+
|
|
309
|
+
// Fallback for namespaceless calls: try to find the tool in any registered upstream
|
|
310
|
+
if (!upstreamId) {
|
|
311
|
+
this.logger.debug({ toolName }, 'Namespaceless call, attempting discovery across upstreams');
|
|
312
|
+
const allStubs = await this.discoverTools(context);
|
|
313
|
+
const found = allStubs.find(t => {
|
|
314
|
+
const parts = t.name.split('__');
|
|
315
|
+
return parts[parts.length - 1] === toolName;
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (found) {
|
|
319
|
+
this.logger.debug({ original: name, resolved: found.name }, 'Resolved namespaceless tool');
|
|
320
|
+
return this.callTool(found.name, params, context);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// No fallback to host - it doesn't support server-to-client tool calls
|
|
324
|
+
const upstreamList = Array.from(this.clients.keys()).filter(k => k !== 'host');
|
|
325
|
+
return {
|
|
326
|
+
jsonrpc: '2.0',
|
|
327
|
+
id: 0,
|
|
328
|
+
error: {
|
|
329
|
+
code: -32601,
|
|
330
|
+
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 ? '...' : ''}`,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
251
335
|
const client = this.clients.get(upstreamId);
|
|
252
336
|
if (!client) {
|
|
337
|
+
this.logger.error({ upstreamId, availableUpstreams: Array.from(this.clients.keys()) }, 'Upstream not found');
|
|
253
338
|
return {
|
|
254
339
|
jsonrpc: '2.0',
|
|
255
340
|
id: 0,
|
|
256
341
|
error: {
|
|
257
342
|
code: -32003,
|
|
258
|
-
message: `Upstream not found: ${upstreamId}`,
|
|
343
|
+
message: `Upstream not found: '${upstreamId}'. Available: ${Array.from(this.clients.keys()).join(', ') || 'none'}`,
|
|
259
344
|
},
|
|
260
345
|
};
|
|
261
346
|
}
|