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 whenmainchanges. - 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:
- Reads through the YAML changes in the PR
- Switches to the author's branch — in Cube Cloud dev mode or locally — and tests in the Playground:
- Do all cubes and views load without errors?
- Do queries return expected results against live BigQuery data?
- Do existing cubes and views still work?
- 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_IMPERSONATORSon that environment - 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:
- In Cube Cloud, switch to the feature branch — this activates a staging environment for that branch
- Go to Settings → Staging Environments and toggle the branch to always active so queries don't fail when no one is viewing the branch
- Find the branch's API URL under API Credentials
- Point the staging instance of the connected tool (dashboard, etc.) at that URL and share it with the stakeholder
- Stakeholder tests queries and dashboards against live data — multiple branches can have active staging environments simultaneously
- Once the stakeholder approves, merge the PR — production redeploys
automatically from
main
Local Dev
cp src/cube/.env.example src/cube/.envand fill in the BigQuery connection variables (already pre-filled in.env.examplefor the teamster project — no credentials needed, ADC handles auth)- Run the GCloud: Application Default Login VS Code task if your ADC token is stale
- Run the Cube: Dev Server VS Code task (
Ctrl+Shift+P→ Tasks: Run Task) - 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.
- Click Edit Security Context and set
{"email": "you@apps.teamschools.org"}.checkAuthruns in developer mode, soresolveAccessenriches that email into the realsecurityContextand 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:
- Build the changed models to your dev schema:
uv run dbt build --project-dir src/dbt/kipptaf --target dev --defer --select <models>(lands inzz_<user>_kipptaf_marts). - 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}'. - Surgically redirect only the changed identity tables in
cube.js/ the cube YAML tozz_<user>_kipptaf_marts; leave unchanged facts/dims onkipptaf_marts. Do not redirect a table whose surrogate keys must join against prod siblings — e.g. redirectingdim_work_assignment_jobsbreaks its join to proddim_staff_work_assignments. This redirect is an uncommitted scaffold;git checkoutto revert before committing (andgrep -r zz_ src/cubeto 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.
- 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. - 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. - 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.
- 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
- 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
- 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 {}.
- Add the Cube MCP server. Replace
[YOUR-API-KEY]with your key, and update thecommandpath if yournpxlocation 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.
- Restart Claude Desktop. Press
Cmd+Qto 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):
dim_staff_cube_access— one row per active+primary staff member, keyed ongoogle_email. Carries per-field scope enums (student_location_scope,staff_pii_scope, etc.) thatcube.jstranslates into Cube group strings viaaccess.buildGroups(row).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 forreporting_chainandreporting_chain_or_below_rankscopes.
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_policy
— row_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:
- 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
- Connect the
TEAMSchools/teamsterGitHub repository - Set the Cube project path to
src/cube/ - Set the production branch to
main— merges trigger automatic redeploy - Set the following environment variables in Cube Cloud:
CUBEJS_DB_TYPE=bigqueryCUBEJS_DB_BQ_PROJECT_ID=teamster-332318CUBEJS_DB_BQ_CREDENTIALS— service account JSON (base64-encoded)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.
- The service account for BigQuery needs
roles/bigquery.dataViewerandroles/bigquery.jobUseron theteamster-332318project — both for the warehouse data (dim_*/fct_*) and fordim_staff_cube_access/dim_staff_reporting_chainused for identity resolution