4. Archive notes and errors

Archives differ in ways the IVOA standards do not capture. MANNA carries those differences as archive notes and delivers them two ways:

  • Up-front notes ride in the run_adql_query tool description, so a client sees them before it writes a query.

  • Error hints ride on a failed query’s error envelope, only when the failure matches a known pattern. (At the time of writing only the paused NRAO archive carries error hints, so this notebook shows the up-front channel and a plain error envelope.)

This notebook previews a table, makes a classic mistake at Astro Data Lab, reads the error envelope, finds the up-front note that already said how to avoid it, and runs the corrected query. It ends with what truncated=true looks like on a cone search.

Requirements: pip install manna-mcp and network access.

import logging

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`

# wrap_tool_errors (src/manna/errors.py) logs every tool error at warning
# level; quiet it so the error cell below shows only the typed payload.
logging.getLogger("manna.tools").setLevel(logging.ERROR)


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


DATALAB_TAP = "https://datalab.noirlab.edu/tap"

Preview a table

preview_table is a small workflow over describe_table plus a sample read: columns, curated notes, the values enumerated columns take, and a few real rows.

async with client:
    preview = payload(
        await client.call_tool(
            "preview_table", {"table": "nsc_dr2.object", "archive": "datalab", "sample_rows": 3}
        )
    )

print("known to the archive notes:", preview["known"], "| sample:", preview["sample_status"])
for note in preview["notes"]:
    print(" -", note)
print(len(preview["columns"]), "columns; first sample row:")
row = preview["sample_rows"][0] if preview["sample_rows"] else {}
first_eight = dict(list(row.items())[:8])
print(first_eight)
if len(row) > len(first_eight):
    print(f"...and {len(row) - len(first_eight)} more columns")
known to the archive notes: True | sample: ok
 - For a cone, the simplest reliable filter is q3c_radial_query(ra, dec, <ra0>, <dec0>, <radius_deg>) = 't' (the table is Q3C-clustered on ra/dec). ADQL CONTAINS/POINT do NOT work here — see the datalab usage_notes.
 - Pre-computed index columns exist for coarse bucketing: htm9 (~10 arcmin), ring256 (~14 arcmin), nest4096 (~52 arcsec). Usable in bounding-box / equality predicates.
 - ~99 columns wide. Always project an explicit column list; SELECT * (or an SCS cone) returns the whole row.
99 columns; first sample row:
{'imag': 99.98999786376953, 'ra': 324.4056125480407, 'dec': 57.2020002255535, 'glon': 98.96396179957577, 'glat': 3.6442779088941273, 'elon': 5.846377460870681, 'elat': 63.719028738545006, 'raerr': 0.08526600152254105}
...and 91 more columns

The classic mistake

Astro Data Lab’s TAP service does not translate ADQL geometry. A textbook cone search with CONTAINS(POINT(...), CIRCLE(...)) fails with a PostgreSQL error that never mentions the fix.

wrong = """
SELECT TOP 5 ra, dec, gmag
FROM nsc_dr2.object
WHERE CONTAINS(POINT('ICRS', ra, dec), CIRCLE('ICRS', 187.706, 12.391, 0.01)) = 1
"""

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

err
{'error_class': 'tap_query_error',
 'message': 'PSQLException: ERROR: function point(unknown, double precision, double precision) does not exist\n  Hint: No function matches the given name and argument types. You might need to add explicit type casts.\n  Position: 57',
 'retry_strategy': 'fix_and_retry',
 'request_id': None}

Every error envelope has the same shape. error_class is the field a client branches on; retry_strategy says whether retrying as-is makes sense. Here it is fix_and_retry: the query is wrong, not the archive.

print("error_class:   ", err["error_class"])
print("retry_strategy:", err["retry_strategy"])
print("message:       ", err["message"].splitlines()[0])
error_class:    tap_query_error
retry_strategy: fix_and_retry
message:        PSQLException: ERROR: function point(unknown, double precision, double precision) does not exist

The note that was there all along

The fix was already in the tool description, in the block of up-front notes the server injects for the active archives. An LLM client reads this every turn; here we pull it out of list_tools by hand.

async with client:
    tools = {t.name: t for t in await client.list_tools()}

lines = tools["run_adql_query"].description.splitlines()
hit = next(i for i, line in enumerate(lines) if "q3c_radial_query" in line)
print("\n".join(lines[max(0, hit - 3) : hit + 2]))
On error, returns a Tool Execution Error payload with `error_class`, `message`, `retry_strategy`, and (when available) `hint`. The presence of `error_class` is the discriminator the LLM should branch on — do NOT rely on a separate `isError` field.

Archive quirks that give wrong results or unactionable errors — apply BEFORE querying:
- NOIRLab Astro Data Lab (datalab.noirlab.edu): ADQL geometry (CONTAINS/CIRCLE/POINT) is NOT translated and errors. For a cone use q3c_radial_query(ra, dec, <ra0>, <dec0>, <radius_deg>) = 't'; a ra/dec BETWEEN box also works but is a box, not a circle.
- ALMA Science Archive (almascience.nrao.edu): rows are per spectral-window, so COUNT(*) over-counts observations — count with COUNT(DISTINCT member_ous_uid).

The corrected query

right = """
SELECT TOP 5 ra, dec, gmag
FROM nsc_dr2.object
WHERE q3c_radial_query(ra, dec, 187.706, 12.391, 0.01) = 't'
"""

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

print("row_count:", ok["row_count"], "| truncated:", ok["truncated"])
for row in ok["rows"]:
    print(row)
row_count: 5 | truncated: False
[187.69908916463416, 12.385164487845838, 99.98999786376953]
[187.69940033166472, 12.384056412997252, 99.98999786376953]
[187.70059535382427, 12.383574207385431, 99.98999786376953]
[187.70190418411642, 12.38457785916341, 99.98999786376953]
[187.69790314958155, 12.387814519298455, 16.483739852905273]

What truncation looks like

Cone and image searches have no async job to promote to, so an oversize result is cut at the inline cap and flagged: truncated is always a top-level boolean, never silently true. The right response is to narrow the search.

async with client:
    wide = payload(
        await client.call_tool(
            "search_catalog_by_position",
            {
                "endpoint": "https://gaia.ari.uni-heidelberg.de/cone/gaiadr2?",
                "ra": 187.706,
                "dec": 12.391,
                "radius_deg": 0.2,
            },
        )
    )

print("row_count:", wide["row_count"])
print("truncated:", wide["truncated"], "| truncation_reason:", wide["truncation_reason"])
print("hint:", wide["hints"][0]["text"])
row_count: 51
truncated: True | truncation_reason: inline_cap_exceeded
hint: Showing 51 of 646 rows (inline cap). Narrow the search region or lower maxrec to see every row inline.

That is the whole surface: small results inline, large TAP results as a job you fetch yourself, discovery searches truncated and flagged, and every failure as a typed envelope. The Tool reference page lists every tool and its parameters.