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.
This commit is contained in:
Olivier Chafik 2026-09-07 15:28:23 +01:00
commit 7f686ff2b4

View file

@ -17,13 +17,20 @@ const packageJson = JSON.parse(
const client = "@modelcontextprotocol/client"; const client = "@modelcontextprotocol/client";
const server = "@modelcontextprotocol/server"; 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]) { for (const role of [client, server]) {
if (!packageJson.peerDependencies?.[role]) { if (!packageJson.peerDependencies?.[role]) {
throw new Error(`${role} must remain a peer dependency`); throw new Error(`${role} must remain a peer dependency`);
} }
if (packageJson.peerDependenciesMeta?.[role]?.optional !== true) { }
throw new Error(`${role} must be an optional 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`);
} }
/** /**
@ -42,6 +49,13 @@ function exactDevDependency(name) {
return 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-")); const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
try { try {
// Minimal environment: nothing inherited from the caller's npm config // Minimal environment: nothing inherited from the caller's npm config
@ -72,17 +86,25 @@ try {
const consumers = [ const consumers = [
{ {
// View / host author: ext-apps + client (+ react for the hooks entry).
name: "app-only", name: "app-only",
dependencies: { dependencies: {
"@types/node": exactDevDependency("@types/node"), "@types/node": exactDevDependency("@types/node"),
"@types/react": installedVersion("@types/react"),
[client]: exactDevDependency(client), [client]: exactDevDependency(client),
"@modelcontextprotocol/ext-apps": `file:${tarball}`, "@modelcontextprotocol/ext-apps": `file:${tarball}`,
react: installedVersion("react"),
}, },
absent: server, absent: server,
// server must be neither installed (it is an optional peer) nor bundled.
mustNotInstall: true,
entry: entry:
'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; console.log(App, AppBridge);', '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", name: "server-only",
dependencies: { dependencies: {
"@types/node": exactDevDependency("@types/node"), "@types/node": exactDevDependency("@types/node"),
@ -90,6 +112,7 @@ try {
[server]: exactDevDependency(server), [server]: exactDevDependency(server),
}, },
absent: client, absent: client,
mustNotInstall: false,
entry: entry:
'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);', 'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);',
}, },
@ -115,22 +138,24 @@ try {
"--no-audit", "--no-audit",
"--no-fund", "--no-fund",
], ],
{ cwd: directory, stdio: "pipe", env: npmEnvironment }, { cwd: directory, stdio: "inherit", env: npmEnvironment },
); );
const absentPath = join( if (consumer.mustNotInstall) {
directory, const absentPath = join(
"node_modules", directory,
...consumer.absent.split("/"), "node_modules",
"package.json", ...consumer.absent.split("/"),
); "package.json",
try {
readFileSync(absentPath);
throw new Error(
`${consumer.name} unexpectedly installed ${consumer.absent}`,
); );
} catch (error) { try {
if (error?.code !== "ENOENT") throw error; 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, "entry.ts"), consumer.entry);
@ -138,6 +163,7 @@ try {
join(directory, "tsconfig.json"), join(directory, "tsconfig.json"),
JSON.stringify({ JSON.stringify({
compilerOptions: { compilerOptions: {
jsx: "react-jsx",
lib: ["ES2020", "DOM"], lib: ["ES2020", "DOM"],
module: "ESNext", module: "ESNext",
moduleResolution: "bundler", moduleResolution: "bundler",
@ -165,7 +191,7 @@ try {
"--outfile=bundle.js", "--outfile=bundle.js",
`--metafile=${metafile}`, `--metafile=${metafile}`,
], ],
{ cwd: directory, stdio: "pipe" }, { cwd: directory, stdio: "inherit" },
); );
const bundleInputs = Object.keys( const bundleInputs = Object.keys(
JSON.parse(readFileSync(metafile, "utf8")).inputs, JSON.parse(readFileSync(metafile, "utf8")).inputs,