@dsh-cc/mcp-client 0.5.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/LICENSE +201 -0
- package/README.i18n.yaml +6 -0
- package/README.md +149 -0
- package/README.zh.md +150 -0
- package/lib/auth.d.ts +66 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/auth.js +121 -0
- package/lib/auth.js.map +1 -0
- package/lib/connection.d.ts +98 -0
- package/lib/connection.d.ts.map +1 -0
- package/lib/connection.js +409 -0
- package/lib/connection.js.map +1 -0
- package/lib/defer.d.ts +39 -0
- package/lib/defer.d.ts.map +1 -0
- package/lib/defer.js +40 -0
- package/lib/defer.js.map +1 -0
- package/lib/index.d.ts +129 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +198 -0
- package/lib/index.js.map +1 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.d.ts.map +1 -0
- package/lib/invariant.js +22 -0
- package/lib/invariant.js.map +1 -0
- package/lib/prompts.d.ts +44 -0
- package/lib/prompts.d.ts.map +1 -0
- package/lib/prompts.js +166 -0
- package/lib/prompts.js.map +1 -0
- package/lib/registry.d.ts +94 -0
- package/lib/registry.d.ts.map +1 -0
- package/lib/registry.js +101 -0
- package/lib/registry.js.map +1 -0
- package/lib/resources.d.ts +42 -0
- package/lib/resources.d.ts.map +1 -0
- package/lib/resources.js +136 -0
- package/lib/resources.js.map +1 -0
- package/lib/stdio-stderr.d.ts +66 -0
- package/lib/stdio-stderr.d.ts.map +1 -0
- package/lib/stdio-stderr.js +187 -0
- package/lib/stdio-stderr.js.map +1 -0
- package/lib/tools.d.ts +161 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +373 -0
- package/lib/tools.js.map +1 -0
- package/lib/transport.d.ts +50 -0
- package/lib/transport.d.ts.map +1 -0
- package/lib/transport.js +79 -0
- package/lib/transport.js.map +1 -0
- package/package.json +62 -0
package/lib/auth.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth support: a `ctx.credentials`-backed `OAuthClientProvider` for the MCP
|
|
3
|
+
* SDK's streamable-HTTP and SSE transports.
|
|
4
|
+
*
|
|
5
|
+
* The SDK implements RFC 9728 → RFC 8414 metadata discovery, PKCE, dynamic
|
|
6
|
+
* client registration, and token refresh behind its `OAuthClientProvider`
|
|
7
|
+
* interface (driven by the transport's `authProvider` option). This module
|
|
8
|
+
* supplies the durable half of that seam: OAuth tokens, registered-client
|
|
9
|
+
* information, the PKCE code verifier, and discovery state are persisted through
|
|
10
|
+
* the `ctx.credentials` reference capability — values are stored under
|
|
11
|
+
* server-derived credential references rather than inline, so configuration
|
|
12
|
+
* surfaces never see the secret material.
|
|
13
|
+
*
|
|
14
|
+
* 401 handling: the transport auto-refreshes an expired access token against a
|
|
15
|
+
* stored refresh token before a request; a mid-session `401` surfaces as an
|
|
16
|
+
* `UnauthorizedError`, which the tool bridge retries once after invalidating
|
|
17
|
+
* and re-running the token flow ({@link retryUnauthorizedOnce}).
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
22
|
+
/** Deterministic credential ref for one OAuth artifact, derived per server. */
|
|
23
|
+
function oauthRef(prefix, kind) {
|
|
24
|
+
const safe = prefix.toUpperCase().replace(/[^A-Za-z0-9_]/g, '_');
|
|
25
|
+
return credentialRef(`${safe}_${kind}`);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* `ctx.credentials`-backed {@link OAuthClientProvider}. Every artifact is a
|
|
29
|
+
* JSON value persisted under a derived credential reference; absent refs mean
|
|
30
|
+
* "no state" (unconfigured), and an empty stored value counts as unset.
|
|
31
|
+
*/
|
|
32
|
+
export class CredentialsOAuthClientProvider {
|
|
33
|
+
ctx;
|
|
34
|
+
tokensRef;
|
|
35
|
+
clientRef;
|
|
36
|
+
verifierRef;
|
|
37
|
+
discoveryRef;
|
|
38
|
+
redirectUrlValue;
|
|
39
|
+
constructor(ctx, serverName, config) {
|
|
40
|
+
this.ctx = ctx;
|
|
41
|
+
const prefix = config.credentialPrefix ?? `MCP_OAUTH_${serverName}`;
|
|
42
|
+
this.tokensRef = oauthRef(prefix, 'TOKENS');
|
|
43
|
+
this.clientRef = oauthRef(prefix, 'CLIENT');
|
|
44
|
+
this.verifierRef = oauthRef(prefix, 'VERIFIER');
|
|
45
|
+
this.discoveryRef = oauthRef(prefix, 'DISCOVERY');
|
|
46
|
+
this.redirectUrlValue = config.redirectUrl;
|
|
47
|
+
void config.clientName; // reserved for client metadata naming
|
|
48
|
+
}
|
|
49
|
+
get redirectUrl() {
|
|
50
|
+
return this.redirectUrlValue;
|
|
51
|
+
}
|
|
52
|
+
get clientMetadata() {
|
|
53
|
+
const redirectUris = this.redirectUrlValue !== undefined ? [this.redirectUrlValue] : [];
|
|
54
|
+
return {
|
|
55
|
+
client_name: 'dsh-mcp-client',
|
|
56
|
+
redirect_uris: redirectUris,
|
|
57
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
58
|
+
token_endpoint_auth_method: 'none',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async clientInformation() {
|
|
62
|
+
const value = await this.read(this.clientRef);
|
|
63
|
+
return value === undefined ? undefined : JSON.parse(value);
|
|
64
|
+
}
|
|
65
|
+
async saveClientInformation(clientInformation) {
|
|
66
|
+
await this.write(this.clientRef, JSON.stringify(clientInformation));
|
|
67
|
+
}
|
|
68
|
+
async tokens() {
|
|
69
|
+
const value = await this.read(this.tokensRef);
|
|
70
|
+
return value === undefined ? undefined : JSON.parse(value);
|
|
71
|
+
}
|
|
72
|
+
async saveTokens(tokens) {
|
|
73
|
+
await this.write(this.tokensRef, JSON.stringify(tokens));
|
|
74
|
+
}
|
|
75
|
+
redirectToAuthorization(authorizationUrl) {
|
|
76
|
+
// Headless host: there is no browser to drive. Log the URL so an operator
|
|
77
|
+
// can complete the flow; the transport raises an UnauthorizedError until
|
|
78
|
+
// then, which the tool bridge surfaces (retrying once after a refresh).
|
|
79
|
+
this.ctx.logger.info(`mcp-client oauth: open ${String(authorizationUrl)} to authorize`);
|
|
80
|
+
}
|
|
81
|
+
async saveCodeVerifier(codeVerifier) {
|
|
82
|
+
await this.write(this.verifierRef, codeVerifier);
|
|
83
|
+
}
|
|
84
|
+
async codeVerifier() {
|
|
85
|
+
const value = await this.read(this.verifierRef);
|
|
86
|
+
return value ?? '';
|
|
87
|
+
}
|
|
88
|
+
async saveDiscoveryState(state) {
|
|
89
|
+
await this.write(this.discoveryRef, JSON.stringify(state));
|
|
90
|
+
}
|
|
91
|
+
async discoveryState() {
|
|
92
|
+
const value = await this.read(this.discoveryRef);
|
|
93
|
+
return value === undefined ? undefined : JSON.parse(value);
|
|
94
|
+
}
|
|
95
|
+
async invalidateCredentials(scope) {
|
|
96
|
+
const refs = scope === 'all'
|
|
97
|
+
? [this.tokensRef, this.clientRef, this.verifierRef, this.discoveryRef]
|
|
98
|
+
: scope === 'client'
|
|
99
|
+
? [this.clientRef]
|
|
100
|
+
: scope === 'tokens'
|
|
101
|
+
? [this.tokensRef]
|
|
102
|
+
: scope === 'verifier'
|
|
103
|
+
? [this.verifierRef]
|
|
104
|
+
: [this.discoveryRef];
|
|
105
|
+
await Promise.all(refs.map(ref => this.ctx.credentials.unset(ref)));
|
|
106
|
+
}
|
|
107
|
+
/** Read a credential value, treating an empty value as unset. */
|
|
108
|
+
async read(ref) {
|
|
109
|
+
const resolved = await this.ctx.credentials.resolve(ref);
|
|
110
|
+
return resolved?.value ?? undefined;
|
|
111
|
+
}
|
|
112
|
+
/** Persist a value; an empty value is removed rather than stored. */
|
|
113
|
+
async write(ref, value) {
|
|
114
|
+
if (value.length === 0) {
|
|
115
|
+
await this.ctx.credentials.unset(ref);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
await this.ctx.credentials.set(ref, value);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=auth.js.map
|
package/lib/auth.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection supervisor: owns the MCP client/transport generations for one
|
|
3
|
+
* plugin instance, keeps the harness registries (tools, resources-as-tools,
|
|
4
|
+
* and prompt-skills) in sync with the live generation, and — when the
|
|
5
|
+
* connection drops — restarts the configured server with bounded exponential
|
|
6
|
+
* backoff.
|
|
7
|
+
*
|
|
8
|
+
* One outage shares one attempt budget (`maxAttempts` consecutive failed
|
|
9
|
+
* attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
|
|
10
|
+
* connection that stays up past the stability window closes the outage, so
|
|
11
|
+
* the next disconnect starts a fresh budget while a crash-looping server —
|
|
12
|
+
* even one whose connects briefly succeed — still exhausts the cap instead of
|
|
13
|
+
* restarting forever. Exhaustion unregisters the server's tools and stops;
|
|
14
|
+
* disposal (including HMR) is the only way back from that state.
|
|
15
|
+
*
|
|
16
|
+
* Capability branching follows the server's declared `ServerCapabilities`:
|
|
17
|
+
* `tools`, `resources`, and `prompts` each contribute a disposer map that is
|
|
18
|
+
* kept live across an outage (last-good wins) and removed on give-up or
|
|
19
|
+
* disposal. Every swap runs on one serialized chain so generations can never
|
|
20
|
+
* interleave a dispose-previous/register-next swap.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
25
|
+
import type { Config } from './index.ts';
|
|
26
|
+
/** Automatic reconnect policy for one MCP server connection. */
|
|
27
|
+
export interface ReconnectConfig {
|
|
28
|
+
/** Reconnect automatically after a lost connection (default true). */
|
|
29
|
+
enabled?: boolean;
|
|
30
|
+
/** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */
|
|
31
|
+
initialDelayMs?: number;
|
|
32
|
+
/** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */
|
|
33
|
+
maxDelayMs?: number;
|
|
34
|
+
/** Consecutive failed attempts per outage before giving up for good (default 10). */
|
|
35
|
+
maxAttempts?: number;
|
|
36
|
+
}
|
|
37
|
+
/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
|
|
38
|
+
export declare const RECONNECT_DEFAULTS: Required<ReconnectConfig>;
|
|
39
|
+
/** Fully resolved reconnect policy captured at plugin load. */
|
|
40
|
+
export type ResolvedReconnectPolicy = Readonly<Required<ReconnectConfig>>;
|
|
41
|
+
/**
|
|
42
|
+
* The one explicit resolve step from raw reconnect config to the policy the
|
|
43
|
+
* supervisor runs. Programmatic construction may bypass Schemastery
|
|
44
|
+
* normalization, so every default and bound is re-judged here — misconfiguration
|
|
45
|
+
* fails the plugin instance at load.
|
|
46
|
+
*
|
|
47
|
+
* @param config - Raw `reconnect` config; omission uses the defaults.
|
|
48
|
+
* @param path - Diagnostic prefix naming the config location in thrown messages.
|
|
49
|
+
* @returns The frozen resolved policy.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveReconnectPolicy(config: ReconnectConfig | undefined, path: string): ResolvedReconnectPolicy;
|
|
52
|
+
/** Result from the initial connection attempt, for startup-await semantics. */
|
|
53
|
+
export interface ConnectionOutcome {
|
|
54
|
+
/** If the initial connection or tool sync failed, the error; otherwise absent. */
|
|
55
|
+
error?: unknown;
|
|
56
|
+
}
|
|
57
|
+
/** Handle for one plugin instance's supervised connection. */
|
|
58
|
+
export interface ConnectionHandle {
|
|
59
|
+
/**
|
|
60
|
+
* Settles when the first connection attempt completes (success or failure).
|
|
61
|
+
* The supervisor enters its reconnect loop regardless; the caller decides
|
|
62
|
+
* whether a failed startup is fatal via `failOnStartupError`.
|
|
63
|
+
*/
|
|
64
|
+
ready: Promise<ConnectionOutcome>;
|
|
65
|
+
/**
|
|
66
|
+
* Stop reconnection, close the live client, wait for the in-flight attempt
|
|
67
|
+
* and queued capability syncs to quiesce, then unregister every tool,
|
|
68
|
+
* resource bridge, and prompt skill this server still owns.
|
|
69
|
+
*/
|
|
70
|
+
dispose(): Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* How many capabilities this server currently publishes to the tool
|
|
73
|
+
* registries: bridged MCP tools (deferred reservations included — they are
|
|
74
|
+
* invisible in the model-facing schema but this server owns them) plus the
|
|
75
|
+
* resource-bridge tools. Prompts are skills and do not count.
|
|
76
|
+
*/
|
|
77
|
+
toolCount(): number;
|
|
78
|
+
/**
|
|
79
|
+
* Eager/deferred split of {@link toolCount}: eager counts listed tools
|
|
80
|
+
* registered visibly plus the always-eager resource-bridge tools; deferred
|
|
81
|
+
* counts listed tools hidden behind ToolSearch. `eager + deferred === toolCount()`.
|
|
82
|
+
*/
|
|
83
|
+
toolBreakdown(): {
|
|
84
|
+
eager: number;
|
|
85
|
+
deferred: number;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Start the supervised connection for one MCP server and keep it alive per
|
|
90
|
+
* the reconnect policy.
|
|
91
|
+
*
|
|
92
|
+
* @param ctx - Cordis context providing the `tools` registry and logger.
|
|
93
|
+
* @param config - Resolved plugin config selecting the transport and server identity.
|
|
94
|
+
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
|
95
|
+
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
|
96
|
+
*/
|
|
97
|
+
export declare function startConnection(ctx: Context, config: Config, policy: ResolvedReconnectPolicy): ConnectionHandle;
|
|
98
|
+
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAQH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAWlD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAExC,gEAAgE;AAChE,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,mGAAmG;IACnG,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,8GAA8G;IAC9G,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,+EAA+E;AAC/E,eAAO,MAAM,kBAAkB,EAAE,QAAQ,CAAC,eAAe,CAKvD,CAAA;AAOF,+DAA+D;AAC/D,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC,CAAA;AAEzE;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,uBAAuB,CAyBjH;AAED,+EAA+E;AAC/E,MAAM,WAAW,iBAAiB;IAChC,kFAAkF;IAClF,KAAK,CAAC,EAAE,OAAO,CAAA;CAChB;AAED,8DAA8D;AAC9D,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,KAAK,EAAE,OAAO,CAAC,iBAAiB,CAAC,CAAA;IACjC;;;;OAIG;IACH,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB;;;;;OAKG;IACH,SAAS,IAAI,MAAM,CAAA;IACnB;;;;OAIG;IACH,aAAa,IAAI;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CACrD;AAkBD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,uBAAuB,GAAG,gBAAgB,CAsT/G"}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection supervisor: owns the MCP client/transport generations for one
|
|
3
|
+
* plugin instance, keeps the harness registries (tools, resources-as-tools,
|
|
4
|
+
* and prompt-skills) in sync with the live generation, and — when the
|
|
5
|
+
* connection drops — restarts the configured server with bounded exponential
|
|
6
|
+
* backoff.
|
|
7
|
+
*
|
|
8
|
+
* One outage shares one attempt budget (`maxAttempts` consecutive failed
|
|
9
|
+
* attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
|
|
10
|
+
* connection that stays up past the stability window closes the outage, so
|
|
11
|
+
* the next disconnect starts a fresh budget while a crash-looping server —
|
|
12
|
+
* even one whose connects briefly succeed — still exhausts the cap instead of
|
|
13
|
+
* restarting forever. Exhaustion unregisters the server's tools and stops;
|
|
14
|
+
* disposal (including HMR) is the only way back from that state.
|
|
15
|
+
*
|
|
16
|
+
* Capability branching follows the server's declared `ServerCapabilities`:
|
|
17
|
+
* `tools`, `resources`, and `prompts` each contribute a disposer map that is
|
|
18
|
+
* kept live across an outage (last-good wins) and removed on give-up or
|
|
19
|
+
* disposal. Every swap runs on one serialized chain so generations can never
|
|
20
|
+
* interleave a dispose-previous/register-next swap.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
25
|
+
import { PromptListChangedNotificationSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
26
|
+
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
|
|
27
|
+
import { createTransport, buildAuthProvider } from "./transport.js";
|
|
28
|
+
import { formatStdioStderrForWarn, stdioStderrTail } from "./stdio-stderr.js";
|
|
29
|
+
import { DEFAULT_DEFER_TOOL_THRESHOLD, emptyToolGeneration, syncTools } from "./tools.js";
|
|
30
|
+
import { syncResources } from "./resources.js";
|
|
31
|
+
import { syncPrompts } from "./prompts.js";
|
|
32
|
+
/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
|
|
33
|
+
export const RECONNECT_DEFAULTS = Object.freeze({
|
|
34
|
+
enabled: true,
|
|
35
|
+
initialDelayMs: 500,
|
|
36
|
+
maxDelayMs: 30_000,
|
|
37
|
+
maxAttempts: 10,
|
|
38
|
+
});
|
|
39
|
+
// The SDK's stdio transport owns two two-second termination grace periods.
|
|
40
|
+
// Keep one additional second for the process-close event that proves the old
|
|
41
|
+
// generation is gone; timing out fails closed instead of overlapping children.
|
|
42
|
+
const GENERATION_CLOSE_TIMEOUT_MS = 5_000;
|
|
43
|
+
/**
|
|
44
|
+
* The one explicit resolve step from raw reconnect config to the policy the
|
|
45
|
+
* supervisor runs. Programmatic construction may bypass Schemastery
|
|
46
|
+
* normalization, so every default and bound is re-judged here — misconfiguration
|
|
47
|
+
* fails the plugin instance at load.
|
|
48
|
+
*
|
|
49
|
+
* @param config - Raw `reconnect` config; omission uses the defaults.
|
|
50
|
+
* @param path - Diagnostic prefix naming the config location in thrown messages.
|
|
51
|
+
* @returns The frozen resolved policy.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveReconnectPolicy(config, path) {
|
|
54
|
+
if (config !== undefined) {
|
|
55
|
+
for (const key of Object.keys(config)) {
|
|
56
|
+
if (!Object.hasOwn(RECONNECT_DEFAULTS, key))
|
|
57
|
+
throw new Error(`${path}.${key} is not a reconnect option`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled;
|
|
61
|
+
const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs;
|
|
62
|
+
const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs;
|
|
63
|
+
const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts;
|
|
64
|
+
/* jscpd:ignore-start — domain-specific delay validation parallels llm retry-policy; not extractable */
|
|
65
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
|
|
66
|
+
throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
67
|
+
}
|
|
68
|
+
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
|
|
69
|
+
throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
70
|
+
}
|
|
71
|
+
if (initialDelayMs > maxDelayMs) {
|
|
72
|
+
throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
75
|
+
throw new Error(`${path}.maxAttempts must be a positive integer`);
|
|
76
|
+
}
|
|
77
|
+
/* jscpd:ignore-end */
|
|
78
|
+
return Object.freeze({ enabled, initialDelayMs, maxDelayMs, maxAttempts });
|
|
79
|
+
}
|
|
80
|
+
function clear(entry) {
|
|
81
|
+
const disposers = entry instanceof Map ? entry.values() : entry.disposers.values();
|
|
82
|
+
for (const dispose of disposers)
|
|
83
|
+
dispose();
|
|
84
|
+
return entry instanceof Map ? new Map() : emptyToolGeneration();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Start the supervised connection for one MCP server and keep it alive per
|
|
88
|
+
* the reconnect policy.
|
|
89
|
+
*
|
|
90
|
+
* @param ctx - Cordis context providing the `tools` registry and logger.
|
|
91
|
+
* @param config - Resolved plugin config selecting the transport and server identity.
|
|
92
|
+
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
|
93
|
+
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
|
94
|
+
*/
|
|
95
|
+
export function startConnection(ctx, config, policy) {
|
|
96
|
+
const label = `mcp-client(${config.serverName})`;
|
|
97
|
+
// OAuth provider shared by the transport and the 401-retry hook.
|
|
98
|
+
const authProvider = config.transport !== 'stdio' && config.oauth !== undefined
|
|
99
|
+
&& ctx.get('credentials') !== undefined
|
|
100
|
+
? buildAuthProvider(ctx, config.serverName, config.oauth)
|
|
101
|
+
: undefined;
|
|
102
|
+
const transportCtx = { ctx, ...authProvider !== undefined ? { authProvider } : {} };
|
|
103
|
+
const opts = {
|
|
104
|
+
registrationFailure: 'contain',
|
|
105
|
+
serverName: config.serverName,
|
|
106
|
+
toolCallTimeoutMs: config.toolCallTimeoutMs,
|
|
107
|
+
deferToolThreshold: DEFAULT_DEFER_TOOL_THRESHOLD,
|
|
108
|
+
...authProvider !== undefined ? { onUnauthorized: () => authProvider.invalidateCredentials('tokens') } : {},
|
|
109
|
+
};
|
|
110
|
+
// The initial sync uses 'throw' when failOnStartupError is configured, so
|
|
111
|
+
// a registration conflict propagates to the startup-await path. Re-syncs
|
|
112
|
+
// and reconnect syncs always contain conflicts.
|
|
113
|
+
const startupOpts = config.failOnStartupError
|
|
114
|
+
? { ...opts, registrationFailure: 'throw' }
|
|
115
|
+
: opts;
|
|
116
|
+
let disposed = false;
|
|
117
|
+
/** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
|
|
118
|
+
let client;
|
|
119
|
+
/** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
|
|
120
|
+
let clientClosed;
|
|
121
|
+
/** Live registrations owned by this server; each map is swapped by its own sync task or clear. */
|
|
122
|
+
let registrations = { tools: emptyToolGeneration(), resources: new Map(), prompts: new Map() };
|
|
123
|
+
let reconnectTimer;
|
|
124
|
+
/** Consecutive failed connection attempts within the current outage. */
|
|
125
|
+
let failedAttempts = 0;
|
|
126
|
+
/** When the current generation finished connect + initial sync; undefined while down. */
|
|
127
|
+
let connectedAt;
|
|
128
|
+
/** The real error from the first connection attempt, for startup-await diagnostics. */
|
|
129
|
+
let firstAttemptError;
|
|
130
|
+
/** Most recent stdio transport; snapshotted at warn time after stdio has flushed. */
|
|
131
|
+
let lastTransport;
|
|
132
|
+
/** One-line `; stderr: …` suffix, omitted when the ring is empty. */
|
|
133
|
+
function stderrWarnSuffix() {
|
|
134
|
+
if (lastTransport === undefined)
|
|
135
|
+
return '';
|
|
136
|
+
const formatted = formatStdioStderrForWarn(stdioStderrTail(lastTransport));
|
|
137
|
+
return formatted === undefined ? '' : `; stderr: ${formatted}`;
|
|
138
|
+
}
|
|
139
|
+
/** A generation may act only while it is the current one on a live plugin. */
|
|
140
|
+
const isCurrent = (generation) => !disposed && client === generation;
|
|
141
|
+
/**
|
|
142
|
+
* Serializes every registration swap — tool syncs, resource registration,
|
|
143
|
+
* and prompt syncs, initial and notification-driven, across all generations —
|
|
144
|
+
* so two swaps can never interleave their dispose-previous/register-next
|
|
145
|
+
* phase (which would double-dispose one generation and leak another).
|
|
146
|
+
*/
|
|
147
|
+
let syncChain = Promise.resolve();
|
|
148
|
+
function enqueue(task) {
|
|
149
|
+
const run = syncChain.then(task);
|
|
150
|
+
// The chain tail must survive a failed sync; the enqueuing caller owns reporting.
|
|
151
|
+
syncChain = run.catch(() => { });
|
|
152
|
+
return run;
|
|
153
|
+
}
|
|
154
|
+
/** Swap the tool generation on connect / tools-list-changed / reconnect. */
|
|
155
|
+
function syncToolGeneration(generation, syncOpts = opts) {
|
|
156
|
+
return enqueue(async () => {
|
|
157
|
+
if (!isCurrent(generation))
|
|
158
|
+
return;
|
|
159
|
+
const next = await syncTools(generation, ctx, syncOpts, registrations.tools);
|
|
160
|
+
registrations = { ...registrations, tools: next };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** Register the resource bridge once the server declares the capability. */
|
|
164
|
+
function syncResourceGeneration(generation) {
|
|
165
|
+
return enqueue(() => {
|
|
166
|
+
if (!isCurrent(generation))
|
|
167
|
+
return;
|
|
168
|
+
const next = syncResources(generation, ctx, config.serverName);
|
|
169
|
+
const old = registrations.resources;
|
|
170
|
+
registrations = { ...registrations, resources: next };
|
|
171
|
+
for (const dispose of old.values())
|
|
172
|
+
dispose();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Sync prompt-skills once the server declares the capability. */
|
|
176
|
+
function syncPromptGeneration(generation) {
|
|
177
|
+
return enqueue(async () => {
|
|
178
|
+
if (!isCurrent(generation))
|
|
179
|
+
return;
|
|
180
|
+
const next = await syncPrompts(generation, ctx, config.serverName);
|
|
181
|
+
for (const dispose of registrations.prompts.values())
|
|
182
|
+
dispose();
|
|
183
|
+
registrations = { ...registrations, prompts: next };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
|
|
187
|
+
function generationDown(generation) {
|
|
188
|
+
if (!isCurrent(generation))
|
|
189
|
+
return;
|
|
190
|
+
client = undefined;
|
|
191
|
+
clientClosed = undefined;
|
|
192
|
+
scheduleReconnect();
|
|
193
|
+
}
|
|
194
|
+
/** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
|
|
195
|
+
function waitForClose(closed) {
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
const timeout = setTimeout(() => { resolve(false); }, GENERATION_CLOSE_TIMEOUT_MS);
|
|
198
|
+
timeout.unref();
|
|
199
|
+
void closed.then(() => {
|
|
200
|
+
clearTimeout(timeout);
|
|
201
|
+
resolve(true);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function scheduleReconnect() {
|
|
206
|
+
const lostEstablishedConnection = connectedAt !== undefined;
|
|
207
|
+
if (!policy.enabled) {
|
|
208
|
+
const message = lostEstablishedConnection
|
|
209
|
+
? 'connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart'
|
|
210
|
+
: 'connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect';
|
|
211
|
+
ctx.logger.error(`${label}: ${message}${stderrWarnSuffix()}`);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// A connection that stayed up past the stability window (= maxDelayMs, the
|
|
215
|
+
// longest backoff spacing) ended the previous outage: start a fresh budget.
|
|
216
|
+
if (connectedAt !== undefined && Date.now() - connectedAt >= policy.maxDelayMs)
|
|
217
|
+
failedAttempts = 0;
|
|
218
|
+
connectedAt = undefined;
|
|
219
|
+
failedAttempts += 1;
|
|
220
|
+
if (failedAttempts > policy.maxAttempts) {
|
|
221
|
+
// Enqueue the give-up disposal so it cannot race an in-flight sync's
|
|
222
|
+
// swap (which checks isCurrent inside the queue).
|
|
223
|
+
void enqueue(() => {
|
|
224
|
+
registrations = {
|
|
225
|
+
tools: clear(registrations.tools),
|
|
226
|
+
resources: clear(registrations.resources),
|
|
227
|
+
prompts: clear(registrations.prompts),
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1));
|
|
234
|
+
const action = lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying';
|
|
235
|
+
ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})${stderrWarnSuffix()}`);
|
|
236
|
+
reconnectTimer = setTimeout(() => {
|
|
237
|
+
reconnectTimer = undefined;
|
|
238
|
+
settling = connectGeneration(false);
|
|
239
|
+
}, delayMs);
|
|
240
|
+
// An armed reconnect timer must never hold the process open on its own.
|
|
241
|
+
reconnectTimer.unref();
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* One connection attempt: fresh transport + client (the MCP SDK binds a
|
|
245
|
+
* Protocol to one transport for life), connect, then queue the capability
|
|
246
|
+
* syncs for the declared server capabilities. The startup flag belongs to the
|
|
247
|
+
* attempt rather than the shared sync queue, so an early notification cannot
|
|
248
|
+
* consume strict startup semantics. Every failure funnels through
|
|
249
|
+
* {@link generationDown}; success arms the onclose-driven disconnect path.
|
|
250
|
+
* Never rejects.
|
|
251
|
+
*
|
|
252
|
+
* @param startup - Whether this is the plugin's activation attempt.
|
|
253
|
+
*/
|
|
254
|
+
async function connectGeneration(startup) {
|
|
255
|
+
const generation = new Client({ name: 'dsh-mcp-client', version: '0.0.1' }, { capabilities: {} });
|
|
256
|
+
const closed = Promise.withResolvers();
|
|
257
|
+
let attemptSettled = false;
|
|
258
|
+
let closeObserved = false;
|
|
259
|
+
const hasClosed = () => closeObserved;
|
|
260
|
+
client = generation;
|
|
261
|
+
clientClosed = closed.promise;
|
|
262
|
+
generation.onclose = () => {
|
|
263
|
+
closeObserved = true;
|
|
264
|
+
closed.resolve();
|
|
265
|
+
// A failed connect owns its close barrier in the catch path below. An
|
|
266
|
+
// established generation can transition down directly from this signal.
|
|
267
|
+
if (attemptSettled)
|
|
268
|
+
generationDown(generation);
|
|
269
|
+
};
|
|
270
|
+
// Registered before connect so a list change during an initial sync is
|
|
271
|
+
// queued behind it rather than dropped.
|
|
272
|
+
generation.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
273
|
+
if (!isCurrent(generation))
|
|
274
|
+
return;
|
|
275
|
+
ctx.logger.info(`${label}: tool list changed, re-syncing`);
|
|
276
|
+
try {
|
|
277
|
+
await syncToolGeneration(generation);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
// Fetch-phase failure: the previous generation is still registered
|
|
281
|
+
// and `registrations.tools` still owns it — keep serving the last good list.
|
|
282
|
+
if (!disposed)
|
|
283
|
+
ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
generation.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
|
|
287
|
+
if (!isCurrent(generation))
|
|
288
|
+
return;
|
|
289
|
+
ctx.logger.info(`${label}: resource list changed, re-syncing resources`);
|
|
290
|
+
await syncResourceGeneration(generation);
|
|
291
|
+
});
|
|
292
|
+
generation.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
|
|
293
|
+
if (!isCurrent(generation))
|
|
294
|
+
return;
|
|
295
|
+
ctx.logger.info(`${label}: prompt list changed, re-syncing prompts`);
|
|
296
|
+
await syncPromptGeneration(generation);
|
|
297
|
+
});
|
|
298
|
+
try {
|
|
299
|
+
const transport = createTransport(config, transportCtx);
|
|
300
|
+
lastTransport = transport;
|
|
301
|
+
await generation.connect(transport);
|
|
302
|
+
if (hasClosed()) {
|
|
303
|
+
attemptSettled = true;
|
|
304
|
+
generationDown(generation);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const caps = typeof generation.getServerCapabilities === 'function'
|
|
308
|
+
? generation.getServerCapabilities()
|
|
309
|
+
: undefined;
|
|
310
|
+
// Tools are bridged unconditionally (the long-standing behavior); only
|
|
311
|
+
// the optional resource and prompt bridges are capability-gated.
|
|
312
|
+
await syncToolGeneration(generation, startup ? startupOpts : opts);
|
|
313
|
+
if (caps?.resources !== undefined)
|
|
314
|
+
await syncResourceGeneration(generation);
|
|
315
|
+
if (caps?.prompts !== undefined)
|
|
316
|
+
await syncPromptGeneration(generation);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
if (firstAttemptError === undefined)
|
|
320
|
+
firstAttemptError = error;
|
|
321
|
+
// Disposal clears current ownership before it closes the generation, so
|
|
322
|
+
// only a live supervisor reports an attempt failure.
|
|
323
|
+
if (isCurrent(generation))
|
|
324
|
+
ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}${stderrWarnSuffix()}`);
|
|
325
|
+
try {
|
|
326
|
+
await generation.close();
|
|
327
|
+
}
|
|
328
|
+
catch { /* transport already gone */ }
|
|
329
|
+
const quiesced = hasClosed() || await waitForClose(closed.promise);
|
|
330
|
+
attemptSettled = true;
|
|
331
|
+
if (!isCurrent(generation))
|
|
332
|
+
return;
|
|
333
|
+
if (!quiesced) {
|
|
334
|
+
client = undefined;
|
|
335
|
+
clientClosed = undefined;
|
|
336
|
+
ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
generationDown(generation);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
attemptSettled = true;
|
|
343
|
+
if (hasClosed()) {
|
|
344
|
+
generationDown(generation);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!isCurrent(generation))
|
|
348
|
+
return;
|
|
349
|
+
connectedAt = Date.now();
|
|
350
|
+
if (failedAttempts > 0)
|
|
351
|
+
ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
352
|
+
}
|
|
353
|
+
/** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
|
|
354
|
+
let settling = connectGeneration(true);
|
|
355
|
+
// The ready promise settles when the first attempt finishes (regardless of
|
|
356
|
+
// success). If the first attempt fails and reconnect is enabled, the
|
|
357
|
+
// supervisor is already scheduling a retry — ready just reports the outcome.
|
|
358
|
+
const ready = settling.then(() => {
|
|
359
|
+
// After settling: if client is set the initial connect+sync succeeded.
|
|
360
|
+
// If not, the supervisor either scheduled a retry (error logged) or gave
|
|
361
|
+
// up (error logged). Either way the outcome is reported with the real error.
|
|
362
|
+
if (client !== undefined)
|
|
363
|
+
return {};
|
|
364
|
+
/* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */
|
|
365
|
+
return { error: firstAttemptError ?? new Error(`${label}: initial connection failed`) };
|
|
366
|
+
});
|
|
367
|
+
return {
|
|
368
|
+
ready,
|
|
369
|
+
toolCount() {
|
|
370
|
+
return registrations.tools.disposers.size + registrations.resources.size;
|
|
371
|
+
},
|
|
372
|
+
toolBreakdown() {
|
|
373
|
+
return {
|
|
374
|
+
eager: registrations.tools.eagerCount + registrations.resources.size,
|
|
375
|
+
deferred: registrations.tools.deferredCount,
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
async dispose() {
|
|
379
|
+
disposed = true;
|
|
380
|
+
if (reconnectTimer !== undefined) {
|
|
381
|
+
clearTimeout(reconnectTimer);
|
|
382
|
+
reconnectTimer = undefined;
|
|
383
|
+
}
|
|
384
|
+
const current = client;
|
|
385
|
+
const currentClosed = clientClosed;
|
|
386
|
+
client = undefined;
|
|
387
|
+
clientClosed = undefined;
|
|
388
|
+
if (current !== undefined) {
|
|
389
|
+
try {
|
|
390
|
+
await current.close();
|
|
391
|
+
}
|
|
392
|
+
catch { /* transport already gone */ }
|
|
393
|
+
if (currentClosed !== undefined && !await waitForClose(currentClosed)) {
|
|
394
|
+
ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Quiesce, don't just request it: the in-flight attempt enqueues its
|
|
398
|
+
// syncs before settling, so awaiting both leaves `registrations` final.
|
|
399
|
+
await settling;
|
|
400
|
+
await syncChain;
|
|
401
|
+
registrations = {
|
|
402
|
+
tools: clear(registrations.tools),
|
|
403
|
+
resources: clear(registrations.resources),
|
|
404
|
+
prompts: clear(registrations.prompts),
|
|
405
|
+
};
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
//# sourceMappingURL=connection.js.map
|