Skip to content
Merged
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
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions python/mcp-servers/fp_mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://modelcontextprotocol.io/>
Expand Down
29 changes: 29 additions & 0 deletions python/mcp-servers/fp_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -85,6 +105,10 @@
FplibNotImplementedError,
)

# The fd isolation shared by the stdio servers; it lives beside the wrapper,
# in ``<repo>/python/``.
import _fd_isolation # noqa: E402


# =====================================================================
# Parameter & state schema metadata.
Expand Down Expand Up @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions python/mcp-servers/fp_mcp/tests/test_fd_isolation.py
Original file line number Diff line number Diff line change
@@ -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] # <repo>/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)
2 changes: 1 addition & 1 deletion python/tests/test_fd_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----
Expand Down
Loading