@sparkelf/dsh-plugin-mcp-credentials 0.2.1-rc.2.2 → 0.2.1-rc.2.4
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 +90 -15
- package/lib/types/connection.d.ts +30 -0
- package/package.json +1 -1
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
|
|
600
|
-
|
|
601
|
-
|
|
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 (
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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