test: isolate optional SDK role dependencies
This commit is contained in:
parent
43eed45404
commit
7664bb1010
11 changed files with 209 additions and 9 deletions
|
|
@ -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
6
package-lock.json
generated
|
|
@ -60,6 +60,12 @@
|
|||
"zod": "^4.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@modelcontextprotocol/client": {
|
||||
"optional": true
|
||||
},
|
||||
"@modelcontextprotocol/server": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
159
scripts/check-dependency-isolation.mjs
Normal file
159
scripts/check-dependency-isolation.mjs
Normal 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.");
|
||||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
5
src/constants.ts
Normal 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
20
src/core-types.ts
Normal 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>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type {
|
|||
RequestId,
|
||||
ResourceLink,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
} from "./core-types";
|
||||
|
||||
/**
|
||||
* Current protocol version supported by this SDK.
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue