feat: Warn MCP agents when a newer CLI is available (#6462)

## What changes

When MCP starts, the CLI checks npm's `latest` release while the project
session and API contract are loading. If a newer CLI is available, the
MCP initialization instructions tell the connected agent which versions
are running and recommend restarting with the latest CLI. The CLI never
updates or restarts itself.

The registry request has a 1.5-second timeout and fails open on network
or response errors. It may add up to 1.5 seconds to startup if the
project session finishes before the check; the existing server
compatibility check remains the authority for whether writes are
supported.

## Verification

- [x] Added tests for newer, same/older, invalid, and unavailable
registry responses; the newer-version test failed when the check was
temporarily disabled.
- [x] Verified the update notice reaches the connected MCP client; the
handshake test failed when the notice was temporarily omitted.
- [x] Full CLI suite: 1,159 tests passed.
- [x] CLI and project-build typechecks passed; focused MCP
initialization integration test passed.
- [x] Lint, formatting, and diff checks passed.
- [x] Documentation impact reviewed: no docs change is needed because
the notice is sent dynamically at MCP startup.
- [ ] Full Luna/low agent evaluation suite: approval requested, not
received; not run.

Ref #6461
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-09-28 16:40:01 +02:00
1 parent e1e5bc152e
commit 11c31bdc25
6 files changed
+146 -1

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
import { expect, test, vi } from "vitest";
import { checkForCliUpdate } from "./cli-update";
test("detects a newer CLI release from the npm registry", async () => {
const request = vi.fn(async () => Response.json({ version: "0.301.0" }));
await expect(
checkForCliUpdate({ currentVersion: "0.299.0", request })
).resolves.toEqual({
currentVersion: "0.299.0",
latestVersion: "0.301.0",
});
expect(request).toHaveBeenCalledWith(
"https://registry.npmjs.org/webstudio/latest",
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
});
test.each(["0.301.0", "0.300.0"])(
"does not recommend an update when installed CLI is %s or newer",
async (currentVersion) => {
const request = vi.fn(async () => Response.json({ version: "0.300.0" }));
await expect(
checkForCliUpdate({ currentVersion, request })
).resolves.toBeUndefined();
}
);
test("fails open when the registry is unavailable or has invalid data", async () => {
const failedRequest = vi.fn(async () => {
throw new Error("registry unavailable");
});
const invalidResponse = vi.fn(async () =>
Response.json({ version: "not-semver" })
);
await expect(
checkForCliUpdate({ currentVersion: "0.299.0", request: failedRequest })
).resolves.toBeUndefined();
await expect(
checkForCliUpdate({ currentVersion: "0.299.0", request: invalidResponse })
).resolves.toBeUndefined();
});
test("skips the registry check for a development build without a release version", async () => {
const request = vi.fn();
await expect(
checkForCliUpdate({ currentVersion: "0.0.0-webstudio-version", request })
).resolves.toBeUndefined();
expect(request).not.toHaveBeenCalled();
});
+53
View File
@@ -0,0 +1,53 @@
import semver from "semver";
import { isPlainRecord } from "./type-utils";
const latestPackageUrl = "https://registry.npmjs.org/webstudio/latest";
const requestTimeoutMs = 1_500;
export type CliUpdate = {
currentVersion: string;
latestVersion: string;
};
export const checkForCliUpdate = async ({
currentVersion,
request = fetch,
}: {
currentVersion: string;
request?: typeof fetch;
}): Promise<CliUpdate | undefined> => {
if (
currentVersion === "0.0.0-webstudio-version" ||
semver.valid(currentVersion) === null
) {
return;
}
try {
const response = await request(latestPackageUrl, {
signal: AbortSignal.timeout(requestTimeoutMs),
});
if (response.ok === false) {
return;
}
const latestPackage: unknown = await response.json();
if (isPlainRecord(latestPackage) === false) {
return;
}
const latestVersion =
typeof latestPackage.version === "string"
? semver.valid(latestPackage.version)
: null;
if (
latestVersion === null ||
semver.gt(latestVersion, currentVersion) === false
) {
return;
}
return { currentVersion, latestVersion };
} catch {
return;
}
};
+13
View File
@@ -33,6 +33,7 @@ const {
createMcpRunErrorPayload,
createMcpSingleOpCallErrorPayload,
createMcpStatusReporter,
getCliUpdateInstructions,
getLoadedProjectSessionSnapshot,
getMcpOperationInput,
prepareTextAssetWriteFeedback,
@@ -52,6 +53,18 @@ const {
withMcpHost,
} = __testing__;
test("instructs connected agents to update an outdated MCP CLI", () => {
expect(
getCliUpdateInstructions({
currentVersion: "0.299.0",
latestVersion: "0.301.0",
})
).toBe(
"This MCP server runs Webstudio CLI 0.299.0, but 0.301.0 is available. Tell the user before editing and recommend restarting this MCP server with the latest CLI, for example `npx -y webstudio@latest mcp`. Do not try to update or restart the CLI yourself. The existing API compatibility check remains the authority for whether writes are supported."
);
expect(getCliUpdateInstructions(undefined)).toBeUndefined();
});
test("disposes an MCP host when its operation fails", async () => {
const dispose = vi.fn(async () => undefined);
+14
View File
@@ -95,6 +95,7 @@ import { readCliDoc } from "../docs";
import { printJson } from "../json-output";
import { isPlainRecord } from "../type-utils";
import { withTimeout } from "../async-utils";
import { checkForCliUpdate, type CliUpdate } from "../cli-update";
import { LOCAL_DATA_FILE } from "../config";
import {
assertMcpBatchMutationApproved,
@@ -146,6 +147,13 @@ export const prepareMcpProjectSession = async (
const mcpStatusPrefix = "[webstudio mcp]";
const renderedAuditArtifactDirectory = ".webstudio/audits";
const getCliUpdateInstructions = (update: CliUpdate | undefined) => {
if (update === undefined) {
return;
}
return `This MCP server runs Webstudio CLI ${update.currentVersion}, but ${update.latestVersion} is available. Tell the user before editing and recommend restarting this MCP server with the latest CLI, for example \`npx -y webstudio@latest mcp\`. Do not try to update or restart the CLI yourself. The existing API compatibility check remains the authority for whether writes are supported.`;
};
export const formatMcpStatusLine = (message: string) =>
`${mcpStatusPrefix} ${message}`;
@@ -2771,6 +2779,9 @@ export const mcp = async (
stdin.once("end", reportClose);
stdin.once("close", reportClose);
status.starting();
const cliUpdatePromise = checkForCliUpdate({
currentVersion: packageJson.version,
});
const {
host,
toolCount,
@@ -2782,6 +2793,7 @@ export const mcp = async (
} = await createCliMcpHost({
projectId: options.project,
});
const cliUpdate = await cliUpdatePromise;
disposeHost = dispose;
if (didReportClose) {
await disposeHost().catch(() => undefined);
@@ -2792,6 +2804,7 @@ export const mcp = async (
status.ready(toolCount);
const server = await connectProjectSessionMcpServer({
...host,
additionalInstructions: getCliUpdateInstructions(cliUpdate),
toolNameFormat: options.toolNameFormat,
getErrorCode: getStableErrorCode,
onToolFailure: recordToolFailure,
@@ -2809,6 +2822,7 @@ export const mcp = async (
export const __testing__ = {
getMcpDownloadAsset,
getCliUpdateInstructions,
createMcpStatusReporter,
formatMcpStatusLine,
assertSingleOpCallToolSupported,
+8
View File
@@ -8072,6 +8072,8 @@ describe("project session mcp adapter", () => {
],
createProjectSession: createSessionFactory(),
executeOperation: createExecuteOperation(),
additionalInstructions:
"This MCP server runs CLI 0.299.0; CLI 0.301.0 is available.",
});
const { client, close } = await createConnectedClient(server);
@@ -8085,6 +8087,12 @@ describe("project session mcp adapter", () => {
name: "webstudio",
version: "0.0.0",
});
expect(client.getInstructions()).toContain(
"This MCP server runs CLI 0.299.0; CLI 0.301.0 is available."
);
expect(client.getInstructions()).toContain(
"For any multi-step authoring task"
);
const listedTools = await client.listTools();
expect(
listedTools.tools.find(({ name }) => name === "list-pages")?.annotations
+5 -1
View File
@@ -9311,7 +9311,9 @@ export const createProjectSessionMcpServer = async <
toolHeartbeatIntervalMs = 10_000,
onToolFailure,
onToolSuccess,
additionalInstructions,
}: Omit<ProjectSessionMcpCoreOptions<Command>, "reportToolProgress"> & {
additionalInstructions?: string;
getErrorCode?: McpErrorCodeResolver;
reportLog?: (level: McpLogLevel, message: string) => void;
onInitialized?: (clientName: string | undefined) => void;
@@ -9333,7 +9335,9 @@ export const createProjectSessionMcpServer = async <
resources: {},
logging: {},
},
instructions: startupGuidance,
instructions: [startupGuidance, additionalInstructions?.trim()]
.filter(Boolean)
.join("\n\n"),
}
);
const sendLog = (level: "info" | "error", data: string) => {