One MCP server, two assistants: connecting TexPro to Claude and ChatGPT
TexPro's subscribers wanted to ask questions about their market data from inside the assistant they already use. I built the MCP server that lets Claude and ChatGPT do that, signed in as the subscriber and limited to their plan. The protocol was the easy part.
On this page
TexPro is Fibre2Fashion's paid market-intelligence product for the textile industry: raw material prices, trade flows, country profiles, energy and financial data. Subscribers already ask it questions in its own assistant. They also spend their day in Claude or ChatGPT, and wanted to ask from there.
I built the MCP server that makes that possible. A subscriber adds TexPro as a connector, signs in once with their TexPro account, and their assistant can then query the data their plan includes.
Getting the protocol to respond was the quick part. Everything worth writing down came after that.
One tool, and the server does the routing
The server exposes exactly one tool: ask_texpro(question).
The obvious design is one tool per dataset. I did not do that, because the tool description is the only thing the assistant reads when deciding whether and how to call you. It is product copy for a reader you cannot interview. Five tools means five descriptions for the model to choose between, and five ways to choose wrong.
So the assistant sends a question in plain language, and the server decides where it goes. A small model, at temperature zero and capped at a few tokens of output, picks one of five data modules. If that call fails, a keyword matcher makes the same decision more crudely. Routing lives in code I can test and change without any assistant having to relearn a tool list.
The tool is marked read-only, and it returns plain text: the data, wrapped with a short instruction to present it faithfully.
The transport is the small part
It runs over streamable HTTP in stateless mode, on a single endpoint, with ordinary JSON responses. No stdio and no long-lived session.
The data modules behind it stream their answers. The MCP server reads that whole stream and returns one complete result. An assistant gains nothing from a tool result that arrives in pieces, and a half-delivered answer is harder to reason about than a late one.
Identity is the real work
A public MCP server for a paid product has to answer three questions on every call: who is this, are they still a subscriber, and what does their plan include.
The MCP server itself is only a resource server. A separate authorization server handles sign-in, built on OpenIddict in .NET, and it does what the MCP authorization spec expects:
- An unauthenticated request gets a
401whoseWWW-Authenticateheader points at the protected-resource metadata. - Clients register themselves through dynamic client registration.
- Public clients must use PKCE with S256.
- Sign-in goes through the product's existing login, so nobody gets a second password.
Access tokens are opaque and short-lived, and only their hash is stored. Refresh tokens are single-use and rotate. The subscription is checked at sign-in, again on every refresh, and again when a token is resolved, so cancelling a plan ends access without anyone revoking anything by hand.
Clients differ here more than anywhere else. Claude and ChatGPT both register as public clients with no secret. Microsoft's Copilot Studio insists on a client secret, so the same registration endpoint issues confidential clients too. ChatGPT also wants a verification file served from the domain. One server, one tool, three sets of expectations at the door.
What actually broke
A new protocol version, rejected for eighteen days. Claude began opening connections with a newer, stateless version of the protocol that has no initialize handshake. The SDK version I was on answered every one with 400 Unsupported protocol version. There were 1,792 of those before I found them. The fix was an SDK upgrade and one endpoint that serves both generations of the protocol, pinned by a test for each.
A trailing slash. The resource identifier the MCP server publishes has to match what the authorization server allows, character for character. With the slash on one side and not the other, every real sign-in failed with invalid_target.
A crash became a retry loop. When a tool raised an exception, the assistant received a generic execution error and tried again, and again. From the user's side the request simply hung. Every failure is now returned as an ordinary sentence the assistant can relay, behind a catch-all, so a bad call ends the turn instead of repeating it.
Abandoned calls were charged twice. Usage is metered in credits. Two backend calls once ran for over three minutes each. The assistant gave up after about two, retried, and both attempts were recorded. A call the caller has walked away from is now logged with its own status and not charged, and the module timeout came down to two minutes so the server gives up before the assistant does.
The quietest failure: a refused refresh. If a refresh token is rejected, Claude marks the connector as expired and stops calling the tool. It does not tell the user. It answers from the open web instead, and the subscriber believes they are still looking at TexPro data. I raised the refresh lifetime from two weeks to ninety days and then to a year, and made the server repair an expired upstream session during refresh instead of failing it.
Stateless means anonymous. With no initialize message, the server never learns the client's name the usual way. The User-Agent header is the only signal on each call for telling Claude from ChatGPT, so that is what the usage records are built from.
The description is cached. Claude reads the tool description when the connector is added and keeps it. Editing the text on the server changes nothing until the connector is removed and added again, which matters when the description is the thing you are tuning.
Credits fail closed
The balance is checked before the routing model is called, so a subscriber who has run out costs nothing further. If the balance cannot be read, the call is refused, not allowed. Only an answer that actually came back is charged.
One detail I did not expect: a message the user must see, such as a low-balance warning, has to be marked as something to include word for word. Without that instruction the assistant treats it as background and drops it.
What I'd tell my past self
An MCP server is a small HTTP service with a schema. The protocol will not be where your time goes.
Your time goes on identity across clients that do not all register the same way, on timeouts where the caller's patience is shorter than your backend's, and on the fact that your error messages are now read by a model that will act on them. Design those three first.
If you have a product whose users would rather ask than click, tell me about it. The same thinking, a model for the judgement and code for everything with a right answer, runs through the pipeline I built for articles.
Frequently asked questions
- Do Claude and ChatGPT need separate MCP servers?
- No. One server over streamable HTTP serves both. The differences are at the edges: how each client registers itself for OAuth, a domain verification route ChatGPT asks for, and how each behaves when a call is slow or a token is refused.
- How does a user sign in to a remote MCP server?
- With OAuth. The server answers an unauthenticated request with a 401 that points to its metadata, the assistant registers itself as a client, and the user signs in through the product's normal login. The assistant then calls the server with an access token tied to that user.
- Why expose one tool instead of one per dataset?
- The tool description is what the assistant reads to decide when to call you, so every extra tool is more text to get wrong. One tool that takes a question lets the server choose the dataset, and the routing can be tested and changed without the assistant relearning anything.
- What should an MCP tool return when something fails?
- A normal text result that says what happened. A raw exception makes the assistant retry in a loop and the request looks stuck. A clear sentence it can pass on to the user ends the turn properly.
Fibre2Fashion's news desk spent every morning searching dozens of sources by hand, judging what mattered, confirming it, then writing it up. A two-phase agent now does the finding and the drafting, with an editor choosing what gets pursued — cutting 80–90% of that routine and running the same desk with fewer people on it.
ReadAn editor used to retype eight fields for every article and hope the table of contents still pointed at the right headings. I built the pipeline that replaced that work, and the most useful decision in it was what I refused to hand to the model.
Read