Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ MCP_TOOL_TIMEOUT_MS=30000
# a long random value for each environment; never commit the populated value.
SESSION_SECRET=

# Registers the Turf.js spatial-analysis tools (turf_register_dataset, turf_buffer,
# turf_points_within_polygon, turf_intersect, turf_area, turf_hex_grid). Each session's
# datasets are stored separately (SESSION_SECRET, if set, signs that session cookie; otherwise a
# random per-process secret is used — datasets are in-memory anyway, so a restart already
# invalidates them). See @cesium-ai/turf-tools. true | false (default: true)
ENABLE_TURF_TOOLS=true

# Idle time-to-live (ms) for a session's stored Turf datasets before eviction. Default: 1800000 (30 min).
TURF_DATASET_TTL_MS=1800000


# OpenTelemetry log + trace export for backend + server-side packages (mcp-tools adapter
# included). Traces cover the agent loop's GenAI spans (invoke_agent/chat/execute_tool, emitted
Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Viewer tools (camera, entities) are streamed via [Server-Sent Events](https://de
| [`@cesium-ai/codegen-czml`](packages/codegen-czml/) | Intent → verified [CZML](https://github.com/CesiumGS/cesium/wiki/CZML-Guide) generation pipeline; owns the `generateCzml` tool definition |
| [`@cesium-ai/codegen-sandbox`](packages/codegen-sandbox/) | [QuickJS](https://bellard.org/quickjs/) + WASM runtime isolation for executing verified CesiumJS snippets in the browser |
| [`@cesium-ai/mcp-tools`](packages/mcp-tools/) | Optional [Model Context Protocol](https://modelcontextprotocol.io) client bridge — opt-in via `mcp.config.json`, exposes allowlisted MCP tools to the agent |
| [`@cesium-ai/turf-tools`](packages/turf-tools/) | Server-only [Turf.js](https://turfjs.org/) spatial-analysis tools (buffer, points-within-polygon, intersect, area, hex grid), backed by a session-scoped GeoJSON dataset store — no `Viewer` dependency |
| [`@cesium-ai/webmcp-cesium`](packages/webmcp-cesium/) | Registers viewer tools on `document.modelContext`, the browser-native [WebMCP](https://developer.chrome.com/docs/ai/webmcp) standard — a different, in-browser counterpart to `@cesium-ai/mcp-tools`' server-side MCP client bridge |
| [`@cesium-ai/chat-element`](packages/chat-element/) | Reusable React chat panel component that renders streamed assistant/tool activity and the tool-approval UX |
| [`@cesium-ai/sample-config`](shared/) | App-level tool allowlist and shared `flyTo` args contract |
Expand Down Expand Up @@ -133,16 +134,17 @@ Separately from the MCP client bridge above, this app also registers its viewer

The most commonly configured variables:

| Variable | Required | Description |
| ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_CESIUM_ION_ACCESS_TOKEN` | No | Cesium Ion token, baked into the client bundle at build time. Improves terrain/imagery quality. |
| `AI_PROVIDER` | No | `openai` (default) \| `anthropic` \| `google` |
| `OPENAI_API_KEY` | Yes\* | Required when `AI_PROVIDER=openai`. |
| `ANTHROPIC_API_KEY` | Yes\* | Required when `AI_PROVIDER=anthropic`. |
| `GOOGLE_GENERATIVE_AI_API_KEY` | Yes\* | Required when `AI_PROVIDER=google`. |
| `AI_MODEL` | No | Override the default model for the selected provider. |
| `RATE_LIMIT_RPM` | No | Per-IP requests/minute for `/api/chat` (default `20`). |
| `VITE_API_BASE_URL` | No | Dev default `http://localhost:3001`. Built as `""` under `docker compose`, so the frontend calls relative `/api/chat`, proxied by nginx. |
| Variable | Required | Description |
| ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_CESIUM_ION_ACCESS_TOKEN` | No | Cesium Ion token, baked into the client bundle at build time. Improves terrain/imagery quality. |
| `AI_PROVIDER` | No | `openai` (default) \| `anthropic` \| `google` |
| `OPENAI_API_KEY` | Yes\* | Required when `AI_PROVIDER=openai`. |
| `ANTHROPIC_API_KEY` | Yes\* | Required when `AI_PROVIDER=anthropic`. |
| `GOOGLE_GENERATIVE_AI_API_KEY` | Yes\* | Required when `AI_PROVIDER=google`. |
| `AI_MODEL` | No | Override the default model for the selected provider. |
| `RATE_LIMIT_RPM` | No | Per-IP requests/minute for `/api/chat` (default `20`). |
| `ENABLE_TURF_TOOLS` | No | Registers the Turf.js spatial-analysis tools (default `true`). No `SESSION_SECRET` required. See [`@cesium-ai/turf-tools`](packages/turf-tools/). |
| `VITE_API_BASE_URL` | No | Dev default `http://localhost:3001`. Built as `""` under `docker compose`, so the frontend calls relative `/api/chat`, proxied by nginx. |

\* Only the key matching your chosen `AI_PROVIDER` is required — the rest can stay blank.

Expand Down Expand Up @@ -184,6 +186,7 @@ cesiumjs-ai-starter-app/
│ ├── codegen-czml/ # @cesium-ai/codegen-czml — CZML generation pipeline + generateCzml tool
│ ├── codegen-sandbox/ # @cesium-ai/codegen-sandbox — frontend sandbox for generated code
│ ├── mcp-tools/ # @cesium-ai/mcp-tools — optional MCP client bridge
│ ├── turf-tools/ # @cesium-ai/turf-tools — server-only Turf.js spatial-analysis tools
│ ├── webmcp-cesium/ # @cesium-ai/webmcp-cesium — registers viewer tools on document.modelContext
│ └── chat-element/ # @cesium-ai/chat-element — reusable chat panel component
├── shared/ # @cesium-ai/sample-config — enabled tools + flyTo args contract
Expand Down
17 changes: 11 additions & 6 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
# `docker build -f backend/Dockerfile .` — this is what compose.yaml uses.
#
# This app's workspace packages (@cesium-ai/server, @cesium-ai/tools-schemas,
# @cesium-ai/observability, @cesium-ai/codegen-cesium, @cesium-ai/codegen-czml, @cesium-ai/mcp-tools,
# @cesium-ai/sample-config) are resolved via npm workspaces from the repo root,
# so the backend image can't be built from the backend/ folder alone. Only
# manifests for workspaces the backend actually depends on are copied — npm ci
# is fine with the rest of the `workspaces` glob in the root package.json not
# resolving to anything on disk.
# @cesium-ai/observability, @cesium-ai/codegen-cesium, @cesium-ai/codegen-czml,
# @cesium-ai/mcp-tools, @cesium-ai/turf-tools, @cesium-ai/sample-config)
# are resolved via npm workspaces from the repo root, so the backend image
# can't be built from the backend/ folder alone. Only manifests for
# workspaces the backend actually depends on are copied — npm ci is fine with
# the rest of the `workspaces` glob in the root package.json not resolving to
# anything on disk.

# ---- Stage 1: build --------------------------------------------------------
FROM node:lts-alpine AS build
Expand All @@ -25,6 +26,7 @@ COPY packages/tools-schemas/package.json packages/tools-schemas/package.json
COPY packages/codegen-cesium/package.json packages/codegen-cesium/package.json
COPY packages/codegen-czml/package.json packages/codegen-czml/package.json
COPY packages/mcp-tools/package.json packages/mcp-tools/package.json
COPY packages/turf-tools/package.json packages/turf-tools/package.json
RUN npm ci

COPY shared shared
Expand All @@ -34,6 +36,7 @@ COPY packages/tools-schemas packages/tools-schemas
COPY packages/codegen-cesium packages/codegen-cesium
COPY packages/codegen-czml packages/codegen-czml
COPY packages/mcp-tools packages/mcp-tools
COPY packages/turf-tools packages/turf-tools
COPY backend backend

RUN npm run build:packages && npm run build -w backend
Expand Down Expand Up @@ -71,6 +74,8 @@ COPY --from=build /app/packages/codegen-czml/schema packages/codegen-czml/schema
COPY --from=build /app/packages/codegen-czml/skills packages/codegen-czml/skills
COPY --from=build /app/packages/mcp-tools/package.json packages/mcp-tools/package.json
COPY --from=build /app/packages/mcp-tools/dist packages/mcp-tools/dist
COPY --from=build /app/packages/turf-tools/package.json packages/turf-tools/package.json
COPY --from=build /app/packages/turf-tools/dist packages/turf-tools/dist

USER node

Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"@cesium-ai/sample-config": "*",
"@cesium-ai/server": "*",
"@cesium-ai/tools-schemas": "*",
"@cesium-ai/turf-tools": "*",
"@opentelemetry/api-logs": "^0.221.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.221.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.221.0",
Expand Down
47 changes: 47 additions & 0 deletions backend/src/app.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,53 @@ describe("backend app — /api/tools", () => {
expect(ionTool?.mcpApp).toEqual({ resourceUri: "ui://ion/importer" });
expect(body.tools.find((t) => t.name === "flyTo")?.mcpApp).toBeUndefined();
});

it("includes the Turf.js tools when ENABLE_TURF_TOOLS is on (the default)", async () => {
const { url } = await start(
createBackendApp({ env: fakeEnv({ ENABLE_TURF_TOOLS: true }), model: flyToModel() }),
);

const res = await fetch(`${url}/api/tools`);
const body = (await res.json()) as { tools: { name: string }[] };
const names = body.tools.map((t) => t.name);

expect(names).toEqual(
expect.arrayContaining([
"turf_register_dataset",
"turf_get_dataset",
"turf_buffer",
"turf_points_within_polygon",
"turf_intersect",
"turf_area",
"turf_hex_grid",
]),
);
});

it("omits the Turf.js tools when ENABLE_TURF_TOOLS is off", async () => {
const { url } = await start(
createBackendApp({ env: fakeEnv({ ENABLE_TURF_TOOLS: false }), model: flyToModel() }),
);

const res = await fetch(`${url}/api/tools`);
const body = (await res.json()) as { tools: { name: string }[] };

expect(body.tools.map((t) => t.name)).not.toContain("turf_buffer");
});

it("works without SESSION_SECRET set — falls back to a random per-process secret", async () => {
const { url } = await start(
createBackendApp({
env: fakeEnv({ ENABLE_TURF_TOOLS: true, SESSION_SECRET: undefined }),
model: flyToModel(),
}),
);

const res = await fetch(`${url}/api/tools`);
const body = (await res.json()) as { tools: { name: string }[] };

expect(body.tools.map((t) => t.name)).toContain("turf_buffer");
});
});

describe("backend app — CORS", () => {
Expand Down
42 changes: 36 additions & 6 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { createMcpAppRouter, createMcpSessionRouter } from "@cesium-ai/server/mc
import { createCesiumTools } from "@cesium-ai/tools-schemas";
import { CODEGEN_CESIUM_TOOL_NAMES } from "@cesium-ai/codegen-cesium";
import { CODEGEN_CZML_TOOL_NAMES } from "@cesium-ai/codegen-czml";
import {
createTurfDatasetStore,
createTurfTools,
type TurfDatasetStore,
} from "@cesium-ai/turf-tools";
import {
resolveMcpTools,
type McpToolsHandle,
Expand All @@ -15,6 +20,7 @@ import type { CodegenMetrics, Logger, ServerMetrics } from "@cesium-ai/observabi
import cors from "cors";
import express, { type Express, type Request } from "express";
import type { SessionOptions } from "express-session";
import { randomBytes } from "node:crypto";
import type { Env } from "./utils/env.js";
import { createHealthRouter } from "./routers/health-router.js";
import { createExecuteCesiumCodeTool } from "./tools/execute-cesium-code-tool.js";
Expand Down Expand Up @@ -55,8 +61,9 @@ export interface BackendAppOptions {
*/
sessionMcp?: SessionMcpManager;
/**
* `express-session` store for session middleware. Only meaningful alongside
* `sessionMcp`. Defaults to the in-memory `MemoryStore` — pass a real store
* `express-session` store for session middleware. Meaningful alongside
* `sessionMcp` and/or `env.ENABLE_TURF_TOOLS` (both need session middleware
* mounted). Defaults to the in-memory `MemoryStore` — pass a real store
* (e.g. `connect-redis`) so sessions survive a restart / are shared across
* replicas in production. See `./utils/session.js` for why this alone
* doesn't make session-scoped MCP connections multi-instance-safe.
Expand Down Expand Up @@ -118,19 +125,33 @@ export function createBackendApp({
app.use(cors({ origin: env.ALLOWED_ORIGIN, credentials: true }));
app.use(express.json({ limit: "256kb" }));

if (sessionMcp) {
if (!env.SESSION_SECRET) {
// Session-scoped MCP connections and the Turf dataset store both need a
// stable per-browser session id, so session middleware is applied whenever
// either is enabled — not just for `sessionMcp` as before.
const needsSessionMiddleware = Boolean(sessionMcp) || env.ENABLE_TURF_TOOLS;
if (needsSessionMiddleware) {
if (sessionMcp && !env.SESSION_SECRET) {
throw new Error(
"SESSION_SECRET must be set when session-scoped MCP connections are enabled.",
);
}
// Turf's dataset-store cookie only correlates a session's own in-memory
// datasets (never anything sensitive like an OAuth token), so — unlike
// `sessionMcp` above — it doesn't require an operator-set SESSION_SECRET:
// fall back to a random per-process secret so ENABLE_TURF_TOOLS's
// default-on stays a zero-config, no-`.env`-edit-required default. A
// restart simply invalidates old session cookies (their datasets are
// already gone too, since the store itself is in-memory).
const sessionSecret = env.SESSION_SECRET ?? randomBytes(32).toString("hex");
// Marks the session cookie `Secure` (HTTPS-only) whenever `PUBLIC_URL` is
// itself HTTPS — avoids sending the session cookie in plaintext over the
// network in any deployment reachable at an https:// URL, with no extra
// env var needed. Stays `false` for local http://localhost dev.
const secure = env.PUBLIC_URL.startsWith("https://");
app.use(createSessionMiddleware({ secret: env.SESSION_SECRET, secure, store: sessionStore }));
app.use(createSessionMiddleware({ secret: sessionSecret, secure, store: sessionStore }));
app.use(rateLimiter({ rpm: env.RATE_LIMIT_RPM }));
}
if (sessionMcp) {
// No `frontendUrl` needed: `/api/mcp/callback` renders its own plain
// result page directly (see @cesium-ai/server/mcp's mcp-session-router.ts)
// rather than redirecting back to "the" frontend — this app may be
Expand All @@ -139,6 +160,10 @@ export function createBackendApp({
app.use(createMcpSessionRouter(sessionMcp));
}

const turfDatasetStore: TurfDatasetStore | undefined = env.ENABLE_TURF_TOOLS
? createTurfDatasetStore({ ttlMs: env.TURF_DATASET_TTL_MS })
: undefined;

app.use(createHealthRouter({ env, modelConfigured: model !== undefined, mcp }));

app.use("/api/chat", rateLimiter({ rpm: env.RATE_LIMIT_RPM }));
Expand Down Expand Up @@ -187,9 +212,14 @@ export function createBackendApp({
// Resolved per-request rather than once: a request's own session may have
// user-initiated MCP connections (see `sessionMcp`) not known statically
// at server-construction time. `createChatRouter`'s `tools` option accepts
// a `(req) => ToolSet | Promise<ToolSet>` for exactly this reason.
// a `(req) => ToolSet | Promise<ToolSet>` for exactly this reason. Turf
// tools are built per-request for the same reason: `createTurfTools` binds
// one session id per call (see `@cesium-ai/turf-tools`), so a shared
// instance built once at startup would leak one session's id into every
// other request.
const buildTools = async (req: Request): Promise<Record<string, McpTool>> => ({
...staticTools,
...(turfDatasetStore ? createTurfTools(turfDatasetStore, req.sessionID) : {}),
...(await resolveMcpTools({ sessionMcp }, req.sessionID)),
});

Expand Down
13 changes: 13 additions & 0 deletions backend/src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ const EnvSchema = z.object({
// 401 — see `createMcpTools`'s `authRequiredServers`). Required whenever
// session-scoped MCP connections are enabled.
SESSION_SECRET: z.preprocess(blankToUndefined, z.string().optional()),

// Registers the Turf.js spatial-analysis tools (turf_register_dataset, turf_buffer,
// turf_points_within_polygon, turf_intersect, turf_area, turf_hex_grid). Default on: these are
// pure server-side data tools with no model/Viewer dependency, unlike executeCesiumCode.
ENABLE_TURF_TOOLS: boolEnv(true),

// Idle time-to-live (ms) for a session's stored Turf datasets before eviction. See
// `@cesium-ai/turf-tools`'s `TurfDatasetStore`.
TURF_DATASET_TTL_MS: z.coerce
.number()
.int()
.positive()
.default(30 * 60 * 1000),
});

export type Env = z.infer<typeof EnvSchema> & { mcpServers: McpServerConfig[] };
Expand Down
Binary file added docs/assets/turf.js.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading