The MCP server I almost didn't build | Building Products  

 [Mischa Sigtermans](https://mischa.sigtermans.me)

   Menu Close        [Thoughts](https://mischa.sigtermans.me/thoughts) [Books](https://mischa.sigtermans.me/books) [Consultancy](https://mischa.sigtermans.me/consultancy) [About](https://mischa.sigtermans.me/about)  

 [← Thoughts](https://mischa.sigtermans.me/thoughts)   July 31st, 2026  · AI Product 

The MCP server I almost didn't build
====================================

I committed an MCP server for Stagent and deleted it the same day. A year later it has 40 tools, per-team consent, and one test file that tries to steal another agency's data.

On 20 August 2025 I committed an MCP server for [Stagent](https://stagent.com). Two commits later, the same day, I removed it.

I'd built the thing before I could answer the question underneath it: what happens when an AI asks my software for a booking, and the software says yes to the wrong person?

It came back a month later. This month it grew to 40 tools, and most of my work on it had nothing to do with tools.

What it does
------------

Model Context Protocol is the standard for letting an AI query an application directly instead of through copy and paste. A booking agent asks their assistant to draft a follow-up to a promoter, and the assistant pulls the booking, the fee, the advancing status and the promoter's contact from Stagent itself.

The server exposes 40 tools now. 30 read, 10 write. Teams, artists, bookings, events, venues, companies, contacts, invoices, contracts, statements, travel and itineraries, advancing, activity, plus a search across all of it. The tool list reads like the sidebar of the app, which is the point: these are the lookups an agent already does by clicking.

The interesting decisions are all in the other half.

Writes are off until you say so
-------------------------------

The ten write tools create and update. Nothing deletes, ever. Fees can't be edited at all, because a deal change belongs in the app where a human signs off on it.

And writing is off by default. The server's own instructions say it plainly:

> Write tools appear only when the user has enabled assistant writes in their account settings. They create and update; nothing can be deleted.

Without the toggle, the write tools are hidden from the tool list and denied at call time. Not greyed out, not refused with an explanation the model can argue with. Absent. A tool the model doesn't have is a tool it can't loop on.

The permission model was already there
--------------------------------------

Every tool enforces the same per-team permissions the web app checks. Not a parallel set of rules for AI, the same ones: `bookings.view`, `events.itinerary.view`, `advancing.view`, `analytics.view`, and the rest.

One line in the instructions took me a while to get right, and it's aimed at the model rather than the code:

> An absent field or an empty list can mean 'not permitted', never assume it means 'none exist'.

A model that treats 'you can't see this' as 'this doesn't exist' will confidently tell a booker their artist has no shows next month. Silence and absence are different answers, and only one of them is safe to summarise.

There's a Stagent-specific trap in there too. Our roster limits normally resolve from whichever team you're currently in, which is a meaningless concept for a stateless MCP request. So those limits get re-applied explicitly on every query. The invisible assumptions in your app are exactly what breaks when a different kind of client shows up.

Consent is per team, not per account
------------------------------------

In July I added something I hadn't planned: a per-team AI data-sharing consent. An agency decides whether its data may feed an AI surface at all, and a team without consent never appears in an MCP response, no matter who's asking or what permissions they hold.

That matters because a user can sit in several agencies. One of them saying yes to AI is not the other one saying yes. Consent belongs to whoever owns the data, not to whoever holds the session.

Bank details in team settings come back masked to the last four characters. Full account numbers never enter a chat transcript, because a transcript is a copy of your data in a place you don't control.

One test file that tries to steal
---------------------------------

The commit I'm happiest with this month isn't a feature. It's a test file.

It sets up two tenants, an attacker and a victim, and then drives every registered tool with the victim's ids: unscoped lists, the victim's team id, the victim's record ids, and writes aimed at the victim's records. 34 cases in one file.

```
it('refuses a victim invoice', function () {
  StagentServer::tool(GetInvoice::class, ['invoice_id' => encodeId($this->victim->invoice->id)])
  ->assertHasErrors(['You do not have access to this invoice.'])
  ->assertDontSee('VICTIMINV');
});

```

The writes get checked twice: the call is refused, and the victim's record is confirmed untouched afterwards.

I could have written those assertions inside each tool's own test file. I did it in one file on purpose, and the commit message says why: the value is uniformity, so no tool can drift out of coverage in its own file's phrasing. Forty tools written over a month by different sessions will each describe security in their own words if you let them. One matrix that every tool must pass doesn't care about your phrasing.

That's the part I'd tell anyone building an MCP server for a multi-tenant product. Your tools will multiply faster than your review capacity. Write the test that tries to break every one of them at once, before there are forty.

Why it came back
----------------

The reason I deleted it in August 2025 is the reason it works now. An MCP server isn't a feature you bolt on. It's a second front door to everything your product knows, and a front door is mostly locks.

The tools took a week. The consent model, the permission enforcement, the masking and the attack matrix took the rest.

A year ago I couldn't answer what happens when the wrong person asks. Now there's a test that asks on their behalf, forty times, every time I push.

 *thanks for reading*

Hi, I'm [Mischa](https://mischa.sigtermans.me/about). I've been *shipping products* and *building ventures* for over a decade. First exit at 25, second at 30. Now Partner &amp; CPO at [Ryde Ventures](https://ryde.ventures), an AI venture studio in Amsterdam. Currently shipping [Stagent](https://stagent.com), [TAP](https://tap.fm) and [Steddle](https://steddle.com). I [write](https://mischa.sigtermans.me/thoughts) about what I learn along the way. [More about me](https://mischa.sigtermans.me/about).

Keep reading: [My simplified Ralph loop setup for Claude Code](https://mischa.sigtermans.me/thought/my-simplified-ralph-loop-setup-for-claude-code).

  [← Thoughts](https://mischa.sigtermans.me/thoughts) Connect
-------

  [X](https://x.com/mischamartijn) [LinkedIn](https://linkedin.com/in/mischasigtermans) [GitHub](https://github.com/mischasigtermans)  

 © 2026 Hold My Beer B.V. · [RSS](feed:https://mischa.sigtermans.me/feed)
