ext-apps/scripts/check-dependency-isolation.mjs
Olivier Chafik 7f686ff2b4 Update dependency isolation check for the required client peer
The app-only consumer now also exercises the ./react entry. The
server-only consumer no longer asserts that client is absent from
node_modules (npm auto-installs required peers) but still checks that
the server entry does not bundle it. Surface npm and esbuild errors
instead of swallowing them.
2026-09-08 13:58:36 +01:00

209 lines
6.8 KiB
JavaScript

import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const packageJson = JSON.parse(
readFileSync(join(root, "package.json"), "utf8"),
);
const client = "@modelcontextprotocol/client";
const server = "@modelcontextprotocol/server";
// App and AppBridge extend the client package's Protocol class, so client is
// a required peer for every consumer. The server helpers are only needed by
// server authors, so server stays optional: View-only consumers must not have
// it installed or bundled.
for (const role of [client, server]) {
if (!packageJson.peerDependencies?.[role]) {
throw new Error(`${role} must remain a peer dependency`);
}
}
if (packageJson.peerDependenciesMeta?.[client]?.optional) {
throw new Error(`${client} must be a required peer dependency`);
}
if (packageJson.peerDependenciesMeta?.[server]?.optional !== true) {
throw new Error(`${server} must be an optional peer dependency`);
}
/**
* Exact version for a synthetic consumer dependency. Read from
* devDependencies so the consumers exercise the same SDK version the
* repository tests against; the check would silently drift if a range were
* allowed here.
*/
function exactDevDependency(name) {
const version = packageJson.devDependencies?.[name];
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version ?? "")) {
throw new Error(
`devDependencies["${name}"] must be an exact version, got ${JSON.stringify(version)}`,
);
}
return version;
}
/** Exact version of a package as installed in this repository's node_modules. */
function installedVersion(name) {
return JSON.parse(
readFileSync(join(root, "node_modules", name, "package.json"), "utf8"),
).version;
}
const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
try {
// Minimal environment: nothing inherited from the caller's npm config
// (registry overrides, auth tokens, npm_config_* set by an outer `npm run`)
// can leak into the synthetic consumers.
const npmEnvironment = {
PATH: process.env.PATH ?? process.env.Path,
HOME: process.env.HOME ?? process.env.USERPROFILE,
npm_config_cache: join(temporaryRoot, "npm-cache"),
};
const packDestination = join(temporaryRoot, "pack");
mkdirSync(packDestination);
// `npm pack` runs the package's `prepare` script even with --ignore-scripts
// (pacote's directory fetcher), and its output can pollute stdout, so do not
// rely on `--json`: locate the tarball on disk instead.
execFileSync(
"npm",
["pack", "--ignore-scripts", "--pack-destination", packDestination],
{ cwd: root, stdio: "pipe", env: npmEnvironment },
);
const tarballs = readdirSync(packDestination).filter((name) =>
name.endsWith(".tgz"),
);
if (tarballs.length !== 1) {
throw new Error(`expected exactly one tarball, found ${tarballs}`);
}
const tarball = join(packDestination, tarballs[0]);
const consumers = [
{
// View / host author: ext-apps + client (+ react for the hooks entry).
name: "app-only",
dependencies: {
"@types/node": exactDevDependency("@types/node"),
"@types/react": installedVersion("@types/react"),
[client]: exactDevDependency(client),
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
react: installedVersion("react"),
},
absent: server,
// server must be neither installed (it is an optional peer) nor bundled.
mustNotInstall: true,
entry:
'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; console.log(App, AppBridge, useApp);',
},
{
// Server author: ext-apps + server. npm auto-installs client as a
// required peer (its types back the shared wire types), but the server
// entry must not pull it into a runtime bundle.
name: "server-only",
dependencies: {
"@types/node": exactDevDependency("@types/node"),
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
[server]: exactDevDependency(server),
},
absent: client,
mustNotInstall: false,
entry:
'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);',
},
];
for (const consumer of consumers) {
const directory = join(temporaryRoot, consumer.name);
mkdirSync(directory);
writeFileSync(
join(directory, "package.json"),
JSON.stringify({
private: true,
type: "module",
dependencies: consumer.dependencies,
}),
);
execFileSync(
"npm",
[
"install",
"--ignore-scripts",
"--package-lock=false",
"--no-audit",
"--no-fund",
],
{ cwd: directory, stdio: "inherit", env: npmEnvironment },
);
if (consumer.mustNotInstall) {
const absentPath = join(
directory,
"node_modules",
...consumer.absent.split("/"),
"package.json",
);
try {
readFileSync(absentPath);
throw new Error(
`${consumer.name} unexpectedly installed ${consumer.absent}`,
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
writeFileSync(join(directory, "entry.ts"), consumer.entry);
writeFileSync(
join(directory, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
jsx: "react-jsx",
lib: ["ES2020", "DOM"],
module: "ESNext",
moduleResolution: "bundler",
noEmit: true,
skipLibCheck: false,
strict: true,
target: "ES2020",
},
include: ["entry.ts"],
}),
);
execFileSync(
process.execPath,
[join(root, "node_modules", "typescript", "bin", "tsc")],
{ cwd: directory, stdio: "inherit" },
);
const metafile = join(directory, "bundle-meta.json");
execFileSync(
join(root, "node_modules", ".bin", "esbuild"),
[
"entry.ts",
"--bundle",
"--platform=browser",
"--outfile=bundle.js",
`--metafile=${metafile}`,
],
{ cwd: directory, stdio: "inherit" },
);
const bundleInputs = Object.keys(
JSON.parse(readFileSync(metafile, "utf8")).inputs,
).join("\n");
if (bundleInputs.includes(`/node_modules/${consumer.absent}/`)) {
throw new Error(
`${consumer.name} unexpectedly bundled ${consumer.absent}`,
);
}
}
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
console.log("Role peer dependency isolation checks passed.");