diff --git a/CHANGELOG.md b/CHANGELOG.md index 292edd0..ec1fefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Changelog -## Unreleased +## 1.2.0 + +A migration release. APOD and DONKI moved to new upstream APIs (the old api.nasa.gov endpoints no longer return data), and nine tools were added. It is published as a minor version, but **`nasa_apod` and `nasa_donki` parameters and output changed**. Review the list below before upgrading, or pin `1.1.0`; note that APOD and DONKI no longer work in 1.1.0. ### Breaking changes and migration @@ -10,7 +12,8 @@ - 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`. +- **`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. The new API needs no key, so `nasa_donki` works without `NASA_API_KEY`. CCMC kept the parameters and record formats but now rejects ranges longer than 30 days. The tool checks this before calling, and with only `endDate` it requests the 30 days before it (CCMC would count back from today and fail). +- **`nasa_donki` returns one line per event by default** (`response_mode: "compact"`), with the key fields for each type: flare class and peak time, CME speed, cone angle and modeled Earth arrival, the highest Kp of a geomagnetic storm, and so on. `response_mode: "raw"` returns the full records as before, and `limit` keeps the most recent N events. The retained resource always has the full records. A month of CMEs is about 21 KB compact; the full records (about 270 KB) exceeded the output limit. ### New tools diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0487495..5b373d5 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -157,15 +157,17 @@ Aliases: `nasa/exoplanet` ## `nasa_donki` — NASA DONKI space weather -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. +Space Weather Database Of Notifications, Knowledge, Information (DONKI) events by type for up to 30 days at a time, from the NASA CCMC DONKI API (ccmc.gsfc.nasa.gov): solar flares, CMEs and their analyses, geomagnetic storms, interplanetary shocks, SEP, MPC, RBE and HSS events, WSA-ENLIL simulations and notifications. Compact one-line summaries by default; response_mode raw returns the full records. No API key needed. Aliases: `nasa/donki` | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `type` | `"cme"` \\| `"cmea"` \\| `"gst"` \\| `"ips"` \\| `"flr"` \\| `"sep"` \\| `"mpc"` \\| `"rbe"` \\| `"hss"` \\| `"wsa"` \\| `"notifications"` | yes | | Event type: cme, cmea, gst, ips, flr, sep, mpc, rbe, hss, wsa, notifications. | -| `startDate` | string | | | Start date (YYYY-MM-DD). DONKI defaults to 30 days before endDate. | -| `endDate` | string | | | End date (YYYY-MM-DD). DONKI defaults to today. | +| `startDate` | string | | | Start date (YYYY-MM-DD). With only endDate, 30 days before endDate; with neither, the last 30 days. | +| `endDate` | string | | | End date (YYYY-MM-DD), at most 30 days after startDate. Defaults to today. | +| `response_mode` | `"compact"` \\| `"raw"` | | `"compact"` | compact (default): one line per event with its key fields. raw: the full DONKI records as JSON (large for cme, cmea and wsa). | +| `limit` | integer (1–1000) | | | Return only the most recent N events. Applied by this server; DONKI has no limit parameter. | ## `nasa_mars_rover` — NASA Mars Rover Photos (retired upstream) diff --git a/docs/inspector-test-examples.md b/docs/inspector-test-examples.md index 2fb6700..97bb1b7 100644 --- a/docs/inspector-test-examples.md +++ b/docs/inspector-test-examples.md @@ -91,7 +91,7 @@ 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): +DONKI space weather events (solar flares, CMEs, geomagnetic storms and more), up to 30 days per call. Results are one line per event; `response_mode: "raw"` returns the full records: ```json { "name": "nasa_donki", "arguments": { "type": "cme", "startDate": "2024-01-01", "endDate": "2024-01-10" } } @@ -101,6 +101,10 @@ DONKI space weather events (solar flares, CMEs, geomagnetic storms and more): { "name": "nasa_donki", "arguments": { "type": "flr", "startDate": "2026-09-01", "endDate": "2026-09-29" } } ``` +```json +{ "name": "nasa_donki", "arguments": { "type": "wsa", "startDate": "2026-09-01", "endDate": "2026-09-29", "response_mode": "raw", "limit": 3 } } +``` + Astronomy Picture of the Day: the latest picture, one date, or a range of up to 100 days: ```json diff --git a/package-lock.json b/package-lock.json index 9547b12..38fb5ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@programcomputer/nasa-mcp-server", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@programcomputer/nasa-mcp-server", - "version": "1.1.0", + "version": "1.2.0", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.31.0", diff --git a/package.json b/package.json index 49a576d..cdec963 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@programcomputer/nasa-mcp-server", - "version": "1.1.0", + "version": "1.2.0", "description": "Model Context Protocol (MCP) server for NASA and JPL public APIs", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/handlers/nasa/donki.ts b/src/handlers/nasa/donki.ts index e094ec7..7b59da5 100644 --- a/src/handlers/nasa/donki.ts +++ b/src/handlers/nasa/donki.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; import { defineTool, READ_ONLY_REMOTE } from '../../tools/types'; +import { ToolInputError } from '../../util/errors'; import { httpRequest } from '../../util/http'; -import { daysBetween, isoDate } from '../../util/validation'; +import { daysBetween, isoDate, todayUtc } from '../../util/validation'; import { boundedText, buildUrl, json, sourceInfo, text } from '../common'; const SERVICE = 'NASA DONKI API'; @@ -11,6 +12,8 @@ const SERVICE = 'NASA DONKI API'; * now redirects to a CCMC news page instead of returning data. */ const DONKI_API_BASE_URL = 'https://ccmc.gsfc.nasa.gov/DONKI-API/get'; +/** The CCMC API rejects longer ranges with HTTP 400. */ +const MAX_RANGE_DAYS = 30; const TYPE_ENDPOINTS = { cme: '/CME', @@ -34,35 +37,143 @@ export const donkiInputSchema = z type: z .preprocess((value) => (typeof value === 'string' ? value.toLowerCase() : value), z.enum(DONKI_TYPES)) .describe('Event type: cme, cmea, gst, ips, flr, sep, mpc, rbe, hss, wsa, notifications.'), - startDate: isoDate('Start date (YYYY-MM-DD). DONKI defaults to 30 days before endDate.').optional(), - endDate: isoDate('End date (YYYY-MM-DD). DONKI defaults to today.').optional() + startDate: isoDate('Start date (YYYY-MM-DD). With only endDate, 30 days before endDate; with neither, the last 30 days.').optional(), + endDate: isoDate(`End date (YYYY-MM-DD), at most ${MAX_RANGE_DAYS} days after startDate. Defaults to today.`).optional(), + response_mode: z + .enum(['compact', 'raw']) + .default('compact') + .describe('compact (default): one line per event with its key fields. raw: the full DONKI records as JSON (large for cme, cmea and wsa).'), + limit: z.int().min(1).max(1000).describe('Return only the most recent N events. Applied by this server; DONKI has no limit parameter.').optional() }) .superRefine((args, ctx) => { - if (args.startDate && args.endDate && daysBetween(args.startDate, args.endDate) < 0) { - ctx.addIssue({ code: 'custom', path: ['endDate'], message: 'endDate must not be before startDate' }); + if (args.startDate && args.endDate) { + const span = daysBetween(args.startDate, args.endDate); + if (span < 0) ctx.addIssue({ code: 'custom', path: ['endDate'], message: 'endDate must not be before startDate' }); + if (span > MAX_RANGE_DAYS) { + ctx.addIssue({ code: 'custom', path: ['endDate'], message: `DONKI returns at most ${MAX_RANGE_DAYS} days per request` }); + } } }); +function addDays(date: string, days: number): string { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} + +/* eslint-disable @typescript-eslint/no-explicit-any -- loosely typed upstream JSON */ +const list = (items: unknown, pick: (item: any) => unknown): string[] => + Array.isArray(items) ? items.map(pick).filter((value): value is string => typeof value === 'string' && value !== '') : []; + +function common(event: any): string { + const instruments = list(event.instruments, (i) => i?.displayName); + const linked = list(event.linkedEvents, (l) => l?.activityID); + return `${instruments.length ? `; instruments: ${instruments.join(', ')}` : ''}${linked.length ? `; linked: ${linked.join(', ')}` : ''}`; +} + +function sourceText(event: any): string { + const where = [event.sourceLocation || undefined, event.activeRegionNum ? `AR ${event.activeRegionNum}` : undefined].filter(Boolean); + return where.length ? `, source ${where.join(' ')}` : ''; +} + +function earthImpact(enlil: any): string { + if (!enlil?.estimatedShockArrivalTime) return 'no Earth arrival modeled'; + const kp = [enlil.kp_18, enlil.kp_90, enlil.kp_135, enlil.kp_180].filter((k) => typeof k === 'number'); + const kind = enlil.isEarthGB ? 'glancing blow' : enlil.isEarthMinorImpact ? 'minor impact' : 'impact'; + return `Earth arrival ${enlil.estimatedShockArrivalTime} (${kind}${kp.length ? `, Kp up to ${Math.max(...kp)}` : ''})`; +} + +/** One line per event with the fields that matter for that event type. */ +function describe(type: DonkiType, e: any): string { + switch (type) { + case 'flr': + return `${e.flrID}: class ${e.classType ?? '?'}, peak ${e.peakTime ?? '?'} (${e.beginTime ?? '?'} to ${e.endTime ?? '?'})${sourceText(e)}${common(e)}`; + case 'cme': { + const analyses = Array.isArray(e.cmeAnalyses) ? e.cmeAnalyses : []; + const best = analyses.find((a: any) => a?.isMostAccurate) ?? analyses[0]; + const fit = best + ? `; ${best.speed ?? '?'} km/s, half-angle ${best.halfAngle ?? '?'}°, type ${best.type ?? '?'}, lat ${best.latitude ?? '?'} lon ${best.longitude ?? '?'}` + + (Array.isArray(best.enlilList) && best.enlilList.length ? `; ${earthImpact(best.enlilList.at(-1))}` : '') + : '; no analysis'; + return `${e.activityID}: start ${e.startTime ?? '?'}${sourceText(e)}${fit}${common(e)}`; + } + case 'cmea': + return ( + `${e.associatedCMEID ?? '?'}: ${e.speed ?? '?'} km/s, half-angle ${e.halfAngle ?? '?'}°, type ${e.type ?? '?'}, lat ${e.latitude ?? '?'} lon ${e.longitude ?? '?'}` + + `, at 21.5 solar radii ${e.time21_5 ?? '?'}${e.isMostAccurate ? ', most accurate' : ''}${e.measurementTechnique ? `, ${e.measurementTechnique}` : ''}` + ); + case 'gst': { + const readings = Array.isArray(e.allKpIndex) ? e.allKpIndex.filter((k: any) => typeof k?.kpIndex === 'number') : []; + const peak = readings.reduce((max: any, k: any) => (!max || k.kpIndex > max.kpIndex ? k : max), undefined); + return `${e.gstID}: start ${e.startTime ?? '?'}${peak ? `, max Kp ${peak.kpIndex} at ${peak.observedTime} (${peak.source})` : ''}${common({ linkedEvents: e.linkedEvents })}`; + } + case 'ips': + return `${e.activityID}: ${e.eventTime ?? '?'} at ${e.location ?? '?'}${common(e)}`; + case 'wsa': { + const cmes = list(e.cmeInputs, (c) => (c?.CMEID ? `${c.CMEID} (${c.speed ?? '?'} km/s)` : undefined)); + const impacts = list(e.impactList, (i) => (i?.location ? `${i.location} ${i.arrivalTime ?? '?'}` : undefined)); + return ( + `${e.simulationID}: completed ${e.modelCompletionTime ?? '?'}${cmes.length ? `, CMEs ${cmes.join(', ')}` : ''}; ${earthImpact(e)}` + + `${impacts.length ? `; other arrivals: ${impacts.join(', ')}` : ''}` + ); + } + case 'notifications': { + const body = typeof e.messageBody === 'string' ? e.messageBody.replace(/[#*]+/g, ' ').replace(/\s+/g, ' ').trim() : ''; + return `${e.messageIssueTime ?? '?'} ${e.messageType ?? '?'} ${e.messageID ?? '?'}${body ? `: ${body.length > 200 ? `${body.slice(0, 200)}…` : body}` : ''} ${e.messageURL ?? ''}`.trim(); + } + default: { + const id = e.sepID ?? e.mpcID ?? e.rbeID ?? e.hssID ?? e.activityID ?? '?'; + return `${id}: ${e.eventTime ?? '?'}${common(e)}`; + } + } +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + 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, from the NASA CCMC DONKI API ' + - '(ccmc.gsfc.nasa.gov). No API key needed.', + 'Space Weather Database Of Notifications, Knowledge, Information (DONKI) events by type for up to 30 days at a time, from the NASA CCMC DONKI API ' + + '(ccmc.gsfc.nasa.gov): solar flares, CMEs and their analyses, geomagnetic storms, interplanetary shocks, SEP, MPC, RBE and HSS events, ' + + 'WSA-ENLIL simulations and notifications. Compact one-line summaries by default; response_mode raw returns the full records. No API key needed.', inputSchema: donkiInputSchema, annotations: READ_ONLY_REMOTE, async handler({ args, ctx }) { - const url = buildUrl(`${DONKI_API_BASE_URL}${TYPE_ENDPOINTS[args.type]}`, { startDate: args.startDate, endDate: args.endDate }); + const today = todayUtc(ctx.now()); + let startDate = args.startDate; + // Given only endDate, CCMC counts its 30-day default back from today, not from endDate. + if (!startDate && args.endDate) startDate = addDays(args.endDate, -MAX_RANGE_DAYS); + if (startDate && !args.endDate) { + if (startDate > today) throw new ToolInputError(`startDate ${startDate} is in the future (today is ${today} UTC)`); + const span = daysBetween(startDate, today); + if (span > MAX_RANGE_DAYS) { + throw new ToolInputError( + `DONKI returns at most ${MAX_RANGE_DAYS} days per request, and startDate ${startDate} is ${span} days before today; ` + + `also set endDate (at most ${addDays(startDate, MAX_RANGE_DAYS)})` + ); + } + } + + const url = buildUrl(`${DONKI_API_BASE_URL}${TYPE_ENDPOINTS[args.type]}`, { startDate, endDate: args.endDate }); const response = await httpRequest(ctx.fetch, { service: SERVICE, url }); - // DONKI answers "no events" with an empty body on some endpoints. + // DONKI has answered "no events" with an empty body on some endpoints. const data = response.text().trim() ? response.json() : []; const source = sourceInfo(ctx, SERVICE, response.url); - const count = Array.isArray(data) ? data.length : null; - const range = `${args.startDate ? ` from ${args.startDate}` : ''}${args.endDate ? ` to ${args.endDate}` : ''}`; - const summary = count === 0 ? `No DONKI ${args.type.toUpperCase()} events${range}.` : `Retrieved ${count ?? 'DONKI'} ${args.type.toUpperCase()} records${range}.`; + const records = Array.isArray(data) ? data : []; + const shown = args.limit !== undefined && records.length > args.limit ? records.slice(-args.limit) : records; + const label = args.type.toUpperCase(); + const range = `${startDate ? ` from ${startDate}` : ''}${args.endDate ? ` to ${args.endDate}` : ''}`; + const summary = + records.length === 0 + ? `No DONKI ${label} events${range}.` + : `Retrieved ${records.length} ${label} records${range}${shown.length < records.length ? `; showing the ${shown.length} most recent` : ''}.`; + const body = + records.length === 0 + ? [] + : args.response_mode === 'raw' + ? [boundedText(json(shown), SERVICE, 'Use response_mode compact, limit, or a shorter date range.')] + : [boundedText(shown.map((event) => `- ${describe(args.type, event)}`).join('\n'), SERVICE, 'Use limit or a shorter date range.')]; return { - content: [text(summary), ...(count === 0 ? [] : [boundedText(json(data), SERVICE, 'Use a shorter date range.')])], - resource: { name: `DONKI ${args.type.toUpperCase()}${range}`, mimeType: 'application/json', text: json({ source, data }), source } + content: [text(summary), ...body], + resource: { name: `DONKI ${label}${range}`, mimeType: 'application/json', text: json({ source, data }), source } }; } }); diff --git a/test/unit/handlers.test.ts b/test/unit/handlers.test.ts index 5ebbf8a..2e3e79c 100644 --- a/test/unit/handlers.test.ts +++ b/test/unit/handlers.test.ts @@ -178,25 +178,76 @@ describe('api.nasa.gov tools', () => { assert.equal(today.calls[0].url.searchParams.get('start_date'), '2026-09-29'); }); - 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 })]], { + it('nasa_donki queries the CCMC DONKI API without a key and fills in the 30-day window DONKI expects', async () => { + const { result, calls } = await call('nasa_donki', { type: 'FLR', startDate: '2026-09-10' }, [['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 from 2024-01-01/); + assert.match(textOf(result), /No DONKI FLR events from 2026-09-10\./); 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.get('startDate'), '2026-09-10'); 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'); + assert.equal(calls[0].url.searchParams.has('endDate'), false, 'DONKI defaults the end to today'); - 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\./); + // CCMC counts its default start back from today, so an old endDate alone would fail upstream. + const endOnly = await call('nasa_donki', { type: 'gst', endDate: '2010-01-31' }, [['ccmc.gsfc.nasa.gov/DONKI-API/get/GST', () => jsonResponse([])]]); + assert.equal(endOnly.calls[0].url.searchParams.get('startDate'), '2010-01-01'); + assert.equal(endOnly.calls[0].url.searchParams.get('endDate'), '2010-01-31'); + assert.match(endOnly.text, /No DONKI GST events from 2010-01-01 to 2010-01-31\./); - 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/); + for (const [args, pattern] of [ + [{ type: 'flr', startDate: '2026-08-01', endDate: '2026-09-29' }, /endDate: DONKI returns at most 30 days per request/], + [{ type: 'flr', startDate: '2026-01-01' }, /startDate 2026-01-01 is 271 days before today; also set endDate \(at most 2026-01-31\)/], + [{ type: 'flr', startDate: '2026-10-01' }, /startDate 2026-10-01 is in the future/], + [{ type: 'flr', startDate: '2026-09-02', endDate: '2026-09-01' }, /endDate must not be before startDate/], + [{ type: 'flr', response_mode: 'full' }, /response_mode/] + ] as Array<[Record, RegExp]>) { + const out = await call('nasa_donki', args, []); + assert.equal(out.result.isError, true, JSON.stringify(args)); + assert.match(out.text, pattern); + assert.equal(out.calls.length, 0); + } + }); + + it('nasa_donki summarizes each event on one line by default and returns full records on request', async () => { + const flares = [ + { flrID: '2026-09-01T21:04:00-FLR-001', classType: 'C5.7', beginTime: '2026-09-01T21:04Z', peakTime: '2026-09-01T21:10Z', endTime: '2026-09-01T21:12Z', sourceLocation: 'N15E90', activeRegionNum: 14524, instruments: [{ displayName: 'GOES-P: EXIS 1.0-8.0' }], linkedEvents: [{ activityID: '2026-09-01T23:00:00-CME-001' }], note: 'long note' }, + { flrID: '2026-09-02T18:57:00-FLR-001', classType: 'M3.0', beginTime: '2026-09-02T18:57Z', peakTime: '2026-09-02T19:20Z', endTime: '2026-09-02T19:48Z', sourceLocation: 'N12E90', activeRegionNum: null, linkedEvents: null } + ]; + const flr = await call('nasa_donki', { type: 'flr', startDate: '2026-09-01', endDate: '2026-09-29' }, [['ccmc.gsfc.nasa.gov/DONKI-API/get/FLR', () => jsonResponse(flares)]]); + assert.equal( + flr.text, + 'Retrieved 2 FLR records from 2026-09-01 to 2026-09-29.\n' + + '- 2026-09-01T21:04:00-FLR-001: class C5.7, peak 2026-09-01T21:10Z (2026-09-01T21:04Z to 2026-09-01T21:12Z), source N15E90 AR 14524; instruments: GOES-P: EXIS 1.0-8.0; linked: 2026-09-01T23:00:00-CME-001\n' + + '- 2026-09-02T18:57:00-FLR-001: class M3.0, peak 2026-09-02T19:20Z (2026-09-02T18:57Z to 2026-09-02T19:48Z), source N12E90' + ); + + const cme = { + activityID: '2026-09-02T10:24:00-CME-001', + startTime: '2026-09-02T10:24Z', + sourceLocation: '', + cmeAnalyses: [ + { isMostAccurate: false, speed: 100 }, + { isMostAccurate: true, speed: 280, halfAngle: 38, type: 'S', latitude: 6, longitude: 27, enlilList: [{ estimatedShockArrivalTime: '2026-09-06T16:00Z', isEarthGB: true, kp_90: 2, kp_135: 3, kp_180: 3 }] } + ], + linkedEvents: [{ activityID: '2026-09-06T17:38:00-IPS-001' }] + }; + const gst = { gstID: '2026-06-05T15:00:00-GST-001', startTime: '2026-06-05T15:00Z', allKpIndex: [{ kpIndex: 5.67, observedTime: '2026-06-05T15:00Z', source: 'NOAA' }, { kpIndex: 6.33, observedTime: '2026-06-05T18:00Z', source: 'NOAA' }] }; + const notice = { messageType: 'CME', messageID: '20260929-AL-001', messageURL: 'https://ccmc.gsfc.nasa.gov/x', messageIssueTime: '2026-09-29T01:01Z', messageBody: '## Summary:\n\nA fast CME was detected.' }; + const lines = async (type: string, record: unknown) => + (await call('nasa_donki', { type, startDate: '2026-09-01', endDate: '2026-09-29' }, [['ccmc.gsfc.nasa.gov', () => jsonResponse([record])]])).text.split('\n')[1]; + assert.equal( + await lines('cme', cme), + '- 2026-09-02T10:24:00-CME-001: start 2026-09-02T10:24Z; 280 km/s, half-angle 38°, type S, lat 6 lon 27; Earth arrival 2026-09-06T16:00Z (glancing blow, Kp up to 3); linked: 2026-09-06T17:38:00-IPS-001' + ); + assert.equal(await lines('gst', gst), '- 2026-06-05T15:00:00-GST-001: start 2026-06-05T15:00Z, max Kp 6.33 at 2026-06-05T18:00Z (NOAA)'); + assert.equal(await lines('notifications', notice), '- 2026-09-29T01:01Z CME 20260929-AL-001: Summary: A fast CME was detected. https://ccmc.gsfc.nasa.gov/x'); + assert.equal(await lines('sep', { sepID: 'SEP-1', eventTime: '2026-09-05T15:45Z', linkedEvents: [{ activityID: 'FLR-1' }] }), '- SEP-1: 2026-09-05T15:45Z; linked: FLR-1'); + + const recent = await call('nasa_donki', { type: 'flr', startDate: '2026-09-01', endDate: '2026-09-29', limit: 1, response_mode: 'raw' }, [['ccmc.gsfc.nasa.gov', () => jsonResponse(flares)]]); + assert.match(recent.text, /^Retrieved 2 FLR records from 2026-09-01 to 2026-09-29; showing the 1 most recent\.\n\[/); + const raw = JSON.parse(recent.text.slice(recent.text.indexOf('\n') + 1)) as Array<{ flrID: string }>; + assert.deepEqual(raw.map((r) => r.flrID), ['2026-09-02T18:57:00-FLR-001']); }); it('nasa_mars_rover reports the retired upstream accurately instead of fabricating photos', async () => {