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_fingerprintis a stable hash of the query identity (tool + endpointADQL).
save_recipeis a code snippet that writes the rows tomanna_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.