We built an MCP server for our product in a few weeks. Most of it was reuse: the same reporting APIs, the same identity provider, the same Kubernetes cluster and pipeline. This is a write up of what we did, what worked and what we plan next.
Why
Our customers have a lot of operational data in our product: orders, events, exceptions, team and location performance. Today they see it through report screens. When they have a question the screens don't answer, it becomes a feature request or an Excel export.
Most of our customers already use an AI assistant. So the ask was: let them connect their own assistant to their data in our product, with the same permissions they have in the web app. No new copy of the data, no new login.
flowchart LR subgraph Today Q1[New question] --> F1[Feature request] --> F2[Report built<br/>weeks later] --> F3[Answer] Q1 --> E1[Export to<br/>Excel] --> F3 end subgraph With MCP Q2[Question in<br/>plain language] --> AI[AI assistant] --> T[MCP tools] --> A2[Answer and chart] end What MCP is
MCP (Model Context Protocol) is an open standard for connecting AI assistants to other systems. A server exposes tools. Each tool has a name, typed inputs and a description. The assistant reads the descriptions, decides which tools to call and uses the results in its answer. Since it is a standard, one server works with Claude, other assistants, IDEs and the customer's own agents.
flowchart LR C1[Claude] --> S C2[Other AI assistants] --> S C3[IDE and agent tools] --> S C4[Customer's own agent] --> S S[MCP server] --> P[(Operational data)] We looked at two other options before choosing MCP:
| Option | Why not |
|---|---|
| Build our own chatbot | We would own the model, the UI and the cost. Customers already have assistants. |
| Export data to a warehouse for AI | New pipelines, stale data, a second permission model and a bigger security review. |
| MCP server | Chosen. Works with any assistant and uses our existing security. |
Where the tools get data
There were three choices:
- A new data API built for AI.
- A warehouse or lakehouse loaded with reporting data.
- The reporting APIs our web app already uses.
We went with the third. The reporting service already finds the tenant for each request and connects to that tenant's database, checks the user's permissions on every endpoint and reads through stored procedures that are tuned and tested.
flowchart TB Q{Where should the<br/>tools get data?} Q --> N[New data API] Q --> W[Warehouse or lakehouse] Q --> E[Existing reporting APIs] N --> N1[Duplicate tenancy,<br/>auth and queries] W --> W1[New pipelines,<br/>new permission model] E --> E1[Tenancy, permissions and<br/>queries already exist] E1 --> OK[Chosen for version 1] W1 -. "later, for analytics" .-> LT[See: Next phase] So the MCP server only translates. A tool call comes in, it calls an existing endpoint and returns a compact JSON result. It has no database credentials. If a user can't see something in the web app, the assistant can't see it either.
Architecture
flowchart LR U[User] --> A[AI assistant<br/>any MCP client] A -- "MCP over HTTPS<br/>+ user's access token" --> G[Ingress /<br/>API gateway] G --> M[MCP server<br/>stateless container] M -- "same token<br/>+ tenant header" --> R[Existing reporting API] M -- "same token" --> O[Existing app API<br/>e.g. location list] R --> D[(Tenant reporting<br/>database)] A -. "OAuth sign in" .-> I[Existing identity<br/>provider] M -. "verify token<br/>signature" .-> I - AI assistant: any MCP client. We used Claude with a custom connector.
- MCP server: a small TypeScript service on the official MCP SDK. It defines the tools, validates inputs, calls the APIs and trims the results.
- Identity provider: our existing OpenID Connect provider, same one the web app uses.
- Reporting API and database: no changes, except two new endpoints (see Performance below).
Authentication
We expected this to be the hardest part. It was mostly configuration, because MCP uses standard OAuth.
sequenceDiagram participant C as AI assistant participant M as MCP server participant I as Identity provider participant R as Reporting API C->>M: Call /mcp (no token) M-->>C: 401 + pointer to resource metadata C->>M: GET /.well-known/oauth-protected-resource M-->>C: "My authorization server is <issuer>" C->>I: Discover endpoints, start sign in (PKCE) I-->>C: User signs in as usual, gets access token C->>M: Call tool with Bearer token M->>M: Verify signature and issuer M->>R: Same request, same token R-->>M: Data the user is allowed to see M-->>C: Compact result - Protected resource metadata (RFC 9728). For a request without a token, the server returns
401with aWWW-Authenticateheader. The header points to a small JSON document that tells the assistant which authorization server to use. - Authorization server metadata. Some clients look for discovery documents at specific well known paths, so we serve the identity provider's metadata at those paths too.
- Authorization code flow with PKCE. The user signs in on the normal login page. PKCE secures the exchange without a client secret, since the assistant is a public client.
- Token pass through. The server checks the token signature and issuer, then sends the same token to the APIs. The APIs enforce permissions the same way they do for the web app. The model never sees the token.
Public client instead of dynamic registration
MCP clients can register themselves with the identity provider automatically (dynamic client registration). Many enterprise identity setups don't allow that. Our first try failed with a generic "couldn't register" error. We created one public client for MCP, allowed only the assistant's redirect URL, and entered that client ID in the connector settings. That fixed it.
Token claims
Our APIs filter data using claims in the token, for example which locations a user can see and which roles they have. A new client doesn't get the same claims as the web app client by default, so we added the same claim mappers. Check this early. A valid token with a missing claim returns empty results, and it takes time to figure out why.
Multi tenancy
Each customer has its own subdomain and its own identity realm. The MCP server gets everything from the host name of the request:
- the tenant, sent as a header to the APIs so they connect to the right database,
- the expected token issuer, so a token from one tenant is rejected on another,
- the resource metadata URL it advertises.
flowchart LR H["Request host<br/>customer-a.example.com"] --> P[MCP server parses host] P --> T1[Tenant id<br/>customer-a] P --> T2[Expected issuer<br/>customer-a realm] P --> T3[Resource metadata URL] T1 --> API[Tenant header on<br/>every API call] API --> DB[(customer-a<br/>reporting database)] T2 --> V{Token issuer<br/>matches?} V -- no --> X[401 rejected] V -- yes --> API One deployment serves all tenants on a cluster, with no per tenant configuration. Signing keys are fetched from each issuer and cached.
Tool design
Most of the answer quality depends on this.
- Small set of read only tools. We started with eight, covering a location list, KPI summary, team and location performance, exceptions, slowest events and record lookup. No write actions in version 1.
- Write descriptions for the model. Each description says what the tool returns, what each input means and when to use it. For example, the location input says to call the location list first if the user gives a city name. With that one line, users can say "Chicago" instead of an internal code.
- Summarize on the server. API responses built for screens can be hundreds of KB. We aggregate before returning, for example totals per team instead of every event. One response went from over 700 KB to under 50 KB. Smaller results are faster, cost fewer tokens and give more accurate answers.
- Limit the size. Top 10 by default and row caps on everything.
- Validate inputs. Zod schemas reject bad dates or missing fields before any API call, with an error message the model can act on.
- Ask for charts. MCP servers can send instructions to the client. Ours asks for a chart when the result compares items or shows a trend.
Example of one question turning into two tool calls. The model decides the order from the descriptions:
sequenceDiagram participant U as User participant A as AI assistant participant M as MCP server U->>A: "Where are the biggest delays at our Chicago site this month?" A->>M: list_locations(search: "Chicago") M-->>A: Chicago = location code CHI A->>M: get_slowest_events(location: CHI, from, to) M-->>A: Top 10 items with average delay minutes A-->>U: Bar chart + explanation + suggested actions flowchart LR R[Raw API response<br/>every record, every event<br/>~700 KB] --> S[MCP server<br/>aggregate, rank, top N] --> O[Tool result<br/>per team totals<br/>under 50 KB] Performance
Two of our questions ("longest delays last month" and "exceptions by location") took several minutes in the first version. The queries were parsing large JSON event records across millions of rows on every call.
We fixed it with regular database work:
- a summary table with one row per event and the duration and exception flags already calculated,
- a stored procedure that refreshes it for a date range,
- two small query stored procedures behind two new reporting endpoints.
flowchart LR subgraph Before["Before: minutes"] Q1[Tool call] --> J[Parse JSON events<br/>across millions of rows] --> A1[Answer] end subgraph After["After: under a second"] S[Scheduled refresh<br/>stored procedure] --> T[(Event<br/>summary table)] Q2[Tool call] --> P[Small query<br/>stored procedure] --> T P --> A2[Answer] end Both now return in under a second.
In our tests, the MCP server and APIs take about 0.1 to 2.5 seconds per tool call. Most of the remaining time is the model thinking and writing. A faster model, lower reasoning effort, simple charts and small results help more than tuning the server further.
Hosting
Nothing new was set up.
- Container: a small multi stage Docker image.
- Kubernetes: one more subchart in the existing Helm release, in the same cluster and namespace as the APIs. It calls them by internal service name, so tool traffic stays inside the cluster.
- Ingress: new routes on the existing gateway for
/mcpand the well known discovery paths. The server runs on the tenant's existing host name. - Stateless transport: MCP Streamable HTTP in stateless mode. Each request gets a new server and transport, so no sticky sessions and it scales horizontally.
- CI/CD: the existing pipeline builds, scans and pushes the image with the other services.
flowchart TB subgraph CI["CI/CD pipeline (existing)"] B[Build image] --> SC[Security scan] --> PU[Push to registry] end PU --> HL[Helm release<br/>new subchart added] subgraph K["Kubernetes cluster (existing)"] IN[Ingress gateway<br/>+ /mcp and well known routes] --> MS[MCP server pods<br/>stateless] MS --> RS[Reporting API service] MS --> AS[App API service] IN --> WEB[Web app] end HL --> MS Internet[AI assistant] --> IN Few things we ran into:
- Our build installs all workspace packages at once. Adding the MCP server to the main backend workspace pushed a code analysis step over its memory limit. Moving it to its own folder fixed it.
- After a deploy, the gateway can take a minute or more to pick up new pods. Run at least two replicas for anything you demo live.
- Our database deployment is incremental and skips new tables, so the summary table needed a release script.
Connecting Claude
This took a few minutes:
- An org admin adds a custom connector with the MCP URL.
- In advanced settings, the admin sets our public client ID (no secret).
- Each user clicks Connect and signs in on the normal login page.
flowchart LR A1[Admin adds custom<br/>connector with MCP URL] --> A2[Admin sets public<br/>client id, no secret] A2 --> U1[User clicks Connect] U1 --> U2[Normal login page] U2 --> U3[Tools available in chat] After that, a user can ask "How did the Chicago site do last Monday? Show it as a dashboard" and get the answer with charts.
What we learned
- The existing APIs already handled tenancy and permissions. That is why the MCP server is only a few hundred lines.
- Standard OAuth was enough. Resource metadata, PKCE and a public client worked with our identity provider without custom auth code.
- Tool descriptions, result size and defaults matter more than the number of tools.
- If a question is slow in SQL, it is slower through an assistant. Precompute it.
- Treat a live demo like a release: sign in beforehand, two replicas, and questions that return in seconds.
Other next steps
- Finer scopes, so a customer can give the assistant less access than the full user has.
- Audience restriction on tokens issued for the MCP client.
- More tools, based on what customers actually ask.
- Write actions that only draft a change for a person to approve.
- Usage telemetry on which tools and questions get used.
If you already have secure APIs and an identity provider, you don't need a new platform to get started. A thin MCP server on top of them is enough for version 1. See what people ask, then decide what to build behind it.

