Skip to content

Commit 928dd83

Browse files
committed
fix: isolate ext-apps from MCP role packages
1 parent f50ec6e commit 928dd83

21 files changed

Lines changed: 1228 additions & 217 deletions

build.bun.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
#!/usr/bin/env bun
22
import { $ } from "bun";
3-
import { cpSync, mkdirSync } from "node:fs";
3+
import { cpSync, mkdirSync, rmSync } from "node:fs";
4+
5+
// Avoid publishing declarations left behind by a previous broader build.
6+
rmSync("dist", { recursive: true, force: true });
47

58
// Run TypeScript compiler for type declarations
6-
await $`tsc`;
9+
await $`tsc --noEmit`;
10+
await $`tsc -p tsconfig.build.json`;
711

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

4443
await Promise.all([
4544
buildJs("src/app.ts", {

package-lock.json

Lines changed: 0 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"prepack": "npm run build",
5555
"build:all": "npm run examples:build",
5656
"test": "bun test src examples",
57+
"test:dependency-isolation": "npm run build && node scripts/check-dependency-isolation.mjs",
5758
"test:e2e": "playwright test",
5859
"test:e2e:update": "playwright test --update-snapshots",
5960
"test:e2e:ui": "playwright test --ui",
@@ -110,9 +111,7 @@
110111
"zod": "^4.2.0"
111112
},
112113
"peerDependencies": {
113-
"@modelcontextprotocol/client": "2.0.0-beta.4",
114114
"@modelcontextprotocol/core": "2.0.0-beta.4",
115-
"@modelcontextprotocol/server": "2.0.0-beta.4",
116115
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
117116
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
118117
"zod": "^4.2.0"
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { execFileSync } from "node:child_process";
2+
import {
3+
mkdtempSync,
4+
mkdirSync,
5+
readFileSync,
6+
readdirSync,
7+
rmSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { tmpdir } from "node:os";
11+
import { join, resolve } from "node:path";
12+
13+
const root = resolve(import.meta.dirname, "..");
14+
const packageJson = JSON.parse(
15+
readFileSync(join(root, "package.json"), "utf8"),
16+
);
17+
const forbidden = [
18+
"@modelcontextprotocol/client",
19+
"@modelcontextprotocol/server",
20+
];
21+
22+
for (const dependency of forbidden) {
23+
if (packageJson.peerDependencies?.[dependency]) {
24+
throw new Error(`${dependency} must not be a published peer dependency`);
25+
}
26+
}
27+
28+
function walk(directory) {
29+
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
30+
const path = join(directory, entry.name);
31+
return entry.isDirectory() ? walk(path) : [path];
32+
});
33+
}
34+
35+
for (const file of walk(join(root, "dist", "src"))) {
36+
if (!/\.(?:js|d\.ts)$/.test(file)) continue;
37+
const contents = readFileSync(file, "utf8");
38+
for (const dependency of forbidden) {
39+
const escaped = dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40+
const importEdge = new RegExp(
41+
`(?:from\\s*["']${escaped}|import\\(["']${escaped}["']\\)|require\\(["']${escaped}["']\\))`,
42+
);
43+
if (importEdge.test(contents)) {
44+
throw new Error(
45+
`${file} has an unintended runtime or declaration edge to ${dependency}`,
46+
);
47+
}
48+
}
49+
}
50+
51+
const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-isolation-"));
52+
try {
53+
const npmEnvironment = {
54+
...process.env,
55+
npm_config_cache: join(temporaryRoot, "npm-cache"),
56+
};
57+
const packOutput = JSON.parse(
58+
execFileSync(
59+
"npm",
60+
[
61+
"pack",
62+
"--ignore-scripts",
63+
"--json",
64+
"--pack-destination",
65+
temporaryRoot,
66+
],
67+
{ cwd: root, encoding: "utf8", env: npmEnvironment },
68+
),
69+
);
70+
const tarball = join(temporaryRoot, packOutput[0].filename);
71+
72+
const consumers = [
73+
{
74+
name: "app-only",
75+
dependencies: { "@modelcontextprotocol/ext-apps": `file:${tarball}` },
76+
absent: forbidden,
77+
entry:
78+
'import { App } from "@modelcontextprotocol/ext-apps"; console.log(App);',
79+
bundleAbsent: ["@modelcontextprotocol/server"],
80+
},
81+
{
82+
name: "server-only",
83+
dependencies: {
84+
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
85+
"@modelcontextprotocol/server": "2.0.0-beta.4",
86+
},
87+
absent: ["@modelcontextprotocol/client"],
88+
entry:
89+
'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);',
90+
bundleAbsent: ["@modelcontextprotocol/client"],
91+
},
92+
];
93+
94+
for (const consumer of consumers) {
95+
const directory = join(temporaryRoot, consumer.name);
96+
mkdirSync(directory);
97+
writeFileSync(
98+
join(directory, "package.json"),
99+
JSON.stringify({ private: true, dependencies: consumer.dependencies }),
100+
);
101+
execFileSync(
102+
"npm",
103+
[
104+
"install",
105+
"--ignore-scripts",
106+
"--package-lock=false",
107+
"--no-audit",
108+
"--no-fund",
109+
],
110+
{ cwd: directory, stdio: "pipe", env: npmEnvironment },
111+
);
112+
113+
writeFileSync(join(directory, "entry.mjs"), consumer.entry);
114+
const metafile = join(directory, "bundle-meta.json");
115+
execFileSync(
116+
join(root, "node_modules", ".bin", "esbuild"),
117+
[
118+
"entry.mjs",
119+
"--bundle",
120+
"--platform=browser",
121+
"--outfile=bundle.js",
122+
`--metafile=${metafile}`,
123+
],
124+
{ cwd: directory, stdio: "pipe" },
125+
);
126+
const bundleInputs = Object.keys(
127+
JSON.parse(readFileSync(metafile, "utf8")).inputs,
128+
).join("\n");
129+
for (const dependency of consumer.bundleAbsent) {
130+
if (bundleInputs.includes(`/node_modules/${dependency}/`)) {
131+
throw new Error(`${consumer.name} unexpectedly bundled ${dependency}`);
132+
}
133+
}
134+
135+
for (const dependency of consumer.absent) {
136+
const packagePath = join(
137+
directory,
138+
"node_modules",
139+
...dependency.split("/"),
140+
);
141+
try {
142+
readFileSync(join(packagePath, "package.json"));
143+
throw new Error(
144+
`${consumer.name} unexpectedly installed ${dependency}`,
145+
);
146+
} catch (error) {
147+
if (error?.code !== "ENOENT") throw error;
148+
}
149+
}
150+
}
151+
} finally {
152+
rmSync(temporaryRoot, { recursive: true, force: true });
153+
}
154+
155+
console.log("Dependency isolation checks passed.");

scripts/generate-schemas.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,50 @@ import {
196196
} from "@modelcontextprotocol/core";`,
197197
);
198198

199+
// Give declaration emit a stable local name for ToolSchema's recursive JSON
200+
// value type instead of reaching into a role package or core's internals.
201+
content = content.replace(
202+
'} from "@modelcontextprotocol/core";',
203+
`} from "@modelcontextprotocol/core";
204+
import type {
205+
CallToolResult,
206+
ContentBlock,
207+
EmbeddedResource,
208+
Implementation,
209+
RequestId,
210+
ResourceLink,
211+
Tool,
212+
} from "../mcp-types.js";`,
213+
);
214+
const namedExternalSchemas = {
215+
CallToolResult: ["CallToolResultSchema.describe("],
216+
ContentBlock: ["z.array(ContentBlockSchema)"],
217+
Implementation: ["ImplementationSchema.describe("],
218+
RequestId: ["RequestIdSchema.optional("],
219+
Tool: ["ToolSchema.describe("],
220+
} as const;
221+
for (const [type, patterns] of Object.entries(namedExternalSchemas)) {
222+
for (const pattern of patterns) {
223+
if (pattern.startsWith("z.array(")) {
224+
content = content.replaceAll(
225+
pattern,
226+
`z.array(ContentBlockSchema as z.ZodType<${type}>)`,
227+
);
228+
} else {
229+
const schema = pattern.slice(0, pattern.indexOf("."));
230+
const method = pattern.slice(pattern.indexOf("."));
231+
content = content.replaceAll(
232+
pattern,
233+
`(${schema} as z.ZodType<${type}>)${method}`,
234+
);
235+
}
236+
}
237+
}
238+
content = content.replaceAll(
239+
"z.union([EmbeddedResourceSchema, ResourceLinkSchema])",
240+
"z.union([EmbeddedResourceSchema as z.ZodType<EmbeddedResource>, ResourceLinkSchema as z.ZodType<ResourceLink>])",
241+
);
242+
199243
// 2. Remove z.any() placeholders for external types (now imported from MCP SDK)
200244
for (const schema of EXTERNAL_TYPE_SCHEMAS) {
201245
content = content.replace(

0 commit comments

Comments
 (0)