Skip to main content
Glama
Am6er

mcp-pwsh

by Am6er
README.md
# mcp-pwsh

A persistent, pooled **PowerShell MCP server**. It gives an MCP client (Claude
Desktop, or any [Model Context Protocol](https://modelcontextprotocol.io) host)
a pool of long-lived, named **PowerShell 7** sessions with clean `stdin → stdout`
I/O — like keeping several real consoles open, instead of firing one-shot
commands.

## Why

A naive "run a PowerShell command" tool spawns a fresh process every call, so
state does not survive (cwd, variables, imported modules), and the output comes
back wrapped in host noise. `mcp-pwsh` instead keeps one real `pwsh` process per
**named session**:

- **Persistent state** — `cwd`, variables, `$env:`, and imported modules survive
  between calls within a session.
- **Clean output** — a bootstrap loop reads commands with `[Console]::In.ReadLine()`
  (no prompt, no echo) and delimits each result with a runtime marker, so you get
  exactly the command's output plus its exit code — no `PS>` noise.
- **Named session pool (lanes)** — each session is an isolated lane with its own
  process, state, and lock. Different sessions run independently, so two tasks can
  work at once without corrupting each other's state.
- **No escaping games** — commands are sent base64-encoded over one line, so
  quoting, newlines, and multi-line scripts just work.

## Install

```sh
pip install mcp-pwsh
```

Requires **Python 3.9+** and **PowerShell 7** (`pwsh`). On Windows the default
`pwsh` location is auto-detected; otherwise `pwsh` is looked up on `PATH`.

## Configure in an MCP client

Using the installed console script (recommended):

```json
{
  "mcpServers": {
    "pwsh": {
      "command": "mcp-pwsh"
    }
  }
}
```

Or via the module (useful to pin a specific interpreter):

```json
{
  "mcpServers": {
    "pwsh": {
      "command": "C:\\path\\to\\python.exe",
      "args": ["-m", "mcp_pwsh"]
    }
  }
}
```

## Tools

| Tool | Purpose |
| --- | --- |
| `pwsh(command, session, timeout=60)` | Run a command in the named session; returns clean output + exit code. `session` is your chat's stable id — reuse it for every call. |
| `pwsh_list()` | List live sessions with owner id, pid, busy/idle, idle time, creation time, and last command. |
| `pwsh_close(session)` | Kill and remove one session. Call it when your task is done to free the lane. |
| `pwsh_reset(session)` | Restart one session's process (state cleared) — use if a command hung. |
| `pwsh_kill_all()` | Fallback: remove every session. |

**Discipline:** pick one stable `session` id per chat, reuse it, and
`pwsh_close` it when finished. Use `pwsh_list` to see lingering sessions and
`pwsh_kill_all` only as a last resort.

## Configuration (environment variables)

| Variable | Default | Meaning |
| --- | --- | --- |
| `MCP_PWSH_EXE` | auto-detected `pwsh` | Path to the PowerShell 7 executable. |
| `MCP_PWSH_CWD` | user home directory | Starting working directory for new sessions. |
| `MCP_PWSH_TIMEOUT_SEC` | `60` | Default per-command timeout (seconds). |

## How it works

Each session launches `pwsh` running a small bootstrap loop (passed via
`-EncodedCommand`). The loop reads `"<id> <base64-command>"` lines from stdin,
runs the command in a persistent scope, and prints `<<<MCPEND:<id>:<exitcode>>>>`.
Because the full marker is never present in the input, it can never false-match,
and because input is read (not typed into an interactive prompt) there is no echo
or prompt to strip. The bootstrap also restores a sane `PATHEXT`/`ComSpec` in
case the host launched the process with a stripped environment, so native
executables run normally.

## Self-test

Runs a local check of session isolation, listing, close, and kill-all (needs
`pwsh` available; no MCP host required):

```sh
python -m mcp_pwsh --selftest
```

## License

[GPL-2.0](LICENSE)