Skip to main content
Glama
gvprime

tenant-scoped-crm

by gvprime
README.md
# mcp-tenant-scoped-crm

A reference MCP server demonstrating safe agent access to multi-tenant CRM data:
tenant scoping enforced in the data layer rather than the prompt, tools that
execute under the calling user's permissions rather than a service account,
human confirmation on writes, and an audit record for every tool call. This is a
reference implementation, clearly labelled as such. It is not client work and
not a production system. It exists to make one architectural argument concrete
and runnable.

## The problem

An AI agent over a CRM is three security problems wearing one trench coat.

**Tenant-aware retrieval.** In a multi-tenant system the agent must never return
one customer's data to another. The tempting shortcut is to fetch broadly and
filter afterwards, or to instruct the model in its system prompt to stay within
a tenant. Both are wrong. A prompt is not a security control. If tenant scoping
is a post-filter or a system-prompt instruction, you have a data-leakage bug that
will not show up in testing. It will show up in a customer's screenshot. Scoping
belongs in the retrieval layer, where there is no code path that can express a
query without it.

**Permission-aware tools.** The agent's tools must run under the calling user's
role, not a service account with full access. A sales rep should see only their
own opportunities, a manager the team's, an admin the tenant's. If the tools run
under god-mode, you have built a privilege escalation path with a chat interface
on it.

**Actions requiring approval.** Reads within scope can be unrestricted. Writes
cannot. A write tool must show what it would do and require an explicit human
confirmation before it does it. And the confirmation flag must not be mistaken
for permission: confirmation is not authorisation. The write has to be scoped to
what the caller may change regardless of what they confirm.

## The three security properties

Each property is enforced at a specific point in the code and proved by a
specific test. The enforcement is not spread across the codebase or asserted in
prose; it is one place, with one test pointed at it.

### 1. Tenant isolation, enforced in the data layer

Every query runs through a `Repo` that is constructed against a `Context`
(tenant, user, role) and binds the tenant into the SQL itself. The tenant
predicate is not a caller-supplied argument, and there is no method that omits
it. A valid id from another tenant returns nothing, because `AND tenant_id =
@tenantId` sits on the row being returned, not on a join that could be dropped.

- Enforcement point: `src/repo.ts`.
- Proof: `tests/tenant-isolation.test.ts` hands a user in one tenant a real,
  existing id from another tenant and asserts that every read returns empty. The
  only thing standing between the caller and the other tenant's data is the
  tenant predicate, so the test feeds it genuine cross-tenant ids.

### 2. Permission-aware tools

Opportunity visibility is narrowed by role on top of tenant scoping: a rep sees
their own, a manager sees their reports' plus their own, an admin sees the whole
tenant. The role-to-visibility rule lives in one file and is called by the
repository, never by a tool, so a tool cannot widen its own scope.

- Enforcement point: `src/roles.ts` (the visibility predicate), applied in
  `src/repo.ts`.
- Proof: `tests/permissions.test.ts` asserts the full matrix: rep sees only their
  own, manager sees the team's, admin sees the tenant's, and no role sees another
  tenant's rows.

### 3. Read-only by default, human confirmation on writes

There is exactly one write tool, `update_opportunity_stage`. Without
`confirmed: true` it writes nothing and returns a preview of the change it would
make. With `confirmed: true` it applies the change. Both the preview read and
the write go through the same tenant-and-owner-scoped repository queries, so a
preview cannot reveal, and a confirmed call cannot mutate, an opportunity outside
the caller's scope.

- Enforcement point: `src/tools/update_opportunity_stage.ts`, backed by the
  scoped `getOpportunity` and `updateOpportunityStage` in `src/repo.ts`.
- Proof: `tests/write-confirmation.test.ts`. The case that matters most is
  **"a CONFIRMED write against a same-tenant peer's opportunity changes
  nothing"**: one rep passes `confirmed: true` against another rep's opportunity
  in the same tenant, and nothing changes. Tenant scoping alone would allow it;
  owner scoping refuses it. That test is what shows confirmation alone is
  insufficient. The cross-tenant confirmed write is proved in the same file.

Every tool call, allowed or denied, writes an audit record (`src/audit.ts`,
via the dispatch middleware in `src/dispatch.ts`). The record holds the
timestamp, tenant, user, tool, validated arguments, result count, and decision.
It stores arguments and a count, never returned row contents: an audit log that
copies protected records into itself becomes a second, unscoped store of the data
it exists to protect. The `get_audit_log` tool is itself scoped, an admin sees
the tenant's trail, everyone else sees only their own rows.

## The headline: isolation holds through the real server, not just in unit tests

Unit tests exercise the repository directly. The claim that matters is that
isolation holds through the actual MCP server over its actual transport. It does.

Running the built server over stdio as `rep-carol@globex`, calling `get_account`
with `acc-acme-1`, a real and valid Acme account id:

```
serving as rep-carol@globex (role: rep)
get_account(acc-acme-1 = a REAL acme id) as carol/globex => null
list_opportunities as carol/globex => [ { "id": "opp-globex-1", ... "tenantId": "globex" ... } ]
```

Carol asks for a genuine Acme row, by its real id, through the real server, and
gets `null`. Her opportunity list contains only her own tenant's row. The
isolation is a property of the data layer, so it does not matter that the request
arrived over MCP rather than from a test harness.

## Known limitations, stated plainly

- **Identity is asserted, not authenticated.** Under the stdio transport the
  caller's tenant and user come from environment variables (`CRM_TENANT`,
  `CRM_USER`). What the caller asserts is only their tenant and user id; their
  role is resolved from the database, never trusted from the environment. But
  nothing authenticates the assertion itself. In production this is where real
  auth would sit.
- **TypeScript cannot enforce "no code path can run an unscoped query."** A tool
  could, in principle, import the database connection and bypass the repository.
  The type system will not stop it. The connection is therefore module-private in
  `src/db.ts` and never exported, and `tests/no-raw-db-access.test.ts` asserts
  the contract structurally: no file under `src/tools` imports the connection or
  `better-sqlite3`. Encapsulation plus that test is the guarantee, not the
  compiler.
- **The SDK transport validation gap was closed, not left open.** The MCP SDK's
  high-level server validates tool arguments before the tool callback runs and,
  on failure, returns an error without invoking our code, which would leave a
  schema-rejected call unaudited. Rather than weaken the published schemas to a
  passthrough (which would trade away the tool-design property to buy the
  auditability one), the server registers tools with the SDK for schema
  publication and then supersedes the low-level `CallTool` handler in
  `src/server.ts`, so validation and auditing both happen in one place. Every
  call, valid or invalid or unknown-tool, produces an allow or deny record.
  `tests/server-audit.test.ts` proves this by driving a real MCP client over an
  in-memory transport and asserting both that the published schema is still
  strict and that a validation failure at the transport boundary is recorded as a
  deny.

## What I would do differently in production

- **Postgres row-level security** instead of an application-layer repository. The
  repository pattern here is a faithful demonstration, but the strongest place to
  enforce tenant isolation is the database itself, where even a compromised
  application cannot escape the policy.
- **Real authentication** instead of environment variables, so identity is proved
  rather than asserted.
- **pgvector** for the unstructured side of the CRM, so semantic retrieval is
  subject to the same tenant policy as structured queries.
- **Auditing at or in front of the transport**, so that malformed and rejected
  calls are recorded by infrastructure that no application bug can bypass. This
  server closes the equivalent gap in application code; in production the audit
  boundary would sit lower.

## Run it in 30 seconds

Requires Node 20. The database is in-memory and seeded on start, so there is no
setup step.

```bash
npm i
npm test
CRM_TENANT=acme CRM_USER=rep-alice npm start
```

`npm test` runs the full suite, including the tenant-isolation and
write-confirmation tests described above. `npm start` builds and serves the MCP
server over stdio as the identity in the environment.

Seeded identities: tenant `acme` has `rep-alice`, `rep-erin`, `mgr-bob`,
`admin-dan`; tenant `globex` has `rep-carol`.

To attach it to Claude Desktop, build it (`npm run build`) and add this to
`claude_desktop_config.json`, using an absolute path:

```json
{
  "mcpServers": {
    "tenant-scoped-crm": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-tenant-scoped-crm/dist/index.js"],
      "env": {
        "CRM_TENANT": "acme",
        "CRM_USER": "rep-alice"
      }
    }
  }
}
```

Change `CRM_USER` to `rep-carol` (with `CRM_TENANT` set to `globex`) to see the
same tools return a different, correctly scoped view of the data.

## Note

Built as a portfolio piece to demonstrate architecture and discipline. The
production multi-tenant automation work behind it is under NDA.

Licensed MIT. See `LICENSE`.