Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions build.bun.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
#!/usr/bin/env bun
import { $ } from "bun";
import { cpSync, mkdirSync } from "node:fs";
import { cpSync, mkdirSync, rmSync } from "node:fs";

// Avoid publishing declarations left behind by a previous broader build.
rmSync("dist", { recursive: true, force: true });

// Run TypeScript compiler for type declarations
await $`tsc`;
await $`tsc --noEmit`;
await $`tsc -p tsconfig.build.json`;

// Copy schema.json (tsc is emitDeclarationOnly, Bun.build doesn't emit JSON assets).
// Needed for the "./schema.json" package export.
Expand Down Expand Up @@ -34,12 +38,7 @@ function buildJs(
// Peer dependencies stay external in the standard entry points so consumers
// share one base MCP SDK and Zod instance. The *-with-deps entry points keep
// bundling these dependencies for standalone browser use.
const PEER_EXTERNALS = [
"@modelcontextprotocol/client",
"@modelcontextprotocol/core",
"@modelcontextprotocol/server",
"zod",
];
const PEER_EXTERNALS = ["@modelcontextprotocol/core", "zod"];

await Promise.all([
buildJs("src/app.ts", {
Expand Down
2 changes: 0 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"prepack": "npm run build",
"build:all": "npm run examples:build",
"test": "bun test src examples",
"test:dependency-isolation": "npm run build && node scripts/check-dependency-isolation.mjs",
"test:e2e": "playwright test",
"test:e2e:update": "playwright test --update-snapshots",
"test:e2e:ui": "playwright test --ui",
Expand Down Expand Up @@ -110,9 +111,7 @@
"zod": "^4.2.0"
},
"peerDependencies": {
"@modelcontextprotocol/client": "2.0.0-beta.4",
"@modelcontextprotocol/core": "2.0.0-beta.4",
"@modelcontextprotocol/server": "2.0.0-beta.4",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"zod": "^4.2.0"
Expand Down
155 changes: 155 additions & 0 deletions scripts/check-dependency-isolation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
readdirSync,
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 forbidden = [
"@modelcontextprotocol/client",
"@modelcontextprotocol/server",
];

for (const dependency of forbidden) {
if (packageJson.peerDependencies?.[dependency]) {
throw new Error(`${dependency} must not be a published peer dependency`);
}
}

function walk(directory) {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? walk(path) : [path];
});
}

for (const file of walk(join(root, "dist", "src"))) {
if (!/\.(?:js|d\.ts)$/.test(file)) continue;
const contents = readFileSync(file, "utf8");
for (const dependency of forbidden) {
const escaped = dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const importEdge = new RegExp(
`(?:from\\s*["']${escaped}|import\\(["']${escaped}["']\\)|require\\(["']${escaped}["']\\))`,
);
if (importEdge.test(contents)) {
throw new Error(
`${file} has an unintended runtime or declaration edge to ${dependency}`,
);
}
}
}

const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-isolation-"));
try {
const npmEnvironment = {
...process.env,
npm_config_cache: join(temporaryRoot, "npm-cache"),
};
const packOutput = JSON.parse(
execFileSync(
"npm",
[
"pack",
"--ignore-scripts",
"--json",
"--pack-destination",
temporaryRoot,
],
{ cwd: root, encoding: "utf8", env: npmEnvironment },
),
);
const tarball = join(temporaryRoot, packOutput[0].filename);

const consumers = [
{
name: "app-only",
dependencies: { "@modelcontextprotocol/ext-apps": `file:${tarball}` },
absent: forbidden,
entry:
'import { App } from "@modelcontextprotocol/ext-apps"; console.log(App);',
bundleAbsent: ["@modelcontextprotocol/server"],
},
{
name: "server-only",
dependencies: {
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
"@modelcontextprotocol/server": "2.0.0-beta.4",
},
absent: ["@modelcontextprotocol/client"],
entry:
'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);',
bundleAbsent: ["@modelcontextprotocol/client"],
},
];

for (const consumer of consumers) {
const directory = join(temporaryRoot, consumer.name);
mkdirSync(directory);
writeFileSync(
join(directory, "package.json"),
JSON.stringify({ private: true, dependencies: consumer.dependencies }),
);
execFileSync(
"npm",
[
"install",
"--ignore-scripts",
"--package-lock=false",
"--no-audit",
"--no-fund",
],
{ cwd: directory, stdio: "pipe", env: npmEnvironment },
);

writeFileSync(join(directory, "entry.mjs"), consumer.entry);
const metafile = join(directory, "bundle-meta.json");
execFileSync(
join(root, "node_modules", ".bin", "esbuild"),
[
"entry.mjs",
"--bundle",
"--platform=browser",
"--outfile=bundle.js",
`--metafile=${metafile}`,
],
{ cwd: directory, stdio: "pipe" },
);
const bundleInputs = Object.keys(
JSON.parse(readFileSync(metafile, "utf8")).inputs,
).join("\n");
for (const dependency of consumer.bundleAbsent) {
if (bundleInputs.includes(`/node_modules/${dependency}/`)) {
throw new Error(`${consumer.name} unexpectedly bundled ${dependency}`);
}
}

for (const dependency of consumer.absent) {
const packagePath = join(
directory,
"node_modules",
...dependency.split("/"),
);
try {
readFileSync(join(packagePath, "package.json"));
throw new Error(
`${consumer.name} unexpectedly installed ${dependency}`,
);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
}
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}

console.log("Dependency isolation checks passed.");
44 changes: 44 additions & 0 deletions scripts/generate-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,50 @@ import {
} from "@modelcontextprotocol/core";`,
);

// Give declaration emit a stable local name for ToolSchema's recursive JSON
// value type instead of reaching into a role package or core's internals.
content = content.replace(
'} from "@modelcontextprotocol/core";',
`} from "@modelcontextprotocol/core";
import type {
CallToolResult,
ContentBlock,
EmbeddedResource,
Implementation,
RequestId,
ResourceLink,
Tool,
} from "../mcp-types.js";`,
);
const namedExternalSchemas = {
CallToolResult: ["CallToolResultSchema.describe("],
ContentBlock: ["z.array(ContentBlockSchema)"],
Implementation: ["ImplementationSchema.describe("],
RequestId: ["RequestIdSchema.optional("],
Tool: ["ToolSchema.describe("],
} as const;
for (const [type, patterns] of Object.entries(namedExternalSchemas)) {
for (const pattern of patterns) {
if (pattern.startsWith("z.array(")) {
content = content.replaceAll(
pattern,
`z.array(ContentBlockSchema as z.ZodType<${type}>)`,
);
} else {
const schema = pattern.slice(0, pattern.indexOf("."));
const method = pattern.slice(pattern.indexOf("."));
content = content.replaceAll(
pattern,
`(${schema} as z.ZodType<${type}>)${method}`,
);
}
}
}
content = content.replaceAll(
"z.union([EmbeddedResourceSchema, ResourceLinkSchema])",
"z.union([EmbeddedResourceSchema as z.ZodType<EmbeddedResource>, ResourceLinkSchema as z.ZodType<ResourceLink>])",
);

// 2. Remove z.any() placeholders for external types (now imported from MCP SDK)
for (const schema of EXTERNAL_TYPE_SCHEMAS) {
content = content.replace(
Expand Down
Loading