@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.
Files changed (41) hide show
  1. package/README.fr.md +2 -2
  2. package/README.md +3 -2
  3. package/codebuddy-runtime.json +4 -4
  4. package/dist/agent/execution/agent-executor.js +16 -5
  5. package/dist/agent/execution/tool-selection-strategy.d.ts +7 -0
  6. package/dist/agent/execution/tool-selection-strategy.js +13 -0
  7. package/dist/codebuddy/fleet-tool-defs.d.ts +1 -0
  8. package/dist/codebuddy/fleet-tool-defs.js +31 -0
  9. package/dist/codebuddy/providers/provider-openai-compat.d.ts +7 -0
  10. package/dist/codebuddy/providers/provider-openai-compat.js +28 -0
  11. package/dist/commands/handlers/missing-handlers.js +8 -0
  12. package/dist/commands/mcp.d.ts +13 -0
  13. package/dist/commands/mcp.js +62 -21
  14. package/dist/config/model-tools.js +99 -0
  15. package/dist/doctor/integrations.d.ts +18 -0
  16. package/dist/doctor/integrations.js +14 -0
  17. package/dist/errors/crash-handler.js +2 -0
  18. package/dist/mcp/client.d.ts +11 -0
  19. package/dist/mcp/client.js +62 -3
  20. package/dist/mcp/mcp-oauth-constants.d.ts +8 -0
  21. package/dist/mcp/mcp-oauth-constants.js +9 -0
  22. package/dist/mcp/mcp-oauth-provider.d.ts +80 -0
  23. package/dist/mcp/mcp-oauth-provider.js +241 -0
  24. package/dist/mcp/mcp-oauth.d.ts +60 -1
  25. package/dist/mcp/mcp-oauth.js +241 -65
  26. package/dist/mcp/transports.d.ts +4 -0
  27. package/dist/mcp/transports.js +52 -3
  28. package/dist/services/prompt-builder.js +3 -1
  29. package/dist/services/runtime-settings-context.d.ts +8 -0
  30. package/dist/services/runtime-settings-context.js +11 -1
  31. package/dist/tools/metadata.js +9 -0
  32. package/dist/tools/peer-tool-invoke-tool.d.ts +61 -0
  33. package/dist/tools/peer-tool-invoke-tool.js +396 -0
  34. package/dist/tools/registry/fleet-tools.d.ts +12 -3
  35. package/dist/tools/registry/fleet-tools.js +116 -4
  36. package/dist/tools/registry/index.d.ts +1 -1
  37. package/dist/tools/registry/index.js +2 -2
  38. package/dist/utils/config-validation/schema.d.ts +2 -2
  39. package/dist/utils/graceful-shutdown.d.ts +8 -0
  40. package/dist/utils/graceful-shutdown.js +23 -7
  41. package/package.json +1 -1
@@ -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
- // Use CODEBUDDY_VAULT_KEY if available, otherwise derive from machine ID
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
- * Start a temporary local HTTP server to receive the OAuth callback.
106
- * Returns a promise that resolves with the authorization code.
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 startCallbackServer(redirectUri, expectedState, timeoutMs = 120_000) {
109
- return new Promise((resolve, reject) => {
110
- const url = new URL(redirectUri);
111
- const port = parseInt(url.port || '19836', 10);
112
- const pathname = url.pathname || '/callback';
113
- const server = http.createServer((req, res) => {
114
- const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
115
- if (reqUrl.pathname !== pathname) {
116
- res.writeHead(404);
117
- res.end('Not found');
118
- return;
119
- }
120
- const code = reqUrl.searchParams.get('code');
121
- const state = reqUrl.searchParams.get('state');
122
- const error = reqUrl.searchParams.get('error');
123
- if (error) {
124
- res.writeHead(200, { 'Content-Type': 'text/html' });
125
- res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
126
- server.close();
127
- reject(new Error(`OAuth authorization error: ${error}`));
128
- return;
129
- }
130
- if (!code) {
131
- res.writeHead(400, { 'Content-Type': 'text/html' });
132
- res.end('<html><body><h1>Missing authorization code</h1></body></html>');
133
- return;
134
- }
135
- if (state !== expectedState) {
136
- res.writeHead(400, { 'Content-Type': 'text/html' });
137
- res.end('<html><body><h1>State mismatch — possible CSRF attack</h1></body></html>');
138
- server.close();
139
- reject(new Error('OAuth state mismatch'));
140
- return;
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 Successful</h1><p>You can close this window and return to the terminal.</p></body></html>');
144
- server.close();
145
- resolve({ code, state });
146
- });
147
- // Set timeout
148
- const timer = setTimeout(() => {
149
- server.close();
150
- reject(new Error('OAuth callback timed out (120s)'));
151
- }, timeoutMs);
152
- server.on('close', () => clearTimeout(timer));
153
- server.on('error', (err) => {
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
- reject(new Error(`Failed to start callback server: ${err.message}`));
156
- });
157
- server.listen(port, '127.0.0.1', () => {
158
- logger.debug(`OAuth callback server listening on port ${port}`);
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
- // Start callback server
248
- const callbackPromise = startCallbackServer(redirectUri, state);
249
- // Open browser
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
- // Wait for callback
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
- if (!entry)
425
+ const token = entry?.token;
426
+ if (!token?.accessToken || !entry)
310
427
  return null;
311
- const { token, config } = entry;
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: config.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 in store;
547
+ return Boolean(store[serverId]?.token?.accessToken);
372
548
  }
373
549
  }
374
550
  // ============================================================================
@@ -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>;
@@ -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
- await this.transport.close();
53
- this.transport = undefined;
53
+ try {
54
+ await this.transport.close();
55
+ }
56
+ finally {
57
+ this.transport = undefined;
58
+ }
54
59
  }
55
- // The StdioClientTransport from the SDK is expected to terminate the child process.
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. For ordered specialist collaboration, pass ` +
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 ['list_peers', 'route_peer', 'peer_delegate'];
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'];
@@ -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>;