mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
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:
1 parent
e1e5bc152e
commit
11c31bdc25
6 files changed
+146
-1
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user