glen-wasm
OfficialRuns DuckDB-wasm in a Web Worker to hold one California county's H3 hex datasets as queryable tables, exposing tools to list the loaded datasets and to execute SQL queries against them, with external file and network access disabled.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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 S3The 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.jsimplements the MCP SDK's transport interface overpostMessage, 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 andh0=hive partitions work as they do on the server.The SQL can read only the data hosts. DuckDB's
allowed_directorieslimits reads tos3://paths and tohttps://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_tileskeeps its H3 pyramid as a table in DuckDB's memory, and the worker encodes each tile as MVT with DuckDB'sST_AsMVTwhen 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.jsholds 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 |
| Same markdown |
| Same markdown |
| Same JSON, with |
| Same arguments and errors. Adds |
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 |
| Same per-query secret and endpoint allow-list |
| Refused with the reason: DuckDB-wasm does not sign S3 requests |
| Same |
| Same text ( |
| The same files at a pinned commit, with |
|
|
Locked DuckDB configuration | Locked, and also SELECT only, because the lock does not cover secrets |
| 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 |
MVT tiles and | MCP resources at the same paths under |
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 ( |
Bearer-token auth, | 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 |
| The source.coop mirror of GLEN's catalog | STAC catalog URL, as |
| true | false hides |
| false | true keeps the guidance meant for small models, as |
|
| geo-query commit to take the guidance from |
| none | Server instructions for the MCP handshake |
| 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 |
| 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. |
| NRP Ceph, and source.coop on AWS | Anonymous S3 secrets: |
| NRP Ceph and the lab MinIO | S3 hosts a query's |
| none |
|
| none | URL prefixes whose |
| geo-query's | DuckDB settings, merged over the defaults, so an app that adds one keeps the rest. A value of |
|
| The only places SQL may read. |
| true | External access and extension loading off, configuration locked, SELECT only. The worker always sets it. |
|
| 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 testruns the server over aMessageChannelwith 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.mjsruns 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 reads3://paths, read oneh0partition of PAD-US, read anhttps://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 itsmetadata.json, and draws the layer with MapLibre through thegeoquerywasm: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 | With |
First read ( | 1 plain GET of the whole file, 1,013 MB, 28 s | 2 range GETs of the footer, 0.3 MB, 1 s |
| served from the downloaded copy | 64 range GETs, 18 MB, 10 s |
One row selected by a filter on | 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 |
| 0.1 s | 1 | the bucket listing |
| 17–20 s | 323, at most 2 at once | 11.7 MB of footers |
One | 10 s | 71, one at a time | 19.3 MB |
| 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 buts3://public-allows nothing. The worker allowss3://, which reaches any bucket but only through the endpoints its secrets name. A query's owns3_endpointmust 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 SECRETstill works, and a new secret could send an alloweds3://path to another endpoint. The worker therefore checks every query withjson_serialize_sql(), which DuckDB refuses for anything but a SELECT statement, and runs only queries that pass.DESCRIBE,SUMMARIZEand FROM-first queries pass, whileSET,CREATE,ATTACH,COPY,INSTALLandPRAGMAdo not.
Other limits from the documentation.
The data host must send CORS headers that allow the page's origin, for
GETandHEAD. 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_keyands3_secretare 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
maxTilesetsnewer 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.
This server cannot be deployed
Maintenance
Related MCP Connectors
Query, join, profile, clean and convert CSV/JSON/Parquet with server-side DuckDB over MCP.
Analytical memory for AI agents: a real Postgres queried in plain English over MCP. One command.
Query your warehouse or a CSV with Claude/ChatGPT over MCP, governed by table-level ACL + audit.
Text-to-SQL MCP server: read-only queries on PostgreSQL, MySQL and SQL Server with your real schema
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceAn 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.22BSD 3-Clause
- AlicenseAqualityCmaintenanceEnables 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.6MIT
- FlicenseNot gradedqualityCmaintenanceExposes 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-
- AlicenseNot gradedqualityCmaintenanceEnables 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