Skip to content

Example: NASA APOD (authed toolkit)

A toolkit that fetches NASA’s Astronomy Picture of the Day (APOD). NASA’s API is free but requires an API key, so this is the minimal example of the authed pattern: declare the secret in the manifest, store its value with trove secret set, and read it at call time with ctx.secret.

It builds directly on the USGS example — same one-tool shape, plus a key.

A free NASA API key takes ten seconds at api.nasa.gov (the demo key DEMO_KEY also works for light testing, but is heavily rate-limited). Keep the key out of your code — it goes in the Trove vault, not server.ts.

nasa-apod/
manifest.json
server.ts

The two new fields versus the no-auth example are secrets (the name you’ll read with ctx.secret) and the NASA host in egress.

manifest.json
{
"id": "nasa-apod",
"name": "NASA Astronomy Picture of the Day",
"description": "Fetch NASA's Astronomy Picture of the Day and its explanation.",
"version": "1.0.0",
"sdk": "@ontrove/mcp@^1",
"secrets": ["NASA_API_KEY"],
"egress": ["api.nasa.gov"],
"scopes": [],
"visibility": "private"
}
server.ts
import { defineMcpServer, z, ToolError } from "@ontrove/mcp";
export default defineMcpServer({
tools: [
{
name: "astronomy_picture",
title: "Astronomy Picture of the Day",
description:
"Fetch NASA's Astronomy Picture of the Day (APOD) for a given date (or today), " +
"including the title, explanation, and image/video URL.",
// Read-only over a public third-party API.
annotations: { readOnlyHint: true, openWorldHint: true },
input: z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD.")
.optional()
.describe("The APOD date (YYYY-MM-DD). Defaults to today."),
}),
output: z.object({
title: z.string(),
date: z.string(),
explanation: z.string(),
mediaType: z.enum(["image", "video"]),
url: z.string(),
copyright: z.string().optional(),
}),
async handler({ date }, ctx) {
// Pull the API key from the encrypted vault at call time — never bundled.
const apiKey = await ctx.secret("NASA_API_KEY");
const params = new URLSearchParams({ api_key: apiKey });
if (date) params.set("date", date);
const res = await ctx.fetch(
`https://api.nasa.gov/planetary/apod?${params.toString()}`,
{ headers: { accept: "application/json" } },
);
if (res.status === 403) {
throw new ToolError("NASA rejected the API key. Check NASA_API_KEY.", {
retryable: false,
});
}
if (res.status === 429) {
throw new ToolError("NASA rate limit reached, try again shortly.", {
retryable: true,
});
}
if (!res.ok) {
throw new ToolError("NASA APOD is temporarily unavailable.", { retryable: true });
}
const apod = (await res.json()) as {
title: string;
date: string;
explanation: string;
media_type: "image" | "video";
url: string;
copyright?: string;
};
const structured = {
title: apod.title,
date: apod.date,
explanation: apod.explanation,
mediaType: apod.media_type,
url: apod.url,
...(apod.copyright ? { copyright: apod.copyright.trim() } : {}),
};
return {
text: `${apod.title} (${apod.date})\n\n${apod.explanation}\n\n${apod.url}`,
structured,
};
},
},
],
});

Deploy first so the server exists, then seal the key into the vault by server slug:

Terminal window
trove mcp deploy
# ✓ deployed nasa-apod (version 1.0.0, building)
# nasa-apod__astronomy_picture
# Set the secret value (scoped to this server). Keep it out of shell history:
printf '%s' "$NASA_KEY" | trove secret set nasa-apod NASA_API_KEY --from-stdin
# ✓ secret 'NASA_API_KEY' sealed for nasa-apod

The value goes to the encrypted vault via the setServerSecret mutation — never to a database column or a log line. Until the secret is set, ctx.secret("NASA_API_KEY") (and therefore the tool) fails. Rotating the key is the same command again — no redeploy.

See Secrets and Auth for the full vault model.

Show me NASA’s picture of the day.

Claude calls nasa-apod__astronomy_picture({}). The handler reads the vaulted key, calls api.nasa.gov, and returns the title, explanation, and image URL — plus the structured object for any client that renders it.