An AI assistant needs one door into your data, not a key to every SaaS app

6 min#agents#architecture#patterns

The first thing anyone asks for once Claude is in the building is "can it answer questions about our customers?" The reflex answer is to connect it to the systems that hold customers: an MCP server for the CRM, another for the ticketing system, another for billing, another for the product analytics tool. Six weeks later there are a dozen connectors, each with its own login, its own idea of what an "account" is, and its own per-seat fee. The assistant has forty tools to choose from on every turn, and when someone asks for a customer's revenue it gets three different numbers depending on which tool it picked.

I built Steward, an open-source Rust service, because that reflex solves the wrong problem. The hard work of reconciling those systems has already been done, by the data team, in the warehouse. Finance already argued about what counts as revenue. Someone already decided which accounts are test accounts. The curated tables are where the organization agreed on the answers. So put the assistant on those tables, behind one door, and make the door enforce who may see what.

One tool, and adding data means adding a file

Steward exposes exactly one MCP tool, read, and it routes on a path the way a URL does. / is the catalog of kinds. /ACCOUNTS describes what an account has and what this caller may search on. /ACCOUNTS?q=acme searches. /ACCOUNTS/A-17 reads one. And /ACCOUNTS/A-17/links lists what else in the warehouse points at it. The same routing is served as plain REST under /v1/ for programs, from the same implementation, returning the same documents.

The alternative, one tool per operation per dataset, makes the model classify its own intent before it knows what it needs, and the tool list grows with every table. A single routed tool means the model learns one thing. Adding a dataset is a JSON5 schema file that names the table, the fields, their classifications and their relationships. No code, no new tool, no release. The schemas are validated together at boot, so a typo in a relationship target fails the deploy rather than a request at two in the morning.

The authorization is the product

Everything else is plumbing. What makes this safe to hand to a whole company is that every field carries a classification, and every request is authorized field by field.

Fields are grouped into sections, and each section has one classification level. The deployment declares its own taxonomy: public and internal are baseline, granted to anyone with a role. confidential_pii and confidential_financial need an explicit rule. A level marked searchable: false can be read one record at a time but never searched, filtered or sorted, so customer-written free text can be opened from a link but not trawled. A level marked restricted: true is withheld even from the admin role.

At boot the taxonomy and the access rules compile into Cedar policies and are validated against a fixed Cedar schema. read and search are separate actions, so "look at one record, never trawl" is a policy, not a prompt instruction. Every non-searchable level also gets an unconditional forbid on search, and Cedar's forbid-overrides-permit rule makes that impossible to undo with a later permit. Running steward policies prints the whole set, which is the evidence an auditor asks for when they want to know who can read what. Masking lives in the schema rather than in policy: an email appears masked in the internal section and raw in the pii section, and Cedar decides which of the two a caller gets.

The whole thing fails closed. An unknown level, an unknown section, a caller with no roles: nothing is granted. Row-level security, where visibility depends on which rows a caller owns, is deliberately out of scope, because a shared cache cannot serve it safely. That is stated in the decision record rather than left for someone to discover.

Tell the model what it does not know

MCP tool results are text a model reads and reasons about, and models over-read them. An empty page looks like "there are none". A missing total looks like zero. So every response is a small hypermedia document: the properties grouped by section, a list of ready-made links the model passes back verbatim, and an action describing what this caller can actually search on. Pagination is "follow the next link", not "compute the offset". Relationships are links derived from the schema, so account to contacts to their tickets is a sequence of link follows with no query construction by the model.

Uncertainty is stated in the document rather than implied by its shape. A search that could not count says total_unknown. A search narrowed by what the caller may see says search_narrowed. Every value is a string and NULLs are omitted, so absent never looks like zero. Search results are links, not rows, which means every row a caller ever sees went through the read check.

Refuse the wrong number, and point at the right one

The number that gets a company in trouble is the plausible one. Sum a per-account revenue column across five pages and you get a figure that looks authoritative and disagrees with what finance published, because the governed definition excluded some accounts and applied a currency rule the rows do not show.

A field can declare it is governed by a named view. A point read still returns the value, marked with a note not to aggregate it. Filtering or sorting on it is refused with a governed_metric error that names the view to use instead. A kind can also declare a governed population: rows where a flag such as IS_TEST_ACCOUNT is true are removed from every search in SQL, and a point read of one returns the same not_found as a missing id, so the refusal does not confirm the row exists. That check runs on cached rows too, because a rule that only works when someone remembers it is not a rule.

What it costs

The response format is bespoke, so a client written against HAL or Siren needs an adapter. Seeing a search result's rows takes one extra call per row. The registry of governed views is maintained by hand and describes the warehouse as of when it was written. Analytic warehouses are built for scans, so point reads are slow and cost compute, which is why there is an optional cache with stale-while-revalidate, and why searches always go live. Each of those is written down as a consequence in the decision record that created it, which is the part of this project I would defend hardest.

Where it is

The server speaks MCP and REST, reads from Snowflake or Amazon Redshift, validates OIDC tokens against any identity provider, and ships under Apache 2.0. It comes with a fictional SaaS company's data, twenty-two kinds across CRM, support, billing, HR and recruiting, with scripted storylines and questions that have known answers, so you can check what the assistant says against what is true. It is young, and the schema surface will move. The question I keep coming back to is not whether one door is better than twelve. It is how much of the data a company already trusts is sitting in the warehouse, unused by the assistant, because nobody built the door.

More in AI

All in AI →