@mhingston5/conduit 1.1.1 → 1.1.3
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 -5
- package/dist/assets/deno-shim.ts +93 -0
- package/dist/assets/python-shim.py +21 -0
- package/dist/executors/pyodide.worker.d.ts +2 -0
- package/dist/executors/pyodide.worker.js +163 -0
- package/dist/executors/pyodide.worker.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3250 -0
- package/dist/index.js.map +1 -0
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CODE_MODE.md +1 -1
- package/docs/SECURITY.md +1 -1
- package/package.json +6 -1
- package/src/assets/deno-shim.ts +2 -2
- package/src/auth.cmd.ts +95 -0
- package/src/core/config.service.ts +5 -2
- package/src/core/middleware/auth.middleware.ts +1 -1
- package/src/core/request.controller.ts +25 -9
- package/src/core/security.service.ts +3 -3
- package/src/executors/pyodide.worker.ts +2 -2
- package/src/gateway/auth.service.ts +19 -18
- package/src/gateway/gateway.service.ts +3 -3
- package/src/index.ts +52 -8
- package/src/transport/socket.transport.ts +8 -3
- package/tests/auth.service.test.ts +8 -13
- package/tests/config.service.test.ts +31 -0
- package/tests/contract.test.ts +2 -2
- package/tests/dynamic.tool.test.ts +5 -5
- package/tests/e2e_stdio_upstream.test.ts +3 -3
- package/tests/gateway.service.test.ts +5 -5
- package/tests/hardening.test.ts +3 -3
- package/tests/routing.test.ts +24 -4
- package/tests/socket.transport.test.ts +3 -3
- package/tests/vscode_e2e.test.ts +1 -1
package/docs/CODE_MODE.md
CHANGED
|
@@ -28,6 +28,6 @@ Because logic executes in a sandbox, you can enforce limits on loops, memory, an
|
|
|
28
28
|
## Implementation in Conduit
|
|
29
29
|
|
|
30
30
|
Conduit provides:
|
|
31
|
-
1. **`
|
|
31
|
+
1. **`mcp_execute_typescript` / `mcp_execute_python`**: The entry points.
|
|
32
32
|
2. **`tools.*` SDK**: A dynamically generated client injected into the runtime.
|
|
33
33
|
3. **Sandboxes**: Deno, Pyodide, and isolated-vm to run the code safely.
|
package/docs/SECURITY.md
CHANGED
|
@@ -27,7 +27,7 @@ Conduit enforces strict Server-Side Request Forgery (SSRF) protections on upstre
|
|
|
27
27
|
## Authorization
|
|
28
28
|
|
|
29
29
|
- **Master Token**: Full access to all methods (set via `IPC_BEARER_TOKEN`).
|
|
30
|
-
- **Session Tokens**: Generated per-execution, restricted to `
|
|
30
|
+
- **Session Tokens**: Generated per-execution, restricted to `mcp_discover_tools` and `mcp_call_tool` only.
|
|
31
31
|
- **Tool Allowlisting**: Per-request scope limits which tools code can discover/call (e.g., `["github.*"]`).
|
|
32
32
|
|
|
33
33
|
## Runtime Security
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mhingston5/conduit",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A secure Code Mode execution substrate for MCP agents",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"conduit": "./dist/index.js"
|
|
9
|
+
},
|
|
7
10
|
"publishConfig": {
|
|
8
11
|
"access": "public"
|
|
9
12
|
},
|
|
@@ -47,11 +50,13 @@
|
|
|
47
50
|
"ajv": "^8.17.1",
|
|
48
51
|
"ajv-formats": "^3.0.1",
|
|
49
52
|
"axios": "^1.13.2",
|
|
53
|
+
"commander": "^14.0.2",
|
|
50
54
|
"dotenv": "^17.2.3",
|
|
51
55
|
"fastify": "^5.6.2",
|
|
52
56
|
"isolated-vm": "^6.0.2",
|
|
53
57
|
"js-yaml": "^4.1.1",
|
|
54
58
|
"lru-cache": "^11.2.4",
|
|
59
|
+
"open": "^11.0.0",
|
|
55
60
|
"p-limit": "^7.2.0",
|
|
56
61
|
"pino": "^10.1.0",
|
|
57
62
|
"pyodide": "^0.29.0",
|
package/src/assets/deno-shim.ts
CHANGED
|
@@ -81,12 +81,12 @@ async function sendIPCRequest(method: string, params: any) {
|
|
|
81
81
|
|
|
82
82
|
// Internal tool call function - used by generated SDK
|
|
83
83
|
const __internalCallTool = async (name: string, params: any) => {
|
|
84
|
-
return await sendIPCRequest('
|
|
84
|
+
return await sendIPCRequest('mcp_call_tool', { name, arguments: params });
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
// Tool discovery - still available for dynamic scenarios
|
|
88
88
|
(globalThis as any).discoverMCPTools = async (options: any) => {
|
|
89
|
-
const result = await sendIPCRequest('
|
|
89
|
+
const result = await sendIPCRequest('mcp_discover_tools', options);
|
|
90
90
|
return result.tools || [];
|
|
91
91
|
};
|
|
92
92
|
|
package/src/auth.cmd.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
|
|
6
|
+
export interface AuthOptions {
|
|
7
|
+
authUrl: string;
|
|
8
|
+
tokenUrl: string;
|
|
9
|
+
clientId: string;
|
|
10
|
+
clientSecret: string;
|
|
11
|
+
scopes?: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function handleAuth(options: AuthOptions) {
|
|
16
|
+
const port = options.port || 3333;
|
|
17
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
18
|
+
const state = uuidv4();
|
|
19
|
+
|
|
20
|
+
const fastify = Fastify();
|
|
21
|
+
|
|
22
|
+
return new Promise<void>((resolve, reject) => {
|
|
23
|
+
fastify.get('/callback', async (request, reply) => {
|
|
24
|
+
const { code, state: returnedState, error, error_description } = request.query as any;
|
|
25
|
+
|
|
26
|
+
if (error) {
|
|
27
|
+
reply.send(`Authentication failed: ${error} - ${error_description}`);
|
|
28
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (returnedState !== state) {
|
|
33
|
+
reply.send('Invalid state parameter');
|
|
34
|
+
reject(new Error('State mismatch'));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const response = await axios.post(options.tokenUrl, {
|
|
40
|
+
grant_type: 'authorization_code',
|
|
41
|
+
code,
|
|
42
|
+
redirect_uri: redirectUri,
|
|
43
|
+
client_id: options.clientId,
|
|
44
|
+
client_secret: options.clientSecret,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const { refresh_token, access_token } = response.data;
|
|
48
|
+
|
|
49
|
+
console.log('\n--- Authentication Successful ---\n');
|
|
50
|
+
console.log('Use these values in your conduit.yaml:\n');
|
|
51
|
+
console.log('credentials:');
|
|
52
|
+
console.log(' type: oauth2');
|
|
53
|
+
console.log(` clientId: ${options.clientId}`);
|
|
54
|
+
console.log(` clientSecret: ${options.clientSecret}`);
|
|
55
|
+
console.log(` tokenUrl: "${options.tokenUrl}"`);
|
|
56
|
+
console.log(` refreshToken: "${refresh_token || 'N/A (No refresh token returned)'}"`);
|
|
57
|
+
|
|
58
|
+
if (!refresh_token) {
|
|
59
|
+
console.log('\nWarning: No refresh token was returned. Ensure your app has "offline_access" scope or similar.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log('\nRaw response data:', JSON.stringify(response.data, null, 2));
|
|
63
|
+
|
|
64
|
+
reply.send('Authentication successful! You can close this window and return to the terminal.');
|
|
65
|
+
resolve();
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
const msg = err.response?.data?.error_description || err.response?.data?.error || err.message;
|
|
68
|
+
reply.send(`Failed to exchange code for token: ${msg}`);
|
|
69
|
+
reject(new Error(`Token exchange failed: ${msg}`));
|
|
70
|
+
} finally {
|
|
71
|
+
setTimeout(() => fastify.close(), 1000);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
fastify.listen({ port: port, host: '127.0.0.1' }, async (err) => {
|
|
76
|
+
if (err) {
|
|
77
|
+
reject(err);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const authUrl = new URL(options.authUrl);
|
|
82
|
+
authUrl.searchParams.append('client_id', options.clientId);
|
|
83
|
+
authUrl.searchParams.append('redirect_uri', redirectUri);
|
|
84
|
+
authUrl.searchParams.append('response_type', 'code');
|
|
85
|
+
authUrl.searchParams.append('state', state);
|
|
86
|
+
if (options.scopes) {
|
|
87
|
+
authUrl.searchParams.append('scope', options.scopes);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(`Opening browser to: ${authUrl.toString()}`);
|
|
91
|
+
console.log('Waiting for callback...');
|
|
92
|
+
await open(authUrl.toString());
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import dotenv from 'dotenv';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import yaml from 'js-yaml';
|
|
6
7
|
|
|
7
8
|
// Silence dotenv logging
|
|
@@ -22,12 +23,14 @@ export const ResourceLimitsSchema = z.object({
|
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
export const UpstreamCredentialsSchema = z.object({
|
|
25
|
-
type: z.enum(['oauth2', '
|
|
26
|
+
type: z.enum(['oauth2', 'apiKey', 'bearer']), // Align with AuthType
|
|
26
27
|
clientId: z.string().optional(),
|
|
27
28
|
clientSecret: z.string().optional(),
|
|
28
29
|
tokenUrl: z.string().optional(),
|
|
30
|
+
refreshToken: z.string().optional(),
|
|
29
31
|
scopes: z.array(z.string()).optional(),
|
|
30
32
|
apiKey: z.string().optional(),
|
|
33
|
+
bearerToken: z.string().optional(),
|
|
31
34
|
headerName: z.string().optional(),
|
|
32
35
|
});
|
|
33
36
|
|
|
@@ -63,7 +66,7 @@ export const ConfigSchema = z.object({
|
|
|
63
66
|
secretRedactionPatterns: z.array(z.string()).default([
|
|
64
67
|
'[A-Za-z0-9-_]{20,}', // Default pattern from spec
|
|
65
68
|
]),
|
|
66
|
-
ipcBearerToken: z.string().optional().default(() =>
|
|
69
|
+
ipcBearerToken: z.string().optional().default(() => crypto.randomUUID()),
|
|
67
70
|
maxConcurrent: z.number().default(10),
|
|
68
71
|
denoMaxPoolSize: z.number().default(10),
|
|
69
72
|
pyodideMaxPoolSize: z.number().default(3),
|
|
@@ -31,7 +31,7 @@ export class AuthMiddleware implements Middleware {
|
|
|
31
31
|
|
|
32
32
|
// Strict scoping for session tokens
|
|
33
33
|
if (isSession) {
|
|
34
|
-
const allowedMethods = ['initialize', 'notifications/initialized', '
|
|
34
|
+
const allowedMethods = ['initialize', 'notifications/initialized', 'mcp_discover_tools', 'mcp_call_tool', 'ping', 'tools/list', 'tools/call'];
|
|
35
35
|
if (!allowedMethods.includes(request.method)) {
|
|
36
36
|
return {
|
|
37
37
|
jsonrpc: '2.0',
|
|
@@ -102,23 +102,24 @@ export class RequestController {
|
|
|
102
102
|
|
|
103
103
|
switch (method) {
|
|
104
104
|
case 'tools/list': // Standard MCP method name
|
|
105
|
-
case '
|
|
105
|
+
case 'mcp_discover_tools':
|
|
106
106
|
return this.handleDiscoverTools(params, context, id);
|
|
107
|
-
case '
|
|
107
|
+
case 'mcp_list_tool_packages':
|
|
108
108
|
return this.handleListToolPackages(params, context, id);
|
|
109
|
-
case '
|
|
109
|
+
case 'mcp_list_tool_stubs':
|
|
110
110
|
return this.handleListToolStubs(params, context, id);
|
|
111
|
-
case '
|
|
111
|
+
case 'mcp_read_tool_schema':
|
|
112
112
|
return this.handleReadToolSchema(params, context, id);
|
|
113
|
-
case '
|
|
113
|
+
case 'mcp_validate_tool':
|
|
114
114
|
return this.handleValidateTool(request, context);
|
|
115
|
-
case '
|
|
115
|
+
case 'mcp_call_tool':
|
|
116
|
+
case 'tools/call':
|
|
116
117
|
return this.handleCallTool(params, context, id);
|
|
117
|
-
case '
|
|
118
|
+
case 'mcp_execute_typescript':
|
|
118
119
|
return this.handleExecuteTypeScript(params, context, id);
|
|
119
|
-
case '
|
|
120
|
+
case 'mcp_execute_python':
|
|
120
121
|
return this.handleExecutePython(params, context, id);
|
|
121
|
-
case '
|
|
122
|
+
case 'mcp_execute_isolate':
|
|
122
123
|
return this.handleExecuteIsolate(params, context, id);
|
|
123
124
|
case 'initialize':
|
|
124
125
|
return this.handleInitialize(params, context, id);
|
|
@@ -208,12 +209,25 @@ export class RequestController {
|
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
private async handleCallTool(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
212
|
+
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
211
213
|
const { name, arguments: toolArgs } = params;
|
|
214
|
+
|
|
215
|
+
// Route built-in tools to their specific handlers
|
|
216
|
+
switch (name) {
|
|
217
|
+
case 'mcp_execute_typescript':
|
|
218
|
+
return this.handleExecuteTypeScript(toolArgs, context, id);
|
|
219
|
+
case 'mcp_execute_python':
|
|
220
|
+
return this.handleExecutePython(toolArgs, context, id);
|
|
221
|
+
case 'mcp_execute_isolate':
|
|
222
|
+
return this.handleExecuteIsolate(toolArgs, context, id);
|
|
223
|
+
}
|
|
224
|
+
|
|
212
225
|
const response = await this.gatewayService.callTool(name, toolArgs, context);
|
|
213
226
|
return { ...response, id };
|
|
214
227
|
}
|
|
215
228
|
|
|
216
229
|
private async handleExecuteTypeScript(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
230
|
+
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
217
231
|
const { code, limits, allowedTools } = params;
|
|
218
232
|
|
|
219
233
|
if (Array.isArray(allowedTools)) {
|
|
@@ -238,6 +252,7 @@ export class RequestController {
|
|
|
238
252
|
}
|
|
239
253
|
|
|
240
254
|
private async handleExecutePython(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
255
|
+
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
241
256
|
const { code, limits, allowedTools } = params;
|
|
242
257
|
|
|
243
258
|
if (Array.isArray(allowedTools)) {
|
|
@@ -287,6 +302,7 @@ export class RequestController {
|
|
|
287
302
|
}
|
|
288
303
|
|
|
289
304
|
private async handleExecuteIsolate(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
305
|
+
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
290
306
|
const { code, limits, allowedTools } = params;
|
|
291
307
|
|
|
292
308
|
if (Array.isArray(allowedTools)) {
|
|
@@ -9,11 +9,11 @@ export type { Session };
|
|
|
9
9
|
|
|
10
10
|
export class SecurityService implements IUrlValidator {
|
|
11
11
|
private logger: Logger;
|
|
12
|
-
private ipcToken: string;
|
|
12
|
+
private ipcToken: string | undefined;
|
|
13
13
|
private networkPolicy: NetworkPolicyService;
|
|
14
14
|
private sessionManager: SessionManager;
|
|
15
15
|
|
|
16
|
-
constructor(logger: Logger, ipcToken: string) {
|
|
16
|
+
constructor(logger: Logger, ipcToken: string | undefined) {
|
|
17
17
|
this.logger = logger;
|
|
18
18
|
this.ipcToken = ipcToken;
|
|
19
19
|
this.networkPolicy = new NetworkPolicyService(logger);
|
|
@@ -67,7 +67,7 @@ export class SecurityService implements IUrlValidator {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
getIpcToken(): string {
|
|
70
|
+
getIpcToken(): string | undefined {
|
|
71
71
|
return this.ipcToken;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -131,11 +131,11 @@ async function handleTask(data: any) {
|
|
|
131
131
|
};
|
|
132
132
|
|
|
133
133
|
(p as any).globals.set('discover_mcp_tools_js', (options: any) => {
|
|
134
|
-
return sendIPCRequest('
|
|
134
|
+
return sendIPCRequest('mcp_discover_tools', options);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
(p as any).globals.set('call_mcp_tool_js', (name: string, args: any) => {
|
|
138
|
-
return sendIPCRequest('
|
|
138
|
+
return sendIPCRequest('mcp_call_tool', { name, arguments: args });
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
if (shim) {
|
|
@@ -7,12 +7,10 @@ export interface UpstreamCredentials {
|
|
|
7
7
|
type: AuthType;
|
|
8
8
|
apiKey?: string;
|
|
9
9
|
bearerToken?: string;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
refreshToken: string;
|
|
15
|
-
};
|
|
10
|
+
clientId?: string;
|
|
11
|
+
clientSecret?: string;
|
|
12
|
+
tokenUrl?: string;
|
|
13
|
+
refreshToken?: string;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
16
|
interface CachedToken {
|
|
@@ -45,10 +43,11 @@ export class AuthService {
|
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
private async getOAuth2Token(creds: UpstreamCredentials): Promise<string> {
|
|
48
|
-
if (!creds.
|
|
46
|
+
if (!creds.tokenUrl || !creds.clientId) {
|
|
47
|
+
throw new Error('OAuth2 credentials missing required fields (tokenUrl, clientId)');
|
|
48
|
+
}
|
|
49
49
|
|
|
50
|
-
const
|
|
51
|
-
const cacheKey = `${oauth2.clientId}:${oauth2.tokenUrl}`;
|
|
50
|
+
const cacheKey = `${creds.clientId}:${creds.tokenUrl}`;
|
|
52
51
|
|
|
53
52
|
// Check cache first (with 30s buffer)
|
|
54
53
|
const cached = this.tokenCache.get(cacheKey);
|
|
@@ -74,17 +73,18 @@ export class AuthService {
|
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
private async doRefresh(creds: UpstreamCredentials, cacheKey: string): Promise<string> {
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
if (!creds.tokenUrl || !creds.refreshToken || !creds.clientId || !creds.clientSecret) {
|
|
77
|
+
throw new Error('OAuth2 credentials missing required fields for refresh');
|
|
78
|
+
}
|
|
79
79
|
|
|
80
|
-
this.logger.info('Refreshing OAuth2 token');
|
|
80
|
+
this.logger.info({ tokenUrl: creds.tokenUrl, clientId: creds.clientId }, 'Refreshing OAuth2 token');
|
|
81
81
|
|
|
82
82
|
try {
|
|
83
|
-
const response = await axios.post(
|
|
83
|
+
const response = await axios.post(creds.tokenUrl, {
|
|
84
84
|
grant_type: 'refresh_token',
|
|
85
|
-
refresh_token:
|
|
86
|
-
client_id:
|
|
87
|
-
client_secret:
|
|
85
|
+
refresh_token: creds.refreshToken,
|
|
86
|
+
client_id: creds.clientId,
|
|
87
|
+
client_secret: creds.clientSecret,
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
const { access_token, expires_in } = response.data;
|
|
@@ -97,8 +97,9 @@ export class AuthService {
|
|
|
97
97
|
|
|
98
98
|
return `Bearer ${access_token}`;
|
|
99
99
|
} catch (err: any) {
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
const errorMsg = err.response?.data?.error_description || err.response?.data?.error || err.message;
|
|
101
|
+
this.logger.error({ err: errorMsg }, 'Failed to refresh OAuth2 token');
|
|
102
|
+
throw new Error(`OAuth2 refresh failed: ${errorMsg}`);
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
}
|
|
@@ -12,7 +12,7 @@ import addFormats from 'ajv-formats';
|
|
|
12
12
|
|
|
13
13
|
const BUILT_IN_TOOLS: ToolSchema[] = [
|
|
14
14
|
{
|
|
15
|
-
name: '
|
|
15
|
+
name: 'mcp_execute_typescript',
|
|
16
16
|
description: 'Executes TypeScript code in a secure sandbox with access to `tools.*` SDK.',
|
|
17
17
|
inputSchema: {
|
|
18
18
|
type: 'object',
|
|
@@ -31,7 +31,7 @@ const BUILT_IN_TOOLS: ToolSchema[] = [
|
|
|
31
31
|
}
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
name: '
|
|
34
|
+
name: 'mcp_execute_python',
|
|
35
35
|
description: 'Executes Python code in a secure sandbox with access to `tools.*` SDK.',
|
|
36
36
|
inputSchema: {
|
|
37
37
|
type: 'object',
|
|
@@ -50,7 +50,7 @@ const BUILT_IN_TOOLS: ToolSchema[] = [
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
|
-
name: '
|
|
53
|
+
name: 'mcp_execute_isolate',
|
|
54
54
|
description: 'Executes JavaScript code in a high-speed V8 isolate (no Deno/Node APIs).',
|
|
55
55
|
inputSchema: {
|
|
56
56
|
type: 'object',
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
1
2
|
import { ConfigService } from './core/config.service.js';
|
|
2
3
|
import { createLogger, loggerStorage } from './core/logger.js';
|
|
3
4
|
import { SocketTransport } from './transport/socket.transport.js';
|
|
@@ -14,10 +15,56 @@ import { IsolateExecutor } from './executors/isolate.executor.js';
|
|
|
14
15
|
import { ExecutorRegistry } from './core/registries/executor.registry.js';
|
|
15
16
|
import { ExecutionService } from './core/execution.service.js';
|
|
16
17
|
import { buildDefaultMiddleware } from './core/middleware/middleware.builder.js';
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
import { handleAuth } from './auth.cmd.js';
|
|
19
|
+
|
|
20
|
+
const program = new Command();
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.name('conduit')
|
|
24
|
+
.description('A secure Code Mode execution substrate for MCP agents')
|
|
25
|
+
.version('1.0.0');
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command('serve', { isDefault: true })
|
|
29
|
+
.description('Start the Conduit server')
|
|
30
|
+
.option('--stdio', 'Use stdio transport')
|
|
31
|
+
.action(async (options) => {
|
|
32
|
+
try {
|
|
33
|
+
await startServer();
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error('Failed to start Conduit:', err);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command('auth')
|
|
42
|
+
.description('Help set up OAuth for an upstream MCP server')
|
|
43
|
+
.requiredOption('--client-id <id>', 'OAuth Client ID')
|
|
44
|
+
.requiredOption('--client-secret <secret>', 'OAuth Client Secret')
|
|
45
|
+
.requiredOption('--auth-url <url>', 'OAuth Authorization URL')
|
|
46
|
+
.requiredOption('--token-url <url>', 'OAuth Token URL')
|
|
47
|
+
.option('--scopes <scopes>', 'OAuth Scopes (comma separated)')
|
|
48
|
+
.option('--port <port>', 'Port for the local callback server', '3333')
|
|
49
|
+
.action(async (options) => {
|
|
50
|
+
try {
|
|
51
|
+
await handleAuth({
|
|
52
|
+
clientId: options.clientId,
|
|
53
|
+
clientSecret: options.clientSecret,
|
|
54
|
+
authUrl: options.authUrl,
|
|
55
|
+
tokenUrl: options.tokenUrl,
|
|
56
|
+
scopes: options.scopes,
|
|
57
|
+
port: parseInt(options.port, 10),
|
|
58
|
+
});
|
|
59
|
+
console.log('\nSuccess! Configuration generated.');
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
console.error('Authentication helper failed:', err.message);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
async function startServer() {
|
|
19
67
|
const configService = new ConfigService();
|
|
20
|
-
// console.error('DEBUG: Config loaded');
|
|
21
68
|
const logger = createLogger(configService);
|
|
22
69
|
|
|
23
70
|
const otelService = new OtelService(logger);
|
|
@@ -78,7 +125,7 @@ async function main() {
|
|
|
78
125
|
const port = configService.get('port');
|
|
79
126
|
address = await transport.listen({ port });
|
|
80
127
|
}
|
|
81
|
-
executionService.ipcAddress = address;
|
|
128
|
+
executionService.ipcAddress = address;
|
|
82
129
|
|
|
83
130
|
// Pre-warm workers
|
|
84
131
|
await requestController.warmup();
|
|
@@ -102,7 +149,4 @@ async function main() {
|
|
|
102
149
|
});
|
|
103
150
|
}
|
|
104
151
|
|
|
105
|
-
|
|
106
|
-
console.error('Failed to start Conduit:', err);
|
|
107
|
-
process.exit(1);
|
|
108
|
-
});
|
|
152
|
+
program.parse(process.argv);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
|
+
import fs from 'node:fs';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { Logger } from 'pino';
|
|
@@ -46,9 +47,13 @@ export class SocketTransport {
|
|
|
46
47
|
|
|
47
48
|
// Cleanup existing socket if needed (unlikely on Windows, but good for Unix)
|
|
48
49
|
if (os.platform() !== 'win32' && path.isAbsolute(socketPath)) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
try {
|
|
51
|
+
fs.unlinkSync(socketPath);
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
if (error.code !== 'ENOENT') {
|
|
54
|
+
this.logger.warn({ err: error, socketPath }, 'Failed to unlink socket before binding');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
this.server.listen(socketPath, () => {
|
|
@@ -25,13 +25,10 @@ describe('AuthService', () => {
|
|
|
25
25
|
it('should refresh OAuth2 token when expired', async () => {
|
|
26
26
|
const creds: any = {
|
|
27
27
|
type: 'oauth2',
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
refreshToken: 'refresh',
|
|
33
|
-
expiresAt: Date.now() - 1000, // Expired
|
|
34
|
-
},
|
|
28
|
+
clientId: 'id',
|
|
29
|
+
clientSecret: 'secret',
|
|
30
|
+
tokenUrl: 'http://token',
|
|
31
|
+
refreshToken: 'refresh',
|
|
35
32
|
};
|
|
36
33
|
|
|
37
34
|
(axios.post as any).mockResolvedValue({
|
|
@@ -50,12 +47,10 @@ describe('AuthService', () => {
|
|
|
50
47
|
// First call - will trigger refresh
|
|
51
48
|
const creds: any = {
|
|
52
49
|
type: 'oauth2',
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
refreshToken: 'refresh',
|
|
58
|
-
},
|
|
50
|
+
clientId: 'id',
|
|
51
|
+
clientSecret: 'secret',
|
|
52
|
+
tokenUrl: 'http://token',
|
|
53
|
+
refreshToken: 'refresh',
|
|
59
54
|
};
|
|
60
55
|
|
|
61
56
|
(axios.post as any).mockResolvedValue({
|
|
@@ -67,4 +67,35 @@ describe('ConfigService', () => {
|
|
|
67
67
|
existsSpy.mockRestore();
|
|
68
68
|
readSpy.mockRestore();
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('should parse OAuth2 credentials correctly', () => {
|
|
72
|
+
const existsSpy = vi.spyOn(fs, 'existsSync').mockImplementation((p: any) => p.endsWith('conduit.test.yaml'));
|
|
73
|
+
const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue(`
|
|
74
|
+
upstreams:
|
|
75
|
+
- id: test-oauth
|
|
76
|
+
type: http
|
|
77
|
+
url: http://upstream
|
|
78
|
+
credentials:
|
|
79
|
+
type: oauth2
|
|
80
|
+
clientId: my-id
|
|
81
|
+
clientSecret: my-secret
|
|
82
|
+
tokenUrl: http://token
|
|
83
|
+
refreshToken: my-refresh
|
|
84
|
+
`);
|
|
85
|
+
|
|
86
|
+
vi.stubEnv('CONFIG_FILE', 'conduit.test.yaml');
|
|
87
|
+
const configService = new ConfigService();
|
|
88
|
+
const upstreams = configService.get('upstreams');
|
|
89
|
+
expect(upstreams).toHaveLength(1);
|
|
90
|
+
expect(upstreams![0].credentials).toEqual({
|
|
91
|
+
type: 'oauth2',
|
|
92
|
+
clientId: 'my-id',
|
|
93
|
+
clientSecret: 'my-secret',
|
|
94
|
+
tokenUrl: 'http://token',
|
|
95
|
+
refreshToken: 'my-refresh'
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
existsSpy.mockRestore();
|
|
99
|
+
readSpy.mockRestore();
|
|
100
|
+
});
|
|
70
101
|
});
|
package/tests/contract.test.ts
CHANGED
|
@@ -32,8 +32,8 @@ describe('Contract Test: Conduit vs Reference MCP', () => {
|
|
|
32
32
|
|
|
33
33
|
it('should successfully discover tools from reference MCP', async () => {
|
|
34
34
|
const tools = await gateway.discoverTools(context);
|
|
35
|
-
expect(tools).
|
|
36
|
-
expect(tools
|
|
35
|
+
expect(tools.length).toBeGreaterThanOrEqual(1);
|
|
36
|
+
expect(tools.find(t => t.name === 'ref__echo')).toBeDefined();
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it('should successfully call tool on reference MCP', async () => {
|
|
@@ -96,7 +96,7 @@ describe('Dynamic Tool Calling (E2E)', () => {
|
|
|
96
96
|
const response = await requestController.handleRequest({
|
|
97
97
|
jsonrpc: '2.0',
|
|
98
98
|
id: 1,
|
|
99
|
-
method: '
|
|
99
|
+
method: 'mcp_execute_typescript',
|
|
100
100
|
params: { code },
|
|
101
101
|
auth: { bearerToken: testToken }
|
|
102
102
|
}, context);
|
|
@@ -122,7 +122,7 @@ print(f"RESULT:{result}")
|
|
|
122
122
|
const response = await requestController.handleRequest({
|
|
123
123
|
jsonrpc: '2.0',
|
|
124
124
|
id: 2,
|
|
125
|
-
method: '
|
|
125
|
+
method: 'mcp_execute_python',
|
|
126
126
|
params: { code },
|
|
127
127
|
auth: { bearerToken: testToken }
|
|
128
128
|
}, context);
|
|
@@ -151,7 +151,7 @@ print(f"RESULT:{result}")
|
|
|
151
151
|
const response = await requestController.handleRequest({
|
|
152
152
|
jsonrpc: '2.0',
|
|
153
153
|
id: 3,
|
|
154
|
-
method: '
|
|
154
|
+
method: 'mcp_execute_typescript',
|
|
155
155
|
params: {
|
|
156
156
|
code,
|
|
157
157
|
allowedTools: ['mock.hello'] // Only mock.hello allowed
|
|
@@ -178,7 +178,7 @@ print(f"RESULT:{result}")
|
|
|
178
178
|
const response = await requestController.handleRequest({
|
|
179
179
|
jsonrpc: '2.0',
|
|
180
180
|
id: 4,
|
|
181
|
-
method: '
|
|
181
|
+
method: 'mcp_execute_typescript',
|
|
182
182
|
params: {
|
|
183
183
|
code,
|
|
184
184
|
allowedTools: ['mock.*'] // Wildcard allows all mock tools
|
|
@@ -206,7 +206,7 @@ print(f"RESULT:{result}")
|
|
|
206
206
|
const response = await requestController.handleRequest({
|
|
207
207
|
jsonrpc: '2.0',
|
|
208
208
|
id: 5,
|
|
209
|
-
method: '
|
|
209
|
+
method: 'mcp_execute_isolate',
|
|
210
210
|
params: {
|
|
211
211
|
code,
|
|
212
212
|
allowedTools: ['mock.*'],
|