@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhingston5/conduit",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "type": "module",
5
5
  "description": "A secure Code Mode execution substrate for MCP agents",
6
6
  "main": "index.js",
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 body = new URLSearchParams();
161
- body.set('grant_type', 'authorization_code');
162
- body.set('code', code);
163
- body.set('redirect_uri', redirectUri);
164
- body.set('client_id', options.clientId);
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
- body.set('client_secret', options.clientSecret);
168
+ payload.client_secret = options.clientSecret;
167
169
  }
168
170
  if (codeVerifier) {
169
- body.set('code_verifier', codeVerifier);
171
+ payload.code_verifier = codeVerifier;
170
172
  }
171
173
  if (resolvedResource) {
172
- body.set('resource', resolvedResource);
174
+ payload.resource = resolvedResource;
173
175
  }
174
176
 
175
- const response = await axios.post(resolvedTokenUrl, body, {
176
- headers: {
177
- 'Content-Type': 'application/x-www-form-urlencoded',
178
- 'Accept': 'application/json',
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([HttpUpstreamSchema, StdioUpstreamSchema]);
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) return {};
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
- switch (name) {
220
- case 'mcp_execute_typescript':
221
- return this.handleExecuteToolCall('typescript', toolArgs, context, id);
222
- case 'mcp_execute_python':
223
- return this.handleExecuteToolCall('python', toolArgs, context, id);
224
- case 'mcp_execute_isolate':
225
- return this.handleExecuteToolCall('isolate', toolArgs, context, id);
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 body = new URLSearchParams();
85
- body.set('grant_type', 'refresh_token');
86
- body.set('refresh_token', creds.refreshToken);
87
- body.set('client_id', creds.clientId);
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
- body.set('client_secret', creds.clientSecret);
103
+ payload.client_secret = creds.clientSecret;
90
104
  }
91
105
 
92
- const response = await axios.post(creds.tokenUrl, body, {
93
- headers: {
94
- 'Content-Type': 'application/x-www-form-urlencoded',
95
- 'Accept': 'application/json',
96
- },
97
- });
106
+ if (creds.tokenParams) {
107
+ Object.assign(payload, creds.tokenParams);
108
+ }
98
109
 
99
- const { access_token, expires_in } = response.data;
100
- const expiresInSeconds = Number(expires_in) || 3600;
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, UpstreamClient> = new Map();
77
+ private clients: Map<string, any> = new Map();
76
78
  private authService: AuthService;
77
79
  private schemaCache: SchemaCache;
78
80
  private urlValidator: IUrlValidator;
79
- private policyService: PolicyService;
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 }); // Strict mode off for now to be permissive with upstream schemas
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
- return Array.from(this.clients.entries()).map(([id, client]) => ({
111
+ const upstreams = Array.from(this.clients.entries()).map(([id, client]) => ({
103
112
  id,
104
- description: `Upstream ${id}`, // NOTE: Upstream description fetching deferred to V2
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
- // Try manifest first if tools not cached
147
+ // Discover tools if not cached
118
148
  if (!tools) {
119
- try {
120
- // Try to fetch manifest first
121
- const manifest = await client.getManifest(context);
122
- if (manifest) {
123
- const stubs: ToolStub[] = manifest.tools.map((t: any) => ({
124
- id: `${packageId}__${t.name}`,
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
- return stubs;
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
- const response = await client.call({
140
- jsonrpc: '2.0',
141
- id: 'discovery',
142
- method: 'tools/list',
143
- }, context);
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 (response.result?.tools) {
146
- tools = response.result.tools as ToolSchema[];
184
+ if (tools && tools.length > 0) {
147
185
  this.schemaCache.set(packageId, tools);
148
- } else {
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 toolName = parsed.name; // Use a new variable for the un-namespaced name
174
-
175
- // Check for built-in tools
176
- const builtIn = BUILT_IN_TOOLS.find(t => t.name === toolId); // Compare with the full toolId
177
- if (builtIn) return builtIn;
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 = parsed.namespace;
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[] = [...BUILT_IN_TOOLS];
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
- let tools = this.schemaCache.get(id);
257
+ // Skip host - it's not a tool provider
258
+ if (id === 'host') {
259
+ continue;
260
+ }
204
261
 
205
- if (!tools) {
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
- if (response.result?.tools) {
213
- tools = response.result.tools as ToolSchema[];
214
- this.schemaCache.set(id, tools);
215
- } else {
216
- this.logger.warn({ upstreamId: id, error: response.error }, 'Failed to discover tools from upstream');
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
- const prefixedTools = tools.map(t => ({ ...t, name: `${id}__${t.name}` }));
272
+ this.logger.debug({ upstreamId: id, toolCount: tools.length }, 'Discovery result');
222
273
 
223
- if (context.allowedTools) {
224
- // Support wildcard patterns: "mock.*" matches "mock__hello"
225
- allTools.push(...prefixedTools.filter(t => this.policyService.isToolAllowed(t.name, context.allowedTools!)));
226
- } else {
227
- allTools.push(...prefixedTools);
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
  }