Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-monorepo-package-entrypoints.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 1 addition & 2 deletions packages/benchmarks/test/benchmark.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/benchmarks/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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"),
},
},
});
2 changes: 1 addition & 1 deletion packages/cli/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
5 changes: 3 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand Down
23 changes: 23 additions & 0 deletions packages/cli/test/packaging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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)), "..");
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"], {
cwd: packageRoot,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(stdout.trim()).toBe(packageVersion);
});
Comment on lines +13 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Build test will time out with default vitest timeout

This test runs two sequential pnpm build invocations before asserting, but there is no vitest.config.ts in packages/cli and no per-test timeout override. Vitest's default testTimeout is 5 000 ms; on a cold CI runner, building @caplets/core alone typically takes longer than that, so the test will almost always time out before the assertion is reached. Add a timeout argument to it() or create a vitest.config.ts that sets test.testTimeout to a value that covers both builds (e.g. 120 000 ms).

Suggested change
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"], {
cwd: packageRoot,
});
expect(stdout.trim()).toBe(packageVersion);
});
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"], {
cwd: packageRoot,
});
expect(stdout.trim()).toBe(packageVersion);
}, 120_000);

Fix in Codex

});
4 changes: 2 additions & 2 deletions packages/core/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@modelcontextprotocol/sdk/client/auth")>()),
vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => ({
...(await importOriginal<typeof import("@modelcontextprotocol/sdk/client/auth.js")>()),
auth: mockMcpAuth,
}));

Expand Down
95 changes: 95 additions & 0 deletions packages/core/test/package-boundaries.test.ts
Original file line number Diff line number Diff line change
@@ -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}`);
Comment on lines +37 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Relative cross-package import detection misses nested traversals.

Line 37 only matches a single ../, so imports like ../../core/src/... bypass this guard and won’t be reported.

Proposed fix
-        .filter((specifier) => /^\.\.\/(core|cli|opencode|pi|benchmarks)(?:\/|$)/.test(specifier))
+        .filter((specifier) =>
+          /^(\.\.\/)+(core|cli|opencode|pi|benchmarks)(?:\/|$)/.test(specifier),
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.filter((specifier) => /^\.\.\/(core|cli|opencode|pi|benchmarks)(?:\/|$)/.test(specifier))
.map((specifier) => `${formatPath(filePath)} imports ${specifier}`);
.filter((specifier) =>
/^(\.\.\/)+(core|cli|opencode|pi|benchmarks)(?:\/|$)/.test(specifier)
)
.map((specifier) => `${formatPath(filePath)} imports ${specifier}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/package-boundaries.test.ts` around lines 37 - 38, The
current filter's regex only matches a single parent traversal and misses nested
traversals; update the filter in package-boundaries.test.ts so the regex accepts
one or more "../" segments (for example using a repetition for the `../` prefix)
before matching package names (core|cli|opencode|pi|benchmarks), so imports like
"../../core/..." are detected; adjust the pattern used in the
.filter((specifier) => ...) call that produces `${formatPath(filePath)} imports
${specifier}` accordingly.

Comment on lines +37 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Boundary guard misses multi-level relative imports

The regex /^\.\.\/(core|cli|opencode|pi|benchmarks)(?:\/|$)/ only matches paths that go up exactly one level (../core/...). It will silently miss the ../../core/... pattern — which is precisely what the benchmark test file used before this PR (../../core/src/config.js). A developer adding a new test in a subdirectory and using that pattern again would not be caught by this guard.

Suggested change
.filter((specifier) => /^\.\.\/(core|cli|opencode|pi|benchmarks)(?:\/|$)/.test(specifier))
.map((specifier) => `${formatPath(filePath)} imports ${specifier}`);
const relativeCrossPackageImports = specifiers
.filter((specifier) => /^(?:\.\.\/)+(?:core|cli|opencode|pi|benchmarks)(?:\/|$)/.test(specifier))

Fix in Codex

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]);
}
Comment on lines +74 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

importSpecifiers misses side-effect imports (import "module").

Current parsing ignores side-effect imports, so boundary violations in that form are not scanned.

Proposed fix
 function importSpecifiers(source: string): string[] {
   const specifiers: string[] = [];
+  for (const match of source.matchAll(/\bimport\s+["']([^"']+)["']/g)) {
+    if (match[1]) specifiers.push(match[1]);
+  }
   for (const match of source.matchAll(/\bfrom\s+["']([^"']+)["']/g)) {
     if (match[1]) specifiers.push(match[1]);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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]);
}
function importSpecifiers(source: string): string[] {
const specifiers: string[] = [];
for (const match of source.matchAll(/\bimport\s+["']([^"']+)["']/g)) {
if (match[1]) specifiers.push(match[1]);
}
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]);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/package-boundaries.test.ts` around lines 74 - 84,
importSpecifiers currently misses side-effect-only imports like `import
"module"`, so add a new matchAll loop to capture /\bimport\s+["']([^"']+)["']/g
and push match[1] into specifiers; locate the importSpecifiers function and
mirror the existing pattern used for the other regexes (check the loops handling
from "..." and import("...") and vi.mock("...")) to ensure side-effect imports
are included in the returned array.

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("/");
}
2 changes: 1 addition & 1 deletion packages/core/test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<Hooks> {
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)),
),
);
},
};
}
43 changes: 2 additions & 41 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,14 @@
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();
registerNativeCapletsProcessCleanup(service);
return createCapletsOpenCodeHooks(service);
};

export async function createCapletsOpenCodeHooks(service: NativeCapletsService): Promise<Hooks> {
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;
16 changes: 11 additions & 5 deletions packages/opencode/test/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => [
{
Expand Down Expand Up @@ -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: () => [
{
Expand Down Expand Up @@ -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: () => [
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/pi/test/pi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
Loading