Skip to content

Cube

Cube is the semantic layer between BigQuery and downstream reporting tools (dashboards, LLMs, apps, etc.). Cube models live in src/cube/ alongside dbt models and are version-controlled in this repository. Security — row-level filtering, column access policies, group membership — is enforced once in Cube for all downstream consumers.

Jump to: Concepts · Development Workflow · Review and Staging · Local Dev · Using Cube with Claude · Admin Setup

Concepts

Deployment types

Cube Cloud has two deployment types that control infrastructure scaling:

  • Development Instance — deallocates after inactivity and reallocates when a request comes in. Cheaper, but has a cold-start delay on the first query after idle.
  • Production Cluster — always running, no cold starts. Use once downstream tools are live and users expect instant responses.

Environments

A Cube Cloud deployment has two contexts:

  • Production environment — always tracks main. This is what downstream tools (Superset, Streamlit) connect to. Redeploys automatically when main changes.
  • Staging environments — one per branch, activated automatically when a user switches to that branch in the Cube Cloud UI. Each has its own isolated API endpoints. Multiple staging environments can be active simultaneously for different branches. Suspends after 10 minutes of inactivity by default; toggle always active in Settings → Staging Environments to keep a branch live for multi-day stakeholder review.

Development mode is the interactive UI session in Cube Cloud (not a separate environment — it targets whichever branch is currently active in the UI). Switching branches in development mode activates that branch's staging environment.

How KIPP uses them

One deployment covers everything:

Context What it is Tracks
Production Production environment main, auto-redeploys on merge
Staging Per-branch staging environments, separate API URL Any branch, multiple active simultaneously

Staging environments are how analysts test feature branches, reviewers validate changes, and stakeholders preview models before merge — all within one deployment, with no additional infrastructure.

Development Workflow

1. Create a branch

git fetch origin main && git merge origin/main
git checkout -b you/feat/my-cube-change

2. Edit cube models in VS Code

Edit files in src/cube/model/cubes/ or src/cube/model/views/.

If main has new or renamed dbt models since you last compiled, regenerate the local manifest first:

uv run dbt compile --project-dir src/dbt/kipptaf

You only need this when dbt model definitions change. If you are only editing Cube YAML files, the existing manifest stays valid.

Do not use the Playground Models tab.

In dev mode, Cube treats it as a live editor and overwrites YAML files. Edit in VS Code only.

3. Test locally

Run the Cube: Dev Server VS Code task to start Cube at localhost:4000 — see Local Dev. To exercise row-level security (not just that models compile), see Testing row-level security locally — both auth hooks run in developer mode, but each surface emulates a viewer differently.

4. Test in Cube Cloud

Push your branch, then switch to it in the Cube Cloud UI's development mode branch switcher. Cube Cloud activates a staging environment for the branch automatically.

Test in the Cube Cloud Playground or Explore. Cube Cloud does not run our checkAuth — it injects its own security context (cubeCloud.username, iss: "cubecloud") directly, so resolveAccess never runs there. Instead contextToGroups enriches that injected context: it resolves cubeCloud.username against dim_staff_cube_access and populates the groups and row_level values the access policies read, so you see your own real scope (#4526).

If every view is hidden and you see only source tables, that enrichment did not run — check the deployment log for resolveAccess failed for, and confirm the BigQuery connection variables are set on that environment (branch environments do not inherit them from production).

Check:

  • Cubes and views load without errors
  • Queries return expected results against live BigQuery data
  • Your own scope is what you expect (a region-scoped viewer sees one region)
  • Existing cubes and views still work (no regressions)

Pasted groups and scope values are discarded.

Cube Cloud merges whatever you paste into the top level of the security context. contextToGroups therefore treats every top-level value as untrusted and overwrites it by re-resolving cubeCloud.username — so a pasted groups, region_key, or allowed_abbreviations is discarded, not honored. Pasting them used to grant them (fixed in #4526); do not write code that trusts them again.

The one paste that is honored is {"email": "<a viewer>"}, and only for a caller listed in CUBE_IMPERSONATORS — see Emulating another viewer.

5. Open a PR

When ready, open a pull request from your feature branch to main.

Review and Staging

Peer review

The reviewing analyst:

  1. Reads through the YAML changes in the PR
  2. Switches to the author's branch — in Cube Cloud dev mode or locally — and tests in the Playground:
  3. Do all cubes and views load without errors?
  4. Do queries return expected results against live BigQuery data?
  5. Do existing cubes and views still work?
  6. To test row-level security behavior, either use the local matrix tool — ground truth, one connection per viewer — or emulate a viewer in Cube Cloud if your email is in CUBE_IMPERSONATORS on that environment
  7. Leaves review comments on the PR, or approves

Author and reviewer can work together in the same Cube Cloud Playground session since they're both hitting the same branch.

Stakeholder review

When a business user needs to validate changes before merge:

  1. In Cube Cloud, switch to the feature branch — this activates a staging environment for that branch
  2. Go to Settings → Staging Environments and toggle the branch to always active so queries don't fail when no one is viewing the branch
  3. Find the branch's API URL under API Credentials
  4. Point the staging instance of the connected tool (dashboard, etc.) at that URL and share it with the stakeholder
  5. Stakeholder tests queries and dashboards against live data — multiple branches can have active staging environments simultaneously
  6. Once the stakeholder approves, merge the PR — production redeploys automatically from main

Local Dev

  1. cp src/cube/.env.example src/cube/.env and fill in the BigQuery connection variables (already pre-filled in .env.example for the teamster project — no credentials needed, ADC handles auth)
  2. Run the GCloud: Application Default Login VS Code task if your ADC token is stale
  3. Run the Cube: Dev Server VS Code task (Ctrl+Shift+P → Tasks: Run Task)
  4. Playground opens at http://localhost:4000

Claude can start the dev server itself — it does not need to ask you.

Claude runs npm run dev (cwd src/cube) as a backgrounded shell call, redirecting output to a log under .claude/scratch/ and polling it for is listening on 4000. Only a foreground call fails, because a server never exits and the call hangs until timeout.

The task and Claude's invocation are the same command with the same configuration, so they are interchangeable. Use the task when you want the server visible in a terminal panel or under your own control; let Claude start one when it is verifying its own work. Claude stops it with pkill -f 'cubejs[-]server'.

Two servers cannot both bind port 4000 — if Claude reports the port in use, one of you already has it running.

  1. Click Edit Security Context and set {"email": "you@apps.teamschools.org"}. checkAuth runs in developer mode, so resolveAccess enriches that email into the real securityContext and gated views resolve. If every view still returns zero rows, a stale cached Playground token is the usual cause — see Testing row-level security locally.

The dev server always serves the main checkout — never a worktree.

The Cube: Dev Server task runs npm --prefix src/cube run dev from the VS Code workspace root, so it serves /workspaces/teamster/src/cube/ no matter which worktree you are editing in. Branch changes to cube.js or model YAML are not exercised — you are testing whichever branch the main checkout happens to be sitting on, which may be an unrelated one.

Repointing --prefix at the worktree does not fix it on its own: .env is gitignored, so a fresh worktree has only .env.example. The server would start with no BigQuery connection variables and no SQL API port at all.

For local Cube work, check the branch out in the main checkout. If you must use a worktree, copy src/cube/.env into it first, then run npm --prefix {worktree}/src/cube run dev.

Symptom: every viewer returns 0 rows in the RLS matrix while ADC is healthy and the identity table is readable. You are running a different checkout's resolveAccess.

Testing row-level security locally

Row-level security is enforced by per-view access_policy, driven by the securityContext that resolveAccess builds. Both local auth hooks run in developer mode (verified on Cube 1.6.59 and 1.7.14), and Cube Cloud is covered by the contextToGroups enrichment — so every surface can emulate a viewer by email, which is how a user's scope gets signed off before they are granted access:

Surface Emulate a viewer by Use it for
Local SQL API connecting as the viewer's email in the SQL user ground truth — the prod BI/Superset path, and one loop covers a whole matrix of viewers
Local REST Playground pasting {"email": "viewer@apps.teamschools.org"} into Edit Security Context spot checks and response metadata (e.g. usedPreAggregations)
Cube Cloud Playground / Explore pasting {"email": "viewer@apps.teamschools.org"}, if you are in CUBE_IMPERSONATORS the surface a pilot user actually uses — and the only one that shows how a denial behaves there

First, credentials. resolveAccess issues its own BigQuery reads, separate from the driver's. With the BigQuery credentials variable unset — the normal local setup — it falls back to Application Default Credentials while keeping the teamster-332318 project pin, so a current ADC login is all you need. Run the GCloud: Application Default Login task if your token is stale. A uniform zero across every viewer, including a network-scoped one, means the identity read itself failed rather than the policies denying; check the dev-server log for resolveAccess failed for <email>. Two causes, in order of likelihood: a stale ADC token, or the dev server is serving a checkout whose resolveAccess predates the ADC fallback — see the warning under Local Dev.

The SQL API is ground truth. It is the surface Superset/BI actually use, and identity resolves per connection, so one script covers every viewer. Tesseract (CUBEJS_TESSERACT_SQL_PLANNER, default true) is the planner on the SQL API, and joining views is a supported feature there (multi-fact views). Enable the SQL API in src/cube/.env:

CUBEJS_PG_SQL_PORT=15432
CUBEJS_SQL_USER=cube_dev
CUBEJS_SQL_PASSWORD=local-dev-sql
# Signs REST/Playground tokens. Cube Cloud generates its OWN value per
# deployment, so this local one is a fixed placeholder, never a real credential.
CUBEJS_API_SECRET=local-dev-secret
# Who may emulate another viewer via an `act_as` claim (#4526). Unset means
# emulation is inert, which is the correct production default.
CUBE_IMPERSONATORS=you@apps.teamschools.org
# Optional: pin EVERY connection to one viewer (dev-only override of the
# connecting user). Leave it commented to resolve as the connecting SQL user
# instead, which lets you switch viewers per connection with no restart.
# CUBE_SQL_DEV_EMAIL=someone@apps.teamschools.org

The local values above are fixed placeholders, deliberately documented here rather than treated as secrets. Local Cube binds to 127.0.0.1, and Cube Cloud generates its own CUBEJS_API_SECRET per deployment, so nothing here reaches production. The payoff is that anyone — a teammate, or Claude Code, which is blocked from reading dotenv files — can run the local validation tooling without handling a real credential. Keep it that way: never set a local value equal to a production secret, and never document a Cube Cloud value here.

Restart the Cube: Dev Server task. Identity resolves from the SQL user you connect as (unless CUBE_SQL_DEV_EMAIL is set, which overrides it) — so put the viewer's email in user, and switch viewers by opening a new connection rather than restarting. MEASURE() wraps measures:

uv run --with 'psycopg[binary]>=3.3' python - <<'PY'
import psycopg

# identity = the `user` you connect as; swap it to test a different viewer.
# prepare_threshold=None disables psycopg's automatic statement preparation —
# Cube's SQL API is a partial Postgres implementation, and there is no reason
# to prepare a statement that runs once per connection (see
# scripts/cube_rls_matrix.py, which follows the same pattern).
with psycopg.connect(
    host="127.0.0.1",
    port=15432,
    user="you@apps.teamschools.org",
    password="local-dev-sql",
    dbname="cube",
    prepare_threshold=None,
) as conn, conn.cursor() as cur:
    cur.execute("SELECT MEASURE(count_employees) FROM staff_directory")
    print(cur.fetchall())
PY

resolveAccess reads that email's row from dim_staff_cube_access, builds the real securityContext, and the policies enforce. Compare a scoped viewer's counts against a network viewer's breakdown to confirm scoping.

For the whole viewer matrix at once, use the committed tool rather than an ad-hoc script. It takes the viewer list as input, so no staff emails land in the repo:

# one email per line; .claude/scratch/ is gitignored
uv run scripts/cube_rls_matrix.py --viewers-file .claude/scratch/viewers.txt

# or inline
uv run scripts/cube_rls_matrix.py --viewers a-viewer@apps.teamschools.org

Include a network-scoped, a region-scoped, a school-scoped, a none-scope, and one deliberately unresolvable viewer. Expect the network viewer to return all four regions, the region viewer only their own, the school viewer a subset of that region, and the last two no rows at all (default-deny) — which confirms resolveAccess and the student-<scope> policies agree. Because identity is the connecting user, one run covers the matrix with no restart.

It exits non-zero if any viewer's connection or query fails, and calls out the one ambiguous result explicitly: if every viewer returns zero rows, including a network-scoped one, the identity read itself failed rather than the policies denying — check the dev-server log for resolveAccess failed for.

Treat the output as PII. Summarize it ("5 viewers checked, all scopes as intended") rather than pasting it into a PR, issue, or Slack message.

Alternative: the REST Playground. checkAuth runs in developer mode, so no NODE_ENV flip is needed: click Edit Security Context, paste {"email": "someone@apps.teamschools.org"}, and resolveAccess enriches it into that viewer's real context. One gotcha to know before you conclude a policy is broken — the Playground caches its signed token in localhost local storage, and checkAuth caps token age at 12h (maxAge, derived from iat, independent of the token's own exp). A token minted more than 12h ago fails with TokenExpiredError: maxAge exceeded and every view denies. Clear localhost local storage, or re-save the security context, to re-mint a fresh token.

Emulating another viewer with act_as

On the REST surface an approved caller can resolve another viewer's real context, which is how a pilot user's scope gets signed off (#4526). Put your own email in CUBE_IMPERSONATORS and restart, then pass both claims:

{
  "email": "you@apps.teamschools.org",
  "act_as": "a-viewer@apps.teamschools.org"
}

checkAuth resolves the target's context and the query returns their scope, not yours. Each emulated request writes one cube_emulation line to the dev-server log carrying both identities and a timestamp — identities only, no row data.

The gate reads the signed email claim, so it cannot be forged: a caller who is not in CUBE_IMPERSONATORS keeps their own scope, and their act_as is silently ignored rather than rejected. Verify that yourself before trusting the feature — mint a token as a narrowly-scoped viewer with act_as set to a broader one and confirm you get the narrow scope back. With CUBE_IMPERSONATORS unset entirely, emulation is inert, which is the correct production default.

Emulation works on two surfaces, with the same gate on each:

Surface Pass the target as Caller identity
REST (hand-minted token) an act_as claim in the signed token the signed email claim
Cube Cloud Playground and Explore {"email": "<viewer>"} in the Security Context editor cubeCloud.username, as authenticated by Cube Cloud

The shipped cube MCP server cannot emulate. _mint_token(email) in src/cube/mcp/server.py takes a single argument and hardcodes its JWT claims to {"email", "iat", "exp"} — there is no code path that sets act_as. To emulate, hand-mint the JWT yourself and call the REST API directly (see the "Alternative: REST /load with your own JWT" example below), not through the cube MCP tools.

The SQL API needs neither: identity is the connecting user, so switch viewers by reconnecting (that is what the matrix tool does).

Choosing the impersonator list

CUBE_IMPERSONATORS is read from the environment on every request, so it is purely deployment configuration — nothing is committed to enable it, and it takes effect on the next deploy or dev-server restart.

Where you set it determines whether it is a control at all:

Where Effect
Cube Cloud production / branch staging A real control — the config is not the user's to edit
Local src/cube/.env Not a control. Any developer can list themselves; running the local server already requires ADC credentials that grant direct kipptaf_marts access, so emulation grants nothing they could not query directly

So treat the local line in .env.example as developer convenience, and spend the scrutiny on the deployment values.

Selection rule: prefer callers whose own scope already covers anything they could emulate. For a viewer who already holds network student scope and all_in_scope staff PII, emulating someone else can only ever show them a subset of what they already see — emulation is a viewport change, not a grant. For anyone narrower, it is a genuine privilege grant and should be decided on its own merits rather than folded into a team roster.

To see who qualifies today:

select google_email
from `teamster-332318.kipptaf_marts.dim_staff_cube_access`
where department_group = 'data_technology'
  and student_location_scope = 'network'
  and staff_pii_scope = 'all_in_scope'
order by google_email

Those emails are staff PII: they belong in deployment configuration, never in a commit, PR, or issue. Re-run the query when the team changes — the list does not maintain itself, and a departure leaves a live grant behind.

Alternative: REST /load with your own JWT. Sign an HS256 JWT whose email claim is the viewer (with CUBEJS_API_SECRET) and POST it — again no NODE_ENV change. jsonwebtoken stamps iat automatically, so mint it fresh per session for the same maxAge reason:

tok=$(node -e "const j=require('jsonwebtoken');console.log(j.sign({email:'you@apps.teamschools.org'},process.env.CUBEJS_API_SECRET,{algorithm:'HS256'}))")
curl -s -H "Authorization: $tok" -H 'Content-Type: application/json' \
  -X POST --data '{"query":{"measures":["staff_pii.count_employees"]}}' \
  http://localhost:4000/cubejs-api/v1/load

The CUBE_GROUP_MAP trap. .env.example no longer ships this variable, and it should stay that way — but check an older .env for it, because a copy made before it was removed still carries it. It is a dev bypass that supplies groups only, so it cannot validate row_level scoping at all, and its old placeholder value used group names (cube-network-detail, cube-access-student-data) that predate the current taxonomy (student-<scope> / staff-directory / staff-pii-<scope>) — dead groups no policy matches, so every view denies. The bypass fires whenever NODE_ENV !== production and the variable is set, and it sits in the resolution path shared by both auth hooks, so it corrupts the REST and SQL surfaces alike. Leave it unset locally; never set it in Cube Cloud.

Testing branch models not yet in production. The cubes and resolveAccess read kipptaf_marts (production). If your branch reworks a mart the cubes read (dim_staff_cube_access, dim_staff_reporting_chain, etc.), production still has the old schema and resolution fails closed. To test against your branch:

  1. Build the changed models to your dev schema: uv run dbt build --project-dir src/dbt/kipptaf --target dev --defer --select <models> (lands in zz_<user>_kipptaf_marts).
  2. If a changed source is a Google Sheet, re-stage its external first — the external table caches the sheet's columns, so a stale external makes the staging model fail its contract: uv run dbt run-operation stage_external_sources --project-dir src/dbt/kipptaf --target dev --args "select: <source>.<table>" --vars '{ext_full_refresh: true}'.
  3. Surgically redirect only the changed identity tables in cube.js / the cube YAML to zz_<user>_kipptaf_marts; leave unchanged facts/dims on kipptaf_marts. Do not redirect a table whose surrogate keys must join against prod siblings — e.g. redirecting dim_work_assignment_jobs breaks its join to prod dim_staff_work_assignments. This redirect is an uncommitted scaffold; git checkout to revert before committing (and grep -r zz_ src/cube to confirm none leaked).

count_students is seasonal. On student_enrollments it anchors to is_current_record (current-as-of-now), so it returns 0 during summer/breaks. For a location-scoping check that returns real numbers year-round, use student_attendance's count_students (additive over a date range).

Signing off a new user's scope

Before granting someone access, confirm what they will actually see. The point is to catch a wrong scope while it is still a spreadsheet row, not after they open a dashboard.

  1. Check ground truth over the SQL API, against a server started with auth on (see the warning below). Put their email in a local, gitignored viewer file and run uv run scripts/cube_rls_matrix.py --viewers-file <file>. Include a network-scoped viewer in the same run as a control — if everyone returns zero, the identity read failed rather than the policies denying, and the tool says so.
  2. Compare against their intended scope. A region lead should see one region; a school leader a subset of it; someone with no student scope, no rows. A mismatch is an HR-data problem in dim_staff_cube_access, so fix it upstream rather than adding a policy to compensate.
  3. Check the surface they will actually use. For a BI tool over the SQL API, step 1 already exercised the real path. For Cube Cloud, emulate them in Explore — that catches surface-specific behavior the SQL API does not. Note that an out-of-tier member hard-errors on Cube Cloud; it also hard-errors locally, but only with auth on — see the warning below.
  4. Record the sign-off without the emails. "5 viewers checked, all scopes as intended" is the durable artifact; the identities are PII and stay local.

Run the matrix with auth ON, or denials read as falsely benign.

A dev-mode server reports an out-of-tier member request as a quiet zero rows. With auth on it is a hard failure — You requested hidden member (500) on REST, and Table or CTE with name '{view}' not found on the SQL API, because the view is absent from that viewer's schema entirely. Cube Cloud runs with auth on, so a dev-mode "no rows" can mean "the query fails" in production: an empty chart versus an error.

This is a mode difference, not a version difference. Measured on both Cube 1.6.59 and 1.7.14, all four combinations, the denial shape tracks the mode and is identical across versions (#4605). Upgrading Cube does not change it.

So start the server with auth enabled before signing off:

cd src/cube && NODE_ENV=production CUBEJS_DEV_MODE=false npm run dev

Scoped viewers return identical row counts either way, so a dev-mode run is still authoritative for which rows a viewer can reach. Only the shape of a denial needs auth on.

SQL-level RLS invariants to check

The default-deny behavior rests entirely on Cube compiling an empty allow-list array to IN () — no explicit "deny" branch exists at the SQL level. These two cases exercise that boundary directly and are worth running whenever access_policy or a pre-aggregation's dimension list changes.

1. Empty-allow-list case. A viewer whose role resolves staff_pii_scope='all_in_scope' but staff_department_scope='none' should get zero rows on staff_pii, not an error and not every row. Over the SQL API (viewer identity = the connecting user):

uv run --with 'psycopg[binary]>=3.3' python - <<'PY'
import psycopg

with psycopg.connect(
    host="127.0.0.1",
    port=15432,
    user="a-department-scope-none-viewer@apps.teamschools.org",
    password="local-dev-sql",
    dbname="cube",
    prepare_threshold=None,
) as conn, conn.cursor() as cur:
    cur.execute("SELECT MEASURE(count_employees) FROM staff_pii")
    print(cur.fetchall())  # expect zero rows / zero count, not an error
PY

2. Pre-agg-served scoped case. Once a cube carries a pre-aggregation (e.g. proficiency_rollup on student_assessment_scores), a region-scoped viewer querying a rolled-up measure by subject on the corresponding view should get region-scoped rows AND the response should show the query was served by the pre-aggregation (rollup hit), not a fact-table fallback. Over the REST API, check usedPreAggregations in the response metadata:

curl -s -H "Authorization: $tok" -H 'Content-Type: application/json' \
  -X POST --data '{"query":{
    "measures":["student_assessment_scores_view.pct_proficient"],
    "dimensions":["student_assessment_scores_view.academic_subject"]
  }}' \
  http://localhost:4000/cubejs-api/v1/load | jq '.usedPreAggregations'

An empty {} means the query fell back to the fact — check that every row_level scoping member the view filters on (e.g. region_key, abbreviation) is also declared in the pre-aggregation's dimensions: list. A schema test can catch this statically; this case confirms it end-to-end against a live server.

Warnings

Do not set CUBE_GROUP_MAP in Cube Cloud. This variable is a dev bypass that short-circuits BigQuery identity reads; it must never be configured in production. It only supplies groups (not the row_level interpolation values), so it cannot validate row-level scoping even locally. It is no longer in .env.example; leave it out. See Testing row-level security locally.

Do not set CUBE_IMPERSONATORS in Cube Cloud without deciding the list deliberately. Every listed email can resolve any internal user's full context on that deployment, including student PII and the gated staff fields. Unset means emulation is inert, which is the right default. See Choosing the impersonator list.

Do not use the Cube Playground Models tab in dev mode. It overwrites YAML files in model/cubes/ and model/views/ with auto-generated content, discarding hand-authored definitions.

When a user requests a field their access tier excludes, Cube blocks the entire query — it does not silently drop the column and return the rest. In practice this only surfaces in Tableau, where a workbook published by someone with broad access (e.g. staff-pii) may error at query time for viewers with narrower access. BI tools that connect via Cube's SQL API (Superset) avoid this because each user's field list is filtered at connection time. A member-strip approach that drops inaccessible fields transparently is tracked in #4268. Until then, build Tableau workbooks using only the fields your least-privileged audience can see, or publish separate workbooks per access tier.

Using Cube with Claude

The Cube MCP server lets Claude query your organization's data using plain English — no SQL required. Once connected, you can ask questions like:

  • "What metrics are available?"
  • "Show me ADA by school for this year"
  • "What are the available dimensions in the student cube?"

Claude uses the Cube semantic layer to find and return the right data. You'll need a Cube API key from the data team before getting started.

Claude Desktop

  1. Install Node.js if you don't have it:
node --version

If you see a version number, skip ahead. Otherwise install via Homebrew:

brew install node

Then find the full path to npx — you'll need it below:

which npx
  1. Open the config file. In Claude Desktop, go to Settings → Developer → Edit Config. Or navigate directly in Finder:
~/Library/Application Support/Claude/claude_desktop_config.json

If the file doesn't exist yet, create it with an empty {}.

  1. Add the Cube MCP server. Replace [YOUR-API-KEY] with your key, and update the command path if your npx location differs:
{
  "mcpServers": {
    "cube-mcp-server": {
      "command": "/opt/homebrew/bin/npx",
      "args": [
        "-y",
        "mcp-remote",
        "https://ai.gcp-us-central1.cubecloud.dev/api/mcp",
        "--transport",
        "http"
      ],
      "env": {
        "CUBE_TOKEN": "[YOUR-API-KEY]"
      }
    }
  }
}

If your config already has content, add mcpServers alongside the existing keys — don't replace anything.

  1. Restart Claude Desktop. Press Cmd+Q to fully quit (don't just close the window), then reopen. A tools/hammer icon in the bottom-right of the chat input confirms the server is connected.

!!! warning "Keep your API key private." Treat it like a password — don't share your config file with anyone outside the pilot group.

Claude Code (VS Code)

In the Codespace, the Cube MCP server is already configured in .mcp.json — no manual setup required. When Claude Code first tries to use it, you'll be prompted to OAuth into Cube Cloud. Approve the connection and you're ready to query.

Using Cube in Claude

Once connected, toggle Cube MCP on via the + or tools menu in your chat, then ask questions in plain English:

  • "What data do you have access to?"
  • "What metrics can I query?"
  • "Show me [metric] by [dimension] for [time period]"

Claude interprets your question using the Cube semantic layer and returns results directly in the chat. No table names, field names, or SQL required.

Troubleshooting

"Server disconnected" error — Claude can't find npx. Run which npx in Terminal and make sure the path in your config matches exactly.

npx not found — Node.js isn't installed. Follow step 1 above.

Tools icon doesn't appear after restart — Your JSON has a formatting error (missing comma, mismatched brackets). Paste the file into jsonlint.com to check.

Check the logs — For any other issue, check the MCP server log:

tail -f ~/Library/Logs/Claude/mcp-server-cube-mcp-server.log

Admin Setup

How access is resolved

Cube resolves each user's access at query time via two BigQuery reads against kipptaf_marts (no Google Admin Directory API):

  1. dim_staff_cube_access — one row per active+primary staff member, keyed on google_email. Carries per-field scope enums (student_location_scope, staff_pii_scope, etc.) that cube.js translates into Cube group strings via access.buildGroups(row).
  2. dim_staff_reporting_chain — transitive closure of the org tree, keyed on (manager_staff_key, reportee_staff_key). Used to resolve the viewer's direct and indirect reports for reporting_chain and reporting_chain_or_below_rank scopes.

Results are cached until next midnight ET. A staff member not in dim_staff_cube_access (e.g. a non-staff admin user) resolves to an empty group list and sees no data (default deny).

Access groups

access.buildGroups(row) emits scope-specific group strings from the access row's scope columns. A viewer holds at most one group per axis, and each gated view's access_policy matches exactly one of them — no group on an axis means default-deny for the views gated by it:

Group Emitted when
student-region / student-school / student-network matching non-none student_location_scope
staff-directory always (every resolved viewer)
staff-pii-<scope> one group per non-none staff_pii_scope
staff-compensation / staff-observations / staff-benefits matching non-none *_scope

The staff_pii_scope values are all_in_scope, teaching_staff, reporting_chain, and reporting_chain_or_below_rank. The compensation / observations / benefits groups are emitted but no view consumes them yet (forward-compat).

Row-level filtering is enforced declaratively in each view's access_policyrow_level filters that interpolate the securityContext values resolveAccess builds — not in queryRewrite, which now carries only the snapshot-anchor guard. Student domains are single collapsed views (no summary/detail split); any student-<scope> group sees every field, including PII, with location scoping applied by the matching policy. Staff is split into staff_directory (open roster, no PII) and staff_pii (the sensitive fields, gated per staff_pii_scope by a location-and-department remit precomputed into securityContext).

Cube Cloud One-Time Setup

Performed in the Cube Cloud UI by an admin:

  1. Create a new Cube Cloud deployment — use Development Instance type for now; switch to Production Cluster before connecting downstream tools (Superset, Streamlit) so queries don't hit a cold start
  2. Connect the TEAMSchools/teamster GitHub repository
  3. Set the Cube project path to src/cube/
  4. Set the production branch to main — merges trigger automatic redeploy
  5. Set the following environment variables in Cube Cloud:
  6. CUBEJS_DB_TYPE=bigquery
  7. CUBEJS_DB_BQ_PROJECT_ID=teamster-332318
  8. CUBEJS_DB_BQ_CREDENTIALS — service account JSON (base64-encoded)
  9. CUBEJS_SQL_SUPER_USER=cube-superset-service — SQL API super-user for Superset user impersonation (follow-up integration)

Cube Cloud automatically generates CUBEJS_API_SECRET, the SQL API username, and the SQL API password on deployment creation — find them under the deployment's Settings → Environment Variables. Do not set these manually.

  1. The service account for BigQuery needs roles/bigquery.dataViewer and roles/bigquery.jobUser on the teamster-332318 project — both for the warehouse data (dim_* / fct_*) and for dim_staff_cube_access / dim_staff_reporting_chain used for identity resolution