From faf8678816fec65b89c6f6e7cbf0674b30547618 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 15 May 2026 16:28:30 -0400 Subject: [PATCH 1/3] fix: harden monorepo package entrypoints --- .../fix-monorepo-package-entrypoints.md | 7 ++ packages/benchmarks/test/benchmark.test.ts | 3 +- packages/benchmarks/vitest.config.ts | 10 ++ packages/cli/rolldown.config.ts | 2 +- packages/cli/src/index.ts | 5 +- packages/cli/test/packaging.test.ts | 19 ++++ packages/core/src/auth.ts | 4 +- packages/core/src/cli.ts | 3 +- packages/core/src/runtime.ts | 2 +- packages/core/test/auth.test.ts | 4 +- packages/core/test/package-boundaries.test.ts | 95 +++++++++++++++++++ packages/core/test/runtime.test.ts | 2 +- packages/opencode/src/hooks.ts | 40 ++++++++ packages/opencode/src/index.ts | 43 +-------- packages/opencode/test/opencode.test.ts | 16 +++- packages/pi/test/pi.test.ts | 6 ++ 16 files changed, 203 insertions(+), 58 deletions(-) create mode 100644 .changeset/fix-monorepo-package-entrypoints.md create mode 100644 packages/benchmarks/vitest.config.ts create mode 100644 packages/cli/test/packaging.test.ts create mode 100644 packages/core/test/package-boundaries.test.ts create mode 100644 packages/opencode/src/hooks.ts diff --git a/.changeset/fix-monorepo-package-entrypoints.md b/.changeset/fix-monorepo-package-entrypoints.md new file mode 100644 index 00000000..44d6c00a --- /dev/null +++ b/.changeset/fix-monorepo-package-entrypoints.md @@ -0,0 +1,7 @@ +--- +"caplets": patch +"@caplets/core": patch +"@caplets/opencode": patch +--- + +Fix monorepo package entrypoints so the CLI resolves MCP SDK subpaths on Node ESM, reports the CLI package version, and the OpenCode plugin exposes only its default plugin export. diff --git a/packages/benchmarks/test/benchmark.test.ts b/packages/benchmarks/test/benchmark.test.ts index 474d55a3..39e47ef2 100644 --- a/packages/benchmarks/test/benchmark.test.ts +++ b/packages/benchmarks/test/benchmark.test.ts @@ -3,8 +3,7 @@ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import { parseConfig } from "../../core/src/config.js"; -import { capabilityDescription, ServerRegistry } from "../../core/src/registry.js"; +import { capabilityDescription, parseConfig, ServerRegistry } from "@caplets/core"; import { PROCESS_TERMINATION_BEHAVIOR, parseJsonEvents, diff --git a/packages/benchmarks/vitest.config.ts b/packages/benchmarks/vitest.config.ts new file mode 100644 index 00000000..75063b79 --- /dev/null +++ b/packages/benchmarks/vitest.config.ts @@ -0,0 +1,10 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@caplets/core": resolve(import.meta.dirname, "../core/src/index.ts"), + }, + }, +}); diff --git a/packages/cli/rolldown.config.ts b/packages/cli/rolldown.config.ts index 1c45a57a..209b4d2b 100644 --- a/packages/cli/rolldown.config.ts +++ b/packages/cli/rolldown.config.ts @@ -8,5 +8,5 @@ export default defineConfig({ banner: "#!/usr/bin/env node", }, platform: "node", - external: ["@caplets/core", "@modelcontextprotocol/sdk/server/stdio"], + external: ["@caplets/core", "@modelcontextprotocol/sdk/server/stdio.js"], }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ad4a7715..a3dd401b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,9 +1,10 @@ -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CapletsRuntime, runCli } from "@caplets/core"; +import { version as packageVersion } from "../package.json"; async function main() { if (process.argv[2] && process.argv[2] !== "serve") { - await runCli(process.argv.slice(2)); + await runCli(process.argv.slice(2), { version: packageVersion }); return; } diff --git a/packages/cli/test/packaging.test.ts b/packages/cli/test/packaging.test.ts new file mode 100644 index 00000000..a7561b69 --- /dev/null +++ b/packages/cli/test/packaging.test.ts @@ -0,0 +1,19 @@ +import { execFile } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { version as packageVersion } from "../package.json"; + +const execFileAsync = promisify(execFile); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +describe("caplets package entrypoint", () => { + it("can start far enough to print its version from the built bin", async () => { + const { stdout } = await execFileAsync(process.execPath, ["dist/index.js", "--version"], { + cwd: packageRoot, + }); + + expect(stdout.trim()).toBe(packageVersion); + }); +}); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 26dc6106..9e08b8e4 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -5,12 +5,12 @@ import { type AuthResult, extractWWWAuthenticateParams, type OAuthClientProvider, -} from "@modelcontextprotocol/sdk/client/auth"; +} from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens, -} from "@modelcontextprotocol/sdk/shared/auth"; +} from "@modelcontextprotocol/sdk/shared/auth.js"; import { isTokenBundleExpired, readTokenBundle, diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index dfc12787..34adb876 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -38,6 +38,7 @@ type CliIO = { writeOut?: (value: string) => void; writeErr?: (value: string) => void; authDir?: string; + version?: string; }; export async function runCli(args: string[], io: CliIO = {}): Promise { @@ -63,7 +64,7 @@ export function createProgram(io: CliIO = {}): Command { program .name("caplets") .description("Progressive-disclosure gateway for MCP servers.") - .version(packageJsonVersion) + .version(io.version ?? packageJsonVersion) .exitOverride() .configureOutput({ writeOut, diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 34abbeee..37a392cd 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,4 +1,4 @@ -import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; +import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { version as packageJsonVersion } from "../package.json"; import { type CapletConfig, type CapletsConfig } from "./config.js"; diff --git a/packages/core/test/auth.test.ts b/packages/core/test/auth.test.ts index bb74edd2..6d491bb0 100644 --- a/packages/core/test/auth.test.ts +++ b/packages/core/test/auth.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; const mockMcpAuth = vi.hoisted(() => vi.fn()); -vi.mock("@modelcontextprotocol/sdk/client/auth", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => ({ + ...(await importOriginal()), auth: mockMcpAuth, })); diff --git a/packages/core/test/package-boundaries.test.ts b/packages/core/test/package-boundaries.test.ts new file mode 100644 index 00000000..77e16a15 --- /dev/null +++ b/packages/core/test/package-boundaries.test.ts @@ -0,0 +1,95 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { relative, resolve, sep } from "node:path"; +import { describe, expect, it } from "vitest"; +import corePackage from "../package.json"; + +const repoRoot = resolve(import.meta.dirname, "../../.."); +const packagesRoot = resolve(repoRoot, "packages"); + +const scannedExtensions = new Set([".ts", ".mjs"]); +const ignoredDirectories = new Set(["dist", "dist-schema", "node_modules"]); + +describe("package boundaries", () => { + it("uses Node ESM-safe MCP SDK subpath imports", () => { + const violations = scanFiles([packagesRoot]).flatMap((filePath) => { + const source = readFileSync(filePath, "utf8"); + return Array.from(source.matchAll(/["'](@modelcontextprotocol\/sdk\/[^"']+)["']/g)) + .map((match) => match[1]) + .filter((specifier): specifier is string => Boolean(specifier)) + .filter((specifier) => !specifier.endsWith(".js")) + .map((specifier) => `${formatPath(filePath)} imports ${specifier}`); + }); + + expect(violations).toEqual([]); + }); + + it("imports workspace packages through declared package exports", () => { + const exportedCoreSpecifiers = new Set( + Object.keys(corePackage.exports).map((specifier) => + specifier === "." ? "@caplets/core" : `@caplets/core/${specifier.slice(2)}`, + ), + ); + const violations = scanFiles([packagesRoot]).flatMap((filePath) => { + const packageName = packageNameForFile(filePath); + const source = readFileSync(filePath, "utf8"); + const specifiers = importSpecifiers(source); + const relativeCrossPackageImports = specifiers + .filter((specifier) => /^\.\.\/(core|cli|opencode|pi|benchmarks)(?:\/|$)/.test(specifier)) + .map((specifier) => `${formatPath(filePath)} imports ${specifier}`); + const undeclaredCoreExports = specifiers + .filter((specifier) => specifier.startsWith("@caplets/core")) + .filter((specifier) => packageName !== "core" && !exportedCoreSpecifiers.has(specifier)) + .map((specifier) => `${formatPath(filePath)} imports undeclared export ${specifier}`); + + return [...relativeCrossPackageImports, ...undeclaredCoreExports]; + }); + + expect(violations).toEqual([]); + }); +}); + +function scanFiles(roots: string[]): string[] { + const files: string[] = []; + for (const root of roots) { + collectFiles(root, files); + } + return files; +} + +function collectFiles(directory: string, files: string[]): void { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + if (!ignoredDirectories.has(entry.name)) { + collectFiles(path, files); + } + continue; + } + if ([...scannedExtensions].some((extension) => entry.name.endsWith(extension))) { + files.push(path); + } + } +} + +function importSpecifiers(source: string): string[] { + const specifiers: string[] = []; + for (const match of source.matchAll(/\bfrom\s+["']([^"']+)["']/g)) { + if (match[1]) specifiers.push(match[1]); + } + for (const match of source.matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g)) { + if (match[1]) specifiers.push(match[1]); + } + for (const match of source.matchAll(/\bvi\.mock\(\s*["']([^"']+)["']/g)) { + if (match[1]) specifiers.push(match[1]); + } + return specifiers; +} + +function packageNameForFile(filePath: string): string | undefined { + const parts = relative(packagesRoot, filePath).split(sep); + return parts[0]; +} + +function formatPath(filePath: string): string { + return relative(repoRoot, filePath).split(sep).join("/"); +} diff --git a/packages/core/test/runtime.test.ts b/packages/core/test/runtime.test.ts index e8359ee3..f78850c6 100644 --- a/packages/core/test/runtime.test.ts +++ b/packages/core/test/runtime.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp"; +import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { nativeCapletToolName } from "../src/native.js"; import { CapletsRuntime } from "../src/runtime.js"; diff --git a/packages/opencode/src/hooks.ts b/packages/opencode/src/hooks.ts new file mode 100644 index 00000000..05c0c0b1 --- /dev/null +++ b/packages/opencode/src/hooks.ts @@ -0,0 +1,40 @@ +import { tool, type Hooks } from "@opencode-ai/plugin"; +import { nativeCapletsSystemGuidance, type NativeCapletsService } from "@caplets/core/native"; +import { capletsOpenCodeArgs } from "./schema.js"; + +export async function createCapletsOpenCodeHooks(service: NativeCapletsService): Promise { + const capletTools = service.listTools(); + const registeredToolNames = new Set(capletTools.map((caplet) => caplet.toolName)); + + return { + tool: Object.fromEntries( + capletTools.map((caplet) => [ + caplet.toolName, + tool({ + description: caplet.description, + args: capletsOpenCodeArgs(), + async execute(args) { + const result = await service.execute(caplet.caplet, args); + if (typeof result === "string") return result; + try { + return JSON.stringify(result, null, 2) ?? "null"; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `[Serialization error: ${message}]`; + } + }, + }), + ]), + ), + "experimental.chat.system.transform": async (_input, output) => { + output.system.push( + nativeCapletsSystemGuidance( + service + .listTools() + .map((caplet) => caplet.toolName) + .filter((toolName) => registeredToolNames.has(toolName)), + ), + ); + }, + }; +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 99013277..7166b8e5 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,11 +1,9 @@ -import { tool, type Hooks, type Plugin, type PluginInput } from "@opencode-ai/plugin"; +import { type Plugin, type PluginInput } from "@opencode-ai/plugin"; import { createNativeCapletsService, - nativeCapletsSystemGuidance, registerNativeCapletsProcessCleanup, - type NativeCapletsService, } from "@caplets/core/native"; -import { capletsOpenCodeArgs } from "./schema.js"; +import { createCapletsOpenCodeHooks } from "./hooks.js"; const plugin: Plugin = async (_ctx: PluginInput) => { const service = createNativeCapletsService(); @@ -13,41 +11,4 @@ const plugin: Plugin = async (_ctx: PluginInput) => { return createCapletsOpenCodeHooks(service); }; -export async function createCapletsOpenCodeHooks(service: NativeCapletsService): Promise { - const capletTools = service.listTools(); - const registeredToolNames = new Set(capletTools.map((caplet) => caplet.toolName)); - - return { - tool: Object.fromEntries( - capletTools.map((caplet) => [ - caplet.toolName, - tool({ - description: caplet.description, - args: capletsOpenCodeArgs(), - async execute(args) { - const result = await service.execute(caplet.caplet, args); - if (typeof result === "string") return result; - try { - return JSON.stringify(result, null, 2) ?? "null"; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return `[Serialization error: ${message}]`; - } - }, - }), - ]), - ), - "experimental.chat.system.transform": async (_input, output) => { - output.system.push( - nativeCapletsSystemGuidance( - service - .listTools() - .map((caplet) => caplet.toolName) - .filter((toolName) => registeredToolNames.has(toolName)), - ), - ); - }, - }; -} - export default plugin; diff --git a/packages/opencode/test/opencode.test.ts b/packages/opencode/test/opencode.test.ts index 07f859c6..a10f72ba 100644 --- a/packages/opencode/test/opencode.test.ts +++ b/packages/opencode/test/opencode.test.ts @@ -16,8 +16,14 @@ vi.mock("@opencode-ai/plugin", () => ({ })); describe("@caplets/opencode", () => { + it("only exposes the default plugin function from the package entrypoint", async () => { + const exports = await import("../src/index.js"); + + expect(Object.keys(exports).sort()).toEqual(["default"]); + }); + it("registers one prefixed native tool per Caplet", async () => { - const { createCapletsOpenCodeHooks } = await import("../src/index.js"); + const { createCapletsOpenCodeHooks } = await import("../src/hooks.js"); const service = { listTools: () => [ { @@ -50,7 +56,7 @@ describe("@caplets/opencode", () => { }); it("returns stable text when tool result serialization fails", async () => { - const { createCapletsOpenCodeHooks } = await import("../src/index.js"); + const { createCapletsOpenCodeHooks } = await import("../src/hooks.js"); const service = { listTools: () => [ { @@ -79,7 +85,7 @@ describe("@caplets/opencode", () => { }); it("returns stable text when JSON.stringify returns undefined", async () => { - const { createCapletsOpenCodeHooks } = await import("../src/index.js"); + const { createCapletsOpenCodeHooks } = await import("../src/hooks.js"); const service = { listTools: () => [ { @@ -107,7 +113,7 @@ describe("@caplets/opencode", () => { }); it("refreshes system guidance for remaining registered native tools", async () => { - const { createCapletsOpenCodeHooks } = await import("../src/index.js"); + const { createCapletsOpenCodeHooks } = await import("../src/hooks.js"); let tools = [ { caplet: "git-hub", @@ -151,7 +157,7 @@ describe("@caplets/opencode", () => { }); it("does not advertise newly added unregistered native tools", async () => { - const { createCapletsOpenCodeHooks } = await import("../src/index.js"); + const { createCapletsOpenCodeHooks } = await import("../src/hooks.js"); let tools = [ { caplet: "git-hub", diff --git a/packages/pi/test/pi.test.ts b/packages/pi/test/pi.test.ts index abe3323c..e42233b3 100644 --- a/packages/pi/test/pi.test.ts +++ b/packages/pi/test/pi.test.ts @@ -43,6 +43,12 @@ type MockService = NativeCapletsService & { }; describe("@caplets/pi", () => { + it("only exposes the default extension function from the package entrypoint", async () => { + const exports = await import("../src/index.js"); + + expect(Object.keys(exports).sort()).toEqual(["default"]); + }); + it("registers prefixed native tools with explicit prompt guidance", async () => { const service = mockService([ { From e41b305887c03630816c89d0c597bc3c58b91c3d Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 15 May 2026 16:35:20 -0400 Subject: [PATCH 2/3] test: build cli before packaging smoke test --- packages/cli/test/packaging.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/test/packaging.test.ts b/packages/cli/test/packaging.test.ts index a7561b69..0d243963 100644 --- a/packages/cli/test/packaging.test.ts +++ b/packages/cli/test/packaging.test.ts @@ -10,6 +10,8 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); describe("caplets package entrypoint", () => { it("can start far enough to print its version from the built bin", async () => { + await execFileAsync("pnpm", ["build"], { cwd: packageRoot }); + const { stdout } = await execFileAsync(process.execPath, ["dist/index.js", "--version"], { cwd: packageRoot, }); From cac1289b475a5d5e5ba9043b030301e39643da8f Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 15 May 2026 16:38:12 -0400 Subject: [PATCH 3/3] test: build core before cli packaging smoke test --- packages/cli/test/packaging.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/test/packaging.test.ts b/packages/cli/test/packaging.test.ts index 0d243963..3e556c86 100644 --- a/packages/cli/test/packaging.test.ts +++ b/packages/cli/test/packaging.test.ts @@ -7,9 +7,11 @@ import { version as packageVersion } from "../package.json"; const execFileAsync = promisify(execFile); const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(packageRoot, "../.."); describe("caplets package entrypoint", () => { it("can start far enough to print its version from the built bin", async () => { + await execFileAsync("pnpm", ["--filter", "@caplets/core", "build"], { cwd: repoRoot }); await execFileAsync("pnpm", ["build"], { cwd: packageRoot }); const { stdout } = await execFileAsync(process.execPath, ["dist/index.js", "--version"], {