Architecting Robust MCP Servers: A TypeScript Blueprint
Introduction: The New Frontier of LLM Integration
In the evolving landscape of B2B SaaS, the Model Context Protocol (MCP) has emerged as the definitive standard for bridging the gap between LLMs and enterprise data. However, as an architect who has built multiple iterations of multi-tenant API platforms, I see the same patterns of failure emerging in the rush to adopt MCP. Many developers treat MCP servers as simple RPC bridges, ignoring the fundamental requirements of data isolation, context scoping, and secure resource management.
This article provides a blueprint for building an MCP server in TypeScript from scratch. We will move beyond the basic 'Hello World' examples to address the architectural rigors required for production-grade, multi-tenant environments. By the end of this guide, you will understand how to build a robust server that maintains strict tenant boundaries while providing rich, type-safe integration for LLMs.
The Tenant-Scoped Middleware Fallacy
The most dangerous architectural mistake I see in MCP implementations is the assumption that global state is safe. Because MCP servers often run in long-lived node processes, it is tempting to use module-level variables to track the current tenant or user session. This is a catastrophic anti-pattern. If Tenant A’s request context leaks into a tool call executed on behalf of Tenant B, your data isolation is compromised at the protocol layer.
To build this correctly, we must enforce a request-scoped context. In a standard Express or Fastify API, we use AsyncLocalStorage. In the context of an MCP server, we must ensure that every tool execution, resource fetch, and prompt retrieval carries an immutable context payload. We do not store context; we propagate it. This requires a middleware chain—or in MCP terms, a request-interception layer—that validates the meta object provided by the MCP client before any business logic is touched.
Designing the Server Infrastructure with TypeScript
TypeScript is not merely a tool for code completion; it is the primary mechanism for architectural enforcement. When defining your MCP tools, you should never rely on untyped JSON schemas generated on the fly. Instead, define your tool signatures using a shared schema library that generates both the JSON schema for the LLM and the TypeScript interface for your implementation logic.
Consider the following structure for initializing an MCP server that enforces tenant-scoped boundaries:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { AsyncLocalStorage } from "node:async_hooks";
// Define a secure, read-only tenant context
interface TenantContext {
tenantId: string;
userId: string;
permissions: string[];
}
// Use AsyncLocalStorage to keep the context scoped to the request execution
const tenantContextStorage = new AsyncLocalStorage<TenantContext>();
export class SecureMcpServer {
private server: Server;
constructor(name: string, version: string) {
this.server = new Server({ name, version }, { capabilities: { tools: {} } });
}
// Middleware wrapper to inject context and ensure data isolation
public registerTool(name: string, handler: (args: any) => Promise<any>) {
this.server.setRequestHandler({ method: "tools/call", params: { name } }, async (request) => {
const context = await this.extractContext(request);
return tenantContextStorage.run(context, () => handler(request.params.arguments));
});
}
private async extractContext(request: any): Promise<TenantContext> {
// Validate token/signature in request.meta
// Throw immediate error if authentication fails
return { tenantId: "123", userId: "abc", permissions: ["read"] };
}
}
This approach ensures that your service layer always has access to the current tenant context via the storage, without the developer having to manually pass tenantId into every internal function call, which inevitably leads to a 'forgotten parameter' bug.
Implementing Type-Safe Resource Isolation
Once the middleware chain is established, we must address the resources themselves. In a multi-tenant SaaS, users should only be able to access the data they own. If your MCP server exposes a resource like database://users, you must inject the tenantId from the AsyncLocalStorage into the query filter at the repository layer.
I recommend using a Repository Pattern that takes an optional tenantId parameter, but defaults to the AsyncLocalStorage value. This forces developers to be explicit about isolation. If a repository method is called without a context, the application should throw a runtime exception during development. Never allow a 'null' tenant to pass through to the database driver.
Furthermore, keep your resource identifiers URI-compliant. An MCP resource URI should look like mcp://tenant-{id}/resource-type/{resource-id}. By embedding the tenant ID directly into the URI structure, you add an extra layer of visibility for auditing and debugging, making it trivial to trace logs in ELK or Datadog.
Integration Testing: The Silent Guard
Unit tests are insufficient for MCP servers. You must implement integration tests that simulate the client-server interaction over the Stdio transport. You should write tests that specifically attempt to cross the tenant boundary—for instance, a test where Tenant A tries to call a tool to update a resource owned by Tenant B.
Use a testing framework like Vitest with a dedicated MCP client stub. Your tests should verify that:
- The server rejects requests missing proper headers or authentication metadata.
- The server returns an
AccessDeniederror when thetenantIdin the request metadata does not match the ownership of the requested resource. - Context is properly cleaned up after each tool execution, preventing state contamination across different MCP requests in the same process.
Practical Application: The B2B SaaS Workflow
In our Wrocław office, we utilize this MCP architecture to allow LLMs to query internal documentation and production logs. By enforcing the tenant boundary at the protocol level, we can confidently provide LLM-driven insights to our clients without the risk of exposing cross-tenant data. The architectural rigor pays for itself the moment a client asks, 'How can you guarantee my data remains isolated when you use LLMs?'
The MCP protocol is essentially an API surface. Treat it with the same caution as your REST or GraphQL endpoints. Validate early, store nothing in global scope, and use TypeScript to ensure that your business logic is physically unable to access data outside of the scoped context. If you find yourself writing if (tenantId === ...), you have failed; the architecture should handle the filtering before the logic layer is ever reached.
Conclusion: Building for Sustainability
Building an MCP server from scratch is an exercise in restraint. The temptation to reach for simple patterns is high, but the cost of technical debt in an multi-tenant system is astronomical. By focusing on immutable, request-scoped contexts, leveraging TypeScript for type-safe resource access, and mandating integration tests that aggressively verify data boundaries, you can build a system that is not only functional but resilient to the complexities of modern, AI-integrated SaaS.
Always remember: your server is only as secure as its weakest link. If you leak a tenant ID once, your entire architectural house of cards will fall. Build for the edge case, design for isolation, and keep your middleware clean. The future of enterprise AI development depends on the quality of the servers we build today. Take the time to implement these layers properly, and you will find that the overhead of structural discipline is significantly lower than the cost of a tenant data breach.