From b26e26f764522ed88d2fb06629be20c9cbd4a601 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 18 Jun 2026 14:57:57 -0400 Subject: [PATCH 1/5] fix: missing env vars warnings and best-effort config --- .gitignore | 5 +- packages/core/src/config.ts | 111 +++++++++++++++- packages/core/test/config.test.ts | 59 +++++++++ packages/core/test/native-remote.test.ts | 37 ++++++ skills/caplets/SKILL.md | 128 +++++++++++++++++++ skills/caplets/references/troubleshooting.md | 56 ++++++++ 6 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 skills/caplets/SKILL.md create mode 100644 skills/caplets/references/troubleshooting.md diff --git a/.gitignore b/.gitignore index e6196865..7cc9f8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,8 @@ benchmark-results/ # autoresearch .auto/ -# Compound Engineering local config +# compound-engineering .compound-engineering/*.local.yaml + +# worktrees +.worktrees/ diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d109a416..5a088eb0 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1074,6 +1074,21 @@ type ConfigInput = { [key: string]: unknown; }; +const CAPLET_BACKEND_KEYS = [ + "mcpServers", + "openapiEndpoints", + "googleDiscoveryApis", + "graphqlEndpoints", + "httpApis", + "cliTools", + "capletSets", +] as const satisfies ReadonlyArray; + +type MissingEnvReference = { + name: string; + path: string; +}; + function configSchemaFor( serverValueSchema: z.ZodTypeAny, openApiEndpointValueSchema: z.ZodTypeAny, @@ -1712,14 +1727,106 @@ function readBestEffortConfigInput( transform?: (input: ConfigInput) => ConfigInput, ): ConfigInput | undefined { try { - const input = readPublicConfigInput(path); - return transform ? transform(input) : input; + const input = readBestEffortJsonConfigInput(path); + const normalized = normalizeLocalPaths(input, dirname(path)); + const transformed = transform ? transform(normalized) : normalized; + const filtered = quarantineMissingEnvCaplets(transformed, kind, path, warnings); + const parsed = configFileSchema.safeParse(interpolateConfig(filtered)); + if (!parsed.success) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplets config at ${path} is invalid`, + parsed.error.issues, + ); + } + return filtered; } catch (error) { warnings.push({ kind, path, message: errorMessage(error) }); return undefined; } } +function readBestEffortJsonConfigInput(path: string): ConfigInput { + try { + return JSON.parse(readFileSync(path, "utf8")) as ConfigInput; + } catch (error) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplets config at ${path} is not valid JSON`, + redactSecrets(error), + ); + } +} + +function quarantineMissingEnvCaplets( + input: ConfigInput, + kind: ConfigSourceKind, + sourcePath: string, + warnings: LocalOverlayConfigWarning[], +): ConfigInput { + let filtered = input; + + for (const backend of CAPLET_BACKEND_KEYS) { + const caplets = filtered[backend]; + if (!isPlainObject(caplets)) { + continue; + } + + for (const [id, caplet] of Object.entries(caplets)) { + const missing = missingEnvReferences(caplet, [backend, id]); + if (missing.length === 0) { + continue; + } + + filtered = removeCapletId(filtered, id); + warnings.push({ + kind, + path: sourcePath, + message: formatMissingEnvWarning(id, missing), + }); + } + } + + return filtered; +} + +function missingEnvReferences(value: unknown, path: string[]): MissingEnvReference[] { + if (isPublicMetadataPath(path)) { + return []; + } + if (typeof value === "string") { + return missingEnvReferencesInString(value, path.join(".")); + } + if (Array.isArray(value)) { + return value.flatMap((item, index) => missingEnvReferences(item, [...path, String(index)])); + } + if (isPlainObject(value)) { + return Object.entries(value).flatMap(([key, nested]) => + missingEnvReferences(nested, [...path, key]), + ); + } + return []; +} + +function missingEnvReferencesInString(value: string, path: string): MissingEnvReference[] { + const missing: MissingEnvReference[] = []; + const pattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$env:([A-Za-z_][A-Za-z0-9_]*)/g; + for (const match of value.matchAll(pattern)) { + const name = match[1] ?? match[2]; + if (name && process.env[name] === undefined) { + missing.push({ name, path }); + } + } + return missing; +} + +function formatMissingEnvWarning(id: string, missing: MissingEnvReference[]): string { + const names = [...new Set(missing.map((reference) => reference.name))]; + const paths = [...new Set(missing.map((reference) => reference.path))]; + const variableLabel = names.length === 1 ? "environment variable" : "environment variables"; + return `Caplet ${id} references missing ${variableLabel} ${names.join(", ")} at ${paths.join(", ")}; skipping Caplet ${id}.`; +} + function loadBestEffortCapletFiles( root: string, kind: ConfigSourceKind, diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index f7fc925c..796f1950 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -886,6 +886,65 @@ describe("config", () => { } }); + it("quarantines Caplets with missing env refs in local overlays without dropping valid siblings", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-missing-env-")); + const missingEnvName = "CAPLETS_TEST_MISSING_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectRoot = join(dir, "project", ".caplets"); + const projectConfigPath = join(projectRoot, "config.json"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + broken: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + healthy: { + name: "Healthy Local", + description: "A useful healthy downstream server.", + command: "healthy-server", + }, + }, + }), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config.mcpServers.healthy?.command).toBe("healthy-server"); + expect(config.mcpServers.broken).toBeUndefined(); + expect(sources.healthy).toEqual({ kind: "global-config", path: userConfigPath }); + expect(sources.broken).toBeUndefined(); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "global-config", + path: userConfigPath, + message: expect.stringContaining(missingEnvName), + }), + ]); + expect(warnings[0]?.message).toContain("mcpServers.broken.url"); + expect(warnings[0]?.message).toContain("skipping Caplet broken"); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + rmSync(dir, { recursive: true, force: true }); + } + }); + it("preserves local overlay source and shadow metadata", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-shadows-")); try { diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index dfda869d..584661ca 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -2298,6 +2298,43 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("starts valid local tools and loudly warns when a sibling Caplet references a missing env var", async () => { + const fixture = client([{ name: "remote", title: "Remote" }]); + const writeErr = vi.fn(); + const missingEnvName = "CAPLETS_NATIVE_TEST_MISSING_REMOTE_URL"; + delete process.env[missingEnvName]; + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + broken: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + + const service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + writeErr, + }); + + await service.reload(); + + expect(configuredCapletIds(service.listTools())).toEqual(["remote", "local"]); + expect(writeErr).toHaveBeenCalledWith( + expect.stringContaining(`missing environment variable ${missingEnvName}`), + ); + expect(writeErr).toHaveBeenCalledWith(expect.stringContaining("mcpServers.broken.url")); + await service.close(); + }); + it("starts Cloud Project Binding when native service runs in cloud mode", async () => { const path = tempCloudAuthPath(); vi.stubEnv("CAPLETS_CLOUD_AUTH_PATH", path); diff --git a/skills/caplets/SKILL.md b/skills/caplets/SKILL.md new file mode 100644 index 00000000..06450f56 --- /dev/null +++ b/skills/caplets/SKILL.md @@ -0,0 +1,128 @@ +--- +name: caplets +description: Always use when the user asks to use a capability that may not appear in your visible toolset, or when accessing configured backend capabilities through Caplets, including APIs, CLIs, resources, prompts, backend operations, capability handles, tool schemas, or compact Code Mode CLI results. +--- + +# Caplets + +## Overview + +Use `caplets code-mode <<'EOF'` as the default CLI workflow. Put discovery, schema inspection, tool calls, filtering, and summarization inside one TypeScript run, then return only the compact result the user needs. + +Do not turn Caplets work into a long sequence of `caplets search-tools`, `get-tool`, and `call-tool` shell commands unless the user specifically asks for progressive CLI commands. Code Mode is the primary surface. + +## Run Contract + +Before executing Code Mode, run `caplets code-mode types` to inspect the generated handles and method declarations available in the current config. + +When showing a reusable CLI script, use `caplets code-mode --json <<'EOF'` and `return` the compact value from the script. Do not use `console.log(...)` as the main output path; Code Mode returns the final expression/envelope for you. + +For unknown Caplets, unknown tools, or non-trivial argument shapes, discover first, describe when arguments matter, then call. + +## Default Pattern + +```sh +caplets code-mode --json <<'EOF' +const h = caplets.; +const ready = await h.check(); +if (!ready.ok) return ready; + +const tools = await h.searchTools("", { limit: 5 }); +const detail = await h.describeTool(""); +if (!detail.ok) return detail; + +const result = await h.callTool("", { + /* exact args from describeTool */ +}); +if (!result.ok) return result; + +return { + tools: tools.items.map((tool) => tool.name), + data: result.data, +}; +EOF +``` + +Keep bulky lists, schemas, and raw payloads inside the script. Return names, IDs, counts, selected fields, and error envelopes that drive the next decision. + +## Discover Handles + +When you do not know the configured Caplet ID, ask Code Mode first: + +```sh +caplets code-mode --json <<'EOF' +return Object.keys(caplets).filter((name) => name !== "debug").sort(); +EOF +``` + +For type hints and exact generated handle names: + +```sh +caplets code-mode types +``` + +## Handle API + +Use these methods inside the heredoc: + +| Need | Code Mode call | +| ----------------------------- | -------------------------------------------- | +| Check backend readiness | `await h.check()` | +| List tools | `await h.tools()` | +| Search tools | `await h.searchTools("query", { limit: 5 })` | +| Describe a tool | `await h.describeTool("tool_name")` | +| Call a tool | `await h.callTool("tool_name", args)` | +| List resources | `await h.resources()` | +| Search resources | `await h.searchResources("query")` | +| Read resource | `await h.readResource(uri)` | +| List prompts | `await h.prompts()` | +| Get prompt | `await h.getPrompt("name", args)` | +| Complete prompt/resource args | `await h.complete(...)` | + +Never invent handle IDs, tool names, resource URIs, prompt names, argument names, or result fields. Discover first, describe when arguments matter, then call. + +## Output Discipline + +Return decision-ready JSON: + +```ts +return { + names: items.map((item) => item.name), + count: items.length, + nextCursor: page.nextCursor, +}; +``` + +On failure, return the exact Caplets envelope: + +```ts +const result = await h.callTool("", args); +if (!result.ok) return result; +``` + +Do not paste full schemas, full tool lists, logs, or raw downstream responses into chat unless the user asks for them. + +## Files And Long Scripts + +Use a heredoc for one-off workflows. Use `--file` when the script is long or should be checked into a repo: + +```sh +caplets code-mode --file scripts/caplets-workflow.ts --json +``` + +`--file` paths are relative to the current directory. One-shot CLI Code Mode runs do not reuse heap state across invocations. + +## Common Mistakes + +| Mistake | Fix | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Running many progressive CLI commands for a multi-step task | Put discovery, call, and summarization inside one `caplets code-mode` heredoc. | +| Calling a guessed tool | `searchTools`, then `describeTool`, then `callTool`. | +| Using `console.log` for the answer | `return` the compact object; use `--json` on the CLI when structure matters. | +| Returning raw payloads | Project the fields the user needs inside the script. | +| Using direct network or filesystem APIs inside Code Mode | Use Caplet handles; direct I/O is intentionally unavailable. | +| Confusing CLI and Code Mode names | Shell commands are kebab-case; inside Code Mode use methods like `searchTools()` and `callTool()`. | + +## References + +- `references/troubleshooting.md` for missing Caplets, config paths, MCP registration, auth, and stdio startup checks. diff --git a/skills/caplets/references/troubleshooting.md b/skills/caplets/references/troubleshooting.md new file mode 100644 index 00000000..db33216d --- /dev/null +++ b/skills/caplets/references/troubleshooting.md @@ -0,0 +1,56 @@ +# Caplets CLI Troubleshooting + +Use this only when the Code Mode heredoc fails because setup, config, auth, or agent registration is suspect. + +## Smallest Local Checks + +```sh +caplets doctor +caplets config path +caplets config paths +caplets list +``` + +`caplets doctor` is the first health signal. `config path` and `config paths` prove which user, project, root, and auth paths are active before editing config. + +## Agent Registration + +For Codex: + +```sh +codex mcp list +codex mcp get caplets +``` + +For Claude Code: + +```sh +claude mcp list +claude mcp get caplets +``` + +The command should usually be `caplets serve` or `npx --yes caplets serve`. Remote-backed setups may use `caplets attach`. + +## Stdio Startup + +Do not treat a bare `caplets serve` process as a health check. Stdio MCP servers stay open waiting for JSON-RPC, so a hanging process is normal. + +If registration and config look correct but the client still cannot connect, probe the exact registered command with an `initialize` request and an external timeout: + +```sh +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"caplets-smoke","version":"1.0.0"}}}' \ + | timeout 5s caplets serve +``` + +Adapt the command and environment from the agent registration output. + +## Auth + +```sh +caplets auth list +caplets auth login +caplets auth refresh +caplets doctor +``` + +For Google Discovery Caplets, rerun `caplets auth login ` after changing operation filters or scopes. From 2ad6f5c8451b9daa5dbc9d4fc2d417bd05a71c9d Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 18 Jun 2026 15:04:37 -0400 Subject: [PATCH 2/5] fix(config): quarantine missing env var caplets --- .changeset/quiet-ravens-sneeze.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-ravens-sneeze.md diff --git a/.changeset/quiet-ravens-sneeze.md b/.changeset/quiet-ravens-sneeze.md new file mode 100644 index 00000000..dac03af7 --- /dev/null +++ b/.changeset/quiet-ravens-sneeze.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": patch +--- + +Keep local overlay startup alive when a Caplet references a missing environment variable by skipping only the affected Caplet and warning with the missing variable and config path. From 0618645394f41004e4469fa8c82cf4235a6ab15f Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 18 Jun 2026 16:40:52 -0400 Subject: [PATCH 3/5] fix(config): keep env quarantine reloads recoverable --- packages/core/src/config.ts | 18 +++- packages/core/src/native/service.ts | 8 +- packages/core/test/config.test.ts | 65 ++++++++++++++ packages/core/test/native-remote.test.ts | 103 +++++++++++++++++++---- 4 files changed, 173 insertions(+), 21 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 5a088eb0..fdb96053 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -359,6 +359,7 @@ export type LocalOverlayConfigWarning = { kind: ConfigSourceKind; path: string; message: string; + recoverable?: boolean | undefined; }; export type LocalOverlayConfigWithSources = ConfigWithSources & { @@ -1761,7 +1762,7 @@ function readBestEffortJsonConfigInput(path: string): ConfigInput { function quarantineMissingEnvCaplets( input: ConfigInput, kind: ConfigSourceKind, - sourcePath: string, + sourcePath: string | ((id: string) => string), warnings: LocalOverlayConfigWarning[], ): ConfigInput { let filtered = input; @@ -1781,8 +1782,9 @@ function quarantineMissingEnvCaplets( filtered = removeCapletId(filtered, id); warnings.push({ kind, - path: sourcePath, + path: typeof sourcePath === "function" ? sourcePath(id) : sourcePath, message: formatMissingEnvWarning(id, missing), + recoverable: true, }); } } @@ -1839,7 +1841,17 @@ function loadBestEffortCapletFiles( for (const warning of result.warnings) { warnings.push({ kind, path: warning.path ?? root, message: warning.message }); } - return { config: result.config, paths: result.paths }; + const config = quarantineMissingEnvCaplets( + result.config, + kind, + (id) => result.paths[id] ?? root, + warnings, + ); + const retainedIds = new Set(capletIds(config)); + const paths = Object.fromEntries( + Object.entries(result.paths).filter(([id]) => retainedIds.has(id)), + ); + return { config, paths }; } function errorMessage(error: unknown): string { diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index aca1f52a..93b16a47 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1009,14 +1009,16 @@ function createLocalOverlayConfigLoader(options: NativeCapletsServiceOptions) { const path = typeof warning.path === "string" ? ` at ${warning.path}` : ""; writeErr(options, `Caplets local overlay warning${path}: ${warning.message}\n`); } - const warnings = new Set(result.warnings.map(warningKey)); - if (hasLoaded && [...warnings].some((warning) => !previousWarnings.has(warning))) { + const fatalWarnings = new Set( + result.warnings.filter((warning) => !warning.recoverable).map(warningKey), + ); + if (hasLoaded && [...fatalWarnings].some((warning) => !previousWarnings.has(warning))) { throw new CapletsError( "CONFIG_INVALID", "Caplets local overlay reload produced new warnings; keeping last known-good config.", ); } - previousWarnings = warnings; + previousWarnings = fatalWarnings; hasLoaded = true; return result.config; }; diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 796f1950..90e5a890 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -945,6 +945,71 @@ describe("config", () => { } }); + it("quarantines Caplet files with missing env refs in local overlays without dropping valid siblings", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-file-missing-env-")); + const missingEnvName = "CAPLETS_TEST_MISSING_FILE_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + join(userRoot, "broken.md"), + [ + "---", + "name: Broken Remote", + "description: References a missing startup URL.", + "mcpServer:", + " transport: http", + ` url: $env:${missingEnvName}`, + "---", + "# Broken Remote", + ].join("\n"), + ); + writeFileSync( + join(userRoot, "healthy.md"), + [ + "---", + "name: Healthy Local", + "description: A useful healthy downstream server.", + "mcpServer:", + " command: healthy-server", + "---", + "# Healthy Local", + ].join("\n"), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config.mcpServers.healthy?.command).toBe("healthy-server"); + expect(config.mcpServers.broken).toBeUndefined(); + expect(sources.healthy).toEqual({ kind: "global-file", path: join(userRoot, "healthy.md") }); + expect(sources.broken).toBeUndefined(); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "global-file", + path: join(userRoot, "broken.md"), + message: expect.stringContaining(missingEnvName), + recoverable: true, + }), + ]); + expect(warnings[0]?.message).toContain("mcpServers.broken.url"); + expect(warnings[0]?.message).toContain("skipping Caplet broken"); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + rmSync(dir, { recursive: true, force: true }); + } + }); + it("preserves local overlay source and shadow metadata", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-shadows-")); try { diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index 584661ca..221b7d31 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -2302,6 +2302,7 @@ describe("createNativeCapletsService remote mode", () => { const fixture = client([{ name: "remote", title: "Remote" }]); const writeErr = vi.fn(); const missingEnvName = "CAPLETS_NATIVE_TEST_MISSING_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; delete process.env[missingEnvName]; const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { @@ -2316,23 +2317,32 @@ describe("createNativeCapletsService remote mode", () => { }); dirs.push(dir); - const service = createNativeCapletsService({ - mode: "remote", - remote: { url: "http://127.0.0.1:5387" }, - remoteClientFactory: vi.fn(() => fixture.api), - configPath, - projectConfigPath, - writeErr, - }); + let service: NativeCapletsService | undefined; + try { + service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + writeErr, + }); - await service.reload(); + await service.reload(); - expect(configuredCapletIds(service.listTools())).toEqual(["remote", "local"]); - expect(writeErr).toHaveBeenCalledWith( - expect.stringContaining(`missing environment variable ${missingEnvName}`), - ); - expect(writeErr).toHaveBeenCalledWith(expect.stringContaining("mcpServers.broken.url")); - await service.close(); + expect(configuredCapletIds(service.listTools())).toEqual(["remote", "local"]); + expect(writeErr).toHaveBeenCalledWith( + expect.stringContaining(`missing environment variable ${missingEnvName}`), + ); + expect(writeErr).toHaveBeenCalledWith(expect.stringContaining("mcpServers.broken.url")); + } finally { + await service?.close(); + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + } }); it("starts Cloud Project Binding when native service runs in cloud mode", async () => { @@ -2409,6 +2419,69 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("picks up healthy local overlay edits when reload introduces missing-env warnings", async () => { + const fixture = client([{ name: "remote", title: "Remote" }]); + const writeErr = vi.fn(); + const missingEnvName = "CAPLETS_NATIVE_TEST_RELOAD_MISSING_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + + let service: NativeCapletsService | undefined; + try { + service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + writeErr, + }); + await service.reload(); + + writeFileSync( + configPath, + JSON.stringify( + progressiveTestConfig({ + mcpServers: { + broken: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + alpha: { name: "Alpha", description: "Alpha Caplet.", command: process.execPath }, + beta: { name: "Beta", description: "Beta Caplet.", command: process.execPath }, + }, + }), + ), + "utf8", + ); + + await expect(service.reload()).resolves.toBe(true); + + expect(configuredCapletIds(service.listTools())).toEqual(["remote", "alpha", "beta"]); + expect(writeErr).toHaveBeenCalledWith( + expect.stringContaining(`missing environment variable ${missingEnvName}`), + ); + expect(writeErr).not.toHaveBeenCalledWith( + expect.stringContaining("reload produced new warnings"), + ); + } finally { + await service?.close(); + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + } + }); + it("closes remote and local overlay services idempotently", async () => { vi.useFakeTimers(); const fixture = client(); From 1f7ca67e31d8336eb1c0c776efe51eff45b418e5 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 18 Jun 2026 17:02:38 -0400 Subject: [PATCH 4/5] fix(config): address review feedback --- packages/core/src/config.ts | 13 +++---------- skills/caplets/SKILL.md | 3 +++ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index fdb96053..cab22e45 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1085,6 +1085,8 @@ const CAPLET_BACKEND_KEYS = [ "capletSets", ] as const satisfies ReadonlyArray; +const CAPLET_BACKEND_KEY_SET = new Set(CAPLET_BACKEND_KEYS); + type MissingEnvReference = { name: string; path: string; @@ -2353,16 +2355,7 @@ function interpolateConfig(value: T, path: string[] = []): T { } function isPublicMetadataPath(path: string[]): boolean { - if ( - path.length < 3 || - (path[0] !== "mcpServers" && - path[0] !== "openapiEndpoints" && - path[0] !== "googleDiscoveryApis" && - path[0] !== "graphqlEndpoints" && - path[0] !== "httpApis" && - path[0] !== "cliTools" && - path[0] !== "capletSets") - ) { + if (path.length < 3 || !CAPLET_BACKEND_KEY_SET.has(path[0] ?? "")) { return false; } return NON_INTERPOLATED_SERVER_FIELDS.has(path[2] ?? ""); diff --git a/skills/caplets/SKILL.md b/skills/caplets/SKILL.md index 06450f56..b2cd74f7 100644 --- a/skills/caplets/SKILL.md +++ b/skills/caplets/SKILL.md @@ -67,6 +67,7 @@ Use these methods inside the heredoc: | Need | Code Mode call | | ----------------------------- | -------------------------------------------- | +| Inspect Caplet card | `await h.inspect()` | | Check backend readiness | `await h.check()` | | List tools | `await h.tools()` | | Search tools | `await h.searchTools("query", { limit: 5 })` | @@ -74,8 +75,10 @@ Use these methods inside the heredoc: | Call a tool | `await h.callTool("tool_name", args)` | | List resources | `await h.resources()` | | Search resources | `await h.searchResources("query")` | +| List resource templates | `await h.resourceTemplates()` | | Read resource | `await h.readResource(uri)` | | List prompts | `await h.prompts()` | +| Search prompts | `await h.searchPrompts("query")` | | Get prompt | `await h.getPrompt("name", args)` | | Complete prompt/resource args | `await h.complete(...)` | From cab274c5d31f7ad16d1604d7ce4cd573feccd38b Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 18 Jun 2026 17:35:58 -0400 Subject: [PATCH 5/5] fix(config): preserve matching backend siblings --- packages/core/src/config.ts | 15 ++++++- packages/core/test/config.test.ts | 65 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index cab22e45..2e583c74 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1781,7 +1781,7 @@ function quarantineMissingEnvCaplets( continue; } - filtered = removeCapletId(filtered, id); + filtered = removeCapletBackendId(filtered, backend, id); warnings.push({ kind, path: typeof sourcePath === "function" ? sourcePath(id) : sourcePath, @@ -2170,6 +2170,19 @@ function mergeConfigInputsWithSources(...inputs: Array { } }); + it("quarantines only the affected backend entry when another backend uses the same ID", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-missing-env-shared-id-")); + const missingEnvName = "CAPLETS_TEST_MISSING_SHARED_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + shared: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + }, + cliTools: { + shared: { + name: "Healthy CLI", + description: "Uses the same ID as the broken remote.", + actions: { + status: { + command: "git", + args: ["status", "--short"], + }, + }, + }, + }, + }), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config.mcpServers.shared).toBeUndefined(); + expect(config.cliTools.shared?.actions.status).toEqual( + expect.objectContaining({ command: "git" }), + ); + expect(sources.shared).toEqual({ kind: "global-config", path: userConfigPath }); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "global-config", + path: userConfigPath, + message: expect.stringContaining(missingEnvName), + recoverable: true, + }), + ]); + expect(warnings[0]?.message).toContain("mcpServers.shared.url"); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + rmSync(dir, { recursive: true, force: true }); + } + }); + it("quarantines Caplet files with missing env refs in local overlays without dropping valid siblings", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-file-missing-env-")); const missingEnvName = "CAPLETS_TEST_MISSING_FILE_REMOTE_URL";