@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/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/mcp/client.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
package/dist/mcp/mcp-oauth.d.ts
CHANGED
|
@@ -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 {};
|