How Natuurhuisje’s non-technical employees ask the data platform questions in plain language and get governed answers, without a SQL console, a spreadsheet export or a cleartext guest list.
TLDR: we put Cube Core over the gold layer in BigQuery, exposed it through an MCP server with two tools, and shipped it to colleagues as an organisation-wide Claude plugin. The plugin runs as the user’s identity and is not able to generate any raw SQL. Every employee signs in with their own company Google account, so every query runs as them and BigQuery’s dynamic data masking decides what they see.

An agent should never write its own SQL
Pointing an LLM at a warehouse and letting it write SQL is generally a bad idea. Even though this sounds obvious, we still see this happening. It is really easy and fast to setup, just connect an LLM to raw tables from a database.
A colleague in customer support who wants to know how many bookings were cancelled last quarter, by house type, can simply let an LLM write some SQL. An LLM with table access will happily join the booking fact to some SCD2 history, double count every booking that ever changed status, and present the result with the same confidence as a correct one. It also reads column contents on the fly, which on this data platform means guest names, emails and addresses.
The alternatives that we often see are not much better. Traditionally the question goes to the data team and the answer arrives on Thursday, or worse, somebody exports a CSV directly from Power BI for use with their agent, introducing two versions of the truth. Real analytics as a service should mean that anyone can ask a question and always receive the same number as the validated dashboard shows.
This issue is not fixed by better prompts. You fix it by not giving the models direct table access at all. A well-defined semantic layer on top of your data that predefines any metric and KPI definition, and describes them and all fields with good metadata, is the foundation of any analytics as a service solution.
This has already been well measured and implemented
Anthropic’s own data science team published their setup. Without a structured context and semantic layer, Claude answered their analytics evaluations correctly 21% of the time. With such a layer, it scored consistently above 95%. The difference was canonical datasets instead of forty plausible candidates, a semantic layer, and procedural knowledge written down as skills.
LangChain landed on the same stack: dbt models with real descriptions and a semantic layer, plus guides and trust signals on top. Their three-person data team, supported by agents, now handles roughly forty times the request volume it could handle previously. Their conclusion: agentic data access raises the need for proper data modelling. LLMs amplify whatever they are given, both good data and bad data.
We built a semantic layer on top of a BigQuery gold layer
Natuurhuisje’s data platform runs on GCP: dlt for extraction, dbt for modelling into bronze, silver and gold, Dagster for orchestration on their own Kubernetes cluster. On top of gold sits Cube Core, where all metrics and KPIs are defined with a fixed piece of SQL, a name and a description about its use. cancellation_rate is cancelled_bookings / total_bookings, written down once in YAML and reviewed in a merge request like all other platform code.
This hardwiring of a definition matters a lot more for a model than for a human. A human who recalculates cancellation_rate slightly differently will usually notice if something looks off about the result and adjust their SQL query where needed. An agent will not (without any context).
We created a simple FastMCP server on top of Cube that exposes two tools. describe_data returns the catalogue: which cubes, measures and dimensions exist and their descriptions. read_data runs a Cube query and returns rows from BigQuery. An agent that cannot express a question in measures and dimensions has to say so, which is far more useful than a confident incorrect number. This is why the MCP server is always packaged with a matching skill that clearly defines rules and limits for the agent.

Your personal token travels all the way down to BigQuery
The easy way to deploy this is by letting the MCP server connect to Cube and BigQuery with a fixed service account. That works straight away and breaks every selective access rule: everyone can see what the service account can query, which on this platform includes personally identifiable information (PII) from guests.
So every person authenticates to the MCP server as themselves, with Google OAuth baked in:
- Your Claude client is refused with a 401 and runs the OAuth flow. The server is DCR compliant, so there is no client ID or secret for anyone to paste into a settings screen.
- You sign in with your own Natuurhuisje Google account. Only company domain email addresses get through: the hosted domain claim on the token is checked server side, fail closed, on the tool listing as well as on tool calls. An outside account does not even see the model, because the list of measures is itself information.
- Your BigQuery-scoped token is passed into Cube’s security context. Cube builds its BigQuery client from your token, not the pod’s service account, so the query runs as you.
- BigQuery decides what you see. Policy tags and dynamic data masking, attached per column by dbt, return cleartext to the people granted it and
CONFIDENTIALto everyone else.
Masking is evaluated against the principal running the query, so the policy that already governs the data warehouse governs the agentic chat window too. This prevents us from having to add an additional security ruleset to Cube, which would be duplication that introduces extra maintenance and potential for drift.

Shipping it as a cohesive package rather than individual components
-
Cube does not read dbt’s model metadata. dbt’s
persist_docspushes column and table descriptions into BigQuery. Cube should not also define those descriptions in its YAMLs manually, but it does need them for proper agent interaction. So we push all column and table descriptions from dbt to Cube in a CI step. -
A semantic layer tells an agent what exists, not how to behave. This is why it is important to always bundle your MCP and semantic layer with a skill that clearly defines the boundaries and rules. Skills are what moved Anthropic from 21% to above 95%. Ours sits next to the Cube model, so a definition change and its rules land in the same merge request.
-
Non-technical colleagues do not run CLI commands. The MCP server, the connection and the skill ship as one Claude plugin from the organisation’s plugin settings. Install, sign in once using OAuth, and the tools are there in Claude Code, chat and Cowork without ever running a single CLI command.
Chat and Cowork sign in from Anthropic’s servers
That last piece turned out to be a bit more complicated than initially thought. Claude Code integration worked straight away. Claude Chat and Cowork failed with “couldn’t register with the sign-in service” and nothing else.
The surfaces do not connect from the same place. Claude Code runs OAuth discovery and client registration from your laptop on a trusted network. Chat and Cowork don’t actually connect from your laptop; they run on Anthropic’s servers.
The host sits behind a CDN whose rules passed a login attempt from a local laptop and dropped an unknown data centre range, which is exactly what they should do. Fetching the OAuth metadata from outside the company network returned a valid response, which felt like proof and was not: the laptop passed the same rule Anthropic’s servers were failing. A request refused at the edge never reaches the ingress controller or the MCP pod, so neither logs it. For days, an empty log looked exactly like a client that never tried.
The fix was one rule at the CDN that skips the IP restriction only for the MCP endpoint, the five OAuth routes and the two discovery documents. Everything else on that hostname stays behind it.
(http.host eq "<your MCP host>") and (
http.request.uri.path eq "/mcp"
or starts_with(http.request.uri.path, "/mcp/")
or http.request.uri.path in {"/authorize" "/token" "/register" "/consent" "/auth/callback"}
or http.request.uri.path eq "/.well-known/oauth-authorization-server"
or http.request.uri.path eq "/.well-known/oauth-protected-resource/mcp"
)
That path list now exists twice, at the edge and in the Ingress. Add a route on one side and forget the other and you are back to a failure that logs nothing. If you ship an MCP server to a whole organisation, test every layer in front of your cluster from where the sign-in actually comes from.
What this looks like for a colleague
A support colleague opens Claude and asks the question from the top of this post. Claude calls describe_data, finds the measure and dimension it needs, and runs read_data. The answer comes back with the measure name, the filters and the time window next to the number, so anyone can check it against the dashboard.
Catalogue read, measure found, query run as that specific person, masking applied by BigQuery. The model never writes SQL, and the number matches the dashboard.
Wolk heeft fantastisch werk geleverd bij de opzet van ons nieuwe dataplatform. Ze combineerden hun enorme expertise in data-engineering met een fijne nuchtere aanpak, waarbij ze hun kennis direct en praktisch hebben overgedragen op ons eigen team. Dankzij deze samenwerking hebben we een grote en belangrijke stap gezet in datazelfstandigheid bij Natuurhuisje en hebben we zelf de touwtjes in handen om verder te innoveren.
– Pieter van der Hammen, CPTO at Natuurhuisje
This is not tied to BigQuery
We built this on BigQuery because that is where Natuurhuisje’s data lives, but nothing in the pattern depends on it. Cube Core is open source and there are several alternatives, MCP is an open protocol, and the same setup works on Trino over Iceberg with any OIDC provider.
Want analytics as a service on your own stack? Get in touch. At Wolk we specialize in opensource and EU sovereign data solutions.






