diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1fefd..f0f2601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.3.0 + +Three new tools for the Mikulski Archive for Space Telescopes (MAST), NASA's archive for TESS, Hubble, JWST, Kepler and other missions. Existing tools are unchanged. The new tools need no API key and return download links rather than files, so a client can find TESS light curves and other data but cannot read the FITS files through this server. + +### New tools + +- `nasa_mast_observations`: observations of a named object, a TIC ID or a sky position. For TESS (the default) it matches TESS Input Catalog stars and lists their light curves by sector: SPOC 2-minute and 20-second light curves, and high-level science product (HLSP) light curves made from full-frame images, such as QLP and TESS-SPOC. Multi-sector transit searches are shown as sector ranges and are not counted as observed sectors. Other collections (`HST`, `JWST`, `Kepler`, `all` and others) and data product types use MAST's position search, which can take a minute when MAST is busy. Its radius defaults to 60″ because recent observations of fast-moving nearby stars lie tens of arcseconds from their catalog position, and the summary counts all matches by type and instrument. +- `nasa_mast_products`: the files of up to 20 observations, with type, size and a download URL for each. It lists science files by default; `subgroups` and `minimum_recommended` narrow Hubble and JWST observations, which can list hundreds of files. Files still in their proprietary period are marked `EXCLUSIVE_ACCESS` and need a MAST token. +- `nasa_tess_ffi`: the TESS sectors, cameras and CCDs whose full-frame images cover a position, with sector dates and a TESSCut cutout URL (a ZIP of FITS target pixel files) for each sector. + ## 1.2.0 A migration release. APOD and DONKI moved to new upstream APIs (the old api.nasa.gov endpoints no longer return data), and nine tools were added. It is published as a minor version, but **`nasa_apod` and `nasa_donki` parameters and output changed**. Review the list below before upgrading, or pin `1.1.0`; note that APOD and DONKI no longer work in 1.1.0. diff --git a/README.md b/README.md index 051e61f..b420546 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Ask your MCP client things like: * "Which near-Earth asteroids need the least delta-v for a mission launched in 2030?" * "Which NASA technology projects have worked on solar sails?" * "Find NASA patents about solar panels that companies can license." +* "Which TESS sectors observed Pi Mensae, and where can I download its 2-minute light curves?" +* "Has JWST observed TRAPPIST-1, and with which instruments?" ## Disclaimer @@ -271,6 +273,7 @@ This MCP server integrates the following NASA APIs: - Mars, Moon and Vesta Trek (WMTS map layers and tiles) - NASA Image and Video Library - Exoplanet Archive + - MAST (Mikulski Archive for Space Telescopes): TESS, Hubble, JWST, Kepler/K2 and other observations, their data products, and TESS full-frame image cutouts (TESSCut). The tools return download links to the FITS files rather than the files themselves, so your client can find data but not analyze it. - Open Science Data Repository (OSDR) files - POWER (Prediction Of Worldwide Energy Resources) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 5b373d5..3618af8 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -327,6 +327,52 @@ Aliases: `nasa/trek_tile`, `nasa/trek-tile` | `latitude` | number (-90–90) | | | Equirectangular only: pick the tile containing this latitude (degrees). | | `longitude` | number (-180–360) | | | Equirectangular only: pick the tile containing this longitude (degrees east, -180 to 360). | +## `nasa_mast_observations` — MAST observations (TESS, Hubble, JWST, Kepler and more) + +Search the Mikulski Archive for Space Telescopes (MAST), NASA's archive for TESS, Hubble, JWST, Kepler/K2 and other missions, for observations of a named object, a TIC ID or a sky position. For TESS (the default) it lists light curves of the matching TIC stars with their sectors: SPOC 2-minute and 20-second light curves and high-level science product (HLSP) light curves such as QLP and TESS-SPOC, made from full-frame images. Other collections and data product types use MAST's position search, which can take a minute when MAST is busy. Returns obsid values for nasa_mast_products (files and download links). No API key needed. + +Aliases: `nasa/mast_observations`, `nasa/mast-observations` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `target` | string | | | Object name resolved by MAST (SIMBAD, NED or the TESS Input Catalog), e.g. "Pi Mensae", "TRAPPIST-1", "M31" or "TIC 261136679". | +| `tic_id` | integer (1–99999999999) | | | TESS Input Catalog (TIC) ID, e.g. 261136679. | +| `ra` | number (0–360) | | | Right ascension in decimal degrees (ICRS). Use with dec instead of target or tic_id. | +| `dec` | number (-90–90) | | | Declination in decimal degrees (ICRS). Use with ra. | +| `radius_arcsec` | number (>0–600) | | | Search radius in arcseconds (max 600). TESS light curve searches match TIC stars within it (default 10). Other searches match observations whose footprint overlaps the circle (default 60, because recent observations of fast-moving nearby stars such as TRAPPIST-1 lie tens of arcseconds from their catalog position). | +| `collection` | string | | `"TESS"` | MAST collection (mission), e.g. TESS, HST, JWST, Kepler, K2, GALEX, SWIFT, PS1 or HLSP (high-level science products). "all" searches every collection. TESS light curve searches also include TESS high-level science products such as QLP and TESS-SPOC. | +| `dataproduct_type` | `"image"` \\| `"spectrum"` \\| `"timeseries"` \\| `"cube"` \\| `"measurements"` | | | Only this data product type. For TESS, light curves (timeseries) are listed by default; image lists full-frame image observations, which nasa_tess_ffi covers faster. | +| `limit` | integer (1–200) | | `25` | How many observations to list, newest first (1-200). Counts in the summary cover all matches. | + +## `nasa_mast_products` — MAST data products and download links + +List the files of MAST observations (from nasa_mast_observations), such as TESS light curves and target pixel files or Hubble and JWST images and spectra, with type, size and a download URL for each. Files are not downloaded or embedded. No API key needed. + +Aliases: `nasa/mast_products`, `nasa/mast-products` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `obsids` | string[] | yes | | 1-20 numeric obsid values from nasa_mast_observations (not the obs_id text). A comma-separated string also works. | +| `product_types` | `"science"` \\| `"info"` \\| `"preview"` \\| `"auxiliary"`[] | | | Product types to list (default science): science, info (for example TESS data validation reports), preview and auxiliary. | +| `subgroups` | string[] | | | Only these product subgroups, e.g. ["LC"] for TESS light curves, ["TP"] for target pixel files, ["DRZ", "FLT"] for Hubble images or ["X1D"] for spectra. High-level science products (HLSP) have no subgroup, so this filter leaves them out. | +| `minimum_recommended` | boolean | | `false` | Only MAST's minimum recommended products (useful for Hubble and JWST, which list many intermediate files). | +| `limit` | integer (1–500) | | `50` | How many products to list (1-500). | + +## `nasa_tess_ffi` — TESS full-frame image sectors and cutouts (TESSCut) + +Which TESS sectors, cameras and CCDs observed a sky position in their full-frame images (FFIs), with sector dates and a TESSCut download URL for a cutout of each sector (a ZIP of FITS target pixel files made from SPOC FFIs). Works for any star, including those without 2-minute light curves. Accepts an object name, TIC ID or coordinates. Cutouts are not downloaded. No API key needed. + +Aliases: `nasa/tess_ffi`, `nasa/tess-ffi` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `target` | string | | | Object name resolved by MAST (SIMBAD, NED or the TESS Input Catalog), e.g. "Pi Mensae", "TRAPPIST-1", "M31" or "TIC 261136679". | +| `tic_id` | integer (1–99999999999) | | | TESS Input Catalog (TIC) ID, e.g. 261136679. | +| `ra` | number (0–360) | | | Right ascension in decimal degrees (ICRS). Use with dec instead of target or tic_id. | +| `dec` | number (-90–90) | | | Declination in decimal degrees (ICRS). Use with ra. | +| `cutout_size` | integer (1–100) | | `10` | Width and height of each cutout in TESS pixels (21″ each), 1-100. TESSCut limits a cutout to 10,000 pixels. | +| `sector` | integer (1–9999) | | | Only this sector. | + ## `jpl_sbdb` — JPL Small-Body Database lookup Orbital and physical data for one asteroid or comet from the JPL Small-Body Database (SBDB). diff --git a/docs/inspector-test-examples.md b/docs/inspector-test-examples.md index 97bb1b7..78ff724 100644 --- a/docs/inspector-test-examples.md +++ b/docs/inspector-test-examples.md @@ -189,6 +189,28 @@ Mars, Moon and Vesta Trek map layers and tiles: { "name": "nasa_osdr_files", "arguments": { "accession_number": "OSD-87" } } ``` +MAST (TESS, Hubble, JWST, Kepler and more): TESS light curves of a star, the files of one observation, and TESS full-frame image sectors with cutout URLs. Results list download links; files are not downloaded: + +```json +{ "name": "nasa_mast_observations", "arguments": { "target": "Pi Mensae", "limit": 10 } } +``` + +```json +{ "name": "nasa_mast_observations", "arguments": { "target": "TRAPPIST-1", "collection": "JWST", "dataproduct_type": "timeseries", "limit": 5 } } +``` + +```json +{ "name": "nasa_mast_products", "arguments": { "obsids": ["176755222"], "subgroups": ["LC"] } } +``` + +```json +{ "name": "nasa_mast_products", "arguments": { "obsids": "120865840", "minimum_recommended": true } } +``` + +```json +{ "name": "nasa_tess_ffi", "arguments": { "tic_id": 261136679, "cutout_size": 20 } } +``` + ## JPL Solar System Dynamics ```json diff --git a/package-lock.json b/package-lock.json index 38fb5ed..86b9c8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@programcomputer/nasa-mcp-server", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@programcomputer/nasa-mcp-server", - "version": "1.2.0", + "version": "1.3.0", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.31.0", diff --git a/package.json b/package.json index cdec963..bed55b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@programcomputer/nasa-mcp-server", - "version": "1.2.0", + "version": "1.3.0", "description": "Model Context Protocol (MCP) server for NASA and JPL public APIs", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -34,6 +34,8 @@ "jpl", "earthdata", "cmr", + "mast", + "tess", "mcp", "model-context-protocol" ], diff --git a/src/handlers/nasa/mast.ts b/src/handlers/nasa/mast.ts new file mode 100644 index 0000000..e2d18d0 --- /dev/null +++ b/src/handlers/nasa/mast.ts @@ -0,0 +1,588 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE, type SourceInfo, type TextContent, type ToolContext, type ToolOutput } from '../../tools/types'; +import { ToolInputError, UpstreamError } from '../../util/errors'; +import { httpRequest } from '../../util/http'; +import { formatNumber, listInput } from '../../util/validation'; +import { boundedText, json, sourceInfo, text } from '../common'; + +const SERVICE = 'MAST'; +const MAST_INVOKE_URL = 'https://mast.stsci.edu/api/v0/invoke'; +const MAST_DOWNLOAD_URL = 'https://mast.stsci.edu/api/v0.1/Download/file'; +/** Uncached position searches have taken over a minute while MAST was busy. */ +const MAST_TIMEOUT_MS = 90_000; +const NAME_LOOKUP_TIMEOUT_MS = 40_000; +/** + * MAST latency is heavy-tailed: a request that usually takes half a second + * sometimes stalls for a minute while an identical one sent next answers at + * once. Stalled requests are therefore abandoned and re-sent (they are all + * read-only), a bounded number of times. + */ +const ATTEMPT_TIMEOUT_MS = 30_000; +const MAX_ATTEMPTS = 3; +const EXECUTING_POLL_MS = 1_000; +const RETRY_HINT = 'MAST keeps running slow searches and caches the result, so the same call often succeeds a minute later.'; +/** MAST's row order changes between identical queries, so results are fetched in one page and sorted locally. */ +const MAX_OBSERVATION_ROWS = 2000; +const MAX_PRODUCT_ROWS = 5000; +const MJD_UNIX_EPOCH = 40_587; + +async function mastPost(ctx: ToolContext, request: Record, timeoutMs: number): Promise<{ data: T; source: SourceInfo }> { + const deadline = Date.now() + timeoutMs; + for (let attempt = 1; ; attempt++) { + try { + const response = await httpRequest(ctx.fetch, { + service: SERVICE, + url: MAST_INVOKE_URL, + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: new URLSearchParams({ request: JSON.stringify(request) }).toString(), + timeoutMs: Math.max(1, Math.min(ATTEMPT_TIMEOUT_MS, deadline - Date.now())) + }); + return { data: response.json(), source: sourceInfo(ctx, SERVICE, response.url) }; + } catch (error) { + if (!(error instanceof UpstreamError && error.kind === 'timeout')) throw error; + if (attempt >= MAX_ATTEMPTS || deadline - Date.now() < 1_000) { + throw new UpstreamError(SERVICE, 'timeout', `MAST did not answer within ${Math.round(timeoutMs / 1000)} seconds (${plural(attempt, 'attempt')}). ${RETRY_HINT}`); + } + } + } +} + +interface MastTable { + status?: string; + msg?: string; + data?: T[]; + paging?: { rowsFiltered?: number }; +} + +/** + * Runs one MAST API service and returns its first page. A query MAST is + * still working on comes back as EXECUTING and is re-requested. + */ +export async function mastQuery( + ctx: ToolContext, + service: string, + params: Record, + pagesize: number, + timeoutMs = MAST_TIMEOUT_MS +): Promise<{ rows: T[]; total: number; source: SourceInfo }> { + const request = { service, params, format: 'json', pagesize, page: 1 }; + const deadline = Date.now() + timeoutMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new UpstreamError(SERVICE, 'timeout', `MAST was still running ${service} after ${timeoutMs / 1000} seconds. ${RETRY_HINT}`); + } + const { data, source } = await mastPost>(ctx, request, remaining); + if (data.status === 'EXECUTING') { + await new Promise((resolve) => setTimeout(resolve, EXECUTING_POLL_MS)); + continue; + } + if (data.status !== 'COMPLETE') { + throw new UpstreamError(SERVICE, 'invalid_response', `MAST ${service} failed: ${data.msg?.trim() || `status ${data.status ?? 'missing'}`}`); + } + const rows = Array.isArray(data.data) ? data.data : []; + return { rows, total: data.paging?.rowsFiltered ?? rows.length, source }; + } +} + +/** Target parameters shared by the MAST and TESSCut tools: a name, a TIC ID, or coordinates. */ +export const targetShape = { + target: z + .string() + .trim() + .min(1) + .max(100) + .describe('Object name resolved by MAST (SIMBAD, NED or the TESS Input Catalog), e.g. "Pi Mensae", "TRAPPIST-1", "M31" or "TIC 261136679".') + .optional(), + tic_id: z.int().min(1).max(99_999_999_999).describe('TESS Input Catalog (TIC) ID, e.g. 261136679.').optional(), + ra: z.number().min(0).max(360).describe('Right ascension in decimal degrees (ICRS). Use with dec instead of target or tic_id.').optional(), + dec: z.number().min(-90).max(90).describe('Declination in decimal degrees (ICRS). Use with ra.').optional() +}; + +interface TargetArgs { + target?: string; + tic_id?: number; + ra?: number; + dec?: number; +} + +export function checkTarget(args: TargetArgs, ctx: z.RefinementCtx): void { + const hasCoordinates = args.ra !== undefined || args.dec !== undefined; + const forms = [args.target !== undefined, args.tic_id !== undefined, hasCoordinates].filter(Boolean).length; + if (forms !== 1) { + ctx.addIssue({ code: 'custom', message: 'give exactly one of target, tic_id, or ra and dec' }); + } else if (hasCoordinates && (args.ra === undefined || args.dec === undefined)) { + ctx.addIssue({ code: 'custom', path: [args.ra === undefined ? 'ra' : 'dec'], message: 'ra and dec must be given together' }); + } +} + +export interface ResolvedTarget { + ra: number; + dec: number; + /** Human-readable target and position for summaries. */ + label: string; +} + +interface NameLookup { + resolvedCoordinate?: Array<{ canonicalName?: string; ra?: number; decl?: number; resolver?: string }>; +} + +const degrees = (value: number) => `${value.toFixed(5)}°`; + +/** Turns a name or TIC ID into coordinates with MAST's name resolver; coordinates pass through. */ +export async function resolveTarget(ctx: ToolContext, args: TargetArgs): Promise { + if (args.ra !== undefined && args.dec !== undefined) { + return { ra: args.ra, dec: args.dec, label: `RA ${degrees(args.ra)}, Dec ${degrees(args.dec)}` }; + } + const input = args.tic_id !== undefined ? `TIC ${args.tic_id}` : (args.target ?? ''); + const { data } = await mastPost(ctx, { service: 'Mast.Name.Lookup', params: { input, format: 'json' }, format: 'json' }, NAME_LOOKUP_TIMEOUT_MS); + const match = data.resolvedCoordinate?.find((c) => Number.isFinite(c.ra) && Number.isFinite(c.decl)); + if (!match) { + throw new ToolInputError(`MAST could not resolve "${input}" to a sky position. Check the name, or pass ra and dec in degrees.`); + } + const ra = match.ra!; + const dec = match.decl!; + const details = [ + match.canonicalName && match.canonicalName.toLowerCase() !== input.toLowerCase() ? match.canonicalName : undefined, + `RA ${degrees(ra)}, Dec ${degrees(dec)}`, + match.resolver ? `resolved by ${match.resolver}` : undefined + ].filter(Boolean); + return { ra, dec, label: `${input} (${details.join('; ')})` }; +} + +export function mjdToDate(mjd: unknown): string | undefined { + if (typeof mjd !== 'number' || !Number.isFinite(mjd)) return undefined; + return new Date((mjd - MJD_UNIX_EPOCH) * 86_400_000).toISOString().slice(0, 10); +} + +export function dateRange(start: unknown, end: unknown): string | undefined { + const from = mjdToDate(start); + const to = mjdToDate(end); + if (!from) return undefined; + return to && to !== from ? `${from} to ${to}` : from; +} + +const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`; + +function countBy(items: T[], key: (item: T) => string | undefined): string { + const counts = new Map(); + for (const item of items) { + const value = key(item); + if (value) counts.set(value, (counts.get(value) ?? 0) + 1); + } + return [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([value, count]) => `${value} ${count}`).join(', '); +} + +const OBSERVATION_COLUMNS = [ + 'obsid', 'obs_collection', 'provenance_name', 'instrument_name', 'filters', 'dataproduct_type', 'target_name', 'obs_id', + 't_min', 't_max', 't_exptime', 'sequence_number', 'proposal_id', 'proposal_pi', 'dataRights', 'calib_level' +].join(','); + +interface Observation { + obsid?: string | number; + obs_collection?: string | null; + provenance_name?: string | null; + instrument_name?: string | null; + filters?: string | null; + dataproduct_type?: string | null; + target_name?: string | null; + obs_id?: string | null; + t_min?: number | null; + t_max?: number | null; + t_exptime?: number | null; + sequence_number?: number | null; + proposal_id?: string | null; + proposal_pi?: string | null; + dataRights?: string | null; + calib_level?: number | null; +} + +/** TESS mission rows, and high-level light curves made from TESS data (filter "TESS"). */ +const isTess = (row: Observation) => row.obs_collection?.toUpperCase() === 'TESS' || row.filters?.toUpperCase() === 'TESS'; +/** MAST uses -999 for observations without a sequence (sector, campaign or quarter). */ +const sequenceOf = (row: Observation) => (typeof row.sequence_number === 'number' && row.sequence_number >= 0 ? row.sequence_number : undefined); +/** + * Multi-sector transit searches (obs_id like ...-s0001-s0039-...) carry only + * their last sector as sequence_number, so they must not count as observed sectors. + */ +const SECTOR_RANGE = /-s(\d{4})-s(\d{4})[-_]/; +const sectorRange = (row: Observation) => { + const match = isTess(row) ? SECTOR_RANGE.exec(row.obs_id ?? '') : null; + return match ? `${Number(match[1])}-${Number(match[2])}` : undefined; +}; + +function observationLine(row: Observation): string { + const kind = [row.obs_collection, row.instrument_name, row.dataproduct_type].filter(Boolean).join(' '); + const parts = [ + kind || 'unknown observation', + row.filters && !isTess(row) ? `filter ${row.filters}` : undefined, + sectorRange(row) + ? `sectors ${sectorRange(row)} (multi-sector search)` + : sequenceOf(row) !== undefined + ? `${isTess(row) ? 'sector' : 'sequence'} ${sequenceOf(row)}` + : undefined, + typeof row.t_exptime === 'number' ? `${Number(row.t_exptime.toFixed(1))} s exposure` : undefined, + dateRange(row.t_min, row.t_max), + row.provenance_name ? `pipeline ${row.provenance_name}` : undefined, + row.target_name ? `target ${row.target_name}` : undefined, + row.obs_id ? `obs_id ${row.obs_id}` : undefined, + row.proposal_id && row.proposal_id !== 'N/A' ? `proposal ${row.proposal_id}${row.proposal_pi ? ` (PI ${row.proposal_pi})` : ''}` : undefined, + row.dataRights && row.dataRights.toUpperCase() !== 'PUBLIC' ? row.dataRights.toUpperCase() : undefined + ]; + return `- obsid ${row.obsid}: ${parts.filter(Boolean).join('; ')}`; +} + +function sectorList(rows: Observation[]): string { + const sectors = new Set(rows.filter((row) => !sectorRange(row)).map(sequenceOf).filter((sector) => sector !== undefined)); + return [...sectors].sort((a, b) => a - b).join(', '); +} + +/** The newest `limit` observations as text, plus the line that introduces them. */ +function observationListing(rows: Observation[], limit: number, hint: string): { footer: string; listing: TextContent } { + const sorted = [...rows].sort((a, b) => (b.t_min ?? -Infinity) - (a.t_min ?? -Infinity) || String(a.obsid).localeCompare(String(b.obsid))); + const shown = sorted.slice(0, limit); + return { + footer: + `Showing the ${shown.length} most recent${shown.length < rows.length ? `; ${hint}` : ''}. ` + + 'Pass an obsid to nasa_mast_products to list its files and download links.', + listing: boundedText(shown.map(observationLine).join('\n'), SERVICE, 'Lower limit.') + }; +} + +const DATAPRODUCT_TYPES = ['image', 'spectrum', 'timeseries', 'cube', 'measurements'] as const; +const TIC_MATCH_RADIUS_ARCSEC = 10; +/** + * Recent observations of fast-moving nearby stars lie far from their catalog + * (J2000) position: TRAPPIST-1 is about 25″ away, outside a 10″ circle around it. + */ +const POSITION_RADIUS_ARCSEC = 60; + +export const mastObservationsInputSchema = z + .strictObject({ + ...targetShape, + radius_arcsec: z + .number() + .positive() + .max(600) + .describe( + `Search radius in arcseconds (max 600). TESS light curve searches match TIC stars within it (default ${TIC_MATCH_RADIUS_ARCSEC}). ` + + `Other searches match observations whose footprint overlaps the circle (default ${POSITION_RADIUS_ARCSEC}, because recent observations ` + + 'of fast-moving nearby stars such as TRAPPIST-1 lie tens of arcseconds from their catalog position).' + ) + .optional(), + collection: z + .string() + .trim() + .regex(/^[A-Za-z0-9_]+$/, 'must be a MAST collection name such as TESS, HST or JWST, or "all"') + .default('TESS') + .describe( + 'MAST collection (mission), e.g. TESS, HST, JWST, Kepler, K2, GALEX, SWIFT, PS1 or HLSP (high-level science products). "all" searches every collection. ' + + 'TESS light curve searches also include TESS high-level science products such as QLP and TESS-SPOC.' + ), + dataproduct_type: z + .enum(DATAPRODUCT_TYPES) + .describe('Only this data product type. For TESS, light curves (timeseries) are listed by default; image lists full-frame image observations, which nasa_tess_ffi covers faster.') + .optional(), + limit: z.int().min(1).max(200).default(25).describe('How many observations to list, newest first (1-200). Counts in the summary cover all matches.') + }) + .superRefine(checkTarget); + +type ObservationsArgs = z.output; + +const TIC_NAME = /^TIC\s*(\d{1,11})$/i; +const MAX_TIC_STARS = 50; + +interface TicStar { + ID?: number | string | null; + Tmag?: number | null; + dstArcSec?: number | null; +} + +function ticLabel(star: TicStar): string { + const details = [ + typeof star.Tmag === 'number' ? `Tmag ${star.Tmag.toFixed(2)}` : undefined, + typeof star.dstArcSec === 'number' ? `${star.dstArcSec.toFixed(1)}″ away` : undefined + ].filter(Boolean); + return `TIC ${star.ID}${details.length ? ` (${details.join(', ')})` : ''}`; +} + +/** + * TESS light curves (SPOC and high-level products) carry the TIC ID as their + * target name. Matching TIC stars first and filtering on that ID takes about a + * second, where MAST's footprint search has taken over a minute for TESS. + */ +async function tessLightCurves(ctx: ToolContext, args: ObservationsArgs): Promise { + const ticName = args.tic_id ?? (args.target ? TIC_NAME.exec(args.target)?.[1] : undefined); + const notes: string[] = []; + let ids: string[]; + let where: string; + let target: ResolvedTarget | undefined; + if (ticName !== undefined) { + ids = [String(Number(ticName))]; + where = `TIC ${ids[0]}`; + } else { + target = await resolveTarget(ctx, args); + const radiusArcsec = args.radius_arcsec ?? TIC_MATCH_RADIUS_ARCSEC; + const radius = `${formatNumber(radiusArcsec)}″`; + const cone = await mastQuery(ctx, 'Mast.Catalogs.Tic.Cone', { ra: target.ra, dec: target.dec, radius: radiusArcsec / 3600 }, MAX_TIC_STARS); + const stars = cone.rows.filter((s) => s.ID !== undefined && s.ID !== null).sort((a, b) => (a.dstArcSec ?? Infinity) - (b.dstArcSec ?? Infinity)); + if (stars.length === 0) { + return { + content: [text(`No TESS Input Catalog stars lie within ${radius} of ${target.label}. Raise radius_arcsec, or use nasa_tess_ffi for full-frame image coverage.`)] + }; + } + ids = stars.map((s) => String(s.ID)); + where = `TIC stars within ${radius} of ${target.label}`; + notes.push(`Matched ${stars.map(ticLabel).join('; ')}${cone.total > stars.length ? ` (the first ${stars.length} of ${cone.total}; lower radius_arcsec)` : ''}.`); + } + + const filters = [ + { paramName: 'target_name', values: ids }, + { paramName: 'obs_collection', values: ['TESS', 'HLSP'] }, + { paramName: 'filters', values: ['TESS'] }, + { paramName: 'dataproduct_type', values: ['timeseries'] } + ]; + const query = { columns: OBSERVATION_COLUMNS, filters }; + const { rows, total, source } = await mastQuery(ctx, 'Mast.Caom.Filtered', query, MAX_OBSERVATION_ROWS); + const resource = { + name: `TESS light curves for ${where}`, + mimeType: 'application/json', + text: json({ source, target, tic_ids: ids, query, total, observations: rows }), + source + }; + if (rows.length === 0) { + return { + content: [text([`No TESS light curves in MAST for ${where}.`, ...notes, 'Full-frame images may still cover it: try nasa_tess_ffi.'].join('\n'))], + resource + }; + } + + const lines = [`${plural(total, 'TESS light curve observation')} in MAST for ${where}.` + (rows.length < total ? ` Counts below cover the first ${rows.length}.` : ''), ...notes]; + const mission = rows.filter((r) => r.obs_collection?.toUpperCase() === 'TESS'); + if (mission.length) { + const cadences = [...new Set(mission.map((r) => r.t_exptime).filter((v): v is number => typeof v === 'number'))].sort((a, b) => b - a); + const cadenceText = cadences.length ? ` (cadence ${cadences.map((c) => `${Number(c.toFixed(1))} s`).join(', ')})` : ''; + lines.push(`TESS mission (SPOC) target light curves: sectors ${sectorList(mission)}${cadenceText}.`); + } + const pipelines = new Map(); + for (const row of rows) { + if (row.obs_collection?.toUpperCase() === 'TESS') continue; + const name = row.provenance_name ?? 'unnamed'; + pipelines.set(name, [...(pipelines.get(name) ?? []), row]); + } + if (pipelines.size) { + const groups = [...pipelines] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, group]) => (sectorList(group) ? `${name} sectors ${sectorList(group)}` : `${name} (no sector number)`)); + lines.push(`High-level science product (HLSP) light curves: ${groups.join('; ')}.`); + } + lines.push('For full-frame image coverage and cutouts, use nasa_tess_ffi.'); + const { footer, listing } = observationListing(rows, args.limit, 'raise limit to see others'); + lines.push(footer); + return { content: [text(lines.join('\n')), listing], resource }; +} + +async function positionSearch(ctx: ToolContext, args: ObservationsArgs, collection: string | undefined): Promise { + const target = await resolveTarget(ctx, args); + const filters = [ + ...(collection ? [{ paramName: 'obs_collection', values: [collection] }] : []), + ...(args.dataproduct_type ? [{ paramName: 'dataproduct_type', values: [args.dataproduct_type] }] : []) + ]; + const radiusArcsec = args.radius_arcsec ?? POSITION_RADIUS_ARCSEC; + const position = [target.ra, target.dec, radiusArcsec / 3600].map(formatNumber).join(', '); + const query = { columns: OBSERVATION_COLUMNS, filters, position }; + const { rows, total, source } = await mastQuery(ctx, 'Mast.Caom.Filtered.Position', query, MAX_OBSERVATION_ROWS); + + const scope = [collection ?? 'MAST', args.dataproduct_type].filter(Boolean).join(' '); + const where = `within ${formatNumber(radiusArcsec)}″ of ${target.label}`; + const resource = { + name: `MAST observations ${scope} ${where}`, + mimeType: 'application/json', + text: json({ source, target, query, total, observations: rows }), + source + }; + if (rows.length === 0) return { content: [text(`No ${scope} observations ${where}.`)], resource }; + + const lines = [ + `${plural(total, `${scope} observation`)} ${where}.` + (rows.length < total ? ` Counts below cover the first ${rows.length}; narrow the search to see all.` : ''), + ...(collection ? [] : [`By collection: ${countBy(rows, (r) => r.obs_collection ?? undefined)}.`]), + `By type: ${countBy(rows, (r) => r.dataproduct_type ?? undefined)}.`, + `By instrument: ${countBy(rows, (r) => (r.instrument_name ? (collection ? r.instrument_name : `${r.obs_collection} ${r.instrument_name}`) : undefined))}.` + ]; + const lightCurveSectors = sectorList(rows.filter((r) => isTess(r) && r.dataproduct_type === 'timeseries')); + const ffiSectors = sectorList(rows.filter((r) => isTess(r) && r.dataproduct_type === 'image')); + if (lightCurveSectors) lines.push(`TESS sectors with light curves: ${lightCurveSectors}.`); + if (ffiSectors) lines.push(`TESS sectors with full-frame images: ${ffiSectors} (use nasa_tess_ffi for cutouts).`); + const { footer, listing } = observationListing(rows, args.limit, 'raise limit or set dataproduct_type to see others'); + lines.push(footer); + return { content: [text(lines.join('\n')), listing], resource }; +} + +export const mastObservationsTool = defineTool({ + name: 'nasa_mast_observations', + title: 'MAST observations (TESS, Hubble, JWST, Kepler and more)', + description: + "Search the Mikulski Archive for Space Telescopes (MAST), NASA's archive for TESS, Hubble, JWST, Kepler/K2 and other missions, for observations " + + 'of a named object, a TIC ID or a sky position. For TESS (the default) it lists light curves of the matching TIC stars with their sectors: ' + + 'SPOC 2-minute and 20-second light curves and high-level science product (HLSP) light curves such as QLP and TESS-SPOC, made from full-frame images. ' + + "Other collections and data product types use MAST's position search, which can take a minute when MAST is busy. " + + 'Returns obsid values for nasa_mast_products (files and download links). No API key needed.', + inputSchema: mastObservationsInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const collection = args.collection.toLowerCase() === 'all' ? undefined : args.collection; + if (collection?.toUpperCase() === 'TESS' && (args.dataproduct_type ?? 'timeseries') === 'timeseries') return tessLightCurves(ctx, args); + return positionSearch(ctx, args, collection); + } +}); + +interface Product { + obsID?: string | number; + parent_obsid?: string | number; + obs_id?: string | null; + description?: string | null; + dataURI?: string | null; + productType?: string | null; + productGroupDescription?: string | null; + productSubGroupDescription?: string | null; + productFilename?: string | null; + size?: number | null; + dataRights?: string | null; + calib_level?: number | null; +} + +const PRODUCT_TYPES = ['science', 'info', 'preview', 'auxiliary'] as const; +const MINIMUM_RECOMMENDED = 'Minimum Recommended Products'; + +export function formatBytes(bytes: unknown): string { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return 'size unknown'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unit = 0; + while (value >= 1000 && unit < units.length - 1) { + value /= 1000; + unit += 1; + } + return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${units[unit]}`; +} + +/** MAST data URIs (mast:...) become anonymous download links; other https URIs are kept. */ +export function downloadUrl(dataUri: string | null | undefined): string | undefined { + if (!dataUri) return undefined; + if (dataUri.startsWith('mast:')) return `${MAST_DOWNLOAD_URL}?uri=${encodeURIComponent(dataUri)}`; + return /^https:\/\//i.test(dataUri) ? dataUri : undefined; +} + +function productLine(product: Product): string { + const details = [ + product.productSubGroupDescription, + product.productType, + formatBytes(product.size), + typeof product.calib_level === 'number' ? `level ${product.calib_level}` : undefined, + product.productGroupDescription === MINIMUM_RECOMMENDED ? 'minimum recommended' : undefined, + product.dataRights && product.dataRights.toUpperCase() !== 'PUBLIC' ? product.dataRights.toUpperCase() : undefined + ].filter(Boolean); + const description = product.description ? `: ${product.description}` : ''; + const url = downloadUrl(product.dataURI); + return `- ${product.productFilename ?? product.dataURI ?? 'unnamed'} (${details.join(', ')})${description}\n ${url ?? 'no download link'}`; +} + +/** obsids look numeric, so callers often send numbers; they are kept as text. */ +const obsidList = (value: unknown) => { + const asText = (item: unknown) => (typeof item === 'number' ? String(item) : item); + return listInput((item) => item)(Array.isArray(value) ? value.map(asText) : asText(value)); +}; + +export const mastProductsInputSchema = z.strictObject({ + obsids: z + .preprocess(obsidList, z.array(z.string().regex(/^\d{1,15}$/, 'must be a numeric MAST obsid such as 176755222')).min(1).max(20)) + .describe('1-20 numeric obsid values from nasa_mast_observations (not the obs_id text). A comma-separated string also works.'), + product_types: z + .preprocess(listInput(), z.array(z.enum(PRODUCT_TYPES)).min(1).max(PRODUCT_TYPES.length)) + .default(['science']) + .describe('Product types to list (default science): science, info (for example TESS data validation reports), preview and auxiliary.'), + subgroups: z + .preprocess(listInput((item) => item.toUpperCase()), z.array(z.string().regex(/^[A-Z0-9_]+$/, 'must be a product subgroup such as LC, TP, DRZ or X1D')).min(1).max(20)) + .describe( + 'Only these product subgroups, e.g. ["LC"] for TESS light curves, ["TP"] for target pixel files, ["DRZ", "FLT"] for Hubble images or ["X1D"] for spectra. ' + + 'High-level science products (HLSP) have no subgroup, so this filter leaves them out.' + ) + .optional(), + minimum_recommended: z.boolean().default(false).describe("Only MAST's minimum recommended products (useful for Hubble and JWST, which list many intermediate files)."), + limit: z.int().min(1).max(500).default(50).describe('How many products to list (1-500).') +}); + +export const mastProductsTool = defineTool({ + name: 'nasa_mast_products', + title: 'MAST data products and download links', + description: + 'List the files of MAST observations (from nasa_mast_observations), such as TESS light curves and target pixel files or Hubble and JWST images and spectra, ' + + 'with type, size and a download URL for each. Files are not downloaded or embedded. No API key needed.', + inputSchema: mastProductsInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const obsids = [...new Set(args.obsids)]; + const { rows, total, source } = await mastQuery(ctx, 'Mast.Caom.Products', { obsid: obsids.join(',') }, MAX_PRODUCT_ROWS); + const seen = new Set(); + const products = rows.filter((p) => { + const key = p.dataURI ?? `${p.obsID}/${p.productFilename}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + const types = new Set(args.product_types); + const subgroups = args.subgroups ? new Set(args.subgroups) : undefined; + const matches = products.filter( + (p) => + types.has((p.productType ?? '').toLowerCase()) && + (!subgroups || subgroups.has((p.productSubGroupDescription ?? '').toUpperCase())) && + (!args.minimum_recommended || p.productGroupDescription === MINIMUM_RECOMMENDED) + ); + const order = new Map(obsids.map((id, index) => [id, index])); + matches.sort( + (a, b) => + (order.get(String(a.parent_obsid)) ?? obsids.length) - (order.get(String(b.parent_obsid)) ?? obsids.length) || + (b.calib_level ?? 0) - (a.calib_level ?? 0) || + String(a.productFilename).localeCompare(String(b.productFilename)) + ); + const shown = matches.slice(0, args.limit); + + const filterLabel = [ + `types ${args.product_types.join('/')}`, + args.subgroups ? `subgroups ${args.subgroups.join('/')}` : undefined, + args.minimum_recommended ? 'minimum recommended only' : undefined + ].filter(Boolean).join(', '); + if (products.length === 0) { + return { + content: [text(`MAST lists no products for obsid ${obsids.join(', ')}. Check the obsid values from nasa_mast_observations.`)], + resource: { name: `MAST products for obsid ${obsids.join(',')}`, mimeType: 'application/json', text: json({ source, obsids, total, products: [] }), source } + }; + } + const lines = [ + `${products.length} products for obsid ${obsids.join(', ')}` + + (rows.length < total ? ` (the first ${rows.length} of ${total})` : '') + + `; ${matches.length} match ${filterLabel}.` + + (matches.length ? ` Matching files total ${formatBytes(matches.reduce((sum, p) => sum + (p.size ?? 0), 0))}.` : '') + ]; + const withProducts = new Set(products.map((p) => String(p.parent_obsid))); + const empty = obsids.filter((id) => !withProducts.has(id)); + if (empty.length) lines.push(`No products are listed for obsid ${empty.join(', ')}.`); + if (matches.length) lines.push(`By subgroup: ${countBy(matches, (p) => p.productSubGroupDescription ?? 'none')}.`); + const others = products.filter((p) => !types.has((p.productType ?? '').toLowerCase())); + if (others.length) lines.push(`Not listed because of product_types: ${countBy(others, (p) => p.productType?.toLowerCase())}.`); + if (shown.some((p) => p.dataRights && p.dataRights.toUpperCase() !== 'PUBLIC')) { + lines.push('Files marked EXCLUSIVE_ACCESS are still proprietary and need a MAST token to download.'); + } + if (shown.length < matches.length) lines.push(`Showing ${shown.length}; raise limit or add subgroups to see others.`); + return { + content: [text(lines.join('\n')), ...(shown.length ? [boundedText(shown.map(productLine).join('\n'), SERVICE, 'Lower limit or add subgroups.')] : [])], + resource: { + name: `MAST products for obsid ${obsids.join(',')}`, + mimeType: 'application/json', + text: json({ source, obsids, total, products: matches.map((p) => ({ ...p, download_url: downloadUrl(p.dataURI) })) }), + source + } + }; + } +}); diff --git a/src/handlers/nasa/ssc.ts b/src/handlers/nasa/ssc.ts index 5e3c5d0..86e5aab 100644 --- a/src/handlers/nasa/ssc.ts +++ b/src/handlers/nasa/ssc.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { defineTool, READ_ONLY_REMOTE, type ToolContext } from '../../tools/types'; import { ToolInputError, UpstreamError } from '../../util/errors'; import { httpRequest } from '../../util/http'; -import { isoDate } from '../../util/validation'; +import { isoDate, listInput } from '../../util/validation'; import { boundedText, buildUrl, json, sourceInfo, text, type QueryValue } from '../common'; const SERVICE = 'NASA Satellite Situation Center'; @@ -103,22 +103,15 @@ export const sscObservatoriesTool = defineTool({ } }); -/** List parameters also accept "a,b" text; items are trimmed and lower-cased. */ -function listInput(value: unknown): unknown { - const items = typeof value === 'string' ? value.split(',') : value; - if (!Array.isArray(items)) return items; - return items.map((item) => (typeof item === 'string' ? item.trim().toLowerCase() : item)).filter((item) => item !== ''); -} - export const sscLocationsInputSchema = z .strictObject({ observatories: z - .preprocess(listInput, z.array(z.string().regex(/^[a-z0-9]+$/, 'must be an SSC observatory ID such as iss or mms1')).min(1).max(5)) + .preprocess(listInput(), z.array(z.string().regex(/^[a-z0-9]+$/, 'must be an SSC observatory ID such as iss or mms1')).min(1).max(5)) .describe('1-5 SSC observatory IDs, e.g. ["iss"] or ["mms1", "moon"] (see nasa_ssc_observatories). A comma-separated string also works.'), start_time: utcTime('Start time (UTC), e.g. 2026-09-29T06:00:00Z; a bare date such as 2026-09-29 means 00:00 UTC.'), end_time: utcTime(`End time (UTC); at most ${MAX_SPAN_DAYS} days after start_time.`), coordinate_systems: z - .preprocess(listInput, z.array(z.enum(COORDINATE_SYSTEMS)).min(1).max(COORDINATE_SYSTEMS.length)) + .preprocess(listInput(), z.array(z.enum(COORDINATE_SYSTEMS)).min(1).max(COORDINATE_SYSTEMS.length)) .default(['geo']) .describe('Coordinate systems (default geo): geo, gm, gse, gsm, sm, geitod, geij2000. A comma-separated string also works.'), resolution_factor: z diff --git a/src/handlers/nasa/tesscut.ts b/src/handlers/nasa/tesscut.ts new file mode 100644 index 0000000..8cd1d01 --- /dev/null +++ b/src/handlers/nasa/tesscut.ts @@ -0,0 +1,125 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { UpstreamError } from '../../util/errors'; +import { httpRequest } from '../../util/http'; +import { formatNumber } from '../../util/validation'; +import { boundedText, buildUrl, json, sourceInfo, text } from '../common'; +import { checkTarget, dateRange, mastQuery, resolveTarget, targetShape } from './mast'; + +const SERVICE = 'MAST TESSCut'; +const TESSCUT_BASE_URL = 'https://mast.stsci.edu/tesscut/api/v0.1'; +const TESS_PIXEL_ARCSEC = 21; +/** Sector dates are optional, so a slow MAST should not hold up the sector list. */ +const SECTOR_DATES_TIMEOUT_MS = 15_000; + +interface SectorResult { + sectorName?: string; + sector?: string; + camera?: string; + ccd?: string; +} + +interface Sector { + name: string; + sector: number; + camera: number; + ccd: number; + dates?: string; + cutout_url: string; +} + +interface FfiObservation { + obs_id?: string; + t_min?: number; + t_max?: number; +} + +export const tessFfiInputSchema = z + .strictObject({ + ...targetShape, + cutout_size: z + .int() + .min(1) + .max(100) + .default(10) + .describe('Width and height of each cutout in TESS pixels (21″ each), 1-100. TESSCut limits a cutout to 10,000 pixels.'), + sector: z.int().min(1).max(9999).describe('Only this sector.').optional() + }) + .superRefine(checkTarget); + +export const tessFfiTool = defineTool({ + name: 'nasa_tess_ffi', + title: 'TESS full-frame image sectors and cutouts (TESSCut)', + description: + 'Which TESS sectors, cameras and CCDs observed a sky position in their full-frame images (FFIs), with sector dates and a TESSCut download URL ' + + 'for a cutout of each sector (a ZIP of FITS target pixel files made from SPOC FFIs). Works for any star, including those without 2-minute light curves. ' + + 'Accepts an object name, TIC ID or coordinates. Cutouts are not downloaded. No API key needed.', + inputSchema: tessFfiInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const target = await resolveTarget(ctx, args); + const position = { ra: formatNumber(target.ra), dec: formatNumber(target.dec) }; + const response = await httpRequest(ctx.fetch, { service: SERVICE, url: buildUrl(`${TESSCUT_BASE_URL}/sector`, position) }); + const source = sourceInfo(ctx, SERVICE, response.url); + const results = response.json<{ results?: SectorResult[] }>().results; + if (!Array.isArray(results)) throw new UpstreamError(SERVICE, 'invalid_response', `${SERVICE} returned no sector list.`); + + const cutoutUrl = (sector?: number) => + buildUrl(`${TESSCUT_BASE_URL}/astrocut`, { ...position, y: args.cutout_size, x: args.cutout_size, units: 'px', sector }).toString(); + const all: Sector[] = results + .map((r) => ({ name: r.sectorName ?? '', sector: Number(r.sector), camera: Number(r.camera), ccd: Number(r.ccd) })) + .filter((s) => s.name && Number.isInteger(s.sector)) + .sort((a, b) => a.sector - b.sector || a.camera - b.camera || a.ccd - b.ccd) + .map((s) => ({ ...s, cutout_url: cutoutUrl(s.sector) })); + const sectorNumbers = (sectors: Sector[]) => [...new Set(sectors.map((s) => s.sector))].join(', '); + const resource = (sectors: Sector[]) => ({ + name: `TESS FFI sectors for ${target.label}`, + mimeType: 'application/json', + text: json({ source, target, cutout_size_px: args.cutout_size, sectors, all_sectors_cutout_url: args.sector ? undefined : cutoutUrl() }), + source + }); + + if (all.length === 0) { + return { content: [text(`No TESS full-frame images cover ${target.label} (TESSCut lists no sectors for this position).`)], resource: resource(all) }; + } + const sectors = args.sector === undefined ? all : all.filter((s) => s.sector === args.sector); + if (sectors.length === 0) { + return { + content: [text(`Sector ${args.sector} did not cover ${target.label}. TESS full-frame images cover it in sectors ${sectorNumbers(all)}.`)], + resource: resource(all) + }; + } + + const notes: string[] = []; + try { + const names = sectors.map((s) => s.name); + const filters = [ + { paramName: 'obs_collection', values: ['TESS'] }, + { paramName: 'obs_id', values: names } + ]; + const { rows } = await mastQuery(ctx, 'Mast.Caom.Filtered', { columns: 'obs_id,t_min,t_max', filters }, names.length, SECTOR_DATES_TIMEOUT_MS); + const dates = new Map(rows.map((row) => [row.obs_id, dateRange(row.t_min, row.t_max)])); + for (const sector of sectors) sector.dates = dates.get(sector.name); + } catch (error) { + notes.push(`Sector dates are unavailable: ${error instanceof Error ? error.message : String(error)}`); + } + + const size = args.cutout_size; + const arcmin = Number(((size * TESS_PIXEL_ARCSEC) / 60).toFixed(1)); + const count = new Set(sectors.map((s) => s.sector)).size; + const lines = [ + `TESS full-frame images cover ${target.label} in ${count} sector${count === 1 ? '' : 's'}: ${sectorNumbers(sectors)}.`, + `Each URL downloads a ZIP of FITS target pixel files cut from SPOC full-frame images by TESSCut: ${size} × ${size} pixels (${arcmin}′ × ${arcmin}′). ` + + 'This tool does not download them.', + ...notes + ]; + const sectorLines = sectors.map( + (s) => `- Sector ${s.sector} (camera ${s.camera}, CCD ${s.ccd})${s.dates ? `, ${s.dates}` : ''}: ${s.cutout_url}` + ); + if (args.sector === undefined) sectorLines.push(`All sectors in one ZIP: ${cutoutUrl()}`); + return { + content: [text(lines.join('\n')), boundedText(sectorLines.join('\n'), SERVICE, 'Pass sector to list one sector.')], + resource: resource(sectors) + }; + } +}); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index bc44715..1586c3e 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -10,12 +10,14 @@ import { gibsTool } from '../handlers/nasa/gibs'; import { imagesTool } from '../handlers/nasa/images'; import { insightTool } from '../handlers/nasa/insight'; import { marsRoverTool } from '../handlers/nasa/mars_rover'; +import { mastObservationsTool, mastProductsTool } from '../handlers/nasa/mast'; import { neoTool } from '../handlers/nasa/neo'; import { osdrFilesTool } from '../handlers/nasa/osdr_files'; import { powerTool } from '../handlers/nasa/power'; import { sscLocationsTool, sscObservatoriesTool } from '../handlers/nasa/ssc'; import { techportTool } from '../handlers/nasa/techport'; import { techTransferTool } from '../handlers/nasa/techtransfer'; +import { tessFfiTool } from '../handlers/nasa/tesscut'; import { tleTool } from '../handlers/nasa/tle'; import { trekLayersTool, trekTileTool } from '../handlers/nasa/trek'; import { cadTool } from '../handlers/jpl/cad'; @@ -56,6 +58,9 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [ techTransferTool, trekLayersTool, trekTileTool, + mastObservationsTool, + mastProductsTool, + tessFfiTool, sbdbTool, fireballTool, jdCalTool, diff --git a/src/util/validation.ts b/src/util/validation.ts index 219e40f..38f7615 100644 --- a/src/util/validation.ts +++ b/src/util/validation.ts @@ -14,6 +14,18 @@ export function isIsoDate(value: string): boolean { export const isoDate = (description: string) => z.string().refine(isIsoDate, { message: 'must be a valid calendar date in YYYY-MM-DD format' }).describe(description); +/** + * Preprocessor for list parameters that also accept "a,b" text: items are + * trimmed and normalized (lower-cased by default), and empty items dropped. + */ +export function listInput(normalize: (item: string) => string = (item) => item.toLowerCase()) { + return (value: unknown): unknown => { + const items = typeof value === 'string' ? value.split(',') : value; + if (!Array.isArray(items)) return items; + return items.map((item) => (typeof item === 'string' ? normalize(item.trim()) : item)).filter((item) => item !== ''); + }; +} + /** Whole days between two YYYY-MM-DD dates (end - start). */ export function daysBetween(start: string, end: string): number { return Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86_400_000); diff --git a/test/integration/transports.test.ts b/test/integration/transports.test.ts index 36962d8..d21852b 100644 --- a/test/integration/transports.test.ts +++ b/test/integration/transports.test.ts @@ -9,7 +9,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { startMockCmr, type MockCmr } from '../helpers/mock-cmr-server'; import { serverCommand, serverEnv, spawnServer, stopServer, waitForExit } from '../helpers/server-process'; -const EXPECTED_TOOL_COUNT = 32; +const EXPECTED_TOOL_COUNT = 35; let cmr: MockCmr; // Run from an empty directory so a developer's .env (and the checkout) is never picked up. const isolatedCwd = mkdtempSync(join(tmpdir(), 'nasa-mcp-cwd-')); diff --git a/test/live/upstream.live.ts b/test/live/upstream.live.ts index c69e6a2..1111627 100644 --- a/test/live/upstream.live.ts +++ b/test/live/upstream.live.ts @@ -65,6 +65,44 @@ describe('live APOD', { skip: skipLive }, () => { }); }); +describe('live MAST', { skip: skipLive }, () => { + const textOf = (result: { content: Array<{ type: string; text?: string }> }) => result.content.map((c) => c.text ?? '').join('\n'); + + it('finds Pi Mensae light curves by TIC star, with SPOC and HLSP sectors', async () => { + const result = await server().callTool('nasa_mast_observations', { target: 'Pi Mensae', limit: 5 }); + const text = textOf(result); + assert.equal(result.isError, undefined, text); + assert.match(text, /Matched TIC 261136679 /); + assert.match(text, /TESS mission \(SPOC\) target light curves: sectors 1, 4, /); + assert.match(text, /HLSP\) light curves: .*QLP sectors 1, /); + assert.match(text, /- obsid \d+: /); + }); + + it('lists the light curve file of a SPOC observation with a working download link', async () => { + const result = await server().callTool('nasa_mast_products', { obsids: '176755222', subgroups: 'LC' }); + const text = textOf(result); + assert.equal(result.isError, undefined, text); + const url = /https:\/\/mast\.stsci\.edu\/api\/v0\.1\/Download\/file\?uri=\S+_lc\.fits/.exec(text)?.[0]; + assert.ok(url, text); + const head = await fetch(url, { method: 'HEAD', redirect: 'manual' }); + assert.ok([200, 302, 307].includes(head.status), `download link answered HTTP ${head.status}`); + }); + + it('lists TESS full-frame image sectors with dates and cutout URLs', async () => { + const result = await server().callTool('nasa_tess_ffi', { tic_id: 261136679 }); + const text = textOf(result); + assert.equal(result.isError, undefined, text); + assert.match(text, /- Sector 1 \(camera 4, CCD 2\), 2018-07-25 to 2018-08-22: https:\/\/mast\.stsci\.edu\/tesscut\/api\/v0\.1\/astrocut\?/); + }); + + it('searches other collections by position', async () => { + const result = await server().callTool('nasa_mast_observations', { target: 'TRAPPIST-1', collection: 'JWST', limit: 3 }); + const text = textOf(result); + assert.equal(result.isError, undefined, text); + assert.match(text, /^\d+ JWST observations within 60″ of TRAPPIST-1 .*\nBy type: .*\nBy instrument: .*NIRSPEC\/SLIT \d+/, 'NIRSpec slit observations sit about 25″ from the catalog position'); + }); +}); + describe('live FIRMS', { skip: skipFirms }, () => { it('area query with the configured MAP_KEY returns a valid result', async () => { const result = await server().callTool('nasa_firms', { bbox: '-125,32,-114,42', days: 1, limit: 5 }); diff --git a/test/unit/mast.test.ts b/test/unit/mast.test.ts new file mode 100644 index 0000000..130f7cc --- /dev/null +++ b/test/unit/mast.test.ts @@ -0,0 +1,408 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { downloadUrl, formatBytes, mjdToDate } from '../../src/handlers/nasa/mast'; +import { executeTool } from '../../src/tools/execute'; +import { resolveTool } from '../../src/tools/registry'; +import { testContext, textOf } from '../helpers/context'; +import { fakeFetch, jsonResponse, type RecordedRequest, type Responder } from '../helpers/fake-fetch'; + +interface MastRequest { + service: string; + params: Record & { filters?: Array<{ paramName: string; values: unknown[] }> }; + format: string; + pagesize?: number; +} + +const INVOKE = 'mast.stsci.edu/api/v0/invoke'; +const SECTORS = 'mast.stsci.edu/tesscut/api/v0.1/sector'; + +function mastRequest(request: RecordedRequest): MastRequest { + return JSON.parse(new URLSearchParams(String(request.body)).get('request')!) as MastRequest; +} + +/** Answers MAST API calls by service name; any other service fails the test. */ +function mast(handlers: Record unknown>): [string, Responder] { + return [ + INVOKE, + (request) => { + const parsed = mastRequest(request); + const handler = handlers[parsed.service]; + if (!handler) throw new Error(`unexpected MAST service ${parsed.service}`); + const body = handler(parsed); + return body instanceof Response ? body : jsonResponse(body); + } + ]; +} + +const table = (data: unknown[], total = data.length) => ({ + status: 'COMPLETE', + msg: '', + data, + fields: [], + paging: { page: 1, pageSize: data.length, pagesFiltered: 1, rows: data.length, rowsFiltered: total, rowsTotal: total } +}); + +const piMensae = { + resolvedCoordinate: [{ searchString: 'pi mensae', resolver: 'SIMBAD', canonicalName: '* pi. Men', ra: 84.2911951861308, decl: -80.46912070924 }], + status: '' +}; + +const filterValues = (request: MastRequest, name: string) => request.params.filters?.find((f) => f.paramName === name)?.values; +const services = (calls: RecordedRequest[]) => calls.filter((c) => `${c.url.host}${c.url.pathname}` === INVOKE).map((c) => mastRequest(c).service); + +async function call(name: string, args: Record, routes: Array<[string, Responder]>) { + const definition = resolveTool(name); + assert.ok(definition, name); + const fake = fakeFetch(routes); + const result = await executeTool(definition, args, testContext(fake.fetch, { nasaApiKey: undefined })); + return { result, calls: fake.calls, text: textOf(result) }; +} + +describe('MAST helpers', () => { + it('converts MJD to dates, formats sizes and builds anonymous download links', () => { + assert.equal(mjdToDate(58324.81304390046), '2018-07-25'); + assert.equal(mjdToDate(null), undefined); + assert.equal(formatBytes(2013120), '2.0 MB'); + assert.equal(formatBytes(512), '512 B'); + assert.equal(formatBytes(null), 'size unknown'); + assert.equal( + downloadUrl('mast:TESS/product/tess2023209231226-s0068-0000000261136679-0262-s_lc.fits'), + 'https://mast.stsci.edu/api/v0.1/Download/file?uri=mast%3ATESS%2Fproduct%2Ftess2023209231226-s0068-0000000261136679-0262-s_lc.fits' + ); + assert.equal(downloadUrl('https://archive.stsci.edu/hlsps/x.fits'), 'https://archive.stsci.edu/hlsps/x.fits'); + assert.equal(downloadUrl('ftp://example.org/x.fits'), undefined); + }); + + it('requires exactly one of target, tic_id, or ra and dec', async () => { + for (const tool of ['nasa_mast_observations', 'nasa_tess_ffi']) { + for (const [args, pattern] of [ + [{}, /exactly one of target, tic_id, or ra and dec/], + [{ target: 'Pi Mensae', tic_id: 261136679 }, /exactly one of target, tic_id, or ra and dec/], + [{ ra: 84.29 }, /dec: ra and dec must be given together/], + [{ ra: 360.5, dec: 0 }, /ra: /], + [{ ra: 10, dec: -91 }, /dec: /] + ] as Array<[Record, RegExp]>) { + const { result, calls, text } = await call(tool, args, []); + assert.equal(result.isError, true, `${tool} ${JSON.stringify(args)}`); + assert.match(text, pattern); + assert.equal(calls.length, 0); + } + } + }); + + it('resolves names with Mast.Name.Lookup and reports names MAST cannot resolve', async () => { + const unknown = await call('nasa_tess_ffi', { target: 'notastar xyz123' }, [mast({ 'Mast.Name.Lookup': () => ({ resolvedCoordinate: [], status: '' }) })]); + assert.equal(unknown.result.isError, true); + assert.match(unknown.text, /MAST could not resolve "notastar xyz123" to a sky position/); + const request = unknown.calls[0]; + assert.equal(request.method, 'POST'); + assert.equal(request.headers.get('content-type'), 'application/x-www-form-urlencoded'); + assert.deepEqual(mastRequest(request), { service: 'Mast.Name.Lookup', params: { input: 'notastar xyz123', format: 'json' }, format: 'json' }); + }); + + it('re-sends a stalled request, at most three times', async () => { + const stall = () => { + throw new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + }; + let attempts = 0; + const recovered = await call('nasa_mast_observations', { tic_id: 261136679 }, [ + mast({ 'Mast.Caom.Filtered': () => (++attempts === 1 ? stall() : table([])) }) + ]); + assert.equal(recovered.result.isError, undefined, recovered.text); + assert.equal(attempts, 2); + + const stalled = await call('nasa_mast_observations', { tic_id: 261136679 }, [[INVOKE, stall]]); + assert.equal(stalled.result.isError, true); + assert.equal(stalled.calls.length, 3); + assert.match(stalled.text, /^MAST did not answer within 90 seconds \(3 attempts\)\. MAST keeps running slow searches/); + }); + + it('re-requests a query MAST is still executing and surfaces MAST errors', async () => { + let attempts = 0; + const executing = await call('nasa_mast_observations', { tic_id: 261136679 }, [ + mast({ 'Mast.Caom.Filtered': () => (++attempts === 1 ? { status: 'EXECUTING', msg: '' } : table([])) }) + ]); + assert.equal(executing.result.isError, undefined, executing.text); + assert.equal(attempts, 2); + assert.match(executing.text, /No TESS light curves in MAST for TIC 261136679/); + + const failed = await call('nasa_mast_observations', { tic_id: 261136679 }, [mast({ 'Mast.Caom.Filtered': () => ({ status: 'ERROR', msg: 'Request failed.' }) })]); + assert.equal(failed.result.isError, true); + assert.match(failed.text, /MAST Mast\.Caom\.Filtered failed: Request failed\./); + }); +}); + +describe('nasa_mast_observations', () => { + const lightCurve = (overrides: Record) => ({ + obs_collection: 'TESS', + provenance_name: 'SPOC', + instrument_name: 'Photometer', + filters: 'TESS', + dataproduct_type: 'timeseries', + target_name: '261136679', + t_exptime: 120, + dataRights: 'PUBLIC', + ...overrides + }); + const rows = [ + lightCurve({ obsid: 1, sequence_number: 1, t_min: 58324.81, t_max: 58352.67, obs_id: 'tess2018206045859-s0001-0000000261136679-0120-s' }), + lightCurve({ obsid: 2, sequence_number: 1, t_min: 58324.81, t_max: 58352.67, t_exptime: 20, obs_id: 'tess2018206045859-s0001-0000000261136679-0120-a_fast' }), + lightCurve({ obsid: 3, sequence_number: 39, t_min: 58324.81, t_max: 59389.5, obs_id: 'tess2018206190142-s0001-s0039-0000000261136679' }), + lightCurve({ obsid: 4, obs_collection: 'HLSP', provenance_name: 'QLP', sequence_number: 4, t_min: 58410.42, t_max: 58436.33, t_exptime: 1800, obs_id: 'hlsp_qlp_tess_ffi_s0004-0000000261136679_tess_v01_llc' }), + lightCurve({ obsid: 5, obs_collection: 'HLSP', provenance_name: 'QLP', sequence_number: 1, t_min: 58324.81, t_max: 58352.67, t_exptime: 1800, obs_id: 'hlsp_qlp_tess_ffi_s0001-0000000261136679_tess_v01_llc' }), + lightCurve({ obsid: 6, obs_collection: 'HLSP', provenance_name: 'DIAMANTE', sequence_number: -999, t_min: null, t_exptime: 1800, obs_id: 'hlsp_diamante_tess_lightcurve_tic-0000000261136679_tess_v1_llc' }) + ]; + + it('matches TIC stars around a named target and lists SPOC and HLSP light curves by sector', async () => { + let filtered: MastRequest | undefined; + const { calls, text, result } = await call('nasa_mast_observations', { target: 'Pi Mensae', radius_arcsec: 30, limit: 4 }, [ + mast({ + 'Mast.Name.Lookup': () => piMensae, + 'Mast.Catalogs.Tic.Cone': (request) => { + assert.deepEqual(request.params, { ra: 84.2911951861308, dec: -80.46912070924, radius: 30 / 3600 }); + return table([ + { ID: 261139071, Tmag: 13.9952, dstArcSec: 22.02 }, + { ID: 261136679, Tmag: 5.1054, dstArcSec: 0.0054 } + ]); + }, + 'Mast.Caom.Filtered': (request) => { + filtered = request; + return table(rows); + } + }) + ]); + assert.equal(result.isError, undefined, text); + assert.deepEqual(services(calls), ['Mast.Name.Lookup', 'Mast.Catalogs.Tic.Cone', 'Mast.Caom.Filtered']); + assert.deepEqual(filterValues(filtered!, 'target_name'), ['261136679', '261139071'], 'nearest star first'); + assert.deepEqual(filterValues(filtered!, 'obs_collection'), ['TESS', 'HLSP']); + assert.deepEqual(filterValues(filtered!, 'filters'), ['TESS']); + assert.deepEqual(filterValues(filtered!, 'dataproduct_type'), ['timeseries']); + assert.equal(filtered!.pagesize, 2000); + assert.match(filtered!.params.columns as string, /^obsid,obs_collection,.*sequence_number/); + + assert.match( + text, + /^6 TESS light curve observations in MAST for TIC stars within 30″ of Pi Mensae \(\* pi\. Men; RA 84\.29120°, Dec -80\.46912°; resolved by SIMBAD\)\.\n/ + ); + assert.match(text, /Matched TIC 261136679 \(Tmag 5\.11, 0\.0″ away\); TIC 261139071 \(Tmag 14\.00, 22\.0″ away\)\./); + assert.match(text, /TESS mission \(SPOC\) target light curves: sectors 1 \(cadence 120 s, 20 s\)\./, 'the multi-sector search is not a sector'); + assert.match(text, /High-level science product \(HLSP\) light curves: DIAMANTE \(no sector number\); QLP sectors 1, 4\./); + assert.match(text, /Showing the 4 most recent; raise limit to see others\./); + assert.match(text, /- obsid 4: HLSP Photometer timeseries; sector 4; 1800 s exposure; 2018-10-19 to 2018-11-14; pipeline QLP; target 261136679; obs_id hlsp_qlp/); + assert.match(text, /- obsid 3: TESS Photometer timeseries; sectors 1-39 \(multi-sector search\); 120 s exposure; 2018-07-25 to 2021-06-24; pipeline SPOC/); + assert.doesNotMatch(text, /obsid 6:/, 'rows without dates sort last'); + }); + + it('uses a TIC ID directly, without name resolution or a cone search', async () => { + for (const args of [{ tic_id: 261136679 }, { target: 'TIC 0261136679' }]) { + let targets: unknown[] | undefined; + const { calls, text } = await call('nasa_mast_observations', args, [ + mast({ + 'Mast.Caom.Filtered': (request) => { + targets = filterValues(request, 'target_name'); + return table(rows.slice(0, 1)); + } + }) + ]); + assert.deepEqual(services(calls), ['Mast.Caom.Filtered']); + assert.deepEqual(targets, ['261136679']); + assert.match(text, /^1 TESS light curve observation in MAST for TIC 261136679\./); + } + }); + + it('explains when no TIC star or no light curve matches', async () => { + const empty = await call('nasa_mast_observations', { ra: 10, dec: -30 }, [mast({ 'Mast.Catalogs.Tic.Cone': () => table([]) })]); + assert.match(empty.text, /^No TESS Input Catalog stars lie within 10″ of RA 10\.00000°, Dec -30\.00000°\. Raise radius_arcsec, or use nasa_tess_ffi/); + assert.deepEqual(services(empty.calls), ['Mast.Catalogs.Tic.Cone']); + + const none = await call('nasa_mast_observations', { tic_id: 1 }, [mast({ 'Mast.Caom.Filtered': () => table([]) })]); + assert.match(none.text, /^No TESS light curves in MAST for TIC 1\.\nFull-frame images may still cover it: try nasa_tess_ffi\.$/); + }); + + it('uses the position search for other collections and data product types', async () => { + let request: MastRequest | undefined; + const jwst = await call('nasa_mast_observations', { target: 'TRAPPIST-1', collection: 'JWST', dataproduct_type: 'timeseries', radius_arcsec: 5 }, [ + mast({ + 'Mast.Name.Lookup': () => ({ resolvedCoordinate: [{ canonicalName: 'TRAPPIST-1', ra: 346.6223683, decl: -5.0413976, resolver: 'SIMBAD' }], status: '' }), + 'Mast.Caom.Filtered.Position': (r) => { + request = r; + return table([ + { + obsid: 233336015, obs_collection: 'JWST', instrument_name: 'MIRI/IMAGE', filters: 'F1280W', dataproduct_type: 'timeseries', sequence_number: -999, + t_exptime: 15625.71, t_min: 60649.1, t_max: 60649.3, provenance_name: 'CALJWST', target_name: 'TRAPPIST-1', obs_id: 'jw05191004001_03101_00001-seg007_mirimage', + proposal_id: '5191', proposal_pi: 'Ducrot, Elsa', dataRights: 'EXCLUSIVE_ACCESS' + } + ], 270); + } + }) + ]); + assert.equal(request!.params.position, `346.6223683, -5.0413976, ${5 / 3600}`); + assert.deepEqual(request!.params.filters, [ + { paramName: 'obs_collection', values: ['JWST'] }, + { paramName: 'dataproduct_type', values: ['timeseries'] } + ]); + assert.match(jwst.text, /^270 JWST timeseries observations within 5″ of TRAPPIST-1 \(RA 346\.62237°, Dec -5\.04140°; resolved by SIMBAD\)\. Counts below cover the first 1;/); + assert.match(jwst.text, /\nBy type: timeseries 1\.\nBy instrument: MIRI\/IMAGE 1\.\n/); + assert.match( + jwst.text, + /- obsid 233336015: JWST MIRI\/IMAGE timeseries; filter F1280W; 15625\.7 s exposure; 2024-12-05; pipeline CALJWST; target TRAPPIST-1; obs_id jw05191004001_03101_00001-seg007_mirimage; proposal 5191 \(PI Ducrot, Elsa\); EXCLUSIVE_ACCESS$/ + ); + + const all = await call('nasa_mast_observations', { ra: 133.14921, dec: 28.33082, collection: 'all' }, [ + mast({ + 'Mast.Caom.Filtered.Position': (r) => { + request = r; + return table([ + { obsid: 10, obs_collection: 'HST', instrument_name: 'FGS', dataproduct_type: 'timeseries', t_min: 50000 }, + { obsid: 11, obs_collection: 'TESS', filters: 'TESS', dataproduct_type: 'image', sequence_number: 44, obs_id: 'tess-s0044-1-1', t_min: 59000 }, + { obsid: 12, obs_collection: 'TESS', filters: 'TESS', dataproduct_type: 'timeseries', sequence_number: 21, t_min: 58800 } + ]); + } + }) + ]); + assert.equal(request!.params.position, `133.14921, 28.33082, ${60 / 3600}`, 'position searches default to 60″'); + assert.deepEqual(request!.params.filters, []); + assert.match(all.text, /By collection: TESS 2, HST 1\.\nBy type: timeseries 2, image 1\.\nBy instrument: HST FGS 1\.\nTESS sectors with light curves: 21\.\nTESS sectors with full-frame images: 44 \(use nasa_tess_ffi for cutouts\)\./); + + const ffi = await call('nasa_mast_observations', { tic_id: 261136679, dataproduct_type: 'image' }, [ + mast({ 'Mast.Name.Lookup': () => piMensae, 'Mast.Caom.Filtered.Position': () => table([]) }) + ]); + assert.deepEqual(services(ffi.calls), ['Mast.Name.Lookup', 'Mast.Caom.Filtered.Position']); + assert.match(ffi.text, /^No TESS image observations within 60″ of TIC 261136679/); + }); +}); + +describe('nasa_mast_products', () => { + const product = (overrides: Record) => ({ + obsID: 176755222, + parent_obsid: 176755222, + obs_collection: 'TESS', + productType: 'SCIENCE', + productGroupDescription: 'Minimum Recommended Products', + dataRights: 'PUBLIC', + calib_level: 3, + ...overrides + }); + const tess = [ + product({ productFilename: 'x_tp.fits', dataURI: 'mast:TESS/product/x_tp.fits', productSubGroupDescription: 'TP', description: 'Target pixel files', size: 92180160, calib_level: 2 }), + product({ productFilename: 'x_lc.fits', dataURI: 'mast:TESS/product/x_lc.fits', productSubGroupDescription: 'LC', description: 'Light curves', size: 2013120 }), + product({ productFilename: 'x_lc.fits', dataURI: 'mast:TESS/product/x_lc.fits', productSubGroupDescription: 'LC', description: 'Light curves', size: 2013120 }), + product({ productFilename: 'x_dvr.pdf', dataURI: 'mast:TESS/product/x_dvr.pdf', productType: 'INFO', productSubGroupDescription: 'DVR', productGroupDescription: null, size: 14318908 }), + product({ productFilename: 'x.png', dataURI: 'mast:TESS/product/x.png', productType: 'PREVIEW', productSubGroupDescription: null, productGroupDescription: null, size: 1000 }) + ]; + const hlsp = [ + product({ + obsID: 45092053, parent_obsid: 45092053, obs_collection: 'HLSP', productFilename: 'hlsp_qlp_llc.fits', dataURI: 'mast:HLSP/qlp/s0013/hlsp_qlp_llc.fits', + productSubGroupDescription: null, description: 'FITS', size: 85504, calib_level: 4, dataRights: 'EXCLUSIVE_ACCESS' + }) + ]; + + it('lists science files with sizes and download links, newest calibration level first', async () => { + let request: MastRequest | undefined; + const { text } = await call('nasa_mast_products', { obsids: [176755222, '45092053', 176755222] }, [ + mast({ + 'Mast.Caom.Products': (r) => { + request = r; + return table([...tess, ...hlsp]); + } + }) + ]); + assert.deepEqual(request!.params, { obsid: '176755222,45092053' }); + assert.equal(request!.pagesize, 5000); + assert.match(text, /^5 products for obsid 176755222, 45092053; 3 match types science\. Matching files total 94\.3 MB\.\n/, 'the duplicate light curve is dropped'); + assert.match(text, /By subgroup: LC 1, none 1, TP 1\./); + assert.match(text, /Not listed because of product_types: info 1, preview 1\./); + assert.match(text, /Files marked EXCLUSIVE_ACCESS are still proprietary and need a MAST token/); + assert.match( + text, + /- x_lc\.fits \(LC, SCIENCE, 2\.0 MB, level 3, minimum recommended\): Light curves\n {2}https:\/\/mast\.stsci\.edu\/api\/v0\.1\/Download\/file\?uri=mast%3ATESS%2Fproduct%2Fx_lc\.fits\n- x_tp\.fits \(TP, SCIENCE, 92\.2 MB, level 2/ + ); + assert.match(text, /- hlsp_qlp_llc\.fits \(SCIENCE, 85\.5 KB, level 4, minimum recommended, EXCLUSIVE_ACCESS\): FITS\n {2}https:\/\/mast\.stsci\.edu\/api\/v0\.1\/Download\/file\?uri=mast%3AHLSP%2Fqlp/); + assert.ok(text.indexOf('x_tp.fits') < text.indexOf('hlsp_qlp_llc.fits'), 'products follow the requested obsid order'); + }); + + it('filters by type, subgroup and minimum recommended products, and notes obsids without products', async () => { + const routes = [mast({ 'Mast.Caom.Products': () => table([...tess, ...hlsp]) })]; + const reports = await call('nasa_mast_products', { obsids: '176755222, 45092053, 99', product_types: 'INFO', subgroups: 'dvr' }, routes); + assert.match(reports.text, /^5 products for obsid 176755222, 45092053, 99; 1 match types info, subgroups DVR\. Matching files total 14\.3 MB\.\nNo products are listed for obsid 99\./); + assert.match(reports.text, /- x_dvr\.pdf \(DVR, INFO, 14\.3 MB, level 3\)/); + + const recommended = await call('nasa_mast_products', { obsids: ['176755222'], product_types: ['science', 'preview'], minimum_recommended: true, limit: 1 }, routes); + assert.match(recommended.text, /; 3 match types science\/preview, minimum recommended only\./); + assert.match(recommended.text, /Showing 1; raise limit or add subgroups to see others\./); + + const none = await call('nasa_mast_products', { obsids: '5' }, [mast({ 'Mast.Caom.Products': () => table([]) })]); + assert.equal(none.text, 'MAST lists no products for obsid 5. Check the obsid values from nasa_mast_observations.'); + }); + + it('validates obsids and product types before calling MAST', async () => { + for (const [args, pattern] of [ + [{ obsids: 'tess2023209231226-s0068' }, /numeric MAST obsid/], + [{ obsids: [] }, /obsids/], + [{ obsids: '1', product_types: ['raw'] }, /product_types/], + [{ obsids: '1', subgroups: ['L C'] }, /product subgroup/] + ] as Array<[Record, RegExp]>) { + const { result, calls, text } = await call('nasa_mast_products', args, []); + assert.equal(result.isError, true, JSON.stringify(args)); + assert.match(text, pattern); + assert.equal(calls.length, 0); + } + }); +}); + +describe('nasa_tess_ffi', () => { + const sectors = { + results: [ + { sectorName: 'tess-s0004-4-3', sector: '0004', camera: '4', ccd: '3' }, + { sectorName: 'tess-s0001-4-2', sector: '0001', camera: '4', ccd: '2' } + ] + }; + const dates = (request: MastRequest) => { + assert.deepEqual(filterValues(request, 'obs_id'), ['tess-s0001-4-2', 'tess-s0004-4-3']); + return table([ + { obs_id: 'tess-s0001-4-2', t_min: 58324.81304390046, t_max: 58352.666903854166 }, + { obs_id: 'tess-s0004-4-3', t_min: 58410.4159353, t_max: 58436.33232073 } + ]); + }; + + it('lists sectors with dates and a TESSCut cutout URL for each', async () => { + const { calls, text } = await call('nasa_tess_ffi', { tic_id: 261136679, cutout_size: 20 }, [ + mast({ + 'Mast.Name.Lookup': (request) => { + assert.equal(request.params.input, 'TIC 261136679'); + return { resolvedCoordinate: [{ canonicalName: 'TIC 261136679', ra: 84.2911879979852, decl: -80.4691197969941, resolver: 'TIC' }], status: '' }; + }, + 'Mast.Caom.Filtered': dates + }), + [SECTORS, () => jsonResponse(sectors)] + ]); + const sectorCall = calls.find((c) => c.url.pathname.endsWith('/sector'))!; + assert.equal(sectorCall.url.search, '?ra=84.2911879979852&dec=-80.4691197969941'); + assert.match(text, /^TESS full-frame images cover TIC 261136679 \(RA 84\.29119°, Dec -80\.46912°; resolved by TIC\) in 2 sectors: 1, 4\.\n/); + assert.match(text, /20 × 20 pixels \(7′ × 7′\)\. This tool does not download them\./); + const base = 'https://mast.stsci.edu/tesscut/api/v0.1/astrocut?ra=84.2911879979852&dec=-80.4691197969941&y=20&x=20&units=px'; + assert.ok(text.includes(`- Sector 1 (camera 4, CCD 2), 2018-07-25 to 2018-08-22: ${base}§or=1\n- Sector 4 (camera 4, CCD 3), 2018-10-19 to 2018-11-14: ${base}§or=4`), text); + assert.ok(text.endsWith(`All sectors in one ZIP: ${base}`)); + }); + + it('handles one sector, uncovered positions and missing dates', async () => { + const position = { ra: 97.0968, dec: -65.5793 }; + const one = await call('nasa_tess_ffi', { ...position, sector: 4 }, [ + mast({ 'Mast.Caom.Filtered': () => ({ status: 'ERROR', msg: 'Request failed.' }) }), + [SECTORS, () => jsonResponse(sectors)] + ]); + assert.match(one.text, /^TESS full-frame images cover RA 97\.09680°, Dec -65\.57930° in 1 sector: 4\.\n/); + assert.match(one.text, /Sector dates are unavailable: MAST Mast\.Caom\.Filtered failed: Request failed\./); + assert.match(one.text, /- Sector 4 \(camera 4, CCD 3\): https:\/\/mast\.stsci\.edu\/tesscut\/api\/v0\.1\/astrocut\?ra=97\.0968&dec=-65\.5793&y=10&x=10&units=px§or=4$/); + + const missed = await call('nasa_tess_ffi', { ...position, sector: 2 }, [[SECTORS, () => jsonResponse(sectors)]]); + assert.equal(missed.text, 'Sector 2 did not cover RA 97.09680°, Dec -65.57930°. TESS full-frame images cover it in sectors 1, 4.'); + + const none = await call('nasa_tess_ffi', position, [[SECTORS, () => jsonResponse({ results: [] })]]); + assert.equal(none.text, 'No TESS full-frame images cover RA 97.09680°, Dec -65.57930° (TESSCut lists no sectors for this position).'); + + const tooBig = await call('nasa_tess_ffi', { ...position, cutout_size: 101 }, []); + assert.match(tooBig.text, /cutout_size/); + assert.equal(tooBig.calls.length, 0); + }); +}); diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index 594b98e..f74de28 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -12,7 +12,7 @@ const EXPECTED_TOOLS = [ 'nasa_mars_rover', 'nasa_eonet', 'nasa_power', 'nasa_osdr_files', 'jpl_sbdb', 'jpl_fireball', 'jpl_jd_cal', 'jpl_nhats', 'jpl_cad', 'jpl_sentry', 'jpl_horizons', 'jpl_horizons_file', 'jpl_periodic_orbits', 'jpl_scout', 'nasa_insight_weather', 'nasa_tle', 'nasa_ssc_observatories', 'nasa_ssc_locations', 'nasa_techport', 'nasa_techtransfer', - 'nasa_trek_layers', 'nasa_trek_tile', 'jpl_mission_design' + 'nasa_trek_layers', 'nasa_trek_tile', 'jpl_mission_design', 'nasa_mast_observations', 'nasa_mast_products', 'nasa_tess_ffi' ]; describe('tool registry', () => { diff --git a/test/unit/server.test.ts b/test/unit/server.test.ts index 4308c45..eb63566 100644 --- a/test/unit/server.test.ts +++ b/test/unit/server.test.ts @@ -72,7 +72,7 @@ describe('MCP server', () => { const invalid = await client.request({ method: 'nasa/cmr', params: { limit: 'ten' } } as never, CallToolResultSchema); assert.equal(invalid.isError, true); const manifest = await client.request({ method: 'tools/manifest', params: {} } as never, z.object({ apis: z.array(z.object({ name: z.string(), id: z.string() })) })); - assert.equal(manifest.apis.length, 32); + assert.equal(manifest.apis.length, 35); assert.ok(manifest.apis.some((api) => api.name === 'nasa_mars_rover' && api.id === 'nasa/mars_rover')); await client.close(); });