diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b41a93c..e9205b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. The format - [#18](https://github.com/mohanagy/miftah/issues/18) The packed-package contract now exercises the installed CLI through shell and Windows command quoting, paths with spaces, generated help, stable category exits, JSON automation output, and normalized/redacted audit-log output. - [#18](https://github.com/mohanagy/miftah/issues/18) The CLI reference now documents generated help, every command and option, version compatibility output, exit statuses, JSON streams, and audit snapshot/follow safety boundaries. +- [#19](https://github.com/mohanagy/miftah/issues/19) The versioned strict preset catalog, first-run onboarding wizard, generated absolute client snippets, compatibility matrix, and exact generated examples are documented and contract-tested. Tests validate generated configuration without constructing or starting external providers. ### Changed diff --git a/README.md b/README.md index 0555a02c..1d3ed8ff 100644 --- a/README.md +++ b/README.md @@ -23,84 +23,26 @@ Miftah runs locally by default. It has no cloud dependency or telemetry. Generate a safe template: ```bash -miftah init github --preset github --output ~/.config/miftah/github.json +miftah init github --preset github --output ~/.config/miftah/github.json --client claude-desktop ``` -Edit the profile environment to reference shell variables, then validate it: +Set the generated profile environment references in the environment that launches your client, then validate the configuration: ```bash -export GITHUB_WORK_TOKEN='...' miftah validate --config ~/.config/miftah/github.json ``` -Run a wrapped server directly: +`init --client` prints a host-specific JSON snippet with absolute launcher paths. Copy that JSON to the appropriate client configuration; Miftah does not write a client file. See the [preset and client compatibility matrix](docs/presets-and-clients.md) for the catalog pin, client location, and security boundaries. + +Run a wrapped server directly when testing local STDIO: ```bash miftah --config ~/.config/miftah/github.json ``` -The same process can be configured in Claude Desktop: - -```json -{ - "mcpServers": { - "github": { - "command": "miftah", - "args": ["--config", "/Users/me/.config/miftah/github.json"] - } - } -} -``` - ## Profiles -Profiles are named credential environments. Keep secret values outside JSON whenever possible: - -```json -{ - "version": "1", - "name": "github", - "defaultProfile": "work", - "upstream": { - "transport": "stdio", - "command": "docker", - "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"] - }, - "profiles": { - "work": { - "description": "Work GitHub", - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_WORK_TOKEN}" - }, - "policy": "safe-write" - }, - "personal": { - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_TOKEN}" - }, - "policy": "readonly" - } - }, - "policies": { - "readonly": { - "allowRisk": ["read"], - "denyRisk": ["write", "destructive"] - }, - "safe-write": { - "allowRisk": ["read", "write"], - "denyRisk": ["destructive"], - "requireConfirmation": ["write"] - } - }, - "security": { - "allowPlaintextSecrets": false, - "allowProfileSwitchingFromMcp": true, - "requireExplicitProfileForDestructive": true - } -} -``` - -The GitHub preset pins `ghcr.io/github/github-mcp-server:v1.5.0`. To upgrade safely, read the release notes first, update the tag in your config, run `miftah validate`, then smoke-test both profiles before rollout. +Profiles are named credential environments. Keep secret values outside JSON and use the exact generated references in the checked-in [GitHub](examples/github.miftah.json), [Sentry](examples/sentry.miftah.json), or [generic reference](examples/generic.miftah.json) example. The strict catalog pins GitHub to `ghcr.io/github/github-mcp-server:v1.5.0` with its documented read-only tool configuration; it does not claim a digest. The [compatibility matrix](docs/presets-and-clients.md) describes safe promotion and deployment recording for that tag. Claude can call `miftah_list_profiles`, `miftah_current_profile`, `miftah_use_profile`, `miftah_profile_info`, `miftah_health`, `miftah_validate_config`, `miftah_list_upstream_tools`, `miftah_restart_profile`, and `miftah_route_preview`. Upstream tools are exposed unchanged unless they collide with a reserved management name. After a profile change, restart, upstream failure, or recovery that changes a public capability surface, MCP clients receive list-change notifications and should re-list the affected tools, resources, or prompts before relying on cached capabilities. @@ -182,7 +124,7 @@ Use `miftah --help` for the generated command list and `miftah --help` | `miftah --config ` / `miftah serve --config ` | Run the STDIO MCP wrapper. | | `miftah validate --config ` | Parse and validate JSON config; writes JSON. | | `miftah doctor --config [--json]` | Report redacted configuration and upstream readiness. | -| `miftah init [name] [--name ] [--preset ] [--output ]` | Generate a generic, GitHub, or Sentry template. | +| `miftah init [name] [--name ] [--preset ] [--output ] [--interactive] [--client ] [--credential-env ] [--npm-package ] [--docker-image ] [--url ] [--header-name ] [--header-prefix ]` | Generate a strict catalog template and optionally print client JSON. | | `miftah schema` | Print the JSON Schema. | | `miftah list-tools --config [--profile ]` | Discover upstream tools as JSON. | | `miftah test-profile --config [--profile ]` | Start and initialize one profile; writes JSON. | @@ -200,6 +142,7 @@ Structured success output is written to stdout with stderr empty. Stable nonzero - [CLI](docs/cli.md) - [Library API](docs/library-api.md) - [Claude Desktop](docs/claude-desktop.md) +- [Preset and client compatibility](docs/presets-and-clients.md) - [GitHub example](docs/examples/github.md) - [Sentry example](docs/examples/sentry.md) - [Changelog and release policy](CHANGELOG.md) diff --git a/docs/claude-desktop.md b/docs/claude-desktop.md index 5d4a57b0..fd33f222 100644 --- a/docs/claude-desktop.md +++ b/docs/claude-desktop.md @@ -1,21 +1,22 @@ # Claude Desktop -Install Miftah globally, create a wrapper config, and add one server entry: - -```json -{ - "mcpServers": { - "github": { - "command": "miftah", - "args": [ - "--config", - "/Users/me/.config/miftah/github.json" - ] - } - } -} +Generate a strict configuration and a Claude Desktop snippet: + +```sh +miftah init github --preset github --output ~/.config/miftah/github.json --client claude-desktop ``` +Miftah writes only `~/.config/miftah/github.json` in this example. It prints JSON for you to copy and does not create or overwrite a Claude Desktop configuration file. + +Claude Desktop uses an `mcpServers` object and is officially available on macOS and Windows. Its normal locations are: + +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +Use the installed app’s **Developer → Edit Config** flow as the source of truth for the actual location and schema. Merge the generated top-level `mcpServers` property into the host config. If the host config already has an `mcpServers` object, merge the generated server entry into that object instead of nesting another `mcpServers` property. Do not convert its absolute command and argument paths into a shell command. The launcher uses absolute Node and compiled Miftah CLI paths because desktop GUI processes often have a different `PATH` than a terminal. + +Regenerate the snippet after moving or upgrading Miftah, or changing the Miftah config path. Keep credentials outside both JSON files; set only the generated `${ENV_NAME}` references in the environment that launches Claude Desktop. + Claude Desktop generally provides one STDIO session per configured server. Profile state is therefore session-local in the default mode. Miftah cannot infer every detail of a conversation; use `miftah_use_profile` for explicit switching and configure routing rules for stable tool arguments such as repositories, organizations, or projects. -Do not configure every account as a separate Claude server. Give each wrapper one entry and keep account-specific credentials in profiles. +Do not configure every account as a separate Claude server. Give each wrapper one entry and keep account-specific credentials in profiles. For catalog pins, safety boundaries, and all client destinations, see [preset and client compatibility](presets-and-clients.md). diff --git a/docs/cli.md b/docs/cli.md index 75b96e8a..50532e8e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,7 +20,7 @@ The root command list is `serve`, `validate`, `doctor`, `schema`, `init`, `list- | `miftah validate --config ` | `--config` | `--config ` | Validates the JSON configuration without starting an upstream. Writes a JSON object with `ok`, `name`, and `profiles`. | | `miftah doctor --config ` | `--config` | `--config `, `--json` | Validates configuration and checks upstream readiness. Default output is a human-readable report; `--json` writes only the JSON report. A healthy or degraded report exits `0`; a failed report exits `1`. | | `miftah schema` | none | none | Writes the Miftah JSON Schema as pretty-printed JSON. | -| `miftah init [name]` | none | `--name `, `--preset `, `--output ` | Writes a starter configuration with exclusive creation. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | +| `miftah init [name]` | none | `--name `, `--preset `, `--output `, `--interactive`, `--client `, `--credential-env `, `--npm-package `, `--docker-image `, `--url `, `--header-name `, `--header-prefix ` | Writes a strict catalog configuration with exclusive creation and can print client JSON snippets. The positional `name` and `--name` are alternatives; the default name is `miftah-wrapper`. | | `miftah list-tools --config ` | `--config` | `--config `, `--profile ` | Starts the selected profile, discovers its upstream tools, writes a JSON array, then closes the manager. `--profile` defaults to the configured default profile. | | `miftah test-profile --config ` | `--config` | `--config `, `--profile ` | Starts and initializes one profile, writes `{"ok":true,"profile":"…"}`, then closes the manager. `--profile` defaults to the configured default profile. | | `miftah logs --config ` | `--config` | `--config `, `--follow` | Reads the configured audit JSONL as normalized, redacted JSONL. `--follow` continues watching it. This command does not construct an upstream manager. | @@ -30,13 +30,17 @@ Every command also accepts `--help` and `-h`; those generated per-command help s ### `init` presets and paths -`--preset` defaults to `generic`. The named templates are `generic`, `github`, and `sentry`; an unrecognized preset name falls back to the generic template. `--output` defaults to `.miftah.json`. Miftah resolves the output path from the current working directory, creates missing parent directories, and refuses to overwrite an existing file. Quote shell paths and names containing spaces: +`--preset` defaults to `generic`. The strict catalog accepts `generic`, `github`, `sentry`, `generic-npx`, `generic-docker`, and `streamable-http`; an unrecognized preset is a usage error. `--output` defaults to `.miftah.json`. Miftah resolves the output path from the current working directory, creates missing parent directories, and refuses to overwrite an existing file. Quote shell paths and names containing spaces: ```sh miftah init "work wrapper" --preset github --output "$HOME/Miftah configs/work wrapper.json" miftah validate --config "$HOME/Miftah configs/work wrapper.json" ``` +`generic-npx` requires `--npm-package` with exact package SemVer; `generic-docker` requires a canonical digest in `--docker-image`; and `streamable-http` requires `--url` plus optional credential environment/header metadata. `--credential-env` is optional where supported. See [preset and client compatibility](presets-and-clients.md) for exact inputs, pins, provenance, and client snippets. + +`--interactive` uses a wizard only when both input and output are TTYs. EOF or Ctrl-C cancels without writing a config. It asks for variable names and safe metadata, never secret values. In noninteractive use, `init` creates only the config unless `--client` is supplied. `--client` prints JSON with absolute Node and compiled Miftah paths; it does not write a host config. Regenerate the snippet after moving or upgrading Miftah or changing the config path. + ### `doctor` `doctor` checks configuration, secret references, redaction, permissions, configured audit storage, executable availability, upstream startup, discovery, and clean shutdown where applicable. Its checks have stable `code`, `status`, `target`, `explanation`, and `remediation` fields. diff --git a/docs/config.md b/docs/config.md index 897e3b53..17b62e9e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -10,6 +10,8 @@ miftah schema > miftah.schema.json Miftah accepts config version `"1"` only and does not silently migrate config files. A future migration must be explicit and documented; an unsupported version returns `UNSUPPORTED_CONFIG_VERSION` with remediation. +For strict starter configurations, use the versioned `init` catalog rather than treating generic command examples as trusted upstream recommendations. The [preset and client compatibility matrix](presets-and-clients.md) records exact pins, required inputs, upstream provenance, and the validation boundary for every catalog entry. + The generated JSON Schema enforces static structure, including exactly one of `upstream` or `upstreams`. References to names declared in dynamic maps cannot be represented by JSON Schema alone; run `miftah validate` in addition to editor validation to verify profile, policy, routing, lock, and per-profile upstream references. With `upstreams`, each profile may override `env`, `headers`, `args`, or `cwd` under a named upstream. Miftah namespaces discovered tools as `__` so one wrapper can safely expose several providers. Tool discovery uses the active profile. Clients receive `notifications/tools/list_changed` after a profile change, restart, or upstream recovery that changes the public tool surface and must re-run `tools/list` before relying on cached tools or schemas. If a routing rule selects another profile, Miftah forwards only tools with an identical client-visible schema in both profiles; otherwise it returns `TOOL_SCHEMA_MISMATCH` instead of forwarding a call whose schema the client did not see. diff --git a/docs/examples/github.md b/docs/examples/github.md index 3e5afbd9..7527b132 100644 --- a/docs/examples/github.md +++ b/docs/examples/github.md @@ -1,45 +1,13 @@ # GitHub -Use the generic wrapper to run GitHub MCP with separate work and personal tokens: +The strict GitHub catalog output is [`examples/github.miftah.json`](../../examples/github.miftah.json). Generate an equivalent configuration with: -```json -{ - "version": "1", - "name": "github", - "defaultProfile": "work", - "upstream": { - "transport": "stdio", - "command": "docker", - "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server:v1.5.0"] - }, - "profiles": { - "work": { - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_WORK_TOKEN}" - }, - "policy": "safe-write" - }, - "personal": { - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_TOKEN}" - }, - "policy": "readonly" - } - }, - "policies": { - "readonly": { - "allowRisk": ["read"], - "denyRisk": ["write", "destructive"] - }, - "safe-write": { - "allowRisk": ["read", "write"], - "denyRisk": ["destructive"], - "requireConfirmation": ["write"] - } - } -} +```sh +miftah init github --preset github --output github.miftah.json ``` -This config contains references only. Set the variables in the shell that launches Claude Desktop. +It runs Docker STDIO with the exact `ghcr.io/github/github-mcp-server:v1.5.0` tag, `--read-only`, and `--toolsets=repos,issues,pull_requests`. The example contains only `${GITHUB_WORK_TOKEN}` and `${GITHUB_PERSONAL_TOKEN}` references; provide least-privilege GitHub provider tokens outside the JSON. -When upgrading the pinned image tag, review upstream release notes first, then run `miftah validate --config ` and test both profiles before adopting the new tag. +The tag is intentionally not presented as a digest. Before reproducible production deployment, use an authenticated promotion process and record the resolved image digest in deployment records; do not invent one. Miftah’s local policy cannot make a write-capable provider token read-only. + +See the [preset and client compatibility matrix](../presets-and-clients.md) for upstream links, digest guidance, validation boundaries, and client snippets. diff --git a/docs/examples/sentry.md b/docs/examples/sentry.md index 5a936ff1..5f02bfba 100644 --- a/docs/examples/sentry.md +++ b/docs/examples/sentry.md @@ -1,37 +1,13 @@ # Sentry -Sentry MCP can use the same wrapper pattern: +The strict Sentry catalog output is [`examples/sentry.miftah.json`](../../examples/sentry.miftah.json). Generate an equivalent configuration with: -```json -{ - "version": "1", - "name": "sentry", - "defaultProfile": "work", - "upstream": { - "transport": "stdio", - "command": "npx", - "args": ["-y", "@sentry/mcp-server"] - }, - "profiles": { - "work": { - "env": { - "SENTRY_AUTH_TOKEN": "${SENTRY_WORK_TOKEN}", - "SENTRY_ORG": "work-org" - } - }, - "client-a": { - "env": { - "SENTRY_AUTH_TOKEN": "${SENTRY_CLIENT_A_TOKEN}", - "SENTRY_ORG": "client-a" - }, - "policy": "readonly" - } - }, - "policies": { - "readonly": { - "allowRisk": ["read"], - "denyRisk": ["write", "destructive"] - } - } -} +```sh +miftah init sentry --preset sentry --output sentry.miftah.json ``` + +It runs `@sentry/mcp-server@0.36.0` with `--skills=inspect` and contains only the `${SENTRY_ACCESS_TOKEN}` reference. The upstream requires Node.js `>=20`, independently of Miftah’s own Node.js requirement. + +`--skills=inspect` filters Sentry MCP CLI skills. It does not authorize a token, and it is not a read-only flag or preset. Use least-privilege Sentry token scopes; Miftah local policy cannot reduce provider-side token permissions. + +See the [preset and client compatibility matrix](../presets-and-clients.md) for upstream sources, validation boundaries, and client snippets. diff --git a/docs/presets-and-clients.md b/docs/presets-and-clients.md new file mode 100644 index 00000000..4cd7accc --- /dev/null +++ b/docs/presets-and-clients.md @@ -0,0 +1,81 @@ +# Presets and client onboarding + +This is the compatibility source of truth for generated `miftah init` configurations and client snippets. + +- Catalog version: `1` +- Miftah package version: `0.1.1` +- Last tested / validation boundary: the catalog builds strict Miftah configuration that `validateConfig` accepts. The docs contract test checks generated configuration only; it does **not** construct a runtime, start, authenticate to, or smoke-test external providers. + +Miftah itself requires Node.js `>=20`. That does not establish an upstream server's Node requirement. + +## Catalog compatibility matrix + +| Catalog key | Exact generated command or required input | Upstream requirements and security boundary | +| --- | --- | --- | +| `generic` | `npx --yes @modelcontextprotocol/server-everything@2026.7.4 stdio` | This is the MCP reference/test server, not a production-ready provider recommendation. Its npm metadata declares no upstream Node engine floor. An optional `--credential-env ` adds only a `${ENV_NAME}` reference. | +| `github` | Docker STDIO: `docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server:v1.5.0 stdio --read-only --toolsets=repos,issues,pull_requests` | Docker is required. Supply least-privilege GitHub provider tokens through the generated environment references. The catalog intentionally pins a tag, **not** an invented OCI digest. Before a reproducible production deployment, use an authenticated registry process to promote the approved tag, inspect and record its resolved digest in deployment records, then deploy that recorded digest according to the [GitHub digest guidance](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#pull-by-digest). | +| `sentry` | `npx --yes @sentry/mcp-server@0.36.0 --skills=inspect`; generated environment reference: `SENTRY_ACCESS_TOKEN` | The upstream package requires Node.js `>=20`. Use least-privilege Sentry token scopes. `--skills=inspect` filters CLI skills; it is not provider-token authorization and is not a read-only flag or preset. Miftah local policy does not reduce provider token permissions. | +| `generic-npx` | Required: `--npm-package `; optional `--credential-env ` | Only an exact npm package SemVer is accepted. The selected external package, not Miftah, declares its own Node requirement. | +| `generic-docker` | Required: `--docker-image `; optional `--credential-env ` | Docker is required. Only a canonical image reference with a 64-hex-character `@sha256:` digest is accepted. | +| `streamable-http` | Required: `--url `; optional credential metadata: `--credential-env`, `--header-name`, and `--header-prefix` | The URL must be HTTPS and must not contain userinfo, a query, or a fragment. Credentials may appear only as `${ENV_NAME}` through validated header metadata; allowed prefixes are empty, `Bearer `, and `Sentry `. Never place a credential in a URL or URL component. | + +The three checked-in provider/reference examples are exact catalog output: `examples/generic.miftah.json`, `examples/github.miftah.json`, and `examples/sentry.miftah.json`. They contain secret references only, never credentials. + +## Provenance and last-tested sources + +Use the following upstream materials when assessing a pin or provider configuration. They describe upstream behavior; Miftah's validation boundary above does not assert that a provider was started in CI. + +### GitHub MCP + +- [GitHub MCP source](https://github.com/github/github-mcp-server) +- [GitHub IDE setup](https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp-in-your-ide/set-up-the-github-mcp-server) +- [GitHub read-only mode](https://github.com/github/github-mcp-server/blob/main/docs/server-configuration.md#read-only-mode) +- [GitHub tool configuration](https://github.com/github/github-mcp-server#tool-configuration) +- [GitHub Container registry pull by digest](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#pull-by-digest) + +### Sentry MCP and MCP Everything + +- [Sentry MCP source at `0.36.0`](https://github.com/getsentry/sentry-mcp/tree/0.36.0) +- [Sentry package metadata at `0.36.0`](https://registry.npmjs.org/@sentry/mcp-server/0.36.0) +- [Sentry `0.36.0` CLI usage](https://github.com/getsentry/sentry-mcp/blob/0.36.0/packages/mcp-server/src/cli/usage.ts) +- [MCP Everything source](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) +- [MCP Everything npm package](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) + +## First run + +`init` uses the strict catalog. The default preset is `generic`; an unknown preset is rejected rather than falling back. (The legacy library-only `presetConfig` fallback does not describe CLI behavior.) + +```sh +miftah init [name] \ + [--name ] [--preset ] [--output ] \ + [--interactive] [--client ] \ + [--credential-env ] [--npm-package ] \ + [--docker-image ] [--url ] \ + [--header-name ] [--header-prefix ] +``` + +Without `--interactive`, `init` creates only a configuration unless `--client` is supplied. With `--client`, it still creates the configuration and prints JSON snippets; it never writes a client file. Creation is exclusive and never overwrites an existing output path. + +`--interactive` is available only when both input and output are real TTYs. EOF or Ctrl-C cancels before the configuration write. The wizard asks only for a name, catalog preset, safe preset metadata (variable names, URLs, header metadata, pins), output location, and client selection. It never asks for or echoes a secret value. + +The printed snippets use absolute paths to the Node executable and compiled Miftah CLI so GUI clients do not depend on `PATH`. Regenerate them after moving or upgrading Miftah, or after changing the configuration path. Copy generated JSON as JSON; do not hand-edit the command into a shell string. + +## Client destinations and JSON shapes + +Miftah does not create any of these files. Copy only the generated JSON into the matching host configuration. + +| Client | Target and scope | Required shape | +| --- | --- | --- | +| Claude Desktop | Officially supported on macOS at `~/Library/Application Support/Claude/claude_desktop_config.json` and Windows at `%APPDATA%\Claude\claude_desktop_config.json`. Use Claude Desktop’s **Developer → Edit Config** flow as the source of truth for the installed app. | `mcpServers` with the generated command/args object. | +| Claude Code | Project `.mcp.json` is the generated-snippet target. User-scope MCP entries live in `~/.claude.json` across projects; local-scope entries also live in `~/.claude.json` but apply only to the current project. | `mcpServers` with the generated command/args object. The generated `.mcp.json` is authoritative for this workflow. | +| Cursor | Project `.cursor/mcp.json`, or global `~/.cursor/mcp.json`. | `mcpServers`, with each generated server explicitly declaring `"type": "stdio"`. | +| VS Code | Use **MCP: Open User Configuration** for user scope, or workspace `.vscode/mcp.json`. | `servers`, with each generated server explicitly declaring `"type": "stdio"`. | + +For Claude Code, the official [`claude mcp add` workflow](https://code.claude.com/docs/en/mcp) is a secondary way to manage its own configuration. Prefer the generated project `.mcp.json` here so the copied JSON remains reviewable and matches Miftah's output. + +## Client references + +- [Claude Code MCP](https://code.claude.com/docs/en/mcp) +- [Cursor MCP](https://cursor.com/docs/mcp) +- [VS Code MCP servers](https://code.visualstudio.com/docs/agent-customization/mcp-servers) +- [VS Code MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration) diff --git a/docs/security.md b/docs/security.md index 137f1351..e4687d7e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -16,6 +16,8 @@ Audit writes default to fail-closed: Miftah verifies the configured sink before Miftah cannot reduce privileges granted by a provider token. A read-only Miftah policy is a local blocklist, not a replacement for provider-side scopes. Avoid putting real credentials in examples, commits, or support logs. +`init` never asks for or echoes a secret value: its wizard accepts variable names and validated endpoint/header metadata only. Generated provider examples contain `${ENV_NAME}` references, and client snippets are printed for explicit copy rather than written into host configuration files. For exact preset pins, GitHub tag-to-digest recording guidance, Sentry skill-filtering limits, and client locations, see [preset and client compatibility](presets-and-clients.md). + The STDIO transport is the default because it avoids a network listener. Remote upstream connections carry profile-bound credentials, so Miftah requires HTTPS outside loopback development URLs (`localhost`, `127.0.0.0/8`, or `::1`). It uses Node's normal certificate validation and does not disable TLS verification; self-signed endpoints fail closed unless an operator explicitly provides a trusted local CA through Node's normal trust configuration. Do not use `NODE_TLS_REJECT_UNAUTHORIZED=0`, and do not send credentials to a cleartext non-loopback endpoint. For remote HTTP diagnostics, Miftah retains only a stable HTTP status or MCP protocol code. It deliberately omits server response bodies and remote protocol messages, because they can contain credentials or sensitive provider context. Streamable HTTP is preferred and sends DELETE on intentional local session cleanup; legacy SSE is deprecated and has no equivalent remote-session deletion. Any future HTTP server exposed by Miftah itself must bind localhost by default and require explicit authentication before non-local binding. diff --git a/examples/generic.miftah.json b/examples/generic.miftah.json index c7ad30ae..51fae255 100644 --- a/examples/generic.miftah.json +++ b/examples/generic.miftah.json @@ -1,23 +1,46 @@ { "version": "1", "name": "generic", - "description": "Generic MCP wrapped by Miftah", + "description": "generic wrapped by Miftah", "defaultProfile": "default", "upstream": { "transport": "stdio", "command": "npx", "args": [ - "-y", - "your-mcp-server" + "--yes", + "@modelcontextprotocol/server-everything@2026.7.4", + "stdio" ] }, "profiles": { "default": { + "description": "Default account", "env": {} } }, + "routing": { + "mode": "hybrid", + "fallback": "activeProfile", + "rules": [] + }, "security": { "allowPlaintextSecrets": false, - "redactSecrets": true + "redactSecrets": true, + "allowProfileSwitchingFromMcp": true, + "requireExplicitProfileForDestructive": true + }, + "process": { + "startupTimeoutMs": 30000 + }, + "audit": { + "enabled": true, + "path": "~/.local/state/miftah/audit.jsonl", + "format": "jsonl", + "includeArguments": false, + "redact": true, + "failureMode": "fail-closed" + }, + "tooling": { + "collisionStrategy": "prefix-upstream" } } diff --git a/examples/github.miftah.json b/examples/github.miftah.json index 5ad612a7..13a3e091 100644 --- a/examples/github.miftah.json +++ b/examples/github.miftah.json @@ -12,7 +12,10 @@ "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:v1.5.0" + "ghcr.io/github/github-mcp-server:v1.5.0", + "stdio", + "--read-only", + "--toolsets=repos,issues,pull_requests" ] }, "profiles": { @@ -21,7 +24,7 @@ "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_WORK_TOKEN}" }, - "policy": "safe-write" + "policy": "readonly" }, "personal": { "description": "Personal GitHub account", @@ -31,19 +34,6 @@ "policy": "readonly" } }, - "routing": { - "mode": "hybrid", - "fallback": "activeProfile", - "rules": [ - { - "name": "work-repository", - "when": { - "args.repo": "my-org/work" - }, - "profile": "work" - } - ] - }, "policies": { "readonly": { "allowRisk": [ @@ -53,20 +43,13 @@ "write", "destructive" ] - }, - "safe-write": { - "allowRisk": [ - "read", - "write" - ], - "denyRisk": [ - "destructive" - ], - "requireConfirmation": [ - "write" - ] } }, + "routing": { + "mode": "hybrid", + "fallback": "activeProfile", + "rules": [] + }, "security": { "allowPlaintextSecrets": false, "redactSecrets": true, @@ -78,7 +61,7 @@ }, "audit": { "enabled": true, - "path": "~/.local/state/miftah/github.audit.jsonl", + "path": "~/.local/state/miftah/audit.jsonl", "format": "jsonl", "includeArguments": false, "redact": true, diff --git a/examples/sentry.miftah.json b/examples/sentry.miftah.json index 305c4072..405afdf6 100644 --- a/examples/sentry.miftah.json +++ b/examples/sentry.miftah.json @@ -1,27 +1,22 @@ { "version": "1", "name": "sentry", - "description": "Sentry MCP wrapped by Miftah", - "defaultProfile": "work", + "description": "sentry wrapped by Miftah", + "defaultProfile": "default", "upstream": { "transport": "stdio", "command": "npx", "args": [ - "-y", - "@sentry/mcp-server" + "--yes", + "@sentry/mcp-server@0.36.0", + "--skills=inspect" ] }, "profiles": { - "work": { + "default": { + "description": "Default account", "env": { - "SENTRY_AUTH_TOKEN": "${SENTRY_WORK_TOKEN}", - "SENTRY_ORG": "work-org" - } - }, - "client-a": { - "env": { - "SENTRY_AUTH_TOKEN": "${SENTRY_CLIENT_A_TOKEN}", - "SENTRY_ORG": "client-a" + "SENTRY_ACCESS_TOKEN": "${SENTRY_ACCESS_TOKEN}" }, "policy": "readonly" } @@ -37,9 +32,29 @@ ] } }, + "routing": { + "mode": "hybrid", + "fallback": "activeProfile", + "rules": [] + }, "security": { "allowPlaintextSecrets": false, "redactSecrets": true, - "allowProfileSwitchingFromMcp": true + "allowProfileSwitchingFromMcp": true, + "requireExplicitProfileForDestructive": true + }, + "process": { + "startupTimeoutMs": 30000 + }, + "audit": { + "enabled": true, + "path": "~/.local/state/miftah/audit.jsonl", + "format": "jsonl", + "includeArguments": false, + "redact": true, + "failureMode": "fail-closed" + }, + "tooling": { + "collisionStrategy": "prefix-upstream" } } diff --git a/src/cli/client-snippets.ts b/src/cli/client-snippets.ts new file mode 100644 index 00000000..76fcab59 --- /dev/null +++ b/src/cli/client-snippets.ts @@ -0,0 +1,135 @@ +import { posix, win32 } from "node:path"; + +export const CLIENT_NAMES = Object.freeze(["claude-desktop", "claude-code", "cursor", "vscode"] as const); + +export type ClientName = (typeof CLIENT_NAMES)[number]; +export type ClientSelection = ClientName | "all"; + +export interface ClientLauncher { + command: string; + args: readonly string[]; +} + +export interface ClientSnippetInput { + serverName: string; + configPath: string; + launcher: ClientLauncher; +} + +export interface ClientSnippet { + client: ClientName; + target: { + label: string; + }; + json: string; +} + +export class ClientSnippetError extends Error { + constructor(message: string) { + super(message); + this.name = "ClientSnippetError"; + } +} + +const targetLabels: Record = { + "claude-desktop": "Claude Desktop settings config", + "claude-code": "Claude Code project .mcp.json", + cursor: "Cursor .cursor/mcp.json", + vscode: "VS Code .vscode/mcp.json" +}; + +function inputError(message: string): never { + throw new ClientSnippetError(message); +} + +function isClientName(value: unknown): value is ClientName { + return typeof value === "string" && (CLIENT_NAMES as readonly string[]).includes(value); +} + +function isAbsolutePath(path: string): boolean { + return posix.isAbsolute(path) || win32.isAbsolute(path); +} + +function validateInput(input: ClientSnippetInput): void { + if (input === null || typeof input !== "object") { + inputError("A snippet input object is required."); + } + if (typeof input.serverName !== "string" || input.serverName.length === 0) { + inputError("A non-empty server name is required."); + } + if (typeof input.configPath !== "string" || input.configPath.includes("\0")) { + inputError("Config path must not contain a NUL character."); + } + if (!isAbsolutePath(input.configPath)) { + inputError("Config path must be absolute."); + } + if (input.launcher === null || typeof input.launcher !== "object") { + inputError("A launcher object is required."); + } + if (typeof input.launcher?.command !== "string" || input.launcher.command.length === 0) { + inputError("A non-empty launcher command is required."); + } + if (input.launcher.command.includes("\0")) { + inputError("Launcher command must not contain a NUL character."); + } + if (!isAbsolutePath(input.launcher.command)) { + inputError("Launcher command must be absolute."); + } + if (!Array.isArray(input.launcher.args)) { + inputError("Every launcher argument must be a non-empty string."); + } + const launcherArguments = Array.from(input.launcher.args); + if (launcherArguments.length === 0 || launcherArguments.some((argument) => typeof argument !== "string" || argument.length === 0)) { + inputError("Every launcher argument must be a non-empty string."); + } + if (launcherArguments.some((argument) => argument.includes("\0"))) { + inputError("Launcher arguments must not contain a NUL character."); + } + const entrypoint = launcherArguments[0]; + if (entrypoint === undefined || !isAbsolutePath(entrypoint)) { + inputError("Miftah CLI entrypoint must be absolute."); + } + if (launcherArguments.some((argument) => argument === "--config" || argument.startsWith("--config="))) { + inputError("Launcher arguments must not include '--config'; the snippet supplies it."); + } +} + +function renderedServer(input: ClientSnippetInput): { command: string; args: string[] } { + return { + command: input.launcher.command, + args: [...input.launcher.args, "--config", input.configPath] + }; +} + +function renderConfiguration(client: ClientName, input: ClientSnippetInput): object { + const server = renderedServer(input); + + switch (client) { + case "claude-desktop": + case "claude-code": + return { mcpServers: { [input.serverName]: server } }; + case "cursor": + return { mcpServers: { [input.serverName]: { type: "stdio", ...server } } }; + case "vscode": + return { servers: { [input.serverName]: { type: "stdio", ...server } } }; + } +} + +export function renderClientSnippet(client: ClientName, input: ClientSnippetInput): ClientSnippet { + if (!isClientName(client)) { + inputError("Unsupported client."); + } + validateInput(input); + return { + client, + target: { label: targetLabels[client] }, + json: JSON.stringify(renderConfiguration(client, input), undefined, 2) + }; +} + +export function renderClientSnippets(selection: ClientSelection, input: ClientSnippetInput): ClientSnippet[] { + if (selection === "all") { + return CLIENT_NAMES.map((client) => renderClientSnippet(client, input)); + } + return [renderClientSnippet(selection, input)]; +} diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 00000000..d0fb21ca --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,293 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import type { Readable, Writable } from "node:stream"; +import { validateConfig } from "../config/validate-config.js"; +import { buildPresetConfig, PresetCatalogError } from "../config/presets.js"; +import type { PresetBuildOptions } from "../config/presets.js"; +import type { MiftahConfig } from "../config/types.js"; +import { + CLIENT_NAMES, + ClientSnippetError, + renderClientSnippets +} from "./client-snippets.js"; +import type { ClientLauncher, ClientSelection, ClientSnippet } from "./client-snippets.js"; +import { CliUsageError } from "./parse.js"; +import type { CliOptions } from "./parse.js"; + +export type InitCommandOptions = Pick< + CliOptions, + | "name" + | "preset" + | "output" + | "interactive" + | "client" + | "credentialEnv" + | "npmPackage" + | "dockerImage" + | "url" + | "headerName" + | "headerPrefix" +>; + +export interface InitCommandContext { + readonly input: Readable & { readonly isTTY?: boolean }; + readonly output: Writable & { readonly isTTY?: boolean }; + readonly cwd: string; + readonly launcher: ClientLauncher; +} + +interface InitValues extends PresetBuildOptions { + readonly name: string; + readonly preset: string; + readonly output: string; + readonly client?: string; +} + +interface InitPlan { + readonly output: string; + readonly config: MiftahConfig; + readonly snippets: readonly ClientSnippet[]; +} + +interface Cancellation { + readonly promise: Promise; + dispose(): void; +} + +type PromptInterface = ReturnType; + +function usageError(message: string): never { + throw new CliUsageError(message); +} + +function isTty(context: InitCommandContext): boolean { + return context.input.isTTY === true && context.output.isTTY === true; +} + +function createCancellation(line: PromptInterface): Cancellation { + let rejectCancellation: (reason: CliUsageError) => void = () => undefined; + let cancelled = false; + const promise = new Promise((_resolve, reject) => { + rejectCancellation = reject; + }); + void promise.catch(() => undefined); + const cancel = (message: string) => { + if (cancelled) return; + cancelled = true; + rejectCancellation(new CliUsageError(message)); + }; + const onClose = () => cancel("Interactive init was cancelled because input closed."); + const onSigint = () => cancel("Interactive init was cancelled."); + + line.once("close", onClose); + line.once("SIGINT", onSigint); + + return { + promise, + dispose() { + cancelled = true; + line.removeListener("close", onClose); + line.removeListener("SIGINT", onSigint); + } + }; +} + +async function prompt( + line: PromptInterface, + cancellation: Cancellation, + label: string, + defaultValue?: string, + preserveTrailingWhitespace = false +): Promise { + const suffix = defaultValue === undefined ? ": " : ` [${defaultValue}]: `; + const answer = await Promise.race([line.question(`${label}${suffix}`), cancellation.promise]); + const value = preserveTrailingWhitespace ? answer.trimStart() : answer.trim(); + return value === "" ? defaultValue : value; +} + +async function collectStreamableOptions( + line: PromptInterface, + cancellation: Cancellation, + options: InitCommandOptions +): Promise { + const url = options.url ?? (await prompt(line, cancellation, "Streamable HTTPS URL")); + const credentialEnv = options.credentialEnv ?? (await prompt(line, cancellation, "Credential environment variable name (optional)")); + if (credentialEnv === undefined) { + return { + url, + headerName: options.headerName, + headerPrefix: options.headerPrefix + }; + } + + return { + url, + credentialEnv, + headerName: options.headerName ?? (await prompt(line, cancellation, "Credential header name")), + headerPrefix: options.headerPrefix ?? (await prompt( + line, + cancellation, + "Credential header prefix (optional)", + undefined, + true + )) + }; +} + +async function collectPresetOptions( + line: PromptInterface, + cancellation: Cancellation, + preset: string, + options: InitCommandOptions +): Promise { + switch (preset) { + case "generic-npx": + return { + credentialEnv: options.credentialEnv, + npmPackage: options.npmPackage ?? (await prompt(line, cancellation, "NPM package (exact package@semver)")) + }; + case "generic-docker": + return { + credentialEnv: options.credentialEnv, + dockerImage: options.dockerImage ?? (await prompt(line, cancellation, "Docker image (digest-pinned)")) + }; + case "streamable-http": + return collectStreamableOptions(line, cancellation, options); + default: + return { + credentialEnv: options.credentialEnv, + npmPackage: options.npmPackage, + dockerImage: options.dockerImage, + url: options.url, + headerName: options.headerName, + headerPrefix: options.headerPrefix + }; + } +} + +async function collectInteractiveValues(options: InitCommandOptions, context: InitCommandContext): Promise { + if (!isTty(context)) { + usageError("Option '--interactive' requires TTY input and output."); + } + + const line = createInterface({ input: context.input, output: context.output, terminal: true }); + const cancellation = createCancellation(line); + try { + const name = options.name ?? (await prompt(line, cancellation, "Name", "miftah-wrapper")); + const preset = options.preset ?? (await prompt(line, cancellation, "Catalog preset", "generic")); + const presetOptions = await collectPresetOptions(line, cancellation, preset ?? "generic", options); + const output = options.output ?? (await prompt(line, cancellation, "Output location", `${name}.miftah.json`)); + const client = options.client ?? (await prompt( + line, + cancellation, + "Client (claude-desktop, claude-code, cursor, vscode, all; blank for config only)" + )); + + if (name === undefined || preset === undefined || output === undefined) { + usageError("Interactive init requires a name, preset, and output location."); + } + return { name, preset, output, client, ...presetOptions }; + } catch (error) { + if (error instanceof CliUsageError) throw error; + throw new CliUsageError("Interactive init was cancelled."); + } finally { + cancellation.dispose(); + line.close(); + } +} + +function nonInteractiveValues(options: InitCommandOptions): InitValues { + const name = options.name ?? "miftah-wrapper"; + return { + name, + preset: options.preset ?? "generic", + output: options.output ?? `${name}.miftah.json`, + client: options.client, + credentialEnv: options.credentialEnv, + npmPackage: options.npmPackage, + dockerImage: options.dockerImage, + url: options.url, + headerName: options.headerName, + headerPrefix: options.headerPrefix + }; +} + +function isClientSelection(value: string): value is ClientSelection { + return value === "all" || (CLIENT_NAMES as readonly string[]).includes(value); +} + +function resolveOutputPath(output: string, cwd: string): string { + if (output.includes("\0")) usageError("Output path must not contain a NUL character."); + return resolve(cwd, output); +} + +function isExistingOutputError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST"; +} + +function buildInitPlan(values: InitValues, context: InitCommandContext): InitPlan { + const output = resolveOutputPath(values.output, context.cwd); + if (values.client !== undefined && !isClientSelection(values.client)) { + usageError(`Unsupported client '${values.client}'.`); + } + + let config: MiftahConfig; + try { + config = buildPresetConfig(values.name, values.preset, { + credentialEnv: values.credentialEnv, + npmPackage: values.npmPackage, + dockerImage: values.dockerImage, + url: values.url, + headerName: values.headerName, + headerPrefix: values.headerPrefix + }); + validateConfig(config); + } catch (error) { + if (error instanceof PresetCatalogError) throw new CliUsageError(error.message); + if (error instanceof Error) throw new CliUsageError(`Invalid init configuration: ${error.message}`); + throw error; + } + + let snippets: ClientSnippet[] = []; + if (values.client !== undefined) { + try { + snippets = renderClientSnippets(values.client, { + serverName: config.name, + configPath: output, + launcher: context.launcher + }); + } catch (error) { + if (error instanceof ClientSnippetError) throw new CliUsageError(error.message); + throw error; + } + } + + return { output, config, snippets }; +} + +function writeSnippets(output: Writable, snippets: readonly ClientSnippet[]): void { + for (const snippet of snippets) { + output.write(`${snippet.target.label} (${snippet.client}):\n${snippet.json}\n`); + } +} + +/** Creates a strict catalog config and optionally prints copy-paste client snippets. */ +export async function runInitCommand(options: InitCommandOptions, context: InitCommandContext): Promise { + const values = options.interactive === true + ? await collectInteractiveValues(options, context) + : nonInteractiveValues(options); + const plan = buildInitPlan(values, context); + + await mkdir(dirname(plan.output), { recursive: true }); + try { + await writeFile(plan.output, `${JSON.stringify(plan.config, null, 2)}\n`, { flag: "wx" }); + } catch (error) { + if (isExistingOutputError(error)) { + usageError(`Output '${plan.output}' already exists.`); + } + throw error; + } + context.output.write(`Created ${plan.output}\n`); + writeSnippets(context.output, plan.snippets); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 60d52674..8b3aa7ab 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -1,7 +1,5 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { loadConfig } from "../config/load-config.js"; -import { presetConfig } from "../config/presets.js"; import { generateConfigSchema } from "../config/generate-json-schema.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { redactSecrets } from "../secrets/redact.js"; @@ -13,6 +11,7 @@ import { formatDoctorReport } from "./doctor-report.js"; import { CliUsageError, parseCli, renderCommandHelp, renderRootHelp } from "./parse.js"; import { exitCodeForError } from "./exit-codes.js"; import { runLogsCommand } from "./logs.js"; +import { runInitCommand } from "./init.js"; async function serve(configPath: string): Promise { const runtime = await createMiftahRuntime(configPath); @@ -41,12 +40,15 @@ async function main(argv = process.argv.slice(2)): Promise { return; } if (command === "init") { - const name = args.name ?? "miftah-wrapper"; - const output = resolve(args.output ?? `${name}.miftah.json`); - const config = presetConfig(name, args.preset ?? "generic"); - await mkdir(dirname(output), { recursive: true }); - await writeFile(output, `${JSON.stringify(config, null, 2)}\n`, { flag: "wx" }); - process.stdout.write(`Created ${output}\n`); + await runInitCommand(args, { + input: process.stdin, + output: process.stdout, + cwd: process.cwd(), + launcher: { + command: process.execPath, + args: [fileURLToPath(import.meta.url), "serve"] + } + }); return; } if (!args.config) { diff --git a/src/cli/parse.ts b/src/cli/parse.ts index 518fc0ac..a8d7fe0c 100644 --- a/src/cli/parse.ts +++ b/src/cli/parse.ts @@ -1,5 +1,17 @@ -type ValueOptionName = "config" | "profile" | "output" | "preset" | "name"; -type BooleanOptionName = "follow" | "json"; +type ValueOptionName = + | "config" + | "profile" + | "output" + | "preset" + | "name" + | "client" + | "credentialEnv" + | "npmPackage" + | "dockerImage" + | "url" + | "headerName" + | "headerPrefix"; +type BooleanOptionName = "follow" | "json" | "interactive"; type CliOptionName = ValueOptionName | BooleanOptionName; export interface CliOptions { @@ -8,8 +20,16 @@ export interface CliOptions { readonly output?: string; readonly preset?: string; readonly name?: string; + readonly client?: string; + readonly credentialEnv?: string; + readonly npmPackage?: string; + readonly dockerImage?: string; + readonly url?: string; + readonly headerName?: string; + readonly headerPrefix?: string; readonly follow?: true; readonly json?: true; + readonly interactive?: true; } interface CliCommandMetadata { @@ -38,7 +58,19 @@ export const CLI_COMMANDS = { init: { description: "Create a starter Miftah configuration.", arguments: "[name]", - options: ["name", "preset", "output"] + options: [ + "name", + "preset", + "output", + "interactive", + "client", + "credentialEnv", + "npmPackage", + "dockerImage", + "url", + "headerName", + "headerPrefix" + ] }, "list-tools": { description: "List tools available for a profile.", @@ -112,6 +144,48 @@ const OPTION_DEFINITIONS: Record = { usage: "--name ", description: "Configuration name." }, + client: { + name: "client", + takesValue: true, + usage: "--client ", + description: "Print client configuration snippets." + }, + credentialEnv: { + name: "credentialEnv", + takesValue: true, + usage: "--credential-env ", + description: "Environment variable name for a credential reference." + }, + npmPackage: { + name: "npmPackage", + takesValue: true, + usage: "--npm-package ", + description: "Exact npm package spec for the generic-npx preset." + }, + dockerImage: { + name: "dockerImage", + takesValue: true, + usage: "--docker-image ", + description: "Digest-pinned Docker image for the generic-docker preset." + }, + url: { + name: "url", + takesValue: true, + usage: "--url ", + description: "HTTPS URL for the streamable-http preset." + }, + headerName: { + name: "headerName", + takesValue: true, + usage: "--header-name ", + description: "Credential header name for the streamable-http preset." + }, + headerPrefix: { + name: "headerPrefix", + takesValue: true, + usage: "--header-prefix ", + description: "Credential header prefix for the streamable-http preset." + }, follow: { name: "follow", takesValue: false, @@ -123,6 +197,12 @@ const OPTION_DEFINITIONS: Record = { takesValue: false, usage: "--json", description: "Use machine-readable output when supported." + }, + interactive: { + name: "interactive", + takesValue: false, + usage: "--interactive", + description: "Collect init settings through a TTY wizard." } }; @@ -132,8 +212,16 @@ const FLAG_DEFINITIONS: Record "--output": OPTION_DEFINITIONS.output, "--preset": OPTION_DEFINITIONS.preset, "--name": OPTION_DEFINITIONS.name, + "--client": OPTION_DEFINITIONS.client, + "--credential-env": OPTION_DEFINITIONS.credentialEnv, + "--npm-package": OPTION_DEFINITIONS.npmPackage, + "--docker-image": OPTION_DEFINITIONS.dockerImage, + "--url": OPTION_DEFINITIONS.url, + "--header-name": OPTION_DEFINITIONS.headerName, + "--header-prefix": OPTION_DEFINITIONS.headerPrefix, "--follow": OPTION_DEFINITIONS.follow, "--json": OPTION_DEFINITIONS.json, + "--interactive": OPTION_DEFINITIONS.interactive, "--help": "help", "-h": "help", "--version": "version", @@ -170,7 +258,7 @@ function allowedOptions(command: CliCommand): ReadonlySet { } function formatOption(definition: CliOptionDefinition): string { - return ` ${definition.usage.padEnd(20)}${definition.description}`; + return ` ${definition.usage.padEnd(Math.max(20, definition.usage.length + 2))}${definition.description}`; } function commandUsage(command: CliCommand): string { diff --git a/src/config/presets.ts b/src/config/presets.ts index 7ee8c083..19b9ba8c 100644 --- a/src/config/presets.ts +++ b/src/config/presets.ts @@ -1,10 +1,61 @@ -import type { MiftahConfig, UpstreamConfig } from "./types.js"; +import type { MiftahConfig, ProfileConfig, UpstreamConfig } from "./types.js"; /** Pinned GitHub MCP server image used by the GitHub preset. */ export const GITHUB_MCP_IMAGE = "ghcr.io/github/github-mcp-server:v1.5.0"; +const GENERIC_MCP_PACKAGE = "@modelcontextprotocol/server-everything@2026.7.4"; +const SENTRY_MCP_PACKAGE = "@sentry/mcp-server@0.36.0"; +const environmentVariableName = /^[A-Za-z_][A-Za-z0-9_]*$/u; +const headerName = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u; +const allowedHeaderPrefixes = new Set(["Bearer ", "Sentry "]); +const npmPackageWithVersion = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*@(.+)$/u; +const exactSemver = + /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|[0-9A-Za-z]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:(?:0|[1-9][0-9]*)|[0-9A-Za-z]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; +const canonicalDigestImage = + /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?(?:[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\/)*[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?::[A-Za-z0-9_][A-Za-z0-9_.-]*)?@sha256:[A-Fa-f0-9]{64}$/u; + type SharedDefaults = Pick; -type PresetBuilder = (name: string) => MiftahConfig; + +export interface PresetBuildOptions { + credentialEnv?: string; + npmPackage?: string; + dockerImage?: string; + url?: string; + headerName?: string; + headerPrefix?: string; +} + +type PresetOptionRequirement = "required" | "optional" | "optional-with-credentialEnv" | "provider-managed"; +type PresetRequirements = Readonly>>; + +/** An ordinary input error that CLI code can translate to its own usage error. */ +export class PresetCatalogError extends Error { + constructor(message: string) { + super(message); + this.name = "PresetCatalogError"; + } +} + +function catalogError(message: string): never { + throw new PresetCatalogError(message); +} + +function environmentReference(name: unknown): string { + if (typeof name !== "string" || !environmentVariableName.test(name)) { + catalogError(`Invalid credential environment variable name '${name}'.`); + } + return `\${${name}}`; +} + +function validateCredentialEnv(credentialEnv: unknown): void { + if (credentialEnv === undefined) return; + if (typeof credentialEnv !== "string") { + catalogError("Preset option 'credentialEnv' must be a string when supplied."); + } + if (!environmentVariableName.test(credentialEnv)) { + catalogError(`Invalid credential environment variable name '${credentialEnv}'.`); + } +} /** Builds fresh shared runtime defaults so generated configs never share mutable state. */ function buildSharedDefaults(): SharedDefaults { @@ -29,8 +80,21 @@ function buildSharedDefaults(): SharedDefaults { }; } -/** Builds the common single-profile shape used by package-based presets. */ -function buildStandardPreset(name: string, upstream: UpstreamConfig): MiftahConfig { +function buildReadonlyPolicies(): NonNullable { + return { + readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] } + }; +} + +function buildCredentialProfile(credentialEnv?: string): ProfileConfig { + return { + description: "Default account", + env: credentialEnv === undefined ? {} : { [credentialEnv]: environmentReference(credentialEnv) } + }; +} + +/** Builds the common single-profile shape used by generic presets. */ +function buildStandardPreset(name: string, upstream: UpstreamConfig, credentialEnv?: string): MiftahConfig { return { version: "1", name, @@ -38,32 +102,40 @@ function buildStandardPreset(name: string, upstream: UpstreamConfig): MiftahConf defaultProfile: "default", upstream, profiles: { - default: { - description: "Default account", - env: {} - } + default: buildCredentialProfile(credentialEnv) }, - policies: undefined, ...buildSharedDefaults() }; } -/** Builds the generic starter preset for an unspecified MCP package. */ -function buildGenericPreset(name: string): MiftahConfig { - return buildStandardPreset(name, { - transport: "stdio", - command: "npx", - args: ["-y", "your-mcp-server"] - }); +/** Builds the generic reference MCP server preset. */ +function buildGenericPreset(name: string, options: PresetBuildOptions): MiftahConfig { + // npm registry metadata for this package does not declare an upstream Node engine floor. + return buildStandardPreset( + name, + { + transport: "stdio", + command: "npx", + args: ["--yes", GENERIC_MCP_PACKAGE, "stdio"] + }, + options.credentialEnv + ); } /** Builds the Sentry MCP package preset. */ function buildSentryPreset(name: string): MiftahConfig { - return buildStandardPreset(name, { + const config = buildStandardPreset(name, { transport: "stdio", command: "npx", - args: ["-y", "@sentry/mcp-server"] + args: ["--yes", SENTRY_MCP_PACKAGE, "--skills=inspect"] }); + config.profiles.default = { + description: "Default account", + env: { SENTRY_ACCESS_TOKEN: "${SENTRY_ACCESS_TOKEN}" }, + policy: "readonly" + }; + config.policies = buildReadonlyPolicies(); + return config; } /** Builds the multi-profile GitHub preset and its referenced policies. */ @@ -76,13 +148,23 @@ function buildGithubPreset(name: string): MiftahConfig { upstream: { transport: "stdio", command: "docker", - args: ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", GITHUB_MCP_IMAGE] + args: [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + GITHUB_MCP_IMAGE, + "stdio", + "--read-only", + "--toolsets=repos,issues,pull_requests" + ] }, profiles: { work: { description: "Work GitHub account", env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_WORK_TOKEN}" }, - policy: "safe-write" + policy: "readonly" }, personal: { description: "Personal GitHub account", @@ -90,21 +172,199 @@ function buildGithubPreset(name: string): MiftahConfig { policy: "readonly" } }, - policies: { - readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] }, - "safe-write": { allowRisk: ["read", "write"], denyRisk: ["destructive"], requireConfirmation: ["write"] } - }, + policies: buildReadonlyPolicies(), ...buildSharedDefaults() }; } -const presetBuilders = new Map([ - ["generic", buildGenericPreset], - ["sentry", buildSentryPreset], - ["github", buildGithubPreset] -]); +function requireExactNpmPackage(value: unknown): string { + if (typeof value !== "string") { + catalogError("Preset option 'npmPackage' must be a string."); + } + const version = npmPackageWithVersion.exec(value)?.[1]; + if (!value || version === undefined || !exactSemver.test(version)) { + catalogError("Preset 'generic-npx' requires an exact npm package semver spec such as '@scope/server@1.2.3'."); + } + return value; +} + +function buildGenericNpxPreset(name: string, options: PresetBuildOptions): MiftahConfig { + return buildStandardPreset( + name, + { + transport: "stdio", + command: "npx", + args: ["--yes", requireExactNpmPackage(options.npmPackage)] + }, + options.credentialEnv + ); +} + +function requireCanonicalDigestImage(value: unknown): string { + if (typeof value !== "string") { + catalogError("Preset option 'dockerImage' must be a string."); + } + if (!value || !canonicalDigestImage.test(value)) { + catalogError( + "Preset 'generic-docker' requires a canonical image reference with an @sha256: digest containing 64 hexadecimal characters." + ); + } + return value; +} + +function buildGenericDockerPreset(name: string, options: PresetBuildOptions): MiftahConfig { + const credentialArgs = options.credentialEnv === undefined ? [] : ["-e", options.credentialEnv]; + return buildStandardPreset( + name, + { + transport: "stdio", + command: "docker", + args: ["run", "-i", "--rm", ...credentialArgs, requireCanonicalDigestImage(options.dockerImage), "stdio"] + }, + options.credentialEnv + ); +} + +function requireHttpsUrl(value: unknown): string { + if (value === undefined) { + catalogError("Preset 'streamable-http' requires an HTTPS URL."); + } + if (typeof value !== "string") { + catalogError("Preset option 'url' must be a string."); + } + if (!value) { + catalogError("Preset 'streamable-http' requires an HTTPS URL."); + } + + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) { + catalogError("Preset 'streamable-http' requires an HTTPS URL without userinfo, query, or fragment."); + } + } catch (error) { + if (error instanceof PresetCatalogError) throw error; + catalogError("Preset 'streamable-http' requires a valid HTTPS URL without userinfo, query, or fragment."); + } + return value; +} + +function buildCredentialHeaders(options: PresetBuildOptions): Record | undefined { + const hasHeaderName = options.headerName !== undefined; + const hasHeaderPrefix = options.headerPrefix !== undefined; + if (!hasHeaderName && !hasHeaderPrefix && options.credentialEnv === undefined) return undefined; + + if (options.credentialEnv === undefined || options.headerName === undefined) { + catalogError("Streamable HTTP credentials require credentialEnv and headerName together."); + } + if (typeof options.headerName !== "string") { + catalogError("Preset option 'headerName' must be a string when supplied."); + } + if (!headerName.test(options.headerName)) { + catalogError(`Invalid HTTP header name '${options.headerName}'.`); + } + const headerPrefix = options.headerPrefix === undefined ? "" : options.headerPrefix; + if (typeof headerPrefix !== "string") { + catalogError("Preset option 'headerPrefix' must be a string when supplied."); + } + if (headerPrefix !== "" && !allowedHeaderPrefixes.has(headerPrefix)) { + catalogError("HTTP header prefix must be empty, 'Bearer ', or 'Sentry '."); + } + + return { [options.headerName]: `${headerPrefix}${environmentReference(options.credentialEnv)}` }; +} + +function buildStreamableHttpPreset(name: string, options: PresetBuildOptions): MiftahConfig { + const headers = buildCredentialHeaders(options); + return buildStandardPreset( + name, + { + transport: "streamable-http", + url: requireHttpsUrl(options.url), + ...(headers ? { headers } : {}) + }, + undefined + ); +} + +/** + * Versioned internal catalog for strict preset creation. Requirements define which + * caller-supplied inputs each builder may receive. + */ +export const PRESET_CATALOG = { + version: "1", + presets: { + generic: { + requirements: { credentialEnv: "optional" }, + build: buildGenericPreset + }, + github: { + requirements: { credentialEnv: "provider-managed" }, + build: buildGithubPreset + }, + sentry: { + requirements: { credentialEnv: "provider-managed" }, + build: buildSentryPreset + }, + "generic-npx": { + requirements: { npmPackage: "required", credentialEnv: "optional" }, + build: buildGenericNpxPreset + }, + "generic-docker": { + requirements: { dockerImage: "required", credentialEnv: "optional" }, + build: buildGenericDockerPreset + }, + "streamable-http": { + requirements: { + url: "required", + credentialEnv: "optional", + headerName: "optional-with-credentialEnv", + headerPrefix: "optional-with-credentialEnv" + }, + build: buildStreamableHttpPreset + } + } +} as const satisfies { + readonly version: string; + readonly presets: Record< + string, + { + readonly requirements: PresetRequirements; + readonly build: (name: string, options: PresetBuildOptions) => MiftahConfig; + } + >; +}; + +export type PresetCatalogName = keyof typeof PRESET_CATALOG.presets; + +function validatePresetOptions( + preset: string, + requirements: PresetRequirements, + options: PresetBuildOptions +): void { + for (const [option, value] of Object.entries(options)) { + if (value === undefined) continue; + const requirement = requirements[option]; + if (requirement === undefined || requirement === "provider-managed") { + catalogError(`Preset '${preset}' does not support option '${option}'.`); + } + } + validateCredentialEnv(options.credentialEnv); +} + +/** Builds a catalog preset strictly, rejecting unknown names instead of falling back. */ +export function buildPresetConfig(name: string, preset: string, options: PresetBuildOptions = {}): MiftahConfig { + if (!Object.hasOwn(PRESET_CATALOG.presets, preset)) { + catalogError(`Unknown preset '${preset}'. Supported presets: ${Object.keys(PRESET_CATALOG.presets).join(", ")}.`); + } + const definition = PRESET_CATALOG.presets[preset as PresetCatalogName]; + validatePresetOptions(preset, definition.requirements, options); + return definition.build(name, options); +} -/** Builds a named configuration preset, falling back to the generic template. */ +/** Builds a named legacy configuration preset, retaining its generic fallback behavior. */ export function presetConfig(name: string, preset = "generic"): MiftahConfig { - return (presetBuilders.get(preset) ?? buildGenericPreset)(name); + if (preset === "generic" || preset === "github" || preset === "sentry") { + return buildPresetConfig(name, preset); + } + return buildPresetConfig(name, "generic"); } diff --git a/tests/cli-parse.test.ts b/tests/cli-parse.test.ts index d150fb7f..3b1d121c 100644 --- a/tests/cli-parse.test.ts +++ b/tests/cli-parse.test.ts @@ -60,6 +60,57 @@ describe("CLI parser", () => { expectUsageError(["init", "example", "--name", "named-example"]); }); + it("parses all init-only onboarding options before or after init, including equals values", () => { + expect( + parseCli([ + "--interactive", + "--client=all", + "--credential-env", + "MCP_TOKEN", + "init", + "--name=remote", + "--preset=streamable-http", + "--url=https://mcp.example.com/v1", + "--header-name=Authorization", + "--header-prefix=Bearer ", + "--output=remote.json" + ]) + ).toEqual({ + kind: "run", + command: "init", + options: { + interactive: true, + client: "all", + credentialEnv: "MCP_TOKEN", + name: "remote", + preset: "streamable-http", + url: "https://mcp.example.com/v1", + headerName: "Authorization", + headerPrefix: "Bearer ", + output: "remote.json" + } + }); + expect(parseCli(["--npm-package", "@scope/server@1.2.3", "init", "--preset", "generic-npx"])).toEqual({ + kind: "run", + command: "init", + options: { npmPackage: "@scope/server@1.2.3", preset: "generic-npx" } + }); + expect( + parseCli([ + "init", + "--preset=generic-docker", + "--docker-image=ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ]) + ).toEqual({ + kind: "run", + command: "init", + options: { + preset: "generic-docker", + dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + }); + }); + it("renders root and per-command help from every documented command", () => { const rootHelp = renderRootHelp(); @@ -72,6 +123,16 @@ describe("CLI parser", () => { expect(renderCommandHelp("doctor")).toContain("--config "); expect(renderCommandHelp("doctor")).toContain("--json"); expect(renderCommandHelp("logs")).toContain("Continue reading audit logs as they are appended or rotated."); + const initHelp = renderCommandHelp("init"); + expect(initHelp).toContain("--interactive"); + expect(initHelp).toContain("--client "); + expect(initHelp).toContain("--client Print client configuration snippets."); + expect(initHelp).toContain("--credential-env "); + expect(initHelp).toContain("--npm-package "); + expect(initHelp).toContain("--docker-image "); + expect(initHelp).toContain("--url "); + expect(initHelp).toContain("--header-name "); + expect(initHelp).toContain("--header-prefix "); }); it("returns help without requiring a command and recognizes help around commands", () => { @@ -92,6 +153,33 @@ describe("CLI parser", () => { expectUsageError(["validate", "--config", "--json"]); expectUsageError(["validate", "--config", "one.json", "--config=two.json"]); expectUsageError(["schema", "--config", "wrapper.json"]); + expectUsageError(["validate", "--interactive"]); + expectUsageError(["doctor", "--client", "cursor"]); + expectUsageError(["serve", "--credential-env", "MCP_TOKEN"]); + expectUsageError(["logs", "--npm-package", "@scope/server@1.2.3"]); + expectUsageError(["schema", "--docker-image", "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]); + expectUsageError(["version", "--url", "https://mcp.example.com"]); + expectUsageError(["validate", "--header-name", "Authorization"]); + expectUsageError(["doctor", "--header-prefix", "Bearer "]); + expectUsageError(["init", "--client"]); + expectUsageError(["init", "--credential-env"]); + expectUsageError(["init", "--npm-package"]); + expectUsageError(["init", "--docker-image"]); + expectUsageError(["init", "--url"]); + expectUsageError(["init", "--header-name"]); + expectUsageError(["init", "--header-prefix"]); + expectUsageError(["init", "--interactive", "--interactive"]); + expectUsageError(["init", "--client=cursor", "--client=cursor"]); + expectUsageError(["init", "--credential-env=MCP_TOKEN", "--credential-env=MCP_TOKEN"]); + expectUsageError(["init", "--npm-package=server@1.2.3", "--npm-package=server@1.2.3"]); + expectUsageError([ + "init", + "--docker-image=ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "--docker-image=ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ]); + expectUsageError(["init", "--url=https://one.example", "--url=https://two.example"]); + expectUsageError(["init", "--header-name=Authorization", "--header-name=Authorization"]); + expectUsageError(["init", "--header-prefix=Bearer", "--header-prefix=Bearer"]); expectUsageError(["validate", "unexpected"]); expectUsageError(["--version", "validate"]); expectUsageError(["version", "--version"]); diff --git a/tests/client-snippets.test.ts b/tests/client-snippets.test.ts new file mode 100644 index 00000000..389ed3b9 --- /dev/null +++ b/tests/client-snippets.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; +import { + CLIENT_NAMES, + ClientSnippetError, + renderClientSnippet, + renderClientSnippets +} from "../src/cli/client-snippets.js"; + +const posixInput = { + serverName: "miftah server", + configPath: "/Users/Ada Lovelace/Miftah/config.json", + launcher: { + command: "/Applications/Miftah/bin/node", + args: ["/Applications/Miftah/dist/cli/main.js", "serve"] + } +}; + +describe("client snippets", () => { + it("renders the official Claude Desktop stdio configuration", () => { + const snippet = renderClientSnippet("claude-desktop", posixInput); + + expect(snippet.client).toBe("claude-desktop"); + expect(snippet.target).toEqual({ label: "Claude Desktop settings config" }); + expect(JSON.parse(snippet.json)).toEqual({ + mcpServers: { + "miftah server": { + command: "/Applications/Miftah/bin/node", + args: ["/Applications/Miftah/dist/cli/main.js", "serve", "--config", "/Users/Ada Lovelace/Miftah/config.json"] + } + } + }); + }); + + it("renders the official Claude Code project .mcp.json configuration", () => { + const snippet = renderClientSnippet("claude-code", posixInput); + + expect(snippet.target).toEqual({ label: "Claude Code project .mcp.json" }); + expect(JSON.parse(snippet.json)).toEqual({ + mcpServers: { + "miftah server": { + command: "/Applications/Miftah/bin/node", + args: ["/Applications/Miftah/dist/cli/main.js", "serve", "--config", "/Users/Ada Lovelace/Miftah/config.json"] + } + } + }); + }); + + it("renders the official Cursor stdio configuration", () => { + const snippet = renderClientSnippet("cursor", posixInput); + + expect(snippet.target).toEqual({ label: "Cursor .cursor/mcp.json" }); + expect(JSON.parse(snippet.json)).toEqual({ + mcpServers: { + "miftah server": { + type: "stdio", + command: "/Applications/Miftah/bin/node", + args: ["/Applications/Miftah/dist/cli/main.js", "serve", "--config", "/Users/Ada Lovelace/Miftah/config.json"] + } + } + }); + }); + + it("renders the official VS Code stdio configuration", () => { + const snippet = renderClientSnippet("vscode", posixInput); + + expect(snippet.target).toEqual({ label: "VS Code .vscode/mcp.json" }); + expect(JSON.parse(snippet.json)).toEqual({ + servers: { + "miftah server": { + type: "stdio", + command: "/Applications/Miftah/bin/node", + args: ["/Applications/Miftah/dist/cli/main.js", "serve", "--config", "/Users/Ada Lovelace/Miftah/config.json"] + } + } + }); + }); + + it("renders every supported client in a deterministic order", () => { + const snippets = renderClientSnippets("all", posixInput); + + expect(CLIENT_NAMES).toEqual(["claude-desktop", "claude-code", "cursor", "vscode"]); + expect(snippets.map((snippet) => snippet.client)).toEqual(CLIENT_NAMES); + }); + + it("keeps supported client membership immutable and canonical", () => { + const mutableNames = CLIENT_NAMES as unknown as string[]; + + expect(Object.isFrozen(CLIENT_NAMES)).toBe(true); + expect(() => mutableNames.push("unsupported")).toThrow(TypeError); + expect(renderClientSnippets("all", posixInput).map((snippet) => snippet.client)).toEqual([ + "claude-desktop", + "claude-code", + "cursor", + "vscode" + ]); + }); + + it("preserves Windows paths and launcher values exactly in JSON arrays", () => { + const command = String.raw`C:\Program Files\nodejs\node.exe`; + const configPath = String.raw`C:\Users\Ada Lovelace\Miftah\config.json`; + const entrypoint = String.raw`C:\Program Files\Miftah\dist\cli\main.js`; + const snippet = renderClientSnippet("cursor", { + serverName: "Miftah", + configPath, + launcher: { command, args: [entrypoint, "--label", "value with spaces; $(not-a-shell)"] } + }); + const parsed = JSON.parse(snippet.json) as { + mcpServers: Record; + }; + const server = parsed.mcpServers.Miftah; + + expect(server).toEqual({ + type: "stdio", + command, + args: [entrypoint, "--label", "value with spaces; $(not-a-shell)", "--config", configPath] + }); + expect(server?.shell).toBeUndefined(); + }); + + it("JSON-escapes ordinary Unicode and JSON-sensitive names without changing them", () => { + const serverName = "Miftah \"東京\"\n\\"; + const snippet = renderClientSnippet("vscode", { + ...posixInput, + serverName, + launcher: { command: "/opt/node\"quoted", args: ["/opt/line\nbreak", "\\slash"] } + }); + const parsed = JSON.parse(snippet.json) as { servers: Record }; + + expect(parsed.servers[serverName]).toEqual({ + type: "stdio", + command: "/opt/node\"quoted", + args: ["/opt/line\nbreak", "\\slash", "--config", posixInput.configPath] + }); + }); + + it.each([ + [{ ...posixInput, serverName: "" }, "server name"], + [{ ...posixInput, launcher: { ...posixInput.launcher, command: "" } }, "launcher command"], + [{ ...posixInput, launcher: { ...posixInput.launcher, command: "node" } }, "absolute"], + [{ ...posixInput, launcher: { ...posixInput.launcher, command: "/safe\u0000node" } }, "NUL"], + [{ ...posixInput, launcher: { ...posixInput.launcher, args: [] } }, "launcher argument"], + [{ ...posixInput, launcher: { ...posixInput.launcher, args: [""] } }, "launcher argument"], + [{ ...posixInput, launcher: { ...posixInput.launcher, args: ["dist/cli/main.js"] } }, "absolute"], + [{ ...posixInput, launcher: { ...posixInput.launcher, args: ["/safe\u0000cli.js"] } }, "NUL"], + [{ ...posixInput, launcher: { ...posixInput.launcher, args: [posixInput.launcher.args[0]!, "--config", "/other/config.json"] } }, "--config"], + [{ ...posixInput, launcher: { ...posixInput.launcher, args: [posixInput.launcher.args[0]!, "--config=/other/config.json"] } }, "--config"], + [{ ...posixInput, configPath: "relative/config.json" }, "absolute"], + [{ ...posixInput, configPath: "/safe\u0000path/config.json" }, "NUL"] + ])("rejects invalid input %#", (input, message) => { + expect(() => renderClientSnippet("cursor", input)).toThrow(ClientSnippetError); + expect(() => renderClientSnippet("cursor", input)).toThrow(message); + }); + + it.each([null, undefined, true, "not an object"])("rejects malformed top-level input %#", (input) => { + expect(() => renderClientSnippet("cursor", input as never)).toThrow(ClientSnippetError); + expect(() => renderClientSnippet("cursor", input as never)).toThrow("snippet input"); + }); + + it("rejects sparse launcher argument arrays", () => { + const sparseArgs = [posixInput.launcher.args[0]!, "serve"]; + delete sparseArgs[1]; + + expect(() => renderClientSnippet("cursor", { ...posixInput, launcher: { ...posixInput.launcher, args: sparseArgs } })).toThrow( + ClientSnippetError + ); + }); + + it("accepts UNC paths and rejects unrecognized client names at runtime", () => { + const snippet = renderClientSnippet("claude-code", { + ...posixInput, + configPath: String.raw`\\server\share\miftah\config.json` + }); + + expect(JSON.parse(snippet.json).mcpServers["miftah server"].args.at(-1)).toBe( + String.raw`\\server\share\miftah\config.json` + ); + expect(() => renderClientSnippet("unsupported" as never, posixInput)).toThrow(ClientSnippetError); + expect(() => renderClientSnippet("unsupported" as never, posixInput)).toThrow("Unsupported client"); + }); + + it.each([Symbol("cursor"), 1, null])("rejects malformed client values %#", (client) => { + expect(() => renderClientSnippet(client as never, posixInput)).toThrow(ClientSnippetError); + expect(() => renderClientSnippets(client as never, posixInput)).toThrow(ClientSnippetError); + }); + + it("accepts Windows extended-length absolute paths", () => { + const command = String.raw`\\?\C:\Program Files\nodejs\node.exe`; + const entrypoint = String.raw`\\?\C:\Program Files\Miftah\dist\cli\main.js`; + const configPath = String.raw`\\?\C:\Users\Ada Lovelace\Miftah\config.json`; + const snippet = renderClientSnippet("cursor", { + serverName: "Miftah", + configPath, + launcher: { command, args: [entrypoint] } + }); + + expect(JSON.parse(snippet.json).mcpServers.Miftah).toEqual({ + type: "stdio", + command, + args: [entrypoint, "--config", configPath] + }); + }); +}); diff --git a/tests/config-runtime-parity.test.ts b/tests/config-runtime-parity.test.ts index 3059b493..a89010d5 100644 --- a/tests/config-runtime-parity.test.ts +++ b/tests/config-runtime-parity.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { presetConfig } from "../src/config/presets.js"; +import { buildPresetConfig } from "../src/config/presets.js"; import { validateConfig } from "../src/config/validate-config.js"; import { MiftahError } from "../src/utils/errors.js"; @@ -211,8 +211,17 @@ describe("config runtime parity", () => { expect(error.message).toContain("tooling.toolDiscoveryMode"); }); - it.each(["generic", "github", "sentry"])("keeps the %s generated preset valid", (preset) => { - expect(() => validateConfig(presetConfig("test", preset))).not.toThrow(); + it.each([ + ["generic", {}], + ["github", {}], + ["sentry", {}], + ["generic-npx", { npmPackage: "server@1.2.3" }], + ["generic-docker", { + dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + }], + ["streamable-http", { url: "https://mcp.example.com" }] + ])("keeps the %s generated preset valid", (preset, options) => { + expect(() => validateConfig(buildPresetConfig("test", preset, options))).not.toThrow(); }); it.each(checkedInExamples)("keeps the checked-in %s example valid", (example) => { diff --git a/tests/init-command.test.ts b/tests/init-command.test.ts new file mode 100644 index 00000000..f969f35f --- /dev/null +++ b/tests/init-command.test.ts @@ -0,0 +1,264 @@ +import { access, readFile, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { validateConfig } from "../src/config/validate-config.js"; +import { runInitCommand } from "../src/cli/init.js"; +import { CliUsageError } from "../src/cli/parse.js"; + +const outputRoot = resolve(process.cwd(), ".init-command-test-output"); + +interface TtyStreams { + readonly input: PassThrough & { isTTY?: boolean }; + readonly output: PassThrough & { isTTY?: boolean }; + readonly transcript: StreamTranscript; +} + +class StreamTranscript { + #contents = ""; + #waiters: Array<{ readonly text: string; readonly resolve: () => void }> = []; + + append(chunk: Buffer | string): void { + this.#contents += chunk.toString(); + this.#waiters = this.#waiters.filter((waiter) => { + if (!this.#contents.includes(waiter.text)) return true; + waiter.resolve(); + return false; + }); + } + + get contents(): string { + return this.#contents; + } + + waitFor(text: string): Promise { + if (this.#contents.includes(text)) return Promise.resolve(); + return new Promise((resolve) => { + this.#waiters.push({ text, resolve }); + }); + } +} + +function createStreams(isTTY = true): TtyStreams { + const input = Object.assign(new PassThrough(), { isTTY }); + const output = Object.assign(new PassThrough(), { isTTY }); + const transcript = new StreamTranscript(); + output.on("data", (chunk: Buffer) => transcript.append(chunk)); + return { input, output, transcript }; +} + +function commandContext(streams: TtyStreams) { + return { + input: streams.input, + output: streams.output, + cwd: outputRoot, + launcher: { + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve"] + } + }; +} + +async function answer(streams: TtyStreams, prompt: string, value: string): Promise { + await streams.transcript.waitFor(prompt); + streams.input.write(`${value}\n`); +} + +async function expectNoPath(path: string): Promise { + await expect(access(path)).rejects.toMatchObject({ code: "ENOENT" }); +} + +beforeEach(async () => { + await rm(outputRoot, { recursive: true, force: true }); +}); + +afterEach(async () => { + await rm(outputRoot, { recursive: true, force: true }); +}); + +describe("init command", () => { + it("keeps noninteractive init config-only output compatible and writes a strict valid config", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "generic.json"); + + await runInitCommand({ name: "generic", output: "generic.json" }, commandContext(streams)); + streams.input.end(); + + const config = validateConfig(JSON.parse(await readFile(output, "utf8"))); + expect(config.name).toBe("generic"); + expect(streams.transcript.contents).toBe(`Created ${output}\n`); + }); + + it("reports an existing output file as a usage error without changing it", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "existing.json"); + + await runInitCommand({ name: "existing", output: "existing.json" }, commandContext(streams)); + const originalContents = await readFile(output, "utf8"); + + await expect( + runInitCommand({ name: "replacement", output: "existing.json" }, commandContext(streams)) + ).rejects.toThrow(CliUsageError); + + expect(await readFile(output, "utf8")).toBe(originalContents); + streams.input.end(); + }); + + it("rejects unknown presets and missing generic metadata before creating output directories", async () => { + const streams = createStreams(); + + await expect( + runInitCommand({ name: "unknown", preset: "not-a-catalog-preset", output: "unknown/config.json" }, commandContext(streams)) + ).rejects.toThrow(CliUsageError); + await expect( + runInitCommand({ name: "npx", preset: "generic-npx", output: "npx/config.json" }, commandContext(streams)) + ).rejects.toThrow(CliUsageError); + await expect( + runInitCommand( + { name: "generic", preset: "generic", npmPackage: "server@1.2.3", output: "inapplicable/config.json" }, + commandContext(streams) + ) + ).rejects.toThrow(CliUsageError); + await expect( + runInitCommand({ name: "client", client: "unsupported", output: "client/config.json" }, commandContext(streams)) + ).rejects.toThrow(CliUsageError); + + await expectNoPath(resolve(outputRoot, "unknown")); + await expectNoPath(resolve(outputRoot, "npx")); + await expectNoPath(resolve(outputRoot, "inapplicable")); + await expectNoPath(resolve(outputRoot, "client")); + streams.input.end(); + }); + + it("prints client snippets with resolved config and absolute launcher values after one config write", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "client.json"); + + await runInitCommand({ name: "client", output: "client.json", client: "cursor" }, commandContext(streams)); + streams.input.end(); + + expect(JSON.parse(await readFile(output, "utf8"))).toMatchObject({ name: "client" }); + expect(streams.transcript.contents).toContain(`Created ${output}\n`); + expect(streams.transcript.contents).toContain("Cursor .cursor/mcp.json (cursor):"); + expect(streams.transcript.contents).toContain(JSON.stringify(process.execPath)); + expect(streams.transcript.contents).toContain(JSON.stringify(output)); + const json = streams.transcript.contents.slice(streams.transcript.contents.indexOf("{")); + expect(JSON.parse(json).mcpServers.client).toEqual({ + type: "stdio", + command: process.execPath, + args: [resolve(process.cwd(), "dist/cli/main.js"), "serve", "--config", output] + }); + }); + + it("runs the TTY wizard with real streams for a generic config", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "wizard-generic.json"); + const command = runInitCommand({ interactive: true }, commandContext(streams)); + + await answer(streams, "Name [miftah-wrapper]", "wizard-generic"); + await answer(streams, "Catalog preset [generic]", ""); + await answer(streams, "Output location [wizard-generic.miftah.json]", "wizard-generic.json"); + await answer(streams, "Client", "claude-code"); + await command; + streams.input.end(); + + const config = validateConfig(JSON.parse(await readFile(output, "utf8"))); + expect(config.name).toBe("wizard-generic"); + expect(streams.transcript.contents).toContain("Claude Code project .mcp.json (claude-code):"); + }); + + it("prompts for generic-npx metadata and validates the resulting config", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "wizard-npx.json"); + const command = runInitCommand( + { interactive: true, name: "wizard-npx", preset: "generic-npx", output: "wizard-npx.json" }, + commandContext(streams) + ); + + await answer(streams, "NPM package", "@scope/server@1.2.3"); + await answer(streams, "Client", ""); + await command; + streams.input.end(); + + const config = validateConfig(JSON.parse(await readFile(output, "utf8"))); + expect(config.upstream?.args).toEqual(["--yes", "@scope/server@1.2.3"]); + }); + + it("preserves a supported streamable HTTP credential header prefix", async () => { + const streams = createStreams(); + const output = resolve(outputRoot, "wizard-http.json"); + const command = runInitCommand( + { interactive: true, name: "wizard-http", preset: "streamable-http", output: "wizard-http.json" }, + commandContext(streams) + ); + + await answer(streams, "Streamable HTTPS URL", "https://mcp.example.com/v1"); + await answer(streams, "Credential environment variable name", "MCP_TOKEN"); + await answer(streams, "Credential header name", "Authorization"); + await answer(streams, "Credential header prefix", "Bearer "); + await answer(streams, "Client", ""); + await command; + streams.input.end(); + + const fileConfig = JSON.parse(await readFile(output, "utf8")); + expect(fileConfig.upstream.headers).toEqual({ Authorization: "Bearer ${MCP_TOKEN}" }); + expect(() => validateConfig(fileConfig)).not.toThrow(); + }); + + it("normalizes wizard EOF and SIGINT cancellation to usage errors without creating files", async () => { + const eofStreams = createStreams(); + eofStreams.input.end(); + await expect(runInitCommand({ interactive: true, output: "eof/config.json" }, commandContext(eofStreams))).rejects.toThrow( + CliUsageError + ); + + const cancelStreams = createStreams(); + const command = runInitCommand({ interactive: true, output: "cancel/config.json" }, commandContext(cancelStreams)); + await cancelStreams.transcript.waitFor("Name"); + cancelStreams.input.write("\u0003"); + await expect(command).rejects.toThrow(CliUsageError); + cancelStreams.input.end(); + + await expectNoPath(resolve(outputRoot, "eof")); + await expectNoPath(resolve(outputRoot, "cancel")); + }); + + it("does not emit an unhandled rejection when fully supplied wizard input closes", async () => { + const streams = createStreams(); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => unhandledRejections.push(reason); + process.on("unhandledRejection", onUnhandledRejection); + queueMicrotask(() => { + streams.input.write("\u0003"); + streams.input.end(); + }); + + try { + await runInitCommand( + { + interactive: true, + name: "fully-supplied", + preset: "generic", + output: "fully-supplied.json", + client: "all" + }, + commandContext(streams) + ); + await new Promise((resolve) => setImmediate(resolve)); + + expect(unhandledRejections).toEqual([]); + } finally { + process.removeListener("unhandledRejection", onUnhandledRejection); + } + }); + + it("rejects interactive init without TTY input and output before creating files", async () => { + const streams = createStreams(false); + + await expect(runInitCommand({ interactive: true, output: "notty/config.json" }, commandContext(streams))).rejects.toThrow( + CliUsageError + ); + await expectNoPath(resolve(outputRoot, "notty")); + streams.input.end(); + }); +}); diff --git a/tests/preset-catalog.test.ts b/tests/preset-catalog.test.ts new file mode 100644 index 00000000..e54df523 --- /dev/null +++ b/tests/preset-catalog.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "vitest"; +import { + buildPresetConfig, + PRESET_CATALOG, + PresetCatalogError +} from "../src/config/presets.js"; +import type { PresetBuildOptions } from "../src/config/presets.js"; +import { validateConfig } from "../src/config/validate-config.js"; + +function serializedConfig(config: unknown): string { + return JSON.stringify(config); +} + +describe("preset catalog", () => { + it("publishes one versioned catalog with inspectable preset requirements", () => { + expect(PRESET_CATALOG.version).toBe("1"); + expect(Object.keys(PRESET_CATALOG.presets)).toEqual([ + "generic", + "github", + "sentry", + "generic-npx", + "generic-docker", + "streamable-http" + ]); + expect(PRESET_CATALOG.presets["generic-npx"].requirements.npmPackage).toBe("required"); + expect(PRESET_CATALOG.presets["generic-docker"].requirements.dockerImage).toBe("required"); + expect(PRESET_CATALOG.presets["streamable-http"].requirements.url).toBe("required"); + }); + + it("builds every catalog config as a valid strict Miftah config without literal secrets", () => { + const genericOptions = { credentialEnv: "GENERIC_TOKEN" }; + const configs = [ + buildPresetConfig("generic", "generic", genericOptions), + buildPresetConfig("github", "github"), + buildPresetConfig("sentry", "sentry"), + buildPresetConfig("npx", "generic-npx", { + npmPackage: "@scope/server@1.2.3", + credentialEnv: "NPM_SERVER_TOKEN" + }), + buildPresetConfig("docker", "generic-docker", { + dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + credentialEnv: "DOCKER_SERVER_TOKEN" + }), + buildPresetConfig("remote", "streamable-http", { + url: "https://mcp.example.com/v1", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix: "Bearer " + }) + ]; + + for (const config of configs) { + expect(() => validateConfig(config)).not.toThrow(); + expect(serializedConfig(config)).not.toContain("literal-secret-that-must-not-appear"); + } + }); + + it("builds exact provider contracts with only environment secret references", () => { + const generic = buildPresetConfig("generic", "generic"); + const github = buildPresetConfig("github", "github"); + const sentry = buildPresetConfig("sentry", "sentry"); + + expect(generic.upstream?.args).toEqual(["--yes", "@modelcontextprotocol/server-everything@2026.7.4", "stdio"]); + expect(github.upstream?.args).toEqual([ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:v1.5.0", + "stdio", + "--read-only", + "--toolsets=repos,issues,pull_requests" + ]); + expect(github.profiles).toMatchObject({ + work: { env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_WORK_TOKEN}" }, policy: "readonly" }, + personal: { env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PERSONAL_TOKEN}" }, policy: "readonly" } + }); + expect(sentry.upstream?.args).toEqual(["--yes", "@sentry/mcp-server@0.36.0", "--skills=inspect"]); + expect(sentry.profiles.default).toMatchObject({ + env: { SENTRY_ACCESS_TOKEN: "${SENTRY_ACCESS_TOKEN}" }, + policy: "readonly" + }); + }); + + it("requires and validates exact generic preset inputs", () => { + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@1.2.3" })).not.toThrow(); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "@scope/server@1.2.3" })).not.toThrow(); + expect(buildPresetConfig("npx", "generic-npx", { npmPackage: "@sentry/mcp-server@0.36.0" }).upstream?.args).toEqual([ + "--yes", + "@sentry/mcp-server@0.36.0" + ]); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@latest" })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@^1.2.3" })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server" })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@1.2.3-01" })).toThrow( + PresetCatalogError + ); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@1.2.3-alpha.01" })).toThrow( + PresetCatalogError + ); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@1.2.3-0" })).not.toThrow(); + expect(() => buildPresetConfig("npx", "generic-npx", { npmPackage: "server@1.2.3-01alpha" })).not.toThrow(); + expect(() => buildPresetConfig("docker", "generic-docker", { dockerImage: "ghcr.io/acme/server:latest" })).toThrow( + PresetCatalogError + ); + const dockerImage = "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const docker = buildPresetConfig("docker", "generic-docker", { + dockerImage, + credentialEnv: "DOCKER_SERVER_TOKEN" + }); + expect(docker.upstream?.args).toEqual(["run", "-i", "--rm", "-e", "DOCKER_SERVER_TOKEN", dockerImage, "stdio"]); + expect(() => buildPresetConfig("docker", "generic-docker", { + dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + })).not.toThrow(); + }); + + it("accepts only safe streamable HTTP credential header inputs", () => { + const config = buildPresetConfig("remote", "streamable-http", { + url: "https://mcp.example.com/v1", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix: "Bearer " + }); + + expect(config.upstream).toMatchObject({ + transport: "streamable-http", + url: "https://mcp.example.com/v1", + headers: { Authorization: "Bearer ${REMOTE_TOKEN}" } + }); + expect(() => buildPresetConfig("remote", "streamable-http", { url: "http://mcp.example.com" })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("remote", "streamable-http", { url: "https://user@example.com" })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("remote", "streamable-http", { url: "https://example.com/?q=1" })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("remote", "streamable-http", { url: "https://example.com/#fragment" })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("remote", "streamable-http", { + url: "https://example.com", + credentialEnv: "REMOTE_TOKEN", + headerName: "Bad Header", + headerPrefix: "Bearer " + })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("remote", "streamable-http", { + url: "https://example.com", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix: "Bearer ${INJECTED}" + })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("remote", "streamable-http", { + url: "https://example.com", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix: "Bearer\r\nX-Injected: yes" + })).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("remote", "streamable-http", { + url: "https://example.com", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix: "Bearer\u0000" + })).toThrow(PresetCatalogError); + }); + + it.each([ + ["Bearer ", "Bearer ${REMOTE_TOKEN}"], + ["Sentry ", "Sentry ${REMOTE_TOKEN}"] + ])("constructs an Authorization header from the supported %s auth scheme", (headerPrefix, expectedHeader) => { + const config = buildPresetConfig("remote", "streamable-http", { + url: "https://mcp.example.com/v1", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix + }); + + expect(config.upstream?.headers).toEqual({ Authorization: expectedHeader }); + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("uses no default scheme when a streamable HTTP header prefix is empty or omitted", () => { + const emptyPrefix = buildPresetConfig("remote", "streamable-http", { + url: "https://mcp.example.com/v1", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix: "" + }); + const omittedPrefix = buildPresetConfig("remote", "streamable-http", { + url: "https://mcp.example.com/v1", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization" + }); + + expect(emptyPrefix.upstream?.headers).toEqual({ Authorization: "${REMOTE_TOKEN}" }); + expect(omittedPrefix.upstream?.headers).toEqual({ Authorization: "${REMOTE_TOKEN}" }); + expect(() => validateConfig(emptyPrefix)).not.toThrow(); + expect(() => validateConfig(omittedPrefix)).not.toThrow(); + }); + + it("rejects a literal credential followed by a space as a streamable HTTP header prefix", () => { + expect(() => buildPresetConfig("remote", "streamable-http", { + url: "https://mcp.example.com/v1", + credentialEnv: "REMOTE_TOKEN", + headerName: "Authorization", + headerPrefix: "literal-secret-value " + })).toThrow(PresetCatalogError); + }); + + it.each([ + ["generic", {}, ""], + ["generic", {}, "INVALID-NAME"], + ["generic-npx", { npmPackage: "server@1.2.3" }, ""], + ["generic-npx", { npmPackage: "server@1.2.3" }, "INVALID-NAME"], + [ + "generic-docker", + { dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }, + "" + ], + [ + "generic-docker", + { dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }, + "INVALID-NAME" + ] + ] as const)("rejects a supplied %s credential environment name of %j", (preset, options, credentialEnv) => { + expect(() => buildPresetConfig("test", preset, { ...options, credentialEnv })).toThrow(PresetCatalogError); + }); + + it.each([ + ["generic", "credentialEnv", {}], + ["generic-npx", "npmPackage", { npmPackage: "server@1.2.3" }], + [ + "generic-docker", + "dockerImage", + { dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } + ], + ["streamable-http", "url", { url: "https://mcp.example.com/v1" }], + [ + "streamable-http", + "headerName", + { url: "https://mcp.example.com/v1", credentialEnv: "REMOTE_TOKEN", headerName: "Authorization" } + ], + [ + "streamable-http", + "headerPrefix", + { url: "https://mcp.example.com/v1", credentialEnv: "REMOTE_TOKEN", headerName: "Authorization" } + ] + ] as const)( + "rejects a non-string %s option for %s presets before generating config", + (preset, option, validOptions) => { + for (const value of [null, true, {}, []]) { + const options = { ...validOptions, [option]: value }; + expect(() => buildPresetConfig("test", preset, options as unknown as PresetBuildOptions)).toThrow( + /must be a string/ + ); + } + } + ); + + it("accepts explicitly undefined optional preset inputs", () => { + expect(() => buildPresetConfig("generic", "generic", { credentialEnv: undefined })).not.toThrow(); + expect(() => buildPresetConfig("remote", "streamable-http", { + url: "https://mcp.example.com/v1", + credentialEnv: undefined, + headerName: undefined, + headerPrefix: undefined + })).not.toThrow(); + }); + + it("rejects unknown strict catalog presets with a clear typed error", () => { + expect(() => buildPresetConfig("test", "unknown")).toThrow(PresetCatalogError); + expect(() => buildPresetConfig("test", "unknown")).toThrow(/Unknown preset 'unknown'/); + expect(() => buildPresetConfig("test", "toString")).toThrow(PresetCatalogError); + }); + + it.each([ + ["generic", { npmPackage: "server@1.2.3" }], + ["github", { credentialEnv: "GITHUB_TOKEN" }], + ["sentry", { credentialEnv: "SENTRY_TOKEN" }], + ["generic-npx", { npmPackage: "server@1.2.3", dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }], + ["generic-docker", { dockerImage: "ghcr.io/acme/server@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", url: "https://mcp.example.com" }], + ["streamable-http", { url: "https://mcp.example.com", npmPackage: "server@1.2.3" }] + ] as const)("rejects inapplicable preset inputs for %s", (preset, options) => { + expect(() => buildPresetConfig("test", preset, options)).toThrow(PresetCatalogError); + }); + + it.each(["unsupported", "secret"])("rejects undeclared strict catalog input %s", (option) => { + expect(() => + buildPresetConfig( + "test", + "generic", + { [option]: option === "secret" ? "literal-secret-that-must-not-appear" : "value" } as unknown as PresetBuildOptions + ) + ).toThrow(new RegExp(`Preset 'generic' does not support option '${option}'`)); + }); +}); diff --git a/tests/preset-docs-contract.test.ts b/tests/preset-docs-contract.test.ts new file mode 100644 index 00000000..4cfc431c --- /dev/null +++ b/tests/preset-docs-contract.test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { buildPresetConfig, PRESET_CATALOG } from "../src/config/presets.js"; +import { validateConfig } from "../src/config/validate-config.js"; + +function readRepositoryFile(path: string): string { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +} + +function parseRepositoryJson(path: string): unknown { + return JSON.parse(readRepositoryFile(path)) as unknown; +} + +function unreleasedSection(changelog: string): string { + const afterHeading = changelog.split(/^## \[Unreleased\]\s*$/mu)[1]; + if (afterHeading === undefined) { + throw new Error("CHANGELOG.md must contain an Unreleased section."); + } + const nextRelease = afterHeading.search(/^## \[/mu); + return nextRelease === -1 ? afterHeading : afterHeading.slice(0, nextRelease); +} + +describe("preset documentation contract", () => { + it("keeps the catalog, generated examples, onboarding docs, provenance, and changelog aligned", () => { + const compatibility = readRepositoryFile("docs/presets-and-clients.md"); + const readme = readRepositoryFile("README.md"); + const cli = readRepositoryFile("docs/cli.md"); + const claudeDesktop = readRepositoryFile("docs/claude-desktop.md"); + const changelog = readRepositoryFile("CHANGELOG.md"); + const packageManifest = parseRepositoryJson("package.json") as { version: string }; + + expect(compatibility).toContain(`Catalog version: \`${PRESET_CATALOG.version}\``); + expect(compatibility).toContain(`Miftah package version: \`${packageManifest.version}\``); + for (const preset of Object.keys(PRESET_CATALOG.presets)) { + expect(compatibility).toContain(`\`${preset}\``); + } + + for (const [name, preset] of [ + ["generic", "generic"], + ["github", "github"], + ["sentry", "sentry"] + ] as const) { + const example = parseRepositoryJson(`examples/${name}.miftah.json`); + expect(example).toEqual(buildPresetConfig(name, preset)); + expect(() => validateConfig(example)).not.toThrow(); + } + + for (const requiredFact of [ + "@modelcontextprotocol/server-everything@2026.7.4", + "ghcr.io/github/github-mcp-server:v1.5.0", + "--read-only", + "repos,issues,pull_requests", + "@sentry/mcp-server@0.36.0", + "--skills=inspect" + ]) { + expect(compatibility).toContain(requiredFact); + } + for (const sourceLink of [ + "[GitHub MCP source](https://github.com/github/github-mcp-server)", + "[GitHub IDE setup](https://docs.github.com/en/copilot/how-tos/provide-context/use-mcp-in-your-ide/set-up-the-github-mcp-server)", + "[GitHub read-only mode](https://github.com/github/github-mcp-server/blob/main/docs/server-configuration.md#read-only-mode)", + "[GitHub tool configuration](https://github.com/github/github-mcp-server#tool-configuration)", + "[GitHub Container registry pull by digest](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#pull-by-digest)", + "[Sentry MCP source at `0.36.0`](https://github.com/getsentry/sentry-mcp/tree/0.36.0)", + "[Sentry package metadata at `0.36.0`](https://registry.npmjs.org/@sentry/mcp-server/0.36.0)", + "[Sentry `0.36.0` CLI usage](https://github.com/getsentry/sentry-mcp/blob/0.36.0/packages/mcp-server/src/cli/usage.ts)", + "[MCP Everything source](https://github.com/modelcontextprotocol/servers/tree/main/src/everything)", + "[MCP Everything npm package](https://www.npmjs.com/package/@modelcontextprotocol/server-everything)", + "[Claude Code MCP](https://code.claude.com/docs/en/mcp)", + "[Cursor MCP](https://cursor.com/docs/mcp)", + "[VS Code MCP servers](https://code.visualstudio.com/docs/agent-customization/mcp-servers)", + "[VS Code MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)" + ]) { + expect(compatibility).toContain(sourceLink); + } + + expect(readme).toContain("[Preset and client compatibility](docs/presets-and-clients.md)"); + expect(cli).toContain("[preset and client compatibility](presets-and-clients.md)"); + expect(claudeDesktop).toContain("Merge the generated top-level `mcpServers` property"); + expect(claudeDesktop).toMatch(/merge the generated server entry into that object instead of nesting/iu); + expect(claudeDesktop).not.toContain("Paste the generated JSON into the `mcpServers` object"); + expect(claudeDesktop).not.toContain("Linux: `~/.config/Claude/claude_desktop_config.json`"); + expect(compatibility).not.toContain("Linux: `~/.config/Claude/claude_desktop_config.json`"); + for (const option of [ + "--name", + "--preset", + "--output", + "--interactive", + "--client", + "--credential-env", + "--npm-package", + "--docker-image", + "--url", + "--header-name", + "--header-prefix" + ]) { + expect(cli).toContain(option); + } + expect(compatibility).not.toContain("runtime construction"); + + const unreleased = unreleasedSection(changelog); + expect(unreleased).toMatch(/\[#19\][\s\S]*catalog[\s\S]*onboarding/iu); + expect(unreleased).not.toContain("runtime construction"); + }); +}); diff --git a/tests/presets.test.ts b/tests/presets.test.ts index 87d34579..46b58f8a 100644 --- a/tests/presets.test.ts +++ b/tests/presets.test.ts @@ -13,12 +13,17 @@ describe("preset config", () => { "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:v1.5.0" + "ghcr.io/github/github-mcp-server:v1.5.0", + "stdio", + "--read-only", + "--toolsets=repos,issues,pull_requests" ]); expect(config.defaultProfile).toBe("work"); expect(config.profiles.work?.env?.GITHUB_PERSONAL_ACCESS_TOKEN).toBe("${GITHUB_WORK_TOKEN}"); expect(config.profiles.personal?.env?.GITHUB_PERSONAL_ACCESS_TOKEN).toBe("${GITHUB_PERSONAL_TOKEN}"); + expect(config.profiles.work?.policy).toBe("readonly"); + expect(config.profiles.personal?.policy).toBe("readonly"); const refs = [ config.profiles.work?.env?.GITHUB_PERSONAL_ACCESS_TOKEN, @@ -38,19 +43,36 @@ describe("preset config", () => { expect(config).toMatchObject({ description: "example wrapped by Miftah", defaultProfile: "default", - upstream: { transport: "stdio", command: "npx", args: ["-y", "your-mcp-server"] }, + upstream: { + transport: "stdio", + command: "npx", + args: ["--yes", "@modelcontextprotocol/server-everything@2026.7.4", "stdio"] + }, profiles: { default: { description: "Default account", env: {} } } }); }); + it("retains the public generic fallback for unknown preset names", () => { + const config = presetConfig("example", "not-a-catalog-preset"); + + expect(config.upstream?.args).toEqual(["--yes", "@modelcontextprotocol/server-everything@2026.7.4", "stdio"]); + }); + it("uses the Sentry package with the shared preset defaults", () => { const config = presetConfig("sentry", "sentry"); expect(config).toMatchObject({ description: "sentry wrapped by Miftah", defaultProfile: "default", - upstream: { transport: "stdio", command: "npx", args: ["-y", "@sentry/mcp-server"] }, - profiles: { default: { description: "Default account", env: {} } } + upstream: { transport: "stdio", command: "npx", args: ["--yes", "@sentry/mcp-server@0.36.0", "--skills=inspect"] }, + profiles: { + default: { + description: "Default account", + env: { SENTRY_ACCESS_TOKEN: "${SENTRY_ACCESS_TOKEN}" }, + policy: "readonly" + } + }, + policies: { readonly: { allowRisk: ["read"], denyRisk: ["write", "destructive"] } } }); }); });