From e15c56344548075df428e285aea2ff725dfe4335 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 21 Jul 2026 16:54:38 +0100 Subject: [PATCH] refactor: import MCP core from autofit[mcp] instead of a local copy Delete the byte-identical `autoassistant/mcp/tools.py`; `server.py` is now a thin launcher that pins config (autonerves-only, before any autofit import) and builds `autofit.mcp.core_server()`. Core test moves to PyAutoFit. Marker-opt the launcher out of the skill-API audit (it is not skill-API surface). Skill install line -> `pip install autofit[mcp]`. Part of PyAutoLabs/PyAutoFit#1403 Co-Authored-By: Claude Opus 4.8 --- autoassistant/mcp/__init__.py | 8 +- autoassistant/mcp/server.py | 166 ++++---------------------- autoassistant/mcp/tools.py | 148 ----------------------- autoassistant/tests/test_mcp_tools.py | 126 ------------------- skills/af_inspect_results_mcp.md | 2 +- 5 files changed, 28 insertions(+), 422 deletions(-) delete mode 100644 autoassistant/mcp/tools.py delete mode 100644 autoassistant/tests/test_mcp_tools.py diff --git a/autoassistant/mcp/__init__.py b/autoassistant/mcp/__init__.py index 485b14e..a24ec4e 100644 --- a/autoassistant/mcp/__init__.py +++ b/autoassistant/mcp/__init__.py @@ -1,8 +1,8 @@ """ The read-only results-inspector MCP server. -`tools.py` holds the plain tool functions (no MCP dependency — the test suite -exercises them directly); `server.py` registers them with an MCP stdio server; -`python -m autoassistant.mcp` runs it. Documentation, client configuration and -the design rules live in `skills/af_inspect_results_mcp.md`. +The tool core is the ``autofit[mcp]`` extra (``autofit.mcp``); `server.py` is a +thin launcher that pins this assistant's config and serves the core tools over +an MCP stdio server. `python -m autoassistant.mcp` runs it. Documentation, client +configuration and the design rules live in `skills/af_inspect_results_mcp.md`. """ diff --git a/autoassistant/mcp/server.py b/autoassistant/mcp/server.py index 9fd7732..4410cc8 100644 --- a/autoassistant/mcp/server.py +++ b/autoassistant/mcp/server.py @@ -1,162 +1,42 @@ """ The read-only results-inspector MCP stdio server. -Registers the `tools` functions with an MCP server so chat harnesses without -code execution (Claude Desktop, Claude Code) can inspect PyAutoFit output -directories. Run with `python -m autoassistant.mcp`; client configuration and -the design rules live in `skills/af_inspect_results_mcp.md`. +The tool core is the ``autofit[mcp]`` extra (``autofit.mcp``); this thin launcher +pins the assistant's config and hardens stdout before importing it, then serves +the core tools unchanged. Run with ``python -m autoassistant.mcp``; client +configuration and the design rules live in ``skills/af_inspect_results_mcp.md``. -Every tool is read-only: nothing here composes models, runs fits, or writes -into `output/`. +Every tool is read-only: nothing here composes models, runs fits, or writes into +``output/``. """ +# pyauto-api-gate: skip — this is a thin launcher, not skill-API surface. The +# read-only tool functions (and their `af.`/`autofit.aggregator` usage) graduated +# to the autofit[mcp] extra (`autofit.mcp`, covered by PyAutoFit's own tests); the +# only references here are internal imports of that library core. import contextlib -import io -import logging import os import sys from pathlib import Path -from mcp.server.fastmcp import FastMCP, Image - - -def _pin_config(): - """ - autonerves derives its config directory from the process working directory - (`conf.instance` defaults to `os.getcwd()/config`) and autofit reads config - at import time. A server launched from a foreign directory — e.g. a chat - client spawning `wsl.exe`, which lands in a Windows folder — would otherwise - scan unrelated files and crash on import (a `desktop.ini` trips configparser - interpolation). Pin the config to this assistant's own `config/` so the - server runs from any working directory. - """ - from autonerves import conf - - config_path = Path(__file__).resolve().parents[2] / "config" - conf.instance = conf.Config( - str(config_path), output_path=str(config_path.parent / "output") - ) - - -def _route_logging_to_stderr(): - """ - stdout carries the JSON-RPC channel, but autofit's logging config (loaded - on import) attaches stdout stream handlers — one stray log line corrupts - the protocol, so every stdout handler is rebound to stderr. - """ - loggers = [logging.getLogger()] + [ - logging.getLogger(name) for name in logging.root.manager.loggerDict - ] - for logger in loggers: - for handler in getattr(logger, "handlers", []): - if ( - isinstance(handler, logging.StreamHandler) - and getattr(handler, "stream", None) is sys.stdout - ): - handler.setStream(sys.stderr) - - -# Importing the tool modules imports autofit, which (a) reads autonerves config -# at import and (b) lets jax's xla_bridge log its backend probe to stdout — both -# fatal to the JSON-RPC channel. So, before that import: force CPU (skips the -# jax backend probe), pin the config so it does not depend on the launch -# directory, and redirect any residual import-time stdout to stderr. Rebind -# logging handlers afterwards for anything attached during import. +# autofit reads autonerves config at import time, and jax's xla_bridge logs its +# backend probe to stdout during that import — both fatal to the JSON-RPC channel. +# So, before importing anything under autofit (including autofit.mcp): force CPU +# (skips the jax probe), pin config to this assistant's own config/ (so it does +# not depend on the launch directory), and guard the import's stdout. The pin uses +# only autonerves — it MUST precede the first autofit import, so it cannot come +# from a helper that itself lives under autofit. os.environ.setdefault("JAX_PLATFORMS", "cpu") -_pin_config() -with contextlib.redirect_stdout(sys.stderr): - from autoassistant.mcp import tools - -_route_logging_to_stderr() - -mcp = FastMCP("pyauto-results-inspector") - - -def _png(image) -> Image: - buffer = io.BytesIO() - image.save(buffer, format="PNG") - return Image(data=buffer.getvalue(), format="png") +from autonerves import conf -@mcp.tool() -def list_searches( - directory: str, - sort_by: str = "log_evidence", - limit: int = 20, - completed_only: bool = False, -) -> list: - """ - List every model-fit found under `directory` (searched recursively): one - row per fit with its name, unique tag, output directory, completion state, - log evidence, maximum log likelihood and free-parameter count. - - Rows are sorted by `sort_by` (descending; fits without that value last) — - use "log_evidence" for nested samplers or "max_log_likelihood" generally — - and truncated to `limit` (pass 0 for all). The returned `directory` of a - row is what the other tools take as their `directory` argument. - """ - return tools.list_searches( - directory, sort_by=sort_by, limit=limit, completed_only=completed_only - ) - - -@mcp.tool() -def get_model(directory: str) -> dict: - """ - The model that was fitted in one search-output directory: a human-readable - `info` block (component classes, priors) and the full model as a dict. - """ - return tools.get_model(directory) - - -@mcp.tool() -def get_result_summary(directory: str) -> str: - """ - The `model.results` text for one search-output directory: the fit's own - summary of the maximum-likelihood model and (when the search produces - them) parameter estimates with errors. - """ - return tools.get_result_summary(directory) - - -@mcp.tool() -def get_samples_summary(directory: str) -> dict: - """ - Posterior summary for one search-output directory: log evidence (None for - MLE/MCMC searches without one), maximum log likelihood, the model's - parameter paths, and the maximum-likelihood and median-PDF parameter - vectors (`median_pdf_parameters` is None for MLE searches, which have no - PDF). - """ - return tools.get_samples_summary(directory) - - -@mcp.tool() -def get_search_info(directory: str) -> dict: - """ - The non-linear search used in one search-output directory: name, unique - tag, completion state, and the search's serialized settings. - """ - return tools.get_search_info(directory) - - -@mcp.tool() -def list_images(directory: str) -> list: - """ - Names of the visualization images (`image/*.png`) available in one - search-output directory — pass a name (without `.png`) to `fetch_image`. - """ - return tools.list_images(directory) +_config = Path(__file__).resolve().parents[2] / "config" +conf.instance = conf.Config(str(_config), output_path=str(_config.parent / "output")) +with contextlib.redirect_stdout(sys.stderr): + from autofit.mcp import core_server -@mcp.tool() -def fetch_image(directory: str, name: str = "subplot_fit") -> Image: - """ - One visualization image from a search-output directory (e.g. - "subplot_fit"), returned inline so it renders directly in chat. Use - `list_images` to see what is available. - """ - return _png(tools.fetch_image(directory, name=name)) +mcp = core_server() if __name__ == "__main__": diff --git a/autoassistant/mcp/tools.py b/autoassistant/mcp/tools.py deleted file mode 100644 index 41c47e7..0000000 --- a/autoassistant/mcp/tools.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Read-only results-inspector tools over PyAutoFit output directories. - -Each function is a thin wrapper over an existing public PyAutoFit aggregator -API (`autofit.aggregator.Aggregator`, `af.SearchOutput`): argument parsing, one call, and -JSON-friendly serialization — nothing more. Any behaviour beyond that belongs -in PyAutoFit itself, not here (`skills/af_inspect_results_mcp.md`, -"glue, not code"). - -This module deliberately has no MCP dependency: `server.py` registers these -functions as MCP tools, and the test suite exercises them without the `mcp` -package installed. -""" - -import contextlib -import json -import sys -from pathlib import Path - -from autonerves.dictable import to_dict - -import autofit as af - -# The directory-backed aggregator — af.Aggregator is the database-backed one, -# and the alias also keeps the API audit from resolving it there. -from autofit.aggregator import Aggregator as DirectoryAggregator - - -@contextlib.contextmanager -def _stdout_to_stderr(): - """ - An MCP stdio server must keep stdout clean — it carries the JSON-RPC - channel — but the directory aggregator prints progress to stdout, - so every autofit call runs with stdout redirected to stderr. - """ - with contextlib.redirect_stdout(sys.stderr): - yield - - -def _float_or_none(value): - try: - return None if value is None else float(value) - except (TypeError, ValueError): - return None - - -def _search_row(search) -> dict: - summary = search.samples_summary - max_lh_sample = getattr(summary, "max_log_likelihood_sample", None) - return dict( - name=search.name, - unique_tag=search.unique_tag, - directory=str(search.directory), - is_complete=search.is_complete, - log_evidence=_float_or_none(getattr(summary, "log_evidence", None)), - max_log_likelihood=_float_or_none( - getattr(max_lh_sample, "log_likelihood", None) - ), - model_free_parameters=getattr(search.model, "prior_count", None), - ) - - -def list_searches( - directory: str, - sort_by: str = "log_evidence", - limit: int = 20, - completed_only: bool = False, -) -> list: - with _stdout_to_stderr(): - aggregator = DirectoryAggregator.from_directory( - directory, completed_only=completed_only - ) - rows = [_search_row(search) for search in aggregator] - rows.sort( - key=lambda row: (row.get(sort_by) is not None, row.get(sort_by)), - reverse=True, - ) - return rows[:limit] if limit else rows - - -def get_model(directory: str) -> dict: - with _stdout_to_stderr(): - model = af.SearchOutput(Path(directory)).model - return dict(info=model.info, model=to_dict(model)) - - -def get_result_summary(directory: str) -> str: - with _stdout_to_stderr(): - return af.SearchOutput(Path(directory)).model_results - - -def get_samples_summary(directory: str) -> dict: - with _stdout_to_stderr(): - search_output = af.SearchOutput(Path(directory)) - summary = search_output.samples_summary - if summary is None: - raise FileNotFoundError( - f"No samples summary found under {directory}/files/ — " - "is this a completed search output directory?" - ) - model = search_output.model - return dict( - log_evidence=_float_or_none(summary.log_evidence), - max_log_likelihood=_float_or_none( - summary.max_log_likelihood_sample.log_likelihood - ), - parameter_paths=[".".join(path) for path in model.paths], - max_log_likelihood_parameters=[ - float(value) - for value in summary.max_log_likelihood_sample.parameter_lists_for_model( - model - ) - ], - # MLE searches (e.g. LBFGS) have no PDF, so no median-PDF sample. - median_pdf_parameters=None - if summary.median_pdf_sample is None - else [ - float(value) - for value in summary.median_pdf_sample.parameter_lists_for_model( - model - ) - ], - ) - - -def get_search_info(directory: str) -> dict: - search_json = Path(directory) / "files" / "search.json" - with _stdout_to_stderr(): - search_output = af.SearchOutput(Path(directory)) - return dict( - name=search_output.name, - unique_tag=search_output.unique_tag, - is_complete=search_output.is_complete, - search=json.loads(search_json.read_text()) - if search_json.exists() - else None, - ) - - -def list_images(directory: str) -> list: - # Visualization outputs live under /image/ (SearchOutput.image - # reads there, despite its docstring saying `files/`). - return sorted(path.name for path in (Path(directory) / "image").glob("*.png")) - - -def fetch_image(directory: str, name: str = "subplot_fit"): - with _stdout_to_stderr(): - return af.SearchOutput(Path(directory)).image(name) diff --git a/autoassistant/tests/test_mcp_tools.py b/autoassistant/tests/test_mcp_tools.py deleted file mode 100644 index f64b7ba..0000000 --- a/autoassistant/tests/test_mcp_tools.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Tests for the results-inspector MCP tool functions (`autoassistant/mcp/tools.py`). - -The fixture runs a real (tiny) `af.LBFGS` fit of the `af.ex.Gaussian` toy so the -output directory always matches the installed PyAutoFit's on-disk format — -a frozen fixture directory would silently drift. LBFGS is an MLE search, so -`log_evidence` is legitimately absent; tests assert that shape rather than -skipping it. -""" - -from pathlib import Path - -import numpy as np -import pytest -from PIL import Image - -import autofit as af -from autonerves import conf - -from autoassistant.mcp import tools - -ROOT = Path(__file__).resolve().parents[2] - - -@pytest.fixture(scope="module") -def output_root(tmp_path_factory): - root = tmp_path_factory.mktemp("output") - conf.instance.push(new_path=str(ROOT / "config"), output_path=str(root)) - - xvalues = np.arange(100.0) - gaussian = af.ex.Gaussian(centre=50.0, normalization=25.0, sigma=10.0) - data = gaussian.model_data_from(xvalues=xvalues) - noise_map = np.full(fill_value=1.0, shape=data.shape) - analysis = af.ex.Analysis(data=data, noise_map=noise_map) - - for name in ("fit_0", "fit_1"): - search = af.LBFGS(name=name, path_prefix="mcp_fixture") - search.fit(model=af.Model(af.ex.Gaussian), analysis=analysis) - - return root - - -@pytest.fixture(scope="module") -def fit_directory(output_root): - directory = sorted( - marker.parent for marker in output_root.rglob(".completed") - )[0] - (directory / "image").mkdir(exist_ok=True) - Image.new("RGB", (32, 16), color=(200, 40, 40)).save( - directory / "image" / "subplot_fit.png" - ) - return directory - - -def test_list_searches(output_root, fit_directory): - rows = tools.list_searches(str(output_root), sort_by="max_log_likelihood") - - assert len(rows) == 2 - assert {row["name"] for row in rows} == {"fit_0", "fit_1"} - for row in rows: - assert row["is_complete"] is True - assert isinstance(row["max_log_likelihood"], float) - assert row["log_evidence"] is None - assert row["model_free_parameters"] == 3 - - -def test_list_searches_limit(output_root): - assert len(tools.list_searches(str(output_root), limit=1)) == 1 - - -def test_list_searches_sorts_none_last(output_root): - rows = tools.list_searches(str(output_root), sort_by="log_evidence") - assert len(rows) == 2 - - -def test_get_model(fit_directory): - result = tools.get_model(str(fit_directory)) - - assert "Gaussian" in result["info"] - assert result["model"]["class_path"].endswith("Gaussian") - - -def test_get_result_summary(fit_directory): - text = tools.get_result_summary(str(fit_directory)) - - assert "Maximum Log Likelihood" in text - - -def test_get_samples_summary(fit_directory): - summary = tools.get_samples_summary(str(fit_directory)) - - assert summary["log_evidence"] is None - assert isinstance(summary["max_log_likelihood"], float) - assert summary["parameter_paths"] == [ - "centre", - "normalization", - "sigma", - ] - assert len(summary["max_log_likelihood_parameters"]) == 3 - assert summary["median_pdf_parameters"] is None - assert summary["max_log_likelihood_parameters"][0] == pytest.approx( - 50.0, abs=1.0 - ) - - -def test_get_search_info(fit_directory): - info = tools.get_search_info(str(fit_directory)) - - assert info["name"] == "fit_0" - assert info["is_complete"] is True - assert info["search"] is not None - - -def test_list_images(fit_directory): - assert "subplot_fit.png" in tools.list_images(str(fit_directory)) - - -def test_fetch_image(fit_directory): - image = tools.fetch_image(str(fit_directory), name="subplot_fit") - - assert image.size == (32, 16) - - -def test_fetch_image_missing(fit_directory): - with pytest.raises(Exception): - tools.fetch_image(str(fit_directory), name="no_such_image") diff --git a/skills/af_inspect_results_mcp.md b/skills/af_inspect_results_mcp.md index 1cc01f7..7bc59b3 100644 --- a/skills/af_inspect_results_mcp.md +++ b/skills/af_inspect_results_mcp.md @@ -19,7 +19,7 @@ schema flattens the compositional API and is out of scope by design. - Server: `autoassistant/mcp/server.py`, run as `python -m autoassistant.mcp` from the repo root (stdio; nothing listens on a port). -- Requires the `mcp` package in the PyAutoFit environment: `pip install mcp` +- The tool core ships as an optional PyAutoFit extra: `pip install autofit[mcp]` (assistant-environment dependency only — never add it to library requirements). - All tools are read-only; exceptions surface as MCP tool errors, nothing is written.