On October 7 we added seven tools to our public MCP server: read the booking calendars of an app, list sessions, cancel a booking, manage closing days. The server is listed in ChatGPT’s directory and in Claude’s, and people also connect to it directly from Claude Code, Cursor and others. The question I asked before anyone wrote code: how do we add tools for the direct users without touching what ChatGPT approved?
One line of context: I run engineering at GoodBarber, an app platform, and our MCP server lets assistants manage the apps through an authenticated connection.
One server, three audiences
The first step was a map of who sees what. I had a rough idea; the code had the exact one.
So a new tool is free for the Claude directory and for direct clients, and the frozen list keeps it away from ChatGPT. That could have been the whole plan.
The coupling no test covered
The ChatGPT side is a facade over the same catalog. It picks the 45 tools and overrides their descriptions, plus one schema. Everything else it serves, the titles, the input schemas, the annotations that say whether a tool reads or writes, comes from the code shared with every other audience.
That means a change made for the new tools, say a tweak to the helper that turns tool names into titles, or to the function that builds annotations, would also change what ChatGPT serves for tools OpenAI already reviewed. Nobody would notice: the existing test only checked the names and the order of the 45.
So step zero, before any booking code, was a test that freezes everything ChatGPT sees, for several configurations the facade serves: name, title, description, input schema, annotations, compared to a committed reference file. Any difference fails CI and names the tool. Simplified:
def test_chatgpt_surface_matches_reference():
served = {tool["name"]: tool for tool in chatgpt_tools_for(SAMPLE_APP)}
reference = json.loads(REFERENCE.read_text())
assert served.keys() == reference.keys()
for name, expected in reference.items():
assert served[name] == expected, f"{name} changed for ChatGPT"
Then we checked that the test can fail. The first attempt changed a field, and the test stayed green. Not because the test was wrong: that field is rewritten by an override before it reaches ChatGPT, so the change never got there. The second attempt added a field nothing overrides, and the test failed, naming the tool. A guard you haven’t seen fail is a guess.
A second test checks the other direction: none of the new tools reaches ChatGPT.
With both in place, the booking work could touch the shared code freely. If it moved something OpenAI had approved, CI would say so before a deploy did.
A tool that cancels bookings
Four of the seven tools read. The three that write are the reason this section exists.
Creating a closing day in the back office cancels every booking on that day and sends each attendee a cancellation email. If the app’s calendars inherit closing days from a default one, the closure applies to all of them. A person in the back office sees a confirmation dialog. A model sees a tool description, and a tool description is a suggestion.
So the tool checks before it acts:
- it counts the bookings that the closure would cancel, across every calendar it would apply to;
- if there are any, it creates nothing and returns confirmation_required with the count;
- it only goes ahead when the call carries cancel_existing_bookings: true;
- it refuses past days;
- it is annotated as destructive, and its description says what happens to attendees.
The model can still cancel twenty bookings, and nothing stops it from setting the flag on its first call. What it can’t do is cancel them by default: a call that doesn’t ask for it returns the count, and the description tells the model to show that count to the user and get a yes first. That part is an instruction, not a lock.
Deleting a closing day makes the day bookable again if the calendar opens on it, but restores none of the bookings the closure cancelled. That one is not destructive in itself, and it’s the kind of thing a model would assume: undoing the closure undoes the cancellations. It doesn’t, and the description says so.
Cancelling a booking uses the same call as the back-office button, with the same email to the attendee. A booking that is already cancelled comes back as “not found, nothing was changed” instead of an error, so a model that retries after a timeout, or after the attendee cancelled from their own link, doesn’t report a failure that didn’t happen.
Access: a permission, not a list of pilot apps
Booking data includes attendees’ names and emails, so the tools got their own permission in our API credentials service, read-only or read-write, offered only to the app type that has bookings. Existing keys don’t get it; someone has to tick it.
That gave us a safe deploy order with no list of pilot apps: ship the server first, with tools nobody can see because no key carries the permission; then create the permission, which is the moment they become available. One decision went the other way on purpose: new keys pre-select every service in read-only, and bookings follow the same rule as the others.
The metadata that drifts
The tools were live in minutes. The descriptions of the server took longer.
- The server card builds its tool list from the catalog, so it showed the new tools right after the deploy: 157, up from 150. Its version, though, was written by hand, and it was already behind the version in our registry entry. Both moved to 1.3.0.
- The registry entry has a 100-character description. Adding “bookings” meant “mobile app” became “app”. The publishing token had expired, so that took a new login.
- The Claude directory listing shows a list of tool names that is edited by hand and validated by a reviewer, one of the details I went through in what each MCP directory actually checks. The tools themselves were already available to Claude users; the listing just didn’t say so yet.
- The ChatGPT app needed nothing. Nothing it serves changed, and the frozen test is what lets us say that. The day bookings should reach ChatGPT, that will be a new version of the facade and a new submission, on purpose.
Before the deploy, every call to the booking API ran against mocks, since only the production server is allowed to call it. The first real call came after the deploy, from a test key with only the booking permission. It worked.
What I’d keep
- Map your audiences from the code, not from memory. ChatGPT’s frozen list was the part I knew about; the shared code underneath it was the part I didn’t.
- Freeze what a reviewer approved, not what you meant to ship. Names and order are what you think of. Schemas, titles and annotations are what a shared helper changes behind your back.
- Watch the guard fail once. Our first attempt at breaking it changed a field that never reaches ChatGPT.
- For a model, a confirmation dialog is a refusal with a number in it. Return what would happen, and require an explicit flag to make it happen.
- Gate new tools with a permission. It gives you a deploy where the code is live and the capability isn’t.
If your MCP server is listed somewhere: what exactly did the reviewer approve, and which test would tell you it changed?
Originally published at https://dev.to on October 20, 2026.

