<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://harisnadeem93.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://harisnadeem93.github.io/" rel="alternate" type="text/html" /><updated>2026-09-09T14:54:18+00:00</updated><id>https://harisnadeem93.github.io/feed.xml</id><title type="html">Haris Nadeem</title><subtitle>Principal Software Engineer. I write about the parts of multi-tenant SaaS that only show up once you have more than one customer.</subtitle><author><name>Haris Nadeem</name></author><entry><title type="html">Ticket IDs restart per tenant, and other things I learned wiring a .NET backend to an MCP server</title><link href="https://harisnadeem93.github.io/2026/09/09/ticket-ids-restart-per-tenant.html" rel="alternate" type="text/html" title="Ticket IDs restart per tenant, and other things I learned wiring a .NET backend to an MCP server" /><published>2026-09-09T00:00:00+00:00</published><updated>2026-09-09T00:00:00+00:00</updated><id>https://harisnadeem93.github.io/2026/09/09/ticket-ids-restart-per-tenant</id><content type="html" xml:base="https://harisnadeem93.github.io/2026/09/09/ticket-ids-restart-per-tenant.html"><![CDATA[<p>Almost everything written about the Model Context Protocol assumes you’re building in TypeScript or Python, and that the data your tools reach for lives somewhere convenient. Neither was true for me. The agent and MCP server sat in a Node service. The data they needed — tickets, work logs, time entries, org membership — sat in a .NET backend with a separate database per customer.</p>

<p>Connecting those two took longer than I expected, and almost none of the difficulty was about MCP. It was about tenancy. I’m writing this down because I couldn’t find much on it when I needed it.</p>

<p>There’s a runnable version of the pattern here: <a href="https://github.com/harisnadeem93/dotnet-mcp-tenancy">dotnet-mcp-tenancy</a>. Stripped-down .NET API plus MCP server, demonstrating everything below — including the bugs.</p>

<h2 id="why-not-just-let-the-agent-read-the-database">Why not just let the agent read the database</h2>

<p>This was the first thing people suggested, and it’s the wrong instinct.</p>

<p>In a database-per-tenant system, a global database holds the tenant registry and handles provisioning, each customer gets their own store, and migrations fan out across all of them. Identity often lives in a separate auth service, so the tenant databases don’t even have a user table — only user references.</p>

<p>Point an agent at that directly and you’ve handed a language model the job of picking the right connection string. Every tool call becomes a place where one customer’s data can surface for another. I didn’t want tenant resolution to be something the model could get wrong, so it had to happen in code, on the backend, before any query ran.</p>

<p>The shape I settled on: the Node service owns the agent and the MCP server. The .NET backend exposes a small set of internal endpoints under <code class="language-plaintext highlighter-rouge">/internal/</code>, behind a service-key header. Each MCP tool is a thin wrapper that calls one of those with an explicit org and user. The model picks a <em>tool</em>. The tenant comes from the authenticated session that invoked it.</p>

<h2 id="internal-endpoints-are-not-your-public-api">Internal endpoints are not your public API</h2>

<p>I assumed at first we’d reuse the existing controllers. That was wrong, and it took a while to see why.</p>

<p>A public endpoint answers a request from a browser session that’s already authenticated and scoped. An internal service-to-service endpoint answers another <em>service</em>, acting on behalf of a user, in a tenant the caller has to name. Different trust models. Serving both from one pipeline means either weakening the public one or contorting the internal one.</p>

<p>So the internal surface got its own controller that does nothing else. Every action takes org and user explicitly. Nothing is inferred from a cookie, because there isn’t one.</p>

<p>The part that surprised me: these endpoints need <em>more</em> validation than the public ones, not less. When a human links a ticket to a task, they’re doing it through a UI that only offers valid options. When an agent does it, it’s working from whatever it inferred from a conversation. So the link operation checks the ticket exists, checks it’s active, checks whether the task is already linked, and <strong>refuses to overwrite</strong> rather than silently replacing — returning a message the agent can relay straight back.</p>

<p>That refusal behaviour matters more than it sounds. An agent that quietly overwrites a link is much worse than one that says “that task is already linked to #114, unlink it first.” The first one loses data and nobody notices for a week.</p>

<h2 id="ticket-ids-restart-per-tenant">Ticket IDs restart per tenant</h2>

<p>Here’s the one that cost me an afternoon.</p>

<p>I moved an AI diagnosis feature from an inline call to a queued job. When I built the job table I keyed jobs on ticket ID, which is the obvious thing to do and is completely wrong in a database-per-tenant system. Ticket numbering restarts in every tenant store. Tenant A’s ticket 47 and tenant B’s ticket 47 are different tickets, and a job keyed on <code class="language-plaintext highlighter-rouge">47</code> will cheerfully attach one customer’s diagnosis to another customer’s ticket.</p>

<p>Jobs are keyed on <code class="language-plaintext highlighter-rouge">(TenantId, TicketId)</code> now. If you’re building anything asynchronous over per-tenant databases, go and check your job keys, your cache keys and your idempotency keys. Any identifier unique only <em>within</em> a tenant will betray you the moment it leaves one.</p>

<p>While I’m here — the reason for queuing at all is that Azure App Service terminates a request at 230 seconds and a diagnosis run takes minutes. The inline version also bypassed the concurrency cap entirely, so runs were effectively unbounded. We just hadn’t noticed, because nobody had triggered enough at once.</p>

<p>I chose polling over a callback from the worker service. A callback needs retry logic and has to carry the tenant with it to find the ticket again. Polling needs neither, survives a restart because all the state is in one row, and requires no hosted service or background worker. It’s the less elegant design and it was the right one.</p>

<h2 id="telemetry-is-where-the-tenancy-bugs-hide">Telemetry is where the tenancy bugs hide</h2>

<p>We tracked AI token usage so organisations could see their spend and split it across projects. That turned out to be the buggiest surface in the system, and every bug was the same bug wearing a different hat.</p>

<p>The usage view showed nothing recorded while the product was visibly being used. Three separate causes. The streaming handler dropped token counts when the stream completed, without ever recording them. Tool operations — embeddings, search, evaluation — recorded a flat source with a null user, so nothing could be attributed to a caller. And where a workspace hadn’t been explicitly linked to a project, unassigned usage records from <em>other organisations</em> leaked into the summary.</p>

<p>That third one is the one I still think about. It wasn’t a feature that broke. It was a query that was correct under a single-tenant assumption nobody had written down.</p>

<p>The fix was small — an optional user ID on the usage record, mapped through the usage controller — but it’s what made everything else possible: telling apart tokens a human spent in chat from tokens someone’s IDE spent through MCP, and scoping project usage strictly to authorised members of that org rather than to whatever happened to be unassigned.</p>

<p>If you’re adding agent surfaces to a multi-tenant product, build attribution before you build features. Retrofitting means auditing every call site, and you will miss one. Usually the background job — which then shows up as a gap between the provider’s invoice and your own numbers.</p>

<h2 id="the-connector-table-with-no-tenant-column">The connector table with no tenant column</h2>

<p>Last one, and it’s my favourite because it’s so ordinary.</p>

<p>Customers create OAuth clients to connect Claude, ChatGPT or Copilot to their workspace. Those connector records lived in the AI service’s own table — which had no tenant column, because when it was written there was only one tenant that mattered.</p>

<p>The symptom: a brand-new, empty workspace would open the connectors page and see every connector in the product.</p>

<p>We moved the data into the per-tenant stores, where isolation already existed and was enforced. Only the data moved. The endpoint stayed exactly where it was, so every already-connected client kept working through the migration and nobody had to re-authenticate.</p>

<p>There’s a lesson in that beyond the bug. When you bolt an AI layer onto an existing product, that layer tends to get built as though it’s a greenfield single-tenant app — because that’s how all the examples are written. Every table it introduces is a place where your tenancy model doesn’t apply yet.</p>

<h2 id="what-id-tell-someone-starting-this">What I’d tell someone starting this</h2>

<p>Keep tenant resolution out of the model’s reach. Give internal endpoints their own controller and their own trust model. Validate harder for agents than for humans, and make tools refuse rather than overwrite. Treat every identifier that’s unique within a tenant as a landmine outside one. And instrument attribution on day one, because tenancy bugs surface in your telemetry first — assuming your telemetry is capable of showing them at all.</p>

<p>None of this is MCP-specific, which is sort of the point. The protocol was the easy part.</p>

<hr />

<p><em>Haris Nadeem is a Principal Software Engineer working on multi-tenant SaaS platforms in .NET and React. Code for this post: <a href="https://github.com/harisnadeem93/dotnet-mcp-tenancy">github.com/harisnadeem93/dotnet-mcp-tenancy</a></em></p>]]></content><author><name>Haris Nadeem</name></author><category term="mcp" /><category term="dotnet" /><category term="multi-tenancy" /><category term="saas" /><summary type="html"><![CDATA[Putting an agent in front of per-tenant data. Four bugs, and why none of them were about MCP.]]></summary></entry></feed>