From 09f9d87ad7776ac9cadd29743700617d7e633534 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 20:24:35 +0000 Subject: [PATCH] fix(targets): Wait for npm before publishing to the MCP Registry The mcp-registry target runs right after the npm target, and the registry rejects a publish when npm does not serve the new package version yet. getsentry/toolkit 0.48.0 failed this way one second after npm accepted @sentry/mcp-server@0.48.0. Before authenticating, poll npm for each npm package version at the same URL the registry validates, for up to about five minutes. Retry 429 responses as well. The registry still does the mcpName check. Refs getsentry/publish#10254 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- docs/src/content/docs/targets/mcp-registry.md | 9 ++- src/targets/__tests__/mcpRegistry.test.ts | 81 ++++++++++++++++++- src/targets/mcpRegistry.ts | 65 ++++++++++++++- 3 files changed, 146 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/targets/mcp-registry.md b/docs/src/content/docs/targets/mcp-registry.md index a8013fe1..ec969b2b 100644 --- a/docs/src/content/docs/targets/mcp-registry.md +++ b/docs/src/content/docs/targets/mcp-registry.md @@ -34,12 +34,13 @@ For the release version, the target: 2. Looks up that version in the registry. Registry versions are immutable: - If it is already listed and matches the manifest, the target does nothing. Re-running a release is safe. - If it is listed but differs from the manifest, the target fails. Release a new version instead. -3. Authenticates and publishes the manifest. -4. Checks that the new listing matches the manifest (ignoring `$schema`) and is `active`. +3. For each `npm` package in the manifest, waits up to about five minutes for npm to serve that exact version. npm can lag behind a fresh publish, and the registry rejects a package version it cannot fetch yet. +4. Authenticates and publishes the manifest. +5. Checks that the new listing matches the manifest (ignoring `$schema`) and is `active`. -The registry validates each package when you publish. For npm packages, it checks that the version exists on npm and that its `mcpName` equals the manifest `name`. If npm doesn't serve the new version yet, the registry rejects the publish and says so. Run the publish again once the version appears on npm; targets that already finished are skipped. +The registry validates each package when you publish. For npm packages, it checks that the version exists on npm and that its `mcpName` equals the manifest `name`. If npm still doesn't serve the new version after the wait, the target fails. Run the publish again once the version appears on npm; targets that already finished are skipped. -In dry-run mode, the target still looks up the listing, but does not authenticate or publish. +In dry-run mode, the target still looks up the listing, but does not wait for npm, authenticate, or publish. ## Example diff --git a/src/targets/__tests__/mcpRegistry.test.ts b/src/targets/__tests__/mcpRegistry.test.ts index 9020f350..133c02fa 100644 --- a/src/targets/__tests__/mcpRegistry.test.ts +++ b/src/targets/__tests__/mcpRegistry.test.ts @@ -21,6 +21,7 @@ vi.mock('../../utils/async', () => ({ sleep: vi.fn(async () => undefined) })); const VERSION = '1.2.3'; const SERVER_NAME = 'io.github.getsentry/sentry-mcp'; const NPM_PACKAGE = '@sentry/mcp-server'; +const NPM_URL = `https://registry.npmjs.org/@sentry%2Fmcp-server/${VERSION}`; const LISTING_URL = `${DEFAULT_REGISTRY_URL}/v0.1/servers/io.github.getsentry%2Fsentry-mcp/versions/${VERSION}`; const OIDC_ORIGIN = 'https://token.actions.example'; const OIDC_REQUEST_URL = `${OIDC_ORIGIN}/token?api-version=2.0`; @@ -48,6 +49,8 @@ const EXPECTED_SERVER = { packages: [{ ...MANIFEST.packages[0], version: VERSION }], }; +const NPM_INFO = { name: NPM_PACKAGE, version: VERSION, mcpName: SERVER_NAME }; + function listing(server: Record = EXPECTED_SERVER) { return { // The registry may omit $schema; it is not compared. @@ -119,6 +122,7 @@ function createTarget(config: Record = {}) { /** Routes for a version that is not listed yet and publishes successfully */ function publishRoutes(): Record { return { + [`GET ${NPM_URL}`]: json(NPM_INFO), [`GET ${LISTING_URL}`]: [json({}, 404), json(listing())], [`POST ${DEFAULT_REGISTRY_URL}/v0/publish`]: json( { server: EXPECTED_SERVER }, @@ -223,9 +227,10 @@ describe('publish', () => { }); expect(JSON.parse(String(publish?.init?.body))).toEqual(EXPECTED_SERVER); - // Order: existing listing, auth, publish, verification. + // Order: existing listing, npm wait, auth, publish, verification. expect(requests(fetchMock).map(request => request.key)).toEqual([ `GET ${LISTING_URL}`, + `GET ${NPM_URL}`, expect.stringMatching(new RegExp(`^GET ${OIDC_ORIGIN}/`)), `POST ${DEFAULT_REGISTRY_URL}/v0/auth/github-oidc`, `POST ${DEFAULT_REGISTRY_URL}/v0/publish`, @@ -237,6 +242,7 @@ describe('publish', () => { const registryUrl = 'https://registry.example.com'; const listingUrl = `${registryUrl}/v0.1/servers/io.github.getsentry%2Fsentry-mcp/versions/${VERSION}`; const fetchMock = mockFetch({ + [`GET ${NPM_URL}`]: json(NPM_INFO), [`GET ${listingUrl}`]: [json({}, 404), json(listing())], 'GET oidc': json({ value: 'github-oidc-token' }), [`POST ${registryUrl}/v0/auth/github-oidc`]: json({ @@ -286,6 +292,7 @@ describe('publish', () => { delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; const fetchMock = mockFetch({ + [`GET ${NPM_URL}`]: json(NPM_INFO), [`GET ${LISTING_URL}`]: json({}, 404), }); @@ -299,6 +306,7 @@ describe('publish', () => { test('reports a failed token exchange', async () => { mockFetch({ + [`GET ${NPM_URL}`]: json(NPM_INFO), [`GET ${LISTING_URL}`]: json({}, 404), 'GET oidc': json({ value: 'github-oidc-token' }), [`POST ${DEFAULT_REGISTRY_URL}/v0/auth/github-oidc`]: new Response( @@ -329,6 +337,7 @@ describe('publish', () => { test('reports a rejected publish', async () => { mockFetch({ + [`GET ${NPM_URL}`]: json(NPM_INFO), [`GET ${LISTING_URL}`]: json({}, 404), 'GET oidc': json({ value: 'github-oidc-token' }), [`POST ${DEFAULT_REGISTRY_URL}/v0/auth/github-oidc`]: json({ @@ -365,6 +374,76 @@ describe('publish', () => { }); }); +describe('npm wait', () => { + test('waits for npm to serve the version before publishing', async () => { + const fetchMock = mockFetch({ + ...publishRoutes(), + [`GET ${NPM_URL}`]: [ + json({}, 404), + json({}, 429), + json({}, 503), + json(NPM_INFO), + ], + 'GET oidc': json({ value: 'github-oidc-token' }), + [`POST ${DEFAULT_REGISTRY_URL}/v0/auth/github-oidc`]: json({ + registry_token: 'registry-token', + }), + }); + + await createTarget().publish(VERSION, 'sha'); + + expect(sleep).toHaveBeenCalledTimes(3); + const keys = requests(fetchMock).map(request => request.key); + expect(keys.lastIndexOf(`GET ${NPM_URL}`)).toBeLessThan( + keys.indexOf(`POST ${DEFAULT_REGISTRY_URL}/v0/publish`), + ); + }); + + test('gives up after a bounded number of attempts', async () => { + const fetchMock = mockFetch({ + [`GET ${LISTING_URL}`]: json({}, 404), + [`GET ${NPM_URL}`]: () => json({}, 404), + }); + + await expect(createTarget().publish(VERSION, 'sha')).rejects.toThrow( + /@sentry\/mcp-server@1\.2\.3 is still not available on npm/, + ); + expect( + requests(fetchMock).filter(request => request.key === `GET ${NPM_URL}`), + ).toHaveLength(30); + expect(sleep).toHaveBeenCalledTimes(29); + expect( + findRequest(fetchMock, `POST ${DEFAULT_REGISTRY_URL}/v0/publish`), + ).toBeUndefined(); + }); + + test('skips packages from other registries', async () => { + const ociPackage = { + registryType: 'oci', + identifier: 'ghcr.io/example/mcp', + version: '0.0.0', + }; + writeManifest({ ...MANIFEST, packages: [ociPackage] }); + const server = { + ...EXPECTED_SERVER, + packages: [{ ...ociPackage, version: VERSION }], + }; + const fetchMock = mockFetch({ + ...publishRoutes(), + [`GET ${NPM_URL}`]: [], + [`GET ${LISTING_URL}`]: [json({}, 404), json(listing(server))], + 'GET oidc': json({ value: 'github-oidc-token' }), + [`POST ${DEFAULT_REGISTRY_URL}/v0/auth/github-oidc`]: json({ + registry_token: 'registry-token', + }), + }); + + await createTarget().publish(VERSION, 'sha'); + + expect(findRequest(fetchMock, `GET ${NPM_URL}`)).toBeUndefined(); + }); +}); + describe('existing listing', () => { test('skips publishing when the listed version matches', async () => { const fetchMock = mockFetch({ diff --git a/src/targets/mcpRegistry.ts b/src/targets/mcpRegistry.ts index c4168566..43a1f7f5 100644 --- a/src/targets/mcpRegistry.ts +++ b/src/targets/mcpRegistry.ts @@ -19,6 +19,7 @@ import { BaseArtifactProvider } from '../artifact_providers/base'; export const DEFAULT_REGISTRY_URL = 'https://registry.modelcontextprotocol.io'; const DEFAULT_MANIFEST = 'server.json'; +const DEFAULT_NPM_REGISTRY_URL = 'https://registry.npmjs.org'; /** * Optional GitHub token, used when GitHub Actions OIDC is not available. Same @@ -31,6 +32,14 @@ const OFFICIAL_META_KEY = 'io.modelcontextprotocol.registry/official'; const REQUEST_TIMEOUT_MS = 30_000; +/** + * npm can take a while to serve a version it just accepted, and the registry + * rejects a publish whose npm package version it cannot fetch yet. Wait up to + * about five minutes for each npm package before publishing. + */ +const NPM_ATTEMPTS = 30; +const NPM_RETRY_DELAY_MS = 10_000; + /** Bounded wait for a fresh listing to become readable after publishing */ const LISTING_ATTEMPTS = 6; const LISTING_RETRY_DELAY_MS = 5_000; @@ -43,6 +52,9 @@ interface ServerManifest extends Record { } interface ServerPackage extends Record { + registryType?: string; + registryBaseUrl?: string; + identifier?: string; version?: string; } @@ -74,8 +86,9 @@ export interface McpRegistryTargetConfig { * skipped, and one that differs is an error. * * The registry checks each package itself when publishing (for npm: that the - * version exists and its `mcpName` matches the server name) and returns a - * descriptive error, so this target does not duplicate those checks. + * version exists and its `mcpName` matches the server name). Because npm can + * lag behind a fresh publish, the target first waits until npm serves each + * npm package version, and leaves the remaining checks to the registry. */ export class McpRegistryTarget extends BaseTarget { /** Target name */ @@ -150,6 +163,8 @@ export class McpRegistryTarget extends BaseTarget { return; } + await this.waitForNpmPackages(manifest, version); + const token = await this.getRegistryToken(); this.logger.info(`Publishing ${label} to the MCP Registry...`); await this.publishManifest(manifest, token); @@ -189,6 +204,43 @@ export class McpRegistryTarget extends BaseTarget { return manifest; } + /** + * Waits until npm serves `version` of each npm package in the manifest, at + * the same URL the registry fetches when it validates the package. + */ + private async waitForNpmPackages( + manifest: ServerManifest, + version: string, + ): Promise { + for (const pkg of manifest.packages ?? []) { + if (pkg.registryType !== 'npm' || !pkg.identifier) { + continue; + } + const baseUrl = (pkg.registryBaseUrl || DEFAULT_NPM_REGISTRY_URL).replace( + /\/+$/, + '', + ); + // Same escaping as npm and the registry: `@scope%2Fname`. + const escapedName = encodeURIComponent(pkg.identifier).replace( + /^%40/, + '@', + ); + const label = `npm package ${pkg.identifier}@${version}`; + const info = await this.pollJson( + `${baseUrl}/${escapedName}/${encodeURIComponent(version)}`, + label, + NPM_ATTEMPTS, + NPM_RETRY_DELAY_MS, + ); + if (!info) { + throw new Error( + `${label} is still not available on npm. List the npm target ` + + 'before mcp-registry, and re-run the publish once npm serves it.', + ); + } + } + } + private listingUrl(name: string, version: string): string { return ( `${this.mcpRegistryConfig.registryUrl}/v0.1/servers/` + @@ -264,7 +316,7 @@ export class McpRegistryTarget extends BaseTarget { } /** - * GETs `url` until it returns JSON. Retries 404, 5xx and network errors up + * GETs `url` until it returns JSON. Retries 404, 429, 5xx and network errors up * to `attempts` times and returns undefined if they never resolve. Other * HTTP errors throw immediately. */ @@ -289,7 +341,12 @@ export class McpRegistryTarget extends BaseTarget { if (response?.ok) { return response.json(); } - if (response && response.status !== 404 && response.status < 500) { + if ( + response && + response.status !== 404 && + response.status !== 429 && + response.status < 500 + ) { throw new Error(`Fetching ${label} failed: ${reason}`); } if (attempt >= attempts) {