Skip to content

fix(mcp): eq_mcp, tr_mcp and tot_mcp keep Fortran output off the JSON-RPC pipe - #257

Merged
k-yoshimi merged 2 commits into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:fix/mcp-fd-isolation-from-main
Oct 17, 2026
Merged

k-yoshimi merged 2 commits into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:fix/mcp-fd-isolation-from-main

Conversation

@HengyuLi-Ozaki-lab

@HengyuLi-Ozaki-lab HengyuLi-Ozaki-lab commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

The second of the three pieces split out of #226, as asked there on 2026-10-13: the MCP stdio isolation for eq_mcp, tr_mcp and tot_mcp, installed from the launcher.

What

Each of the three servers gets _install_fd_isolation(): it dups fd 1 (the JSON-RPC pipe to the client), points fd 1 at stderr and rebuilds sys.stdout on the saved fd, so the library's WRITE(6,...) goes to the server's stderr and JSON-RPC keeps the pipe. main() calls it once, after the --help and --print-tools paths and the check for the MCP SDK, immediately before the stdio server is built and started. The function is idempotent. It is the form #232 gives fp_mcp.

Importing a server installs nothing and leaves fd 1 and sys.stdout alone (#227 item 1). On develop none of these servers has any isolation, so this adds it in its final place rather than moving it.

tot_mcp gets its own installer: it imports its siblings for their parameter registries, and an isolation that only tr_mcp's main() installs never runs in a tot_mcp process (the observation on #227).

Measured

One real stdio session per server (initialize, init, run; mcp 1.27.1), stdout lines that are not JSON-RPC of all stdout lines:

develop (a0b58408) this branch
tr_mcp 9 of 12 0 of 3
tot_mcp 9 of 12 0 of 3
eq_mcp, run(mode=1) 2 of 5 0 of 3

(eq_mcp's run(mode=1) with the defaults returns an error after EQ has printed; run(mode=0) prints nothing in either tree.)

Tests

python/mcp-servers/eq_mcp/tests/test_import_isolation.py, 19 cases over the three servers, each in a fresh interpreter: an import leaves fd 1 and sys.stdout alone; main() installs the isolation before the server is built and before its first write; a second call keeps sys.stdout on the pipe; --help, --print-tools and a missing SDK install nothing; tot_mcp's main() leaves tr_mcp uninstalled. All 19 fail on develop: seven for what develop lacks, twelve (the import, --help / --print-tools and missing-SDK cases) only because the flag they read does not exist there. Against this branch, installing at import fails 15 of them, and installing after build_server() fails the main() case of that server. The tests set PYTHONPATH for their subprocesses themselves and need neither the SDK nor a built library.

python/ with CI's flags, macOS arm64, gfortran 15: 792 passed, 35 skipped, 1 xfailed (develop: 773 passed). The 35 skips are the 19 Linux-only equivalence tests, 13 that need the mono image or TOTLIB_PATH, and 3 without a ti fixture; with the mono image, the three test_mono_* files and the three servers' tests: 173 passed.

Not in this pull request

Scope

Based on develop (a0b58408). Python only. It shares eq_mcp/server.py with the companion pull request for eq_save (#256), in different hunks (the two merge without a conflict), and eq_mcp/server.py and tr_mcp/server.py with #226, which will take develop's version of this code when it is brought up to date.

🤖 Generated with Claude Code

…-RPC pipe

Split out of k-yoshimi#226 at the maintainer's request, in the form k-yoshimi#232 gives
fp_mcp: `_install_fd_isolation()` dups fd 1 (the pipe to the MCP client),
points fd 1 at stderr and rebuilds sys.stdout on the saved fd. main() calls
it once, after the --help and --print-tools paths and the check for the MCP
SDK, immediately before the stdio server is built and started. Importing a
server installs nothing (k-yoshimi#227 item 1). On develop no server had any
isolation, so this adds it in its final place rather than moving it.

tot_mcp gets its own: it imports tr_mcp and its other siblings for their
parameter registries, and an isolation that only tr_mcp's main() installs
never runs in a tot_mcp process.

One stdio session per server (initialize, init, run; mcp 1.27.1), stdout
lines that are not JSON-RPC of all stdout lines, develop (a0b5840) and
this commit:
  tr_mcp   9 of 12   0 of 3
  tot_mcp  9 of 12   0 of 3
  eq_mcp   2 of 5    0 of 3   (run(mode=1), which returns an error after
                               EQ has printed; run(mode=0) prints nothing)

python/mcp-servers/eq_mcp/tests/test_import_isolation.py, 19 cases over the
three servers, each in a fresh interpreter: an import leaves fd 1 and
sys.stdout alone; main() installs the isolation before the server is built
and before its first write; a second call keeps sys.stdout on the pipe;
--help, --print-tools and a missing SDK install nothing; tot_mcp's main()
leaves tr_mcp uninstalled. All 19 fail on develop: seven for what develop
lacks, twelve (the import, --help / --print-tools and missing-SDK cases)
only because the flag they read does not exist there. Against this commit,
installing at import fails 15 of them, and installing after build_server()
fails the main() case of that server.

Not in this commit: fp_mcp (k-yoshimi#232); ti_mcp, wr_mcp and wrx_mcp, which have
no isolation either; the Windows handling of stdin, which exists downstream
only; and k-yoshimi#226's _redirect_fortran_stdout_to_stderr context manager for
in-process callers.

python/ with CI's flags: 792 passed, 35 skipped, 1 xfailed (develop: 773
passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@k-yoshimi k-yoshimi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Review of 0214201 (in-house + two independent Codex passes, plus a local run; details at the end). The mechanism is right for the normal layout: the installed mcp SDK (1.27.0) writes through TextIOWrapper(sys.stdout.buffer) and flushes after every message, so the rebuilt sys.stdout works; libgfortran keeps the fd number for unit 6, so the redirect takes effect whether or not unit 6 was used before main(); no existing in-process test reaches the install (the --help/--print-tools calls return first); the main() placement is what #227 item 1 asked for. One thing before merge, then suggestions.

Before merge

Two descriptor layouts silently defeat the isolation while _FD_ISOLATION_INSTALLED reports success (eq_mcp/server.py:90-92, tr_mcp/server.py:80-82, tot_mcp/server.py:93-95). Reproduced with a stub server that writes JSONRPC through sys.stdout and FORTRAN through os.write(1, …):

-- launched with stderr merged into stdout (2>&1): pipe got ['JSONRPC', 'FORTRAN']
-- launched with fd 2 closed:                      rc 0, pipe got ['JSONRPC', 'FORTRAN']

With fd 2 closed, os.dup(1) returns the lowest free descriptor, which is 2, so dup2(2, 1) copies the pipe onto itself. With 2>&1, fd 1 and fd 2 are the same open file description and dup2 is a no-op. Neither is a regression against develop, and MCP hosts normally give the server its own stderr, but the helper should not claim success there. Suggested shape:

  • before the dup: try: os.fstat(2) except OSError: os.dup2(os.open(os.devnull, os.O_WRONLY), 2);
  • then compare os.fstat(1) and os.fstat(2) (st_dev, st_ino): if they are the same file, point fd 1 at os.devnull (or a log file) instead of fd 2, and write one warning line to stderr;
  • two subprocess tests: stderr=subprocess.STDOUT, and preexec_fn=lambda: os.close(2) (or pass_fds/close_fds equivalent), both asserting that the Fortran marker never reaches the pipe. Every case in test_import_isolation.py uses capture_output=True, which always supplies distinct open pipes, so these two layouts are untested today.

Suggestions (not blocking)

  • One shared helper and one process-wide flag. The function is copied verbatim into three servers, and #232 makes four. python/mcp-servers/__init__.py is not an importable package (hyphenated directory), but every server already puts <repo>/python and <repo>/python/mcp-servers on sys.path, so a flat python/mcp-servers/_fd_isolation.py (or a module next to trlib) works with no packaging change. A shared flag also closes a hole the per-module flags leave: if two servers' installers run in one process (tot + tr, say), the second dup(1) saves the already-redirected stderr as "the pipe". test_installing_twice_keeps_sys_stdout_on_the_pipe covers same-module repeats only. ti_mcp, wr_mcp and wrx_mcp have the same exposure and would then adopt it in one line each. Fine as a follow-up if you prefer to keep this PR to the three servers; I will track it.
  • Ordering with #232. tot_mcp imports fp_mcp.server at import time. If #232 merges in its current import-time form, test_import_leaves_fd1_and_sys_stdout_alone[tot_mcp] here goes red. So: this PR first, then #232 rebased to install from main() (ideally via the shared helper).
  • Comment wording (eq_mcp/server.py:67-69 and the two siblings, same sentence in the docstrings): the SDK does not write through sys.stdout's line buffering; it wraps sys.stdout.buffer in its own TextIOWrapper and flushes explicitly. buffering=1 only affects Python-side print(). Relatedly, _STUB_SERVER writes via sys.stdout.write; one extra sys.stdout.buffer.write(b"…") would exercise the attribute the SDK actually needs.
  • Import layout: import os as _os; import sys as _sys + a function above the regular import os / import sys block; in tot_mcp that gives import os as _os directly followed by import os # noqa: F401. Goes away with the shared helper.
  • The test file parametrises over tr/tot but lives in eq_mcp/tests/, so pytest python/mcp-servers/tr_mcp alone never runs the tr/tot cases (CI's pytest python/ does). _run replaces PYTHONPATH rather than prepending to the inherited value.

Verified OK

  • mcp/server/stdio.py (1.27.0): anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")), await stdout.write(json + "\n"); await stdout.flush(); FastMCP logging goes to a stderr console.
  • A tiny WRITE(6,…) .so loaded via ctypes and called before and after the redirect in a piped subprocess: first call on the pipe, second on stderr.
  • tr_mcp/tests/test_server.py:335, eq_mcp/tests/test_server.py:423,439, tot_mcp/tests/test_server.py:550,566 call main() only with --help / --print-tools.
  • Local: pytest python/mcp-servers/eq_mcp/tests/test_import_isolation.py python/mcp-servers/eq_mcp/tests/test_server.py python/mcp-servers/tr_mcp/tests python/mcp-servers/tot_mcp/tests --forked --timeout=120 --timeout-method=signal on develop + this branch → 147 passed, 12 skipped (macOS, py3.10).

… did nothing

Answers the review of 0214201 on k-yoshimi#257.

Two descriptor layouts defeated the isolation while it reported itself
installed. Reproduced on the three servers with a write through
sys.stdout (JSON-RPC) and one to fd 1 (Fortran):

  launched with its own stderr   the pipe gets JSON-RPC only
  stderr merged into stdout      the pipe gets both
  fd 2 closed                    the pipe gets both

With fd 2 closed, dup(1) returns 2 and dup2(2, 1) copies the pipe back
onto fd 1; with 2>&1, fd 1 and fd 2 are the same file and dup2 changes
nothing.

The helper is now one module, python/_fd_isolation.py, beside
_runtime_mode.py: every server already puts <repo>/python on sys.path
for its library wrapper, so the module is found wherever the wrapper is
(also for a server package copied out of the tree and run with
PYTHONPATH=<repo>/python; python/mcp-servers was on no server's path,
and is not put there). The three copies are gone. install():

  - opens fd 2 on the null device when it is closed, makes it
    inheritable, and gives it to sys.stderr, which Python left at None
    (print(file=sys.stderr) and a traceback would go to sys.stdout, the
    pipe). A repair that fails half-way is taken back, so that the next
    call does not find fd 2 open and skip the rest; a sys.stderr of None
    beside an open fd 2 is given fd 2 as well;
  - when fd 1 and fd 2 are the same file (st_dev and st_ino of fstat),
    points fd 1 at the null device instead of fd 2 and writes one warning
    line to stderr, best effort. That includes one terminal on both: a
    host that runs the server on a pty parses it, and a server started by
    hand shows the warning instead of the library's output. An inode
    number of 0 is no identity (Windows gives it for a pipe and a
    console; two TCP sockets gave it on macOS): such descriptors are
    taken for different files, so there a merged stream is not
    recognised and fd 1 goes to fd 2 as before;
  - keeps one flag for the process, so that a second server's main() in
    the same process does not dup the redirected fd 1 and take stderr for
    the pipe;
  - saves the pipe on a descriptor above 2, also when 0 is free;
  - changes nothing until everything is prepared. The one call that
    changes fd 1 is a dup2; a failure up to and including it leaves
    fd 1, sys.stdout and the flag as they were and closes what was
    opened for the redirect (a repair of fd 2 that was completed
    stays), and nothing after it can fail (the saved descriptor is
    wrapped with closefd=False, so one owner closes it);
  - flushes the old sys.stdout first and keeps a reference to it: a
    stream that owns fd 1 would close the redirected descriptor when
    collected.

In a real stdio session (initialize, init, run) of tr_mcp with stderr
merged into stdout, the stream now carries the three JSON-RPC answers,
the warning and four lines of the SDK's own log; before this pull request
it carried 13 lines that were not JSON-RPC, nine of them Fortran output.
With its own stderr and with fd 2 closed: three lines, all JSON-RPC, for
each of the three servers.

Tests, 38 cases against 19. python/mcp-servers/fd_isolation_cases.py
holds the checks every server has to pass, and each server's
tests/test_fd_isolation.py runs them for itself (7 each), so a run of
one server's directory includes its share; python/tests/test_fd_isolation.py
has the 17 of the module itself, three of which check that each server's
file calls every shared case. The importing modules register the cases
module for assertion rewriting, so that a failure shows the values. New
since 0214201: stderr merged into stdout and fd 2 closed, through each
server's main(); two servers' main() in one process; a failure at the
stream's construction and at the redirect; a stream that owns fd 1;
descriptors without an identity; the repair of fd 2 for Python and for a
child, a repair that fails (with fd 2 closed before, and with fd 2
open), and a Python without sys.stderr beside an open fd 2; the null
device closed again after the redirect; the saved
pipe with fd 0 closed; text buffered before the installation; a warning
that cannot be written. The stub server writes
through sys.stdout.buffer, the attribute the SDK wraps, and to fd 2; the
child's PYTHONPATH is prepended to, not replaced. Measured by mutation of
the module, each with the number of cases that then fail: no flag (2),
fd 2 not repaired (8), fd 2 repaired with the pipe (6), merged stream
not recognised (5), identity 0 accepted (1), old stream not kept (1),
sys.stderr not repaired (4), fd 2 left close-on-exec (1), old stdout not
flushed (1), the pipe saved on the lowest free descriptor (1), no
cleanup after a failure (2), the warning not best effort (1), a failed
repair of fd 2 not undone (1), sys.stderr built only on a descriptor the
call opened (2), the repaired sys.stderr strict and block-buffered (1),
the null device left open (1), the undo closing an fd 2 it did not open
(1), the repaired sys.stderr owning fd 2 (1). One detail has no case:
the new sys.stdout not owning its descriptor (closefd=False). The cases
also pass with PYTHONDEVMODE=1, PYTHONWARNINGS=default and
PYTHONUNBUFFERED=1 set.

Comments and docstrings no longer say that the SDK writes through
sys.stdout's line buffering.

Not done here: fp_mcp (k-yoshimi#232, which installs from main() since cd51cb4),
ti_mcp, wr_mcp and wrx_mcp would each adopt the module with an import
and a call; the last three have no isolation today. Nothing was run on
Windows or Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HengyuLi-Ozaki-lab

Copy link
Copy Markdown
Collaborator Author

The two layouts are closed and the helper is one module: one commit on 02142011, 93779004.

Before merge. Reproduced on the three servers as you describe (stderr merged into stdout: the pipe gets both markers; fd 2 closed: the pipe gets both), and fixed in the shape you gave. Where it goes further than the shape:

  • fd 2 closed. fd 2 is opened on the null device before the dup, made inheritable (when os.open itself returns 2 the descriptor is close-on-exec, and a child process would start without it), and sys.stderr, which Python leaves at None in that case, is built on it: otherwise print(..., file=sys.stderr) and a traceback are written to sys.stdout, the pipe. A repair that fails half-way is taken back, so that a second call does not find fd 2 open and skip the rest; and a sys.stderr of None beside an fd 2 that is open is given fd 2 for the same reason.
  • The same file. fd 1 goes to the null device and one warning line goes to stderr. One terminal on both counts as the same file: a host that runs the server on a pty parses that stream, and a server started by hand shows the warning instead of the library's output.
  • No identity. An inode number of 0 is not taken for an identity. Windows gives st_dev = st_ino = 0 for a pipe and a console (by reading CPython's source; nothing was run on Windows), and two TCP sockets gave it on macOS: comparing them would discard the library's output of every server there. So on such descriptors a merged stream is not recognised and fd 1 goes to fd 2, as before this commit.
  • The saved pipe is always a descriptor above 2: with fd 0 closed as well, dup(1) returns 0.
  • Nothing is changed until everything is prepared. The one call that changes fd 1 is the dup2; a failure up to and including it leaves fd 1, sys.stdout and the flag as they were and closes what was opened for the redirect (a repair of fd 2 that was completed stays), and nothing after it can fail. The old sys.stdout is flushed first and a reference to it is kept (a stream that owns fd 1 would close the redirected descriptor when collected).

Both of your tests are there, through each server's main(): stderr=subprocess.STDOUT, and preexec_fn=lambda: os.close(2).

In a real stdio session of tr_mcp (initialize, init, run) with stderr merged into stdout, the stream now carries the three JSON-RPC answers, the warning and four lines of the SDK's own log; on develop (a0b58408, no isolation) it carried 13 lines that were not JSON-RPC, nine of them Fortran output. With its own stderr and with fd 2 closed: three lines, all JSON-RPC, for each of the three servers.

Suggestions.

  • One helper, one flag: taken, at python/_fd_isolation.py, beside _runtime_mode.py, and not in python/mcp-servers/. The servers put only <repo>/python on sys.path (_PYTHON_ROOT, for the library wrapper); python/mcp-servers is on the path only when the launcher's PYTHONPATH names it. A first version with the module in python/mcp-servers/ could not be imported by a server package copied out of the tree and run with PYTHONPATH=<repo>/python, and changed which copy of its siblings tot_mcp finds. Where the wrapper is found, the module is found. Two servers' main() in one process is a test (tot, then tr: both answers on the pipe, both Fortran lines on stderr).
  • Ordering with fix(fp_mcp): redirect Fortran WRITE(6,...) to stderr to protect JSON-RPC channel #232. fix(fp_mcp): redirect Fortran WRITE(6,...) to stderr to protect JSON-RPC channel #232 has installed from main() since cd51cb4d (pushed 2026-10-13), so test_import_leaves_fd1_and_sys_stdout_alone[tot_mcp] stays green in either order. Once this is in develop I will change fp_mcp in fix(fp_mcp): redirect Fortran WRITE(6,...) to stderr to protect JSON-RPC channel #232 to the shared module (an import and the call). ti_mcp, wr_mcp and wrx_mcp are not in this pull request; each needs the same two lines.
  • Comment wording: corrected in the module's docstring (the SDK wraps sys.stdout.buffer and flushes itself; the line buffering only matters for print()). The stub server writes through sys.stdout.buffer.
  • Import layout: gone with the copies. Each server now differs from develop by an import after the wrapper's and one call in main().
  • Where the tests live. python/mcp-servers/fd_isolation_cases.py holds the cases every server has to pass, and each server's tests/test_fd_isolation.py runs them for itself (7 each), so pytest python/mcp-servers/tr_mcp includes tr's share. python/tests/test_fd_isolation.py has the module's own cases (17), three of which check that each server's file calls every shared case. The child's PYTHONPATH is prepended to. eq_mcp/tests/test_import_isolation.py is removed; its cases are among these.

Measured. 38 cases against 19. Each of these changes to the module makes at least one case fail (the number that fail): no flag (2), fd 2 not repaired (8), fd 2 repaired with the pipe (6), a merged stream not recognised (5), identity 0 accepted (1), the old stream not kept (1), sys.stderr not repaired (4), fd 2 left close-on-exec (1), the old stdout not flushed (1), the pipe saved on the lowest free descriptor (1), no cleanup after a failure (2), the warning not best effort (1), a failed repair of fd 2 not undone (1), sys.stderr built only on a descriptor the call opened (2), the repaired sys.stderr strict and block-buffered (1), the null device left open (1), the undo closing an fd 2 it did not open (1), the repaired sys.stderr owning fd 2 (1). One detail has no case: closefd=False on the new sys.stdout. The cases also pass with PYTHONDEVMODE=1, PYTHONWARNINGS=default and PYTHONUNBUFFERED=1. On macOS the suite is 811 passed, 35 skipped, 1 xfailed with the CI flags.

Not run: Windows, and Linux before this push.

@k-yoshimi k-yoshimi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Re-review of 9377900 (in-house + Codex on the delta; locally in a scratch worktree: pytest python/tests/test_fd_isolation.py python/mcp-servers/{eq,tr,tot}_mcp/tests --forked --timeout=120 --timeout-method=signal → 166 passed, 12 skipped; my own two probes against the new code: with 2>&1 the pipe carries the warning line and the JSON-RPC marker and the Fortran marker is discarded, with fd 2 closed the pipe carries the JSON-RPC marker only).

Verified: fd 2 closed → /dev/null on 2 (direct or via dup2), inheritable, sys.stderr rebuilt; fd 0 and 2 both closed → fd 0 stays closed and the saved pipe is 3; the (st_dev, st_ino) identity with the st_ino != 0 guard works on macOS (pipes have st_dev 0 but unique inodes) and Linux, and on Windows falls through to the plain dup2(2, 1) as documented; failure paths undo what they opened and leave fd 1 / sys.stdout / the flag untouched; the SDK's TextIOWrapper(sys.stdout.buffer) is built at run time and finds the rebuilt stream; libgfortran's unit 6 follows fd number 1. The three servers differ from develop by one import and one call in main() after the --help / --print-tools / SDK-missing returns; no stale names remain. #232's cd51cb4d does install from main(), so the order no longer matters; whichever lands second rebases its main().

Approving. Notes, none blocking:

  • A server started by hand in a terminal has fd 1 and 2 on the same pty, so it is now classified "same file" and the library's output is discarded (the CHANGELOG says so). A one-line os.isatty(1) exemption would keep manual runs useful for debugging; fine as a follow-up.
  • Windows: the merged-stream case is not detected there (documented), so Windows keeps develop's behaviour; nothing was run on Windows, which #240 will have to cover.
  • Nits: _repair_fd2 treats every fstat(2) OSError as EBADF (checking errno would be stricter); previous.flush() catches OSError / ValueError only; the merged layout puts the warning line itself on the JSON-RPC stream before the first message (deliberate, the host put stderr there).
  • fp / ti / wr / wrx servers still write Fortran output to their pipes (fp via #232; the other three are the same two lines each).

@k-yoshimi
k-yoshimi merged commit 89e09c7 into k-yoshimi:develop Oct 17, 2026
3 checks passed
HengyuLi-Ozaki-lab pushed a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
…-RPC pipe (k-yoshimi#262)

The three servers had the layout of eq_mcp / tr_mcp / tot_mcp (k-yoshimi#257) and
the same exposure: the library's WRITE(6,...) went to fd 1, the pipe to
the MCP client. Each main() now calls _fd_isolation.install() (the module
k-yoshimi#257 added, python/_fd_isolation.py) immediately before the stdio server
is built; importing a server module leaves fd 1 and sys.stdout of the
importing process alone, and --help / --print-tools / the missing-SDK path
return before anything is redirected.

Each server gets tests/test_fd_isolation.py running the shared checks of
python/mcp-servers/fd_isolation_cases.py, and the meta-test in
python/tests/test_fd_isolation.py now lists all six servers, so a case a
server's file forgets to call fails there.

fp_mcp is left to k-yoshimi#232, which the author is rebasing onto the shared module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA2KahTs3qLMZL6yjSsWwT
HengyuLi-Ozaki-lab pushed a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
…isolation

The logging FAQ of tr / tot / ti / wr / wrx_mcp (and a new Logs section
of eq_mcp's README) now say that the library's WRITE(6,...) lands on
stderr because main() redirects fd 1 (k-yoshimi#257 / k-yoshimi#263), that a merged
stdout/stderr (a terminal, 2>&1) discards it with one warning line, and
how to keep it (2>server.log). Suggested by the docs review of k-yoshimi#263.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TA2KahTs3qLMZL6yjSsWwT
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
develop gained k-yoshimi#256, k-yoshimi#257, k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263 and k-yoshimi#264 since the
last merge (f41fe5b).

Two conflicts:
- CHANGELOG.md: this branch's entry and develop's new entries were
  added at the same place under "### Added"; both kept.
- fp/fp_api.f90, the USE lists of the module: this branch reads NTG1,
  TVOLR and the six P*T arrays from fpcomm, k-yoshimi#259 imports
  fp_param_generation; both kept.

And one change that no line conflict shows. k-yoshimi#259 replaced the clear of
g_prepared in fp_api_set_param by a count of registry sets
(fp_param_generation, compared by fp_run with g_prep_generation). The
global scalars of fp_get_state were gated on g_prepared alone, so after
the merge a set no longer zeroed them, against what this branch
documents and tests ("zero ... and again after a set_param until the
next run that is made"). The gate is now "prepared, and no set since
the last prep" (g_prepared .AND. g_prep_generation ==
fp_param_generation()), which also covers sets through tot's "fp:"
dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
develop gained k-yoshimi#242, k-yoshimi#243, k-yoshimi#248, k-yoshimi#249, k-yoshimi#251, k-yoshimi#254, k-yoshimi#255, k-yoshimi#256, k-yoshimi#257,
k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263 and k-yoshimi#264 since this branch left it.

One conflict, in python/eqlib/_ffi.py, python/fplib/_ffi.py and
python/trlib/_ffi.py alike: the end of load_library. k-yoshimi#261 (k-yoshimi#246) checks
the state layout of the library just loaded and raises
<Mod>libAbiError on a mismatch; this branch closes the directory it
registered with os.add_dll_directory when the load fails. The check is
now inside the block that cleans up, so a library refused for its
layout does not leave its directory in the process-wide DLL search set
either (or an entry in _DLL_DIR_HANDLES). Order as on develop: the
layout check, then the prototypes.

With the check on the load path, the cases of test_platform_libname.py
that fake the loaded library (ctypes.CDLL patched) were refused by it:
24 of 39 failed. Each of the three files now has an autouse fixture that
patches check_state_layout out for them (the check has its own cases in
test_ffi.py, TestLibraryLayout), and one new case per module puts a
check back that raises AbiMismatchError (with the prototypes passing,
so that only the check can refuse the load) and asserts
<Mod>libAbiError, the directory handle closed and nothing registered;
with the check moved after the cleanup block, that case fails (measured
on eq).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
develop gained, among others, k-yoshimi#257 (python/_fd_isolation.py, the fd
isolation shared by the stdio servers) and k-yoshimi#263 (ti_mcp, wr_mcp and
wrx_mcp adopt it). No conflict. The next commit moves fp_mcp onto the
shared module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 18, 2026
As said on k-yoshimi#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
k-yoshimi#257 and k-yoshimi#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 k-yoshimi#264 did for
the other servers; a CHANGELOG entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 21, 2026
No line conflict. What the merge changes for this branch without one:
- k-yoshimi#233 put a scalars object into fp_mcp's state schema, zero after a
  set until the next run; set_param_str opens that window too, which
  the schema text did not say yet (next commit).
- k-yoshimi#256 brought eq_mcp's save tool, which this branch's docs still
  called future, after k-yoshimi#226 (fixed in a later commit).
- k-yoshimi#232 and k-yoshimi#257 moved fp_mcp's fd isolation into python/_fd_isolation;
  this branch's server.py keeps it.
- k-yoshimi#261 added the layout queries fp_state_abi_version and fp_state_size
  to libfpapi.so; fplib refuses a library without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 21, 2026
develop now carries the pieces split out of this branch (k-yoshimi#256 eq_save,
k-yoshimi#257 the MCP fd isolation, k-yoshimi#265 the mcp pin) and k-yoshimi#232, k-yoshimi#233, k-yoshimi#240, k-yoshimi#242,
k-yoshimi#243, k-yoshimi#248, k-yoshimi#249, k-yoshimi#251, k-yoshimi#254, k-yoshimi#255, k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263, k-yoshimi#264. 43
files conflicted; each was resolved as follows.

- Taken from develop (36 files): every file where this branch only
  changed the mcp pin to "mcp>=0.9,<2" (develop has ">=1.2,<2", k-yoshimi#265):
  the seven pyproject.toml, six READMEs, the fp, ti, tot, wr and wrx
  servers, the Sphinx MCP pages, the trlib presentation and its
  builder, the 2026-04-18 MCP plan; and the eq_save pieces now in
  develop in their reviewed form: eq/eq_api.f90, eq/eq_api.h,
  python/eqlib/tests/test_save.py, eq_mcp/tests/test_save_tool.py; and
  tr_mcp/server.py, whose changes here were this branch's own fd
  isolation (develop's _fd_isolation replaces it) and the os._exit(0)
  at the end of main().
- The os._exit(0) that ended main() of tr_mcp and eq_mcp on this branch
  (to skip the Python teardown, cited in k-yoshimi#227 item 5) is gone with
  them: develop's main() returns 0, as approved in k-yoshimi#257. The abort it
  avoided was a double DEALLOCATE, which this branch fixed in
  tr/trcomm.f90 (DEALLOCATE_TRCOMM returns when nothing is allocated);
  that fix stays, merged without a conflict.
- eq/eqfile.f90: develop's (EQSAVE without an argument, EQSAVE_IERR
  doing the work, and its kept EQDSKR error), plus this branch's
  MODELG=25 in EQ_READ's EQDSK branch (k-yoshimi#228-2). eq/eqmenu.f90 merged
  without a conflict but kept this branch's "CALL EQSAVE(IERR)"; it is
  develop's "CALL EQSAVE" again, as EQSAVE takes no argument.
- python/eqlib/_ffi.py, eqlib.py, tests/test_eqlib.py: develop's, plus
  this branch's get_psi_rz (the eq_common_get_psi_rz_ prototype, the
  Eq.get_psi_rz method, its closed-handle assertion).
- eq_mcp/server.py: develop's (the save tool of k-yoshimi#256, the shared fd
  isolation), plus this branch's get_psi_rz handler and tool and its
  --print-tools entry (13 tools). eq_mcp/tests/test_server.py: this
  branch's (13 tools with save and get_psi_rz), which contains
  develop's change.
- python/mcp-servers/eq_mcp/tests/test_import_isolation.py (this branch
  only, no conflict) is removed: it pinned this branch's own isolation
  helper (_install_fd_isolation, _FD_ISOLATION_INSTALLED in eq_mcp and
  tr_mcp), which develop's python/_fd_isolation.py replaces; develop's
  tests/test_fd_isolation.py of each of the seven servers checks the
  same contract (an import leaves fd 1 and sys.stdout alone).
- .github/workflows/python-tests.yml: develop's mcp pin; this branch's
  numpy install (the get_psi_rz tests import numpy) and ../task symlink
  step kept.
- CHANGELOG.md did not conflict. The three k-yoshimi#228 entries this branch held
  back in d8b6e4d are added to [Unreleased] Fixed, after its first
  entry, where no other open pull request inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants