diff --git a/.changeset/clean-spoons-tell.md b/.changeset/clean-spoons-tell.md new file mode 100644 index 00000000..b2525038 --- /dev/null +++ b/.changeset/clean-spoons-tell.md @@ -0,0 +1,7 @@ +--- +"caplets": minor +--- + +# CLI inspection and Caplet installation + +Add CLI inspection commands for version, configured Caplets, resolved config paths, and installing Caplets from a repo. diff --git a/README.md b/README.md index 3443f944..1e72d1f8 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,15 @@ CAPLETS_CONFIG=/path/to/config.json caplets init CAPLETS_CONFIG=/path/to/config.json caplets serve ``` +Inspect the installed CLI version and resolved config locations: + +```sh +caplets --version +caplets config path +caplets config paths +caplets config paths --json +``` + Caplets validates this file at startup. Config changes take effect after restarting the Caplets MCP server. @@ -201,6 +210,30 @@ Top-level files derive their Caplet ID from the filename. Directory-style Caplet `linear/CAPLET.md`, which is exposed as `linear`; sibling files can be referenced with normal Markdown links from `CAPLET.md`. +This repository includes polished working examples under [`caplets/`](caplets/): + +- `github`: GitHub's official MCP server container, using `GITHUB_PERSONAL_ACCESS_TOKEN`. +- `linear`: Linear's hosted OAuth MCP endpoint. +- `context7`: Context7 documentation lookup through `@upstash/context7-mcp`. + +Install every example from a repo's `caplets/` directory: + +```sh +caplets install spiritledsoftware/caplets +``` + +Install one or more individual Caplets by ID: + +```sh +caplets install spiritledsoftware/caplets github +caplets install spiritledsoftware/caplets github linear +``` + +`caplets install` accepts a GitHub `owner/repo` shorthand, a Git URL, or a local repository path. +It installs into your user Caplets root, which is `~/.caplets` by default or the parent directory +of `CAPLETS_CONFIG` when that environment variable is set. Existing Caplets are not overwritten +unless `--force` is passed. + Caplets always loads user Caplet files from `~/.caplets`. Project `./.caplets/config.json` is still loaded as project config, but project Markdown Caplet files are executable configuration and are ignored unless explicitly trusted: @@ -390,6 +423,14 @@ caplets auth list caplets auth logout ``` +To list configured Caplets without starting downstream backends: + +```sh +caplets list +caplets list --all +caplets list --json +``` + ### Optional Server Settings Every server can set: diff --git a/caplets/context7.md b/caplets/context7.md new file mode 100644 index 00000000..b3930868 --- /dev/null +++ b/caplets/context7.md @@ -0,0 +1,33 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: Context7 Documentation +description: Fetch current library and framework documentation through Context7 before using version-sensitive APIs. +tags: + - docs + - libraries + - frameworks + - api-reference +mcpServer: + command: npx + args: + - -y + - "@upstash/context7-mcp" +--- + +# Context7 Documentation + +Use this Caplet when the agent needs up-to-date library, SDK, framework, CLI, or cloud-service +documentation before writing code or giving technical instructions. + +## Good Fits + +- Check current API signatures for fast-moving JavaScript, TypeScript, Python, or cloud libraries. +- Look up migration notes before changing framework configuration. +- Retrieve official examples for a specific package version. +- Resolve uncertainty about CLI flags, config files, or SDK initialization. + +## Use Carefully + +- Prefer primary documentation over snippets when implementation risk is high. +- Record the library or package name clearly before searching. +- Do not use this as a substitute for project-local types and tests. diff --git a/caplets/github/CAPLET.md b/caplets/github/CAPLET.md new file mode 100644 index 00000000..594e34b4 --- /dev/null +++ b/caplets/github/CAPLET.md @@ -0,0 +1,54 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: GitHub +description: Inspect and manage GitHub repositories, issues, pull requests, branches, commits, and code review workflows. +tags: + - code + - github + - pull-requests + - issues + - reviews +mcpServer: + command: docker + args: + - run + - -i + - --rm + - -e + - GITHUB_PERSONAL_ACCESS_TOKEN + - ghcr.io/github/github-mcp-server + env: + GITHUB_PERSONAL_ACCESS_TOKEN: $env:GITHUB_PERSONAL_ACCESS_TOKEN +--- + +# GitHub + +Use this Caplet when the agent needs live GitHub repository context or needs to act on +issues, pull requests, branches, commits, or review feedback. + +## Good Fits + +- Summarize recent pull request activity before a code review. +- Inspect open issues and identify implementation work. +- Create or update issues from an implementation plan. +- Compare branches, inspect commits, or review pull request files. +- Leave review comments after the agent has inspected the relevant diff. + +## Use Carefully + +- Mutating operations can affect real repositories. Prefer read operations first. +- Use a least-privilege `GITHUB_PERSONAL_ACCESS_TOKEN`. +- Do not ask the agent to expose token values, repository secrets, or private issue contents outside + the intended conversation. + +## Setup + +Create a GitHub personal access token with the minimum repository scopes needed for your workflow, +then export it before starting Caplets: + +```sh +export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_... +caplets serve +``` + +This Caplet uses GitHub's official MCP server container, so Docker must be available on the host. diff --git a/caplets/github/README.md b/caplets/github/README.md new file mode 100644 index 00000000..6b7a2ce1 --- /dev/null +++ b/caplets/github/README.md @@ -0,0 +1,13 @@ +# GitHub Caplet + +This Caplet wraps GitHub's official MCP server container: + +```sh +docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server +``` + +Install it from this repo: + +```sh +caplets install spiritledsoftware/caplets github +``` diff --git a/caplets/linear/CAPLET.md b/caplets/linear/CAPLET.md new file mode 100644 index 00000000..df5046ab --- /dev/null +++ b/caplets/linear/CAPLET.md @@ -0,0 +1,50 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: Linear +description: Plan and track product work in Linear by reading teams, projects, cycles, issues, comments, and workflow state. +tags: + - planning + - linear + - issues + - projects + - triage +mcpServer: + transport: http + url: https://mcp.linear.app/mcp + auth: + type: oauth2 +--- + +# Linear + +Use this Caplet when the agent needs live product planning context from Linear or needs to keep +implementation work synchronized with issues, projects, and team workflows. + +## Good Fits + +- Find the current issue or project that matches a requested feature. +- Summarize open work by team, project, cycle, label, or assignee. +- Draft issue breakdowns from a technical plan. +- Add implementation notes or status comments after code changes. +- Check whether a bug or feature already has active work before creating a new issue. + +## Reference Files + +- [Workflows](./workflows.md): recommended lookup, planning, status update, and triage flows. + +## Use Carefully + +- Linear issue updates are visible to teammates. Read first, then write deliberately. +- Keep issue titles and comments concise; use links to detailed implementation artifacts when useful. +- Avoid broad, noisy searches when a team key, issue ID, project, or label is available. + +## Setup + +Authenticate once through Caplets: + +```sh +caplets auth login linear +``` + +The Linear MCP endpoint supports OAuth. Caplets stores the resulting token bundle in your local +Caplets auth store. diff --git a/caplets/linear/workflows.md b/caplets/linear/workflows.md new file mode 100644 index 00000000..65f50b71 --- /dev/null +++ b/caplets/linear/workflows.md @@ -0,0 +1,9 @@ +# Linear Workflows + +Useful agent flows: + +- **Issue lookup**: search by issue key first, then by title or project if no key is provided. +- **Planning**: read project context, summarize constraints, then create child issues only when the + requested breakdown is clear. +- **Status updates**: comment with what changed, verification run, and remaining risk. +- **Triage**: group candidate issues by urgency, owner, and whether they are blocked. diff --git a/docs/plans/2026-05-12-cli-inspection-polish.md b/docs/plans/2026-05-12-cli-inspection-polish.md new file mode 100644 index 00000000..851ac039 --- /dev/null +++ b/docs/plans/2026-05-12-cli-inspection-polish.md @@ -0,0 +1,88 @@ +# Caplets CLI Inspection Polish + +## Summary + +Add a small read-only inspection suite to the existing Commander-based CLI: + +- `caplets --version` prints the package version from `package.json`. +- `caplets list` lists enabled configured Caplets by default. +- `caplets list --all` includes disabled entries. +- `caplets list --json` emits stable machine-readable JSON. +- `caplets config path` prints the effective user config path, honoring `CAPLETS_CONFIG`. +- `caplets config paths` prints user config, project config, Caplets roots, auth directory, and trust/env state. + +No runtime MCP server behavior changes. + +## Key Changes + +- Update `src/cli.ts` to import `package.json` version and register Commander version support. +- Add a `list` command that loads config through existing `loadConfig(envConfigPath())`, builds a `ServerRegistry`, and prints: + - text columns: `server`, `backend`, `status`, `name` + - JSON array objects: `{ server, backend, name, description, disabled, status }` +- Add `config` subcommands: + - `config path`: prints only `resolveConfigPath(envConfigPath())` + - `config paths`: prints resolved user config path, project config path, user root, project root, auth dir, whether `CAPLETS_CONFIG` is set, and whether `CAPLETS_TRUST_PROJECT_CAPLETS` is enabled + - `--json` on `config paths` for scriptable output +- Keep existing `init` and `auth` behavior unchanged. +- Keep disabled Caplets excluded from `caplets list` unless `--all` is passed. + +## Interfaces + +Public CLI additions: + +```sh +caplets --version +caplets list +caplets list --all +caplets list --json +caplets config path +caplets config paths +caplets config paths --json +``` + +Recommended text output shapes: + +```txt +server backend status name +docs mcp not_started Hosted Docs +users openapi not_started Users API +``` + +```txt +userConfig: /home/you/.caplets/config.json +projectConfig: /repo/.caplets/config.json +userRoot: /home/you/.caplets +projectRoot: /repo/.caplets +authDir: /home/you/.caplets/auth +envConfig: unset +projectCapletsTrusted: false +``` + +## Test Plan + +Add focused tests in `test/cli.test.ts`: + +- `runCli(["--version"])` prints `package.json` version and does not throw. +- `runCli(["list"])` prints only enabled MCP/OpenAPI/GraphQL Caplets. +- `runCli(["list", "--all"])` includes disabled Caplets with `disabled` status. +- `runCli(["list", "--json"])` emits parseable JSON and redacts/no-ops on auth secrets. +- `runCli(["config", "path"])` honors `CAPLETS_CONFIG`. +- `runCli(["config", "paths", "--json"])` returns stable resolved paths and env/trust flags. +- Unsupported options still raise `REQUEST_INVALID`. + +Run: + +```sh +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +## Assumptions + +- "Servers" means all configured Caplet backends: MCP servers, OpenAPI endpoints, and GraphQL endpoints. +- Human-readable text is the default; `--json` is the stable automation contract. +- The inspection commands must be read-only and must not start downstream servers, load remote specs, or validate live OAuth. +- `caplets serve` remains handled by `src/index.ts` as the MCP server entrypoint. diff --git a/package.json b/package.json index de5bf9ba..5cafafb6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "files": [ "dist", + "caplets", "schemas", "README.md", "LICENSE" diff --git a/src/auth.ts b/src/auth.ts index 07064e9e..600fb0ad 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,16 +1,4 @@ -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; import { createServer } from "node:http"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; import { randomBytes, createHash } from "node:crypto"; import { auth, @@ -23,8 +11,24 @@ import type { OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth"; -import { CapletsError, redactSecrets } from "./errors.js"; +import { + isTokenBundleExpired, + readTokenBundle, + writeTokenBundle, + type StoredOAuthTokenBundle, +} from "./auth/store.js"; import type { CapletServerConfig } from "./config.js"; +import { CapletsError, redactSecrets } from "./errors.js"; + +export { + authStorePath, + deleteTokenBundle, + isTokenBundleExpired, + listTokenBundles, + readTokenBundle, + writeTokenBundle, + type StoredOAuthTokenBundle, +} from "./auth/store.js"; type OAuthLikeAuthConfig = { type: "oauth2" | "oidc"; @@ -50,79 +54,6 @@ export type GenericAuthTarget = { requestTimeoutMs?: number | undefined; }; -export type StoredOAuthTokenBundle = { - server: string; - authType?: "oauth2" | "oidc" | undefined; - accessToken: string; - refreshToken?: string | undefined; - tokenType?: string | undefined; - expiresAt?: string | undefined; - scope?: string | undefined; - idToken?: string | undefined; - issuer?: string | undefined; - subject?: string | undefined; - clientId?: string | undefined; - clientSecret?: string | undefined; - protectedResourceOrigin?: string | undefined; - metadata?: Record; -}; - -export function authStorePath( - server: string, - authDir = join(homedir(), ".caplets", "auth"), -): string { - return join(authDir, `${server}.json`); -} - -export function readTokenBundle( - server: string, - authDir?: string, -): StoredOAuthTokenBundle | undefined { - const path = authStorePath(server, authDir); - if (!existsSync(path)) { - return undefined; - } - return JSON.parse(readFileSync(path, "utf8")) as StoredOAuthTokenBundle; -} - -export function listTokenBundles(authDir?: string): StoredOAuthTokenBundle[] { - const dir = authDir ?? join(homedir(), ".caplets", "auth"); - if (!existsSync(dir)) { - return []; - } - return readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) - .map((entry) => readTokenBundle(entry.name.slice(0, -".json".length), dir)) - .filter((bundle): bundle is StoredOAuthTokenBundle => Boolean(bundle)) - .sort((left, right) => left.server.localeCompare(right.server)); -} - -export function deleteTokenBundle(server: string, authDir?: string): boolean { - const path = authStorePath(server, authDir); - if (!existsSync(path)) { - return false; - } - rmSync(path, { force: true }); - return true; -} - -export function isTokenBundleExpired(bundle: StoredOAuthTokenBundle): boolean { - return Boolean(bundle.expiresAt && Date.parse(bundle.expiresAt) <= Date.now()); -} - -export function writeTokenBundle(bundle: StoredOAuthTokenBundle, authDir?: string): void { - const path = authStorePath(bundle.server, authDir); - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 }); - try { - chmodSync(tempPath, 0o600); - } catch { - // Best effort on platforms without POSIX permissions. - } - renameSync(tempPath, path); -} - export function staticRemoteHeaders(server: CapletServerConfig): Record { if (server.auth?.type === "bearer") { return { authorization: `Bearer ${server.auth.token}` }; diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 00000000..e2b98a8d --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,98 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { CapletsError } from "../errors.js"; + +export type StoredOAuthTokenBundle = { + server: string; + authType?: "oauth2" | "oidc" | undefined; + accessToken: string; + refreshToken?: string | undefined; + tokenType?: string | undefined; + expiresAt?: string | undefined; + scope?: string | undefined; + idToken?: string | undefined; + issuer?: string | undefined; + subject?: string | undefined; + clientId?: string | undefined; + clientSecret?: string | undefined; + protectedResourceOrigin?: string | undefined; + metadata?: Record; +}; + +export function authStorePath( + server: string, + authDir = join(homedir(), ".caplets", "auth"), +): string { + if (!server || server.includes("/") || server.includes("\\") || server.includes("..")) { + throw new CapletsError("REQUEST_INVALID", `Invalid auth store server name ${server}`); + } + const authRoot = resolve(authDir); + const candidate = resolve(authRoot, `${server}.json`); + if (candidate !== authRoot && candidate.startsWith(`${authRoot}${sep}`)) { + return candidate; + } + throw new CapletsError("REQUEST_INVALID", `Invalid auth store server name ${server}`); +} + +export function readTokenBundle( + server: string, + authDir?: string, +): StoredOAuthTokenBundle | undefined { + const path = authStorePath(server, authDir); + if (!existsSync(path)) { + return undefined; + } + try { + return JSON.parse(readFileSync(path, "utf8")) as StoredOAuthTokenBundle; + } catch { + return undefined; + } +} + +export function listTokenBundles(authDir?: string): StoredOAuthTokenBundle[] { + const dir = authDir ?? join(homedir(), ".caplets", "auth"); + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => readTokenBundle(entry.name.slice(0, -".json".length), dir)) + .filter((bundle): bundle is StoredOAuthTokenBundle => Boolean(bundle)) + .sort((left, right) => left.server.localeCompare(right.server)); +} + +export function deleteTokenBundle(server: string, authDir?: string): boolean { + const path = authStorePath(server, authDir); + if (!existsSync(path)) { + return false; + } + rmSync(path, { force: true }); + return true; +} + +export function isTokenBundleExpired(bundle: StoredOAuthTokenBundle): boolean { + return Boolean(bundle.expiresAt && Date.parse(bundle.expiresAt) <= Date.now()); +} + +export function writeTokenBundle(bundle: StoredOAuthTokenBundle, authDir?: string): void { + const path = authStorePath(bundle.server, authDir); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 }); + try { + chmodSync(tempPath, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + renameSync(tempPath, path); +} diff --git a/src/caplet-files.ts b/src/caplet-files.ts index fc7efbff..a5db383a 100644 --- a/src/caplet-files.ts +++ b/src/caplet-files.ts @@ -3,29 +3,16 @@ import { basename, dirname, extname, isAbsolute, join } from "node:path"; import { VFile } from "vfile"; import { matter as parseMatter } from "vfile-matter"; import { z } from "zod"; +import { + FORBIDDEN_HEADERS, + HEADER_NAME_PATTERN, + SERVER_ID_PATTERN, + isAllowedRemoteUrl, +} from "./config/validation.js"; import { CapletsError, redactSecrets } from "./errors.js"; const MAX_CAPLET_FILE_BYTES = 128 * 1024; const MAX_CAPLET_BODY_CHARS = 64 * 1024; -const SERVER_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; -const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; -const FORBIDDEN_HEADERS = new Set([ - "accept", - "authorization", - "connection", - "content-length", - "content-type", - "host", - "keep-alive", - "mcp-protocol-version", - "mcp-session-id", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]); const capletRemoteAuthSchema = z .discriminatedUnion("type", [ @@ -459,7 +446,7 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { : undefined; } -function discoverCapletFiles(root: string): Array<{ id: string; path: string }> { +export function discoverCapletFiles(root: string): Array<{ id: string; path: string }> { const entries = readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name), ); @@ -519,6 +506,10 @@ function readCapletFile(path: string): unknown { return capletToServerConfig(parsed.data, body, dirname(path)); } +export function validateCapletFile(path: string): void { + readCapletFile(path); +} + function capletToServerConfig( frontmatter: CapletFileFrontmatter, body: string, @@ -654,14 +645,3 @@ function isUrl(value: string): boolean { return false; } } - -function isAllowedRemoteUrl(value: string): boolean { - const url = new URL(value); - if (url.protocol === "https:") { - return true; - } - if (url.protocol !== "http:") { - return false; - } - return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); -} diff --git a/src/cli.ts b/src/cli.ts index 9290a89c..e1465ce4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,18 +1,19 @@ -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; import { Command, CommanderError } from "commander"; -import { loadConfig, resolveConfigPath } from "./config.js"; -import { CapletsError, toSafeError } from "./errors.js"; +import { version as packageJsonVersion } from "../package.json"; +import { loginAuth, logoutAuth, listAuth } from "./cli/auth.js"; +import { initConfig } from "./cli/init.js"; import { - deleteTokenBundle, - isTokenBundleExpired, - readTokenBundle, - runGenericOAuthFlow, - runOAuthFlow, - type GenericAuthTarget, -} from "./auth.js"; + formatCapletList, + formatConfigPaths, + listCaplets, + resolveCliConfigPaths, +} from "./cli/inspection.js"; +import { installCaplets } from "./cli/install.js"; +import { loadConfig, resolveCapletsRoot, resolveConfigPath } from "./config.js"; +import { CapletsError } from "./errors.js"; + +export { initConfig, starterConfig } from "./cli/init.js"; +export { installCaplets, normalizeGitRepo } from "./cli/install.js"; type CliIO = { writeOut?: (value: string) => void; @@ -43,6 +44,7 @@ export function createProgram(io: CliIO = {}): Command { program .name("caplets") .description("Progressive-disclosure gateway for MCP servers.") + .version(packageJsonVersion) .exitOverride() .configureOutput({ writeOut, @@ -63,6 +65,60 @@ export function createProgram(io: CliIO = {}): Command { writeOut(`Created Caplets config at ${path}\n`); }); + program + .command("list") + .description("List configured Caplets.") + .option("--all", "include disabled Caplets") + .option("--json", "print JSON output") + .action((options: { all?: boolean; json?: boolean }) => { + const config = loadConfig(envConfigPath()); + const rows = listCaplets(config, { includeDisabled: Boolean(options.all) }); + if (options.json) { + writeOut(`${JSON.stringify(rows, null, 2)}\n`); + return; + } + writeOut(formatCapletList(rows)); + }); + + program + .command("install") + .description("Install Caplets from a repo's caplets directory.") + .argument("", "local repo path, Git URL, or GitHub owner/repo") + .argument("[caplets...]", "optional Caplet IDs to install") + .option("--force", "overwrite installed Caplets") + .action((repo: string, capletIds: string[], options: { force?: boolean }) => { + const result = installCaplets(repo, { + capletIds, + force: Boolean(options.force), + destinationRoot: resolveCapletsRoot(resolveConfigPath(envConfigPath())), + }); + for (const caplet of result.installed) { + writeOut(`Installed ${caplet.id} to ${caplet.destination}\n`); + } + }); + + const config = program.command("config").description("Inspect Caplets config locations."); + + config + .command("path") + .description("Print the effective user config path.") + .action(() => { + writeOut(`${resolveConfigPath(envConfigPath())}\n`); + }); + + config + .command("paths") + .description("Print resolved Caplets config, root, and auth paths.") + .option("--json", "print JSON output") + .action((options: { json?: boolean }) => { + const paths = resolveCliConfigPaths(envConfigPath(), io.authDir); + if (options.json) { + writeOut(`${JSON.stringify(paths, null, 2)}\n`); + return; + } + writeOut(formatConfigPaths(paths)); + }); + const auth = program.command("auth").description("Manage OAuth credentials for remote servers."); auth @@ -71,10 +127,12 @@ export function createProgram(io: CliIO = {}): Command { .argument("", "configured server ID") .option("--no-open", "print the authorization URL without opening a browser") .action(async (serverId: string, options: { open?: boolean }) => { + const configPath = envConfigPath(); await loginAuth(serverId, { noOpen: options.open === false, writeOut, writeErr, + ...(configPath ? { configPath } : {}), ...(io.authDir ? { authDir: io.authDir } : {}), }); }); @@ -84,185 +142,29 @@ export function createProgram(io: CliIO = {}): Command { .description("Delete stored OAuth credentials for a server.") .argument("", "configured server ID") .action((serverId: string) => { - const target = findAuthTarget(serverId); - assertLoginTarget(target, serverId); - - if (deleteTokenBundle(serverId, io.authDir)) { - writeOut(`Deleted OAuth credentials for ${serverId}\n`); - } else { - writeOut(`No OAuth credentials found for ${serverId}\n`); - } + const configPath = envConfigPath(); + logoutAuth(serverId, { + writeOut, + ...(configPath ? { configPath } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }); }); auth .command("list") .description("List servers with stored OAuth credentials.") .action(() => { - const config = loadConfig(envConfigPath()); - const servers = authTargets(config).sort((left, right) => - left.server.localeCompare(right.server), - ); - - if (servers.length === 0) { - writeOut("No configured remote OAuth servers found.\n"); - return; - } - for (const server of servers) { - const bundle = readTokenBundle(server.server, io.authDir); - const status = !bundle - ? "missing" - : isTokenBundleExpired(bundle) - ? "expired" - : "authenticated"; - writeOut( - [ - server.server, - status, - bundle?.expiresAt ? `expires ${bundle.expiresAt}` : undefined, - bundle?.scope ? `scope ${bundle.scope}` : undefined, - ] - .filter(Boolean) - .join("\t"), - ); - writeOut("\n"); - } + const configPath = envConfigPath(); + listAuth({ + writeOut, + ...(configPath ? { configPath } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }); }); return program; } -async function loginAuth( - serverId: string, - options: { - noOpen: boolean; - writeOut: (value: string) => void; - writeErr: (value: string) => void; - authDir?: string; - }, -): Promise { - const config = loadConfig(envConfigPath()); - const server = findAuthTarget(serverId, config); - assertLoginTarget(server, serverId); - - try { - const flowOptions = { - noOpen: options.noOpen, - ...(options.authDir ? { authDir: options.authDir } : {}), - ...(options.noOpen ? { readManualInput: maybeReadManualInput } : {}), - print: (line: string) => options.writeOut(`${line}\n`), - }; - if (server.backend === "mcp") { - await runOAuthFlow(server, flowOptions); - } else { - await runGenericOAuthFlow(server, flowOptions); - } - options.writeOut(`Authenticated ${serverId}\n`); - } catch (error) { - options.writeErr(`${JSON.stringify(toSafeError(error, "AUTH_FAILED"), null, 2)}\n`); - process.exitCode = 1; - } -} - -type AuthTarget = ReturnType[number]; - -function findAuthTarget( - serverId: string, - config = loadConfig(envConfigPath()), -): AuthTarget | undefined { - return authTargets(config).find((server) => server.server === serverId); -} - -function authTargets(config: ReturnType) { - const graphqlEndpoints = ( - config as unknown as { graphqlEndpoints?: Record } - ).graphqlEndpoints; - return [ - ...Object.values(config.mcpServers).filter( - (server) => - server.transport !== "stdio" && - (server.auth?.type === "oauth2" || server.auth?.type === "oidc"), - ), - ...Object.values(config.openapiEndpoints).filter( - (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", - ), - ...Object.values(graphqlEndpoints ?? {}).filter( - (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", - ), - ]; -} - -function assertLoginTarget( - target: AuthTarget | undefined, - serverId: string, -): asserts target is AuthTarget { - if (!target) { - throw new CapletsError("SERVER_NOT_FOUND", `Server ${serverId} is not configured for OAuth`); - } - if ("disabled" in target && target.disabled) { - throw new CapletsError("SERVER_UNAVAILABLE", `Server ${serverId} is disabled`); - } -} - -export function initConfig(options: { path?: string; force?: boolean } = {}): string { - const path = resolveConfigPath(options.path); - if (existsSync(path) && !options.force) { - throw new CapletsError( - "CONFIG_EXISTS", - `Caplets config already exists at ${path}; pass --force to overwrite it`, - ); - } - - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - writeFileSync(path, `${starterConfig()}\n`, { - mode: 0o600, - flag: options.force ? "w" : "wx", - }); - try { - chmodSync(path, 0o600); - } catch { - // Best effort on platforms without POSIX permissions. - } - return path; -} - -export function starterConfig(): string { - return JSON.stringify( - { - $schema: - "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplets-config.schema.json", - version: 1, - defaultSearchLimit: 20, - maxSearchLimit: 50, - mcpServers: { - example: { - name: "Example MCP Server", - description: "Replace this with a real MCP server and what agents should use it for.", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-everything"], - disabled: true, - }, - }, - }, - null, - 2, - ); -} - function envConfigPath(): string | undefined { return process.env.CAPLETS_CONFIG?.trim() || undefined; } - -async function maybeReadManualInput(): Promise { - if (!input.isTTY) { - return undefined; - } - const rl = createInterface({ input, output }); - try { - const answer = await rl.question( - "Paste callback URL or authorization code after completing authorization, or press Enter to wait for loopback callback: ", - ); - return answer.trim() || undefined; - } finally { - rl.close(); - } -} diff --git a/src/cli/auth.ts b/src/cli/auth.ts new file mode 100644 index 00000000..6e129e74 --- /dev/null +++ b/src/cli/auth.ts @@ -0,0 +1,148 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { + deleteTokenBundle, + isTokenBundleExpired, + readTokenBundle, + runGenericOAuthFlow, + runOAuthFlow, + type GenericAuthTarget, +} from "../auth.js"; +import { loadConfig, type GraphQlEndpointConfig } from "../config.js"; +import { CapletsError, toSafeError } from "../errors.js"; + +type AuthTarget = ReturnType[number]; + +export async function loginAuth( + serverId: string, + options: { + configPath?: string; + noOpen: boolean; + writeOut: (value: string) => void; + writeErr: (value: string) => void; + authDir?: string; + }, +): Promise { + const config = loadConfig(options.configPath); + const server = findAuthTarget(serverId, config); + assertLoginTarget(server, serverId); + + try { + const flowOptions = { + noOpen: options.noOpen, + ...(options.authDir ? { authDir: options.authDir } : {}), + ...(options.noOpen ? { readManualInput: maybeReadManualInput } : {}), + print: (line: string) => options.writeOut(`${line}\n`), + }; + if (server.backend === "mcp") { + await runOAuthFlow(server, flowOptions); + } else { + await runGenericOAuthFlow(server, flowOptions); + } + options.writeOut(`Authenticated ${serverId}\n`); + } catch (error) { + options.writeErr(`${JSON.stringify(toSafeError(error, "AUTH_FAILED"), null, 2)}\n`); + process.exitCode = 1; + } +} + +export function logoutAuth( + serverId: string, + options: { authDir?: string; configPath?: string; writeOut: (value: string) => void }, +): void { + const target = findAuthTarget(serverId, loadConfig(options.configPath)); + assertLoginTarget(target, serverId); + + if (deleteTokenBundle(serverId, options.authDir)) { + options.writeOut(`Deleted OAuth credentials for ${serverId}\n`); + } else { + options.writeOut(`No OAuth credentials found for ${serverId}\n`); + } +} + +export function listAuth(options: { + authDir?: string; + configPath?: string; + writeOut: (value: string) => void; +}): void { + const config = loadConfig(options.configPath); + const servers = authTargets(config).sort((left, right) => + left.server.localeCompare(right.server), + ); + + if (servers.length === 0) { + options.writeOut("No configured remote OAuth servers found.\n"); + return; + } + for (const server of servers) { + const bundle = readTokenBundle(server.server, options.authDir); + const status = !bundle ? "missing" : isTokenBundleExpired(bundle) ? "expired" : "authenticated"; + options.writeOut( + [ + server.server, + status, + bundle?.expiresAt ? `expires ${bundle.expiresAt}` : undefined, + bundle?.scope ? `scope ${bundle.scope}` : undefined, + ] + .filter(Boolean) + .join("\t"), + ); + options.writeOut("\n"); + } +} + +function findAuthTarget(serverId: string, config = loadConfig()): AuthTarget | undefined { + return authTargets(config).find((server) => server.server === serverId); +} + +function authTargets(config: ReturnType) { + return [ + ...Object.values(config.mcpServers).filter( + (server) => + server.transport !== "stdio" && + (server.auth?.type === "oauth2" || server.auth?.type === "oidc"), + ), + ...Object.values(config.openapiEndpoints).filter( + (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", + ), + ...Object.values(config.graphqlEndpoints) + .filter((endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc") + .map(graphQlAuthTarget), + ]; +} + +function graphQlAuthTarget( + endpoint: GraphQlEndpointConfig, +): GraphQlEndpointConfig & GenericAuthTarget { + return { + ...endpoint, + url: endpoint.endpointUrl, + }; +} + +function assertLoginTarget( + target: AuthTarget | undefined, + serverId: string, +): asserts target is AuthTarget { + if (!target) { + throw new CapletsError("SERVER_NOT_FOUND", `Server ${serverId} is not configured for OAuth`); + } + if ("disabled" in target && target.disabled) { + throw new CapletsError("SERVER_UNAVAILABLE", `Server ${serverId} is disabled`); + } +} + +async function maybeReadManualInput(): Promise { + if (!input.isTTY) { + return undefined; + } + const rl = createInterface({ input, output }); + try { + const answer = await rl.question( + "Paste callback URL or authorization code after completing authorization, or press Enter to wait for loopback callback: ", + ); + return answer.trim() || undefined; + } finally { + rl.close(); + } +} diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 00000000..5e8a3580 --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,49 @@ +import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { resolveConfigPath } from "../config.js"; +import { CapletsError } from "../errors.js"; + +export function initConfig(options: { path?: string; force?: boolean } = {}): string { + const path = resolveConfigPath(options.path); + if (existsSync(path) && !options.force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplets config already exists at ${path}; pass --force to overwrite it`, + ); + } + + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${starterConfig()}\n`, { + mode: 0o600, + flag: options.force ? "w" : "wx", + }); + try { + chmodSync(path, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + return path; +} + +export function starterConfig(): string { + return JSON.stringify( + { + $schema: + "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplets-config.schema.json", + version: 1, + defaultSearchLimit: 20, + maxSearchLimit: 50, + mcpServers: { + example: { + name: "Example MCP Server", + description: "Replace this with a real MCP server and what agents should use it for.", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everything"], + disabled: true, + }, + }, + }, + null, + 2, + ); +} diff --git a/src/cli/inspection.ts b/src/cli/inspection.ts new file mode 100644 index 00000000..2beb11fc --- /dev/null +++ b/src/cli/inspection.ts @@ -0,0 +1,130 @@ +import { + DEFAULT_AUTH_DIR, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectCapletsRoot, + resolveProjectConfigPath, + TRUST_PROJECT_CAPLETS_ENV, + isTrustedEnvEnabled, + type CapletConfig, + type CapletsConfig, +} from "../config.js"; +import type { ServerStatus } from "../registry.js"; + +type CapletListRow = { + server: string; + backend: CapletConfig["backend"]; + name: string; + description: string; + disabled: boolean; + status: ServerStatus; +}; + +type ConfigPaths = { + userConfig: string; + projectConfig: string; + userRoot: string; + projectRoot: string; + authDir: string; + envConfig: string | null; + projectCapletsTrusted: boolean; +}; + +export function listCaplets( + config: CapletsConfig, + options: { includeDisabled: boolean }, +): CapletListRow[] { + const rows = allCaplets(config) + .filter((server) => options.includeDisabled || !server.disabled) + .map((server) => ({ + server: server.server, + backend: server.backend, + name: server.name, + description: server.description, + disabled: server.disabled, + status: initialServerStatus(server), + })); + return rows.sort((left, right) => left.server.localeCompare(right.server)); +} + +function initialServerStatus(server: CapletConfig): ServerStatus { + return server.disabled ? "disabled" : "not_started"; +} + +function allCaplets(config: CapletsConfig): CapletConfig[] { + return [ + ...Object.values(config.mcpServers), + ...Object.values(config.openapiEndpoints), + ...Object.values(config.graphqlEndpoints), + ]; +} + +export function formatCapletList(rows: CapletListRow[]): string { + if (rows.length === 0) { + return "No configured Caplets found.\n"; + } + + return `${formatTable([ + ["server", "backend", "status", "name"], + ...rows.map((row) => [row.server, row.backend, row.status, row.name]), + ])}\n`; +} + +export function resolveCliConfigPaths( + envConfigPath: string | undefined, + authDir?: string, +): ConfigPaths { + const configPath = resolveConfigPath(envConfigPath); + return { + userConfig: configPath, + projectConfig: resolveProjectConfigPath(), + userRoot: resolveCapletsRoot(configPath), + projectRoot: resolveProjectCapletsRoot(), + authDir: authDir ?? DEFAULT_AUTH_DIR, + envConfig: envConfigPath ?? null, + projectCapletsTrusted: isTrustedProjectCapletsEnabled(), + }; +} + +export function formatConfigPaths(paths: ConfigPaths): string { + return ( + [ + `userConfig: ${paths.userConfig}`, + `projectConfig: ${paths.projectConfig}`, + `userRoot: ${paths.userRoot}`, + `projectRoot: ${paths.projectRoot}`, + `authDir: ${paths.authDir}`, + `envConfig: ${paths.envConfig ?? "unset"}`, + `projectCapletsTrusted: ${paths.projectCapletsTrusted}`, + ].join("\n") + "\n" + ); +} + +function isTrustedProjectCapletsEnabled(): boolean { + return isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV]); +} + +function formatTable(rows: string[][]): string { + const firstRow = rows[0]; + if (!firstRow) { + return ""; + } + + const widths = firstRow.map((_, column) => + Math.max(...rows.map((row) => row[column]?.length ?? 0)), + ); + + return rows.map((row) => formatTableRow(row, widths)).join("\n"); +} + +function formatTableRow(row: string[], widths: number[]): string { + return row + .map((value, column) => { + if (column === row.length - 1) { + return value; + } + return value.padEnd((widths[column] ?? 0) + 2); + }) + .join("") + .trimEnd(); +} diff --git a/src/cli/install.ts b/src/cli/install.ts new file mode 100644 index 00000000..dd113676 --- /dev/null +++ b/src/cli/install.ts @@ -0,0 +1,214 @@ +import { execFileSync } from "node:child_process"; +import { + accessSync, + constants, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, relative } from "node:path"; +import { discoverCapletFiles, validateCapletFile } from "../caplet-files.js"; +import { resolveCapletsRoot, resolveConfigPath } from "../config.js"; +import { SERVER_ID_PATTERN } from "../config/validation.js"; +import { CapletsError, toSafeError } from "../errors.js"; + +type InstallableCaplet = { + id: string; + source: string; + destination: string; + kind: "file" | "directory"; +}; + +type InstallPlan = InstallableCaplet & { + sourcePath: string; +}; + +export function installCaplets( + repo: string, + options: { + capletIds?: string[]; + destinationRoot?: string; + force?: boolean; + } = {}, +): { installed: InstallableCaplet[] } { + const source = resolveInstallSource(repo); + try { + const sourceRoot = join(source.repoRoot, "caplets"); + if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) { + throw new CapletsError("CONFIG_NOT_FOUND", `No caplets directory found at ${sourceRoot}`); + } + + const selectedIds = new Set(options.capletIds ?? []); + const destinationRoot = options.destinationRoot ?? resolveCapletsRoot(resolveConfigPath()); + const available = + selectedIds.size === 0 + ? discoverCapletFiles(sourceRoot) + : discoverSelectedCapletFiles(sourceRoot, selectedIds); + const selected = available.filter( + (caplet) => selectedIds.size === 0 || selectedIds.has(caplet.id), + ); + const missing = [...selectedIds].filter((id) => !available.some((caplet) => caplet.id === id)); + if (missing.length > 0) { + throw new CapletsError( + "CONFIG_NOT_FOUND", + `Caplet ${missing.join(", ")} not found in ${sourceRoot}`, + ); + } + if (selected.length === 0) { + throw new CapletsError("CONFIG_NOT_FOUND", `No Caplets found in ${sourceRoot}`); + } + + for (const caplet of selected) { + validateCapletFile(caplet.path); + } + const installed = preflightInstallCaplets(selected, { + destinationRoot, + force: Boolean(options.force), + repoRoot: source.repoRoot, + sourceId: source.id, + }).map((plan) => installOneCaplet(plan, { force: Boolean(options.force) })); + return { installed }; + } finally { + source.cleanup(); + } +} + +function discoverSelectedCapletFiles( + sourceRoot: string, + selectedIds: Set, +): Array<{ id: string; path: string }> { + const candidates: Array<{ id: string; path: string }> = []; + for (const id of selectedIds) { + if (!SERVER_ID_PATTERN.test(id)) { + continue; + } + + const filePath = join(sourceRoot, `${id}.md`); + if (existsSync(filePath) && statSync(filePath).isFile()) { + candidates.push({ id, path: filePath }); + } + + const directoryPath = join(sourceRoot, id, "CAPLET.md"); + if (existsSync(directoryPath) && statSync(directoryPath).isFile()) { + candidates.push({ id, path: directoryPath }); + } + } + return candidates.sort((left, right) => left.id.localeCompare(right.id)); +} + +function resolveInstallSource(repo: string): { id: string; repoRoot: string; cleanup: () => void } { + if (existsSync(repo) && statSync(repo).isDirectory()) { + return { id: repo, repoRoot: repo, cleanup: () => {} }; + } + + const normalizedRepo = normalizeGitRepo(repo); + const repoRoot = mkdtempSync(join(tmpdir(), "caplets-install-")); + try { + execFileSync("git", ["clone", "--depth", "1", "--", normalizedRepo, repoRoot], { + stdio: "ignore", + timeout: 60_000, + }); + return { + id: normalizedRepo, + repoRoot, + cleanup: () => rmSync(repoRoot, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(repoRoot, { recursive: true, force: true }); + throw new CapletsError("CONFIG_NOT_FOUND", `Could not clone repo ${repo}`, toSafeError(error)); + } +} + +export function normalizeGitRepo(repo: string): string { + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + const normalized = repo.endsWith(".git") ? repo.slice(0, -4) : repo; + return `https://github.com/${normalized}.git`; + } + return repo; +} + +function preflightInstallCaplets( + caplets: Array<{ id: string; path: string }>, + options: { destinationRoot: string; force: boolean; repoRoot: string; sourceId: string }, +): InstallPlan[] { + const plans = caplets.map((caplet) => installPlan(caplet, options)); + for (const plan of plans) { + if (existsSync(plan.destination) && !options.force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet ${plan.id} already exists at ${plan.destination}; pass --force to overwrite it`, + ); + } + } + + const writableRoot = nearestExistingParent(options.destinationRoot); + accessSync(writableRoot, constants.W_OK); + for (const plan of plans) { + const destinationParent = existsSync(plan.destination) + ? dirname(plan.destination) + : nearestExistingParent(dirname(plan.destination)); + accessSync(destinationParent, constants.W_OK); + } + + mkdirSync(options.destinationRoot, { recursive: true, mode: 0o700 }); + return plans; +} + +function installPlan( + caplet: { id: string; path: string }, + options: { destinationRoot: string; repoRoot: string; sourceId: string }, +): InstallPlan { + const isDirectory = basename(caplet.path) === "CAPLET.md"; + const sourcePath = isDirectory ? dirname(caplet.path) : caplet.path; + const sourcePathRelative = relative(options.repoRoot, sourcePath); + const destination = isDirectory + ? join(options.destinationRoot, caplet.id) + : join(options.destinationRoot, `${caplet.id}.md`); + + return { + id: caplet.id, + source: `${options.sourceId}#${sourcePathRelative}`, + sourcePath, + destination, + kind: isDirectory ? "directory" : "file", + }; +} + +function installOneCaplet(plan: InstallPlan, options: { force: boolean }): InstallableCaplet { + if (existsSync(plan.destination)) { + if (!options.force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet ${plan.id} already exists at ${plan.destination}; pass --force to overwrite it`, + ); + } + rmSync(plan.destination, { recursive: true, force: true }); + } + + cpSync(plan.sourcePath, plan.destination, { + recursive: plan.kind === "directory", + force: false, + errorOnExist: true, + }); + return { + id: plan.id, + source: plan.source, + destination: plan.destination, + kind: plan.kind, + }; +} + +function nearestExistingParent(path: string): string { + if (existsSync(path)) { + return path; + } + const parent = dirname(path); + if (parent === path) { + return parent; + } + return nearestExistingParent(parent); +} diff --git a/src/config.ts b/src/config.ts index 102db70f..3e9cd45f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,34 @@ import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; import { z } from "zod"; import { loadCapletFiles } from "./caplet-files.js"; +import { + TRUST_PROJECT_CAPLETS_ENV, + isTrustedEnvEnabled, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectConfigPath, +} from "./config/paths.js"; +import { + FORBIDDEN_HEADERS, + HEADER_NAME_PATTERN, + SERVER_ID_PATTERN, + isAllowedRemoteUrl, +} from "./config/validation.js"; import { CapletsError, redactSecrets } from "./errors.js"; +export { + DEFAULT_AUTH_DIR, + DEFAULT_CONFIG_PATH, + PROJECT_CONFIG_FILE, + TRUST_PROJECT_CAPLETS_ENV, + isTrustedEnvEnabled, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectCapletsRoot, + resolveProjectConfigPath, +} from "./config/paths.js"; + export type RemoteAuthConfig = | { type: "none" } | { type: "bearer"; token: string } @@ -119,32 +143,8 @@ export type CapletsConfig = { graphqlEndpoints: Record; }; -const SERVER_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; -const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; -const FORBIDDEN_HEADERS = new Set([ - "accept", - "authorization", - "connection", - "content-length", - "content-type", - "host", - "keep-alive", - "mcp-protocol-version", - "mcp-session-id", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]); const NON_INTERPOLATED_SERVER_FIELDS = new Set(["name", "description", "tags", "body"]); -export const DEFAULT_CONFIG_PATH = join(homedir(), ".caplets", "config.json"); -export const DEFAULT_AUTH_DIR = join(homedir(), ".caplets", "auth"); -export const PROJECT_CONFIG_FILE = join(".caplets", "config.json"); -export const TRUST_PROJECT_CAPLETS_ENV = "CAPLETS_TRUST_PROJECT_CAPLETS"; - const remoteAuthSchema = z .discriminatedUnion("type", [ z.object({ type: z.literal("none") }).strict(), @@ -654,22 +654,6 @@ export function configJsonSchema(): unknown { }; } -export function resolveConfigPath(path?: string): string { - return path ?? join(homedir(), ".caplets", "config.json"); -} - -export function resolveProjectConfigPath(cwd = process.cwd()): string { - return join(cwd, PROJECT_CONFIG_FILE); -} - -export function resolveCapletsRoot(configPath = resolveConfigPath()): string { - return dirname(configPath); -} - -export function resolveProjectCapletsRoot(cwd = process.cwd()): string { - return join(cwd, ".caplets"); -} - export function loadConfig( path = resolveConfigPath(), projectPath = resolveProjectConfigPath(), @@ -723,10 +707,6 @@ function shouldLoadProjectCaplets(): boolean { return isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV]); } -function isTrustedEnvEnabled(value: string | undefined): boolean { - return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; -} - function readPublicConfigInput(path: string): ConfigInput { try { const input = JSON.parse(readFileSync(path, "utf8")); @@ -978,14 +958,3 @@ export function interpolateEnv(value: string): string { .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name: string) => process.env[name] ?? "") .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name: string) => process.env[name] ?? ""); } - -function isAllowedRemoteUrl(value: string): boolean { - const url = new URL(value); - if (url.protocol === "https:") { - return true; - } - if (url.protocol !== "http:") { - return false; - } - return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); -} diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 00000000..1f0bdbea --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,27 @@ +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export const DEFAULT_CONFIG_PATH = join(homedir(), ".caplets", "config.json"); +export const DEFAULT_AUTH_DIR = join(homedir(), ".caplets", "auth"); +export const PROJECT_CONFIG_FILE = join(".caplets", "config.json"); +export const TRUST_PROJECT_CAPLETS_ENV = "CAPLETS_TRUST_PROJECT_CAPLETS"; + +export function resolveConfigPath(path?: string): string { + return path ?? DEFAULT_CONFIG_PATH; +} + +export function resolveProjectConfigPath(cwd = process.cwd()): string { + return join(cwd, PROJECT_CONFIG_FILE); +} + +export function resolveCapletsRoot(configPath = resolveConfigPath()): string { + return dirname(configPath); +} + +export function resolveProjectCapletsRoot(cwd = process.cwd()): string { + return join(cwd, ".caplets"); +} + +export function isTrustedEnvEnabled(value: string | undefined): boolean { + return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; +} diff --git a/src/config/validation.ts b/src/config/validation.ts new file mode 100644 index 00000000..af9ecf37 --- /dev/null +++ b/src/config/validation.ts @@ -0,0 +1,34 @@ +export const SERVER_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; +export const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +export const FORBIDDEN_HEADERS = new Set([ + "accept", + "authorization", + "connection", + "content-length", + "content-type", + "host", + "keep-alive", + "mcp-protocol-version", + "mcp-session-id", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +export function isAllowedRemoteUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.protocol === "https:") { + return true; + } + return ( + url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname) + ); +} diff --git a/src/graphql.ts b/src/graphql.ts index dd9b7d9c..2857d99a 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -27,13 +27,14 @@ import { type TypeNode, validate, } from "graphql"; -import type { GraphQlEndpointConfig } from "./config.js"; import { genericOAuthHeaders } from "./auth.js"; +import type { GraphQlEndpointConfig } from "./config.js"; +import { isAllowedRemoteUrl } from "./config/validation.js"; import type { CompactTool } from "./downstream.js"; import { CapletsError, toSafeError } from "./errors.js"; +import { isAbortError, parseHttpBody, readLimitedText } from "./http/utils.js"; import type { ServerRegistry } from "./registry.js"; -const MAX_RESPONSE_BYTES = 1024 * 1024; const GRAPHQL_METHOD = "POST"; const SCALAR_JSON_SCHEMA: Record> = { String: { type: "string" }, @@ -165,9 +166,9 @@ export class GraphQLManager { }, ); } - const body = parseGraphQlBody( + const body = parseHttpBody( response.headers.get("content-type") ?? "", - await readLimitedText(response), + await readGraphQlText(response), ); const result = { status: response.status, @@ -334,7 +335,7 @@ async function loadSchema( status: response.status, }); } - const parsed = JSON.parse(await readLimitedText(response)) as { + const parsed = JSON.parse(await readGraphQlText(response)) as { data?: unknown; errors?: unknown; }; @@ -659,7 +660,7 @@ async function fetchGraphQlText( status: response.status, }); } - return readLimitedText(response); + return readGraphQlText(response); } function schemaAuthHeaders( @@ -698,53 +699,12 @@ function shouldSendSchemaAuth(endpoint: GraphQlEndpointConfig): boolean { ); } -function parseGraphQlBody(contentType: string, text: string): unknown { - if (!text) { - return undefined; - } - if (!contentType.includes("application/json")) { - return text; - } - try { - return JSON.parse(text); - } catch { - return text; - } -} - -async function readLimitedText(response: Response): Promise { - if (!response.body) { - return ""; - } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let bytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (value) { - bytes += value.byteLength; - if (bytes > MAX_RESPONSE_BYTES) { - await reader.cancel(); - throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", "GraphQL response exceeded byte limit"); - } - chunks.push(value); - } - } - return new TextDecoder().decode(Buffer.concat(chunks)); +async function readGraphQlText(response: Response): Promise { + return readLimitedText(response, { errorMessage: "GraphQL response exceeded byte limit" }); } function validateEndpointUrl(value: string): void { - const url = new URL(value); - if (url.protocol === "https:") { - return; - } - if ( - url.protocol === "http:" && - ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname) - ) { + if (isAllowedRemoteUrl(value)) { return; } throw new CapletsError( @@ -753,10 +713,6 @@ function validateEndpointUrl(value: string): void { ); } -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; -} - function graphQlCacheKey(endpoint: GraphQlEndpointConfig): string { return JSON.stringify({ endpointUrl: endpoint.endpointUrl, diff --git a/src/http/utils.ts b/src/http/utils.ts new file mode 100644 index 00000000..da790a8c --- /dev/null +++ b/src/http/utils.ts @@ -0,0 +1,49 @@ +import { CapletsError } from "../errors.js"; + +export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; + +export function parseHttpBody(contentType: string, text: string): unknown { + if (!text) { + return undefined; + } + const mime = contentType.split(";")[0]?.toLowerCase().trim() ?? ""; + if (mime !== "application/json" && !mime.endsWith("+json") && !mime.endsWith("/json")) { + return text; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export async function readLimitedText( + response: Response, + options: { maxBytes?: number; errorMessage: string }, +): Promise { + if (!response.body) { + return ""; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value) { + bytes += value.byteLength; + if (bytes > (options.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES)) { + await reader.cancel(); + throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", options.errorMessage); + } + chunks.push(value); + } + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +export function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} diff --git a/src/openapi.ts b/src/openapi.ts index 839e41de..78b0ddfd 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,14 +1,15 @@ import SwaggerParser from "@apidevtools/swagger-parser"; import type { CompatibilityCallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { genericOAuthHeaders } from "./auth.js"; import type { OpenApiEndpointConfig } from "./config.js"; +import { isAllowedRemoteUrl } from "./config/validation.js"; +import type { CompactTool } from "./downstream.js"; import { CapletsError, toSafeError } from "./errors.js"; +import { isAbortError, parseHttpBody, readLimitedText } from "./http/utils.js"; import type { ServerRegistry } from "./registry.js"; -import type { CompactTool } from "./downstream.js"; -import { genericOAuthHeaders } from "./auth.js"; const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; const JSON_CONTENT_TYPES = ["application/json"]; -const MAX_RESPONSE_BYTES = 1024 * 1024; const FORBIDDEN_ARGUMENT_HEADERS = new Set([ "accept", "authorization", @@ -564,8 +565,10 @@ function shouldSendSpecAuth(endpoint: OpenApiEndpointConfig): boolean { async function readResponse(response: Response): Promise> { const contentType = response.headers.get("content-type") ?? ""; - const text = await readLimitedText(response); - const body = parseResponseBody(contentType, text); + const text = await readLimitedText(response, { + errorMessage: "OpenAPI response exceeded byte limit", + }); + const body = parseHttpBody(contentType, text); return { status: response.status, statusText: response.statusText, @@ -576,44 +579,6 @@ async function readResponse(response: Response): Promise }; } -function parseResponseBody(contentType: string, text: string): unknown { - if (!text) { - return undefined; - } - if (!contentType.includes("application/json")) { - return text; - } - try { - return JSON.parse(text); - } catch { - return text; - } -} - -async function readLimitedText(response: Response): Promise { - if (!response.body) { - return ""; - } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let bytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (value) { - bytes += value.byteLength; - if (bytes > MAX_RESPONSE_BYTES) { - await reader.cancel(); - throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", "OpenAPI response exceeded byte limit"); - } - chunks.push(value); - } - } - return new TextDecoder().decode(Buffer.concat(chunks)); -} - async function fetchWithLimit( url: string, timeoutMs: number, @@ -638,7 +603,9 @@ async function fetchWithLimit( status: response.status, }); } - return await readLimitedText(response); + return await readLimitedText(response, { + errorMessage: "OpenAPI response exceeded byte limit", + }); } catch (error) { if (isAbortError(error)) { throw new CapletsError("TOOL_CALL_TIMEOUT", "OpenAPI spec request timed out"); @@ -662,7 +629,7 @@ function validateOperationBaseUrl( `${endpoint.server} must configure baseUrl when using remote specUrl`, ); } - if (!isAllowedRequestBaseUrl(base)) { + if (!isAllowedRemoteUrl(base)) { throw new CapletsError("CONFIG_INVALID", `${endpoint.server} OpenAPI baseUrl is not allowed`); } const url = new URL(base); @@ -685,21 +652,6 @@ function buildOperationUrl(base: string, operationPath: string): URL { return baseUrl; } -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; -} - -function isAllowedRequestBaseUrl(value: string): boolean { - const url = new URL(value); - if (url.protocol === "https:") { - return true; - } - if (url.protocol !== "http:") { - return false; - } - return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); -} - function openApiCacheKey(endpoint: OpenApiEndpointConfig): string { return JSON.stringify({ specPath: endpoint.specPath, diff --git a/test/auth.test.ts b/test/auth.test.ts index 011b620d..70559e48 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -4,13 +4,15 @@ import { genericOAuthHeaders, extractCompletion, FileOAuthProvider, + authStorePath, oauthHeaders, + readTokenBundle, runGenericOAuthFlow, writeTokenBundle, } from "../src/auth.js"; import { parseConfig } from "../src/config.js"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -63,6 +65,28 @@ describe("auth helpers", () => { } }); + it("ignores corrupt token bundle files", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); + try { + writeFileSync(join(dir, "remote.json"), "{not-json\n"); + + expect(readTokenBundle("remote", dir)).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects auth store path traversal", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); + try { + expect(() => authStorePath("../remote", dir)).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("builds generic OAuth headers for OpenAPI and GraphQL auth targets", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); try { diff --git a/test/cli.test.ts b/test/cli.test.ts index c3cab81a..98b8caa9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,14 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { initConfig, runCli, starterConfig } from "../src/cli.js"; -import { parseConfig } from "../src/config.js"; +import { version as packageJsonVersion } from "../package.json"; +import { initConfig, installCaplets, normalizeGitRepo, runCli, starterConfig } from "../src/cli.js"; +import { parseConfig, TRUST_PROJECT_CAPLETS_ENV } from "../src/config.js"; import { CapletsError } from "../src/errors.js"; import { writeTokenBundle } from "../src/auth.js"; describe("cli init", () => { const originalConfigPath = process.env.CAPLETS_CONFIG; + const originalTrustProjectCaplets = process.env[TRUST_PROJECT_CAPLETS_ENV]; afterEach(() => { vi.restoreAllMocks(); @@ -17,6 +19,11 @@ describe("cli init", () => { } else { process.env.CAPLETS_CONFIG = originalConfigPath; } + if (originalTrustProjectCaplets === undefined) { + delete process.env[TRUST_PROJECT_CAPLETS_ENV]; + } else { + process.env[TRUST_PROJECT_CAPLETS_ENV] = originalTrustProjectCaplets; + } }); it("writes a valid starter config and creates parent directories", () => { @@ -90,6 +97,317 @@ describe("cli init", () => { ); }); + it("prints the package version", async () => { + const out: string[] = []; + + await runCli(["--version"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toBe(`${packageJsonVersion}\n`); + }); + + it("lists enabled Caplets by default", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeInspectionConfig(configPath); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["list"], { writeOut: (value) => out.push(value) }); + + const text = out.join(""); + expect(text).toContain("server"); + expect(text).toContain("filesystem"); + expect(text).toContain("mcp"); + expect(text).toContain("not_started"); + expect(text).toContain("Project Files"); + expect(text).toContain("users"); + expect(text).toContain("openapi"); + expect(text).toContain("catalog"); + expect(text).toContain("graphql"); + expect(text).not.toContain("disabled_remote"); + expect(text).not.toContain("secret-access-token"); + expect(text).not.toContain("openapi-client"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("can include disabled Caplets in the list", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeInspectionConfig(configPath); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["list", "--all"], { writeOut: (value) => out.push(value) }); + + const text = out.join(""); + expect(text).toContain("disabled_remote"); + expect(text).toContain("disabled"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints listed Caplets as JSON", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeInspectionConfig(configPath); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["list", "--json"], { writeOut: (value) => out.push(value) }); + + const rows = JSON.parse(out.join("")) as Array<{ + server: string; + backend: string; + name: string; + description: string; + disabled: boolean; + status: string; + }>; + expect(rows).toEqual([ + expect.objectContaining({ + server: "catalog", + backend: "graphql", + disabled: false, + status: "not_started", + }), + expect.objectContaining({ + server: "filesystem", + backend: "mcp", + disabled: false, + status: "not_started", + }), + expect.objectContaining({ + server: "users", + backend: "openapi", + disabled: false, + status: "not_started", + }), + ]); + expect(out.join("")).not.toContain("secret-access-token"); + expect(out.join("")).not.toContain("openapi-client"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints the effective config path", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-config-path-")); + const configPath = join(dir, "custom.json"); + const out: string[] = []; + try { + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["config", "path"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toBe(`${configPath}\n`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints resolved config paths as JSON", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-config-paths-")); + const configPath = join(dir, "custom.json"); + const authDir = join(dir, "auth"); + const out: string[] = []; + try { + process.env.CAPLETS_CONFIG = configPath; + process.env[TRUST_PROJECT_CAPLETS_ENV] = "yes"; + + await runCli(["config", "paths", "--json"], { + writeOut: (value) => out.push(value), + authDir, + }); + + expect(JSON.parse(out.join(""))).toEqual({ + userConfig: configPath, + projectConfig: join(process.cwd(), ".caplets", "config.json"), + userRoot: dir, + projectRoot: join(process.cwd(), ".caplets"), + authDir, + envConfig: configPath, + projectCapletsTrusted: true, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs all Caplets from a local repo caplets directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + const out: string[] = []; + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["install", repo], { writeOut: (value) => out.push(value) }); + + expect(readFileSync(join(dir, "user", "filesystem.md"), "utf8")).toContain( + "name: Project Files", + ); + expect(readFileSync(join(dir, "user", "github", "CAPLET.md"), "utf8")).toContain( + "name: GitHub", + ); + expect(out.join("")).toContain("Installed filesystem"); + expect(out.join("")).toContain("Installed github"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs selected Caplets from a local repo caplets directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + const out: string[] = []; + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["install", repo, "github"], { writeOut: (value) => out.push(value) }); + + expect(existsSync(join(dir, "user", "github", "CAPLET.md"))).toBe(true); + expect(existsSync(join(dir, "user", "filesystem.md"))).toBe(false); + expect(out.join("")).toBe(`Installed github to ${join(dir, "user", "github")}\n`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs a selected Caplet when an unrelated Caplet is invalid", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + writeFileSync(join(repo, "caplets", "broken.md"), "not frontmatter\n"); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["install", repo, "github"], { writeOut: () => {} }); + + expect(existsSync(join(dir, "user", "github", "CAPLET.md"))).toBe(true); + expect(existsSync(join(dir, "user", "broken.md"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs a selected Caplet when an unrelated Caplet filename has an invalid ID", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + writeFileSync(join(repo, "caplets", "api.v2.md"), "not frontmatter\n"); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["install", repo, "github"], { writeOut: () => {} }); + + expect(existsSync(join(dir, "user", "github", "CAPLET.md"))).toBe(true); + expect(existsSync(join(dir, "user", "api.v2.md"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses to overwrite installed Caplets without force", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["install", repo, "github"], { writeOut: () => {} }); + + await expect(runCli(["install", repo, "github"], { writeOut: () => {} })).rejects.toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + + await runCli(["install", repo, "github", "--force"], { writeOut: () => {} }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("preflights selected installs before copying any Caplets", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + mkdirSync(join(destinationRoot, "github"), { recursive: true }); + writeFileSync(join(destinationRoot, "github", "CAPLET.md"), "existing\n"); + + expect(() => installCaplets(repo, { destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + + expect(existsSync(join(destinationRoot, "filesystem.md"))).toBe(false); + expect(readFileSync(join(destinationRoot, "github", "CAPLET.md"), "utf8")).toBe("existing\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns stable install source identifiers", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + + const result = installCaplets(repo, { capletIds: ["github"], destinationRoot }); + + expect(result.installed).toEqual([ + { + id: "github", + source: `${repo}#caplets/github`, + destination: join(destinationRoot, "github"), + kind: "directory", + }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects selected Caplets that are not in the repo", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + await expect(runCli(["install", repo, "missing"], { writeOut: () => {} })).rejects.toThrow( + expect.objectContaining({ code: "CONFIG_NOT_FOUND" }) as CapletsError, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("normalizes GitHub shorthand repos without double appending git suffixes", () => { + expect(normalizeGitRepo("spiritledsoftware/caplets")).toBe( + "https://github.com/spiritledsoftware/caplets.git", + ); + expect(normalizeGitRepo("spiritledsoftware/caplets.git")).toBe( + "https://github.com/spiritledsoftware/caplets.git", + ); + expect(normalizeGitRepo("https://github.com/spiritledsoftware/caplets.git")).toBe( + "https://github.com/spiritledsoftware/caplets.git", + ); + }); + it("lists configured OAuth servers without printing token values", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); const authDir = join(dir, "auth"); @@ -164,6 +482,35 @@ describe("cli init", () => { } }); + it("lists configured GraphQL OAuth endpoints", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeFileSync( + configPath, + JSON.stringify({ + graphqlEndpoints: { + catalog: { + name: "Catalog", + description: "Query catalog data through GraphQL.", + endpointUrl: "https://api.example.com/graphql", + introspection: true, + auth: { type: "oauth2", issuer: "https://issuer.example" }, + }, + }, + }), + ); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["auth", "list"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toContain("catalog\tmissing"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("logs out configured OAuth servers", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); const authDir = join(dir, "auth"); @@ -239,3 +586,85 @@ describe("cli init", () => { } }); }); + +function writeInspectionConfig(path: string): void { + writeFileSync( + path, + JSON.stringify({ + mcpServers: { + filesystem: { + name: "Project Files", + description: "Read and search local project files.", + command: "node", + env: { + TOKEN: "secret-access-token", + }, + }, + disabled_remote: { + name: "Disabled Remote", + description: "A disabled remote server for testing.", + transport: "http", + url: "https://disabled.example.com/mcp", + disabled: true, + }, + }, + openapiEndpoints: { + users: { + name: "Users API", + description: "Manage users through the internal HTTP API.", + specPath: "/tmp/users-openapi.json", + auth: { type: "oauth2", clientId: "openapi-client" }, + }, + }, + graphqlEndpoints: { + catalog: { + name: "Catalog GraphQL", + description: "Query and update catalog data through GraphQL.", + endpointUrl: "https://api.example.com/graphql", + schemaPath: "/tmp/catalog.graphql", + auth: { type: "none" }, + }, + }, + }), + ); +} + +function writeInstallableRepo(repo: string): void { + const root = join(repo, "caplets"); + mkdirSync(join(root, "github"), { recursive: true }); + writeFileSync( + join(root, "filesystem.md"), + [ + "---", + "name: Project Files", + "description: Read and search local project files.", + "mcpServer:", + " command: npx", + " args:", + " - -y", + " - '@modelcontextprotocol/server-filesystem'", + " - .", + "---", + "# Project Files", + ].join("\n"), + ); + writeFileSync( + join(root, "github", "CAPLET.md"), + [ + "---", + "name: GitHub", + "description: Work with GitHub repositories and pull requests.", + "mcpServer:", + " command: npx", + " args:", + " - -y", + " - github-mcp-server", + "---", + "# GitHub", + ].join("\n"), + ); + writeFileSync( + join(root, "github", "README.md"), + "Extra files are copied with directory Caplets.\n", + ); +} diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts new file mode 100644 index 00000000..e62e1226 --- /dev/null +++ b/test/config-validation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedRemoteUrl } from "../src/config/validation.js"; + +describe("config validation helpers", () => { + it("returns false for malformed remote URLs", () => { + expect(isAllowedRemoteUrl("not a url")).toBe(false); + }); + + it("allows https and loopback http only", () => { + expect(isAllowedRemoteUrl("https://example.com/mcp")).toBe(true); + expect(isAllowedRemoteUrl("http://localhost:3000/mcp")).toBe(true); + expect(isAllowedRemoteUrl("http://example.com/mcp")).toBe(false); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts index 1654ce16..f9846e9c 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { capletJsonSchema } from "../src/caplet-files.js"; +import { capletJsonSchema, loadCapletFiles } from "../src/caplet-files.js"; import { configJsonSchema, loadConfig, parseConfig } from "../src/config.js"; import { CapletsError } from "../src/errors.js"; @@ -262,6 +270,75 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); }); + it("keeps repository example Caplets loadable", () => { + const originalGithubToken = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + delete process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + try { + const examples = loadCapletFiles(join(import.meta.dirname, "..", "caplets")); + + const config = parseConfig(examples); + + expect(config.mcpServers.context7).toMatchObject({ + server: "context7", + name: "Context7 Documentation", + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }); + expect(config.mcpServers.github).toMatchObject({ + server: "github", + name: "GitHub", + command: "docker", + args: [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server", + ], + env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" }, + }); + expect(config.mcpServers.linear).toMatchObject({ + server: "linear", + name: "Linear", + transport: "http", + url: "https://mcp.linear.app/mcp", + auth: { type: "oauth2" }, + }); + } finally { + if (originalGithubToken === undefined) { + delete process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + } else { + process.env.GITHUB_PERSONAL_ACCESS_TOKEN = originalGithubToken; + } + } + }); + + it("keeps repository Caplet reference files linked from CAPLET.md", () => { + const examplesRoot = join(import.meta.dirname, "..", "caplets"); + const capletDirs = readdirSync(examplesRoot, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + for (const entry of capletDirs) { + const capletPath = join(examplesRoot, entry.name, "CAPLET.md"); + if (!existsSync(capletPath)) { + continue; + } + const caplet = readFileSync(capletPath, "utf8"); + const referenceFiles = readdirSync(join(examplesRoot, entry.name)).filter( + (file) => file.endsWith(".md") && file !== "CAPLET.md" && file !== "README.md", + ); + + for (const file of referenceFiles) { + expect( + markdownLinkTargets(caplet), + `${entry.name}/CAPLET.md should link ${file}`, + ).toContain(`./${file}`); + } + } + }); + it("does not load project Caplet files without explicit trust", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-files-")); const userRoot = join(dir, "user"); @@ -831,3 +908,9 @@ describe("config", () => { ).toThrow(CapletsError); }); }); + +function markdownLinkTargets(markdown: string): string[] { + return [...markdown.matchAll(/\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)].flatMap((match) => + match[1] ? [match[1]] : [], + ); +} diff --git a/test/http-utils.test.ts b/test/http-utils.test.ts new file mode 100644 index 00000000..232e5bb6 --- /dev/null +++ b/test/http-utils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { parseHttpBody } from "../src/http/utils.js"; + +describe("http utils", () => { + it("parses JSON and structured JSON media types", () => { + expect(parseHttpBody("application/json; charset=utf-8", '{"ok":true}')).toEqual({ ok: true }); + expect(parseHttpBody("application/graphql-response+json", '{"data":{}}')).toEqual({ + data: {}, + }); + }); + + it("leaves non-JSON bodies as text", () => { + expect(parseHttpBody("text/plain", "hello")).toBe("hello"); + }); +});