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) {