Skip to main content
Glama
README.md
<img src="https://raw.githubusercontent.com/peter4leadson/local-workspace-mcp/main/assets/local-workspace-mcp-icon-128.png" alt="Local Workspace MCP icon" width="72" height="72" align="right">

# local-workspace-mcp

Your AI assistant needs context. It does not need unrestricted access.

A read-oriented MCP server for local workspaces. Write, delete, and
arbitrary-shell tools do not exist in it. Not gated or disabled: absent. Every
filesystem path is checked against operator-authorized roots and a
default-deny list for secrets, `.env` files, private keys, and `.git`
internals. Git read tools and operator-declared named tasks provide
bounded capability without giving the model a shell.

- **23 tools** over stdio JSON-RPC: bounded filesystem reads/searches,
  read-only git (status/diff/log/show/branches), multimodal file inspection
  (images/archives/PDF/Office), and `task_run` for
  explicitly allowlisted commands.
- **One transport:** each MCP host spawns `workspace-mcp serve --stdio`.
  The server itself has no inbound listener, keeps no daemon, and makes no
  network calls (allowlisted tasks run as your user and are not
  network-restricted; the ChatGPT tunnel path below runs a supervised
  `tunnel-client` process instead of a host-spawned one).
- **Honest scope:** `task_run` executes real commands as your user. The
  allowlist bounds _what can be invoked_, not what invoked code can do.
  It is not a sandbox.

Evaluating this for team use? Start at [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md)
and [SECURITY.md](SECURITY.md).

**Contents:** [The problem](#the-problem) · [Quick start](#quick-start) ·
[Usage](#usage-examples) · [Tool surface](#tool-surface) ·
[Supported hosts](#supported-hosts) · [Permission model](#permission-model) ·
[What it will not do](#what-it-will-not-do) ·
[Security architecture](#security-architecture) ·
[Configuration](#configuration-reference) · [Troubleshooting](#troubleshooting) ·
[Compatibility](#compatibility) · [Verifying the build](#verifying-the-build) ·
[Release status](#release-status)

## The problem

Hosts' built-in file tools are convenient but live in the client's
permission loop, the same loop users bypass out of fatigue
(`--dangerously-skip-permissions`, "yes to everything"). The official
`@modelcontextprotocol/server-filesystem` ships read **and** write tools
with no default secrets denylist, and has already overwritten a user's
`.env` ([upstream issue #1869](https://github.com/modelcontextprotocol/servers/issues/1869)). Generic filesystem MCPs give a model file
access; they do not give it _bounded_ access.

This server moves the boundary server-side: roots, deny rules, task
allowlists, and audit live in an operator config that repository content
cannot widen. A compromised or bypassed prompt loop cannot invoke a tool
that does not exist.

## Quick start

Requires Node ≥ 20, plus `git` for `git_*` tools and ripgrep (`rg`) for
content search. Building from a clone needs pnpm (`corepack enable`).

```sh
npm install -g local-workspace-mcp          # puts workspace-mcp on PATH

# or from a clone:
pnpm install && pnpm build && npm link

workspace-mcp init-config     # writes ~/.config/local-workspace-mcp/config.json (mode 600)

# REQUIRED: replace the example workspace path with a real absolute path —
# any editor works; doctor fails until you do
nano ~/.config/local-workspace-mcp/config.json

workspace-mcp doctor          # must print "doctor: ALL GREEN" (use --json for CI)
```

The generated config ships a placeholder workspace named `example` —
replace it with a real root or `doctor` will report `root.example` FAIL.
A healthy run ends:

```text
PASS policy.selftest — 9/9 cases correct
PASS mcp.startup — tool registration ok
doctor: ALL GREEN
```

Minimal working config (strict JSON — no comments or trailing commas):

```json
{
  "version": 1,
  "workspaces": {
    "myproj": { "path": "/absolute/path/to/repo", "tasks": [] }
  },
  "tasks": {},
  "deny": []
}
```

Then register the server with a host. For Claude Code:

```sh
claude mcp add local-workspace --scope user -- workspace-mcp serve --stdio
claude mcp list        # confirm it registered
```

Other hosts use config-file entries (below — substitute the absolute path
from `which workspace-mcp`). Then ask: _"use workspace_roots, then
fs_list on myproj"_. First useful call sequence: `fs_list` → `git_status`
→ `git_diff` on uncommitted work, where a chat client otherwise
has no eyes.

## Usage examples

```text
# what the assistant can ask for
workspace_roots                        # authorized workspace ids, no host paths
fs_list   {workspace:"myproj"}
fs_read   {workspace:"myproj", path:"src/index.ts", startLine:1, maxLines:120}
fs_search_content {workspace:"myproj", query:"TODO"}        # literal; regex:true for rg syntax
git_status {workspace:"myproj"}        # branch, HEAD, uncommitted truth
git_diff  {workspace:"myproj"}         # worktree diff, bounded output
git_show  {workspace:"myproj", spec:"HEAD"}
task_list {workspace:"myproj"}
task_run  {workspace:"myproj", taskId:"typecheck"}
```

Denied behavior is explicit, never silent:

```text
fs_read {workspace:"myproj", path:".env"}              → ACCESS_DENIED  (or NOT_FOUND if absent)
fs_read {workspace:"myproj", path:"../../outside-root.txt"} → OUTSIDE_ROOT
task_run {workspace:"myproj", taskId:"nuke"}           → TASK_DENIED    (valid id, not enabled)
task_run {workspace:"myproj", taskId:"rm -rf /"}       → INVALID_ARGUMENT (malformed task id)
git_show {workspace:"myproj", spec:"--exec"}           → INVALID_ARGUMENT
```

Denial codes are the policy working as intended, not errors to report;
widening access happens only in the operator config. Task/search timeouts
and output limits return `timedOut:true`/`truncated:true` in a normal
result rather than an error. `INTERNAL_ERROR` covers spawn/tool failures and should not
appear in healthy use; `git_*` on a non-git root returns `{repo:false}`
gracefully.

## Tool surface

All 23 tools, annotated `readOnlyHint` where true (hosts can auto-approve
pure reads). `task_run` is the only tool with side effects.

| Tool                | Scope         | Notes                                                                                                                                |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `fs_inspect`        | one file      | magic-byte kind detection (image/zip/ooxml/pdf/text/binary), dims, sha256, `openWith` hints                                          |
| `fs_image`          | one image     | native MCP `image` block (original bytes by default; `frame`/`fitMaxDim`/`format` transforms disclosed); svg scanned then rasterized |
| `fs_contact_sheet`  | ≤24 images    | labeled montage; per-item authz + per-item failure cells; bounded output                                                             |
| `fs_archive_list`   | one zip       | member table (sizes, ratio, encrypted, denied/unsafe flags); paginated, capped                                                       |
| `fs_archive_entry`  | one member    | image → native block, text → bounded text, other → refusal; traversal/symlink/nested members fail closed                             |
| `fs_pdf_info`       | one pdf       | page count, text-layer presence (PDFium/WASM)                                                                                        |
| `fs_pdf_page`       | ≤4 pdf pages  | rendered pages as native image blocks; pixel-bounded                                                                                 |
| `fs_pdf_text`       | ≤50 pdf pages | bounded text extraction; image-only pages reported honestly                                                                          |
| `fs_office_text`    | one ooxml     | docx paragraphs / pptx slides / xlsx cells; macros flagged never executed; legacy OLE refused                                        |
| `workspace_roots`   | reads config  | workspace ids + availability; never host paths                                                                                       |
| `fs_list`           | one directory | bounded, paginated; denied entries flagged by class                                                                                  |
| `fs_stat`           | one path      | metadata; symlink resolution disclosed                                                                                               |
| `fs_read`           | one file      | line-range, byte-capped; binary refused                                                                                              |
| `fs_read_many`      | batch         | per-file inline errors, total cap                                                                                                    |
| `fs_search_files`   | filenames     | glob; git file universe where a repo exists (honors .gitignore at the repo toplevel); bounded directory walk otherwise               |
| `fs_search_content` | file contents | ripgrep `--json`; literal or `regex:true`; time/count bounded                                                                        |
| `git_status`        | repo          | branch, HEAD, staged/modified/deleted/renamed/untracked                                                                              |
| `git_diff`          | repo          | worktree/staged/ref diff; bounded; denied paths redacted                                                                             |
| `git_log`           | repo          | ≤100 commits                                                                                                                         |
| `git_show`          | repo          | commits/tags and `ref:path` blobs; strict ref validation                                                                             |
| `git_branches`      | repo          | branches, upstreams, worktrees (host paths redacted)                                                                                 |
| `task_list`         | config        | task ids enabled per workspace                                                                                                       |
| `task_run`          | subprocess    | allowlisted argv only; `shell:false`; caps on time/output                                                                            |

## Supported hosts

| Host                    | Mechanism                                                                                                                    | Status                                     |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| Claude Code             | `claude mcp add local-workspace --scope user -- workspace-mcp serve --stdio`; confirm with `claude mcp list`                 | verified                                   |
| Claude Desktop          | `mcpServers` entry in `claude_desktop_config.json`; restart app                                                              | configured                                 |
| Codex CLI               | `mcp_servers` stdio entry in `~/.codex/config.toml`                                                                          | supported                                  |
| ChatGPT / Responses API | OpenAI Secure MCP Tunnel (`openai/tunnel-client`, outbound-only); see the tunnel notes and cloud disclosure below this table | ChatGPT image verified; Responses untested |
| MCP Inspector           | `scripts/inspector-smoke.sh` battery, 17 checks                                                                              | verified                                   |

GUI hosts (Claude Desktop especially) spawn servers with a minimal PATH,
not your shell's. If a host reports ENOENT or stays disconnected while
`doctor` is green, the binary is not on the host's PATH. Use the absolute
path (`which workspace-mcp`) as the command and check the host's MCP log.

Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json`
on macOS):

```json
{
  "mcpServers": {
    "local-workspace": {
      "command": "/absolute/path/to/workspace-mcp",
      "args": ["serve", "--stdio"]
    }
  }
}
```

Codex CLI (`~/.codex/config.toml`, or `codex mcp add`):

```toml
[mcp_servers.local-workspace]
command = "/absolute/path/to/workspace-mcp"
args = ["serve", "--stdio"]
```

ChatGPT / Responses API use the OpenAI Secure MCP Tunnel: `tunnel-client`
polls an outbound HTTPS path and spawns the stdio command locally; no
inbound network listener opens (tunnel-client optionally binds a
loopback-only health endpoint — see docs/OPERATIONS.md).

Three values are involved — keep them straight (OpenAI's own docs warn
they are commonly conflated): `tunnel_…` is an identifier, not a secret;
the **Runtime API key** is the only secret the daemon needs (its
`env:` name is `CONTROL_PLANE_API_KEY`); an **Admin API key** is needed
only to create or manage tunnels from the CLI — not here.

```sh
# 1. Install tunnel-client (github.com/openai/tunnel-client).
# 2. In the OpenAI platform console, create a tunnel and a Runtime API key
#    (Tunnels permission: Read+Use to run/select one, Read+Manage to create):
#      https://platform.openai.com/settings/organization/tunnels
#      https://platform.openai.com/settings/organization/api-keys
# 3. Store the key where the daemon can read it unattended. On macOS, put it
#    in the login keychain and materialize a 0600 file the profile will
#    reference via `file:` (scripts/launchd/install.sh does both steps
#    against this exact location; on other platforms write the file
#    directly). Never put the raw key in the profile YAML, a plist, or on a
#    command line.
security add-generic-password -s tunnel-client-control-plane -a "$USER" -w
mkdir -p "$HOME/Library/Application Support/tunnel-client/secrets"
chmod 700 "$HOME/Library/Application Support/tunnel-client/secrets"
security find-generic-password -s tunnel-client-control-plane -a "$USER" -w \
  > "$HOME/Library/Application Support/tunnel-client/secrets/control-plane-api-key"
chmod 600 "$HOME/Library/Application Support/tunnel-client/secrets/control-plane-api-key"
# 4. Write the profile, validate it, and start the managed runtime
#    (attach to the existing tunnel id — no admin key needed):
tunnel-client init --profile local-workspace \
  --tunnel-id <tunnel_…> \
  --mcp-command "$(command -v workspace-mcp) serve --stdio" \
  --control-plane-api-key-ref file:"$HOME/Library/Application Support/tunnel-client/secrets/control-plane-api-key"
tunnel-client doctor --profile local-workspace --explain
tunnel-client runtimes connect --alias local-workspace \
  --profile local-workspace --tunnel-id <tunnel_…> \
  --runtime-api-key file:"$HOME/Library/Application Support/tunnel-client/secrets/control-plane-api-key"
tunnel-client runtimes status local-workspace --json
#    expect process_running=true, healthy=true, ready=true
# 5. In ChatGPT: https://chatgpt.com/#settings/Connectors → add connector →
#    Connection: Tunnel → select the tunnel (or paste its id) while the
#    daemon is running. A tunnel created in Platform does not appear in
#    ChatGPT until you bind a connector to it here.
```

macOS persistence: `scripts/launchd/install.sh` installs a per-user
LaunchAgent that keeps the managed runtime alive across restarts, logins,
app restarts, process death, network drops, and sleep/wake — no manual
restarts. It expects the keychain item from step 3
(`tunnel-client-control-plane`, account `$USER`) and materializes the
0600 file itself. Requires `jq` on PATH. See
[docs/OPERATIONS.md §Persistence](docs/OPERATIONS.md#persistence-macos-launchd).
Operator runbook: [docs/OPERATIONS.md](docs/OPERATIONS.md).

ChatGPT host validation (2026-10-11): after connector refresh, a live
connection discovered all 23 tools and consumed a native `fs_image` image
block. A synthetic visual canary was described from its pixels, independently
of file metadata. PDF and Office tools are advertised, but their individual
ChatGPT-side extraction/rendering paths have not yet been sampled. Future
tool-schema updates may require another manual connector refresh; every image
tool also returns metadata for hosts that do not expose visual content.

Cloud disclosure: over the ChatGPT tunnel path, tool outputs — including
image bytes and extracted document text — transit OpenAI's connector
infrastructure to reach the model. That is the point of the integration,
but it means anything the boundary allows the model to read also leaves
the machine for that host. Keep roots and deny rules tight on any
workspace served to a cloud connector.

## Permission model

- **Roots are explicit.** Filesystem tools cannot read anything outside
  `workspaces[].path`;
  `~`, control characters, Windows drive paths, and `..` traversal are
  rejected before any syscall, and again after `realpath`. (Allowlisted
  `task_run` commands are the one exception — they run as you outside the
  root, so allowlist only commands you would run.) Point roots at the
  projects you actually work on; never authorize `~/` or a parent
  directory that contains things the assistant should not read.
- **Denied by default,** including: `.env*`/`*.env` spellings (except
  `.env[.*].{example,sample,template}`), `.envrc`, private-key filenames,
  `.pem/.key/.p12/.pfx/.jks/.kdbx`, `.ssh`, `.aws`, `.azure`, `.gnupg`,
  `.kube`/`kubeconfig`, `.docker`, `.npmrc`, `.netrc`, `.pgpass`, `.pypirc`,
  `.git-credentials`/`.gitconfig`, shell histories, `*.tfvars`,
  `secrets.{json,yaml,yml,toml}`/`.secrets`/`secrets.d`,
  `credentials`/`service-account*.json`/`gha-creds-*.json`, and all `.git`
  internals. Representative list — the authoritative rules are
  `DEFAULT_DENY` in `src/policy.ts` (repository); denies are fail-closed.
- **Name is not the only boundary:** a file containing `BEGIN … PRIVATE KEY`
  armor under a benign name is refused in reads, `git_show` blobs, and
  search previews. The scan is heuristic defense-in-depth (encoding and
  size-budget limits documented in the threat model); name/policy denies
  are the primary boundary.
- **Git is read-only and hardened:** argv-only spawns, `--end-of-options`,
  strict ref validation, literal pathspecs, `GIT_TERMINAL_PROMPT=0`.
  Inside allowlisted tasks the headline repo-config exec keys are pinned
  via `GIT_CONFIG_*` overrides (`core.fsmonitor`, `core.sshCommand`,
  `diff.external`, `core.hooksPath`; per-driver
  `diff.<name>`/`filter.<name>` hooks are a documented residual). Rename
  diffs and merge-conflict `diff --cc` blocks are policy-checked on every
  path they name.
- **Tasks are declared, not typed:** argv arrays in config, `shell:false`,
  sanitized environment, per-task timeout and output caps. Operator env
  cannot inject `GIT_*`, loader, or interpreter hooks.
- **Errors are bounded:** every failure is `CODE: message`; absolute host
  paths are scrubbed from server-generated tool errors, except that a
  task-spawn failure reports the operator-configured argv itself.
  `task_run` returns subprocess stdout/stderr with the task's configured
  `cwd` and workspace-root strings replaced by `<root>` — other absolute
  paths a command prints are not scrubbed; that output belongs to
  commands the operator allowlisted.

Errors: `ACCESS_DENIED`, `OUTSIDE_ROOT`, `NOT_FOUND`, `BINARY_FILE`,
`RESOURCE_LIMIT`, `TASK_DENIED`, `UNKNOWN_WORKSPACE`, `UNSUPPORTED_FORMAT`,
`WORKSPACE_UNAVAILABLE`, `INVALID_ARGUMENT`, `INTERNAL_ERROR`, plus
`CONFIG_ERROR` at config load/startup. `task_run` and search timeouts
return `timedOut:true` in the result; media deadline exhaustion is
reported as `TIMEOUT:` on the failed item or call, so a multi-item
result can come back partial rather than failed wholesale.
Git-command failures surface as
`INVALID_ARGUMENT` (`git_diff`/`git_log`/`git_show` map non-zero exits,
including timeout kills) or `INTERNAL_ERROR` (`git_status`); a timeout on
repo detection reads as `repo:false`, and `git_branches` tolerates a
non-zero exit with an empty list.

## What it will not do

No writes, edits, deletes, renames, or file creation. No shell. No git
mutations (`commit`, `push`, `clean` …). No network listener. No
repo-controlled configuration. Tasks execute only what the operator
pre-declared, and even those run as your user with real side effects,
so keep the allowlist tight and prefer read-only commands.

## Security architecture

Ports-and-adapters, one implementation, one transport:

```text
AI host ── stdio JSON-RPC ──▶ workspace-mcp serve --stdio

  config.ts   trust root: operator config (keep it outside watched roots)
  paths.ts    lexical + realpath containment, bounded error mapping
  policy.ts   default-deny rules + validated operator globs
  fsOps.ts    bounded reads/searches (ripgrep --json, structured parsing)
  git.ts      read-only git, strict refs, diff redaction, object-type gates
  tasks.ts    allowlisted argv only, process-group timeouts
  exec.ts     sanitized + git-hardened subprocess environments
  audit.ts    metadata-only JSONL; refuses symlinked targets
```

The full boundary analysis, the multi-round findings ledger, the adversarial
regression suite, and documented residual risks live in [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md). Disclosure
policy in [SECURITY.md](SECURITY.md).

## Configuration reference

`~/.config/local-workspace-mcp/config.json`; refused at load if the file or
its directory is group/world-writable, or the path is a symlink.

```jsonc
// annotated for reading — the parser is strict JSON;
// this block is documentation, not a template. Start from `init-config`
// output, or strip the // comments (the JSON itself is otherwise valid).
{
  "version": 1,
  "workspaces": {
    "myproj": {
      "path": "/abs/path", // required
      "description": "…",
      "tasks": ["typecheck"], // ids the caller may run here
    },
  },
  "tasks": {
    "typecheck": {
      "command": ["pnpm", "typecheck"], // argv only; never a command string
      "cwd": "subdir-inside-root", // optional; contained to the root
      "timeoutMs": 180000, // 1s..300s; 300s is a hard cap
      "env": { "PATH": "/opt/node/bin:${PATH}" }, // ${VAR} expands vs base env
    },
  },
  "deny": ["**/extra-secret/**"], // appended to built-in rules; evaluated
  // BEFORE the template allowlist, so an operator can re-deny
  // .env.example; malformed patterns fail config load, fail-closed
  "auditLog": "/abs/path.jsonl", // optional; defaults next to the config
  "limits": {}, // byte/line/count caps
}
```

Common `limits` keys (key: default): `maxReadFileBytes`: 5 MB file-size
ceiling for reads, `defaultReadBytes`: 64 KiB per read,
`maxSearchResults`: 200, `searchDeadlineMs`: 20 s, `gitTimeoutMs`: 15 s,
`maxTaskOutputBytes`: 64 KiB, `walkEntryCap`/`walkDepthCap`: 50k entries /
20 deep. Full schema with bounds: `src/config.ts` in the repository. Callers can pass
`timeoutMs` to `task_run` to shorten a task's timeout; it can never
exceed the configured value or the 300 s hard cap.

Overrides: `WORKSPACE_MCP_CONFIG` (config file), `WORKSPACE_MCP_CONFIG_DIR`
(config dir + audit log location), `workspace-mcp serve --config <path>`
(CLI flag). `init-config` refuses to overwrite an existing config; delete
the file first if you intend a reset.

## Troubleshooting

| Symptom                                      | Action                                                                                                                           |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `npm install -g` fails EACCES                | set a user prefix (`npm config set prefix ~/.local`, add `~/.local/bin` to PATH) or use an nvm-managed Node; never `sudo npm -g` |
| `command not found` after install            | binary lives in `$(npm prefix -g)/bin`; add it to PATH, or use the clone+`npm link` path                                         |
| Host shows disconnected                      | `workspace-mcp doctor`, fix first FAIL; if green, the host's spawn PATH differs — use the absolute binary path                   |
| `CONFIG_ERROR` on startup                    | permissions/JSON/symlink/unknown-task; the message names the cause                                                               |
| `init-config` exits 1                        | it refuses to overwrite; delete the existing config first                                                                        |
| `WORKSPACE_UNAVAILABLE`                      | root path moved/deleted; fix or remove the workspace entry                                                                       |
| `TASK_DENIED`                                | task id not enabled for that workspace; `task_list` shows the allowlist                                                          |
| Task executable missing                      | `doctor` flags `task.<id>.executable`; install it or fix task `env.PATH`                                                         |
| Search returns nothing                       | `rg` must be on the sanitized PATH; `doctor` checks it                                                                           |
| `ACCESS_DENIED`/`OUTSIDE_ROOT`/`BINARY_FILE` | policy working as intended; widen only in operator config                                                                        |
| `TIMEOUT:` on a media call/item              | deadline hit — narrow the request (fewer pages/items, smaller `fitMaxDim`) or raise `limits.mediaTimeoutMs`                      |
| Unexpected `INTERNAL_ERROR`                  | check the host's MCP stderr log; report per [SECURITY.md](SECURITY.md)                                                           |
| Tunnel (ChatGPT) down                        | `tunnel-client runtimes status`; recovery in [docs/OPERATIONS.md](docs/OPERATIONS.md)                                            |
| Tunnel created but not selectable in ChatGPT | check Tunnels Read+Use on your principal and wait for propagation; a tunnel in Platform does not appear in ChatGPT on its own    |
| Tunnel gone after reboot/login               | `scripts/launchd/install.sh` installs the self-healing LaunchAgent; see [docs/OPERATIONS.md §Persistence](docs/OPERATIONS.md)    |

`workspace-mcp doctor --json` gives machine-readable output for CI or
wrapper scripts. Full reset: delete the config and
`~/.config/local-workspace-mcp/audit.jsonl`, re-run `init-config`. To
uninstall entirely, also remove the host entry (`claude mcp remove`, the
`mcpServers`/`mcp_servers` block, or the tunnel profile) and `npm uninstall
-g local-workspace-mcp` (or `npm unlink` if linked from a clone).

## Compatibility

- Node ≥ 20 (uses `node:util` parseArgs; packaged install verified on
  Node 20.11, 22.12, and 24.21).
- macOS/Linux only — declared via `os` in `package.json` and checked by
  `doctor` (`platform`). Windows is unsupported, not just untested: task
  timeouts rely on POSIX process-group kill and git/task isolation pins
  `/dev/null`.
- MCP SDK `@modelcontextprotocol/sdk` v1.x line (spec ≤ 2025-11-25);
  v2 API surface is deliberately not adopted yet (see [docs/RESEARCH.md](docs/RESEARCH.md)).
- ripgrep required for `fs_search_content`; git required for `git_*`.

## Verifying the build

```sh
pnpm install --frozen-lockfile
pnpm test                    # 275 vitest cases incl. adversarial + red-team regressions
pnpm typecheck && pnpm build
workspace-mcp doctor         # deterministic diagnostics
scripts/inspector-smoke.sh <workspace-id>   # 17-check MCP Inspector battery
pnpm audit --prod            # dependency audit
```

## Contributing, security, license

- [CONTRIBUTING.md](CONTRIBUTING.md): RED-first tests, fail-closed rules, no new capability
  surface without design review.
- [SECURITY.md](SECURITY.md): vulnerability reporting and scope.
- [CHANGELOG.md](CHANGELOG.md): release notes by finding/version.
- License: [MIT](LICENSE).

## Release status

Version 0.2.0 is published on npm as `local-workspace-mcp` (SLSA
provenance, OIDC trusted publishing) and listed on the official MCP
Registry as `io.github.peter4leadson/local-workspace-mcp` — `latest`.
See [CHANGELOG.md](CHANGELOG.md) for the release history.
A 0.2.0 release decision record is maintained in the repository at
`docs/RELEASE-0.2.0-DECISION.md` (internal ledger — not shipped in the
tarball; the frozen 0.1.0 record is `docs/RELEASE-PACKET.md`).

---

Maintained by [Peter C. Bennett](https://petercbennett.com).