Emilio Cignetti

Senior Full-Stack Engineer

Italian and Argentine citizen

August 2026 to nowPersonal project

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

All work

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.