Skip to content
Open
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
49 changes: 49 additions & 0 deletions tests/tools/test_mcp_stdio_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from __future__ import annotations

import os
from unittest.mock import patch

from tools import mcp_tool


def test_which_with_subprocess_env_uses_subprocess_pathext_on_windows(monkeypatch):
seen = {}
monkeypatch.setattr(mcp_tool.os, "name", "nt")
monkeypatch.setenv("PATHEXT", ".EXE")

def fake_which(command, path=None):
seen["command"] = command
seen["path"] = path
seen["pathext"] = os.environ.get("PATHEXT")
return "C:/tools/server.CMD"

with patch("tools.mcp_tool.shutil.which", side_effect=fake_which):
resolved = mcp_tool._which_with_subprocess_env(
"server",
path="C:/tools",
env={"PATH": "C:/tools", "PATHEXT": ".CMD"},
)

assert resolved == "C:/tools/server.CMD"
assert seen == {"command": "server", "path": "C:/tools", "pathext": ".CMD"}
assert os.environ["PATHEXT"] == ".EXE"


def test_resolve_stdio_command_passes_subprocess_path_and_pathext(monkeypatch):
monkeypatch.setattr(mcp_tool.os, "name", "nt")

def fake_which(command, path=None):
assert command == "server"
assert path == "C:/mcp/bin"
assert os.environ.get("PATHEXT") == ".BAT"
return "C:/mcp/bin/server.BAT"

with patch("tools.mcp_tool.shutil.which", side_effect=fake_which):
command, env = mcp_tool._resolve_stdio_command(
"server",
{"PATH": "C:/mcp/bin", "PATHEXT": ".BAT"},
)

assert command == "C:/mcp/bin/server.BAT"
assert env["PATH"].startswith("C:/mcp/bin")
assert env["PATHEXT"] == ".BAT"
118 changes: 114 additions & 4 deletions tools/mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,22 @@ def _prepend_path(env: dict, directory: str) -> dict:
return updated


def _which_with_subprocess_env(command: str, *, path: str | None, env: dict) -> str | None:
"""Resolve *command* using PATH/PATHEXT from the intended subprocess env."""
if os.name != "nt" or "PATHEXT" not in env:
return shutil.which(command, path=path)

previous = os.environ.get("PATHEXT")
try:
os.environ["PATHEXT"] = str(env.get("PATHEXT") or "")
return shutil.which(command, path=path)
finally:
if previous is None:
os.environ.pop("PATHEXT", None)
else:
os.environ["PATHEXT"] = previous


def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
"""Resolve a stdio MCP command against the exact subprocess environment.

Expand All @@ -526,7 +542,11 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:

if os.sep not in resolved_command:
path_arg = resolved_env["PATH"] if "PATH" in resolved_env else None
which_hit = shutil.which(resolved_command, path=path_arg)
which_hit = _which_with_subprocess_env(
resolved_command,
path=path_arg,
env=resolved_env,
)
if which_hit:
resolved_command = which_hit
elif resolved_command in {"npx", "npm", "node"}:
Expand Down Expand Up @@ -1867,7 +1887,15 @@ async def _run_stdio(self, config: dict):
write_stream,
):
# Capture the newly spawned subprocess PID for force-kill cleanup.
new_pids = _snapshot_child_pids() - pids_before
# Filter out non-MCP children that race into the snapshot window:
# slash_worker and LSP servers (jdtls/pyright/yaml-ls) are spawned
# directly by the gateway without start_new_session, so their pgid
# equals the TUI parent PID. If they leak into _stdio_pgids, the
# shutdown sweep's killpg() kills the TUI parent itself.
# See agent/lsp/client.py for the complementary start_new_session fix.
Comment on lines +1892 to +1895

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Misleading comments claim non-existent start_new_session fix; PID filter markers incomplete for LSP servers (bug)

Three issues in the _filter_mcp_children defense added by this PR in tools/mcp_tool.py:

  1. Line 1895 says "See agent/lsp/client.py for the complementary start_new_session fix" — but agent/lsp/client.py:266 does NOT pass start_new_session=True to asyncio.create_subprocess_exec.

  2. Lines 3037-3038 say "LSP servers and slash_worker now use start_new_session=True too" — but tui_gateway/server.py:287 does NOT pass start_new_session=True to subprocess.Popen, and neither does the LSP client.

  3. Lines 1891-1892 say the filter covers "jdtls/pyright/yaml-ls" — but _NON_MCP_CHILD_CMDLINE_MARKERS (lines 3042-3048) only contains markers for jdtls and TUI components (tui_gateway.slash_worker, tui_gateway.entry, Eclipse launcher markers). There are no markers for pyright-langserver or yaml-language-server.

The pgid guard in _send_signal (line 4933: pgid == _my_pgid check) prevents the gateway from self-killing when an LSP child leaks into _stdio_pgids. However, LSP servers that race into the PID snapshot delta and are not caught by the filter would still be incorrectly tracked and force-killed during MCP shutdown (lines 4965, 4979), disrupting language server features for the user.

💡 Suggestion: The primary fix is to add start_new_session=True to agent/lsp/client.py:266 (asyncio.create_subprocess_exec) and tui_gateway/server.py:287 (subprocess.Popen). This makes the PID filter true defense-in-depth. If that fix is deferred, at minimum: (a) add pyright-langserver and yaml-language-server to _NON_MCP_CHILD_CMDLINE_MARKERS, and (b) update the misleading comments at lines 1895 and 3037-3038 to reflect the actual state.

📋 Prompt for AI Agents

In agent/lsp/client.py, line 266, add start_new_session=True to the asyncio.create_subprocess_exec() call. In tui_gateway/server.py, line 287, add start_new_session=True to the subprocess.Popen() call. Then update the comment at tools/mcp_tool.py line 1895 to remove the reference to the complementary fix (since it will now be present), and update the comment at line 3037 to say the fix has been applied rather than claiming it already exists. Consider adding 'pyright-langserver' and 'yaml-language-server' to _NON_MCP_CHILD_CMDLINE_MARKERS at line 3042 as defense-in-depth.

new_pids = _filter_mcp_children(
_snapshot_child_pids() - pids_before
)
if new_pids:
# Capture pgid while the child is alive — once it exits we
# can no longer call ``os.getpgid`` on it, and the cleanup
Expand Down Expand Up @@ -3005,6 +3033,56 @@ def _snapshot_child_pids() -> set:
return set()


# Non-MCP gateway children that can race into the _snapshot_child_pids() delta
# during stdio MCP server spawn. LSP servers and slash_worker now use
# start_new_session=True too; this remains defense-in-depth for any future
# non-MCP child spawn that briefly appears in the MCP snapshot delta. Match
# argv markers instead of argv[0] because Python/Java children begin with the
# interpreter or binary path.
_NON_MCP_CHILD_CMDLINE_MARKERS: tuple[str, ...] = (
"tui_gateway.slash_worker",
"tui_gateway.entry",
"-dorg.eclipse.equinox.launcher", # jdtls (legacy arg style)
"eclipse.jdt.ls",
"org.eclipse.equinox.launcher_",
)


def _filter_mcp_children(pids: set) -> set:
"""Remove non-MCP children from a PID snapshot delta.

_snapshot_child_pids() returns *all* direct children of the gateway. When
a stdio MCP server spawns concurrently with a slash_worker or LSP server
spawn, the delta ``_snapshot_child_pids() - pids_before`` can include
PIDs that are NOT the MCP server. Tracking those PIDs in _stdio_pgids is
catastrophic if a future child lacks start_new_session: its pgid can be the
TUI parent's PID, so the shutdown sweep's killpg() kills the TUI itself.
"""
if not pids:
return pids
try:
import psutil
except ImportError:
# psutil unavailable — keep all PIDs (preserves prior behavior).
return pids
filtered: set = set()
for pid in pids:
try:
argv = psutil.Process(pid).cmdline()
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
# Process raced away or is a zombie — skip it; it cannot be the
# MCP server we just spawned and is not safe to track.
continue
if any(
marker in arg
for arg in argv[1:]
for marker in _NON_MCP_CHILD_CMDLINE_MARKERS
):
continue
filtered.add(pid)
return filtered


def _mcp_loop_exception_handler(loop, context):
"""Suppress benign 'Event loop is closed' noise during shutdown.

Expand Down Expand Up @@ -3731,11 +3809,43 @@ def _normalize_mcp_input_schema(schema: dict | None) -> dict:
return {"type": "object", "properties": {}}

def _rewrite_local_refs(node):
"""Walk the schema, promoting legacy ``definitions`` to ``$defs``.

The promotion is contextual: ``definitions`` is renamed only when it
appears as a JSON Schema *meta-keyword* (sibling of ``properties`` /
``$ref`` at a schema node), never when it appears as the *name of a
property* (i.e., as a key inside a ``properties`` dict).

Without this gate, MCP servers that legitimately expose a tool
parameter named ``definitions`` (e.g. a CI/pipelines tool that uses
``definitions`` for an array of pipeline-definition IDs) would have
that user-facing property name silently rewritten to ``$defs``.
Anthropic and OpenAI both reject ``$`` in property names
(``^[a-zA-Z0-9_.-]{1,64}$``), so the whole tool array gets a 400 and
every conversation breaks.

The gate works by treating ``properties`` and ``patternProperties``
specially during descent: we iterate the property-name -> schema map
directly, leaving the property names verbatim, then recurse into each
property's schema where ordinary JSON Schema semantics resume (so any
legitimately-nested ``definitions`` meta-keyword inside a property's
schema is still promoted).
"""
if isinstance(node, dict):
normalized = {}
for key, value in node.items():
out_key = "$defs" if key == "definitions" else key
normalized[out_key] = _rewrite_local_refs(value)
if key in ("properties", "patternProperties") and isinstance(value, dict):
# Keys of this dict are user-facing property names, not
# meta-keywords. Preserve them verbatim; recurse only into
# each property's schema, where ``definitions`` again has
# its JSON Schema meaning.
normalized[key] = {
prop_name: _rewrite_local_refs(prop_schema)
for prop_name, prop_schema in value.items()
}
else:
out_key = "$defs" if key == "definitions" else key
normalized[out_key] = _rewrite_local_refs(value)
ref = normalized.get("$ref")
if isinstance(ref, str) and ref.startswith("#/definitions/"):
normalized["$ref"] = "#/$defs/" + ref[len("#/definitions/"):]
Expand Down
Loading