Claude driving three microservices, on the user's own authority
An MCP server in front of id, inventory and social. The user asks in a chat box, and the server mints one token per audience and calls each API as them.
A chat box beats a user interface for anything you can say in one sentence — but only if the agent can reach several APIs on the user's own authority, without ever holding their credentials.
- Model Context Protocol
- OAuth 2.1
- RFC 8693
- RFC 9728
- microservices
- NestJS
- TypeScript
- Claude
The stack already had a user interface. vitrina is a single-page application
behind a backend-for-frontend and it does everything — your collection, your
friends, your trades. This is a second front end for the same APIs: a chat box.
For a surprising share of what people actually do with the product, it is
faster.
The three services behind the conversation
| Service | What it answers |
|---|---|
id |
Every token. The MCP server is a client here, not a resource server |
inventory |
Items, boxes, trades. Audience inventory |
social |
The friend graph, and who may see whose collection. Audience social |
One MCP server, two downstream APIs, and the authorization server that issues
for both. aud is a single string in this stack and an exchange narrows to one
target, so no single token reaches two services — the server mints one per
audience from the same subject token and throws both away at the end of the
request.
Why a sentence beats a screen
Say you want to know what a friend has that you do not, and offer them something for it.
In the application that is: open Friends, pick the friend, open their inventory, hold it in your head or flip back and forth against your own, go to Trading, start an offer, find your item in one list, find theirs in another, submit, confirm. Nine steps across four screens, and the comparison in the middle is the part you do by eye.
In the chat box it is one sentence, and the comparison is the part a model is actually good at.
You — Who are my friends on here?
Claude — You have three: Marina, Tobias and Irene.
(list_friends → social)
You — What interesting things has Marina got that I haven’t?
Claude — Marina has fourteen cats across nine templates. Three of them you
do not own at all: a Norwegian Forest, a Devon Rex, and two Turkish Vans — and
since she has two of the Vans, one is probably spare.
(friends_inventory_digest → social, then inventory)
You — Propose a trade for one of the Turkish Vans. Offer her something she hasn’t got.
Claude — Staged, not sent. You would give one Maine Coon and receive
one Turkish Van, both rare. Marina owns no Maine Coon. Say the word within
five minutes and I will send it.
(list_my_inventory, then propose_trade → inventory)
You — Send it.
Claude — Sent. It is waiting for Marina to accept. (confirm_action)
Four sentences, and two of the services were queried without the person ever being aware there were two.
This is not a replacement for the interface, and the case is weaker if I pretend otherwise. Browsing artwork, seeing a whole collection at a glance, anything you want to look at — the application is better and will stay better. The chat box wins on questions with a specific answer, and on actions you can describe in one sentence but that take four screens to perform. They are two tools, and the APIs underneath do not care which one is calling.
The fifteen tools, and which service each reaches
Inventory — list_my_inventory (grouped by template, with counts),
describe_templates, browse_catalogue, list_locales, list_my_offers,
describe_box (the key it needs, whether you own one, and the drop table with
computed odds), open_box (with a dryRun flag, so a model can show you what
would happen before it happens), propose_trade, accept_offer,
reject_offer, cancel_offer, confirm_action.
Social — list_friends, friends_inventory_digest,
view_friend_inventory. All three read-only.
Two of the social tools cross back into the inventory, because a friend’s collection is a social question about an inventory answer.
One question, three services
A friend’s inventory is the interesting path, because a single sentence in the chat box crosses all three:
you ──▶ Claude "what has Marina got?"
Claude ──▶ mcp view_friend_inventory + bearer token
mcp ──▶ id RFC 8693 exchange, audience=inventory
mcp ──▶ inventory GET /item-instances/by-owner/marina
inventory ──▶ social may this viewer see this owner?
◀── { "allowed": true, "reason": "friend" }
◀── items
Two independent gates have to agree before one item comes back: the token has
to carry inventory:read.others, meaning the connector was consented to ask
about other people at all, and social has to say the owner permits this
particular viewer. Neither substitutes for the other, and
the authorization server’s own case study has that
argument in full.
Connecting it to Claude
There is no plugin, no SDK and nothing in this server that knows what Claude is. It implements the OAuth 2.1 and RFC 9728 pieces the Model Context Protocol specification asks for, and any client that also implements them can connect. Claude is one; LM Studio is another, and neither needed a line of code here.
In Claude it is Settings → Connectors → Add custom connector: a name, the server’s URL, and under advanced settings an OAuth client id and secret.
Pressing Connect starts a conversation the server never has to script:
Claude ──▶ POST /mcp (no token yet)
◀── 401 WWW-Authenticate: Bearer
resource_metadata="…"
──▶ GET /.well-known/oauth-protected-resource
◀── { resource, authorization_servers: […] }
──▶ the authorization server named in that document
you ──▶ log in, read the consent screen, approve
◀── access token, audienced at this server
Claude ──▶ POST /mcp Authorization: Bearer …
The browser opens, you log in, and the consent screen lists exactly what the connector is asking for — read your inventory, trade on your behalf, see your friends list. Approve it and Claude comes back holding a token that says so.
What Claude never gets
The token Claude holds is audienced at the MCP server and nothing else, and the first thing that happens on every request is a check that it is. A token minted for the inventory API, or for the connector itself, is refused — even though the signature is valid and the issuer is right. Without that check, anything holding any token this issuer signed could point it here and have the server spend its own authority for them. The specification is blunt about it:
MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.
So the incoming token is never forwarded. It is exchanged for one audienced at
the service being called, carrying a nested act chain — this server, acting
for the connector, acting for you — so each API sees the whole delegation path
rather than one anonymous caller.
Identity is recovered from the request every time and never held on the process. One server handles many people, no tool takes an owner id as an argument, and the function that resolves the caller throws rather than falling back to anything when it cannot.
The social tools are read-only, and that is enforced by what the connector is
registered for rather than by the absence of a write tool: it holds
social:profile.read and social:friends.read and nothing else, so a write
tool added here could not obtain a token social would accept. That is the
difference between “we did not write that” and “it cannot happen”, and only the
second survives a future contributor.
