diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index 63a0706..47e0ad5 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -1,20 +1,51 @@ name: Publish MCP registry metadata +# Publishes server.json to the official MCP Registry only when it holds a new listing version that +# mcp.dealmachine.com already serves. A master release that changes the listing checks at once; the schedule +# catches a listing whose MCP server release lands later. Every other run publishes nothing. +# See docs/releases.md#mcp-registry-listing. on: + push: + branches: [master] + paths: + - server.json + - scripts/mcp-registry-sync.mjs + - .github/workflows/publish-mcp-registry.yml + schedule: + - cron: '41 */3 * * *' workflow_dispatch: permissions: contents: read - id-token: write + +concurrency: + group: mcp-registry-publish + cancel-in-progress: false jobs: publish: + if: github.ref == 'refs/heads/master' && vars.PRODUCTION_DEPLOY_ENABLED == 'true' runs-on: dm-gcp-public-x64 + timeout-minutes: 15 + permissions: + contents: read + id-token: write steps: - name: Check out the public metadata uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Compare the listing with the live server and the registry + id: plan + run: node scripts/mcp-registry-sync.mjs plan - name: Install the official MCP publisher + if: steps.plan.outputs.action == 'publish' shell: bash run: | curl -fsSLO https://github.com/modelcontextprotocol/registry/releases/download/v1.8.0/mcp-publisher_linux_amd64.tar.gz @@ -22,7 +53,13 @@ jobs: tar -xzf mcp-publisher_linux_amd64.tar.gz mcp-publisher - name: Authenticate with GitHub OIDC + if: steps.plan.outputs.action == 'publish' run: ./mcp-publisher login github-oidc - - name: Publish version 0.3.1 + - name: Publish listing version ${{ steps.plan.outputs.version }} + if: steps.plan.outputs.action == 'publish' run: ./mcp-publisher publish server.json + + - name: Confirm the registry lists it as the latest + if: steps.plan.outputs.action == 'publish' + run: node scripts/mcp-registry-sync.mjs verify diff --git a/README.md b/README.md index 54da2ad..1e01862 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The plugin package includes: - A credit-aware skill that discovers filters and fields, counts first, and confirms large paid operations - A portable [Agent Plugins](https://agent-plugins.org/) package for compatible clients - Manifests for OpenAI, Claude, Cursor, GitHub Copilot, and Gemini -- Official MCP Registry metadata in `server.json` +- Official MCP Registry metadata in `server.json`, published on its own when its version changes ([MCP registry listing](docs/releases.md#mcp-registry-listing)) The portable package follows Agent Plugins 1.0.0: diff --git a/docs/releases.md b/docs/releases.md index 56e519e..1d87fc3 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -76,3 +76,16 @@ Replace `0.4.0` with the exact version from the release receipt. `eval:cold-star The two npm uploads are separate operations. If the alias upload fails, the implementation may already be available; keep the workflow failed until both are verified. Retry the current master workflow to reuse the same source version and verify its bytes. If the source has moved, its next release can publish a new pair, while the partial version remains available for diagnosis. If bytes differ on a retry, retain the original archive and receipt for an owner; do not bypass the integrity check. An authorized rollback moves both distribution tags to the previous verified version, then verifies fresh installs. Existing installed versions do not change. The automated publisher never performs that rollback. After a rollback, a new reviewed master commit receives a higher version, even though `latest` points backward. Moving a tag does not rename an immutable package version. + +## MCP registry listing + +`server.json` is the official MCP Registry listing: the server's name, title, description, version and address. The registry holds nothing else, so tool descriptions, bug fixes and other MCP server changes never need a registry release. The MCP server serves its own copy of the same listing at `https://mcp.dealmachine.com/.well-known/mcp.json`, and the two must agree. + +**Publish MCP registry metadata** (`publish-mcp-registry.yml`) runs on its own after a `master` push that changes `server.json`, and every three hours as a catch-up. Behind the same `PRODUCTION_DEPLOY_ENABLED` gate as npm, it compares `server.json` with the live server's copy and with the versions the registry already has, then: + +- publishes, and confirms the registry lists the version as latest, when `server.json` has a new version the live server already serves; +- passes without publishing when the registry already has that exact version; +- passes without publishing when `server.json` is ahead of the live server, and publishes on a later scheduled run once the MCP server release lands; +- fails without publishing, with a message naming the copy to update, when either copy changed its wording but kept a published version number, when the live server serves a newer version, when both unpublished copies share a version but not their wording, or when the registry already has a higher version. Published registry versions cannot change, so a new wording always needs a new version in both copies. + +To change the listing, raise the version and edit the wording in the MCP server's listing and in `server.json` together, and release both. The manual **Run workflow** button repeats the same check, which is safe to rerun; it never republishes an existing version. Before November 4, 2026, nobody had run the manual publish since July, so the registry showed an old description for months (WORK-14167). diff --git a/scripts/mcp-registry-sync.mjs b/scripts/mcp-registry-sync.mjs new file mode 100644 index 0000000..8abd7b7 --- /dev/null +++ b/scripts/mcp-registry-sync.mjs @@ -0,0 +1,187 @@ +// Decides whether the official MCP Registry needs this repository's server.json, and confirms a publish landed. +// The registry holds only the listing (name, title, description, version, repository, address), never tool +// descriptions, so only a new listing version is ever published. See docs/releases.md#mcp-registry-listing. +import assert from 'node:assert/strict'; +import { realpathSync } from 'node:fs'; +import { appendFile, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; + +const REGISTRY = 'https://registry.modelcontextprotocol.io'; +const LISTING_FIELDS = ['name', 'title', 'description', 'version', 'repository', 'remotes']; +const STABLE_VERSION = /^(\d+)\.(\d+)\.(\d+)$/; +const pause = milliseconds => new Promise(done => setTimeout(done, milliseconds)); + +function parseVersion(version, where) { + const match = STABLE_VERSION.exec(version ?? ''); + assert(match, `${where} has version "${version}", which is not a stable x.y.z version.`); + return match.slice(1).map(Number); +} + +export function compareVersions(left, right) { + const a = parseVersion(left, 'A listing'); + const b = parseVersion(right, 'A listing'); + for (let index = 0; index < 3; index++) if (a[index] !== b[index]) return a[index] - b[index]; + return 0; +} + +// Fields that differ between two listings, ignoring the schema address, registry bookkeeping and key order. +export function listingDifferences(left, right) { + return LISTING_FIELDS.filter(field => !isDeepStrictEqual(left?.[field] ?? null, right?.[field] ?? null)); +} + +/** + * listing: this repository's server.json. live: what mcp.dealmachine.com serves at /.well-known/mcp.json. + * published: every version the registry already has, as server objects. + * Returns { action: 'publish' | 'skip' | 'wait', message }, or throws when the copies disagree in a way only a + * person can settle. Registry versions cannot be changed once published, so nothing is published on a doubt. + */ +export function planRegistryPublish({ listing, live, published }) { + parseVersion(listing?.version, 'server.json'); + parseVersion(live?.version, 'The listing mcp.dealmachine.com serves'); + assert.equal(live.name, listing.name, `mcp.dealmachine.com serves the listing for ${live.name}, but server.json is ${listing.name}.`); + const nothing = 'Nothing was published.'; + + // The registry copy of a version is fixed, so it settles which copy changed when the two disagree. + const existing = published.find(server => server.version === listing.version); + if (existing) { + const differences = listingDifferences(listing, existing); + if (differences.length) { + throw new Error( + `server.json changed its ${differences.join(', ')} but kept version ${listing.version}, which the registry already has. ` + + `Published versions cannot change, so give the new wording a new version in server.json and in the MCP server's listing. ${nothing}` + ); + } + } + + const order = compareVersions(live.version, listing.version); + if (order > 0) { + throw new Error( + `mcp.dealmachine.com serves listing version ${live.version}, newer than ${listing.version} in server.json. ` + + `Copy the MCP server's listing into server.json with that version, then release this repository. ${nothing}` + ); + } + if (order === 0) { + const differences = listingDifferences(listing, live); + if (differences.length && existing) { + throw new Error( + `mcp.dealmachine.com changed its ${differences.join(', ')} but kept version ${listing.version}, which the registry already has. ` + + `Give the MCP server's listing a new version and copy it into server.json. ${nothing}` + ); + } + if (differences.length) { + throw new Error( + `server.json and mcp.dealmachine.com both say version ${listing.version} but differ in ${differences.join(', ')}. ` + + `Make both copies match before it is published. ${nothing}` + ); + } + } + + if (existing) return { action: 'skip', message: `The registry already lists version ${listing.version}. Nothing to publish.` }; + + const newest = published.reduce((top, server) => + STABLE_VERSION.test(server.version ?? '') && (!top || compareVersions(server.version, top) > 0) ? server.version : top, null); + if (newest && compareVersions(newest, listing.version) > 0) { + throw new Error( + `The registry already has version ${newest}, newer than ${listing.version} in server.json, so this version would never show as the latest. ` + + `Raise the version above ${newest} in server.json and in the MCP server's listing. ${nothing}` + ); + } + + if (order < 0) { + return { + action: 'wait', + message: `server.json has version ${listing.version}, but mcp.dealmachine.com still serves ${live.version}. ` + + 'Waiting for the MCP server release; a later scheduled check publishes once the server serves it.', + }; + } + return { action: 'publish', message: `Publishing listing version ${listing.version}, which mcp.dealmachine.com already serves.` }; +} + +async function readJson(url, { allowMissing = false } = {}) { + let failure; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(url, { + headers: { accept: 'application/json', 'cache-control': 'no-cache' }, + redirect: 'error', + signal: AbortSignal.timeout(20_000), + }); + if (allowMissing && response.status === 404) return null; + assert(response.ok, `Cannot read ${url} (${response.status}). Nothing was published.`); + return await response.json(); + } catch (error) { + if (!(error instanceof TypeError || error?.name === 'TimeoutError') || attempt === 2) throw error; + failure = error; + await pause(1_000 * (attempt + 1)); + } + } + throw failure; +} + +export async function readPublished(name) { + const body = await readJson(`${REGISTRY}/v0/servers/${encodeURIComponent(name)}/versions`, { allowMissing: true }); + return (body?.servers ?? []).map(entry => ({ + ...entry.server, + isLatest: entry._meta?.['io.modelcontextprotocol.registry/official']?.isLatest === true, + })); +} + +async function readListing() { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + return JSON.parse(await readFile(resolve(root, 'server.json'), 'utf8')); +} + +async function liveListing(listing) { + const remote = listing.remotes?.[0]?.url; + assert(remote, 'server.json has no remote address to compare with.'); + return readJson(new URL('/.well-known/mcp.json', remote).href); +} + +async function plan() { + const listing = await readListing(); + const decision = planRegistryPublish({ + listing, + live: await liveListing(listing), + published: await readPublished(listing.name), + }); + console.log(decision.message); + if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `action=${decision.action}\nversion=${listing.version}\n`); + if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${decision.message}\n`); +} + +async function verify() { + const listing = await readListing(); + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + const entry = (await readPublished(listing.name)).find(server => server.version === listing.version); + if (entry) { + const differences = listingDifferences(listing, entry); + assert.equal(differences.length, 0, `The registry shows version ${listing.version} with a different ${differences.join(', ')}.`); + if (entry.isLatest) { + console.log(`The registry lists version ${listing.version} as the latest.`); + return; + } + } + await pause(10_000); + } + throw new Error(`The registry did not list version ${listing.version} as the latest within three minutes.`); +} + +// Node gives import.meta.url the real path but keeps process.argv[1] as typed, so compare real paths. Otherwise a +// workspace reached through a linked folder would skip every command and still exit 0. +const startedDirectly = process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + +if (startedDirectly) { + const command = process.argv[2]; + const run = { plan, verify }[command]; + if (!run) { + console.error('Usage: node scripts/mcp-registry-sync.mjs '); + process.exit(2); + } + run().catch(error => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/tests/mcpRegistrySync.test.ts b/tests/mcpRegistrySync.test.ts new file mode 100644 index 0000000..35c1fd6 --- /dev/null +++ b/tests/mcpRegistrySync.test.ts @@ -0,0 +1,75 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { planRegistryPublish } from '../scripts/mcp-registry-sync.mjs'; + +const serverJson = JSON.parse(readFileSync(resolve(__dirname, '../server.json'), 'utf8')); +const listing = (changes: Record = {}) => ({ ...serverJson, version: '0.4.0', description: 'New wording.', ...changes }); +const older = listing({ version: '0.3.9', description: 'Old wording.' }); + +describe('MCP registry publish decision', () => { + it('publishes a new version once mcp.dealmachine.com serves the same listing', () => { + expect(planRegistryPublish({ listing: listing(), live: listing(), published: [older] }).action).toBe('publish'); + }); + + it('publishes nothing when the registry already has this exact version', () => { + expect(planRegistryPublish({ listing: listing(), live: listing(), published: [older, listing()] }).action).toBe('skip'); + }); + + it('waits for the MCP server release when server.json is ahead of the live listing', () => { + const decision = planRegistryPublish({ listing: listing(), live: older, published: [older] }); + expect(decision.action).toBe('wait'); + expect(decision.message).toMatch(/still serves 0\.3\.9/); + }); + + it('names server.json when its wording changed under a version the registry already has', () => { + const published = listing({ description: 'Wording already in the registry.' }); + expect(() => planRegistryPublish({ listing: listing(), live: published, published: [published] })) + .toThrow(/server\.json changed its description but kept version 0\.4\.0/); + }); + + it('names the MCP server when its wording changed under a version the registry already has', () => { + expect(() => planRegistryPublish({ listing: listing(), live: listing({ description: 'Reworded on the server.' }), published: [listing()] })) + .toThrow(/mcp\.dealmachine\.com changed its description but kept version 0\.4\.0/); + }); + + it('refuses when the live server serves a newer listing than server.json', () => { + expect(() => planRegistryPublish({ listing: older, live: listing(), published: [older] })) + .toThrow(/newer than 0\.3\.9 in server\.json/); + }); + + it('refuses when both copies share a version but not their wording', () => { + expect(() => planRegistryPublish({ listing: listing(), live: listing({ description: 'Other wording.' }), published: [older] })) + .toThrow(/differ in description/); + }); + + it('refuses a version below one the registry already has', () => { + expect(() => planRegistryPublish({ listing: listing(), live: listing(), published: [listing({ version: '0.5.0' })] })) + .toThrow(/already has version 0\.5\.0, newer than 0\.4\.0/); + }); + + it('treats the same listing written in another key order as unchanged', () => { + const reordered = { ...listing(), repository: { source: 'github', url: serverJson.repository.url } }; + expect(planRegistryPublish({ listing: listing(), live: reordered, published: [reordered] }).action).toBe('skip'); + }); + + it('matches the copy this repository ships today', () => { + expect(planRegistryPublish({ listing: serverJson, live: serverJson, published: [serverJson] }).action).toBe('skip'); + }); +}); + +describe('MCP registry script entry', () => { + it('still runs when started through a linked folder, so a check can never pass without doing anything', () => { + const linked = mkdtempSync(join(tmpdir(), 'mcp-registry-')); + try { + symlinkSync(resolve(__dirname, '../scripts'), join(linked, 'scripts'), 'dir'); + const result = spawnSync(process.execPath, [join(linked, 'scripts/mcp-registry-sync.mjs'), 'unknown'], { encoding: 'utf8' }); + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/Usage: node scripts\/mcp-registry-sync\.mjs /); + } finally { + rmSync(linked, { recursive: true, force: true }); + } + }); +});