Skip to content

Commit 011fc0d

Browse files
authored
Merge pull request #21 from AztecProtocol/feat/version-self-check
feat: npm-version self-check + accurate aztec_status version
2 parents 8f00f0c + 8fece22 commit 011fc0d

6 files changed

Lines changed: 472 additions & 15 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,20 @@ Keys are free, persistent (re-running `/mcp-key` returns the same key), and revo
3434
### With npx (recommended)
3535

3636
```bash
37-
npx @aztec/mcp-server
37+
npx -y @aztec/mcp-server@latest
3838
```
3939

40+
> **Always pin `@latest`.** npx caches packages aggressively — without `@latest`, you can end up running an old version indefinitely. The `@latest` tag forces npx to check the registry for the current release every run. The server also self-reports an upgrade-available warning at startup if it detects a newer version on npm (see `aztec_status` output).
41+
4042
### Global install
4143

4244
```bash
43-
npm install -g @aztec/mcp-server
45+
npm install -g @aztec/mcp-server@latest
4446
aztec-mcp
4547
```
4648

49+
To update later: `npm install -g @aztec/mcp-server@latest` (or just rely on the `npx -y @aztec/mcp-server@latest` form, which always fetches current).
50+
4751
## Configuration
4852

4953
### Claude Code Plugin
@@ -179,7 +183,7 @@ Override with the `AZTEC_MCP_REPOS_DIR` environment variable:
179183
"mcpServers": {
180184
"aztec-mcp": {
181185
"command": "npx",
182-
"args": ["-y", "@aztec/mcp-server"],
186+
"args": ["-y", "@aztec/mcp-server@latest"],
183187
"env": {
184188
"AZTEC_MCP_REPOS_DIR": "/custom/path"
185189
}
@@ -197,7 +201,7 @@ Set the default Aztec version with the `AZTEC_DEFAULT_VERSION` environment varia
197201
"mcpServers": {
198202
"aztec-mcp": {
199203
"command": "npx",
200-
"args": ["-y", "@aztec/mcp-server"],
204+
"args": ["-y", "@aztec/mcp-server@latest"],
201205
"env": {
202206
"AZTEC_DEFAULT_VERSION": "v3.0.0-devnet.6-plugin.1"
203207
}

src/index.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ import { getSyncState, writeAutoResyncAttempt } from "./utils/sync-metadata.js";
4747
import { getRepoTag } from "./utils/git.js";
4848
import type { Logger } from "./utils/git.js";
4949
import { DocsGPTClient } from "./backends/docsgpt-client.js";
50+
import {
51+
checkForUpgrade,
52+
formatUpgradeBanner,
53+
} from "./utils/version-self-check.js";
5054

5155
// ---------------------------------------------------------------------------
5256
// DocsGPT client — optional, enabled when API_KEY is set
@@ -108,6 +112,16 @@ const LOCAL_ONLY_INSTRUCTIONS =
108112
"API_KEY in their MCP client config (e.g. .mcp.json, Claude Desktop " +
109113
"config, etc.) and restart the server.";
110114

115+
// Check npm registry for a newer release and surface an "outdated"
116+
// banner in the instructions if so. Top-level await: blocks startup
117+
// for at most ~2s on the registry round-trip (with internal timeout)
118+
// — runs once per server process, so the cost amortizes immediately.
119+
// Failure modes (no network, registry down, slow response) all return
120+
// null and produce no banner; the server boots normally.
121+
const upgradeInfo = await checkForUpgrade(MCP_VERSION);
122+
const upgradeBanner =
123+
upgradeInfo && upgradeInfo.outdated ? formatUpgradeBanner(upgradeInfo) : "";
124+
111125
const server = new Server(
112126
{
113127
name: "aztec-mcp",
@@ -118,7 +132,9 @@ const server = new Server(
118132
tools: {},
119133
logging: {},
120134
},
121-
instructions: docsgptClient ? SEMANTIC_INSTRUCTIONS : LOCAL_ONLY_INSTRUCTIONS,
135+
instructions:
136+
(docsgptClient ? SEMANTIC_INSTRUCTIONS : LOCAL_ONLY_INSTRUCTIONS) +
137+
upgradeBanner,
122138
}
123139
);
124140

src/utils/format.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import type { SyncMetadata } from "./sync-metadata.js";
88
import type { ErrorLookupResult } from "./error-lookup.js";
99
import type { SemanticSearchToolResult } from "../tools/search.js";
1010
import type { ErrorLookupToolResult } from "../tools/error-lookup.js";
11+
import { MCP_VERSION } from "../version.js";
12+
import {
13+
formatUpgradeStatusLine,
14+
getUpgradeInfo,
15+
} from "./version-self-check.js";
1116

1217
export function formatSyncResult(result: SyncResult): string {
1318
const lines = [
@@ -40,12 +45,33 @@ export function formatStatus(status: {
4045
const lines = [
4146
"Aztec MCP Server Status",
4247
"",
43-
`Repos directory: ${status.reposDir}`,
48+
// Live version read from package.json at module load (see
49+
// ``src/version.ts``). The previous implementation pulled this
50+
// from sync metadata, which was the version that ran the LAST
51+
// sync — stale across upgrades that didn't touch the clones.
52+
`MCP server version: ${MCP_VERSION}`,
4453
];
4554

55+
// npm-latest comparison done at boot (``checkForUpgrade`` in
56+
// ``src/index.ts``). Prints either "you are up to date" or an
57+
// upgrade-available warning. Empty string when the registry check
58+
// failed at boot, so we stay silent rather than misleading.
59+
const upgradeLine = formatUpgradeStatusLine(getUpgradeInfo());
60+
if (upgradeLine) {
61+
lines.push(upgradeLine);
62+
}
63+
64+
lines.push(`Repos directory: ${status.reposDir}`);
65+
4666
if (status.syncMetadata) {
4767
lines.push(`Last synced: ${status.syncMetadata.syncedAt}`);
48-
lines.push(`MCP server version: ${status.syncMetadata.mcpVersion}`);
68+
if (status.syncMetadata.mcpVersion !== MCP_VERSION) {
69+
// Only mention this when it differs from the live version —
70+
// otherwise it's just noise that duplicates the line above.
71+
lines.push(
72+
` (last sync ran under MCP server v${status.syncMetadata.mcpVersion} — re-run aztec_sync_repos to refresh metadata)`
73+
);
74+
}
4975
lines.push(`Aztec version: ${status.syncMetadata.aztecVersion}`);
5076
}
5177

src/utils/version-self-check.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Self-check for outdated installs of @aztec/mcp-server.
3+
*
4+
* Why: npx caches packages, and users frequently end up running an old
5+
* version while assuming `npx @aztec/mcp-server` always pulls the latest.
6+
* The result is silently-degraded behavior + bug reports against fixes
7+
* that have already shipped. This module fetches the current latest tag
8+
* from the npm registry at startup, compares against the running
9+
* version, and surfaces a warning into both the MCP `instructions`
10+
* banner (so the LLM tells the user) and `aztec_status` (so a curious
11+
* user running diagnostics also sees it).
12+
*
13+
* Failure modes are silent (registry down, no network, slow response)
14+
* — the check should never block startup or fail the server. Worst
15+
* case: no banner, business as usual.
16+
*/
17+
18+
const NPM_REGISTRY_URL = "https://registry.npmjs.org/@aztec/mcp-server/latest";
19+
20+
export interface UpgradeInfo {
21+
current: string;
22+
latest: string;
23+
outdated: boolean;
24+
}
25+
26+
let upgradeInfoCache: UpgradeInfo | null = null;
27+
28+
/**
29+
* Test-only: reset the module-level upgrade cache between tests.
30+
*/
31+
export function _resetUpgradeCache(): void {
32+
upgradeInfoCache = null;
33+
}
34+
35+
export function setUpgradeInfo(info: UpgradeInfo | null): void {
36+
upgradeInfoCache = info;
37+
}
38+
39+
export function getUpgradeInfo(): UpgradeInfo | null {
40+
return upgradeInfoCache;
41+
}
42+
43+
/**
44+
* Fetch the latest published version of @aztec/mcp-server from npm.
45+
* Returns null on any failure (network, timeout, malformed body) —
46+
* never throws, so callers don't have to wrap in try/catch.
47+
*/
48+
export async function fetchLatestNpmVersion(
49+
timeoutMs: number = 2000,
50+
fetchImpl: typeof fetch = fetch
51+
): Promise<string | null> {
52+
const ctl = new AbortController();
53+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
54+
// `unref` (Node-only) prevents this timer from keeping the event
55+
// loop alive on its own. Critical for short-lived processes and
56+
// tests where a forgotten timer would block exit. Optional-chained
57+
// because `setTimeout` in browser-shaped environments returns a
58+
// primitive number with no `unref` — the optional call is safe.
59+
(timer as unknown as { unref?: () => void }).unref?.();
60+
try {
61+
const resp = await fetchImpl(NPM_REGISTRY_URL, { signal: ctl.signal });
62+
if (!resp.ok) return null;
63+
const data = await resp.json();
64+
if (!data || typeof data !== "object") return null;
65+
const v = (data as Record<string, unknown>).version;
66+
return typeof v === "string" ? v : null;
67+
} catch {
68+
return null;
69+
} finally {
70+
// Always clear: the previous implementation only cleared on the
71+
// success path, leaking the timer when `fetchImpl` rejected
72+
// (network error, CORS, malformed body) before the timeout
73+
// fired. Combined with `unref` above this is belt-and-braces.
74+
clearTimeout(timer);
75+
}
76+
}
77+
78+
/**
79+
* Numeric major.minor.patch comparison. Strips a leading ``v`` and
80+
* any pre-release / build suffix (so ``1.20.0-rc.1`` and ``1.20.0``
81+
* compare equal — we don't want to flag a stable user as outdated
82+
* relative to a pre-release on npm).
83+
*/
84+
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
85+
const parse = (v: string): number[] => {
86+
const core = v.replace(/^v/, "").split("-")[0].split("+")[0];
87+
return core.split(".").map((p) => {
88+
const n = parseInt(p, 10);
89+
return Number.isFinite(n) ? n : 0;
90+
});
91+
};
92+
const ma = parse(a);
93+
const mb = parse(b);
94+
for (let i = 0; i < Math.max(ma.length, mb.length); i++) {
95+
const x = ma[i] ?? 0;
96+
const y = mb[i] ?? 0;
97+
if (x < y) return -1;
98+
if (x > y) return 1;
99+
}
100+
return 0;
101+
}
102+
103+
/**
104+
* High-level entry: fetch + compare + cache. Returns the populated
105+
* cache entry (also retrievable via ``getUpgradeInfo()``).
106+
*/
107+
export async function checkForUpgrade(
108+
currentVersion: string,
109+
options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {}
110+
): Promise<UpgradeInfo | null> {
111+
const latest = await fetchLatestNpmVersion(
112+
options.timeoutMs,
113+
options.fetchImpl ?? fetch
114+
);
115+
if (!latest) {
116+
setUpgradeInfo(null);
117+
return null;
118+
}
119+
const info: UpgradeInfo = {
120+
current: currentVersion,
121+
latest,
122+
outdated: compareSemver(currentVersion, latest) < 0,
123+
};
124+
setUpgradeInfo(info);
125+
return info;
126+
}
127+
128+
/**
129+
* Format the upgrade warning that gets appended to the MCP server
130+
* instructions banner. The text is consumed by the LLM, not directly
131+
* by a human, so it explains what the LLM should *do*: tell the user
132+
* to update. Listed remediation commands match the install paths
133+
* documented in the README so the LLM can copy-paste them.
134+
*/
135+
export function formatUpgradeBanner(info: UpgradeInfo): string {
136+
return [
137+
"",
138+
"",
139+
`⚠️ UPDATE AVAILABLE: this MCP server is running v${info.current}, but v${info.latest} is the current release on npm. ` +
140+
`Tell the user they're on an outdated version, and that bug reports about behavior may already be fixed in the latest release. ` +
141+
`To upgrade, ensure their MCP client config uses \`@aztec/mcp-server@latest\` so npx fetches the newest:`,
142+
` • Claude Desktop / Cursor / Codex: change the args to ` +
143+
`["-y", "@aztec/mcp-server@latest"] in the MCP server config and restart the client.`,
144+
` • Claude Code: \`claude mcp remove aztec-docs && claude mcp add aztec-docs ... -- npx -y @aztec/mcp-server@latest\``,
145+
` • If installed globally: \`npm uninstall -g @aztec/mcp-server && npm install -g @aztec/mcp-server@latest\` (or just rely on npx).`,
146+
].join("\n");
147+
}
148+
149+
/**
150+
* Format a one-line upgrade summary suitable for inclusion in the
151+
* ``aztec_status`` output. Returns the empty string when the install
152+
* is current (so the formatter can unconditionally include it).
153+
*/
154+
export function formatUpgradeStatusLine(info: UpgradeInfo | null): string {
155+
if (!info) return "";
156+
if (!info.outdated) {
157+
return `npm latest: v${info.latest} (you are up to date)`;
158+
}
159+
return (
160+
`⚠️ UPDATE AVAILABLE: v${info.current} → v${info.latest} on npm. ` +
161+
`Switch your MCP config to \`@aztec/mcp-server@latest\` and restart the client.`
162+
);
163+
}

0 commit comments

Comments
 (0)