@sparkelf/dsh-plugin-mcp-credentials 0.2.1-rc.2.1 → 0.2.1-rc.2.3

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/lib/index.js CHANGED
@@ -494,8 +494,66 @@ const RECONNECT_DEFAULTS = Object.freeze({
494
494
  maxDelayMs: 3e4,
495
495
  maxAttempts: 10
496
496
  });
497
+ /**
498
+ * How long to wait before trying again after the server rejected our credentials.
499
+ *
500
+ * A rejected credential is not an outage: retrying within seconds cannot help, because the
501
+ * credential only changes when somebody signs in again. Backing off on a minutes-long curve
502
+ * keeps the tools registered (a credential outage is recoverable without a reload) and makes
503
+ * the recovery automatic — the first attempt after a fresh sign-in succeeds.
504
+ */
505
+ const AUTH_RETRY_INITIAL_MS = 6e4;
506
+ /** Ceiling for the credential-rejection retry curve. */
507
+ const AUTH_RETRY_MAX_MS = 3e5;
508
+ /**
509
+ * Whether a connection failure means "the server refused our credentials".
510
+ *
511
+ * The MCP SDK reports HTTP status as `code` on its transport error; 401 and 403 are both
512
+ * refusals of the credential, not signs of a flaky link. Everything else — refused connection,
513
+ * DNS, timeout, 5xx — is treated as an outage and keeps the existing backoff.
514
+ *
515
+ * @param error - Whatever the transport threw.
516
+ * @returns `true` when retrying cannot fix it without a new credential.
517
+ */
518
+ const isCredentialRefusal = (error) => {
519
+ if (typeof error !== "object" || error === null) return false;
520
+ const code = error.code;
521
+ return code === 401 || code === 403;
522
+ };
497
523
  const GENERATION_CLOSE_TIMEOUT_MS = 5e3;
498
524
  /**
525
+ * Decide the next move after a failed connection attempt.
526
+ *
527
+ * Pure on purpose: the interesting rule here is the one that used to be wrong — a refused
528
+ * credential was spent against the outage budget, so ten quick retries unregistered every tool
529
+ * and left the server unusable until the process restarted. Separating the decision from the
530
+ * timers and the tool bookkeeping makes that rule testable without a server.
531
+ *
532
+ * @param input - The failure cause, the policy, and the counters that survive between attempts.
533
+ * @returns The plan; `retry` carries the delay and whether the credential curve was used.
534
+ */
535
+ const planReconnect = (input) => {
536
+ if (isCredentialRefusal(input.cause)) {
537
+ const attempt = input.credentialAttempts + 1;
538
+ return {
539
+ kind: "retry",
540
+ credential: true,
541
+ delayMs: Math.min(AUTH_RETRY_MAX_MS, AUTH_RETRY_INITIAL_MS * 2 ** (attempt - 1))
542
+ };
543
+ }
544
+ if (!input.policy.enabled) return {
545
+ kind: "disabled",
546
+ established: input.established
547
+ };
548
+ const failedAttempts = (input.stableForMs !== void 0 && input.stableForMs >= input.policy.maxDelayMs ? 0 : input.failedAttempts) + 1;
549
+ if (failedAttempts > input.policy.maxAttempts) return { kind: "giveup" };
550
+ return {
551
+ kind: "retry",
552
+ credential: false,
553
+ delayMs: Math.min(input.policy.maxDelayMs, input.policy.initialDelayMs * 2 ** (failedAttempts - 1))
554
+ };
555
+ };
556
+ /**
499
557
  * The one explicit resolve step from raw reconnect config to the policy the
500
558
  * supervisor runs. Programmatic construction may bypass Schemastery
501
559
  * normalization, so every default and bound is re-judged here — misconfiguration
@@ -554,6 +612,8 @@ function startConnection(ctx, config, policy) {
554
612
  let reconnectTimer;
555
613
  /** Consecutive failed connection attempts within the current outage. */
556
614
  let failedAttempts = 0;
615
+ /** Consecutive attempts made since the server last refused our credentials. */
616
+ let credentialAttempts = 0;
557
617
  /** When the current generation finished connect + initial sync; undefined while down. */
558
618
  let connectedAt;
559
619
  /** The real error from the first connection attempt, for startup-await diagnostics. */
@@ -576,11 +636,11 @@ function startConnection(ctx, config, policy) {
576
636
  return run;
577
637
  }
578
638
  /** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
579
- function generationDown(generation) {
639
+ function generationDown(generation, cause) {
580
640
  if (!isCurrent(generation)) return;
581
641
  client = void 0;
582
642
  clientClosed = void 0;
583
- scheduleReconnect();
643
+ scheduleReconnect(cause);
584
644
  }
585
645
  /** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
586
646
  function waitForClose(closed) {
@@ -595,17 +655,23 @@ function startConnection(ctx, config, policy) {
595
655
  });
596
656
  });
597
657
  }
598
- function scheduleReconnect() {
599
- const lostEstablishedConnection = connectedAt !== void 0;
600
- if (!policy.enabled) {
601
- const message = lostEstablishedConnection ? "connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart" : "connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect";
658
+ function scheduleReconnect(cause) {
659
+ const established = connectedAt !== void 0;
660
+ const stableForMs = connectedAt === void 0 ? void 0 : Date.now() - connectedAt;
661
+ const plan = planReconnect({
662
+ ...cause === void 0 ? {} : { cause },
663
+ policy,
664
+ established,
665
+ failedAttempts,
666
+ credentialAttempts,
667
+ ...stableForMs === void 0 ? {} : { stableForMs }
668
+ });
669
+ if (plan.kind === "disabled") {
670
+ const message = plan.established ? "connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart" : "connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect";
602
671
  ctx.logger.error(`${label}: ${message}`);
603
672
  return;
604
673
  }
605
- if (connectedAt !== void 0 && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0;
606
- connectedAt = void 0;
607
- failedAttempts += 1;
608
- if (failedAttempts > policy.maxAttempts) {
674
+ if (plan.kind === "giveup") {
609
675
  syncChain = syncChain.then(() => {
610
676
  for (const dispose of disposers.values()) dispose();
611
677
  disposers = /* @__PURE__ */ new Map();
@@ -613,13 +679,21 @@ function startConnection(ctx, config, policy) {
613
679
  ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`);
614
680
  return;
615
681
  }
616
- const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1));
617
- const action = lostEstablishedConnection ? "connection lost; reconnecting" : "connection failed; retrying";
618
- ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`);
682
+ if (plan.credential) {
683
+ credentialAttempts += 1;
684
+ ctx.logger.warn(`${label}: credential rejected by the server; keeping registered tools and retrying in ${plan.delayMs}ms (attempt ${credentialAttempts})`);
685
+ } else {
686
+ credentialAttempts = 0;
687
+ if (stableForMs !== void 0 && stableForMs >= policy.maxDelayMs) failedAttempts = 0;
688
+ failedAttempts += 1;
689
+ const action = established ? "connection lost; reconnecting" : "connection failed; retrying";
690
+ ctx.logger.warn(`${label}: ${action} in ${plan.delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`);
691
+ }
692
+ connectedAt = void 0;
619
693
  reconnectTimer = setTimeout(() => {
620
694
  reconnectTimer = void 0;
621
695
  settling = connectGeneration(false);
622
- }, delayMs);
696
+ }, plan.delayMs);
623
697
  reconnectTimer.unref();
624
698
  }
625
699
  /**
@@ -680,7 +754,7 @@ function startConnection(ctx, config, policy) {
680
754
  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`);
681
755
  return;
682
756
  }
683
- generationDown(generation);
757
+ generationDown(generation, error);
684
758
  return;
685
759
  }
686
760
  attemptSettled = true;
@@ -690,6 +764,7 @@ function startConnection(ctx, config, policy) {
690
764
  }
691
765
  if (!isCurrent(generation)) return;
692
766
  connectedAt = Date.now();
767
+ credentialAttempts = 0;
693
768
  if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
694
769
  }
695
770
  /** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
@@ -29,6 +29,36 @@ export interface ReconnectConfig {
29
29
  }
30
30
  /** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
31
31
  export declare const RECONNECT_DEFAULTS: Required<ReconnectConfig>;
32
+ /** What the supervisor should do after one failed connection attempt. */
33
+ export type ReconnectPlan = {
34
+ readonly kind: 'disabled';
35
+ readonly established: boolean;
36
+ } | {
37
+ readonly kind: 'giveup';
38
+ } | {
39
+ readonly kind: 'retry';
40
+ readonly delayMs: number;
41
+ readonly credential: boolean;
42
+ };
43
+ /**
44
+ * Decide the next move after a failed connection attempt.
45
+ *
46
+ * Pure on purpose: the interesting rule here is the one that used to be wrong — a refused
47
+ * credential was spent against the outage budget, so ten quick retries unregistered every tool
48
+ * and left the server unusable until the process restarted. Separating the decision from the
49
+ * timers and the tool bookkeeping makes that rule testable without a server.
50
+ *
51
+ * @param input - The failure cause, the policy, and the counters that survive between attempts.
52
+ * @returns The plan; `retry` carries the delay and whether the credential curve was used.
53
+ */
54
+ export declare const planReconnect: (input: {
55
+ readonly cause?: unknown;
56
+ readonly policy: ResolvedReconnectPolicy;
57
+ readonly established: boolean;
58
+ readonly failedAttempts: number;
59
+ readonly credentialAttempts: number;
60
+ readonly stableForMs?: number;
61
+ }) => ReconnectPlan;
32
62
  /** Fully resolved reconnect policy captured at plugin load. */
33
63
  export type ResolvedReconnectPolicy = Readonly<Required<ReconnectConfig>>;
34
64
  /**
package/package.json CHANGED
@@ -1,25 +1,22 @@
1
1
  {
2
- "dependencies": {
3
- "@deepseek-ai/schemastery": "^3.18.2",
4
- "@modelcontextprotocol/sdk": "^1.12.0",
5
- "zod": "^4.4.3"
6
- },
2
+ "name": "@sparkelf/dsh-plugin-mcp-credentials",
7
3
  "description": "Official MCP client replacement with credential-backed Streamable HTTP Bearer authentication",
8
- "devDependencies": {
9
- "@deepseek-ai/cordis": "^4.0.2",
10
- "@deepseek-ai/dsh-attachment": "^0.1.6-alpha.1",
11
- "@deepseek-ai/dsh-credentials": "^0.1.6-alpha.1",
12
- "@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
13
- "@deepseek-ai/dsh-scope": "^0.1.6-alpha.1",
14
- "@deepseek-ai/dsh-subprocess": "^0.1.6-alpha.1",
15
- "@deepseek-ai/dsh-timeout": "^0.1.6-alpha.1",
16
- "@deepseek-ai/dsh-tools": "^0.1.6-alpha.1",
17
- "@deepseek-ai/dsh-util-values": "^0.1.6-alpha.1"
4
+ "version": "0.2.1-rc.2.3",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/plus/mcp-credentials"
18
12
  },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
19
16
  "exports": {
20
17
  ".": {
21
- "default": "./lib/index.js",
22
- "types": "./lib/types/index.d.ts"
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
23
20
  },
24
21
  "./package.json": "./package.json"
25
22
  },
@@ -28,27 +25,30 @@
28
25
  "lib/types/**/*.d.ts"
29
26
  ],
30
27
  "license": "MIT",
31
- "main": "lib/index.js",
32
- "name": "@sparkelf/dsh-plugin-mcp-credentials",
33
28
  "peerDependencies": {
29
+ "@deepseek-ai/dsh-attachment": "workspace:^",
30
+ "@deepseek-ai/dsh-llm": "workspace:^",
31
+ "@deepseek-ai/dsh-scope": "workspace:^",
32
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
33
+ "@deepseek-ai/dsh-timeout": "workspace:^",
34
+ "@deepseek-ai/dsh-tools": "workspace:^",
34
35
  "@deepseek-ai/cordis": ">=4.0.1",
35
- "@deepseek-ai/dsh-attachment": "^0.1.6-alpha.1",
36
- "@deepseek-ai/dsh-credentials": "^0.1.6-alpha.1",
37
- "@deepseek-ai/dsh-llm": "^0.1.6-alpha.1",
38
- "@deepseek-ai/dsh-scope": "^0.1.6-alpha.1",
39
- "@deepseek-ai/dsh-subprocess": "^0.1.6-alpha.1",
40
- "@deepseek-ai/dsh-timeout": "^0.1.6-alpha.1",
41
- "@deepseek-ai/dsh-tools": "^0.1.6-alpha.1"
36
+ "@deepseek-ai/dsh-credentials": "workspace:^"
42
37
  },
43
- "publishConfig": {
44
- "access": "public"
45
- },
46
- "repository": {
47
- "directory": "packages/plus/mcp-credentials",
48
- "type": "git",
49
- "url": "git+https://github.com/deepseek-ai/deepseek-harness.git"
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.12.0",
40
+ "@deepseek-ai/schemastery": "workspace:^",
41
+ "zod": "^4.4.3"
50
42
  },
51
- "type": "module",
52
- "types": "lib/types/index.d.ts",
53
- "version": "0.2.1-rc.2.1"
43
+ "devDependencies": {
44
+ "@deepseek-ai/dsh-attachment": "workspace:^",
45
+ "@deepseek-ai/dsh-llm": "workspace:^",
46
+ "@deepseek-ai/dsh-scope": "workspace:^",
47
+ "@deepseek-ai/dsh-subprocess": "workspace:^",
48
+ "@deepseek-ai/dsh-timeout": "workspace:^",
49
+ "@deepseek-ai/dsh-tools": "workspace:^",
50
+ "@deepseek-ai/dsh-util-values": "workspace:^",
51
+ "@deepseek-ai/cordis": "workspace:^",
52
+ "@deepseek-ai/dsh-credentials": "workspace:^"
53
+ }
54
54
  }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 DeepSeek
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.