From 4062aadb1b3767b9e837c72e301811c2f85564d7 Mon Sep 17 00:00:00 2001 From: ProgramComputer <22284856+ProgramComputer@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:21:17 -0500 Subject: [PATCH 1/2] Migrate nasa_apod to the NASA Science APOD API and add InSight weather, TLE, SSC, TechPort, TechTransfer, Trek and JPL Mission Design tools --- .env.example | 3 +- CHANGELOG.md | 25 ++ README.md | 49 ++-- docs/TOOLS.md | 132 +++++++++- docs/inspector-test-examples.md | 92 ++++++- src/config.ts | 2 +- src/handlers/jpl/mission_design.ts | 98 ++++++++ src/handlers/nasa/apod.ts | 241 ++++++++++++++---- src/handlers/nasa/insight.ts | 73 ++++++ src/handlers/nasa/ssc.ts | 231 ++++++++++++++++++ src/handlers/nasa/techport.ts | 106 ++++++++ src/handlers/nasa/techtransfer.ts | 101 ++++++++ src/handlers/nasa/tle.ts | 90 +++++++ src/handlers/nasa/trek.ts | 275 +++++++++++++++++++++ src/index.ts | 2 +- src/prompts.ts | 22 +- src/resource-templates.ts | 4 +- src/tools/registry.ts | 18 +- src/util/html.ts | 50 ++++ src/util/http.ts | 3 +- test/integration/transports.test.ts | 4 +- test/live/upstream.live.ts | 11 + test/unit/catalog-tools.test.ts | 365 ++++++++++++++++++++++++++++ test/unit/handlers.test.ts | 162 ++++++++++-- test/unit/registry.test.ts | 6 +- test/unit/server.test.ts | 13 +- test/unit/util.test.ts | 14 ++ 27 files changed, 2067 insertions(+), 125 deletions(-) create mode 100644 src/handlers/jpl/mission_design.ts create mode 100644 src/handlers/nasa/insight.ts create mode 100644 src/handlers/nasa/ssc.ts create mode 100644 src/handlers/nasa/techport.ts create mode 100644 src/handlers/nasa/techtransfer.ts create mode 100644 src/handlers/nasa/tle.ts create mode 100644 src/handlers/nasa/trek.ts create mode 100644 src/util/html.ts create mode 100644 test/unit/catalog-tools.test.ts diff --git a/.env.example b/.env.example index a8963f5..9f1a6ed 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ # Copy to .env (never commit .env). Values here are placeholders. -# api.nasa.gov key for APOD, NEO and DONKI. +# api.nasa.gov key for NEO, DONKI and InSight weather. # Get one at https://api.nasa.gov/ (DEMO_KEY works for light use). +# APOD, TLE, SSC, TechPort, TechTransfer, Trek and the JPL tools need no key. NASA_API_KEY=YOUR_NASA_API_KEY # FIRMS MAP_KEY for nasa_firms (separate from NASA_API_KEY). diff --git a/CHANGELOG.md b/CHANGELOG.md index 069262a..30e2b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## Unreleased + +### Breaking changes and migration + +- **`nasa_apod` uses the NASA Science APOD API** (`science.nasa.gov/wp-json/wp/v2/apod-basic`). The api.nasa.gov `/planetary/apod` endpoint returns HTTP 503/504 and the api.nasa.gov catalog says the legacy API goes offline on 2026-12-01. The new API needs no key, so `nasa_apod`, the `nasa://apod/image` resource template and the APOD prompts work without `NASA_API_KEY`. + - `count` (random pictures) and `thumbs` are retired: the new API has no random mode or thumbnail option. Calls that pass them get a migration message. + - `date` defaults to the latest published picture. Dates before 1995-06-16 or in the future are rejected before any request, and a day without a picture reports HTTP 404 clearly. + - Titles, credits and explanations are plain text (the new API returns HTML). Video entries report the video URL and a still frame. Embedded images are 1024-pixel-wide renditions when the image host offers them. + - The `nasa://apod/image` resource returns one entry object, without the upstream `basic_html` page. + - The `nasa/get-astronomy-picture` and `apod-daily` prompts no longer take `count` or `thumbs`. + +### New tools + +- `nasa_insight_weather`: InSight Mars weather. Historical data only: the feed is frozen at sols 675-681 (October 2020). Needs `NASA_API_KEY`. +- `nasa_tle`: satellite two-line element sets by NORAD catalog number or name search (tle.ivanstanojevic.me, with CelesTrak data). +- `nasa_ssc_observatories` and `nasa_ssc_locations`: the Satellite Situation Center observatory list and spacecraft positions in GEO, GM, GSE, GSM, SM and GEI coordinates. Long ranges are thinned automatically to keep the output bounded. +- `nasa_techport`: TechPort project search and project details. These endpoints need no token. +- `nasa_techtransfer`: NASA patents, issued patents, software and spinoffs from technology.nasa.gov (the api.nasa.gov `/techtransfer` route only serves a help page). +- `nasa_trek_layers` and `nasa_trek_tile`: Mars, Moon and Vesta Trek layer search and WMTS tiles, addressed by zoom/row/column or by latitude/longitude. +- `jpl_mission_design`: the JPL Small-Body Mission Design API in object mode and accessible-targets mode. Porkchop-plot maps (mode M) and mission-extension searches (mode T) are not exposed: their responses are too large or too slow for a tool call. + +### Fixes + +- Upstream error messages include the `{"response":{"message":...}}` bodies returned by the TLE API. + ## 1.1.0 A reliability and CMR release. It is published as a minor version, but **default responses, parameter validation and runtime support changed in ways existing clients can notice**. Anyone depending on `^1.0.x` (or `@latest`) will receive these changes, so review the list below before upgrading, or pin `1.0.14`. diff --git a/README.md b/README.md index 56b9964..f606ebb 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Big thanks to the MCP community for their support and guidance! Ask your MCP client things like: +* "Show me today's Astronomy Picture of the Day and explain what it shows." * "Which asteroids fly past Earth today, and which one comes closest?" * "Will any asteroid pass closer to Earth than the Moon in the next 90 days?" * "What are the odds that Bennu hits Earth, according to JPL Sentry?" @@ -30,6 +31,13 @@ Ask your MCP client things like: * "Find cloud-hosted sea ice concentration datasets in NASA Earthdata." * "What were the daily highs, lows and solar energy in Denver during the first week of July 2025?" * "List the nearest roughly Earth-sized, temperate exoplanets discovered since 2020." +* "Where was the ISS over the Earth between 00:00 and 00:10 UTC on 2026-09-29?" +* "Get the latest two-line orbital elements for the ISS." +* "Show me a Mars Trek map tile of Olympus Mons." +* "What was the weather like at NASA's InSight lander during its last reported week on Mars?" +* "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." ## Disclaimer @@ -72,7 +80,7 @@ The server can be configured with the following environment variables: | Variable | Description | |----------|-------------| -| `NASA_API_KEY` | Your NASA API key (get at api.nasa.gov) | +| `NASA_API_KEY` | Your NASA API key (get at api.nasa.gov); used by NEO, DONKI and InSight weather | | `FIRMS_MAP_KEY` | Your FIRMS MAP_KEY for fire data (get at firms.modaps.eosdis.nasa.gov/api/map_key); separate from `NASA_API_KEY` | | `NASA_MCP_CMR_URL` | Optional CMR search URL (default: `https://cmr.earthdata.nasa.gov/search`) | | `MCP_TRANSPORT` | Transport mode: `stdio` (default) or `http` for Streamable HTTP | @@ -100,25 +108,37 @@ http://127.0.0.1:3000/mcp This MCP server integrates the following NASA APIs: -1. **NASA Open API** (api.nasa.gov): - - APOD (Astronomy Picture of the Day) - - EPIC (Earth Polychromatic Imaging Camera) - - DONKI (Space Weather Database Of Notifications, Knowledge, Information) - - Insight (Mars Weather Service) +1. **NASA Open API** (api.nasa.gov, needs `NASA_API_KEY`): - NEO (Near Earth Object Web Service) + - DONKI (Space Weather Database Of Notifications, Knowledge, Information) + - InSight Mars Weather Service (historical: the feed stopped updating in October 2020) + +2. **Other NASA APIs** (no key needed): + - APOD (Astronomy Picture of the Day), from the NASA Science APOD API (science.nasa.gov) - EONET (Earth Observatory Natural Event Tracker) - - TLE (Two-Line Element) + - TLE (Two-Line Element sets from CelesTrak, via tle.ivanstanojevic.me) + - Satellite Situation Center (spacecraft locations) + - TechPort (NASA technology projects) + - Technology Transfer (patents, software and spinoffs) + - Mars, Moon and Vesta Trek (WMTS map layers and tiles) - NASA Image and Video Library - Exoplanet Archive + - Open Science Data Repository (OSDR) files - POWER (Prediction Of Worldwide Energy Resources) -2. **JPL Solar System Dynamics API** (ssd-api.jpl.nasa.gov): +3. **JPL Solar System Dynamics API** (ssd-api.jpl.nasa.gov): - SBDB (Small-Body DataBase) - SBDB Close-Approach Data - Fireball Data - - Scout API - -3. **Earth Data APIs**: + - Sentry (impact risk) + - Scout (NEO Confirmation Page orbits) + - NHATS (human-accessible NEOs) + - Small-Body Mission Design + - Horizons ephemerides + - Periodic Orbits + - Julian date converter + +4. **Earth Data APIs**: - GIBS (Global Imagery Browse Services) - CMR (Common Metadata Repository) - Enhanced with advanced search capabilities - EPIC (Earth Polychromatic Imaging Camera) @@ -134,13 +154,14 @@ Each NASA API is exposed through standardized MCP methods: { "method": "nasa/apod", "params": { - "date": "2023-01-01", // Optional: YYYY-MM-DD format - "count": 5, // Optional: Return a specified number of random images - "thumbs": true // Optional: Return URL of video thumbnail + "date": "2023-01-01", // Optional: YYYY-MM-DD (1995-06-16 or later); defaults to the latest picture + "max_images": 1 // Optional: how many pictures to embed (0-5) } } ``` +For a range, send `start_date` and `end_date` (up to 100 days) instead of `date`. APOD now comes from the NASA Science APOD API, which needs no key and has no random `count` or `thumbs` option. + ### Near Earth Objects ```json diff --git a/docs/TOOLS.md b/docs/TOOLS.md index ef5ca7f..e4417d5 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -6,17 +6,15 @@ Every tool is read-only and queries public NASA/JPL services. Unknown parameters ## `nasa_apod` — NASA Astronomy Picture of the Day -Fetch NASA's Astronomy Picture of the Day (APOD): a single date, a date range (up to 100 days), or random pictures. Requires NASA_API_KEY. +NASA's Astronomy Picture of the Day (APOD) from the NASA Science APOD API (science.nasa.gov): the latest picture, one date (1995-06-16 onward), or a date range of up to 100 days. Returns the title, credit, explanation as plain text, image or video URL and page link, and embeds up to max_images pictures. No API key needed. Aliases: `nasa/apod` | Parameter | Type | Required | Default | Description | |---|---|---|---|---| -| `date` | string | | | Date of the picture (YYYY-MM-DD). Defaults to today. Cannot be combined with count or start_date/end_date. | +| `date` | string | | | Date of the picture (YYYY-MM-DD, 1995-06-16 or later). Defaults to the latest published picture. Cannot be combined with start_date/end_date. | | `start_date` | string | | | Start of a date range (YYYY-MM-DD). | -| `end_date` | string | | | End of a date range (YYYY-MM-DD); requires start_date. Defaults to today when start_date is given. | -| `count` | integer (1–100) | | | Return this many random pictures (1-100). Cannot be combined with dates. | -| `thumbs` | boolean | | | Include thumbnail URLs for video entries. | +| `end_date` | string | | | End of a date range (YYYY-MM-DD); requires start_date. Defaults to today (UTC) when start_date is given. | | `max_images` | integer (0–5) | | `1` | How many pictures to embed as image content (0-5). Remaining entries are listed by URL. | ## `nasa_neo` — NASA Near Earth Object Web Service @@ -225,6 +223,108 @@ Aliases: `nasa/osdr_files`, `nasa/osdr-files` |---|---|---|---|---| | `accession_number` | string | yes | | OSD study accession number, e.g. 87 or OSD-87. | +## `nasa_insight_weather` — NASA InSight Mars weather (historical) + +HISTORICAL DATA ONLY: per-sol Mars surface weather (air temperature, pressure, wind) from NASA's InSight lander at Elysium Planitia. The feed is frozen at the last seven sols it reported (sols 675-681, October 2020) and is no longer updated. Requires NASA_API_KEY. + +Aliases: `nasa/insight_weather`, `nasa/insight-weather` + +No parameters. + +## `nasa_tle` — Satellite two-line element sets + +Two-line element sets (TLE) for Earth-orbiting satellites from the TLE API in the api.nasa.gov catalog (tle.ivanstanojevic.me; CelesTrak data refreshed daily): one satellite by NORAD catalog number, or a paged name search. No API key needed. + +Aliases: `nasa/tle` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `satellite_id` | integer (1–999999999) | | | NORAD catalog number, e.g. 25544 for the ISS. Cannot be combined with search options. | +| `search` | string | | | Search satellite names, e.g. "ISS" or "HUBBLE". Omit to browse all records. | +| `sort` | `"popularity"` \\| `"name"` \\| `"id"` \\| `"inclination"` \\| `"eccentricity"` \\| `"period"` | | | Sort field for searches (upstream default popularity). | +| `sort_dir` | `"asc"` \\| `"desc"` | | | Sort direction (upstream default desc). | +| `page` | integer (1–100000) | | | Result page (1-based). | +| `page_size` | integer (1–100) | | | Records per page (1-100, upstream default 20). | + +## `nasa_ssc_observatories` — Satellite Situation Center spacecraft list + +Spacecraft and other observatories whose locations the NASA Satellite Situation Center (SSCWeb) can compute, with their data time ranges and resolution. Use the IDs with nasa_ssc_locations. No API key needed. + +Aliases: `nasa/ssc_observatories`, `nasa/ssc-observatories` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `search` | string | | | Case-insensitive text to match in the observatory ID or name, e.g. "mms" or "GOES". | +| `active_on` | string | | | Only observatories with location data on this date (YYYY-MM-DD). | + +## `nasa_ssc_locations` — Satellite Situation Center spacecraft locations + +Positions of spacecraft (and the Moon and Sun) over a time range from the NASA Satellite Situation Center (SSCWeb), in geophysical coordinate systems (GEO, GM, GSE, GSM, SM, GEI). Returns a CSV table per observatory: X/Y/Z in km, latitude/longitude in degrees, local time in hours and radial distance in km. The resolution factor is raised automatically to keep the output bounded. No API key needed. + +Aliases: `nasa/ssc_locations`, `nasa/ssc-locations` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `observatories` | string[] | yes | | 1-5 SSC observatory IDs, e.g. ["iss"] or ["mms1", "moon"] (see nasa_ssc_observatories). A comma-separated string also works. | +| `start_time` | string | yes | | Start time (UTC), e.g. 2026-09-29T06:00:00Z; a bare date such as 2026-09-29 means 00:00 UTC. | +| `end_time` | string | yes | | End time (UTC); at most 366 days after start_time. | +| `coordinate_systems` | `"geo"` \\| `"gm"` \\| `"gse"` \\| `"gsm"` \\| `"sm"` \\| `"geitod"` \\| `"geij2000"`[] | | | Coordinate systems (default geo): geo, gm, gse, gsm, sm, geitod, geij2000. A comma-separated string also works. | +| `resolution_factor` | integer (1–100000) | | | Return every Nth point of each observatory's base resolution (60-720 s). Chosen automatically when omitted to keep the output bounded. | + +## `nasa_techport` — NASA TechPort technology projects + +NASA's technology project inventory (TechPort): search projects by keyword, or fetch one project with its status, dates, program, technology readiness levels (TRL), lead organization, description and benefits. No API key needed. + +Aliases: `nasa/techport` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `query` | string | | | Search TechPort technology projects, e.g. "solar sail". Results are ordered by relevance. | +| `project_id` | integer (>0–9007199254740991) | | | Fetch one project by its TechPort ID, e.g. 94703. | +| `limit` | integer (1–50) | | | Search mode: how many projects to return (1-50, default 10). | + +## `nasa_techtransfer` — NASA Technology Transfer search + +Search NASA's Technology Transfer portfolio (technology.nasa.gov): patents available for licensing, issued patents, the NASA Software Catalog, or Spinoff stories about commercial products that use NASA technology. No API key needed. + +Aliases: `nasa/techtransfer` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `query` | string | yes | | Keywords to search for, e.g. "engine" or "solar panel". | +| `collection` | `"patent"` \\| `"patent_issued"` \\| `"software"` \\| `"spinoff"` | | `"patent"` | What to search: patent (licensable patents), patent_issued, software (NASA Software Catalog) or spinoff (Spinoff stories). | +| `limit` | integer (1–50) | | `10` | How many results to return (1-50). The service returns every match; the rest are counted but not shown. | + +## `nasa_trek_layers` — NASA Trek map layers (Mars, Moon, Vesta) + +Search the map layer catalogs of NASA Mars Trek, Moon Trek and Vesta Trek (trek.nasa.gov): mosaics, colour hillshades and other imagery served as OGC WMTS tiles. Returns layer IDs for nasa_trek_tile, with mission, instrument and WMTS endpoint. No API key needed. + +Aliases: `nasa/trek_layers`, `nasa/trek-layers` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `body` | `"mars"` \\| `"moon"` \\| `"vesta"` | yes | | Body: mars, moon or vesta. | +| `projection` | `"equirectangular"` \\| `"north_pole"` \\| `"south_pole"` | | `"equirectangular"` | Map projection: equirectangular (global), north_pole or south_pole (polar stereographic). | +| `search` | string | | | Keywords to match, e.g. "olympus", "LRO WAC" or "HiRISE". | +| `limit` | integer (1–50) | | `20` | How many layers to return (1-50). | + +## `nasa_trek_tile` — NASA Trek map tile (Mars, Moon, Vesta) + +One WMTS map tile image from NASA Mars Trek, Moon Trek or Vesta Trek for a layer from nasa_trek_layers, addressed by zoom/row/col or (equirectangular layers) by latitude/longitude. Reads the layer's WMTS capabilities to validate the tile and pick the format. No API key needed. + +Aliases: `nasa/trek_tile`, `nasa/trek-tile` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `body` | `"mars"` \\| `"moon"` \\| `"vesta"` | yes | | Body: mars, moon or vesta. | +| `layer` | string | yes | | Layer ID from nasa_trek_layers, e.g. Mars_MGS_MOLA_ClrShade_merge_global_463m or LRO_WAC_Mosaic_Global_303ppd_v02. | +| `projection` | `"equirectangular"` \\| `"north_pole"` \\| `"south_pole"` | | `"equirectangular"` | Map projection: equirectangular (global), north_pole or south_pole (polar stereographic). | +| `zoom` | integer (0–30) | | `0` | Zoom level (WMTS TileMatrix). 0 is the whole layer; each level doubles the resolution. | +| `row` | integer (0–2147483648) | | | Tile row (0 at the top). Provide row and col, or latitude and longitude. | +| `col` | integer (0–2147483648) | | | Tile column (0 at the left). | +| `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). | + ## `jpl_sbdb` — JPL Small-Body Database lookup Orbital and physical data for one asteroid or comet from the JPL Small-Body Database (SBDB). @@ -411,3 +511,25 @@ Aliases: `jpl/scout` | `n_orbits` | integer (1–1000) | | | Object mode: number of sampled orbits (1-1000). | | `limit` | integer (1–1000) | | | List mode: return at most this many objects. Applied by this server; Scout has no limit parameter. | +## `jpl_mission_design` — JPL small-body mission design + +Ballistic mission options to asteroids and comets from the JPL Small-Body Mission Design API. Object mode (des, spk or sstr) returns the pre-computed mission options stored for one object (launch and arrival dates, V-infinity, time of flight). List mode returns the most accessible small bodies for the given launch years, ranked by crit, with optional SBDB filters. Porkchop-plot maps (mode M) and mission-extension searches (mode T) are not exposed. + +Aliases: `jpl/mission_design`, `jpl/mission-design` + +| Parameter | Type | Required | Default | Description | +|---|---|---|---|---| +| `des` | string | | | Object mode: designation, e.g. 2012 TC4 or 433. | +| `spk` | integer (>0–9007199254740991) | | | Object mode: SPK-ID, e.g. 2000433. | +| `sstr` | string | | | Object mode: search string (name, designation or SPK-ID), e.g. apophis. | +| `class` | boolean | | | Object mode: orbit class as a name instead of the three-letter code. | +| `crit` | integer (1–6) | | | List mode: optimality criterion. 1 min departure V-infinity (default), 2 min arrival V-infinity, 3 min total delta-V, 4-6 the same with minimum time of flight first. | +| `year` | integer (1900–2200)[] | | | List mode: launch year(s), from the current year up to 20 years ahead. JPL defaults to the next five years. | +| `lim` | integer (1–200) | | `20` | List mode: how many accessible objects to return (1-200). | +| `sb_kind` | `"a"` \\| `"c"` | | | List mode: asteroids (a) or comets (c) only. | +| `sb_group` | `"neo"` \\| `"pha"` | | | List mode: NEOs or PHAs only. | +| `sb_class` | string | | | List mode: orbit class code(s), e.g. APO or MBA,OMB (case-sensitive). | +| `sb_ns` | `"n"` \\| `"u"` | | | List mode: numbered (n) or unnumbered (u) objects only. | +| `sb_sat` | boolean | | | List mode: only objects with at least one known satellite. | +| `sb_xfrag` | boolean | | | List mode: exclude comet fragments. | + diff --git a/docs/inspector-test-examples.md b/docs/inspector-test-examples.md index 6d6c705..f56eab1 100644 --- a/docs/inspector-test-examples.md +++ b/docs/inspector-test-examples.md @@ -80,18 +80,92 @@ EPIC natural-colour images: ## api.nasa.gov (needs `NASA_API_KEY`) ```json -{ "name": "nasa_apod", "arguments": { "date": "2024-01-01" } } +{ "name": "nasa_neo", "arguments": { "start_date": "2024-01-01", "end_date": "2024-01-03" } } ``` ```json -{ "name": "nasa_neo", "arguments": { "start_date": "2024-01-01", "end_date": "2024-01-03" } } +{ "name": "nasa_donki", "arguments": { "type": "cme", "startDate": "2024-01-01", "endDate": "2024-01-10" } } ``` +InSight Mars weather (historical; the feed stopped in October 2020): + ```json -{ "name": "nasa_donki", "arguments": { "type": "cme", "startDate": "2024-01-01", "endDate": "2024-01-10" } } +{ "name": "nasa_insight_weather", "arguments": {} } +``` + +## Other NASA services (no key needed) + +Astronomy Picture of the Day: the latest picture, one date, or a range of up to 100 days: + +```json +{ "name": "nasa_apod", "arguments": {} } +``` + +```json +{ "name": "nasa_apod", "arguments": { "date": "2015-07-14" } } +``` + +```json +{ "name": "nasa_apod", "arguments": { "start_date": "2026-09-01", "end_date": "2026-09-07", "max_images": 0 } } ``` -## Other NASA services +Two-line elements for the ISS, and a name search: + +```json +{ "name": "nasa_tle", "arguments": { "satellite_id": 25544 } } +``` + +```json +{ "name": "nasa_tle", "arguments": { "search": "STARLINK", "page_size": 5 } } +``` + +Satellite Situation Center: find observatory IDs, then locate spacecraft: + +```json +{ "name": "nasa_ssc_observatories", "arguments": { "search": "mms", "active_on": "2026-09-29" } } +``` + +```json +{ "name": "nasa_ssc_locations", "arguments": { "observatories": ["iss"], "start_time": "2026-09-29T00:00:00Z", "end_time": "2026-09-29T01:00:00Z" } } +``` + +```json +{ "name": "nasa_ssc_locations", "arguments": { "observatories": ["mms1", "moon"], "start_time": "2026-09-01", "end_time": "2026-09-08", "coordinate_systems": ["gse", "gsm"] } } +``` + +TechPort technology projects: + +```json +{ "name": "nasa_techport", "arguments": { "query": "solar sail", "limit": 5 } } +``` + +```json +{ "name": "nasa_techport", "arguments": { "project_id": 94703 } } +``` + +Technology Transfer patents, software and spinoffs: + +```json +{ "name": "nasa_techtransfer", "arguments": { "query": "solar panel", "limit": 5 } } +``` + +```json +{ "name": "nasa_techtransfer", "arguments": { "query": "visualization", "collection": "software", "limit": 5 } } +``` + +Mars, Moon and Vesta Trek map layers and tiles: + +```json +{ "name": "nasa_trek_layers", "arguments": { "body": "mars", "search": "olympus" } } +``` + +```json +{ "name": "nasa_trek_tile", "arguments": { "body": "mars", "layer": "olympus_mons.eq", "zoom": 6, "latitude": 18.65, "longitude": 226.2 } } +``` + +```json +{ "name": "nasa_trek_tile", "arguments": { "body": "moon", "layer": "LRO_WAC_Mosaic_Global_303ppd_v02", "zoom": 2, "row": 1, "col": 3 } } +``` ```json { "name": "nasa_images", "arguments": { "q": "apollo 11", "media_type": "image", "year_start": "1969", "year_end": "1970", "page_size": 5 } } @@ -142,3 +216,13 @@ EPIC natural-colour images: ```json { "name": "jpl_periodic_orbits", "arguments": { "sys": "earth-moon", "family": "halo", "libr": 1, "branch": "N" } } ``` + +Mission options to one asteroid, and the most accessible NEOs for 2030 launches: + +```json +{ "name": "jpl_mission_design", "arguments": { "sstr": "apophis" } } +``` + +```json +{ "name": "jpl_mission_design", "arguments": { "crit": 3, "year": [2030], "sb_group": "neo", "lim": 10 } } +``` diff --git a/src/config.ts b/src/config.ts index 9e7ac2a..cb250ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import { registerSecret } from './util/redact'; export const DEFAULT_CMR_BASE_URL = 'https://cmr.earthdata.nasa.gov/search'; export interface ServerConfig { - /** api.nasa.gov key: APOD, NEO, DONKI, Mars Rover Photos. */ + /** api.nasa.gov key: NEO, DONKI, InSight weather, Mars Rover Photos. */ nasaApiKey?: string; /** FIRMS MAP_KEY (distinct from the api.nasa.gov key). */ firmsMapKey?: string; diff --git a/src/handlers/jpl/mission_design.ts b/src/handlers/jpl/mission_design.ts new file mode 100644 index 0000000..6eabe71 --- /dev/null +++ b/src/handlers/jpl/mission_design.ts @@ -0,0 +1,98 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { ToolInputError } from '../../util/errors'; +import { jsonResult, text } from '../common'; +import { jplGet } from './common'; + +const SERVICE = 'JPL Small-Body Mission Design API'; +const MAX_YEARS_AHEAD = 20; + +export const missionDesignInputSchema = z + .strictObject({ + des: z.string().trim().min(1).max(100).describe('Object mode: designation, e.g. 2012 TC4 or 433.').optional(), + spk: z.int().positive().describe('Object mode: SPK-ID, e.g. 2000433.').optional(), + sstr: z.string().trim().min(1).max(100).describe('Object mode: search string (name, designation or SPK-ID), e.g. apophis.').optional(), + class: z.boolean().describe('Object mode: orbit class as a name instead of the three-letter code.').optional(), + crit: z + .int() + .min(1) + .max(6) + .describe( + 'List mode: optimality criterion. 1 min departure V-infinity (default), 2 min arrival V-infinity, 3 min total delta-V, ' + + '4-6 the same with minimum time of flight first.' + ) + .optional(), + year: z + .array(z.int().min(1900).max(2200)) + .min(1) + .max(MAX_YEARS_AHEAD + 1) + .describe(`List mode: launch year(s), from the current year up to ${MAX_YEARS_AHEAD} years ahead. JPL defaults to the next five years.`) + .optional(), + lim: z.int().min(1).max(200).default(20).describe('List mode: how many accessible objects to return (1-200).'), + sb_kind: z.enum(['a', 'c']).describe('List mode: asteroids (a) or comets (c) only.').optional(), + sb_group: z.enum(['neo', 'pha']).describe('List mode: NEOs or PHAs only.').optional(), + sb_class: z + .string() + .regex(/^[A-Z]{3}(,[A-Z]{3})*$/, 'must be three-letter orbit class codes such as APO or TJN,CEN') + .describe('List mode: orbit class code(s), e.g. APO or MBA,OMB (case-sensitive).') + .optional(), + sb_ns: z.enum(['n', 'u']).describe('List mode: numbered (n) or unnumbered (u) objects only.').optional(), + sb_sat: z.boolean().describe('List mode: only objects with at least one known satellite.').optional(), + sb_xfrag: z.boolean().describe('List mode: exclude comet fragments.').optional() + }) + .superRefine((args, ctx) => { + const selectors = [args.des, args.spk, args.sstr].filter((value) => value !== undefined).length; + if (selectors > 1) ctx.addIssue({ code: 'custom', message: 'provide only one of des, spk or sstr' }); + const listOnly = ['crit', 'year', 'sb_kind', 'sb_group', 'sb_class', 'sb_ns', 'sb_sat', 'sb_xfrag'] as const; + if (selectors === 1) { + const used = listOnly.filter((key) => args[key] !== undefined); + if (used.length) ctx.addIssue({ code: 'custom', message: `${used.join(', ')} apply only to list mode (without des, spk or sstr)` }); + } else if (args.class !== undefined) { + ctx.addIssue({ code: 'custom', path: ['class'], message: 'class applies only to object mode (with des, spk or sstr)' }); + } + }); + +export const missionDesignTool = defineTool({ + name: 'jpl_mission_design', + title: 'JPL small-body mission design', + description: + 'Ballistic mission options to asteroids and comets from the JPL Small-Body Mission Design API. Object mode (des, spk or sstr) returns the pre-computed ' + + 'mission options stored for one object (launch and arrival dates, V-infinity, time of flight). List mode returns the most accessible small bodies for ' + + 'the given launch years, ranked by crit, with optional SBDB filters. Porkchop-plot maps (mode M) and mission-extension searches (mode T) are not exposed.', + inputSchema: missionDesignInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, provided, ctx }) { + const objectMode = args.des !== undefined || args.spk !== undefined || args.sstr !== undefined; + if (objectMode && provided.has('lim')) throw new ToolInputError('lim applies only to list mode (without des, spk or sstr)'); + if (!objectMode && args.year) { + const current = ctx.now().getUTCFullYear(); + const outside = args.year.filter((year) => year < current || year > current + MAX_YEARS_AHEAD); + if (outside.length) throw new ToolInputError(`year must be between ${current} and ${current + MAX_YEARS_AHEAD}; got ${outside.join(', ')}`); + } + const params = objectMode + ? { des: args.des, spk: args.spk, sstr: args.sstr, class: args.class } + : { + lim: args.lim, + crit: args.crit ?? 1, + year: args.year?.join(','), + 'sb-kind': args.sb_kind, + 'sb-group': args.sb_group, + 'sb-class': args.sb_class, + 'sb-ns': args.sb_ns, + 'sb-sat': args.sb_sat, + 'sb-xfrag': args.sb_xfrag + }; + const { response, source } = await jplGet(ctx, SERVICE, '/mdesign.api', params); + const data = response.json<{ message?: string; warning?: string; count?: number | string; object?: { fullname?: string }; selectedMissions?: unknown[] }>(); + const label = args.des ?? args.sstr ?? (args.spk !== undefined ? String(args.spk) : undefined); + if (typeof data.message === 'string' && !data.object && data.count === undefined) { + return { content: [text(`Mission Design: ${data.message}${label ? ` ("${label}")` : ''}.`)] }; + } + const summary = objectMode + ? `Mission options to ${data.object?.fullname ?? label}: ${Array.isArray(data.selectedMissions) ? data.selectedMissions.length : 0} pre-computed ballistic missions.` + : Number(data.count ?? 0) === 0 + ? `No accessible small bodies matched${data.warning ? ` (${data.warning})` : ''}.` + : `${data.count} accessible small bodies, best first by criterion ${args.crit ?? 1}.`; + return jsonResult(SERVICE, summary, data, source, objectMode ? `Mission design ${label}` : 'Mission design accessible bodies', 'Use a smaller lim.'); + } +}); diff --git a/src/handlers/nasa/apod.ts b/src/handlers/nasa/apod.ts index e163fb8..37014df 100644 --- a/src/handlers/nasa/apod.ts +++ b/src/handlers/nasa/apod.ts @@ -1,18 +1,39 @@ import { z } from 'zod'; import { defineTool, READ_ONLY_REMOTE, type ToolContext, type ToolContent } from '../../tools/types'; -import { daysBetween, isoDate } from '../../util/validation'; -import { boundedText, fetchImage, nasaApiGet, sourceInfo, text, json } from '../common'; +import { ToolInputError, UpstreamError } from '../../util/errors'; +import { decodeEntities, htmlToText } from '../../util/html'; +import { httpRequest, type HttpResponse } from '../../util/http'; +import { daysBetween, isIsoDate, todayUtc } from '../../util/validation'; +import { boundedText, buildUrl, fetchImage, json, sourceInfo, text } from '../common'; const SERVICE = 'NASA APOD API'; +/** + * APOD moved from api.nasa.gov/planetary/apod (offline from 2026-12-01) to the + * NASA Science WordPress API, which needs no key. + */ +export const APOD_API_BASE_URL = 'https://science.nasa.gov/wp-json/wp/v2/apod-basic'; +const FIRST_APOD_DATE = '1995-06-16'; const MAX_RANGE_DAYS = 100; +/** The WordPress API caps per_page at 25. */ +const PAGE_SIZE = 25; +const MAX_PAGES = Math.ceil((MAX_RANGE_DAYS + 1) / PAGE_SIZE); +/** Width of the rendition embedded as image content. */ +const EMBED_WIDTH = 1024; + +const apodDate = (description: string) => + z + .string() + .superRefine((value, ctx) => { + if (!isIsoDate(value)) ctx.addIssue({ code: 'custom', message: 'must be a valid calendar date in YYYY-MM-DD format' }); + else if (value < FIRST_APOD_DATE) ctx.addIssue({ code: 'custom', message: `APOD starts on ${FIRST_APOD_DATE}` }); + }) + .describe(description); export const apodInputSchema = z .strictObject({ - date: isoDate('Date of the picture (YYYY-MM-DD). Defaults to today. Cannot be combined with count or start_date/end_date.').optional(), - start_date: isoDate('Start of a date range (YYYY-MM-DD).').optional(), - end_date: isoDate('End of a date range (YYYY-MM-DD); requires start_date. Defaults to today when start_date is given.').optional(), - count: z.int().min(1).max(100).describe('Return this many random pictures (1-100). Cannot be combined with dates.').optional(), - thumbs: z.boolean().describe('Include thumbnail URLs for video entries.').optional(), + date: apodDate(`Date of the picture (YYYY-MM-DD, ${FIRST_APOD_DATE} or later). Defaults to the latest published picture. Cannot be combined with start_date/end_date.`).optional(), + start_date: apodDate('Start of a date range (YYYY-MM-DD).').optional(), + end_date: apodDate('End of a date range (YYYY-MM-DD); requires start_date. Defaults to today (UTC) when start_date is given.').optional(), max_images: z .int() .min(0) @@ -21,9 +42,6 @@ export const apodInputSchema = z .describe('How many pictures to embed as image content (0-5). Remaining entries are listed by URL.') }) .superRefine((args, ctx) => { - if (args.count !== undefined && (args.date || args.start_date || args.end_date)) { - ctx.addIssue({ code: 'custom', message: 'count cannot be combined with date, start_date or end_date' }); - } if (args.date && (args.start_date || args.end_date)) { ctx.addIssue({ code: 'custom', message: 'date cannot be combined with start_date/end_date' }); } @@ -41,71 +59,204 @@ export const apodInputSchema = z export type ApodArgs = z.output; -interface ApodEntry { +/** One post from /wp/v2/apod-basic. explanation, credit and copyright are HTML. */ +interface ApodPost { date?: string; + post_id?: number; title?: string; - explanation?: string; + permalink?: string; media_type?: string; + explanation?: string; + credit?: string; + copyright?: string; + alt?: string; url?: string; hdurl?: string; - thumbnail_url?: string; - copyright?: string; + basic_html?: string; + basic_html_url?: string; } -export async function fetchApod(ctx: ToolContext, args: Omit) { - const response = await nasaApiGet(ctx, SERVICE, '/planetary/apod', { - date: args.date, - start_date: args.start_date, - end_date: args.end_date, - count: args.count, - thumbs: args.thumbs - }); - const data = response.json(); - return { data, source: sourceInfo(ctx, SERVICE, response.url) }; +/** An upstream post without its full HTML page, plus the media URLs taken from that page. */ +export interface ApodEntry extends Omit { + /** The picture shown on the APOD page (images only). */ + image_url?: string; + /** The video file or player URL shown on the APOD page (videos only). */ + video_url?: string; +} + +const IMG_SRC = /]*?\bsrc\s*=\s*["']([^"']+)["']/i; +const VIDEO_SOURCE_SRC = /]*?\bsrc\s*=\s*["']([^"']+)["']/i; +const VIDEO_SRC = /]*?\bsrc\s*=\s*["']([^"']+)["']/i; +const IFRAME_SRC = /]*?\bsrc\s*=\s*["']([^"']+)["']/i; +const CREDIT_LABEL = /^(?:(?:image|video|illustration|animation|photo|photograph|text|data)\s+)?(?:credits?|copyright)(?:\s*(?:&|and)\s*(?:credits?|copyright))?\s*:\s*/i; + +/** YYYY-MM-DD to the YYMMDD form the WordPress routes use (1995-2094). */ +function yymmdd(date: string): string { + return `${date.slice(2, 4)}${date.slice(5, 7)}${date.slice(8, 10)}`; +} + +function attribute(html: string, pattern: RegExp): string | undefined { + const raw = pattern.exec(html)?.[1]?.trim(); + if (!raw) return undefined; + const value = decodeEntities(raw); + return value.startsWith('//') ? `https:${value}` : value; +} + +/** Real APOD media live under an /apod/ path; older posts carry a generic placeholder hdurl. */ +function isApodAsset(value: string | undefined): value is string { + if (!value) return false; + try { + return /\/apod\//i.test(new URL(value).pathname); + } catch { + return false; + } +} + +function normalize(post: ApodPost): ApodEntry { + const { basic_html: html = '', ...entry } = post; + const body = html.slice(Math.max(0, html.search(/]/i))); + const result: ApodEntry = { ...entry }; + if (post.media_type === 'video') { + result.video_url = attribute(body, VIDEO_SOURCE_SRC) ?? attribute(body, VIDEO_SRC) ?? attribute(body, IFRAME_SRC); + } else { + result.image_url = attribute(body, IMG_SRC) ?? (isApodAsset(post.hdurl) ? post.hdurl : undefined); + } + return result; +} + +async function apodRequest(ctx: ToolContext, url: string | URL): Promise { + return httpRequest(ctx.fetch, { service: SERVICE, url }); +} + +export async function fetchApod(ctx: ToolContext, args: Pick) { + const today = todayUtc(ctx.now()); + for (const [name, value] of [['date', args.date], ['start_date', args.start_date], ['end_date', args.end_date]] as const) { + if (value && value > today) throw new ToolInputError(`${name} ${value} is in the future (today is ${today} UTC)`); + } + + if (args.date) { + let response: HttpResponse; + try { + response = await apodRequest(ctx, `${APOD_API_BASE_URL}/${yymmdd(args.date)}`); + } catch (error) { + if (error instanceof UpstreamError && error.status === 404) { + throw new UpstreamError(SERVICE, 'http', `${SERVICE} has no picture for ${args.date} (HTTP 404: APOD not found).`, 404); + } + throw error; + } + return { data: [normalize(response.json())], source: sourceInfo(ctx, SERVICE, response.url) }; + } + + if (args.start_date) { + const end = args.end_date ?? today; + if (daysBetween(args.start_date, end) > MAX_RANGE_DAYS) { + throw new ToolInputError(`date ranges are limited to ${MAX_RANGE_DAYS} days; set end_date`); + } + const posts: ApodPost[] = []; + let firstUrl: string | undefined; + for (let page = 1; page <= MAX_PAGES; page++) { + const url = buildUrl(APOD_API_BASE_URL, { date_from: yymmdd(args.start_date), date_to: yymmdd(end), per_page: PAGE_SIZE, page }); + const response = await apodRequest(ctx, url); + firstUrl ??= response.url; + const batch = response.json(); + if (!Array.isArray(batch)) { + throw new UpstreamError(SERVICE, 'invalid_response', `${SERVICE} returned an unexpected response for a date range.`, response.status); + } + posts.push(...batch); + const header = response.headers.get('x-wp-totalpages'); + if (batch.length < PAGE_SIZE || (header !== null && page >= Number(header))) break; + } + posts.sort((a, b) => String(a.date).localeCompare(String(b.date))); + return { data: posts.map(normalize), source: sourceInfo(ctx, SERVICE, firstUrl!) }; + } + + const response = await apodRequest(ctx, buildUrl(APOD_API_BASE_URL, { per_page: 1 })); + const latest = response.json(); + if (!Array.isArray(latest)) { + throw new UpstreamError(SERVICE, 'invalid_response', `${SERVICE} returned an unexpected response for the latest picture.`, response.status); + } + return { data: latest.map(normalize), source: sourceInfo(ctx, SERVICE, response.url) }; +} + +function creditText(html: string | undefined): string | undefined { + if (!html) return undefined; + return htmlToText(html).replace(/\s+/g, ' ').replace(/ ([,;.])/g, '$1').trim().replace(CREDIT_LABEL, '').trim() || undefined; +} + +function describe(entry: ApodEntry): string { + const lines = [`## ${htmlToText(entry.title ?? '') || 'Untitled'} (${entry.date ?? 'unknown date'})`]; + const credit = creditText(entry.credit); + const copyright = creditText(entry.copyright); + if (credit) lines.push(`Credit: ${credit}`); + if (copyright && copyright !== credit) lines.push(`Copyright: ${copyright}`); + lines.push(`Media type: ${entry.media_type ?? 'unknown'}`); + if (entry.image_url) lines.push(`Image: ${entry.image_url}`); + if (entry.video_url) lines.push(`Video: ${entry.video_url}`); + if (entry.media_type === 'video' && isApodAsset(entry.hdurl)) lines.push(`Still frame: ${entry.hdurl}`); + if (entry.permalink) lines.push(`Page: ${entry.permalink}`); + if (entry.alt) lines.push(`Alt text: ${htmlToText(entry.alt)}`); + const explanation = entry.explanation ? htmlToText(entry.explanation).replace(/^explanation:\s*/i, '') : ''; + if (explanation) lines.push('', explanation); + return lines.join('\n'); +} + +/** A 1024-pixel-wide rendition from the NASA Science image service, when the host offers one. */ +function embedUrl(imageUrl: string): string { + let url: URL; + try { + url = new URL(imageUrl); + } catch { + return imageUrl; + } + if (url.hostname !== 'assets.science.nasa.gov') return imageUrl; + const path = url.pathname.startsWith('/content/dam/') ? `/dynamicimage/assets/${url.pathname.slice('/content/dam/'.length)}` : url.pathname; + return path.startsWith('/dynamicimage/') ? `https://assets.science.nasa.gov${path}?w=${EMBED_WIDTH}` : imageUrl; } export const apodTool = defineTool({ name: 'nasa_apod', title: 'NASA Astronomy Picture of the Day', description: - "Fetch NASA's Astronomy Picture of the Day (APOD): a single date, a date range (up to 100 days), or random pictures. Requires NASA_API_KEY.", + "NASA's Astronomy Picture of the Day (APOD) from the NASA Science APOD API (science.nasa.gov): the latest picture, one date (1995-06-16 onward), " + + 'or a date range of up to 100 days. Returns the title, credit, explanation as plain text, image or video URL and page link, and embeds up to max_images pictures. ' + + 'No API key needed.', inputSchema: apodInputSchema, + retiredParameters: { + count: 'The APOD API moved from api.nasa.gov to science.nasa.gov, which has no random mode. Request a date or a start_date/end_date range instead.', + thumbs: 'The new APOD API has no thumbs option. Video entries always include the video URL and a still-frame image URL.' + }, annotations: READ_ONLY_REMOTE, async handler({ args, ctx }) { const { data, source } = await fetchApod(ctx, args); - const entries = Array.isArray(data) ? data : [data]; - const summary = entries - .map((apod) => { - const lines = [`## ${apod.title ?? 'Untitled'} (${apod.date ?? 'unknown date'})`]; - if (apod.copyright) lines.push(`Copyright: ${apod.copyright.trim()}`); - lines.push(`Media type: ${apod.media_type ?? 'unknown'}`); - if (apod.url) lines.push(`URL: ${apod.url}`); - if (apod.hdurl) lines.push(`HD URL: ${apod.hdurl}`); - if (apod.thumbnail_url) lines.push(`Thumbnail: ${apod.thumbnail_url}`); - if (apod.explanation) lines.push('', apod.explanation); - return lines.join('\n'); - }) - .join('\n\n'); - - const content: ToolContent[] = [boundedText(`${entries.length} APOD entr${entries.length === 1 ? 'y' : 'ies'}\n\n${summary}`, SERVICE, 'Request fewer days.')]; + const range = args.start_date ? ` from ${args.start_date} to ${args.end_date ?? todayUtc(ctx.now())}` : ''; + const heading = + data.length === 0 + ? `No APOD entries${range}.` + : `${data.length} APOD entr${data.length === 1 ? 'y' : 'ies'}${range}\n\n${data.map(describe).join('\n\n')}`; + const content: ToolContent[] = [boundedText(heading, SERVICE, 'Request fewer days.')]; + const notes: string[] = []; let embedded = 0; - for (const apod of entries) { + for (const entry of data) { if (embedded >= args.max_images) break; - if (apod.media_type !== 'image' || !apod.url) continue; - const result = await fetchImage(ctx, SERVICE, apod.url); + if (entry.media_type !== 'image' || !entry.image_url) continue; + const preferred = embedUrl(entry.image_url); + let result = await fetchImage(ctx, SERVICE, preferred); + if ('error' in result && preferred !== entry.image_url) result = await fetchImage(ctx, SERVICE, entry.image_url); if ('image' in result) { content.push(result.image); embedded++; } else { - notes.push(`${apod.date ?? apod.url}: image ${result.error}`); + notes.push(`${entry.date ?? entry.image_url}: image ${result.error}`); } } if (notes.length) content.push(text(notes.join('\n'))); + const dates = data.map((entry) => entry.date).filter(Boolean); + const name = dates.length > 1 ? `APOD ${dates[0]} to ${dates.at(-1)}` : `APOD ${dates[0] ?? 'latest'}`; return { content, - resource: { name: `APOD ${entries.map((e) => e.date).filter(Boolean).join(', ')}`, mimeType: 'application/json', text: json({ source, data }), source } + resource: { name, mimeType: 'application/json', text: json({ source, data }), source } }; } }); diff --git a/src/handlers/nasa/insight.ts b/src/handlers/nasa/insight.ts new file mode 100644 index 0000000..ffd819c --- /dev/null +++ b/src/handlers/nasa/insight.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { boundedText, json, nasaApiGet, sourceInfo, text } from '../common'; + +const SERVICE = 'NASA InSight Mars Weather API'; + +export const insightInputSchema = z.strictObject({}); + +interface SensorSummary { + av?: number; + mn?: number; + mx?: number; + ct?: number; +} + +interface SolWeather { + AT?: SensorSummary; + HWS?: SensorSummary; + PRE?: SensorSummary; + WD?: { most_common?: { compass_point?: string; compass_degrees?: number } | null }; + First_UTC?: string; + Last_UTC?: string; + Season?: string; + Northern_season?: string; + Southern_season?: string; +} + +function summarize(sensor: SensorSummary | undefined, unit: string, digits: number): string { + if (!sensor || typeof sensor.av !== 'number') return 'no valid data'; + const fmt = (value: number | undefined) => (typeof value === 'number' ? value.toFixed(digits) : '?'); + return `avg ${fmt(sensor.av)} ${unit} (min ${fmt(sensor.mn)}, max ${fmt(sensor.mx)})`; +} + +function describeSol(sol: string, weather: SolWeather): string { + const period = [weather.First_UTC?.slice(0, 10), weather.Last_UTC?.slice(0, 10)].filter(Boolean).join(' to '); + const seasons = [weather.Northern_season && `northern ${weather.Northern_season}`, weather.Southern_season && `southern ${weather.Southern_season}`].filter(Boolean).join(', '); + const wind = weather.WD?.most_common; + return [ + `## Sol ${sol}${period ? ` (${period} UTC)` : ''}`, + ...(seasons ? [`Season: ${seasons}`] : []), + `Air temperature: ${summarize(weather.AT, '°C', 1)}`, + `Pressure: ${summarize(weather.PRE, 'Pa', 1)}`, + `Horizontal wind speed: ${summarize(weather.HWS, 'm/s', 1)}`, + `Most common wind direction: ${wind?.compass_point ? `from ${wind.compass_point} (${wind.compass_degrees}°)` : 'no valid data'}` + ].join('\n'); +} + +export const insightTool = defineTool({ + name: 'nasa_insight_weather', + title: 'NASA InSight Mars weather (historical)', + description: + "HISTORICAL DATA ONLY: per-sol Mars surface weather (air temperature, pressure, wind) from NASA's InSight lander at Elysium Planitia. " + + 'The feed is frozen at the last seven sols it reported (sols 675-681, October 2020) and is no longer updated. Requires NASA_API_KEY.', + inputSchema: insightInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ ctx }) { + const response = await nasaApiGet(ctx, SERVICE, '/insight_weather/', { feedtype: 'json', ver: '1.0' }); + const data = response.json & { sol_keys?: string[] }>(); + const source = sourceInfo(ctx, SERVICE, response.url); + const sols = Array.isArray(data.sol_keys) ? data.sol_keys.filter((sol) => data[sol] && typeof data[sol] === 'object') : []; + const last = sols.at(-1); + const lastDate = last ? (data[last] as SolWeather).Last_UTC?.slice(0, 10) : undefined; + const notice = + 'HISTORICAL DATA: the InSight weather feed is no longer updated (the api.nasa.gov catalog lists it as last updated 2021-03-30). ' + + (last ? `Its latest sol is ${last}${lastDate ? `, ending ${lastDate}` : ''}.` : 'It returned no sols.') + + ' Temperatures are °C, pressure Pa, wind speed m/s.'; + const body = sols.map((sol) => describeSol(sol, data[sol] as SolWeather)).join('\n\n'); + return { + content: [text(notice), ...(body ? [boundedText(body, SERVICE, '')] : [])], + resource: { name: 'InSight Mars weather (historical)', mimeType: 'application/json', text: json({ source, data }), source } + }; + } +}); diff --git a/src/handlers/nasa/ssc.ts b/src/handlers/nasa/ssc.ts new file mode 100644 index 0000000..5e3c5d0 --- /dev/null +++ b/src/handlers/nasa/ssc.ts @@ -0,0 +1,231 @@ +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 { boundedText, buildUrl, json, sourceInfo, text, type QueryValue } from '../common'; + +const SERVICE = 'NASA Satellite Situation Center'; +const SSC_API_BASE_URL = 'https://sscweb.gsfc.nasa.gov/WS/sscr/2'; +const COORDINATE_SYSTEMS = ['geo', 'gm', 'gse', 'gsm', 'sm', 'geitod', 'geij2000'] as const; +const MAX_SPAN_DAYS = 366; +/** SSC's finest observatory resolution, used to bound the number of returned points. */ +const FINEST_RESOLUTION_SECONDS = 60; +/** Upper bound on numbers returned by one locations call (keeps text output bounded). */ +const MAX_VALUES = 8000; + +/** + * SSC answers with Jackson "typed" JSON, where arrays and objects are wrapped + * as ["java.util.ArrayList", [...]] or ["gov.nasa...ClassName", {...}]. + */ +const JAVA_TYPE = /^(?:java|javax|gov)\.[\w.$]+$/; +export function unwrapTypedJson(value: unknown): unknown { + if (Array.isArray(value)) { + if (value.length === 2 && typeof value[0] === 'string' && JAVA_TYPE.test(value[0])) return unwrapTypedJson(value[1]); + return value.map(unwrapTypedJson); + } + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unwrapTypedJson(item)])); + } + return value; +} + +async function sscGet(ctx: ToolContext, path: string, params: Record = {}) { + const response = await httpRequest(ctx.fetch, { + service: SERVICE, + url: buildUrl(`${SSC_API_BASE_URL}${path}`, params), + headers: { accept: 'application/json' } + }); + return { data: unwrapTypedJson(response.json()), source: sourceInfo(ctx, SERVICE, response.url) }; +} + +const UTC_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?Z)?$/; + +/** Parses YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS]Z as UTC; null for anything else. */ +export function parseUtcTime(value: string): Date | null { + const match = UTC_TIME.exec(value); + if (!match) return null; + const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0)); + const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + const roundTrips = + date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day && + date.getUTCHours() === hour && date.getUTCMinutes() === minute && date.getUTCSeconds() === second; + return roundTrips ? date : null; +} + +const utcTime = (description: string) => + z.string().refine((value) => parseUtcTime(value) !== null, { message: 'must be a UTC time like 2026-09-29T12:00:00Z, or a date like 2026-09-29 (meaning 00:00 UTC)' }).describe(description); + +/** 2026-09-29T00:00:00.000Z -> 20260929T000000Z (the form SSC's REST paths use). */ +const sscTime = (date: Date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, ''); +const isoTime = (value: unknown) => (typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? new Date(value).toISOString().replace('.000Z', 'Z') : String(value)); + +interface Observatory { + Id?: string; + Name?: string; + Resolution?: number; + StartTime?: string; + EndTime?: string; +} + +export const sscObservatoriesInputSchema = z.strictObject({ + search: z.string().trim().min(1).max(100).describe('Case-insensitive text to match in the observatory ID or name, e.g. "mms" or "GOES".').optional(), + active_on: isoDate('Only observatories with location data on this date (YYYY-MM-DD).').optional() +}); + +export const sscObservatoriesTool = defineTool({ + name: 'nasa_ssc_observatories', + title: 'Satellite Situation Center spacecraft list', + description: + 'Spacecraft and other observatories whose locations the NASA Satellite Situation Center (SSCWeb) can compute, with their data time ranges and resolution. ' + + 'Use the IDs with nasa_ssc_locations. No API key needed.', + inputSchema: sscObservatoriesInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const { data, source } = await sscGet(ctx, '/observatories'); + const all = ((data as { Observatory?: Observatory[] }).Observatory ?? []).filter((o) => typeof o.Id === 'string'); + const needle = args.search?.toLowerCase(); + const day = args.active_on; + const matches = all.filter((o) => { + if (needle && !`${o.Id} ${o.Name ?? ''}`.toLowerCase().includes(needle)) return false; + if (day && !((o.StartTime ?? '').slice(0, 10) <= day && day <= (o.EndTime ?? '').slice(0, 10))) return false; + return true; + }); + const filters = [needle && `matching "${args.search}"`, day && `with data on ${day}`].filter(Boolean).join(' '); + const lines = matches.map( + (o) => `- ${o.Id}: ${o.Name ?? o.Id} (${(o.StartTime ?? '?').slice(0, 10)} to ${(o.EndTime ?? '?').slice(0, 10)}, ${o.Resolution ?? '?'} s resolution)` + ); + const summary = `${matches.length} of ${all.length} SSC observatories${filters ? ` ${filters}` : ''}.`; + return { + content: [boundedText([summary, ...lines].join('\n'), SERVICE, 'Add search or active_on.')], + resource: { name: `SSC observatories${filters ? ` ${filters}` : ''}`, mimeType: 'application/json', text: json({ source, data: matches }), source } + }; + } +}); + +/** 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)) + .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)) + .default(['geo']) + .describe('Coordinate systems (default geo): geo, gm, gse, gsm, sm, geitod, geij2000. A comma-separated string also works.'), + resolution_factor: z + .int() + .min(1) + .max(100_000) + .describe('Return every Nth point of each observatory\'s base resolution (60-720 s). Chosen automatically when omitted to keep the output bounded.') + .optional() + }) + .superRefine((args, ctx) => { + const start = parseUtcTime(args.start_time); + const end = parseUtcTime(args.end_time); + if (!start || !end) return; + if (end <= start) ctx.addIssue({ code: 'custom', path: ['end_time'], message: 'end_time must be after start_time' }); + if (end.getTime() - start.getTime() > MAX_SPAN_DAYS * 86_400_000) { + ctx.addIssue({ code: 'custom', path: ['end_time'], message: `the time range is limited to ${MAX_SPAN_DAYS} days` }); + } + }); + +interface CoordinateData { + CoordinateSystem?: string; + X?: number[]; + Y?: number[]; + Z?: number[]; + Latitude?: number[]; + Longitude?: number[]; + LocalTime?: number[]; +} + +interface SatelliteData { + Id?: string; + Time?: string[]; + Coordinates?: CoordinateData[]; + RadialLength?: number[]; +} + +interface DataResult { + StatusCode?: string; + StatusSubCode?: string; + StatusText?: string[]; + Data?: SatelliteData[]; +} + +const round = (value: number | undefined, digits: number) => (typeof value === 'number' ? String(Number(value.toFixed(digits))) : ''); + +function table(satellite: SatelliteData): string { + const times = satellite.Time ?? []; + const systems = satellite.Coordinates ?? []; + const header = ['time_utc']; + for (const system of systems) { + const name = system.CoordinateSystem ?? '?'; + header.push(`${name}_x_km`, `${name}_y_km`, `${name}_z_km`, `${name}_lat_deg`, `${name}_lon_deg`, `${name}_local_time_h`); + } + header.push('radial_km'); + const rows = times.map((time, i) => { + const cells = [isoTime(time)]; + for (const s of systems) { + cells.push(round(s.X?.[i], 1), round(s.Y?.[i], 1), round(s.Z?.[i], 1), round(s.Latitude?.[i], 3), round(s.Longitude?.[i], 3), round(s.LocalTime?.[i], 3)); + } + cells.push(round(satellite.RadialLength?.[i], 1)); + return cells.join(','); + }); + return [header.join(','), ...rows].join('\n'); +} + +export const sscLocationsTool = defineTool({ + name: 'nasa_ssc_locations', + title: 'Satellite Situation Center spacecraft locations', + description: + 'Positions of spacecraft (and the Moon and Sun) over a time range from the NASA Satellite Situation Center (SSCWeb), in geophysical coordinate systems ' + + '(GEO, GM, GSE, GSM, SM, GEI). Returns a CSV table per observatory: X/Y/Z in km, latitude/longitude in degrees, local time in hours and radial distance in km. ' + + 'The resolution factor is raised automatically to keep the output bounded. No API key needed.', + inputSchema: sscLocationsInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const start = parseUtcTime(args.start_time)!; + const end = parseUtcTime(args.end_time)!; + const ids = [...new Set(args.observatories)]; + const systems = [...new Set(args.coordinate_systems)]; + const perObservatory = Math.max(1, Math.floor(MAX_VALUES / (ids.length * (6 * systems.length + 2)))); + const basePoints = Math.floor((end.getTime() - start.getTime()) / 1000 / FINEST_RESOLUTION_SECONDS) + 1; + const needed = Math.max(1, Math.ceil(basePoints / perObservatory)); + if (args.resolution_factor !== undefined && args.resolution_factor < needed) { + throw new ToolInputError( + `resolution_factor ${args.resolution_factor} could return about ${Math.ceil(basePoints / args.resolution_factor)} points per observatory; ` + + `use ${needed} or more, or a shorter time range` + ); + } + const factor = args.resolution_factor ?? needed; + const path = `/locations/${ids.join(',')}/${sscTime(start)},${sscTime(end)}/${systems.join(',')}/`; + const { data, source } = await sscGet(ctx, path, { resolutionFactor: factor > 1 ? factor : undefined }); + const result = (data as { Result?: DataResult }).Result ?? {}; + if (result.StatusCode !== 'SUCCESS') { + const detail = result.StatusText?.join('; ') || result.StatusSubCode || 'no status'; + throw new UpstreamError(SERVICE, 'http', `${SERVICE} rejected the request: ${detail}`); + } + const satellites = result.Data ?? []; + const notes = result.StatusSubCode && result.StatusSubCode !== 'SUCCESS' ? [`SSC status: ${result.StatusSubCode}${result.StatusText?.length ? ` (${result.StatusText.join('; ')})` : ''}`] : []; + const range = `${isoTime(start.toISOString())} to ${isoTime(end.toISOString())}`; + const summary = + `SSC locations for ${ids.join(', ')} from ${range}` + + `${factor > 1 ? ` (resolution factor ${factor}: every ${factor}th point of each observatory's base resolution)` : ''}.` + + (satellites.length === 0 ? ' No location data was returned for this range.' : ''); + const sections = satellites.map((s) => `## ${s.Id ?? '?'}: ${s.Time?.length ?? 0} points\n${table(s)}`); + return { + content: [text([summary, ...notes].join('\n')), ...(sections.length ? [boundedText(sections.join('\n\n'), SERVICE, 'Use a shorter time range or fewer coordinate systems.')] : [])], + resource: { name: `SSC locations ${ids.join(',')} ${range}`, mimeType: 'application/json', text: json({ source, data: result }), source } + }; + } +}); diff --git a/src/handlers/nasa/techport.ts b/src/handlers/nasa/techport.ts new file mode 100644 index 0000000..3946b71 --- /dev/null +++ b/src/handlers/nasa/techport.ts @@ -0,0 +1,106 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { htmlToText } from '../../util/html'; +import { httpRequest } from '../../util/http'; +import { boundedText, buildUrl, json, sourceInfo } from '../common'; + +const SERVICE = 'NASA TechPort API'; +/** Project search and lookup need no token; only POST search and saved searches do. */ +const TECHPORT_API_BASE_URL = 'https://techport.nasa.gov/api'; +const TECHPORT_PROJECT_PAGE = 'https://techport.nasa.gov/projects'; +const SEARCH_EXCERPT_CHARS = 400; + +export const techportInputSchema = z + .strictObject({ + query: z.string().trim().min(1).max(200).describe('Search TechPort technology projects, e.g. "solar sail". Results are ordered by relevance.').optional(), + project_id: z.int().positive().describe('Fetch one project by its TechPort ID, e.g. 94703.').optional(), + limit: z.int().min(1).max(50).describe('Search mode: how many projects to return (1-50, default 10).').optional() + }) + .superRefine((args, ctx) => { + if ((args.query === undefined) === (args.project_id === undefined)) { + ctx.addIssue({ code: 'custom', message: 'provide exactly one of query or project_id' }); + } + if (args.project_id !== undefined && args.limit !== undefined) { + ctx.addIssue({ code: 'custom', path: ['limit'], message: 'limit applies only to query searches' }); + } + }); + +/* eslint-disable @typescript-eslint/no-explicit-any -- loosely typed upstream JSON */ +/** TechPort records carry contact emails; retained resources keep the project data without them. */ +function withoutContacts(project: any): any { + if (!project || typeof project !== 'object') return project; + const { projectContacts: _projectContacts, programContacts: _programContacts, ...rest } = project; + return rest; +} + +function dates(project: any): string { + const start = String(project.startDate ?? '').slice(0, 10); + const end = String(project.endDate ?? '').slice(0, 10); + return start || end ? `${start || '?'} to ${end || '?'}` : 'dates unknown'; +} + +function describe(project: any, excerptChars?: number): string { + const lines = [`## ${project.title ?? 'Untitled'} (project ${project.projectId})`]; + const facts = [project.status, dates(project)]; + if (project.trlBegin !== undefined || project.trlCurrent !== undefined) { + facts.push(`TRL ${project.trlBegin ?? '?'} start, ${project.trlCurrent ?? '?'} current, ${project.trlEnd ?? '?'} target`); + } + lines.push(facts.filter(Boolean).join(' · ')); + const program = project.program; + if (program?.title) lines.push(`Program: ${program.title}${program.acronym ? ` (${program.acronym})` : ''}`); + // Project lookups use camelCase keys; search results use snake_case. + const lead = project.leadOrganization?.organizationName ?? project.leadOrganization?.organization_name; + if (lead) lines.push(`Lead organization: ${lead}`); + const directorate = project.responsibleMd?.organization_name; + if (directorate) lines.push(`Responsible directorate: ${directorate}`); + if (project.primaryTx?.code) lines.push(`Technology area: ${project.primaryTx.code} ${project.primaryTx.title ?? ''}`.trim()); + const destinations = [project.destinationTypes ?? project.destinationType ?? []].flat().filter((d: unknown) => typeof d === 'string' && d.trim()); + if (destinations.length) lines.push(`Destinations: ${destinations.map((d: string) => d.replace(/_/g, ' ')).join(', ')}`); + lines.push(`Page: ${TECHPORT_PROJECT_PAGE}/${project.projectId}`); + for (const [label, html] of [['Description', project.description], ['Benefits', project.benefits]] as const) { + if (typeof html !== 'string' || !html.trim()) continue; + let body = htmlToText(html); + if (excerptChars !== undefined) { + if (label === 'Benefits') continue; + if (body.length > excerptChars) body = `${body.slice(0, excerptChars).trimEnd()}…`; + } + lines.push('', `${label}: ${body}`); + } + return lines.join('\n'); +} + +export const techportTool = defineTool({ + name: 'nasa_techport', + title: 'NASA TechPort technology projects', + description: + "NASA's technology project inventory (TechPort): search projects by keyword, or fetch one project with its status, dates, program, " + + 'technology readiness levels (TRL), lead organization, description and benefits. No API key needed.', + inputSchema: techportInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + if (args.project_id !== undefined) { + const response = await httpRequest(ctx.fetch, { service: SERVICE, url: `${TECHPORT_API_BASE_URL}/projects/${args.project_id}` }); + const data = response.json<{ project?: any }>(); + const source = sourceInfo(ctx, SERVICE, response.url); + const project = data.project ?? data; + return { + content: [boundedText(describe(project), SERVICE, '')], + resource: { name: `TechPort project ${args.project_id}`, mimeType: 'application/json', text: json({ source, data: withoutContacts(project) }), source } + }; + } + + const limit = args.limit ?? 10; + const url = buildUrl(`${TECHPORT_API_BASE_URL}/projects/search`, { query: args.query, limit }); + const response = await httpRequest(ctx.fetch, { service: SERVICE, url }); + const data = response.json<{ results?: any[]; total?: number }>(); + const source = sourceInfo(ctx, SERVICE, response.url); + const results = (Array.isArray(data.results) ? data.results : []).slice(0, limit); + const total = data.total ?? results.length; + const summary = total === 0 ? `No TechPort projects match "${args.query}".` : `${total} TechPort projects match "${args.query}"; showing the ${results.length} most relevant.`; + return { + content: [boundedText([summary, ...results.map((project) => describe(project, SEARCH_EXCERPT_CHARS))].join('\n\n'), SERVICE, 'Use a smaller limit.')], + resource: { name: `TechPort search "${args.query}"`, mimeType: 'application/json', text: json({ source, data: { total, results: results.map(withoutContacts) } }), source } + }; + } +}); +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/src/handlers/nasa/techtransfer.ts b/src/handlers/nasa/techtransfer.ts new file mode 100644 index 0000000..6790644 --- /dev/null +++ b/src/handlers/nasa/techtransfer.ts @@ -0,0 +1,101 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { htmlToText } from '../../util/html'; +import { httpRequest } from '../../util/http'; +import { boundedText, json, sourceInfo } from '../common'; + +const SERVICE = 'NASA Technology Transfer API'; +/** + * api.nasa.gov/techtransfer only serves a help page, which points at this + * host: /api/api/{collection}/{keywords}. No key is needed. + */ +const TECHTRANSFER_API_BASE_URL = 'https://technology.nasa.gov/api/api'; +const EXCERPT_CHARS = 400; + +const COLLECTIONS = { + patent: { label: 'patents', page: (id: string) => `https://technology.nasa.gov/patent/${encodeURIComponent(id)}` }, + patent_issued: { label: 'issued patents', page: (id: string) => `https://technology.nasa.gov/patent/${encodeURIComponent(id)}` }, + software: { label: 'software', page: (id: string) => `https://software.nasa.gov/software/${encodeURIComponent(id)}` }, + spinoff: { label: 'spinoffs', page: undefined } +} as const; +type Collection = keyof typeof COLLECTIONS; + +export const techTransferInputSchema = z.strictObject({ + query: z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[^/\\?#%]+$/, 'must not contain / \\ ? # or %') + .describe('Keywords to search for, e.g. "engine" or "solar panel".'), + collection: z + .enum(Object.keys(COLLECTIONS) as [Collection, ...Collection[]]) + .default('patent') + .describe('What to search: patent (licensable patents), patent_issued, software (NASA Software Catalog) or spinoff (Spinoff stories).'), + limit: z.int().min(1).max(50).default(10).describe('How many results to return (1-50). The service returns every match; the rest are counted but not shown.') +}); + +/** + * Results are positional arrays: [0] record id, [1] case number, [2] title, + * [3] abstract, [4] case number, [5] category, [6] release type (software), + * [7] notes, [8] link (software), [9] NASA center, [10] image, [12] score. + */ +type Row = unknown[]; +const field = (row: Row, index: number) => (typeof row[index] === 'string' ? htmlToText(row[index] as string) : ''); + +/** An http(s) link from a result field, percent-encoded (image paths can contain spaces). */ +function link(value: string): string | undefined { + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function describe(row: Row, collection: Collection): string { + const caseNumber = field(row, 1) || field(row, 4); + const lines = [`## ${field(row, 2) || 'Untitled'}${caseNumber ? ` (${caseNumber})` : ''}`]; + const facts = [field(row, 5) && `Category: ${field(row, 5)}`, field(row, 9) && `Center: ${field(row, 9)}`, field(row, 6) && `Release: ${field(row, 6)}`]; + if (facts.some(Boolean)) lines.push(facts.filter(Boolean).join(' · ')); + const page = COLLECTIONS[collection].page; + if (page && caseNumber) lines.push(`Page: ${page(caseNumber)}`); + const more = link(field(row, 8)); + if (more) lines.push(`Link: ${more}`); + const image = link(field(row, 10)); + if (image) lines.push(`Image: ${image}`); + let abstract = field(row, 3); + if (abstract.length > EXCERPT_CHARS) abstract = `${abstract.slice(0, EXCERPT_CHARS).trimEnd()}…`; + if (abstract) lines.push('', abstract); + return lines.join('\n'); +} + +export const techTransferTool = defineTool({ + name: 'nasa_techtransfer', + title: 'NASA Technology Transfer search', + description: + "Search NASA's Technology Transfer portfolio (technology.nasa.gov): patents available for licensing, issued patents, the NASA Software Catalog, " + + 'or Spinoff stories about commercial products that use NASA technology. No API key needed.', + inputSchema: techTransferInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const url = `${TECHTRANSFER_API_BASE_URL}/${args.collection}/${encodeURIComponent(args.query)}`; + const response = await httpRequest(ctx.fetch, { service: SERVICE, url }); + const data = response.json<{ results?: Row[]; total?: number }>(); + const source = sourceInfo(ctx, SERVICE, response.url); + const rows = (Array.isArray(data.results) ? data.results : []).filter(Array.isArray); + const shown = rows.slice(0, args.limit); + const total = typeof data.total === 'number' ? data.total : rows.length; + const label = COLLECTIONS[args.collection].label; + const summary = total === 0 ? `No NASA ${label} match "${args.query}".` : `${total} NASA ${label} match "${args.query}"; showing ${shown.length}.`; + return { + content: [boundedText([summary, ...shown.map((row) => describe(row, args.collection))].join('\n\n'), SERVICE, 'Use a smaller limit.')], + resource: { + name: `Technology Transfer ${args.collection} "${args.query}"`, + mimeType: 'application/json', + text: json({ source, data: { total, results: shown } }), + source + } + }; + } +}); diff --git a/src/handlers/nasa/tle.ts b/src/handlers/nasa/tle.ts new file mode 100644 index 0000000..c11b504 --- /dev/null +++ b/src/handlers/nasa/tle.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { httpRequest } from '../../util/http'; +import { boundedText, buildUrl, json, sourceInfo } from '../common'; + +const SERVICE = 'TLE API'; +/** Listed in the api.nasa.gov catalog; data comes from CelesTrak and is refreshed daily. No key. */ +const TLE_API_BASE_URL = 'https://tle.ivanstanojevic.me/api/tle'; + +export const tleInputSchema = z + .strictObject({ + satellite_id: z.int().min(1).max(999_999_999).describe('NORAD catalog number, e.g. 25544 for the ISS. Cannot be combined with search options.').optional(), + search: z.string().trim().min(1).max(100).describe('Search satellite names, e.g. "ISS" or "HUBBLE". Omit to browse all records.').optional(), + sort: z.enum(['popularity', 'name', 'id', 'inclination', 'eccentricity', 'period']).describe('Sort field for searches (upstream default popularity).').optional(), + sort_dir: z.enum(['asc', 'desc']).describe('Sort direction (upstream default desc).').optional(), + page: z.int().min(1).max(100_000).describe('Result page (1-based).').optional(), + page_size: z.int().min(1).max(100).describe('Records per page (1-100, upstream default 20).').optional() + }) + .superRefine((args, ctx) => { + const searchOptions = [args.search, args.sort, args.sort_dir, args.page, args.page_size].some((value) => value !== undefined); + if (args.satellite_id !== undefined && searchOptions) { + ctx.addIssue({ code: 'custom', message: 'satellite_id cannot be combined with search, sort, sort_dir, page or page_size' }); + } + }); + +interface TleRecord { + satelliteId?: number; + name?: string; + date?: string; + line1?: string; + line2?: string; +} + +interface TleCollection { + totalItems?: number; + member?: TleRecord[]; + parameters?: { page?: number; 'page-size'?: number }; + view?: { next?: string }; +} + +function describe(record: TleRecord): string { + return `## ${record.name ?? 'Unnamed'} (NORAD ${record.satelliteId ?? '?'})\nEpoch: ${record.date ?? 'unknown'}\n${record.line1 ?? ''}\n${record.line2 ?? ''}`; +} + +export const tleTool = defineTool({ + name: 'nasa_tle', + title: 'Satellite two-line element sets', + description: + 'Two-line element sets (TLE) for Earth-orbiting satellites from the TLE API in the api.nasa.gov catalog (tle.ivanstanojevic.me; CelesTrak data refreshed daily): ' + + 'one satellite by NORAD catalog number, or a paged name search. No API key needed.', + inputSchema: tleInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + if (args.satellite_id !== undefined) { + const response = await httpRequest(ctx.fetch, { service: SERVICE, url: `${TLE_API_BASE_URL}/${args.satellite_id}` }); + const data = response.json(); + const source = sourceInfo(ctx, SERVICE, response.url); + return { + content: [boundedText(describe(data), SERVICE, '')], + resource: { name: `TLE ${args.satellite_id}`, mimeType: 'application/json', text: json({ source, data }), source } + }; + } + + // The collection lives at /api/tle/ (without the slash the API redirects). + const url = buildUrl(`${TLE_API_BASE_URL}/`, { + search: args.search, + sort: args.sort, + 'sort-dir': args.sort_dir, + page: args.page, + 'page-size': args.page_size + }); + const response = await httpRequest(ctx.fetch, { service: SERVICE, url }); + const data = response.json(); + const source = sourceInfo(ctx, SERVICE, response.url); + const members = Array.isArray(data.member) ? data.member : []; + const total = data.totalItems ?? members.length; + const page = data.parameters?.page ?? args.page ?? 1; + const pageSize = data.parameters?.['page-size'] ?? args.page_size ?? 20; + const pages = Math.max(1, Math.ceil(total / pageSize)); + const label = args.search ? ` matching "${args.search}"` : ''; + const summary = + total === 0 + ? `No TLE records${label}.` + : `${total} TLE records${label}; page ${page} of ${pages} (${members.length} shown)${data.view?.next ? `. Next: page ${page + 1}` : ''}.`; + return { + content: [boundedText([summary, ...members.map(describe)].join('\n\n'), SERVICE, 'Use a smaller page_size.')], + resource: { name: `TLE search${label || ' (all)'} page ${page}`, mimeType: 'application/json', text: json({ source, data }), source } + }; + } +}); diff --git a/src/handlers/nasa/trek.ts b/src/handlers/nasa/trek.ts new file mode 100644 index 0000000..38ed1f1 --- /dev/null +++ b/src/handlers/nasa/trek.ts @@ -0,0 +1,275 @@ +import { z } from 'zod'; +import { defineTool, READ_ONLY_REMOTE, type ToolContent } from '../../tools/types'; +import { ToolInputError, UpstreamError } from '../../util/errors'; +import { decodeEntities } from '../../util/html'; +import { httpRequest } from '../../util/http'; +import { boundedText, buildUrl, json, MAX_IMAGE_BYTES, sourceInfo, text } from '../common'; + +const SERVICE = 'NASA Solar System Treks'; +const TREK_BASE_URL = 'https://trek.nasa.gov'; + +type Projection = 'equirectangular' | 'north_pole' | 'south_pole'; + +/** Catalog projection identifiers, as they appear in each layer's trekProjection field. */ +const BODIES = { + mars: { + name: 'Mars', + crs: { equirectangular: 'urn:ogc:def:crs:EPSG::104905', north_pole: 'urn:ogc:def:crs:IAU2000::49918', south_pole: 'urn:ogc:def:crs:IAU2000::49920' } + }, + moon: { + name: 'Moon', + crs: { equirectangular: 'urn:ogc:def:crs:EPSG::104903', north_pole: 'urn:ogc:def:crs:IAU2000::30118', south_pole: 'urn:ogc:def:crs:IAU2000::30120' } + }, + vesta: { + name: 'Vesta', + crs: { equirectangular: 'urn:ogc:def:crs:IAU2000::2000004', north_pole: 'urn:ogc:def:crs:trek::vesta_np', south_pole: 'urn:ogc:def:crs:trek::vesta_sp' } + } +} as const; +type Body = keyof typeof BODIES; +const BODY_NAMES = Object.keys(BODIES) as [Body, ...Body[]]; +const PROJECTION_DIRS: Record = { equirectangular: 'EQ', north_pole: 'NP', south_pole: 'SP' }; + +const bodySchema = z.enum(BODY_NAMES).describe('Body: mars, moon or vesta.'); +const projectionSchema = z + .enum(['equirectangular', 'north_pole', 'south_pole']) + .default('equirectangular') + .describe('Map projection: equirectangular (global), north_pole or south_pole (polar stereographic).'); + +function layerEndpoint(body: Body, projection: Projection, layer: string): string { + return `${TREK_BASE_URL}/tiles/${BODIES[body].name}/${PROJECTION_DIRS[projection]}/${layer}`; +} + +export const trekLayersInputSchema = z.strictObject({ + body: bodySchema, + projection: projectionSchema, + search: z.string().trim().min(1).max(100).describe('Keywords to match, e.g. "olympus", "LRO WAC" or "HiRISE".').optional(), + limit: z.int().min(1).max(50).default(20).describe('How many layers to return (1-50).') +}); + +/* eslint-disable @typescript-eslint/no-explicit-any -- loosely typed upstream JSON */ +export const trekLayersTool = defineTool({ + name: 'nasa_trek_layers', + title: 'NASA Trek map layers (Mars, Moon, Vesta)', + description: + 'Search the map layer catalogs of NASA Mars Trek, Moon Trek and Vesta Trek (trek.nasa.gov): mosaics, colour hillshades and other imagery ' + + 'served as OGC WMTS tiles. Returns layer IDs for nasa_trek_tile, with mission, instrument and WMTS endpoint. No API key needed.', + inputSchema: trekLayersInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const body = BODIES[args.body]; + const index = args.projection === 'equirectangular' ? 'eq' : 'polar'; + // The same visible-layer filter the Trek API docs page uses, plus the projection. + const url = buildUrl(`${TREK_BASE_URL}/${args.body}/TrekServices/ws/index/${index}/searchItems`, { + start: 0, + rows: args.limit, + key: args.search, + facetKeys: 'itemType|productCat2|serviceTypes|thumbnailURLDir|trekProjection', + facetValues: `product|-(DEM ConfidenceRaw Slope)|Mosaic|[* TO *]|"${body.crs[args.projection]}"` + }); + const response = await httpRequest(ctx.fetch, { service: SERVICE, url }); + const data = response.json<{ response?: { numFound?: number; docs?: any[] } }>(); + const source = sourceInfo(ctx, SERVICE, response.url); + const docs = Array.isArray(data.response?.docs) ? data.response!.docs : []; + const total = data.response?.numFound ?? docs.length; + const where = `${body.name} Trek ${args.projection.replace('_', ' ')} layers${args.search ? ` matching "${args.search}"` : ''}`; + const layers = docs.map((doc) => { + const thumbnail = typeof doc.thumbnailURLDir === 'string' ? doc.thumbnailURLDir : ''; + const endpoint = thumbnail.includes('/thumbnail/') ? thumbnail.slice(0, thumbnail.indexOf('/thumbnail/')) : layerEndpoint(args.body, args.projection, doc.productLabel); + return { + layer: doc.productLabel as string, + title: doc.title as string, + mission: doc.mission, + instrument: doc.instrument, + category: [doc.productCat1, doc.productCat2].filter(Boolean).join(' / ') || undefined, + coverage: doc.coverage, + wmts_endpoint: endpoint, + capabilities: `${endpoint}/1.0.0/WMTSCapabilities.xml` + }; + }); + const lines = layers.map( + (l) => + `- ${l.layer}: ${l.title}` + + `${[l.mission, l.instrument].filter(Boolean).length ? ` (${[l.mission, l.instrument].filter(Boolean).join(', ')})` : ''}` + + `${l.category ? `, ${l.category}` : ''}${l.coverage ? `, ${String(l.coverage).toLowerCase()} coverage` : ''}\n WMTS: ${l.wmts_endpoint}` + ); + const summary = total === 0 ? `No ${where}.` : `${total} ${where}; showing ${layers.length}. Fetch tiles with nasa_trek_tile.`; + return { + content: [boundedText([summary, ...lines].join('\n'), SERVICE, 'Use a smaller limit.')], + resource: { name: where, mimeType: 'application/json', text: json({ source, data: { total, layers } }), source } + }; + } +}); +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export const trekTileInputSchema = z + .strictObject({ + body: bodySchema, + layer: z + .string() + .trim() + .regex(/^[A-Za-z0-9_.-]{1,200}$/, 'must be a Trek layer ID such as Mars_MGS_MOLA_ClrShade_merge_global_463m') + .describe('Layer ID from nasa_trek_layers, e.g. Mars_MGS_MOLA_ClrShade_merge_global_463m or LRO_WAC_Mosaic_Global_303ppd_v02.'), + projection: projectionSchema, + zoom: z.int().min(0).max(30).default(0).describe('Zoom level (WMTS TileMatrix). 0 is the whole layer; each level doubles the resolution.'), + row: z.int().min(0).max(2 ** 31).describe('Tile row (0 at the top). Provide row and col, or latitude and longitude.').optional(), + col: z.int().min(0).max(2 ** 31).describe('Tile column (0 at the left).').optional(), + latitude: z.number().min(-90).max(90).describe('Equirectangular only: pick the tile containing this latitude (degrees).').optional(), + longitude: z.number().min(-180).max(360).describe('Equirectangular only: pick the tile containing this longitude (degrees east, -180 to 360).').optional() + }) + .superRefine((args, ctx) => { + const byIndex = args.row !== undefined || args.col !== undefined; + const byPoint = args.latitude !== undefined || args.longitude !== undefined; + if (byIndex && byPoint) ctx.addIssue({ code: 'custom', message: 'use row/col or latitude/longitude, not both' }); + if (byPoint && (args.latitude === undefined || args.longitude === undefined)) { + ctx.addIssue({ code: 'custom', message: 'latitude and longitude must be given together' }); + } + if (byPoint && args.projection !== 'equirectangular') { + ctx.addIssue({ code: 'custom', path: ['projection'], message: 'latitude/longitude tile lookup needs the equirectangular projection' }); + } + }); + +interface TileMatrix { + id: string; + width: number; + height: number; + tileWidth: number; + tileHeight: number; + topLeft: [number, number]; +} + +interface Capabilities { + template: string; + format: string; + style: string; + matrixSet: string; + matrices: TileMatrix[]; +} + +const tag = (xml: string, name: string) => new RegExp(`<(?:ows:)?${name}>([^<]*)`).exec(xml)?.[1]?.trim(); + +/** Reads the parts of a Trek WMTSCapabilities document needed to address one tile. */ +export function parseTrekCapabilities(document: string): Capabilities | null { + // Some Trek capabilities keep retired TileMatrix entries inside XML comments. + const xml = document.replace(//g, ''); + const layer = /([\s\S]*?)<\/Layer>/.exec(xml)?.[1]; + if (!layer) return null; + const resource = /]*\btemplate="([^"]+)"[^>]*>/.exec(layer); + const format = /]*\bformat="([^"]+)"/.exec(layer)?.[1] ?? tag(layer, 'Format') ?? ''; + const style = tag(//.exec(layer)?.[0] ?? '', 'Identifier') ?? 'default'; + const matrixSet = tag(/[\s\S]*?<\/TileMatrixSetLink>/.exec(layer)?.[0] ?? '', 'TileMatrixSet'); + if (!resource || !matrixSet) return null; + const setBlock = [...xml.matchAll(/([\s\S]*?)<\/TileMatrixSet>/g)].map((m) => m[1]).find((block) => tag(block, 'Identifier') === matrixSet) ?? xml; + const matrices = [...setBlock.matchAll(/([\s\S]*?)<\/TileMatrix>/g)].map(({ 1: block }) => { + const [left, top] = (tag(block, 'TopLeftCorner') ?? '0 0').split(/\s+/).map(Number); + return { + id: tag(block, 'Identifier') ?? '', + width: Number(tag(block, 'MatrixWidth')), + height: Number(tag(block, 'MatrixHeight')), + tileWidth: Number(tag(block, 'TileWidth')), + tileHeight: Number(tag(block, 'TileHeight')), + topLeft: [left, top] as [number, number] + }; + }); + return { template: decodeEntities(resource[1]), format, style, matrixSet, matrices }; +} + +/** A global equirectangular matrix (2:1, anchored at -180,90) maps tiles to plain lon/lat boxes. */ +function isGlobalEquirectangular(matrix: TileMatrix): boolean { + return matrix.width === 2 * matrix.height && matrix.topLeft[0] === -180 && matrix.topLeft[1] === 90; +} + +export const trekTileTool = defineTool({ + name: 'nasa_trek_tile', + title: 'NASA Trek map tile (Mars, Moon, Vesta)', + description: + 'One WMTS map tile image from NASA Mars Trek, Moon Trek or Vesta Trek for a layer from nasa_trek_layers, addressed by zoom/row/col ' + + 'or (equirectangular layers) by latitude/longitude. Reads the layer\'s WMTS capabilities to validate the tile and pick the format. No API key needed.', + inputSchema: trekTileInputSchema, + annotations: READ_ONLY_REMOTE, + async handler({ args, ctx }) { + const body = BODIES[args.body]; + const endpoint = layerEndpoint(args.body, args.projection, args.layer); + const capabilitiesUrl = `${endpoint}/1.0.0/WMTSCapabilities.xml`; + let xml: string; + try { + xml = (await httpRequest(ctx.fetch, { service: SERVICE, url: capabilitiesUrl })).text(); + } catch (error) { + if (error instanceof UpstreamError && error.status === 404) { + throw new ToolInputError(`${body.name} Trek has no ${args.projection.replace('_', ' ')} layer "${args.layer}"; find layer IDs with nasa_trek_layers`); + } + throw error; + } + const caps = parseTrekCapabilities(xml); + if (!caps || caps.matrices.length === 0) { + throw new UpstreamError(SERVICE, 'invalid_response', `${SERVICE} returned WMTS capabilities for ${args.layer} that could not be read.`); + } + const matrix = caps.matrices.find((m) => m.id === String(args.zoom)); + if (!matrix) { + throw new ToolInputError(`zoom ${args.zoom} is not available for ${args.layer}; zoom levels are ${caps.matrices.map((m) => m.id).join(', ')}`); + } + let row = args.row ?? 0; + let col = args.col ?? 0; + if (args.latitude !== undefined && args.longitude !== undefined) { + if (!isGlobalEquirectangular(matrix)) throw new ToolInputError(`${args.layer} does not use a global lon/lat tile grid; give row and col instead`); + const lon = args.longitude > 180 ? args.longitude - 360 : args.longitude; + col = Math.min(matrix.width - 1, Math.floor(((lon + 180) / 360) * matrix.width)); + row = Math.min(matrix.height - 1, Math.floor(((90 - args.latitude) / 180) * matrix.height)); + } + if (row >= matrix.height || col >= matrix.width) { + throw new ToolInputError(`zoom ${args.zoom} has rows 0-${matrix.height - 1} and columns 0-${matrix.width - 1}`); + } + + const tileUrl = new URL( + caps.template + .replace('{Style}', caps.style) + .replace('{TileMatrixSet}', caps.matrixSet) + .replace('{TileMatrix}', matrix.id) + .replace('{TileRow}', String(row)) + .replace('{TileCol}', String(col)) + ); + tileUrl.pathname = tileUrl.pathname.replace(/\/{2,}/g, '/'); + if (tileUrl.protocol === 'http:') tileUrl.protocol = 'https:'; + if (tileUrl.hostname !== 'trek.nasa.gov' || tileUrl.protocol !== 'https:') { + throw new UpstreamError(SERVICE, 'invalid_response', `${SERVICE} advertised a tile URL outside trek.nasa.gov; it was not fetched.`); + } + + const lines = [ + `${body.name} Trek tile: layer ${args.layer} (${args.projection.replace('_', ' ')}), zoom ${matrix.id}, row ${row}, column ${col} of a ${matrix.width}×${matrix.height} grid ` + + `(${matrix.tileWidth}×${matrix.tileHeight} px).` + ]; + if (isGlobalEquirectangular(matrix)) { + const lonSpan = 360 / matrix.width; + const latSpan = 180 / matrix.height; + const west = -180 + col * lonSpan; + const north = 90 - row * latSpan; + lines.push(`Covers longitude ${west} to ${west + lonSpan}, latitude ${north - latSpan} to ${north} (degrees).`); + } + lines.push(`Zoom levels: ${caps.matrices[0].id}-${caps.matrices.at(-1)!.id}.`, `Tile URL: ${tileUrl.toString()}`); + + const response = await httpRequest(ctx.fetch, { + service: SERVICE, + url: tileUrl, + maxBytes: MAX_IMAGE_BYTES, + acceptStatus: (status) => status === 200 || status === 404 + }); + const source = sourceInfo(ctx, SERVICE, response.url); + const mimeType = response.contentType.split(';')[0].trim().toLowerCase(); + const content: ToolContent[] = []; + if (response.status === 404) { + lines.push('No tile exists here: the layer has no data coverage for this tile.'); + } else if (!mimeType.startsWith('image/')) { + throw new UpstreamError(SERVICE, 'invalid_response', `${SERVICE} returned ${mimeType || 'untyped'} content instead of a tile image.`); + } else { + content.push({ type: 'image', data: response.body.toString('base64'), mimeType }); + } + return { + content: [text(lines.join('\n')), ...content], + resource: { + name: `${body.name} Trek ${args.layer} z${matrix.id} r${row} c${col}`, + mimeType: 'application/json', + text: json({ source, data: { layer: args.layer, body: args.body, projection: args.projection, zoom: matrix.id, row, col, tile_url: tileUrl.toString(), capabilities: capabilitiesUrl } }), + source + } + }; + } +}); diff --git a/src/index.ts b/src/index.ts index 5806e12..2f01641 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ Model Context Protocol server for NASA and JPL public APIs. Usage: nasa-mcp-server [--nasa-api-key KEY] [--firms-map-key KEY] [--version] [--help] Environment: - NASA_API_KEY api.nasa.gov key (APOD, NEO, DONKI, Mars Rover Photos) + NASA_API_KEY api.nasa.gov key (NEO, DONKI, InSight weather, Mars Rover Photos) FIRMS_MAP_KEY FIRMS MAP_KEY (nasa_firms) MCP_TRANSPORT stdio (default) or http MCP_HTTP_HOST HTTP bind host (default 127.0.0.1) diff --git a/src/prompts.ts b/src/prompts.ts index 5ed9138..5f1917f 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -24,12 +24,6 @@ function numberOrText(value: string | undefined): number | string | undefined { return /^-?\d+(\.\d+)?$/.test(value.trim()) ? Number(value) : value; } -function booleanOrText(value: string | undefined): boolean | string | undefined { - if (value === undefined || value === '') return undefined; - if (/^(true|false)$/i.test(value.trim())) return value.trim().toLowerCase() === 'true'; - return value; -} - function pick(args: Record, mapping: Record unknown>): Record { const out: Record = {}; for (const [key, convert] of Object.entries(mapping)) { @@ -45,8 +39,7 @@ function describeCall(tool: string, toolArgs: Record): string { return `Use the ${tool} tool with arguments ${JSON.stringify(toolArgs)}.`; } -const apodArgs = (args: Record) => - pick(args, { date: asText, count: numberOrText, start_date: asText, end_date: asText, thumbs: booleanOrText }); +const apodArgs = (args: Record) => pick(args, { date: asText, start_date: asText, end_date: asText }); export const PROMPTS: PromptDefinition[] = [ { @@ -54,11 +47,9 @@ export const PROMPTS: PromptDefinition[] = [ aliases: [], description: "Fetch NASA's Astronomy Picture of the Day with optional date selection", arguments: [ - { name: 'date', description: 'Date of the picture (YYYY-MM-DD)', required: false }, - { name: 'count', description: 'Number of random pictures', required: false }, + { name: 'date', description: 'Date of the picture (YYYY-MM-DD); defaults to the latest', required: false }, { name: 'start_date', description: 'Start of a date range (YYYY-MM-DD)', required: false }, - { name: 'end_date', description: 'End of a date range (YYYY-MM-DD)', required: false }, - { name: 'thumbs', description: 'Include video thumbnails (true/false)', required: false } + { name: 'end_date', description: 'End of a date range (YYYY-MM-DD)', required: false } ], tool: 'nasa_apod', toToolArgs: apodArgs, @@ -132,16 +123,15 @@ export const PROMPTS: PromptDefinition[] = [ aliases: [], description: "Get NASA's Astronomy Picture of the Day with a natural language prompt", arguments: [ - { name: 'date', description: 'Date of the picture (YYYY-MM-DD)', required: false }, - { name: 'count', description: 'Number of random pictures', required: false }, + { name: 'date', description: 'Date of the picture (YYYY-MM-DD); defaults to the latest', required: false }, { name: 'start_date', description: 'Start of a date range (YYYY-MM-DD)', required: false }, { name: 'end_date', description: 'End of a date range (YYYY-MM-DD)', required: false } ], tool: 'nasa_apod', toToolArgs: apodArgs, message: (args) => - `Show me the NASA Astronomy Picture of the Day${args.date ? ` for ${args.date}` : ''}${args.count ? ` (${args.count} random images)` : ''}` + - `${args.start_date && args.end_date ? ` from ${args.start_date} to ${args.end_date}` : ''}.` + `Show me the NASA Astronomy Picture of the Day${args.date ? ` for ${args.date}` : ''}` + + `${args.start_date ? ` from ${args.start_date} to ${args.end_date || 'today'}` : ''}.` } ]; diff --git a/src/resource-templates.ts b/src/resource-templates.ts index 49176c0..6125292 100644 --- a/src/resource-templates.ts +++ b/src/resource-templates.ts @@ -41,14 +41,14 @@ export const RESOURCE_TEMPLATES: ResourceTemplateDefinition[] = [ { name: 'nasa-apod', title: 'Astronomy Picture of the Day metadata', - description: 'APOD metadata (title, explanation, image URLs) for a date, fetched from api.nasa.gov on read. Requires NASA_API_KEY.', + description: 'APOD metadata (title, explanation, image or video URL) for a date, or the latest picture when date is empty, fetched from science.nasa.gov on read. No API key needed.', uriTemplate: 'nasa://apod/image?date={date}', mimeType: 'application/json', base: 'nasa://apod/image', async read(params, ctx) { const args = parse(apodInputSchema, onlyParams(params, ['date'])); const { data, source } = await fetchApod(ctx, args); - return json({ source, data }); + return json({ source, data: data[0] ?? null }); } }, { diff --git a/src/tools/registry.ts b/src/tools/registry.ts index d6dd106..bc44715 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -8,14 +8,21 @@ import { exoplanetTool } from '../handlers/nasa/exoplanet'; import { firmsTool } from '../handlers/nasa/firms'; 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 { 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 { tleTool } from '../handlers/nasa/tle'; +import { trekLayersTool, trekTileTool } from '../handlers/nasa/trek'; import { cadTool } from '../handlers/jpl/cad'; import { fireballTool } from '../handlers/jpl/fireball'; import { horizonsFileTool, horizonsTool } from '../handlers/jpl/horizons'; import { jdCalTool } from '../handlers/jpl/jd_cal'; +import { missionDesignTool } from '../handlers/jpl/mission_design'; import { nhatsTool } from '../handlers/jpl/nhats'; import { periodicOrbitsTool } from '../handlers/jpl/periodic_orbits'; import { sbdbTool } from '../handlers/jpl/sbdb'; @@ -41,6 +48,14 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [ eonetTool, powerTool, osdrFilesTool, + insightTool, + tleTool, + sscObservatoriesTool, + sscLocationsTool, + techportTool, + techTransferTool, + trekLayersTool, + trekTileTool, sbdbTool, fireballTool, jdCalTool, @@ -50,7 +65,8 @@ export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [ horizonsTool, horizonsFileTool, periodicOrbitsTool, - scoutTool + scoutTool, + missionDesignTool ] as ToolDefinition[]; /** diff --git a/src/util/html.ts b/src/util/html.ts new file mode 100644 index 0000000..4372a71 --- /dev/null +++ b/src/util/html.ts @@ -0,0 +1,50 @@ +const NAMED_ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: '\u00a0', + ndash: '–', + mdash: '—', + lsquo: '‘', + rsquo: '’', + ldquo: '“', + rdquo: '”', + hellip: '…', + deg: '°', + times: '×', + plusmn: '±', + micro: 'µ', + copy: '©', + reg: '®' +}; + +/** Decodes numeric and common named HTML character references. */ +export function decodeEntities(value: string): string { + return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => { + if (entity[0] === '#') { + const code = entity[1] === 'x' || entity[1] === 'X' ? Number.parseInt(entity.slice(2), 16) : Number(entity.slice(1)); + return Number.isInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match; + } + return NAMED_ENTITIES[entity.toLowerCase()] ?? match; + }); +} + +/** + * Converts an upstream HTML fragment to plain text: line and paragraph breaks + * become newlines, other tags are dropped, entities are decoded. + */ +export function htmlToText(html: string): string { + return decodeEntities( + html + .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '') + .replace(//gi, '\n') + .replace(/<\/?(p|div|li|ul|ol|h[1-6])\b[^>]*>/gi, '\n\n') + .replace(/<[^>]*>/g, '') + ) + .replace(/[^\S\n]+/g, ' ') + .replace(/ *\n */g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} diff --git a/src/util/http.ts b/src/util/http.ts index d71f0cf..8de602a 100644 --- a/src/util/http.ts +++ b/src/util/http.ts @@ -53,7 +53,7 @@ export function looksLikeHtml(text: string): boolean { /** * Extracts a short, sanitized error message from an upstream error body. - * Understands the JSON error shapes used by CMR, api.nasa.gov and JPL. + * Understands the JSON error shapes used by CMR, api.nasa.gov, JPL and the TLE API. */ export function summarizeErrorBody(text: string, contentType: string): string { const trimmed = text.trim(); @@ -65,6 +65,7 @@ export function summarizeErrorBody(text: string, contentType: string): string { Array.isArray(parsed.errors) ? parsed.errors.join('; ') : undefined, Array.isArray(parsed.messages) ? parsed.messages.join('; ') : undefined, (parsed.error as { message?: unknown } | undefined)?.message, + (parsed.response as { message?: unknown } | undefined)?.message, parsed.error_message, parsed.msg, parsed.message, diff --git a/test/integration/transports.test.ts b/test/integration/transports.test.ts index 658bc26..36962d8 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 = 23; +const EXPECTED_TOOL_COUNT = 32; 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-')); @@ -51,7 +51,7 @@ async function exerciseClient(client: Client) { await assert.rejects(client.callTool({ name: 'nasa_unknown', arguments: {} }), /Unknown tool/); - const noKey = await client.callTool({ name: 'nasa_apod', arguments: {} }); + const noKey = await client.callTool({ name: 'nasa_neo', arguments: {} }); assert.equal(noKey.isError, true); assert.match(JSON.stringify(noKey.content), /NASA_API_KEY is not set/); } diff --git a/test/live/upstream.live.ts b/test/live/upstream.live.ts index 9a3ac5f..c69e6a2 100644 --- a/test/live/upstream.live.ts +++ b/test/live/upstream.live.ts @@ -54,6 +54,17 @@ describe('live CMR', { skip: skipLive }, () => { }); }); +describe('live APOD', { skip: skipLive }, () => { + it('the NASA Science APOD API returns a known date without a key', async () => { + const nasa = createNasaMcpServer({ config: { ...loadConfig(process.env, []), nasaApiKey: undefined } }); + const result = await nasa.callTool('nasa_apod', { date: '2015-07-14', max_images: 0 }); + const text = result.content.map((c) => (c.type === 'text' ? c.text : '')).join('\n'); + assert.equal(result.isError, undefined, text); + assert.match(text, /## New Horizons Passes Pluto and Charon \(2015-07-14\)/); + assert.match(text, /Image: https:\/\/assets\.science\.nasa\.gov\//); + }); +}); + 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/catalog-tools.test.ts b/test/unit/catalog-tools.test.ts new file mode 100644 index 0000000..dc243c6 --- /dev/null +++ b/test/unit/catalog-tools.test.ts @@ -0,0 +1,365 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { unwrapTypedJson } from '../../src/handlers/nasa/ssc'; +import { parseTrekCapabilities } from '../../src/handlers/nasa/trek'; +import { executeTool } from '../../src/tools/execute'; +import { resolveTool } from '../../src/tools/registry'; +import { TEST_NASA_KEY, testContext, textOf } from '../helpers/context'; +import { fakeFetch, jsonResponse, textResponse, type Responder } from '../helpers/fake-fetch'; + +const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); + +async function call(name: string, args: Record, routes: Array<[string | RegExp, Responder]>, config = {}) { + const definition = resolveTool(name); + assert.ok(definition, name); + const fake = fakeFetch(routes); + const result = await executeTool(definition, args, testContext(fake.fetch, config)); + return { result, calls: fake.calls, text: textOf(result) }; +} + +describe('nasa_insight_weather', () => { + const feed = { + '675': { + AT: { av: -62.314, mn: -96.872, mx: -15.908, ct: 177556 }, + PRE: { av: 750.563, mn: 722.09, mx: 768.791, ct: 887776 }, + HWS: { av: 7.233, mn: 1.051, mx: 22.455, ct: 88628 }, + WD: { most_common: { compass_point: 'WNW', compass_degrees: 292.5 } }, + First_UTC: '2020-10-19T18:32:20Z', + Last_UTC: '2020-10-20T19:11:55Z', + Northern_season: 'early winter', + Southern_season: 'early summer' + }, + '676': { PRE: { av: 749.09, mn: 722.5, mx: 767.1 }, WD: { most_common: null }, Last_UTC: '2020-10-21T19:51:31Z' }, + sol_keys: ['675', '676'], + validity_checks: {} + }; + + it('labels the frozen feed as historical and summarizes each sol with units', async () => { + const { calls, text } = await call('nasa_insight_weather', {}, [['api.nasa.gov/insight_weather/', () => jsonResponse(feed)]]); + const params = calls[0].url.searchParams; + assert.equal(params.get('api_key'), TEST_NASA_KEY); + assert.equal(params.get('feedtype'), 'json'); + assert.equal(params.get('ver'), '1.0'); + assert.match(text, /^HISTORICAL DATA: the InSight weather feed is no longer updated/); + assert.match(text, /latest sol is 676, ending 2020-10-21/); + assert.match(text, /## Sol 675 \(2020-10-19 to 2020-10-20 UTC\)/); + assert.match(text, /Air temperature: avg -62\.3 °C \(min -96\.9, max -15\.9\)/); + assert.match(text, /Most common wind direction: from WNW \(292\.5°\)/); + assert.match(text, /## Sol 676[\s\S]*Air temperature: no valid data[\s\S]*Most common wind direction: no valid data/); + assert.doesNotMatch(text, new RegExp(TEST_NASA_KEY)); + }); + + it('needs NASA_API_KEY', async () => { + const { text, calls } = await call('nasa_insight_weather', {}, [], { nasaApiKey: undefined }); + assert.match(text, /NASA_API_KEY is not set/); + assert.equal(calls.length, 0); + }); +}); + +describe('nasa_tle', () => { + const iss = { satelliteId: 25544, name: 'ISS (ZARYA)', date: '2026-09-29T19:40:36+00:00', line1: '1 25544U 98067A 26272.81986231', line2: '2 25544 51.6313 142.2618' }; + + it('fetches one satellite by NORAD number without a key', async () => { + const { calls, text } = await call('nasa_tle', { satellite_id: 25544 }, [['tle.ivanstanojevic.me/api/tle/25544', () => jsonResponse(iss)]], { nasaApiKey: undefined }); + assert.equal(calls[0].url.pathname, '/api/tle/25544'); + assert.equal(calls[0].url.search, ''); + assert.match(text, /## ISS \(ZARYA\) \(NORAD 25544\)\nEpoch: 2026-09-29T19:40:36\+00:00\n1 25544U/); + }); + + it('searches the collection with the upstream parameter names and reports paging', async () => { + const { calls, text } = await call('nasa_tle', { search: 'ISS', sort: 'name', sort_dir: 'asc', page: 2, page_size: 1 }, [ + ['tle.ivanstanojevic.me/api/tle/', () => jsonResponse({ totalItems: 3, member: [iss], parameters: { page: 2, 'page-size': 1 }, view: { next: 'x' } })] + ]); + const params = calls[0].url.searchParams; + assert.equal(calls[0].url.pathname, '/api/tle/'); + assert.deepEqual([params.get('search'), params.get('sort'), params.get('sort-dir'), params.get('page'), params.get('page-size')], ['ISS', 'name', 'asc', '2', '1']); + assert.match(text, /^3 TLE records matching "ISS"; page 2 of 3 \(1 shown\)\. Next: page 3\./); + }); + + it('surfaces the API error message and rejects mixed modes', async () => { + const missing = await call('nasa_tle', { satellite_id: 99999999 }, [ + ['tle.ivanstanojevic.me', () => jsonResponse({ response: { message: 'Unable to find record with id 99999999' } }, { status: 404 })] + ]); + assert.equal(missing.result.isError, true); + assert.match(missing.text, /TLE API returned HTTP 404: Unable to find record with id 99999999/); + const mixed = await call('nasa_tle', { satellite_id: 1, search: 'x' }, []); + assert.match(mixed.text, /satellite_id cannot be combined/); + assert.match((await call('nasa_tle', { page_size: 101 }, [])).text, /page_size/); + }); +}); + +const typed = (className: string, value: unknown) => [className, value]; +const list = (items: unknown[]) => typed('java.util.ArrayList', items); + +describe('Satellite Situation Center tools', () => { + it('unwraps Jackson typed JSON', () => { + const wrapped = typed('gov.nasa.gsfc.sscweb.schema.Response', { Result: typed('gov.nasa.gsfc.sscweb.schema.DataResult', { X: list([1, 2]), Name: 'iss' }) }); + assert.deepEqual(unwrapTypedJson(wrapped), { Result: { X: [1, 2], Name: 'iss' } }); + assert.deepEqual(unwrapTypedJson(['plain', 'array']), ['plain', 'array']); + }); + + it('nasa_ssc_observatories filters by text and by the day data is available', async () => { + const observatories = typed('gov.nasa.gsfc.sscweb.schema.ObservatoryResponse', { + Observatory: list([ + typed('gov.nasa.gsfc.sscweb.schema.ObservatoryDescription', { Id: 'mms1', Name: 'MMS 1', Resolution: 60, StartTime: typed('javax.xml.datatype.XMLGregorianCalendar', '2015-03-13T00:00:00.000+00:00'), EndTime: '2027-04-12T00:00:00.000+00:00' }), + typed('gov.nasa.gsfc.sscweb.schema.ObservatoryDescription', { Id: 'active', Name: 'Active', Resolution: 60, StartTime: '1989-09-29T00:00:00.000+00:00', EndTime: '1991-10-04T08:00:00.000+00:00' }) + ]) + }); + const { calls, text } = await call('nasa_ssc_observatories', { search: 'MMS', active_on: '2026-09-29' }, [['sscweb.gsfc.nasa.gov/WS/sscr/2/observatories', () => jsonResponse(observatories)]]); + assert.equal(calls[0].headers.get('accept'), 'application/json'); + assert.match(text, /^1 of 2 SSC observatories matching "MMS" with data on 2026-09-29\.\n- mms1: MMS 1 \(2015-03-13 to 2027-04-12, 60 s resolution\)$/); + }); + + const locations = (status = 'SUCCESS') => + typed('gov.nasa.gsfc.sscweb.schema.Response', { + Result: typed('gov.nasa.gsfc.sscweb.schema.DataResult', { + StatusCode: status, + StatusSubCode: status === 'SUCCESS' ? 'SUCCESS' : 'INVALID_SATELLITE', + ...(status === 'SUCCESS' ? {} : { StatusText: list(["Invalid satellite name 'nope'"]) }), + Data: list([ + typed('gov.nasa.gsfc.sscweb.schema.SatelliteData', { + Id: 'iss', + Time: list(['2026-09-29T00:00:00.000+00:00', '2026-09-29T00:01:00.000+00:00']), + Coordinates: list([ + typed('gov.nasa.gsfc.sscweb.schema.CoordinateData', { + CoordinateSystem: 'GEO', + X: list([-1273.1083505, -906.82]), + Y: list([-4283.12, -4502.37]), + Z: list([5115.54, 5003.92]), + Latitude: list([48.8634071, 47.45311]), + Longitude: list([253.44603, 258.61234]), + LocalTime: list([16.896388, 17.2575]) + }) + ]), + RadialLength: list([6792.2517, 6792.1248]) + }) + ]) + }) + }); + + it('nasa_ssc_locations builds the REST path and returns a CSV table per observatory', async () => { + const { calls, text } = await call('nasa_ssc_locations', { observatories: 'ISS, moon', start_time: '2026-09-29T00:00:00Z', end_time: '2026-09-29T01:00:00Z' }, [ + ['sscweb.gsfc.nasa.gov/WS/sscr/2/locations/', () => jsonResponse(locations())] + ]); + assert.equal(calls[0].url.pathname, '/WS/sscr/2/locations/iss,moon/20260929T000000Z,20260929T010000Z/geo/'); + assert.equal(calls[0].url.searchParams.has('resolutionFactor'), false, 'one hour fits without thinning'); + assert.match(text, /^SSC locations for iss, moon from 2026-09-29T00:00:00Z to 2026-09-29T01:00:00Z\./); + assert.match(text, /## iss: 2 points\ntime_utc,GEO_x_km,GEO_y_km,GEO_z_km,GEO_lat_deg,GEO_lon_deg,GEO_local_time_h,radial_km\n2026-09-29T00:00:00Z,-1273\.1,-4283\.1,5115\.5,48\.863,253\.446,16\.896,6792\.3/); + }); + + it('nasa_ssc_locations bounds long ranges with a resolution factor and reports SSC errors', async () => { + const month = await call('nasa_ssc_locations', { observatories: ['iss'], start_time: '2026-09-01', end_time: '2026-09-30T00:00:00Z', coordinate_systems: 'GEO' }, [ + ['sscweb.gsfc.nasa.gov', () => jsonResponse(locations())] + ]); + // 29 days at 60 s is 41,761 points; 8000 values / 8 per point allows 1000. + assert.equal(month.calls[0].url.searchParams.get('resolutionFactor'), '42'); + assert.match(month.text, /resolution factor 42/); + + const tooFine = await call('nasa_ssc_locations', { observatories: ['iss'], start_time: '2026-09-01', end_time: '2026-09-30', resolution_factor: 2 }, []); + assert.match(tooFine.text, /resolution_factor 2 could return about \d+ points per observatory; use 42 or more/); + + const invalid = await call('nasa_ssc_locations', { observatories: ['nope'], start_time: '2026-09-01', end_time: '2026-09-02' }, [['sscweb.gsfc.nasa.gov', () => jsonResponse(locations('ERROR'))]]); + assert.equal(invalid.result.isError, true); + assert.match(invalid.text, /rejected the request: Invalid satellite name 'nope'/); + + for (const [args, pattern] of [ + [{ observatories: ['iss'], start_time: '2026-09-02', end_time: '2026-09-01' }, /end_time must be after start_time/], + [{ observatories: ['iss'], start_time: '2025-01-01', end_time: '2026-09-01' }, /limited to 366 days/], + [{ observatories: ['iss'], start_time: '2026-09-01T12:00:00', end_time: '2026-09-02' }, /UTC time/], + [{ observatories: ['../x'], start_time: '2026-09-01', end_time: '2026-09-02' }, /SSC observatory ID/] + ] as Array<[Record, RegExp]>) { + assert.match((await call('nasa_ssc_locations', args, [])).text, pattern); + } + }); +}); + +describe('nasa_techport', () => { + const project = { + projectId: 4789, + title: 'Solar Sail Demonstration', + status: 'Canceled', + startDate: '2011-10-01T00:00:00Z', + endDate: '2014-12-19T00:00:00Z', + trlBegin: 5, + trlCurrent: 5, + trlEnd: 6, + program: { title: 'Technology Demonstration Missions', acronym: 'TDM' }, + responsibleMd: { organization_name: 'Space Technology Mission Directorate' }, + primaryTx: { code: 'TX01.4.1', title: 'Solar Sails' }, + destinationTypes: ['Sun', ''], + description: '

Deploy a solar sail & test it.

', + projectContacts: [{ full_name: 'A Person', email: 'a.person@nasa.gov' }] + }; + + it('searches with query and limit, and keeps contact emails out of the output', async () => { + const { calls, text } = await call('nasa_techport', { query: 'solar sail', limit: 3 }, [['techport.nasa.gov/api/projects/search', () => jsonResponse({ total: 184, results: [project] })]]); + assert.equal(calls[0].url.searchParams.get('query'), 'solar sail'); + assert.equal(calls[0].url.searchParams.get('limit'), '3'); + assert.match(text, /^184 TechPort projects match "solar sail"; showing the 1 most relevant\./); + assert.match(text, /Canceled · 2011-10-01 to 2014-12-19 · TRL 5 start, 5 current, 6 target/); + assert.match(text, /Responsible directorate: Space Technology Mission Directorate/); + assert.match(text, /Destinations: Sun\n/); + assert.match(text, /Page: https:\/\/techport\.nasa\.gov\/projects\/4789/); + assert.match(text, /Description: Deploy a solar sail & test it\./); + assert.doesNotMatch(text, /@nasa\.gov/); + }); + + it('fetches one project by ID and validates the mode', async () => { + const detail = { ...project, leadOrganization: { organizationName: 'Nexolve' }, benefits: '

Lighter sails.

', destinationType: ['Others_Inside_the_Solar_System'] }; + const { calls, text } = await call('nasa_techport', { project_id: 94703 }, [['techport.nasa.gov/api/projects/94703', () => jsonResponse({ projectId: 94703, project: detail })]]); + assert.equal(calls[0].url.pathname, '/api/projects/94703'); + assert.match(text, /Lead organization: Nexolve/); + assert.match(text, /Benefits: Lighter sails\./); + assert.match((await call('nasa_techport', {}, [])).text, /exactly one of query or project_id/); + assert.match((await call('nasa_techport', { project_id: 1, limit: 5 }, [])).text, /limit applies only to query searches/); + }); +}); + +describe('nasa_techtransfer', () => { + const row = (id: string, title: string) => ['64e71c1a', id, title, `

About ${title}.

`, id, 'data and image processing', 'Open Source', '', 'https://github.com/nasa/x', 'JPL', '', '', 15.3]; + + it('searches technology.nasa.gov by path, applies limit locally and links catalog pages', async () => { + const results = [row('NPO-53560-1', 'Cloud Tomography Visualization'), row('GSC-1', 'Other')]; + const { calls, text } = await call('nasa_techtransfer', { query: 'cloud visualization', collection: 'software', limit: 1 }, [ + ['technology.nasa.gov/api/api/software/', () => jsonResponse({ results, count: 2, total: 2, perpage: 10, page: 0 })] + ]); + assert.equal(calls[0].url.pathname, '/api/api/software/cloud%20visualization'); + assert.equal(calls[0].url.searchParams.has('api_key'), false); + assert.match(text, /^2 NASA software match "cloud visualization"; showing 1\./); + assert.match(text, /## Cloud Tomography Visualization \(NPO-53560-1\)\nCategory: data and image processing · Center: JPL · Release: Open Source/); + assert.match(text, /Page: https:\/\/software\.nasa\.gov\/software\/NPO-53560-1\nLink: https:\/\/github\.com\/nasa\/x/); + assert.doesNotMatch(text, / { + const patent = await call('nasa_techtransfer', { query: 'engine' }, [['technology.nasa.gov/api/api/patent/engine', () => jsonResponse({ results: [], total: 0 })]]); + assert.match(patent.text, /No NASA patents match "engine"\./); + const spinoff = await call('nasa_techtransfer', { query: 'polymers', collection: 'spinoff' }, [['technology.nasa.gov/api/api/spinoff/', () => jsonResponse({ results: [row('GSFC-SO-113', 'Polymers')], total: 1 })]]); + assert.doesNotMatch(spinoff.text, /Page:/); + assert.match((await call('nasa_techtransfer', { query: '../patent' }, [])).text, /must not contain/); + }); +}); + +const CAPABILITIES = ` + +LAYER +image/jpegdefault028mm + +defaultdefault028mm +0-180.0 90.02562562.01.0 +1-180.0 90.02562564.02.0 + +`; + +describe('NASA Trek tools', () => { + it('nasa_trek_layers filters the catalog by body, projection and keywords', async () => { + const doc = { + productLabel: 'olympus_mons.eq', + title: 'MRO CTX, Mosaic Olympus Mons', + mission: 'Mars Reconnaissance Orbiter', + instrument: 'CTX', + productCat1: 'Imagery', + productCat2: 'Mosaic', + coverage: 'Global', + thumbnailURLDir: 'https://trek.nasa.gov/tiles/Mars/EQ/olympus_mons.eq/thumbnail/olympus-' + }; + const { calls, text } = await call('nasa_trek_layers', { body: 'mars', search: 'olympus', limit: 5 }, [['trek.nasa.gov/mars/TrekServices/ws/index/eq/searchItems', () => jsonResponse({ response: { numFound: 1, docs: [doc] } })]]); + const params = calls[0].url.searchParams; + assert.equal(params.get('key'), 'olympus'); + assert.equal(params.get('rows'), '5'); + assert.match(params.get('facetValues')!, /\|"urn:ogc:def:crs:EPSG::104905"$/); + assert.match(text, /^1 Mars Trek equirectangular layers matching "olympus"; showing 1\./); + assert.match(text, /- olympus_mons\.eq: MRO CTX, Mosaic Olympus Mons \(Mars Reconnaissance Orbiter, CTX\), Imagery \/ Mosaic, global coverage\n {2}WMTS: https:\/\/trek\.nasa\.gov\/tiles\/Mars\/EQ\/olympus_mons\.eq$/); + + const polar = await call('nasa_trek_layers', { body: 'moon', projection: 'south_pole' }, [['trek.nasa.gov/moon/TrekServices/ws/index/polar/searchItems', () => jsonResponse({ response: { numFound: 0, docs: [] } })]]); + assert.match(polar.calls[0].url.searchParams.get('facetValues')!, /IAU2000::30120"$/); + assert.match(polar.text, /No Moon Trek south pole layers\./); + }); + + it('parses WMTS capabilities and ignores commented-out tile matrices', () => { + const caps = parseTrekCapabilities(CAPABILITIES)!; + assert.equal(caps.style, 'default'); + assert.equal(caps.matrixSet, 'default028mm'); + assert.deepEqual(caps.matrices.map((m) => `${m.id}:${m.width}x${m.height}`), ['0:2x1', '1:4x2']); + }); + + it('nasa_trek_tile fills the tile template, picks tiles by latitude/longitude and embeds the image', async () => { + const { calls, result, text } = await call('nasa_trek_tile', { body: 'moon', layer: 'LAYER', zoom: 1, latitude: -10, longitude: 200 }, [ + ['trek.nasa.gov/tiles/Moon/EQ/LAYER/1.0.0/WMTSCapabilities.xml', () => textResponse(CAPABILITIES, { headers: { 'content-type': 'text/xml' } })], + ['trek.nasa.gov/tiles/Moon/EQ/LAYER/1.0.0/default', () => new Response(PNG, { headers: { 'content-type': 'image/jpeg' } })] + ]); + assert.equal(calls[1].url.pathname, '/tiles/Moon/EQ/LAYER/1.0.0/default/default028mm/1/1/0.jpg', 'longitude 200 E is -160, row 1 is the southern half'); + assert.match(text, /zoom 1, row 1, column 0 of a 4×2 grid \(256×256 px\)\.\nCovers longitude -180 to -90, latitude -90 to 0 \(degrees\)\.\nZoom levels: 0-1\./); + assert.equal(result.content.filter((c) => c.type === 'image').length, 1); + }); + + it('nasa_trek_tile explains unknown layers, missing coverage and invalid tiles', async () => { + const caps = (): Response => textResponse(CAPABILITIES, { headers: { 'content-type': 'text/xml' } }); + const unknown = await call('nasa_trek_tile', { body: 'mars', layer: 'Nope' }, [['trek.nasa.gov', () => textResponse('missing', { status: 404 })]]); + assert.match(unknown.text, /Mars Trek has no equirectangular layer "Nope"; find layer IDs with nasa_trek_layers/); + + const empty = await call('nasa_trek_tile', { body: 'moon', layer: 'LAYER', zoom: 1, row: 0, col: 3 }, [ + ['trek.nasa.gov/tiles/Moon/EQ/LAYER/1.0.0/WMTSCapabilities.xml', caps], + ['trek.nasa.gov/tiles/Moon/EQ/LAYER/1.0.0/default', () => textResponse('', { status: 404 })] + ]); + assert.equal(empty.result.isError, undefined); + assert.match(empty.text, /No tile exists here: the layer has no data coverage/); + + const outside = await call('nasa_trek_tile', { body: 'moon', layer: 'LAYER', zoom: 1, row: 2 }, [['trek.nasa.gov', caps]]); + assert.match(outside.text, /zoom 1 has rows 0-1 and columns 0-3/); + const zoom = await call('nasa_trek_tile', { body: 'moon', layer: 'LAYER', zoom: 5 }, [['trek.nasa.gov', caps]]); + assert.match(zoom.text, /zoom 5 is not available for LAYER; zoom levels are 0, 1/); + + const elsewhere = CAPABILITIES.replace('https://trek.nasa.gov/tiles', 'https://example.com/tiles'); + const offsite = await call('nasa_trek_tile', { body: 'moon', layer: 'LAYER' }, [['trek.nasa.gov', () => textResponse(elsewhere)]]); + assert.equal(offsite.result.isError, true); + assert.match(offsite.text, /outside trek\.nasa\.gov; it was not fetched/); + assert.equal(offsite.calls.length, 1); + + for (const [args, pattern] of [ + [{ body: 'ceres', layer: 'x' }, /body/], + [{ body: 'moon', layer: '../x' }, /Trek layer ID/], + [{ body: 'moon', layer: 'x', row: 0, latitude: 1, longitude: 1 }, /not both/], + [{ body: 'moon', layer: 'x', projection: 'north_pole', latitude: 1, longitude: 1 }, /equirectangular/] + ] as Array<[Record, RegExp]>) { + assert.match((await call('nasa_trek_tile', args, [])).text, pattern); + } + }); +}); + +describe('jpl_mission_design', () => { + it('object mode passes the selector and summarizes stored missions', async () => { + const { calls, text } = await call('jpl_mission_design', { sstr: 'apophis', class: true }, [ + ['ssd-api.jpl.nasa.gov/mdesign.api', () => jsonResponse({ object: { fullname: '99942 Apophis (2004 MN4)' }, fields: ['MJD0'], selectedMissions: [[65909], [63399]] })] + ]); + const params = calls[0].url.searchParams; + assert.equal(params.get('sstr'), 'apophis'); + assert.equal(params.get('class'), 'true'); + assert.equal(params.has('lim'), false); + assert.equal(params.has('api_key'), false); + assert.match(text, /^Mission options to 99942 Apophis \(2004 MN4\): 2 pre-computed ballistic missions\./); + }); + + it('list mode sends crit, years and hyphenated SBDB filters', async () => { + const { calls, text } = await call('jpl_mission_design', { year: [2027, 2028], sb_group: 'neo', sb_class: 'APO,ATE', sb_sat: false }, [ + ['ssd-api.jpl.nasa.gov/mdesign.api', () => jsonResponse({ count: 20, fields: ['name'], data: [] })] + ]); + const params = calls[0].url.searchParams; + assert.deepEqual( + [params.get('lim'), params.get('crit'), params.get('year'), params.get('sb-group'), params.get('sb-class'), params.get('sb-sat')], + ['20', '1', '2027,2028', 'neo', 'APO,ATE', 'false'] + ); + assert.match(text, /^20 accessible small bodies, best first by criterion 1\./); + }); + + it('validates modes and years, and reports unknown objects', async () => { + assert.match((await call('jpl_mission_design', { des: '433', crit: 2 }, [])).text, /crit apply only to list mode/); + assert.match((await call('jpl_mission_design', { des: '433', lim: 5 }, [])).text, /lim applies only to list mode/); + assert.match((await call('jpl_mission_design', { class: true }, [])).text, /class applies only to object mode/); + assert.match((await call('jpl_mission_design', { des: '433', spk: 2000433 }, [])).text, /only one of des, spk or sstr/); + assert.match((await call('jpl_mission_design', { year: [2080] }, [])).text, /year must be between 2026 and 2046/); + const missing = await call('jpl_mission_design', { sstr: 'nosuch' }, [['ssd-api.jpl.nasa.gov', () => jsonResponse({ message: 'specified object was not found', code: '200' })]]); + assert.equal(missing.result.isError, undefined); + assert.match(missing.text, /Mission Design: specified object was not found \("nosuch"\)\./); + }); +}); diff --git a/test/unit/handlers.test.ts b/test/unit/handlers.test.ts index a471304..831fa47 100644 --- a/test/unit/handlers.test.ts +++ b/test/unit/handlers.test.ts @@ -20,43 +20,155 @@ async function call(name: string, args: Record, routes: Array<[ return { result, calls: fake.calls, text: textOf(result) }; } -describe('api.nasa.gov tools', () => { - it('nasa_apod sends the key as api_key and embeds only real images from nasa.gov', async () => { - const apod = [ - { date: '2024-01-01', title: 'Galaxy', media_type: 'image', url: 'https://apod.nasa.gov/a.png', explanation: 'x' }, - { date: '2024-01-02', title: 'Video', media_type: 'video', url: 'https://www.youtube.com/embed/x', explanation: 'y' } - ]; - const { result, calls } = await call('nasa_apod', { start_date: '2024-01-01', end_date: '2024-01-02', max_images: 5 }, [ - ['api.nasa.gov/planetary/apod', () => jsonResponse(apod)], - ['apod.nasa.gov', () => new Response(PNG, { headers: { 'content-type': 'image/png' } })] +const APOD_API = 'science.nasa.gov/wp-json/wp/v2/apod-basic'; +const APOD_ASSETS = 'https://assets.science.nasa.gov/content/dam/science/cds/apod/apod'; + +/** A post shaped like /wp/v2/apod-basic output (HTML fields included). */ +function apodPost(date: string, overrides: Record = {}) { + const ymd = date.slice(2).replace(/-/g, ''); + return { + date, + post_id: Number(ymd), + title: `Galaxy “${date}”`, + permalink: `https://science.nasa.gov/image-article/apod-${date}/`, + media_type: 'image', + explanation: 'Explanation: A galaxy & its stars.

Tomorrow's picture: more', + credit: ' Image Credit & Copyright: A. Astronomer , Some Observatory', + copyright: ' Image Credit & Copyright: A. Astronomer , Some Observatory', + alt: 'A spiral galaxy.', + url: `https://science.nasa.gov/image-article/apod-${date}/`, + hdurl: `https://assets.science.nasa.gov/dynamicimage/assets/science/cds/apod/apod/x/${ymd}.jpg?w=2000&h=1000&fit=clip`, + basic_html: + '
' + + `A spiral galaxy.
`, + basic_html_url: `https://science.nasa.gov/wp-json/wp/v2/apod-basic/${ymd}/html`, + ...overrides + }; +} + +const imageResponse = () => new Response(PNG, { headers: { 'content-type': 'image/png' } }); + +describe('nasa_apod (NASA Science WordPress API)', () => { + it('fetches one date by YYMMDD without an API key, strips HTML and embeds a 1024-pixel rendition', async () => { + const { result, calls, text } = await call( + 'nasa_apod', + { date: '2024-01-01' }, + [ + [`${APOD_API}/240101`, () => jsonResponse(apodPost('2024-01-01'))], + ['assets.science.nasa.gov', imageResponse] + ], + { nasaApiKey: undefined } + ); + assert.equal(result.isError, undefined); + assert.equal(calls[0].url.pathname, '/wp-json/wp/v2/apod-basic/240101'); + assert.equal(calls[0].url.search, '', 'the new API takes no key or query parameters'); + assert.match(text, /## Galaxy “2024-01-01” \(2024-01-01\)/); + assert.match(text, /Credit: A\. Astronomer, Some Observatory\n/); + assert.doesNotMatch(text, /Copyright:/, 'an identical copyright line is not repeated'); + assert.match(text, /Image: https:\/\/assets\.science\.nasa\.gov\/content\/dam\/science\/cds\/apod\/apod\/x\/240101\.jpg/); + assert.match(text, /Alt text: A spiral galaxy\./); + assert.match(text, /\nA galaxy & its stars\.\n\nTomorrow's picture: more$/); + assert.doesNotMatch(text, /<|&|Explanation:/); + assert.equal(calls[1].url.pathname, '/dynamicimage/assets/science/cds/apod/apod/x/240101.jpg'); + assert.equal(calls[1].url.searchParams.get('w'), '1024'); + assert.equal(result.content.filter((c) => c.type === 'image').length, 1); + }); + + it('pages through a date range 25 posts at a time and lists it oldest first', async () => { + const dates = Array.from({ length: 30 }, (_, i) => `2024-01-${String(30 - i).padStart(2, '0')}`); + const { calls, text } = await call('nasa_apod', { start_date: '2024-01-01', end_date: '2024-01-30', max_images: 0 }, [ + [ + APOD_API, + (request) => { + const page = Number(request.url.searchParams.get('page')); + return jsonResponse(dates.slice((page - 1) * 25, page * 25).map((d) => apodPost(d)), { headers: { 'x-wp-totalpages': '2' } }); + } + ] ]); - assert.equal(calls[0].url.searchParams.get('api_key'), TEST_NASA_KEY); - assert.equal(calls[0].url.searchParams.get('start_date'), '2024-01-01'); - const images = result.content.filter((c) => c.type === 'image'); - assert.equal(images.length, 1); - assert.equal((images[0] as { mimeType: string }).mimeType, 'image/png'); - assert.equal(calls.length, 2, 'video URLs and non-NASA hosts are never downloaded'); - assert.doesNotMatch(JSON.stringify(result), new RegExp(TEST_NASA_KEY)); + assert.equal(calls.length, 2); + for (const [i, request] of calls.entries()) { + assert.equal(request.url.searchParams.get('date_from'), '240101'); + assert.equal(request.url.searchParams.get('date_to'), '240130'); + assert.equal(request.url.searchParams.get('per_page'), '25'); + assert.equal(request.url.searchParams.get('page'), String(i + 1)); + } + assert.match(text, /^30 APOD entries from 2024-01-01 to 2024-01-30/); + const headings = [...text.matchAll(/^## .* \((\d{4}-\d{2}-\d{2})\)$/gm)].map((m) => m[1]); + assert.deepEqual(headings, [...dates].reverse()); }); - it('nasa_apod never emits image content when the download is not an image', async () => { + it('defaults to the latest post and reports video URLs from the page without downloading them', async () => { + const video = apodPost('2026-09-29', { + media_type: 'video', + hdurl: 'https://assets.science.nasa.gov/dynamicimage/assets/science/cds/apod/apod/2026/september/frame.jpg?w=1280', + basic_html: '' + }); + const { calls, result, text } = await call('nasa_apod', { max_images: 5 }, [[APOD_API, () => jsonResponse([video])]]); + assert.equal(calls.length, 1, 'videos and still frames are never downloaded'); + assert.equal(calls[0].url.searchParams.get('per_page'), '1'); + assert.match(text, /Video: https:\/\/www\.youtube\.com\/embed\/abc123\?rel=0&x=1/); + assert.match(text, /Still frame: https:\/\/assets\.science\.nasa\.gov\/.*frame\.jpg/); + assert.equal(result.content.some((c) => c.type === 'image'), false); + }); + + it('ignores the placeholder hdurl of old posts, falls back to the original image, and explains missing days', async () => { + const placeholder = apodPost('1995-06-20', { + hdurl: 'https://assets.science.nasa.gov/dynamicimage/assets/science/astro/programs/cosmic-origins/images/misc/news-thumbnail.png?w=594', + basic_html: '

no picture element

' + }); + const none = await call('nasa_apod', { date: '1995-06-20' }, [[`${APOD_API}/950620`, () => jsonResponse(placeholder)]]); + assert.doesNotMatch(none.text, /Image:|news-thumbnail/); + assert.equal(none.calls.length, 1); + + const gif = apodPost('1995-06-16', { basic_html: `` }); + const fallback = await call('nasa_apod', { date: '1995-06-16' }, [ + [`${APOD_API}/950616`, () => jsonResponse(gif)], + ['assets.science.nasa.gov/dynamicimage', () => textResponse('not found', { status: 404 })], + ['assets.science.nasa.gov/content/dam', () => new Response(PNG, { headers: { 'content-type': 'image/gif' } })] + ]); + assert.deepEqual( + fallback.calls.map((c) => c.url.pathname.split('/')[1]), + ['wp-json', 'dynamicimage', 'content'] + ); + assert.equal(fallback.result.content.filter((c) => c.type === 'image').length, 1); + + const missing = await call('nasa_apod', { date: '1995-06-17' }, [ + [`${APOD_API}/950617`, () => jsonResponse({ code: 'apod_basic_not_found', message: 'APOD not found.', data: { status: 404 } }, { status: 404 })] + ]); + assert.equal(missing.result.isError, true); + assert.match(missing.text, /has no picture for 1995-06-17 \(HTTP 404: APOD not found\)/); + }); + + it('never emits image content when the download is not an image', async () => { const { result } = await call('nasa_apod', { date: '2024-01-01' }, [ - ['api.nasa.gov/planetary/apod', () => jsonResponse({ date: '2024-01-01', title: 'T', media_type: 'image', url: 'https://apod.nasa.gov/a.jpg' })], - ['apod.nasa.gov', () => textResponse('not an image', { headers: { 'content-type': 'text/html' } })] + [`${APOD_API}/240101`, () => jsonResponse(apodPost('2024-01-01'))], + ['assets.science.nasa.gov', () => textResponse('not an image', { headers: { 'content-type': 'text/html' } })] ]); assert.equal(result.isError, undefined); assert.equal(result.content.some((c) => c.type === 'image'), false); assert.match(textOf(result), /not embedded: response was text\/html/); }); - it('nasa_apod validates date combinations and needs NASA_API_KEY', async () => { - const both = await call('nasa_apod', { date: '2024-01-01', count: 3 }, []); - assert.match(both.text, /count cannot be combined/); - const noKey = await call('nasa_apod', {}, [], { nasaApiKey: undefined }); - assert.match(noKey.text, /NASA_API_KEY is not set/); - assert.equal(noKey.calls.length, 0); + it('retires count and thumbs, and rejects future, pre-1995 and oversized requests before calling upstream', async () => { + const cases: Array<[Record, RegExp]> = [ + [{ count: 3 }, /count: The APOD API moved .* no random mode/], + [{ thumbs: true }, /thumbs: The new APOD API has no thumbs option/], + [{ date: '2026-09-30' }, /date 2026-09-30 is in the future \(today is 2026-09-29 UTC\)/], + [{ date: '1995-06-15' }, /APOD starts on 1995-06-16/], + [{ start_date: '2026-01-01' }, /limited to 100 days/], + [{ date: '2024-01-01', start_date: '2024-01-01' }, /cannot be combined/], + [{ end_date: '2024-01-01' }, /end_date requires start_date/] + ]; + for (const [args, pattern] of cases) { + const out = await call('nasa_apod', args, []); + assert.equal(out.result.isError, true, JSON.stringify(args)); + assert.match(out.text, pattern); + assert.equal(out.calls.length, 0); + } }); +}); +describe('api.nasa.gov tools', () => { it('nasa_neo enforces the 7-day feed window and supports asteroid lookup', async () => { const tooLong = await call('nasa_neo', { start_date: '2024-01-01', end_date: '2024-01-10' }, []); assert.match(tooLong.text, /limited to 7 days/); diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index 5ceb9a5..594b98e 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -10,11 +10,13 @@ import { fakeFetch } from '../helpers/fake-fetch'; const EXPECTED_TOOLS = [ 'nasa_apod', 'nasa_neo', 'nasa_epic', 'nasa_gibs', 'nasa_cmr', 'nasa_firms', 'nasa_images', 'nasa_exoplanet', 'nasa_donki', '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' + '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' ]; describe('tool registry', () => { - it('advertises every tool from 1.0.14 exactly once, in a stable order', () => { + it('advertises every tool exactly once, in a stable order', () => { const names = listTools().map((tool) => tool.name); assert.deepEqual([...names].sort(), [...EXPECTED_TOOLS].sort()); assert.equal(new Set(names).size, names.length); diff --git a/test/unit/server.test.ts b/test/unit/server.test.ts index 74c6e98..4308c45 100644 --- a/test/unit/server.test.ts +++ b/test/unit/server.test.ts @@ -19,7 +19,7 @@ function cmrAndApod(): FakeFetch { return jsonResponse(feed([jsonCollection(1, { title: `result for ${keyword}` })]), { headers: { 'cmr-hits': '1' } }); } ], - ['api.nasa.gov/planetary/apod', (request) => jsonResponse({ date: request.url.searchParams.get('date'), title: 'Real APOD', media_type: 'other' })], + ['science.nasa.gov/wp-json/wp/v2/apod-basic/', (request) => jsonResponse({ date: `20${request.url.pathname.slice(-6, -4)}-${request.url.pathname.slice(-4, -2)}-${request.url.pathname.slice(-2)}`, title: 'Real APOD', media_type: 'other', basic_html: '' })], ['ssd-api.jpl.nasa.gov/sbdb.api', () => jsonResponse({ object: { fullname: '1 Ceres (A801 AA)' } })] ]); } @@ -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, 23); + assert.equal(manifest.apis.length, 32); assert.ok(manifest.apis.some((api) => api.name === 'nasa_mars_rover' && api.id === 'nasa/mars_rover')); await client.close(); }); @@ -94,10 +94,13 @@ describe('MCP server', () => { const { client, fake } = await connect(); const read = await client.readResource({ uri: 'nasa://apod/image?date=2024-01-01' }); const body = JSON.parse((read.contents[0] as { text: string }).text) as { source: { url: string; retrieved_at: string }; data: { title: string } }; - assert.equal(body.data.title, 'Real APOD'); + const data = body.data as { title: string; date: string; basic_html?: string }; + assert.equal(data.title, 'Real APOD'); + assert.equal(data.date, '2024-01-01'); + assert.equal('basic_html' in data, false, 'the full HTML page is not retained'); assert.equal(body.source.retrieved_at, FIXED_NOW.toISOString()); - assert.match(body.source.url, /api_key=\[REDACTED\]/); - assert.equal(fake.calls.at(-1)!.url.searchParams.get('date'), '2024-01-01'); + assert.equal(body.source.url, 'https://science.nasa.gov/wp-json/wp/v2/apod-basic/240101'); + assert.equal(fake.calls.at(-1)!.url.pathname, '/wp-json/wp/v2/apod-basic/240101'); await assert.rejects(client.readResource({ uri: 'nasa://apod/image?date=not-a-date' }), /Invalid resource URI/); await assert.rejects(client.readResource({ uri: 'nasa://apod/image?date=2024-01-01&evil=1' }), /unsupported query parameter/); await assert.rejects(client.readResource({ uri: 'nasa://mars-rover/photo?rover=curiosity&id=1' }), (error: unknown) => error instanceof McpError && error.code === -32002); diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index 5c78cf8..ab8b03a 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { CsvParseError, parseCsv } from '../../src/util/csv'; import { UpstreamError } from '../../src/util/errors'; +import { decodeEntities, htmlToText } from '../../src/util/html'; import { httpRequest, summarizeErrorBody } from '../../src/util/http'; import { clearRegisteredSecrets, redact, redactDeep, registerSecret } from '../../src/util/redact'; import { boundingBoxSchema, canonicalJson, formatNumber, isIsoDate } from '../../src/util/validation'; @@ -53,6 +54,18 @@ describe('redact', () => { }); }); +describe('HTML to text', () => { + it('decodes numeric and named entities and leaves unknown ones alone', () => { + assert.equal(decodeEntities('A & B ’s — ’x‘ &bogus;'), 'A & B ’s — ’x‘ &bogus;'); + }); + + it('keeps line and paragraph breaks, drops tags and scripts, and collapses whitespace', () => { + const html = 'Explanation: A link here.

Second

para

end '; + assert.equal(htmlToText(html), 'Explanation: A link here.\n\nSecond\n\npara\n\nend'); + assert.equal(htmlToText('<b>literal</b>'), 'literal', 'escaped markup stays as text'); + }); +}); + describe('validation helpers', () => { it('validates real calendar dates', () => { assert.equal(isIsoDate('2024-02-29'), true); @@ -121,6 +134,7 @@ describe('httpRequest', () => { }); assert.equal(summarizeErrorBody('Invalid MAP_KEY.', 'text/plain'), 'Invalid MAP_KEY.'); assert.equal(summarizeErrorBody('{"errors":["a","b"]}', 'application/json'), 'a; b'); + assert.equal(summarizeErrorBody('{"response":{"message":"[page-size] too big"}}', 'application/json'), '[page-size] too big'); }); it('reports network failures and invalid JSON distinctly', async () => { From 7429df704d5ee7aedce48e2f39754135a827ce01 Mon Sep 17 00:00:00 2001 From: ProgramComputer <22284856+ProgramComputer@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:47:56 -0500 Subject: [PATCH 2/2] Move nasa_donki to the NASA CCMC DONKI API, which no longer needs NASA_API_KEY --- .env.example | 4 ++-- CHANGELOG.md | 1 + README.md | 5 ++-- docs/TOOLS.md | 2 +- docs/inspector-test-examples.md | 14 +++++++---- src/config.ts | 2 +- src/handlers/nasa/donki.ts | 41 +++++++++++++++++++-------------- src/index.ts | 2 +- test/unit/handlers.test.ts | 20 +++++++++++++--- 9 files changed, 60 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index 9f1a6ed..1844cf5 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Copy to .env (never commit .env). Values here are placeholders. -# api.nasa.gov key for NEO, DONKI and InSight weather. +# api.nasa.gov key for NEO and InSight weather. # Get one at https://api.nasa.gov/ (DEMO_KEY works for light use). -# APOD, TLE, SSC, TechPort, TechTransfer, Trek and the JPL tools need no key. +# APOD, DONKI, TLE, SSC, TechPort, TechTransfer, Trek and the JPL tools need no key. NASA_API_KEY=YOUR_NASA_API_KEY # FIRMS MAP_KEY for nasa_firms (separate from NASA_API_KEY). diff --git a/CHANGELOG.md b/CHANGELOG.md index 30e2b9f..292edd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Titles, credits and explanations are plain text (the new API returns HTML). Video entries report the video URL and a still frame. Embedded images are 1024-pixel-wide renditions when the image host offers them. - The `nasa://apod/image` resource returns one entry object, without the upstream `basic_html` page. - The `nasa/get-astronomy-picture` and `apod-daily` prompts no longer take `count` or `thumbs`. +- **`nasa_donki` uses the NASA CCMC DONKI API** (`ccmc.gsfc.nasa.gov/DONKI-API/get`). CCMC moved the public API there on 2026-09-30, and `api.nasa.gov/DONKI/*` now redirects to a CCMC news page instead of returning data. CCMC kept the parameters and response formats. The new API needs no key, so `nasa_donki` works without `NASA_API_KEY`. ### New tools diff --git a/README.md b/README.md index f606ebb..936492c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Big thanks to the MCP community for their support and guidance! Ask your MCP client things like: * "Show me today's Astronomy Picture of the Day and explain what it shows." +* "Were there any solar storms in September 2026: strong flares, Earth-directed CMEs or geomagnetic storms?" * "Which asteroids fly past Earth today, and which one comes closest?" * "Will any asteroid pass closer to Earth than the Moon in the next 90 days?" * "What are the odds that Bennu hits Earth, according to JPL Sentry?" @@ -80,7 +81,7 @@ The server can be configured with the following environment variables: | Variable | Description | |----------|-------------| -| `NASA_API_KEY` | Your NASA API key (get at api.nasa.gov); used by NEO, DONKI and InSight weather | +| `NASA_API_KEY` | Your NASA API key (get at api.nasa.gov); used by NEO and InSight weather | | `FIRMS_MAP_KEY` | Your FIRMS MAP_KEY for fire data (get at firms.modaps.eosdis.nasa.gov/api/map_key); separate from `NASA_API_KEY` | | `NASA_MCP_CMR_URL` | Optional CMR search URL (default: `https://cmr.earthdata.nasa.gov/search`) | | `MCP_TRANSPORT` | Transport mode: `stdio` (default) or `http` for Streamable HTTP | @@ -110,11 +111,11 @@ This MCP server integrates the following NASA APIs: 1. **NASA Open API** (api.nasa.gov, needs `NASA_API_KEY`): - NEO (Near Earth Object Web Service) - - DONKI (Space Weather Database Of Notifications, Knowledge, Information) - InSight Mars Weather Service (historical: the feed stopped updating in October 2020) 2. **Other NASA APIs** (no key needed): - APOD (Astronomy Picture of the Day), from the NASA Science APOD API (science.nasa.gov) + - DONKI (Space Weather Database Of Notifications, Knowledge, Information), from NASA CCMC (ccmc.gsfc.nasa.gov) - EONET (Earth Observatory Natural Event Tracker) - TLE (Two-Line Element sets from CelesTrak, via tle.ivanstanojevic.me) - Satellite Situation Center (spacecraft locations) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index e4417d5..0487495 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -157,7 +157,7 @@ Aliases: `nasa/exoplanet` ## `nasa_donki` — NASA DONKI space weather -Space Weather Database Of Notifications, Knowledge, Information (DONKI) events by type and date range. Requires NASA_API_KEY. +Space Weather Database Of Notifications, Knowledge, Information (DONKI) events by type and date range, from the NASA CCMC DONKI API (ccmc.gsfc.nasa.gov). No API key needed. Aliases: `nasa/donki` diff --git a/docs/inspector-test-examples.md b/docs/inspector-test-examples.md index f56eab1..2fb6700 100644 --- a/docs/inspector-test-examples.md +++ b/docs/inspector-test-examples.md @@ -83,10 +83,6 @@ EPIC natural-colour images: { "name": "nasa_neo", "arguments": { "start_date": "2024-01-01", "end_date": "2024-01-03" } } ``` -```json -{ "name": "nasa_donki", "arguments": { "type": "cme", "startDate": "2024-01-01", "endDate": "2024-01-10" } } -``` - InSight Mars weather (historical; the feed stopped in October 2020): ```json @@ -95,6 +91,16 @@ InSight Mars weather (historical; the feed stopped in October 2020): ## Other NASA services (no key needed) +DONKI space weather events (solar flares, CMEs, geomagnetic storms and more): + +```json +{ "name": "nasa_donki", "arguments": { "type": "cme", "startDate": "2024-01-01", "endDate": "2024-01-10" } } +``` + +```json +{ "name": "nasa_donki", "arguments": { "type": "flr", "startDate": "2026-09-01", "endDate": "2026-09-29" } } +``` + Astronomy Picture of the Day: the latest picture, one date, or a range of up to 100 days: ```json diff --git a/src/config.ts b/src/config.ts index cb250ec..5c00737 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,7 +3,7 @@ import { registerSecret } from './util/redact'; export const DEFAULT_CMR_BASE_URL = 'https://cmr.earthdata.nasa.gov/search'; export interface ServerConfig { - /** api.nasa.gov key: NEO, DONKI, InSight weather, Mars Rover Photos. */ + /** api.nasa.gov key: NEO, InSight weather, Mars Rover Photos. */ nasaApiKey?: string; /** FIRMS MAP_KEY (distinct from the api.nasa.gov key). */ firmsMapKey?: string; diff --git a/src/handlers/nasa/donki.ts b/src/handlers/nasa/donki.ts index 63afa66..e094ec7 100644 --- a/src/handlers/nasa/donki.ts +++ b/src/handlers/nasa/donki.ts @@ -1,22 +1,29 @@ import { z } from 'zod'; import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { httpRequest } from '../../util/http'; import { daysBetween, isoDate } from '../../util/validation'; -import { boundedText, json, nasaApiGet, sourceInfo, text } from '../common'; +import { boundedText, buildUrl, json, sourceInfo, text } from '../common'; const SERVICE = 'NASA DONKI API'; +/** + * CCMC's public DONKI API, which needs no key. It moved here from + * kauai.ccmc.gsfc.nasa.gov/DONKI/WS/get on 2026-09-30, and api.nasa.gov/DONKI + * now redirects to a CCMC news page instead of returning data. + */ +const DONKI_API_BASE_URL = 'https://ccmc.gsfc.nasa.gov/DONKI-API/get'; const TYPE_ENDPOINTS = { - cme: '/DONKI/CME', - cmea: '/DONKI/CMEAnalysis', - gst: '/DONKI/GST', - ips: '/DONKI/IPS', - flr: '/DONKI/FLR', - sep: '/DONKI/SEP', - mpc: '/DONKI/MPC', - rbe: '/DONKI/RBE', - hss: '/DONKI/HSS', - wsa: '/DONKI/WSAEnlilSimulations', - notifications: '/DONKI/notifications' + cme: '/CME', + cmea: '/CMEAnalysis', + gst: '/GST', + ips: '/IPS', + flr: '/FLR', + sep: '/SEP', + mpc: '/MPC', + rbe: '/RBE', + hss: '/HSS', + wsa: '/WSAEnlilSimulations', + notifications: '/notifications' } as const; type DonkiType = keyof typeof TYPE_ENDPOINTS; @@ -39,14 +46,14 @@ export const donkiInputSchema = z export const donkiTool = defineTool({ name: 'nasa_donki', title: 'NASA DONKI space weather', - description: 'Space Weather Database Of Notifications, Knowledge, Information (DONKI) events by type and date range. Requires NASA_API_KEY.', + description: + 'Space Weather Database Of Notifications, Knowledge, Information (DONKI) events by type and date range, from the NASA CCMC DONKI API ' + + '(ccmc.gsfc.nasa.gov). No API key needed.', inputSchema: donkiInputSchema, annotations: READ_ONLY_REMOTE, async handler({ args, ctx }) { - const response = await nasaApiGet(ctx, SERVICE, TYPE_ENDPOINTS[args.type], { - startDate: args.startDate, - endDate: args.endDate - }); + const url = buildUrl(`${DONKI_API_BASE_URL}${TYPE_ENDPOINTS[args.type]}`, { startDate: args.startDate, endDate: args.endDate }); + const response = await httpRequest(ctx.fetch, { service: SERVICE, url }); // DONKI answers "no events" with an empty body on some endpoints. const data = response.text().trim() ? response.json() : []; const source = sourceInfo(ctx, SERVICE, response.url); diff --git a/src/index.ts b/src/index.ts index 2f01641..a7e84b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ Model Context Protocol server for NASA and JPL public APIs. Usage: nasa-mcp-server [--nasa-api-key KEY] [--firms-map-key KEY] [--version] [--help] Environment: - NASA_API_KEY api.nasa.gov key (NEO, DONKI, InSight weather, Mars Rover Photos) + NASA_API_KEY api.nasa.gov key (NEO, InSight weather, Mars Rover Photos) FIRMS_MAP_KEY FIRMS MAP_KEY (nasa_firms) MCP_TRANSPORT stdio (default) or http MCP_HTTP_HOST HTTP bind host (default 127.0.0.1) diff --git a/test/unit/handlers.test.ts b/test/unit/handlers.test.ts index 831fa47..5ebbf8a 100644 --- a/test/unit/handlers.test.ts +++ b/test/unit/handlers.test.ts @@ -178,11 +178,25 @@ describe('api.nasa.gov tools', () => { assert.equal(today.calls[0].url.searchParams.get('start_date'), '2026-09-29'); }); - it('nasa_donki handles an empty body as no events and lower-cases type', async () => { - const { result, calls } = await call('nasa_donki', { type: 'FLR', startDate: '2024-01-01' }, [['api.nasa.gov/DONKI/FLR', () => new Response('', { status: 200 })]]); + it('nasa_donki queries the CCMC DONKI API without a key, handles empty results and lower-cases type', async () => { + const { result, calls } = await call('nasa_donki', { type: 'FLR', startDate: '2024-01-01' }, [['ccmc.gsfc.nasa.gov/DONKI-API/get/FLR', () => new Response('', { status: 200 })]], { + nasaApiKey: undefined + }); assert.equal(result.isError, undefined); - assert.match(textOf(result), /No DONKI FLR events/); + assert.match(textOf(result), /No DONKI FLR events from 2024-01-01/); + assert.equal(calls[0].url.pathname, '/DONKI-API/get/FLR'); assert.equal(calls[0].url.searchParams.get('startDate'), '2024-01-01'); + assert.equal(calls[0].url.searchParams.has('api_key'), false); + assert.equal(calls[0].url.searchParams.has('endDate'), false, 'DONKI applies its own default end date'); + + const empty = await call('nasa_donki', { type: 'gst', startDate: '2010-01-01', endDate: '2010-01-02' }, [['ccmc.gsfc.nasa.gov/DONKI-API/get/GST', () => jsonResponse([])]]); + assert.match(empty.text, /No DONKI GST events from 2010-01-01 to 2010-01-02\./); + + const wsa = await call('nasa_donki', { type: 'wsa', startDate: '2026-09-01', endDate: '2026-09-29' }, [ + ['ccmc.gsfc.nasa.gov/DONKI-API/get/WSAEnlilSimulations', () => jsonResponse([{ simulationID: 'WSA-ENLIL/1' }, { simulationID: 'WSA-ENLIL/2' }])] + ]); + assert.match(wsa.text, /^Retrieved 2 WSA records from 2026-09-01 to 2026-09-29\./); + assert.match(wsa.text, /WSA-ENLIL\/1/); }); it('nasa_mars_rover reports the retired upstream accurately instead of fabricating photos', async () => {