Skip to content

Commit 7664bb1

Browse files
committed
test: isolate optional SDK role dependencies
1 parent 43eed45 commit 7664bb1

11 files changed

Lines changed: 209 additions & 9 deletions

build.bun.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
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 artifacts left behind by an earlier build or branch.
6+
rmSync("dist", { recursive: true, force: true });
47

58
// Run TypeScript compiler for type declarations
69
await $`tsc`;

package-lock.json

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

package.json

Lines changed: 7 additions & 0 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",
@@ -118,6 +119,12 @@
118119
"zod": "^4.2.0"
119120
},
120121
"peerDependenciesMeta": {
122+
"@modelcontextprotocol/client": {
123+
"optional": true
124+
},
125+
"@modelcontextprotocol/server": {
126+
"optional": true
127+
},
121128
"react": {
122129
"optional": true
123130
},
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { execFileSync } from "node:child_process";
2+
import {
3+
mkdtempSync,
4+
mkdirSync,
5+
readFileSync,
6+
rmSync,
7+
writeFileSync,
8+
} from "node:fs";
9+
import { tmpdir } from "node:os";
10+
import { join, resolve } from "node:path";
11+
12+
const root = resolve(import.meta.dirname, "..");
13+
const packageJson = JSON.parse(
14+
readFileSync(join(root, "package.json"), "utf8"),
15+
);
16+
const client = "@modelcontextprotocol/client";
17+
const server = "@modelcontextprotocol/server";
18+
19+
for (const role of [client, server]) {
20+
if (!packageJson.peerDependencies?.[role]) {
21+
throw new Error(`${role} must remain a peer dependency`);
22+
}
23+
if (packageJson.peerDependenciesMeta?.[role]?.optional !== true) {
24+
throw new Error(`${role} must be an optional peer dependency`);
25+
}
26+
}
27+
28+
const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
29+
try {
30+
const npmEnvironment = {
31+
...process.env,
32+
npm_config_cache: join(temporaryRoot, "npm-cache"),
33+
};
34+
const packOutput = JSON.parse(
35+
execFileSync(
36+
"npm",
37+
[
38+
"pack",
39+
"--ignore-scripts",
40+
"--json",
41+
"--pack-destination",
42+
temporaryRoot,
43+
],
44+
{ cwd: root, encoding: "utf8", env: npmEnvironment },
45+
),
46+
);
47+
const tarball = join(temporaryRoot, packOutput[0].filename);
48+
49+
const consumers = [
50+
{
51+
name: "app-only",
52+
dependencies: {
53+
"@types/node": packageJson.devDependencies["@types/node"],
54+
[client]: packageJson.devDependencies[client],
55+
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
56+
},
57+
absent: server,
58+
entry:
59+
'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; console.log(App, AppBridge);',
60+
},
61+
{
62+
name: "server-only",
63+
dependencies: {
64+
"@types/node": packageJson.devDependencies["@types/node"],
65+
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
66+
[server]: packageJson.devDependencies[server],
67+
},
68+
absent: client,
69+
entry:
70+
'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);',
71+
},
72+
];
73+
74+
for (const consumer of consumers) {
75+
const directory = join(temporaryRoot, consumer.name);
76+
mkdirSync(directory);
77+
writeFileSync(
78+
join(directory, "package.json"),
79+
JSON.stringify({
80+
private: true,
81+
type: "module",
82+
dependencies: consumer.dependencies,
83+
}),
84+
);
85+
execFileSync(
86+
"npm",
87+
[
88+
"install",
89+
"--ignore-scripts",
90+
"--package-lock=false",
91+
"--no-audit",
92+
"--no-fund",
93+
],
94+
{ cwd: directory, stdio: "pipe", env: npmEnvironment },
95+
);
96+
97+
const absentPath = join(
98+
directory,
99+
"node_modules",
100+
...consumer.absent.split("/"),
101+
"package.json",
102+
);
103+
try {
104+
readFileSync(absentPath);
105+
throw new Error(
106+
`${consumer.name} unexpectedly installed ${consumer.absent}`,
107+
);
108+
} catch (error) {
109+
if (error?.code !== "ENOENT") throw error;
110+
}
111+
112+
writeFileSync(join(directory, "entry.ts"), consumer.entry);
113+
writeFileSync(
114+
join(directory, "tsconfig.json"),
115+
JSON.stringify({
116+
compilerOptions: {
117+
lib: ["ES2020", "DOM"],
118+
module: "ESNext",
119+
moduleResolution: "bundler",
120+
noEmit: true,
121+
skipLibCheck: false,
122+
strict: true,
123+
target: "ES2020",
124+
},
125+
include: ["entry.ts"],
126+
}),
127+
);
128+
execFileSync(
129+
process.execPath,
130+
[join(root, "node_modules", "typescript", "bin", "tsc")],
131+
{ cwd: directory, stdio: "inherit" },
132+
);
133+
134+
const metafile = join(directory, "bundle-meta.json");
135+
execFileSync(
136+
join(root, "node_modules", ".bin", "esbuild"),
137+
[
138+
"entry.ts",
139+
"--bundle",
140+
"--platform=browser",
141+
"--outfile=bundle.js",
142+
`--metafile=${metafile}`,
143+
],
144+
{ cwd: directory, stdio: "pipe" },
145+
);
146+
const bundleInputs = Object.keys(
147+
JSON.parse(readFileSync(metafile, "utf8")).inputs,
148+
).join("\n");
149+
if (bundleInputs.includes(`/node_modules/${consumer.absent}/`)) {
150+
throw new Error(
151+
`${consumer.name} unexpectedly bundled ${consumer.absent}`,
152+
);
153+
}
154+
}
155+
} finally {
156+
rmSync(temporaryRoot, { recursive: true, force: true });
157+
}
158+
159+
console.log("Role peer dependency isolation checks passed.");

src/app-bridge.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ import {
8989
McpUiToolMeta,
9090
} from "./types";
9191
export * from "./types";
92-
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app";
93-
import { RESOURCE_URI_META_KEY } from "./app";
92+
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./constants";
93+
import { RESOURCE_URI_META_KEY } from "./constants";
9494
export { PostMessageTransport } from "./message-transport";
9595

9696
/**

src/app.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type Transport,
2323
} from "@modelcontextprotocol/client";
2424
import { EmptyResultSchema } from "@modelcontextprotocol/core";
25+
export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants";
2526
import { EventDispatcher } from "./events";
2627
export { EventDispatcher } from "./events";
2728
import { PostMessageTransport } from "./message-transport";
@@ -158,7 +159,6 @@ export {
158159
* }
159160
* ```
160161
*/
161-
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
162162

163163
/**
164164
* MIME type for MCP UI resources.
@@ -167,7 +167,6 @@ export const RESOURCE_URI_META_KEY = "ui/resourceUri";
167167
*
168168
* Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources.
169169
*/
170-
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
171170

172171
/**
173172
* Options for configuring {@link App `App`} behavior.

src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** Legacy metadata key associating an MCP tool with its App resource. */
2+
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
3+
4+
/** MIME type for MCP App HTML resources. */
5+
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";

src/core-types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {
2+
CallToolResultSchema,
3+
ContentBlockSchema,
4+
EmbeddedResourceSchema,
5+
ImplementationSchema,
6+
RequestIdSchema,
7+
ResourceLinkSchema,
8+
ToolSchema,
9+
} from "@modelcontextprotocol/core";
10+
import type { z } from "zod/v4";
11+
12+
// Infer shared wire types from the public role-neutral schemas so declarations
13+
// used by every entrypoint do not acquire a client or server package edge.
14+
export type CallToolResult = z.infer<typeof CallToolResultSchema>;
15+
export type ContentBlock = z.infer<typeof ContentBlockSchema>;
16+
export type EmbeddedResource = z.infer<typeof EmbeddedResourceSchema>;
17+
export type Implementation = z.infer<typeof ImplementationSchema>;
18+
export type RequestId = z.infer<typeof RequestIdSchema>;
19+
export type ResourceLink = z.infer<typeof ResourceLinkSchema>;
20+
export type Tool = z.infer<typeof ToolSchema>;

src/server/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,12 @@
3232
*/
3333

3434
import {
35-
RESOURCE_URI_META_KEY,
36-
RESOURCE_MIME_TYPE,
3735
McpUiResourceCsp,
3836
McpUiResourceMeta,
3937
McpUiToolMeta,
4038
McpUiClientCapabilities,
41-
} from "../app.js";
39+
} from "../spec.types.js";
40+
import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js";
4241
import type {
4342
ClientCapabilities,
4443
McpServer,

src/spec.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
RequestId,
1919
ResourceLink,
2020
Tool,
21-
} from "@modelcontextprotocol/client";
21+
} from "./core-types";
2222

2323
/**
2424
* Current protocol version supported by this SDK.

0 commit comments

Comments
 (0)