Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions autoassistant/mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -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`.
"""
166 changes: 23 additions & 143 deletions autoassistant/mcp/server.py
Original file line number Diff line number Diff line change
@@ -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__":
Expand Down
148 changes: 0 additions & 148 deletions autoassistant/mcp/tools.py

This file was deleted.

Loading
Loading