From cfeae0ac448f49f4dbff3fb4db6507dfe175b133 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:07:22 +0100 Subject: [PATCH 1/2] feat(release): retry connection-level and expired-JWT MCP publish failures isRetryablePublishFailure only recognised npm-propagation-lag and 5xx failures, so a raw connection timeout (no HTTP status at all) or a 401 caused by the login JWT expiring mid-retry-window failed the release job outright instead of retrying, leaving a git tag with no matching npm package or GitHub release. Add isConnectionError to match Go net-error text (dial tcp, i/o timeout, connection refused, connection reset, no such host) from mcp-publisher's own HTTP client. Add isExpiredJwt to match a 401 whose body specifically says the token is expired, deliberately excluding a 401 from a genuine authorisation failure, which retrying can never fix regardless of how fresh the token is. --- src/core/mcp-registry-retry.ts | 24 ++++++++++++++++++++++-- src/test/mcp-registry-retry.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/core/mcp-registry-retry.ts b/src/core/mcp-registry-retry.ts index ab5f8810..b2c442a9 100644 --- a/src/core/mcp-registry-retry.ts +++ b/src/core/mcp-registry-retry.ts @@ -35,7 +35,27 @@ function isRetryableServerError(output: string): boolean { return SERVER_ERROR_STATUS_PATTERN.test(output); } -/** Recognises a publish failure worth retrying (npm propagation lag, or a transient 5xx from the registry's own infrastructure) as opposed to any other failure -- a genuinely invalid publish request, for instance, which retrying can never fix. */ +// Confirmed as a third retryable failure mode (ExaDev/agent-comms, 2026-09-17, issue #180, run 35249771867, v3.9.0): the registry request never got as far as an HTTP response at all, a raw connection-level failure from mcp-publisher's own Go HTTP client (a DNS/dial/timeout/reset at the TCP layer), which matches neither the propagation-lag text nor the 5xx status pattern above since there is no status line to match against. This is transient infrastructure the same way a 5xx is: the request itself may well have been valid, retrying it can plausibly succeed. +const CONNECTION_ERROR_PATTERN = + /\b(?:dial tcp|i\/o timeout|connection refused|connection reset|no such host)\b/; + +function isConnectionError(output: string): boolean { + return CONNECTION_ERROR_PATTERN.test(output); +} + +// Confirmed as a fourth retryable failure mode (ExaDev/agent-comms, 2026-09-17, issue #180, run 35239013253, v3.9.0): mcp-publisher logs in with `login github-oidc` once before the retry loop starts, and a retry attempt late enough in the MAX_TOTAL_RETRY_MS budget can present a JWT that expired in the meantime, producing a 401 that no amount of retrying the same token will ever fix. It's retryable only because the caller re-runs the login step before every retry attempt (see scripts/publish-mcp-registry.ts), which mints a fresh token, so this classifier deliberately matches only the specific "expired" 401 body, not a 401 in general, since a genuine authorisation failure (wrong scope, revoked credential) would 401 again regardless of how fresh the token is. +const EXPIRED_JWT_STATUS_PATTERN = /server returned status 401\b/; + +function isExpiredJwt(output: string): boolean { + return EXPIRED_JWT_STATUS_PATTERN.test(output) && output.includes("expired"); +} + +/** Recognises a publish failure worth retrying (npm propagation lag, a transient 5xx from the registry's own infrastructure, a connection-level failure that never reached the registry, or a login JWT that expired mid-retry-window) as opposed to any other failure -- a genuinely invalid publish request or authorisation failure, for instance, which retrying can never fix. */ export function isRetryablePublishFailure(output: string): boolean { - return isNpmPropagationLag(output) || isRetryableServerError(output); + return ( + isNpmPropagationLag(output) || + isRetryableServerError(output) || + isConnectionError(output) || + isExpiredJwt(output) + ); } diff --git a/src/test/mcp-registry-retry.test.ts b/src/test/mcp-registry-retry.test.ts index 73424e4a..e118bfb9 100644 --- a/src/test/mcp-registry-retry.test.ts +++ b/src/test/mcp-registry-retry.test.ts @@ -68,4 +68,28 @@ describe("isRetryablePublishFailure", () => { it("does not misclassify an unrelated failure as retryable", () => { expect(isRetryablePublishFailure("permission denied")).toBe(false); }); + + it("recognises a raw connection-level failure with no HTTP status, confirmed in production (ExaDev/agent-comms, run 35249771867, v3.9.0): a dial-tcp i/o timeout reaching the registry, which matches neither the propagation-lag text nor the 5xx status pattern above since no HTTP response was ever received", () => { + const output = + 'Error: publish failed: error sending request: Post "https://registry.modelcontextprotocol.io/v0/publish": dial tcp 34.61.200.254:443: i/o timeout'; + expect(isRetryablePublishFailure(output)).toBe(true); + }); + + it("recognises a connection-refused failure as the same class of connection-level error", () => { + const output = + 'Error: publish failed: error sending request: Post "https://registry.modelcontextprotocol.io/v0/publish": dial tcp 34.61.200.254:443: connect: connection refused'; + expect(isRetryablePublishFailure(output)).toBe(true); + }); + + it("recognises a 401 caused by the login JWT expiring mid-retry-window, confirmed in production (ExaDev/agent-comms, run 35239013253, v3.9.0): mcp-publisher logs in once before the retry loop starts, and a retry attempt late in the 15-minute budget can present a token that has since expired", () => { + const output = + 'Error: publish failed: server returned status 401: {"title":"Unauthorized","status":401,"detail":"Invalid or expired Registry JWT token","instance":"/v0/publish"}'; + expect(isRetryablePublishFailure(output)).toBe(true); + }); + + it("does not retry a 401 that is a genuine authorisation failure rather than an expired token, since retrying an unrecognised or wrongly-scoped credential can never fix it", () => { + const output = + 'Error: publish failed: server returned status 401: {"title":"Unauthorized","status":401,"detail":"Registry JWT token does not grant publish access to this namespace","instance":"/v0/publish"}'; + expect(isRetryablePublishFailure(output)).toBe(false); + }); }); From 46ebbb33050b67ab7f944f00c46203657417c9e9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 18:11:34 +0100 Subject: [PATCH 2/2] fix(release): re-login before each MCP registry publish retry mcp-publisher only logged in once, before the retry loop started, so a retry attempt late enough in the 15-minute budget presented a JWT that had since expired. Retrying with the same expired token just 401s again, so the classifier alone can't fix this: run the login step again before every retry attempt, mint a fresh token, and let the next publish attempt use it. --- scripts/publish-mcp-registry.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/publish-mcp-registry.ts b/scripts/publish-mcp-registry.ts index 552db7f6..e1d5ba3e 100644 --- a/scripts/publish-mcp-registry.ts +++ b/scripts/publish-mcp-registry.ts @@ -22,11 +22,16 @@ function sleepSync(ms: number): void { spawnSync("sleep", [String(ms / MS_PER_SECOND)]); } -function runPublishWithRetry(command: string, args: readonly string[]): void { +// Re-runs the login step before every retry, not just the failure classes that need it, because it's the only way to make a retry of an expired-JWT 401 (see isExpiredJwt in src/core/mcp-registry-retry.ts) actually stand a chance: retrying with the same token that just 401'd would just 401 again. A fresh login before a propagation-lag/5xx/connection-error retry is harmless, so there's no need to special-case which failure triggered the retry. +function runPublishWithRetry( + publisherPath: string, + loginArgs: readonly string[], + publishArgs: readonly string[], +): void { const deadline = Date.now() + MAX_TOTAL_RETRY_MS; let delayMs = INITIAL_RETRY_DELAY_MS; for (let attempt = 1; ; attempt++) { - const result = spawnSync(command, args, { encoding: "utf8" }); + const result = spawnSync(publisherPath, publishArgs, { encoding: "utf8" }); if (result.status === 0) { process.stdout.write(result.stdout); return; @@ -39,11 +44,14 @@ function runPublishWithRetry(command: string, args: readonly string[]): void { `mcp-publisher publish hit a retryable failure (attempt ${String(attempt)}), retrying in ${String(delayMs / MS_PER_SECOND)}s`, ); sleepSync(delayMs); + run(publisherPath, loginArgs); delayMs = nextRetryDelayMs(delayMs); continue; } process.stdout.write(output); - throw new Error(`Command failed: ${command} ${args.join(" ")}`); + throw new Error( + `Command failed: ${publisherPath} ${publishArgs.join(" ")}`, + ); } } @@ -62,8 +70,10 @@ function main(): void { "-lc", `curl -fsSL "${archiveUrl}" | tar -xzf - -C "${tempDir}" mcp-publisher`, ]); - run(path.join(tempDir, "mcp-publisher"), ["login", "github-oidc"]); - runPublishWithRetry(path.join(tempDir, "mcp-publisher"), ["publish"]); + const publisherPath = path.join(tempDir, "mcp-publisher"); + const loginArgs = ["login", "github-oidc"]; + run(publisherPath, loginArgs); + runPublishWithRetry(publisherPath, loginArgs, ["publish"]); } finally { rmSync(tempDir, { recursive: true, force: true }); }