Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions scripts/publish-mcp-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(" ")}`,
);
}
}

Expand All @@ -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 });
}
Expand Down
24 changes: 22 additions & 2 deletions src/core/mcp-registry-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
24 changes: 24 additions & 0 deletions src/test/mcp-registry-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading