Repository navigation
fix(mcp): eq_mcp, tr_mcp and tot_mcp keep Fortran output off the JSON-RPC pipe - #257
Conversation
…-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
left a comment
There was a problem hiding this comment.
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)andos.fstat(2)(st_dev,st_ino): if they are the same file, point fd 1 atos.devnull(or a log file) instead of fd 2, and write one warning line to stderr; - two subprocess tests:
stderr=subprocess.STDOUT, andpreexec_fn=lambda: os.close(2)(orpass_fds/close_fdsequivalent), both asserting that the Fortran marker never reaches the pipe. Every case intest_import_isolation.pyusescapture_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__.pyis not an importable package (hyphenated directory), but every server already puts<repo>/pythonand<repo>/python/mcp-serversonsys.path, so a flatpython/mcp-servers/_fd_isolation.py(or a module next totrlib) 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 seconddup(1)saves the already-redirected stderr as "the pipe".test_installing_twice_keeps_sys_stdout_on_the_pipecovers same-module repeats only.ti_mcp,wr_mcpandwrx_mcphave 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_mcpimportsfp_mcp.serverat 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 frommain()(ideally via the shared helper). - Comment wording (
eq_mcp/server.py:67-69and the two siblings, same sentence in the docstrings): the SDK does not write throughsys.stdout's line buffering; it wrapssys.stdout.bufferin its ownTextIOWrapperand flushes explicitly.buffering=1only affects Python-sideprint(). Relatedly,_STUB_SERVERwrites viasys.stdout.write; one extrasys.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 regularimport os / import sysblock; intot_mcpthat givesimport os as _osdirectly followed byimport os # noqa: F401. Goes away with the shared helper. - The test file parametrises over tr/tot but lives in
eq_mcp/tests/, sopytest python/mcp-servers/tr_mcpalone never runs the tr/tot cases (CI'spytest python/does)._runreplacesPYTHONPATHrather 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,…).soloaded 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,566callmain()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=signalon 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>
|
The two layouts are closed and the helper is one module: one commit on 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:
Both of your tests are there, through each server's In a real stdio session of Suggestions.
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), Not run: Windows, and Linux before this push. |
k-yoshimi
left a comment
There was a problem hiding this comment.
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_fd2treats everyfstat(2)OSErroras EBADF (checkingerrnowould be stricter);previous.flush()catchesOSError/ValueErroronly; 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).
…-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
…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
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>
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>
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>
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>
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>
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>
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_mcpandtot_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 rebuildssys.stdouton the saved fd, so the library'sWRITE(6,...)goes to the server's stderr and JSON-RPC keeps the pipe.main()calls it once, after the--helpand--print-toolspaths 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 givesfp_mcp.Importing a server installs nothing and leaves fd 1 and
sys.stdoutalone (#227 item 1). Ondevelopnone of these servers has any isolation, so this adds it in its final place rather than moving it.tot_mcpgets its own installer: it imports its siblings for their parameter registries, and an isolation that onlytr_mcp'smain()installs never runs in atot_mcpprocess (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)tr_mcptot_mcpeq_mcp,run(mode=1)(
eq_mcp'srun(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 andsys.stdoutalone;main()installs the isolation before the server is built and before its first write; a second call keepssys.stdouton the pipe;--help,--print-toolsand a missing SDK install nothing;tot_mcp'smain()leavestr_mcpuninstalled. All 19 fail ondevelop: seven for whatdeveloplacks, twelve (the import,--help/--print-toolsand 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 afterbuild_server()fails themain()case of that server. The tests setPYTHONPATHfor 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 orTOTLIB_PATH, and 3 without atifixture; with the mono image, the threetest_mono_*files and the three servers' tests: 173 passed.Not in this pull request
fp_mcp: fix(fp_mcp): redirect Fortran WRITE(6,...) to stderr to protect JSON-RPC channel #232.ti_mcp,wr_mcp,wrx_mcp: no isolation either (4 of 7, 5 of 8 and 0 of 3 lines not JSON in the same kind of session, measured on 2026-10-03)._redirect_fortran_stdout_to_stderrcontext manager for a caller that imports a server without runningmain(): it stays with Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226. (The Windows handling of stdin exists downstream only, not on Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226.)tr_mcp's finalize (PR #226 follow-up: fix MCP stdio isolation, TR lifecycle, and EQ save error reporting #227 item 2): not in your list of three, so it stays with Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226 too.Scope
Based on
develop(a0b58408). Python only. It shareseq_mcp/server.pywith the companion pull request foreq_save(#256), in different hunks (the two merge without a conflict), andeq_mcp/server.pyandtr_mcp/server.pywith #226, which will takedevelop's version of this code when it is brought up to date.🤖 Generated with Claude Code