tenant-scoped-crm

tenant-scoped-crm

A reference MCP server demonstrating safe agent access to multi-tenant CRM data with tenant isolation enforced in the data layer, role-based permissions, and human confirmation on writes.

Category
Visit Server

README

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.

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:

{
  "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.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured