Skip to content

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.

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.

usgs-quakes/
manifest.json
server.ts

No secrets, no scopes — only the one public host in egress.

manifest.json
{
"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
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 },
};
},
},
],
});
  • 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 derive readOnlyHint: true (the tool doesn’t mutate and the server has no trove:ingest scope), but a tool that reaches a public third-party API should advertise openWorldHint: true — so we set both explicitly. See Annotations.
  • output schema. Returning { count, quakes } as structured against the declared output schema means clients receive a validatable structuredContent object alongside the human-readable text.
  • ctx.fetch only. All egress goes through ctx.fetch; the global fetch is unavailable.
  • ToolError distinguishes a bad query (not retryable) from an upstream outage (retryable).

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.

Terminal window
trove mcp deploy # alias: trove deploy
Bundling server.ts (esbuild)…
✓ deployed quakes (version 1.0.0, building)
quakes__recent_quakes

There’s nothing else to do — no trove secret set, because there are no secrets. Confirm it’s live:

Terminal window
trove mcp ls
# SLUG NAME STATUS VIS TOOLS ACTIVE
# quakes USGS Earthquakes ACTIVE PRIVATE 1 1.0.0

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.