@mhingston5/conduit 1.1.4 → 1.1.6
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 +13 -2
- package/dist/index.js +500 -131
- package/dist/index.js.map +1 -1
- package/dist/pyodide.worker.js.map +1 -0
- package/package.json +1 -1
- package/src/auth.cmd.ts +161 -14
- package/src/core/config.service.ts +5 -1
- package/src/core/execution.service.ts +5 -0
- package/src/core/policy.service.ts +5 -0
- package/src/core/request.controller.ts +80 -7
- package/src/executors/pyodide.executor.ts +9 -4
- package/src/gateway/auth.service.ts +17 -7
- package/src/gateway/gateway.service.ts +150 -73
- package/src/gateway/host.client.ts +65 -0
- package/src/gateway/upstream.client.ts +21 -14
- package/src/index.ts +33 -7
- package/src/sdk/sdk-generator.ts +90 -30
- package/src/transport/stdio.transport.ts +44 -3
- package/tests/__snapshots__/assets.test.ts.snap +56 -3
- package/tests/code-mode-lite-gateway.test.ts +4 -4
- package/tests/debug.fallback.test.ts +40 -0
- package/tests/debug_upstream.ts +69 -0
- package/tests/dynamic.tool.test.ts +3 -3
- package/tests/gateway.manifest.test.ts +1 -1
- package/tests/gateway.service.test.ts +6 -6
- package/tests/reference_mcp.ts +5 -3
- package/tests/routing.test.ts +8 -1
- package/tests/sdk/sdk-generator.test.ts +10 -9
- package/tsup.config.ts +1 -1
- package/dist/executors/pyodide.worker.js.map +0 -1
- /package/dist/{executors/pyodide.worker.d.ts → pyodide.worker.d.ts} +0 -0
- /package/dist/{executors/pyodide.worker.js → pyodide.worker.js} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/executors/pyodide.worker.ts"],"sourcesContent":["import { parentPort, workerData } from 'node:worker_threads';\nimport { loadPyodide, type PyodideInterface } from 'pyodide';\nimport net from 'node:net';\n\nlet pyodide: PyodideInterface | null = null;\nlet currentStdout = '';\nlet currentStderr = '';\nlet totalOutputBytes = 0;\nlet totalLogEntries = 0;\nlet currentLimits: any = null;\n\nasync function init() {\n if (pyodide) return pyodide;\n\n pyodide = await loadPyodide({\n stdout: (text) => {\n if (currentLimits && (totalOutputBytes > (currentLimits.maxOutputBytes || 1024 * 1024) || totalLogEntries > (currentLimits.maxLogEntries || 10000))) {\n return; // Stop processing logs once limit breached\n }\n currentStdout += text + '\\n';\n totalOutputBytes += text.length + 1;\n totalLogEntries++;\n },\n stderr: (text) => {\n if (currentLimits && (totalOutputBytes > (currentLimits.maxOutputBytes || 1024 * 1024) || totalLogEntries > (currentLimits.maxLogEntries || 10000))) {\n return; // Stop processing logs once limit breached\n }\n currentStderr += text + '\\n';\n totalOutputBytes += text.length + 1;\n totalLogEntries++;\n },\n });\n\n return pyodide;\n}\n\nasync function handleTask(data: any) {\n const { code, limits, ipcInfo, shim } = data;\n currentStdout = '';\n currentStderr = '';\n totalOutputBytes = 0;\n totalLogEntries = 0;\n currentLimits = limits;\n\n try {\n const p = await init();\n\n const sendIPCRequest = async (method: string, params: any) => {\n if (!ipcInfo?.ipcAddress) throw new Error('Conduit IPC address not configured');\n\n return new Promise((resolve, reject) => {\n let client: net.Socket;\n\n if (ipcInfo.ipcAddress.includes(':')) {\n const lastColon = ipcInfo.ipcAddress.lastIndexOf(':');\n const host = ipcInfo.ipcAddress.substring(0, lastColon);\n const port = ipcInfo.ipcAddress.substring(lastColon + 1);\n\n let targetHost = host.replace(/[\\[\\]]/g, '');\n if (targetHost === '0.0.0.0' || targetHost === '::' || targetHost === '::1' || targetHost === '') {\n targetHost = '127.0.0.1';\n }\n\n client = net.createConnection({\n host: targetHost,\n port: parseInt(port)\n });\n } else {\n client = net.createConnection({ path: ipcInfo.ipcAddress });\n }\n\n const id = Math.random().toString(36).substring(7);\n const request = {\n jsonrpc: '2.0',\n id,\n method,\n params: params || {},\n auth: { bearerToken: ipcInfo.ipcToken }\n };\n\n client.on('error', (err) => {\n reject(err);\n client.destroy();\n });\n\n client.write(JSON.stringify(request) + '\\n');\n\n let buffer = '';\n client.on('data', (data) => {\n buffer += data.toString();\n // Robust framing: read until we find a complete JSON object on a line\n const lines = buffer.split('\\n');\n buffer = lines.pop() || ''; // Keep the last partial line\n\n for (const line of lines) {\n if (!line.trim()) continue;\n try {\n const response = JSON.parse(line);\n if (response.id === id) {\n if (response.error) {\n reject(new Error(response.error.message));\n } else {\n resolve(response.result);\n }\n client.end();\n return;\n }\n } catch (e) {\n // If parse fails, it might be a partial line that we haven't seen the end of yet\n // but since we split by \\n, this shouldn't happen unless the \\n was inside the JSON.\n // However, Conduit ensures JSON-RPC is one line.\n }\n }\n });\n\n client.on('end', () => {\n if (buffer.trim()) {\n try {\n const response = JSON.parse(buffer);\n if (response.id === id) {\n if (response.error) {\n reject(new Error(response.error.message));\n } else {\n resolve(response.result);\n }\n }\n } catch (e) { }\n }\n });\n });\n };\n\n (p as any).globals.set('discover_mcp_tools_js', (options: any) => {\n return sendIPCRequest('mcp_discover_tools', options);\n });\n\n (p as any).globals.set('call_mcp_tool_js', (name: string, args: any) => {\n return sendIPCRequest('mcp_call_tool', { name, arguments: args });\n });\n\n if (shim) {\n await p.runPythonAsync(shim);\n }\n\n const result = await p.runPythonAsync(code);\n\n if (totalOutputBytes > (limits.maxOutputBytes || 1024 * 1024)) {\n throw new Error('[LIMIT_OUTPUT]');\n }\n if (totalLogEntries > (limits.maxLogEntries || 10000)) {\n throw new Error('[LIMIT_LOG]');\n }\n\n parentPort?.postMessage({\n stdout: currentStdout,\n stderr: currentStderr,\n result: String(result),\n success: true,\n });\n } catch (err: any) {\n let isOutput = err.message.includes('[LIMIT_OUTPUT]');\n let isLog = err.message.includes('[LIMIT_LOG]');\n\n // Fallback: check counters if message doesn't match (e.g. wrapped in OSError)\n if (!isOutput && !isLog && currentLimits) {\n if (totalOutputBytes > (currentLimits.maxOutputBytes || 1024 * 1024)) {\n isOutput = true;\n }\n // Check specific log limit breach\n if (totalLogEntries > (currentLimits.maxLogEntries || 10000)) {\n isLog = true;\n }\n }\n\n parentPort?.postMessage({\n stdout: currentStdout,\n stderr: currentStderr,\n error: err.message,\n limitBreached: isOutput ? 'output' : (isLog ? 'log' : undefined),\n success: false,\n });\n }\n}\n\nparentPort?.on('message', async (msg) => {\n if (msg.type === 'execute') {\n await handleTask(msg.data);\n } else if (msg.type === 'ping') {\n parentPort?.postMessage({ type: 'pong' });\n }\n});\n\n// Signal ready\nparentPort?.postMessage({ type: 'ready' });\n\n"],"mappings":"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","names":["data"]}
|
package/package.json
CHANGED
package/src/auth.cmd.ts
CHANGED
|
@@ -2,22 +2,143 @@ import Fastify from 'fastify';
|
|
|
2
2
|
import axios from 'axios';
|
|
3
3
|
import open from 'open';
|
|
4
4
|
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
|
|
6
7
|
export interface AuthOptions {
|
|
7
|
-
authUrl
|
|
8
|
-
tokenUrl
|
|
8
|
+
authUrl?: string;
|
|
9
|
+
tokenUrl?: string;
|
|
9
10
|
clientId: string;
|
|
10
|
-
clientSecret
|
|
11
|
+
clientSecret?: string;
|
|
11
12
|
scopes?: string;
|
|
12
13
|
port?: number;
|
|
14
|
+
mcpUrl?: string;
|
|
15
|
+
usePkce?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type DiscoveredOAuth = {
|
|
19
|
+
authUrl: string;
|
|
20
|
+
tokenUrl: string;
|
|
21
|
+
scopes?: string[];
|
|
22
|
+
resource?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const AUTH_REQUEST_PAYLOAD = {
|
|
26
|
+
jsonrpc: '2.0',
|
|
27
|
+
id: 'conduit-auth',
|
|
28
|
+
method: 'initialize',
|
|
29
|
+
params: {
|
|
30
|
+
clientInfo: {
|
|
31
|
+
name: 'conduit-auth',
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function base64UrlEncode(buffer: Buffer): string {
|
|
38
|
+
return buffer
|
|
39
|
+
.toString('base64')
|
|
40
|
+
.replace(/\+/g, '-')
|
|
41
|
+
.replace(/\//g, '_')
|
|
42
|
+
.replace(/=+$/g, '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createCodeVerifier(): string {
|
|
46
|
+
return base64UrlEncode(crypto.randomBytes(32));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createCodeChallenge(verifier: string): string {
|
|
50
|
+
return base64UrlEncode(crypto.createHash('sha256').update(verifier).digest());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseResourceMetadataHeader(headerValue: string | string[] | undefined): string | null {
|
|
54
|
+
if (!headerValue) return null;
|
|
55
|
+
const header = Array.isArray(headerValue) ? headerValue.join(',') : headerValue;
|
|
56
|
+
const match = header.match(/resource_metadata="([^"]+)"/i) || header.match(/resource_metadata=([^, ]+)/i);
|
|
57
|
+
return match ? match[1] : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function discoverOAuthFromMcp(mcpUrl: string): Promise<DiscoveredOAuth> {
|
|
61
|
+
const attempts = [
|
|
62
|
+
() => axios.get(mcpUrl, { validateStatus: () => true }),
|
|
63
|
+
() => axios.post(mcpUrl, AUTH_REQUEST_PAYLOAD, { validateStatus: () => true }),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
let resourceMetadataUrl: string | null = null;
|
|
67
|
+
for (const attempt of attempts) {
|
|
68
|
+
const response = await attempt();
|
|
69
|
+
resourceMetadataUrl = parseResourceMetadataHeader(response.headers['www-authenticate']);
|
|
70
|
+
if (resourceMetadataUrl) break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!resourceMetadataUrl) {
|
|
74
|
+
throw new Error('Unable to discover OAuth metadata (missing WWW-Authenticate resource_metadata)');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const metadataResponse = await axios.get(resourceMetadataUrl);
|
|
78
|
+
const metadata = metadataResponse.data as Record<string, any>;
|
|
79
|
+
|
|
80
|
+
let authUrl = metadata.authorization_endpoint as string | undefined;
|
|
81
|
+
let tokenUrl = metadata.token_endpoint as string | undefined;
|
|
82
|
+
let scopes = Array.isArray(metadata.scopes_supported) ? metadata.scopes_supported : undefined;
|
|
83
|
+
const resource = typeof metadata.resource === 'string' ? metadata.resource : undefined;
|
|
84
|
+
|
|
85
|
+
if (!authUrl || !tokenUrl) {
|
|
86
|
+
const authServer = (Array.isArray(metadata.authorization_servers) && metadata.authorization_servers[0]) || metadata.issuer;
|
|
87
|
+
if (!authServer) {
|
|
88
|
+
throw new Error('OAuth metadata did not include authorization server info');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const asMetadataUrl = new URL('/.well-known/oauth-authorization-server', authServer).toString();
|
|
92
|
+
const asMetadataResponse = await axios.get(asMetadataUrl);
|
|
93
|
+
const asMetadata = asMetadataResponse.data as Record<string, any>;
|
|
94
|
+
|
|
95
|
+
authUrl = authUrl || (asMetadata.authorization_endpoint as string | undefined);
|
|
96
|
+
tokenUrl = tokenUrl || (asMetadata.token_endpoint as string | undefined);
|
|
97
|
+
scopes = scopes || (Array.isArray(asMetadata.scopes_supported) ? asMetadata.scopes_supported : undefined);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!authUrl || !tokenUrl) {
|
|
101
|
+
throw new Error('OAuth discovery failed: missing authorization or token endpoint');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { authUrl, tokenUrl, scopes, resource };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeScopes(rawScopes?: string): string | undefined {
|
|
108
|
+
if (!rawScopes) return undefined;
|
|
109
|
+
return rawScopes
|
|
110
|
+
.split(',')
|
|
111
|
+
.map(scope => scope.trim())
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.join(' ');
|
|
13
114
|
}
|
|
14
115
|
|
|
15
116
|
export async function handleAuth(options: AuthOptions) {
|
|
16
117
|
const port = options.port || 3333;
|
|
17
118
|
const redirectUri = `http://localhost:${port}/callback`;
|
|
18
119
|
const state = uuidv4();
|
|
120
|
+
const codeVerifier = options.usePkce ? createCodeVerifier() : undefined;
|
|
121
|
+
const codeChallenge = codeVerifier ? createCodeChallenge(codeVerifier) : undefined;
|
|
19
122
|
|
|
20
123
|
const fastify = Fastify();
|
|
124
|
+
let resolvedScopes = normalizeScopes(options.scopes);
|
|
125
|
+
let resolvedAuthUrl = options.authUrl;
|
|
126
|
+
let resolvedTokenUrl = options.tokenUrl;
|
|
127
|
+
let resolvedResource: string | undefined;
|
|
128
|
+
|
|
129
|
+
if (options.mcpUrl) {
|
|
130
|
+
const discovered = await discoverOAuthFromMcp(options.mcpUrl);
|
|
131
|
+
resolvedAuthUrl = discovered.authUrl;
|
|
132
|
+
resolvedTokenUrl = discovered.tokenUrl;
|
|
133
|
+
resolvedResource = discovered.resource;
|
|
134
|
+
if (!resolvedScopes && discovered.scopes && discovered.scopes.length > 0) {
|
|
135
|
+
resolvedScopes = discovered.scopes.join(' ');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!resolvedAuthUrl || !resolvedTokenUrl) {
|
|
140
|
+
throw new Error('OAuth configuration missing authUrl or tokenUrl (set --mcp-url or provide both)');
|
|
141
|
+
}
|
|
21
142
|
|
|
22
143
|
return new Promise<void>((resolve, reject) => {
|
|
23
144
|
fastify.get('/callback', async (request, reply) => {
|
|
@@ -36,12 +157,26 @@ export async function handleAuth(options: AuthOptions) {
|
|
|
36
157
|
}
|
|
37
158
|
|
|
38
159
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
165
|
+
if (options.clientSecret) {
|
|
166
|
+
body.set('client_secret', options.clientSecret);
|
|
167
|
+
}
|
|
168
|
+
if (codeVerifier) {
|
|
169
|
+
body.set('code_verifier', codeVerifier);
|
|
170
|
+
}
|
|
171
|
+
if (resolvedResource) {
|
|
172
|
+
body.set('resource', resolvedResource);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const response = await axios.post(resolvedTokenUrl, body, {
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
178
|
+
'Accept': 'application/json',
|
|
179
|
+
},
|
|
45
180
|
});
|
|
46
181
|
|
|
47
182
|
const { refresh_token, access_token } = response.data;
|
|
@@ -51,9 +186,14 @@ export async function handleAuth(options: AuthOptions) {
|
|
|
51
186
|
console.log('credentials:');
|
|
52
187
|
console.log(' type: oauth2');
|
|
53
188
|
console.log(` clientId: ${options.clientId}`);
|
|
54
|
-
|
|
55
|
-
|
|
189
|
+
if (options.clientSecret) {
|
|
190
|
+
console.log(` clientSecret: ${options.clientSecret}`);
|
|
191
|
+
}
|
|
192
|
+
console.log(` tokenUrl: "${resolvedTokenUrl}"`);
|
|
56
193
|
console.log(` refreshToken: "${refresh_token || 'N/A (No refresh token returned)'}"`);
|
|
194
|
+
if (resolvedScopes) {
|
|
195
|
+
console.log(` scopes: ["${resolvedScopes.split(' ').join('", "')}"]`);
|
|
196
|
+
}
|
|
57
197
|
|
|
58
198
|
if (!refresh_token) {
|
|
59
199
|
console.log('\nWarning: No refresh token was returned. Ensure your app has "offline_access" scope or similar.');
|
|
@@ -78,13 +218,20 @@ export async function handleAuth(options: AuthOptions) {
|
|
|
78
218
|
return;
|
|
79
219
|
}
|
|
80
220
|
|
|
81
|
-
const authUrl = new URL(
|
|
221
|
+
const authUrl = new URL(resolvedAuthUrl);
|
|
82
222
|
authUrl.searchParams.append('client_id', options.clientId);
|
|
83
223
|
authUrl.searchParams.append('redirect_uri', redirectUri);
|
|
84
224
|
authUrl.searchParams.append('response_type', 'code');
|
|
85
225
|
authUrl.searchParams.append('state', state);
|
|
86
|
-
if (
|
|
87
|
-
authUrl.searchParams.append('scope',
|
|
226
|
+
if (resolvedScopes) {
|
|
227
|
+
authUrl.searchParams.append('scope', resolvedScopes);
|
|
228
|
+
}
|
|
229
|
+
if (codeChallenge) {
|
|
230
|
+
authUrl.searchParams.append('code_challenge', codeChallenge);
|
|
231
|
+
authUrl.searchParams.append('code_challenge_method', 'S256');
|
|
232
|
+
}
|
|
233
|
+
if (resolvedResource) {
|
|
234
|
+
authUrl.searchParams.append('resource', resolvedResource);
|
|
88
235
|
}
|
|
89
236
|
|
|
90
237
|
console.log(`Opening browser to: ${authUrl.toString()}`);
|
|
@@ -134,10 +134,14 @@ export class ConfigService {
|
|
|
134
134
|
(fs.existsSync(path.resolve(process.cwd(), 'conduit.yaml')) ? 'conduit.yaml' :
|
|
135
135
|
(fs.existsSync(path.resolve(process.cwd(), 'conduit.json')) ? 'conduit.json' : null));
|
|
136
136
|
|
|
137
|
-
if (!configPath)
|
|
137
|
+
if (!configPath) {
|
|
138
|
+
console.warn(`[Conduit] No config file found in ${process.cwd()}. Running with default settings.`);
|
|
139
|
+
return {};
|
|
140
|
+
}
|
|
138
141
|
|
|
139
142
|
try {
|
|
140
143
|
const fullPath = path.resolve(process.cwd(), configPath);
|
|
144
|
+
console.error(`[Conduit] Loading config from ${fullPath}`);
|
|
141
145
|
let fileContent = fs.readFileSync(fullPath, 'utf-8');
|
|
142
146
|
|
|
143
147
|
// Env var substitution: ${VAR} or ${VAR:-default}
|
|
@@ -137,17 +137,22 @@ export class ExecutionService {
|
|
|
137
137
|
const packages = await this.gatewayService.listToolPackages();
|
|
138
138
|
const allBindings = [];
|
|
139
139
|
|
|
140
|
+
this.logger.debug({ packageCount: packages.length, packages: packages.map(p => p.id) }, 'Fetching tool bindings');
|
|
141
|
+
|
|
140
142
|
for (const pkg of packages) {
|
|
141
143
|
try {
|
|
142
144
|
// Determine if we need to fetch tools for this package
|
|
143
145
|
// Optimization: if allowedTools is strict, we could filter packages here
|
|
144
146
|
|
|
145
147
|
const stubs = await this.gatewayService.listToolStubs(pkg.id, context);
|
|
148
|
+
this.logger.debug({ packageId: pkg.id, stubCount: stubs.length }, 'Got stubs from package');
|
|
146
149
|
allBindings.push(...stubs.map(s => toToolBinding(s.id, undefined, s.description)));
|
|
147
150
|
} catch (err: any) {
|
|
148
151
|
this.logger.warn({ packageId: pkg.id, err: err.message }, 'Failed to list stubs for package');
|
|
149
152
|
}
|
|
150
153
|
}
|
|
154
|
+
|
|
155
|
+
this.logger.info({ totalBindings: allBindings.length }, 'Tool bindings ready for SDK generation');
|
|
151
156
|
return allBindings;
|
|
152
157
|
}
|
|
153
158
|
|
|
@@ -66,6 +66,11 @@ export class PolicyService {
|
|
|
66
66
|
return true;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Improved matching: if pattern has only one part, match it against the tool's name part
|
|
70
|
+
if (patternParts.length === 1 && toolParts.length > 1) {
|
|
71
|
+
return patternParts[0] === toolParts[toolParts.length - 1];
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
// Exact match: pattern parts must equal tool parts
|
|
70
75
|
if (patternParts.length !== toolParts.length) return false;
|
|
71
76
|
for (let i = 0; i < patternParts.length; i++) {
|
|
@@ -104,6 +104,9 @@ export class RequestController {
|
|
|
104
104
|
case 'tools/list': // Standard MCP method name
|
|
105
105
|
case 'mcp_discover_tools':
|
|
106
106
|
return this.handleDiscoverTools(params, context, id);
|
|
107
|
+
case 'resources/list':
|
|
108
|
+
case 'prompts/list':
|
|
109
|
+
return { jsonrpc: '2.0', id, result: { items: [] } };
|
|
107
110
|
case 'mcp_list_tool_packages':
|
|
108
111
|
return this.handleListToolPackages(params, context, id);
|
|
109
112
|
case 'mcp_list_tool_stubs':
|
|
@@ -125,6 +128,8 @@ export class RequestController {
|
|
|
125
128
|
return this.handleInitialize(params, context, id);
|
|
126
129
|
case 'notifications/initialized':
|
|
127
130
|
return null; // Notifications don't get responses per MCP spec
|
|
131
|
+
case 'mcp_register_upstream':
|
|
132
|
+
return this.handleRegisterUpstream(params, context, id);
|
|
128
133
|
case 'ping':
|
|
129
134
|
return { jsonrpc: '2.0', id, result: {} };
|
|
130
135
|
default:
|
|
@@ -135,6 +140,23 @@ export class RequestController {
|
|
|
135
140
|
}
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
private async handleRegisterUpstream(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
144
|
+
if (!params || !params.id || !params.type || (!params.url && !params.command)) {
|
|
145
|
+
return this.errorResponse(id, -32602, 'Missing registration parameters (id, type, url/command)');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
this.gatewayService.registerUpstream(params);
|
|
150
|
+
return {
|
|
151
|
+
jsonrpc: '2.0',
|
|
152
|
+
id,
|
|
153
|
+
result: { success: true }
|
|
154
|
+
};
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
return this.errorResponse(id, -32001, err.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
138
160
|
private async handleDiscoverTools(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
139
161
|
const tools = await this.gatewayService.discoverTools(context);
|
|
140
162
|
|
|
@@ -212,20 +234,71 @@ export class RequestController {
|
|
|
212
234
|
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
213
235
|
const { name, arguments: toolArgs } = params;
|
|
214
236
|
|
|
237
|
+
const toolId = this.gatewayService.policyService.parseToolName(name);
|
|
238
|
+
const baseName = toolId.name;
|
|
239
|
+
const isConduit = toolId.namespace === 'conduit' || toolId.namespace === '';
|
|
240
|
+
|
|
215
241
|
// Route built-in tools to their specific handlers
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
242
|
+
if (isConduit) {
|
|
243
|
+
switch (baseName) {
|
|
244
|
+
case 'mcp_execute_typescript':
|
|
245
|
+
return this.handleExecuteToolCall('typescript', toolArgs, context, id);
|
|
246
|
+
case 'mcp_execute_python':
|
|
247
|
+
return this.handleExecuteToolCall('python', toolArgs, context, id);
|
|
248
|
+
case 'mcp_execute_isolate':
|
|
249
|
+
return this.handleExecuteToolCall('isolate', toolArgs, context, id);
|
|
250
|
+
}
|
|
223
251
|
}
|
|
224
252
|
|
|
225
253
|
const response = await this.gatewayService.callTool(name, toolArgs, context);
|
|
226
254
|
return { ...response, id };
|
|
227
255
|
}
|
|
228
256
|
|
|
257
|
+
private formatExecutionResult(result: { stdout: string; stderr: string; exitCode: number | null }) {
|
|
258
|
+
const structured = {
|
|
259
|
+
stdout: result.stdout,
|
|
260
|
+
stderr: result.stderr,
|
|
261
|
+
exitCode: result.exitCode,
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
content: [{
|
|
265
|
+
type: 'text',
|
|
266
|
+
text: JSON.stringify(structured),
|
|
267
|
+
}],
|
|
268
|
+
structuredContent: structured,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async handleExecuteToolCall(
|
|
273
|
+
mode: 'typescript' | 'python' | 'isolate',
|
|
274
|
+
params: any,
|
|
275
|
+
context: ExecutionContext,
|
|
276
|
+
id: string | number
|
|
277
|
+
): Promise<JSONRPCResponse> {
|
|
278
|
+
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
279
|
+
const { code, limits, allowedTools } = params;
|
|
280
|
+
|
|
281
|
+
if (Array.isArray(allowedTools)) {
|
|
282
|
+
context.allowedTools = allowedTools;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = mode === 'typescript'
|
|
286
|
+
? await this.executionService.executeTypeScript(code, limits, context, allowedTools)
|
|
287
|
+
: mode === 'python'
|
|
288
|
+
? await this.executionService.executePython(code, limits, context, allowedTools)
|
|
289
|
+
: await this.executionService.executeIsolate(code, limits, context, allowedTools);
|
|
290
|
+
|
|
291
|
+
if (result.error) {
|
|
292
|
+
return this.errorResponse(id, result.error.code, result.error.message);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
jsonrpc: '2.0',
|
|
297
|
+
id,
|
|
298
|
+
result: this.formatExecutionResult(result),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
229
302
|
private async handleExecuteTypeScript(params: any, context: ExecutionContext, id: string | number): Promise<JSONRPCResponse> {
|
|
230
303
|
if (!params) return this.errorResponse(id, -32602, 'Missing parameters');
|
|
231
304
|
const { code, limits, allowedTools } = params;
|
|
@@ -94,9 +94,15 @@ export class PyodideExecutor implements Executor {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
private createWorker(limits?: ConduitResourceLimits): Worker {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
97
|
+
const candidates = [
|
|
98
|
+
path.resolve(__dirname, './pyodide.worker.js'),
|
|
99
|
+
path.resolve(__dirname, './pyodide.worker.ts'),
|
|
100
|
+
path.resolve(__dirname, './executors/pyodide.worker.js'),
|
|
101
|
+
path.resolve(__dirname, './executors/pyodide.worker.ts'),
|
|
102
|
+
];
|
|
103
|
+
const workerPath = candidates.find(p => fs.existsSync(p));
|
|
104
|
+
if (!workerPath) {
|
|
105
|
+
throw new Error(`Pyodide worker not found. Tried: ${candidates.join(', ')}`);
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
return new Worker(workerPath, {
|
|
@@ -324,4 +330,3 @@ export class PyodideExecutor implements Executor {
|
|
|
324
330
|
}
|
|
325
331
|
}
|
|
326
332
|
}
|
|
327
|
-
|
|
@@ -11,6 +11,7 @@ export interface UpstreamCredentials {
|
|
|
11
11
|
clientSecret?: string;
|
|
12
12
|
tokenUrl?: string;
|
|
13
13
|
refreshToken?: string;
|
|
14
|
+
scopes?: string[];
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
interface CachedToken {
|
|
@@ -73,26 +74,35 @@ export class AuthService {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
private async doRefresh(creds: UpstreamCredentials, cacheKey: string): Promise<string> {
|
|
76
|
-
if (!creds.tokenUrl || !creds.refreshToken || !creds.clientId
|
|
77
|
+
if (!creds.tokenUrl || !creds.refreshToken || !creds.clientId) {
|
|
77
78
|
throw new Error('OAuth2 credentials missing required fields for refresh');
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
this.logger.info({ tokenUrl: creds.tokenUrl, clientId: creds.clientId }, 'Refreshing OAuth2 token');
|
|
81
82
|
|
|
82
83
|
try {
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
if (creds.clientSecret) {
|
|
89
|
+
body.set('client_secret', creds.clientSecret);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const response = await axios.post(creds.tokenUrl, body, {
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
95
|
+
'Accept': 'application/json',
|
|
96
|
+
},
|
|
88
97
|
});
|
|
89
98
|
|
|
90
99
|
const { access_token, expires_in } = response.data;
|
|
100
|
+
const expiresInSeconds = Number(expires_in) || 3600;
|
|
91
101
|
|
|
92
102
|
// Cache the token (don't mutate the input credentials)
|
|
93
103
|
this.tokenCache.set(cacheKey, {
|
|
94
104
|
accessToken: access_token,
|
|
95
|
-
expiresAt: Date.now() + (
|
|
105
|
+
expiresAt: Date.now() + (expiresInSeconds * 1000),
|
|
96
106
|
});
|
|
97
107
|
|
|
98
108
|
return `Bearer ${access_token}`;
|