Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

### Changed

- Windows string commands now prefer a trusted PowerShell 7 launcher and
automatically retain `cmd.exe` compatibility when `pwsh` is unavailable.
The selected shell is visible to agents, and safe mode conservatively gates
dynamic syntax for both interpreters.

## 0.3.0 - 2026-08-13

### Changed
Expand Down
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ walkthroughs, and troubleshooting live in
[docs/quickstart.md](docs/quickstart.md) and
[docs/mcp-client-config.md](docs/mcp-client-config.md).

On Windows, string commands prefer PowerShell 7 (`pwsh`) with
`-NoLogo -NoProfile -NonInteractive`. If `pwsh` is unavailable, the server
automatically preserves command execution through a trusted `cmd.exe`
compatibility fallback. `server_info`, `check_exec_environment`, and each
`exec_command` result disclose the selected shell so agents can use the right
syntax. Set `CODING_TOOLS_MCP_PWSH_PATH` to an absolute trusted `pwsh.exe` path
to pin PowerShell; an invalid explicit pin is reported instead of ignored.

Because PowerShell resolves commands at runtime, `safe` mode on Windows gates
dynamic syntax — variables, splatting, call and dot-source operators, `::`
member access, alias/expression evaluation, and nested shells — behind the
`shell_expansion` and `inline_script` permissions. Literal commands are
unaffected; use `request_permissions` or `trusted` mode for the rest. Under the
`cmd.exe` fallback, percent expansion, caret escaping, and `CALL`/`FOR`
evaluation likewise require `shell_expansion`.

## Seven things to try

**1. Make Claude Desktop your coding agent.** The config above is all it
Expand Down Expand Up @@ -119,9 +135,11 @@ Per-workspace profiles, server and tunnel start/stop, credential setup with
clipboard helpers, live health checks. English and 简体中文.

**6. Keep an interactive command alive.** `exec_command` starts a REPL or
debugger under a real PTY; `write_stdin` feeds it across turns; `read_output`
pages long output; `kill_command` cleans up. Long-running processes are
first-class, with deadline watchdogs and bounded buffers.
debugger under a real POSIX PTY; `write_stdin` feeds it across turns;
`read_output` pages long output; `kill_command` cleans up. Long-running
processes are first-class, with deadline watchdogs and bounded buffers.
Windows prefers PowerShell 7 and otherwise uses the disclosed `cmd.exe`
fallback for non-TTY commands; ConPTY remains a separate limitation.

**7. Give your own agent production-grade hands.** Building an agent loop with
the Anthropic SDK or anything else? Don't hand-roll file and exec tools —
Expand Down
7 changes: 7 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ npx coding-tools-mcp --stdio --workspace /path/to/repo # Node 工具链
[docs/quickstart.md](docs/quickstart.md) 与
[docs/mcp-client-config.md](docs/mcp-client-config.md)。

在 Windows 上,字符串命令优先使用 PowerShell 7(`pwsh`);未安装时会自动回退到
可信系统路径中的 `cmd.exe`,不会让 agent 因缺少额外 shell 而失去命令执行能力。
`server_info`、`check_exec_environment` 和每次 `exec_command` 都会说明实际使用的
shell,便于 agent 选择正确语法。运维方也可以通过
`CODING_TOOLS_MCP_PWSH_PATH` 固定一个绝对的 `pwsh.exe` 路径;显式路径配置错误时
会直接报错,不会掩盖配置问题。

## 七个值得一试的玩法

**1. 让 Claude Desktop 成为你的编程 agent。**
Expand Down
294 changes: 293 additions & 1 deletion coding_tools_mcp/processes.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from __future__ import annotations

import functools
import ntpath
import os
import signal
import subprocess
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, BinaryIO
from typing import Any, BinaryIO, Literal

from .errors import ToolFailure
from .textutils import DEFAULT_MAX_LINES, TextTruncation, truncate_text_tail
Expand All @@ -19,6 +22,287 @@
# agent runtimes.
COMMAND_HEAD_BUFFER_DIVISOR = 8
HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM)
PWSH_PATH_ENV = "CODING_TOOLS_MCP_PWSH_PATH"
CMD_FALLBACK_WARNING = (
"PowerShell 7 (pwsh) is unavailable; exec_command is using the cmd.exe "
"compatibility fallback. Commands use cmd.exe syntax, not PowerShell syntax. "
"Install PowerShell 7 or set CODING_TOOLS_MCP_PWSH_PATH to use PowerShell."
)


@dataclass(frozen=True)
class WindowsCommandShell:
"""Trusted Windows command interpreter selected by the server."""

kind: Literal["pwsh", "cmd"]
executable: str
fallback: bool = False
fallback_reason: str | None = None
warning: str | None = None


def _environment_value(env: Mapping[str, str], target: str) -> str | None:
target_upper = target.upper()
for name, value in env.items():
if name.upper() == target_upper:
return value
return None


def _pwsh_probe_environment() -> dict[str, str]:
"""Build a minimal host-derived environment without server secrets."""

allowed = ("PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC")
result: dict[str, str] = {}
for name in allowed:
value = _environment_value(os.environ, name)
if value is not None:
result[name] = value
return result


def _find_windows_executable_on_path(filename: str, path: str | None) -> str | None:
"""Search absolute PATH entries without Windows' implicit current-directory lookup."""

if not path:
return None
current_dir = ntpath.normcase(ntpath.abspath(os.getcwd()))
for raw_entry in path.split(";"):
entry = ntpath.expandvars(raw_entry.strip().strip('"'))
if not entry or not ntpath.isabs(entry):
continue
normalized_entry = ntpath.normcase(ntpath.abspath(entry))
try:
if ntpath.commonpath((current_dir, normalized_entry)) == current_dir:
continue
except ValueError:
pass
candidate = ntpath.normpath(ntpath.join(entry, filename))
if os.path.isfile(candidate):
return candidate
return None


def resolve_pwsh() -> str:
"""Resolve PowerShell 7 only from the trusted server process environment."""

executable: str | None
configured = (_environment_value(os.environ, PWSH_PATH_ENV) or "").strip().strip('"')
if configured:
executable = ntpath.normpath(ntpath.expandvars(configured))
if (
not ntpath.isabs(executable)
or ntpath.basename(executable).lower() != "pwsh.exe"
or not os.path.isfile(executable)
):
raise ToolFailure(
"SHELL_NOT_FOUND",
f"{PWSH_PATH_ENV} must point to an existing absolute pwsh.exe path.",
category="runtime",
details={"executable": executable, "environment_variable": PWSH_PATH_ENV},
)
else:
executable = _find_windows_executable_on_path(
"pwsh.exe",
_environment_value(os.environ, "PATH"),
)
if executable is None:
raise ToolFailure(
"SHELL_NOT_FOUND",
"PowerShell 7 was not found on the server process PATH.",
category="runtime",
details={
"executable": "pwsh",
"retry_hint": (
"Install PowerShell 7 and add pwsh to PATH, or set "
f"{PWSH_PATH_ENV} to its absolute path."
),
},
)
major = pwsh_major_version(executable)
if major < 7:
raise ToolFailure(
"SHELL_VERSION_UNSUPPORTED",
f"PowerShell 7 or newer is required; resolved major version {major}.",
category="runtime",
details={"executable": executable, "major_version": major, "required_major_version": 7},
)
return executable


def resolve_cmd() -> str:
"""Resolve cmd.exe from trusted server-process Windows locations."""

raw_candidates: list[str] = []
comspec = (_environment_value(os.environ, "COMSPEC") or "").strip().strip('"')
if comspec:
raw_candidates.append(comspec)
for variable in ("SYSTEMROOT", "WINDIR"):
root = (_environment_value(os.environ, variable) or "").strip().strip('"')
if root:
raw_candidates.append(ntpath.join(root, "System32", "cmd.exe"))

seen: set[str] = set()
for raw_candidate in raw_candidates:
candidate = ntpath.normpath(ntpath.expandvars(raw_candidate))
normalized = ntpath.normcase(candidate)
if normalized in seen:
continue
seen.add(normalized)
if (
ntpath.isabs(candidate)
and ntpath.basename(candidate).lower() == "cmd.exe"
and os.path.isfile(candidate)
):
return candidate

executable = _find_windows_executable_on_path(
"cmd.exe",
_environment_value(os.environ, "PATH"),
)
if executable is not None:
return executable
raise ToolFailure(
"SHELL_NOT_FOUND",
"Neither PowerShell 7 nor cmd.exe could be resolved for Windows string commands.",
category="runtime",
details={
"executable": "cmd.exe",
"retry_hint": (
"Restore the Windows command processor or install PowerShell 7 and add "
f"pwsh to PATH (or set {PWSH_PATH_ENV})."
),
},
)


def resolve_windows_command_shell() -> WindowsCommandShell:
"""Prefer PowerShell 7 and automatically retain cmd.exe compatibility."""

configured_pwsh = bool((_environment_value(os.environ, PWSH_PATH_ENV) or "").strip())
try:
return WindowsCommandShell(kind="pwsh", executable=resolve_pwsh())
except ToolFailure as exc:
# An explicit pin is operator intent. Falling back would hide a typo or
# silently ignore a version constraint the operator expected to hold.
if configured_pwsh:
raise
return WindowsCommandShell(
kind="cmd",
executable=resolve_cmd(),
fallback=True,
fallback_reason=exc.code,
warning=CMD_FALLBACK_WARNING,
)


_selected_shell_lock = threading.Lock()
_selected_shell: WindowsCommandShell | ToolFailure | None = None


def selected_windows_command_shell(*, refresh: bool = False) -> WindowsCommandShell:
"""Resolve the Windows shell once per process and pin the answer.

Re-resolving per call would let a transient probe failure flip one exec to
cmd.exe while the next uses pwsh, and would let concurrent requests each
start their own multi-second version probe before command accounting sees
them. The lock serializes the first resolution; every later call reuses the
pinned selection (including a pinned failure) until ``refresh`` — used by
check_exec_environment so operators can pick up a newly installed shell
without restarting the server.
"""

global _selected_shell
with _selected_shell_lock:
if refresh or _selected_shell is None:
try:
_selected_shell = resolve_windows_command_shell()
except ToolFailure as failure:
_selected_shell = failure
pinned = _selected_shell
if isinstance(pinned, ToolFailure):
raise pinned
return pinned


def _reset_selected_windows_command_shell() -> None:
"""Drop the pinned shell selection (test seam)."""

global _selected_shell
with _selected_shell_lock:
_selected_shell = None


@functools.lru_cache(maxsize=16)
def pwsh_major_version(executable: str) -> int:
"""Return and cache the major version reported by a pwsh executable."""

try:
completed = subprocess.run(
[
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
"$PSVersionTable.PSVersion.Major",
],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
check=False,
env=_pwsh_probe_environment(),
cwd=ntpath.dirname(executable) or None,
)
except (OSError, subprocess.SubprocessError) as exc:
raise ToolFailure(
"SHELL_VERSION_UNSUPPORTED",
"PowerShell version could not be verified.",
category="runtime",
details={"executable": executable, "reason": str(exc)},
) from exc
stdout = completed.stdout.strip()
if completed.returncode != 0 or not stdout.isdigit():
raise ToolFailure(
"SHELL_VERSION_UNSUPPORTED",
"PowerShell version could not be verified.",
category="runtime",
details={
"executable": executable,
"exit_code": completed.returncode,
"stderr": completed.stderr.strip()[:500],
},
)
return int(stdout)


def build_pwsh_argv(executable: str, command: str) -> list[str]:
"""Build a deterministic non-interactive PowerShell invocation."""

return [
executable,
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
command,
]


def build_cmd_command_line(executable: str, command: str) -> str:
"""Build the exact cmd.exe command line, bypassing MS CRT argv quoting.

Passing an argument list would make Popen serialize it with list2cmdline,
whose ``\\"`` escapes reach cmd.exe verbatim and corrupt any command that
contains quotes. ``/S /C`` is specified against a raw command line instead:
cmd strips the first and last quote and executes everything between them
unchanged, so the payload needs exactly one enclosing quote pair and no
further escaping.
"""

return f'"{executable}" /D /V:OFF /S /C "{command}"'


def terminate_process_group(
Expand Down Expand Up @@ -69,10 +353,18 @@ def spawn_process(
env: dict[str, str],
tty: bool,
popen_kwargs: dict[str, Any],
windows_shell: WindowsCommandShell | None = None,
) -> tuple[subprocess.Popen[bytes], int | None]:
"""Spawn a pipe-backed or true POSIX PTY-backed process."""

if not tty:
if os.name == "nt" and shell and isinstance(command, str):
selected_shell = windows_shell or selected_windows_command_shell()
if selected_shell.kind == "pwsh":
command = build_pwsh_argv(selected_shell.executable, command)
else:
command = build_cmd_command_line(selected_shell.executable, command)
shell = False
process = subprocess.Popen(
command,
cwd=cwd,
Expand Down
Loading
Loading