DEV Community

CMeIn
CMeIn

Posted on Edited on

We built an MCP server for a consumer product and left the main feature out

Most MCP servers are developer tools. Databases, file systems, issue trackers, CI. The pattern is familiar: you give an agent a capability it did not have, and the capability is usually "do a thing to a system."

We ship a consumer product. CMeIn turns a handful of ordinary phone photos of a person into realistic photos of that person, for dating profiles and social media. Last week we published an MCP server for it at https://mcp.cmein.com/mcp.

The obvious tool to expose would have been image generation. That is the product. That is what people pay for. We did not expose it, and the reasoning turned out to be the most useful part of the project.

What the server actually does

Six tools need no account:

  • photo_advice answers questions about photos of yourself from articles we have published, and returns the source link with the answer
  • cmein_pricing and cmein_info read the live pages, so the numbers are current rather than remembered
  • see_examples and find_video point at real output
  • contact_us sends a message to a person

Four more work on a signed-in account: my_credits, my_generations, find_scene and request_credits_back.

Notice what that list is. It is reading, searching and advising. The one thing the product exists to do is absent.

Why generation stayed out

Three reasons, in increasing order of how long they took us to articulate.

It costs money per call. An agent that can spend a user's credits is an agent that can spend them wrongly, in a loop, without the user seeing the prompt that caused it. The failure mode is not a bad response, it is a bill.

It needs photos the agent does not have. Generation starts from reference photos of a specific person. That upload is a consent moment. Routing it through a chat transcript makes it casual, and it should not be casual.

The output needs to be looked at. The product is photographs of a person's face. The review step, where you decide this one looks like you and that one does not, is the product as much as the generation is. An agent returning twelve images into a conversation is not a shortcut, it is a worse version of the thing.

So the boundary we landed on: the agent can answer questions, read live state and help you decide. The act that costs money, consumes a person's likeness and needs human judgement stays in the app.

The part that was genuinely hard

Account-scoped tools over a remote server. my_credits has to know whose credits.

The server is a resource server and nothing more. It mints no tokens, holds no secret, and knows nothing about passwords. It verifies an RS256 JWT against the key set published at cmein.com/.well-known/jwks.json, then checks three things that matter more than the signature does.

Issuer. Must be cmein.com. A valid token from somewhere else is somebody else's token.

Audience. Must be this server. This is RFC 8707, and it is the check that is easy to leave out. A token obtained for one resource used to be replayable against any other resource that trusted the same issuer. Refusing a token whose audience is not you is the entire mitigation, and it is one line.

Scope. Per tool. A grant of "see my credits" is not a grant of "spend them", and the person chose between those on the consent screen.

Two things that cost us time and are worth stealing.

createRemoteJWKSet from jose caches the key set and refetches when it sees an unknown kid, with its own cooldown. That is what lets you rotate keys without redeploying the MCP server, and what stops a stream of bad tokens turning into a stream of requests to your own auth server.

The WWW-Authenticate header on the 401 is not decoration. A client does not go looking for an authorization server on its own, it does it because a 401 pointed it at the RFC 9728 metadata document. Get that header wrong and the client reports "unauthorized" forever and never offers to connect. It looks like a broken server and it is a missing header.

One more detail, which loops back to the top. The server defines and enforces a photos:write scope, and no tool uses it. It is deliberately left out of the advertised scopes, because a consent screen that asks for a permission nothing exercises is asking for more than it needs.

You can read the whole surface without an account:

curl https://mcp.cmein.com/.well-known/oauth-protected-resource
Enter fullscreen mode Exit fullscreen mode

Why a consumer product wants this at all

Your assistant already has opinions about profile photos. It assembled them from listicles and from the marketing of companies selling headshots, and it will state them with the same confidence either way.

We generate these photos for a living and write up what works. The connector hands the assistant those passages with a link, so the claim can be checked instead of trusted.

This also sits inside a wider position we hold. Our robots.txt blocks the crawlers that collect training corpora, and explicitly allows the ones that crawl to answer a question and cite a source. We publish an llms.txt. The MCP server is the same idea taken one step further: do not train on us, do use us, and link back when you do.

Try it

claude mcp add --transport http cmein https://mcp.cmein.com/mcp
Enter fullscreen mode Exit fullscreen mode

Source is MIT at github.com/BarakBurger/cmein-mcp, and the setup page for other clients is at cmein.com/mcp.

I am interested in where other people drew this line. If you have shipped an MCP server for something consumer facing, what did you deliberately not expose?
`

Top comments (11)

Collapse
 
autenai profile image
Auten •

On the account-scoped tools: did you end up with OAuth on the remote server, or a per-user token pasted into the client config? Curious what my_credits returns when the session expires mid-conversation.

From the Auten team (we make a computer-use MCP): our line was passwords. The agent can ask for a saved login to be filled, but the value is typed locally, never comes back to the model, and stays pinned to the site it was first used on.

Collapse
 
barak066 profile image
CMeIn •

OAuth, and the server is a resource server only. It mints no tokens, holds
no secret and knows nothing about passwords. It verifies an RS256 JWT
against the key set at cmein.com/.well-known/jwks.json, then checks issuer,
audience and the scope for the specific tool. The audience check is RFC 8707
and it is the one I would not skip: without it a token minted for one
resource is replayable against any other resource that trusts the same
issuer.

On your actual question, expiry mid-conversation: verify() returns null, and
the 401 only fires if an account-bound tool is the one being called. So the
conversation does not die. The six tools that need no account keep answering
normally, and only my_credits (or another account tool) comes back with

HTTP 401
WWW-Authenticate: Bearer resource_metadata="...", error="invalid_token",
error_description="Connect your CMeIn account."
{"jsonrpc":"2.0","error":{"code":-32001,
"message":"This tool needs a connected CMeIn account."},"id":null}

which is the shape a client needs in order to offer reconnecting rather than
just printing "unauthorized". The lazy challenge was deliberate: challenging
on every request would make an expired token look like a dead server.

Your password line is the same shape as ours, arrived at from a different
direction. You keep the value out of the model and pin it to the site it was
first used on. We keep the credential out of the server and pin it to the
audience it was minted for. Both are really the same rule: the thing that
proves who you are must not be replayable somewhere else. Yours is pinned by
origin, ours by the aud claim.

Where did you land on re-prompting? If a saved login is filled and the site
rejects it, does the agent get to retry, or does that go back to the human
every time?

Collapse
 
autenai profile image
Auten •

"Pinned by origin vs pinned by aud" is a cleaner way to say it than ours, and the lazy 401 that keeps the other tools alive is a nice detail.

On re-prompting: fill_login just types the saved value, it doesn't judge whether the site accepted it. The skill we ship with the plugin tells the agent to get a yes from the user before typing a password at all, so a rejected login goes back to the human rather than into a retry loop.

Thread Thread
 
barak066 profile image
CMeIn •

The split you just described is worth naming, because I think it is the real
difference between our two setups.

fill_login not judging the outcome is the right call. A tool that cannot tell
success from failure cannot retry, so the retry loop is designed out rather
than guarded against. That is the cheap kind of safety and the kind that
keeps working.

But notice you have two layers doing different jobs. The origin pin is
enforced: the saved value physically cannot go to a site it was not pinned
to, and no amount of clever prompting changes that. The "get a yes before
typing a password" rule is advisory: it lives in a skill, which is
instruction, not mechanism. A model that ignores it is not breaking
anything, it is just not following advice.

Ours lands the same way. The scope and aud checks are enforced in the server
and the model cannot talk its way past a mismatched aud. Everything about
when it is appropriate to call a tool is advisory, sitting in tool
descriptions. I do not think there is a way around this. The things you can
enforce are the ones that do not depend on intent, and intent is exactly
what "did the user actually mean to log in here" is made of.

Which makes me curious about first use specifically. The pin protects every
use after the first, but something has to establish the pin. If an agent is
on a page it was led to, and the page presents a convincing login form for a
site the user does have saved credentials for, what does the first-use path
look like? Is the yes you ask for scoped to "type this password here, now",
or to the site generally?

Thread Thread
 
autenai profile image
Auten •

Fair, and first use is the soft spot: the pin is set where a saved login is first used, so until then it rests on the advisory layer.

The yes in our skill is per action, not per site: the agent shows what it is about to type and where, waits, and asks again if anything changes. The same skill tells it to treat page text as data, so a page asking for a login is not a reason to fill one.

Still advisory, as you say. Setting the origin when the login is saved, instead of at first use, would close that gap; I'm passing that to the team.

Thread Thread
 
barak066 profile image
CMeIn •

Setting the origin at save time is the right move. It turns the property
from something the model is told to respect into something it cannot reach
past, which is the whole distinction.

One thing that will bite during implementation: plenty of real logins are
not single origin. SSO means the credential is typed at accounts.google.com
or at login.example.com and used for example.com, and a pin captured
strictly at save time will refuse the exact flow the user expects to work.
Worth deciding up front whether the pin is an origin or a small allowed set,
because discovering it from a bug report means someone loosens it in a
hurry, and a pin that gets loosened under pressure tends to stay loose.

Also, "treat page text as data" is doing more work in your setup than it
gets credit for. That is the defence against the obvious attack here, which
is a page that simply asks.

Good thread. Thanks for the detail.

Thread Thread
 
autenai profile image
Auten •

The SSO case is a good catch: a strict single origin at save time would break exactly the Google or company login people expect to work. A small allowed set, stated up front, sounds like the safer default than loosening one later. Passing that on together with the rest.

Thanks for the thread, it was a useful one for us.

Thread Thread
 
barak066 profile image
CMeIn •

A stated allowed set beats a loosened one, agreed. Good luck with it, and
thanks for engaging with the detail rather than the headline.

Collapse
 
agentsearchhq profile image
AgentSearch •

Leaving the paid, consent-heavy action out is a boundary more MCP servers should copy. An agent that can spend credits in a loop is a much worse failure than one that answers a question badly. One thing I'd add: say it in the tool descriptions too, so photo_advice or cmein_info can tell the agent that generation happens in the app. Otherwise agents tend to hunt for a tool that isn't there, or improvise one from the closest match. How did you end up handling the account-scoped tools on the remote server: OAuth through the MCP client, or a separate token step?

Collapse
 
cailab profile image
CAI •

The third reason you gave for leaving generation out - it costs money per call, and an agent can spend credits in a loop without the user seeing the prompt - is exactly the logic that makes spending mandates the right primitive for agent payments. The agent can see the balance, discuss the product, and even trigger a payment intent, but the actual move of funds happens through a separate channel where the human confirms the amount and purpose before the transaction settles. Lifting the spend decision out of the conversation entirely mirrors the architectural choice you made, and it solves the runaway-spend problem at the design level instead of the prompt level. The photos:write scope you defined but deliberately never granted is a clean version of the same idea: the permission exists in the schema but no session ever activates it.

Collapse
 
barak066 profile image
CMeIn •

The parallel holds up to a point, and I think the point where it stops is
the interesting part.

There is a ladder here. You can tell the model not to misuse a capability,
you can gate the capability behind a human confirmation, or you can not give
it the capability. Mandates are the middle rung, and they are the right rung
when the agent genuinely has to transact. We took the top rung, but only
because we could afford to: nobody needs an agent to buy our credits, so
there was never a transaction to protect.

I would not flatten that difference, because the middle rung has a failure
mode the top one does not. A confirmation gate works exactly as well as the
human reads it, and a human who has approved nine routine amounts reads the
tenth differently. Lifting the decision out of the conversation helps with
that, but it does not remove it, and the design question becomes how to keep
the tenth confirmation as legible as the first.

Small correction on photos:write, since it is my own schema: it is not that
no session activates it. The server defines and enforces it, but it is left
out of the advertised scopes entirely, so no consent screen ever offers it
and no client can request it. The distinction matters for your argument,
I think, in your favour: the permission is not dormant pending some future
grant, it is unreachable. That is closer to your separate channel than a
scope nobody happens to have asked for yet.