diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f87748e7..270f6e2aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,16 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/). terminal shows that line and no longer the library's output. Where `fstat` gives a descriptor no identity (Windows for a pipe or a console) the merged layout is not recognised and fd 1 goes to stderr as usual. +- **MCP servers `ti_mcp`, `wr_mcp`, `wrx_mcp`: the same fd isolation** (#262). + The three servers had the layout of the other three and the same exposure: + `main()` now calls `_fd_isolation.install()` immediately before the stdio + server is built, importing a server module leaves the importing process's + fd 1 and `sys.stdout` alone, and `--help` / `--print-tools` / the + missing-SDK path return before anything is redirected. Each server's + `tests/test_fd_isolation.py` runs the shared checks + (`python/mcp-servers/fd_isolation_cases.py`), and + `python/tests/test_fd_isolation.py` checks that all six servers run every + one of them. `fp_mcp` follows in #232. ## TR Phase L — library-ization (2026-04-18) diff --git a/python/mcp-servers/ti_mcp/server.py b/python/mcp-servers/ti_mcp/server.py index 57978136c..f4879c8a1 100644 --- a/python/mcp-servers/ti_mcp/server.py +++ b/python/mcp-servers/ti_mcp/server.py @@ -20,6 +20,21 @@ The server is intentionally small — the real heavy lifting is in :mod:`tilib`. This file mirrors ``tr_mcp/server.py`` and swaps the backing library, registry, and state schema to match TI. + +fd-isolation (Fortran WRITE(6) vs MCP JSON-RPC) +----------------------------------------------- +Fortran WRITE(6,...) targets OS fd 1, which is also the JSON-RPC write +pipe to the MCP client parent. Any Fortran diagnostic line corrupts the +pipe: the client logs "Failed to parse JSONRPC message from server" for +each junk line, and in the worst case the session drops with +"Connection closed". + +main() therefore calls ``_fd_isolation.install()`` (the module shared by +the stdio servers, ``python/_fd_isolation.py``) immediately before the +stdio server is built -- NOT at import time, see #227 item 1. +It moves the pipe to a descriptor of its own, rebuilds ``sys.stdout`` on +it and gives fd 1 to stderr, so that Fortran WRITE(6,...) lands in the +server's log. """ from __future__ import annotations @@ -76,6 +91,10 @@ TilibNotImplementedError, ) +# The fd isolation shared by the stdio servers; it lives beside the wrapper, +# in ``/python/``. +import _fd_isolation # noqa: E402 + # ===================================================================== # Parameter & state schema metadata. @@ -690,6 +709,11 @@ def main(argv: Optional[List[str]] = None) -> int: ) return 2 + # Install the fd isolation now -- NOT at import time (#227 item 1). + # Everything above this point (--help, --print-tools, the MCP-missing + # error path) returns before we touch the host process's fds. + _fd_isolation.install() + server = build_server() # FastMCP >=0.9 exposes .run() for stdio transport by default. server.run() diff --git a/python/mcp-servers/ti_mcp/tests/test_fd_isolation.py b/python/mcp-servers/ti_mcp/tests/test_fd_isolation.py new file mode 100644 index 000000000..1b03ae6e1 --- /dev/null +++ b/python/mcp-servers/ti_mcp/tests/test_fd_isolation.py @@ -0,0 +1,43 @@ +"""ti_mcp's share of the fd-isolation checks (fd_isolation_cases): its +main() installs the shared isolation, and nothing else of it does.""" +import sys +from pathlib import Path + +import pytest + +MCP_ROOT = Path(__file__).resolve().parents[2] # /python/mcp-servers +if str(MCP_ROOT) not in sys.path: + sys.path.insert(0, str(MCP_ROOT)) + +pytest.register_assert_rewrite("fd_isolation_cases") +import fd_isolation_cases as cases # noqa: E402 + +SERVER = "ti_mcp" + + +def test_import_leaves_fd1_and_sys_stdout_alone(): + cases.import_leaves_fd1_and_sys_stdout_alone(SERVER) + + +def test_main_installs_the_isolation_before_the_stdio_server_is_built(): + cases.main_installs_the_isolation_before_the_stdio_server_is_built(SERVER) + + +@pytest.mark.parametrize("flag", ["--help", "--print-tools"]) +def test_help_and_print_tools_install_nothing(flag): + cases.flag_installs_nothing(SERVER, flag) + + +def test_missing_sdk_installs_nothing(): + cases.missing_sdk_installs_nothing(SERVER) + + +@pytest.mark.skipif(sys.platform == "win32", + reason="fstat gives a pipe no identity there: a merged stream is not recognised") +def test_stderr_merged_into_stdout_keeps_fortran_output_off_the_stream(): + cases.stderr_merged_into_stdout_keeps_fortran_output_off_the_stream(SERVER) + + +@pytest.mark.skipif(sys.platform == "win32", reason="preexec_fn is POSIX only") +def test_closed_fd2_keeps_everything_but_json_rpc_off_the_pipe(): + cases.closed_fd2_keeps_everything_but_json_rpc_off_the_pipe(SERVER) diff --git a/python/mcp-servers/wr_mcp/server.py b/python/mcp-servers/wr_mcp/server.py index 5da8adcb4..a01c9d194 100644 --- a/python/mcp-servers/wr_mcp/server.py +++ b/python/mcp-servers/wr_mcp/server.py @@ -29,6 +29,21 @@ :mod:`wrlib`. This file mirrors ``tr_mcp/server.py`` with only the module-specific names swapped so that future ti / wrx / fp servers can follow the same pattern. + +fd-isolation (Fortran WRITE(6) vs MCP JSON-RPC) +----------------------------------------------- +Fortran WRITE(6,...) targets OS fd 1, which is also the JSON-RPC write +pipe to the MCP client parent. Any Fortran diagnostic line corrupts the +pipe: the client logs "Failed to parse JSONRPC message from server" for +each junk line, and in the worst case the session drops with +"Connection closed". + +main() therefore calls ``_fd_isolation.install()`` (the module shared by +the stdio servers, ``python/_fd_isolation.py``) immediately before the +stdio server is built -- NOT at import time, see #227 item 1. +It moves the pipe to a descriptor of its own, rebuilds ``sys.stdout`` on +it and gives fd 1 to stderr, so that Fortran WRITE(6,...) lands in the +server's log. """ from __future__ import annotations @@ -84,6 +99,10 @@ WrlibNotImplementedError, ) +# The fd isolation shared by the stdio servers; it lives beside the wrapper, +# in ``/python/``. +import _fd_isolation # noqa: E402 + # ===================================================================== # Parameter & state schema metadata. @@ -724,6 +743,11 @@ def main(argv: Optional[List[str]] = None) -> int: ) return 2 + # Install the fd isolation now -- NOT at import time (#227 item 1). + # Everything above this point (--help, --print-tools, the MCP-missing + # error path) returns before we touch the host process's fds. + _fd_isolation.install() + server = build_server() # FastMCP >=0.9 exposes .run() for stdio transport by default. server.run() diff --git a/python/mcp-servers/wr_mcp/tests/test_fd_isolation.py b/python/mcp-servers/wr_mcp/tests/test_fd_isolation.py new file mode 100644 index 000000000..f21c2eef1 --- /dev/null +++ b/python/mcp-servers/wr_mcp/tests/test_fd_isolation.py @@ -0,0 +1,43 @@ +"""wr_mcp's share of the fd-isolation checks (fd_isolation_cases): its +main() installs the shared isolation, and nothing else of it does.""" +import sys +from pathlib import Path + +import pytest + +MCP_ROOT = Path(__file__).resolve().parents[2] # /python/mcp-servers +if str(MCP_ROOT) not in sys.path: + sys.path.insert(0, str(MCP_ROOT)) + +pytest.register_assert_rewrite("fd_isolation_cases") +import fd_isolation_cases as cases # noqa: E402 + +SERVER = "wr_mcp" + + +def test_import_leaves_fd1_and_sys_stdout_alone(): + cases.import_leaves_fd1_and_sys_stdout_alone(SERVER) + + +def test_main_installs_the_isolation_before_the_stdio_server_is_built(): + cases.main_installs_the_isolation_before_the_stdio_server_is_built(SERVER) + + +@pytest.mark.parametrize("flag", ["--help", "--print-tools"]) +def test_help_and_print_tools_install_nothing(flag): + cases.flag_installs_nothing(SERVER, flag) + + +def test_missing_sdk_installs_nothing(): + cases.missing_sdk_installs_nothing(SERVER) + + +@pytest.mark.skipif(sys.platform == "win32", + reason="fstat gives a pipe no identity there: a merged stream is not recognised") +def test_stderr_merged_into_stdout_keeps_fortran_output_off_the_stream(): + cases.stderr_merged_into_stdout_keeps_fortran_output_off_the_stream(SERVER) + + +@pytest.mark.skipif(sys.platform == "win32", reason="preexec_fn is POSIX only") +def test_closed_fd2_keeps_everything_but_json_rpc_off_the_pipe(): + cases.closed_fd2_keeps_everything_but_json_rpc_off_the_pipe(SERVER) diff --git a/python/mcp-servers/wrx_mcp/server.py b/python/mcp-servers/wrx_mcp/server.py index e1b1d07e2..f67645e4c 100644 --- a/python/mcp-servers/wrx_mcp/server.py +++ b/python/mcp-servers/wrx_mcp/server.py @@ -28,6 +28,21 @@ This mirrors the ``tr_mcp`` reference implementation as closely as possible; the main wrx-specific additions are the WRX_RUN_OK gate and the different parameter registry / state schema. + +fd-isolation (Fortran WRITE(6) vs MCP JSON-RPC) +----------------------------------------------- +Fortran WRITE(6,...) targets OS fd 1, which is also the JSON-RPC write +pipe to the MCP client parent. Any Fortran diagnostic line corrupts the +pipe: the client logs "Failed to parse JSONRPC message from server" for +each junk line, and in the worst case the session drops with +"Connection closed". + +main() therefore calls ``_fd_isolation.install()`` (the module shared by +the stdio servers, ``python/_fd_isolation.py``) immediately before the +stdio server is built -- NOT at import time, see #227 item 1. +It moves the pipe to a descriptor of its own, rebuilds ``sys.stdout`` on +it and gives fd 1 to stderr, so that Fortran WRITE(6,...) lands in the +server's log. """ from __future__ import annotations @@ -83,6 +98,10 @@ WrxlibNotImplementedError, ) +# The fd isolation shared by the stdio servers; it lives beside the wrapper, +# in ``/python/``. +import _fd_isolation # noqa: E402 + # ===================================================================== # Parameter & state schema metadata. @@ -718,6 +737,11 @@ def main(argv: Optional[List[str]] = None) -> int: ) return 2 + # Install the fd isolation now -- NOT at import time (#227 item 1). + # Everything above this point (--help, --print-tools, the MCP-missing + # error path) returns before we touch the host process's fds. + _fd_isolation.install() + server = build_server() # FastMCP >=0.9 exposes .run() for stdio transport by default. server.run() diff --git a/python/mcp-servers/wrx_mcp/tests/test_fd_isolation.py b/python/mcp-servers/wrx_mcp/tests/test_fd_isolation.py new file mode 100644 index 000000000..f21460c1e --- /dev/null +++ b/python/mcp-servers/wrx_mcp/tests/test_fd_isolation.py @@ -0,0 +1,43 @@ +"""wrx_mcp's share of the fd-isolation checks (fd_isolation_cases): its +main() installs the shared isolation, and nothing else of it does.""" +import sys +from pathlib import Path + +import pytest + +MCP_ROOT = Path(__file__).resolve().parents[2] # /python/mcp-servers +if str(MCP_ROOT) not in sys.path: + sys.path.insert(0, str(MCP_ROOT)) + +pytest.register_assert_rewrite("fd_isolation_cases") +import fd_isolation_cases as cases # noqa: E402 + +SERVER = "wrx_mcp" + + +def test_import_leaves_fd1_and_sys_stdout_alone(): + cases.import_leaves_fd1_and_sys_stdout_alone(SERVER) + + +def test_main_installs_the_isolation_before_the_stdio_server_is_built(): + cases.main_installs_the_isolation_before_the_stdio_server_is_built(SERVER) + + +@pytest.mark.parametrize("flag", ["--help", "--print-tools"]) +def test_help_and_print_tools_install_nothing(flag): + cases.flag_installs_nothing(SERVER, flag) + + +def test_missing_sdk_installs_nothing(): + cases.missing_sdk_installs_nothing(SERVER) + + +@pytest.mark.skipif(sys.platform == "win32", + reason="fstat gives a pipe no identity there: a merged stream is not recognised") +def test_stderr_merged_into_stdout_keeps_fortran_output_off_the_stream(): + cases.stderr_merged_into_stdout_keeps_fortran_output_off_the_stream(SERVER) + + +@pytest.mark.skipif(sys.platform == "win32", reason="preexec_fn is POSIX only") +def test_closed_fd2_keeps_everything_but_json_rpc_off_the_pipe(): + cases.closed_fd2_keeps_everything_but_json_rpc_off_the_pipe(SERVER) diff --git a/python/tests/test_fd_isolation.py b/python/tests/test_fd_isolation.py index 12565cd32..8f454a6e0 100644 --- a/python/tests/test_fd_isolation.py +++ b/python/tests/test_fd_isolation.py @@ -25,7 +25,7 @@ import fd_isolation_cases as cases # noqa: E402 from fd_isolation_cases import run # noqa: E402 -SERVERS = ("eq_mcp", "tr_mcp", "tot_mcp") +SERVERS = ("eq_mcp", "tr_mcp", "tot_mcp", "ti_mcp", "wr_mcp", "wrx_mcp") # ----- every server runs every shared check -----