@phuetz/code-buddy 2.1.0 → 2.2.0
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.fr.md +2 -2
- package/README.md +3 -2
- package/codebuddy-runtime.json +4 -4
- package/dist/agent/execution/agent-executor.js +16 -5
- package/dist/agent/execution/tool-selection-strategy.d.ts +7 -0
- package/dist/agent/execution/tool-selection-strategy.js +13 -0
- package/dist/codebuddy/fleet-tool-defs.d.ts +1 -0
- package/dist/codebuddy/fleet-tool-defs.js +31 -0
- package/dist/codebuddy/providers/provider-openai-compat.d.ts +7 -0
- package/dist/codebuddy/providers/provider-openai-compat.js +28 -0
- package/dist/commands/handlers/missing-handlers.js +8 -0
- package/dist/commands/mcp.d.ts +13 -0
- package/dist/commands/mcp.js +62 -21
- package/dist/config/model-tools.js +99 -0
- package/dist/doctor/integrations.d.ts +18 -0
- package/dist/doctor/integrations.js +14 -0
- package/dist/errors/crash-handler.js +2 -0
- package/dist/mcp/client.d.ts +11 -0
- package/dist/mcp/client.js +62 -3
- package/dist/mcp/mcp-oauth-constants.d.ts +8 -0
- package/dist/mcp/mcp-oauth-constants.js +9 -0
- package/dist/mcp/mcp-oauth-provider.d.ts +80 -0
- package/dist/mcp/mcp-oauth-provider.js +241 -0
- package/dist/mcp/mcp-oauth.d.ts +60 -1
- package/dist/mcp/mcp-oauth.js +241 -65
- package/dist/mcp/transports.d.ts +4 -0
- package/dist/mcp/transports.js +52 -3
- package/dist/services/prompt-builder.js +3 -1
- package/dist/services/runtime-settings-context.d.ts +8 -0
- package/dist/services/runtime-settings-context.js +11 -1
- package/dist/tools/metadata.js +9 -0
- package/dist/tools/peer-tool-invoke-tool.d.ts +61 -0
- package/dist/tools/peer-tool-invoke-tool.js +396 -0
- package/dist/tools/registry/fleet-tools.d.ts +12 -3
- package/dist/tools/registry/fleet-tools.js +116 -4
- package/dist/tools/registry/index.d.ts +1 -1
- package/dist/tools/registry/index.js +2 -2
- package/dist/utils/config-validation/schema.d.ts +2 -2
- package/dist/utils/graceful-shutdown.d.ts +8 -0
- package/dist/utils/graceful-shutdown.js +23 -7
- package/package.json +1 -1
package/dist/mcp/mcp-oauth.js
CHANGED
|
@@ -11,6 +11,29 @@ import * as path from 'path';
|
|
|
11
11
|
import { URL } from 'url';
|
|
12
12
|
import { logger } from '../utils/logger.js';
|
|
13
13
|
import { readTextAtomicSync, writeFileAtomicSync } from '../utils/atomic-write.js';
|
|
14
|
+
import { SDK_MANAGED_TOKEN_URL } from './mcp-oauth-constants.js';
|
|
15
|
+
function storedClientFrom(info) {
|
|
16
|
+
const client = { client_id: info.client_id };
|
|
17
|
+
if (info.client_secret)
|
|
18
|
+
client.client_secret = info.client_secret;
|
|
19
|
+
if (info.client_id_issued_at !== undefined)
|
|
20
|
+
client.client_id_issued_at = info.client_id_issued_at;
|
|
21
|
+
if (info.client_secret_expires_at !== undefined)
|
|
22
|
+
client.client_secret_expires_at = info.client_secret_expires_at;
|
|
23
|
+
if (info.redirect_uris?.length)
|
|
24
|
+
client.redirect_uris = [...info.redirect_uris];
|
|
25
|
+
if (info.token_endpoint_auth_method)
|
|
26
|
+
client.token_endpoint_auth_method = info.token_endpoint_auth_method;
|
|
27
|
+
if (info.grant_types?.length)
|
|
28
|
+
client.grant_types = [...info.grant_types];
|
|
29
|
+
if (info.response_types?.length)
|
|
30
|
+
client.response_types = [...info.response_types];
|
|
31
|
+
if (info.client_name)
|
|
32
|
+
client.client_name = info.client_name;
|
|
33
|
+
if (info.scope)
|
|
34
|
+
client.scope = info.scope;
|
|
35
|
+
return client;
|
|
36
|
+
}
|
|
14
37
|
// ============================================================================
|
|
15
38
|
// PKCE (RFC 7636)
|
|
16
39
|
// ============================================================================
|
|
@@ -35,7 +58,9 @@ export function generateCodeChallenge(verifier) {
|
|
|
35
58
|
const ALGORITHM = 'aes-256-gcm';
|
|
36
59
|
const SALT_LENGTH = 32;
|
|
37
60
|
function getEncryptionKey() {
|
|
38
|
-
//
|
|
61
|
+
// Prefer CODEBUDDY_VAULT_KEY or CODEBUDDY_MCP_KEY. The USER+platform fallback
|
|
62
|
+
// is derivable by anyone on the same account: AES-GCM still runs, but that is
|
|
63
|
+
// not a strong vault. Set an explicit key for real confidentiality.
|
|
39
64
|
return process.env.CODEBUDDY_VAULT_KEY
|
|
40
65
|
|| process.env.CODEBUDDY_MCP_KEY
|
|
41
66
|
|| `mcp-oauth-${process.env.USER || process.env.USERNAME || 'default'}-${process.platform}`;
|
|
@@ -101,63 +126,153 @@ function saveTokenStore(store) {
|
|
|
101
126
|
const encrypted = encryptData(JSON.stringify(store));
|
|
102
127
|
writeFileAtomicSync(filePath, encrypted, { mode: 0o600 });
|
|
103
128
|
}
|
|
129
|
+
export class OAuthCallbackCancelledError extends Error {
|
|
130
|
+
constructor(message = 'OAuth authorization cancelled') {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = 'OAuthCallbackCancelledError';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
104
135
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
136
|
+
* Bind the loopback callback server. Callers must await `listening` before
|
|
137
|
+
* opening a browser: a bind failure must not leave an unhandled rejection
|
|
138
|
+
* nor launch a flow that can never complete.
|
|
107
139
|
*/
|
|
108
|
-
function
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
export function startCallbackSession(redirectUri, expectedState, timeoutMs = 120_000, signal) {
|
|
141
|
+
const url = new URL(redirectUri);
|
|
142
|
+
const port = parseInt(url.port || '19836', 10);
|
|
143
|
+
const pathname = url.pathname || '/callback';
|
|
144
|
+
let listenResolve;
|
|
145
|
+
let listenReject;
|
|
146
|
+
let resultResolve;
|
|
147
|
+
let resultReject;
|
|
148
|
+
let listeningSettled = false;
|
|
149
|
+
let resultSettled = false;
|
|
150
|
+
let timer;
|
|
151
|
+
let server;
|
|
152
|
+
const listening = new Promise((resolve, reject) => {
|
|
153
|
+
listenResolve = resolve;
|
|
154
|
+
listenReject = reject;
|
|
155
|
+
});
|
|
156
|
+
const result = new Promise((resolve, reject) => {
|
|
157
|
+
resultResolve = resolve;
|
|
158
|
+
resultReject = reject;
|
|
159
|
+
});
|
|
160
|
+
// Attach observers at construction so a pre-aborted signal cannot leave an
|
|
161
|
+
// unhandledRejection on the public startCallbackServer wrapper (the early
|
|
162
|
+
// return used to skip the .catch() that lived after listen()).
|
|
163
|
+
listening.catch(() => undefined);
|
|
164
|
+
result.catch(() => undefined);
|
|
165
|
+
const settleListen = (err) => {
|
|
166
|
+
if (listeningSettled)
|
|
167
|
+
return;
|
|
168
|
+
listeningSettled = true;
|
|
169
|
+
if (err)
|
|
170
|
+
listenReject(err);
|
|
171
|
+
else
|
|
172
|
+
listenResolve();
|
|
173
|
+
};
|
|
174
|
+
const settleResult = (value, err) => {
|
|
175
|
+
if (resultSettled)
|
|
176
|
+
return;
|
|
177
|
+
resultSettled = true;
|
|
178
|
+
if (timer)
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
if (err)
|
|
181
|
+
resultReject(err);
|
|
182
|
+
else
|
|
183
|
+
resultResolve(value);
|
|
184
|
+
};
|
|
185
|
+
let abort;
|
|
186
|
+
const onAbort = () => abort(new OAuthCallbackCancelledError());
|
|
187
|
+
const detachAbort = () => signal?.removeEventListener('abort', onAbort);
|
|
188
|
+
abort = (reason) => {
|
|
189
|
+
detachAbort();
|
|
190
|
+
const fail = reason ?? new OAuthCallbackCancelledError();
|
|
191
|
+
if (!listeningSettled)
|
|
192
|
+
settleListen(fail);
|
|
193
|
+
if (!resultSettled)
|
|
194
|
+
settleResult(undefined, fail);
|
|
195
|
+
if (timer) {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
timer = undefined;
|
|
198
|
+
}
|
|
199
|
+
if (server) {
|
|
200
|
+
server.removeAllListeners('error');
|
|
201
|
+
const closing = server;
|
|
202
|
+
server = undefined;
|
|
203
|
+
closing.close();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if (signal?.aborted) {
|
|
207
|
+
abort(new OAuthCallbackCancelledError());
|
|
208
|
+
return { listening, result, abort };
|
|
209
|
+
}
|
|
210
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
211
|
+
timer = setTimeout(() => {
|
|
212
|
+
abort(new Error('OAuth callback timed out (120s)'));
|
|
213
|
+
detachAbort();
|
|
214
|
+
}, timeoutMs);
|
|
215
|
+
server = http.createServer((req, res) => {
|
|
216
|
+
const reqUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
|
217
|
+
if (reqUrl.pathname !== pathname) {
|
|
218
|
+
res.writeHead(404);
|
|
219
|
+
res.end('Not found');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const code = reqUrl.searchParams.get('code');
|
|
223
|
+
const state = reqUrl.searchParams.get('state');
|
|
224
|
+
const error = reqUrl.searchParams.get('error');
|
|
225
|
+
if (error) {
|
|
142
226
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
143
|
-
res.end('<html><body><h1>Authorization
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
227
|
+
res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
|
|
228
|
+
detachAbort();
|
|
229
|
+
abort(new Error(`OAuth authorization error: ${error}`));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (!code) {
|
|
233
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
234
|
+
res.end('<html><body><h1>Missing authorization code</h1></body></html>');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (state !== expectedState) {
|
|
238
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
239
|
+
res.end('<html><body><h1>State mismatch — possible CSRF attack</h1></body></html>');
|
|
240
|
+
detachAbort();
|
|
241
|
+
abort(new Error('OAuth state mismatch'));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
245
|
+
res.end('<html><body><h1>Authorization Successful</h1><p>You can close this window and return to the terminal.</p></body></html>');
|
|
246
|
+
detachAbort();
|
|
247
|
+
if (timer) {
|
|
154
248
|
clearTimeout(timer);
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
249
|
+
timer = undefined;
|
|
250
|
+
}
|
|
251
|
+
const closing = server;
|
|
252
|
+
server = undefined;
|
|
253
|
+
closing?.removeAllListeners('error');
|
|
254
|
+
closing?.close();
|
|
255
|
+
settleListen();
|
|
256
|
+
settleResult({ code, state });
|
|
257
|
+
});
|
|
258
|
+
server.on('error', (err) => {
|
|
259
|
+
detachAbort();
|
|
260
|
+
abort(new Error(`Failed to start callback server: ${err.message}`));
|
|
160
261
|
});
|
|
262
|
+
server.listen(port, '127.0.0.1', () => {
|
|
263
|
+
logger.debug(`OAuth callback server listening on port ${port}`);
|
|
264
|
+
settleListen();
|
|
265
|
+
});
|
|
266
|
+
return { listening, result, abort };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Start a temporary local HTTP server to receive the OAuth callback.
|
|
270
|
+
* Awaits a successful listen before waiting for the code.
|
|
271
|
+
*/
|
|
272
|
+
export async function startCallbackServer(redirectUri, expectedState, timeoutMs = 120_000, signal) {
|
|
273
|
+
const session = startCallbackSession(redirectUri, expectedState, timeoutMs, signal);
|
|
274
|
+
await session.listening;
|
|
275
|
+
return session.result;
|
|
161
276
|
}
|
|
162
277
|
// ============================================================================
|
|
163
278
|
// Browser Opener
|
|
@@ -165,7 +280,7 @@ function startCallbackServer(redirectUri, expectedState, timeoutMs = 120_000) {
|
|
|
165
280
|
/**
|
|
166
281
|
* Open a URL in the default browser (cross-platform)
|
|
167
282
|
*/
|
|
168
|
-
async function openBrowser(url) {
|
|
283
|
+
export async function openBrowser(url) {
|
|
169
284
|
const { exec } = await import('child_process');
|
|
170
285
|
const command = process.platform === 'win32'
|
|
171
286
|
? `start "" "${url}"`
|
|
@@ -244,9 +359,11 @@ export class MCPOAuthManager {
|
|
|
244
359
|
authUrl.searchParams.set('state', state);
|
|
245
360
|
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
246
361
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
//
|
|
362
|
+
// Bind the loopback callback before opening the browser: a listen failure
|
|
363
|
+
// must not launch a flow that can never complete (same contract as the SDK
|
|
364
|
+
// provider path). Keep the manual URL if the opener fails.
|
|
365
|
+
const session = startCallbackSession(redirectUri, state);
|
|
366
|
+
await session.listening;
|
|
250
367
|
logger.info('Opening browser for OAuth authorization...');
|
|
251
368
|
try {
|
|
252
369
|
await openBrowser(authUrl.toString());
|
|
@@ -254,8 +371,7 @@ export class MCPOAuthManager {
|
|
|
254
371
|
catch {
|
|
255
372
|
logger.info(`Please open this URL in your browser:\n${authUrl.toString()}`);
|
|
256
373
|
}
|
|
257
|
-
|
|
258
|
-
const { code } = await callbackPromise;
|
|
374
|
+
const { code } = await session.result;
|
|
259
375
|
// Exchange code for token
|
|
260
376
|
const token = await exchangeCodeForToken(config, code, codeVerifier, redirectUri);
|
|
261
377
|
// Store token
|
|
@@ -306,14 +422,20 @@ export class MCPOAuthManager {
|
|
|
306
422
|
// Load from disk
|
|
307
423
|
const store = loadTokenStore();
|
|
308
424
|
const entry = store[serverId];
|
|
309
|
-
|
|
425
|
+
const token = entry?.token;
|
|
426
|
+
if (!token?.accessToken || !entry)
|
|
310
427
|
return null;
|
|
311
|
-
const {
|
|
428
|
+
const { config } = entry;
|
|
312
429
|
// If token is still valid (with 60s buffer), return it
|
|
313
430
|
if (token.expiresAt > Date.now() + 60_000) {
|
|
314
431
|
this.tokenCache.set(serverId, token);
|
|
315
432
|
return token.accessToken;
|
|
316
433
|
}
|
|
434
|
+
// Entries written by the SDK-backed provider are refreshed by the SDK itself.
|
|
435
|
+
if (config.tokenUrl === SDK_MANAGED_TOKEN_URL) {
|
|
436
|
+
logger.debug(`MCP OAuth token for ${serverId} is SDK-managed; refresh happens on the next transport connection`);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
317
439
|
// Try to refresh
|
|
318
440
|
if (token.refreshToken) {
|
|
319
441
|
try {
|
|
@@ -340,17 +462,42 @@ export class MCPOAuthManager {
|
|
|
340
462
|
return null;
|
|
341
463
|
}
|
|
342
464
|
/**
|
|
343
|
-
* Store a token encrypted on disk and in memory cache
|
|
465
|
+
* Store a token encrypted on disk and in memory cache.
|
|
466
|
+
* Preserves any previously persisted client metadata for the same server.
|
|
344
467
|
*/
|
|
345
468
|
storeToken(serverId, token, config) {
|
|
346
469
|
this.tokenCache.set(serverId, token);
|
|
347
470
|
const store = loadTokenStore();
|
|
471
|
+
const prev = store[serverId];
|
|
472
|
+
const clientId = config.clientId || prev?.config.clientId || prev?.client?.client_id || '';
|
|
473
|
+
const client = prev?.client
|
|
474
|
+
? storedClientFrom({ ...prev.client, ...(clientId ? { client_id: clientId } : {}) })
|
|
475
|
+
: (clientId ? storedClientFrom({ client_id: clientId }) : undefined);
|
|
348
476
|
store[serverId] = {
|
|
349
477
|
token,
|
|
350
478
|
config: {
|
|
351
|
-
clientId
|
|
352
|
-
tokenUrl: config.tokenUrl,
|
|
479
|
+
clientId,
|
|
480
|
+
tokenUrl: config.tokenUrl || prev?.config.tokenUrl || SDK_MANAGED_TOKEN_URL,
|
|
481
|
+
},
|
|
482
|
+
...(client ? { client } : {}),
|
|
483
|
+
};
|
|
484
|
+
saveTokenStore(store);
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Persist client registration (client_id and optional DCR metadata) in the
|
|
488
|
+
* encrypted store, even before a token exists. Never writes plaintext.
|
|
489
|
+
*/
|
|
490
|
+
storeClientInformation(serverId, info) {
|
|
491
|
+
const client = storedClientFrom(info);
|
|
492
|
+
const store = loadTokenStore();
|
|
493
|
+
const prev = store[serverId];
|
|
494
|
+
store[serverId] = {
|
|
495
|
+
...(prev?.token ? { token: prev.token } : {}),
|
|
496
|
+
config: {
|
|
497
|
+
clientId: client.client_id,
|
|
498
|
+
tokenUrl: prev?.config.tokenUrl ?? SDK_MANAGED_TOKEN_URL,
|
|
353
499
|
},
|
|
500
|
+
client,
|
|
354
501
|
};
|
|
355
502
|
saveTokenStore(store);
|
|
356
503
|
}
|
|
@@ -363,12 +510,41 @@ export class MCPOAuthManager {
|
|
|
363
510
|
delete store[serverId];
|
|
364
511
|
saveTokenStore(store);
|
|
365
512
|
}
|
|
513
|
+
/** Drop access/refresh tokens only; keep client_id and DCR metadata. */
|
|
514
|
+
clearTokens(serverId) {
|
|
515
|
+
this.tokenCache.delete(serverId);
|
|
516
|
+
const store = loadTokenStore();
|
|
517
|
+
const prev = store[serverId];
|
|
518
|
+
if (!prev)
|
|
519
|
+
return;
|
|
520
|
+
const { token: _dropped, ...rest } = prev;
|
|
521
|
+
store[serverId] = rest;
|
|
522
|
+
if (!rest.client && !rest.config?.clientId)
|
|
523
|
+
delete store[serverId];
|
|
524
|
+
saveTokenStore(store);
|
|
525
|
+
}
|
|
366
526
|
/**
|
|
367
527
|
* Check if a token exists for a server (may be expired)
|
|
368
528
|
*/
|
|
529
|
+
/** Stored token and client id for a server, without refresh side effects. */
|
|
530
|
+
getStoredToken(serverId) {
|
|
531
|
+
const entry = loadTokenStore()[serverId];
|
|
532
|
+
if (!entry?.token?.accessToken)
|
|
533
|
+
return null;
|
|
534
|
+
return { token: entry.token, clientId: entry.config.clientId, ...(entry.client ? { client: entry.client } : {}) };
|
|
535
|
+
}
|
|
536
|
+
/** Persisted client id / DCR metadata, independent of whether a token exists. */
|
|
537
|
+
getStoredClientInformation(serverId) {
|
|
538
|
+
const entry = loadTokenStore()[serverId];
|
|
539
|
+
if (entry?.client?.client_id)
|
|
540
|
+
return entry.client;
|
|
541
|
+
if (entry?.config.clientId)
|
|
542
|
+
return { client_id: entry.config.clientId };
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
369
545
|
hasToken(serverId) {
|
|
370
546
|
const store = loadTokenStore();
|
|
371
|
-
return serverId
|
|
547
|
+
return Boolean(store[serverId]?.token?.accessToken);
|
|
372
548
|
}
|
|
373
549
|
}
|
|
374
550
|
// ============================================================================
|
package/dist/mcp/transports.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
2
|
+
import { type MCPTransportOAuthConfig } from "./mcp-oauth-provider.js";
|
|
2
3
|
import { EventEmitter } from "events";
|
|
3
4
|
export type TransportType = 'stdio' | 'http' | 'sse' | 'sse_sdk' | 'legacy_rpc' | 'streamable_http';
|
|
4
5
|
export interface TransportConfig {
|
|
@@ -11,6 +12,8 @@ export interface TransportConfig {
|
|
|
11
12
|
env?: Record<string, string>;
|
|
12
13
|
url?: string;
|
|
13
14
|
headers?: Record<string, string>;
|
|
15
|
+
/** OAuth bearer for HTTP transports (SDK discovery + PKCE, tokens in .codebuddy/mcp-tokens.json). */
|
|
16
|
+
auth?: MCPTransportOAuthConfig;
|
|
14
17
|
}
|
|
15
18
|
export interface MCPTransport {
|
|
16
19
|
connect(): Promise<Transport>;
|
|
@@ -45,6 +48,7 @@ export declare class SSETransport implements MCPTransport {
|
|
|
45
48
|
export declare class StreamableHttpTransport extends EventEmitter implements MCPTransport {
|
|
46
49
|
private config;
|
|
47
50
|
private transport?;
|
|
51
|
+
private oauthProvider?;
|
|
48
52
|
constructor(config: TransportConfig);
|
|
49
53
|
connect(): Promise<Transport>;
|
|
50
54
|
disconnect(): Promise<void>;
|
package/dist/mcp/transports.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SSEClientTransport as SDKSSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
2
2
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
3
|
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
4
|
+
import { createOAuthProvider } from "./mcp-oauth-provider.js";
|
|
4
5
|
import { EventEmitter } from "events";
|
|
5
6
|
import axios from "axios";
|
|
6
7
|
import { logger } from '../utils/logger.js';
|
|
@@ -49,10 +50,14 @@ export class StdioTransport {
|
|
|
49
50
|
}
|
|
50
51
|
async disconnect() {
|
|
51
52
|
if (this.transport) {
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
try {
|
|
54
|
+
await this.transport.close();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
this.transport = undefined;
|
|
58
|
+
}
|
|
54
59
|
}
|
|
55
|
-
//
|
|
60
|
+
// SDK close() ends stdin, then SIGTERM/SIGKILL the child it spawned.
|
|
56
61
|
}
|
|
57
62
|
getType() {
|
|
58
63
|
return 'stdio';
|
|
@@ -156,6 +161,7 @@ class HttpClientTransport extends EventEmitter {
|
|
|
156
161
|
export class StreamableHttpTransport extends EventEmitter {
|
|
157
162
|
config;
|
|
158
163
|
transport;
|
|
164
|
+
oauthProvider;
|
|
159
165
|
constructor(config) {
|
|
160
166
|
super();
|
|
161
167
|
this.config = config;
|
|
@@ -167,12 +173,18 @@ export class StreamableHttpTransport extends EventEmitter {
|
|
|
167
173
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
168
174
|
throw new Error('Streamable HTTP MCP requires an HTTP(S) URL');
|
|
169
175
|
}
|
|
176
|
+
const provider = this.config.auth?.type === 'oauth' ? createOAuthProvider(this.config.url, this.config.auth) : undefined;
|
|
177
|
+
this.oauthProvider = provider;
|
|
170
178
|
this.transport = new StreamableHTTPClientTransport(url, {
|
|
171
179
|
requestInit: { headers: this.config.headers, redirect: 'error' },
|
|
180
|
+
...(provider ? { authProvider: provider } : {}),
|
|
172
181
|
});
|
|
182
|
+
provider?.attachTransport(this.transport);
|
|
173
183
|
return this.transport;
|
|
174
184
|
}
|
|
175
185
|
async disconnect() {
|
|
186
|
+
this.oauthProvider?.abortAuthorization();
|
|
187
|
+
this.oauthProvider = undefined;
|
|
176
188
|
const transport = this.transport;
|
|
177
189
|
this.transport = undefined;
|
|
178
190
|
await transport?.close();
|
|
@@ -203,10 +215,47 @@ export function resolveMCPTransport(config) {
|
|
|
203
215
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
204
216
|
throw new Error('MCP requires HTTP(S) without embedded credentials, query or fragment');
|
|
205
217
|
}
|
|
218
|
+
if (config.auth) {
|
|
219
|
+
const auth = { ...config.auth,
|
|
220
|
+
serverId: config.auth.serverId && resolve(config.auth.serverId),
|
|
221
|
+
clientId: config.auth.clientId && resolve(config.auth.clientId),
|
|
222
|
+
clientMetadataUrl: config.auth.clientMetadataUrl && resolve(config.auth.clientMetadataUrl),
|
|
223
|
+
redirectUri: config.auth.redirectUri && resolve(config.auth.redirectUri) };
|
|
224
|
+
if (auth.redirectUri) {
|
|
225
|
+
// The authorization code must only ever come back to the local callback server.
|
|
226
|
+
let redirect;
|
|
227
|
+
try {
|
|
228
|
+
redirect = new URL(auth.redirectUri);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
throw new Error('MCP OAuth redirectUri must be a loopback http URL');
|
|
232
|
+
}
|
|
233
|
+
// Hostname localhost is allowed so a CIMD document's redirect_uris can match.
|
|
234
|
+
// The callback server still binds 127.0.0.1 only — a browser that resolves
|
|
235
|
+
// localhost to ::1 will not hit it. Use 127.0.0.1 when the metadata says so.
|
|
236
|
+
if (redirect.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(redirect.hostname))
|
|
237
|
+
throw new Error('MCP OAuth redirectUri must be a loopback http URL');
|
|
238
|
+
}
|
|
239
|
+
if (auth.clientMetadataUrl) {
|
|
240
|
+
let meta;
|
|
241
|
+
try {
|
|
242
|
+
meta = new URL(auth.clientMetadataUrl);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
throw new Error('MCP OAuth clientMetadataUrl must be an https URL with a document path');
|
|
246
|
+
}
|
|
247
|
+
if (meta.protocol !== 'https:' || meta.pathname === '/')
|
|
248
|
+
throw new Error('MCP OAuth clientMetadataUrl must be an https URL with a document path');
|
|
249
|
+
}
|
|
250
|
+
return { ...resolved, auth };
|
|
251
|
+
}
|
|
206
252
|
return resolved;
|
|
207
253
|
}
|
|
208
254
|
export function createTransport(input) {
|
|
209
255
|
const config = resolveMCPTransport(input);
|
|
256
|
+
if (config.auth?.type === 'oauth' && config.type !== 'streamable_http') {
|
|
257
|
+
throw new Error(`MCP OAuth is only supported on streamable_http (got ${config.type}); refusing to connect without the authProvider.`);
|
|
258
|
+
}
|
|
210
259
|
switch (config.type) {
|
|
211
260
|
case 'stdio':
|
|
212
261
|
return new StdioTransport(config);
|
|
@@ -597,7 +597,9 @@ export class PromptBuilder {
|
|
|
597
597
|
`provider status, or list_peers() for a quick status. Then ` +
|
|
598
598
|
`peer_delegate can ask the chosen peer a question; reuse the ` +
|
|
599
599
|
`dispatchProfile returned by route_peer so the peer receives ` +
|
|
600
|
-
`matching guidance.
|
|
600
|
+
`matching guidance. To read a file on a peer, call ` +
|
|
601
|
+
`peer_tool_invoke({"peer":"<id>","tool":"view_file","args":{"path":"oracle.txt"}}) ` +
|
|
602
|
+
`after list_peers — always pass peer and tool. For ordered specialist collaboration, pass ` +
|
|
601
603
|
`chainRoles such as ["code","review","safe"] to route_peer and ` +
|
|
602
604
|
`then run the returned nextCalls in order, or call peer_chain ` +
|
|
603
605
|
`to route and execute the chain with stage handoffs. The peer answers ` +
|
|
@@ -69,5 +69,13 @@ export declare function getRuntimeSettingsSnapshot(evidence?: RuntimeSettingsEvi
|
|
|
69
69
|
provider: string;
|
|
70
70
|
};
|
|
71
71
|
export declare function formatRuntimeSettingsContext(evidence: RuntimeSettingsEvidence): string;
|
|
72
|
+
/**
|
|
73
|
+
* Fleet tools force-included only for a fleet inspection query, or when
|
|
74
|
+
* peers are actually registered. Never part of the global alwaysInclude
|
|
75
|
+
* default (zero token cost with an empty registry).
|
|
76
|
+
*/
|
|
77
|
+
export declare const FLEET_SURFACE_TOOLS: readonly ["list_peers", "route_peer", "peer_delegate", "peer_tool_invoke"];
|
|
78
|
+
/** Force-include fleet tools when at least one peer is registered. */
|
|
79
|
+
export declare function connectedFleetSurfaceTools(): string[];
|
|
72
80
|
/** Ensure operational questions can reach the existing tools even with RAG selection. */
|
|
73
81
|
export declare function runtimeInspectionTools(query: string): string[];
|
|
@@ -55,11 +55,21 @@ export function formatRuntimeSettingsContext(evidence) {
|
|
|
55
55
|
return '<runtime_settings ephemeral="true">\n' +
|
|
56
56
|
JSON.stringify(getRuntimeSettingsSnapshot(evidence)) + '\n</runtime_settings>';
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Fleet tools force-included only for a fleet inspection query, or when
|
|
60
|
+
* peers are actually registered. Never part of the global alwaysInclude
|
|
61
|
+
* default (zero token cost with an empty registry).
|
|
62
|
+
*/
|
|
63
|
+
export const FLEET_SURFACE_TOOLS = ['list_peers', 'route_peer', 'peer_delegate', 'peer_tool_invoke'];
|
|
64
|
+
/** Force-include fleet tools when at least one peer is registered. */
|
|
65
|
+
export function connectedFleetSurfaceTools() {
|
|
66
|
+
return getFleetRegistry().size() > 0 ? [...FLEET_SURFACE_TOOLS] : [];
|
|
67
|
+
}
|
|
58
68
|
/** Ensure operational questions can reach the existing tools even with RAG selection. */
|
|
59
69
|
export function runtimeInspectionTools(query) {
|
|
60
70
|
const text = query.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
61
71
|
if (/\b(fleet|flotte|peers?|autres? (?:code[ -]?)?budd(?:y|ies)|other (?:code[ -]?)?budd(?:y|ies)|instances? (?:actives?|buddy)|buddy.*ensemble)\b/.test(text)) {
|
|
62
|
-
return [
|
|
72
|
+
return [...FLEET_SURFACE_TOOLS];
|
|
63
73
|
}
|
|
64
74
|
if (/\b(theme|parametres?|parametrage|settings|configuration actuelle|current configuration)\b/.test(text)) {
|
|
65
75
|
return ['self_describe'];
|
package/dist/tools/metadata.js
CHANGED
|
@@ -1736,6 +1736,15 @@ export const TOOL_METADATA = [
|
|
|
1736
1736
|
priority: 7,
|
|
1737
1737
|
description: 'Delegate a one-shot question to a connected fleet peer Code Buddy and get its answer plus Hermes-style dispatch policy metadata back inline'
|
|
1738
1738
|
},
|
|
1739
|
+
{
|
|
1740
|
+
name: 'peer_tool_invoke',
|
|
1741
|
+
effect: 'emission',
|
|
1742
|
+
category: 'utility',
|
|
1743
|
+
keywords: ['peer', 'tool', 'invoke', 'fleet', 'view_file', 'list_directory', 'search', 'read', 'remote', 'workspace', 'allowlist', 'file', 'oracle'],
|
|
1744
|
+
priority: 8,
|
|
1745
|
+
description: 'Read a file on a connected fleet peer. Always pass peer and tool, e.g. {"peer":"B","tool":"view_file","args":{"path":"oracle.txt"}}'
|
|
1746
|
+
// fleetSafe omitted / false: outbound fleet call, not peer-exposable.
|
|
1747
|
+
},
|
|
1739
1748
|
{
|
|
1740
1749
|
name: 'peer_chain',
|
|
1741
1750
|
effect: 'emission',
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* peer_tool_invoke tool — wraps `peer.tool.invoke` for the local agent.
|
|
3
|
+
*
|
|
4
|
+
* Lets the LLM read/search on a connected fleet peer (view_file,
|
|
5
|
+
* list_directory, search) without going through `peer.chat` (which has
|
|
6
|
+
* no tools). The three security gates stay on the remote peer
|
|
7
|
+
* (`peer-tool-bridge.ts`): allowlist, fleetSafe, workspace root.
|
|
8
|
+
*
|
|
9
|
+
* Extra tool names from `peer.describe` are trusted only when
|
|
10
|
+
* `CODEBUDDY_PEER_TRUST_DESCRIBE=true`. B still enforces its own
|
|
11
|
+
* allowlist even then.
|
|
12
|
+
*
|
|
13
|
+
* This side (A) does not interpret paths and does not add new remote
|
|
14
|
+
* capabilities. Failures always return `success: false`.
|
|
15
|
+
*
|
|
16
|
+
* @module src/tools/peer-tool-invoke-tool
|
|
17
|
+
*/
|
|
18
|
+
import type { ToolResult } from '../types/index.js';
|
|
19
|
+
export declare const DEFAULT_PEER_TOOL_INVOKE_TOOLS: readonly ["view_file", "list_directory", "search"];
|
|
20
|
+
export declare const DEFAULT_TIMEOUT_MS = 15000;
|
|
21
|
+
export declare const MIN_TIMEOUT_MS = 1000;
|
|
22
|
+
export declare const MAX_TIMEOUT_MS = 120000;
|
|
23
|
+
export declare const MAX_ARGS_BYTES: number;
|
|
24
|
+
export declare const MAX_OUTPUT_BYTES: number;
|
|
25
|
+
export declare const MAX_PEER_ID_LENGTH = 128;
|
|
26
|
+
export declare const MAX_TOOL_NAME_LENGTH = 64;
|
|
27
|
+
/** Imperative tool description with a concrete call example for local models. */
|
|
28
|
+
export declare const PEER_TOOL_INVOKE_DESCRIPTION: string;
|
|
29
|
+
export declare const PEER_TOOL_INVOKE_PARAM_DESCRIPTIONS: {
|
|
30
|
+
readonly peer: "Required. Connected peer id from list_peers (the --name of /fleet listen). Example: \"B\".";
|
|
31
|
+
readonly tool: "Required. Read-only tool on that peer. Default set: view_file, list_directory, search. Extra names from peer.describe are accepted only when CODEBUDDY_PEER_TRUST_DESCRIBE=true. Example: \"view_file\".";
|
|
32
|
+
readonly args: "Object of arguments for the remote tool. For view_file use {\"path\":\"oracle.txt\"} or {\"file_path\":\"oracle.txt\"}. For list_directory use {\"path\":\".\"}. For search use {\"query\":\"TODO\",\"path\":\"src\"}. Paths are relative to the peer workspace and are not resolved on this host.";
|
|
33
|
+
readonly timeoutMs: "Optional timeout in milliseconds. Default 15000. Min 1000. Max 120000.";
|
|
34
|
+
};
|
|
35
|
+
export declare const PEER_ID_RE: RegExp;
|
|
36
|
+
export interface PeerToolInvokeParams {
|
|
37
|
+
peer: string;
|
|
38
|
+
tool: string;
|
|
39
|
+
args?: Record<string, unknown>;
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface PeerToolInvokeData {
|
|
43
|
+
peer: string;
|
|
44
|
+
tool: string;
|
|
45
|
+
output: string;
|
|
46
|
+
durationMs: number;
|
|
47
|
+
truncated?: boolean;
|
|
48
|
+
elapsedMs: number;
|
|
49
|
+
}
|
|
50
|
+
export type PeerToolInvokeResult = ToolResult & {
|
|
51
|
+
data?: PeerToolInvokeData;
|
|
52
|
+
};
|
|
53
|
+
export declare function clampPeerToolInvokeTimeout(raw: unknown): number;
|
|
54
|
+
export declare function isFlatToolArgs(value: unknown): value is Record<string, unknown>;
|
|
55
|
+
/**
|
|
56
|
+
* Strip absolute paths and secrets from a peer error before it reaches the
|
|
57
|
+
* model. Known refusal codes are mapped to path-free sentences; this covers
|
|
58
|
+
* the unrecognized fallback (and peer.describe failures).
|
|
59
|
+
*/
|
|
60
|
+
export declare function redactPeerToolInvokeError(text: string): string;
|
|
61
|
+
export declare function executePeerToolInvoke(params: PeerToolInvokeParams): Promise<PeerToolInvokeResult>;
|