@mhingston5/conduit 1.1.6 → 1.1.8
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 +274 -67
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/auth.cmd.ts +26 -14
- package/src/core/config.service.ts +22 -1
- package/src/core/middleware/auth.middleware.ts +1 -2
- package/src/core/security.service.ts +8 -8
- package/src/executors/isolate.executor.ts +39 -12
- package/src/gateway/auth.service.ts +55 -13
- package/src/gateway/gateway.service.ts +22 -14
- package/src/gateway/upstream.client.ts +172 -15
- package/src/index.ts +5 -1
- package/tests/__snapshots__/assets.test.ts.snap +17 -15
- package/tests/auth.service.test.ts +57 -0
- package/tests/config.service.test.ts +29 -1
- package/tests/middleware.test.ts +16 -13
- package/tests/routing.test.ts +1 -0
- package/tests/upstream.transports.test.ts +156 -0
- package/tests/debug.fallback.test.ts +0 -40
- package/tests/debug_upstream.ts +0 -69
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mhingston5/conduit",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A secure Code Mode execution substrate for MCP agents",
|
|
6
6
|
"main": "index.js",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"p-limit": "^7.2.0",
|
|
61
61
|
"pino": "^10.1.0",
|
|
62
62
|
"pyodide": "^0.29.0",
|
|
63
|
+
"undici": "^7.18.2",
|
|
63
64
|
"uuid": "^13.0.0",
|
|
64
65
|
"zod": "^4.3.5"
|
|
65
66
|
},
|
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
|
|
|
@@ -12,10 +12,9 @@ export class AuthMiddleware implements Middleware {
|
|
|
12
12
|
next: NextFunction
|
|
13
13
|
): Promise<JSONRPCResponse | null> {
|
|
14
14
|
const providedToken = request.auth?.bearerToken || '';
|
|
15
|
-
const masterToken = this.securityService.getIpcToken();
|
|
16
15
|
|
|
17
16
|
// If no master token is set (stdio mode), treat all requests as master (auth disabled)
|
|
18
|
-
const isMaster =
|
|
17
|
+
const isMaster = this.securityService.isMasterToken(providedToken);
|
|
19
18
|
const isSession = !isMaster && this.securityService.validateIpcToken(providedToken);
|
|
20
19
|
|
|
21
20
|
if (!isMaster && !isSession) {
|
|
@@ -38,16 +38,16 @@ export class SecurityService implements IUrlValidator {
|
|
|
38
38
|
return this.networkPolicy.checkRateLimit(key);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (!this.ipcToken) {
|
|
44
|
-
return true;
|
|
45
|
-
}
|
|
46
|
-
|
|
41
|
+
isMasterToken(token: string): boolean {
|
|
42
|
+
if (!this.ipcToken) return true;
|
|
47
43
|
const expected = Buffer.from(this.ipcToken);
|
|
48
|
-
const actual = Buffer.from(token);
|
|
44
|
+
const actual = Buffer.from(token || '');
|
|
45
|
+
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
|
46
|
+
}
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
validateIpcToken(token: string): boolean {
|
|
49
|
+
// Fix Sev1: Use timing-safe comparison for sensitive tokens
|
|
50
|
+
if (this.isMasterToken(token)) {
|
|
51
51
|
return true;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -44,33 +44,36 @@ export class IsolateExecutor implements Executor {
|
|
|
44
44
|
|
|
45
45
|
let currentLogBytes = 0;
|
|
46
46
|
let currentErrorBytes = 0;
|
|
47
|
+
let totalLogEntries = 0;
|
|
47
48
|
|
|
48
|
-
// Inject console.log/error for output capture
|
|
49
49
|
// Inject console.log/error for output capture
|
|
50
50
|
await jail.set('__log', new ivm.Callback((msg: string) => {
|
|
51
|
+
if (totalLogEntries + 1 > limits.maxLogEntries) {
|
|
52
|
+
throw new Error('[LIMIT_LOG_ENTRIES]');
|
|
53
|
+
}
|
|
51
54
|
if (currentLogBytes + msg.length + 1 > limits.maxOutputBytes) {
|
|
52
|
-
// Check log entry count limit? We don't track count here yet effectively, but bytes is safer.
|
|
53
|
-
// The interface says maxOutputBytes applies to total output.
|
|
54
55
|
throw new Error('[LIMIT_LOG]');
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
|
|
58
|
+
totalLogEntries++;
|
|
59
|
+
logs.push(msg);
|
|
60
|
+
currentLogBytes += msg.length + 1; // +1 for newline approximation
|
|
60
61
|
}));
|
|
61
62
|
await jail.set('__error', new ivm.Callback((msg: string) => {
|
|
63
|
+
if (totalLogEntries + 1 > limits.maxLogEntries) {
|
|
64
|
+
throw new Error('[LIMIT_LOG_ENTRIES]');
|
|
65
|
+
}
|
|
62
66
|
if (currentErrorBytes + msg.length + 1 > limits.maxOutputBytes) {
|
|
63
67
|
throw new Error('[LIMIT_OUTPUT]');
|
|
64
68
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
|
|
70
|
+
totalLogEntries++;
|
|
71
|
+
errors.push(msg);
|
|
72
|
+
currentErrorBytes += msg.length + 1;
|
|
69
73
|
}));
|
|
70
74
|
|
|
71
75
|
// Async tool bridge (ID-based to avoid Promise transfer issues)
|
|
72
76
|
let requestIdCounter = 0;
|
|
73
|
-
const pendingToolCalls = new Map<number, Promise<any>>(); // Not used by Host, but Host initiates work
|
|
74
77
|
|
|
75
78
|
await jail.set('__dispatchToolCall', new ivm.Callback((nameStr: string, argsStr: string) => {
|
|
76
79
|
const requestId = ++requestIdCounter;
|
|
@@ -244,6 +247,30 @@ export class IsolateExecutor implements Executor {
|
|
|
244
247
|
};
|
|
245
248
|
}
|
|
246
249
|
|
|
250
|
+
if (message.includes('[LIMIT_LOG_ENTRIES]')) {
|
|
251
|
+
return {
|
|
252
|
+
stdout: logs.join('\n'),
|
|
253
|
+
stderr: errors.join('\n'),
|
|
254
|
+
exitCode: null,
|
|
255
|
+
error: {
|
|
256
|
+
code: ConduitError.LogLimitExceeded,
|
|
257
|
+
message: 'Log entry limit exceeded',
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (message.includes('[LIMIT_LOG]') || message.includes('[LIMIT_OUTPUT]')) {
|
|
263
|
+
return {
|
|
264
|
+
stdout: logs.join('\n'),
|
|
265
|
+
stderr: errors.join('\n'),
|
|
266
|
+
exitCode: null,
|
|
267
|
+
error: {
|
|
268
|
+
code: ConduitError.OutputLimitExceeded,
|
|
269
|
+
message: 'Output limit exceeded',
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
247
274
|
this.logger.error({ err }, 'Isolate execution failed');
|
|
248
275
|
return {
|
|
249
276
|
stdout: logs.join('\n'),
|
|
@@ -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;
|
|
@@ -144,15 +144,23 @@ export class GatewayService {
|
|
|
144
144
|
|
|
145
145
|
let tools = this.schemaCache.get(packageId);
|
|
146
146
|
|
|
147
|
-
//
|
|
147
|
+
// Discover tools if not cached
|
|
148
148
|
if (!tools) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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[];
|
|
155
|
+
}
|
|
156
|
+
} catch (e: any) {
|
|
157
|
+
this.logger.debug({ upstreamId: packageId, err: e.message }, 'Manifest fetch failed (will fallback)');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 2) Fall back to RPC discovery
|
|
162
|
+
if (!tools) {
|
|
163
|
+
try {
|
|
156
164
|
if (typeof (client as any).listTools === 'function') {
|
|
157
165
|
tools = await (client as any).listTools();
|
|
158
166
|
} else {
|
|
@@ -168,14 +176,14 @@ export class GatewayService {
|
|
|
168
176
|
this.logger.warn({ upstreamId: packageId, error: response.error }, 'Failed to discover tools via RPC');
|
|
169
177
|
}
|
|
170
178
|
}
|
|
179
|
+
} catch (e: any) {
|
|
180
|
+
this.logger.error({ upstreamId: packageId, err: e.message }, 'Error during tool discovery');
|
|
171
181
|
}
|
|
182
|
+
}
|
|
172
183
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
} catch (e: any) {
|
|
178
|
-
this.logger.error({ upstreamId: packageId, err: e.message }, 'Error during tool discovery');
|
|
184
|
+
if (tools && tools.length > 0) {
|
|
185
|
+
this.schemaCache.set(packageId, tools);
|
|
186
|
+
this.logger.info({ upstreamId: packageId, toolCount: tools.length }, 'Discovered tools from upstream');
|
|
179
187
|
}
|
|
180
188
|
}
|
|
181
189
|
|
|
@@ -7,13 +7,20 @@ import { IUrlValidator } from '../core/interfaces/url.validator.interface.js';
|
|
|
7
7
|
|
|
8
8
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
9
9
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
10
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
11
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
10
12
|
import { z } from 'zod';
|
|
13
|
+
import dns from 'node:dns';
|
|
14
|
+
import net from 'node:net';
|
|
15
|
+
import { Agent } from 'undici';
|
|
11
16
|
|
|
12
17
|
export type UpstreamInfo = {
|
|
13
18
|
id: string;
|
|
14
19
|
credentials?: UpstreamCredentials;
|
|
15
20
|
} & (
|
|
16
21
|
| { type?: 'http'; url: string }
|
|
22
|
+
| { type: 'streamableHttp'; url: string }
|
|
23
|
+
| { type: 'sse'; url: string }
|
|
17
24
|
| { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
18
25
|
);
|
|
19
26
|
|
|
@@ -23,9 +30,13 @@ export class UpstreamClient {
|
|
|
23
30
|
private authService: AuthService;
|
|
24
31
|
private urlValidator: IUrlValidator;
|
|
25
32
|
private mcpClient?: Client;
|
|
26
|
-
private transport?: StdioClientTransport;
|
|
33
|
+
private transport?: StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport;
|
|
27
34
|
private connected: boolean = false;
|
|
28
35
|
|
|
36
|
+
// Pinned-IP dispatchers per upstream origin (defends against DNS rebinding)
|
|
37
|
+
private dispatcherCache = new Map<string, { resolvedIp: string; agent: Agent }>();
|
|
38
|
+
private pinned?: { origin: string; hostname: string; resolvedIp?: string };
|
|
39
|
+
|
|
29
40
|
constructor(logger: Logger, info: UpstreamInfo, authService: AuthService, urlValidator: IUrlValidator) {
|
|
30
41
|
this.logger = logger.child({ upstreamId: info.id });
|
|
31
42
|
this.info = info;
|
|
@@ -51,13 +62,153 @@ export class UpstreamClient {
|
|
|
51
62
|
}, {
|
|
52
63
|
capabilities: {},
|
|
53
64
|
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this.info.type === 'streamableHttp') {
|
|
69
|
+
const upstreamUrl = new URL(this.info.url);
|
|
70
|
+
this.pinned = { origin: upstreamUrl.origin, hostname: upstreamUrl.hostname };
|
|
71
|
+
|
|
72
|
+
this.transport = new StreamableHTTPClientTransport(upstreamUrl, {
|
|
73
|
+
fetch: this.createAuthedFetch(),
|
|
74
|
+
});
|
|
75
|
+
this.mcpClient = new Client({
|
|
76
|
+
name: 'conduit-gateway',
|
|
77
|
+
version: '1.0.0',
|
|
78
|
+
}, {
|
|
79
|
+
capabilities: {},
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (this.info.type === 'sse') {
|
|
85
|
+
const upstreamUrl = new URL(this.info.url);
|
|
86
|
+
this.pinned = { origin: upstreamUrl.origin, hostname: upstreamUrl.hostname };
|
|
87
|
+
|
|
88
|
+
this.mcpClient = new Client({
|
|
89
|
+
name: 'conduit-gateway',
|
|
90
|
+
version: '1.0.0',
|
|
91
|
+
}, {
|
|
92
|
+
capabilities: {},
|
|
93
|
+
});
|
|
54
94
|
}
|
|
55
95
|
}
|
|
56
96
|
|
|
57
|
-
private
|
|
58
|
-
|
|
97
|
+
private getDispatcher(origin: string, hostname: string, resolvedIp: string): Agent {
|
|
98
|
+
const existing = this.dispatcherCache.get(origin);
|
|
99
|
+
if (existing && existing.resolvedIp === resolvedIp) {
|
|
100
|
+
return existing.agent;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (existing) {
|
|
104
|
+
try {
|
|
105
|
+
existing.agent.close();
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const agent = new Agent({
|
|
112
|
+
connect: {
|
|
113
|
+
lookup: (lookupHostname: string, options: any, callback: any) => {
|
|
114
|
+
if (lookupHostname === hostname) {
|
|
115
|
+
callback(null, resolvedIp, net.isIP(resolvedIp));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
dns.lookup(lookupHostname, options, callback);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
this.dispatcherCache.set(origin, { resolvedIp, agent });
|
|
124
|
+
return agent;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private createAuthedFetch() {
|
|
128
|
+
const creds = this.info.credentials;
|
|
129
|
+
const pinned = this.pinned;
|
|
130
|
+
|
|
131
|
+
// Fall back to global fetch
|
|
132
|
+
const baseFetch = fetch;
|
|
133
|
+
|
|
134
|
+
return async (input: any, init: any = {}) => {
|
|
135
|
+
const requestUrlStr = (() => {
|
|
136
|
+
if (typeof input === 'string') return input;
|
|
137
|
+
if (input instanceof URL) return input.toString();
|
|
138
|
+
if (input instanceof Request) return input.url;
|
|
139
|
+
return String(input);
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
const requestUrl = pinned
|
|
143
|
+
? new URL(requestUrlStr, pinned.origin)
|
|
144
|
+
: new URL(requestUrlStr);
|
|
145
|
+
|
|
146
|
+
// Hard safety boundary: never allow fetch to escape upstream origin
|
|
147
|
+
if (pinned && requestUrl.origin !== pinned.origin) {
|
|
148
|
+
throw new Error(`Forbidden upstream redirect/origin: ${requestUrl.origin}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate and (optionally) pin resolved IP for DNS-rebinding defense
|
|
152
|
+
if (pinned && !pinned.resolvedIp) {
|
|
153
|
+
const securityResult = await this.urlValidator.validateUrl(pinned.origin);
|
|
154
|
+
if (!securityResult.valid) {
|
|
155
|
+
throw new Error(securityResult.message || 'Forbidden URL');
|
|
156
|
+
}
|
|
157
|
+
pinned.resolvedIp = securityResult.resolvedIp;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const headers = new Headers((input instanceof Request ? input.headers : undefined) || undefined);
|
|
161
|
+
const initHeaders = new Headers(init.headers || {});
|
|
162
|
+
for (const [k, v] of initHeaders.entries()) headers.set(k, v);
|
|
163
|
+
|
|
164
|
+
if (creds) {
|
|
165
|
+
const authHeaders = await this.authService.getAuthHeaders(creds);
|
|
166
|
+
for (const [k, v] of Object.entries(authHeaders)) {
|
|
167
|
+
headers.set(k, v);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const request = input instanceof Request
|
|
172
|
+
? new Request(input, { ...init, headers, redirect: init.redirect ?? 'manual' })
|
|
173
|
+
: new Request(requestUrl.toString(), { ...init, headers, redirect: init.redirect ?? 'manual' });
|
|
174
|
+
|
|
175
|
+
const dispatcher = (pinned && pinned.resolvedIp)
|
|
176
|
+
? this.getDispatcher(pinned.origin, pinned.hostname, pinned.resolvedIp)
|
|
177
|
+
: undefined;
|
|
178
|
+
|
|
179
|
+
return baseFetch(request, dispatcher ? { dispatcher } : undefined);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async ensureConnected() {
|
|
184
|
+
if (!this.mcpClient) return;
|
|
185
|
+
|
|
186
|
+
if (!this.transport && this.info.type === 'sse') {
|
|
187
|
+
const authHeaders = this.info.credentials
|
|
188
|
+
? await this.authService.getAuthHeaders(this.info.credentials)
|
|
189
|
+
: {};
|
|
190
|
+
|
|
191
|
+
this.transport = new SSEClientTransport(new URL(this.info.url), {
|
|
192
|
+
fetch: this.createAuthedFetch(),
|
|
193
|
+
eventSourceInit: { headers: authHeaders } as any,
|
|
194
|
+
requestInit: { headers: authHeaders },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!this.transport) return;
|
|
59
199
|
if (this.connected) return;
|
|
60
200
|
|
|
201
|
+
if (this.info.type === 'streamableHttp' || this.info.type === 'sse') {
|
|
202
|
+
const securityResult = await this.urlValidator.validateUrl(this.info.url);
|
|
203
|
+
if (!securityResult.valid) {
|
|
204
|
+
this.logger.error({ url: this.info.url }, 'Blocked upstream URL (SSRF)');
|
|
205
|
+
throw new Error(securityResult.message || 'Forbidden URL');
|
|
206
|
+
}
|
|
207
|
+
if (this.pinned) {
|
|
208
|
+
this.pinned.resolvedIp = securityResult.resolvedIp;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
61
212
|
try {
|
|
62
213
|
this.logger.debug('Connecting to upstream transport...');
|
|
63
214
|
await this.mcpClient.connect(this.transport);
|
|
@@ -70,19 +221,23 @@ export class UpstreamClient {
|
|
|
70
221
|
}
|
|
71
222
|
|
|
72
223
|
async call(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
73
|
-
|
|
74
|
-
|
|
224
|
+
const usesMcpClientTransport = (info: UpstreamInfo): info is (
|
|
225
|
+
| { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
226
|
+
| { type: 'streamableHttp'; url: string }
|
|
227
|
+
| { type: 'sse'; url: string }
|
|
228
|
+
) & { id: string; credentials?: UpstreamCredentials } =>
|
|
229
|
+
info.type === 'stdio' || info.type === 'streamableHttp' || info.type === 'sse';
|
|
75
230
|
|
|
76
|
-
if (
|
|
77
|
-
return this.
|
|
78
|
-
} else {
|
|
79
|
-
return this.callHttp(request, context as ExecutionContext);
|
|
231
|
+
if (usesMcpClientTransport(this.info)) {
|
|
232
|
+
return this.callMcpClient(request);
|
|
80
233
|
}
|
|
234
|
+
|
|
235
|
+
return this.callHttp(request, context as ExecutionContext);
|
|
81
236
|
}
|
|
82
237
|
|
|
83
|
-
private async
|
|
238
|
+
private async callMcpClient(request: JSONRPCRequest): Promise<JSONRPCResponse> {
|
|
84
239
|
if (!this.mcpClient) {
|
|
85
|
-
return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: '
|
|
240
|
+
return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'MCP client not initialized' } };
|
|
86
241
|
}
|
|
87
242
|
|
|
88
243
|
try {
|
|
@@ -128,13 +283,13 @@ export class UpstreamClient {
|
|
|
128
283
|
};
|
|
129
284
|
}
|
|
130
285
|
} catch (error: any) {
|
|
131
|
-
this.logger.error({ err: error }, '
|
|
286
|
+
this.logger.error({ err: error }, 'MCP call failed');
|
|
132
287
|
return {
|
|
133
288
|
jsonrpc: '2.0',
|
|
134
289
|
id: request.id,
|
|
135
290
|
error: {
|
|
136
291
|
code: error.code || -32603,
|
|
137
|
-
message: error.message || 'Internal error in
|
|
292
|
+
message: error.message || 'Internal error in MCP transport'
|
|
138
293
|
}
|
|
139
294
|
};
|
|
140
295
|
}
|
|
@@ -142,7 +297,9 @@ export class UpstreamClient {
|
|
|
142
297
|
|
|
143
298
|
private async callHttp(request: JSONRPCRequest, context: ExecutionContext): Promise<JSONRPCResponse> {
|
|
144
299
|
// Narrowing for TS
|
|
145
|
-
if (this.info.type === 'stdio'
|
|
300
|
+
if (this.info.type === 'stdio' || this.info.type === 'streamableHttp' || this.info.type === 'sse') {
|
|
301
|
+
throw new Error('Unreachable');
|
|
302
|
+
}
|
|
146
303
|
const url = this.info.url;
|
|
147
304
|
|
|
148
305
|
const headers: Record<string, string> = {
|
|
@@ -204,7 +361,7 @@ export class UpstreamClient {
|
|
|
204
361
|
}
|
|
205
362
|
}
|
|
206
363
|
async getManifest(context: ExecutionContext): Promise<ToolManifest | null> {
|
|
207
|
-
if (this.info.type !== 'http') return null;
|
|
364
|
+
if (this.info.type && this.info.type !== 'http') return null;
|
|
208
365
|
|
|
209
366
|
try {
|
|
210
367
|
const baseUrl = this.info.url.replace(/\/$/, ''); // Remove trailing slash
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { ConfigService } from './core/config.service.js';
|
|
4
5
|
import { createLogger, loggerStorage } from './core/logger.js';
|
|
5
6
|
import { SocketTransport } from './transport/socket.transport.js';
|
|
@@ -20,10 +21,13 @@ import { handleAuth } from './auth.cmd.js';
|
|
|
20
21
|
|
|
21
22
|
const program = new Command();
|
|
22
23
|
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const pkg = require('../package.json') as { version?: string };
|
|
26
|
+
|
|
23
27
|
program
|
|
24
28
|
.name('conduit')
|
|
25
29
|
.description('A secure Code Mode execution substrate for MCP agents')
|
|
26
|
-
.version('
|
|
30
|
+
.version(pkg.version || '0.0.0');
|
|
27
31
|
|
|
28
32
|
program
|
|
29
33
|
.command('serve', { isDefault: true })
|