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.
209 lines
6.8 KiB
JavaScript
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.");
|