Skip to content

Commit 62b6889

Browse files
authored
feat(hex): fetch add_hex_tile_layer color-scale metadata by hash (#276) (#315)
Stop routing server-computed value_stats/bounds/value_column/layer_name through the LLM's tool-call arguments — the surface GLM-5.2 corrupted on ca-30x30 (2026-07-14). add_hex_tile_layer now requires only tile_url and fetches the metadata.json sidecar (mcp-data-server#316 serve_metadata) by content hash, deriving the URL from tile_url via metadataUrlFromTileUrl. - required: ['tile_url']; value_stats/bounds/value_column/layer_name are now optional overrides; the description tells the model to pass only tile_url. - resolveHexMetadata: prefer caller-supplied values (fast path, back-compat), else fetch and fill gaps, else fall back to whatever was supplied. Returns a clear error (never a silent blank layer) when neither source yields usable stats, with a hint to ensure mcp-data-server >= v0.8.6. Deploy ordering: the tile host must serve metadata.json (v0.8.6, promoted but awaiting cluster rollout) BEFORE any app bumps its pin to this code. Composes with #313 (dialect scrub recovers a leaked value_stats upstream; this fetches when it is absent/invalid). Full suite 516 green.
1 parent c1b3de3 commit 62b6889

4 files changed

Lines changed: 221 additions & 22 deletions

File tree

app/hex-layer-helpers.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ export function extractHashFromUrl(url) {
1717
return match ? match[1] : null;
1818
}
1919

20+
/**
21+
* Derive a hex tileset's `metadata.json` URL from its tile-URL template (#276).
22+
*
23+
* The MCP tile host serves the color-scale sidecar (value_stats, bounds,
24+
* value_columns, layer_name, …) at the same path as the tiles, with the
25+
* `/{z}/{x}/{y}.pbf` suffix swapped for `metadata.json`
26+
* (mcp-data-server#316 `serve_metadata`). Fetching it by content hash means
27+
* those inputs never have to be transcribed through the LLM's tool-call
28+
* arguments, where large value_stats blobs get corrupted (see add_hex_tile_layer
29+
* and the GLM-5.2 incident that reopened #276).
30+
*
31+
* @param {string} url - a `.../tiles/hex/<hash>/{z}/{x}/{y}.pbf` template.
32+
* @returns {string|null} the `.../tiles/hex/<hash>/metadata.json` URL, or null
33+
* if the input isn't a recognized hex tile template.
34+
*/
35+
export function metadataUrlFromTileUrl(url) {
36+
if (typeof url !== 'string') return null;
37+
const m = url.match(/^(.*\/tiles\/hex\/[^/]+\/)\{z\}\/\{x\}\/\{y\}\.pbf$/);
38+
return m ? `${m[1]}metadata.json` : null;
39+
}
40+
2041
/**
2142
* Named 3-stop color palettes for hex-layer fill-color ramps.
2243
* viridis — sequential, perceptually uniform (default)

app/map-tools.js

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,70 @@
11
import { validateChartSpec } from './chart-renderer.js';
2+
import { metadataUrlFromTileUrl } from './hex-layer-helpers.js';
3+
4+
/**
5+
* Whether a value_stats object is usable for building a hex color ramp (#276):
6+
* an object with a non-empty `by_res` map.
7+
*/
8+
function isUsableStats(vs) {
9+
return !!(vs && typeof vs === 'object' && vs.by_res && Object.keys(vs.by_res).length > 0);
10+
}
11+
12+
/**
13+
* Resolve a hex layer's color-scale metadata (value_column, value_stats, bounds,
14+
* layer_name) for add_hex_tile_layer (#276).
15+
*
16+
* The server already computed these and serves them by content hash, so the LLM
17+
* should not have to transcribe the large value_stats blob into tool args (where
18+
* weak models corrupt it — the failure that reopened #276). This prefers usable
19+
* values the caller supplied, otherwise fetches the sidecar
20+
* (metadataUrlFromTileUrl) and fills the gaps. If the fetch fails it uses
21+
* whatever the caller did supply, and returns `{ error }` only when neither
22+
* source yields the fields the renderer needs — never a silent blank layer.
23+
*/
24+
async function resolveHexMetadata(args) {
25+
let valueColumn = args.value_column;
26+
let valueStats = args.value_stats;
27+
let bounds = (Array.isArray(args.bounds) && args.bounds.length === 4) ? args.bounds : undefined;
28+
let layerName = args.layer_name;
29+
30+
// Fast path: the caller already supplied everything the renderer needs — no
31+
// network round-trip, and back-compat for manual/programmatic callers.
32+
if (isUsableStats(valueStats) && bounds && valueColumn) {
33+
return { valueColumn, valueStats, bounds, layerName };
34+
}
35+
36+
const metaUrl = metadataUrlFromTileUrl(args.tile_url);
37+
if (metaUrl && typeof fetch === 'function') {
38+
try {
39+
const resp = await fetch(metaUrl);
40+
if (resp.ok) {
41+
const meta = await resp.json();
42+
valueColumn = valueColumn || meta.value_columns?.[0];
43+
if (!isUsableStats(valueStats)) valueStats = meta.value_stats?.[valueColumn];
44+
if (!bounds) {
45+
bounds = (Array.isArray(meta.bounds) && meta.bounds.length === 4) ? meta.bounds : undefined;
46+
}
47+
layerName = layerName || meta.layer_name;
48+
}
49+
} catch { /* fall through to whatever the caller supplied */ }
50+
}
51+
52+
// One clear error covering any remaining gap — never a silent blank layer.
53+
// All three gaps share a root cause: the by-hash fetch didn't deliver and
54+
// nothing usable was passed explicitly.
55+
if (!valueColumn || !isUsableStats(valueStats) || !bounds) {
56+
const missing = [
57+
!valueColumn && 'value_column',
58+
!isUsableStats(valueStats) && 'value_stats',
59+
!bounds && 'bounds',
60+
].filter(Boolean).join(', ');
61+
return { error: `add_hex_tile_layer: could not resolve color-scale metadata (${missing}). `
62+
+ `These are normally fetched from ${metaUrl || 'the tile host'} by content hash; that fetch `
63+
+ `returned nothing usable (ensure the tile host serves metadata.json — mcp-data-server >= v0.8.6), `
64+
+ `and no usable values were passed explicitly.` };
65+
}
66+
return { valueColumn, valueStats, bounds, layerName };
67+
}
268

369
/**
470
* Map Tools - Local tool definitions for the LLM agent
@@ -399,18 +465,20 @@ Common use cases:
399465
400466
Flow:
401467
1. Call \`register_hex_tiles\` (MCP) with SQL that returns (h3_index [, value1, ...]).
402-
2. Pass its return fields directly into this tool — no extra min/max query needed; the server already computed \`value_stats\` per H3 resolution.
403-
404-
Pass the following fields straight through from the register_hex_tiles return value:
405-
- tile_url ← tile_url_template
406-
- value_column ← one of value_columns (for agg="COUNT" this is "count")
407-
- value_stats ← value_stats[value_column] (has { by_res: { "<res>": { min, max } } })
408-
- bounds ← bounds
409-
- layer_name ← layer_name (when present; defaults to "layer" otherwise)
468+
2. Pass the returned \`tile_url\` to this tool. That is the ONLY required field.
469+
The color-scale metadata (value_stats, bounds, value_column, layer_name) is
470+
fetched automatically from the tile host by content hash — do NOT copy the
471+
\`value_stats\` blob or \`bounds\` array into this call. Transcribing that large
472+
nested object is error-prone and unnecessary; the server already has it.
473+
474+
Optional inputs:
475+
- value_column which value_columns entry to color by (default: first; "count" for agg=COUNT)
476+
- palette / opacity / display_name / fit_bounds styling (see below)
477+
- value_stats / bounds / layer_name accepted as explicit overrides, but normally OMIT — they are fetched
410478
- format ← format ("geojson" or "vector"; defaults to "vector")
411479
- geojson_url ← geojson_url (REQUIRED when format="geojson"; ignored otherwise)
412480
413-
ALWAYS pass \`format\` and (when present) \`geojson_url\` through. The server auto-selects GeoJSON for small/single-resolution tilesets and vector tiles otherwise; for GeoJSON the \`.pbf\` tile_url 404s, so the layer renders blank unless you also pass format="geojson" + geojson_url.
481+
For GeoJSON tilesets you MUST still pass format="geojson" + geojson_url from the register_hex_tiles return — those two are NOT in the fetched sidecar. The server auto-selects GeoJSON for small/single-resolution tilesets and vector tiles otherwise; for GeoJSON the \`.pbf\` tile_url 404s, so the layer renders blank unless you also pass format="geojson" + geojson_url.
414482
415483
Hexes get finer as the user zooms in: the tile server's pyramid serves the appropriate H3 resolution for each zoom level automatically (vector format only). If the user wants a coarser overall view, re-run \`register_hex_tiles\` with the SQL projected to a coarser resolution by wrapping the first column in \`h3_cell_to_parent(<h3_col>, <target_res>)\` — the server auto-detects the H3 resolution from that column.
416484
@@ -420,18 +488,18 @@ The returned layer_id can be used with show_layer / hide_layer / set_style / set
420488
inputSchema: {
421489
type: 'object',
422490
properties: {
423-
tile_url: { type: 'string', description: 'tile_url_template from register_hex_tiles' },
424-
value_column: { type: 'string', description: 'Which column from register_hex_tiles.value_columns to style by (e.g. "count" for agg=COUNT)' },
491+
tile_url: { type: 'string', description: 'tile_url_template from register_hex_tiles — the ONLY required field' },
492+
value_column: { type: 'string', description: 'Optional: which column from value_columns to style by (default: first; "count" for agg=COUNT)' },
425493
value_stats: {
426494
type: 'object',
427-
description: 'Per-resolution stats for value_column — pass register_hex_tiles.value_stats[value_column] directly. Shape: { by_res: { "<res>": { min, max } } }.'
495+
description: 'Optional override — normally OMIT. Fetched automatically by content hash. Shape if passed: { by_res: { "<res>": { min, max } } }.'
428496
},
429497
bounds: {
430498
type: 'array',
431499
items: { type: 'number' },
432-
description: '[w, s, e, n] from register_hex_tiles.bounds'
500+
description: 'Optional override — normally OMIT. Fetched automatically. [w, s, e, n].'
433501
},
434-
layer_name: { type: 'string', description: 'MVT source-layer name from register_hex_tiles.layer_name (defaults to "layer" when omitted; ignored for format="geojson")' },
502+
layer_name: { type: 'string', description: 'Optional override — normally OMIT. Fetched automatically (defaults to "layer"; ignored for format="geojson").' },
435503
format: {
436504
type: 'string',
437505
enum: ['vector', 'geojson'],
@@ -447,20 +515,22 @@ The returned layer_id can be used with show_layer / hide_layer / set_style / set
447515
opacity: { type: 'number', description: 'Fill opacity 0..1 (default 0.7)' },
448516
fit_bounds: { type: 'boolean', description: 'Fly the camera to fit bounds (default true)' },
449517
},
450-
required: ['tile_url', 'value_column', 'value_stats', 'bounds'],
518+
required: ['tile_url'],
451519
},
452-
execute: (args) => {
453-
const displayName = args.display_name || `Hex: ${args.value_column}`;
520+
execute: async (args) => {
521+
const meta = await resolveHexMetadata(args);
522+
if (meta.error) return JSON.stringify({ success: false, error: meta.error });
523+
const displayName = args.display_name || `Hex: ${meta.valueColumn}`;
454524
const result = mapManager.addHexTileLayer({
455525
tileUrl: args.tile_url,
456-
valueColumn: args.value_column,
457-
valueStats: args.value_stats,
458-
bounds: args.bounds,
526+
valueColumn: meta.valueColumn,
527+
valueStats: meta.valueStats,
528+
bounds: meta.bounds,
459529
palette: args.palette || 'viridis',
460530
opacity: args.opacity ?? 0.7,
461531
displayName,
462532
fitBounds: args.fit_bounds !== false,
463-
layerName: args.layer_name,
533+
layerName: meta.layerName,
464534
format: args.format,
465535
geojsonUrl: args.geojson_url,
466536
});

test/hex-layer-helpers.test.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { extractHashFromUrl, rewriteValueColumn } from '../app/hex-layer-helpers.js';
2+
import { extractHashFromUrl, rewriteValueColumn, metadataUrlFromTileUrl } from '../app/hex-layer-helpers.js';
33

44
describe('extractHashFromUrl', () => {
55
it('extracts hash from a valid MCP tile URL template', () => {
@@ -191,3 +191,22 @@ describe('rewriteValueColumn', () => {
191191
expect(replaced).toEqual([]);
192192
});
193193
});
194+
195+
describe('metadataUrlFromTileUrl (#276)', () => {
196+
it('swaps the /{z}/{x}/{y}.pbf suffix for metadata.json', () => {
197+
expect(metadataUrlFromTileUrl(
198+
'https://duckdb-mcp.nrp-nautilus.io/tiles/hex/abc123/{z}/{x}/{y}.pbf'))
199+
.toBe('https://duckdb-mcp.nrp-nautilus.io/tiles/hex/abc123/metadata.json');
200+
});
201+
202+
it('returns null for a non-hex-template URL', () => {
203+
expect(metadataUrlFromTileUrl('https://h/tiles/hex/abc/6/10/24.pbf')).toBeNull();
204+
expect(metadataUrlFromTileUrl('https://h/other/abc/{z}/{x}/{y}.pbf')).toBeNull();
205+
expect(metadataUrlFromTileUrl('not a url')).toBeNull();
206+
});
207+
208+
it('returns null for non-string input', () => {
209+
expect(metadataUrlFromTileUrl(null)).toBeNull();
210+
expect(metadataUrlFromTileUrl(undefined)).toBeNull();
211+
});
212+
});

test/map-tools.test.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,3 +564,92 @@ describe('createMapTools smoke test', () => {
564564
expect(setFilter.inputSchema.properties.filter.items).toBeDefined();
565565
});
566566
});
567+
568+
describe('add_hex_tile_layer — fetch color-scale metadata by hash (#276)', () => {
569+
afterEach(() => { vi.restoreAllMocks(); delete global.fetch; });
570+
571+
const META = {
572+
finest_res: 8, min_res: 2, agg: 'AVG', zoom_offset: 2,
573+
value_columns: ['conserved_hw_frac'],
574+
value_stats: { conserved_hw_frac: {
575+
by_res: { '2': { min: 0.46, max: 9.45 }, '8': { min: 0, max: 49 } },
576+
suggested_scale: 'linear',
577+
} },
578+
layer_name: 'layer',
579+
bounds: [-124.4, 32.5, -114.3, 42.1],
580+
feature_count_finest: 216305,
581+
};
582+
const TILE_URL = 'https://duckdb-mcp.nrp-nautilus.io/tiles/hex/abc123/{z}/{x}/{y}.pbf';
583+
584+
const getTool = () => {
585+
const mapManager = { addHexTileLayer: vi.fn(() => ({ success: true, layer_id: 'hex-abc123' })) };
586+
const tool = createMapTools(mapManager, { records: new Map() }).find(t => t.name === 'add_hex_tile_layer');
587+
return { tool, mapManager };
588+
};
589+
590+
it('requires only tile_url', () => {
591+
const { tool } = getTool();
592+
expect(tool.inputSchema.required).toEqual(['tile_url']);
593+
});
594+
595+
it('fetches metadata.json by hash and forwards the resolved fields', async () => {
596+
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => META });
597+
const { tool, mapManager } = getTool();
598+
599+
const out = JSON.parse(await tool.execute({ tile_url: TILE_URL }));
600+
601+
expect(global.fetch).toHaveBeenCalledWith('https://duckdb-mcp.nrp-nautilus.io/tiles/hex/abc123/metadata.json');
602+
expect(mapManager.addHexTileLayer).toHaveBeenCalledWith(expect.objectContaining({
603+
tileUrl: TILE_URL,
604+
valueColumn: 'conserved_hw_frac',
605+
valueStats: META.value_stats.conserved_hw_frac,
606+
bounds: META.bounds,
607+
layerName: 'layer',
608+
}));
609+
expect(out.success).toBe(true);
610+
});
611+
612+
it('honors a value_column override while still fetching stats for it', async () => {
613+
const meta = { ...META, value_columns: ['count', 'conserved_hw_frac'],
614+
value_stats: { ...META.value_stats, count: { by_res: { '8': { min: 1, max: 99 } } } } };
615+
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => meta });
616+
const { tool, mapManager } = getTool();
617+
618+
await tool.execute({ tile_url: TILE_URL, value_column: 'count' });
619+
620+
expect(mapManager.addHexTileLayer).toHaveBeenCalledWith(expect.objectContaining({
621+
valueColumn: 'count',
622+
valueStats: meta.value_stats.count,
623+
}));
624+
});
625+
626+
it('uses caller-supplied values without fetching (fast path / back-compat)', async () => {
627+
global.fetch = vi.fn();
628+
const { tool, mapManager } = getTool();
629+
const stats = { by_res: { '8': { min: 0, max: 49 } } };
630+
631+
await tool.execute({ tile_url: TILE_URL, value_column: 'count', value_stats: stats, bounds: [1, 2, 3, 4] });
632+
633+
expect(global.fetch).not.toHaveBeenCalled();
634+
expect(mapManager.addHexTileLayer).toHaveBeenCalledWith(expect.objectContaining({
635+
valueColumn: 'count', valueStats: stats, bounds: [1, 2, 3, 4],
636+
}));
637+
});
638+
639+
it('errors clearly (no silent blank layer) when the fetch fails and nothing was provided', async () => {
640+
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
641+
const { tool, mapManager } = getTool();
642+
643+
const out = JSON.parse(await tool.execute({ tile_url: TILE_URL }));
644+
645+
expect(out.success).toBe(false);
646+
expect(out.error).toMatch(/value_stats/);
647+
expect(mapManager.addHexTileLayer).not.toHaveBeenCalled();
648+
});
649+
650+
it('does not force the model to transcribe value_stats in its description', () => {
651+
const { tool } = getTool();
652+
expect(tool.description).toMatch(/fetched automatically/i);
653+
expect(tool.description).toMatch(/do NOT copy/i);
654+
});
655+
});

0 commit comments

Comments
 (0)