From bb52868b8f0d99bd9ead16b7dfc68116eda6aec7 Mon Sep 17 00:00:00 2001 From: HengyuLi-Ozaki-lab Date: Thu, 6 Aug 2026 16:22:39 +0900 Subject: [PATCH 1/3] fix(fp_mcp): redirect Fortran WRITE(6,...) to stderr to protect JSON-RPC channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fp_mcp was the only MCP server without the fd-isolation guard that eq_mcp/server.py and tr_mcp/server.py have carried since the `fix(mcp): redirect Fortran WRITE(6,...) to stderr` patch. Fortran WRITE(6,...) targets OS fd 1, which for an MCP stdio server IS the JSON-RPC write pipe, so every Fortran diagnostic line lands in the protocol stream. FP is by far the worst offender of the three: FPWRTPRF dumps the whole radial profile table to unit 6 on every time step, so the damage grows linearly with NTMAX. Measured client-side on the fp_iter01 fixture (NRMAX=40, NPMAX=50, NTHMAX=50, NSAMAX=1, NSMAX=3) at NTMAX=2, counting "Failed to parse JSONRPC message from server" records emitted by mcp/client/stdio/__init__.py:157: before this commit : 211 parse errors per run after this commit : 0 parse errors per run (~61 junk lines per extra time step, i.e. ~6000 at NTMAX=100. An earlier measurement put NTMAX=1 at 150.) Runs still reported status: ok before the fix — the client logs each unparseable line and continues — so this was silent protocol corruption, not a hard failure. Fix is the same two-step dance as eq_mcp/tr_mcp, applied at module load before any mcp/logging import touches sys.stdout: 1. dup fd 1 (the JSON-RPC pipe) to a fresh fd, then dup2(2, 1) so Fortran WRITE(6,...) goes to the subprocess stderr; 2. rebuild sys.stdout around the saved fd so the MCP stdio transport still writes to the real pipe. --print-tools / --help / --version keep the untouched stdout via the same _ONESHOT_FLAGS guard the other two servers use. fd 0 is deliberately NOT redirected — the Fortran library uses stdin internally and redirecting it raised crash rates in the eq/tr work. Side benefit confirmed while verifying: Fortran diagnostics are now readable in the per-attempt stderr log instead of being shredded into the protocol stream. That is what made the EQ->FP eq_load trace ("# OLD FILE (eq.bin) IS ASSIGNED FOR INPUT.", "XX FROPEN (EQ): FILE NOT FOUND", "XX FPMESH:EQLOAD:IERR=7") visible at all. Verified: 45 passed, 55 subtests passed; --print-tools still prints to the terminal; 7 live fp_mcp runs at 0 parse errors each. Co-Authored-By: Claude Opus 5 --- python/mcp-servers/fp_mcp/server.py | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/python/mcp-servers/fp_mcp/server.py b/python/mcp-servers/fp_mcp/server.py index cdca3cec5..612be0fad 100644 --- a/python/mcp-servers/fp_mcp/server.py +++ b/python/mcp-servers/fp_mcp/server.py @@ -30,9 +30,63 @@ :mod:`fplib`. This file mirrors ``tr_mcp/server.py``; adding the remaining modules (ti / wr / wrx / tot) should be a matter of copying this file and swapping the backing library. + +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". + +FP is a heavy offender: ``FPWRTPRF`` prints the whole radial profile +table to unit 6 on every time step, so the damage grows linearly with +NTMAX (measured client-side: 150 parse errors at NTMAX=1, 211 at +NTMAX=2, i.e. ~61 junk lines per extra step). + +Fix (same as eq_mcp/tr_mcp): at startup, BEFORE any mcp/logging import +touches sys.stdout: + 1. dup fd 1 (JSON-RPC write pipe) to a fresh fd; redirect fd 1 → stderr + so Fortran WRITE(6,...) goes to the subprocess stderr (backend log). + 2. Rebuild sys.stdout around the saved fd so the MCP framework's stdio + transport still writes to the correct pipe. + +After this: + - Fortran WRITE(6,...) → fd 1 → stderr (harmless backend log) + - MCP sys.stdout.write → saved fd → original JSON-RPC write pipe + +NOTE: We do NOT redirect fd 0 (stdin) to /dev/null because the Fortran +library uses stdin internally; redirecting it increases crash rates. """ from __future__ import annotations +import os as _os +import sys as _sys + +# Skip the redirect dance for --print-tools / --help / similar one-shot +# modes that print to the terminal. +_ONESHOT_FLAGS = {"--print-tools", "--help", "-h", "--version"} +_is_oneshot = any(a in _ONESHOT_FLAGS for a in _sys.argv[1:]) + +if not _is_oneshot: + # ---------- fd-isolation (Fortran WRITE(6) vs MCP JSON-RPC) ---------- + # fd 1 originally points at the parent's JSON-RPC write pipe. Fortran + # WRITE(6,...) also targets fd 1, corrupting the pipe. We dup the pipe + # to a fresh fd and redirect fd 1 → stderr so Fortran writes go to the + # subprocess stderr (visible in backend log; harmless to JSON-RPC). + # + # The MCP framework writes via sys.stdout, so we rebuild sys.stdout to + # write to the saved (original-pipe) fd. Line buffering keeps JSON-RPC + # records flushing per-message. + # + # NOTE: We do NOT redirect fd 0 (stdin) to /dev/null because the + # Fortran library uses stdin internally; redirecting it increases crash + # rates (~20% → ~50%) — see the tr_mcp/eq_mcp note. + _mcp_pipe_fd = _os.dup(1) + _os.dup2(2, 1) + _sys.stdout = _os.fdopen(_mcp_pipe_fd, "w", buffering=1, encoding="utf-8") + # ---------------------------------------------------------------------- + import os import sys from pathlib import Path From cd51cb4df6bbc1ba97d8c905751a23454d40ec93 Mon Sep 17 00:00:00 2001 From: HengyuLi-Ozaki-lab Date: Tue, 13 Oct 2026 20:38:32 +0900 Subject: [PATCH 2/3] fix(fp_mcp): install the fd isolation from main(), not at import time The guard redirected fd 1 and replaced sys.stdout when fp_mcp.server was imported, so pytest, a notebook or an embedding application that imported the module lost its own stdout (what #227 item 1 objects to for eq_mcp and tr_mcp). It is now `_install_fd_isolation()`, idempotent, called once by main() after the --help and --print-tools paths and after the check for the MCP SDK, immediately before the stdio server starts. This is the form #226 gives eq_mcp and tr_mcp. The list of one-shot flags that skipped the import-time redirect is gone with it. It held --version, an option main() does not have: main() starts the stdio server for it, unguarded until now and guarded from here on. tests/test_import_isolation.py, each case in a fresh interpreter: an import leaves fd 1 and sys.stdout alone; main() installs the isolation before the server's first write; a second call keeps sys.stdout on the pipe; --help, --print-tools and a missing SDK install nothing. Against the previous commit all five fail. A running stdio server behaves as before. One real session (initialize, init, run with the library's defaults; mcp 1.27.1), stdout lines that are not JSON-RPC of all stdout lines: 63 of 66 without the guard (NTMAX=1; 83 of 86 at NTMAX=2), 0 of 3 with the guard at import (bb52868b), 0 of 3 with it installed from main(). Co-Authored-By: Claude Opus 5.5 --- python/mcp-servers/fp_mcp/server.py | 68 +++++++---- .../fp_mcp/tests/test_import_isolation.py | 110 ++++++++++++++++++ 2 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 python/mcp-servers/fp_mcp/tests/test_import_isolation.py diff --git a/python/mcp-servers/fp_mcp/server.py b/python/mcp-servers/fp_mcp/server.py index 612be0fad..162ff769c 100644 --- a/python/mcp-servers/fp_mcp/server.py +++ b/python/mcp-servers/fp_mcp/server.py @@ -41,11 +41,11 @@ FP is a heavy offender: ``FPWRTPRF`` prints the whole radial profile table to unit 6 on every time step, so the damage grows linearly with -NTMAX (measured client-side: 150 parse errors at NTMAX=1, 211 at -NTMAX=2, i.e. ~61 junk lines per extra step). +NTMAX (measured client-side on the fp_iter01 fixture: 150 parse errors +at NTMAX=1, 211 at NTMAX=2, i.e. ~61 junk lines per extra step). -Fix (same as eq_mcp/tr_mcp): at startup, BEFORE any mcp/logging import -touches sys.stdout: +Fix: installed by main() immediately before the stdio server starts +(NOT at import time -- see #227 item 1): 1. dup fd 1 (JSON-RPC write pipe) to a fresh fd; redirect fd 1 → stderr so Fortran WRITE(6,...) goes to the subprocess stderr (backend log). 2. Rebuild sys.stdout around the saved fd so the MCP framework's stdio @@ -63,29 +63,44 @@ import os as _os import sys as _sys -# Skip the redirect dance for --print-tools / --help / similar one-shot -# modes that print to the terminal. -_ONESHOT_FLAGS = {"--print-tools", "--help", "-h", "--version"} -_is_oneshot = any(a in _ONESHOT_FLAGS for a in _sys.argv[1:]) - -if not _is_oneshot: - # ---------- fd-isolation (Fortran WRITE(6) vs MCP JSON-RPC) ---------- - # fd 1 originally points at the parent's JSON-RPC write pipe. Fortran - # WRITE(6,...) also targets fd 1, corrupting the pipe. We dup the pipe - # to a fresh fd and redirect fd 1 → stderr so Fortran writes go to the - # subprocess stderr (visible in backend log; harmless to JSON-RPC). - # - # The MCP framework writes via sys.stdout, so we rebuild sys.stdout to - # write to the saved (original-pipe) fd. Line buffering keeps JSON-RPC - # records flushing per-message. - # - # NOTE: We do NOT redirect fd 0 (stdin) to /dev/null because the - # Fortran library uses stdin internally; redirecting it increases crash - # rates (~20% → ~50%) — see the tr_mcp/eq_mcp note. +# ---------- fd-isolation (Fortran WRITE(6) vs MCP JSON-RPC) ---------- +# fd 1 originally points at the parent's JSON-RPC write pipe. Fortran +# WRITE(6,...) also targets fd 1, corrupting the pipe. We dup the pipe +# to a fresh fd and redirect fd 1 → stderr so Fortran writes go to the +# subprocess stderr (visible in backend log; harmless to JSON-RPC). +# +# The MCP framework writes via sys.stdout, so we rebuild sys.stdout to +# write to the saved (original-pipe) fd. Line buffering keeps JSON-RPC +# records flushing per-message. +# +# NOTE: We do NOT redirect fd 0 (stdin) to /dev/null because the +# Fortran library uses stdin internally; redirecting it increases crash +# rates (~20% → ~50%). +# +# #227 item 1: this MUST NOT run at import time. Importing this module -- +# pytest collection, an embedding application, or a bare +# `python -c "import fp_mcp.server"` -- would mutate the *host* process's +# fd 1 and replace its sys.stdout. It is installed explicitly by main(), +# i.e. only when this module actually runs as the stdio server, and after +# the --help / --print-tools paths, which print to the terminal. +_FD_ISOLATION_INSTALLED = False +_mcp_pipe_fd = None + + +def _install_fd_isolation() -> None: + """Redirect fd 1 to stderr and rebuild sys.stdout on the saved pipe fd. + + Idempotent. Called from main() immediately before the stdio server + starts; never at import time. + """ + global _FD_ISOLATION_INSTALLED, _mcp_pipe_fd + if _FD_ISOLATION_INSTALLED: + return _mcp_pipe_fd = _os.dup(1) _os.dup2(2, 1) _sys.stdout = _os.fdopen(_mcp_pipe_fd, "w", buffering=1, encoding="utf-8") - # ---------------------------------------------------------------------- + _FD_ISOLATION_INSTALLED = True +# ---------------------------------------------------------------------- import os import sys @@ -698,6 +713,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. + _install_fd_isolation() + server = build_server() # FastMCP >=0.9 exposes .run() for stdio transport by default. server.run() diff --git a/python/mcp-servers/fp_mcp/tests/test_import_isolation.py b/python/mcp-servers/fp_mcp/tests/test_import_isolation.py new file mode 100644 index 000000000..cf9e3075b --- /dev/null +++ b/python/mcp-servers/fp_mcp/tests/test_import_isolation.py @@ -0,0 +1,110 @@ +"""fp_mcp's fd isolation is installed by ``main()``, never by an import. + +The isolation (``dup(1)`` / ``dup2(2, 1)`` / a new ``sys.stdout`` on the saved +pipe) keeps Fortran ``WRITE(6,...)`` off the JSON-RPC pipe. Run at module +scope it would redirect fd 1 of whatever process imports ``fp_mcp.server``: +pytest during collection, a notebook, an embedding application. This is the +contract #227 item 1 asks of ``eq_mcp`` and ``tr_mcp``, held here for +``fp_mcp``. + +Every check runs in a fresh interpreter: the redirect is process-global, and +in the test process it would take pytest's own capture of fd 1 with it. +""" +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +PYTHON_ROOT = Path(__file__).resolve().parents[3] # /python +REPO_ROOT = PYTHON_ROOT.parent + +# What the stdio transport and the Fortran library do, in that order: +# JSON-RPC through sys.stdout, WRITE(6,...) straight to fd 1. +_STUB_SERVER = """ +class _Stdio: + def run(self): + sys.stdout.write("JSONRPC-MARKER"); sys.stdout.flush() + os.write(1, b"FORTRAN-MARKER") +srv.MCP_AVAILABLE = True # build_server is replaced: the SDK is not needed +srv.build_server = lambda: _Stdio() +""" + + +def _run(code, *args): + env = {**os.environ, + "PYTHONPATH": os.pathsep.join([str(PYTHON_ROOT), str(PYTHON_ROOT / "mcp-servers")])} + out = subprocess.run([sys.executable, "-c", textwrap.dedent(code), *args], env=env, + cwd=str(REPO_ROOT), capture_output=True, stdin=subprocess.DEVNULL, + encoding="utf-8", errors="replace", timeout=60) + assert out.returncode == 0, out.stderr[-2000:] + return out + + +def test_import_leaves_fd1_and_sys_stdout_alone(): + out = _run(""" + import os, sys + before = os.fstat(1); stdout = sys.stdout + import fp_mcp.server as srv + after = os.fstat(1) + print("fd1_same", (before.st_dev, before.st_ino) == (after.st_dev, after.st_ino)) + print("stdout_same", stdout is sys.stdout) + print("hook", callable(getattr(srv, "_install_fd_isolation", None))) + print("installed", srv._FD_ISOLATION_INSTALLED) + sys.stdout.flush() # the prints first: os.write is not buffered + os.write(1, b"FD1-MARKER") + """) + assert out.stdout.split() == ["fd1_same", "True", "stdout_same", "True", + "hook", "True", "installed", "False", "FD1-MARKER"] + assert "FD1-MARKER" not in out.stderr + + +def test_main_installs_the_isolation_before_the_stdio_server_starts(): + out = _run("import os, sys\nimport fp_mcp.server as srv\n" + _STUB_SERVER + """ +assert srv.main([]) == 0 +assert srv._FD_ISOLATION_INSTALLED +""") + assert "JSONRPC-MARKER" in out.stdout and "JSONRPC-MARKER" not in out.stderr + assert "FORTRAN-MARKER" in out.stderr and "FORTRAN-MARKER" not in out.stdout + + +def test_installing_twice_keeps_sys_stdout_on_the_pipe(): + # A second dup(1) would save the already-redirected stderr as "the pipe". + out = _run(""" + import os, sys + import fp_mcp.server as srv + srv._install_fd_isolation() + srv._install_fd_isolation() + sys.stdout.write("JSONRPC-MARKER"); sys.stdout.flush() + os.write(1, b"FORTRAN-MARKER") + """) + assert "JSONRPC-MARKER" in out.stdout and "JSONRPC-MARKER" not in out.stderr + assert "FORTRAN-MARKER" in out.stderr and "FORTRAN-MARKER" not in out.stdout + + +def test_help_and_print_tools_install_nothing(): + for flag in ("--help", "--print-tools"): + out = _run(""" + import os, sys + import fp_mcp.server as srv + assert srv.main([sys.argv[1]]) == 0 + print("installed", srv._FD_ISOLATION_INSTALLED) + os.write(1, b"FD1-MARKER") + """, flag) + assert "installed False" in out.stdout, flag + assert "FD1-MARKER" in out.stdout and "FD1-MARKER" not in out.stderr, flag + assert ("fp-mcp" if flag == "--help" else "run_and_get_state") in out.stdout, flag + + +def test_missing_sdk_installs_nothing(): + out = _run(""" + import os, sys + import fp_mcp.server as srv + srv.MCP_AVAILABLE = False + rc = srv.main([]) + print("rc", rc, "installed", srv._FD_ISOLATION_INSTALLED) + sys.stdout.flush() + os.write(1, b"FD1-MARKER") + """) + assert "rc 2 installed False" in out.stdout + assert "FD1-MARKER" in out.stdout and "FD1-MARKER" not in out.stderr From 56bd898a244ee2f36f01f0d524dfa6563bcf0f1f Mon Sep 17 00:00:00 2001 From: HengyuLi-Ozaki-lab Date: Sun, 18 Oct 2026 19:08:14 +0800 Subject: [PATCH 3/3] fix(fp_mcp): install the shared fd isolation (python/_fd_isolation.py) As said on #257: with the shared module in develop, fp_mcp drops its own copy of the isolation and does what the other six servers do since #257 and #263. main() calls _fd_isolation.install() immediately before the stdio server is built (after the --help / --print-tools / missing-SDK returns); the module is imported after the library wrapper, whose directory it shares. fp_mcp/server.py now differs from develop by that import, that call and the docstring section, which keeps the FP measurement (FPWRTPRF writes the radial profile table at preparation and every NTG2STEP steps, every step by default). So fp_mcp also gets what the module handles beyond the plain redirect: fd 2 closed, stdout and stderr the same file, one flag per process. Tests: fp_mcp/tests/test_fd_isolation.py runs the shared checks (python/mcp-servers/fd_isolation_cases.py), and python/tests/test_fd_isolation.py lists fp_mcp among the servers whose test file must call every shared case. fp_mcp/tests/test_import_isolation.py is removed: its five cases (import leaves fd 1 and sys.stdout alone, main() installs before the server starts, installing twice, --help and --print-tools, the missing SDK) are among the shared ones or the module's own. README Q8 says where unit 6 output goes, as #264 did for the other servers; a CHANGELOG entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++ python/mcp-servers/fp_mcp/README.md | 2 + python/mcp-servers/fp_mcp/server.py | 75 +++--------- .../fp_mcp/tests/test_fd_isolation.py | 43 +++++++ .../fp_mcp/tests/test_import_isolation.py | 110 ------------------ python/tests/test_fd_isolation.py | 2 +- 6 files changed, 70 insertions(+), 171 deletions(-) create mode 100644 python/mcp-servers/fp_mcp/tests/test_fd_isolation.py delete mode 100644 python/mcp-servers/fp_mcp/tests/test_import_isolation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b5faaaebc..ca06d20d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,15 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/). (`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. +- **MCP server `fp_mcp`: the same fd isolation** (#232). `fp_mcp` had the + most of it: `FPWRTPRF` writes the radial profile table to unit 6 once at + preparation and then every `NTG2STEP` steps (every step by default), so + the lines that are not JSON-RPC grew with `NTMAX`: in one stdio session + (`initialize`, `init`, `run`, library defaults) 63 of the 66 stdout lines + at `NTMAX=1`, 83 of 86 at `NTMAX=2`. `main()` now calls `_fd_isolation.install()` + immediately before the stdio server is built, as the other six servers + do; its `tests/test_fd_isolation.py` runs the shared checks, and + `python/tests/test_fd_isolation.py` checks all seven servers. ## TR Phase L — library-ization (2026-04-18) diff --git a/python/mcp-servers/fp_mcp/README.md b/python/mcp-servers/fp_mcp/README.md index cbc447f6e..0ca3e057a 100644 --- a/python/mcp-servers/fp_mcp/README.md +++ b/python/mcp-servers/fp_mcp/README.md @@ -354,6 +354,8 @@ FP の Fortran 側は FPCOMM 単一状態なので、1 プロセス = 1 イン MCP サーバの標準エラー出力が LLM クライアントに渡ります。Claude Desktop なら「開発者ツール」相当のログビューア、Claude Code なら実行ターミナルで確認できます。 +Fortran 側の `WRITE(6,...)`(unit 6 = fd 1)も同じ標準エラー出力に流れます。stdio サーバでは fd 1 が JSON-RPC の送信パイプなので、`main()` がサーバ構築の直前に共有モジュール `python/_fd_isolation.py` で fd 1 を stderr に付け替えています(#227 / #232 / #257)。FP は半径プロファイルの表を unit 6 に書きます(準備時に 1 回、その後は `NTG2STEP` ステップごと、既定では毎ステップ)。この付け替えが無いと、その出力がステップ数とともに増え、パイプを壊します。端末で手動起動したときや `2>&1` で stdout と stderr が同じファイルになっているときは、unit 6 の出力は捨てられ、その旨の警告が stderr に 1 行出ます(同一ファイルの判定は `fstat` の inode によるので、パイプやコンソールに inode が無い Windows では判定されず、fd 1 は通常どおり stderr に向きます)。ライブラリの出力を残したい場合は stderr を別に渡してください(例: `python -m fp_mcp.server 2>server.log`)。 + ## 10. 参考資料 - MCP spec: diff --git a/python/mcp-servers/fp_mcp/server.py b/python/mcp-servers/fp_mcp/server.py index 162ff769c..3ad117f1f 100644 --- a/python/mcp-servers/fp_mcp/server.py +++ b/python/mcp-servers/fp_mcp/server.py @@ -40,68 +40,19 @@ "Connection closed". FP is a heavy offender: ``FPWRTPRF`` prints the whole radial profile -table to unit 6 on every time step, so the damage grows linearly with -NTMAX (measured client-side on the fp_iter01 fixture: 150 parse errors -at NTMAX=1, 211 at NTMAX=2, i.e. ~61 junk lines per extra step). - -Fix: installed by main() immediately before the stdio server starts -(NOT at import time -- see #227 item 1): - 1. dup fd 1 (JSON-RPC write pipe) to a fresh fd; redirect fd 1 → stderr - so Fortran WRITE(6,...) goes to the subprocess stderr (backend log). - 2. Rebuild sys.stdout around the saved fd so the MCP framework's stdio - transport still writes to the correct pipe. - -After this: - - Fortran WRITE(6,...) → fd 1 → stderr (harmless backend log) - - MCP sys.stdout.write → saved fd → original JSON-RPC write pipe - -NOTE: We do NOT redirect fd 0 (stdin) to /dev/null because the Fortran -library uses stdin internally; redirecting it increases crash rates. +table to unit 6 once at preparation and then every NTG2STEP steps (every +step by default), so the damage grows with NTMAX (measured client-side on +the fp_iter01 fixture: 150 parse errors at NTMAX=1, 211 at NTMAX=2). + +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 -import os as _os -import sys as _sys - -# ---------- fd-isolation (Fortran WRITE(6) vs MCP JSON-RPC) ---------- -# fd 1 originally points at the parent's JSON-RPC write pipe. Fortran -# WRITE(6,...) also targets fd 1, corrupting the pipe. We dup the pipe -# to a fresh fd and redirect fd 1 → stderr so Fortran writes go to the -# subprocess stderr (visible in backend log; harmless to JSON-RPC). -# -# The MCP framework writes via sys.stdout, so we rebuild sys.stdout to -# write to the saved (original-pipe) fd. Line buffering keeps JSON-RPC -# records flushing per-message. -# -# NOTE: We do NOT redirect fd 0 (stdin) to /dev/null because the -# Fortran library uses stdin internally; redirecting it increases crash -# rates (~20% → ~50%). -# -# #227 item 1: this MUST NOT run at import time. Importing this module -- -# pytest collection, an embedding application, or a bare -# `python -c "import fp_mcp.server"` -- would mutate the *host* process's -# fd 1 and replace its sys.stdout. It is installed explicitly by main(), -# i.e. only when this module actually runs as the stdio server, and after -# the --help / --print-tools paths, which print to the terminal. -_FD_ISOLATION_INSTALLED = False -_mcp_pipe_fd = None - - -def _install_fd_isolation() -> None: - """Redirect fd 1 to stderr and rebuild sys.stdout on the saved pipe fd. - - Idempotent. Called from main() immediately before the stdio server - starts; never at import time. - """ - global _FD_ISOLATION_INSTALLED, _mcp_pipe_fd - if _FD_ISOLATION_INSTALLED: - return - _mcp_pipe_fd = _os.dup(1) - _os.dup2(2, 1) - _sys.stdout = _os.fdopen(_mcp_pipe_fd, "w", buffering=1, encoding="utf-8") - _FD_ISOLATION_INSTALLED = True -# ---------------------------------------------------------------------- - import os import sys from pathlib import Path @@ -154,6 +105,10 @@ def _install_fd_isolation() -> None: FplibNotImplementedError, ) +# The fd isolation shared by the stdio servers; it lives beside the wrapper, +# in ``/python/``. +import _fd_isolation # noqa: E402 + # ===================================================================== # Parameter & state schema metadata. @@ -716,7 +671,7 @@ def main(argv: Optional[List[str]] = None) -> int: # 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. - _install_fd_isolation() + _fd_isolation.install() server = build_server() # FastMCP >=0.9 exposes .run() for stdio transport by default. diff --git a/python/mcp-servers/fp_mcp/tests/test_fd_isolation.py b/python/mcp-servers/fp_mcp/tests/test_fd_isolation.py new file mode 100644 index 000000000..b1cf0f883 --- /dev/null +++ b/python/mcp-servers/fp_mcp/tests/test_fd_isolation.py @@ -0,0 +1,43 @@ +"""fp_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 = "fp_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/fp_mcp/tests/test_import_isolation.py b/python/mcp-servers/fp_mcp/tests/test_import_isolation.py deleted file mode 100644 index cf9e3075b..000000000 --- a/python/mcp-servers/fp_mcp/tests/test_import_isolation.py +++ /dev/null @@ -1,110 +0,0 @@ -"""fp_mcp's fd isolation is installed by ``main()``, never by an import. - -The isolation (``dup(1)`` / ``dup2(2, 1)`` / a new ``sys.stdout`` on the saved -pipe) keeps Fortran ``WRITE(6,...)`` off the JSON-RPC pipe. Run at module -scope it would redirect fd 1 of whatever process imports ``fp_mcp.server``: -pytest during collection, a notebook, an embedding application. This is the -contract #227 item 1 asks of ``eq_mcp`` and ``tr_mcp``, held here for -``fp_mcp``. - -Every check runs in a fresh interpreter: the redirect is process-global, and -in the test process it would take pytest's own capture of fd 1 with it. -""" -import os -import subprocess -import sys -import textwrap -from pathlib import Path - -PYTHON_ROOT = Path(__file__).resolve().parents[3] # /python -REPO_ROOT = PYTHON_ROOT.parent - -# What the stdio transport and the Fortran library do, in that order: -# JSON-RPC through sys.stdout, WRITE(6,...) straight to fd 1. -_STUB_SERVER = """ -class _Stdio: - def run(self): - sys.stdout.write("JSONRPC-MARKER"); sys.stdout.flush() - os.write(1, b"FORTRAN-MARKER") -srv.MCP_AVAILABLE = True # build_server is replaced: the SDK is not needed -srv.build_server = lambda: _Stdio() -""" - - -def _run(code, *args): - env = {**os.environ, - "PYTHONPATH": os.pathsep.join([str(PYTHON_ROOT), str(PYTHON_ROOT / "mcp-servers")])} - out = subprocess.run([sys.executable, "-c", textwrap.dedent(code), *args], env=env, - cwd=str(REPO_ROOT), capture_output=True, stdin=subprocess.DEVNULL, - encoding="utf-8", errors="replace", timeout=60) - assert out.returncode == 0, out.stderr[-2000:] - return out - - -def test_import_leaves_fd1_and_sys_stdout_alone(): - out = _run(""" - import os, sys - before = os.fstat(1); stdout = sys.stdout - import fp_mcp.server as srv - after = os.fstat(1) - print("fd1_same", (before.st_dev, before.st_ino) == (after.st_dev, after.st_ino)) - print("stdout_same", stdout is sys.stdout) - print("hook", callable(getattr(srv, "_install_fd_isolation", None))) - print("installed", srv._FD_ISOLATION_INSTALLED) - sys.stdout.flush() # the prints first: os.write is not buffered - os.write(1, b"FD1-MARKER") - """) - assert out.stdout.split() == ["fd1_same", "True", "stdout_same", "True", - "hook", "True", "installed", "False", "FD1-MARKER"] - assert "FD1-MARKER" not in out.stderr - - -def test_main_installs_the_isolation_before_the_stdio_server_starts(): - out = _run("import os, sys\nimport fp_mcp.server as srv\n" + _STUB_SERVER + """ -assert srv.main([]) == 0 -assert srv._FD_ISOLATION_INSTALLED -""") - assert "JSONRPC-MARKER" in out.stdout and "JSONRPC-MARKER" not in out.stderr - assert "FORTRAN-MARKER" in out.stderr and "FORTRAN-MARKER" not in out.stdout - - -def test_installing_twice_keeps_sys_stdout_on_the_pipe(): - # A second dup(1) would save the already-redirected stderr as "the pipe". - out = _run(""" - import os, sys - import fp_mcp.server as srv - srv._install_fd_isolation() - srv._install_fd_isolation() - sys.stdout.write("JSONRPC-MARKER"); sys.stdout.flush() - os.write(1, b"FORTRAN-MARKER") - """) - assert "JSONRPC-MARKER" in out.stdout and "JSONRPC-MARKER" not in out.stderr - assert "FORTRAN-MARKER" in out.stderr and "FORTRAN-MARKER" not in out.stdout - - -def test_help_and_print_tools_install_nothing(): - for flag in ("--help", "--print-tools"): - out = _run(""" - import os, sys - import fp_mcp.server as srv - assert srv.main([sys.argv[1]]) == 0 - print("installed", srv._FD_ISOLATION_INSTALLED) - os.write(1, b"FD1-MARKER") - """, flag) - assert "installed False" in out.stdout, flag - assert "FD1-MARKER" in out.stdout and "FD1-MARKER" not in out.stderr, flag - assert ("fp-mcp" if flag == "--help" else "run_and_get_state") in out.stdout, flag - - -def test_missing_sdk_installs_nothing(): - out = _run(""" - import os, sys - import fp_mcp.server as srv - srv.MCP_AVAILABLE = False - rc = srv.main([]) - print("rc", rc, "installed", srv._FD_ISOLATION_INSTALLED) - sys.stdout.flush() - os.write(1, b"FD1-MARKER") - """) - assert "rc 2 installed False" in out.stdout - assert "FD1-MARKER" in out.stdout and "FD1-MARKER" not in out.stderr diff --git a/python/tests/test_fd_isolation.py b/python/tests/test_fd_isolation.py index 8f454a6e0..9401efcd5 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", "ti_mcp", "wr_mcp", "wrx_mcp") +SERVERS = ("eq_mcp", "tr_mcp", "tot_mcp", "ti_mcp", "wr_mcp", "wrx_mcp", "fp_mcp") # ----- every server runs every shared check -----