@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.
Files changed (87) hide show
  1. package/.env.example +13 -0
  2. package/.github/workflows/ci.yml +88 -0
  3. package/.github/workflows/pr-checks.yml +90 -0
  4. package/.tool-versions +2 -0
  5. package/README.md +177 -0
  6. package/conduit.yaml.test +3 -0
  7. package/docs/ARCHITECTURE.md +35 -0
  8. package/docs/CODE_MODE.md +33 -0
  9. package/docs/SECURITY.md +52 -0
  10. package/logo.png +0 -0
  11. package/package.json +74 -0
  12. package/src/assets/deno-shim.ts +93 -0
  13. package/src/assets/python-shim.py +21 -0
  14. package/src/core/asset.utils.ts +42 -0
  15. package/src/core/concurrency.service.ts +70 -0
  16. package/src/core/config.service.ts +147 -0
  17. package/src/core/execution.context.ts +37 -0
  18. package/src/core/execution.service.ts +209 -0
  19. package/src/core/interfaces/app.config.ts +17 -0
  20. package/src/core/interfaces/executor.interface.ts +31 -0
  21. package/src/core/interfaces/middleware.interface.ts +12 -0
  22. package/src/core/interfaces/url.validator.interface.ts +3 -0
  23. package/src/core/logger.ts +64 -0
  24. package/src/core/metrics.service.ts +112 -0
  25. package/src/core/middleware/auth.middleware.ts +56 -0
  26. package/src/core/middleware/error.middleware.ts +21 -0
  27. package/src/core/middleware/logging.middleware.ts +25 -0
  28. package/src/core/middleware/middleware.builder.ts +24 -0
  29. package/src/core/middleware/ratelimit.middleware.ts +31 -0
  30. package/src/core/network.policy.service.ts +106 -0
  31. package/src/core/ops.server.ts +74 -0
  32. package/src/core/otel.service.ts +41 -0
  33. package/src/core/policy.service.ts +77 -0
  34. package/src/core/registries/executor.registry.ts +26 -0
  35. package/src/core/request.controller.ts +297 -0
  36. package/src/core/security.service.ts +68 -0
  37. package/src/core/session.manager.ts +44 -0
  38. package/src/core/types.ts +47 -0
  39. package/src/executors/deno.executor.ts +342 -0
  40. package/src/executors/isolate.executor.ts +281 -0
  41. package/src/executors/pyodide.executor.ts +327 -0
  42. package/src/executors/pyodide.worker.ts +195 -0
  43. package/src/gateway/auth.service.ts +104 -0
  44. package/src/gateway/gateway.service.ts +345 -0
  45. package/src/gateway/schema.cache.ts +46 -0
  46. package/src/gateway/upstream.client.ts +244 -0
  47. package/src/index.ts +92 -0
  48. package/src/sdk/index.ts +2 -0
  49. package/src/sdk/sdk-generator.ts +245 -0
  50. package/src/sdk/tool-binding.ts +86 -0
  51. package/src/transport/socket.transport.ts +203 -0
  52. package/tests/__snapshots__/assets.test.ts.snap +97 -0
  53. package/tests/assets.test.ts +50 -0
  54. package/tests/auth.service.test.ts +78 -0
  55. package/tests/code-mode-lite-execution.test.ts +84 -0
  56. package/tests/code-mode-lite-gateway.test.ts +150 -0
  57. package/tests/concurrency.service.test.ts +50 -0
  58. package/tests/concurrency.test.ts +41 -0
  59. package/tests/config.service.test.ts +70 -0
  60. package/tests/contract.test.ts +43 -0
  61. package/tests/deno.executor.test.ts +68 -0
  62. package/tests/deno_hardening.test.ts +45 -0
  63. package/tests/dynamic.tool.test.ts +237 -0
  64. package/tests/e2e_stdio_upstream.test.ts +197 -0
  65. package/tests/fixtures/stdio-server.ts +42 -0
  66. package/tests/gateway.manifest.test.ts +82 -0
  67. package/tests/gateway.service.test.ts +58 -0
  68. package/tests/gateway.strict.unit.test.ts +74 -0
  69. package/tests/gateway.validation.unit.test.ts +89 -0
  70. package/tests/gateway_validation.test.ts +86 -0
  71. package/tests/hardening.test.ts +139 -0
  72. package/tests/hardening_v1.test.ts +72 -0
  73. package/tests/isolate.executor.test.ts +100 -0
  74. package/tests/log-limit.test.ts +55 -0
  75. package/tests/middleware.test.ts +106 -0
  76. package/tests/ops.server.test.ts +65 -0
  77. package/tests/policy.service.test.ts +90 -0
  78. package/tests/pyodide.executor.test.ts +101 -0
  79. package/tests/reference_mcp.ts +40 -0
  80. package/tests/remediation.test.ts +119 -0
  81. package/tests/routing.test.ts +148 -0
  82. package/tests/schema.cache.test.ts +27 -0
  83. package/tests/sdk/sdk-generator.test.ts +205 -0
  84. package/tests/socket.transport.test.ts +182 -0
  85. package/tests/stdio_upstream.test.ts +54 -0
  86. package/tsconfig.json +25 -0
  87. 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
+ }