TL;DR: A partner needed programmatic access to our proposal-to-billing SaaS, but our existing REST endpoints were designed for the dashboard and authenticated with user sessions. I introduced organization-scoped API keys, then exposed 14 existing operations through MCP using the same application services. The MCP transport was the small part. Establishing trustworthy identity, permissions, and operational boundaries was the real work.
The Request That Exposed the Gap
A partner asked a simple question:
“Do you have API documentation?”
We had Swagger documentation and a mature internal REST API. What we did not have was a developer-facing API product.
The endpoints assumed an authenticated dashboard user. There was no self-service key creation, no explicit scope model, no key rotation, and no independent rate limits for programmatic traffic. Giving a partner a user token would have been fast, but it would also have coupled an external integration to authentication designed for a browser session.
Before adding another transport, I needed to make the existing surface safe to expose.
Building an API Surface That Could Be Trusted
I designed the API key system around three boundaries.
1. Explicit capabilities
Every key receives a set of scopes such as:
proposals.listproposals.create-from-templateclients.getintegrations.status.get
The key does not inherit every operation available to the organization. The caller receives only the capabilities selected when the key is created.
This matters even more for AI clients. A model that can inspect clients is not automatically allowed to update them, send a proposal, or delete a contact.
2. Non-recoverable secrets
Keys are generated from high-entropy random bytes. The raw secret is displayed once, while a SHA-256 hash is stored in the database. Validation hashes the presented key and compares the hashes with a timing-safe operation.
If the secret is lost, it cannot be retrieved. It must be replaced.
3. Controlled lifecycle and traffic
Keys support expiration and successor-based rotation. A new key can be created from an existing one, verified by the consumer, and then used to replace the previous credential without an abrupt cutover.
Programmatic traffic is also limited independently at both key and organization level. That protects one organization from a runaway script while preventing a collection of keys from bypassing the aggregate boundary.
The result was more than a token generator. It was an authentication path with an explicit principal:
API key
└── acting user
└── organization
└── scopes
Controllers can accept either the existing authenticated user flow or an API key where external access is intentionally enabled. Scope enforcement remains a separate authorization decision instead of being hidden inside key validation.
Why MCP Fit the Existing Architecture
Once the developer surface existed, MCP became a natural extension.
Our core operations already lived behind NestJS application services. Proposal creation, client management, template search, and integration health were not implemented directly inside controllers. REST was one adapter over that logic.
MCP could become another:
Application services
proposals · clients · integrations
│
┌─────────────┴─────────────┐
│ │
REST controllers MCP tool catalog
JSON over HTTP Streamable HTTP
I mounted the MCP endpoint in the same NestJS application and registered a catalog of MCP tools (one per operation, and it grows as new operations are added):
| Area | Operations |
|---|---|
| Proposals | List, get, create from template, update, send |
| Clients | List, get, create, update, delete, add contact, remove contact |
| Templates | Search proposal templates |
| Integrations | Read connection status |
Each tool defines its input schema, required scope, and handler. The handler delegates to the same application service used by the corresponding REST endpoint.
That does not eliminate every possible difference between REST and MCP. Each adapter still needs its own validation, serialization, authorization, and tests. It does, however, keep business rules out of the transport layer and substantially reduces duplication.
Propagating Authentication Through Tool Calls
The most interesting implementation problem was not tool registration. It was preserving the authenticated principal after the HTTP request entered the MCP transport.
The HTTP boundary knows the organization, acting user, API key, and scopes. Tool handlers execute deeper in the transport callback and should not require every service method to accept an extra authentication parameter.
I used Node.js AsyncLocalStorage to retain that request-scoped context:
const authStore = new AsyncLocalStorage<AuthContext>();
authStore.run({ organizationId, userId, scopes }, async () => {
await transport.handleRequest(request, response);
});
Any tool executing inside that request can read the same isolated context. Scope checks still happen before the application operation is invoked, and concurrent requests do not share tenant state.
This kept transport-specific authentication at the boundary instead of threading it through unrelated domain signatures.
What This Makes Possible
The API and MCP surfaces support different styles of integration over the same capabilities.
A conventional partner integration can use REST with a narrowly scoped key. An AI client can discover the MCP tools and combine read operations into a workflow such as:
Find active clients with no recent proposal, locate an appropriate template, and prepare the inputs for a draft.
The agent can perform the research while the user retains control over consequential actions such as creating or sending a proposal.
Other possible workflows include:
- Reviewing proposals that have remained pending past a threshold
- Finding and updating incomplete client records
- Checking whether payment, accounting, or CRM integrations are connected
- Creating a proposal from an approved template
These are not new business rules. They are new ways to compose existing operations.
What MCP Did Not Solve
MCP made tools discoverable to AI clients. It did not remove the responsibilities of a public API.
I still needed to answer:
- Who is the caller acting as?
- Which organization owns the data?
- Which operations can this credential execute?
- What happens when a key is leaked, expired, or rotated?
- How is abusive or accidental traffic contained?
- Which operations require explicit human confirmation in the client?
Those questions are protocol-independent. REST, MCP, a CLI, or another future adapter all depend on the same trust model.
The Main Lesson
The difficult part of opening an API was not exposing endpoints. It was designing a developer surface that could be trusted outside the dashboard.
Once identity, scopes, lifecycle, and service boundaries were explicit, MCP became another front door rather than another backend.
That changed how I think about internal APIs: design them as if the next consumer will not be your own frontend, and might not be a human.