feat: add public camera and radio tools

Add find_cctv_cameras, get_cctv_snapshot and find_radio_stations. A
snapshot returns the camera's current frame as image content with its
credit. Tool results may now carry base64 images, which the MCP adapter
sends as image content.
This commit is contained in:
Sameh Khamis committed 2026-10-01 12:11:00 -07:00
1 parent d65402eae3
commit 85fee59da1
10 files changed
+468 -4

No files matched your search

+6 -1
View File
@@ -25,7 +25,9 @@ validates and freezes a tool. `kind` is `query` (answers from data, read-only)
or `action`. `inputSchema` uses a JSON Schema subset that `src/tools/schema.js`
checks completely; unsupported keywords are rejected at definition time.
`run(args, { services, signal })` resolves to `{ summary, data }`: one sentence
for people and a structured object for programs.
for people and a structured object for programs. A tool may also return `images`,
each `{ mimeType, data }` with base64 data; the MCP adapter sends them as image
content.
`composeCatalog({ tools, services, replace, interceptors })` builds a catalog:
@@ -93,3 +95,6 @@ public feeds the sources already use.
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
| `next_satellite_pass` | `satellites` | Next pass over a point (default the ISS), with naked-eye visibility |
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first |
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content |
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
+2 -1
View File
@@ -871,5 +871,6 @@
"src/tools/mcp/mcp.test.mjs",
"src/tools/localServer.test.mjs",
"src/tools/queries/aviation.test.mjs",
"src/tools/queries/space.test.mjs"
"src/tools/queries/space.test.mjs",
"src/tools/queries/media.test.mjs"
]
+1
View File
@@ -3088,6 +3088,7 @@
"src/tools/queries/hazards.js",
"src/tools/queries/space.js",
"src/tools/queries/aviation.js",
"src/tools/queries/media.js",
"src/sources/tle.js",
"src/data/satellitePass.js"
],
+4
View File
@@ -3,9 +3,11 @@
* at a running God's Eye View server's `/api` routes.
*/
import { createCctvSource } from '../../src/layers/cctv/source.js';
import { createUsgsEarthquakeSource } from '../../src/layers/earthquakes/source.js';
import { createFirmsSource } from '../../src/layers/firms/source.js';
import { createLaunchSource } from '../../src/layers/launches/source.js';
import { createRadioSource } from '../../src/layers/radio/source.js';
import { createSatelliteSource } from '../../src/layers/satellites/source.js';
import {
createAdsbLolSource,
@@ -42,6 +44,8 @@ export function createLocalToolServices(options = {}) {
aircraft: createOpenSkySource({ fetchImpl }),
military: createAdsbLolSource({ fetchImpl }),
satellites: createSatelliteSource({ fetchImpl }),
cctv: createCctvSource({ fetchImpl }),
radio: createRadioSource({ fetchImpl }),
places: createGeocodePlaceService({ fetchImpl }),
};
}
+12 -1
View File
@@ -61,7 +61,8 @@ export function fromSourceError(error) {
* Validate and freeze a tool definition.
*
* `run(args, { services, signal })` resolves to `{ summary, data }`: a short
* sentence for people and a structured object for programs.
* sentence for people and a structured object for programs. It may add
* `images`, each `{ mimeType, data }` with base64 data.
*/
export function defineTool({
name,
@@ -146,6 +147,16 @@ export function composeCatalog({
}
if (!result || typeof result.summary !== 'string' || !result.data)
throw new TypeError(`${tool.name} returned no summary or data`);
if (
result.images !== undefined &&
(!Array.isArray(result.images) ||
!result.images.every(
(item) =>
typeof item?.mimeType === 'string' &&
typeof item?.data === 'string',
))
)
throw new TypeError(`${tool.name} returned malformed images`);
return result;
};
const chain = interceptors.reduceRight(
+8
View File
@@ -10,6 +10,11 @@ import {
getAircraftTrack,
} from './queries/aviation.js';
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
import {
findCctvCameras,
findRadioStations,
getCctvSnapshot,
} from './queries/media.js';
import {
getRecentLaunches,
nextSatellitePass,
@@ -43,4 +48,7 @@ export const coreTools = Object.freeze([
getAircraftInfo,
nextSatellitePass,
satellitesOverhead,
findCctvCameras,
getCctvSnapshot,
findRadioStations,
]);
+3
View File
@@ -78,6 +78,9 @@ test('the stdio server answers newline-delimited requests using only its data so
'get_aircraft_info',
'next_satellite_pass',
'satellites_overhead',
'find_cctv_cameras',
'get_cctv_snapshot',
'find_radio_stations',
],
);
assert.equal(
+8 -1
View File
@@ -80,7 +80,14 @@ export function createMcpServer({
signal,
});
return {
content: [{ type: 'text', text: result.summary }],
content: [
{ type: 'text', text: result.summary },
...(result.images || []).map((item) => ({
type: 'image',
data: item.data,
mimeType: item.mimeType,
})),
],
structuredContent: result.data,
isError: false,
};
+202
View File
@@ -0,0 +1,202 @@
/** Public camera and radio queries over the CCTV and radio sources. */
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
areaCenter,
areaContains,
distanceKm,
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const MAX_FRAME_BYTES = 3 * 1024 * 1024;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
const text = (value) => (typeof value === 'string' && value ? value : null);
async function readCameras(services, signal) {
const payload = await services.cctv.getCatalog({ signal });
return payload.sources.filter(
(camera) =>
camera?.id && Number.isFinite(camera.lat) && Number.isFinite(camera.lon),
);
}
export const findCctvCameras = defineTool({
name: 'find_cctv_cameras',
title: 'Public cameras in an area',
description:
'Public traffic and city cameras in an area, nearest the center first. ' +
'Use get_cctv_snapshot with a camera id to see its current view.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['cctv'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area);
const rows = (await readCameras(services, signal))
.filter((camera) => areaContains(area, camera))
.map((camera) => ({
id: camera.id,
name: text(camera.name),
city: text(camera.city),
provider: text(camera.provider),
lat: round(camera.lat, 6),
lon: round(camera.lon, 6),
heading_deg: round(camera.headingDeg, 0),
feed_type: text(camera.feedType),
credit: text(camera.credit),
distance_km: round(distanceKm(center, camera), 2),
}))
.sort((a, b) => a.distance_km - b.distance_km);
return {
summary: `${countNoun(rows.length, 'public camera')} in ${area.label}.`,
data: capRows(rows, args.limit),
};
},
});
export const getCctvSnapshot = defineTool({
name: 'get_cctv_snapshot',
title: 'Current camera view',
description:
'The current image from one public camera, by the id from find_cctv_cameras.',
inputSchema: {
type: 'object',
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } },
required: ['camera_id'],
additionalProperties: false,
},
requires: ['cctv'],
async run(args, { services, signal }) {
const camera = (await readCameras(services, signal)).find(
(candidate) => candidate.id === args.camera_id,
);
if (!camera)
throw new ToolError(
'invalid_arguments',
`No camera has id ${args.camera_id}; use find_cctv_cameras first`,
);
const frame = await services.cctv.getFrame(camera, { signal });
if (!FRAME_TYPES.has(frame.contentType))
throw new ToolError(
'unavailable',
`Camera ${camera.name || camera.id} has no still image right now`,
);
if (frame.bytes.byteLength > MAX_FRAME_BYTES)
throw new ToolError(
'unavailable',
'The camera image is too large to return',
);
const name = text(camera.name) || camera.id;
return {
summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`,
data: {
id: camera.id,
name,
city: text(camera.city),
provider: text(camera.provider),
credit: text(camera.credit),
mime_type: frame.contentType,
bytes: frame.bytes.byteLength,
},
images: [{ mimeType: frame.contentType, data: base64(frame.bytes) }],
};
},
});
export const findRadioStations = defineTool({
name: 'find_radio_stations',
title: 'Radio stations',
description:
'Internet radio stations from the Radio Browser directory, by area and/or ' +
'a search term matched against name, tags, language and country. Each ' +
"result includes the broadcaster's public stream URL.",
inputSchema: {
type: 'object',
properties: {
area: AREA_SCHEMA,
query: {
type: 'string',
minLength: 1,
maxLength: 80,
description: 'Words such as "jazz", "news" or "Tokyo".',
},
limit: LIMIT_SCHEMA,
},
additionalProperties: false,
},
requires: ['radio'],
async run(args, { services, signal }) {
if (!args.area && !args.query)
throw new ToolError('invalid_arguments', 'Give an area, a query or both');
const area = args.area
? await resolveArea(args.area, { services, signal })
: null;
const terms = String(args.query || '')
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
const directory = await services.radio.getDirectory({ signal });
if (!Array.isArray(directory?.stations))
throw new ToolError(
'malformed',
'The radio directory returned no stations',
);
const center = area ? areaCenter(area) : null;
const rows = directory.stations
.filter((station) => !area || areaContains(area, station))
.filter((station) => {
if (!terms.length) return true;
const haystack = [
station.name,
...(station.tags || []),
...(station.languages || []),
station.country,
station.state,
]
.join(' ')
.toLowerCase();
return terms.every((term) => haystack.includes(term));
})
.map((station) => ({
id: station.id,
name: text(station.name),
country: text(station.country),
state: text(station.state),
tags: station.tags || [],
languages: station.languages || [],
codec: text(station.codec),
bitrate_kbps: Number.isFinite(station.bitrate) ? station.bitrate : null,
stream_url: text(station.streamUrl),
homepage: text(station.homepage),
lat: round(station.lat, 4),
lon: round(station.lon, 4),
...(center
? { distance_km: round(distanceKm(center, station), 1) }
: {}),
}));
if (center) rows.sort((a, b) => a.distance_km - b.distance_km);
const where = area ? ` in ${area.label}` : '';
const what = args.query ? ` matching "${args.query}"` : '';
return {
summary: `${countNoun(rows.length, 'radio station')}${what}${where}.`,
data: capRows(rows, args.limit),
};
},
});
/** Base64-encode bytes with the platform's btoa, in chunks. */
function base64(bytes) {
let binary = '';
for (let offset = 0; offset < bytes.length; offset += 0x8000)
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
return btoa(binary);
}
+222
View File
@@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { createMcpServer } from '../mcp/index.js';
const cameras = [
{
id: 'austin-1',
name: 'Congress at 6th',
city: 'Austin',
provider: 'City of Austin',
lat: 30.2685,
lon: -97.7425,
headingDeg: 180,
feedType: 'image',
credit: 'City of Austin',
},
{
id: 'austin-2',
name: 'Lamar at 5th',
city: 'Austin',
provider: 'City of Austin',
lat: 30.2695,
lon: -97.7535,
headingDeg: 90,
feedType: 'image',
credit: '',
},
{ id: 'far', name: 'Elsewhere', city: 'Oslo', lat: 59.9, lon: 10.7 },
{ id: 'no-position', name: 'Unplaced' },
];
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({
frames: [],
async getCatalog() {
return { sources: cameras };
},
async getFrame(camera) {
this.frames.push(camera.id);
return frame;
},
});
const downtown = { lat: 30.2672, lon: -97.7431, radius_km: 3 };
test('cameras in an area are listed nearest first', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: cctv() },
});
const result = await catalog.call('find_cctv_cameras', { area: downtown });
assert.equal(
result.summary,
'2 public cameras in 3 km around 30.267, -97.743.',
);
assert.deepEqual(
result.data.rows.map((row) => row.id),
['austin-1', 'austin-2'],
);
assert.deepEqual(result.data.rows[0], {
id: 'austin-1',
name: 'Congress at 6th',
city: 'Austin',
provider: 'City of Austin',
lat: 30.2685,
lon: -97.7425,
heading_deg: 180,
feed_type: 'image',
credit: 'City of Austin',
distance_km: 0.16,
});
assert.equal(result.data.rows[1].credit, null);
});
test('a snapshot returns the frame as an image with its credit', async () => {
const source = cctv();
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: source },
});
const result = await catalog.call('get_cctv_snapshot', {
camera_id: 'austin-1',
});
assert.equal(
result.summary,
'Current view from Congress at 6th in Austin, courtesy of City of Austin.',
);
assert.deepEqual(result.images, [
{ mimeType: 'image/png', data: 'iVBORw0KGgo=' },
]);
assert.equal(result.data.bytes, 8);
assert.deepEqual(source.frames, ['austin-1']);
const server = createMcpServer({ catalog, name: 'test', version: '1' });
const response = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'get_cctv_snapshot', arguments: { camera_id: 'austin-1' } },
});
assert.deepEqual(response.result.content[1], {
type: 'image',
data: 'iVBORw0KGgo=',
mimeType: 'image/png',
});
});
test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
const catalog = (frame) =>
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });
await assert.rejects(
catalog().call('get_cctv_snapshot', { camera_id: 'nope' }),
/No camera has id nope; use find_cctv_cameras first/,
);
await assert.rejects(
catalog({ contentType: 'video/mp2t', bytes: PNG }).call(
'get_cctv_snapshot',
{ camera_id: 'austin-2' },
),
(error) =>
error.code === 'unavailable' && /no still image/.test(error.message),
);
await assert.rejects(
catalog({
contentType: 'image/jpeg',
bytes: new Uint8Array(3 * 1024 * 1024 + 1),
}).call('get_cctv_snapshot', { camera_id: 'austin-2' }),
/too large/,
);
});
test('radio stations match area and every search term', async () => {
const radio = {
async getDirectory() {
return {
stations: [
{
id: 'a',
name: 'KUTX',
lat: 30.28,
lon: -97.74,
country: 'United States',
state: 'Texas',
tags: ['music', 'indie'],
languages: ['english'],
codec: 'MP3',
bitrate: 128,
streamUrl: 'https://example.org/kutx.mp3',
homepage: 'https://kutx.org',
},
{
id: 'b',
name: 'KMFA Classical',
lat: 30.3,
lon: -97.7,
country: 'United States',
state: 'Texas',
tags: ['classical'],
languages: ['english'],
codec: 'AAC',
bitrate: null,
streamUrl: 'https://example.org/kmfa.aac',
},
{
id: 'c',
name: 'Jazz Oslo',
lat: 59.9,
lon: 10.7,
country: 'Norway',
tags: ['jazz'],
languages: ['norwegian'],
},
],
};
},
};
const catalog = composeCatalog({ tools: coreTools, services: { radio } });
const nearby = await catalog.call('find_radio_stations', {
area: { lat: 30.27, lon: -97.74, radius_km: 20 },
});
assert.deepEqual(
nearby.data.rows.map((row) => row.id),
['a', 'b'],
);
assert.equal(nearby.data.rows[0].stream_url, 'https://example.org/kutx.mp3');
const jazz = await catalog.call('find_radio_stations', {
query: 'jazz norwegian',
});
assert.equal(jazz.summary, '1 radio station matching "jazz norwegian".');
assert.equal(jazz.data.rows[0].distance_km, undefined);
const classicalTexas = await catalog.call('find_radio_stations', {
query: 'classical texas',
area: { lat: 30.27, lon: -97.74, radius_km: 20 },
});
assert.deepEqual(
classicalTexas.data.rows.map((row) => row.id),
['b'],
);
assert.equal(classicalTexas.data.rows[0].bitrate_kbps, null);
await assert.rejects(
catalog.call('find_radio_stations', {}),
/area, a query or both/,
);
});
test('malformed image results are a programming error', async () => {
const { defineTool } = await import('../catalog.js');
const bad = defineTool({
name: 'bad_image',
title: 'Bad',
description: 'Returns a malformed image.',
inputSchema: { type: 'object' },
run: async () => ({
summary: 'x',
data: {},
images: [{ mimeType: 'image/png' }],
}),
});
await assert.rejects(
composeCatalog({ tools: [bad] }).call('bad_image'),
/malformed images/,
);
});