feat: add a tool catalog and local MCP server

Define tools that answer questions from the app's data once, independent
of the surface that exposes them. A tool declares its arguments and the
services it reads; applications compose tools with their own services and
optional call interceptors. Queries reuse the layers' portable source
factories, so they run wherever an application routes /api requests.

The first queries are get_earthquakes, get_active_fires and
get_recent_launches, with a shared area argument (place name, bounding
box, or point and radius) and capped lists.

gods-eye-view/tools/mcp adapts a catalog to the Model Context Protocol
with a small JSON-RPC handler and a stateless HTTP transport, adding no
dependencies. npm run mcp serves Core's tools over stdio to local clients
such as Claude Code, reading from a running app. Both exports are
portable and checked by check:boundaries; the app does not import them.
This commit is contained in:
Sameh Khamis committed 2026-10-01 11:51:59 -07:00
1 parent e7707d9a0f
commit c3dfa8e853
26 files changed
+1886 -3

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
/**
* A `places` service backed by the app's `/api/geocode` route. Applications
* with a different place search can supply any object with the same
* `resolve(name, { signal })` contract.
*/
const POINT_RADIUS_DEGREES = 0.25;
/** Resolve place names to `{ name, bounds: { west, south, east, north } }` or null. */
export function createGeocodePlaceService({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async resolve(name, { signal } = {}) {
const query = new URLSearchParams({ q: name });
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
const payload = await response.json();
return placeFromGeocodeResult(payload?.results?.[0]);
},
};
}
/** Convert one geocode result to a place with bounds, or null. */
export function placeFromGeocodeResult(result) {
const location = result?.geometry?.location;
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
return null;
const viewport = result.geometry.viewport;
const bounds =
viewport &&
[
viewport.southwest?.lat,
viewport.southwest?.lng,
viewport.northeast?.lat,
viewport.northeast?.lng,
].every(Number.isFinite)
? {
west: viewport.southwest.lng,
south: viewport.southwest.lat,
east: viewport.northeast.lng,
north: viewport.northeast.lat,
}
: {
west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES),
south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES),
east: Math.min(180, location.lng + POINT_RADIUS_DEGREES),
north: Math.min(90, location.lat + POINT_RADIUS_DEGREES),
};
return { name: result.formatted_address || null, bounds };
}