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 cdca3cec5..3ad117f1f 100644 --- a/python/mcp-servers/fp_mcp/server.py +++ b/python/mcp-servers/fp_mcp/server.py @@ -30,6 +30,26 @@ :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 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 @@ -85,6 +105,10 @@ 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. @@ -644,6 +668,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/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/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 -----