Skip to content

Commit 2264c0e

Browse files
authored
Merge pull request #173 from boettiger-lab/refactor/drop-mcp-preload
refactor: drop MCP get_collection preload; move schema to call-time (#171)
2 parents a30fd0c + 6e89617 commit 2264c0e

6 files changed

Lines changed: 1320 additions & 211 deletions

File tree

app/dataset-catalog.js

Lines changed: 1 addition & 175 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ export class DatasetCatalog {
1616
constructor() {
1717
/** @type {Map<string, DatasetEntry>} keyed by collection ID */
1818
this.datasets = new Map();
19-
/** @type {Map<string, Object>} MCP get_collection results, keyed by collection ID */
20-
this.mcpCollections = new Map();
2119
this.catalogUrl = null;
2220
this.titilerUrl = null;
2321
}
@@ -507,20 +505,6 @@ export class DatasetCatalog {
507505
return doc?.href || null;
508506
}
509507

510-
// ---- MCP preload ----
511-
512-
/**
513-
* Store a structured collection dict from MCP get_collection.
514-
* Called at startup to cache per-asset schemas and parquet paths
515-
* for system prompt generation.
516-
*
517-
* @param {string} id - Collection ID
518-
* @param {Object} data - Structured STAC collection from MCP
519-
*/
520-
setMcpCollection(id, data) {
521-
this.mcpCollections.set(id, data);
522-
}
523-
524508
// ---- Public API ----
525509

526510
/**
@@ -610,53 +594,12 @@ export class DatasetCatalog {
610594
return sections.join('\n---\n\n');
611595
}
612596

613-
/**
614-
* Extract SQL-relevant parquet assets from MCP data.
615-
* Prefers H3 hex assets over full GeoParquet — when both exist,
616-
* the hex is what DuckDB queries should use (partitioned, H3-indexed).
617-
* @private
618-
*/
619-
_getSqlAssets(mcpData) {
620-
const assets = mcpData.assets || {};
621-
const all = [];
622-
let hasHex = false;
623-
624-
for (const [assetId, asset] of Object.entries(assets)) {
625-
const type = asset.type || '';
626-
const href = asset.href || '';
627-
if (!type.includes('parquet') && !href.endsWith('.parquet') &&
628-
!href.includes('/hex/') && !href.endsWith('/')) continue;
629-
if (type.includes('pmtiles') || type.includes('tiff')) continue;
630-
631-
let s3Path = href;
632-
if (s3Path.endsWith('/')) s3Path = s3Path.replace(/\/+$/, '') + '/**';
633-
634-
const isHex = href.includes('/hex/') || href.includes('/hex') ||
635-
(asset.title || '').toLowerCase().includes('hex') ||
636-
(asset.title || '').toLowerCase().includes('h3');
637-
if (isHex) hasHex = true;
638-
639-
all.push({ assetId, title: asset.title || assetId, s3Path, isHex, asset });
640-
}
641-
642-
// If hex assets exist, omit full GeoParquet (model doesn't need it for SQL)
643-
return hasHex ? all.filter(a => a.isHex) : all;
644-
}
645-
646597
/**
647598
* Render SQL asset paths only (no columns) for the system prompt.
648-
* Uses MCP data when available, falls back to local extraction.
599+
* Uses the client-direct parquetAssets extracted during load().
649600
* @private
650601
*/
651602
_renderSqlPaths(ds) {
652-
const mcpData = this.mcpCollections.get(ds.id);
653-
if (mcpData) {
654-
const sqlAssets = this._getSqlAssets(mcpData);
655-
if (sqlAssets.length === 0) return '';
656-
const lines = sqlAssets.map(a => `- ${a.title}: \`read_parquet('${a.s3Path}')\``);
657-
return '\n**SQL assets:**\n' + lines.join('\n') + '\n';
658-
}
659-
// Fallback: local parquet assets
660603
if (ds.parquetAssets.length === 0) return '';
661604
let out = '\n**SQL assets:**\n';
662605
for (const pa of ds.parquetAssets) {
@@ -665,123 +608,6 @@ export class DatasetCatalog {
665608
return out;
666609
}
667610

668-
/**
669-
* Format schema info for the get_schema tool.
670-
* Returns a compact, tabular representation optimized for LLM consumption:
671-
* paths, column headers with representative values, and coded value lists.
672-
*
673-
* Uses MCP get_collection data (correct per-asset schemas).
674-
* Falls back to local catalog data when MCP data is unavailable.
675-
*
676-
* @param {string} id - Collection ID
677-
* @returns {string|null} Formatted schema text, or null if dataset not found
678-
*/
679-
formatSchema(id) {
680-
const ds = this.datasets.get(id);
681-
if (!ds) return null;
682-
683-
const mcpData = this.mcpCollections.get(id);
684-
if (mcpData) {
685-
return this._formatSchemaFromMcp(ds, mcpData);
686-
}
687-
return this._formatSchemaFallback(ds);
688-
}
689-
690-
/** @private */
691-
_formatSchemaFromMcp(ds, mcpData) {
692-
const sqlAssets = this._getSqlAssets(mcpData);
693-
const collCols = (mcpData['table:columns'] || []).filter(c =>
694-
!['geometry', 'geom', 'bbox'].includes(c.name?.toLowerCase())
695-
);
696-
697-
// Determine which columns to use: per-asset if available, else collection-level
698-
const sections = [];
699-
for (const { title, s3Path, asset } of sqlAssets) {
700-
const assetCols = (asset['table:columns'] || []).filter(c =>
701-
!['geometry', 'geom', 'bbox'].includes(c.name?.toLowerCase())
702-
);
703-
// Use per-asset columns if available, fall back to collection-level
704-
const cols = assetCols.length > 0 ? assetCols : collCols;
705-
sections.push(this._renderOneAssetSchema(title, s3Path, cols));
706-
}
707-
708-
// If no SQL assets found at all, render collection-level columns standalone
709-
if (sections.length === 0 && collCols.length > 0) {
710-
sections.push(this._renderOneAssetSchema(ds.title, null, collCols));
711-
}
712-
713-
if (sections.length === 0) return `No schema available for ${ds.id}. Try get_stac_details("${ds.id}").`;
714-
return sections.join('\n');
715-
}
716-
717-
/**
718-
* Render one asset's schema in tabular format.
719-
* @private
720-
*/
721-
_renderOneAssetSchema(title, s3Path, cols) {
722-
let out = `${title}:\n`;
723-
if (s3Path) out += ` read_parquet('${s3Path}')\n\n`;
724-
725-
if (cols.length === 0) return out;
726-
727-
// Column headers + sample values row (like SELECT * LIMIT 1 output)
728-
const names = cols.map(c => c.name);
729-
const samples = cols.map(c => {
730-
if (c.values?.length > 0) return String(c.values[0]);
731-
return `(${c.type || '?'})`;
732-
});
733-
out += ' ' + names.join(' | ') + '\n';
734-
out += ' ' + samples.join(' | ') + '\n';
735-
736-
// Coded values
737-
const coded = cols.filter(c => c.values?.length > 0);
738-
if (coded.length > 0) {
739-
out += '\n';
740-
for (const c of coded) {
741-
out += ` ${c.name}: ${c.values.join(', ')}\n`;
742-
}
743-
}
744-
745-
// Columns with descriptions but no coded values — show a compact hint
746-
const described = cols.filter(c => !c.values?.length && c.description);
747-
if (described.length > 0) {
748-
out += '\n';
749-
for (const c of described) {
750-
out += ` ${c.name}: ${c.description}\n`;
751-
}
752-
}
753-
754-
// If no coded values at all, suggest SELECT * LIMIT 1 to see real data
755-
if (coded.length === 0 && s3Path) {
756-
out += `\n Tip: run SELECT * FROM read_parquet('${s3Path}') LIMIT 1 to see sample values.\n`;
757-
}
758-
759-
return out;
760-
}
761-
762-
/** @private */
763-
_formatSchemaFallback(ds) {
764-
if (ds.parquetAssets.length === 0 && ds.columns.length === 0) {
765-
return `No schema available for ${ds.id}. Try get_stac_details("${ds.id}").`;
766-
}
767-
let out = '';
768-
for (const pa of ds.parquetAssets) {
769-
out += `${pa.title}:\n read_parquet('${pa.s3Path}')\n\n`;
770-
}
771-
if (ds.columns.length > 0) {
772-
const names = ds.columns.map(c => c.name);
773-
out += ' ' + names.join(' | ') + '\n';
774-
const coded = ds.columns.filter(c => c.values?.length > 0);
775-
if (coded.length > 0) {
776-
out += '\n';
777-
for (const c of coded) {
778-
out += ` ${c.name}: ${c.values.join(', ')}\n`;
779-
}
780-
}
781-
}
782-
return out;
783-
}
784-
785611
/**
786612
* Generate a flat list of all map layer IDs and their configs.
787613
* Used by MapManager to create layers.

app/main.js

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -216,37 +216,6 @@ async function main() {
216216
}], mcp);
217217
}
218218

219-
/* ── 5b. Preload MCP collection data for system prompt ──────────── */
220-
// Call get_collection for each configured dataset to get correct per-asset
221-
// schemas and parquet paths. This data enriches the system prompt.
222-
// Falls back gracefully if MCP is unavailable.
223-
try {
224-
const collectionIds = appConfig.collections.map(c =>
225-
typeof c === 'string' ? c : c.collection_id
226-
);
227-
const preloadResults = await Promise.allSettled(
228-
collectionIds.map(id =>
229-
mcp.callTool('get_collection', { collection_id: id })
230-
)
231-
);
232-
let preloaded = 0;
233-
for (let i = 0; i < collectionIds.length; i++) {
234-
const r = preloadResults[i];
235-
if (r.status === 'fulfilled' && r.value) {
236-
try {
237-
const data = JSON.parse(r.value);
238-
if (!data.error) {
239-
catalog.setMcpCollection(collectionIds[i], data);
240-
preloaded++;
241-
}
242-
} catch { /* not JSON — skip */ }
243-
}
244-
}
245-
console.log(`[main] Preloaded ${preloaded}/${collectionIds.length} collections from MCP`);
246-
} catch (err) {
247-
console.warn('[main] MCP preload failed, using local catalog data:', err.message);
248-
}
249-
250219
/* ── 6. Build system prompt ────────────────────────────────────────── */
251220
const basePrompt = await fetchText('system-prompt.md');
252221
const catalogText = catalog.generatePromptCatalog();

app/map-tools.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -282,23 +282,36 @@ Vector layers: ${vectorLayerIds().join(', ')}`,
282282
// ---- Dataset Knowledge Tools ----
283283
{
284284
name: 'get_schema',
285-
description: 'Get column names, types, sample values, and coded value lists for a dataset — formatted like SELECT * LIMIT 1 output. Also includes the read_parquet() path. **Call this before your first SQL query against a dataset.** Instant, no approval needed. For datasets outside your app, use `get_stac_details` instead.',
285+
description: 'Get column names, types, sample values, and coded value lists for a dataset — formatted like SELECT * LIMIT 1 output. Also includes the read_parquet() path. **Call this before your first SQL query against a dataset.** For datasets outside your app, use `get_stac_details` instead.',
286286
inputSchema: {
287287
type: 'object',
288288
properties: {
289289
dataset_id: { type: 'string', description: 'Collection ID of the dataset' }
290290
},
291291
required: ['dataset_id']
292292
},
293-
execute: (args) => {
294-
const result = catalog.formatSchema(args.dataset_id);
295-
if (result === null) {
293+
execute: async (args) => {
294+
if (!catalog.get(args.dataset_id)) {
296295
return JSON.stringify({
297296
success: false,
298297
error: `Dataset not found: ${args.dataset_id}. Available: ${catalog.getIds().join(', ')}. For datasets outside this app, use get_stac_details.`
299298
});
300299
}
301-
return result;
300+
if (!mcpClient) {
301+
return JSON.stringify({
302+
success: false,
303+
error: 'Schema service unavailable: MCP client not configured.'
304+
});
305+
}
306+
try {
307+
const raw = await mcpClient.callTool('get_stac_details', { dataset_id: args.dataset_id });
308+
return typeof raw === 'string' ? raw : JSON.stringify(raw);
309+
} catch (err) {
310+
return JSON.stringify({
311+
success: false,
312+
error: `Schema service unavailable: ${err.message || err}. Try again, or call get_stac_details directly.`
313+
});
314+
}
302315
},
303316
},
304317

app/system-prompt.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ This applies equally when styling (e.g., building a `match` expression to color
5050

5151
The dataset catalog below lists `read_parquet()` paths for every pre-loaded dataset. **These paths are authoritative — never guess, construct, or modify S3 paths.** Use them directly in SQL.
5252

53+
**When a dataset has both a hex-indexed parquet path and a full GeoParquet path, prefer the hex path for SQL queries.** The hex path is partitioned by H3 cell and dramatically faster for spatial aggregations and joins. Asset titles make the distinction clear (e.g. `"SVI 2022 hex"` vs `"SVI 2022"`).
54+
5355
**Before your first SQL query against a dataset, call `get_schema(dataset_id)`.** It returns column names, types, representative values, and coded value lists — instant, no approval needed. You don't need to call it again for follow-up queries on the same dataset unless you're unsure about column names.
5456

5557
For datasets outside your app config, use `get_stac_details(collection_id)` instead.

0 commit comments

Comments
 (0)