@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.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +149 -0
  4. package/README.zh.md +150 -0
  5. package/lib/auth.d.ts +66 -0
  6. package/lib/auth.d.ts.map +1 -0
  7. package/lib/auth.js +121 -0
  8. package/lib/auth.js.map +1 -0
  9. package/lib/connection.d.ts +98 -0
  10. package/lib/connection.d.ts.map +1 -0
  11. package/lib/connection.js +409 -0
  12. package/lib/connection.js.map +1 -0
  13. package/lib/defer.d.ts +39 -0
  14. package/lib/defer.d.ts.map +1 -0
  15. package/lib/defer.js +40 -0
  16. package/lib/defer.js.map +1 -0
  17. package/lib/index.d.ts +129 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +198 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/invariant.d.ts +16 -0
  22. package/lib/invariant.d.ts.map +1 -0
  23. package/lib/invariant.js +22 -0
  24. package/lib/invariant.js.map +1 -0
  25. package/lib/prompts.d.ts +44 -0
  26. package/lib/prompts.d.ts.map +1 -0
  27. package/lib/prompts.js +166 -0
  28. package/lib/prompts.js.map +1 -0
  29. package/lib/registry.d.ts +94 -0
  30. package/lib/registry.d.ts.map +1 -0
  31. package/lib/registry.js +101 -0
  32. package/lib/registry.js.map +1 -0
  33. package/lib/resources.d.ts +42 -0
  34. package/lib/resources.d.ts.map +1 -0
  35. package/lib/resources.js +136 -0
  36. package/lib/resources.js.map +1 -0
  37. package/lib/stdio-stderr.d.ts +66 -0
  38. package/lib/stdio-stderr.d.ts.map +1 -0
  39. package/lib/stdio-stderr.js +187 -0
  40. package/lib/stdio-stderr.js.map +1 -0
  41. package/lib/tools.d.ts +161 -0
  42. package/lib/tools.d.ts.map +1 -0
  43. package/lib/tools.js +373 -0
  44. package/lib/tools.js.map +1 -0
  45. package/lib/transport.d.ts +50 -0
  46. package/lib/transport.d.ts.map +1 -0
  47. package/lib/transport.js +79 -0
  48. package/lib/transport.js.map +1 -0
  49. 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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,OAAO,EAAE,aAAa,EAAsB,MAAM,8BAA8B,CAAA;AAehF,+EAA+E;AAC/E,SAAS,QAAQ,CAAC,MAAc,EAAE,IAAY;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,gBAAgB,EAAE,GAAG,CAAC,CAAA;IAChE,OAAO,aAAa,CAAC,GAAG,IAAI,IAAI,IAAI,EAAE,CAAC,CAAA;AACzC,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,8BAA8B;IAQtB;IAPF,SAAS,CAAe;IACxB,SAAS,CAAe;IACxB,WAAW,CAAe;IAC1B,YAAY,CAAe;IAC3B,gBAAgB,CAAoB;IAErD,YACmB,GAAY,EAC7B,UAAkB,EAClB,MAAmB;QAFF,QAAG,GAAH,GAAG,CAAS;QAI7B,MAAM,MAAM,GAAG,MAAM,CAAC,gBAAgB,IAAI,aAAa,UAAU,EAAE,CAAA;QACnE,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;QAC3C,IAAI,CAAC,SAAS,GAAG,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;QAC3C,IAAI,CAAC,WAAW,GAAG,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;QAC/C,IAAI,CAAC,YAAY,GAAG,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;QACjD,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAC,WAAW,CAAA;QAC1C,KAAK,MAAM,CAAC,UAAU,CAAA,CAAC,sCAAsC;IAC/D,CAAC;IAED,IAAI,WAAW;QACb,OAAO,IAAI,CAAC,gBAAgB,CAAA;IAC9B,CAAC;IAED,IAAI,cAAc;QAChB,MAAM,YAAY,GAAG,IAAI,CAAC,gBAAgB,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAA;QACvF,OAAO;YACL,WAAW,EAAE,gBAAgB;YAC7B,aAAa,EAAE,YAAY;YAC3B,WAAW,EAAE,CAAC,oBAAoB,EAAE,eAAe,CAAC;YACpD,0BAA0B,EAAE,MAAM;SACnC,CAAA;IACH,CAAC;IAED,KAAK,CAAC,iBAAiB;QACrB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;QAC7C,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAiC,CAAA;IAC7F,CAAC;IAED,KAAK,CAAC,qBAAqB,CAAC,iBAA8C;QACxE,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,iBAAiB,CAAC,CAAC,CAAA;IACrE,CAAC;IAED,KAAK,CAAC,MAAM;QACV,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;QAC7C,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAiB,CAAA;IAC7E,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,MAAmB;QAClC,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAA;IAC1D,CAAC;IAED,uBAAuB,CAAC,gBAAqB;QAC3C,0EAA0E;QAC1E,yEAAyE;QACzE,wEAAwE;QACxE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,MAAM,CAAC,gBAAgB,CAAC,eAAe,CAAC,CAAA;IACzF,CAAC;IAED,KAAK,CAAC,gBAAgB,CAAC,YAAoB;QACzC,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,EAAE,YAAY,CAAC,CAAA;IAClD,CAAC;IAED,KAAK,CAAC,YAAY;QAChB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC/C,OAAO,KAAK,IAAI,EAAE,CAAA;IACpB,CAAC;IAED,KAAK,CAAC,kBAAkB,CAAC,KAA0B;QACjD,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;IAC5D,CAAC;IAED,KAAK,CAAC,cAAc;QAClB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAChD,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAyB,CAAA;IACrF,CAAC;IAED,KAAK,CAAC,qBAAqB,CAAC,KAA6D;QACvF,MAAM,IAAI,GAAG,KAAK,KAAK,KAAK;YAC1B,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,YAAY,CAAC;YACvE,CAAC,CAAC,KAAK,KAAK,QAAQ;gBAClB,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC;gBAClB,CAAC,CAAC,KAAK,KAAK,QAAQ;oBAClB,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC;oBAClB,CAAC,CAAC,KAAK,KAAK,UAAU;wBACpB,CAAC,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC;wBACpB,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC7B,MAAM,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAA;IACrE,CAAC;IAED,iEAAiE;IACzD,KAAK,CAAC,IAAI,CAAC,GAAkB;QACnC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;QACxD,OAAO,QAAQ,EAAE,KAAK,IAAI,SAAS,CAAA;IACrC,CAAC;IAED,qEAAqE;IAC7D,KAAK,CAAC,KAAK,CAAC,GAAkB,EAAE,KAAa;QACnD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;YACrC,OAAM;QACR,CAAC;QACD,MAAM,IAAI,CAAC,GAAG,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;IAC5C,CAAC;CACF"}
@@ -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