diff --git a/.claude/skills/al_inspect_results_mcp.md b/.claude/skills/al_inspect_results_mcp.md new file mode 120000 index 0000000..b926c51 --- /dev/null +++ b/.claude/skills/al_inspect_results_mcp.md @@ -0,0 +1 @@ +../../skills/al_inspect_results_mcp.md \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..970b19a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "pyauto-results-inspector": { + "command": "python", + "args": ["-m", "autoassistant.mcp"] + } + } +} diff --git a/autoassistant/audit_skill_apis.py b/autoassistant/audit_skill_apis.py index 8e01cf0..230e327 100644 --- a/autoassistant/audit_skill_apis.py +++ b/autoassistant/audit_skill_apis.py @@ -428,6 +428,9 @@ def select_files(root: Path, scope: str) -> list[Path]: scripts = [ p for p in sorted((root / "scripts").rglob("*.py")) if p.name not in tooling ] + # The MCP server (autoassistant/mcp/) is the one part of autoassistant/ with + # real API usage rather than alias-pattern text, so it joins the scan. + scripts += sorted((root / "autoassistant" / "mcp").glob("*.py")) if scope == "skills": return skills if scope == "wiki": @@ -450,6 +453,7 @@ def select_idiom_files(root: Path) -> list[Path]: tooling = {"audit_skill_apis.py", "refresh_api_docs.py", "test_api_gate.py"} md = sorted((root / "skills").glob("*.md")) + sorted((root / "wiki").rglob("*.md")) py = [p for p in sorted((root / "scripts").rglob("*.py")) if p.name not in tooling] + py += sorted((root / "autoassistant" / "mcp").glob("*.py")) return md + py diff --git a/autoassistant/mcp/__init__.py b/autoassistant/mcp/__init__.py new file mode 100644 index 0000000..a542acc --- /dev/null +++ b/autoassistant/mcp/__init__.py @@ -0,0 +1,10 @@ +""" +The read-only results-inspector MCP server (lens edition). + +`tools.py` is mirrored verbatim from `autofit_assistant:autoassistant/mcp/tools.py` +(keep in sync — graduation to an `autofit[mcp]` extra is the recorded de-dup +path); `lens_tools.py` layers the PyAutoLens-specific image/FITS extraction on +top; `server.py` registers both tool sets; `python -m autoassistant.mcp` runs +the stdio server. Documentation and client configuration live in +`skills/al_inspect_results_mcp.md`. +""" diff --git a/autoassistant/mcp/__main__.py b/autoassistant/mcp/__main__.py new file mode 100644 index 0000000..b38a6ee --- /dev/null +++ b/autoassistant/mcp/__main__.py @@ -0,0 +1,3 @@ +from autoassistant.mcp.server import mcp + +mcp.run() diff --git a/autoassistant/mcp/lens_tools.py b/autoassistant/mcp/lens_tools.py new file mode 100644 index 0000000..7ac3d38 --- /dev/null +++ b/autoassistant/mcp/lens_tools.py @@ -0,0 +1,77 @@ +""" +Lens-specific results-inspector tools, layered on the mirrored core +(`tools.py`). + +Everything here resolves string specs like "subplot_fit.data" against the +`al.agg` enum groups and hands them to the existing aggregation machinery +(`af.AggregateImages`, `af.AggregateFITS`) — argument parsing + one call, +nothing more (`skills/al_inspect_results_mcp.md`, "glue, not code"). +""" + +import enum +from pathlib import Path + +import autofit as af +import autolens as al + +from autoassistant.mcp.tools import DirectoryAggregator, _stdout_to_stderr + + +def _enum_groups() -> dict: + return { + name: obj + for name in dir(al.agg) + if not name.startswith("_") + and isinstance(obj := getattr(al.agg, name), type) + and issubclass(obj, enum.Enum) + } + + +def list_image_names() -> dict: + return { + name: [member.name for member in group] + for name, group in sorted(_enum_groups().items()) + } + + +def _resolve(spec: str): + group_name, _, member_name = spec.partition(".") + groups = _enum_groups() + if group_name not in groups: + raise KeyError( + f"Unknown group {group_name!r} — one of {sorted(groups)}." + ) + try: + return groups[group_name][member_name] + except KeyError: + raise KeyError( + f"Unknown name {member_name!r} in {group_name!r} — one of " + f"{[member.name for member in groups[group_name]]}." + ) from None + + +def combine_images(directory: str, subplots: list, subplot_width: int = 0): + with _stdout_to_stderr(): + aggregator = DirectoryAggregator.from_directory(directory) + agg_image = af.AggregateImages(aggregator=aggregator) + kwargs = dict(subplot_width=subplot_width) if subplot_width else {} + return agg_image.extract_image( + subplots=[_resolve(spec) for spec in subplots], **kwargs + ) + + +def extract_fits( + directory: str, hdus: list, destination_path: str, overwrite: bool = False +) -> str: + destination = Path(destination_path).resolve() + if Path(directory).resolve() in destination.parents: + raise ValueError( + "destination_path must be outside the search-output directory — " + "nothing writes into output/." + ) + with _stdout_to_stderr(): + aggregator = DirectoryAggregator.from_directory(directory) + agg_fits = af.AggregateFITS(aggregator=aggregator) + hdu_list = agg_fits.extract_fits(hdus=[_resolve(spec) for spec in hdus]) + hdu_list.writeto(destination, overwrite=overwrite) + return str(destination) diff --git a/autoassistant/mcp/server.py b/autoassistant/mcp/server.py new file mode 100644 index 0000000..8d7c093 --- /dev/null +++ b/autoassistant/mcp/server.py @@ -0,0 +1,180 @@ +""" +The read-only results-inspector MCP stdio server (lens edition). + +The core tools mirror `autofit_assistant:autoassistant/mcp/server.py` verbatim +(`tools.py`/`__main__.py` are byte-identical mirrors — `diff` them when +syncing); the lens tools at the bottom are this repo's own layer. Run with +`python -m autoassistant.mcp`; client configuration and the design rules live +in `skills/al_inspect_results_mcp.md`. + +Every tool is read-only against `output/`: nothing here composes models, runs +fits, or writes into a search-output directory. +""" + +import io +import logging +import sys + +from mcp.server.fastmcp import FastMCP, Image + +from autoassistant.mcp import lens_tools, tools + + +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) + + +_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") + + +@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) + + +@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)) + + +# --- Lens layer (this repo's own tools) ------------------------------------- + + +@mcp.tool() +def list_extractable_images() -> dict: + """ + The lens image groups and names that `combine_lens_images` / + `extract_lens_fits` accept, e.g. group "subplot_fit" with names "data", + "model_data", "normalized_residual_map". Pass specs as "group.name". + """ + return lens_tools.list_image_names() + + +@mcp.tool() +def combine_lens_images( + directory: str, subplots: list, subplot_width: int = 0 +) -> Image: + """ + Extract named panels (specs like "subplot_fit.data", + "subplot_fit.model_data") from every fit found under `directory` and + combine them into one image, rendered inline in chat — one row per fit by + default (`subplot_width` overrides the layout). Use + `list_extractable_images` for the available specs. + """ + return _png( + lens_tools.combine_images( + directory, subplots=subplots, subplot_width=subplot_width + ) + ) + + +@mcp.tool() +def extract_lens_fits( + directory: str, hdus: list, destination_path: str, overwrite: bool = False +) -> str: + """ + Extract named FITS HDUs (specs like "fits_fit.model_data", + "fits_tracer.convergence") from every fit found under `directory` into a + single .fits file written to `destination_path` (which must be outside the + search-output directory). Returns the written path. + """ + return lens_tools.extract_fits( + directory, hdus=hdus, destination_path=destination_path, overwrite=overwrite + ) + + +if __name__ == "__main__": + mcp.run() diff --git a/autoassistant/mcp/tools.py b/autoassistant/mcp/tools.py new file mode 100644 index 0000000..2ca9181 --- /dev/null +++ b/autoassistant/mcp/tools.py @@ -0,0 +1,148 @@ +""" +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 autoconf.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 new file mode 100644 index 0000000..9df474a --- /dev/null +++ b/autoassistant/tests/test_mcp_tools.py @@ -0,0 +1,163 @@ +""" +Tests for the results-inspector MCP tools: the mirrored core +(`autoassistant/mcp/tools.py`, byte-identical to autofit_assistant's) and this +repo's lens layer (`lens_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, +then plants a subplot-fit grid image and a `fit.fits` so the lens extraction +tools have real-shaped inputs without paying for a lens fit. +""" + +from pathlib import Path + +import numpy as np +import pytest +from astropy.io import fits +from PIL import Image + +import autofit as af +import autolens as al +from autoconf import conf +from autofit.aggregator.summary.aggregate_images import subplot_filename + +from autoassistant.mcp import lens_tools, tools + +ROOT = Path(__file__).resolve().parents[2] + +PANEL = 40 # planted per-panel size in pixels + + +@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) + + grid_x = 1 + max(position.value[0] for position in al.agg.subplot_fit) + grid_y = 1 + max(position.value[1] for position in al.agg.subplot_fit) + for marker in root.rglob(".completed"): + directory = marker.parent + (directory / "image").mkdir(exist_ok=True) + grid_name = subplot_filename(al.agg.subplot_fit.data) + grid_image = Image.new( + "RGB", (grid_x * PANEL, grid_y * PANEL), color=(200, 40, 40) + ) + grid_image.save(directory / "image" / f"{grid_name}.png") + grid_image.save(directory / "image" / "subplot_fit.png") + fits.HDUList( + [fits.PrimaryHDU()] + + [ + fits.ImageHDU(np.zeros((4, 4)), name=hdu.value) + for hdu in al.agg.fits_fit + ] + ).writeto(directory / "files" / "fit.fits") + + return root + + +@pytest.fixture(scope="module") +def fit_directory(output_root): + return sorted( + marker.parent for marker in output_root.rglob(".completed") + )[0] + + +# --- Mirrored core ---------------------------------------------------------- + + +def test_list_searches(output_root): + 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_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): + assert "Maximum Log Likelihood" in tools.get_result_summary( + str(fit_directory) + ) + + +def test_get_samples_summary(fit_directory): + summary = tools.get_samples_summary(str(fit_directory)) + + assert isinstance(summary["max_log_likelihood"], float) + assert summary["parameter_paths"] == ["centre", "normalization", "sigma"] + assert len(summary["max_log_likelihood_parameters"]) == 3 + + +def test_fetch_image_and_list(fit_directory): + assert "subplot_fit.png" in tools.list_images(str(fit_directory)) + assert tools.fetch_image(str(fit_directory), name="subplot_fit").size[0] > 0 + + +# --- Lens layer ------------------------------------------------------------- + + +def test_list_image_names(): + names = lens_tools.list_image_names() + + assert "data" in names["subplot_fit"] + assert "model_data" in names["fits_fit"] + + +def test_resolve_errors(): + with pytest.raises(KeyError, match="Unknown group"): + lens_tools._resolve("no_such_group.data") + with pytest.raises(KeyError, match="Unknown name"): + lens_tools._resolve("subplot_fit.no_such_name") + + +def test_combine_images(output_root): + image = lens_tools.combine_images( + str(output_root), + subplots=["subplot_fit.data", "subplot_fit.model_data"], + ) + + # Two panels wide, one row per fit. + assert image.size == (2 * PANEL, 2 * PANEL) + + +def test_extract_fits(output_root, tmp_path): + destination = tmp_path / "extracted.fits" + + path = lens_tools.extract_fits( + str(output_root), + hdus=["fits_fit.model_data"], + destination_path=str(destination), + ) + + with fits.open(path) as hdu_list: + # A primary HDU plus one extracted HDU per fit. + assert len(hdu_list) == 3 + + +def test_extract_fits_refuses_output_dir(output_root): + with pytest.raises(ValueError, match="outside the search-output"): + lens_tools.extract_fits( + str(output_root), + hdus=["fits_fit.model_data"], + destination_path=str(output_root / "sneaky.fits"), + ) diff --git a/skills/README.md b/skills/README.md index fe5228f..3d9925e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -121,6 +121,10 @@ to the assistant; the paired literature context is the dedicated - [`al_load_results.md`](./al_load_results.md) — load a completed fit's `Tracer`, `Samples`, dataset and FITS products from its output folder. +- [`al_inspect_results_mcp.md`](./al_inspect_results_mcp.md) — the read-only + results-inspector MCP server: browse fits, summaries, result images and bulk + subplot/FITS extraction from chat harnesses without code execution (Claude + Desktop first). - [`al_plot_tracer.md`](./al_plot_tracer.md) — plot ray tracing, critical curves, caustics, magnification maps. - [`al_plot_fit_residuals.md`](./al_plot_fit_residuals.md) — plot model image, diff --git a/skills/al_inspect_results_mcp.md b/skills/al_inspect_results_mcp.md new file mode 100644 index 0000000..a3838a9 --- /dev/null +++ b/skills/al_inspect_results_mcp.md @@ -0,0 +1,103 @@ +--- +name: al_inspect_results_mcp +description: Run and configure the read-only results-inspector MCP server, which lets chat harnesses without code execution (Claude Desktop, Claude Code) inspect PyAutoLens fit results — list fits ranked by evidence, read model and posterior summaries, view result images inline in chat, and extract/combine subplot panels and FITS HDUs across many fits. Use when the user wants to "browse/inspect my results from chat", asks about the MCP server, or wants Claude Desktop wired to their output folder. Not for loading results in Python (that is `al_load_results` / `al_aggregator_bulk_analysis`) and not for running fits. +user-invocable: true +--- + +# The results-inspector MCP server + +`autoassistant/mcp/` is a read-only MCP (Model Context Protocol) stdio server over +PyAutoFit/PyAutoLens output directories. It exists for harnesses that cannot execute +code — a Claude Desktop chat gets tools to list fits, read summaries and display result +images inline, against the same `output/` folder a script-based session works with. + +It is deliberately **not** a fitting interface: composing models and running searches +stay python-first through the other skills. Exposing `search.fit` through a JSON tool +schema flattens the compositional API and is out of scope by design. + +## Orient + +- Server: `autoassistant/mcp/server.py`, run as `python -m autoassistant.mcp` from the + repo root (stdio; nothing listens on a port). +- The **core tools are mirrored verbatim** from + `autofit_assistant:autoassistant/mcp/` (`tools.py` and `__main__.py` byte-identical + — `diff` them when syncing; graduation to an `autofit[mcp]` extra is the recorded + de-dup path). `lens_tools.py` and the lens registrations in `server.py` are this + repo's own layer. +- Requires the `mcp` package in the PyAutoLens environment: `pip install mcp` + (assistant-environment dependency only — never a library requirement). +- All tools are read-only against `output/`; `extract_lens_fits` writes only to an + explicit destination outside it. + +## Tools + +Core (mirrored): `list_searches`, `get_model`, `get_result_summary`, +`get_samples_summary`, `get_search_info`, `list_images`, `fetch_image` — see +`autofit_assistant:skills/af_inspect_results_mcp.md` for the full table. + +Lens layer: + +| Tool | Returns | +|------|---------| +| `list_extractable_images()` | The `al.agg` enum groups and names the two tools below accept, as "group.name" specs (e.g. `subplot_fit.data`, `fits_tracer.convergence`). | +| `combine_lens_images(directory, subplots, subplot_width=0)` | Named panels extracted from every fit under `directory`, combined into one image rendered inline in chat (`af.AggregateImages`). | +| `extract_lens_fits(directory, hdus, destination_path, overwrite=False)` | A single `.fits` of the named HDUs from every fit, written to `destination_path` (refused inside the output directory); returns the path (`af.AggregateFITS`). | + +Wrappers over `PyAutoFit:autofit/aggregator/` (`Aggregator.from_directory`, +`SearchOutput`, `AggregateImages`, `AggregateFITS`) and the +`PyAutoLens:autolens/aggregator/subplot.py` enum groups exposed as `al.agg.*`. + +## Configure a client + +**Claude Desktop** (`claude_desktop_config.json`, Settings → Developer → Edit Config): + +```json +{ + "mcpServers": { + "pyauto-results-inspector": { + "command": "/absolute/path/to/venv/bin/python", + "args": ["-m", "autoassistant.mcp"], + "env": { "PYTHONPATH": "/absolute/path/to/autolens_assistant" } + } + } +} +``` + +Use the interpreter that has PyAutoLens + `mcp` installed. Ask about fits by absolute +path ("list the fits under /home/me/project/output ranked by evidence, then show me +the data and residuals of the best one side by side"). + +MCP clients spawn the server with a **minimal environment** — nothing from your shell +propagates. Anything the stack needs (`PYTHONPATH` for editable/source checkouts, +`NUMBA_CACHE_DIR`/`MPLCONFIGDIR` in restricted setups) must be declared in the +config's `env` block. + +**Claude Code**: the repo-root `.mcp.json` registers the same server automatically for +sessions opened in this repo. + +## Deployment tiers + +1. **Local stdio (built, above)** — Claude Desktop / Claude Code on the machine that + holds `output/`. +2. **Remote (documented only)** — claude.ai web/mobile custom connectors and ChatGPT + developer mode speak MCP but only to servers reachable over the public internet + (no stdio): expose the server via `mcp.run(transport="streamable-http")` behind an + ngrok/cloudflared tunnel. Not built or hardened here; do not tunnel a machine you + care about without thinking about auth. +3. **Hosted (future)** — a collaboration-scale deployment next to shared outputs + (e.g. Euclid sample-wide triage). Same tools; hosting, auth and scale are its own + task. + +## Design rules (maintainers) + +- **Glue, not code.** Every tool is argument parsing + one existing public + PyAutoFit/PyAutoLens call + serialization. If a tool needs more, add the method to + the library first. +- **Read-only.** No fit-running, no compute, no writes into `output/`. +- **stdout is the protocol.** Autofit calls run under `tools._stdout_to_stderr()` and + `server._route_logging_to_stderr()` rebinds stdout log handlers — keep both when + adding tools; a single stray print corrupts the JSON-RPC channel. +- **Anti-drift.** `autoassistant/mcp/*.py` is scanned by the symbol audit via + `autoassistant/audit_skill_apis.py` (`--scope scripts`); tests + (`autoassistant/tests/test_mcp_tools.py`) build their fixture by running a real + tiny fit so format drift fails loudly. diff --git a/skills/al_setup_environment.md b/skills/al_setup_environment.md index 5aafcf8..28b39f2 100644 --- a/skills/al_setup_environment.md +++ b/skills/al_setup_environment.md @@ -67,6 +67,7 @@ source .venv/bin/activate # Core stack + JAX-accelerated array ops + numba JIT pip install --upgrade pip pip install "autolens[jax]" numba +# Optional — the results-inspector MCP server (al_inspect_results_mcp): pip install mcp ``` Python ≥ 3.9 works in principle (all five repos declare `requires-python = ">=3.9"`)