@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhingston5/conduit",
3
- "version": "1.1.6",
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 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
 
@@ -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 = !masterToken || providedToken === masterToken;
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
- validateIpcToken(token: string): boolean {
42
- // Fix Sev1: Use timing-safe comparison for sensitive tokens
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
- if (expected.length === actual.length && crypto.timingSafeEqual(expected, actual)) {
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
- if (currentLogBytes < limits.maxOutputBytes) {
57
- logs.push(msg);
58
- currentLogBytes += msg.length + 1; // +1 for newline approximation
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
- if (currentErrorBytes < limits.maxOutputBytes) {
66
- errors.push(msg);
67
- currentErrorBytes += msg.length + 1;
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 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;
@@ -144,15 +144,23 @@ export class GatewayService {
144
144
 
145
145
  let tools = this.schemaCache.get(packageId);
146
146
 
147
- // Try manifest first if tools not cached
147
+ // Discover tools if not cached
148
148
  if (!tools) {
149
- try {
150
- // Try to get manifest FIRST
151
- const manifest = await client.getManifest(context);
152
- if (manifest && manifest.tools) {
153
- tools = manifest.tools as ToolSchema[];
154
- } else {
155
- // Fall back to RPC discovery
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
- if (tools && tools.length > 0) {
174
- this.schemaCache.set(packageId, tools);
175
- this.logger.info({ upstreamId: packageId, toolCount: tools.length }, 'Discovered tools from upstream');
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 async ensureConnected() {
58
- if (!this.mcpClient || !this.transport) return;
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
- // Helper to determine type safely
74
- const isStdio = (info: UpstreamInfo): info is { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string>; id: string; credentials?: UpstreamCredentials } => info.type === 'stdio';
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 (isStdio(this.info)) {
77
- return this.callStdio(request);
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 callStdio(request: JSONRPCRequest): Promise<JSONRPCResponse> {
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: 'Stdio client not initialized' } };
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 }, 'Stdio call failed');
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 stdio transport'
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') throw new Error('Unreachable');
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('1.0.0');
30
+ .version(pkg.version || '0.0.0');
27
31
 
28
32
  program
29
33
  .command('serve', { isDefault: true })