@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
@@ -14,9 +14,20 @@ export declare class MCPManager extends EventEmitter {
14
14
  private serverStatuses;
15
15
  private retryCounts;
16
16
  private healthCheckIntervals;
17
+ private reconnectTimers;
18
+ /**
19
+ * Bumped by removeServer. A still-running addServerInternal must not overwrite
20
+ * 'disconnected' with 'error' nor schedule autoReconnect (which would reopen
21
+ * the OAuth browser ~1s after an explicit cancel).
22
+ */
23
+ private connectEpochs;
17
24
  private serverAddPromises;
18
25
  private initializationPromise;
19
26
  addServer(config: MCPServerConfig): Promise<void>;
27
+ private currentEpoch;
28
+ private bumpConnectEpoch;
29
+ private assertConnectCurrent;
30
+ private clearReconnectTimer;
20
31
  private addServerInternal;
21
32
  private startHealthCheck;
22
33
  private stopHealthCheck;
@@ -2,6 +2,7 @@ import { mcpToolAllowed, validateMCPToolFilter } from './import-normalize.js';
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
3
  import { EventEmitter } from "events";
4
4
  import { createTransport } from "./transports.js";
5
+ import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
5
6
  import { logger } from "../utils/logger.js";
6
7
  export const DEFAULT_MCP_INIT_TIMEOUT_MS = 15_000;
7
8
  const MAX_MCP_INIT_TIMEOUT_MS = 10 * 60_000;
@@ -26,6 +27,13 @@ export class MCPManager extends EventEmitter {
26
27
  serverStatuses = new Map();
27
28
  retryCounts = new Map();
28
29
  healthCheckIntervals = new Map();
30
+ reconnectTimers = new Map();
31
+ /**
32
+ * Bumped by removeServer. A still-running addServerInternal must not overwrite
33
+ * 'disconnected' with 'error' nor schedule autoReconnect (which would reopen
34
+ * the OAuth browser ~1s after an explicit cancel).
35
+ */
36
+ connectEpochs = new Map();
29
37
  serverAddPromises = new Map();
30
38
  initializationPromise = null;
31
39
  async addServer(config) {
@@ -51,9 +59,30 @@ export class MCPManager extends EventEmitter {
51
59
  }
52
60
  }
53
61
  }
62
+ currentEpoch(serverName) {
63
+ return this.connectEpochs.get(serverName) ?? 0;
64
+ }
65
+ bumpConnectEpoch(serverName) {
66
+ const next = this.currentEpoch(serverName) + 1;
67
+ this.connectEpochs.set(serverName, next);
68
+ return next;
69
+ }
70
+ assertConnectCurrent(serverName, epoch) {
71
+ if (this.currentEpoch(serverName) !== epoch) {
72
+ throw new Error(`MCP server "${serverName}" connection cancelled`);
73
+ }
74
+ }
75
+ clearReconnectTimer(serverName) {
76
+ const timer = this.reconnectTimers.get(serverName);
77
+ if (timer) {
78
+ clearTimeout(timer);
79
+ this.reconnectTimers.delete(serverName);
80
+ }
81
+ }
54
82
  async addServerInternal(config) {
55
83
  this.serverConfigs.set(config.name, config);
56
84
  this.serverStatuses.set(config.name, 'connecting');
85
+ const epoch = this.currentEpoch(config.name);
57
86
  try {
58
87
  // Handle legacy stdio-only configuration
59
88
  let transportConfig = config.transport;
@@ -80,8 +109,24 @@ export class MCPManager extends EventEmitter {
80
109
  });
81
110
  this.clients.set(config.name, client);
82
111
  // Connect
83
- const sdkTransport = await transport.connect();
84
- await client.connect(sdkTransport);
112
+ let sdkTransport = await transport.connect();
113
+ this.assertConnectCurrent(config.name, epoch);
114
+ try {
115
+ await client.connect(sdkTransport);
116
+ this.assertConnectCurrent(config.name, epoch);
117
+ }
118
+ catch (error) {
119
+ // First connection may end in UnauthorizedError once the OAuth code has been
120
+ // exchanged (SDK contract): reconnect once with the stored token.
121
+ if (!(error instanceof UnauthorizedError) || transportConfig.auth?.type !== 'oauth')
122
+ throw error;
123
+ await transport.disconnect().catch(() => undefined);
124
+ this.assertConnectCurrent(config.name, epoch);
125
+ sdkTransport = await transport.connect();
126
+ this.assertConnectCurrent(config.name, epoch);
127
+ await client.connect(sdkTransport);
128
+ this.assertConnectCurrent(config.name, epoch);
129
+ }
85
130
  // Drain the captured MCP stderr to the logger. The stream only exists
86
131
  // for stdio transports created with stderr:'pipe' (see StdioTransport);
87
132
  // without a consumer the PassThrough back-pressures the child once its
@@ -96,6 +141,7 @@ export class MCPManager extends EventEmitter {
96
141
  }
97
142
  // List available tools
98
143
  const toolsResult = await client.listTools();
144
+ this.assertConnectCurrent(config.name, epoch);
99
145
  // Register tools
100
146
  for (const tool of toolsResult.tools) {
101
147
  if (!mcpToolAllowed(tool.name, config.toolFilter))
@@ -114,6 +160,10 @@ export class MCPManager extends EventEmitter {
114
160
  this.emit('serverAdded', config.name, toolsResult.tools.length);
115
161
  }
116
162
  catch (error) {
163
+ if (this.currentEpoch(config.name) !== epoch) {
164
+ // Explicit removeServer raced this connect; keep disconnected.
165
+ throw error;
166
+ }
117
167
  this.serverStatuses.set(config.name, 'error');
118
168
  this.handleServerError(config.name, error);
119
169
  throw error;
@@ -159,8 +209,13 @@ export class MCPManager extends EventEmitter {
159
209
  if (retryCount < maxRetries) {
160
210
  this.retryCounts.set(serverName, retryCount + 1);
161
211
  const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
212
+ const epochAtError = this.currentEpoch(serverName);
162
213
  logger.info(`Attempting to reconnect to ${serverName} in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
163
- setTimeout(async () => {
214
+ this.clearReconnectTimer(serverName);
215
+ const timer = setTimeout(async () => {
216
+ this.reconnectTimers.delete(serverName);
217
+ if (this.currentEpoch(serverName) !== epochAtError)
218
+ return;
164
219
  try {
165
220
  await this.removeServer(serverName);
166
221
  await this.addServer(config);
@@ -169,6 +224,7 @@ export class MCPManager extends EventEmitter {
169
224
  logger.debug(`Reconnection attempt failed for ${serverName}`, { reconnectError });
170
225
  }
171
226
  }, delay);
227
+ this.reconnectTimers.set(serverName, timer);
172
228
  }
173
229
  else {
174
230
  logger.error(`Max reconnection attempts reached for ${serverName}`);
@@ -179,6 +235,9 @@ export class MCPManager extends EventEmitter {
179
235
  return this.serverStatuses.get(serverName);
180
236
  }
181
237
  async removeServer(serverName) {
238
+ this.bumpConnectEpoch(serverName);
239
+ this.clearReconnectTimer(serverName);
240
+ this.serverAddPromises.delete(serverName);
182
241
  this.stopHealthCheck(serverName);
183
242
  this.serverStatuses.set(serverName, 'disconnected');
184
243
  // Remove tools
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared MCP OAuth markers.
3
+ *
4
+ * Kept in a leaf module so `mcp-oauth.ts` and `mcp-oauth-provider.ts` can share
5
+ * them without an import cycle.
6
+ */
7
+ /** Marker stored as tokenUrl: refresh is handled by the SDK, never by MCPOAuthManager.getValidToken(). */
8
+ export declare const SDK_MANAGED_TOKEN_URL = "sdk-managed";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Shared MCP OAuth markers.
3
+ *
4
+ * Kept in a leaf module so `mcp-oauth.ts` and `mcp-oauth-provider.ts` can share
5
+ * them without an import cycle.
6
+ */
7
+ /** Marker stored as tokenUrl: refresh is handled by the SDK, never by MCPOAuthManager.getValidToken(). */
8
+ export const SDK_MANAGED_TOKEN_URL = 'sdk-managed';
9
+ //# sourceMappingURL=mcp-oauth-constants.js.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * OAuth provider bridge for HTTP MCP transports.
3
+ *
4
+ * Adapts the SDK `OAuthClientProvider` contract (RFC 9728 discovery, PKCE,
5
+ * URL-based client IDs / dynamic registration handled by the SDK itself) onto
6
+ * the existing encrypted token store of `mcp-oauth.ts`. No parallel OAuth
7
+ * implementation: PKCE, discovery and token exchange come from the SDK; the
8
+ * local callback server, browser opening and token persistence come from the
9
+ * existing manager.
10
+ */
11
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
12
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
13
+ import { SDK_MANAGED_TOKEN_URL } from './mcp-oauth-constants.js';
14
+ import { type CallbackSession } from './mcp-oauth.js';
15
+ export { SDK_MANAGED_TOKEN_URL };
16
+ export interface MCPTransportOAuthConfig {
17
+ type: 'oauth';
18
+ /** Key of the stored token; defaults to the server URL host. */
19
+ serverId?: string;
20
+ /** SEP-991 URL-based client id (HTTPS document). Used when the server advertises support. */
21
+ clientMetadataUrl?: string;
22
+ /** Pre-registered public client id (token_endpoint_auth_method none). */
23
+ clientId?: string;
24
+ scopes?: string[];
25
+ /** Loopback redirect; default http://localhost:19836/callback (same as mcp-oauth.ts). */
26
+ redirectUri?: string;
27
+ /** When false, an authorization requirement fails closed instead of opening a browser. */
28
+ interactive?: boolean;
29
+ }
30
+ export interface OAuthWaitOptions {
31
+ signal?: AbortSignal;
32
+ }
33
+ export interface OAuthInteraction {
34
+ openUrl: (url: string) => Promise<void>;
35
+ waitForCode: (redirectUri: string, state: string, opts?: OAuthWaitOptions) => Promise<string>;
36
+ /** Production default: bind the callback port before openUrl. Tests may omit this. */
37
+ beginWaitForCode?: (redirectUri: string, state: string, opts?: OAuthWaitOptions) => CallbackSession;
38
+ }
39
+ /** Tests inject a fake browser; production keeps the default loopback flow. */
40
+ export declare function setMCPOAuthInteraction(next: OAuthInteraction | null): void;
41
+ interface FinishAuthTransport {
42
+ finishAuth(authorizationCode: string): Promise<void>;
43
+ }
44
+ export declare class MCPStoredOAuthProvider implements OAuthClientProvider {
45
+ private readonly serverId;
46
+ private readonly auth;
47
+ private verifier?;
48
+ private stateValue?;
49
+ private client?;
50
+ private transport?;
51
+ /** Single in-flight authorization: concurrent SDK auth() calls share it. */
52
+ private pending?;
53
+ private flowAbort?;
54
+ private callbackSession?;
55
+ /** Set by abortAuthorization so a late successful callback cannot finishAuth. */
56
+ private authCancelled;
57
+ constructor(serverId: string, auth: MCPTransportOAuthConfig);
58
+ attachTransport(transport: FinishAuthTransport): void;
59
+ get redirectUrl(): string;
60
+ get clientMetadataUrl(): string | undefined;
61
+ get clientMetadata(): OAuthClientMetadata;
62
+ state(): string;
63
+ clientInformation(): OAuthClientInformationMixed | undefined;
64
+ saveClientInformation(info: OAuthClientInformationMixed): void;
65
+ tokens(): OAuthTokens | undefined;
66
+ saveTokens(tokens: OAuthTokens): void;
67
+ abortAuthorization(reason?: Error): void;
68
+ redirectToAuthorization(authorizationUrl: URL): Promise<void>;
69
+ private throwIfAuthCancelled;
70
+ /**
71
+ * Open the system browser. A slow opener is raced against abort so
72
+ * removeServer does not stay stuck in openUrl; a failed opener still
73
+ * prints the URL so the user can complete the loopback callback by hand.
74
+ */
75
+ private openAuthorizationUrl;
76
+ saveCodeVerifier(codeVerifier: string): void;
77
+ codeVerifier(): string;
78
+ invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): void;
79
+ }
80
+ export declare function createOAuthProvider(url: string, auth: MCPTransportOAuthConfig): MCPStoredOAuthProvider;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * OAuth provider bridge for HTTP MCP transports.
3
+ *
4
+ * Adapts the SDK `OAuthClientProvider` contract (RFC 9728 discovery, PKCE,
5
+ * URL-based client IDs / dynamic registration handled by the SDK itself) onto
6
+ * the existing encrypted token store of `mcp-oauth.ts`. No parallel OAuth
7
+ * implementation: PKCE, discovery and token exchange come from the SDK; the
8
+ * local callback server, browser opening and token persistence come from the
9
+ * existing manager.
10
+ */
11
+ import * as crypto from 'crypto';
12
+ import { logger } from '../utils/logger.js';
13
+ import { SDK_MANAGED_TOKEN_URL } from './mcp-oauth-constants.js';
14
+ import { getMCPOAuthManager, OAuthCallbackCancelledError, openBrowser, startCallbackServer, startCallbackSession, } from './mcp-oauth.js';
15
+ export { SDK_MANAGED_TOKEN_URL };
16
+ const DEFAULT_REDIRECT = 'http://localhost:19836/callback';
17
+ function waitUntilAborted(signal) {
18
+ if (signal.aborted)
19
+ return Promise.resolve();
20
+ return new Promise((resolve) => {
21
+ signal.addEventListener('abort', () => resolve(), { once: true });
22
+ });
23
+ }
24
+ function storedClientFromMixed(info) {
25
+ const client = { client_id: info.client_id };
26
+ if (info.client_secret)
27
+ client.client_secret = info.client_secret;
28
+ if (info.client_id_issued_at !== undefined)
29
+ client.client_id_issued_at = info.client_id_issued_at;
30
+ if (info.client_secret_expires_at !== undefined)
31
+ client.client_secret_expires_at = info.client_secret_expires_at;
32
+ if ('redirect_uris' in info && info.redirect_uris)
33
+ client.redirect_uris = info.redirect_uris.map(String);
34
+ if ('token_endpoint_auth_method' in info && info.token_endpoint_auth_method) {
35
+ client.token_endpoint_auth_method = info.token_endpoint_auth_method;
36
+ }
37
+ if ('grant_types' in info && info.grant_types)
38
+ client.grant_types = info.grant_types;
39
+ if ('response_types' in info && info.response_types)
40
+ client.response_types = info.response_types;
41
+ if ('client_name' in info && info.client_name)
42
+ client.client_name = info.client_name;
43
+ if ('scope' in info && info.scope)
44
+ client.scope = info.scope;
45
+ return client;
46
+ }
47
+ function defaultInteraction() {
48
+ return {
49
+ openUrl: (url) => openBrowser(url),
50
+ waitForCode: async (redirectUri, state, opts) => (await startCallbackServer(redirectUri, state, 120_000, opts?.signal)).code,
51
+ beginWaitForCode: (redirectUri, state, opts) => startCallbackSession(redirectUri, state, 120_000, opts?.signal),
52
+ };
53
+ }
54
+ let interaction = defaultInteraction();
55
+ /** Tests inject a fake browser; production keeps the default loopback flow. */
56
+ export function setMCPOAuthInteraction(next) {
57
+ interaction = next ?? defaultInteraction();
58
+ }
59
+ export class MCPStoredOAuthProvider {
60
+ serverId;
61
+ auth;
62
+ verifier;
63
+ stateValue;
64
+ client;
65
+ transport;
66
+ /** Single in-flight authorization: concurrent SDK auth() calls share it. */
67
+ pending;
68
+ flowAbort;
69
+ callbackSession;
70
+ /** Set by abortAuthorization so a late successful callback cannot finishAuth. */
71
+ authCancelled = false;
72
+ constructor(serverId, auth) {
73
+ this.serverId = serverId;
74
+ this.auth = auth;
75
+ }
76
+ attachTransport(transport) {
77
+ this.transport = transport;
78
+ }
79
+ get redirectUrl() {
80
+ return this.auth.redirectUri ?? DEFAULT_REDIRECT;
81
+ }
82
+ get clientMetadataUrl() {
83
+ return this.auth.clientMetadataUrl;
84
+ }
85
+ get clientMetadata() {
86
+ return {
87
+ client_name: 'Code Buddy',
88
+ redirect_uris: [this.redirectUrl],
89
+ grant_types: ['authorization_code', 'refresh_token'],
90
+ response_types: ['code'],
91
+ token_endpoint_auth_method: 'none',
92
+ ...(this.auth.scopes?.length ? { scope: this.auth.scopes.join(' ') } : {}),
93
+ };
94
+ }
95
+ state() {
96
+ this.stateValue = crypto.randomBytes(16).toString('hex');
97
+ return this.stateValue;
98
+ }
99
+ clientInformation() {
100
+ if (this.auth.clientId)
101
+ return { client_id: this.auth.clientId };
102
+ if (this.client)
103
+ return this.client;
104
+ const stored = getMCPOAuthManager().getStoredClientInformation(this.serverId);
105
+ return stored ?? undefined;
106
+ }
107
+ saveClientInformation(info) {
108
+ this.client = info;
109
+ getMCPOAuthManager().storeClientInformation(this.serverId, storedClientFromMixed(info));
110
+ }
111
+ tokens() {
112
+ const stored = getMCPOAuthManager().getStoredToken(this.serverId);
113
+ if (!stored)
114
+ return undefined;
115
+ const { token } = stored;
116
+ return {
117
+ access_token: token.accessToken,
118
+ token_type: 'bearer',
119
+ ...(token.refreshToken ? { refresh_token: token.refreshToken } : {}),
120
+ expires_in: Math.max(0, Math.floor((token.expiresAt - Date.now()) / 1000)),
121
+ ...(token.scopes.length ? { scope: token.scopes.join(' ') } : {}),
122
+ };
123
+ }
124
+ saveTokens(tokens) {
125
+ const clientId = this.clientInformation()?.client_id ?? '';
126
+ getMCPOAuthManager().storeToken(this.serverId, {
127
+ accessToken: tokens.access_token,
128
+ refreshToken: tokens.refresh_token,
129
+ expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
130
+ scopes: tokens.scope ? tokens.scope.split(' ') : (this.auth.scopes ?? []),
131
+ }, { clientId, clientSecret: undefined, authorizationUrl: '', tokenUrl: SDK_MANAGED_TOKEN_URL, scopes: this.auth.scopes ?? [] });
132
+ }
133
+ abortAuthorization(reason) {
134
+ this.authCancelled = true;
135
+ this.flowAbort?.abort();
136
+ this.callbackSession?.abort(reason);
137
+ }
138
+ redirectToAuthorization(authorizationUrl) {
139
+ if (this.auth.interactive === false) {
140
+ return Promise.reject(new Error(`MCP server "${this.serverId}" requires OAuth authorization; run the connection interactively to sign in.`));
141
+ }
142
+ if (!this.transport) {
143
+ return Promise.reject(new Error('OAuth provider is not attached to a transport'));
144
+ }
145
+ if (this.pending)
146
+ return this.pending;
147
+ this.authCancelled = false;
148
+ this.flowAbort = new AbortController();
149
+ const signal = this.flowAbort.signal;
150
+ const url = authorizationUrl.toString();
151
+ this.pending = (async () => {
152
+ const state = this.stateValue ?? this.state();
153
+ const opts = { signal };
154
+ let code;
155
+ if (interaction.beginWaitForCode) {
156
+ const session = interaction.beginWaitForCode(this.redirectUrl, state, opts);
157
+ this.callbackSession = session;
158
+ await session.listening;
159
+ this.throwIfAuthCancelled(signal);
160
+ logger.info(`MCP "${this.serverId}": opening browser for OAuth authorization`);
161
+ await this.openAuthorizationUrl(url, signal);
162
+ this.throwIfAuthCancelled(signal);
163
+ code = (await session.result).code;
164
+ }
165
+ else {
166
+ const codePromise = interaction.waitForCode(this.redirectUrl, state, opts);
167
+ logger.info(`MCP "${this.serverId}": opening browser for OAuth authorization`);
168
+ await this.openAuthorizationUrl(url, signal);
169
+ this.throwIfAuthCancelled(signal);
170
+ code = await codePromise;
171
+ }
172
+ this.throwIfAuthCancelled(signal);
173
+ await this.transport.finishAuth(code);
174
+ })().finally(() => {
175
+ this.pending = undefined;
176
+ this.callbackSession = undefined;
177
+ this.flowAbort = undefined;
178
+ });
179
+ return this.pending;
180
+ }
181
+ throwIfAuthCancelled(signal) {
182
+ if (this.authCancelled || signal.aborted) {
183
+ throw new OAuthCallbackCancelledError();
184
+ }
185
+ }
186
+ /**
187
+ * Open the system browser. A slow opener is raced against abort so
188
+ * removeServer does not stay stuck in openUrl; a failed opener still
189
+ * prints the URL so the user can complete the loopback callback by hand.
190
+ */
191
+ async openAuthorizationUrl(url, signal) {
192
+ this.throwIfAuthCancelled(signal);
193
+ const aborted = waitUntilAborted(signal);
194
+ aborted.catch(() => undefined);
195
+ const opener = interaction.openUrl(url).then(() => 'opened', () => 'failed');
196
+ const outcome = await Promise.race([
197
+ opener.then((kind) => ({ kind })),
198
+ aborted.then(() => ({ kind: 'aborted' })),
199
+ ]);
200
+ if (outcome.kind === 'aborted' || this.authCancelled) {
201
+ throw new OAuthCallbackCancelledError();
202
+ }
203
+ if (outcome.kind === 'failed') {
204
+ logger.warn(`MCP "${this.serverId}": browser could not be opened; open this URL manually:\n${url}`);
205
+ }
206
+ }
207
+ saveCodeVerifier(codeVerifier) {
208
+ // A concurrent auth() while the browser flow is pending must not replace the
209
+ // verifier the pending flow will exchange its code with.
210
+ if (this.pending)
211
+ return;
212
+ this.verifier = codeVerifier;
213
+ }
214
+ codeVerifier() {
215
+ if (!this.verifier)
216
+ throw new Error('No PKCE code verifier saved');
217
+ return this.verifier;
218
+ }
219
+ invalidateCredentials(scope) {
220
+ if (scope === 'tokens') {
221
+ getMCPOAuthManager().clearTokens(this.serverId);
222
+ return;
223
+ }
224
+ if (scope === 'all' || scope === 'client') {
225
+ getMCPOAuthManager().removeToken(this.serverId);
226
+ this.client = undefined;
227
+ }
228
+ if (scope === 'all' || scope === 'verifier')
229
+ this.verifier = undefined;
230
+ }
231
+ }
232
+ export function createOAuthProvider(url, auth) {
233
+ const serverId = auth.serverId ?? new URL(url).host;
234
+ // Fail closed before the SDK performs discovery or client registration: without a
235
+ // stored token, a non-interactive connection can never complete.
236
+ if (auth.interactive === false && !getMCPOAuthManager().getStoredToken(serverId)) {
237
+ throw new Error(`MCP server "${serverId}" requires OAuth authorization; run the connection interactively to sign in.`);
238
+ }
239
+ return new MCPStoredOAuthProvider(serverId, auth);
240
+ }
241
+ //# sourceMappingURL=mcp-oauth-provider.js.map
@@ -18,6 +18,19 @@ export interface MCPOAuthToken {
18
18
  expiresAt: number;
19
19
  scopes: string[];
20
20
  }
21
+ /** Client id + optional DCR metadata persisted next to the token (encrypted store). */
22
+ export interface StoredClientInformation {
23
+ client_id: string;
24
+ client_secret?: string;
25
+ client_id_issued_at?: number;
26
+ client_secret_expires_at?: number;
27
+ redirect_uris?: string[];
28
+ token_endpoint_auth_method?: string;
29
+ grant_types?: string[];
30
+ response_types?: string[];
31
+ client_name?: string;
32
+ scope?: string;
33
+ }
21
34
  /**
22
35
  * Generate a cryptographically random code verifier (43-128 chars, URL-safe)
23
36
  */
@@ -26,6 +39,35 @@ export declare function generateCodeVerifier(): string;
26
39
  * Generate code challenge from verifier using S256 method
27
40
  */
28
41
  export declare function generateCodeChallenge(verifier: string): string;
42
+ interface AuthorizationResult {
43
+ code: string;
44
+ state: string;
45
+ }
46
+ export declare class OAuthCallbackCancelledError extends Error {
47
+ constructor(message?: string);
48
+ }
49
+ export interface CallbackSession {
50
+ /** Resolves only after listen() succeeded. Rejects on bind failure or abort-before-listen. */
51
+ listening: Promise<void>;
52
+ /** Resolves with the authorization code after a valid callback. */
53
+ result: Promise<AuthorizationResult>;
54
+ abort: (reason?: Error) => void;
55
+ }
56
+ /**
57
+ * Bind the loopback callback server. Callers must await `listening` before
58
+ * opening a browser: a bind failure must not leave an unhandled rejection
59
+ * nor launch a flow that can never complete.
60
+ */
61
+ export declare function startCallbackSession(redirectUri: string, expectedState: string, timeoutMs?: number, signal?: AbortSignal): CallbackSession;
62
+ /**
63
+ * Start a temporary local HTTP server to receive the OAuth callback.
64
+ * Awaits a successful listen before waiting for the code.
65
+ */
66
+ export declare function startCallbackServer(redirectUri: string, expectedState: string, timeoutMs?: number, signal?: AbortSignal): Promise<AuthorizationResult>;
67
+ /**
68
+ * Open a URL in the default browser (cross-platform)
69
+ */
70
+ export declare function openBrowser(url: string): Promise<void>;
29
71
  export declare class MCPOAuthManager {
30
72
  private tokenCache;
31
73
  /**
@@ -48,17 +90,34 @@ export declare class MCPOAuthManager {
48
90
  */
49
91
  getValidToken(serverId: string): Promise<string | null>;
50
92
  /**
51
- * Store a token encrypted on disk and in memory cache
93
+ * Store a token encrypted on disk and in memory cache.
94
+ * Preserves any previously persisted client metadata for the same server.
52
95
  */
53
96
  storeToken(serverId: string, token: MCPOAuthToken, config: MCPOAuthConfig): void;
97
+ /**
98
+ * Persist client registration (client_id and optional DCR metadata) in the
99
+ * encrypted store, even before a token exists. Never writes plaintext.
100
+ */
101
+ storeClientInformation(serverId: string, info: StoredClientInformation): void;
54
102
  /**
55
103
  * Remove a stored token
56
104
  */
57
105
  removeToken(serverId: string): void;
106
+ /** Drop access/refresh tokens only; keep client_id and DCR metadata. */
107
+ clearTokens(serverId: string): void;
58
108
  /**
59
109
  * Check if a token exists for a server (may be expired)
60
110
  */
111
+ /** Stored token and client id for a server, without refresh side effects. */
112
+ getStoredToken(serverId: string): {
113
+ token: MCPOAuthToken;
114
+ clientId: string;
115
+ client?: StoredClientInformation;
116
+ } | null;
117
+ /** Persisted client id / DCR metadata, independent of whether a token exists. */
118
+ getStoredClientInformation(serverId: string): StoredClientInformation | null;
61
119
  hasToken(serverId: string): boolean;
62
120
  }
63
121
  export declare function getMCPOAuthManager(): MCPOAuthManager;
64
122
  export declare function resetMCPOAuthManager(): void;
123
+ export {};