Skip to main content
Glama
glen-wasm

glen-wasm

Official
by SchmidtDSE

geo-query-wasm

geo-query-wasm is geo-query's MCP server running entirely in the browser. DuckDB-wasm runs in a Web Worker, and the worker serves geo-query's tools, resources and prompt over postMessage instead of HTTP. A client that works with geo-query works with it unchanged: the tool names, arguments, descriptions and output follow geo-query's, and the model-facing guidance is geo-query's own, fetched at a pinned commit. No server is involved. The worker is a set of static files, the data are public files on source.coop and NRP, and the user's questions go only to the language model endpoint the client uses. By default the worker reads the source.coop mirror of GLEN's catalog, which a browser reads much faster than NRP.

Architecture

page: any MCP client (the demo uses glen-harness)
        │  postMessage (MCP JSON-RPC)
        ▼
Web Worker (src/worker.js)
  MCP server (src/server.js): geo-query's tools, resources and prompt
        │                      │
        │                      ├── STAC JSON ◀── source.coop mirror (cboettig/glen/catalog.json)
        │                      └── guidance ◀── geo-query at a pinned commit, from jsDelivr
        ▼
  engine (src/engine.js)
  DuckDB-wasm, one or more instances, each in its own worker
        ◀── parquet byte ranges ◀── source.coop, NRP S3
  • The worker is the server's container. Its only interface is postMessage, and a page talks to it only in MCP. This is the browser counterpart of geo-query in a Docker container behind a TCP port. src/transport.js implements the MCP SDK's transport interface over postMessage, for both ends.

  • The server depends only on the MCP SDK and DuckDB-wasm. The server half of the MCP SDK is bundled into vendor/mcp-server-sdk.js, and DuckDB-wasm comes from jsDelivr at a pinned version. The demo page's agent, glen-harness, is a separate project that the page loads from jsDelivr at a pinned commit.

  • The worker reads S3 as geo-query does. It loads httpfs, h3 and spatial, creates geo-query's anonymous S3 secrets for NRP Ceph and the source.coop bucket on AWS, and applies geo-query's settings. s3:// paths, globs and h0= hive partitions work as they do on the server.

  • The SQL can read only the data hosts. DuckDB's allowed_directories limits reads to s3:// paths and to https:// URLs on NRP and source.coop. The worker turns external access and extension loading off, locks the configuration, and runs only SELECT queries. The details are in the DuckDB-wasm section below.

  • Hex tiles stay in the worker. register_hex_tiles keeps its H3 pyramid as a table in DuckDB's memory, and the worker encodes each tile as MVT with DuckDB's ST_AsMVT when the map asks for it (src/hex.js). The page passes the map's tile requests to the worker as MCP resource reads (src/hex-protocol.js).

  • The engine is a module of its own. src/engine.js holds the DuckDB-wasm setup: start-up, extensions, secrets, settings, access limits, a pool of instances and row conversion. The worker serves MCP on it, and an app can import it into the page to run its own SQL without MCP framing.

Related MCP server: MCP Analytics Server

Parity with geo-query

We compared geo-query at commit 222cecf (server.py, stac.py).

geo-query feature

geo-query-wasm

browse_stac_catalog, with an inline catalog

Same markdown

get_stac_details, with an inline collection

Same markdown

get_collection, with an inline collection

Same JSON, with s3:// hrefs, routing hints and nested children, which round-trip into collection

query with sql_query or sql

Same arguments and errors. Adds format: "json" for app code.

Results as a markdown table of 50 rows

Same truncation note, "No results found.", 64-bit integers in full, floats to 10 significant figures, dates as YYYY-MM-DD, NULL as an empty cell

s3_endpoint, s3_scope, s3_region, s3_url_style for an anonymous source

Same per-query secret and endpoint allow-list

s3_key and s3_secret for a private bucket

Refused with the reason: DuckDB-wasm does not sign S3 requests

catalog://list and catalog://{dataset_id} resources

Same

geospatial-analyst prompt

Same text (assistant-role.md)

query description with query-optimization.md and h3-guide.md

The same files at a pinned commit, with tier=extra sections dropped unless extraInstructions is set, plus a section on the browser

STAC_CATALOG_URL, STAC_DISCOVERY, EXTRA_INSTRUCTIONS

catalog, discovery and extraInstructions in the worker's configuration. The default catalog is the source.coop mirror, where geo-query's is NRP's.

Locked DuckDB configuration

Locked, and also SELECT only, because the lock does not cover secrets

register_hex_tiles and get_hex_tile_status

Same arguments, statuses and responses. The pyramid is a table in the worker's memory, not parquet in object storage, and the tile URL is geoquerywasm://tiles/hex/<hash>/{z}/{x}/{y}.pbf (below).

MVT tiles and metadata.json from /tiles over HTTP

MCP resources at the same paths under geoquerywasm://. metadata.json adds cell_counts, the cells at each level.

The H3 level a zoom serves, from the data's extent and an estimate of each level's cells

From the extent and each level's real cell count, and no finer than hexes with edges of 3 pixels (minEdgePx in createHexTiles). See below.

Bearer-token auth, /healthz, /version

Not applicable in a browser

The server reports itself as geo-query-wasm in the MCP handshake. geo-query sends no server instructions, and neither does this server unless an app configures them.

Using geo-query-wasm from an app

Load the files from jsDelivr at a pinned commit, https://cdn.jsdelivr.net/gh/SchmidtDSE/geo-query-wasm@<commit>/src/…, or serve a copy from the app's own origin.

Over MCP, from a worker. The page starts src/worker.js and connects any MCP client over postMessage: the MCP SDK's Client with PostMessageTransport from src/transport.js, or glen-harness's MCPClient and PostMessageTransport, as the demo does. The app configures the worker through its URL, as JSON in a config parameter:

const cfg = {
    workers: 2,
    discovery: false,
    instructions: 'Text for the MCP handshake.',
    rewrite: [{ from: 's3://public-', to: 's3://us-west-2.opendata.source.coop/cboettig/public-' }],
    fetchWhole: ['https://schmidtdse.github.io/my-app/'],
};
const worker = new Worker(`${BASE}/src/worker.js?config=${encodeURIComponent(JSON.stringify(cfg))}`, { type: 'module' });

With format: "json", the query tool returns every row, with no row cap, as the JSON array that DuckDB's to_json writes, so cells with | or newlines come through intact. The worker always locks its SQL to SELECT statements on the allowed directories, because a model may write it.

Hex tiles on a map. register_hex_tiles returns a tile URL such as geoquerywasm://tiles/hex/<hash>/{z}/{x}/{y}.pbf, with geo-query's paths, so a client that reads the hash or the metadata.json URL from a tile URL finds them. The worker serves each tile and each tileset's metadata.json as an MCP resource at its URL. The page registers the scheme with MapLibre GL JS (version 4 or later), and can read the metadata with a fetch that knows the scheme:

import { addHexTileProtocol, hexFetch } from 'https://cdn.jsdelivr.net/gh/SchmidtDSE/geo-query-wasm@<commit>/src/hex-protocol.js';
addHexTileProtocol(maplibregl, client);  // client: the MCP SDK's Client, or glen-harness's MCPClient
const meta = await (await hexFetch(client)(metadataUrl)).json();

glen's map client (add_hex_tile_layer) needs both: MapLibre fetches the tiles through the protocol, and the client fetches metadata.json with the page's fetch, which an app can replace with hexFetch(client). A tile with no cells is empty, where geo-query answers 204.

In the page, without MCP. createEngine(options) from src/engine.js returns json(sql), which gives rows as to_json types them, and query(sql), which gives Arrow-typed rows with 64-bit integers as BigInt. arrow(sql) gives the Arrow table itself, and createTable(name, sql) keeps a SELECT's result in one instance's memory for later queries that pass that instance's index as { instance }, as the hex tiles do. An app that writes its own SQL from fixed templates can pass lock: false, which leaves reads unrestricted unless it also gives allowedDirectories. glen-charts' wasm engine can use the engine this way (glen-charts#28).

The worker's own options:

Option

Default

Use

catalog

The source.coop mirror of GLEN's catalog

STAC catalog URL, as STAC_CATALOG_URL. The mirror lists the license-clear collections, about 30, and a browser reads it much faster than NRP. NRP's canonical catalog, with about 80, is NRP_CATALOG_URL in src/stac.js.

discovery

true

false hides browse_stac_catalog and catalog://list, as STAC_DISCOVERY=0

extraInstructions

false

true keeps the guidance meant for small models, as EXTRA_INSTRUCTIONS

guidanceRef

222cecf

geo-query commit to take the guidance from

instructions

none

Server instructions for the MCP handshake

maxTilesets

8

Hex tilesets kept in memory. Beyond this, the least recently used is dropped, and its tile URL stops working.

The engine's options, with the defaults in src/defaults.js, which the worker also takes:

Option

Default

Use

workers

1

DuckDB instances. Each runs one query at a time and reads one or two byte ranges at a time, so a page that sends several queries at once gains from more. A query goes to the least busy instance.

secrets

NRP Ceph, and source.coop on AWS

Anonymous S3 secrets: { name, scope, endpoint, region, url_style, use_ssl }.

allowedEndpoints

NRP Ceph and the lab MinIO

S3 hosts a query's s3_endpoint may name, beyond the secrets' endpoints and AWS S3 hosts.

rewrite

none

{ from, to } text swapped in the SQL before it runs, such as an NRP prefix for its source.coop mirror. A URL that matches fetchWhole before or after the rewrites is fetched whole, so a rewrite never turns a whole-file fetch into range reads.

fetchWhole

none

URL prefixes whose .parquet files are fetched whole with fetch(), in parallel, and read from memory. GitHub Pages gzips responses and applies byte ranges to the compressed stream, which breaks DuckDB's range reads, so an app's own parquet on Pages needs this.

settings

geo-query's preserve_insertion_order and disabled_optimizers, and enable_geoparquet_conversion = false

DuckDB settings, merged over the defaults, so an app that adds one keeps the rest. A value of null leaves a default unset. The wasm build fails with "stoi: no conversion" on some GeoParquet metadata, and geometry columns are dropped from results anyway.

allowedDirectories

s3://, NRP and source.coop over https

The only places SQL may read.

lock

true

External access and extension loading off, configuration locked, SELECT only. The worker always sets it.

duckdb

1.33.1-dev57.0

The DuckDB-wasm version on jsDelivr.

Rewrites are applied, and whole-fetch files substituted, before the SELECT check, so the check covers the SQL that runs. glen-charts also sets s3_allow_recursive_globbing = false. It is not a default here, because the checks glob with **; an app can set it in settings.

Demo page

index.html runs an agent against the worker. Serve the repository as static files and open it:

python3 -m http.server 8000
# http://localhost:8000/

Enter an OpenAI-compatible endpoint, a model name and an API key, then Start. DuckDB and its extensions load in about 4 seconds. The key stays in the page's memory; the endpoint and model name are remembered in the browser. The page gives the worker instructions about the tpl-cities browser tile set, one small parquet file per California county for eight GLEN hex datasets (src/california.js). A browser reads one in under a second, whereas scans over the national hex files take many serial requests.

Checks

  • pnpm test runs the server over a MessageChannel with the MCP SDK's client, using a stub in place of DuckDB. It also tests the hex tiles' math, SQL and builds against geo-query's values, the STAC rendering against fixture collections, the guidance tiers, and the engine's SQL planning and per-query secrets.

  • node scripts/e2e.mjs runs the demo page in headless Chrome ($CHROME, a Chrome or Chromium on the PATH, or Playwright's download) against a scripted stand-in for the model, so no API key is needed. It reads the live catalog and data on the source.coop mirror, which is faster from a browser than NRP and stays up when NRP is down; no check needs NRP. It checks that SQL can glob and read s3:// paths, read one h0 partition of PAD-US, read an https:// URL, and call h3 and spatial functions. It runs a full agent turn, and checks geo-query's resources, prompt, get_collection, result formatting, per-query endpoints and refused credentials. It builds a hex tileset from one county's cells, reads a tile and its metadata.json, and draws the layer with MapLibre through the geoquerywasm: protocol. It also checks JSON results, a configured worker with two instances, a rewrite and whole-fetched files, and the engine imported into the page. Last, it checks that SQL cannot read another host, change its settings, create a secret or attach a database. CI runs it on every push.

  • node scripts/duckdb-http-probe.mjs [httpfs|autoload|s3|locked] measures the HTTP requests behind each query. The DuckDB-wasm section below reports its results.

Vendored code

vendor/mcp-server-sdk.js is the server half of the MCP SDK 1.12.0, bundled by pnpm run build:vendor. CI regenerates it and fails if it differs from what is committed. Development dependencies are installed with pnpm, which runs no dependency install scripts and installs no version less than a week old (pnpm-workspace.yaml).

DuckDB-wasm extensions and remote reads

We pin @duckdb/duckdb-wasm@1.33.1-dev57.0, which is DuckDB v1.5.4. The measurements below come from scripts/duckdb-http-probe.mjs in headless Chrome on 2026-11-06. The official documentation is the DuckDB-wasm pages on duckdb.org, especially Extensions and Troubleshoot.

Extensions. LOAD httpfs;, INSTALL spatial; LOAD spatial; and INSTALL h3 FROM community; LOAD h3; all work in this build. h3 is a community extension, so a bare LOAD h3 fails. In DuckDB-wasm, INSTALL stores nothing, because the browser keeps no storage between sessions. LOAD fetches the extension's wasm build from DuckDB's extension repository each time the page starts. Core extensions are also autoloaded on first use, but not once external access is off.

The httpfs extension must be loaded explicitly. DuckDB-wasm has two HTTP paths, and they read remote files very differently. On a 1 GB CHELSA hex parquet on source.coop with 3,780 row groups:

Query on the 1 GB file

Without LOAD httpfs

With LOAD httpfs

First read (count(*))

1 plain GET of the whole file, 1,013 MB, 28 s

2 range GETs of the footer, 0.3 MB, 1 s

avg() of one column over all rows

served from the downloaded copy

64 range GETs, 18 MB, 10 s

One row selected by a filter on h8

served from the downloaded copy

1 range GET, 0.08 MB

Without the explicit LOAD, DuckDB-wasm's built-in HTTP file system sends a GET with no Range header, so the browser downloads the whole file. This is open issue duckdb-wasm#2153, and a maintainer's workaround there is to run LOAD httpfs first. The overview describes the same behaviour the other way round, saying the httpfs path "may currently download the whole file". Our measurement agrees with the issue. An earlier version of this prototype fetched whole county files with fetch(), all at once, to avoid DuckDB-wasm's serial range requests (below). With httpfs loaded, reading a county file by URL takes under a second, so the worker now leaves reads to DuckDB.

With httpfs loaded, DuckDB-wasm reads parquet the way native DuckDB does. It reads the footer, fetches only the column chunks a query needs, and skips row groups whose min/max statistics rule them out. A selective filter on a sorted column such as an H3 index therefore reads a tiny part of the file.

S3 paths and globs work in the browser. With an anonymous S3 secret, DuckDB-wasm reads the same s3:// paths that geo-query does. Both NRP Ceph (s3-west.nrp-nautilus.io) and the source.coop bucket on AWS send CORS headers that allow any origin to make GET and range requests and to list a bucket. A glob such as s3://public-wdpa/wdpa/hex/** becomes an S3 ListObjects request, and DuckDB reads the h0= hive partitions from the paths. A plain https:// server cannot list a directory, so globs need s3:// paths. On WDPA's hex files (114 files, 4.6 GB in all) we measured:

Query

Time

Requests

Data read

glob() over the 114 files

0.1 s

1

the bucket listing

count(*) over all 114 files

17–20 s

323, at most 2 at once

11.7 MB of footers

One h0 partition, count(DISTINCT h8)

10 s

71, one at a time

19.3 MB

count(*) of one file by https:// URL

0.3 s

2–3

0.3 MB

The range requests run one at a time. In every query we measured, no more than two requests were in flight. Native DuckDB issues these reads in parallel, and geo-query's server reads with 100 threads next to the data. In the browser, latency rather than bandwidth sets the speed of a scan. Filtering on h0 reads only the matching partition, so a scoped query is quick, but a query that opens every partition of a global dataset pays a round trip per footer and per column chunk. That is why the demo page points the model at the California county files first.

Limiting what the SQL can reach. DuckDB's allowed_directories setting, combined with enable_external_access = false and lock_configuration = true, lets SQL read only the listed prefixes. A read from any other host, or from a look-alike host such as s3-west.nrp-nautilus.io.example.org, fails with a permission error, and the settings cannot be changed afterwards. We found three details in testing:

  • Each entry is a directory, so s3://public-wdpa/ allows that bucket but s3://public- allows nothing. The worker allows s3://, which reaches any bucket but only through the endpoints its secrets name. A query's own s3_endpoint must be on the allow-list (allowedEndpoints).

  • Extensions cannot be autoloaded once external access is off, so parquet, json, httpfs, h3 and spatial have to be loaded before the lock.

  • The lock does not cover secrets. CREATE SECRET still works, and a new secret could send an allowed s3:// path to another endpoint. The worker therefore checks every query with json_serialize_sql(), which DuckDB refuses for anything but a SELECT statement, and runs only queries that pass. DESCRIBE, SUMMARIZE and FROM-first queries pass, while SET, CREATE, ATTACH, COPY, INSTALL and PRAGMA do not.

Other limits from the documentation.

  • The data host must send CORS headers that allow the page's origin, for GET and HEAD. NRP and source.coop do.

  • Every request is upgraded to HTTPS.

  • HTTP authentication headers are not supported (duckdb-wasm#1967), so private buckets that need signed headers cannot be read directly. We confirmed this for S3: a read with made-up keys succeeds on a public AWS bucket, which rejects any request with a bad signature, so DuckDB-wasm sends no signature.

  • Range requests can fail on Firefox (duckdb-wasm#1932). Some S3 pre-signed URLs are not detected as supporting ranges (duckdb-wasm#2228), which forces a whole-file download.

  • WebAssembly memory is capped at 4 GB, and browsers may allow less.

  • DuckDB-wasm runs single-threaded unless the page is cross-origin isolated (COOP and COEP headers), which GitHub Pages does not provide. Multithreading is experimental.

Limitations

  • Scans of global datasets are slow. Reads are serial, as described above, and a query has no time limit, so a model that scans every partition of a large dataset can keep the user waiting for minutes. geo-query stops a query at 300 seconds.

  • Private buckets cannot be read. DuckDB-wasm does not sign S3 requests, so s3_key and s3_secret are refused.

  • Hex tiles are coarser than geo-query's at low and middle zooms. Each tile is made on request, at about 0.25 ms per hex in DuckDB-wasm, and a DuckDB instance makes one tile at a time. Two rules keep tiles small. First, the level for a zoom uses each level's real cell count: geo-query's estimate assumes seven cells roll up into one, and scattered data, such as a million points over the globe, then got a 33 MB tile at zoom 0 that took a minute to make; with the real counts it is 240 KB in half a second. Second, a hex's edge is at least 3 pixels long at the latitude in the data's bounds nearest the equator; finer levels draw specks rather than hexes. On 5.75 million res-10 cells in five southern California counties, a 1280×800 view filled in 0.8, 1.0 and 1.0 s at zooms 7, 10 and 13, at res 6, 8 and 10. geo-query's rule served res 7, 9 and 10, 17,500 and 32,600 hexes at zooms 7 and 10, and took 1.9 and 3.5 s.

  • Hex tilesets last as long as the worker, and must fit in its memory. A tileset is a table in one DuckDB instance; a page reload, or maxTilesets newer ones, removes it. WebAssembly memory is capped at 4 GB. In headless Chrome, the five counties' 5.75 million cells took 12.5 s to register: 7 s to read the parquet and the rest to build the levels and each cell's centre. A pyramid from 3 million synthetic cells held about 1 GB. A build waits behind the queries before it on its DuckDB instance, and tile requests for that tileset go to the same instance, so with one instance they also wait behind queries.

  • DuckDB-wasm, its extensions and geo-query's guidance come from jsDelivr and DuckDB's extension repositories at start-up. An offline version would serve them from the same origin.

License

Apache-2.0. See LICENSE and DISCLOSURE.md.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    A
    maintenance
    An MCP server that connects AI agents to cloud-native geospatial data via STAC metadata and DuckDB with H3 spatial indexing, enabling zero-configuration SQL queries on terabyte-scale datasets over S3.
    22
    BSD 3-Clause
  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to discover and execute analytical queries on a DuckDB dataset through typed MCP tools, with guarded read-only SQL support for complex calculations without direct database access.
    6
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Exposes a DuckDB-backed MCP server that lets an agent fetch a dataset's schema and run read-only SQL queries against it. All execution is filtered server-side, enforcing single-statement, read-only safety independent of the calling model's behavior.
    1
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP clients like Claude Desktop, Claude Code and Cursor to safely explore local CSV, Excel, Parquet and SQLite files by running guarded read-only DuckDB SQL over a sandboxed data directory. It also supports data profiling, chart generation as image content, and exporting query results, without ever allowing writes or access outside the configured folder.
    MIT