From 85fee59da14e8f23331aebb74e7fabed88c92fdc Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 1 Oct 2026 12:11:00 -0700 Subject: [PATCH] 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. --- docs/TOOLS.md | 7 +- scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 1 + server/mcp/services.js | 4 + src/tools/catalog.js | 13 +- src/tools/index.js | 8 ++ src/tools/localServer.test.mjs | 3 + src/tools/mcp/protocol.js | 9 +- src/tools/queries/media.js | 202 ++++++++++++++++++++++++++++ src/tools/queries/media.test.mjs | 222 +++++++++++++++++++++++++++++++ 10 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 src/tools/queries/media.js create mode 100644 src/tools/queries/media.test.mjs diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 747d19d2e..a33bac852 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9d9e71c17..674618cba 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 503fb0255..033e9ce3e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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" ], diff --git a/server/mcp/services.js b/server/mcp/services.js index b828ef1ef..5e4ae774a 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -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 }), }; } diff --git a/src/tools/catalog.js b/src/tools/catalog.js index e289665b6..aa993e765 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -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( diff --git a/src/tools/index.js b/src/tools/index.js index 84d2aaa1e..68f3b9158 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -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, ]); diff --git a/src/tools/localServer.test.mjs b/src/tools/localServer.test.mjs index 42425ea39..a8457fe25 100644 --- a/src/tools/localServer.test.mjs +++ b/src/tools/localServer.test.mjs @@ -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( diff --git a/src/tools/mcp/protocol.js b/src/tools/mcp/protocol.js index 0450b66ea..d86a0c6b8 100644 --- a/src/tools/mcp/protocol.js +++ b/src/tools/mcp/protocol.js @@ -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, }; diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js new file mode 100644 index 000000000..2ad0f78e3 --- /dev/null +++ b/src/tools/queries/media.js @@ -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); +} diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs new file mode 100644 index 000000000..b99e9e781 --- /dev/null +++ b/src/tools/queries/media.test.mjs @@ -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/, + ); +});