diff --git a/.gitleaks.toml b/.gitleaks.toml index b3f4602d1..488141f1b 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -22,3 +22,10 @@ condition = "AND" paths = ['''^packages/runtime-core/tests/stream_relay_contract\.rs$'''] regexTarget = "line" regexes = ['''^\s*let token = "0123456789abcdef0123456789abcdef";\s*$'''] + +[[rules.allowlists]] +description = "MCP inspect-tool tests use a fixed synthetic canary to prove values are never echoed" +condition = "AND" +paths = ['''^tests/mcp-inspect-tools\.test\.ts$'''] +regexTarget = "line" +regexes = ['''(?m)^\s*const SECRET_CANARY = "zqx-secret-canary-7731";\s*$'''] diff --git a/docs/guides/agent-first-setup.md b/docs/guides/agent-first-setup.md index 1d5159371..c26c99e76 100644 --- a/docs/guides/agent-first-setup.md +++ b/docs/guides/agent-first-setup.md @@ -69,6 +69,9 @@ but reaching it is a cutoff: the result is `ok: true` with `outputTruncated: tru full output is needed. The budget bounds captured bytes, not total process memory or JSON response size. +The [MCP tool contract](../reference/mcp-tools.md) lists every tool with its +annotations, inputs, the CLI command it runs and its result shape. + Each MCP session captures its launch working directory and environment. Project registry selection, command execution and audit-file locations use that snapshot; later sessions do not replace the state of an existing session. The standard stdio diff --git a/docs/reference.md b/docs/reference.md index 64c7e50a5..ee490977b 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -27,4 +27,5 @@ - [Extensions](./extensions.md) - [Creating an extension](./guides/create-extension.md) - [Integrations](./integrations.md) +- [MCP tool contract](./reference/mcp-tools.md) — every MCP tool: annotations, inputs, argv, results, and the value-free env tools - [MCP logs contract](./reference/mcp-logs.md) — cutoff versus backend failure for the MCP log tools, and the cursor/handle decision diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md new file mode 100644 index 000000000..237bf124c --- /dev/null +++ b/docs/reference/mcp-tools.md @@ -0,0 +1,202 @@ +# MCP tool contract + +`hack mcp serve` exposes the tools below to MCP clients that have no shell. Each +tool runs one `hack` CLI command as a child process, with the session's captured +working directory and environment, and `HACK_NO_INTERACTIVE=1`. When a shell is +available, run the CLI command directly instead. + +This page is the contract for tool names, annotations, inputs, the argv each tool +runs, and result shapes. The log tools have their own page: +[MCP logs contract](./mcp-logs.md). + +## Tools + +The annotation class is published in `tools/list`: + +- `read-only`: `readOnlyHint: true`. The tool only inspects state. +- `additive`: `readOnlyHint: false`, `destructiveHint: false`. The tool starts + or restarts local runtime state but does not remove it. +- `destructive`: `readOnlyHint: false`, `destructiveHint: true`. The tool can + stop, replace or overwrite local state, or run an arbitrary command. + +Every tool sets `openWorldHint: false`. Mutating tools set `idempotentHint: false`. + +| Tool | Class | CLI command | +| --- | --- | --- | +| `hack.projects.list` | read-only | `hack projects --json` | +| `hack.project.status` | read-only | `hack ps --json` | +| `hack.project.up` | additive | `hack up --detach` | +| `hack.project.down` | destructive | `hack down` | +| `hack.project.restart` | additive | `hack restart` | +| `hack.project.run` | destructive | `hack run -- [cmd...]` | +| `hack.project.logs.snapshot` | read-only | `hack logs --json --no-follow` | +| `hack.project.logs.tail` | read-only | `hack logs --json` | +| `hack.project.open` | read-only | `hack open --json` (never launches a browser) | +| `hack.project.init` | destructive | `hack init --auto` | +| `hack.project.exec` | destructive | `hack exec -- ` | +| `hack.project.doctor` | read-only | `hack doctor --json` | +| `hack.project.env.explain` | read-only | `hack env explain --json -- ` | +| `hack.project.env.keys` | read-only | `hack env list --json` | +| `hack.project.instances` | read-only | `hack projects --json --project=` | + +## Project selection + +Project-scoped tools accept at most one of `projectName`, `repoRoot` or `path`. +With none, the session's working directory selects the project. + +- `projectName` must name a registered project (`~/.hack/projects.json`). +- `repoRoot` and `path` must exist and lie inside a registered project's repo + root or project directory. +- With no selection, the working directory must contain a hack project or lie + inside a registered one. + +`hack.project.doctor` passes a named project to the CLI as `--path=`, +because `hack doctor` has no `--project` option. + +`hack.project.instances` needs a registered project name, because +`hack projects` selects by name only. A path, or the working directory when +nothing is selected, maps to the registered project whose repo root or project +directory contains it. When registrations are nested, the deepest one wins. Two +different registrations at the same root are refused as `ambiguous_project`. A +project that has a `.hack/` directory but no registration is refused; run +`hack projects` or any project command in it to register it. + +## Inputs and identifiers + +Inputs are validated before any child process starts. Unknown input fields are +dropped, so they never reach the CLI argv. + +- `service`, `services`, `profiles`, open `target`, and the env tools' `env` and + `service` must match `^[A-Za-z0-9][A-Za-z0-9_.-]*$`. +- `branch` must match `^[A-Za-z0-9][A-Za-z0-9_./-]*$`. +- Env `key` must match `^[A-Za-z_][A-Za-z0-9_]*$`. Scoped `service.KEY` syntax is + not accepted. Use the `service` input for the scope. +- Identifiers are at most 253 characters. None of these patterns can start with + `-`, so no identifier can be read as an option. +- Numeric limits are positive integers. A value above the ceiling is refused, + not clamped: `tail` and `maxEvents` up to 10,000, `maxMs` up to 60,000, and + `timeoutMs` up to 600,000. + +Option values are always fused (`--branch=`). Positional values that come +from the caller follow `--`, so the CLI parser ends option parsing before them. + +## Results + +Every result has the same structured content: + +| Field | Meaning | +| --- | --- | +| `ok` | The child exited 0 (for the log tools, see the logs contract) | +| `command` | The argv that ran, or `""` when refused before spawn | +| `exitCode` | The child's exit code (1 for a refusal) | +| `stdout`, `stderr` | Captured child output (withheld for the env tools; `stdout` withheld for instances) | +| `data` | Parsed or projected result, when the tool defines one | +| `outputTruncated` | `true` when output reached the 8 MiB capture limit | +| `spawned` | `false` only on a refusal: no child, no audit line | +| `error` | Closed refusal or backend error: `code`, `reason`, `message` | + +When `ok` is `false`, the result also has `isError: true`. Output above the +8 MiB capture limit stops the command, and `data` is omitted so that a prefix is +never presented as a complete result. The log tools are the exception, as their +contract describes. + +### Refusals + +A refusal happens before any child process exists. Its `error.reason` is one of +`invalid_arguments`, `conflicting_project_selection`, `invalid_project_name`, +`ambiguous_project`, `project_not_found`, `path_not_found`, +`path_not_registered`, `no_project_context` and `init_path_required`. The code is +`E_USAGE` or `E_PROJECT_NOT_FOUND`. The message is fixed text. It never repeats +caller input or registry values. For `invalid_arguments`, the message names the +rejected input fields, using only the tool's own schema keys. + +## `hack.project.exec` + +Runs a command in a service container that is already running (`docker compose +exec`). `hack.project.run` starts a new one-off container instead. Exec never +starts a container, and the CLI fails when the service is not running. + +- Required: `service` and `cmd`, with at least one entry. +- Optional: project selection, `branch`, `profiles`, `workdir` and `timeoutMs` + (default 120,000). +- Argv: `hack exec [--project=…|--path=…] [--branch=…] [--profile=…] + [--workdir=…] -- `. Entries in `cmd` such as `--help` or + `--branch` reach the container command and are never parsed by hack. +- `data.limits` echoes `{ timeoutMs, outputBytes }`. `outputBytes` is the shared + 8 MiB capture limit. + +## `hack.project.doctor` + +Runs read-only setup diagnostics: `hack doctor [--path=…] [--branch=…] --json`. +The argv set is closed. `--fix`, `--migrate-env-config`, `--domain-migration`, +`--native-cleanup`, `--native-run-mapping`, browser observations and legacy +rebind acceptance are never passed. The CLI also refuses `--json` together with +`--fix`. + +`data` is the CLI's JSON result envelope, with `data.checks`, `data.summary` and +`data.counts`. When any check reports an error, the CLI exits 1, so `ok` is +`false` while `data` still carries the full report. + +## `hack.project.env.explain` + +Explains one env key without its value: +`hack env explain [--project=…|--path=…] [--env=…] [--service=…] [--target=…] +--json -- `. + +- Required: `key`. Optional: project selection, `env` (overlay; `base` or + `default` selects base layers only), `service` (scope; default `global`) and + `target` (`host` or `compose`). +- `data` is an allowlisted projection: `key`, `available`, `secret`, `format`, + `environment`, `requested_scope`, `effective_scope`, `target`, + `delivered_to`, `source` and `precedence`. Any other field in the CLI output is + dropped. + +## `hack.project.env.keys` + +Lists env key names with metadata: `hack env list [--project=…|--path=…] +[--env=…] [--service=…] --json`. `--show-secrets` is never passed, and there is +no input for it. + +`data` is `{ format, environment, selected_scope, scopes, keys, +missing_required }`. Each entry in `keys` is `{ key, secret, required, source, +resolved_from, services }`. `secret` is `true` when the CLI reports the key as +`keychain`-backed. Row values, materialized values, storage locations and +warning text are dropped. A row whose key does not match the env key pattern is +dropped whole. + +### Value-free output + +`hack env list --json` prints plaintext values, and CLI errors can quote env +files. Both env tools therefore withhold the child's output: + +- `stdout` is always `""`. +- `stderr` is `""` on success. On failure it is a fixed message that tells you + to run the same `hack env` command in a terminal. +- `data` carries only the allowlisted fields above. It is `null` when the CLI + printed no parseable JSON object. + +`command` still shows the argv, which contains the key name but no values. The +MCP audit log records the same argv. + +## `hack.project.instances` + +Lists a registered project's runtime instances: `hack projects --json +--project=`. It reads the project's `runtime` and `branch_runtime` +entries, so it lists only instances that currently have containers, running or +stopped. A configured branch that was never started is not listed. + +`data` is `{ project, found, runtime_ok, instances }`. `instances` lists the +default instance first, when it has containers, as `branch: null`. Each branch +instance follows, in CLI order. Each instance is `{ branch, compose_project, +working_dir, services }`, and each service is `{ service, containers, running }` +with container counts. Both counts include lifecycle-process containers, which +the CLI adds to the service with the process name. `stdout` is always `""`: the raw listing can cover other +projects and carries container labels, mounts and networks, which `data` omits. +`stderr` is returned as captured. `found` is `false` when the project is +registered but the CLI listing does not include it. + +## Audit log + +Every spawned command appends one JSON line to `$HACK_HOME/mcp-audit.log` (by +default `~/.hack/mcp-audit.log`): `ts`, `tool`, `command` and `exit_code`. +Refusals write nothing. diff --git a/src/mcp/inspect-projections.ts b/src/mcp/inspect-projections.ts new file mode 100644 index 000000000..596613307 --- /dev/null +++ b/src/mcp/inspect-projections.ts @@ -0,0 +1,252 @@ +/** + * Value-free projections for the MCP inspection tools (HACK-1133). + * + * `hack.project.env.explain` and `hack.project.env.keys` run `hack env explain + * --json` and `hack env list --json`. The CLI payloads are not guaranteed to be + * value-free (`env list --json` carries plaintext values), so the MCP server + * never forwards their raw stdout or stderr. Each projection below copies only + * an allowlisted set of metadata fields with narrowed types; a field that is + * missing, unexpected or of the wrong type is dropped, never passed through. + * + * `hack.project.instances` projects one project's runtime instances out of + * `hack projects --json`. It is not a secret boundary, but it keeps the result + * small: container inspect data (labels, mounts, networks) is not returned. + */ +import { isRecord } from "../lib/guards.ts"; +import { normalizeProjectName } from "../lib/project-name.ts"; + +/** + * Env key accepted by the env tools. It starts with a letter or underscore, so + * it can never be read as an option, and it carries no scope separator. The + * CLI applies its own stricter key rule after this. + */ +export const MCP_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Fixed stderr text for a failed env tool call; child output is withheld. */ +export const MCP_ENV_OUTPUT_WITHHELD_MESSAGE = + "hack env command failed. Its output is withheld because it may contain env values; run the same hack env command in a terminal for details."; + +function optionalString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function optionalBoolean(value: unknown): boolean | null { + return typeof value === "boolean" ? value : null; +} + +function stringList(value: unknown): string[] | null { + if (!Array.isArray(value)) { + return null; + } + return value.filter((entry): entry is string => typeof entry === "string"); +} + +function envKeyOrNull(value: unknown): string | null { + return typeof value === "string" && MCP_ENV_KEY_PATTERN.test(value) + ? value + : null; +} + +/** Env explain metadata: where a key comes from, never its value. */ +export type McpEnvExplainData = { + readonly key: string | null; + readonly available: boolean | null; + readonly secret: boolean | null; + readonly format: string | null; + readonly environment: string | null; + readonly requested_scope: string | null; + readonly effective_scope: string | null; + readonly target: string | null; + readonly delivered_to: readonly string[] | null; + readonly source: string | null; + readonly precedence: readonly string[] | null; +}; + +/** Project `hack env explain --json` stdout onto {@link McpEnvExplainData}. */ +export function projectEnvExplain(payload: unknown): McpEnvExplainData | null { + if (!isRecord(payload)) { + return null; + } + return { + key: envKeyOrNull(payload.key), + available: optionalBoolean(payload.available), + secret: optionalBoolean(payload.secret), + format: optionalString(payload.format), + environment: optionalString(payload.environment), + requested_scope: optionalString(payload.requested_scope), + effective_scope: optionalString(payload.effective_scope), + target: optionalString(payload.target), + delivered_to: stringList(payload.delivered_to), + source: optionalString(payload.source), + precedence: stringList(payload.precedence), + }; +} + +/** One inventory row: key metadata only. */ +export type McpEnvKeyEntry = { + readonly key: string; + readonly secret: boolean; + readonly required: boolean | null; + readonly source: string | null; + readonly resolved_from: string | null; + readonly services: readonly string[] | null; +}; + +export type McpEnvKeysData = { + readonly format: string | null; + readonly environment: string | null; + readonly selected_scope: string | null; + readonly scopes: readonly string[] | null; + readonly keys: readonly McpEnvKeyEntry[]; + readonly missing_required: readonly string[]; +}; + +/** + * Project `hack env list --json` stdout onto a key inventory. Row `value`, + * `materialized`, `storage`, `warnings` and every other field are dropped. A + * row whose key does not match {@link MCP_ENV_KEY_PATTERN} is dropped whole. + */ +export function projectEnvKeys(payload: unknown): McpEnvKeysData | null { + if (!isRecord(payload)) { + return null; + } + const vars = Array.isArray(payload.vars) ? payload.vars : []; + const keys: McpEnvKeyEntry[] = []; + for (const row of vars) { + if (!isRecord(row)) { + continue; + } + const key = envKeyOrNull(row.key); + if (key === null) { + continue; + } + const source = optionalString(row.source); + keys.push({ + key, + secret: source === "keychain", + required: optionalBoolean(row.required), + source, + resolved_from: optionalString(row.resolved_from), + services: stringList(row.services), + }); + } + const selection = isRecord(payload.env_selection) + ? payload.env_selection + : {}; + return { + format: optionalString(payload.format), + environment: optionalString(selection.effective), + selected_scope: optionalString(payload.selected_scope), + scopes: stringList(payload.scopes), + keys, + missing_required: (stringList(payload.missing_required) ?? []).filter( + (key) => MCP_ENV_KEY_PATTERN.test(key) + ), + }; +} + +export type McpInstanceService = { + readonly service: string; + readonly containers: number; + readonly running: number; +}; + +/** One runtime instance: the default instance (`branch: null`) or a branch. */ +export type McpProjectInstance = { + readonly branch: string | null; + readonly compose_project: string | null; + readonly working_dir: string | null; + readonly services: readonly McpInstanceService[]; +}; + +export type McpInstancesData = { + readonly project: string; + readonly found: boolean; + readonly runtime_ok: boolean | null; + readonly instances: readonly McpProjectInstance[]; +}; + +function projectRuntime(opts: { + readonly branch: string | null; + readonly runtime: unknown; +}): McpProjectInstance | null { + if (!isRecord(opts.runtime)) { + return null; + } + const services: McpInstanceService[] = []; + for (const entry of Array.isArray(opts.runtime.services) + ? opts.runtime.services + : []) { + if (!(isRecord(entry) && typeof entry.service === "string")) { + continue; + } + const containers = Array.isArray(entry.containers) + ? entry.containers.filter(isRecord) + : []; + services.push({ + service: entry.service, + containers: containers.length, + running: containers.filter((container) => container.state === "running") + .length, + }); + } + return { + branch: opts.branch, + compose_project: optionalString(opts.runtime.project), + working_dir: optionalString(opts.runtime.working_dir), + services, + }; +} + +/** + * Select `project` from a `hack projects --json` payload and list its runtime + * instances: the default instance first (when it has containers), then each + * branch instance in the order the CLI reported them. + */ +export function projectInstances(opts: { + readonly payload: unknown; + readonly project: string; +}): McpInstancesData { + const payload = isRecord(opts.payload) ? opts.payload : {}; + const projects = Array.isArray(payload.projects) ? payload.projects : []; + const view = projects.find( + (entry): entry is Record => + isRecord(entry) && + typeof entry.name === "string" && + normalizeProjectName(entry.name) === opts.project + ); + const runtimeOk = optionalBoolean(payload.runtime_ok); + if (!view) { + return { + project: opts.project, + found: false, + runtime_ok: runtimeOk, + instances: [], + }; + } + const instances: McpProjectInstance[] = []; + const base = projectRuntime({ branch: null, runtime: view.runtime }); + if (base) { + instances.push(base); + } + for (const entry of Array.isArray(view.branch_runtime) + ? view.branch_runtime + : []) { + if (!(isRecord(entry) && typeof entry.branch === "string")) { + continue; + } + const instance = projectRuntime({ + branch: entry.branch, + runtime: entry.runtime, + }); + if (instance) { + instances.push(instance); + } + } + return { + project: opts.project, + found: true, + runtime_ok: runtimeOk, + instances, + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e9376c87f..cc6c00ce5 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -28,12 +28,21 @@ import { } from "../lib/project-name.ts"; import { AmbiguousProjectIdError, + type RegisteredProject, readProjectsRegistry, resolveRegisteredProjectByName, + selectRegisteredProjectByName, } from "../lib/projects-registry.ts"; import { readLinesFromStream } from "../ui/lines.ts"; import type { McpCommandAdmission } from "./command-admission.ts"; import { superviseMcpCommand } from "./command-lifetime.ts"; +import { + MCP_ENV_KEY_PATTERN, + MCP_ENV_OUTPUT_WITHHELD_MESSAGE, + projectEnvExplain, + projectEnvKeys, + projectInstances, +} from "./inspect-projections.ts"; import { classifyLogCollection, MCP_LOG_BACKEND_ERROR, @@ -161,6 +170,10 @@ type ProjectArgsResolution = | { readonly ok: true; readonly args: string[] } | { readonly ok: false; readonly reason: FixedRefusalReason }; +type ProjectNameResolution = + | { readonly ok: true; readonly name: string } + | { readonly ok: false; readonly reason: FixedRefusalReason }; + const DEFAULT_CMD_TIMEOUT_MS = 120_000; const DEFAULT_LOG_TAIL_EVENTS = 200; const DEFAULT_LOG_TAIL_MS = 5000; @@ -234,6 +247,14 @@ function composeIdentifierInput(description: string) { .describe(description); } +function envKeyInput(description: string) { + return z + .string() + .max(IDENTIFIER_MAX_LENGTH) + .regex(MCP_ENV_KEY_PATTERN) + .describe(description); +} + function branchIdentifierInput(description: string) { return z .string() @@ -433,6 +454,15 @@ function registerTools(opts: { until: z.string().describe("End time (RFC3339 or duration)").optional(), }; + const envSelectionInput = { + env: composeIdentifierInput( + "Env overlay name; 'base' or 'default' selects base layers only" + ).optional(), + service: composeIdentifierInput( + "Env scope: 'global' or a compose service name" + ).optional(), + }; + register({ name: "hack.projects.list", title: "List hack projects", @@ -900,6 +930,221 @@ function registerTools(opts: { }, }); + register({ + name: "hack.project.exec", + title: "Exec command in running service", + description: + "Run a command inside an already-running service container (hack exec). Unlike hack.project.run it never starts a new container. Command arguments are passed to the child after `--` and are never parsed as hack options. Output is bounded by the shared 8 MiB capture limit and timeoutMs.", + annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, + inputSchema: { + ...projectSelectorInput, + ...branchInput, + ...profileInput, + service: composeIdentifierInput("Running compose service name"), + cmd: z + .array(z.string()) + .min(1) + .describe("Command and arguments (at least one entry)"), + workdir: z + .string() + .describe("Working directory inside container") + .optional(), + timeoutMs: boundedLimitInput({ + ceiling: MCP_INPUT_CEILINGS.timeoutMs, + description: "Command timeout in ms", + }).optional(), + }, + handler: async (input, extra) => { + const resolved = await resolveProjectArgs({ + selection: toProjectSelection(input), + cwd: opts.context.cwd, + registryPath: opts.context.registryPath, + }); + if (!resolved.ok) { + return buildToolError(resolved); + } + + const timeoutMs = input.timeoutMs ?? DEFAULT_CMD_TIMEOUT_MS; + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.exec", + args: buildExecArgs({ + projectArgs: resolved.args, + branch: input.branch, + profiles: input.profiles, + workdir: input.workdir, + service: input.service, + cmd: input.cmd, + }), + timeoutMs, + }); + + return buildToolResult({ + result, + data: { + limits: { timeoutMs, outputBytes: MCP_OUTPUT_LIMIT_BYTES }, + }, + }); + }, + }); + + register({ + name: "hack.project.doctor", + title: "Diagnose project setup", + description: + "Run read-only setup diagnostics (hack doctor --json). Never applies repairs: --fix and the migration or recovery modes are not reachable from MCP.", + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + ...projectSelectorInput, + ...branchInput, + }, + handler: async (input, extra) => { + const resolved = await resolveProjectArgs({ + selection: toProjectSelection(input), + cwd: opts.context.cwd, + registryPath: opts.context.registryPath, + selectByPath: true, + }); + if (!resolved.ok) { + return buildToolError(resolved); + } + + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.doctor", + args: buildDoctorArgs({ + projectArgs: resolved.args, + branch: input.branch, + }), + }); + + return buildToolResult({ result, data: parseJson(result.stdout) }); + }, + }); + + register({ + name: "hack.project.env.explain", + title: "Explain env key", + description: + "Explain where one env key comes from: availability, secret flag, scope, source and precedence. Never returns the value; child stdout and stderr are withheld.", + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + ...projectSelectorInput, + ...envSelectionInput, + key: envKeyInput("Env key name (letters, digits and _)"), + target: z + .enum(["host", "compose"]) + .describe("Env view: host (default) or compose") + .optional(), + }, + handler: async (input, extra) => { + const resolved = await resolveProjectArgs({ + selection: toProjectSelection(input), + cwd: opts.context.cwd, + registryPath: opts.context.registryPath, + }); + if (!resolved.ok) { + return buildToolError(resolved); + } + + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.env.explain", + args: buildEnvExplainArgs({ + projectArgs: resolved.args, + env: input.env, + service: input.service, + target: input.target, + key: input.key, + }), + }); + + return buildValueFreeToolResult({ + result, + data: projectEnvExplain(parseJson(result.stdout)), + }); + }, + }); + + register({ + name: "hack.project.env.keys", + title: "List env keys", + description: + "List env key names with secret flag, source and scope metadata. Never returns values (never --show-secrets); child stdout and stderr are withheld.", + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + ...projectSelectorInput, + ...envSelectionInput, + }, + handler: async (input, extra) => { + const resolved = await resolveProjectArgs({ + selection: toProjectSelection(input), + cwd: opts.context.cwd, + registryPath: opts.context.registryPath, + }); + if (!resolved.ok) { + return buildToolError(resolved); + } + + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.env.keys", + args: buildEnvKeysArgs({ + projectArgs: resolved.args, + env: input.env, + service: input.service, + }), + }); + + return buildValueFreeToolResult({ + result, + data: projectEnvKeys(parseJson(result.stdout)), + }); + }, + }); + + register({ + name: "hack.project.instances", + title: "List project instances", + description: + "List a registered project's runtime instances from `hack projects --json`: only the default and branch instances that currently have containers (running or stopped); a branch that was never started is not listed. Per-service `containers` and `running` counts include lifecycle-process containers.", + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + ...projectSelectorInput, + }, + handler: async (input, extra) => { + const resolved = await resolveRegisteredProjectName({ + selection: toProjectSelection(input), + cwd: opts.context.cwd, + registryPath: opts.context.registryPath, + }); + if (!resolved.ok) { + return buildToolError(resolved); + } + + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.instances", + args: ["projects", "--json", `--project=${resolved.name}`], + }); + + // `data` replaces stdout: the raw listing covers every matching + // project and carries container inspect data the projection omits. + return buildToolResult({ + result: { ...result, stdout: "" }, + data: projectInstances({ + payload: parseJson(result.stdout), + project: resolved.name, + }), + }); + }, + }); + installTypedToolDispatch({ server: opts.server, tools }); } @@ -1069,6 +1314,30 @@ function buildToolResult(opts: { }; } +/** + * Result for the env tools. The child's stdout and stderr are never returned: + * `stdout` is always empty, and `stderr` is empty on success or the fixed + * {@link MCP_ENV_OUTPUT_WITHHELD_MESSAGE} on failure. Only `data`, already + * projected onto allowlisted metadata, carries the child's answer. Exit status, + * `outputTruncated` and the argv in `command` follow {@link buildToolResult}. + */ +function buildValueFreeToolResult(opts: { + readonly result: HackCommandResult; + readonly data: unknown; +}): CallToolResult { + const ok = opts.result.exitCode === 0; + return buildToolResult({ + result: { + command: opts.result.command, + exitCode: opts.result.exitCode, + stdout: "", + stderr: ok ? "" : MCP_ENV_OUTPUT_WITHHELD_MESSAGE, + ...(opts.result.outputTruncated ? { outputTruncated: true } : {}), + }, + data: opts.data, + }); +} + /** * Logs tools result. Unlike {@link buildToolResult}, success follows the * collection outcome rather than the child's exit status: a cutoff is `ok` @@ -1167,6 +1436,93 @@ export function buildRunArgs(opts: { ]; } +/** + * `hack exec` argv: same boundary as {@link buildRunArgs}. Every option value + * is fused and `--` precedes the service, so `cmd` entries such as `--branch` + * or `--help` reach the container command, never hack's parser. + */ +export function buildExecArgs(opts: { + readonly projectArgs: readonly string[]; + readonly branch?: string; + readonly profiles?: readonly string[]; + readonly workdir?: string; + readonly service: string; + readonly cmd: readonly string[]; +}): string[] { + return [ + "exec", + ...opts.projectArgs, + ...buildBranchArgs({ branch: opts.branch }), + ...buildProfileArgs({ profiles: opts.profiles }), + ...(opts.workdir ? [`--workdir=${opts.workdir}`] : []), + "--", + opts.service, + ...opts.cmd, + ]; +} + +/** + * `hack doctor` argv. The set is closed: project path, branch and `--json`. + * `--fix`, `--migrate-env-config` and the migration and recovery modes are + * never emitted, and the CLI also refuses `--json` with `--fix`. + */ +export function buildDoctorArgs(opts: { + readonly projectArgs: readonly string[]; + readonly branch?: string; +}): string[] { + return [ + "doctor", + ...opts.projectArgs, + ...buildBranchArgs({ branch: opts.branch }), + "--json", + ]; +} + +/** `hack env explain --json` argv; the validated key follows `--`. */ +export function buildEnvExplainArgs(opts: { + readonly projectArgs: readonly string[]; + readonly env?: string; + readonly service?: string; + readonly target?: "host" | "compose"; + readonly key: string; +}): string[] { + return [ + "env", + "explain", + ...opts.projectArgs, + ...buildEnvScopeArgs(opts), + ...(opts.target ? [`--target=${opts.target}`] : []), + "--json", + "--", + opts.key, + ]; +} + +/** `hack env list --json` argv. `--show-secrets` is never emitted. */ +export function buildEnvKeysArgs(opts: { + readonly projectArgs: readonly string[]; + readonly env?: string; + readonly service?: string; +}): string[] { + return [ + "env", + "list", + ...opts.projectArgs, + ...buildEnvScopeArgs(opts), + "--json", + ]; +} + +function buildEnvScopeArgs(opts: { + readonly env?: string; + readonly service?: string; +}): string[] { + return [ + ...(opts.env ? [`--env=${opts.env}`] : []), + ...(opts.service ? [`--service=${opts.service}`] : []), + ]; +} + function buildLogSelectionArgs(input: { readonly loki?: boolean; readonly compose?: boolean; @@ -1225,6 +1581,11 @@ async function resolveProjectArgs(opts: { readonly selection: ProjectSelection; readonly cwd: string; readonly allowUnregistered?: boolean; + /** + * Emit `--path=` for a projectName selection, for commands + * (doctor) that accept `--path` but not `--project`. + */ + readonly selectByPath?: boolean; }): Promise { const selection = opts.selection; const picked = [ @@ -1240,6 +1601,7 @@ async function resolveProjectArgs(opts: { return await resolveProjectNameArgs({ projectName: selection.projectName, registryPath: opts.registryPath, + selectByPath: opts.selectByPath === true, }); } @@ -1282,6 +1644,7 @@ async function resolveProjectArgs(opts: { async function resolveProjectNameArgs(opts: { readonly projectName: string; readonly registryPath: string; + readonly selectByPath: boolean; }): Promise { const name = normalizeProjectName(opts.projectName); if (!name) { @@ -1295,7 +1658,14 @@ async function resolveProjectNameArgs(opts: { if (!project) { return { ok: false, reason: "project_not_found" }; } - return { ok: true, args: [`--project=${name}`] }; + return { + ok: true, + args: [ + opts.selectByPath + ? `--path=${project.projectRoot}` + : `--project=${name}`, + ], + }; } catch (error) { if ( error instanceof AmbiguousProjectNameError || @@ -1307,6 +1677,102 @@ async function resolveProjectNameArgs(opts: { } } +/** + * Resolve a selection to one registered project name, for commands that select + * by name only (`hack projects --project`). A path (or, with no selection, the + * session cwd) selects the registered project whose repo root or project dir + * contains it; the deepest root wins, and two distinct registrations at that + * depth are ambiguous. Unregistered paths are refused rather than guessed. + */ +async function resolveRegisteredProjectName(opts: { + readonly registryPath: string; + readonly selection: ProjectSelection; + readonly cwd: string; +}): Promise { + const selection = opts.selection; + const picked = [ + selection.projectName, + selection.repoRoot, + selection.path, + ].filter((value) => value !== undefined); + if (picked.length > 1) { + return { ok: false, reason: "conflicting_project_selection" }; + } + + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + }); + if (selection.projectName !== undefined) { + const name = normalizeProjectName(selection.projectName); + if (!name) { + return { ok: false, reason: "invalid_project_name" }; + } + try { + const match = selectRegisteredProjectByName({ + projects: registry.projects, + name, + }); + return match + ? { ok: true, name } + : { ok: false, reason: "project_not_found" }; + } catch (error) { + if ( + error instanceof AmbiguousProjectNameError || + error instanceof AmbiguousProjectIdError + ) { + return { ok: false, reason: "ambiguous_project" }; + } + throw error; + } + } + + const pathLike = selection.repoRoot ?? selection.path; + const absPath = resolve(opts.cwd, pathLike ?? "."); + if (pathLike && !(await pathExists(absPath))) { + return { ok: false, reason: "path_not_found" }; + } + const notFound: FixedRefusalReason = pathLike + ? "path_not_registered" + : "no_project_context"; + const containing = selectContainingRegistrations({ + projects: registry.projects, + absPath, + }); + const names = new Set( + containing.map((project) => normalizeProjectName(project.name)) + ); + if (names.size > 1) { + return { ok: false, reason: "ambiguous_project" }; + } + const [name] = [...names]; + return name ? { ok: true, name } : { ok: false, reason: notFound }; +} + +/** Registrations at the deepest root that contains `absPath`. */ +function selectContainingRegistrations(opts: { + readonly projects: readonly RegisteredProject[]; + readonly absPath: string; +}): RegisteredProject[] { + let best: RegisteredProject[] = []; + let bestDepth = -1; + for (const project of opts.projects) { + for (const root of [project.repoRoot, project.projectDir]) { + const resolvedRoot = resolve(root); + if (!isPathWithin({ parent: resolvedRoot, child: opts.absPath })) { + continue; + } + const depth = resolvedRoot.length; + if (depth > bestDepth) { + best = [project]; + bestDepth = depth; + } else if (depth === bestDepth && !best.includes(project)) { + best.push(project); + } + } + } + return best; +} + async function isPathAllowed(opts: { readonly registryPath: string; readonly absPath: string; diff --git a/tests/mcp-inspect-tools.test.ts b/tests/mcp-inspect-tools.test.ts new file mode 100644 index 000000000..b7fa56319 --- /dev/null +++ b/tests/mcp-inspect-tools.test.ts @@ -0,0 +1,992 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { parseCliArgv, resolveCommand } from "../src/cli/command.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { + MCP_ENV_OUTPUT_WITHHELD_MESSAGE, + projectEnvKeys, +} from "../src/mcp/inspect-projections.ts"; +import { MCP_OUTPUT_LIMIT_BYTES } from "../src/mcp/output-budget.ts"; +import { + buildEnvExplainArgs, + buildExecArgs, + createMcpServer, + MCP_INPUT_CEILINGS, +} from "../src/mcp/server.ts"; + +// --------------------------------------------------------------------------- +// HACK-1133: exec, doctor, env.explain, env.keys and instances MCP tools. +// +// Every fixture is a temp root with its own HOME, HACK_HOME and registry. The +// hack binary is a stub selected by HACK_MCP_COMMAND: it records its argv and +// replays a scripted response per command, so no Docker/OrbStack socket, agent +// client config, keychain or real home directory is touched. +// --------------------------------------------------------------------------- + +type ScriptedResponse = { + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; +}; + +/** Keyed by the command word, or `env `. */ +type ScriptedResponses = Readonly>; + +type StubInvocation = { + readonly args: readonly string[]; + readonly noInteractive: string | null; +}; + +type InspectFixture = { + readonly root: string; + readonly repo: string; + readonly client: Client; + readonly script: (responses: ScriptedResponses) => Promise; + readonly invocations: () => Promise; + readonly auditLog: string; +}; + +type FixtureOptions = { + /** Session cwd relative to the fixture root (default: the demo repo). */ + readonly cwdName?: string; + /** Extra registrations, repo roots relative to the fixture root. */ + readonly extraProjects?: readonly { name: string; repoRoot: string }[]; +}; + +const fixtureClients: Client[] = []; +const fixtureRoots: string[] = []; + +afterEach(async () => { + await Promise.all(fixtureClients.splice(0).map((mcp) => mcp.close())); + await Promise.all( + fixtureRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +function registryRow(opts: { readonly name: string; readonly repo: string }) { + return { + id: opts.name, + createdAt: "2026-01-01T00:00:00Z", + name: opts.name, + repoRoot: opts.repo, + projectDirName: ".hack", + projectDir: join(opts.repo, ".hack"), + }; +} + +async function writeProject(opts: { + readonly repo: string; + readonly name: string; +}): Promise { + await mkdir(join(opts.repo, ".hack"), { recursive: true }); + await writeFile( + join(opts.repo, ".hack", "docker-compose.yml"), + "services: {}\n" + ); + await writeFile( + join(opts.repo, ".hack", "hack.config.json"), + JSON.stringify({ name: opts.name }) + ); +} + +async function startFixture( + opts: FixtureOptions = {} +): Promise { + // realpath: macOS tmpdir is a symlink; the registry stores canonical roots. + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-mcp-inspect-")) + ); + fixtureRoots.push(root); + const repo = join(root, "repo"); + const state = join(root, "state"); + const registryDir = join(root, "registry"); + await writeProject({ repo, name: "demo" }); + await mkdir(registryDir); + const extra = opts.extraProjects ?? []; + for (const entry of extra) { + await writeProject({ repo: join(root, entry.repoRoot), name: entry.name }); + } + await writeFile( + join(registryDir, "projects.json"), + JSON.stringify({ + version: 1, + projects: [ + registryRow({ name: "demo", repo }), + ...extra.map((entry) => + registryRow({ name: entry.name, repo: join(root, entry.repoRoot) }) + ), + ], + }) + ); + let cwd = repo; + if (opts.cwdName !== undefined) { + cwd = join(root, opts.cwdName); + await mkdir(cwd, { recursive: true }); + } + const argvLog = join(root, "argv.jsonl"); + const responsesPath = join(root, "responses.json"); + await writeFile(responsesPath, "{}"); + const stub = join(root, "hack-stub"); + await writeFile( + stub, + [ + `#!${process.execPath}`, + 'import { appendFileSync, readFileSync } from "node:fs";', + "const args = Bun.argv.slice(2);", + `appendFileSync(${JSON.stringify(argvLog)}, \`\${JSON.stringify({ args, noInteractive: process.env.HACK_NO_INTERACTIVE ?? null })}\\n\`);`, + `const responses = JSON.parse(readFileSync(${JSON.stringify(responsesPath)}, "utf8"));`, + 'const key = args[0] === "env" ? `env ${args[1]}` : args[0];', + 'const response = responses[key] ?? { stdout: "{\\"ok\\":true}\\n" };', + 'process.stdout.write(response.stdout ?? "");', + 'process.stderr.write(response.stderr ?? "");', + "process.exitCode = response.exitCode ?? 0;", + ].join("\n") + ); + await chmod(stub, 0o755); + const env: Record = { + ...process.env, + HOME: root, + HACK_HOME: state, + HACK_GLOBAL_CONFIG_PATH: join(registryDir, "config.json"), + HACK_MCP_COMMAND: stub, + }; + Reflect.deleteProperty(env, "HACK_NO_INTERACTIVE"); + const server = createMcpServer({ cwd, env }); + const client = new Client({ name: "hack-mcp-inspect", version: "1" }); + fixtureClients.push(client); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return { + root, + repo, + client, + auditLog: join(state, "mcp-audit.log"), + script: async (responses) => { + await writeFile(responsesPath, JSON.stringify(responses)); + }, + invocations: async () => { + if (!(await Bun.file(argvLog).exists())) { + return []; + } + return (await readFile(argvLog, "utf8")) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as StubInvocation); + }, + }; +} + +type ToolPayload = { + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly command: string; + readonly data?: unknown; + readonly spawned?: false; + readonly error?: { code: string; reason: string; message: string }; +}; + +async function call(opts: { + readonly fixture: InspectFixture; + readonly name: string; + readonly arguments: Record; +}): Promise<{ + readonly isError: unknown; + readonly payload: ToolPayload; + readonly serialized: string; +}> { + const result = await opts.fixture.client.callTool({ + name: opts.name, + arguments: opts.arguments, + }); + return { + isError: result.isError, + payload: result.structuredContent as ToolPayload, + serialized: JSON.stringify(result), + }; +} + +// --------------------------------------------------------------------------- +// hack.project.exec +// --------------------------------------------------------------------------- + +test("exec targets the running container with hack exec and ends hack options with --", async () => { + const fixture = await startFixture(); + const exec = await call({ + fixture, + name: "hack.project.exec", + arguments: { + projectName: "demo", + branch: "feature/x", + profiles: ["tools"], + workdir: "/srv/app dir", + service: "api", + cmd: ["sh", "-c", "--help", "--branch", "other", "--version"], + timeoutMs: 5000, + }, + }); + expect(exec.isError).toBeUndefined(); + expect(exec.payload).toMatchObject({ + ok: true, + data: { limits: { timeoutMs: 5000, outputBytes: MCP_OUTPUT_LIMIT_BYTES } }, + }); + const run = await call({ + fixture, + name: "hack.project.run", + arguments: { projectName: "demo", service: "api", cmd: ["true"] }, + }); + expect(run.payload.ok).toBe(true); + expect(await fixture.invocations()).toEqual([ + { + args: [ + "exec", + "--project=demo", + "--branch=feature/x", + "--profile=tools", + "--workdir=/srv/app dir", + "--", + "api", + "sh", + "-c", + "--help", + "--branch", + "other", + "--version", + ], + noInteractive: "1", + }, + // run stays a separate tool and a separate hack command. + { + args: ["run", "--project=demo", "--", "api", "true"], + noInteractive: "1", + }, + ]); +}); + +test("exec echoes the default timeout and the shared output bound", async () => { + const fixture = await startFixture(); + const exec = await call({ + fixture, + name: "hack.project.exec", + arguments: { service: "api", cmd: ["true"] }, + }); + expect(exec.payload).toMatchObject({ + ok: true, + data: { + limits: { timeoutMs: 120_000, outputBytes: MCP_OUTPUT_LIMIT_BYTES }, + }, + }); + // No selection: the session cwd is the demo project, so no project flag. + expect((await fixture.invocations()).map((entry) => entry.args)).toEqual([ + ["exec", "--", "api", "true"], + ]); +}); + +test("hack's own parser reads exec child flags as container argv", () => { + const argv = buildExecArgs({ + projectArgs: ["--project=demo"], + service: "api", + cmd: ["node", "--version", "--branch", "other", "--help"], + }); + const parsed = parseCliArgv(CLI_SPEC, argv); + expect(parsed.values.version).toBeUndefined(); + expect(parsed.values.help).toBeUndefined(); + expect(parsed.values.branch).toBeUndefined(); + expect(parsed.values.project).toBe("demo"); + const resolved = resolveCommand(CLI_SPEC, parsed.positionals); + expect(resolved.command?.name).toBe("exec"); + expect(resolved.remainingPositionals).toEqual([ + "api", + "node", + "--version", + "--branch", + "other", + "--help", + ]); +}); + +const MARKER = "-REFUSED-VALUE-MARKER"; + +type RefusedCall = { + readonly label: string; + readonly name: string; + readonly arguments: Record; +}; + +const refusedCalls: readonly RefusedCall[] = [ + { + label: "exec missing cmd", + name: "hack.project.exec", + arguments: { projectName: "demo", service: "api" }, + }, + { + label: "exec empty cmd", + name: "hack.project.exec", + arguments: { projectName: "demo", service: "api", cmd: [] }, + }, + { + label: "exec leading-dash service", + name: "hack.project.exec", + arguments: { projectName: "demo", service: MARKER, cmd: [MARKER] }, + }, + { + label: "exec empty service", + name: "hack.project.exec", + arguments: { projectName: "demo", service: "", cmd: ["true"] }, + }, + { + label: "exec leading-dash branch", + name: "hack.project.exec", + arguments: { service: "api", cmd: ["true"], branch: MARKER }, + }, + { + label: "exec leading-dash profile", + name: "hack.project.exec", + arguments: { service: "api", cmd: ["true"], profiles: [MARKER] }, + }, + ...[0, -1, 1.5, MCP_INPUT_CEILINGS.timeoutMs + 1].map((timeoutMs) => ({ + label: `exec timeoutMs=${timeoutMs}`, + name: "hack.project.exec", + arguments: { service: "api", cmd: ["true"], timeoutMs }, + })), + { + label: "doctor leading-dash branch", + name: "hack.project.doctor", + arguments: { branch: MARKER }, + }, + { + label: "explain missing key", + name: "hack.project.env.explain", + arguments: {}, + }, + { + label: "explain leading-dash key", + name: "hack.project.env.explain", + arguments: { key: MARKER }, + }, + { + label: "explain key with assignment", + name: "hack.project.env.explain", + arguments: { key: "TOKEN=ZQXMARKERVALUE" }, + }, + { + label: "explain dotted key", + name: "hack.project.env.explain", + arguments: { key: "api.ZQXMARKERKEY" }, + }, + { + label: "explain unknown target", + name: "hack.project.env.explain", + arguments: { key: "PORT", target: "ZQXMARKERTARGET" }, + }, + { + label: "explain leading-dash env", + name: "hack.project.env.explain", + arguments: { key: "PORT", env: MARKER }, + }, + { + label: "keys leading-dash service", + name: "hack.project.env.keys", + arguments: { service: MARKER }, + }, +]; + +test("invalid exec, doctor and env inputs are typed refusals that never spawn or audit", async () => { + const fixture = await startFixture(); + for (const refused of refusedCalls) { + const result = await call({ + fixture, + name: refused.name, + arguments: refused.arguments, + }); + expect({ label: refused.label, isError: result.isError }).toEqual({ + label: refused.label, + isError: true, + }); + expect(result.payload).toMatchObject({ + ok: false, + spawned: false, + command: "", + exitCode: 1, + stdout: "", + error: { code: "E_USAGE", reason: "invalid_arguments" }, + }); + // Closed diagnostics: the rejected value is never echoed back. + expect(result.serialized).not.toContain(MARKER); + expect(result.serialized).not.toContain("ZQXMARKER"); + } + expect(await fixture.invocations()).toEqual([]); + expect(await Bun.file(fixture.auditLog).exists()).toBe(false); +}); + +// --------------------------------------------------------------------------- +// hack.project.doctor +// --------------------------------------------------------------------------- + +const DOCTOR_REPORT = { + ok: true, + data: { + checks: [{ id: "bun", status: "ok", detail: "found", durationMs: 1 }], + summary: [], + counts: { ok: 1, warn: 0, error: 0 }, + }, +}; + +test("doctor runs doctor --json only, selecting a named project by path", async () => { + const fixture = await startFixture(); + await fixture.script({ + doctor: { stdout: `${JSON.stringify(DOCTOR_REPORT)}\n` }, + }); + const named = await call({ + fixture, + name: "hack.project.doctor", + arguments: { projectName: "demo", branch: "feature/x" }, + }); + expect(named.isError).toBeUndefined(); + expect(named.payload).toMatchObject({ ok: true, data: DOCTOR_REPORT }); + // Repair and migration inputs are not in the schema; they are dropped + // before argv construction and can never reach the child. + const repair = await call({ + fixture, + name: "hack.project.doctor", + arguments: { + fix: true, + migrateEnvConfig: true, + domainMigration: "apply", + nativeRunMapping: "repair", + }, + }); + expect(repair.payload.ok).toBe(true); + const invocations = await fixture.invocations(); + expect(invocations.map((entry) => entry.args)).toEqual([ + ["doctor", `--path=${fixture.repo}`, "--branch=feature/x", "--json"], + ["doctor", "--json"], + ]); + for (const entry of invocations) { + expect(entry.noInteractive).toBe("1"); + expect(entry.args.join(" ")).not.toMatch( + /--fix|--migrate|--domain-migration|--native-|--accept-legacy/ + ); + } +}); + +test("doctor reports failing checks as ok:false while keeping the parsed report", async () => { + const fixture = await startFixture(); + const failing = { + ok: true, + data: { ...DOCTOR_REPORT.data, counts: { ok: 0, warn: 0, error: 1 } }, + }; + await fixture.script({ + doctor: { stdout: `${JSON.stringify(failing)}\n`, exitCode: 1 }, + }); + const result = await call({ + fixture, + name: "hack.project.doctor", + arguments: { path: "." }, + }); + expect(result.isError).toBe(true); + expect(result.payload).toMatchObject({ + ok: false, + exitCode: 1, + data: failing, + }); + expect((await fixture.invocations()).map((entry) => entry.args)).toEqual([ + ["doctor", `--path=${fixture.repo}`, "--json"], + ]); +}); + +// --------------------------------------------------------------------------- +// hack.project.env.explain / hack.project.env.keys +// --------------------------------------------------------------------------- + +const SECRET_CANARY = "zqx-secret-canary-7731"; + +const EXPLAIN_PAYLOAD = { + key: "DATABASE_URL", + available: true, + secret: true, + format: "project_env_config_v1", + environment: "dev", + requested_scope: "api", + effective_scope: "api", + target: "compose", + delivered_to: ["compose"], + source: "project_env_config", + precedence: [".hack/hack.env.default.yaml", ".hack/hack.env.dev.yaml"], +}; + +test("env.explain forwards only allowlisted metadata and withholds child output", async () => { + const fixture = await startFixture(); + await fixture.script({ + "env explain": { + stdout: `${JSON.stringify({ + ...EXPLAIN_PAYLOAD, + value: SECRET_CANARY, + debug: { resolved: SECRET_CANARY }, + })}\n`, + stderr: `resolved ${SECRET_CANARY}\n`, + }, + }); + const result = await call({ + fixture, + name: "hack.project.env.explain", + arguments: { + projectName: "demo", + env: "dev", + service: "api", + target: "compose", + key: "DATABASE_URL", + }, + }); + expect(result.isError).toBeUndefined(); + expect(result.payload).toMatchObject({ + ok: true, + stdout: "", + stderr: "", + data: EXPLAIN_PAYLOAD, + }); + expect(result.serialized).not.toContain(SECRET_CANARY); + expect((await fixture.invocations()).map((entry) => entry.args)).toEqual([ + [ + "env", + "explain", + "--project=demo", + "--env=dev", + "--service=api", + "--target=compose", + "--json", + "--", + "DATABASE_URL", + ], + ]); +}); + +test("hack's own parser reads the env.explain key as the explain positional", () => { + const parsed = parseCliArgv( + CLI_SPEC, + buildEnvExplainArgs({ projectArgs: [], key: "_PORT" }) + ); + expect(parsed.values.json).toBe(true); + const resolved = resolveCommand(CLI_SPEC, parsed.positionals); + expect(resolved.command?.name).toBe("explain"); + expect(resolved.remainingPositionals).toEqual(["_PORT"]); +}); + +test("a failed env call returns fixed stderr and never the child's output", async () => { + const fixture = await startFixture(); + const failure = { + stdout: `partial ${SECRET_CANARY}\n`, + stderr: `YAML parse error near: TOKEN: ${SECRET_CANARY}\n`, + exitCode: 1, + }; + await fixture.script({ "env explain": failure, "env list": failure }); + for (const [name, args] of [ + ["hack.project.env.explain", { key: "TOKEN" }], + ["hack.project.env.keys", {}], + ] as const) { + const result = await call({ fixture, name, arguments: args }); + expect(result.isError).toBe(true); + expect(result.payload).toMatchObject({ + ok: false, + exitCode: 1, + stdout: "", + stderr: MCP_ENV_OUTPUT_WITHHELD_MESSAGE, + data: null, + }); + expect(result.serialized).not.toContain(SECRET_CANARY); + } + expect(await fixture.invocations()).toHaveLength(2); +}); + +const ENV_LIST_PAYLOAD = { + project: "demo", + env_selection: { + requested: null, + default: null, + effective: "dev", + overlay_path: "/repo/.hack/hack.env.dev.yaml", + overlay_exists: true, + }, + format: "project_env_config_v1", + scopes: ["global", "api"], + selected_scope: "global", + storage: { local_plaintext: { path: "/repo/.hack/.env", exists: true } }, + warnings: [{ kind: "shadowed", key: "PORT", message: SECRET_CANARY }], + vars: [ + { + key: "PORT", + required: false, + source: "plain_env", + services: null, + resolved_from: "project_env_config", + storage: { kind: "plaintext", location: SECRET_CANARY }, + value: SECRET_CANARY, + }, + { + key: "API_TOKEN", + required: true, + source: "keychain", + services: ["api"], + resolved_from: "project_env_config", + value: SECRET_CANARY, + }, + { key: `bad key ${SECRET_CANARY}`, source: "plain_env", value: "x" }, + "not-a-row", + ], + missing_required: ["API_TOKEN"], + materialized: { PORT: SECRET_CANARY }, + materialized_keys: ["PORT"], +}; + +const ENV_KEYS_DATA = { + format: "project_env_config_v1", + environment: "dev", + selected_scope: "global", + scopes: ["global", "api"], + keys: [ + { + key: "PORT", + secret: false, + required: false, + source: "plain_env", + resolved_from: "project_env_config", + services: null, + }, + { + key: "API_TOKEN", + secret: true, + required: true, + source: "keychain", + resolved_from: "project_env_config", + services: ["api"], + }, + ], + missing_required: ["API_TOKEN"], +}; + +test("env.keys lists key metadata only and never requests or returns values", async () => { + const fixture = await startFixture(); + await fixture.script({ + "env list": { + stdout: `${JSON.stringify(ENV_LIST_PAYLOAD)}\n`, + stderr: `warning: ${SECRET_CANARY}\n`, + }, + }); + const result = await call({ + fixture, + name: "hack.project.env.keys", + arguments: { path: ".", env: "dev", showSecrets: true }, + }); + expect(result.isError).toBeUndefined(); + expect(result.payload).toMatchObject({ + ok: true, + stdout: "", + stderr: "", + }); + expect(result.payload.data).toEqual(ENV_KEYS_DATA); + expect(result.serialized).not.toContain(SECRET_CANARY); + const invocations = await fixture.invocations(); + expect(invocations.map((entry) => entry.args)).toEqual([ + ["env", "list", `--path=${fixture.repo}`, "--env=dev", "--json"], + ]); + expect(invocations[0]?.args).not.toContain("--show-secrets"); +}); + +test("negative control: the scripted env payload does carry values that a raw tool returns", async () => { + // The projection itself is what removes values from the payload ... + expect(JSON.stringify(ENV_LIST_PAYLOAD)).toContain(SECRET_CANARY); + expect(JSON.stringify(projectEnvKeys(ENV_LIST_PAYLOAD))).not.toContain( + SECRET_CANARY + ); + // ... and the same stub output reaches a raw-forwarding tool unchanged, so + // the env tools' absence of the canary is not a stub or transport artifact. + const fixture = await startFixture(); + await fixture.script({ + projects: { stdout: `${JSON.stringify(ENV_LIST_PAYLOAD)}\n` }, + }); + const raw = await call({ + fixture, + name: "hack.projects.list", + arguments: {}, + }); + expect(raw.serialized).toContain(SECRET_CANARY); +}); + +// --------------------------------------------------------------------------- +// hack.project.instances +// --------------------------------------------------------------------------- + +function runtime(opts: { + readonly project: string; + readonly services: Record; +}) { + return { + project: opts.project, + working_dir: "/repo", + services: Object.entries(opts.services).map(([service, states]) => ({ + service, + containers: states.map((state, index) => ({ + id: `${service}-${index}`, + state, + labels: { secretish: "label" }, + mounts: [{ source: "/host/path" }], + })), + })), + }; +} + +const PROJECTS_PAYLOAD = { + runtime_ok: true, + projects: [ + { + name: "other", + runtime: runtime({ project: "other", services: { web: ["running"] } }), + branch_runtime: [], + }, + { + name: "demo", + runtime: runtime({ + project: "demo", + services: { api: ["running"], db: ["running", "exited"] }, + }), + branch_runtime: [ + { + branch: "feature-x", + runtime: runtime({ + project: "demo--feature-x", + services: { api: ["exited"] }, + }), + }, + { branch: "broken", runtime: null }, + ], + }, + ], +}; + +test("instances lists the default and branch instances of the selected project", async () => { + const fixture = await startFixture(); + await fixture.script({ + projects: { stdout: `${JSON.stringify(PROJECTS_PAYLOAD)}\n` }, + }); + const result = await call({ + fixture, + name: "hack.project.instances", + arguments: { projectName: "Demo" }, + }); + expect(result.isError).toBeUndefined(); + expect(result.payload.data).toEqual({ + project: "demo", + found: true, + runtime_ok: true, + instances: [ + { + branch: null, + compose_project: "demo", + working_dir: "/repo", + services: [ + { service: "api", containers: 1, running: 1 }, + { service: "db", containers: 2, running: 1 }, + ], + }, + { + branch: "feature-x", + compose_project: "demo--feature-x", + working_dir: "/repo", + services: [{ service: "api", containers: 1, running: 0 }], + }, + ], + }); + expect(result.serialized).not.toContain("/host/path"); + expect((await fixture.invocations()).map((entry) => entry.args)).toEqual([ + ["projects", "--json", "--project=demo"], + ]); +}); + +test("instances resolves paths and the session cwd to the deepest registration", async () => { + const fixture = await startFixture({ + extraProjects: [{ name: "inner", repoRoot: "repo/packages/inner" }], + }); + await mkdir(join(fixture.repo, "src"), { recursive: true }); + await fixture.script({ + projects: { stdout: `${JSON.stringify(PROJECTS_PAYLOAD)}\n` }, + }); + const fromCwd = await call({ + fixture, + name: "hack.project.instances", + arguments: {}, + }); + const fromSubdir = await call({ + fixture, + name: "hack.project.instances", + arguments: { path: "src" }, + }); + const nested = await call({ + fixture, + name: "hack.project.instances", + arguments: { repoRoot: resolve(fixture.repo, "packages/inner") }, + }); + expect(fromCwd.payload.data).toMatchObject({ project: "demo", found: true }); + expect(fromSubdir.payload.data).toMatchObject({ + project: "demo", + found: true, + }); + // "inner" is registered but absent from the runtime listing. + expect(nested.payload.data).toEqual({ + project: "inner", + found: false, + runtime_ok: true, + instances: [], + }); + expect((await fixture.invocations()).map((entry) => entry.args)).toEqual([ + ["projects", "--json", "--project=demo"], + ["projects", "--json", "--project=demo"], + ["projects", "--json", "--project=inner"], + ]); +}); + +test("instances refuses unregistered or ambiguous selections before spawning", async () => { + const fixture = await startFixture({ + cwdName: "outside", + extraProjects: [ + { name: "twin-a", repoRoot: "shared" }, + { name: "twin-b", repoRoot: "shared" }, + ], + }); + const cases = [ + { arguments: {}, reason: "no_project_context" }, + { arguments: { path: "." }, reason: "path_not_registered" }, + { arguments: { path: "missing-dir" }, reason: "path_not_found" }, + { + arguments: { path: join(fixture.root, "shared") }, + reason: "ambiguous_project", + }, + { arguments: { projectName: "absent" }, reason: "project_not_found" }, + { arguments: { projectName: "%%%" }, reason: "invalid_project_name" }, + { + arguments: { projectName: "demo", path: "." }, + reason: "conflicting_project_selection", + }, + ] as const; + for (const entry of cases) { + const result = await call({ + fixture, + name: "hack.project.instances", + arguments: entry.arguments, + }); + expect({ reason: entry.reason, isError: result.isError }).toEqual({ + reason: entry.reason, + isError: true, + }); + expect(result.payload).toMatchObject({ + ok: false, + spawned: false, + error: { reason: entry.reason }, + }); + expect(result.serialized).not.toContain(fixture.root); + } + expect(await fixture.invocations()).toEqual([]); + expect(await Bun.file(fixture.auditLog).exists()).toBe(false); +}); + +// --------------------------------------------------------------------------- +// Discovery and documentation contract +// --------------------------------------------------------------------------- + +const READ_ONLY = { readOnlyHint: true, openWorldHint: false }; +const DESTRUCTIVE = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, +}; + +test("tools/list annotates inspection tools read-only and exec destructive", async () => { + const fixture = await startFixture(); + const listed = await fixture.client.listTools(); + const byName = new Map(listed.tools.map((tool) => [tool.name, tool])); + const expectations = [ + ["hack.project.exec", DESTRUCTIVE, ["service", "cmd"]], + ["hack.project.doctor", READ_ONLY, []], + ["hack.project.env.explain", READ_ONLY, ["key"]], + ["hack.project.env.keys", READ_ONLY, []], + ["hack.project.instances", READ_ONLY, []], + ] as const; + for (const [name, annotations, required] of expectations) { + const tool = byName.get(name); + expect({ name, annotations: tool?.annotations }).toMatchObject({ + name, + annotations, + }); + expect(tool?.inputSchema.required ?? []).toEqual([...required]); + } + const doctor = byName.get("hack.project.doctor"); + expect(Object.keys(doctor?.inputSchema.properties ?? {}).sort()).toEqual([ + "branch", + "path", + "projectName", + "repoRoot", + ]); + const keys = byName.get("hack.project.env.keys"); + expect(Object.keys(keys?.inputSchema.properties ?? {})).not.toContain( + "showSecrets" + ); + expect( + byName.get("hack.project.env.explain")?.inputSchema.properties?.key + ).toMatchObject({ type: "string", pattern: "^[A-Za-z_][A-Za-z0-9_]*$" }); +}); + +test("docs/reference/mcp-tools.md documents every listed tool and its annotation class", async () => { + const fixture = await startFixture(); + const listed = await fixture.client.listTools(); + const doc = await readFile( + resolve(import.meta.dir, "../docs/reference/mcp-tools.md"), + "utf8" + ); + const rows = new Map( + doc + .split("\n") + .filter((line) => line.startsWith("| `hack.")) + .map((line) => { + const cells = line.split("|").map((cell) => cell.trim()); + return [cells[1]?.replaceAll("`", "") ?? "", cells[2] ?? ""] as const; + }) + ); + const classOf = (annotations: unknown): string => { + const value = annotations as { + readOnlyHint?: boolean; + destructiveHint?: boolean; + }; + if (value.readOnlyHint) { + return "read-only"; + } + return value.destructiveHint ? "destructive" : "additive"; + }; + expect([...rows.keys()].sort()).toEqual( + listed.tools.map((tool) => tool.name).sort() + ); + for (const tool of listed.tools) { + expect({ name: tool.name, class: rows.get(tool.name) }).toEqual({ + name: tool.name, + class: classOf(tool.annotations), + }); + } +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index e6570aa68..1dfce8c04 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -783,6 +783,34 @@ test("tools/list publishes names, annotations and required inputs for every tool annotations: { title: "Initialize project", ...DESTRUCTIVE }, required: [], }, + { + name: "hack.project.exec", + annotations: { + title: "Exec command in running service", + ...DESTRUCTIVE, + }, + required: ["service", "cmd"], + }, + { + name: "hack.project.doctor", + annotations: { title: "Diagnose project setup", ...READ_ONLY }, + required: [], + }, + { + name: "hack.project.env.explain", + annotations: { title: "Explain env key", ...READ_ONLY }, + required: ["key"], + }, + { + name: "hack.project.env.keys", + annotations: { title: "List env keys", ...READ_ONLY }, + required: [], + }, + { + name: "hack.project.instances", + annotations: { title: "List project instances", ...READ_ONLY }, + required: [], + }, ]); const run = listed.tools.find((tool) => tool.name === "hack.project.run"); expect(run?.inputSchema.properties?.timeoutMs).toMatchObject({