mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
7e4ece697d
Adds the two files the official MCP registry
(`registry.modelcontextprotocol.io`) needs in order to list argent.
Argent is not in that registry today
(`?search=io.github.software-mansion` returns 0 servers).
The registry is metadata-only — it points at the npm package rather than
hosting anything.
### `server.json`
Describes the server and how a client starts it: `npx @swmansion/argent
mcp`. The `mcp` positional is the dispatcher case in
`packages/argent/src/cli.ts:137`, and it is the same subcommand `argent
init` writes into every client config —
`packages/argent-installer/src/mcp-configs.ts:130-141` spells it four
ways (`node <path> mcp`, `yarn argent mcp`, `npx --no-install argent
mcp`, and the global `{ command: MCP_BINARY_NAME, args: ["mcp"] }`,
where `MCP_BINARY_NAME` is `"argent"`). None of them is literally the
npx-by-package-name form in `server.json`, which is the form the
registry expects a client to construct from `identifier` + `runtimeHint`
— so it is verified below by running it, not by matching a string.
`repository.id` is the GitHub repo id (`1164885639`); the schema
recommends it so a registry can detect a delete-and-recreate of the
repo.
### `mcpName` in `packages/argent/package.json`
The ownership proof. The registry fetches the npm package and requires
`mcpName` to equal the server name in `server.json` before it will
accept a publish under `io.github.software-mansion`. It is the field the
registry's own quickstart names for npm packages. Nothing in the publish
path drops it: `scripts/pack-mcp.cjs` shells out to `npm pack` without
rewriting the manifest, and the canary path's
`scripts/next-canary-version.mjs --write` round-trips the JSON and only
sets `version`.
### Namespace
`io.github.software-mansion/argent` — GitHub OAuth auth. The registry
grants an org namespace only to org **Owners**; plain membership is no
longer enough. If no Owner is available to run the publish, the
alternative is DNS auth via an apex TXT record on `swmansion.com`, which
changes the name to `com.swmansion/argent` (and the `mcpName` value with
it).
### Version sync
`server.json` sits at the repo root, outside `packages/*`, so a release
bump would have left its two `0.18.0`s untouched — and the registry
rejects a publish whose `packages[].version` names a version npm has
never seen. `scripts/check-workspace-versions.mjs` (already run by the
Static checks job) also holds `server.json` to the version in
`packages/argent/package.json` — the package it names — and pins the two
cross-file links a publish depends on: `name` == `mcpName`, `identifier`
== the published package name. Both halves are reported in one run, so a
half-finished bump doesn't cost a CI round-trip per file.
### Not in this PR
- **Publishing.** Nothing has been published; this is just the manifest.
Publishing is `mcp-publisher login github && mcp-publisher publish`, and
it can only succeed **after** a release whose npm tarball carries
`mcpName` — i.e. not against `0.18.0`, which is `latest` (published
2026-07-30) and predates `mcpName` — `curl
registry.npmjs.org/@swmansion%2Fargent/0.18.0 | jq .mcpName` → `null`.
The first publishable release is the next bump.
- **`github.com/mcp`.** A separate, manual step. Onboarding a new server
there is hand-curated: per
[github/github-mcp-server#1257](https://github.com/github/github-mcp-server/discussions/1257)
(trent-j, a collaborator on that repo, 19 May 2026) "onboarding a new
server is still a manual curation process today. Once a server has been
onboarded, newly published versions from the OSS registry should sync
from there." The nomination channel is `partnerships@github.com`, from
GitHub's [registry how-to
post](https://github.blog/ai-and-ml/generative-ai/how-to-find-install-and-manage-mcp-servers-with-the-github-mcp-registry/)
(24 Oct 2025) — that address does not appear anywhere in the discussion
thread.
### Verification
- `mcp-publisher validate` (v1.8.0, which posts the file to the live
registry) → `server.json is valid`. Mutation-checked what that actually
covers: removing `transport` fails it and removing `$schema` is a 422,
but a foreign namespace (`io.github.anthropics/argent`), a nonexistent
npm identifier and a `99.0.0` package version all still pass — so it
checks shape only, and the ownership/existence checks happen at publish
time. Also validated offline against the pinned `2025-12-11` schema.
- Ran the entrypoint the file declares. `npx -y @swmansion/argent@latest
mcp` fed an `initialize` over stdio answered
`{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"argent","version":"0.18.0"},"instructions":"…"}`,
with stderr empty.
- Mutation-checked the new version guard: drifting `server.json`
`version`, drifting `packages[0].version`, deleting `mcpName`, and
typo-ing `identifier` each fail `check-workspace-versions.mjs` with the
specific line; the pre-existing packages/* drift check still fires on
its own.
- Asserted across the two files: `mcpName` == `server.json` `name`,
`identifier` == package `name`, all three versions equal, description 89
chars (registry limit is 100).
- `prettier --check` and `eslint` clean on both changed files.
- Mutation-checked the guard as a whole — 22 mutants, 1 survivor.
Killed: reverting the self-invocation guard to a raw `argv[1]` compare
and making it never match; `exit(1)` → `exit(0)` on the failure path;
dropping either `failed = true`; exiting early on `packages/*` drift
instead of collecting; `packages.slice(0, 1)`; hardcoding `packages[0]`
in either message; dropping the no-packages-array guard, the
non-object-entry guard, the non-object-JSON guard, and the presence
checks for `version`, `name` and `mcpName`; `continue` → `break` in the
`packages/*` scan (both arms) and in the entry loop; dropping the
on-message read, the `git checkout` hint, and the per-version name
accumulator. The survivor is the `packages/*` readdir sort: it fixes the
scan order rather than any output, so no single run can observe it — it
is there so a scan that stops early drops the same packages on every
machine instead of a filesystem-dependent set.
- Reproduced the symlink no-op on the real repo: with
`packages/registry` at `9.9.9` and `server.json` at `0.17.0`, `node
/tmp/argent-alias/scripts/check-workspace-versions.mjs` now exits 1 and
prints both problems, where before it exited 0 with no output at all.
- Closed the vacuous-pass class in `serverJsonMismatches`: all four
comparisons it makes were bare `!==`, so a field absent from *both*
sides compared equal and passed. A `packages/argent/package.json` with
no `version` printed `All workspace packages and server.json are at
undefined.` and exited 0; with no `name`, a `server.json` naming nothing
installable was reported in sync. `version` and `name` now require
presence, as `mcpName` already did.
- `readTrackedJson` guarantees an object, not just parseable bytes — a
`server.json` holding `null` used to reach the field reads and throw,
and `"0.18.0"` or `[]` produced mismatch lines that never said the file
was the wrong shape.
- Full CI parity locally: `test:scripts` 85/85, `tsc -p
tsconfig.scripts.json`, `eslint --max-warnings 0`, `prettier --check`.
212 lines
8.9 KiB
JavaScript
212 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Fails if the workspace packages under packages/* don't all share the same
|
|
// version. Keeps the monorepo's lockstep versioning from silently drifting when
|
|
// a release bump misses a package.
|
|
//
|
|
// The root server.json (the MCP registry manifest) is held to the version and
|
|
// the npm coordinates in packages/argent/package.json, the package it points at.
|
|
// It is checked here because it is the one such file outside packages/*, so a
|
|
// bump that sweeps the workspace leaves it behind — and the registry then
|
|
// rejects the publish, either because `packages[].version` names a version npm
|
|
// has never seen or because `name` doesn't match the tarball's `mcpName`.
|
|
import { readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
import { dirname, join, relative } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { argv as processArgv } from "node:process";
|
|
|
|
const selfPath = realpathSync(fileURLToPath(import.meta.url));
|
|
const repoRoot = join(dirname(selfPath), "..");
|
|
const packagesDir = join(repoRoot, "packages");
|
|
const argentManifestPath = join(packagesDir, "argent", "package.json");
|
|
const serverJsonPath = join(repoRoot, "server.json");
|
|
|
|
/**
|
|
* Everything server.json has to keep in step with the npm package it points at,
|
|
* as one human-readable line per problem. Pure, separated from the fs reads so
|
|
* it can be unit-tested.
|
|
* @param {string | undefined} publishedVersion the version packages/argent/package.json carries
|
|
* @param {{ name?: string, mcpName?: string }} argentPkg packages/argent/package.json
|
|
* @param {{ name?: string, version?: string, packages?: { identifier?: string, version?: string }[] }} server server.json
|
|
* @returns {string[]} empty when in sync
|
|
*/
|
|
export function serverJsonMismatches(publishedVersion, argentPkg, server) {
|
|
const mismatches = [];
|
|
|
|
// Without a version on the manifest there is no target to compare against, and
|
|
// every comparison below would pass by matching undefined against undefined —
|
|
// so its absence is a mismatch in its own right, exactly like mcpName's.
|
|
if (!publishedVersion) {
|
|
mismatches.push(
|
|
"packages/argent/package.json has no version — nothing for server.json to be checked against"
|
|
);
|
|
} else if (server.version !== publishedVersion) {
|
|
mismatches.push(
|
|
`server.json version is ${server.version}, packages/argent/package.json is ${publishedVersion}`
|
|
);
|
|
}
|
|
|
|
// mcpName on the published npm tarball is what proves to the registry that the
|
|
// io.github.software-mansion namespace is ours, so its absence is as fatal as a
|
|
// mismatch — and an absent one equals an absent server.json name by ===.
|
|
if (!argentPkg.mcpName) {
|
|
mismatches.push(
|
|
"packages/argent/package.json has no mcpName — the registry reads it off the published tarball to prove the namespace"
|
|
);
|
|
} else if (server.name !== argentPkg.mcpName) {
|
|
mismatches.push(
|
|
`server.json name (${server.name}) != packages/argent/package.json mcpName (${argentPkg.mcpName})`
|
|
);
|
|
}
|
|
|
|
// Every packages[].identifier below is compared against this name, so without
|
|
// it those comparisons pass on undefined === undefined and server.json is free
|
|
// to name no installable package at all.
|
|
if (!argentPkg.name) {
|
|
mismatches.push(
|
|
"packages/argent/package.json has no name — server.json's identifier has nothing to point at"
|
|
);
|
|
}
|
|
|
|
// Absent, empty and non-array all leave the registry entry naming nothing to
|
|
// install, so they get one message rather than a TypeError out of the loop.
|
|
const packages = server.packages;
|
|
if (!Array.isArray(packages) || packages.length === 0) {
|
|
mismatches.push(
|
|
"server.json has no packages array — the registry entry would name nothing to install"
|
|
);
|
|
return mismatches;
|
|
}
|
|
|
|
for (const [i, pkg] of packages.entries()) {
|
|
// Same reason as the array check above: an entry that is not an object has
|
|
// no coordinates to read, and reading through it would throw out of the loop.
|
|
if (typeof pkg !== "object" || pkg === null) {
|
|
mismatches.push(
|
|
`server.json packages[${i}] is ${pkg === null ? "null" : typeof pkg}, not an object naming a registry and identifier`
|
|
);
|
|
continue;
|
|
}
|
|
if (pkg.version !== publishedVersion) {
|
|
mismatches.push(
|
|
`server.json packages[${i}].version is ${pkg.version}, packages/argent/package.json is ${publishedVersion}`
|
|
);
|
|
}
|
|
if (pkg.identifier !== argentPkg.name) {
|
|
mismatches.push(
|
|
`server.json packages[${i}].identifier (${pkg.identifier}) != published package name (${argentPkg.name})`
|
|
);
|
|
}
|
|
}
|
|
|
|
return mismatches;
|
|
}
|
|
|
|
/**
|
|
* Reads a tracked JSON file, reporting a missing or malformed one as a sentence
|
|
* instead of a raw stack trace. Both only happen by hand-corrupting a file git
|
|
* tracks, and the fix is the same either way.
|
|
* @param {string} path
|
|
* @returns {any}
|
|
*/
|
|
function readTrackedJson(path) {
|
|
const shown = relative(repoRoot, path);
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(path, "utf8");
|
|
} catch (error) {
|
|
console.error(`Cannot read ${shown}: ${error.message}`);
|
|
console.error(`\nIt is tracked in git — restore it with \`git checkout -- ${shown}\`.`);
|
|
process.exit(1);
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
console.error(`${shown} is not valid JSON: ${error.message}`);
|
|
console.error(`\nIt is tracked in git — restore it with \`git checkout -- ${shown}\`.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// JSON.parse returns whatever the file holds, so a scalar, null or an array
|
|
// parses cleanly and then either throws or compares equal to undefined on
|
|
// every field read below. Both callers want an object or nothing.
|
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
const shape = parsed === null ? "null" : Array.isArray(parsed) ? "an array" : typeof parsed;
|
|
console.error(`${shown} is ${shape}, not a JSON object`);
|
|
console.error(`\nIt is tracked in git — restore it with \`git checkout -- ${shown}\`.`);
|
|
process.exit(1);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function main() {
|
|
const byVersion = new Map(); // version -> package names
|
|
// Sorted so the scan visits packages/* in the same order on every machine —
|
|
// readdir order is filesystem-dependent, and a scan that stops early would
|
|
// otherwise drop a different set of packages each run.
|
|
const entries = readdirSync(packagesDir, { withFileTypes: true }).sort((a, b) =>
|
|
a.name < b.name ? -1 : a.name > b.name ? 1 : 0
|
|
);
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(readFileSync(join(packagesDir, entry.name, "package.json"), "utf8"));
|
|
} catch {
|
|
continue; // directory without a package.json
|
|
}
|
|
// `?.` because JSON.parse succeeds on a file holding `null`, which the catch
|
|
// above never sees — the same shape readTrackedJson rejects for the two
|
|
// manifests it reads.
|
|
if (!manifest?.version) continue;
|
|
const names = byVersion.get(manifest.version) ?? [];
|
|
names.push(manifest.name ?? entry.name);
|
|
byVersion.set(manifest.version, names);
|
|
}
|
|
|
|
let failed = false;
|
|
|
|
// Reported before the reads below, so a corrupt server.json can't swallow it.
|
|
if (byVersion.size > 1) {
|
|
console.error("Workspace package versions are out of sync:");
|
|
for (const [version, names] of [...byVersion].sort()) {
|
|
console.error(` ${version}: ${names.sort().join(", ")}`);
|
|
}
|
|
console.error(
|
|
"\nEvery package under packages/* must share one version. Bump the outliers to match.\n"
|
|
);
|
|
failed = true;
|
|
}
|
|
|
|
// An empty packages/ never gets past this read. It only proves the file parses,
|
|
// though — a manifest missing its version is caught as a mismatch below.
|
|
const argentPkg = readTrackedJson(argentManifestPath);
|
|
const server = readTrackedJson(serverJsonPath);
|
|
|
|
// server.json names the npm coordinates of packages/argent, so that manifest
|
|
// is what it has to agree with. Reported even when packages/* disagree, so a
|
|
// half-finished bump surfaces every remaining edit in one run.
|
|
const mismatches = serverJsonMismatches(argentPkg.version, argentPkg, server);
|
|
if (mismatches.length > 0) {
|
|
console.error("server.json is out of sync with packages/argent/package.json:");
|
|
for (const line of mismatches) console.error(` ${line}`);
|
|
console.error(
|
|
"\nserver.json is not under packages/*, so a workspace bump leaves it behind — edit it\n" +
|
|
"by hand to match packages/argent/package.json."
|
|
);
|
|
failed = true;
|
|
}
|
|
|
|
if (failed) process.exit(1);
|
|
|
|
console.log(`All workspace packages and server.json are at ${argentPkg.version}.`);
|
|
}
|
|
|
|
// Run only when invoked directly, not when imported by the test. Both sides are
|
|
// realpath'd: node realpaths the main module before setting import.meta.url but
|
|
// leaves process.argv[1] as typed, so comparing them raw makes a path through a
|
|
// symlink skip main() and exit 0 with no output — a silent pass.
|
|
if (processArgv[1] && realpathSync(processArgv[1]) === selfPath) {
|
|
main();
|
|
}
|