A Model Context Protocol (MCP) server that dynamically exposes all astroquery functionality to AI assistants. This server uses introspection to discover and expose Python package functionality through MCP tools, making it a reusable template for creating MCP servers for any Python library.
Part of AI Agents for Science (Accelerated Knowledge Discovery — AKD). This MCP server is the data-access backend for the AKD Astro Data Search Agent, a natural-language astrophysics dataset-discovery system, also packaged as a Claude Code plugin.
This is an MCP server that:
- Dynamically discovers all functions from astroquery modules using Python introspection
- Exposes them as MCP tools that AI assistants can call
- Handles parameter conversion (e.g., coordinates, units) automatically
- Serializes results (Astropy Tables → JSON) for AI consumption
- Provides a reusable pattern for wrapping any Python package as an MCP server
This server provides access to 14 astronomical data services with 141 functions total:
| Module | Functions | Description |
|---|---|---|
| SIMBAD | 18 | Astronomical database for object resolution and crossmatch |
| NED | 15 | NASA/IPAC Extragalactic Database for galaxies and extragalactic objects |
| VizieR | 9 | Access to astronomical catalogs and data tables |
| ADS | 1 | NASA Astrophysics Data System for literature search |
| MAST | 19 | Mikulski Archive for Space Telescopes (HST, JWST, etc.) |
| MAST Catalogs | 12 | Query catalogs in MAST (HSC, PanSTARRS, etc.) |
| HEASARC | 9 | High Energy Astrophysics archive (X-ray, gamma-ray) |
| IRSA | 7 | NASA/IPAC Infrared Science Archive |
| NASA Exoplanet Archive | 7 | Exoplanet data and confirmed planets |
| Gaia | 13 | ESA Gaia mission astrometric catalog |
| SDSS | 16 | Sloan Digital Sky Survey optical imaging and spectroscopy |
| ALMA | 16 | Atacama Large Millimeter/submillimeter Array data |
| ESA Hubble | 21 | ESA Hubble Space Telescope Archive |
| ESA JWST | 16 | ESA James Webb Space Telescope Archive |
Example queries:
- Query SIMBAD for object data:
astroquery_execute("simbad", "query_object", {"object_name": "M31"}) - Search MAST for observations:
astroquery_execute("mast", "query_region", {"coordinates": "M31", "radius": 0.1}) - Get exoplanet data:
astroquery_execute("nea", "query_object", {"object_name": "Kepler-22 b"}) - Run TAP/ADQL queries:
astroquery_execute("simbad", "query_tap", {"query": "SELECT TOP 10 * FROM basic"})
For the complete, always-current list of MCP tools, archives and functions with full parameters, see the documentation site or the wiki.
┌─────────────────┐
│ AI Assistant │
└────────┬────────┘
│ MCP Protocol
┌────────▼────────────────────────────────────────┐
│ FastMCP Server (server.py) │
│ ┌────────────────────────────────────────┐ │
│ │ MCP Tools: │ │
│ │ - astroquery_list_modules() │ │
│ │ - astroquery_list_functions(module) │ │
│ │ - astroquery_get_function_info(...) │ │
│ │ - astroquery_execute(module, func, params) │ │
│ └─────────┬──────────────────────────────┘ │
└────────────┼──────────────────────────────────┘
│
┌────────────▼──────────────────────────────────┐
│ Introspection Layer (introspection.py) │
│ - Discovers Python classes/modules │
│ - Extracts method signatures │
│ - Parses docstrings │
└────────────┬──────────────────────────────────┘
│
┌────────────▼──────────────────────────────────┐
│ Executor (executor.py) │
│ - Dynamically imports and calls functions │
│ - Converts parameters (dict → SkyCoord, etc) │
│ - Serializes results (Table → dict) │
└────────────┬──────────────────────────────────┘
│
┌────────────▼──────────────────────────────────┐
│ Python Package (astroquery) │
│ - SIMBAD, MAST, ADS, Vizier, etc. │
└───────────────────────────────────────────────┘
- server.py - FastMCP server with 4 core tools
- introspection.py - Discovers functions from Python modules
- executor.py - Dynamically executes discovered functions
- config.py - Configuration management
- auth.py - Authentication handling
- utils/ - Serialization helpers (Table → dict, etc.)
- models/ - Type definitions and error handling
This codebase can be adapted for any Python package with a similar structure. Here's how:
Edit introspection.py to define which classes/modules to expose:
# introspection.py
YOUR_PACKAGE_MODULES = {
"service1": "your_package.service1.ServiceClass",
"service2": "your_package.service2.AnotherClass",
# Add more modules here
}Examples for different packages:
# For scikit-learn
SKLEARN_MODULES = {
"linear_model": "sklearn.linear_model",
"tree": "sklearn.tree",
"ensemble": "sklearn.ensemble",
}
# For requests
REQUESTS_MODULES = {
"requests": "requests",
}
# For pandas
PANDAS_MODULES = {
"dataframe": "pandas.DataFrame",
"series": "pandas.Series",
"io": "pandas", # For read_csv, etc.
}Edit executor.py's prepare_argument() function to handle package-specific types:
def prepare_argument(value: Any, param_name: str) -> Any:
"""Convert argument values to appropriate types."""
# Example: Handle file paths
if param_name.lower() in {"filepath", "path", "filename"}:
return Path(value)
# Example: Handle dataframes from dicts
if param_name.lower() in {"data", "df"}:
if isinstance(value, dict):
import pandas as pd
return pd.DataFrame(value)
# Add your package-specific conversions here
return valueFor astroquery, we handle:
- Coordinates:
dict → SkyCoord - Radius:
number → Quantity - Units:
string → astropy.units
Edit executor.py's serialize_result() to convert package-specific types to JSON:
def serialize_result(result: Any) -> Any:
"""Serialize results to JSON-compatible format."""
# Example: Handle pandas DataFrames
if isinstance(result, pd.DataFrame):
return result.to_dict(orient="records")
# Example: Handle numpy arrays
if isinstance(result, np.ndarray):
return result.tolist()
# Add your package-specific serialization here
return resultFor astroquery, we serialize:
astropy.Table → dictSkyCoord → {ra_deg, dec_deg, frame}Quantity → {value, unit}
Edit server.py to customize server name and instructions:
mcp = FastMCP(
"your-package-mcp", # Change this
instructions="""MCP server for [your package].
Use `list_modules` to see available modules
Use `list_functions` to see available functions
Use `get_function_info` to get parameter details
Use `execute` to call any function
Example workflow:
1. list_modules()
2. list_functions("module_name")
3. get_function_info("module_name", "function_name")
4. execute("module_name", "function_name", {"param": "value"})
""",
)
# Update tool names
@mcp.tool()
def your_package_execute(...):
...If your package requires API keys:
- Edit
config.pyto define auth requirements:
"auth": {
"your_service": {"token_env": "YOUR_SERVICE_TOKEN"},
}- Edit
auth.pyto configure the package:
def configure_your_package_auth():
token = get_token("your_service")
if token:
import your_package
your_package.api_key = token[project]
name = "your-package-mcp"
version = "0.1.0"
description = "MCP server for your package"
dependencies = [
"your-package>=1.0.0",
"fastmcp>=2.14.2",
"mcp>=1.25.0",
]
[project.scripts]
your-package-mcp = "server:main"
[tool.fastmcp]
name = "your-package-mcp"Python 3.10 or newer. Direct dependencies and the oldest versions CI tests against:
| Package | Minimum | Notes |
|---|---|---|
| astropy | 6.1 | Python 3.10 resolves to astropy 6.1.x; 3.11+ gets 7.x |
| astroquery | 0.4.10 | |
| fastmcp | 2.14.2 | |
| mcp | 1.25.0 | |
| numpy | 1.24 | |
| httpx | 0.28.1 | Required by fastmcp 2.14.2 |
| pydantic | 2.11.7 | Required by fastmcp 2.14.2 |
| pyyaml | 6.0 | |
| tenacity | 8.2 |
Every name the code uses from these packages is checked against the minimum versions by
scripts/check_api_compat.py (see Testing).
# Using uv (recommended)
uv pip install -e .
# Or using pip
pip install -e .# Run directly
python server.py
# Or using fastmcp deploy (for MCP client integration)
fastmcp deploy server.pyFor services requiring authentication:
export API_DEV_KEY="your-ads-token"
export MAST_TOKEN="your-mast-token"astroquery_execute keeps at most max_rows rows (default 20) in every table of a result,
for all modules: a single table, a list of tables (VizieR), or tables nested in a dict (Gaia
load_data). Each table reports row_count (rows returned), total_rows and truncated.
Raise max_rows or narrow the query (smaller radius, filters, TOP in ADQL) to get more rows.
For example, a MAST get_product_list with 6,342 products drops from about 4.1 million
characters to about 14,000 with the default.
Functions are labeled by astroquery_list_functions as either:
metadata: catalog rows, product lists, URLs. Results are returned inline, sized for the model's context. Use these to discover what exists (e.g. MASTget_product_list, which lists every product with itsdataURI).retrieval:download_*,get_images,get_spectra,get_product, ... These fetch files. File contents are never returned to the model.
Each file a retrieval call produces is saved in the artifact store and described in the
response's artifacts list:
{
"artifact_id": "c1931e4c4cf75a42",
"archive": "sdss",
"product_id": "frame-r-000756-2-0427",
"filename": "frame-r-000756-2-0427.fits",
"content_type": "application/fits",
"content_encoding": null,
"size_bytes": 12447360,
"sha256": "c1931e4c4cf75a42...",
"download_url": "https://data.sdss.org/sas/dr17/eboss/photoObj/frames/301/756/2/frame-r-000756-2-0427.fits.bz2",
"local_path": "~/Library/Caches/astroquery-mcp/artifacts/downloads/.../frame-r-000756-2-0427.fits",
"resource_uri": "astroquery://artifacts/c1931e4c4cf75a42",
"fits_summary": {"hdus": [{"index": 0, "name": "PRIMARY", "type": "PrimaryHDU", "shape": [1489, 2048]}], "header": {"TELESCOP": "..."}}
}download_urlis the archive's original URL when the archive reports it. MASTdownload_productsdoes not, but thedataURIfromget_product_listidentifies the file.resource_uriis an MCP resource: MCP clients read the file's bytes through it, outside the model's context. Works whether the server runs locally or remotely.fits_summary(FITS files only) lists HDU names, types and shapes plus a few primary-header cards (TELESCOP, INSTRUME, OBJECT, DATE-OBS, EXPTIME, ...). Only headers are read.- In-memory products are stored the same way: FITS
HDULists, binary payloads, and numpy arrays over 1000 elements (saved as.npy). Text payloads up to 4 KB stay inline. - For NED/SDSS
get_images/get_spectra, the server calls the_asyncvariant so the original URL is kept.
Where files go. Each retrieval call writes into its own new folder:
<store>/downloads/<UTC time>-<archive>-<id>/. The call runs in that folder, and output-folder
parameters (download_dir, savedir, folder, location) point there unless you pass your
own. The store is, in order of priority:
ASTROQUERY_MCP_ARTIFACT_DIRenvironment variableartifacts.dirin the YAML config (ASTROQUERY_MCP_CONFIG)- The per-user cache dir:
~/Library/Caches/astroquery-mcp/artifacts(macOS),$XDG_CACHE_HOMEor~/.cache/astroquery-mcp/artifacts(Linux),%LOCALAPPDATA%\astroquery-mcp\artifacts(Windows)
Files are not deleted automatically; remove old folders under downloads/ when needed.
Because every call gets a fresh folder, astroquery's "already downloaded" cache does not
apply across calls.
uv run pytest # offline tests: no network, about 2 seconds
uv run pytest -m network # live tests against ADS, SIMBAD, XMM, ... (needs API_DEV_KEY)CI (.github/workflows/tests.yml) runs on every pull request into main and again on main
after each merge. Merging is blocked unless the Offline tests check passes, which requires:
- the offline tests passing on Python 3.10, 3.11, 3.12 and 3.13, and
- a minimum versions job: install the oldest dependency versions allowed by
pyproject.tomlon Python 3.10, runscripts/check_api_compat.py, then the offline tests.
scripts/check_api_compat.py reads the source without running it, collects every
third-party import, attribute (u.arcmin, SkyCoord.from_name) and keyword argument the
code uses, and confirms each one exists in the installed versions. To check locally against
the minimum versions:
uv pip compile pyproject.toml --group dev --resolution lowest-direct -p 3.10 -o /tmp/lowest.txt
uv venv -p 3.10 /tmp/lowest && VIRTUAL_ENV=/tmp/lowest uv pip install -r /tmp/lowest.txt
/tmp/lowest/bin/python scripts/check_api_compat.py && /tmp/lowest/bin/python -m pytestLive tests run only when started by hand: Actions → Tests → Run workflow → tick live.
They need the API_DEV_KEY (and optionally MAST_TOKEN) repository secrets.
Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"astroquery": {
"command": "python",
"args": ["/path/to/server.py"],
"env": {
"API_DEV_KEY": "your-token-here"
}
}
}
}Here's a complete minimal example for pandas:
# introspection.py
PANDAS_MODULES = {
"io": "pandas", # read_csv, read_json, etc.
"dataframe": "pandas.DataFrame",
}
# executor.py - parameter handling
def prepare_argument(value: Any, param_name: str) -> Any:
if param_name == "filepath_or_buffer":
return value # Already a string path
return value
# executor.py - result serialization
def serialize_result(result: Any) -> Any:
if isinstance(result, pd.DataFrame):
return {
"columns": result.columns.tolist(),
"data": result.to_dict(orient="records"),
"shape": result.shape,
}
return result
# server.py
mcp = FastMCP(
"pandas-mcp",
instructions="MCP server for pandas data operations"
)Now AI assistants can call:
execute("io", "read_csv", {"filepath_or_buffer": "data.csv"})
execute("dataframe", "describe", {})The documentation site (Sphinx, Read the Docs theme) and the
GitHub wiki are generated from the source docstrings by
scripts/gen_docs.py and republished automatically on every GitHub release (.github/workflows/docs.yml).
Build locally:
uv run --group docs sphinx-build -b html docs docs/_build/htmlThis writes the site to docs/_build/html and the wiki pages to build/wiki.
By default, introspection looks for methods starting with common patterns:
QUERY_METHOD_PREFIXES = (
"query_", "get_", "list_", "search_", "download_",
"fetch_", "resolve_", "cone_search",
)Customize this in introspection.py for your package:
# For a REST API wrapper
API_METHOD_PREFIXES = (
"get_", "post_", "put_", "delete_", "fetch_",
)
# For a data processing library
PROCESSING_METHOD_PREFIXES = (
"transform_", "process_", "compute_", "analyze_",
)The template includes structured error handling in models/errors.py:
class MCPError(Exception):
def __init__(
self,
code: ErrorCode,
message: str,
service: str,
recoverable: bool = True,
suggestion: str = "",
details: dict = None,
):
...Use it in your code:
raise MCPError(
code=ErrorCode.VALIDATION_ERROR,
message="Invalid parameter",
service="your_module",
recoverable=False,
suggestion="Check the function signature",
)The template includes configurable retry logic in retry.py for handling:
- Network errors
- Rate limiting
- Temporary service failures
Customize in config.py:
defaults:
timeout: 60
max_retries: 3
backoff_factor: 2.0astroquery_mcp/
├── server.py # Main MCP server (FastMCP)
├── introspection.py # Function discovery via reflection
├── executor.py # Dynamic function execution
├── config.py # Configuration management
├── auth.py # Authentication handling
├── http_client.py # HTTP client with retry logic
├── retry.py # Retry decorators
├── models/
│ ├── errors.py # Error types
│ └── results.py # Result types
├── utils/
│ ├── table_utils.py # Table serialization
│ └── param_utils.py # Parameter conversion
└── tools/
├── discovery.py # Service discovery
├── simbad.py # Service-specific helpers
└── ...
Uses Python's inspect and importlib to discover functions at runtime:
def introspect_class(module_name: str, class_path: str):
module = importlib.import_module(module_path)
cls = getattr(module, class_name)
for method_name in dir(cls):
method = getattr(cls, method_name)
sig = inspect.signature(method)
# Extract parameters, docstrings, etc.Single execute() function handles all discovered functions:
def execute_function(module_name, function_name, params):
cls = get_class_instance(module_name)
method = getattr(cls, function_name)
prepared_params = {k: prepare_argument(v, k) for k, v in params.items()}
result = method(**prepared_params)
return serialize_result(result)Bidirectional conversion between JSON and package-specific types:
JSON dict → Python objects → Function call → Result → JSON dict
↓ ↓ ↓ ↓ ↓
{ra: 10, SkyCoord(...) method() Table {columns: [],
dec: 41} data: [...]}
- Zero boilerplate - No need to manually write MCP tools for each function
- Automatic updates - New package functions are automatically exposed
- Type safety - Introspection captures parameter types and defaults
- Discoverable - AI can explore available functions via
list_functions - Reusable - Same pattern works for any Python package
- Maintainable - Changes to the package are automatically reflected
This pattern works well for packages that:
- ✅ Have class-based or module-based APIs
- ✅ Use standard Python types or have serializable custom types
- ✅ Have functions with clear parameters (not *args/**kwargs heavy)
- ✅ Have docstrings for parameters
May need customization for:
⚠️ Packages with heavy use of callbacks/generators⚠️ Packages requiring complex state management⚠️ Packages with binary data (images, audio, etc.)⚠️ Packages with streaming APIs
To add support for a new astroquery module:
- Add to
ASTROQUERY_MODULESinintrospection.py - Add type conversions if needed in
executor.py - Add auth config if needed in
config.py
MIT License - feel free to use this template for your own MCP servers!
- FastMCP - FastAPI-like framework for MCP servers
- Astroquery - Python package for astronomical data queries
- MCP - Model Context Protocol specification
This README explains the architecture and how to adapt it. Key files to study:
server.py:1-171- MCP server setup and tool definitionsintrospection.py:109-198- How function discovery worksexecutor.py:66-118- Parameter conversion examplesexecutor.py:121-165- Result serialization examples
Below is a comprehensive list of all available modules and functions with their complete signatures.
- Total Modules: 14
- Total Functions: 141
- SIMBAD - 18 functions
- NED - 15 functions
- VizieR - 9 functions
- ADS - 1 function
- MAST - 19 functions
- MAST Catalogs - 12 functions
- HEASARC - 9 functions
- IRSA - 7 functions
- NASA Exoplanet Archive - 7 functions
- Gaia - 13 functions
- SDSS - 16 functions
- ALMA - 16 functions
- ESA Hubble - 21 functions
- ESA JWST - 16 functions
Class: astroquery.simbad.Simbad
SIMBAD is an astronomical database providing basic data, cross-identifications, bibliography, and measurements for astronomical objects outside the solar system.
1.1 simbad_query_object(object_name, wildcard=False, criteria=None, get_query_payload=False, async_job=False, verbose=False)
Query SIMBAD for the given object.
Parameters:
object_name(Any, required): Name of object to be queriedwildcard(Any, optional, default=False): When True, object specified with wildcardscriteria(Any, optional): Criteria in ADQL syntaxget_query_payload(Any, optional, default=False): When True, returns HTTP request parametersasync_job(Any, optional, default=False): Execute in asynchronous modeverbose(Any, optional, default=False): Verbose output flag
1.2 simbad_query_region(coordinates, radius='2.0 arcmin', criteria=None, get_query_payload=False, async_job=False, equinox=None, epoch=None, cache=None)
Query SIMBAD in a cone around the specified coordinates.
Parameters:
coordinates(Any, required): Identifier or coordinates to query aroundradius(Any, optional, default='2.0 arcmin'): Radius of the regioncriteria(Any, optional): Criteria in ADQL syntaxget_query_payload(Any, optional, default=False): When True, returns HTTP request parametersasync_job(Any, optional, default=False): Execute in asynchronous modeequinox(Any, optional): Equinox for coordinatesepoch(Any, optional): Epoch for coordinatescache(Any, optional): Cache results flag
Query SIMBAD TAP service.
Parameters:
query(str, required): ADQL query stringmaxrec(Any, optional, default=10000): Maximum number of records to returnasync_job(Any, optional, default=False): Execute in asynchronous modeget_query_payload(Any, optional, default=False): When True, returns HTTP request parametersuploads(Any, required): Local tables to be used in query
For the complete list of all 141 functions across all 14 modules, see the full scope documentation or use the astroquery_list_functions() MCP tool.
Most modules support these common query patterns:
- Object queries:
query_object(object_name, ...) - Region queries:
query_region(coordinates, radius, ...) - TAP/ADQL queries:
query_tap(query, ...)for advanced SQL-like queries - Criteria queries:
query_criteria(**filters)for filtering by attributes - Download functions:
download_*()for retrieving data products - List functions:
list_*()for discovering available catalogs, tables, columns
All functions are called through the astroquery_execute MCP tool:
# Basic syntax
astroquery_execute(module_name, function_name, params)
# Examples
astroquery_execute("simbad", "query_object", {"object_name": "M31"})
astroquery_execute("mast", "query_region", {"coordinates": "M31", "radius": 0.1})
astroquery_execute("heasarc", "query_tap", {"query": "SELECT * FROM chandra_observation"})- List modules:
astroquery_list_modules()- See all 14 available modules - List functions:
astroquery_list_functions("simbad")- See all functions for SIMBAD - Get function info:
astroquery_get_function_info("simbad", "query_object")- See detailed parameters - Execute:
astroquery_execute("simbad", "query_object", {"object_name": "M31"})- Run the query
If you use this software in your research, please cite it as:
Astroquery MCP Server. NASA IMPACT.
https://github.com/NASA-IMPACT/astroquery-mcp
- Gaurab Panthee (@igaurab) — original author
- Ashkbiz Danehkar (@danehkar) — astrophysics domain expertise; Universities Space Research Association
- Nish (@NISH1001) — NASA IMPACT / UAH
- Muthukumaran R (@muthukumaranR) — NASA IMPACT
Originally developed by Gaurab Panthee in collaboration with astrophysicist Ashkbiz Danehkar (Universities Space Research Association). This repository is the canonical home for continued development under NASA IMPACT.
Built on astroquery, an Astropy-affiliated package, and served over MCP with FastMCP.
Licensed under the Apache License, Version 2.0. See LICENSE for details.