@mhingston5/conduit 1.0.0
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/.env.example +13 -0
- package/.github/workflows/ci.yml +88 -0
- package/.github/workflows/pr-checks.yml +90 -0
- package/.tool-versions +2 -0
- package/README.md +177 -0
- package/conduit.yaml.test +3 -0
- package/docs/ARCHITECTURE.md +35 -0
- package/docs/CODE_MODE.md +33 -0
- package/docs/SECURITY.md +52 -0
- package/logo.png +0 -0
- package/package.json +74 -0
- package/src/assets/deno-shim.ts +93 -0
- package/src/assets/python-shim.py +21 -0
- package/src/core/asset.utils.ts +42 -0
- package/src/core/concurrency.service.ts +70 -0
- package/src/core/config.service.ts +147 -0
- package/src/core/execution.context.ts +37 -0
- package/src/core/execution.service.ts +209 -0
- package/src/core/interfaces/app.config.ts +17 -0
- package/src/core/interfaces/executor.interface.ts +31 -0
- package/src/core/interfaces/middleware.interface.ts +12 -0
- package/src/core/interfaces/url.validator.interface.ts +3 -0
- package/src/core/logger.ts +64 -0
- package/src/core/metrics.service.ts +112 -0
- package/src/core/middleware/auth.middleware.ts +56 -0
- package/src/core/middleware/error.middleware.ts +21 -0
- package/src/core/middleware/logging.middleware.ts +25 -0
- package/src/core/middleware/middleware.builder.ts +24 -0
- package/src/core/middleware/ratelimit.middleware.ts +31 -0
- package/src/core/network.policy.service.ts +106 -0
- package/src/core/ops.server.ts +74 -0
- package/src/core/otel.service.ts +41 -0
- package/src/core/policy.service.ts +77 -0
- package/src/core/registries/executor.registry.ts +26 -0
- package/src/core/request.controller.ts +297 -0
- package/src/core/security.service.ts +68 -0
- package/src/core/session.manager.ts +44 -0
- package/src/core/types.ts +47 -0
- package/src/executors/deno.executor.ts +342 -0
- package/src/executors/isolate.executor.ts +281 -0
- package/src/executors/pyodide.executor.ts +327 -0
- package/src/executors/pyodide.worker.ts +195 -0
- package/src/gateway/auth.service.ts +104 -0
- package/src/gateway/gateway.service.ts +345 -0
- package/src/gateway/schema.cache.ts +46 -0
- package/src/gateway/upstream.client.ts +244 -0
- package/src/index.ts +92 -0
- package/src/sdk/index.ts +2 -0
- package/src/sdk/sdk-generator.ts +245 -0
- package/src/sdk/tool-binding.ts +86 -0
- package/src/transport/socket.transport.ts +203 -0
- package/tests/__snapshots__/assets.test.ts.snap +97 -0
- package/tests/assets.test.ts +50 -0
- package/tests/auth.service.test.ts +78 -0
- package/tests/code-mode-lite-execution.test.ts +84 -0
- package/tests/code-mode-lite-gateway.test.ts +150 -0
- package/tests/concurrency.service.test.ts +50 -0
- package/tests/concurrency.test.ts +41 -0
- package/tests/config.service.test.ts +70 -0
- package/tests/contract.test.ts +43 -0
- package/tests/deno.executor.test.ts +68 -0
- package/tests/deno_hardening.test.ts +45 -0
- package/tests/dynamic.tool.test.ts +237 -0
- package/tests/e2e_stdio_upstream.test.ts +197 -0
- package/tests/fixtures/stdio-server.ts +42 -0
- package/tests/gateway.manifest.test.ts +82 -0
- package/tests/gateway.service.test.ts +58 -0
- package/tests/gateway.strict.unit.test.ts +74 -0
- package/tests/gateway.validation.unit.test.ts +89 -0
- package/tests/gateway_validation.test.ts +86 -0
- package/tests/hardening.test.ts +139 -0
- package/tests/hardening_v1.test.ts +72 -0
- package/tests/isolate.executor.test.ts +100 -0
- package/tests/log-limit.test.ts +55 -0
- package/tests/middleware.test.ts +106 -0
- package/tests/ops.server.test.ts +65 -0
- package/tests/policy.service.test.ts +90 -0
- package/tests/pyodide.executor.test.ts +101 -0
- package/tests/reference_mcp.ts +40 -0
- package/tests/remediation.test.ts +119 -0
- package/tests/routing.test.ts +148 -0
- package/tests/schema.cache.test.ts +27 -0
- package/tests/sdk/sdk-generator.test.ts +205 -0
- package/tests/socket.transport.test.ts +182 -0
- package/tests/stdio_upstream.test.ts +54 -0
- package/tsconfig.json +25 -0
- package/tsup.config.ts +22 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { Logger } from 'pino';
|
|
2
|
+
import { UpstreamClient, UpstreamInfo } from './upstream.client.js';
|
|
3
|
+
import { AuthService } from './auth.service.js';
|
|
4
|
+
import { SchemaCache, ToolSchema } from './schema.cache.js';
|
|
5
|
+
import { JSONRPCRequest, JSONRPCResponse, ToolPackage, ToolStub } from '../core/types.js';
|
|
6
|
+
import { ExecutionContext } from '../core/execution.context.js';
|
|
7
|
+
import { IUrlValidator } from '../core/interfaces/url.validator.interface.js';
|
|
8
|
+
import { metrics } from '../core/metrics.service.js';
|
|
9
|
+
import { PolicyService, ToolIdentifier } from '../core/policy.service.js';
|
|
10
|
+
import { Ajv } from 'ajv';
|
|
11
|
+
import addFormats from 'ajv-formats';
|
|
12
|
+
|
|
13
|
+
export class GatewayService {
|
|
14
|
+
private logger: Logger;
|
|
15
|
+
private clients: Map<string, UpstreamClient> = new Map();
|
|
16
|
+
private authService: AuthService;
|
|
17
|
+
private schemaCache: SchemaCache;
|
|
18
|
+
private urlValidator: IUrlValidator;
|
|
19
|
+
private policyService: PolicyService;
|
|
20
|
+
private ajv: Ajv;
|
|
21
|
+
// Cache compiled validators to avoid recompilation on every call
|
|
22
|
+
private validatorCache = new Map<string, any>();
|
|
23
|
+
|
|
24
|
+
constructor(logger: Logger, urlValidator: IUrlValidator, policyService?: PolicyService) {
|
|
25
|
+
this.logger = logger;
|
|
26
|
+
this.urlValidator = urlValidator;
|
|
27
|
+
this.authService = new AuthService(logger);
|
|
28
|
+
this.schemaCache = new SchemaCache(logger);
|
|
29
|
+
this.policyService = policyService ?? new PolicyService();
|
|
30
|
+
this.ajv = new Ajv({ strict: false }); // Strict mode off for now to be permissive with upstream schemas
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
32
|
+
(addFormats as any).default(this.ajv);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
registerUpstream(info: UpstreamInfo) {
|
|
36
|
+
const client = new UpstreamClient(this.logger, info, this.authService, this.urlValidator);
|
|
37
|
+
this.clients.set(info.id, client);
|
|
38
|
+
this.logger.info({ upstreamId: info.id }, 'Registered upstream MCP');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async listToolPackages(): Promise<ToolPackage[]> {
|
|
42
|
+
return Array.from(this.clients.entries()).map(([id, client]) => ({
|
|
43
|
+
id,
|
|
44
|
+
description: `Upstream ${id}`, // NOTE: Upstream description fetching deferred to V2
|
|
45
|
+
version: '1.0.0'
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async listToolStubs(packageId: string, context: ExecutionContext): Promise<ToolStub[]> {
|
|
50
|
+
const client = this.clients.get(packageId);
|
|
51
|
+
if (!client) {
|
|
52
|
+
throw new Error(`Upstream package not found: ${packageId}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let tools = this.schemaCache.get(packageId);
|
|
56
|
+
|
|
57
|
+
// Try manifest first if tools not cached
|
|
58
|
+
if (!tools) {
|
|
59
|
+
try {
|
|
60
|
+
// Try to fetch manifest first
|
|
61
|
+
const manifest = await client.getManifest(context);
|
|
62
|
+
if (manifest) {
|
|
63
|
+
const stubs: ToolStub[] = manifest.tools.map((t: any) => ({
|
|
64
|
+
id: `${packageId}__${t.name}`,
|
|
65
|
+
name: t.name,
|
|
66
|
+
description: t.description
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
if (context.allowedTools) {
|
|
70
|
+
return stubs.filter(t => this.policyService.isToolAllowed(t.id, context.allowedTools!));
|
|
71
|
+
}
|
|
72
|
+
return stubs;
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// Manifest fetch failed, fall back
|
|
76
|
+
this.logger.debug({ packageId, err: e }, 'Manifest fetch failed, falling back to RPC');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const response = await client.call({
|
|
80
|
+
jsonrpc: '2.0',
|
|
81
|
+
id: 'discovery',
|
|
82
|
+
method: 'list_tools',
|
|
83
|
+
}, context);
|
|
84
|
+
|
|
85
|
+
if (response.result?.tools) {
|
|
86
|
+
tools = response.result.tools as ToolSchema[];
|
|
87
|
+
this.schemaCache.set(packageId, tools);
|
|
88
|
+
} else {
|
|
89
|
+
this.logger.warn({ upstreamId: packageId, error: response.error }, 'Failed to discover tools from upstream');
|
|
90
|
+
tools = [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stubs: ToolStub[] = tools.map(t => ({
|
|
95
|
+
id: `${packageId}__${t.name}`,
|
|
96
|
+
name: t.name,
|
|
97
|
+
description: t.description
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
if (context.allowedTools) {
|
|
101
|
+
return stubs.filter(t => this.policyService.isToolAllowed(t.id, context.allowedTools!));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return stubs;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async getToolSchema(toolId: string, context: ExecutionContext): Promise<ToolSchema | null> {
|
|
108
|
+
if (context.allowedTools && !this.policyService.isToolAllowed(toolId, context.allowedTools)) {
|
|
109
|
+
throw new Error(`Access to tool ${toolId} is forbidden by allowlist`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parsed = this.policyService.parseToolName(toolId);
|
|
113
|
+
const upstreamId = parsed.namespace;
|
|
114
|
+
const toolName = parsed.name;
|
|
115
|
+
|
|
116
|
+
// Ensure we have schemas for this upstream
|
|
117
|
+
if (!this.schemaCache.get(upstreamId)) {
|
|
118
|
+
// Force refresh if missing
|
|
119
|
+
await this.listToolStubs(upstreamId, context);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const tools = this.schemaCache.get(upstreamId) || [];
|
|
123
|
+
const tool = tools.find(t => t.name === toolName);
|
|
124
|
+
|
|
125
|
+
if (!tool) return null;
|
|
126
|
+
|
|
127
|
+
// Return schema with namespaced name
|
|
128
|
+
return {
|
|
129
|
+
...tool,
|
|
130
|
+
name: toolId
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async discoverTools(context: ExecutionContext): Promise<ToolSchema[]> {
|
|
135
|
+
const allTools: ToolSchema[] = [];
|
|
136
|
+
|
|
137
|
+
for (const [id, client] of this.clients.entries()) {
|
|
138
|
+
let tools = this.schemaCache.get(id);
|
|
139
|
+
|
|
140
|
+
if (!tools) {
|
|
141
|
+
const response = await client.call({
|
|
142
|
+
jsonrpc: '2.0',
|
|
143
|
+
id: 'discovery',
|
|
144
|
+
method: 'list_tools', // Standard MCP method
|
|
145
|
+
}, context);
|
|
146
|
+
|
|
147
|
+
if (response.result?.tools) {
|
|
148
|
+
tools = response.result.tools as ToolSchema[];
|
|
149
|
+
this.schemaCache.set(id, tools);
|
|
150
|
+
} else {
|
|
151
|
+
this.logger.warn({ upstreamId: id, error: response.error }, 'Failed to discover tools from upstream');
|
|
152
|
+
tools = [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const prefixedTools = tools.map(t => ({ ...t, name: `${id}__${t.name}` }));
|
|
157
|
+
|
|
158
|
+
if (context.allowedTools) {
|
|
159
|
+
// Support wildcard patterns: "mock.*" matches "mock__hello"
|
|
160
|
+
allTools.push(...prefixedTools.filter(t => this.policyService.isToolAllowed(t.name, context.allowedTools!)));
|
|
161
|
+
} else {
|
|
162
|
+
allTools.push(...prefixedTools);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return allTools;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async callTool(name: string, params: any, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
170
|
+
if (context.allowedTools && !this.policyService.isToolAllowed(name, context.allowedTools)) {
|
|
171
|
+
this.logger.warn({ name, allowedTools: context.allowedTools }, 'Tool call blocked by allowlist');
|
|
172
|
+
return {
|
|
173
|
+
jsonrpc: '2.0',
|
|
174
|
+
id: 0,
|
|
175
|
+
error: {
|
|
176
|
+
code: -32003,
|
|
177
|
+
message: `Authorization failed: tool ${name} is not in the allowlist`,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const toolId = this.policyService.parseToolName(name);
|
|
183
|
+
const upstreamId = toolId.namespace;
|
|
184
|
+
const toolName = toolId.name;
|
|
185
|
+
|
|
186
|
+
const client = this.clients.get(upstreamId);
|
|
187
|
+
if (!client) {
|
|
188
|
+
return {
|
|
189
|
+
jsonrpc: '2.0',
|
|
190
|
+
id: 0,
|
|
191
|
+
error: {
|
|
192
|
+
code: -32003,
|
|
193
|
+
message: `Upstream not found: ${upstreamId}`,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Lazy load schema if missing (Phase 1)
|
|
199
|
+
if (!this.schemaCache.get(upstreamId)) {
|
|
200
|
+
await this.listToolStubs(upstreamId, context);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const tools = this.schemaCache.get(upstreamId) || [];
|
|
204
|
+
const toolSchema = tools.find(t => t.name === toolName);
|
|
205
|
+
|
|
206
|
+
if (context.strictValidation) {
|
|
207
|
+
if (!toolSchema) {
|
|
208
|
+
return {
|
|
209
|
+
jsonrpc: '2.0',
|
|
210
|
+
id: 0,
|
|
211
|
+
error: {
|
|
212
|
+
code: -32601, // Method not found / Schema missing
|
|
213
|
+
message: `Strict mode: Tool schema for ${name} not found`,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (!toolSchema.inputSchema) {
|
|
218
|
+
return {
|
|
219
|
+
jsonrpc: '2.0',
|
|
220
|
+
id: 0,
|
|
221
|
+
error: {
|
|
222
|
+
code: -32602, // Invalid params
|
|
223
|
+
message: `Strict mode: Tool ${name} has no input schema defined`,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (toolSchema && toolSchema.inputSchema) {
|
|
230
|
+
const cacheKey = `${upstreamId}__${toolName}`;
|
|
231
|
+
let validate = this.validatorCache.get(cacheKey);
|
|
232
|
+
if (!validate) {
|
|
233
|
+
validate = this.ajv.compile(toolSchema.inputSchema);
|
|
234
|
+
this.validatorCache.set(cacheKey, validate);
|
|
235
|
+
}
|
|
236
|
+
const valid = validate(params);
|
|
237
|
+
if (!valid) {
|
|
238
|
+
return {
|
|
239
|
+
jsonrpc: '2.0',
|
|
240
|
+
id: 0,
|
|
241
|
+
error: {
|
|
242
|
+
code: -32602, // Invalid params
|
|
243
|
+
message: `Invalid parameters for tool ${name}: ${this.ajv.errorsText(validate.errors)}`,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const startTime = performance.now();
|
|
250
|
+
let success = false;
|
|
251
|
+
let response: JSONRPCResponse;
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
response = await client.call({
|
|
255
|
+
jsonrpc: '2.0',
|
|
256
|
+
id: context.correlationId,
|
|
257
|
+
method: 'call_tool',
|
|
258
|
+
params: {
|
|
259
|
+
name: toolName,
|
|
260
|
+
arguments: params,
|
|
261
|
+
},
|
|
262
|
+
}, context);
|
|
263
|
+
success = !response.error;
|
|
264
|
+
} catch (error: any) {
|
|
265
|
+
success = false;
|
|
266
|
+
throw error;
|
|
267
|
+
} finally {
|
|
268
|
+
const duration = performance.now() - startTime;
|
|
269
|
+
metrics.recordToolExecution(duration, toolName, success);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (response.error && response.error.code === -32008) {
|
|
273
|
+
// Potentially refresh cache on certain types of errors
|
|
274
|
+
this.schemaCache.invalidate(upstreamId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return response;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async healthCheck(): Promise<{ status: string; upstreams: Record<string, string> }> {
|
|
281
|
+
const upstreamStatus: Record<string, string> = {};
|
|
282
|
+
const context = new ExecutionContext({ logger: this.logger });
|
|
283
|
+
|
|
284
|
+
await Promise.all(
|
|
285
|
+
Array.from(this.clients.entries()).map(async ([id, client]) => {
|
|
286
|
+
try {
|
|
287
|
+
const response = await client.call({
|
|
288
|
+
jsonrpc: '2.0',
|
|
289
|
+
id: 'health',
|
|
290
|
+
method: 'list_tools',
|
|
291
|
+
}, context);
|
|
292
|
+
upstreamStatus[id] = response.error ? 'degraded' : 'active';
|
|
293
|
+
} catch (err) {
|
|
294
|
+
upstreamStatus[id] = 'error';
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const allOk = Object.values(upstreamStatus).every(s => s === 'active');
|
|
300
|
+
return {
|
|
301
|
+
status: allOk ? 'ok' : 'degraded',
|
|
302
|
+
upstreams: upstreamStatus,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async validateTool(name: string, params: any, context: ExecutionContext): Promise<{ valid: boolean; errors?: string[] }> {
|
|
306
|
+
const toolId = this.policyService.parseToolName(name);
|
|
307
|
+
const upstreamId = toolId.namespace;
|
|
308
|
+
const toolName = toolId.name;
|
|
309
|
+
|
|
310
|
+
// Ensure we have schemas
|
|
311
|
+
if (!this.schemaCache.get(upstreamId)) {
|
|
312
|
+
await this.listToolStubs(upstreamId, context);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const tools = this.schemaCache.get(upstreamId) || [];
|
|
316
|
+
const toolSchema = tools.find(t => t.name === toolName);
|
|
317
|
+
|
|
318
|
+
if (!toolSchema) {
|
|
319
|
+
return { valid: false, errors: [`Tool ${name} not found`] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (context.strictValidation) {
|
|
323
|
+
if (!toolSchema.inputSchema) {
|
|
324
|
+
return { valid: false, errors: [`Strict mode: Tool ${name} has no input schema defined`] };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!toolSchema.inputSchema) {
|
|
329
|
+
// No schema means any params are valid (unless strict mode, which we handled above)
|
|
330
|
+
return { valid: true };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const validate = this.ajv.compile(toolSchema.inputSchema);
|
|
334
|
+
const valid = validate(params);
|
|
335
|
+
|
|
336
|
+
if (!valid) {
|
|
337
|
+
return {
|
|
338
|
+
valid: false,
|
|
339
|
+
errors: validate.errors?.map(e => this.ajv.errorsText([e])) || ['Unknown validation error']
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { valid: true };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LRUCache } from 'lru-cache';
|
|
2
|
+
import { Logger } from 'pino';
|
|
3
|
+
import { metrics } from '../core/metrics.service.js';
|
|
4
|
+
|
|
5
|
+
export interface ToolSchema {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
inputSchema: any;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SchemaCache {
|
|
12
|
+
private cache: LRUCache<string, ToolSchema[]>;
|
|
13
|
+
private logger: Logger;
|
|
14
|
+
|
|
15
|
+
constructor(logger: Logger, max: number = 100, ttl: number = 1000 * 60 * 60) { // 1 hour TTL default
|
|
16
|
+
this.logger = logger;
|
|
17
|
+
this.cache = new LRUCache({
|
|
18
|
+
max,
|
|
19
|
+
ttl,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(upstreamId: string): ToolSchema[] | undefined {
|
|
24
|
+
const result = this.cache.get(upstreamId);
|
|
25
|
+
if (result) {
|
|
26
|
+
metrics.recordCacheHit();
|
|
27
|
+
} else {
|
|
28
|
+
metrics.recordCacheMiss();
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(upstreamId: string, tools: ToolSchema[]) {
|
|
34
|
+
this.logger.debug({ upstreamId, count: tools.length }, 'Caching tool schemas');
|
|
35
|
+
this.cache.set(upstreamId, tools);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
invalidate(upstreamId: string) {
|
|
39
|
+
this.logger.debug({ upstreamId }, 'Invalidating schema cache');
|
|
40
|
+
this.cache.delete(upstreamId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clear() {
|
|
44
|
+
this.cache.clear();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { Logger } from 'pino';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { JSONRPCRequest, JSONRPCResponse, ToolManifest } from '../core/types.js';
|
|
4
|
+
import { AuthService, UpstreamCredentials } from './auth.service.js';
|
|
5
|
+
import { ExecutionContext } from '../core/execution.context.js';
|
|
6
|
+
import { IUrlValidator } from '../core/interfaces/url.validator.interface.js';
|
|
7
|
+
|
|
8
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
9
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
export type UpstreamInfo = {
|
|
13
|
+
id: string;
|
|
14
|
+
credentials?: UpstreamCredentials;
|
|
15
|
+
} & (
|
|
16
|
+
| { type?: 'http'; url: string }
|
|
17
|
+
| { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export class UpstreamClient {
|
|
21
|
+
private logger: Logger;
|
|
22
|
+
private info: UpstreamInfo;
|
|
23
|
+
private authService: AuthService;
|
|
24
|
+
private urlValidator: IUrlValidator;
|
|
25
|
+
private mcpClient?: Client;
|
|
26
|
+
private transport?: StdioClientTransport;
|
|
27
|
+
|
|
28
|
+
constructor(logger: Logger, info: UpstreamInfo, authService: AuthService, urlValidator: IUrlValidator) {
|
|
29
|
+
this.logger = logger.child({ upstreamId: info.id });
|
|
30
|
+
this.info = info;
|
|
31
|
+
this.authService = authService;
|
|
32
|
+
this.urlValidator = urlValidator;
|
|
33
|
+
|
|
34
|
+
if (this.info.type === 'stdio') {
|
|
35
|
+
const env = { ...process.env, ...this.info.env };
|
|
36
|
+
// Filter undefined values
|
|
37
|
+
const cleanEnv = Object.entries(env).reduce((acc, [k, v]) => {
|
|
38
|
+
if (v !== undefined) acc[k] = v;
|
|
39
|
+
return acc;
|
|
40
|
+
}, {} as Record<string, string>);
|
|
41
|
+
|
|
42
|
+
this.transport = new StdioClientTransport({
|
|
43
|
+
command: this.info.command,
|
|
44
|
+
args: this.info.args,
|
|
45
|
+
env: cleanEnv,
|
|
46
|
+
});
|
|
47
|
+
this.mcpClient = new Client({
|
|
48
|
+
name: 'conduit-gateway',
|
|
49
|
+
version: '1.0.0',
|
|
50
|
+
}, {
|
|
51
|
+
capabilities: {},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async ensureConnected() {
|
|
57
|
+
if (!this.mcpClient || !this.transport) return;
|
|
58
|
+
// There isn't a public isConnected property easily accessible,
|
|
59
|
+
// usually we just connect once.
|
|
60
|
+
// We can track connected state or just try/catch connect.
|
|
61
|
+
// For simplicity, we connect once and existing sdk handles reconnection or errors usually kill it.
|
|
62
|
+
// Actually SDK Client.connect() is for the transport.
|
|
63
|
+
try {
|
|
64
|
+
// @ts-ignore - Check internal state or just attempt connect if we haven't
|
|
65
|
+
if (!this.transport.connection) {
|
|
66
|
+
await this.mcpClient.connect(this.transport);
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// connection might already be active
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async call(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
74
|
+
// Helper to determine type safely
|
|
75
|
+
const isStdio = (info: UpstreamInfo): info is { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string>; id: string; credentials?: UpstreamCredentials } => info.type === 'stdio';
|
|
76
|
+
|
|
77
|
+
if (isStdio(this.info)) {
|
|
78
|
+
return this.callStdio(request);
|
|
79
|
+
} else {
|
|
80
|
+
return this.callHttp(request, context as ExecutionContext);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async callStdio(request: JSONRPCRequest): Promise<JSONRPCResponse> {
|
|
85
|
+
if (!this.mcpClient) {
|
|
86
|
+
return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Stdio client not initialized' } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await this.ensureConnected();
|
|
91
|
+
|
|
92
|
+
// Map GatewayService method names to SDK typed methods
|
|
93
|
+
if (request.method === 'list_tools') {
|
|
94
|
+
const result = await this.mcpClient.listTools();
|
|
95
|
+
return {
|
|
96
|
+
jsonrpc: '2.0',
|
|
97
|
+
id: request.id,
|
|
98
|
+
result: result
|
|
99
|
+
};
|
|
100
|
+
} else if (request.method === 'call_tool') {
|
|
101
|
+
const params = request.params as { name: string; arguments?: Record<string, unknown> };
|
|
102
|
+
const result = await this.mcpClient.callTool({
|
|
103
|
+
name: params.name,
|
|
104
|
+
arguments: params.arguments,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
jsonrpc: '2.0',
|
|
108
|
+
id: request.id,
|
|
109
|
+
result: result
|
|
110
|
+
};
|
|
111
|
+
} else {
|
|
112
|
+
// Fallback to generic request for other methods
|
|
113
|
+
const result = await this.mcpClient.request(
|
|
114
|
+
{ method: request.method, params: request.params },
|
|
115
|
+
z.any()
|
|
116
|
+
);
|
|
117
|
+
return {
|
|
118
|
+
jsonrpc: '2.0',
|
|
119
|
+
id: request.id,
|
|
120
|
+
result: result
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
} catch (error: any) {
|
|
124
|
+
this.logger.error({ err: error }, 'Stdio call failed');
|
|
125
|
+
return {
|
|
126
|
+
jsonrpc: '2.0',
|
|
127
|
+
id: request.id,
|
|
128
|
+
error: {
|
|
129
|
+
code: error.code || -32603,
|
|
130
|
+
message: error.message || 'Internal error in stdio transport'
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async callHttp(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
137
|
+
// Narrowing for TS
|
|
138
|
+
if (this.info.type === 'stdio') throw new Error('Unreachable');
|
|
139
|
+
const url = this.info.url;
|
|
140
|
+
|
|
141
|
+
const headers: Record<string, string> = {
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
'X-Correlation-Id': context.correlationId,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (context.tenantId) {
|
|
147
|
+
headers['X-Tenant-Id'] = context.tenantId;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.info.credentials) {
|
|
151
|
+
const authHeaders = await this.authService.getAuthHeaders(this.info.credentials);
|
|
152
|
+
Object.assign(headers, authHeaders);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const securityResult = await this.urlValidator.validateUrl(url);
|
|
156
|
+
if (!securityResult.valid) {
|
|
157
|
+
this.logger.error({ url }, 'Blocked upstream URL (SSRF)');
|
|
158
|
+
return {
|
|
159
|
+
jsonrpc: '2.0',
|
|
160
|
+
id: request.id,
|
|
161
|
+
error: {
|
|
162
|
+
code: -32003,
|
|
163
|
+
message: securityResult.message || 'Forbidden URL',
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
this.logger.debug({ method: request.method }, 'Calling upstream MCP');
|
|
170
|
+
|
|
171
|
+
// Fix Sev1: Use the resolved safe IP to prevent DNS rebinding
|
|
172
|
+
const originalUrl = new URL(url);
|
|
173
|
+
const requestUrl = securityResult.resolvedIp ?
|
|
174
|
+
`${originalUrl.protocol}//${securityResult.resolvedIp}${originalUrl.port ? ':' + originalUrl.port : ''}${originalUrl.pathname}${originalUrl.search}${originalUrl.hash}` :
|
|
175
|
+
url;
|
|
176
|
+
|
|
177
|
+
// Ensure Host header is set to the original hostname for virtual hosting/SNI
|
|
178
|
+
headers['Host'] = originalUrl.hostname;
|
|
179
|
+
|
|
180
|
+
const response = await axios.post(requestUrl, request, {
|
|
181
|
+
headers,
|
|
182
|
+
timeout: 10000,
|
|
183
|
+
maxRedirects: 0,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return response.data as JSONRPCResponse;
|
|
187
|
+
} catch (err: any) {
|
|
188
|
+
this.logger.error({ err: err.message }, 'Upstream MCP call failed');
|
|
189
|
+
return {
|
|
190
|
+
jsonrpc: '2.0',
|
|
191
|
+
id: request.id,
|
|
192
|
+
error: {
|
|
193
|
+
code: -32008,
|
|
194
|
+
message: `Upstream error: ${err.message}`,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async getManifest(context: ExecutionContext): Promise<ToolManifest | null> {
|
|
200
|
+
if (this.info.type !== 'http') return null;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const baseUrl = this.info.url.replace(/\/$/, ''); // Remove trailing slash
|
|
204
|
+
const manifestUrl = `${baseUrl}/conduit.manifest.json`;
|
|
205
|
+
|
|
206
|
+
const headers: Record<string, string> = {
|
|
207
|
+
'X-Correlation-Id': context.correlationId,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (this.info.credentials) {
|
|
211
|
+
const authHeaders = await this.authService.getAuthHeaders(this.info.credentials);
|
|
212
|
+
Object.assign(headers, authHeaders);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const securityResult = await this.urlValidator.validateUrl(manifestUrl);
|
|
216
|
+
if (!securityResult.valid) {
|
|
217
|
+
this.logger.warn({ url: manifestUrl }, 'Blocked manifest URL (SSRF)');
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Fix Sev1 approach: Use resolved IP
|
|
222
|
+
const originalUrl = new URL(manifestUrl);
|
|
223
|
+
const requestUrl = securityResult.resolvedIp ?
|
|
224
|
+
`${originalUrl.protocol}//${securityResult.resolvedIp}${originalUrl.port ? ':' + originalUrl.port : ''}${originalUrl.pathname}${originalUrl.search}${originalUrl.hash}` :
|
|
225
|
+
manifestUrl;
|
|
226
|
+
|
|
227
|
+
headers['Host'] = originalUrl.hostname;
|
|
228
|
+
|
|
229
|
+
const response = await axios.get(requestUrl, {
|
|
230
|
+
headers,
|
|
231
|
+
timeout: 5000,
|
|
232
|
+
maxRedirects: 0,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (response.status === 200 && response.data && Array.isArray(response.data.tools)) {
|
|
236
|
+
return response.data;
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// Ignore manifest errors and fallback to RPC
|
|
240
|
+
this.logger.debug({ err: error }, 'Failed to fetch manifest (will fallback)');
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|