Skip to content

feat: add @kepler.gl/mcp package and demo-app MCP harness bridge - #3678

Open
lixun910 wants to merge 71 commits into
masterfrom
xli-kepler-mcp
Open

feat: add @kepler.gl/mcp package and demo-app MCP harness bridge#3678
lixun910 wants to merge 71 commits into
masterfrom
xli-kepler-mcp

Conversation

@lixun910

@lixun910 lixun910 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Extracts the AI assistant's map surface into a publishable, dependency-light @kepler.gl/mcp workspace at src/mcp/, so any AI harness (Claude Code / Codex / sqlrooms) can drive kepler.gl's map without going through kepler-assistant.

@kepler.gl/mcp (src/mcp/)

  • map. commands* — get-boundary, get-dataset-context, load-data, add-layer, update-layer-color, set-basemap, add-time-filter, toggle-time-filter, split-view, plus table ops (create-table, add-column, save-data) — each with a zod inputSchema, risk metadata, and human-facing data.details results
  • Structural types only: local RoomCommand / RoomCommandResult / KeplerContext replace @sqlrooms/room-store and redux imports; the DuckDB connector slice is typed against apache-arrow so the real @sqlrooms/duckdb connector still satisfies it
  • Glue helpers move here and are re-exported: getValuesFromDataset (+ vector-tile branch), isObjectColumn, stringifyObjectColumn, restoreObjectColumns, buildAddColumnPayload
  • kepler skill/kepler ships with the package (files: ["dist", "skill"]); build files modeled on src/duckdb; jest spec ported from the vitest one

Demo-app

MCP server based on @kepler.gl/mcp

  • esbuild alias resolves @kepler.gl/mcp to source
  • kepler-mcp-bridge.tsx — opt-in WebSocket client (reverse-connect, token-gated) that connects out to a local kepler-mcp-demo process, advertises the DuckDB-free command subset with JSON-Schema-converted input schemas, and executes commands against the demo's own redux store. Auto-connects via ?mcp=<token>, or via the status chip (bottom-left)

Codex/Claude -> kepler-mcp-demo-server -> https://deploy-preview-3678--keplergl.netlify.app/

webMCP based on @kepler.gl/mcp

Codex -> https://deploy-preview-3678--keplergl.netlify.app/

webMCP-demo-1-x3.mp4

Verification

  • jest: yarn jest src/mcp
  • End-to-end with a local kepler-mcp-demo harness against the rendered demo map: bridge connect → map.get-boundary → generate 10k points within the viewport → map.load-data from the harness's /files URL → map.add-layer (points, color-by-value breaks) → map.update-layer-color → screenshot-confirmed render
  • End-to-end with Codex to connect to webMCP at https://deploy-preview-3678--keplergl.netlify.app/ and ask to generate 10k points within the viewport

Out of scope

  • npm publish of @kepler.gl/mcp (separate step once imports are proven)
  • kepler-assistant consuming the package (local repo, separate PR)

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 29, 2026 19:53
@igorDykhta
igorDykhta self-requested a review August 29, 2026 19:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new workspace package, @kepler.gl/mcp (src/mcp/), intended to externalize kepler.gl’s “map surface” (the map.* command/tool contract + implementations) into a dependency-light module that can be consumed by AI harnesses. It also wires an opt-in WebSocket “reverse-connect” bridge into the demo-app so a local MCP harness process can execute a DuckDB-free subset of those map.* commands against the demo page’s Redux store.

Changes:

  • Adds the new @kepler.gl/mcp workspace with contract types (MapContract, tool IDs), command implementations, shared helpers, and a packaged skill/kepler.
  • Adds a demo-app MCP bridge component that advertises available commands (DuckDB-free subset) and executes them via WebSocket messages.
  • Updates monorepo workspace configuration and lockfile to include the new package and its dependencies (notably zod).

Reviewed changes

Copilot reviewed 30 out of 31 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
yarn.lock Adds @kepler.gl/mcp workspace entry and zod@^4.4.0 lock entry.
src/mcp/tsconfig.production.json Adds production TS config for generating declaration files.
src/mcp/src/types.ts Introduces shared map-surface contract types and MAP_TOOL_IDS.
src/mcp/src/map-contract.ts Introduces MapContract interface and re-exports tool IDs/types.
src/mcp/src/index.ts Barrel export for contract + types + commands.
src/mcp/src/commands/utils.ts Adds shared helpers for Arrow conversion, dataset value extraction, and LLM formatting.
src/mcp/src/commands/types.ts Defines local structural RoomCommand/KeplerContext/DuckDB connector types.
src/mcp/src/commands/toggle-time-filter-command.ts Adds map.toggle-time-filter command.
src/mcp/src/commands/time-filter-command.ts Adds map.add-time-filter command + interval detection logic.
src/mcp/src/commands/table-command.ts Adds map.create-table command using DuckDB + Arrow round-trips.
src/mcp/src/commands/split-view-command.ts Adds map.split-view command.
src/mcp/src/commands/save-data-command.ts Adds map.save-data command for loading DuckDB tables into kepler.
src/mcp/src/commands/load-data-command.ts Adds map.load-data command for URL-based dataset loading.
src/mcp/src/commands/layer-style-command.ts Adds map.update-layer-color command.
src/mcp/src/commands/layer-style-command.spec.ts Adds Jest tests for map.update-layer-color.
src/mcp/src/commands/layer-creation-command.ts Adds map.add-layer command with default-layer guessing + color config.
src/mcp/src/commands/index.ts Registers/builds the command catalog and re-exports helpers.
src/mcp/src/commands/dataset-context-command.ts Adds map.get-dataset-context command.
src/mcp/src/commands/command-wrappers.ts Adds generic wrapper to adapt existing tools into RoomCommands.
src/mcp/src/commands/boundary-command.ts Adds map.get-boundary command.
src/mcp/src/commands/basemap-command.ts Adds map.set-basemap command.
src/mcp/src/commands/add-column-command.ts Adds map.add-column command for in-place column appends via DuckDB.
src/mcp/skill/kepler/skill.yaml Adds packaged skill metadata for the map surface.
src/mcp/skill/kepler/SKILL.md Adds detailed skill documentation and command guidance.
src/mcp/package.json Defines the new package build, exports, and dependencies.
src/mcp/babel.config.js Adds Babel build config (mirrors other workspaces’ patterns).
package.json Adds ./src/mcp to monorepo workspaces list.
examples/demo-app/src/kepler-mcp-bridge.tsx Adds the opt-in WebSocket bridge exposing DuckDB-free commands.
examples/demo-app/src/app.tsx Mounts KeplerMcpBridge into the demo app.
examples/demo-app/esbuild.config.mjs Adds mcp workspace alias for local-source resolution.
examples/demo-app/docs/NEXT_PLAN.md Removes now-outdated “vendored map surface” planning doc.
Suppressed comments (1)

src/mcp/src/commands/time-filter-command.ts:10

  • KeplerContext is type-only. Because the ESM build preserves imports, importing it as a value can throw at module load time. Use import type here.
import {KeplerContext} from './types';

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/mcp/src/map-contract.ts Outdated
Comment thread src/mcp/src/commands/utils.ts Outdated
Comment thread src/mcp/src/commands/utils.ts Outdated
Comment thread src/mcp/src/commands/types.ts Outdated
Comment thread src/mcp/src/commands/basemap-command.ts Outdated
Comment thread src/mcp/src/commands/split-view-command.ts Outdated
Comment thread src/mcp/src/commands/dataset-context-command.ts Outdated
Comment thread src/mcp/src/commands/layer-style-command.ts Outdated
Comment thread src/mcp/src/commands/time-filter-command.ts Outdated
Comment thread src/mcp/src/commands/layer-creation-command.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

src/mcp/src/commands/types.ts:131

  • ToolDescriptor is referenced by the demo-app (toDescriptor(...): ToolDescriptor) but isn’t defined in this package. Adding a shared descriptor type here keeps the map surface contract self-contained.
    src/mcp/src/commands/utils.ts:183
  • getValuesFromVectorTileLayer stops collecting values on the first null, which can truncate results if the field legitimately contains nulls. Vector-tile accessors can return null for missing values, so this should not break the loop.
    examples/demo-app/src/kepler-mcp-bridge.tsx:82
  • getUrlConfig() supports mcpHost, but the WebSocket URL always uses WSHost() (localhost), so the mcpHost query param is ignored.
      setError(null);

      const wsUrl = `ws://${WSHost()}:${port}/ws?token=${encodeURIComponent(useToken)}`;
      const ws = new WebSocket(wsUrl);

Comment thread src/mcp/src/commands/index.ts Outdated
Comment thread src/mcp/src/commands/utils.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (4)

Previously missed (4) — in code that hasn't changed since the last review.

examples/demo-app/src/kepler-mcp-bridge.tsx:81

  • getUrlConfig() reads mcpHost, but the WebSocket URL always uses WSHost() (localhost). This makes the ?mcpHost= parameter ineffective and can be confusing when debugging or running the harness on a different host.
      const wsUrl = `ws://${WSHost()}:${port}/ws?token=${encodeURIComponent(useToken)}`;
      const ws = new WebSocket(wsUrl);

examples/demo-app/src/kepler-mcp-bridge.tsx:92

  • ws.onmessage assumes every incoming message is valid JSON. If the server sends a non-JSON frame (or a partial message), JSON.parse will throw and break the message handler for the lifetime of the socket.
      ws.onmessage = async ev => {
        const msg = JSON.parse(String(ev.data));
        if (msg?.type !== 'call') return;

src/mcp/src/commands/time-filter-command.ts:185

  • When interval is omitted, the command iterates every row in the dataset to infer an interval (for (let i = 0; i < rows; i++)). For large datasets this can freeze the UI thread; sampling a bounded number of rows is enough for a reasonable interval estimate.
            if (fieldIdx >= 0 && dataset.dataContainer) {
              const rows = dataset.dataContainer.numRows();
              mappedValues = [];
              for (let i = 0; i < rows; i++) {
                const val = dataset.dataContainer.valueAt(i, fieldIdx);
                if (val != null) {
                  const ts = typeof val === 'number' ? val : new Date(val as any).getTime();
                  if (!isNaN(ts)) mappedValues.push(ts);
                }
              }

src/mcp/src/commands/layer-creation-command.ts:260

  • The datasetName schema description says “do NOT use the datasetId”, but the implementation accepts either datasets[dataId].label === datasetName or dataId === datasetName. This mismatch can mislead tool callers.
    inputSchema: z.object({
      datasetName: z.string().describe('The name of the dataset. Note: do NOT use the datasetId.'),
      latitudeColumn: z.string().optional(),

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated 2 comments.

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

src/mcp/src/commands/load-data-command.ts:67

  • readFileInBatches() can legally yield zero batches (or the first next() can be done: true). In that case parsedData stays empty, but the command still dispatches addDataToMap and returns success: true, which is a broken/false-positive load. Also fileName = url.split('/').pop() will include query strings (e.g. data.csv?x=1). Prefer consuming the async generator like loadExternallyHostedDataset does and erroring when nothing was produced, and derive fileName from new URL(url).pathname.
        const blob = await response.blob();
        const fileName = url.split('/').pop() || 'data';
        const file = new File([blob], fileName);

        const batches = await readFileInBatches({
          file,
          fileCache: [],
          loaders: visState.loaders ?? [],
          loadOptions: visState.loadOptions ?? {}
        });

examples/demo-app/src/kepler-webmcp.tsx:82

  • The initial enabled state reads window.localStorage.getItem(...) without a try/catch. In private mode or locked-down storage environments this can throw during render and break the whole WebMCP surface. Mirror the guarded access you already use in setPersistedEnabled.
  const modelContext = useMemo(getModelContext, []);
  const [enabled, setEnabled] = useState(
    () => (typeof window === 'undefined' ? true : window.localStorage.getItem(ENABLED_KEY) !== 'false')
  );
  const [status, setStatus] = useState<WebMcpStatus>('idle');

examples/demo-app/src/kepler-mcp-bridge.tsx:80

  • connect() writes the token with localStorage.setItem(...) without guarding for storage failures (private mode / blocked third-party storage). Since a storage exception aborts the connect path, wrap this in try/catch so the bridge can still function without persistence.
      if (!useToken) {
        setError('No token. Paste the one printed by kepler-mcp-demo.');
        setStatus('error');
        return;
      }
      localStorage.setItem('kepler-mcp-token', useToken);
      setStatus('connecting');
      setError(null);

Comment thread examples/demo-app/src/kepler-mcp-bridge.tsx
Comment thread examples/demo-app/src/kepler-mcp-bridge.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Previously missed (2) — in code that hasn't changed since the last review.

src/mcp/src/commands/split-view-command.ts:59

  • In the disable branch, toggleSplitMap(0) closes map 0 and keeps map 1 (see closeSpecificMapAtIndex: it merges visibility from 1 - payload). Since this command’s API calls the panels map0 (left) and map1 (right), disabling split view should keep the left panel by closing index 1 instead, otherwise layer visibility can unexpectedly flip to whatever map 1 had configured.

        if (action === 'disable') {
          if (isSplit) {
            ctx.dispatch(toggleSplitMap(0));
          }
          return {
            success: true,
            commandId: splitViewCommandId,
            data: {details: 'Split map view disabled. Returned to single map view.'}

src/mcp/src/commands/split-view-command.ts:88

  • toggleLayerForMapUpdater treats a missing splitMaps[i].layers[layerId] entry as false and toggles it to true (see reducer comment: “if layerId not in layers, set it to visible”). Here const isVisible = map0Layers[layerId]; yields undefined for missing keys, so layers that are already hidden can get toggled on when shouldBeVisible is false. Coerce to boolean before comparing.

This issue also appears on line 93 of the same file.

              const map0Layers = splitMaps[0]?.layers || {};
              const desiredSet0 = new Set(layerIdsForMap0);
              for (const layerId of allLayerIds) {
                const isVisible = map0Layers[layerId];
                const shouldBeVisible = desiredSet0.has(layerId);
                if (isVisible !== shouldBeVisible) {
                  ctx.dispatch(toggleLayerForMap(0, layerId));
                }
              }

src/mcp/src/commands/split-view-command.ts:101

  • Same issue for the right panel: map1Layers[layerId] can be undefined for hidden layers, but toggleLayerForMapUpdater interprets missing as false and will toggle it to true. Use Boolean(map1Layers[layerId]) so that layers not listed in layerIdsForMap1 remain hidden.
              const map1Layers = freshSplitMaps?.[1]?.layers || {};
              const desiredSet1 = new Set(layerIdsForMap1);
              for (const layerId of allLayerIds) {
                const isVisible = map1Layers[layerId];
                const shouldBeVisible = desiredSet1.has(layerId);
                if (isVisible !== shouldBeVisible) {
                  ctx.dispatch(toggleLayerForMap(1, layerId));
                }
              }

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (4)

Previously missed (4) — in code that hasn't changed since the last review.

src/mcp/src/commands/layer-creation-command.ts:176

  • The categorical colorMap validation builds a Set of every value in the column (Array.from({length: dataset.length}...)), which can be very expensive and freeze the UI on large datasets. You can validate that all provided colorMap values exist without materializing the full column by scanning rows and deleting matches from a small wanted set (early-exit when all are found), while still collecting a small sample for the error message.
    if (colorType !== 'breaks' && providedColorMap) {
      const actualValues = new Set(
        Array.from({length: dataset.length}, (_, i) => dataset.getValue(colorBy, i))
      );
      const missing = providedColorMap.filter(c => !actualValues.has(c.value)).map(c => c.value);

src/mcp/src/commands/save-data-command.ts:50

  • In the error path, the error string interpolates the caught value directly (${error}), which often becomes Error: ... or [object Object] and can leak non-string objects. Other commands in this package normalize errors to error.message / String(error); align this one so the message is predictable.
        return {
          success: false,
          commandId: saveDataCommandId,
          error: `Cannot save data to kepler.gl: ${error}`
        };

examples/demo-app/src/kepler-mcp-shared.ts:63

  • getMapboxToken reads localStorage without a try/catch. In some browser contexts (private mode, blocked storage, sandboxed iframes) this can throw and break all command execution. Other localStorage reads in the demo bridge are guarded; this one should be too.
    getMapboxToken: () =>
      typeof window !== 'undefined' ? (localStorage.getItem('mapbox-token') ?? undefined) : undefined,
    dispatch: (action: any) => reduxStore?.dispatch(action),

src/mcp/src/commands/load-data-command.ts:50

  • When loading a URL, the created File loses the original source URL, so processFileData will generate the dataset id from fileName only. That can cause collisions/overwrites if two different URLs share the same filename, and it also prevents externally-hosted metadata from being attached. Set file.keplerSourceUrl so the processors can hash by URL and persist source metadata.
        // Derive the filename from the URL pathname so query strings (e.g.
        // `data.csv?x=1`) don't end up in the dataset name.
        const fileName = new URL(url).pathname.split('/').pop() || 'data';
        const file = new File([blob], fileName);

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

src/mcp/src/map-contract.ts:34

  • MapContract.callTool redefines a result shape that already exists as ToolResult in ./types. Duplicating this contract increases the risk of the two drifting (e.g., adding a field to ToolResult but forgetting MapContract). Reuse ToolResult directly for callTool’s return type and import it from ./types.
    src/mcp/src/commands/dataset-context-command.ts:31
  • map.get-dataset-context currently assumes ctx.getDatasetContext() returns a two-part string (a human line + JSON), and blindly JSON.parse()s everything after the first newline. Since KeplerContext is meant to be a reusable host seam, this is brittle (a host returning pure JSON, or a slightly different preface, will make this tool fail). Consider parsing both formats: accept pure JSON (starting with '[' / '{') and otherwise fall back to stripping the first line.
        const datasets = JSON.parse(context.split('\n').slice(1).join('\n'));
        return {

src/mcp/src/commands/utils.ts:71

  • The comment claims the decimal conversion is “exact for any scale”, but the implementation ultimately coerces the decimal string into a JS Number. That coercion can still lose precision (or overflow) for large Decimal128 values, so the comment is misleading.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated 2 comments.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

src/mcp/src/commands/utils.ts:185

  • getValuesFromVectorTileLayer stops iterating the tileSet when it encounters a null value. In kepler's own vector-tile TileDataset iteration, nulls are treated as “skip this row” rather than “end of data”, so breaking here can truncate the returned values array and misalign downstream logic.
    examples/demo-app/src/kepler-mcp-shared.ts:43
  • buildKeplerContext declares getVisState: () => VisState (per KeplerContext), but currently returns readMap()?.visState, which can be undefined before the map is initialized. That leads to confusing downstream errors like "Cannot read properties of undefined" inside commands.
    getVisState: () => readMap()?.visState,

Comment thread src/mcp/src/commands/dataset-context-command.ts
Comment thread examples/demo-app/src/kepler-mcp-bridge.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

examples/demo-app/src/kepler-webmcp.tsx:149

  • WebMCP tool execution ignores the caller-provided AbortSignal (2nd argument to execute). This prevents agents from cancelling long-running commands and also drops cancellation semantics expected by the WebMCP API.
            execute: async input => {
              try {
                const result = await cmd.execute(
                  {store: undefined, getState: () => undefined, invocation: {surface: 'webmcp'}} as any,
                  input ?? {}
                );
                return formatResult(result as any);

Comment thread src/mcp/src/commands/utils.ts
lixun910 and others added 29 commits September 1, 2026 14:22
- toolToCommand: propagate rawOutput.error (with a fallback message)
  when a wrapped tool returns success:false without throwing, so callers
  never see {success:false, error:undefined}
- formatResult: treat success:false as a failure even when no error field
  is set, and honor a message field — a structured failure can no longer
  be formatted as a ✓ success in bridge/WebMCP logs

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…view

String(v) turned object elements into "[object Object]" and bypassed the
bigint-safe JSON stringify branch. Format each array element via
truncateValue so objects and typed values render consistently and are
truncated correctly in the LLM-facing table preview.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…s, test toggle-time-filter

- layer-creation-command: cap the colorType "unique" validation scan at
  10000 rows so a large dataset can't freeze the UI while validating
  category values — fail with the best-effort sample instead
- add-column-command: runtime guard mirroring the zod superRefine
  constraint (exactly one of copyFromColumn/expression) plus basic string
  checks, since the bridge/webMCP call execute without zod parsing
- kepler-mcp-bridge: aria-labels on the token and port inputs so screen
  readers announce them instead of relying on placeholders alone
- toggle-time-filter-command: add a Jest spec covering the invalid-action
  runtime guard, no-filters, auto-pick, out-of-range index, and show/hide
  dispatch paths

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…MCP server

The workspace does ship dependency-light helper utilities (commands/utils.ts)
that hosts are meant to reuse, so "no glue" was misleading. Wording now says
no MCP server, no analysis, no app-specific wiring glue.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
The assistant slice may hold null, but KeplerContext.getMapBoundary is
typed to return the boundary object or undefined only — returning null
would break the contract for strict consumers.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
- tsconfig.production.json: exclude *.spec.ts / *.test.ts so build:types
  no longer emits test-only .d.ts files into dist
- package.json build: add **/*.spec.ts and **/*.test.ts to the babel
  --ignore list so compiled test files don't ship to consumers

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…tract exports

- commands/index.ts: re-export RoomCommandExecuteOutput alongside
  RoomCommandResult — the demo-app imports it from @kepler.gl/mcp but it
  was missing from the commands entrypoint, breaking TS consumers
- map-contract.ts: drop the MAP_TOOL_IDS / MapToolId re-exports (they
  already come from ./types via the package root), avoiding duplicate /
  ambiguous re-export errors; keep the imports for the MapContract
  interface

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…rscore trim, README accuracy

- layer-creation-command: guessDefaultLayer returns null instead of
  falling back to the first default layer when the requested layerType
  can't be created — silently substituting a different layer type (e.g.
  requesting "h3" and getting a point layer) violated the command
  contract; the caller now errors clearly
- load-data-command: restrict map.load-data to http(s) URLs up front with
  a clear error, instead of letting other schemes reach fetch
- utils: datasetNameToTableName trims ALL leading/trailing underscores
  (/^_+|_+$/g), matching the documented behavior
- demo-app README: the chip shows "webMCP · API unavailable" when the API
  is missing — it is not hidden (the harness status stays visible)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
When filterIndex is omitted and the map has filters but none are time
filters, the auto-pick previously defaulted to index 0 and toggled the
first (non-time) filter — mutating an unrelated filter and contradicting
the "a time filter must already exist" contract. It now errors with a
clear message. Added a spec covering the no-time-filter case.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
Date.now()-based layer ids can collide when multiple layers are created
within the same millisecond (rapid successive tool calls), producing
duplicate layer ids and unpredictable reducer behavior. Append a random
suffix (the same Math.random().toString(36) trick kepler's generateHashId
uses) in both the buildLayerConfig and point-fallback id sites.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
… create-table

- add-column: escape double quotes in copyFromColumn/newColumnName before
  embedding them in quoted SQL identifiers — a `"` would otherwise break
  the query or enable SQL injection into DuckDB (the expression is
  user-provided SQL by design and is not escaped)
- create-table: fail fast when the SQL omits the required __TABLE__
  placeholder, instead of running against unintended tables or returning
  unrelated results

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
The 150ms close timer scheduled by scheduleClose was never cleared on
unmount, so setExpanded(false) could fire after the component was removed
(React "state update on an unmounted component" warning / memory leak).
Add a useEffect cleanup that clears the pending timeout.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
The bridge/webMCP call execute without zod parsing, so required inputs
could fall through to TypeErrors or surprising mutations:

- map.create-table: validate datasetName/variableNames/sql/resultDatasetName
  are non-empty strings (variableNames a non-empty string array) up front.
- map.toggle-time-filter: reject a non-integer or negative filterIndex
  instead of dispatching it into setFilterView.
- map.split-view: reject non-array or non-string entries in
  layerIdsForMap0/layerIdsForMap1 instead of treating them as iterable.

Adds a spec case for the toggle-time-filter guard.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
Both commands run non-trivial paths (DuckDB round-trips, SQL construction,
identifier escaping, object-column stringify/restore) with no coverage.
- table-command.spec: requires __TABLE__ placeholder, dedupes variableNames,
  errors on missing dataset, preserves object-valued columns through the
  stringify/restore round-trip.
- add-column-command.spec: runtime guards (exactly one of copyFromColumn/
  expression), SQL identifier escaping, expression path, unknown/duplicate
  column rejection, row-count mismatch, and the updateDataset payload.

Also picks up the on-disk LED redesign of the transport status chip (aria-label
+ title + summary, LED colors), which already includes the close-timer cleanup.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…-column

- time-filter: `interval in INTERVAL_MILLIS` matched inherited properties
  (e.g. "toString"), so a crafted interval could bypass the runtime guard and
  be written into filter state. Use an own-property check and require a string.
- add-column: zod schema allowed empty strings for datasetName/newColumnName
  and copyFromColumn/expression; the bridge/webMCP path skips zod, so an empty
  expression built `() AS ...` and failed with a non-actionable DuckDB error.
  Add .min(1) to the schema and runtime non-empty guards for the selected source.

Adds spec cases for the inherited-property interval and empty-string guards.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
setHost was destructured but never called — host is fixed from the URL config
at init (loopback-only) and not changed by the UI. Omit the setter to avoid
dead code / noUnusedLocals failures.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
… in skill

- layer-creation: the categorical colorMap validation scanned only the first
  10k rows, so a valid category appearing later in a large dataset was falsely
  flagged "not found". Sample evenly across the whole dataset (stride =
  length / cap) to avoid the prefix bias.
- demo-app: kepler.get-map-skill serves the SKILL.md verbatim, but the demo
  catalog filters out the DuckDB-backed commands (map.create-table,
  map.add-column, map.save-data). Prepend a note listing them so agents don't
  get stuck trying tools the surface doesn't serve.

Adds spec cases for the even-sampling scan and the still-rejected missing
category.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
toolToCommand only fell back to rawOutput.error when success === false, so a
tool/result shape carrying the failure in a `message` field (like
RoomCommandResult) got {success:false, error:"Tool execution failed."} and the
actionable message was discarded. Fall back to `message` before the generic
string. Adds a spec covering the message/error/generic/throw paths.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
- load-data: the fetch error only carried response.statusText, which is often
  empty and omits the numeric code. Include the status (falling back to the
  bare code when statusText is empty) so failures are diagnosable.
- layer-style: the runtime guard accepted any string in customColors, but the
  schema promises hex colors. Require numberOfColors to be a positive integer
  and each color to match #RGB/#RRGGBB/#RRGGBBAA, so bridge/webMCP callers get
  a clear error early instead of an invalid palette.

Adds spec cases for the status-code fallback and the hex/positive-integer
guards.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
mcpHost=::1 was accepted as loopback but produced an invalid WebSocket URL
(ws://::1:port/... — IPv6 hosts must be bracketed), so the bridge failed to
connect for the common bare-::1 form. Normalize the host to [::1] when it
contains a colon and isn't already bracketed.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…le branch

The vector-tile branch re-found the field by name (vtField) even though it was
already resolved just above. Reuse `field` and early-return.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…tives)

The fixed-stride sampling (step = floor(length / scanCount)) systematically
skipped values — e.g. step 2 only visited even indices — so a category that
existed in the data could still be falsely reported "not found". Scan ALL rows
with early-exit: the common case (all categories found early) stays fast, and
only a genuinely missing category costs a full scan, which is the price of an
accurate error.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
…files

A scheme-less URL is a relative reference — resolve it against the page
origin so files served by the demo app itself (e.g. /sf_streets.geojson
for a file in the served directory) can be loaded. Browsers cannot fetch
file:// URLs, so a local file must be served over http(s); the http(s)-
only scheme check still applies to absolute URLs. The resolved href is
used for fetch, the derived filename, and keplerSourceUrl so a relative
path and its absolute form hash to the same dataset id.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
A 16k-feature GeoJSON can exceed the WebMCP harness's tool-call budget and
abort mid-load. Parquet sidesteps the JSON-parse bottleneck: it is smaller
on the wire and the Arrow batches are the data (no wasted readBatch
accumulation). Kepler reads GeoParquet geometry (WKB + geo metadata) as a
geoarrow field natively, so the map renders the same lines. Document the
geopandas conversion in the command description and the skill markdown.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
An agent in a remote browser context cannot serve a local file over
http(s), so it kept concluding the page only accepts public URLs. Allow
the file content itself to be passed as a data URL (up to ~2MB): the
dataset name is derived from the MIME type, and the source is stored as
a short content hash so the dataset id stays unique per payload without
bloating the metadata. Also make the local-file guidance actionable
(copy into the demo's served dir, or npx serve --cors) in the command
description and skill markdown.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
In TABLE column mode, each feature's properties.index is the feature index
(trip ordinal), not a row index into the data container. The color accessor
was resolving field values via dc.valueAt(featureIndex, fieldIdx), which
reads the value at the feature-index row — the wrong trip's value whenever
the first trip spans more rows than the feature count. When the first trip
has many points, every trip resolves to the first trip's category (e.g. all
green for an Uber-leading dataset).

Read field values from the trip's first row (d.properties.values[0]) in
TABLE mode, matching the behavior of efb072e that 9194faa reverted.
GEOJSON mode keeps resolving by row index.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
The master release bumped all workspaces to 3.3.0-alpha.9 while the new
src/mcp package still pinned @kepler.gl/* at 3.3.0-alpha.8, which would make
yarn resolve those deps from the registry instead of the workspaces.
Bump the manifest so the package links against the local workspaces and the
lockfile stays in sync.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Xun Li <lixun910@gmail.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
The color-by-field fix wrapped the ternary's arrow functions in parens, but
the CI's `yarn eslint --fix` (prettier) strips them as unnecessary, and
babel's TS parser (7.25.0) then fails to parse the result ("Binding member
expression"). Move the ternary inside the arrow body so the branches are
plain expressions — parses cleanly and is prettier-stable.

Also apply the file's remaining prettier fixes so the committed code matches
what CI tests after `eslint --fix`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants