1. First query

This notebook calls MANNA’s tools directly from Python. No LLM is involved: the point is to see exactly what each tool returns, in the order an agent would call them.

Requirements: pip install manna-mcp and network access to the archives. The server runs in-process through fastmcp’s in-memory client, so nothing listens on a port. The commented line shows how to point the same client at a running server instead.

from fastmcp import Client

from manna.app import build_mcp

client = Client(build_mcp())
# client = Client("http://localhost:8000/mcp/")  # a server started with `python -m manna`


def payload(result):
    """The tool's JSON envelope. fastmcp exposes it as structured_content."""
    return result.structured_content


def show_rows(envelope, limit=5):
    """Print the first rows of an inline table envelope with their column names."""
    names = [c["name"] for c in envelope["columns"]]
    print(" | ".join(names))
    for row in envelope["rows"][:limit]:
        print(" | ".join(str(v) for v in row))
    if envelope["row_count"] > limit:
        print(f"... {envelope['row_count']} rows in total")

Which archives does this server know about?

list_archives returns the archive notes: one entry per archive with its endpoints, waveband, notable tables, and usage notes. Everything else stays reachable through the IVOA registry; these are just the archives the server carries curated notes for.

async with client:
    archives = payload(await client.call_tool("list_archives", {}))

print(archives["count"], "archives with notes")
for a in archives["archives"]:
    print(f"{a['short_name']:10s} {a['waveband']:14s} {a['tap_url']}")
7 archives with notes
datalab    optical        https://datalab.noirlab.edu/tap
alma       millimeter     https://almascience.nrao.edu/tap
eso        optical        https://archive.eso.org/tap_obs
cadc       multi          https://ws.cadc-ccda.hia-iha.nrc-cnrc.gc.ca/argus
gaia       optical        https://gea.esac.esa.int/tap-server/tap
gaia_ari   optical        None
sdss       optical        None

Resolve a target name

resolve_target_name turns a name into ICRS coordinates. Tools that take a position need this first.

async with client:
    m87 = payload(await client.call_tool("resolve_target_name", {"name": "M87"}))

m87
{'resolved': True,
 'name': 'M87',
 'ra': 187.70593077,
 'dec': 12.39112325,
 'frame': 'icrs',
 'unit': 'deg'}

What columns does the table have?

describe_table merges the archive’s live TAP_SCHEMA with the archive notes for that table: column list, curated notes, and the values some columns take.

async with client:
    obscore = payload(
        await client.call_tool("describe_table", {"archive": "alma", "table": "ivoa.obscore"})
    )

print(len(obscore["columns"]), "columns; notes from the archive file:")
for note in obscore["notes"]:
    print(" -", note)
print("\nvalue_enums:", list(obscore["value_enums"]))
73 columns; notes from the archive file:
 - member_ous_uid identifies a downloadable dataset (Member OUS). Rows are finer than that — one per spectral window per execution — so SELECT DISTINCT member_ous_uid is the way to count/collapse to datasets.
 - Two spatial columns: s_ra/s_dec is the pointing centre (a point); s_region is the WKT footprint of the observed field. Use INTERSECTS(CIRCLE(...), s_region) to catch mosaics and fields whose centre lies outside a small search radius.
 - band_list is a space-separated list of ALMA receiver bands present, e.g. '6' or '3 6 7'. Bands run 1, 3-10 (no band 2). Beware LIKE '%1%' — it also matches band 10; match an exact token (band_list = '6') or pad with delimiters.
 - calib_level: 2 = Member-OUS (per-execution) products, 3 = Group-OUS (combined) products.
 - frequency is the tuned sky reference frequency (GHz); frequency_support holds the full per-spectral-window frequency ranges. em_min/em_max are the standard ObsCore wavelengths (m).
 - proposal_id (e.g. '2022.1.01515.S') encodes the observing Cycle in its 'YYYY.N' prefix; there is no numeric cycle column, so filter a Cycle with proposal_id LIKE '2022.1.%'. Mapping: Cy6='2018.1', Cy7='2019.1' (+ '2019.2' ACA supplemental call), Cy8='2021.1', Cy9='2022.1', Cy10='2023.1', Cy11='2024.1'. NOTE the gap: there is NO '2020.1' (Cycle 8 was delayed by the COVID shutdown), so never infer a Cycle from a linear year count.
 - antenna_arrays is a space-separated list of 'Jxxx:PAD' tokens (one per antenna), NOT an array-type label. Derive the ALMA array from the PAD prefixes: DA*/DV* = 12-m (main) array, CM* = 7-m ACA, PM* = Total Power. e.g. 12-m -> antenna_arrays LIKE '%DV%' OR LIKE '%DA%'; 7-m -> LIKE '%CM%'; TP -> LIKE '%PM%'. The 12-m/7-m/TP components of one program are separate rows, so a project can appear under several types.
 - s_resolution and spatial_resolution are the synthesized-beam angular resolution in ARCSEC (usually equal); for a '<1 arcsec' request use spatial_resolution < 1.0. spatial_scale_max is the largest recoverable angular scale (arcsec); velocity_resolution is in m/s.
 - science_keyword is a ';'-delimited list from ALMA's controlled keyword vocabulary (a row may carry several; a 'null' token appears for an unused slot), so match with LIKE, e.g. science_keyword LIKE '%Outflows%'. Two distinct outflow keywords exist: 'Outflows, jets and ionized winds' (protostellar/ISM) vs 'Outflows, jets, feedback' (galaxy-scale). scientific_category is the coarser, single-valued parent classification.
 - scientific_category (coarse single-valued classification) and dataproduct_type ('cube' or 'image') are controlled-vocabulary columns.
 - Bibliography is IN this table: publication_year (int), first_author, authors, pub_title, bib_reference. So 'recent papers that used ALMA data on X' is answerable here directly (filter science_keyword + ORDER BY publication_year DESC) with no separate publications service; rows with no linked paper carry NULL in these columns.

value_enums: ['scientific_category', 'dataproduct_type', 'data_rights', 'science_observation', 'qa2_passed']

Run a small query

run_adql_query in mode="sync" returns the rows inline when the result fits the inline caps (200 rows / 48 KiB by default). The envelope always carries a top-level truncated boolean.

ALMA_TAP = archives["archives"][[a["short_name"] for a in archives["archives"]].index("alma")][
    "tap_url"
]

adql = """
SELECT TOP 20 obs_id, target_name, s_ra, s_dec, band_list
FROM ivoa.obscore
WHERE target_name = 'M87'
"""

async with client:
    result = payload(
        await client.call_tool(
            "run_adql_query", {"endpoint": ALMA_TAP, "adql": adql, "mode": "sync"}
        )
    )

print("archive:", result["archive"])
print("row_count:", result["row_count"], "| truncated:", result["truncated"])
show_rows(result)
archive: alma
row_count: 20 | truncated: False
obs_id | target_name | s_ra | s_dec | band_list
uid://A001/X11a7/X3c.source.M87.spw.2 | M87 | 187.70593075000306 | 12.391123310006023 | 6
uid://A001/X11a7/X3c.source.M87.spw.10 | M87 | 187.70593075000306 | 12.391123310006023 | 6
uid://A001/X11a7/X3c.source.M87.spw.6 | M87 | 187.70593075000306 | 12.391123310006023 | 6
uid://A001/X11a7/X3c.source.M87.spw.14 | M87 | 187.70593075000306 | 12.391123310006023 | 6
uid://A002/Xbee37d/Xb.source.M87.spw.18 | M87 | 187.70593074996975 | 12.391123310005284 | 6
... 20 rows in total

What else is in the envelope?

Two fields support result handling on the client side:

  • query_fingerprint is a stable hash of the query identity (tool + endpoint

    • ADQL).

  • save_recipe is a code snippet that writes the rows to manna_cache/ and appends a catalog row, so a client can avoid re-running the same query. The server computes it and forgets it; nothing is stored server-side.

load_recipe.code re-runs the query with pyvo and, in the same snippet, saves it — the save lines are fused in on purpose so a client cannot fetch without caching.

print("query_fingerprint:", result["query_fingerprint"])
print("save_recipe.path:", result["save_recipe"]["path"])
print()
print(result["load_recipe"]["code"])
query_fingerprint: 285421e244dc
save_recipe.path: manna_cache/285421e244dc.csv

import pyvo
table = pyvo.dal.TAPService('https://almascience.nrao.edu/tap').run_sync("\nSELECT TOP 20 obs_id, target_name, s_ra, s_dec, band_list\nFROM ivoa.obscore\nWHERE target_name = 'M87'\n").to_table()
df = table.to_pandas()
import csv, os
from datetime import datetime, timezone
os.makedirs('manna_cache', exist_ok=True)
df.to_csv('manna_cache/285421e244dc.csv', index=False)
_new = not os.path.exists('manna_cache/catalog.csv')
with open('manna_cache/catalog.csv', 'a', newline='') as _f:
    _w = csv.writer(_f, quoting=csv.QUOTE_ALL)
    if _new: _w.writerow(['fingerprint', 'tool', 'endpoint', 'archive', 'query', 'target', 'n_rows', 'truncated', 'maxrec', 'csv_path', 'saved_at'])
    _w.writerow(['285421e244dc', 'tap', 'https://almascience.nrao.edu/tap', 'alma', "\nSELECT TOP 20 obs_id, target_name, s_ra, s_dec, band_list\nFROM ivoa.obscore\nWHERE target_name = 'M87'\n", '', len(df), False, 10000, 'manna_cache/285421e244dc.csv', datetime.now(timezone.utc).isoformat()])

Next: 2. Large results shows what happens when a result does not fit inline.