Example: USGS Earthquakes (no-auth toolkit)
A worked, end-to-end toolkit over the public USGS earthquake feed. It needs no secrets and no auth — the feed is open — so it’s the smallest complete example of the @ontrove/mcp authoring contract: one tool, an output schema, the right annotations, and ctx.fetch egress.
By the end, quakes__recent_quakes appears in your Trove connection inside Claude — on desktop, web, and mobile.
What you’ll build
Section titled “What you’ll build”A single tool, recent_quakes, that returns recent earthquakes above a magnitude threshold near an optional location. It calls the USGS FDSN event service (earthquake.usgs.gov/fdsnws/event/1/query), which returns GeoJSON.
Project layout
Section titled “Project layout”usgs-quakes/ manifest.json server.tsmanifest.json
Section titled “manifest.json”No secrets, no scopes — only the one public host in egress.
{ "id": "quakes", "name": "USGS Earthquakes", "description": "Recent earthquakes from the public USGS feed.", "version": "1.0.0", "sdk": "@ontrove/mcp@^1", "secrets": [], "egress": ["earthquake.usgs.gov"], "scopes": [], "visibility": "private"}server.ts
Section titled “server.ts”import { defineMcpServer, z, ToolError } from "@ontrove/mcp";
export default defineMcpServer({ tools: [ { name: "recent_quakes", title: "Recent earthquakes", description: "List recent earthquakes from the USGS feed, optionally filtered by minimum " + "magnitude and a radius around a latitude/longitude. Use for questions like " + "'any big quakes near Tokyo this week?'.", // Read-only is auto-derived, but this tool reaches a public third-party API, // so we declare openWorldHint explicitly. annotations: { readOnlyHint: true, openWorldHint: true }, input: z.object({ minMagnitude: z .number() .min(0) .max(10) .default(4.5) .describe("Minimum magnitude. Defaults to 4.5 (newsworthy quakes)."), days: z .number() .int() .min(1) .max(30) .default(7) .describe("How many days back to search (1–30). Defaults to 7."), latitude: z.number().min(-90).max(90).optional().describe("Center latitude."), longitude: z.number().min(-180).max(180).optional().describe("Center longitude."), radiusKm: z .number() .min(1) .max(20000) .default(500) .describe("Search radius in km around the center point. Used only with lat/long."), limit: z.number().int().min(1).max(100).default(20).describe("Max quakes to return."), }), // A structured output schema → outputSchema + structuredContent. output: z.object({ count: z.number(), quakes: z.array( z.object({ place: z.string(), magnitude: z.number(), time: z.string(), url: z.string(), longitude: z.number(), latitude: z.number(), depthKm: z.number(), }), ), }), async handler(args, ctx) { const { minMagnitude, days, latitude, longitude, radiusKm, limit } = args;
const starttime = new Date(Date.now() - days * 86_400_000) .toISOString() .slice(0, 10);
const params = new URLSearchParams({ format: "geojson", starttime, minmagnitude: String(minMagnitude), orderby: "time", limit: String(limit), }); // Geographic filtering is optional — only when a center point is given. if (latitude !== undefined && longitude !== undefined) { params.set("latitude", String(latitude)); params.set("longitude", String(longitude)); params.set("maxradiuskm", String(radiusKm)); }
const url = `https://earthquake.usgs.gov/fdsnws/event/1/query?${params.toString()}`; ctx.log("recent_quakes querying USGS", { starttime, minMagnitude, limit });
const res = await ctx.fetch(url, { headers: { accept: "application/json" } }); if (res.status === 400) { throw new ToolError("USGS rejected the query parameters.", { retryable: false }); } if (!res.ok) { throw new ToolError("USGS feed is temporarily unavailable.", { retryable: true }); }
const body = (await res.json()) as { features: Array<{ properties: { place: string | null; mag: number | null; time: number; url: string }; geometry: { coordinates: [number, number, number] }; }>; };
const quakes = body.features.map((f) => { const [lng, lat, depth] = f.geometry.coordinates; return { place: f.properties.place ?? "Unknown location", magnitude: f.properties.mag ?? 0, time: new Date(f.properties.time).toISOString(), url: f.properties.url, longitude: lng, latitude: lat, depthKm: depth, }; });
if (quakes.length === 0) { return { text: `No earthquakes ≥ M${minMagnitude} in the last ${days} day(s).`, structured: { count: 0, quakes: [] }, }; }
const lines = quakes .slice(0, 10) .map((q) => ` M${q.magnitude.toFixed(1)} — ${q.place} (${q.time.slice(0, 16)}Z)`) .join("\n");
return { text: `${quakes.length} earthquake(s) ≥ M${minMagnitude} in the last ${days} day(s):\n` + lines, structured: { count: quakes.length, quakes }, }; }, }, ],});Why this shape
Section titled “Why this shape”- No secrets, no scopes. The USGS feed is public, so the manifest declares only
egress: ["earthquake.usgs.gov"]. Every other host is denied. annotations: { readOnlyHint: true, openWorldHint: true }. The SDK would already derivereadOnlyHint: true(the tool doesn’t mutate and the server has notrove:ingestscope), but a tool that reaches a public third-party API should advertiseopenWorldHint: true— so we set both explicitly. See Annotations.outputschema. Returning{ count, quakes }asstructuredagainst the declaredoutputschema means clients receive a validatablestructuredContentobject alongside the human-readabletext.ctx.fetchonly. All egress goes throughctx.fetch; the globalfetchis unavailable.ToolErrordistinguishes a bad query (not retryable) from an upstream outage (retryable).
Not a fit for hosted MCP
Section titled “Not a fit for hosted MCP”The USGS feed works because it’s a public internet API — reachable from any server (and from your phone). A toolkit runs in Trove’s cloud, so anything that lives on your own machine is out of reach. None of these belongs in a toolkit:
- Scanning your home network or hitting a device at
192.168.1.x/localhost— the cloud has no route to your LAN. - Editing your todo list in Things, Reminders, or another Mac app — there’s no path from the cloud to apps on your laptop.
- Reading local files on your computer.
For those, run a local stdio MCP server on your desktop yourself. And if instead you want local or private data searchable inside Trove (Apple Notes, a folder of files), that’s a source — it syncs on your Mac and collects the data in. See What toolkits can and can’t reach.
Deploy
Section titled “Deploy”trove mcp deploy # alias: trove deployBundling server.ts (esbuild)…✓ deployed quakes (version 1.0.0, building)quakes__recent_quakesThere’s nothing else to do — no trove secret set, because there are no secrets. Confirm it’s live:
trove mcp ls# SLUG NAME STATUS VIS TOOLS ACTIVE# quakes USGS Earthquakes ACTIVE PRIVATE 1 1.0.0What the user sees in Claude
Section titled “What the user sees in Claude”The next time Claude lists tools on the Trove connection, quakes__recent_quakes appears next to trove_search. Ask:
Any earthquakes above magnitude 6 in the last two weeks near Tokyo?
Claude calls:
quakes__recent_quakes({ "minMagnitude": 6, "days": 14, "latitude": 35.68, "longitude": 139.69, "radiusKm": 500})The handler queries USGS, and Claude gets both a readable summary and the structured quakes array (place, magnitude, time, coordinates, depth) to reason over.
Next steps
Section titled “Next steps”- NASA APOD — the same shape, but with an API key (
secrets+ctx.secret) - Knowledge-base tool — a server that reads and writes the user’s own Trove
- SDK Reference — the full
@ontrove/mcpAPI