@ruifung/codemode-bridge 1.0.3-1
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/LICENSE +202 -0
- package/README.md +378 -0
- package/dist/cli/commands.d.ts +70 -0
- package/dist/cli/commands.js +436 -0
- package/dist/cli/config-manager.d.ts +53 -0
- package/dist/cli/config-manager.js +142 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.js +165 -0
- package/dist/executor/container-executor.d.ts +81 -0
- package/dist/executor/container-executor.js +351 -0
- package/dist/executor/executor-test-suite.d.ts +22 -0
- package/dist/executor/executor-test-suite.js +395 -0
- package/dist/executor/isolated-vm-executor.d.ts +78 -0
- package/dist/executor/isolated-vm-executor.js +368 -0
- package/dist/executor/vm2-executor.d.ts +21 -0
- package/dist/executor/vm2-executor.js +109 -0
- package/dist/executor/wrap-code.d.ts +52 -0
- package/dist/executor/wrap-code.js +80 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/mcp/config.d.ts +44 -0
- package/dist/mcp/config.js +102 -0
- package/dist/mcp/e2e-bridge-test-suite.d.ts +28 -0
- package/dist/mcp/e2e-bridge-test-suite.js +429 -0
- package/dist/mcp/executor.d.ts +31 -0
- package/dist/mcp/executor.js +121 -0
- package/dist/mcp/mcp-adapter.d.ts +12 -0
- package/dist/mcp/mcp-adapter.js +49 -0
- package/dist/mcp/mcp-client.d.ts +85 -0
- package/dist/mcp/mcp-client.js +441 -0
- package/dist/mcp/oauth-handler.d.ts +33 -0
- package/dist/mcp/oauth-handler.js +138 -0
- package/dist/mcp/server.d.ts +25 -0
- package/dist/mcp/server.js +322 -0
- package/dist/mcp/token-persistence.d.ts +57 -0
- package/dist/mcp/token-persistence.js +131 -0
- package/dist/utils/logger.d.ts +44 -0
- package/dist/utils/logger.js +123 -0
- package/package.json +56 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client - Wrapper around official MCP SDK for connecting to upstream MCP servers
|
|
3
|
+
*
|
|
4
|
+
* This client:
|
|
5
|
+
* 1. Uses the official @modelcontextprotocol/sdk Client API
|
|
6
|
+
* 2. Supports stdio, HTTP (Streamable HTTP), and SSE transports
|
|
7
|
+
* 3. Returns tools in native MCP format (JSON Schema for inputSchema)
|
|
8
|
+
* 4. Provides tool execution via callTool()
|
|
9
|
+
*/
|
|
10
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
11
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
12
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
13
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
14
|
+
import { tokenPersistence } from "./token-persistence.js";
|
|
15
|
+
import { OAuthCallbackServer } from "./oauth-handler.js";
|
|
16
|
+
import { logDebug, logInfo } from "../utils/logger.js";
|
|
17
|
+
/**
|
|
18
|
+
* Simple in-memory OAuth provider implementation for basic OAuth flows
|
|
19
|
+
* Supports both pre-registered clients and dynamic client registration (RFC 7591)
|
|
20
|
+
*/
|
|
21
|
+
class SimpleOAuthProvider {
|
|
22
|
+
constructor(config, serverUrl) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.serverUrl = serverUrl;
|
|
25
|
+
logDebug(`Creating provider for ${serverUrl}`, { component: 'OAuth' });
|
|
26
|
+
// Load tokens from persistence on initialization
|
|
27
|
+
const persistedTokens = tokenPersistence.getTokens(serverUrl);
|
|
28
|
+
if (persistedTokens) {
|
|
29
|
+
this._tokens = persistedTokens;
|
|
30
|
+
logDebug(`Loaded persisted tokens for ${serverUrl}`, { component: 'OAuth' });
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
logDebug(`No persisted tokens found for ${serverUrl}`, { component: 'OAuth' });
|
|
34
|
+
}
|
|
35
|
+
// Load client information from persistence
|
|
36
|
+
const persistedClientInfo = tokenPersistence.getClientInformation(serverUrl);
|
|
37
|
+
if (persistedClientInfo) {
|
|
38
|
+
this._clientInfo = persistedClientInfo;
|
|
39
|
+
logDebug(`Loaded persisted client info for ${serverUrl}`, { component: 'OAuth' });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
logDebug(`No persisted client info found for ${serverUrl}`, { component: 'OAuth' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Set the callback to be invoked when authorization code is received.
|
|
47
|
+
* This should be called by MCPClient with the transport's finishAuth method.
|
|
48
|
+
*/
|
|
49
|
+
setFinishAuthCallback(callback) {
|
|
50
|
+
this._finishAuthCallback = callback;
|
|
51
|
+
}
|
|
52
|
+
get redirectUrl() {
|
|
53
|
+
// If using dynamic port assignment, return the server's actual URL
|
|
54
|
+
if (this._callbackServer) {
|
|
55
|
+
return this._callbackServer.getRedirectUrl();
|
|
56
|
+
}
|
|
57
|
+
// Otherwise use config or default
|
|
58
|
+
return this.config.redirectUrl || SimpleOAuthProvider.DEFAULT_REDIRECT_URL;
|
|
59
|
+
}
|
|
60
|
+
get clientMetadata() {
|
|
61
|
+
const redirectUrl = this.redirectUrl || SimpleOAuthProvider.DEFAULT_REDIRECT_URL;
|
|
62
|
+
const metadata = {
|
|
63
|
+
redirect_uris: [String(redirectUrl)],
|
|
64
|
+
client_name: 'CodeMode Bridge',
|
|
65
|
+
};
|
|
66
|
+
if (this.config.grantType) {
|
|
67
|
+
metadata.grant_types = [this.config.grantType];
|
|
68
|
+
}
|
|
69
|
+
return metadata;
|
|
70
|
+
}
|
|
71
|
+
clientInformation() {
|
|
72
|
+
// If client info was dynamically registered and saved, return it
|
|
73
|
+
if (this._clientInfo) {
|
|
74
|
+
return this._clientInfo;
|
|
75
|
+
}
|
|
76
|
+
// If clientId was provided in config (pre-registered), return static info
|
|
77
|
+
if (this.config.clientId) {
|
|
78
|
+
const info = {
|
|
79
|
+
client_id: this.config.clientId,
|
|
80
|
+
};
|
|
81
|
+
if (this.config.clientSecret) {
|
|
82
|
+
info.client_secret = this.config.clientSecret;
|
|
83
|
+
}
|
|
84
|
+
return info;
|
|
85
|
+
}
|
|
86
|
+
// Return undefined to trigger dynamic registration
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
saveClientInformation(clientInformation) {
|
|
90
|
+
// Save dynamically registered client information in memory
|
|
91
|
+
this._clientInfo = clientInformation;
|
|
92
|
+
// Also persist to disk
|
|
93
|
+
tokenPersistence.saveClientInformation(this.serverUrl, clientInformation);
|
|
94
|
+
logDebug(`Client dynamically registered: ${clientInformation.client_id}`, { component: 'OAuth' });
|
|
95
|
+
}
|
|
96
|
+
tokens() {
|
|
97
|
+
// First try in-memory tokens
|
|
98
|
+
if (this._tokens) {
|
|
99
|
+
logDebug('tokens() returning in-memory tokens', { component: 'OAuth' });
|
|
100
|
+
return this._tokens;
|
|
101
|
+
}
|
|
102
|
+
// Fallback to persistence if memory is empty
|
|
103
|
+
logDebug('tokens() checking persistence...', { component: 'OAuth' });
|
|
104
|
+
const persistedTokens = tokenPersistence.getTokens(this.serverUrl);
|
|
105
|
+
if (persistedTokens) {
|
|
106
|
+
this._tokens = persistedTokens;
|
|
107
|
+
logDebug(`tokens() returning persisted tokens for ${this.serverUrl}`, { component: 'OAuth' });
|
|
108
|
+
return persistedTokens;
|
|
109
|
+
}
|
|
110
|
+
logDebug('tokens() returning undefined - no tokens available', { component: 'OAuth' });
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
saveTokens(tokens) {
|
|
114
|
+
logDebug(`saveTokens() called for ${this.serverUrl}`, { component: 'OAuth' });
|
|
115
|
+
this._tokens = tokens;
|
|
116
|
+
// Also persist tokens to disk
|
|
117
|
+
tokenPersistence.saveTokens(this.serverUrl, tokens);
|
|
118
|
+
logDebug('Tokens saved to persistence', { component: 'OAuth' });
|
|
119
|
+
}
|
|
120
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
121
|
+
logDebug('=== OAuth Authorization Required ===', { component: 'OAuth' });
|
|
122
|
+
logDebug(`Server: ${this.serverUrl}`, { component: 'OAuth' });
|
|
123
|
+
logDebug('Opening browser for authorization...', { component: 'OAuth' });
|
|
124
|
+
logDebug(`URL: ${authorizationUrl.toString()}`, { component: 'OAuth' });
|
|
125
|
+
// Start the callback server to listen for the authorization code
|
|
126
|
+
const redirectUrl = this.config.redirectUrl || SimpleOAuthProvider.DEFAULT_REDIRECT_URL;
|
|
127
|
+
this._callbackServer = new OAuthCallbackServer(redirectUrl);
|
|
128
|
+
try {
|
|
129
|
+
// Start listening for callback in the background
|
|
130
|
+
// Don't await this - it will resolve when code is received
|
|
131
|
+
const authCodePromise = this._callbackServer.waitForAuthorizationCode(300000); // 5 min timeout
|
|
132
|
+
// Open in default browser using dynamic import
|
|
133
|
+
import('open').then((module) => {
|
|
134
|
+
const open = module.default;
|
|
135
|
+
open(authorizationUrl.toString()).catch((err) => {
|
|
136
|
+
logDebug('Could not open browser automatically.', { component: 'OAuth' });
|
|
137
|
+
logDebug('Please visit this URL to authorize:', { component: 'OAuth' });
|
|
138
|
+
logDebug(authorizationUrl.toString(), { component: 'OAuth' });
|
|
139
|
+
});
|
|
140
|
+
}).catch((err) => {
|
|
141
|
+
logDebug('Could not import open module.', { component: 'OAuth' });
|
|
142
|
+
logDebug('Please visit this URL to authorize:', { component: 'OAuth' });
|
|
143
|
+
logDebug(authorizationUrl.toString(), { component: 'OAuth' });
|
|
144
|
+
});
|
|
145
|
+
// Wait for the authorization code
|
|
146
|
+
const authorizationCode = await authCodePromise;
|
|
147
|
+
logDebug(`Authorization code received: ${authorizationCode.substring(0, 10)}...`, { component: 'OAuth' });
|
|
148
|
+
// Call the finish auth callback if set
|
|
149
|
+
if (this._finishAuthCallback) {
|
|
150
|
+
logDebug('Calling finishAuth with authorization code', { component: 'OAuth' });
|
|
151
|
+
await this._finishAuthCallback(authorizationCode);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
logDebug('WARNING: finishAuthCallback not set - authorization code cannot be exchanged', { component: 'OAuth' });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
159
|
+
logDebug(`OAuth authorization failed: ${errorMsg}`, { component: 'OAuth' });
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
// Clean up callback server
|
|
164
|
+
if (this._callbackServer) {
|
|
165
|
+
await this._callbackServer.stop();
|
|
166
|
+
this._callbackServer = undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
saveCodeVerifier(codeVerifier) {
|
|
171
|
+
this._codeVerifier = codeVerifier;
|
|
172
|
+
}
|
|
173
|
+
codeVerifier() {
|
|
174
|
+
if (!this._codeVerifier) {
|
|
175
|
+
throw new Error('Code verifier not available');
|
|
176
|
+
}
|
|
177
|
+
return this._codeVerifier;
|
|
178
|
+
}
|
|
179
|
+
saveDiscoveryState(state) {
|
|
180
|
+
this._discoveryState = state;
|
|
181
|
+
}
|
|
182
|
+
discoveryState() {
|
|
183
|
+
return this._discoveryState;
|
|
184
|
+
}
|
|
185
|
+
// Prepare token request for client_credentials grant
|
|
186
|
+
prepareTokenRequest(scope) {
|
|
187
|
+
if (this.config.grantType === 'client_credentials') {
|
|
188
|
+
const params = new URLSearchParams({
|
|
189
|
+
grant_type: 'client_credentials',
|
|
190
|
+
});
|
|
191
|
+
if (scope || this.config.scope) {
|
|
192
|
+
params.set('scope', scope || this.config.scope);
|
|
193
|
+
}
|
|
194
|
+
return params;
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
SimpleOAuthProvider.DEFAULT_REDIRECT_URL = 'http://localhost:3000/oauth/callback';
|
|
200
|
+
/**
|
|
201
|
+
* MCP Client wrapper using official SDK
|
|
202
|
+
*/
|
|
203
|
+
export class MCPClient {
|
|
204
|
+
constructor(config) {
|
|
205
|
+
this.config = config;
|
|
206
|
+
this.transport = null;
|
|
207
|
+
this.connected = false;
|
|
208
|
+
this.client = new Client({
|
|
209
|
+
name: `codemode-bridge-client-${config.name}`,
|
|
210
|
+
version: "1.0.0",
|
|
211
|
+
}, {
|
|
212
|
+
capabilities: {},
|
|
213
|
+
});
|
|
214
|
+
// Create OAuth provider if config is present
|
|
215
|
+
if (config.oauth && config.url) {
|
|
216
|
+
this.oauthProvider = new SimpleOAuthProvider(config.oauth, config.url);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Authenticate with OAuth server and obtain tokens without connecting the full client.
|
|
221
|
+
* This is useful for CLI auth flows where we just want to get tokens without initializing
|
|
222
|
+
* the full MCP client.
|
|
223
|
+
*/
|
|
224
|
+
async authenticateOAuth() {
|
|
225
|
+
if (!this.config.oauth || !this.config.url) {
|
|
226
|
+
throw new Error("OAuth not configured for this server");
|
|
227
|
+
}
|
|
228
|
+
if (this.config.type !== "http") {
|
|
229
|
+
throw new Error("OAuth is only supported for HTTP servers");
|
|
230
|
+
}
|
|
231
|
+
// Create OAuth provider if not already created
|
|
232
|
+
if (!this.oauthProvider) {
|
|
233
|
+
this.oauthProvider = new SimpleOAuthProvider(this.config.oauth, this.config.url);
|
|
234
|
+
}
|
|
235
|
+
const provider = this.oauthProvider;
|
|
236
|
+
const currentTokens = provider.tokens();
|
|
237
|
+
if (currentTokens) {
|
|
238
|
+
logDebug('OAuth tokens already available, skipping OAuth flow', { component: 'HTTP' });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
logDebug('No tokens available, initiating OAuth flow', { component: 'OAuth' });
|
|
242
|
+
// Create HTTP transport for OAuth flow
|
|
243
|
+
const httpTransport = new StreamableHTTPClientTransport(new URL(this.config.url), { authProvider: provider });
|
|
244
|
+
try {
|
|
245
|
+
// Set up the callback for OAuth code exchange BEFORE connecting
|
|
246
|
+
provider.setFinishAuthCallback(async (authorizationCode) => {
|
|
247
|
+
logDebug('Exchanging authorization code for tokens...', { component: 'OAuth' });
|
|
248
|
+
logDebug(`Authorization code: ${authorizationCode.substring(0, 20)}...`, { component: 'OAuth' });
|
|
249
|
+
try {
|
|
250
|
+
await httpTransport.finishAuth(authorizationCode);
|
|
251
|
+
logDebug('Authorization code exchanged successfully', { component: 'OAuth' });
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
logDebug(`Token exchange failed: ${error instanceof Error ? error.message : String(error)}`, { component: 'OAuth' });
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// Try to make a request to trigger OAuth
|
|
259
|
+
// This will fail with 401, which should trigger the auth provider's OAuth flow
|
|
260
|
+
try {
|
|
261
|
+
await httpTransport.send({
|
|
262
|
+
jsonrpc: '2.0',
|
|
263
|
+
id: 1,
|
|
264
|
+
method: 'initialize',
|
|
265
|
+
params: {
|
|
266
|
+
protocolVersion: '2024-11-05',
|
|
267
|
+
capabilities: {},
|
|
268
|
+
clientInfo: {
|
|
269
|
+
name: `codemode-bridge-oauth-${this.config.name}`,
|
|
270
|
+
version: '1.0.0',
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
// Expected - will trigger OAuth or be a real error
|
|
277
|
+
logDebug(`Initial request result: ${error instanceof Error ? error.message : String(error)}`, { component: 'OAuth' });
|
|
278
|
+
// Don't rethrow - the OAuth flow may have been triggered
|
|
279
|
+
}
|
|
280
|
+
// Wait a moment for OAuth flow to complete
|
|
281
|
+
// If tokens were saved, we're done
|
|
282
|
+
const tokensAfterOAuth = provider.tokens();
|
|
283
|
+
if (tokensAfterOAuth) {
|
|
284
|
+
logDebug('Tokens obtained successfully via OAuth', { component: 'OAuth' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
throw new Error('OAuth authentication did not produce tokens');
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
// Clean up the transport
|
|
291
|
+
if (httpTransport) {
|
|
292
|
+
try {
|
|
293
|
+
await httpTransport.close();
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
logDebug(`Error closing transport: ${e instanceof Error ? e.message : String(e)}`, { component: 'OAuth' });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Connect to the upstream MCP server
|
|
303
|
+
*/
|
|
304
|
+
async connect() {
|
|
305
|
+
if (this.connected) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (this.config.type === "stdio") {
|
|
309
|
+
if (!this.config.command) {
|
|
310
|
+
throw new Error(`stdio type requires "command" field`);
|
|
311
|
+
}
|
|
312
|
+
// Merge provided env vars with current process env, filtering out undefined values
|
|
313
|
+
const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([, v]) => v !== undefined));
|
|
314
|
+
const env = this.config.env ? { ...baseEnv, ...this.config.env } : baseEnv;
|
|
315
|
+
// Create stdio transport with stderr piped so we can capture and log it
|
|
316
|
+
const stdioTransport = new StdioClientTransport({
|
|
317
|
+
command: this.config.command,
|
|
318
|
+
args: this.config.args || [],
|
|
319
|
+
env,
|
|
320
|
+
stderr: 'pipe',
|
|
321
|
+
});
|
|
322
|
+
// Attach stderr handler to capture tool output and pass through logger
|
|
323
|
+
if (stdioTransport.stderr) {
|
|
324
|
+
stdioTransport.stderr.on('data', (data) => {
|
|
325
|
+
const lines = data.toString().split('\n').filter(line => line.trim());
|
|
326
|
+
for (const line of lines) {
|
|
327
|
+
logInfo(line, { component: this.config.name, suppressEarly: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
stdioTransport.stderr.on('error', (error) => {
|
|
331
|
+
logDebug(`stderr error from ${this.config.name}: ${error.message}`, {
|
|
332
|
+
component: this.config.name
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
this.transport = stdioTransport;
|
|
337
|
+
await this.client.connect(this.transport);
|
|
338
|
+
this.connected = true;
|
|
339
|
+
}
|
|
340
|
+
else if (this.config.type === "http") {
|
|
341
|
+
if (!this.config.url) {
|
|
342
|
+
throw new Error(`http type requires "url" field`);
|
|
343
|
+
}
|
|
344
|
+
// Create HTTP transport using Streamable HTTP with optional OAuth
|
|
345
|
+
if (this.oauthProvider) {
|
|
346
|
+
logDebug(`Connecting with OAuth provider to ${this.config.url}`, { component: 'HTTP' });
|
|
347
|
+
const currentTokens = this.oauthProvider.tokens();
|
|
348
|
+
if (currentTokens) {
|
|
349
|
+
logDebug('OAuth tokens available', { component: 'HTTP' });
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
logDebug('WARNING: No OAuth tokens available', { component: 'HTTP' });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
logDebug(`Connecting without OAuth to ${this.config.url}`, { component: 'HTTP' });
|
|
357
|
+
}
|
|
358
|
+
// Create HTTP transport
|
|
359
|
+
const httpTransport = new StreamableHTTPClientTransport(new URL(this.config.url), this.oauthProvider ? { authProvider: this.oauthProvider } : undefined);
|
|
360
|
+
// Set up the callback for OAuth code exchange
|
|
361
|
+
if (this.oauthProvider) {
|
|
362
|
+
this.oauthProvider.setFinishAuthCallback(async (authorizationCode) => {
|
|
363
|
+
logDebug('Exchanging authorization code for tokens...', { component: 'OAuth' });
|
|
364
|
+
await httpTransport.finishAuth(authorizationCode);
|
|
365
|
+
logDebug('Authorization code exchanged successfully', { component: 'OAuth' });
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
this.transport = httpTransport;
|
|
369
|
+
await this.client.connect(this.transport);
|
|
370
|
+
this.connected = true;
|
|
371
|
+
}
|
|
372
|
+
else if (this.config.type === "sse") {
|
|
373
|
+
if (!this.config.url) {
|
|
374
|
+
throw new Error(`sse type requires "url" field`);
|
|
375
|
+
}
|
|
376
|
+
// Create SSE transport (deprecated but still supported) with optional OAuth
|
|
377
|
+
this.transport = new SSEClientTransport(new URL(this.config.url), this.oauthProvider ? { authProvider: this.oauthProvider } : undefined);
|
|
378
|
+
await this.client.connect(this.transport);
|
|
379
|
+
this.connected = true;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
throw new Error(`Unsupported transport type: ${this.config.type}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* List all tools from the upstream server
|
|
387
|
+
* Returns tools in native MCP format with JSON Schema
|
|
388
|
+
*/
|
|
389
|
+
async listTools() {
|
|
390
|
+
if (!this.connected) {
|
|
391
|
+
throw new Error("Client not connected. Call connect() first.");
|
|
392
|
+
}
|
|
393
|
+
const response = await this.client.listTools();
|
|
394
|
+
return response.tools;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Call a tool on the upstream server
|
|
398
|
+
*/
|
|
399
|
+
async callTool(name, args) {
|
|
400
|
+
if (!this.connected) {
|
|
401
|
+
throw new Error("Client not connected. Call connect() first.");
|
|
402
|
+
}
|
|
403
|
+
logDebug(`Invoking upstream tool: ${name}`, {
|
|
404
|
+
component: 'MCP Client',
|
|
405
|
+
transport: this.config.type,
|
|
406
|
+
server: this.config.name,
|
|
407
|
+
argsSize: JSON.stringify(args).length
|
|
408
|
+
});
|
|
409
|
+
try {
|
|
410
|
+
const response = await this.client.callTool({
|
|
411
|
+
name,
|
|
412
|
+
arguments: args,
|
|
413
|
+
});
|
|
414
|
+
logDebug(`Upstream tool completed: ${name}`, {
|
|
415
|
+
component: 'MCP Client',
|
|
416
|
+
transport: this.config.type,
|
|
417
|
+
server: this.config.name,
|
|
418
|
+
resultType: typeof response
|
|
419
|
+
});
|
|
420
|
+
return response;
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
logDebug(`Upstream tool failed: ${name}`, {
|
|
424
|
+
component: 'MCP Client',
|
|
425
|
+
transport: this.config.type,
|
|
426
|
+
server: this.config.name,
|
|
427
|
+
error: error instanceof Error ? error.message : String(error)
|
|
428
|
+
});
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Close the connection to the upstream server
|
|
434
|
+
*/
|
|
435
|
+
async close() {
|
|
436
|
+
if (this.connected && this.client) {
|
|
437
|
+
await this.client.close();
|
|
438
|
+
this.connected = false;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 Authorization Handler
|
|
3
|
+
*
|
|
4
|
+
* Implements a loopback HTTP server to handle OAuth2 redirect callbacks.
|
|
5
|
+
* When user completes authorization on the OAuth provider, the browser redirects
|
|
6
|
+
* to our loopback server with the authorization code, which we capture and use
|
|
7
|
+
* to complete the authorization flow.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Loopback HTTP server that listens for OAuth2 redirect callbacks
|
|
11
|
+
*/
|
|
12
|
+
export declare class OAuthCallbackServer {
|
|
13
|
+
private server?;
|
|
14
|
+
private port;
|
|
15
|
+
private host;
|
|
16
|
+
private pendingAuthorization?;
|
|
17
|
+
constructor(redirectUrl?: string);
|
|
18
|
+
/**
|
|
19
|
+
* Start the callback server and wait for authorization code
|
|
20
|
+
* Returns a promise that resolves when the authorization code is received
|
|
21
|
+
* or rejects if timeout occurs or server fails to start
|
|
22
|
+
*/
|
|
23
|
+
waitForAuthorizationCode(timeoutMs?: number): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Stop the callback server
|
|
26
|
+
*/
|
|
27
|
+
stop(): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Get the redirect URL for this server
|
|
30
|
+
* After waitForAuthorizationCode is called, returns the actual URL with the assigned port
|
|
31
|
+
*/
|
|
32
|
+
getRedirectUrl(): string;
|
|
33
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 Authorization Handler
|
|
3
|
+
*
|
|
4
|
+
* Implements a loopback HTTP server to handle OAuth2 redirect callbacks.
|
|
5
|
+
* When user completes authorization on the OAuth provider, the browser redirects
|
|
6
|
+
* to our loopback server with the authorization code, which we capture and use
|
|
7
|
+
* to complete the authorization flow.
|
|
8
|
+
*/
|
|
9
|
+
import { createServer } from 'http';
|
|
10
|
+
import { URL } from 'url';
|
|
11
|
+
/**
|
|
12
|
+
* Loopback HTTP server that listens for OAuth2 redirect callbacks
|
|
13
|
+
*/
|
|
14
|
+
export class OAuthCallbackServer {
|
|
15
|
+
constructor(redirectUrl) {
|
|
16
|
+
this.port = 0; // 0 means OS will assign an available port
|
|
17
|
+
this.host = 'localhost';
|
|
18
|
+
// Parse redirect URL to extract host and port
|
|
19
|
+
if (redirectUrl) {
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(redirectUrl);
|
|
22
|
+
this.host = url.hostname || 'localhost';
|
|
23
|
+
// If a specific port is provided in the URL, use it
|
|
24
|
+
// Otherwise use 0 (OS will assign available port)
|
|
25
|
+
this.port = url.port ? parseInt(url.port) : 0;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
this.port = 0;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
this.port = 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Start the callback server and wait for authorization code
|
|
37
|
+
* Returns a promise that resolves when the authorization code is received
|
|
38
|
+
* or rejects if timeout occurs or server fails to start
|
|
39
|
+
*/
|
|
40
|
+
async waitForAuthorizationCode(timeoutMs = 300000) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
// Create HTTP server
|
|
43
|
+
this.server = createServer((req, res) => {
|
|
44
|
+
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
|
45
|
+
const code = url.searchParams.get('code');
|
|
46
|
+
const error = url.searchParams.get('error');
|
|
47
|
+
const errorDescription = url.searchParams.get('error_description');
|
|
48
|
+
if (error) {
|
|
49
|
+
// OAuth error from authorization server
|
|
50
|
+
const errorMsg = errorDescription
|
|
51
|
+
? `${error}: ${errorDescription}`
|
|
52
|
+
: `OAuth error: ${error}`;
|
|
53
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
54
|
+
res.end(`
|
|
55
|
+
<html>
|
|
56
|
+
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
|
57
|
+
<h1>Authorization Failed</h1>
|
|
58
|
+
<p>${errorMsg}</p>
|
|
59
|
+
<p>You can close this window.</p>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
`);
|
|
63
|
+
if (this.pendingAuthorization) {
|
|
64
|
+
this.pendingAuthorization.reject(new Error(errorMsg));
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (code) {
|
|
69
|
+
// Success - authorization code received
|
|
70
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
71
|
+
res.end(`
|
|
72
|
+
<html>
|
|
73
|
+
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
|
|
74
|
+
<h1>Authorization Successful</h1>
|
|
75
|
+
<p>You have successfully authorized the Code Mode Bridge.</p>
|
|
76
|
+
<p>You can close this window and return to your terminal.</p>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
79
|
+
`);
|
|
80
|
+
if (this.pendingAuthorization) {
|
|
81
|
+
this.pendingAuthorization.resolve(code);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Unknown request
|
|
86
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
87
|
+
res.end('Invalid OAuth callback request');
|
|
88
|
+
});
|
|
89
|
+
// Set up timeout
|
|
90
|
+
const timeout = setTimeout(() => {
|
|
91
|
+
reject(new Error(`Authorization timeout after ${timeoutMs}ms`));
|
|
92
|
+
this.stop();
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
// Start listening on available port
|
|
95
|
+
this.server.listen(this.port, this.host, () => {
|
|
96
|
+
// Get the actual port assigned by the OS (in case we used 0)
|
|
97
|
+
const addr = this.server.address();
|
|
98
|
+
if (addr && typeof addr === 'object') {
|
|
99
|
+
this.port = addr.port;
|
|
100
|
+
}
|
|
101
|
+
// Store the promise handlers for when request arrives
|
|
102
|
+
this.pendingAuthorization = {
|
|
103
|
+
resolve,
|
|
104
|
+
reject: (error) => {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
reject(error);
|
|
107
|
+
this.stop();
|
|
108
|
+
},
|
|
109
|
+
timeout,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
this.server.on('error', (error) => {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
reject(error);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Stop the callback server
|
|
120
|
+
*/
|
|
121
|
+
async stop() {
|
|
122
|
+
if (this.server) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
this.server.close(() => {
|
|
125
|
+
this.server = undefined;
|
|
126
|
+
resolve();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get the redirect URL for this server
|
|
133
|
+
* After waitForAuthorizationCode is called, returns the actual URL with the assigned port
|
|
134
|
+
*/
|
|
135
|
+
getRedirectUrl() {
|
|
136
|
+
return `http://${this.host}:${this.port}/oauth/callback`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server - Exposes the Code Mode bridge as an MCP server
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* - Upstream: Use official MCP SDK's Client to connect to and collect tools from other MCP servers
|
|
6
|
+
* - Orchestration: Pass collected tools to codemode SDK's createCodeTool()
|
|
7
|
+
* - Downstream: Use MCP SDK to expose the codemode tool via MCP protocol (stdio transport)
|
|
8
|
+
*
|
|
9
|
+
* This server:
|
|
10
|
+
* 1. Connects to upstream MCP servers using official MCP SDK Client
|
|
11
|
+
* 2. Collects tools from all upstream servers in native MCP format (JSON Schema)
|
|
12
|
+
* 3. Converts tools to ToolDescriptor format (with Zod schemas)
|
|
13
|
+
* 4. Uses @cloudflare/codemode SDK to create the "codemode" tool with those tools
|
|
14
|
+
* 5. Adapts the codemode SDK's AI SDK Tool to MCP protocol using a shim layer
|
|
15
|
+
* 6. Exposes the "codemode" tool via MCP protocol downstream
|
|
16
|
+
*/
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { type MCPServerConfig } from "./mcp-client.js";
|
|
19
|
+
export type { MCPServerConfig };
|
|
20
|
+
/**
|
|
21
|
+
* Convert JSON Schema to Zod schema
|
|
22
|
+
* MCP tools use JSON Schema, but createCodeTool expects Zod schemas
|
|
23
|
+
*/
|
|
24
|
+
export declare function jsonSchemaToZod(schema: any): z.ZodType<any>;
|
|
25
|
+
export declare function startCodeModeBridgeServer(serverConfigs: MCPServerConfig[]): Promise<void>;
|