Skip to content

Commit 330c632

Browse files
Jammy2211claude
authored andcommitted
feat: add results-inspector MCP core as an autofit[mcp] extra
Graduate the read-only results-inspector MCP core into PyAutoFit as an optional `autofit[mcp]` extra, so the two assistants import it instead of copying the byte-identical `tools.py` verbatim. New `autofit/mcp/`: tools (aggregator wrappers, autofit+autonerves only), server.core_server (FastMCP factory), bootstrap (config-pin + stdout routing, autonerves-only so a launcher can pin config before the autofit import), __main__ (standalone `python -m autofit.mcp`). The `mcp` package is an optional extra, never a core requirement. Closes #1403 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 929b21c commit 330c632

8 files changed

Lines changed: 510 additions & 0 deletions

File tree

autofit/mcp/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
The read-only results-inspector MCP core, distributed as the optional
3+
``autofit[mcp]`` extra.
4+
5+
It exposes PyAutoFit output directories to chat harnesses that cannot execute
6+
code (Claude Desktop, ChatGPT) as a small set of read-only tools — list fits
7+
ranked by evidence, read model/posterior/result summaries, view result images
8+
inline. It is deliberately *glue, not code*: every tool is one existing public
9+
aggregator call plus serialization; composing models and running searches stay
10+
Python-first.
11+
12+
Layout:
13+
14+
- ``tools`` — the plain aggregator-wrapper functions (no ``mcp`` dependency; the
15+
test suite exercises them directly).
16+
- ``server.core_server`` — registers those functions on a FastMCP stdio server.
17+
- ``bootstrap`` — the launch-time config-pin + stdout protections, kept free of
18+
any autofit import so a launcher can call them *before* importing the
19+
autofit-backed modules.
20+
21+
Run standalone with ``python -m autofit.mcp`` (needs the extra:
22+
``pip install autofit[mcp]``). Downstream servers — e.g. the PyAutoLens
23+
results-inspector, which adds a lens image/FITS layer — call ``core_server()``
24+
and register their own tools on top.
25+
"""
26+
27+
28+
def __getattr__(name):
29+
# Lazy re-exports (PEP 562): ``autofit.mcp.core_server`` / ``autofit.mcp.png``
30+
# resolve without importing ``autofit.mcp.server`` (and therefore autofit and
31+
# the ``mcp`` package) at ``import autofit.mcp`` time — keeping this package
32+
# import-light so a launcher can pin config before the heavy import.
33+
if name in ("core_server", "png", "_png"):
34+
from autofit.mcp import server
35+
36+
return server.core_server if name == "core_server" else server._png
37+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
38+

autofit/mcp/__main__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
Standalone runner for the ``autofit[mcp]`` extra: ``python -m autofit.mcp``.
3+
4+
Serves the seven read-only core tools over stdio. Downstream servers that add
5+
their own tools (e.g. the PyAutoLens lens layer) provide their own launcher and
6+
call :func:`autofit.mcp.server.core_server` directly.
7+
8+
The ordering here is load-bearing: force JAX onto CPU and pin config to autofit's
9+
own bundled ``config/`` (so the server does not depend on the launch directory)
10+
*before* importing the autofit-backed server, and guard that import so no
11+
import-time stdout reaches the JSON-RPC channel.
12+
"""
13+
14+
import contextlib
15+
import os
16+
import sys
17+
from pathlib import Path
18+
19+
os.environ.setdefault("JAX_PLATFORMS", "cpu")
20+
21+
from autofit.mcp.bootstrap import pin_config
22+
23+
pin_config(Path(__file__).resolve().parents[1] / "config")
24+
25+
with contextlib.redirect_stdout(sys.stderr):
26+
from autofit.mcp.server import core_server
27+
28+
core_server().run()

autofit/mcp/bootstrap.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Launch-time hardening for the MCP stdio server.
3+
4+
This module is deliberately kept free of any ``autofit`` import so a launcher can
5+
call it *before* importing the autofit-backed tool modules — autofit reads its
6+
configuration at import time, and jax's backend probe logs to stdout during that
7+
import, both of which must be handled first. It imports only the standard library
8+
and ``autonerves``.
9+
"""
10+
11+
import logging
12+
import sys
13+
from pathlib import Path
14+
from typing import Union
15+
16+
17+
def pin_config(config_path: Union[str, Path]) -> None:
18+
"""
19+
Pin ``autonerves`` config to ``config_path`` instead of letting it default to
20+
``os.getcwd()/config``.
21+
22+
autofit reads config at import time; if the server is launched from a foreign
23+
working directory — e.g. a chat client spawning ``wsl.exe`` in a Windows
24+
folder — the default would scan unrelated files and crash on import (a
25+
``desktop.ini`` trips configparser interpolation). Call this before importing
26+
``autofit.mcp.server`` (or anything else that pulls in autofit).
27+
"""
28+
from autonerves import conf
29+
30+
config_path = Path(config_path)
31+
conf.instance = conf.Config(
32+
str(config_path), output_path=str(config_path.parent / "output")
33+
)
34+
35+
36+
def route_logging_to_stderr() -> None:
37+
"""
38+
Rebind every stdout-bound logging handler to stderr.
39+
40+
stdout carries the JSON-RPC channel, but autofit's logging config (loaded on
41+
import) attaches stdout stream handlers — one stray log line corrupts the
42+
protocol. Call this after autofit has been imported.
43+
"""
44+
loggers = [logging.getLogger()] + [
45+
logging.getLogger(name) for name in logging.root.manager.loggerDict
46+
]
47+
for logger in loggers:
48+
for handler in getattr(logger, "handlers", []):
49+
if (
50+
isinstance(handler, logging.StreamHandler)
51+
and getattr(handler, "stream", None) is sys.stdout
52+
):
53+
handler.setStream(sys.stderr)

autofit/mcp/server.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
The read-only results-inspector MCP stdio server core.
3+
4+
``core_server()`` returns a FastMCP with the seven read-only tools registered
5+
over ``autofit.mcp.tools``. Callers — the assistants, or ``python -m autofit.mcp``
6+
— register any extra tools on the returned object and call ``.run()``.
7+
8+
Importing this module imports autofit. A launcher must therefore, *before*
9+
importing it: pin config with ``autofit.mcp.bootstrap.pin_config`` (so config
10+
does not depend on the working directory), set ``JAX_PLATFORMS=cpu`` (skips the
11+
jax backend probe, which logs to stdout), and guard the import with
12+
``contextlib.redirect_stdout(sys.stderr)`` — stdout carries the JSON-RPC channel.
13+
``__main__`` does exactly this for the standalone case.
14+
"""
15+
16+
import io
17+
18+
from mcp.server.fastmcp import FastMCP, Image
19+
20+
from autofit.mcp import tools
21+
from autofit.mcp.bootstrap import route_logging_to_stderr
22+
23+
24+
def _png(image) -> Image:
25+
buffer = io.BytesIO()
26+
image.save(buffer, format="PNG")
27+
return Image(data=buffer.getvalue(), format="png")
28+
29+
30+
def core_server(name: str = "pyauto-results-inspector") -> FastMCP:
31+
"""
32+
Build a FastMCP server with the seven read-only results-inspector tools
33+
registered. Downstream servers add their own tools on the returned object.
34+
"""
35+
route_logging_to_stderr()
36+
37+
mcp = FastMCP(name)
38+
39+
@mcp.tool()
40+
def list_searches(
41+
directory: str,
42+
sort_by: str = "log_evidence",
43+
limit: int = 20,
44+
completed_only: bool = False,
45+
) -> list:
46+
"""
47+
List every model-fit found under `directory` (searched recursively): one
48+
row per fit with its name, unique tag, output directory, completion state,
49+
log evidence, maximum log likelihood and free-parameter count.
50+
51+
Rows are sorted by `sort_by` (descending; fits without that value last) —
52+
use "log_evidence" for nested samplers or "max_log_likelihood" generally —
53+
and truncated to `limit` (pass 0 for all). The returned `directory` of a
54+
row is what the other tools take as their `directory` argument.
55+
"""
56+
return tools.list_searches(
57+
directory, sort_by=sort_by, limit=limit, completed_only=completed_only
58+
)
59+
60+
@mcp.tool()
61+
def get_model(directory: str) -> dict:
62+
"""
63+
The model that was fitted in one search-output directory: a human-readable
64+
`info` block (component classes, priors) and the full model as a dict.
65+
"""
66+
return tools.get_model(directory)
67+
68+
@mcp.tool()
69+
def get_result_summary(directory: str) -> str:
70+
"""
71+
The `model.results` text for one search-output directory: the fit's own
72+
summary of the maximum-likelihood model and (when the search produces
73+
them) parameter estimates with errors.
74+
"""
75+
return tools.get_result_summary(directory)
76+
77+
@mcp.tool()
78+
def get_samples_summary(directory: str) -> dict:
79+
"""
80+
Posterior summary for one search-output directory: log evidence (None for
81+
MLE/MCMC searches without one), maximum log likelihood, the model's
82+
parameter paths, and the maximum-likelihood and median-PDF parameter
83+
vectors (`median_pdf_parameters` is None for MLE searches, which have no
84+
PDF).
85+
"""
86+
return tools.get_samples_summary(directory)
87+
88+
@mcp.tool()
89+
def get_search_info(directory: str) -> dict:
90+
"""
91+
The non-linear search used in one search-output directory: name, unique
92+
tag, completion state, and the search's serialized settings.
93+
"""
94+
return tools.get_search_info(directory)
95+
96+
@mcp.tool()
97+
def list_images(directory: str) -> list:
98+
"""
99+
Names of the visualization images (`image/*.png`) available in one
100+
search-output directory — pass a name (without `.png`) to `fetch_image`.
101+
"""
102+
return tools.list_images(directory)
103+
104+
@mcp.tool()
105+
def fetch_image(directory: str, name: str = "subplot_fit") -> Image:
106+
"""
107+
One visualization image from a search-output directory (e.g.
108+
"subplot_fit"), returned inline so it renders directly in chat. Use
109+
`list_images` to see what is available.
110+
"""
111+
return _png(tools.fetch_image(directory, name=name))
112+
113+
return mcp

autofit/mcp/tools.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Read-only results-inspector tools over PyAutoFit output directories.
3+
4+
Each function is a thin wrapper over an existing public PyAutoFit aggregator
5+
API (`autofit.aggregator.Aggregator`, `af.SearchOutput`): argument parsing, one call, and
6+
JSON-friendly serialization — nothing more. Any behaviour beyond that belongs
7+
in PyAutoFit itself, not here ("glue, not code").
8+
9+
This module deliberately has no MCP dependency: `server.py` registers these
10+
functions as MCP tools, and the test suite exercises them without the `mcp`
11+
package installed.
12+
"""
13+
14+
import contextlib
15+
import json
16+
import sys
17+
from pathlib import Path
18+
19+
from autonerves.dictable import to_dict
20+
21+
import autofit as af
22+
23+
# The directory-backed aggregator — af.Aggregator is the database-backed one,
24+
# and the alias also keeps the API audit from resolving it there.
25+
from autofit.aggregator import Aggregator as DirectoryAggregator
26+
27+
28+
@contextlib.contextmanager
29+
def _stdout_to_stderr():
30+
"""
31+
An MCP stdio server must keep stdout clean — it carries the JSON-RPC
32+
channel — but the directory aggregator prints progress to stdout,
33+
so every autofit call runs with stdout redirected to stderr.
34+
"""
35+
with contextlib.redirect_stdout(sys.stderr):
36+
yield
37+
38+
39+
def _float_or_none(value):
40+
try:
41+
return None if value is None else float(value)
42+
except (TypeError, ValueError):
43+
return None
44+
45+
46+
def _search_row(search) -> dict:
47+
summary = search.samples_summary
48+
max_lh_sample = getattr(summary, "max_log_likelihood_sample", None)
49+
return dict(
50+
name=search.name,
51+
unique_tag=search.unique_tag,
52+
directory=str(search.directory),
53+
is_complete=search.is_complete,
54+
log_evidence=_float_or_none(getattr(summary, "log_evidence", None)),
55+
max_log_likelihood=_float_or_none(
56+
getattr(max_lh_sample, "log_likelihood", None)
57+
),
58+
model_free_parameters=getattr(search.model, "prior_count", None),
59+
)
60+
61+
62+
def list_searches(
63+
directory: str,
64+
sort_by: str = "log_evidence",
65+
limit: int = 20,
66+
completed_only: bool = False,
67+
) -> list:
68+
with _stdout_to_stderr():
69+
aggregator = DirectoryAggregator.from_directory(
70+
directory, completed_only=completed_only
71+
)
72+
rows = [_search_row(search) for search in aggregator]
73+
rows.sort(
74+
key=lambda row: (row.get(sort_by) is not None, row.get(sort_by)),
75+
reverse=True,
76+
)
77+
return rows[:limit] if limit else rows
78+
79+
80+
def get_model(directory: str) -> dict:
81+
with _stdout_to_stderr():
82+
model = af.SearchOutput(Path(directory)).model
83+
return dict(info=model.info, model=to_dict(model))
84+
85+
86+
def get_result_summary(directory: str) -> str:
87+
with _stdout_to_stderr():
88+
return af.SearchOutput(Path(directory)).model_results
89+
90+
91+
def get_samples_summary(directory: str) -> dict:
92+
with _stdout_to_stderr():
93+
search_output = af.SearchOutput(Path(directory))
94+
summary = search_output.samples_summary
95+
if summary is None:
96+
raise FileNotFoundError(
97+
f"No samples summary found under {directory}/files/ — "
98+
"is this a completed search output directory?"
99+
)
100+
model = search_output.model
101+
return dict(
102+
log_evidence=_float_or_none(summary.log_evidence),
103+
max_log_likelihood=_float_or_none(
104+
summary.max_log_likelihood_sample.log_likelihood
105+
),
106+
parameter_paths=[".".join(path) for path in model.paths],
107+
max_log_likelihood_parameters=[
108+
float(value)
109+
for value in summary.max_log_likelihood_sample.parameter_lists_for_model(
110+
model
111+
)
112+
],
113+
# MLE searches (e.g. LBFGS) have no PDF, so no median-PDF sample.
114+
median_pdf_parameters=None
115+
if summary.median_pdf_sample is None
116+
else [
117+
float(value)
118+
for value in summary.median_pdf_sample.parameter_lists_for_model(
119+
model
120+
)
121+
],
122+
)
123+
124+
125+
def get_search_info(directory: str) -> dict:
126+
search_json = Path(directory) / "files" / "search.json"
127+
with _stdout_to_stderr():
128+
search_output = af.SearchOutput(Path(directory))
129+
return dict(
130+
name=search_output.name,
131+
unique_tag=search_output.unique_tag,
132+
is_complete=search_output.is_complete,
133+
search=json.loads(search_json.read_text())
134+
if search_json.exists()
135+
else None,
136+
)
137+
138+
139+
def list_images(directory: str) -> list:
140+
# Visualization outputs live under <directory>/image/ (SearchOutput.image
141+
# reads there, despite its docstring saying `files/`).
142+
return sorted(path.name for path in (Path(directory) / "image").glob("*.png"))
143+
144+
145+
def fetch_image(directory: str, name: str = "subplot_fit"):
146+
with _stdout_to_stderr():
147+
return af.SearchOutput(Path(directory)).image(name)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ local_scheme = "no-local-version"
6767

6868
[project.optional-dependencies]
6969
jax = ["autonerves[jax]", "optax>=0.2.5"]
70+
mcp = ["mcp"]
7071
optional = [
7172
"autofit[jax]",
7273
"astropy>=5.0",

test_autofit/mcp/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)