test: isolate optional SDK role dependencies

This commit is contained in:
Enrico 2026-07-20 11:29:43 -07:00
commit 7664bb1010
11 changed files with 209 additions and 9 deletions

View file

@ -1,6 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun";
import { cpSync, mkdirSync } from "node:fs";
import { cpSync, mkdirSync, rmSync } from "node:fs";
// Avoid publishing artifacts left behind by an earlier build or branch.
rmSync("dist", { recursive: true, force: true });
// Run TypeScript compiler for type declarations
await $`tsc`;

6
package-lock.json generated
View file

@ -60,6 +60,12 @@
"zod": "^4.2.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/client": {
"optional": true
},
"@modelcontextprotocol/server": {
"optional": true
},
"react": {
"optional": true
},

View file

@ -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",
@ -118,6 +119,12 @@
"zod": "^4.2.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/client": {
"optional": true
},
"@modelcontextprotocol/server": {
"optional": true
},
"react": {
"optional": true
},

View file

@ -0,0 +1,159 @@
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
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";
for (const role of [client, server]) {
if (!packageJson.peerDependencies?.[role]) {
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`);
}
}
const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
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: {
"@types/node": packageJson.devDependencies["@types/node"],
[client]: packageJson.devDependencies[client],
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
},
absent: server,
entry:
'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; console.log(App, AppBridge);',
},
{
name: "server-only",
dependencies: {
"@types/node": packageJson.devDependencies["@types/node"],
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
[server]: packageJson.devDependencies[server],
},
absent: client,
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: "pipe", env: npmEnvironment },
);
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: {
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: "pipe" },
);
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.");

View file

@ -89,8 +89,8 @@ import {
McpUiToolMeta,
} from "./types";
export * from "./types";
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./app";
import { RESOURCE_URI_META_KEY } from "./app";
export { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "./constants";
import { RESOURCE_URI_META_KEY } from "./constants";
export { PostMessageTransport } from "./message-transport";
/**

View file

@ -22,6 +22,7 @@ import {
type Transport,
} from "@modelcontextprotocol/client";
import { EmptyResultSchema } from "@modelcontextprotocol/core";
export { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./constants";
import { EventDispatcher } from "./events";
export { EventDispatcher } from "./events";
import { PostMessageTransport } from "./message-transport";
@ -158,7 +159,6 @@ export {
* }
* ```
*/
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
/**
* MIME type for MCP UI resources.
@ -167,7 +167,6 @@ export const RESOURCE_URI_META_KEY = "ui/resourceUri";
*
* Used by {@link server-helpers!registerAppResource `registerAppResource`} as the default MIME type for app resources.
*/
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
/**
* Options for configuring {@link App `App`} behavior.

5
src/constants.ts Normal file
View file

@ -0,0 +1,5 @@
/** Legacy metadata key associating an MCP tool with its App resource. */
export const RESOURCE_URI_META_KEY = "ui/resourceUri";
/** MIME type for MCP App HTML resources. */
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";

20
src/core-types.ts Normal file
View file

@ -0,0 +1,20 @@
import {
CallToolResultSchema,
ContentBlockSchema,
EmbeddedResourceSchema,
ImplementationSchema,
RequestIdSchema,
ResourceLinkSchema,
ToolSchema,
} from "@modelcontextprotocol/core";
import type { z } from "zod/v4";
// Infer shared wire types from the public role-neutral schemas so declarations
// used by every entrypoint do not acquire a client or server package edge.
export type CallToolResult = z.infer<typeof CallToolResultSchema>;
export type ContentBlock = z.infer<typeof ContentBlockSchema>;
export type EmbeddedResource = z.infer<typeof EmbeddedResourceSchema>;
export type Implementation = z.infer<typeof ImplementationSchema>;
export type RequestId = z.infer<typeof RequestIdSchema>;
export type ResourceLink = z.infer<typeof ResourceLinkSchema>;
export type Tool = z.infer<typeof ToolSchema>;

View file

@ -32,13 +32,12 @@
*/
import {
RESOURCE_URI_META_KEY,
RESOURCE_MIME_TYPE,
McpUiResourceCsp,
McpUiResourceMeta,
McpUiToolMeta,
McpUiClientCapabilities,
} from "../app.js";
} from "../spec.types.js";
import { RESOURCE_URI_META_KEY, RESOURCE_MIME_TYPE } from "../constants.js";
import type {
ClientCapabilities,
McpServer,

View file

@ -18,7 +18,7 @@ import type {
RequestId,
ResourceLink,
Tool,
} from "@modelcontextprotocol/client";
} from "./core-types";
/**
* Current protocol version supported by this SDK.

View file

@ -36,12 +36,14 @@ const config = {
ClientOptions: BASE_SDK_DOCS,
ConnectOptions: BASE_SDK_DOCS,
DiscoverResult: BASE_SDK_DOCS,
"MessageExtraInfo.classification": BASE_SDK_DOCS,
ProtocolError: BASE_SDK_DOCS,
"Protocol.close": BASE_SDK_DOCS,
"Protocol.notification": BASE_SDK_DOCS,
ResponseCacheStore: BASE_SDK_DOCS,
SdkError: BASE_SDK_DOCS,
"SdkErrorCode.MethodNotSupportedByProtocolVersion": BASE_SDK_DOCS,
"SdkErrorCode.UnsupportedResultType": BASE_SDK_DOCS,
"__type.enforceStrictCapabilities": BASE_SDK_DOCS,
},
"@modelcontextprotocol/server": {