diff --git a/.changeset/fuzzy-carpets-serve-http.md b/.changeset/fuzzy-carpets-serve-http.md new file mode 100644 index 00000000..0a4289cd --- /dev/null +++ b/.changeset/fuzzy-carpets-serve-http.md @@ -0,0 +1,6 @@ +--- +"@caplets/core": minor +"caplets": minor +--- + +Add `caplets serve` transport options, including opt-in Hono Streamable HTTP MCP serving with optional Basic Auth, health/info endpoints, and no-arg help behavior. diff --git a/.changeset/remote-native-caplets.md b/.changeset/remote-native-caplets.md new file mode 100644 index 00000000..33b892fe --- /dev/null +++ b/.changeset/remote-native-caplets.md @@ -0,0 +1,8 @@ +--- +"@caplets/core": minor +"@caplets/opencode": minor +"@caplets/pi": minor +"caplets": patch +--- + +Add remote Caplets service support for native integrations, including remote-backed OpenCode and Pi native tools plus documentation for MCP-backed Codex and Claude Code remote connections. diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 7130675b..90efc8a7 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,6 +1,16 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": ["@caplets/opencode@file:./packages/opencode"], + "plugin": [ + [ + "@caplets/opencode@file:./packages/opencode", + { + "mode": "remote", + "remote": { + "url": "http://localhost:5387/mcp" + } + } + ] + ], "permission": { "external_directory": { "~/.config/caplets/": "allow", diff --git a/.pi/settings.json b/.pi/settings.json index 82c9c908..3006a2e0 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,3 +1,9 @@ { - "packages": ["npm:@caplets/pi@file:../../packages/pi"] + "packages": ["npm:@caplets/pi@file:../../packages/pi"], + "caplets": { + "mode": "remote", + "remote": { + "url": "http://localhost:5387/mcp" + } + } } diff --git a/README.md b/README.md index 453c9e45..bc31bddc 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,33 @@ The Claude Code and Codex commands install from this GitHub repository through e plugin marketplace flow; users do not need to clone the repository manually. Plugin MCP configs run `caplets serve` directly, so install the Caplets CLI globally first. +### Remote Caplets service + +OpenCode and Pi can use native `caplets_` tools backed by a remote Caplets HTTP service. Codex, Claude Code, and any MCP client can connect to the same remote MCP endpoint directly. + +Start a local HTTP service: + +```sh +caplets serve --transport http --host 127.0.0.1 --port 5387 --path /mcp +``` + +`caplets serve --transport http` serves plain HTTP. For non-loopback or network access, expose it only through HTTPS/TLS (for example, a reverse proxy or secure tunnel) and enable Basic Auth; Basic Auth over plain HTTP exposes credentials. Keep credentials out of plugin manifests: + +```sh +CAPLETS_SERVER_PASSWORD=... caplets serve --transport http --host 127.0.0.1 --port 5387 --path /mcp +``` + +Native integrations read remote client settings from environment variables: + +```sh +CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \ +CAPLETS_REMOTE_USER=caplets \ +CAPLETS_REMOTE_PASSWORD=... \ +opencode +``` + +For MCP-backed Codex or Claude Code configs, point the agent's MCP server entry at the remote URL using that agent's supported HTTP MCP configuration. If Basic Auth is needed, use the agent's secure secret or environment interpolation mechanism rather than hardcoding credentials. + ## Convert Existing Tooling Caplets is designed to convert what you already use into agent-friendly capability domains. diff --git a/docs/plans/2026-05-19-http-mcp-serving.md b/docs/plans/2026-05-19-http-mcp-serving.md new file mode 100644 index 00000000..b66e0e74 --- /dev/null +++ b/docs/plans/2026-05-19-http-mcp-serving.md @@ -0,0 +1,1648 @@ +# HTTP MCP Serving Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in Hono-based Streamable HTTP MCP serving to `caplets serve` with optional Basic Auth, while making no-arg `caplets` show help. + +**Architecture:** Refactor MCP tool registration/reload reconciliation into a reusable `CapletsMcpSession` that shares one `CapletsEngine` across stdio and HTTP sessions. Stdio creates one session and connects it to `StdioServerTransport`; HTTP creates a Hono app, routes each `Mcp-Session-Id` to a per-session `@hono/mcp` `StreamableHTTPTransport`, and keeps all sessions backed by the same engine. + +**Tech Stack:** TypeScript, Commander, Vitest, MCP SDK, Hono, `@hono/node-server`, `@hono/mcp`, pnpm. + +--- + +## File structure + +- Modify `packages/core/package.json` to add direct Hono dependencies. +- Create `packages/core/src/serve/session.ts` for reusable MCP server/session registration and reload reconciliation. +- Modify `packages/core/src/runtime.ts` so `CapletsRuntime` delegates to `CapletsMcpSession` and preserves its public API. +- Create `packages/core/src/serve/options.ts` for serve option normalization, validation, auth resolution, path normalization, and loopback detection. +- Create `packages/core/src/serve/stdio.ts` for stdio serve orchestration. +- Create `packages/core/src/serve/http.ts` for Hono routes, Basic Auth middleware, MCP session map, DNS rebinding transport options, startup logs, and shutdown cleanup. +- Create `packages/core/src/serve/index.ts` to expose `serveCaplets`, `serveStdio`, `serveHttp`, and option types. +- Modify `packages/core/src/cli.ts` to add `serve`, no-arg help, and a test-injectable serve runner. +- Modify `packages/cli/src/index.ts` to always delegate to `runCli`. +- Add `packages/core/test/serve-options.test.ts` for option validation and auth resolution. +- Add `packages/core/test/serve-session.test.ts` for shared session tool reconciliation. +- Add `packages/core/test/serve-http.test.ts` for Hono route/auth/session behavior. +- Modify `packages/core/test/cli.test.ts` for no-arg help and `serve` command parsing through injected runner. +- Keep `packages/core/test/runtime.test.ts` passing as compatibility coverage. + +--- + +### Task 1: Add Hono dependencies + +**Files:** + +- Modify: `packages/core/package.json` +- Modify: `pnpm-lock.yaml` + +- [ ] **Step 1: Add dependencies with pnpm** + +Run: + +```bash +pnpm --filter @caplets/core add @hono/mcp@^0.3.0 @hono/node-server@^1.19.9 hono@^4.11.5 +``` + +Expected: `packages/core/package.json` contains the three new dependencies and `pnpm-lock.yaml` is updated. + +- [ ] **Step 2: Verify package manifest** + +Check `packages/core/package.json` contains this dependency block shape: + +```json +{ + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "@hono/mcp": "^0.3.0", + "@hono/node-server": "^1.19.9", + "@modelcontextprotocol/sdk": "^1.29.0", + "commander": "^14.0.3", + "graphql": "^16.14.0", + "hono": "^4.11.5", + "vfile": "^6.0.3", + "vfile-matter": "^5.0.1", + "zod": "^4.4.3" + } +} +``` + +The exact order may be adjusted by the package manager, but all three Hono dependencies must be direct dependencies. + +- [ ] **Step 3: Commit dependency change** + +Run: + +```bash +git add packages/core/package.json pnpm-lock.yaml +git commit -m "chore(core): add Hono MCP dependencies" +``` + +--- + +### Task 2: Extract reusable MCP session wrapper + +**Files:** + +- Create: `packages/core/src/serve/session.ts` +- Modify: `packages/core/src/runtime.ts` +- Test: `packages/core/test/serve-session.test.ts` +- Existing test: `packages/core/test/runtime.test.ts` + +- [ ] **Step 1: Write failing session tests** + +Create `packages/core/test/serve-session.test.ts`: + +```ts +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.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CapletsEngine } from "../src/engine"; +import { CapletsMcpSession } from "../src/serve/session"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("CapletsMcpSession", () => { + it("registers enabled Caplets from a shared engine", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { name: "Alpha", description: "Search alpha.", command: "node" }, + beta: { name: "Beta", description: "Search beta.", command: "node", disabled: true }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const server = mockServer(); + const session = new CapletsMcpSession(engine, { server }); + + expect(session.registeredToolIds()).toEqual(["alpha"]); + expect(server.registerTool).toHaveBeenCalledTimes(1); + + await session.close(); + await engine.close(); + }); + + it("reconciles tools when the shared engine reloads", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { name: "Alpha", description: "Search alpha.", command: "node" }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const server = mockServer(); + const session = new CapletsMcpSession(engine, { server }); + const alpha = server.registered.get("alpha")!; + + writeConfig(configPath, { + httpApis: { + gamma: { + name: "Gamma HTTP", + description: "Call gamma over HTTP.", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { search: { method: "GET", path: "/search" } }, + }, + }, + }); + await engine.reload(); + + expect(alpha.remove).toHaveBeenCalledTimes(1); + expect(session.registeredToolIds()).toEqual(["gamma"]); + expect(server.registered.get("gamma")).toBeDefined(); + + await session.close(); + await engine.close(); + }); +}); + +function tempConfig(config: unknown): { + dir: string; + configPath: string; + projectConfigPath: string; +} { + const dir = mkdtempSync(join(tmpdir(), "caplets-session-")); + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project", ".caplets"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + const configPath = join(userRoot, "config.json"); + const projectConfigPath = join(projectRoot, "config.json"); + writeConfig(configPath, config); + return { dir, configPath, projectConfigPath }; +} + +function writeConfig(path: string, config: unknown): void { + writeFileSync(path, JSON.stringify(config)); +} + +function mockServer() { + const registered = new Map(); + return { + registered, + registerTool: vi.fn((name: string) => { + const tool = { + update: vi.fn(), + remove: vi.fn(() => registered.delete(name)), + enable: vi.fn(), + disable: vi.fn(), + enabled: true, + handler: vi.fn(), + } as unknown as RegisteredTool; + registered.set(name, tool); + return tool; + }), + connect: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-session.test.ts +``` + +Expected: FAIL because `../src/serve/session` does not exist. + +- [ ] **Step 3: Implement `CapletsMcpSession`** + +Create `packages/core/src/serve/session.ts`: + +```ts +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, CapletsConfig } from "../config"; +import { CapletsEngine } from "../engine"; +import { capabilityDescription } from "../registry"; +import { generatedToolInputSchema } from "../tools"; + +export type ToolServer = Pick; + +export type CapletsMcpSessionOptions = { + server?: ToolServer; +}; + +export class CapletsMcpSession { + readonly server: ToolServer; + private readonly tools = new Map(); + private readonly unsubscribeReload: () => void; + private closed = false; + + constructor( + private readonly engine: CapletsEngine, + options: CapletsMcpSessionOptions = {}, + ) { + this.server = + options.server ?? + new McpServer({ + name: "caplets", + version: packageJsonVersion, + }); + this.unsubscribeReload = this.engine.onReload(({ previous, next }) => + this.reconcileTools(previous, next), + ); + this.reconcileTools(undefined, this.engine.currentConfig()); + } + + async connect(transport: Transport): Promise { + await this.server.connect(transport); + } + + registeredToolIds(): string[] { + return [...this.tools.keys()].sort(); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.unsubscribeReload(); + this.tools.clear(); + await this.server.close(); + } + + private reconcileTools(previous: CapletsConfig | undefined, next: CapletsConfig): void { + const enabled = new Map(nextEnabledServers(next).map((server) => [server.server, server])); + + for (const [serverId, tool] of this.tools) { + const caplet = enabled.get(serverId); + if (!caplet) { + tool.remove(); + this.tools.delete(serverId); + continue; + } + + const previousCaplet = previous ? capletById(previous, serverId) : undefined; + if (!previousCaplet || serializeCaplet(previousCaplet) !== serializeCaplet(caplet)) { + tool.update({ + title: caplet.name, + description: capabilityDescription(caplet), + callback: async (request) => this.handleTool(serverId, request), + enabled: true, + }); + } + } + + for (const caplet of enabled.values()) { + if (this.tools.has(caplet.server)) { + continue; + } + this.tools.set(caplet.server, this.registerCapletTool(caplet)); + } + } + + private registerCapletTool(caplet: CapletConfig): RegisteredTool { + return this.server.registerTool( + caplet.server, + { + title: caplet.name, + description: capabilityDescription(caplet), + inputSchema: generatedToolInputSchema, + }, + async (request) => this.handleTool(caplet.server, request), + ); + } + + private async handleTool(serverId: string, request: unknown): Promise { + return await this.engine.execute(serverId, request); + } +} + +function nextEnabledServers(config: CapletsConfig): CapletConfig[] { + return [ + ...Object.values(config.mcpServers), + ...Object.values(config.openapiEndpoints), + ...Object.values(config.graphqlEndpoints), + ...Object.values(config.httpApis), + ...Object.values(config.cliTools), + ...Object.values(config.capletSets), + ].filter((server) => !server.disabled); +} + +function capletById(config: CapletsConfig, serverId: string): CapletConfig | undefined { + return ( + config.mcpServers[serverId] ?? + config.openapiEndpoints[serverId] ?? + config.graphqlEndpoints[serverId] ?? + config.httpApis[serverId] ?? + config.cliTools[serverId] ?? + config.capletSets[serverId] + ); +} + +function serializeCaplet(caplet: CapletConfig | undefined): string { + return JSON.stringify(caplet ?? null); +} +``` + +- [ ] **Step 4: Refactor `CapletsRuntime` to delegate to the session wrapper** + +Replace `packages/core/src/runtime.ts` with: + +```ts +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { type CapletsConfig } from "./config"; +import { CapletsEngine, type CapletsEngineOptions } from "./engine"; +import { CapletsMcpSession, type ToolServer } from "./serve/session"; + +type CapletsRuntimeOptions = { + configPath?: string; + projectConfigPath?: string; + authDir?: string; + watchDebounceMs?: number; + server?: ToolServer; + writeErr?: (value: string) => void; +}; + +export class CapletsRuntime { + readonly server: ToolServer; + private readonly engine: CapletsEngine; + private readonly session: CapletsMcpSession; + + constructor(options: CapletsRuntimeOptions = {}) { + this.engine = new CapletsEngine(engineOptions(options)); + this.session = new CapletsMcpSession(this.engine, selectSessionOptions(options)); + this.server = this.session.server; + } + + async connect(transport: Transport): Promise { + await this.session.connect(transport); + } + + scheduleReload(): void { + this.engine.scheduleReload(); + } + + async reload(): Promise { + return await this.engine.reload(); + } + + async close(): Promise { + try { + await this.session.close(); + } finally { + await this.engine.close(); + } + } + + currentConfig(): CapletsConfig { + return this.engine.currentConfig(); + } + + registeredToolIds(): string[] { + return this.session.registeredToolIds(); + } + + watchedPaths(): string[] { + return this.engine.watchedPaths(); + } +} + +function selectSessionOptions(options: CapletsRuntimeOptions): { server?: ToolServer } { + return options.server === undefined ? {} : { server: options.server }; +} + +function engineOptions(options: CapletsRuntimeOptions): CapletsEngineOptions { + const engineOptions: CapletsEngineOptions = {}; + if (options.configPath !== undefined) { + engineOptions.configPath = options.configPath; + } + if (options.projectConfigPath !== undefined) { + engineOptions.projectConfigPath = options.projectConfigPath; + } + if (options.authDir !== undefined) { + engineOptions.authDir = options.authDir; + } + if (options.watchDebounceMs !== undefined) { + engineOptions.watchDebounceMs = options.watchDebounceMs; + } + if (options.writeErr !== undefined) { + engineOptions.writeErr = options.writeErr; + } + return engineOptions; +} +``` + +- [ ] **Step 5: Run session and runtime tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-session.test.ts test/runtime.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit session refactor** + +Run: + +```bash +git add packages/core/src/serve/session.ts packages/core/src/runtime.ts packages/core/test/serve-session.test.ts +git commit -m "refactor(core): share MCP session registration" +``` + +--- + +### Task 3: Add serve option normalization + +**Files:** + +- Create: `packages/core/src/serve/options.ts` +- Test: `packages/core/test/serve-options.test.ts` + +- [ ] **Step 1: Write failing option tests** + +Create `packages/core/test/serve-options.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { resolveServeOptions } from "../src/serve/options"; + +describe("resolveServeOptions", () => { + it("defaults serve to stdio", () => { + expect(resolveServeOptions({}, {})).toEqual({ transport: "stdio" }); + }); + + it("defaults HTTP serving to localhost port 5387 and /mcp", () => { + expect(resolveServeOptions({ transport: "http" }, {})).toMatchObject({ + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/mcp", + auth: { enabled: false, user: "caplets" }, + }); + }); + + it("normalizes trailing slashes in HTTP path", () => { + expect(resolveServeOptions({ transport: "http", path: "/custom/" }, {})).toMatchObject({ + transport: "http", + path: "/custom", + }); + }); + + it("resolves Basic Auth from password with default user", () => { + const testPassword = ["test", "password"].join("-"); + + expect(resolveServeOptions({ transport: "http", password: testPassword }, {})).toMatchObject({ + transport: "http", + auth: { enabled: true, user: "caplets", password: testPassword }, + }); + }); + + it("resolves Basic Auth from env and lets flags win", () => { + const envPassword = ["test", "env", "password"].join("-"); + + expect( + resolveServeOptions( + { transport: "http", user: "cli-user" }, + { CAPLETS_SERVER_USER: "env-user", CAPLETS_SERVER_PASSWORD: envPassword }, + ), + ).toMatchObject({ + transport: "http", + auth: { enabled: true, user: "cli-user", password: envPassword }, + }); + }); + + it("rejects explicit user without password", () => { + expect(() => resolveServeOptions({ transport: "http", user: "alice" }, {})).toThrow( + /requires a password/u, + ); + }); + + it("rejects HTTP-only options for stdio", () => { + expect(() => resolveServeOptions({ transport: "stdio", host: "127.0.0.1" }, {})).toThrow( + /only valid with --transport http/u, + ); + }); + + it("rejects invalid port and path", () => { + expect(() => resolveServeOptions({ transport: "http", port: "0" }, {})).toThrow( + /valid TCP port/u, + ); + expect(() => resolveServeOptions({ transport: "http", path: "mcp" }, {})).toThrow( + /must start with/u, + ); + expect(() => resolveServeOptions({ transport: "http", path: "/mcp?x=1" }, {})).toThrow( + /query string/u, + ); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-options.test.ts +``` + +Expected: FAIL because `../src/serve/options` does not exist. + +- [ ] **Step 3: Implement `serve/options.ts`** + +Create `packages/core/src/serve/options.ts`: + +```ts +import { CapletsError } from "../errors"; + +export type ServeTransport = "stdio" | "http"; + +export type RawServeOptions = { + transport?: string; + host?: string; + port?: string | number; + path?: string; + user?: string; + password?: string; +}; + +export type StdioServeOptions = { + transport: "stdio"; +}; + +export type HttpServeOptions = { + transport: "http"; + host: string; + port: number; + path: string; + auth: HttpBasicAuthOptions; + warnUnauthenticatedNetwork: boolean; + loopback: boolean; +}; + +export type HttpBasicAuthOptions = + | { enabled: false; user: string } + | { enabled: true; user: string; password: string }; + +export type ServeOptions = StdioServeOptions | HttpServeOptions; + +export type ServeEnv = Partial>; + +const HTTP_ONLY_OPTIONS = ["host", "port", "path", "user", "password"] as const; + +export function resolveServeOptions( + raw: RawServeOptions, + env: ServeEnv = process.env, +): ServeOptions { + const transport = parseTransport(raw.transport ?? "stdio"); + if (transport === "stdio") { + const invalid = HTTP_ONLY_OPTIONS.filter((key) => raw[key] !== undefined); + if (invalid.length > 0) { + throw new CapletsError( + "REQUEST_INVALID", + `${invalid.map((key) => `--${key}`).join(", ")} ${invalid.length === 1 ? "is" : "are"} only valid with --transport http`, + ); + } + return { transport }; + } + + const host = nonEmpty(raw.host, "--host") ?? "127.0.0.1"; + const port = parsePort(raw.port ?? 5387); + const path = normalizeHttpPath(raw.path ?? "/mcp"); + const userWasExplicit = raw.user !== undefined || hasEnv(env.CAPLETS_SERVER_USER); + const user = + nonEmpty(raw.user, "--user") ?? + nonEmpty(env.CAPLETS_SERVER_USER, "CAPLETS_SERVER_USER") ?? + "caplets"; + const password = + nonEmpty(raw.password, "--password") ?? + nonEmpty(env.CAPLETS_SERVER_PASSWORD, "CAPLETS_SERVER_PASSWORD"); + + if (userWasExplicit && password === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "HTTP Basic Auth requires a password; pass --password or set CAPLETS_SERVER_PASSWORD.", + ); + } + + const loopback = isLoopbackHost(host); + return { + transport, + host, + port, + path, + auth: password === undefined ? { enabled: false, user } : { enabled: true, user, password }, + warnUnauthenticatedNetwork: !loopback && password === undefined, + loopback, + }; +} + +export function isLoopbackHost(host: string): boolean { + const normalized = host.toLocaleLowerCase(); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +} + +function parseTransport(value: string): ServeTransport { + if (value === "stdio" || value === "http") { + return value; + } + throw new CapletsError( + "REQUEST_INVALID", + `Expected --transport to be stdio or http, got ${value}`, + ); +} + +function parsePort(value: string | number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new CapletsError( + "REQUEST_INVALID", + `Expected --port to be a valid TCP port, got ${value}`, + ); + } + return parsed; +} + +function normalizeHttpPath(value: string): string { + if (!value.startsWith("/")) { + throw new CapletsError("REQUEST_INVALID", "HTTP --path must start with /"); + } + if (value.includes("?") || value.includes("#")) { + throw new CapletsError( + "REQUEST_INVALID", + "HTTP --path must not include a query string or fragment", + ); + } + return value === "/" ? value : value.replace(/\/+$/u, ""); +} + +function nonEmpty(value: string | undefined, label: string): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`); + } + return trimmed; +} + +function hasEnv(value: string | undefined): boolean { + return value !== undefined && value.trim() !== ""; +} +``` + +- [ ] **Step 4: Run option tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-options.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit option normalization** + +Run: + +```bash +git add packages/core/src/serve/options.ts packages/core/test/serve-options.test.ts +git commit -m "feat(core): validate serve transport options" +``` + +--- + +### Task 4: Add stdio serve helper + +**Files:** + +- Create: `packages/core/src/serve/stdio.ts` +- Create: `packages/core/src/serve/index.ts` +- Test indirectly through later CLI tests + +- [ ] **Step 1: Implement stdio helper** + +Create `packages/core/src/serve/stdio.ts`: + +```ts +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CapletsEngine, type CapletsEngineOptions } from "../engine"; +import { CapletsMcpSession } from "./session"; + +export type ServeStdioOptions = CapletsEngineOptions & { + signalHandling?: boolean; +}; + +export async function serveStdio(options: ServeStdioOptions = {}): Promise { + const engine = new CapletsEngine(options); + const session = new CapletsMcpSession(engine); + let closing = false; + + const close = async () => { + if (closing) { + return; + } + closing = true; + try { + await session.close(); + } finally { + await engine.close(); + } + }; + + if (options.signalHandling !== false) { + process.once("SIGINT", () => void close().finally(() => process.exit(130))); + process.once("SIGTERM", () => void close().finally(() => process.exit(143))); + } + + await session.connect(new StdioServerTransport()); +} +``` + +Create `packages/core/src/serve/index.ts`: + +```ts +import type { CapletsEngineOptions } from "../engine"; +import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./options"; +import { serveHttp } from "./http"; +import { serveStdio } from "./stdio"; + +export { resolveServeOptions } from "./options"; +export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./options"; +export { serveHttp } from "./http"; +export { serveStdio } from "./stdio"; + +export type ServeCapletsOptions = { + raw: RawServeOptions; + engine?: CapletsEngineOptions; + env?: NodeJS.ProcessEnv; + writeErr?: (value: string) => void; +}; + +export async function serveCaplets(options: ServeCapletsOptions): Promise { + const resolved = resolveServeOptions(options.raw, options.env ?? process.env); + await serveResolvedCaplets(resolved, options.engine, options.writeErr); +} + +export async function serveResolvedCaplets( + resolved: ServeOptions, + engineOptions: CapletsEngineOptions = {}, + writeErr?: (value: string) => void, +): Promise { + if (resolved.transport === "stdio") { + await serveStdio({ ...engineOptions, ...(writeErr ? { writeErr } : {}) }); + return; + } + await serveHttp(resolved, { ...engineOptions, ...(writeErr ? { writeErr } : {}) }, writeErr); +} +``` + +This references `serveHttp` before it exists. The repository will not compile until Task 5 creates `packages/core/src/serve/http.ts`. + +- [ ] **Step 2: Do not commit yet** + +Do not commit this task independently because `serve/index.ts` imports `serveHttp`, which is created in the next task. Commit Task 4 and Task 5 together after tests pass. + +--- + +### Task 5: Add Hono HTTP serving + +**Files:** + +- Create: `packages/core/src/serve/http.ts` +- Complete: `packages/core/src/serve/index.ts` +- Test: `packages/core/test/serve-http.test.ts` + +- [ ] **Step 1: Write failing HTTP route/auth tests** + +Create `packages/core/test/serve-http.test.ts`: + +```ts +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CapletsEngine } from "../src/engine"; +import { createHttpServeApp } from "../src/serve/http"; +import type { HttpServeOptions } from "../src/serve/options"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("createHttpServeApp", () => { + it("serves root info and health without auth", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + + const root = await app.request("http://127.0.0.1:5387/"); + expect(root.status).toBe(200); + await expect(root.json()).resolves.toMatchObject({ + name: "caplets", + transport: "http", + mcp: "/mcp", + health: "/healthz", + auth: { type: "basic", enabled: false }, + }); + + const health = await app.request("http://127.0.0.1:5387/healthz"); + expect(health.status).toBe(200); + await expect(health.json()).resolves.toEqual({ + status: "ok", + transport: "http", + mcpPath: "/mcp", + }); + + await engine.close(); + }); + + it("requires Basic Auth on MCP path when password is configured", async () => { + const { engine } = testEngine(); + const testPassword = ["test", "password"].join("-"); + const app = createHttpServeApp( + httpOptions({ auth: { enabled: true, user: "caplets", password: testPassword } }), + engine, + { writeErr: () => {} }, + ); + + const missing = await app.request("http://127.0.0.1:5387/mcp", { method: "POST" }); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toContain("Basic"); + + const wrong = await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + authorization: `Basic ${Buffer.from(`caplets:not-the-${testPassword}`).toString("base64")}`, + }, + }); + expect(wrong.status).toBe(401); + + await engine.close(); + }); + + it("returns 404 for nested MCP paths", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + + const response = await app.request("http://127.0.0.1:5387/mcp/extra"); + expect(response.status).toBe(404); + + await engine.close(); + }); +}); + +function httpOptions(overrides: Partial = {}): HttpServeOptions { + return { + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/mcp", + auth: { enabled: false, user: "caplets" }, + warnUnauthenticatedNetwork: false, + loopback: true, + ...overrides, + }; +} + +function testEngine(): { engine: CapletsEngine } { + const dir = mkdtempSync(join(tmpdir(), "caplets-http-")); + dirs.push(dir); + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project", ".caplets"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + const configPath = join(userRoot, "config.json"); + const projectConfigPath = join(projectRoot, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + httpApis: { + status: { + name: "Status", + description: "Status API.", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { check: { method: "GET", path: "/check" } }, + }, + }, + }), + ); + return { engine: new CapletsEngine({ configPath, projectConfigPath, watch: false }) }; +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-http.test.ts +``` + +Expected: FAIL because `../src/serve/http` does not exist. + +- [ ] **Step 3: Implement Hono HTTP serving** + +Create `packages/core/src/serve/http.ts`: + +```ts +import { randomUUID, timingSafeEqual } from "node:crypto"; +import type { ServerType } from "@hono/node-server"; +import { serve } from "@hono/node-server"; +import { StreamableHTTPTransport } from "@hono/mcp"; +import { Hono } from "hono"; +import type { Context, MiddlewareHandler } from "hono"; +import type { CapletsEngineOptions } from "../engine"; +import { CapletsEngine } from "../engine"; +import type { HttpBasicAuthOptions, HttpServeOptions } from "./options"; +import { CapletsMcpSession } from "./session"; + +type HttpServeIo = { + writeErr?: (value: string) => void; +}; + +type HttpSession = { + server: CapletsMcpSession; + transport: StreamableHTTPTransport; +}; + +export function createHttpServeApp( + options: HttpServeOptions, + engine: CapletsEngine, + io: HttpServeIo = {}, +): Hono { + const app = new Hono(); + const sessions = new Map(); + + app.get("/", (c) => + c.json({ + name: "caplets", + transport: "http", + mcp: options.path, + health: "/healthz", + auth: { type: "basic", enabled: options.auth.enabled }, + }), + ); + + app.get("/healthz", (c) => + c.json({ + status: "ok", + transport: "http", + mcpPath: options.path, + }), + ); + + app.all(options.path, basicAuth(options.auth), async (c) => { + const sessionId = c.req.header("mcp-session-id"); + if (sessionId) { + const existing = sessions.get(sessionId); + if (!existing) { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }, + 404, + ); + } + return existing.transport.handleRequest(c); + } + + if (c.req.method !== "POST") { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: Mcp-Session-Id header is required" }, + id: null, + }, + 400, + ); + } + + const nextSessionId = randomUUID(); + const session = await createHttpSession( + engine, + nextSessionId, + options, + async (closedSessionId) => { + const closed = sessions.get(closedSessionId); + sessions.delete(closedSessionId); + if (closed) { + await closed.server.close(); + } + }, + ); + sessions.set(nextSessionId, session); + return session.transport.handleRequest(c); + }); + + app.notFound((c) => c.json({ error: "not_found" }, 404)); + + Object.defineProperty(app, "__capletsClose", { + value: async () => { + await Promise.allSettled( + [...sessions.values()].map(async (session) => { + await session.server.close(); + }), + ); + sessions.clear(); + }, + }); + + if (options.warnUnauthenticatedNetwork) { + (io.writeErr ?? process.stderr.write.bind(process.stderr))( + `Warning: Caplets MCP HTTP server is listening on ${options.host} without authentication.\n`, + ); + } + + return app; +} + +export async function serveHttp( + options: HttpServeOptions, + engineOptions: CapletsEngineOptions = {}, + writeErr: (value: string) => void = (value) => process.stderr.write(value), +): Promise { + const engine = new CapletsEngine(engineOptions); + const app = createHttpServeApp(options, engine, { writeErr }); + const server = serve({ fetch: app.fetch, hostname: options.host, port: options.port }, () => { + writeErr( + `Caplets MCP HTTP server listening on http://${formatHost(options.host)}:${options.port}${options.path}\n`, + ); + writeErr(`Health check: http://${formatHost(options.host)}:${options.port}/healthz\n`); + writeErr( + `Basic Auth: ${options.auth.enabled ? `enabled (user: ${options.auth.user})` : "disabled"}\n`, + ); + }); + + installHttpSignalHandlers(server, app, engine, writeErr); +} + +async function createHttpSession( + engine: CapletsEngine, + sessionId: string, + options: HttpServeOptions, + onClose: (sessionId: string) => Promise, +): Promise { + const transport = new StreamableHTTPTransport({ + sessionIdGenerator: () => sessionId, + onsessionclosed: onClose, + ...(options.loopback ? dnsRebindingOptions(options) : {}), + }); + const server = new CapletsMcpSession(engine); + await server.connect(transport); + return { server, transport }; +} + +function basicAuth(auth: HttpBasicAuthOptions): MiddlewareHandler { + return async (c, next) => { + if (!auth.enabled) { + await next(); + return; + } + const header = c.req.header("authorization") ?? ""; + const credentials = parseBasicAuth(header); + if ( + !credentials || + !safeEqual(credentials.user, auth.user) || + !safeEqual(credentials.password, auth.password) + ) { + c.header("www-authenticate", 'Basic realm="caplets"'); + return c.text("Unauthorized", 401); + } + await next(); + }; +} + +function parseBasicAuth(header: string): { user: string; password: string } | undefined { + const [scheme, encoded] = header.split(" "); + if (scheme?.toLocaleLowerCase() !== "basic" || !encoded) { + return undefined; + } + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator < 0) { + return undefined; + } + return { user: decoded.slice(0, separator), password: decoded.slice(separator + 1) }; +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function dnsRebindingOptions(options: HttpServeOptions): { + enableDnsRebindingProtection: true; + allowedHosts: string[]; + allowedOrigins: string[]; +} { + const hostForHeader = options.host === "::1" ? "[::1]" : options.host; + return { + enableDnsRebindingProtection: true, + allowedHosts: [ + options.host, + hostForHeader, + `${hostForHeader}:${options.port}`, + `localhost:${options.port}`, + ], + allowedOrigins: [`http://${hostForHeader}:${options.port}`, `http://localhost:${options.port}`], + }; +} + +function installHttpSignalHandlers( + server: ServerType, + app: Hono, + engine: CapletsEngine, + writeErr: (value: string) => void, +): void { + const close = async () => { + await new Promise((resolve) => server.close(() => resolve())); + await (app as unknown as { __capletsClose?: () => Promise }).__capletsClose?.(); + await engine.close(); + }; + process.once( + "SIGINT", + () => + void close() + .catch((error) => writeErr(`${String(error)}\n`)) + .finally(() => process.exit(130)), + ); + process.once( + "SIGTERM", + () => + void close() + .catch((error) => writeErr(`${String(error)}\n`)) + .finally(() => process.exit(143)), + ); +} + +function formatHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} +``` + +- [ ] **Step 4: Fix import/type issues from actual Hono versions** + +Run: + +```bash +pnpm --filter @caplets/core typecheck +``` + +Expected first run may FAIL on exact exported type names from `@hono/node-server` or `@hono/mcp`. If it fails: + +- If `ServerType` is type-only-exported from `@hono/node-server/dist/types`, replace `import type { ServerType } from "@hono/node-server";` with `import type { ServerType } from "@hono/node-server/dist/types";` only if the package export permits it. +- If `StreamableHTTPTransport` option type rejects `allowedOrigins`, keep only `enableDnsRebindingProtection` and `allowedHosts`. +- If `Hono` generic typing rejects the synthetic `__capletsClose` property, replace the property attachment with a helper return type in this file: + +```ts +type CapletsHttpApp = Hono & { closeCapletsSessions: () => Promise }; +``` + +Then return that from `createHttpServeApp` and call `app.closeCapletsSessions()` in `serveHttp`. + +- [ ] **Step 5: Run HTTP tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-http.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit stdio/http serve helpers** + +Run: + +```bash +git add packages/core/src/serve/index.ts packages/core/src/serve/stdio.ts packages/core/src/serve/http.ts packages/core/test/serve-http.test.ts +git commit -m "feat(core): serve MCP over Hono HTTP" +``` + +--- + +### Task 6: Wire `serve` into the core CLI + +**Files:** + +- Modify: `packages/core/src/cli.ts` +- Modify: `packages/core/src/index.ts` +- Test: `packages/core/test/cli.test.ts` + +- [ ] **Step 1: Add failing CLI tests** + +Append tests near the existing CLI help/version tests in `packages/core/test/cli.test.ts`: + +```ts +it("prints top-level help for no arguments", async () => { + const out: string[] = []; + + await runCli([], { + writeOut: (value) => out.push(value), + writeErr: (value) => out.push(value), + }); + + expect(out.join("")).toContain("Usage: caplets"); + expect(out.join("")).toContain("Commands:"); + expect(out.join("")).toContain("serve"); +}); + +it("resolves serve defaults to stdio", async () => { + const served: unknown[] = []; + + await runCli(["serve"], { + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }); + + expect(served).toEqual([{ transport: "stdio" }]); +}); + +it("resolves HTTP serve defaults", async () => { + const served: unknown[] = []; + + await runCli(["serve", "--transport", "http"], { + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }); + + expect(served).toEqual([ + expect.objectContaining({ + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/mcp", + auth: { enabled: false, user: "caplets" }, + }), + ]); +}); + +it("rejects HTTP-only serve options with stdio", async () => { + await expect( + runCli(["serve", "--transport", "stdio", "--port", "5387"], { writeErr: () => {} }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); +}); +``` + +Also update the local `CliIO` TypeScript expectations by importing the real type only if needed. The test uses `serve` before the type exists, so it should fail initially. + +- [ ] **Step 2: Run CLI tests to verify failure** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/cli.test.ts --runInBand +``` + +Expected: FAIL because `CliIO` does not accept `serve`, no-arg behavior is not implemented, and the `serve` command does not exist in Commander. + +- [ ] **Step 3: Modify CLI imports and `CliIO`** + +In `packages/core/src/cli.ts`, add imports: + +```ts +import { resolveServeOptions, type ServeOptions } from "./serve"; +import { serveResolvedCaplets } from "./serve"; +``` + +Update `CliIO`: + +```ts +type CliIO = { + writeOut?: (value: string) => void; + writeErr?: (value: string) => void; + authDir?: string; + version?: string; + setExitCode?: (code: number) => void; + serve?: (options: ServeOptions) => Promise; +}; +``` + +- [ ] **Step 4: Add no-arg help behavior** + +In `runCli`, before `program.parseAsync`, add: + +```ts +if (args.length === 0) { + program.outputHelp(); + return; +} +``` + +The resulting function body should start like: + +```ts +export async function runCli(args: string[], io: CliIO = {}): Promise { + const program = createProgram(io); + try { + if (args.length === 0) { + program.outputHelp(); + return; + } + await program.parseAsync(["node", "caplets", ...args]); + } catch (error) { + // existing error handling stays unchanged + } +} +``` + +- [ ] **Step 5: Add `serve` command to `createProgram`** + +After the main `program` setup and before `init`, add: + +```ts +program + .command("serve") + .description("Serve configured Caplets as an MCP server.") + .option("--transport ", "server transport: stdio or http") + .option("--host ", "HTTP bind host") + .option("--port ", "HTTP bind port") + .option("--path ", "HTTP MCP endpoint path") + .option("--user ", "HTTP Basic Auth username") + .option("--password ", "HTTP Basic Auth password") + .action( + async (options: { + transport?: string; + host?: string; + port?: string; + path?: string; + user?: string; + password?: string; + }) => { + const resolved = resolveServeOptions(options); + const runner = + io.serve ?? + ((serveOptions: ServeOptions) => + serveResolvedCaplets( + serveOptions, + { + ...(envConfigPath() ? { configPath: envConfigPath() } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }, + writeErr, + )); + await runner(resolved); + }, + ); +``` + +- [ ] **Step 6: Export serve helpers from core index** + +In `packages/core/src/index.ts`, add: + +```ts +export { serveCaplets, serveHttp, serveResolvedCaplets, serveStdio } from "./serve"; +export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./serve"; +``` + +- [ ] **Step 7: Run focused CLI tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/cli.test.ts +``` + +Expected: PASS. + +- [ ] **Step 8: Commit CLI wiring** + +Run: + +```bash +git add packages/core/src/cli.ts packages/core/src/index.ts packages/core/test/cli.test.ts +git commit -m "feat(core): add serve command options" +``` + +--- + +### Task 7: Simplify CLI binary entrypoint + +**Files:** + +- Modify: `packages/cli/src/index.ts` +- Test: covered by package build/typecheck + +- [ ] **Step 1: Replace special-case entrypoint** + +Replace `packages/cli/src/index.ts` with: + +```ts +import { runCli } from "@caplets/core"; +import { version as packageVersion } from "../package.json"; + +async function main() { + await runCli(process.argv.slice(2), { version: packageVersion }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +- [ ] **Step 2: Run CLI package typecheck/build** + +Run: + +```bash +pnpm --filter caplets typecheck +pnpm --filter caplets build +``` + +Expected: PASS. + +- [ ] **Step 3: Commit entrypoint simplification** + +Run: + +```bash +git add packages/cli/src/index.ts +git commit -m "refactor(cli): delegate serve command to core" +``` + +--- + +### Task 8: Add MCP HTTP integration coverage + +**Files:** + +- Modify: `packages/core/test/serve-http.test.ts` + +- [ ] **Step 1: Add initialize/list-tools integration test** + +Append this test to `packages/core/test/serve-http.test.ts`: + +```ts +it("initializes an MCP HTTP session and lists Caplet tools", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + + const init = await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + }), + }); + + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": sessionId!, + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + + const tools = await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": sessionId!, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), + }); + + expect(tools.status).toBe(200); + const body = await tools.text(); + expect(body).toContain("status"); + + const deleted = await app.request("http://127.0.0.1:5387/mcp", { + method: "DELETE", + headers: { "mcp-session-id": sessionId! }, + }); + expect(deleted.status).toBe(200); + + await engine.close(); +}); +``` + +If `@hono/mcp` returns SSE frames for `tools/list`, asserting `body` contains `status` is sufficient. If it returns pure JSON, the same assertion still passes. + +- [ ] **Step 2: Run HTTP tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-http.test.ts +``` + +Expected: PASS. If the DELETE request requires `mcp-protocol-version`, add header: + +```ts +"mcp-protocol-version": "2025-03-26" +``` + +and rerun. + +- [ ] **Step 3: Commit HTTP integration test** + +Run: + +```bash +git add packages/core/test/serve-http.test.ts +git commit -m "test(core): cover HTTP MCP sessions" +``` + +--- + +### Task 9: Run focused and full verification + +**Files:** + +- No source files unless verification reveals issues. + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/serve-options.test.ts test/serve-session.test.ts test/serve-http.test.ts test/runtime.test.ts test/cli.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run typecheck** + +Run: + +```bash +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Run all tests** + +Run: + +```bash +pnpm test +``` + +Expected: PASS. + +- [ ] **Step 4: Run full gate** + +Run: + +```bash +pnpm verify +``` + +Expected: PASS through format, lint, typecheck, schema check, tests, benchmark check, and build. + +- [ ] **Step 5: Commit any verification fixes** + +If verification required fixes, commit them: + +```bash +git add +git commit -m "fix(core): polish HTTP serve implementation" +``` + +If no fixes were needed, do not create an empty commit. + +--- + +## Self-review notes + +- Spec coverage: CLI behavior is covered by Tasks 3, 6, and 7. Hono HTTP behavior, Basic Auth, root/health endpoints, DNS rebinding options, session routing, startup logs, and shutdown are covered by Tasks 5 and 8. Runtime architecture is covered by Task 2. Verification is covered by Task 9. +- Red-flag scan: no incomplete sections are intentionally left. The only conditional instructions are concrete version/type compatibility branches for real package typings. +- Type consistency: `ServeOptions`, `HttpServeOptions`, `CapletsMcpSession`, and `ToolServer` are introduced before later tasks consume them. diff --git a/docs/plans/2026-05-19-remote-native-integrations.md b/docs/plans/2026-05-19-remote-native-integrations.md new file mode 100644 index 00000000..47735cb8 --- /dev/null +++ b/docs/plans/2026-05-19-remote-native-integrations.md @@ -0,0 +1,1438 @@ +# Remote Native Integrations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let OpenCode, Pi, Codex, and Claude Code connect to a remote `caplets serve --transport http` service while preserving existing local defaults. + +**Architecture:** Add remote-aware native service option resolution in `@caplets/core/native`, then implement a `RemoteNativeCapletsService` backed by MCP SDK `Client` + `StreamableHTTPClientTransport`. OpenCode and Pi continue using `createNativeCapletsService()` but pass host-specific config into it; Codex/Claude remain MCP-backed and get documented remote HTTP config examples. + +**Tech Stack:** TypeScript, Vitest, MCP SDK Streamable HTTP client, existing Caplets native service interfaces, OpenCode plugin API, Pi extension API, pnpm. + +--- + +## File structure + +- Create `packages/core/src/native/options.ts` for env/config resolution, mode selection, remote URL validation, and Basic Auth header creation. +- Create `packages/core/src/native/remote.ts` for remote MCP client wrapper and `RemoteNativeCapletsService`. +- Modify `packages/core/src/native/service.ts` to delegate to local or remote implementation. +- Modify `packages/core/src/native/tools.ts` only if remote tool guidance needs a helper; otherwise keep existing local helpers unchanged. +- Modify `packages/core/src/native.ts` to export new option types needed by integrations. +- Add `packages/core/test/native-options.test.ts`. +- Add `packages/core/test/native-remote.test.ts`. +- Modify `packages/core/test/process-cleanup.test.ts` only if `NativeCapletsService` type changes. +- Modify `packages/opencode/src/index.ts` to accept second-argument config and pass it into `createNativeCapletsService()`. +- Add `packages/opencode/src/config.ts` for OpenCode config normalization if the logic is more than one small helper. +- Modify `packages/opencode/test/opencode.test.ts` to verify second-argument config propagation. +- Modify `packages/opencode/README.md` with env and plugin-config remote examples. +- Modify `packages/pi/src/index.ts` to accept args/native options while preserving the `{ service }` test seam. +- Modify `packages/pi/test/pi.test.ts` to verify Pi args propagation. +- Modify `packages/pi/README.md` with `~/.pi/agent/settings.json` package args examples and note to use the active Pi settings path if different. +- Modify `README.md` and `plugins/caplets/skills/caplets/SKILL.md` with remote service guidance for Codex/Claude. +- Modify `packages/core/test/agent-plugins.test.ts` if docs/plugin artifact assertions need remote examples. +- Add a changeset for `@caplets/core`, `@caplets/opencode`, `@caplets/pi`, and `caplets`. + +--- + +### Task 1: Add native service option resolution + +**Files:** + +- Create: `packages/core/src/native/options.ts` +- Modify: `packages/core/src/native.ts` +- Test: `packages/core/test/native-options.test.ts` + +- [ ] **Step 1: Write failing option-resolution tests** + +Create `packages/core/test/native-options.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { CapletsError } from "../src/errors"; +import { resolveNativeCapletsServiceOptions } from "../src/native/options"; + +describe("resolveNativeCapletsServiceOptions", () => { + it("defaults to local mode without remote configuration", () => { + expect(resolveNativeCapletsServiceOptions({}, {})).toEqual({ + mode: "local", + }); + }); + + it("uses remote mode in auto when a remote URL is configured", () => { + expect( + resolveNativeCapletsServiceOptions({}, { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/mcp" }), + ).toMatchObject({ + mode: "remote", + remote: { + url: new URL("http://127.0.0.1:5387/mcp"), + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 30_000, + }, + }); + }); + + it("lets explicit local mode ignore remote env vars", () => { + expect( + resolveNativeCapletsServiceOptions( + { mode: "local" }, + { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/mcp" }, + ), + ).toEqual({ mode: "local" }); + }); + + it("requires a URL in explicit remote mode", () => { + expect(() => resolveNativeCapletsServiceOptions({ mode: "remote" }, {})).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + }); + + it("rejects non-loopback http URLs", () => { + expect(() => + resolveNativeCapletsServiceOptions({ remote: { url: "http://caplets.example.com/mcp" } }, {}), + ).toThrow(/https/u); + }); + + it("lets config override env vars", () => { + const configPassword = ["config", "password"].join("-"); + expect( + resolveNativeCapletsServiceOptions( + { + remote: { + url: "https://configured.example.com/mcp", + user: "configured", + password: configPassword, + }, + }, + { + CAPLETS_REMOTE_URL: "https://env.example.com/mcp", + CAPLETS_REMOTE_USER: "env-user", + CAPLETS_REMOTE_PASSWORD: ["env", "password"].join("-"), + }, + ), + ).toMatchObject({ + mode: "remote", + remote: { + url: new URL("https://configured.example.com/mcp"), + auth: { enabled: true, user: "configured", password: configPassword }, + }, + }); + }); + + it("defaults Basic Auth user when password exists", () => { + const password = ["remote", "password"].join("-"); + expect( + resolveNativeCapletsServiceOptions( + { remote: { url: "https://caplets.example.com/mcp", password } }, + {}, + ), + ).toMatchObject({ + remote: { auth: { enabled: true, user: "caplets", password } }, + }); + }); + + it("rejects user without password", () => { + expect(() => + resolveNativeCapletsServiceOptions( + { remote: { url: "https://caplets.example.com/mcp", user: "caplets" } }, + {}, + ), + ).toThrow(/requires a password/u); + }); + + it("builds request headers without logging credentials", () => { + const password = ["remote", "password"].join("-"); + const resolved = resolveNativeCapletsServiceOptions( + { + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + password, + }, + }, + {}, + ); + expect(resolved.mode).toBe("remote"); + expect(resolved.mode === "remote" ? resolved.remote.requestInit.headers : undefined).toEqual({ + Authorization: `Basic ${Buffer.from(`caplets:${password}`).toString("base64")}`, + }); + }); +}); +``` + +- [ ] **Step 2: Run option tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/native-options.test.ts +``` + +Expected: FAIL with module-not-found for `../src/native/options`. + +- [ ] **Step 3: Implement native option resolution** + +Create `packages/core/src/native/options.ts`: + +```ts +import { CapletsError } from "../errors"; + +export type NativeCapletsMode = "auto" | "local" | "remote"; + +export type NativeRemoteCapletsOptions = { + url?: string; + user?: string; + password?: string; + pollIntervalMs?: number; + fetch?: typeof fetch; +}; + +export type NativeCapletsServiceResolutionInput = { + mode?: NativeCapletsMode; + remote?: NativeRemoteCapletsOptions; +}; + +export type NativeCapletsEnv = Partial< + Record< + | "CAPLETS_NATIVE_MODE" + | "CAPLETS_REMOTE_URL" + | "CAPLETS_REMOTE_USER" + | "CAPLETS_REMOTE_PASSWORD", + string + > +>; + +export type NativeRemoteAuthOptions = + | { enabled: false; user: string } + | { enabled: true; user: string; password: string }; + +export type ResolvedNativeCapletsServiceOptions = + | { mode: "local" } + | { + mode: "remote"; + remote: { + url: URL; + auth: NativeRemoteAuthOptions; + pollIntervalMs: number; + requestInit: RequestInit; + fetch?: typeof fetch; + }; + }; + +const DEFAULT_REMOTE_USER = "caplets"; +const DEFAULT_POLL_INTERVAL_MS = 30_000; + +export function resolveNativeCapletsServiceOptions( + input: NativeCapletsServiceResolutionInput = {}, + env: NativeCapletsEnv = process.env, +): ResolvedNativeCapletsServiceOptions { + const mode = parseMode(input.mode ?? env.CAPLETS_NATIVE_MODE ?? "auto"); + if (mode === "local") { + return { mode: "local" }; + } + + const rawUrl = + nonEmpty(input.remote?.url, "remote.url") ?? + nonEmpty(env.CAPLETS_REMOTE_URL, "CAPLETS_REMOTE_URL"); + if (mode === "remote" && rawUrl === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "CAPLETS_NATIVE_MODE=remote requires CAPLETS_REMOTE_URL or remote.url.", + ); + } + if (rawUrl === undefined) { + return { mode: "local" }; + } + + const url = parseRemoteUrl(rawUrl); + const userWasExplicit = input.remote?.user !== undefined || hasEnv(env.CAPLETS_REMOTE_USER); + const user = + nonEmpty(input.remote?.user, "remote.user") ?? + nonEmpty(env.CAPLETS_REMOTE_USER, "CAPLETS_REMOTE_USER") ?? + DEFAULT_REMOTE_USER; + const password = + nonEmpty(input.remote?.password, "remote.password") ?? + nonEmpty(env.CAPLETS_REMOTE_PASSWORD, "CAPLETS_REMOTE_PASSWORD"); + + if (userWasExplicit && password === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "Remote Caplets Basic Auth requires a password; set CAPLETS_REMOTE_PASSWORD or remote.password.", + ); + } + + const auth: NativeRemoteAuthOptions = + password === undefined ? { enabled: false, user } : { enabled: true, user, password }; + const requestInit: RequestInit = auth.enabled + ? { headers: { Authorization: basicAuthHeader(auth.user, auth.password) } } + : {}; + + return { + mode: "remote", + remote: { + url, + auth, + pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs), + requestInit, + ...(input.remote?.fetch ? { fetch: input.remote.fetch } : {}), + }, + }; +} + +function parseMode(value: string): NativeCapletsMode { + if (value === "auto" || value === "local" || value === "remote") { + return value; + } + throw new CapletsError( + "REQUEST_INVALID", + `Expected CAPLETS_NATIVE_MODE to be auto, local, or remote, got ${value}`, + ); +} + +function parseRemoteUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new CapletsError("REQUEST_INVALID", `Invalid remote Caplets URL: ${value}`); + } + if (url.protocol === "https:") { + return url; + } + if (url.protocol === "http:" && isLoopbackHost(url.hostname)) { + return url; + } + throw new CapletsError( + "REQUEST_INVALID", + "Remote Caplets URL must use https except loopback development URLs.", + ); +} + +function isLoopbackHost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1"; +} + +function parsePollInterval(value: number | undefined): number { + if (value === undefined) { + return DEFAULT_POLL_INTERVAL_MS; + } + if (!Number.isInteger(value) || value < 1_000) { + throw new CapletsError("REQUEST_INVALID", "remote.pollIntervalMs must be an integer >= 1000."); + } + return value; +} + +function basicAuthHeader(user: string, password: string): string { + return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; +} + +function nonEmpty(value: string | undefined, label: string): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`); + } + return trimmed; +} + +function hasEnv(value: string | undefined): boolean { + return value !== undefined && value.trim() !== ""; +} +``` + +- [ ] **Step 4: Export option types from native entrypoint** + +Modify `packages/core/src/native.ts` to add: + +```ts +export { + resolveNativeCapletsServiceOptions, + type NativeCapletsMode, + type NativeRemoteCapletsOptions, + type ResolvedNativeCapletsServiceOptions, +} from "./native/options"; +``` + +- [ ] **Step 5: Run option tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/native-options.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit native option resolution** + +Run: + +```bash +git add packages/core/src/native/options.ts packages/core/src/native.ts packages/core/test/native-options.test.ts +git commit -m "feat(core): resolve remote native service options" +``` + +--- + +### Task 2: Add remote native service implementation + +**Files:** + +- Create: `packages/core/src/native/remote.ts` +- Modify: `packages/core/src/native/service.ts` +- Test: `packages/core/test/native-remote.test.ts` + +- [ ] **Step 1: Write failing remote-service tests** + +Create `packages/core/test/native-remote.test.ts`: + +```ts +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { CapletsError } from "../src/errors"; +import { RemoteNativeCapletsService, type RemoteCapletsClient } from "../src/native/remote"; +import { createNativeCapletsService } from "../src/native/service"; + +describe("RemoteNativeCapletsService", () => { + it("maps remote MCP tools to native Caplets tools", async () => { + const client = fakeRemoteClient({ + tools: [ + { + name: "git-hub", + title: "GitHub", + description: "GitHub progressive tools.", + inputSchema: { type: "object" }, + }, + ], + }); + const service = new RemoteNativeCapletsService({ + clientFactory: async () => client, + pollIntervalMs: 10_000, + }); + + await service.reload(); + + expect(service.listTools()).toEqual([ + expect.objectContaining({ + caplet: "git-hub", + toolName: "caplets_git_hub", + title: "GitHub", + }), + ]); + expect(service.listTools()[0]?.description).toContain("GitHub progressive tools."); + expect(service.listTools()[0]?.promptGuidance[0]).toContain("remote Caplets service"); + + await service.close(); + }); + + it("calls remote tools by Caplet ID", async () => { + const client = fakeRemoteClient({ + tools: [{ name: "linear", inputSchema: { type: "object" } }], + }); + const service = new RemoteNativeCapletsService({ + clientFactory: async () => client, + pollIntervalMs: 10_000, + }); + + const result = await service.execute("linear", { operation: "get_caplet" }); + + expect(client.callTool).toHaveBeenCalledWith("linear", { + operation: "get_caplet", + }); + expect(result).toEqual({ content: [{ type: "text", text: "ok" }] }); + + await service.close(); + }); + + it("notifies listeners on tool-list-change notifications", async () => { + const client = fakeRemoteClient({ + tools: [{ name: "alpha", inputSchema: { type: "object" } }], + }); + const service = new RemoteNativeCapletsService({ + clientFactory: async () => client, + pollIntervalMs: 10_000, + }); + const changes: string[][] = []; + service.onToolsChanged((tools) => changes.push(tools.map((tool) => tool.caplet))); + + await service.reload(); + client.setTools([{ name: "beta", title: "Beta", inputSchema: { type: "object" } }]); + await client.emitToolsChanged(); + + expect(changes).toEqual([["alpha"], ["beta"]]); + await service.close(); + }); + + it("keeps last known-good tools when refresh fails", async () => { + const errors: string[] = []; + const client = fakeRemoteClient({ + tools: [{ name: "alpha", inputSchema: { type: "object" } }], + }); + const service = new RemoteNativeCapletsService({ + clientFactory: async () => client, + pollIntervalMs: 10_000, + writeErr: (value) => errors.push(value), + }); + + await service.reload(); + client.failListTools = true; + const reloaded = await service.reload(); + + expect(reloaded).toBe(false); + expect(service.listTools().map((tool) => tool.caplet)).toEqual(["alpha"]); + expect(errors.join("")).toContain("keeping last known-good remote tools"); + await service.close(); + }); + + it("closes the client and stops future notifications", async () => { + const client = fakeRemoteClient({ + tools: [{ name: "alpha", inputSchema: { type: "object" } }], + }); + const service = new RemoteNativeCapletsService({ + clientFactory: async () => client, + pollIntervalMs: 10_000, + }); + const listener = vi.fn(); + service.onToolsChanged(listener); + + await service.reload(); + await service.close(); + client.setTools([{ name: "beta", inputSchema: { type: "object" } }]); + await client.emitToolsChanged(); + + expect(client.close).toHaveBeenCalled(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe("createNativeCapletsService remote mode", () => { + it("returns a remote service when remote mode resolves", async () => { + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387/mcp" }, + remoteClientFactory: async () => fakeRemoteClient({ tools: [] }), + }); + + await expect(service.reload()).resolves.toBe(true); + await service.close(); + }); + + it("fails fast for invalid remote configuration", () => { + expect(() => createNativeCapletsService({ mode: "remote" })).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + }); +}); + +type FakeRemoteTool = { + name: string; + title?: string; + description?: string; + inputSchema: { type: "object" }; +}; + +function fakeRemoteClient(initial: { tools: FakeRemoteTool[] }): RemoteCapletsClient & { + failListTools: boolean; + setTools(tools: FakeRemoteTool[]): void; + emitToolsChanged(): Promise; + callTool: ReturnType; + close: ReturnType; +} { + const events = new EventEmitter(); + let tools = initial.tools; + return { + failListTools: false, + setTools(next) { + tools = next; + }, + async emitToolsChanged() { + events.emit("toolsChanged"); + await Promise.resolve(); + }, + async listTools() { + if (this.failListTools) { + throw new Error("list failed"); + } + return tools; + }, + callTool: vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] })), + onToolsChanged(listener) { + events.on("toolsChanged", listener); + return () => events.off("toolsChanged", listener); + }, + close: vi.fn(async () => {}), + }; +} +``` + +- [ ] **Step 2: Run remote tests to verify red** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/native-remote.test.ts +``` + +Expected: FAIL because `../src/native/remote` does not exist and `remoteClientFactory` is not accepted. + +- [ ] **Step 3: Implement remote service** + +Create `packages/core/src/native/remote.ts`: + +```ts +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { version as packageJsonVersion } from "../../package.json"; +import { CapletsError } from "../errors"; +import type { + NativeCapletTool, + NativeCapletsService, + NativeCapletsToolsChangedListener, +} from "./service"; +import { nativeCapletToolName } from "./tools"; + +type RemoteTool = { + name: string; + title?: string; + description?: string; + inputSchema: { type: "object" }; +}; + +export type RemoteCapletsClient = { + listTools(): Promise; + callTool(name: string, arguments_: unknown): Promise; + onToolsChanged(listener: () => void | Promise): () => void; + close(): Promise; +}; + +export type RemoteCapletsClientFactory = () => Promise; + +export type RemoteNativeCapletsServiceOptions = { + clientFactory: RemoteCapletsClientFactory; + pollIntervalMs: number; + writeErr?: (value: string) => void; +}; + +export class RemoteNativeCapletsService implements NativeCapletsService { + private client: RemoteCapletsClient | undefined; + private tools: NativeCapletTool[] = []; + private readonly listeners = new Set(); + private unsubscribeToolsChanged: (() => void) | undefined; + private poll: NodeJS.Timeout | undefined; + private closed = false; + + constructor(private readonly options: RemoteNativeCapletsServiceOptions) {} + + listTools(): NativeCapletTool[] { + return this.tools; + } + + async execute(capletId: string, request: unknown): Promise { + const client = await this.ensureClient(); + try { + return await client.callTool(capletId, request); + } catch (error) { + throw classifyRemoteError(error, `Remote Caplets tool ${capletId} failed`); + } + } + + async reload(): Promise { + try { + const client = await this.ensureClient(); + const next = mapRemoteTools(await client.listTools()); + const changed = toolSignature(next) !== toolSignature(this.tools); + this.tools = next; + if (changed) { + this.emitToolsChanged(); + } + return true; + } catch (error) { + this.writeErr( + `Remote Caplets refresh failed; keeping last known-good remote tools: ${safeMessage(error)}\n`, + ); + return false; + } + } + + onToolsChanged(listener: NativeCapletsToolsChangedListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + if (this.poll) { + clearInterval(this.poll); + this.poll = undefined; + } + this.unsubscribeToolsChanged?.(); + this.unsubscribeToolsChanged = undefined; + await this.client?.close(); + this.client = undefined; + this.listeners.clear(); + } + + private async ensureClient(): Promise { + if (this.closed) { + throw new CapletsError("SERVER_UNAVAILABLE", "Remote Caplets service is closed."); + } + if (this.client) { + return this.client; + } + const client = await this.options.clientFactory(); + this.client = client; + this.unsubscribeToolsChanged = client.onToolsChanged(() => void this.reload()); + this.startPolling(); + return client; + } + + private startPolling(): void { + if (this.poll) { + return; + } + this.poll = setInterval(() => void this.reload(), this.options.pollIntervalMs); + this.poll.unref?.(); + } + + private emitToolsChanged(): void { + const snapshot = this.listTools(); + for (const listener of this.listeners) { + listener(snapshot); + } + } + + private writeErr(value: string): void { + (this.options.writeErr ?? process.stderr.write.bind(process.stderr))(value); + } +} + +export function createSdkRemoteCapletsClient(options: { + url: URL; + requestInit: RequestInit; + fetch?: typeof fetch; +}): RemoteCapletsClientFactory { + return async () => { + const transport = new StreamableHTTPClientTransport(options.url, { + requestInit: options.requestInit, + ...(options.fetch ? { fetch: options.fetch } : {}), + }); + const client = new Client( + { name: "caplets-native", version: packageJsonVersion }, + { + capabilities: {}, + }, + ); + await client.connect(transport); + return { + async listTools() { + const result = await client.listTools(); + return result.tools; + }, + async callTool(name, arguments_) { + return await client.callTool({ + name, + arguments: arguments_ as Record, + }); + }, + onToolsChanged(listener) { + client.setNotificationHandler( + { method: "notifications/tools/list_changed" } as never, + () => { + void listener(); + }, + ); + return () => + client.removeNotificationHandler({ + method: "notifications/tools/list_changed", + } as never); + }, + async close() { + await transport.terminateSession().catch(() => undefined); + await client.close(); + }, + }; + }; +} + +function mapRemoteTools(tools: RemoteTool[]): NativeCapletTool[] { + return tools + .map((tool) => { + const toolName = nativeCapletToolName(tool.name); + const title = tool.title ?? tool.name; + return { + caplet: tool.name, + toolName, + title, + description: [ + tool.description ?? `Remote Caplets capability domain ${title}.`, + "", + `Native tool name: ${toolName}`, + `Remote Caplet ID: ${tool.name}`, + ].join("\n"), + promptGuidance: [ + `Use ${toolName} for the ${title} remote Caplets service capability domain.`, + ], + }; + }) + .sort((left, right) => left.caplet.localeCompare(right.caplet)); +} + +function toolSignature(tools: NativeCapletTool[]): string { + return JSON.stringify(tools); +} + +function safeMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function classifyRemoteError(error: unknown, fallbackMessage: string): Error { + const message = safeMessage(error); + if (/401|403|unauthorized|forbidden/i.test(message)) { + return new CapletsError( + "AUTH_FAILED", + "Remote Caplets authentication failed. Check CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.", + ); + } + return error instanceof Error ? error : new CapletsError("SERVER_UNAVAILABLE", fallbackMessage); +} +``` + +If TypeScript rejects `setNotificationHandler` object literals, use the SDK's exported notification schema type for tool-list changes or fall back to client options `listChanged.tools.onChanged` during `new Client(...)`. Keep the public `RemoteCapletsClient` interface unchanged so tests stay stable. + +- [ ] **Step 4: Wire service factory** + +Modify `packages/core/src/native/service.ts`: + +- Add imports: + +```ts +import { + resolveNativeCapletsServiceOptions, + type NativeCapletsServiceResolutionInput, +} from "./options"; +import { + createSdkRemoteCapletsClient, + RemoteNativeCapletsService, + type RemoteCapletsClientFactory, +} from "./remote"; +``` + +- Extend `NativeCapletsServiceOptions`: + +```ts +export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput & { + configPath?: string; + projectConfigPath?: string; + authDir?: string; + watchDebounceMs?: number; + watch?: boolean; + writeErr?: (value: string) => void; + remoteClientFactory?: RemoteCapletsClientFactory; +}; +``` + +- Replace `createNativeCapletsService` with: + +```ts +export function createNativeCapletsService( + options: NativeCapletsServiceOptions = {}, +): NativeCapletsService { + const resolved = resolveNativeCapletsServiceOptions(options); + if (resolved.mode === "remote") { + return new RemoteNativeCapletsService({ + clientFactory: + options.remoteClientFactory ?? + createSdkRemoteCapletsClient({ + url: resolved.remote.url, + requestInit: resolved.remote.requestInit, + ...(resolved.remote.fetch ? { fetch: resolved.remote.fetch } : {}), + }), + pollIntervalMs: resolved.remote.pollIntervalMs, + ...(options.writeErr ? { writeErr: options.writeErr } : {}), + }); + } + return new DefaultNativeCapletsService(localEngineOptions(options)); +} +``` + +- Add helper: + +```ts +function localEngineOptions( + options: NativeCapletsServiceOptions, +): ConstructorParameters[0] { + const engineOptions: ConstructorParameters[0] = {}; + if (options.configPath !== undefined) engineOptions.configPath = options.configPath; + if (options.projectConfigPath !== undefined) + engineOptions.projectConfigPath = options.projectConfigPath; + if (options.authDir !== undefined) engineOptions.authDir = options.authDir; + if (options.watchDebounceMs !== undefined) + engineOptions.watchDebounceMs = options.watchDebounceMs; + if (options.watch !== undefined) engineOptions.watch = options.watch; + if (options.writeErr !== undefined) engineOptions.writeErr = options.writeErr; + return engineOptions; +} +``` + +- [ ] **Step 5: Export remote types from native entrypoint** + +Modify `packages/core/src/native.ts` to add: + +```ts +export { + RemoteNativeCapletsService, + createSdkRemoteCapletsClient, + type RemoteCapletsClient, + type RemoteCapletsClientFactory, +} from "./native/remote"; +``` + +- [ ] **Step 6: Run remote tests and typecheck** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/native-remote.test.ts test/native-options.test.ts test/process-cleanup.test.ts +pnpm --filter @caplets/core typecheck +``` + +Expected: PASS. If SDK notification typing requires a different handler shape, fix `createSdkRemoteCapletsClient` without changing tests. + +- [ ] **Step 7: Commit remote native service** + +Run: + +```bash +git add packages/core/src/native/remote.ts packages/core/src/native/service.ts packages/core/src/native.ts packages/core/test/native-remote.test.ts +git commit -m "feat(core): add remote native Caplets service" +``` + +--- + +### Task 3: Add OpenCode second-argument config + +**Files:** + +- Modify: `packages/opencode/src/index.ts` +- Test: `packages/opencode/test/opencode.test.ts` +- Docs: `packages/opencode/README.md` + +- [ ] **Step 1: Write failing OpenCode config propagation test** + +Append this test to `packages/opencode/test/opencode.test.ts`: + +```ts +it("passes second-argument config into the native service", async () => { + vi.resetModules(); + const nativeMocks = vi.hoisted(() => ({ + createNativeCapletsService: vi.fn(() => ({ + listTools: () => [], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + })), + registerNativeCapletsProcessCleanup: vi.fn(), + })); + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index.js")).default; + + await plugin( + {} as never, + { + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + pollIntervalMs: 5_000, + }, + } as never, + ); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + pollIntervalMs: 5_000, + }, + }); +}); +``` + +If this conflicts with the existing top-level `vi.mock("@caplets/core/native")`, instead add a new isolated test file `packages/opencode/test/opencode-config.test.ts` that mocks before importing `../src/index.js`. + +- [ ] **Step 2: Run OpenCode test to verify red** + +Run: + +```bash +pnpm --filter @caplets/opencode test -- test/opencode.test.ts +``` + +Expected: FAIL because plugin ignores the second argument. + +- [ ] **Step 3: Update OpenCode plugin signature** + +Modify `packages/opencode/src/index.ts`: + +```ts +import { type Plugin, type PluginInput } from "@opencode-ai/plugin"; +import { + createNativeCapletsService, + registerNativeCapletsProcessCleanup, + type NativeCapletsServiceOptions, +} from "@caplets/core/native"; +import { createCapletsOpenCodeHooks } from "./hooks"; + +export type CapletsOpenCodeConfig = Pick; + +const plugin: Plugin = async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => { + const service = createNativeCapletsService(normalizeOpenCodeConfig(config)); + registerNativeCapletsProcessCleanup(service); + return createCapletsOpenCodeHooks(service); +}; + +function normalizeOpenCodeConfig(config: CapletsOpenCodeConfig | undefined): CapletsOpenCodeConfig { + if (!config) { + return {}; + } + return { + ...(config.mode ? { mode: config.mode } : {}), + ...(config.remote ? { remote: config.remote } : {}), + }; +} + +export default plugin; +``` + +If `Plugin` type does not allow the second argument, define the implementation separately and cast at export: + +```ts +const plugin = (async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => { ... }) as Plugin; +``` + +- [ ] **Step 4: Document OpenCode remote config** + +Append to `packages/opencode/README.md`: + +````md +## Remote Caplets service + +By default the plugin reads local Caplets config. To use a remote `caplets serve --transport http` service, set environment variables: + +```sh +CAPLETS_REMOTE_URL=http://127.0.0.1:5387/mcp opencode +``` +```` + +For authenticated remote services, keep the password in the environment: + +```sh +CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \ +CAPLETS_REMOTE_USER=caplets \ +CAPLETS_REMOTE_PASSWORD=... \ +opencode +``` + +OpenCode plugin config can also pass non-secret settings as the plugin factory's second argument: + +```ts +export default { + plugin: [ + [ + "@caplets/opencode", + { + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + }, + }, + ], + ], +}; +``` + +Plugin config overrides environment variables. Prefer `CAPLETS_REMOTE_PASSWORD` for the Basic Auth password unless your OpenCode setup provides secure secret storage. + +````md +- [ ] **Step 5: Run OpenCode tests** + +Run: + +```bash +pnpm --filter @caplets/opencode test -- test/opencode.test.ts +pnpm --filter @caplets/opencode typecheck +``` +```` + +Expected: PASS. + +- [ ] **Step 6: Commit OpenCode config support** + +Run: + +```bash +git add packages/opencode/src/index.ts packages/opencode/test/opencode.test.ts packages/opencode/README.md +git commit -m "feat(opencode): accept remote Caplets config" +``` + +--- + +### Task 4: Add Pi args/native option support + +**Files:** + +- Modify: `packages/pi/src/index.ts` +- Test: `packages/pi/test/pi.test.ts` +- Docs: `packages/pi/README.md` + +- [ ] **Step 1: Write failing Pi args propagation test** + +Add this test near the existing service-creation tests in `packages/pi/test/pi.test.ts`: + +```ts +it("passes Pi args into the native service", () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + const pi = mockPiApi(); + + capletsPiExtension(pi, { + args: { + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + pollIntervalMs: 5_000, + }, + }, + }); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + pollIntervalMs: 5_000, + }, + }); +}); +``` + +If the existing `CapletsPiOptions` type rejects `args`, the test should fail at typecheck until implementation. + +- [ ] **Step 2: Run Pi test to verify red** + +Run: + +```bash +pnpm --filter @caplets/pi test -- test/pi.test.ts +``` + +Expected: FAIL because `args` is not part of `CapletsPiOptions` and service creation ignores it. + +- [ ] **Step 3: Update Pi option types and service creation** + +Modify `packages/pi/src/index.ts` imports: + +```ts +import { + createNativeCapletsService, + registerNativeCapletsProcessCleanup, + type NativeCapletTool, + type NativeCapletsService, + type NativeCapletsServiceOptions, +} from "@caplets/core/native"; +``` + +Replace `CapletsPiOptions` with: + +```ts +export type CapletsPiOptions = { + service?: NativeCapletsService; + native?: Pick; + args?: Pick; +}; +``` + +Replace service creation with: + +```ts +const ownsService = !options.service; +const serviceOptions = options.native ?? options.args ?? {}; +const service = options.service ?? createNativeCapletsService(serviceOptions); +``` + +Do not change behavior when `service` is injected; tests and advanced users rely on that seam. + +- [ ] **Step 4: Document Pi settings args** + +Append to `packages/pi/README.md`: + +````md +## Remote Caplets service + +By default the extension reads local Caplets config. To use a remote `caplets serve --transport http` service, set environment variables: + +```sh +CAPLETS_REMOTE_URL=http://127.0.0.1:5387/mcp pi +``` +```` + +For authenticated remote services, keep the password in the environment: + +```sh +CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \ +CAPLETS_REMOTE_USER=caplets \ +CAPLETS_REMOTE_PASSWORD=... \ +pi +``` + +You can also pass non-secret remote settings through Pi package args in your Pi user settings file. Current Pi docs use `~/.pi/agent/settings.json`; use your runtime's active settings path if it differs: + +```json +{ + "packages": [ + { + "source": "npm:@caplets/pi", + "args": { + "mode": "remote", + "remote": { + "url": "https://caplets.example.com/mcp", + "user": "caplets" + } + } + } + ] +} +``` + +Package args override environment variables. Prefer `CAPLETS_REMOTE_PASSWORD` for the Basic Auth password unless your Pi setup provides secure secret storage. + +````md +- [ ] **Step 5: Run Pi tests** + +Run: + +```bash +pnpm --filter @caplets/pi test -- test/pi.test.ts +pnpm --filter @caplets/pi typecheck +``` +```` + +Expected: PASS. + +- [ ] **Step 6: Commit Pi args support** + +Run: + +```bash +git add packages/pi/src/index.ts packages/pi/test/pi.test.ts packages/pi/README.md +git commit -m "feat(pi): accept remote Caplets args" +``` + +--- + +### Task 5: Document Codex and Claude remote MCP configuration + +**Files:** + +- Modify: `README.md` +- Modify: `plugins/caplets/skills/caplets/SKILL.md` +- Modify: `packages/core/test/agent-plugins.test.ts` if needed + +- [ ] **Step 1: Add failing docs assertion if appropriate** + +If `packages/core/test/agent-plugins.test.ts` already checks README/plugin guidance, add a test asserting the root README includes `CAPLETS_REMOTE_URL` and `caplets serve --transport http`. Use this pattern: + +```ts +it("documents remote Caplets service configuration for MCP-backed plugins", () => { + const readme = readFileSync(path.join(repoRoot, "README.md"), "utf8"); + expect(readme).toContain("CAPLETS_REMOTE_URL"); + expect(readme).toContain("caplets serve --transport http"); + expect(readme).toContain("https://caplets.example.com/mcp"); +}); +``` + +If that test file is not a good fit, skip this test and rely on docs review plus `pnpm test`. + +- [ ] **Step 2: Update root README Agent Plugins section** + +In `README.md`, after the existing paragraph ending with “install the Caplets CLI globally first,” add: + +````md +### Remote Caplets service + +OpenCode and Pi can use native `caplets_` tools backed by a remote Caplets HTTP service. Codex, Claude Code, and any MCP client can connect to the same remote MCP endpoint directly. + +Start the remote service: + +```sh +caplets serve --transport http --host 127.0.0.1 --port 5387 --path /mcp +``` +```` + +For authenticated network use, configure Basic Auth on the server and keep credentials out of plugin manifests: + +```sh +CAPLETS_SERVER_PASSWORD=... caplets serve --transport http --host 0.0.0.0 +``` + +Native integrations read remote client settings from environment variables: + +```sh +CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \ +CAPLETS_REMOTE_USER=caplets \ +CAPLETS_REMOTE_PASSWORD=... \ +opencode +``` + +For MCP-backed Codex or Claude Code configs, point the agent's MCP server entry at the remote URL using that agent's supported HTTP MCP configuration. If Basic Auth is needed, use the agent's secure secret or environment interpolation mechanism rather than hardcoding credentials. + +````md +- [ ] **Step 3: Update Caplets skill guidance** + +In `plugins/caplets/skills/caplets/SKILL.md`, add one bullet under “Guidance”: + +```md +- When Caplets is configured as a remote MCP HTTP service, treat connection/auth failures as remote-service issues and ask the user to verify `CAPLETS_REMOTE_URL`, Basic Auth credentials, and that `caplets serve --transport http` is running. +``` +```` + +- [ ] **Step 4: Run docs/plugin tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/agent-plugins.test.ts +pnpm format:check +``` + +Expected: PASS. + +- [ ] **Step 5: Commit remote MCP documentation** + +Run: + +```bash +git add README.md plugins/caplets/skills/caplets/SKILL.md packages/core/test/agent-plugins.test.ts +git commit -m "docs: document remote Caplets service connections" +``` + +If `agent-plugins.test.ts` was not modified, omit it from `git add`. + +--- + +### Task 6: Add changeset + +**Files:** + +- Create: `.changeset/.md` + +- [ ] **Step 1: Create changeset file** + +Create a changeset such as `.changeset/remote-native-caplets.md`: + +```md +--- +"@caplets/core": minor +"@caplets/opencode": minor +"@caplets/pi": minor +"caplets": patch +--- + +Add remote Caplets service support for native integrations, including remote-backed OpenCode and Pi native tools plus documentation for MCP-backed Codex and Claude Code remote connections. +``` + +- [ ] **Step 2: Check changeset status** + +Run: + +```bash +pnpm changeset status --since=origin/main +``` + +Expected: output includes minor bumps for `@caplets/core`, `@caplets/opencode`, and `@caplets/pi`. `caplets` may appear as patch or as an internal dependent bump depending on Changesets dependency analysis. + +- [ ] **Step 3: Commit changeset** + +Run: + +```bash +git add .changeset/remote-native-caplets.md +git commit -m "chore: add changeset for remote native integrations" +``` + +--- + +### Task 7: Full verification + +**Files:** + +- No source files unless verification reveals issues. + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts test/process-cleanup.test.ts test/agent-plugins.test.ts +pnpm --filter @caplets/opencode test -- test/opencode.test.ts +pnpm --filter @caplets/pi test -- test/pi.test.ts +``` + +Expected: PASS. + +- [ ] **Step 2: Run typecheck** + +Run: + +```bash +pnpm typecheck +``` + +Expected: PASS. + +- [ ] **Step 3: Run all tests** + +Run: + +```bash +pnpm test +``` + +Expected: PASS. + +- [ ] **Step 4: Run full gate** + +Run: + +```bash +pnpm verify +``` + +Expected: PASS through format, lint, typecheck, schema check, tests, benchmark check, and build. + +- [ ] **Step 5: Commit verification fixes if any** + +If verification required source changes, review the working tree and stage only the files changed by those fixes: + +```bash +git status --short +git add packages/core/src/native/options.ts packages/core/src/native/remote.ts packages/core/src/native/service.ts packages/core/src/native.ts packages/opencode/src/index.ts packages/pi/src/index.ts README.md packages/opencode/README.md packages/pi/README.md plugins/caplets/skills/caplets/SKILL.md +git commit -m "fix: polish remote native integration support" +``` + +If some listed files were not changed, `git add` will still succeed for tracked files. If verification fixes touched tests or a changeset, add those concrete changed paths too after checking `git status --short`. If no fixes were needed, do not create an empty commit. + +--- + +## Self-review notes + +- Spec coverage: Tasks 1 and 2 cover core remote option resolution, Basic Auth, URL validation, remote MCP client use, refresh notifications, polling fallback, last known-good behavior, and cleanup. Tasks 3 and 4 cover OpenCode second-argument config and Pi settings/package args. Task 5 covers Codex/Claude MCP-backed remote guidance. Task 6 covers release metadata. Task 7 covers verification. +- Red-flag scan: no incomplete implementation steps are intentionally left. Conditional notes are limited to concrete SDK/host typing compatibility paths with stable public interfaces preserved. +- Type consistency: `NativeCapletsServiceOptions`, `NativeRemoteCapletsOptions`, `RemoteCapletsClient`, `RemoteCapletsClientFactory`, and `RemoteNativeCapletsService` names are introduced before later tasks consume them. diff --git a/docs/specs/2026-05-19-http-mcp-serving-design.md b/docs/specs/2026-05-19-http-mcp-serving-design.md new file mode 100644 index 00000000..034652fd --- /dev/null +++ b/docs/specs/2026-05-19-http-mcp-serving-design.md @@ -0,0 +1,240 @@ +# HTTP MCP Serving via `caplets serve` + +## Status + +Approved design from grilling session. Implementation plan included in this PR stack. + +## Goal + +Add opt-in HTTP MCP serving to `caplets serve` while preserving stdio serving as the default explicit serve transport. HTTP mode should expose Caplets over modern MCP Streamable HTTP using Hono and `@hono/mcp`, with optional Basic Auth, safe localhost defaults, and clean session/shutdown handling. + +## Non-goals + +- Do not make HTTP the default transport. +- Do not support deprecated HTTP+SSE endpoints in v1. +- Do not allow unauthenticated non-loopback HTTP serving unless the operator explicitly opts in. +- Do not keep no-arg `caplets` as an implicit MCP stdio server. + +## CLI behavior + +```sh +caplets +# prints help and exits 0 + +caplets serve +# serves MCP over stdio + +caplets serve --transport stdio +# same as above + +caplets serve --transport http +# serves MCP over Streamable HTTP at http://127.0.0.1:5387/mcp +``` + +Serve options: + +```text +--transport +--host # HTTP only, default 127.0.0.1 +--port # HTTP only, default 5387 +--path # HTTP only, default /mcp +--user # HTTP only, optional; defaults to env or caplets when password is set +--password # HTTP only, optional; enables Basic Auth +--allow-unauthenticated-http # HTTP only, required for unauthenticated non-loopback serving +``` + +Validation rules: + +- Unknown transport errors. +- Port must be a valid TCP port. +- Path must start with `/`, contain no query string or fragment, and normalize trailing slashes except for root. +- `--host`, `--port`, `--path`, `--user`, `--password`, and `--allow-unauthenticated-http` are invalid with stdio transport. +- `caplets` with no arguments prints help and exits successfully. +- `caplets serve` remains quiet in stdio mode to preserve MCP framing. + +## HTTP serving behavior + +HTTP mode uses Hono, `@hono/node-server`, and `@hono/mcp` `StreamableHTTPTransport`. + +Required direct dependencies in `@caplets/core`: + +```json +{ + "@hono/mcp": "^0.3.0", + "@hono/node-server": "^1.19.9", + "hono": "^4.11.5" +} +``` + +Endpoints: + +```text +GET / -> info JSON, unauthenticated +GET /healthz -> health JSON, unauthenticated +GET/POST/DELETE /mcp -> MCP Streamable HTTP, Basic Auth protected when enabled +``` + +`GET /` returns informational JSON similar to: + +```json +{ + "name": "caplets", + "transport": "http", + "mcp": "/mcp", + "health": "/healthz", + "auth": { "type": "basic", "enabled": true } +} +``` + +`GET /healthz` returns: + +```json +{ + "status": "ok", + "transport": "http", + "mcpPath": "/mcp" +} +``` + +Only the exact normalized MCP path handles MCP requests. For example, when `--path /mcp`, `/mcp/foo` returns 404. + +## Basic Auth + +Basic Auth is optional and applies only to the MCP path. + +Credential resolution: + +- Effective user is `--user`, else `CAPLETS_SERVER_USER`, else `caplets`. +- Effective password is `--password`, else `CAPLETS_SERVER_PASSWORD`. +- Auth is enabled only when an effective password exists. +- If an explicit user is provided via `--user` or `CAPLETS_SERVER_USER` but no password exists, fail fast with a clear error asking for `--password` or `CAPLETS_SERVER_PASSWORD`. +- Never log the password. + +Non-loopback and wildcard hosts may run without authentication only when the operator explicitly passes `--allow-unauthenticated-http`; otherwise HTTP startup must fail fast. When explicit unauthenticated non-loopback serving is allowed, startup must still warn. + +## DNS rebinding protection + +The Hono MCP transport should enable DNS rebinding protection for loopback hosts: `127.0.0.1`, `localhost`, and `::1`. + +For loopback serving, configure `StreamableHTTPTransport` with: + +- `enableDnsRebindingProtection: true` +- `allowedHosts` containing the expected loopback host and host-with-port values. +- `allowedOrigins` for matching localhost origins when practical. + +For non-loopback hosts, do not enable strict host validation by default. Require Basic Auth unless `--allow-unauthenticated-http` is present, and warn when explicit unauthenticated non-loopback serving is used. + +## Session model + +Use modern Streamable HTTP only. + +- Initial `POST` to the MCP path without `Mcp-Session-Id` creates a session. +- Each session gets a random ID from `crypto.randomUUID()`. +- Each session owns one `@hono/mcp` `StreamableHTTPTransport` and one MCP server instance. +- Later `GET`, `POST`, and `DELETE` requests with `Mcp-Session-Id` route to the matching session. +- Missing or unknown session IDs return JSON-RPC-style errors consistent with MCP transport behavior. +- `DELETE` closes the session, removes it from the session map, and closes its server/transport. +- Global shutdown closes all active sessions. + +## Runtime architecture + +Refactor the current runtime shape into reusable pieces: + +1. **Shared engine owner** + - One `CapletsEngine` owns config loading, file watching, reload serialization, backend managers, auth dir, and last-known-good config. + +2. **MCP session wrapper** + - Creates an `McpServer`, registers Caplet tools from the shared engine's current config, and subscribes to engine reload events. + - Reconciles tools on reload. + - Connects to exactly one MCP transport. + +3. **Stdio serving** + - Creates one shared engine, one MCP session wrapper, and one `StdioServerTransport`. + +4. **HTTP serving** + - Creates one shared engine. + - Creates one MCP session wrapper per Hono MCP session. + - Shares tool registration and reload reconciliation logic with stdio. + +This respects the MCP SDK constraint that a protocol/server instance connects to only one transport, while avoiding duplicate config watchers and backend managers per HTTP client. + +## Startup and shutdown + +HTTP startup logs go to stderr, not stdout: + +```text +Caplets MCP HTTP server listening on http://127.0.0.1:5387/mcp +Health check: http://127.0.0.1:5387/healthz +Basic Auth: enabled (user: caplets) +``` + +If HTTP listens on a non-loopback or wildcard host without auth, also print a warning to stderr. + +Shutdown behavior: + +- Handle `SIGINT` and `SIGTERM` in serve mode. +- Stop accepting new HTTP connections. +- Close all active MCP sessions. +- Close the shared Caplets engine and downstream connections. +- Exit `130` on `SIGINT` and `143` on `SIGTERM`. +- If cleanup fails, log the error to stderr and still exit. + +## Implementation touchpoints + +Likely files: + +- `packages/cli/src/index.ts` + - Remove special-casing for no args and `serve`; always delegate to `runCli`. + +- `packages/core/src/cli.ts` + - Add Commander `serve` command. + - Add no-arg help behavior. + - Parse and validate serve options. + - Wire signal handling for serve mode. + +- New core serve modules, likely: + - `packages/core/src/serve/options.ts` + - `packages/core/src/serve/stdio.ts` + - `packages/core/src/serve/http.ts` + - `packages/core/src/serve/session.ts` + +- `packages/core/src/runtime.ts` + - Keep as compatibility wrapper or refactor to use the shared session helper. + +- `packages/core/package.json` + - Add Hono dependencies. + +## Testing plan + +CLI/options tests: + +- `caplets` with no args prints help and does not serve. +- `serve` defaults to stdio. +- `serve --transport http` parses defaults: host `127.0.0.1`, port `5387`, path `/mcp`. +- Invalid transport, port, and path error. +- HTTP-only options with stdio error. +- Auth resolution from flags/env/default user works. + +HTTP integration tests: + +- `GET /` returns info JSON. +- `GET /healthz` returns health JSON. +- Basic Auth blocks MCP path when password is configured. +- MCP initialize/list-tools works over Streamable HTTP. +- Session cleanup works on `DELETE` or transport close. + +Verification commands: + +```sh +pnpm --filter @caplets/core test -- test/.test.ts +pnpm --filter @caplets/core typecheck +pnpm typecheck +pnpm test +pnpm verify +``` + +## References + +- Hono MCP README: +- Hono MCP docs: +- Hono MCP stateful docs: diff --git a/docs/specs/2026-05-19-remote-native-integrations-design.md b/docs/specs/2026-05-19-remote-native-integrations-design.md new file mode 100644 index 00000000..8521d0e6 --- /dev/null +++ b/docs/specs/2026-05-19-remote-native-integrations-design.md @@ -0,0 +1,243 @@ +# Remote Caplets Service for Native Integrations Design + +## Goal + +Let Caplets integrations connect to a remote `caplets serve --transport http` service instead of always loading local Caplets config and backends. The first implementation should support all existing integration surfaces: + +- OpenCode native plugin (`@caplets/opencode`) keeps native `caplets_` tools. +- Pi native extension (`@caplets/pi`) keeps native `caplets_` tools. +- Codex and Claude Code plugin artifacts can point at a remote MCP HTTP service instead of spawning local `caplets serve`. + +The feature should build on the Hono Streamable HTTP server added for `caplets serve --transport http` and should not introduce a separate Caplets-specific HTTP API. + +## Non-goals + +- Do not add a hosted Caplets cloud service. +- Do not add OAuth client flows for connecting to the Caplets service in v1. Basic Auth and unauthenticated loopback development are enough. +- Do not remove local mode. Local mode remains the default unless remote configuration is present or explicitly selected. +- Do not flatten remote downstream tools directly into Codex/Claude plugin manifests. Codex/Claude remain MCP-backed. + +## Configuration model + +Remote client settings have three conceptual fields: + +- `url`: full MCP endpoint URL, for example `http://127.0.0.1:5387/mcp` or `https://caplets.example.com/mcp`. +- `user`: Basic Auth user. Defaults to `caplets` when a password is present. +- `password`: Basic Auth password. If a user is explicitly provided without a password, configuration is invalid. + +Universal environment variables: + +- `CAPLETS_REMOTE_URL` +- `CAPLETS_REMOTE_USER` +- `CAPLETS_REMOTE_PASSWORD` +- `CAPLETS_NATIVE_MODE` with values `auto`, `local`, or `remote` + +Resolution rules: + +1. Integration-specific config overrides environment variables. +2. OpenCode integration-specific config is read from the plugin factory's second argument and translated into `NativeCapletsServiceOptions`. +3. Pi integration-specific config is read from package/extension args passed through Pi settings. The implementation should consume args passed by Pi rather than parsing settings files directly; examples use Pi's current documented user settings path (`~/.pi/agent/settings.json`) and should be adjusted if the active runtime path is `~/.pi/agents/settings.json`. +4. `CAPLETS_NATIVE_MODE=local` forces current local behavior and ignores remote URL variables. +5. `CAPLETS_NATIVE_MODE=remote` requires a remote URL. +6. `CAPLETS_NATIVE_MODE=auto` or unset uses remote mode when a remote URL is configured, otherwise local mode. +7. Remote URLs must be `https:` except loopback `http://localhost`, `http://127.0.0.1`, and `http://[::1]` for development. +8. Basic Auth is enabled only when a password is present. `user` without `password` fails fast with a clear error. + +## Core architecture + +Add a native service factory that chooses between local and remote implementations: + +```ts +createNativeCapletsService(options?: NativeCapletsServiceOptions): NativeCapletsService +``` + +`NativeCapletsServiceOptions` gains: + +```ts +type NativeCapletsServiceOptions = { + mode?: "auto" | "local" | "remote"; + remote?: { + url?: string; + user?: string; + password?: string; + pollIntervalMs?: number; + fetch?: typeof fetch; + }; + // existing local options stay unchanged +}; +``` + +The existing `DefaultNativeCapletsService` becomes the local implementation. A new `RemoteNativeCapletsService` uses MCP SDK client primitives: + +- `Client` from `@modelcontextprotocol/sdk/client/index.js` +- `StreamableHTTPClientTransport` from `@modelcontextprotocol/sdk/client/streamableHttp.js` + +Remote mode connects once, initializes MCP, lists remote tools, maps them to `NativeCapletTool`, and calls tools through `client.callTool()`. + +## Tool mapping + +The remote Caplets HTTP server already exposes one MCP tool per Caplet, with the Caplet ID as the MCP tool name. The remote native service maps each remote MCP tool to the same native shape local mode uses: + +- `caplet`: remote MCP tool name +- `toolName`: `nativeCapletToolName(remoteTool.name)`, preserving `caplets_` names +- `title`: remote MCP tool title when present, otherwise remote tool name +- `description`: remote MCP tool description, wrapped with native Caplets guidance where useful +- `promptGuidance`: generated guidance that says the tool is backed by the configured remote Caplets service + +Execution passes the existing generated Caplets input schema through unchanged. Native integrations continue to pass parameters directly to `service.execute(capletId, params)`. + +## Refresh and notifications + +Remote mode should prefer live MCP notifications but always have a polling fallback. + +1. On connect, call `listTools()` and notify listeners if the mapped tool list changed. +2. Register an MCP client `listChanged.tools.onChanged` handler when the server advertises tool-list-change notifications. The handler refreshes tools and notifies native listeners. +3. Also poll at a default interval, proposed `30_000ms`, to cover transports, proxies, or servers that do not deliver notifications reliably. +4. `reload()` manually refreshes the remote tool list and reconnects once if the session is closed or invalid. +5. `close()` stops polling, terminates the MCP session when possible, closes transport/client resources, and prevents further listener notifications. + +OpenCode still cannot dynamically add newly discovered native tools after plugin load because its plugin API snapshots tools at load time. The remote service should still keep its internal list fresh so executions use current remote state, and the README should document that OpenCode restart may be needed for newly added or renamed Caplets. Pi can continue registering newly discovered tools during the current session. + +## Error handling + +Remote service errors should be explicit and agent-friendly: + +- Missing remote URL in forced remote mode: configuration error before integration registration. +- Invalid URL or disallowed non-HTTPS URL: configuration error. +- User without password: configuration error. +- 401/403: auth error mentioning `CAPLETS_REMOTE_USER` and `CAPLETS_REMOTE_PASSWORD`. +- Connection failure: remote unavailable error including host and path but not credentials. +- Tool removed between list and call: normal Caplets-style structured tool error where possible, otherwise a clear remote tool-not-found error. +- Notification/poll refresh failure: keep last known-good tools, report through `writeErr`, and retry on the next poll/manual reload. + +No logs may include Basic Auth credentials or full `Authorization` headers. + +## Integration behavior + +### OpenCode + +`@caplets/opencode` should accept remote configuration through the plugin factory's second argument while preserving environment-variable fallback: + +```ts +type CapletsOpenCodeConfig = { + mode?: "auto" | "local" | "remote"; + remote?: { + url?: string; + user?: string; + password?: string; + pollIntervalMs?: number; + }; +}; + +const plugin: Plugin = async (_ctx, config?: CapletsOpenCodeConfig) => { + const service = createNativeCapletsService(nativeOptionsFromOpenCodeConfig(config)); + // existing hook registration continues +}; +``` + +OpenCode config values override environment variables. README examples should show both environment-variable configuration and second-argument/plugin configuration in whichever JSON/TypeScript shape OpenCode currently documents for plugin config. The second-argument config is the preferred non-secret location for `url`, `mode`, and `user`; `password` should still usually come from `CAPLETS_REMOTE_PASSWORD` unless OpenCode provides a secure secret mechanism. + +```sh +CAPLETS_REMOTE_URL=http://127.0.0.1:5387/mcp opencode +CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \ +CAPLETS_REMOTE_USER=caplets \ +CAPLETS_REMOTE_PASSWORD=... \ +opencode +``` + +### Pi + +`@caplets/pi` continues to accept an injected `service` for tests and advanced users. It should also accept native service options from Pi package/extension args loaded from Pi settings, so users can configure remote mode in Pi user settings without relying only on environment variables. + +Example target shape: + +```json +{ + "packages": [ + { + "source": "npm:@caplets/pi", + "args": { + "mode": "remote", + "remote": { + "url": "https://caplets.example.com/mcp", + "user": "caplets" + } + } + } + ] +} +``` + +Pi args override environment variables. As with OpenCode, `password` should normally come from `CAPLETS_REMOTE_PASSWORD` unless Pi adds a secure secret surface. The extension API should keep the current test seam by accepting `{ service }`, and it should merge that with `{ native?: NativeCapletsServiceOptions }` or `{ args?: NativeCapletsServiceOptions }` without breaking existing callers. + +### Codex and Claude Code + +The plugin artifacts remain MCP-backed. The default bundled `plugins/caplets/mcp.json` should continue to use local stdio for zero-config installs: + +```json +{ + "mcpServers": { + "caplets": { + "command": "caplets", + "args": ["serve"] + } + } +} +``` + +Add a documented remote variant users can copy into their agent config: + +```json +{ + "mcpServers": { + "caplets": { + "url": "https://caplets.example.com/mcp", + "headers": { + "Authorization": "Basic ${base64(CAPLETS_REMOTE_USER + ':' + CAPLETS_REMOTE_PASSWORD)}" + } + } + } +} +``` + +Because Codex/Claude plugin manifest schemas may not support runtime environment interpolation in bundled MCP JSON consistently, v1 should document remote configuration rather than shipping secrets or a second remote-only plugin manifest. If both agents support local plugin config overrides, add examples there; otherwise keep the plugin artifact unchanged. + +## Security + +- Remote mode must never persist passwords unless the host integration explicitly provides secure storage later. +- Do not put passwords in plugin manifests. +- Prefer `https:` for non-loopback endpoints and reject non-loopback `http:`. +- Basic Auth should be created in memory per request using `requestInit.headers.Authorization` for the MCP HTTP transport. +- The default server remains loopback-only and unauthenticated unless explicitly configured otherwise. + +## Testing plan + +Core tests: + +- Option resolution: env/config precedence, mode selection, URL validation, Basic Auth validation. +- Remote native service with a mocked MCP client/transport or local in-process Streamable HTTP test server: + - lists remote Caplets as native tools + - calls remote tools + - handles tool-list-change notifications + - polls as fallback + - keeps last known-good tools on refresh failure + - closes/terminates sessions cleanly + +Integration tests: + +- OpenCode passes second-argument plugin config into native service options, with config overriding env vars, and preserves native tool registration behavior. +- Pi passes settings/package args into native service options, with args overriding env vars, and keeps existing dynamic registration behavior. +- Codex/Claude plugin tests verify bundled local config remains valid and docs include remote config examples. + +Verification: + +- Focused package tests for core, opencode, pi, and plugin artifacts. +- `pnpm typecheck` +- `pnpm test` +- `pnpm verify` + +## Implementation constraints + +- Environment variables are required in v1, but OpenCode second-argument config and Pi settings/package args are also required integration config surfaces. +- Codex/Claude examples should avoid promising interpolation behavior that their current MCP config schemas do not support. Use agent-specific documented override mechanisms when available; otherwise document manual config edits. +- Poll interval defaults to 30 seconds and is configurable through core options. Add an environment override only if implementation finds a clear cross-agent need. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a3dd401b..23a9e70e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,25 +1,8 @@ -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { CapletsRuntime, runCli } from "@caplets/core"; +import { 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), { version: packageVersion }); - return; - } - - const runtime = process.env.CAPLETS_CONFIG - ? new CapletsRuntime({ configPath: process.env.CAPLETS_CONFIG }) - : new CapletsRuntime(); - - const shutdown = async () => { - await runtime.close(); - }; - process.once("SIGINT", () => void shutdown().finally(() => process.exit(130))); - process.once("SIGTERM", () => void shutdown().finally(() => process.exit(143))); - - const transport = new StdioServerTransport(); - await runtime.connect(transport); + await runCli(process.argv.slice(2), { version: packageVersion }); } main().catch((error) => { diff --git a/packages/core/package.json b/packages/core/package.json index b4d67d1b..08d842a3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,9 +52,12 @@ }, "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", + "@hono/mcp": "^0.3.0", + "@hono/node-server": "^1.19.14", "@modelcontextprotocol/sdk": "^1.29.0", "commander": "^14.0.3", "graphql": "^16.14.0", + "hono": "^4.12.19", "vfile": "^6.0.3", "vfile-matter": "^5.0.1", "zod": "^4.4.3" diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index c692f747..1dfeacd9 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -24,6 +24,7 @@ import { } from "./config"; import { CapletsEngine } from "./engine"; import { CapletsError } from "./errors"; +import { resolveServeOptions, serveResolvedCaplets, type ServeOptions } from "./serve"; export { initConfig, starterConfig } from "./cli/init"; export { installCaplets, normalizeGitRepo } from "./cli/install"; @@ -41,11 +42,16 @@ type CliIO = { authDir?: string; version?: string; setExitCode?: (code: number) => void; + serve?: (options: ServeOptions) => Promise; }; export async function runCli(args: string[], io: CliIO = {}): Promise { const program = createProgram(io); try { + if (args.length === 0) { + program.outputHelp(); + return; + } await program.parseAsync(["node", "caplets", ...args]); } catch (error) { if (error instanceof CommanderError) { @@ -83,6 +89,46 @@ export function createProgram(io: CliIO = {}): Command { outputError: (value, write) => write(value), }); + program + .command("serve") + .description("Serve configured Caplets as an MCP server.") + .option("--transport ", "server transport: stdio or http") + .option("--host ", "HTTP bind host") + .option("--port ", "HTTP bind port") + .option("--path ", "HTTP MCP endpoint path") + .option("--user ", "HTTP Basic Auth username") + .option("--password ", "HTTP Basic Auth password") + .option( + "--allow-unauthenticated-http", + "allow unauthenticated HTTP serving on non-loopback hosts", + ) + .action( + async (options: { + transport?: string; + host?: string; + port?: string; + path?: string; + user?: string; + password?: string; + allowUnauthenticatedHttp?: boolean; + }) => { + const resolved = resolveServeOptions(options); + const configPath = envConfigPath(); + const runner = + io.serve ?? + ((serveOptions: ServeOptions) => + serveResolvedCaplets( + serveOptions, + { + ...(configPath ? { configPath } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }, + writeErr, + )); + await runner(resolved); + }, + ); + program .command("init") .description("Create a starter Caplets config file.") diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index deaeb205..e4c8dbb5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,6 @@ export { runCli, createProgram } from "./cli"; export { parseConfig, loadConfig } from "./config"; export { capabilityDescription, ServerRegistry } from "./registry"; export { generatedToolInputSchema, handleServerTool } from "./tools"; + +export { serveCaplets, serveHttp, serveResolvedCaplets, serveStdio } from "./serve"; +export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./serve"; diff --git a/packages/core/src/native.ts b/packages/core/src/native.ts index 30a1f00d..041269f7 100644 --- a/packages/core/src/native.ts +++ b/packages/core/src/native.ts @@ -14,3 +14,20 @@ export { } from "./native/tools"; export { generatedToolInputSchema } from "./tools"; export { generatedToolInputJsonSchema } from "./generated-tool-input-schema"; +export { + resolveNativeCapletsServiceOptions, + type NativeCapletsEnv, + type NativeCapletsMode, + type NativeCapletsServiceResolutionInput, + type NativeRemoteAuthOptions, + type NativeRemoteCapletsOptions, + type ResolvedNativeCapletsServiceOptions, +} from "./native/options"; +export { + createSdkRemoteCapletsClient, + RemoteNativeCapletsService, + type RemoteCapletsClient, + type RemoteCapletsClientOptions, + type RemoteCapletsTool, + type RemoteNativeCapletsServiceOptions, +} from "./native/remote"; diff --git a/packages/core/src/native/options.ts b/packages/core/src/native/options.ts new file mode 100644 index 00000000..df6e0ca5 --- /dev/null +++ b/packages/core/src/native/options.ts @@ -0,0 +1,173 @@ +import { Buffer } from "node:buffer"; + +import { CapletsError } from "../errors"; + +export type NativeCapletsMode = "auto" | "local" | "remote"; + +export type NativeRemoteCapletsOptions = { + url?: string; + user?: string; + password?: string; + pollIntervalMs?: number; + fetch?: typeof fetch; +}; + +export type NativeCapletsServiceResolutionInput = { + mode?: NativeCapletsMode; + remote?: NativeRemoteCapletsOptions; +}; + +export type NativeCapletsEnv = Partial< + Record< + | "CAPLETS_NATIVE_MODE" + | "CAPLETS_REMOTE_URL" + | "CAPLETS_REMOTE_USER" + | "CAPLETS_REMOTE_PASSWORD", + string + > +>; + +export type NativeRemoteAuthOptions = + | { enabled: false; user: string } + | { enabled: true; user: string; password: string }; + +export type ResolvedNativeCapletsServiceOptions = + | { mode: "local" } + | { + mode: "remote"; + remote: { + url: URL; + auth: NativeRemoteAuthOptions; + pollIntervalMs: number; + requestInit: RequestInit; + fetch?: typeof fetch; + }; + }; + +const DEFAULT_REMOTE_USER = "caplets"; +const DEFAULT_POLL_INTERVAL_MS = 30_000; + +export function resolveNativeCapletsServiceOptions( + input: NativeCapletsServiceResolutionInput = {}, + env: NativeCapletsEnv = process.env, +): ResolvedNativeCapletsServiceOptions { + const mode = parseMode(input.mode ?? env.CAPLETS_NATIVE_MODE ?? "auto"); + if (mode === "local") { + return { mode: "local" }; + } + + const rawUrl = + nonEmpty(input.remote?.url, "remote.url") ?? + nonEmpty(env.CAPLETS_REMOTE_URL, "CAPLETS_REMOTE_URL"); + if (mode === "remote" && rawUrl === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "CAPLETS_NATIVE_MODE=remote requires CAPLETS_REMOTE_URL or remote.url.", + ); + } + if (rawUrl === undefined) { + return { mode: "local" }; + } + + const url = parseRemoteUrl(rawUrl); + const userWasExplicit = input.remote?.user !== undefined || hasEnv(env.CAPLETS_REMOTE_USER); + const user = + nonEmpty(input.remote?.user, "remote.user") ?? + nonEmpty(env.CAPLETS_REMOTE_USER, "CAPLETS_REMOTE_USER") ?? + DEFAULT_REMOTE_USER; + const password = + nonEmpty(input.remote?.password, "remote.password") ?? + nonEmpty(env.CAPLETS_REMOTE_PASSWORD, "CAPLETS_REMOTE_PASSWORD"); + + if (userWasExplicit && password === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "Remote Caplets Basic Auth requires a password; set CAPLETS_REMOTE_PASSWORD or remote.password.", + ); + } + + const auth: NativeRemoteAuthOptions = + password === undefined ? { enabled: false, user } : { enabled: true, user, password }; + const requestInit: RequestInit = auth.enabled + ? { headers: { Authorization: basicAuthHeader(auth.user, auth.password) } } + : {}; + + return { + mode: "remote", + remote: { + url, + auth, + pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs), + requestInit, + ...(input.remote?.fetch ? { fetch: input.remote.fetch } : {}), + }, + }; +} + +function parseMode(value: string): NativeCapletsMode { + if (value === "auto" || value === "local" || value === "remote") { + return value; + } + throw new CapletsError( + "REQUEST_INVALID", + `Expected CAPLETS_NATIVE_MODE to be auto, local, or remote, got ${value}`, + ); +} + +function parseRemoteUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new CapletsError("REQUEST_INVALID", "Invalid remote Caplets URL."); + } + if (url.username !== "" || url.password !== "") { + throw new CapletsError( + "REQUEST_INVALID", + "Remote Caplets URL must not include username or password; use CAPLETS_REMOTE_USER/CAPLETS_REMOTE_PASSWORD or remote.user/remote.password instead.", + ); + } + if (url.protocol === "https:") { + return url; + } + if (url.protocol === "http:" && isLoopbackHost(url.hostname)) { + return url; + } + throw new CapletsError( + "REQUEST_INVALID", + "Remote Caplets URL must use https except loopback development URLs.", + ); +} + +function isLoopbackHost(host: string): boolean { + return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1"; +} + +function parsePollInterval(value: number | undefined): number { + if (value === undefined) { + return DEFAULT_POLL_INTERVAL_MS; + } + if (!Number.isInteger(value) || value < 1_000) { + throw new CapletsError("REQUEST_INVALID", "remote.pollIntervalMs must be an integer >= 1000."); + } + return value; +} + +function basicAuthHeader(user: string, password: string): string { + return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`; +} + +function nonEmpty(value: string | undefined, label: string): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`); + } + return trimmed; +} + +function hasEnv(value: string | undefined): boolean { + return value !== undefined && value.trim() !== ""; +} diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts new file mode 100644 index 00000000..0340b101 --- /dev/null +++ b/packages/core/src/native/remote.ts @@ -0,0 +1,288 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { CapletsError } from "../errors"; +import type { ResolvedNativeCapletsServiceOptions } from "./options"; +import type { + NativeCapletsService, + NativeCapletsToolsChangedListener, + NativeCapletTool, +} from "./service"; +import { nativeCapletToolName } from "./tools"; + +export type RemoteCapletsTool = { + name: string; + title?: string | undefined; + description?: string | undefined; +}; + +export type RemoteCapletsClient = { + listTools(): Promise; + callTool(name: string, args: unknown): Promise; + onToolsChanged(listener: () => void): () => void; + close(): Promise; +}; + +export type RemoteCapletsClientOptions = ResolvedNativeCapletsServiceOptions & { + mode: "remote"; +}; + +export type RemoteNativeCapletsServiceOptions = { + client: RemoteCapletsClient; + clientFactory?: () => RemoteCapletsClient; + pollIntervalMs: number; + writeErr?: (value: string) => void; +}; + +export function createSdkRemoteCapletsClient( + options: RemoteCapletsClientOptions["remote"], +): RemoteCapletsClient { + const client = new Client({ name: "caplets-native", version: "1.0.0" }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(options.url, { + requestInit: options.requestInit, + ...(options.fetch ? { fetch: options.fetch } : {}), + }); + // The SDK transport type is narrower than StreamableHTTPClientTransport at compile time, + // but this is the documented transport used by the streamable HTTP client. + const ready = client.connect(transport as never); + const readyObserved = ready.catch(() => undefined); + const listeners = new Set<() => void>(); + + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { + for (const listener of listeners) { + listener(); + } + }); + + return { + async listTools() { + await ready; + const result = await client.listTools(); + return (result.tools ?? []).map((tool) => ({ + name: tool.name, + ...(tool.title ? { title: tool.title } : {}), + ...(tool.description ? { description: tool.description } : {}), + })); + }, + async callTool(name, args) { + await ready; + const toolArguments = + args && typeof args === "object" && !Array.isArray(args) + ? (args as Record) + : undefined; + return await client.callTool({ name, arguments: toolArguments }); + }, + onToolsChanged(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() { + listeners.clear(); + await readyObserved; + await transport.terminateSession().catch(() => undefined); + await client.close(); + }, + }; +} + +export class RemoteNativeCapletsService implements NativeCapletsService { + private tools: NativeCapletTool[] = []; + private readonly listeners = new Set(); + private readonly clientFactory: () => RemoteCapletsClient; + private client: RemoteCapletsClient; + private unsubscribeRemote: () => void; + private readonly pollTimer: ReturnType; + private closed = false; + private resetInFlight: Promise | undefined; + + constructor(private readonly options: RemoteNativeCapletsServiceOptions) { + this.client = options.client; + this.clientFactory = options.clientFactory ?? (() => options.client); + this.unsubscribeRemote = this.subscribeRemote(this.client); + this.pollTimer = setInterval(() => { + void this.reload(); + }, options.pollIntervalMs); + this.pollTimer.unref?.(); + } + + listTools(): NativeCapletTool[] { + return [...this.tools]; + } + + async execute(capletId: string, request: unknown): Promise { + try { + return await this.client.callTool(capletId, request); + } catch (error) { + if (isAuthFailure(error)) { + throw remoteAuthError(); + } + if (isSessionFailure(error)) { + if (!(await this.resetClient()) || this.closed) { + throw error; + } + try { + return await this.client.callTool(capletId, request); + } catch (retryError) { + if (isAuthFailure(retryError)) { + throw remoteAuthError(); + } + throw retryError; + } + } + throw error; + } + } + + async reload(): Promise { + if (this.closed) { + return false; + } + try { + await this.reloadFromClient(); + return true; + } catch (error) { + if (isSessionFailure(error)) { + try { + if (!(await this.resetClient()) || this.closed) { + return false; + } + await this.reloadFromClient(); + return !this.closed; + } catch (retryError) { + this.warn(`Could not reload remote Caplets tools: ${errorMessage(retryError)}\n`); + return false; + } + } + this.warn(`Could not reload remote Caplets tools: ${errorMessage(error)}\n`); + return false; + } + } + + onToolsChanged(listener: NativeCapletsToolsChangedListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + clearInterval(this.pollTimer); + this.unsubscribeRemote(); + this.listeners.clear(); + await this.client.close(); + } + + private async reloadFromClient(): Promise { + const tools = (await this.client.listTools()).map(remoteToolToNativeTool); + const changed = JSON.stringify(tools) !== JSON.stringify(this.tools); + this.tools = tools; + if (changed) { + this.emitToolsChanged(); + } + } + + private subscribeRemote(client: RemoteCapletsClient): () => void { + return client.onToolsChanged(() => { + void this.reload(); + }); + } + + private async resetClient(): Promise { + if (this.resetInFlight) { + return await this.resetInFlight; + } + this.resetInFlight = this.doResetClient(); + try { + return await this.resetInFlight; + } finally { + this.resetInFlight = undefined; + } + } + + private async doResetClient(): Promise { + if (this.closed) { + return false; + } + this.unsubscribeRemote(); + await this.client.close().catch(() => undefined); + if (this.closed) { + return false; + } + const nextClient = this.clientFactory(); + if (this.closed) { + await nextClient.close().catch(() => undefined); + return false; + } + this.client = nextClient; + this.unsubscribeRemote = this.subscribeRemote(this.client); + return true; + } + + private emitToolsChanged(): void { + const tools = this.listTools(); + for (const listener of this.listeners) { + listener(tools); + } + } + + private warn(message: string): void { + if (this.options.writeErr) { + this.options.writeErr(message); + return; + } + process.stderr.write(message); + } +} + +function remoteToolToNativeTool(tool: RemoteCapletsTool): NativeCapletTool { + const toolName = nativeCapletToolName(tool.name); + return { + caplet: tool.name, + toolName, + title: tool.title ?? tool.name, + description: [ + tool.description ?? "Remote Caplets tool.", + "", + `Native tool name: ${toolName}`, + `Remote Caplet ID: ${tool.name}`, + ].join("\n"), + promptGuidance: [`Use ${toolName} through the remote Caplets service.`], + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function remoteAuthError(): CapletsError { + return new CapletsError( + "AUTH_FAILED", + "Remote Caplets authentication failed; check CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.", + ); +} + +function isSessionFailure(error: unknown): boolean { + const message = errorMessage(error).toLowerCase(); + return /session|transport|connection|connect|closed|invalid/u.test(message); +} + +function isAuthFailure(error: unknown): boolean { + const candidate = error as { status?: unknown; statusCode?: unknown; code?: unknown }; + const status = typeof candidate?.status === "number" ? candidate.status : undefined; + const statusCode = typeof candidate?.statusCode === "number" ? candidate.statusCode : undefined; + const code = typeof candidate?.code === "number" ? candidate.code : undefined; + if ( + status === 401 || + status === 403 || + statusCode === 401 || + statusCode === 403 || + code === 401 || + code === 403 + ) { + return true; + } + return /\b(401|403|unauthorized|forbidden)\b/iu.test(errorMessage(error)); +} diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index ac69e4df..a2ddf0e4 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1,3 +1,10 @@ +import type { NativeCapletsServiceResolutionInput } from "./options"; +import { resolveNativeCapletsServiceOptions } from "./options"; +import { + createSdkRemoteCapletsClient, + RemoteNativeCapletsService, + type RemoteCapletsClient, +} from "./remote"; import { CapletsEngine } from "../engine"; import { nativeCapletPromptGuidance, @@ -5,13 +12,19 @@ import { nativeCapletToolName, } from "./tools"; -export type NativeCapletsServiceOptions = { +export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput & { configPath?: string; projectConfigPath?: string; authDir?: string; watchDebounceMs?: number; watch?: boolean; writeErr?: (value: string) => void; + remoteClientFactory?: ( + options: Extract< + ReturnType, + { mode: "remote" } + >["remote"], + ) => RemoteCapletsClient; }; export type NativeCapletTool = { @@ -35,6 +48,17 @@ export type NativeCapletsService = { export function createNativeCapletsService( options: NativeCapletsServiceOptions = {}, ): NativeCapletsService { + const resolved = resolveNativeCapletsServiceOptions(options); + if (resolved.mode === "remote") { + const client = (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(resolved.remote); + return new RemoteNativeCapletsService({ + client, + clientFactory: () => + (options.remoteClientFactory ?? createSdkRemoteCapletsClient)(resolved.remote), + pollIntervalMs: resolved.remote.pollIntervalMs, + ...(options.writeErr ? { writeErr: options.writeErr } : {}), + }); + } return new DefaultNativeCapletsService(options); } diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index e7e26a7b..87c434d4 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1,12 +1,7 @@ -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"; +import type { CapletsConfig } from "./config"; import { CapletsEngine, type CapletsEngineOptions } from "./engine"; -import { capabilityDescription } from "./registry"; -import { generatedToolInputSchema } from "./tools"; - -type ToolServer = Pick; +import { CapletsMcpSession, type ToolServer } from "./serve/session"; type CapletsRuntimeOptions = { configPath?: string; @@ -20,25 +15,16 @@ type CapletsRuntimeOptions = { export class CapletsRuntime { readonly server: ToolServer; private readonly engine: CapletsEngine; - private readonly tools = new Map(); - private readonly unsubscribeReload: () => void; + private readonly session: CapletsMcpSession; constructor(options: CapletsRuntimeOptions = {}) { this.engine = new CapletsEngine(engineOptions(options)); - this.server = - options.server ?? - new McpServer({ - name: "caplets", - version: packageJsonVersion, - }); - this.unsubscribeReload = this.engine.onReload(({ previous, next }) => - this.reconcileTools(previous, next), - ); - this.reconcileTools(undefined, this.engine.currentConfig()); + this.session = new CapletsMcpSession(this.engine, selectSessionOptions(options)); + this.server = this.session.server; } async connect(transport: Transport): Promise { - await this.server.connect(transport); + await this.session.connect(transport); } scheduleReload(): void { @@ -50,11 +36,10 @@ export class CapletsRuntime { } async close(): Promise { - this.unsubscribeReload(); try { - await this.engine.close(); + await this.session.close(); } finally { - await this.server.close(); + await this.engine.close(); } } @@ -63,84 +48,16 @@ export class CapletsRuntime { } registeredToolIds(): string[] { - return [...this.tools.keys()].sort(); + return this.session.registeredToolIds(); } watchedPaths(): string[] { return this.engine.watchedPaths(); } - - private reconcileTools(previous: CapletsConfig | undefined, next: CapletsConfig): void { - const enabled = new Map(nextEnabledServers(next).map((server) => [server.server, server])); - - for (const [serverId, tool] of this.tools) { - const caplet = enabled.get(serverId); - if (!caplet) { - tool.remove(); - this.tools.delete(serverId); - continue; - } - - const previousCaplet = previous ? capletById(previous, serverId) : undefined; - if (!previousCaplet || serializeCaplet(previousCaplet) !== serializeCaplet(caplet)) { - tool.update({ - title: caplet.name, - description: capabilityDescription(caplet), - callback: async (request) => this.handleTool(serverId, request), - enabled: true, - }); - } - } - - for (const caplet of enabled.values()) { - if (this.tools.has(caplet.server)) { - continue; - } - this.tools.set(caplet.server, this.registerCapletTool(caplet)); - } - } - - private registerCapletTool(caplet: CapletConfig): RegisteredTool { - return this.server.registerTool( - caplet.server, - { - title: caplet.name, - description: capabilityDescription(caplet), - inputSchema: generatedToolInputSchema, - }, - async (request) => this.handleTool(caplet.server, request), - ); - } - - private async handleTool(serverId: string, request: unknown): Promise { - return await this.engine.execute(serverId, request); - } -} - -function nextEnabledServers(config: CapletsConfig): CapletConfig[] { - return [ - ...Object.values(config.mcpServers), - ...Object.values(config.openapiEndpoints), - ...Object.values(config.graphqlEndpoints), - ...Object.values(config.httpApis), - ...Object.values(config.cliTools), - ...Object.values(config.capletSets), - ].filter((server) => !server.disabled); -} - -function capletById(config: CapletsConfig, serverId: string): CapletConfig | undefined { - return ( - config.mcpServers[serverId] ?? - config.openapiEndpoints[serverId] ?? - config.graphqlEndpoints[serverId] ?? - config.httpApis[serverId] ?? - config.cliTools[serverId] ?? - config.capletSets[serverId] - ); } -function serializeCaplet(caplet: CapletConfig | undefined): string { - return JSON.stringify(caplet ?? null); +function selectSessionOptions(options: CapletsRuntimeOptions): { server?: ToolServer } { + return options.server === undefined ? {} : { server: options.server }; } function engineOptions(options: CapletsRuntimeOptions): CapletsEngineOptions { diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts new file mode 100644 index 00000000..8b524130 --- /dev/null +++ b/packages/core/src/serve/http.ts @@ -0,0 +1,247 @@ +import { randomUUID, timingSafeEqual } from "node:crypto"; +import { StreamableHTTPTransport } from "@hono/mcp"; +import { serve, type ServerType } from "@hono/node-server"; +import { Hono, type MiddlewareHandler } from "hono"; +import { logger } from "hono/logger"; +import { CapletsEngine, type CapletsEngineOptions } from "../engine"; +import type { HttpBasicAuthOptions, HttpServeOptions } from "./options"; +import { CapletsMcpSession } from "./session"; + +type HttpServeIo = { + writeErr?: (value: string) => void; +}; + +type HttpSession = { + server: CapletsMcpSession; + transport: StreamableHTTPTransport; +}; + +export type CapletsHttpApp = Hono & { + closeCapletsSessions: () => Promise; +}; + +export function createHttpServeApp( + options: HttpServeOptions, + engine: CapletsEngine, + io: HttpServeIo = {}, +): CapletsHttpApp { + const app = new Hono() as CapletsHttpApp; + const sessions = new Map(); + const writeErr = io.writeErr ?? process.stderr.write.bind(process.stderr); + app.use( + "*", + logger((message, ...rest) => { + writeErr(`${[message, ...rest].join(" ")}\n`); + }), + ); + + app.get("/", (c) => + c.json({ + name: "caplets", + transport: "http", + mcp: options.path, + health: "/healthz", + auth: { type: "basic", enabled: options.auth.enabled }, + }), + ); + + app.get("/healthz", (c) => + c.json({ + status: "ok", + transport: "http", + mcpPath: options.path, + }), + ); + + app.all(options.path, basicAuth(options.auth), async (c) => { + const sessionId = c.req.header("mcp-session-id"); + if (sessionId) { + const existing = sessions.get(sessionId); + if (!existing) { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }, + 404, + ); + } + return existing.transport.handleRequest(c); + } + + if (c.req.method !== "POST") { + return c.json( + { + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: Mcp-Session-Id header is required" }, + id: null, + }, + 400, + ); + } + + const nextSessionId = randomUUID(); + const session = await createHttpSession( + engine, + nextSessionId, + options, + async (closedSessionId) => { + const closed = sessions.get(closedSessionId); + sessions.delete(closedSessionId); + if (closed) { + await closed.server.close(); + } + }, + ); + sessions.set(nextSessionId, session); + return session.transport.handleRequest(c); + }); + + app.notFound((c) => c.json({ error: "not_found" }, 404)); + + app.closeCapletsSessions = async () => { + await Promise.allSettled( + [...sessions.values()].map(async (session) => { + await session.server.close(); + }), + ); + sessions.clear(); + }; + + if (options.warnUnauthenticatedNetwork) { + writeErr( + `Warning: Caplets MCP HTTP server is listening on ${options.host} without authentication.\n`, + ); + } + + return app; +} + +export async function serveHttp( + options: HttpServeOptions, + engineOptions: CapletsEngineOptions = {}, + writeErr: (value: string) => void = (value) => process.stderr.write(value), +): Promise { + const engine = new CapletsEngine(engineOptions); + const app = createHttpServeApp(options, engine, { writeErr }); + const server = serve({ fetch: app.fetch, hostname: options.host, port: options.port }, () => { + writeErr( + `Caplets MCP HTTP server listening on http://${formatHost(options.host)}:${options.port}${options.path}\n`, + ); + writeErr(`Health check: http://${formatHost(options.host)}:${options.port}/healthz\n`); + writeErr( + `Basic Auth: ${options.auth.enabled ? `enabled (user: ${options.auth.user})` : "disabled"}\n`, + ); + }); + + installHttpSignalHandlers(server, app, engine, writeErr); +} + +async function createHttpSession( + engine: CapletsEngine, + sessionId: string, + options: HttpServeOptions, + onClose: (sessionId: string) => Promise, +): Promise { + const transport = new StreamableHTTPTransport({ + sessionIdGenerator: () => sessionId, + onsessionclosed: onClose, + ...(options.loopback ? dnsRebindingOptions(options) : {}), + }); + const server = new CapletsMcpSession(engine); + await server.connect(transport); + return { server, transport }; +} + +function basicAuth(auth: HttpBasicAuthOptions): MiddlewareHandler { + return async (c, next) => { + if (!auth.enabled) { + await next(); + return; + } + const header = c.req.header("authorization") ?? ""; + const credentials = parseBasicAuth(header); + if ( + !credentials || + !safeEqual(credentials.user, auth.user) || + !safeEqual(credentials.password, auth.password) + ) { + c.header("www-authenticate", 'Basic realm="caplets"'); + return c.text("Unauthorized", 401); + } + await next(); + }; +} + +function parseBasicAuth(header: string): { user: string; password: string } | undefined { + const [scheme, encoded] = header.split(" "); + if (scheme?.toLocaleLowerCase() !== "basic" || !encoded) { + return undefined; + } + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator < 0) { + return undefined; + } + return { user: decoded.slice(0, separator), password: decoded.slice(separator + 1) }; +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +type DnsRebindingOptions = { + enableDnsRebindingProtection: true; + allowedHosts: string[]; +}; + +function dnsRebindingOptions(options: HttpServeOptions): DnsRebindingOptions { + const hostForHeader = options.host === "::1" ? "[::1]" : options.host; + return { + enableDnsRebindingProtection: true, + allowedHosts: [ + options.host, + hostForHeader, + `${hostForHeader}:${options.port}`, + `localhost:${options.port}`, + ], + }; +} + +function installHttpSignalHandlers( + server: ServerType, + app: CapletsHttpApp, + engine: CapletsEngine, + writeErr: (value: string) => void, +): void { + let closing: Promise | undefined; + const close = async () => { + closing ??= (async () => { + await new Promise((resolve) => server.close(() => resolve())); + await app.closeCapletsSessions(); + await engine.close(); + })(); + return closing; + }; + process.once( + "SIGINT", + () => + void close() + .catch((error) => writeErr(`${String(error)}\n`)) + .finally(() => process.exit(130)), + ); + process.once( + "SIGTERM", + () => + void close() + .catch((error) => writeErr(`${String(error)}\n`)) + .finally(() => process.exit(143)), + ); +} + +function formatHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} diff --git a/packages/core/src/serve/index.ts b/packages/core/src/serve/index.ts new file mode 100644 index 00000000..764479c9 --- /dev/null +++ b/packages/core/src/serve/index.ts @@ -0,0 +1,33 @@ +import type { CapletsEngineOptions } from "../engine"; +import { serveHttp } from "./http"; +import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./options"; +import { serveStdio } from "./stdio"; + +export { serveHttp } from "./http"; +export { resolveServeOptions } from "./options"; +export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./options"; +export { serveStdio } from "./stdio"; + +export type ServeCapletsOptions = { + raw: RawServeOptions; + engine?: CapletsEngineOptions; + env?: NodeJS.ProcessEnv; + writeErr?: (value: string) => void; +}; + +export async function serveCaplets(options: ServeCapletsOptions): Promise { + const resolved = resolveServeOptions(options.raw, options.env ?? process.env); + await serveResolvedCaplets(resolved, options.engine, options.writeErr); +} + +export async function serveResolvedCaplets( + resolved: ServeOptions, + engineOptions: CapletsEngineOptions = {}, + writeErr?: (value: string) => void, +): Promise { + if (resolved.transport === "stdio") { + await serveStdio({ ...engineOptions, ...(writeErr ? { writeErr } : {}) }); + return; + } + await serveHttp(resolved, { ...engineOptions, ...(writeErr ? { writeErr } : {}) }, writeErr); +} diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts new file mode 100644 index 00000000..1fd8130d --- /dev/null +++ b/packages/core/src/serve/options.ts @@ -0,0 +1,155 @@ +import { CapletsError } from "../errors"; + +export type ServeTransport = "stdio" | "http"; + +export type RawServeOptions = { + transport?: string; + host?: string; + port?: string | number; + path?: string; + user?: string; + password?: string; + allowUnauthenticatedHttp?: boolean; +}; + +export type StdioServeOptions = { + transport: "stdio"; +}; + +export type HttpServeOptions = { + transport: "http"; + host: string; + port: number; + path: string; + auth: HttpBasicAuthOptions; + warnUnauthenticatedNetwork: boolean; + loopback: boolean; +}; + +export type HttpBasicAuthOptions = + | { enabled: false; user: string } + | { enabled: true; user: string; password: string }; + +export type ServeOptions = StdioServeOptions | HttpServeOptions; + +export type ServeEnv = Partial>; + +const HTTP_ONLY_OPTIONS = [ + "host", + "port", + "path", + "user", + "password", + "allowUnauthenticatedHttp", +] as const; + +export function resolveServeOptions( + raw: RawServeOptions, + env: ServeEnv = process.env, +): ServeOptions { + const transport = parseTransport(raw.transport ?? "stdio"); + if (transport === "stdio") { + const invalid = HTTP_ONLY_OPTIONS.filter((key) => raw[key] !== undefined); + if (invalid.length > 0) { + throw new CapletsError( + "REQUEST_INVALID", + `${invalid.map((key) => `--${key}`).join(", ")} ${invalid.length === 1 ? "is" : "are"} only valid with --transport http`, + ); + } + return { transport }; + } + + const host = nonEmpty(raw.host, "--host") ?? "127.0.0.1"; + const port = parsePort(raw.port ?? 5387); + const path = normalizeHttpPath(raw.path ?? "/mcp"); + const userWasExplicit = raw.user !== undefined || hasEnv(env.CAPLETS_SERVER_USER); + const user = + nonEmpty(raw.user, "--user") ?? + nonEmpty(env.CAPLETS_SERVER_USER, "CAPLETS_SERVER_USER") ?? + "caplets"; + const password = + nonEmpty(raw.password, "--password") ?? + nonEmpty(env.CAPLETS_SERVER_PASSWORD, "CAPLETS_SERVER_PASSWORD"); + + if (userWasExplicit && password === undefined) { + throw new CapletsError( + "REQUEST_INVALID", + "HTTP Basic Auth requires a password; pass --password or set CAPLETS_SERVER_PASSWORD.", + ); + } + + const loopback = isLoopbackHost(host); + const auth = + password === undefined + ? { enabled: false as const, user } + : { enabled: true as const, user, password }; + if (!loopback && !auth.enabled && raw.allowUnauthenticatedHttp !== true) { + throw new CapletsError( + "REQUEST_INVALID", + "Unauthenticated HTTP serving on non-loopback hosts requires --allow-unauthenticated-http.", + ); + } + return { + transport, + host, + port, + path, + auth, + warnUnauthenticatedNetwork: !loopback && !auth.enabled, + loopback, + }; +} + +export function isLoopbackHost(host: string): boolean { + const normalized = host.toLocaleLowerCase(); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +} + +function parseTransport(value: string): ServeTransport { + if (value === "stdio" || value === "http") { + return value; + } + throw new CapletsError( + "REQUEST_INVALID", + `Expected --transport to be stdio or http, got ${value}`, + ); +} + +function parsePort(value: string | number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new CapletsError( + "REQUEST_INVALID", + `Expected --port to be a valid TCP port, got ${value}`, + ); + } + return parsed; +} + +function normalizeHttpPath(value: string): string { + if (!value.startsWith("/")) { + throw new CapletsError("REQUEST_INVALID", "HTTP --path must start with /"); + } + if (value.includes("?") || value.includes("#")) { + throw new CapletsError( + "REQUEST_INVALID", + "HTTP --path must not include a query string or fragment", + ); + } + return value === "/" ? value : value.replace(/\/+$/u, ""); +} + +function nonEmpty(value: string | undefined, label: string): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`); + } + return trimmed; +} + +function hasEnv(value: string | undefined): boolean { + return value !== undefined && value.trim() !== ""; +} diff --git a/packages/core/src/serve/session.ts b/packages/core/src/serve/session.ts new file mode 100644 index 00000000..78403937 --- /dev/null +++ b/packages/core/src/serve/session.ts @@ -0,0 +1,126 @@ +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, CapletsConfig } from "../config"; +import type { CapletsEngine } from "../engine"; +import { capabilityDescription } from "../registry"; +import { generatedToolInputSchema } from "../tools"; + +export type ToolServer = Pick; + +export type CapletsMcpSessionOptions = { + server?: ToolServer; +}; + +export class CapletsMcpSession { + readonly server: ToolServer; + private readonly tools = new Map(); + private readonly unsubscribeReload: () => void; + private closed = false; + + constructor( + private readonly engine: CapletsEngine, + options: CapletsMcpSessionOptions = {}, + ) { + this.server = + options.server ?? + new McpServer({ + name: "caplets", + version: packageJsonVersion, + }); + this.unsubscribeReload = this.engine.onReload(({ previous, next }) => + this.reconcileTools(previous, next), + ); + this.reconcileTools(undefined, this.engine.currentConfig()); + } + + async connect(transport: Transport): Promise { + await this.server.connect(transport); + } + + registeredToolIds(): string[] { + return [...this.tools.keys()].sort(); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + this.unsubscribeReload(); + this.tools.clear(); + await this.server.close(); + } + + private reconcileTools(previous: CapletsConfig | undefined, next: CapletsConfig): void { + const enabled = new Map(nextEnabledServers(next).map((server) => [server.server, server])); + + for (const [serverId, tool] of this.tools) { + const caplet = enabled.get(serverId); + if (!caplet) { + tool.remove(); + this.tools.delete(serverId); + continue; + } + + const previousCaplet = previous ? capletById(previous, serverId) : undefined; + if (!previousCaplet || serializeCaplet(previousCaplet) !== serializeCaplet(caplet)) { + tool.update({ + title: caplet.name, + description: capabilityDescription(caplet), + callback: async (request) => this.handleTool(serverId, request), + enabled: true, + }); + } + } + + for (const caplet of enabled.values()) { + if (this.tools.has(caplet.server)) { + continue; + } + this.tools.set(caplet.server, this.registerCapletTool(caplet)); + } + } + + private registerCapletTool(caplet: CapletConfig): RegisteredTool { + return this.server.registerTool( + caplet.server, + { + title: caplet.name, + description: capabilityDescription(caplet), + inputSchema: generatedToolInputSchema, + }, + async (request) => this.handleTool(caplet.server, request), + ); + } + + private async handleTool(serverId: string, request: unknown): Promise { + return await this.engine.execute(serverId, request); + } +} + +function nextEnabledServers(config: CapletsConfig): CapletConfig[] { + return [ + ...Object.values(config.mcpServers), + ...Object.values(config.openapiEndpoints), + ...Object.values(config.graphqlEndpoints), + ...Object.values(config.httpApis), + ...Object.values(config.cliTools), + ...Object.values(config.capletSets), + ].filter((server) => !server.disabled); +} + +function capletById(config: CapletsConfig, serverId: string): CapletConfig | undefined { + return ( + config.mcpServers[serverId] ?? + config.openapiEndpoints[serverId] ?? + config.graphqlEndpoints[serverId] ?? + config.httpApis[serverId] ?? + config.cliTools[serverId] ?? + config.capletSets[serverId] + ); +} + +function serializeCaplet(caplet: CapletConfig | undefined): string { + return JSON.stringify(caplet ?? null); +} diff --git a/packages/core/src/serve/stdio.ts b/packages/core/src/serve/stdio.ts new file mode 100644 index 00000000..740f7443 --- /dev/null +++ b/packages/core/src/serve/stdio.ts @@ -0,0 +1,43 @@ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { CapletsEngine, type CapletsEngineOptions } from "../engine"; +import { CapletsMcpSession } from "./session"; + +export type ServeStdioOptions = CapletsEngineOptions & { + signalHandling?: boolean; +}; + +export async function serveStdio(options: ServeStdioOptions = {}): Promise { + const engine = new CapletsEngine(options); + const session = new CapletsMcpSession(engine); + let closing = false; + + const close = async () => { + if (closing) { + return; + } + closing = true; + try { + await session.close(); + } finally { + await engine.close(); + } + }; + + let sigintHandler: (() => void) | undefined; + let sigtermHandler: (() => void) | undefined; + + if (options.signalHandling !== false) { + sigintHandler = () => void close().finally(() => process.exit(130)); + sigtermHandler = () => void close().finally(() => process.exit(143)); + process.once("SIGINT", sigintHandler); + process.once("SIGTERM", sigtermHandler); + } + + try { + await session.connect(new StdioServerTransport()); + } finally { + if (sigintHandler) process.off("SIGINT", sigintHandler); + if (sigtermHandler) process.off("SIGTERM", sigtermHandler); + await close(); + } +} diff --git a/packages/core/test/agent-plugins.test.ts b/packages/core/test/agent-plugins.test.ts index 975949ae..ec31f729 100644 --- a/packages/core/test/agent-plugins.test.ts +++ b/packages/core/test/agent-plugins.test.ts @@ -92,6 +92,15 @@ describe("root agent plugin artifacts", () => { expect(JSON.stringify(mcp)).not.toContain("caplets@"); }); + it("documents remote Caplets service configuration for MCP-backed plugins", async () => { + const readme = await readFile(path.join(repoRoot, "README.md"), "utf8"); + + expect(readme).toContain("CAPLETS_REMOTE_URL"); + expect(readme).toContain("caplets serve --transport http"); + expect(readme).toContain("https://caplets.example.com/mcp"); + expect(readme).toMatch(/HTTPS\/TLS|TLS|HTTPS/); + }); + it("uses a strong shared plugin skill for automatic selection", async () => { const skill = await readFile( path.join(repoRoot, "plugins/caplets/skills/caplets/SKILL.md"), diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index db280ac9..d4bd2cf8 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -18,6 +18,8 @@ import { writeTokenBundle } from "../src/auth"; describe("cli init", () => { const originalConfigPath = process.env.CAPLETS_CONFIG; + const originalServerUser = process.env.CAPLETS_SERVER_USER; + const originalServerPassword = process.env.CAPLETS_SERVER_PASSWORD; afterEach(() => { vi.restoreAllMocks(); @@ -26,6 +28,16 @@ describe("cli init", () => { } else { process.env.CAPLETS_CONFIG = originalConfigPath; } + if (originalServerUser === undefined) { + delete process.env.CAPLETS_SERVER_USER; + } else { + process.env.CAPLETS_SERVER_USER = originalServerUser; + } + if (originalServerPassword === undefined) { + delete process.env.CAPLETS_SERVER_PASSWORD; + } else { + process.env.CAPLETS_SERVER_PASSWORD = originalServerPassword; + } }); it("writes a valid starter config and creates parent directories", () => { @@ -115,6 +127,65 @@ describe("cli init", () => { expect(out.join("")).toBe(`${packageJsonVersion}\n`); }); + it("prints top-level help for no arguments", async () => { + const out: string[] = []; + + await runCli([], { + writeOut: (value) => out.push(value), + writeErr: (value) => out.push(value), + }); + + expect(out.join("")).toContain("Usage: caplets"); + expect(out.join("")).toContain("Commands:"); + expect(out.join("")).toContain("serve"); + }); + + it("resolves serve defaults to stdio", async () => { + const served: unknown[] = []; + + delete process.env.CAPLETS_SERVER_USER; + delete process.env.CAPLETS_SERVER_PASSWORD; + + await runCli(["serve"], { + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }); + + expect(served).toEqual([{ transport: "stdio" }]); + }); + + it("resolves HTTP serve defaults", async () => { + const served: unknown[] = []; + + delete process.env.CAPLETS_SERVER_USER; + delete process.env.CAPLETS_SERVER_PASSWORD; + + await runCli(["serve", "--transport", "http"], { + writeOut: () => {}, + serve: async (options) => { + served.push(options); + }, + }); + + expect(served).toEqual([ + expect.objectContaining({ + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/mcp", + auth: { enabled: false, user: "caplets" }, + }), + ]); + }); + + it("rejects HTTP-only serve options with stdio", async () => { + await expect( + runCli(["serve", "--transport", "stdio", "--port", "5387"], { writeErr: () => {} }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + }); + it("lists enabled Caplets by default", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-list-")); const configPath = join(dir, "config.json"); diff --git a/packages/core/test/native-options.test.ts b/packages/core/test/native-options.test.ts new file mode 100644 index 00000000..71580f0d --- /dev/null +++ b/packages/core/test/native-options.test.ts @@ -0,0 +1,150 @@ +import { Buffer } from "node:buffer"; +import { describe, expect, it } from "vitest"; + +import type { CapletsError } from "../src/errors"; +import { resolveNativeCapletsServiceOptions } from "../src/native/options"; + +describe("resolveNativeCapletsServiceOptions", () => { + it("defaults to local mode without remote configuration", () => { + expect(resolveNativeCapletsServiceOptions({}, {})).toEqual({ + mode: "local", + }); + }); + + it("uses remote mode in auto when a remote URL is configured", () => { + expect( + resolveNativeCapletsServiceOptions({}, { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/mcp" }), + ).toMatchObject({ + mode: "remote", + remote: { + url: new URL("http://127.0.0.1:5387/mcp"), + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 30_000, + }, + }); + }); + + it("lets explicit local mode ignore remote env vars", () => { + expect( + resolveNativeCapletsServiceOptions( + { mode: "local" }, + { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/mcp" }, + ), + ).toEqual({ mode: "local" }); + }); + + it("requires a URL in explicit remote mode", () => { + expect(() => resolveNativeCapletsServiceOptions({ mode: "remote" }, {})).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + }); + + it("rejects non-loopback http URLs", () => { + expect(() => + resolveNativeCapletsServiceOptions({ remote: { url: "http://caplets.example.com/mcp" } }, {}), + ).toThrow(/https/u); + }); + + it("does not echo invalid credential-bearing remote URL inputs", () => { + const rawUrl = "https://caplets:secret@exa mple.com/mcp"; + + expect(() => resolveNativeCapletsServiceOptions({ remote: { url: rawUrl } }, {})).toThrowError( + expect.not.stringContaining(rawUrl), + ); + }); + + it("rejects remote URLs with embedded username or password", () => { + for (const url of [ + "https://caplets:secret@caplets.example.com/mcp", + "http://caplets:secret@127.0.0.1:5387/mcp", + ]) { + expect(() => resolveNativeCapletsServiceOptions({ remote: { url } }, {})).toThrow( + /must not include username or password/u, + ); + expect(() => resolveNativeCapletsServiceOptions({ remote: { url } }, {})).toThrow( + /CAPLETS_REMOTE_USER\/CAPLETS_REMOTE_PASSWORD or remote\.user\/remote\.password/u, + ); + } + }); + + it("lets config override env vars", () => { + const configPassword = ["config", "password"].join("-"); + expect( + resolveNativeCapletsServiceOptions( + { + remote: { + url: "https://configured.example.com/mcp", + user: "configured", + password: configPassword, + }, + }, + { + CAPLETS_REMOTE_URL: "https://env.example.com/mcp", + CAPLETS_REMOTE_USER: "env-user", + CAPLETS_REMOTE_PASSWORD: ["env", "password"].join("-"), + }, + ), + ).toMatchObject({ + mode: "remote", + remote: { + url: new URL("https://configured.example.com/mcp"), + auth: { enabled: true, user: "configured", password: configPassword }, + }, + }); + }); + + it("defaults Basic Auth user when password exists", () => { + const password = ["remote", "password"].join("-"); + expect( + resolveNativeCapletsServiceOptions( + { remote: { url: "https://caplets.example.com/mcp", password } }, + {}, + ), + ).toMatchObject({ + remote: { auth: { enabled: true, user: "caplets", password } }, + }); + }); + + it("rejects user without password", () => { + expect(() => + resolveNativeCapletsServiceOptions( + { remote: { url: "https://caplets.example.com/mcp", user: "caplets" } }, + {}, + ), + ).toThrow(/requires a password/u); + }); + + it("builds request headers without logging credentials", () => { + const password = ["remote", "password"].join("-"); + const resolved = resolveNativeCapletsServiceOptions( + { + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + password, + }, + }, + {}, + ); + expect(resolved.mode).toBe("remote"); + expect(resolved.mode === "remote" ? resolved.remote.requestInit.headers : undefined).toEqual({ + Authorization: `Basic ${Buffer.from(`caplets:${password}`).toString("base64")}`, + }); + }); + + it("rejects invalid poll intervals", () => { + expect(() => + resolveNativeCapletsServiceOptions( + { remote: { url: "https://caplets.example.com/mcp", pollIntervalMs: 999 } }, + {}, + ), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + + expect(() => + resolveNativeCapletsServiceOptions( + { remote: { url: "https://caplets.example.com/mcp", pollIntervalMs: 1_000.5 } }, + {}, + ), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + }); +}); diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts new file mode 100644 index 00000000..fed42958 --- /dev/null +++ b/packages/core/test/native-remote.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { CapletsError } from "../src/errors"; +import { RemoteNativeCapletsService, type RemoteCapletsClient } from "../src/native/remote"; +import { createNativeCapletsService } from "../src/native/service"; + +function client( + tools: Array<{ name: string; title?: string | undefined; description?: string | undefined }> = [ + { name: "alpha", title: "Alpha", description: "Remote alpha" }, + ], +) { + const listeners = new Set<() => void>(); + return { + api: { + listTools: vi.fn(async () => tools), + callTool: vi.fn(async (name: string, args: unknown) => ({ name, args })), + onToolsChanged: vi.fn((listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + close: vi.fn(async () => undefined), + } satisfies RemoteCapletsClient, + emit: () => { + for (const listener of listeners) listener(); + }, + listenerCount: () => listeners.size, + setTools: (next: typeof tools) => { + tools = next; + }, + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +describe("RemoteNativeCapletsService", () => { + it("maps remote MCP tools to native Caplet tools", async () => { + const fixture = client([{ name: "git-hub", title: undefined, description: "GitHub tools" }]); + const service = new RemoteNativeCapletsService({ client: fixture.api, pollIntervalMs: 60_000 }); + + await service.reload(); + + expect(service.listTools()).toEqual([ + expect.objectContaining({ + caplet: "git-hub", + toolName: "caplets_git_hub", + title: "git-hub", + }), + ]); + expect(service.listTools()[0]?.description).toContain("GitHub tools"); + expect(service.listTools()[0]?.description).toContain("Native tool name: caplets_git_hub"); + expect(service.listTools()[0]?.description).toContain("Remote Caplet ID: git-hub"); + expect(service.listTools()[0]?.promptGuidance.join("\n")).toContain("remote Caplets service"); + + await service.close(); + }); + + it("executes by remote Caplet ID", async () => { + const fixture = client(); + const service = new RemoteNativeCapletsService({ client: fixture.api, pollIntervalMs: 60_000 }); + + await expect(service.execute("alpha", { input: true })).resolves.toEqual({ + name: "alpha", + args: { input: true }, + }); + expect(fixture.api.callTool).toHaveBeenCalledWith("alpha", { input: true }); + + await service.close(); + }); + + it("reconnects once and retries executions after session-like failures", async () => { + const first = client(); + const second = client(); + first.api.callTool = vi.fn(async () => { + throw new Error("transport connection closed"); + }); + second.api.callTool = vi.fn(async (name: string, args: unknown) => ({ + name, + args, + client: "second", + })); + const factory = vi.fn(() => second.api); + const service = new RemoteNativeCapletsService({ + client: first.api, + clientFactory: factory, + pollIntervalMs: 60_000, + }); + + await expect(service.execute("alpha", { input: true })).resolves.toEqual({ + name: "alpha", + args: { input: true }, + client: "second", + }); + + expect(first.api.callTool).toHaveBeenCalledTimes(1); + expect(factory).toHaveBeenCalledTimes(1); + expect(first.api.close).toHaveBeenCalledTimes(1); + expect(second.api.callTool).toHaveBeenCalledWith("alpha", { input: true }); + + await service.close(); + }); + + it("notifies listeners when remote tool list changes", async () => { + const fixture = client([{ name: "alpha", description: "Alpha" }]); + const service = new RemoteNativeCapletsService({ client: fixture.api, pollIntervalMs: 60_000 }); + await service.reload(); + const listener = vi.fn(); + service.onToolsChanged(listener); + fixture.setTools([{ name: "beta", title: "Beta", description: "Beta" }]); + + fixture.emit(); + await vi.waitFor(() => expect(listener).toHaveBeenCalled()); + + expect(listener).toHaveBeenCalledWith([ + expect.objectContaining({ caplet: "beta", toolName: "caplets_beta" }), + ]); + await service.close(); + }); + + it("keeps last known-good tools and warns when reload fails", async () => { + const fixture = client([{ name: "alpha", description: "Alpha" }]); + const writeErr = vi.fn(); + const service = new RemoteNativeCapletsService({ + client: fixture.api, + pollIntervalMs: 60_000, + writeErr, + }); + await service.reload(); + fixture.api.listTools = vi.fn(async () => { + throw new Error("boom"); + }); + + await expect(service.reload()).resolves.toBe(false); + + expect(service.listTools()).toEqual([expect.objectContaining({ caplet: "alpha" })]); + expect(writeErr).toHaveBeenCalledWith( + expect.stringContaining("Could not reload remote Caplets tools"), + ); + await service.close(); + }); + + it("reconnects once for invalid remote sessions and keeps last known-good tools if retry fails", async () => { + const first = client([{ name: "alpha", description: "Alpha" }]); + const second = client([{ name: "beta", description: "Beta" }]); + const writeErr = vi.fn(); + second.api.listTools = vi.fn(async () => { + throw new Error("still closed connection"); + }); + const factory = vi.fn(() => second.api); + const service = new RemoteNativeCapletsService({ + client: first.api, + clientFactory: factory, + pollIntervalMs: 60_000, + writeErr, + }); + await service.reload(); + first.api.listTools = vi.fn(async () => { + throw new Error("invalid session"); + }); + + await expect(service.reload()).resolves.toBe(false); + + expect(factory).toHaveBeenCalledTimes(1); + expect(first.api.close).toHaveBeenCalledTimes(1); + expect(service.listTools()).toEqual([expect.objectContaining({ caplet: "alpha" })]); + expect(writeErr).toHaveBeenCalledWith(expect.stringContaining("still closed connection")); + await service.close(); + }); + + it("does not create or retain a new client when closed during failed reconnect", async () => { + const first = client([{ name: "alpha", description: "Alpha" }]); + const second = client([{ name: "beta", description: "Beta" }]); + const firstClose = deferred(); + const writeErr = vi.fn(); + first.api.close = vi.fn(async (): Promise => { + await firstClose.promise; + return undefined; + }); + first.api.listTools = vi.fn(async () => { + throw new Error("transport connection closed"); + }); + const factory = vi.fn(() => second.api); + const service = new RemoteNativeCapletsService({ + client: first.api, + clientFactory: factory, + pollIntervalMs: 60_000, + writeErr, + }); + + const reload = service.reload(); + await vi.waitFor(() => expect(first.api.close).toHaveBeenCalledTimes(1)); + const closing = service.close(); + firstClose.resolve(); + + await expect(reload).resolves.toBe(false); + await closing; + + expect(factory).not.toHaveBeenCalled(); + expect(second.api.onToolsChanged).not.toHaveBeenCalled(); + expect(second.listenerCount()).toBe(0); + expect(second.api.close).not.toHaveBeenCalled(); + expect(writeErr).not.toHaveBeenCalled(); + }); + + it("reconnects once for invalid remote sessions and reloads from the new client", async () => { + const first = client([{ name: "alpha", description: "Alpha" }]); + const second = client([{ name: "beta", description: "Beta" }]); + const service = new RemoteNativeCapletsService({ + client: first.api, + clientFactory: vi.fn(() => second.api), + pollIntervalMs: 60_000, + }); + await service.reload(); + first.api.listTools = vi.fn(async () => { + throw new Error("transport connection closed"); + }); + + await expect(service.reload()).resolves.toBe(true); + + expect(service.listTools()).toEqual([expect.objectContaining({ caplet: "beta" })]); + await service.close(); + }); + + it("classifies remote auth failures with credential guidance", async () => { + const fixture = client(); + fixture.api.callTool = vi.fn(async () => { + throw new Error("403 Forbidden"); + }); + const service = new RemoteNativeCapletsService({ client: fixture.api, pollIntervalMs: 60_000 }); + + await expect(service.execute("alpha", {})).rejects.toMatchObject({ + code: "AUTH_FAILED", + message: expect.stringContaining("CAPLETS_REMOTE_USER"), + } satisfies Partial); + + await service.close(); + }); + + it("polls the remote service as a fallback for tool changes", async () => { + vi.useFakeTimers(); + const fixture = client([{ name: "alpha", description: "Alpha" }]); + const service = new RemoteNativeCapletsService({ client: fixture.api, pollIntervalMs: 1_000 }); + + vi.advanceTimersByTime(1_000); + await vi.waitFor(() => expect(fixture.api.listTools).toHaveBeenCalledTimes(1)); + + await service.close(); + vi.useRealTimers(); + }); + + it("cleans up subscriptions, polling, listeners, and client close idempotently", async () => { + vi.useFakeTimers(); + const fixture = client(); + const service = new RemoteNativeCapletsService({ client: fixture.api, pollIntervalMs: 1_000 }); + service.onToolsChanged(vi.fn()); + expect(fixture.listenerCount()).toBe(1); + + await service.close(); + await service.close(); + vi.advanceTimersByTime(1_000); + + expect(fixture.listenerCount()).toBe(0); + expect(fixture.api.close).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); + +describe("createNativeCapletsService remote mode", () => { + it("creates a remote service using the factory seam", () => { + const fixture = client(); + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387/mcp" }, + remoteClientFactory: vi.fn(() => fixture.api), + }); + + expect(service).toBeInstanceOf(RemoteNativeCapletsService); + }); + + it("fails fast for invalid remote config", () => { + expect(() => + createNativeCapletsService({ mode: "remote", remote: { url: "http://example.com/mcp" } }), + ).toThrow(/https/u); + }); +}); diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts new file mode 100644 index 00000000..0e412b3c --- /dev/null +++ b/packages/core/test/serve-http.test.ts @@ -0,0 +1,201 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CapletsEngine } from "../src/engine"; +import { createHttpServeApp } from "../src/serve/http"; +import type { HttpServeOptions } from "../src/serve/options"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("createHttpServeApp", () => { + it("serves root info and health without auth", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + + const root = await app.request("http://127.0.0.1:5387/"); + expect(root.status).toBe(200); + await expect(root.json()).resolves.toMatchObject({ + name: "caplets", + transport: "http", + mcp: "/mcp", + health: "/healthz", + auth: { type: "basic", enabled: false }, + }); + + const health = await app.request("http://127.0.0.1:5387/healthz"); + expect(health.status).toBe(200); + await expect(health.json()).resolves.toEqual({ + status: "ok", + transport: "http", + mcpPath: "/mcp", + }); + + await engine.close(); + }); + + it("logs basic HTTP requests to stderr", async () => { + const { engine } = testEngine(); + const logs: string[] = []; + const app = createHttpServeApp(httpOptions(), engine, { + writeErr: (value) => logs.push(value), + }); + + const response = await app.request("http://127.0.0.1:5387/healthz"); + + expect(response.status).toBe(200); + expect(logs.join("")).toContain("<-- GET /healthz"); + const plainLogs = logs.join("").replaceAll(String.fromCharCode(27), ""); + expect(plainLogs).toContain("--> GET /healthz"); + expect(plainLogs).toContain("200"); + + await engine.close(); + }); + + it("requires Basic Auth on MCP path when password is configured", async () => { + const { engine } = testEngine(); + const testPassword = ["test", "password"].join("-"); + const app = createHttpServeApp( + httpOptions({ auth: { enabled: true, user: "caplets", password: testPassword } }), + engine, + { writeErr: () => {} }, + ); + + const missing = await app.request("http://127.0.0.1:5387/mcp", { method: "POST" }); + expect(missing.status).toBe(401); + expect(missing.headers.get("www-authenticate")).toContain("Basic"); + + const wrong = await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + authorization: `Basic ${Buffer.from(`caplets:not-the-${testPassword}`).toString("base64")}`, + }, + }); + expect(wrong.status).toBe(401); + + await engine.close(); + }); + + it("returns 404 for nested MCP paths", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + + const response = await app.request("http://127.0.0.1:5387/mcp/extra"); + expect(response.status).toBe(404); + + await engine.close(); + }); + + it("initializes an MCP HTTP session and lists Caplet tools", async () => { + const { engine } = testEngine(); + const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} }); + + const init = await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + host: "127.0.0.1:5387", + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + }), + }); + + expect(init.status).toBe(200); + const sessionId = init.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + + await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + host: "127.0.0.1:5387", + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": sessionId!, + "mcp-protocol-version": "2025-03-26", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), + }); + + const tools = await app.request("http://127.0.0.1:5387/mcp", { + method: "POST", + headers: { + host: "127.0.0.1:5387", + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": sessionId!, + "mcp-protocol-version": "2025-03-26", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), + }); + + expect(tools.status).toBe(200); + const body = await tools.text(); + expect(body).toContain("status"); + + const deleted = await app.request("http://127.0.0.1:5387/mcp", { + method: "DELETE", + headers: { + "mcp-session-id": sessionId!, + "mcp-protocol-version": "2025-03-26", + host: "127.0.0.1:5387", + }, + }); + expect(deleted.status).toBe(200); + + await engine.close(); + }); +}); + +function httpOptions(overrides: Partial = {}): HttpServeOptions { + return { + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/mcp", + auth: { enabled: false, user: "caplets" }, + warnUnauthenticatedNetwork: false, + loopback: true, + ...overrides, + }; +} + +function testEngine(): { engine: CapletsEngine } { + const dir = mkdtempSync(join(tmpdir(), "caplets-http-")); + dirs.push(dir); + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project", ".caplets"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + const configPath = join(userRoot, "config.json"); + const projectConfigPath = join(projectRoot, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + httpApis: { + status: { + name: "Status", + description: "Status API.", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { check: { method: "GET", path: "/check" } }, + }, + }, + }), + ); + return { engine: new CapletsEngine({ configPath, projectConfigPath, watch: false }) }; +} diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts new file mode 100644 index 00000000..9089c531 --- /dev/null +++ b/packages/core/test/serve-options.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { resolveServeOptions } from "../src/serve/options"; + +describe("resolveServeOptions", () => { + it("defaults serve to stdio", () => { + expect(resolveServeOptions({}, {})).toEqual({ transport: "stdio" }); + }); + + it("defaults HTTP serving to localhost port 5387 and /mcp", () => { + expect(resolveServeOptions({ transport: "http" }, {})).toMatchObject({ + transport: "http", + host: "127.0.0.1", + port: 5387, + path: "/mcp", + auth: { enabled: false, user: "caplets" }, + }); + }); + + it("normalizes trailing slashes in HTTP path", () => { + expect(resolveServeOptions({ transport: "http", path: "/custom/" }, {})).toMatchObject({ + transport: "http", + path: "/custom", + }); + }); + + it("resolves Basic Auth from password with default user", () => { + const testPassword = ["test", "password"].join("-"); + + expect(resolveServeOptions({ transport: "http", password: testPassword }, {})).toMatchObject({ + transport: "http", + auth: { enabled: true, user: "caplets", password: testPassword }, + }); + }); + + it("resolves Basic Auth from env and lets flags win", () => { + const envPassword = ["test", "env", "password"].join("-"); + + expect( + resolveServeOptions( + { transport: "http", user: "cli-user" }, + { CAPLETS_SERVER_USER: "env-user", CAPLETS_SERVER_PASSWORD: envPassword }, + ), + ).toMatchObject({ + transport: "http", + auth: { enabled: true, user: "cli-user", password: envPassword }, + }); + }); + + it("rejects explicit user without password", () => { + expect(() => resolveServeOptions({ transport: "http", user: "alice" }, {})).toThrow( + /requires a password/u, + ); + }); + + it("requires explicit opt-in for unauthenticated non-loopback HTTP serving", () => { + expect(() => resolveServeOptions({ transport: "http", host: "0.0.0.0" }, {})).toThrow( + /requires --allow-unauthenticated-http/u, + ); + }); + + it("allows unauthenticated non-loopback HTTP serving with explicit opt-in", () => { + expect( + resolveServeOptions( + { transport: "http", host: "0.0.0.0", allowUnauthenticatedHttp: true }, + {}, + ), + ).toMatchObject({ + transport: "http", + host: "0.0.0.0", + auth: { enabled: false, user: "caplets" }, + warnUnauthenticatedNetwork: true, + }); + }); + + it("rejects HTTP-only options for stdio", () => { + expect(() => resolveServeOptions({ transport: "stdio", host: "127.0.0.1" }, {})).toThrow( + /only valid with --transport http/u, + ); + }); + + it("rejects invalid port and path", () => { + expect(() => resolveServeOptions({ transport: "http", port: "0" }, {})).toThrow( + /valid TCP port/u, + ); + expect(() => resolveServeOptions({ transport: "http", path: "mcp" }, {})).toThrow( + /must start with/u, + ); + expect(() => resolveServeOptions({ transport: "http", path: "/mcp?x=1" }, {})).toThrow( + /query string/u, + ); + }); +}); diff --git a/packages/core/test/serve-session.test.ts b/packages/core/test/serve-session.test.ts new file mode 100644 index 00000000..f90bdf6d --- /dev/null +++ b/packages/core/test/serve-session.test.ts @@ -0,0 +1,110 @@ +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.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CapletsEngine } from "../src/engine"; +import { CapletsMcpSession } from "../src/serve/session"; + +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("CapletsMcpSession", () => { + it("registers enabled Caplets from a shared engine", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { name: "Alpha", description: "Search alpha.", command: "node" }, + beta: { name: "Beta", description: "Search beta.", command: "node", disabled: true }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const server = mockServer(); + const session = new CapletsMcpSession(engine, { server }); + + expect(session.registeredToolIds()).toEqual(["alpha"]); + expect(server.registerTool).toHaveBeenCalledTimes(1); + + await session.close(); + await engine.close(); + }); + + it("reconciles tools when the shared engine reloads", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { name: "Alpha", description: "Search alpha.", command: "node" }, + }, + }); + dirs.push(dir); + const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false }); + const server = mockServer(); + const session = new CapletsMcpSession(engine, { server }); + const alpha = server.registered.get("alpha")!; + + writeConfig(configPath, { + httpApis: { + gamma: { + name: "Gamma HTTP", + description: "Call gamma over HTTP.", + baseUrl: "http://127.0.0.1:1", + auth: { type: "none" }, + actions: { search: { method: "GET", path: "/search" } }, + }, + }, + }); + await engine.reload(); + + expect(alpha.remove).toHaveBeenCalledTimes(1); + expect(session.registeredToolIds()).toEqual(["gamma"]); + expect(server.registered.get("gamma")).toBeDefined(); + + await session.close(); + await engine.close(); + }); +}); + +function tempConfig(config: unknown): { + dir: string; + configPath: string; + projectConfigPath: string; +} { + const dir = mkdtempSync(join(tmpdir(), "caplets-session-")); + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project", ".caplets"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + const configPath = join(userRoot, "config.json"); + const projectConfigPath = join(projectRoot, "config.json"); + writeConfig(configPath, config); + return { dir, configPath, projectConfigPath }; +} + +function writeConfig(path: string, config: unknown): void { + writeFileSync(path, JSON.stringify(config)); +} + +function mockServer() { + const registered = new Map(); + return { + registered, + registerTool: vi.fn((name: string) => { + const tool = { + update: vi.fn(), + remove: vi.fn(() => registered.delete(name)), + enable: vi.fn(), + disable: vi.fn(), + enabled: true, + handler: vi.fn(), + } as unknown as RegisteredTool; + registered.set(name, tool); + return tool; + }), + connect: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} diff --git a/packages/opencode/README.md b/packages/opencode/README.md index d6e221bb..580c02b0 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -15,3 +15,41 @@ existing native tools execute against the latest valid backend config and prompt rebuilt from current Caplets state for the tools registered when the plugin loaded. OpenCode's current plugin API snapshots `Hooks.tool` at plugin load, so adding, removing, or renaming native tools still requires restarting OpenCode; newly added tools are not advertised until restart. + +## Remote Caplets service + +By default the plugin reads local Caplets config. To use a remote `caplets serve --transport http` service, set environment variables: + +```sh +CAPLETS_REMOTE_URL=http://127.0.0.1:5387/mcp opencode +``` + +For authenticated remote services, keep the password in the environment: + +```sh +CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \ +CAPLETS_REMOTE_USER=caplets \ +CAPLETS_REMOTE_PASSWORD=... \ +opencode +``` + +OpenCode plugin config can also pass non-secret settings as the plugin factory's second argument: + +```ts +export default { + plugin: [ + [ + "@caplets/opencode", + { + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + }, + }, + ], + ], +}; +``` + +Plugin config overrides environment variables. Prefer `CAPLETS_REMOTE_PASSWORD` for the Basic Auth password unless your OpenCode setup provides secure secret storage. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c04de4fb..e92da454 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,14 +1,41 @@ -import { type Plugin, type PluginInput } from "@opencode-ai/plugin"; +import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import { createNativeCapletsService, registerNativeCapletsProcessCleanup, + type NativeCapletsServiceOptions, } from "@caplets/core/native"; import { createCapletsOpenCodeHooks } from "./hooks"; -const plugin: Plugin = async (_ctx: PluginInput) => { - const service = createNativeCapletsService(); +export type CapletsOpenCodeConfig = { + mode?: "auto" | "local" | "remote"; + remote?: { + url?: string; + user?: string; + password?: string; + pollIntervalMs?: number; + fetch?: typeof fetch; + }; +}; + +const plugin = (async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => { + const service = createNativeCapletsService( + normalizeOpenCodeConfig(config) as NativeCapletsServiceOptions, + ); registerNativeCapletsProcessCleanup(service); + if (!(await service.reload())) { + throw new Error("Failed to initialize Caplets native service."); + } return createCapletsOpenCodeHooks(service); -}; +}) as Plugin; + +function normalizeOpenCodeConfig(config: CapletsOpenCodeConfig | undefined): CapletsOpenCodeConfig { + if (!config) { + return {}; + } + return { + ...(config.mode ? { mode: config.mode } : {}), + ...(config.remote ? { remote: config.remote } : {}), + }; +} export default plugin; diff --git a/packages/opencode/test/opencode.test.ts b/packages/opencode/test/opencode.test.ts index 07bec4d5..75141224 100644 --- a/packages/opencode/test/opencode.test.ts +++ b/packages/opencode/test/opencode.test.ts @@ -158,4 +158,77 @@ describe("@caplets/opencode", () => { expect(system).not.toContain("caplets_git_hub"); expect(system).not.toContain("caplets_slack"); }); + + it("passes second-argument config into the native service", async () => { + vi.resetModules(); + const nativeMocks = { + createNativeCapletsService: vi.fn(() => ({ + listTools: () => [], + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => true), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + })), + registerNativeCapletsProcessCleanup: vi.fn(), + }; + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index.js")).default; + + await plugin( + {} as never, + { + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + pollIntervalMs: 5_000, + }, + } as never, + ); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({ + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + pollIntervalMs: 5_000, + }, + }); + }); + + it("awaits initial native service reload before creating hooks", async () => { + vi.resetModules(); + const tools = [ + { + caplet: "git-hub", + toolName: "caplets_git_hub", + title: "GitHub", + description: "GitHub Caplet", + promptGuidance: ["Use caplets_git_hub for GitHub."], + }, + ]; + let reloaded = false; + const service = { + listTools: vi.fn(() => (reloaded ? tools : [])), + execute: vi.fn(async () => ({})), + reload: vi.fn(async () => { + reloaded = true; + return true; + }), + onToolsChanged: vi.fn(() => () => {}), + close: vi.fn(async () => {}), + }; + const nativeMocks = { + createNativeCapletsService: vi.fn(() => service), + registerNativeCapletsProcessCleanup: vi.fn(), + }; + vi.doMock("@caplets/core/native", () => nativeMocks); + const plugin = (await import("../src/index.js")).default; + + const hooks = await plugin({} as never, undefined as never); + + expect(service.reload).toHaveBeenCalledOnce(); + expect(service.listTools).toHaveBeenCalledAfter(service.reload); + expect(Object.keys(hooks.tool ?? {})).toEqual(["caplets_git_hub"]); + }); }); diff --git a/packages/pi/README.md b/packages/pi/README.md index bae6311a..5ad154be 100644 --- a/packages/pi/README.md +++ b/packages/pi/README.md @@ -32,3 +32,60 @@ removed or disabled Caplets are deactivated with Pi's active-tool APIs when avai running without `getActiveTools()` / `setActiveTools()`, stale tools may remain registered until Pi reloads extensions or restarts, but calls to removed Caplets return Caplets' normal structured "server not found" error. + +## Remote Caplets service + +By default the extension uses the local Caplets native service. To connect Pi to a remote +`caplets serve --transport http` service, prefer environment variables for connection details, +especially the password: + +```sh +export CAPLETS_REMOTE_URL="https://caplets.example.com/mcp" +export CAPLETS_REMOTE_USER="caplets" +export CAPLETS_REMOTE_PASSWORD="..." # or load from your shell/secret manager +``` + +Pi currently calls extension factories with the Pi API only, so this extension reads its remote +settings from the top-level `caplets` key in `~/.pi/agent/settings.json` when no programmatic +options are supplied: + +```json +{ + "packages": ["npm:@caplets/pi"], + "caplets": { + "mode": "remote", + "remote": { + "url": "https://caplets.example.com/mcp", + "user": "caplets" + }, + "statusWidget": true, + "nerdFontIcons": true + } +} +``` + +Only this top-level `caplets` settings form is read from Pi settings. Object package entries +with `args` or `native` are ignored. + +When remote mode is active, Pi shows a compact footer status such as `󰖟 caplets ✓` or +`󰖟 caplets ×`. Set `"statusWidget": false` under top-level `caplets` to hide it, or +`"nerdFontIcons": false` to use plain `caplets ✓` / `caplets ×` text. +Programmatic or inline embedding can pass explicit native options with the exported factory +helper instead of relying on Pi package-loader args: + +```ts +import { createCapletsPiExtension } from "@caplets/pi"; + +export default createCapletsPiExtension({ + args: { + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "caplets", + }, + }, +}); +``` + +Prefer environment variables for `CAPLETS_REMOTE_PASSWORD` rather than storing passwords in +settings files or source code. diff --git a/packages/pi/src/index.ts b/packages/pi/src/index.ts index 6b1a8d17..0b99e3bf 100644 --- a/packages/pi/src/index.ts +++ b/packages/pi/src/index.ts @@ -1,25 +1,122 @@ -import { keyText, type ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + keyText, + type ExtensionAPI, + type ExtensionContext, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { generatedToolInputJsonSchema } from "@caplets/core/generated-tool-input-schema"; import { createNativeCapletsService, registerNativeCapletsProcessCleanup, type NativeCapletTool, type NativeCapletsService, + type NativeCapletsServiceOptions, } from "@caplets/core/native"; +type PiNativeCapletsOptions = Pick; + export type PiExtensionApi = Pick & - Partial>; + Partial> & { + on?: ( + event: "session_start" | "session_shutdown", + handler: (event?: unknown, ctx?: ExtensionContext) => void, + ) => void; + }; -type PiToolDefinition = Parameters[0]; +type PiCapletsSettings = PiNativeCapletsOptions & { + statusWidget?: boolean; + nerdFontIcons?: boolean; +}; export type CapletsPiOptions = { service?: NativeCapletsService; + args?: PiNativeCapletsOptions; + native?: PiNativeCapletsOptions; + statusWidget?: boolean; }; -export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsPiOptions = {}) { +type InternalCapletsPiOptions = CapletsPiOptions & { + loadSettings?: boolean; + settingsPath?: string; + readSettingsFile?: (path: string) => Promise; + writeWarning?: (message: string) => void; +}; + +export function createCapletsPiExtension( + options: CapletsPiOptions, +): (pi: PiExtensionApi) => void | Promise { + return (pi) => registerCapletsPiExtension(pi, options); +} + +export async function loadPiSettingsArgs( + options: { + settingsPath?: string; + projectSettingsPath?: string; + readSettingsFile?: (path: string) => Promise; + writeWarning?: (message: string) => void; + } = {}, +): Promise { + const settingsPath = options.settingsPath ?? join(homedir(), ".pi", "agent", "settings.json"); + const projectSettingsPath = + options.projectSettingsPath ?? join(process.cwd(), ".pi", "settings.json"); + const readSettingsFile = options.readSettingsFile ?? readFileUtf8; + const writeWarning = options.writeWarning ?? ((message) => process.stderr.write(`${message}\n`)); + const userSettings = await readPiSettingsFile(settingsPath, readSettingsFile, writeWarning); + const projectSettings = await readPiSettingsFile( + projectSettingsPath, + readSettingsFile, + writeWarning, + ); + return { + ...extractPiSettingsArgs(userSettings, writeWarning), + ...extractPiSettingsArgs(projectSettings, writeWarning), + }; +} + +async function readPiSettingsFile( + settingsPath: string, + readSettingsFile: (path: string) => Promise, + writeWarning: (message: string) => void, +): Promise { + try { + return JSON.parse(await readSettingsFile(settingsPath)); + } catch (error) { + if (isFileNotFoundError(error)) { + return {}; + } + writeWarning( + `[caplets/pi] Ignoring Pi settings args from ${settingsPath}: ${safeErrorMessage(error)}`, + ); + return {}; + } +} + +export default async function capletsPiExtension(pi: PiExtensionApi): Promise { + return registerCapletsPiExtension(pi, { loadSettings: true }); +} + +async function registerCapletsPiExtension( + pi: PiExtensionApi, + options: InternalCapletsPiOptions, +): Promise { const ownsService = !options.service; - const service = options.service ?? createNativeCapletsService(); + const explicitNativeOptions = options.native ?? options.args; + const settingsArgs = + ownsService && !explicitNativeOptions && options.loadSettings + ? await loadPiSettingsArgs(options) + : undefined; + const serviceOptions = explicitNativeOptions ?? settingsArgs ?? {}; + const service = + options.service ?? createNativeCapletsService(nativeServiceOptions(serviceOptions)); + const showStatusWidget = shouldShowStatusWidget( + serviceOptions, + options.statusWidget ?? settingsArgs?.statusWidget, + ); + const useNerdFontIcons = settingsArgs?.nerdFontIcons !== false; if (ownsService) { registerNativeCapletsProcessCleanup(service); } @@ -28,6 +125,27 @@ export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsP let currentCapletTools = new Set(); let knownCapletTools = new Set(); let canSyncActiveTools = false; + let statusCtx: ExtensionContext | undefined; + let remoteStatus: "connected" | "offline" = "offline"; + + const syncStatusWidget = () => { + if (!showStatusWidget || !statusCtx) { + return; + } + statusCtx.ui.setWidget( + "caplets", + (_tui, theme) => + new Text( + theme.fg( + remoteStatus === "connected" ? "success" : "error", + capletsRemoteStatusText(remoteStatus, useNerdFontIcons), + ), + 0, + 0, + ), + { placement: "belowEditor" }, + ); + }; const syncToolRegistrations = (caplets = service.listTools()) => { const nextCapletTools = new Set(caplets.map((caplet) => caplet.toolName)); @@ -61,11 +179,25 @@ export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsP knownCapletTools = nextCapletTools; }; - currentCapletTools = syncToolRegistrations(); - const unsubscribe = service.onToolsChanged((caplets) => { - syncActiveTools(syncToolRegistrations(caplets)); - }); - pi.on?.("session_start", () => { + let unsubscribe: (() => void) | undefined; + let isShutdown = false; + const startSync = () => { + if (isShutdown) { + return; + } + currentCapletTools = syncToolRegistrations(); + unsubscribe = service.onToolsChanged((caplets) => { + if (isShutdown) { + return; + } + remoteStatus = "connected"; + syncStatusWidget(); + syncActiveTools(syncToolRegistrations(caplets)); + }); + }; + pi.on?.("session_start", (_event, ctx) => { + statusCtx = ctx; + syncStatusWidget(); canSyncActiveTools = true; knownCapletTools = new Set( pi.getActiveTools?.().filter((name) => name.startsWith("caplets_")) ?? [], @@ -73,11 +205,138 @@ export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsP syncActiveTools(); }); pi.on?.("session_shutdown", () => { - unsubscribe(); + isShutdown = true; + statusCtx?.ui.setWidget("caplets", undefined); + statusCtx = undefined; + unsubscribe?.(); if (ownsService) { void service.close(); } }); + if (ownsService) { + remoteStatus = (await service.reload()) ? "connected" : "offline"; + if (!isShutdown) { + startSync(); + } + return; + } + startSync(); +} + +function extractPiSettingsArgs( + settings: unknown, + writeWarning: (message: string) => void, + path = "settings", +): PiCapletsSettings { + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + return {}; + } + const settingsObject = settings as Record; + return topLevelCapletsOptions(settingsObject, writeWarning, path) ?? {}; +} + +function topLevelCapletsOptions( + settings: Record, + writeWarning: (message: string) => void, + path: string, +): PiCapletsSettings | undefined { + for (const key of ["caplets", "@caplets/pi", "capletsPi", "caplets-pi"]) { + const value = objectProperty(settings, key); + if (!value) { + continue; + } + const argsValue = objectProperty(value, "native") ?? objectProperty(value, "args") ?? value; + const parsed = parsePiNativeOptions(argsValue); + if (!parsed) { + writeWarning(`[caplets/pi] Ignoring Pi settings args: invalid ${path}.${key} shape`); + return {}; + } + return parsed; + } + return undefined; +} + +function parsePiNativeOptions(value: unknown): PiCapletsSettings | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const result: PiCapletsSettings = {}; + const mode = (value as Record).mode; + if (mode !== undefined) { + if (mode !== "auto" && mode !== "local" && mode !== "remote") return undefined; + result.mode = mode; + } + const statusWidget = (value as Record).statusWidget; + if (statusWidget !== undefined) { + if (typeof statusWidget !== "boolean") return undefined; + result.statusWidget = statusWidget; + } + const nerdFontIcons = (value as Record).nerdFontIcons; + if (nerdFontIcons !== undefined) { + if (typeof nerdFontIcons !== "boolean") return undefined; + result.nerdFontIcons = nerdFontIcons; + } + const remote = objectProperty(value, "remote"); + if (remote) { + const parsedRemote: NonNullable = {}; + for (const key of ["url", "user", "password"] as const) { + const field = remote[key]; + if (field !== undefined) { + if (typeof field !== "string") return undefined; + parsedRemote[key] = field; + } + } + const pollIntervalMs = remote.pollIntervalMs; + if (pollIntervalMs !== undefined) { + if (typeof pollIntervalMs !== "number" || !Number.isFinite(pollIntervalMs)) return undefined; + parsedRemote.pollIntervalMs = pollIntervalMs; + } + result.remote = parsedRemote; + } + return result; +} + +function capletsRemoteStatusText(status: "connected" | "offline", nerdFontIcons: boolean): string { + if (nerdFontIcons) { + return status === "connected" ? "󰖟 caplets ✓" : "󰖟 caplets ×"; + } + return status === "connected" ? "caplets ✓" : "caplets ×"; +} + +function nativeServiceOptions(options: PiCapletsSettings): PiNativeCapletsOptions { + return { + ...(options.mode ? { mode: options.mode } : {}), + ...(options.remote ? { remote: options.remote } : {}), + }; +} + +function shouldShowStatusWidget( + options: PiNativeCapletsOptions, + statusWidget: boolean | undefined, +): boolean { + if (statusWidget === false) { + return false; + } + return ( + options.mode === "remote" || + !!options.remote?.url || + process.env.CAPLETS_REMOTE_URL !== undefined + ); +} + +async function readFileUtf8(path: string): Promise { + return readFile(path, "utf8"); +} + +function isFileNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && error !== null && (error as { code?: unknown }).code === "ENOENT" + ); +} + +function safeErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/(password|token|secret)(["'\s:=]+)([^\s,"'}]+)/giu, "$1$2[redacted]"); } function piToolSignature(caplet: NativeCapletTool): string { @@ -89,14 +348,14 @@ function piToolSignature(caplet: NativeCapletTool): string { }); } -function createPiTool(service: NativeCapletsService, caplet: NativeCapletTool): PiToolDefinition { +function createPiTool(service: NativeCapletsService, caplet: NativeCapletTool): ToolDefinition { return { name: caplet.toolName, label: caplet.title, description: caplet.description, promptSnippet: `Use ${caplet.toolName} for the ${caplet.title} Caplet capability domain.`, promptGuidelines: caplet.promptGuidance, - parameters: generatedToolInputJsonSchema() as PiToolDefinition["parameters"], + parameters: generatedToolInputJsonSchema() as ToolDefinition["parameters"], async execute(_toolCallId, params) { const result = await service.execute(caplet.caplet, params); const serialized = serializeResult(result); diff --git a/packages/pi/test/pi.test.ts b/packages/pi/test/pi.test.ts index 0aa38d2a..da372350 100644 --- a/packages/pi/test/pi.test.ts +++ b/packages/pi/test/pi.test.ts @@ -1,16 +1,24 @@ -import { describe, expect, it, vi, type Mock } from "vitest"; +import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { visibleWidth } from "@earendil-works/pi-tui"; import { generatedToolInputJsonSchema } from "@caplets/core/generated-tool-input-schema"; -import type { NativeCapletTool, NativeCapletsService } from "@caplets/core/native"; -import capletsPiExtension, { type PiExtensionApi } from "../src/index"; +import type { + NativeCapletTool, + NativeCapletsService, + NativeCapletsServiceOptions, +} from "@caplets/core/native"; +import capletsPiExtension, { createCapletsPiExtension, type PiExtensionApi } from "../src/index"; const nativeMocks = vi.hoisted(() => ({ createNativeCapletsService: vi.fn(), registerNativeCapletsProcessCleanup: vi.fn(), })); -vi.mock("@caplets/core/native", () => nativeMocks); +const fsMocks = vi.hoisted(() => ({ + readFile: vi.fn(), +})); +vi.mock("@caplets/core/native", () => nativeMocks); +vi.mock("node:fs/promises", () => fsMocks); type RenderTheme = { bold(text: string): string; fg(_key: string, text: string): string; @@ -47,7 +55,17 @@ type MockPiApi = { registerTool: Mock<(definition: unknown) => void>; getActiveTools: Mock<() => string[]>; setActiveTools: Mock<(names: string[]) => void>; - on: Mock<(event: "session_start" | "session_shutdown", handler: () => void) => void>; + on: Mock< + ( + event: "session_start" | "session_shutdown", + handler: ( + event?: unknown, + ctx?: { + ui: { setWidget: Mock<(key: string, content: unknown, options?: unknown) => void> }; + }, + ) => void, + ) => void + >; }; type MockService = NativeCapletsService & { @@ -61,6 +79,10 @@ type MockService = NativeCapletsService & { }; describe("@caplets/pi", () => { + beforeEach(() => { + vi.clearAllMocks(); + fsMocks.readFile.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })); + }); it("uses the core generated schema as Pi tool parameters", () => { const service = mockService([ { @@ -73,10 +95,9 @@ describe("@caplets/pi", () => { ]); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); expect(registered[0]?.parameters).toEqual(generatedToolInputJsonSchema()); }); @@ -100,10 +121,9 @@ describe("@caplets/pi", () => { ]); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); expect(registered.map((tool) => tool.name)).toEqual(["caplets_git_hub", "caplets_linear"]); expect(registered.map((tool) => tool.promptGuidelines[0])).toEqual([ @@ -133,10 +153,9 @@ describe("@caplets/pi", () => { service.execute.mockRejectedValueOnce(error); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); await expect(registered[0]?.execute("call-1", { operation: "get_caplet" })).rejects.toThrow( "execution failed", @@ -157,10 +176,9 @@ describe("@caplets/pi", () => { service.execute.mockResolvedValueOnce({ count: 1n }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const result = await registered[0]?.execute("call-1", { operation: "get_caplet" }); expect(result?.content[0]?.text).toContain("Serialization error"); @@ -180,10 +198,9 @@ describe("@caplets/pi", () => { service.execute.mockResolvedValueOnce(undefined); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const result = await registered[0]?.execute("call-1", { operation: "get_caplet" }); expect(result?.content[0]?.text).toBe("null"); @@ -212,10 +229,9 @@ describe("@caplets/pi", () => { }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const tool = registered[0]; const result = await tool?.execute("call-1", { @@ -255,10 +271,9 @@ describe("@caplets/pi", () => { }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const tool = registered[0]; const result = await tool?.execute("call-1", { operation: "call_tool", tool: "query-docs" }); @@ -298,10 +313,9 @@ describe("@caplets/pi", () => { }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const browserResult = await registered[0]?.execute("call-1", { operation: "call_tool", @@ -365,10 +379,9 @@ describe("@caplets/pi", () => { }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const tool = registered[0]; const result = await tool?.execute("call-1", { @@ -412,10 +425,9 @@ describe("@caplets/pi", () => { }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const tool = registered[0]; const result = await tool?.execute("call-1", { @@ -464,10 +476,9 @@ describe("@caplets/pi", () => { }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const tool = registered[0]; const result = await tool?.execute("call-1", { @@ -511,10 +522,9 @@ describe("@caplets/pi", () => { }); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); const tool = registered[0]; const result = await tool?.execute("call-1", { operation: "list_tools" }); @@ -551,22 +561,65 @@ describe("@caplets/pi", () => { on: vi.fn(), }; - expect(() => capletsPiExtension(api as unknown as PiExtensionApi, { service })).not.toThrow(); + expect(() => + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi), + ).not.toThrow(); expect(api.registerTool).toHaveBeenCalledOnce(); expect(api.getActiveTools).not.toHaveBeenCalled(); expect(api.setActiveTools).not.toHaveBeenCalled(); }); - it("registers process cleanup for owned services", () => { + it("registers process cleanup for owned services", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + + await capletsPiExtension({ registerTool: vi.fn() }); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({}); + expect(nativeMocks.registerNativeCapletsProcessCleanup).toHaveBeenCalledWith(service); + }); + + it("passes explicit factory args to owned native service creation", () => { const service = mockService([]); + const args = { + mode: "remote", + remote: { url: "https://caplets.example.com/mcp", user: "pi-user" }, + } satisfies Pick; nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); - capletsPiExtension({ registerTool: vi.fn() }); + createCapletsPiExtension({ args })({ registerTool: vi.fn() }); - expect(nativeMocks.createNativeCapletsService).toHaveBeenCalled(); + expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith(args); expect(nativeMocks.registerNativeCapletsProcessCleanup).toHaveBeenCalledWith(service); }); + it("awaits owned service reload before initial tool registration", async () => { + const tools = [ + { + caplet: "git-hub", + toolName: "caplets_git_hub", + title: "GitHub", + description: "GitHub Caplet", + promptGuidance: ["Use caplets_git_hub for GitHub."], + }, + ]; + let reloaded = false; + const service = mockService([]); + service.listTools.mockImplementation(() => (reloaded ? tools : [])); + service.reload.mockImplementation(async () => { + reloaded = true; + return true; + }); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + const { api, registered } = mockPiApi(); + + await createCapletsPiExtension({ args: { mode: "remote" } })(api as unknown as PiExtensionApi); + + expect(service.reload).toHaveBeenCalledOnce(); + expect(service.listTools).toHaveBeenCalledAfter(service.reload); + expect(registered.map((tool) => tool.name)).toEqual(["caplets_git_hub"]); + }); it("registers newly added tools when the native service changes", () => { const service = mockService([ { @@ -579,7 +632,7 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api as unknown as PiExtensionApi, { service }); + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi); triggerSessionStart(api); expect(api.setActiveTools).toHaveBeenNthCalledWith(1, ["read", "caplets_git_hub"]); service.setTools([ @@ -620,7 +673,7 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api as unknown as PiExtensionApi, { service }); + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi); triggerSessionStart(api); service.setTools([ { @@ -654,7 +707,7 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api as unknown as PiExtensionApi, { service }); + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi); triggerSessionStart(api); service.setTools([ { @@ -683,7 +736,7 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(["read", "caplets_git_hub"]); - capletsPiExtension(api as unknown as PiExtensionApi, { service }); + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi); triggerSessionStart(api); service.setTools([]); service.emitToolsChanged(); @@ -721,7 +774,7 @@ describe("@caplets/pi", () => { ]); const { api } = mockPiApi(["read", "bash", "caplets_git_hub", "caplets_linear"]); - capletsPiExtension(api as unknown as PiExtensionApi, { service }); + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi); triggerSessionStart(api); service.setTools([ { @@ -749,7 +802,7 @@ describe("@caplets/pi", () => { ]); const { api } = mockPiApi(["read", "caplets_stale", "caplets_git_hub"]); - capletsPiExtension(api as unknown as PiExtensionApi, { service }); + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi); triggerSessionStart(api); expect(api.setActiveTools).toHaveBeenCalledWith(["read", "caplets_git_hub"]); @@ -759,10 +812,9 @@ describe("@caplets/pi", () => { const service = mockService([]); const registered: RegisteredTool[] = []; - capletsPiExtension( - { registerTool: (definition) => registered.push(definition as unknown as RegisteredTool) }, - { service }, - ); + createCapletsPiExtension({ service })({ + registerTool: (definition) => registered.push(definition as unknown as RegisteredTool), + }); service.setTools([ { @@ -790,7 +842,7 @@ describe("@caplets/pi", () => { ]); const { api, registered } = mockPiApi(); - capletsPiExtension(api as unknown as PiExtensionApi, { service }); + createCapletsPiExtension({ service })(api as unknown as PiExtensionApi); triggerSessionStart(api); const shutdown = api.on.mock.calls.find(([event]) => event === "session_shutdown")?.[1]; shutdown?.(); @@ -816,12 +868,255 @@ describe("@caplets/pi", () => { expect(service.close).not.toHaveBeenCalled(); }); - it("closes owned services on Pi session shutdown", () => { + it("project Pi settings override user Pi settings", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile + .mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { mode: "local" }, + }), + ) + .mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + remote: { url: "http://localhost:5387/mcp" }, + }, + }), + ); + const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(fsMocks.readFile).toHaveBeenNthCalledWith( + 1, + expect.stringContaining(".pi/agent/settings.json"), + "utf8", + ); + expect(fsMocks.readFile).toHaveBeenNthCalledWith( + 2, + expect.stringContaining(".pi/settings.json"), + "utf8", + ); + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({ + mode: "remote", + remote: { url: "http://localhost:5387/mcp" }, + }); + }); + + it("default export loads top-level Pi settings for the native service", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "ian", + pollIntervalMs: 1_000, + }, + }, + }), + ); + fsMocks.readFile.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({ + mode: "remote", + remote: { + url: "https://caplets.example.com/mcp", + user: "ian", + pollIntervalMs: 1_000, + }, + }); + }); + + it("ignores package entry args and uses empty settings without top-level caplets config", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: [ + { + source: "npm:@caplets/pi", + args: { mode: "remote", remote: { url: "https://ignored.example.com/mcp" } }, + }, + ], + }), + ); + fsMocks.readFile.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({}); + }); + + it("default export falls back to empty args when Pi settings are missing", async () => { const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })) + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({}); + }); + + it("warns and falls back to empty args when Pi settings are malformed", async () => { + const service = mockService([]); + const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true); nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile + .mockResolvedValueOnce("{ not json") + .mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + const { api } = mockPiApi(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({}); + expect(write).toHaveBeenCalledWith(expect.stringContaining("Ignoring Pi settings args")); + write.mockRestore(); + }); + + it("does not show a status widget for local settings", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { mode: "local" }, + }), + ); + const { api } = mockPiApi(); + const setWidget = vi.fn(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + triggerSessionStart(api, { ui: { setWidget } }); - capletsPiExtension(api as unknown as PiExtensionApi); + expect(setWidget).not.toHaveBeenCalled(); + }); + + it("shows a remote status widget by default for remote settings", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + remote: { url: "https://caplets.example.com/mcp" }, + }, + }), + ); + const { api } = mockPiApi(); + const setWidget = vi.fn(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + triggerSessionStart(api, { ui: { setWidget } }); + + expect(setWidget).toHaveBeenCalledWith("caplets", expect.any(Function), { + placement: "belowEditor", + }); + expect(renderStatusWidget(setWidget)).toBe("󰖟 caplets ✓"); + }); + + it("can disable nerd font icons in the remote status widget", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + remote: { url: "https://caplets.example.com/mcp" }, + nerdFontIcons: false, + }, + }), + ); + const { api } = mockPiApi(); + const setWidget = vi.fn(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + triggerSessionStart(api, { ui: { setWidget } }); + + expect(setWidget).toHaveBeenCalledWith("caplets", expect.any(Function), { + placement: "belowEditor", + }); + expect(renderStatusWidget(setWidget)).toBe("caplets ✓"); + }); + + it("can disable the remote status widget from settings", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { + mode: "remote", + remote: { url: "https://caplets.example.com/mcp" }, + statusWidget: false, + }, + }), + ); + const { api } = mockPiApi(); + const setWidget = vi.fn(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + triggerSessionStart(api, { ui: { setWidget } }); + + expect(setWidget).not.toHaveBeenCalled(); + }); + + it("shows remote offline when initial reload fails", async () => { + const service = mockService([]); + service.reload.mockResolvedValueOnce(false); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockResolvedValueOnce( + JSON.stringify({ + packages: ["npm:@caplets/pi"], + caplets: { mode: "remote", remote: { url: "https://caplets.example.com/mcp" } }, + }), + ); + const { api } = mockPiApi(); + const setWidget = vi.fn(); + + await capletsPiExtension(api as unknown as PiExtensionApi); + triggerSessionStart(api, { ui: { setWidget } }); + + expect(setWidget).toHaveBeenCalledWith("caplets", expect.any(Function), { + placement: "belowEditor", + }); + expect(renderStatusWidget(setWidget)).toBe("󰖟 caplets ×"); + }); + + it("programmatic args override Pi settings without reading the settings file", async () => { + const service = mockService([]); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + const { api } = mockPiApi(); + + await createCapletsPiExtension({ args: { mode: "local" } })(api as unknown as PiExtensionApi); + + expect(fsMocks.readFile).not.toHaveBeenCalled(); + expect(nativeMocks.createNativeCapletsService).toHaveBeenLastCalledWith({ mode: "local" }); + }); + + it("closes owned services on Pi session shutdown", async () => { + const service = mockService([]); + const { api } = mockPiApi(); + nativeMocks.createNativeCapletsService.mockReturnValueOnce(service); + fsMocks.readFile.mockRejectedValueOnce(Object.assign(new Error("missing"), { code: "ENOENT" })); + + await capletsPiExtension(api as unknown as PiExtensionApi); const shutdown = api.on.mock.calls.find(([event]) => event === "session_shutdown")?.[1]; shutdown?.(); @@ -834,13 +1129,29 @@ const plainTheme: RenderTheme = { fg: (_key, text) => text, }; +function renderStatusWidget(setWidget: Mock): string { + const factory = setWidget.mock.calls.at(-1)?.[1] as + | ((tui: unknown, theme: { fg(key: string, text: string): string }) => RenderComponent) + | undefined; + if (!factory) { + return ""; + } + return factory({} as never, { fg: (key, text) => `<${key}>${text}` }) + .render(80) + .join("\n") + .trimEnd(); +} + function renderText(component: RenderComponent | undefined): string { return component?.render(120).join("\n") ?? ""; } -function triggerSessionStart(api: MockPiApi): void { +function triggerSessionStart( + api: MockPiApi, + ctx?: { ui: { setWidget: Mock<(key: string, content: unknown, options?: unknown) => void> } }, +): void { const sessionStart = api.on.mock.calls.find(([event]) => event === "session_start")?.[1]; - sessionStart?.(); + sessionStart?.(undefined, ctx); } function mockPiApi(activeTools: string[] = []): { api: MockPiApi; registered: RegisteredTool[] } { diff --git a/plugins/caplets/skills/caplets/SKILL.md b/plugins/caplets/skills/caplets/SKILL.md index 06bcc1ac..0f4d8f91 100644 --- a/plugins/caplets/skills/caplets/SKILL.md +++ b/plugins/caplets/skills/caplets/SKILL.md @@ -33,6 +33,7 @@ Caplets exposes progressive discovery operations instead of flattening every dow - Request only the output fields needed when the Caplet supports field selection. - Treat Caplet backends as live integrations: handle unavailable services, auth failures, validation errors, and partial responses explicitly. - Avoid loading broad tool lists unless the user task requires exploration. +- When Caplets is configured as a remote MCP HTTP service, treat connection/auth failures as remote-service issues and ask the user to verify `CAPLETS_REMOTE_URL`, Basic Auth credentials, and that `caplets serve --transport http` is running. ## Example diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f4472a7..c4e477a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,7 +242,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages/benchmarks: dependencies: @@ -267,7 +267,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages/cli: dependencies: @@ -292,13 +292,19 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages/core: dependencies: '@apidevtools/swagger-parser': specifier: ^12.1.0 version: 12.1.0(openapi-types@12.1.3) + '@hono/mcp': + specifier: ^0.3.0 + version: 0.3.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(hono-rate-limiter@0.5.3(hono@4.12.19))(hono@4.12.19)(zod@4.4.3) + '@hono/node-server': + specifier: ^1.19.14 + version: 1.19.14(hono@4.12.19) '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) @@ -308,6 +314,9 @@ importers: graphql: specifier: ^16.14.0 version: 16.14.0 + hono: + specifier: ^4.12.19 + version: 4.12.19 vfile: specifier: ^6.0.3 version: 6.0.3 @@ -332,7 +341,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages/opencode: dependencies: @@ -357,7 +366,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages/pi: dependencies: @@ -385,7 +394,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + version: 4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) packages: @@ -817,6 +826,14 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@hono/mcp@0.3.0': + resolution: {integrity: sha512-UhSWFbZwRxHBdptOn2r1HyB8Vt6gU+BL3AbTis2t8TBW69ZhG4soJQ09yEL/t/ymxszJnWp5Rq6tOXXOjuK1qg==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.1 + hono: '*' + hono-rate-limiter: ^0.5.3 + zod: ^3.25.0 || ^4.0.0 + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -2312,6 +2329,15 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono-rate-limiter@0.5.3: + resolution: {integrity: sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A==} + peerDependencies: + hono: ^4.10.8 + unstorage: ^1.17.3 + peerDependenciesMeta: + unstorage: + optional: true + hono@4.12.19: resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==} engines: {node: '>=16.9.0'} @@ -4112,6 +4138,14 @@ snapshots: - supports-color - utf-8-validate + '@hono/mcp@0.3.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(hono-rate-limiter@0.5.3(hono@4.12.19))(hono@4.12.19)(zod@4.4.3)': + dependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + hono: 4.12.19 + hono-rate-limiter: 0.5.3(hono@4.12.19) + pkce-challenge: 5.0.1 + zod: 4.4.3 + '@hono/node-server@1.19.14(hono@4.12.19)': dependencies: hono: 4.12.19 @@ -4822,13 +4856,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0))': + '@vitest/mocker@4.1.6(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) '@vitest/pretty-format@4.1.6': dependencies: @@ -5420,6 +5454,10 @@ snapshots: highlight.js@10.7.3: {} + hono-rate-limiter@0.5.3(hono@4.12.19): + dependencies: + hono: 4.12.19 + hono@4.12.19: {} hosted-git-info@9.0.3: @@ -6278,7 +6316,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0): + vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -6289,13 +6327,14 @@ snapshots: '@types/node': 25.9.0 esbuild: 0.28.0 fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.22.2 yaml: 2.9.0 - vitest@4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)): + vitest@4.1.6(@types/node@25.9.0)(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0)) + '@vitest/mocker': 4.1.6(vite@8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -6312,7 +6351,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(tsx@4.22.2)(yaml@2.9.0) + vite: 8.0.11(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.0