Agent Surface

MCP Servers

Server Architecture

Structuring MCP servers for testability, maintainability, production reliability, and public distribution

Last verified 2026-09-25

Summary

server architecture determines testability and maintainability. Single-responsibility servers own one domain (billing, documents, CRM). The createServer() factory pattern enables testing without network I/O by returning a configured McpServer. Recommended project structure separates tools, resources, and prompts into distinct files.

  • Single responsibility: one domain per server
  • createServer() factory returns configured server (enables testing)
  • Domain-specific servers reduce context overhead and tool explosion
  • Project structure: src/server.ts (factory), src/tools/, src/resources/, src/prompts/
  • Shared code in common packages; servers isolated in apps/
  • Support multiple transports (stdio, HTTP) via pluggable transport layer

A well-structured MCP server is not simply a file with tools registered in sequence. The architecture determines whether your server can be unit-tested without network I/O, deployed to multiple environments without modification, and extended by other developers without needing to understand the entire codebase.

Single Responsibility

Each MCP server should own one clear domain. A server for your billing system exposes billing tools. A server for your document store exposes document tools. They do not share a codebase.

This has practical consequences for agents:

  • Agents connecting to a billing server get a tool list that is entirely relevant to billing. There is no noise from unrelated domains.
  • Smaller tool lists mean less overhead in the agent's when the server's capabilities are described.
  • Deployment, versioning, and access control operate at the server level. One server per domain means one access decision per domain.

When you find yourself building a server that does "everything an agent might need," that is a signal to split it into domain-specific servers and expose them together through a gateway or by registering multiple servers in the agent's configuration.

For a TypeScript monorepo hosting multiple MCP servers, this layout keeps servers isolated while sharing common packages:

my-mcp/
├── apps/
│   ├── billing-mcp/
│   │   ├── src/
│   │   │   ├── server.ts        # createServer() factory - the main export
│   │   │   ├── cli.ts           # Entry point for subprocess/stdio mode
│   │   │   ├── tools/
│   │   │   │   ├── create-invoice.ts
│   │   │   │   ├── list-invoices.ts
│   │   │   │   └── void-invoice.ts
│   │   │   ├── resources/
│   │   │   │   └── invoice-schema.ts
│   │   │   └── prompts/
│   │   │       └── billing-assistant.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── documents-mcp/
│       └── ...
└── packages/
    ├── shared-auth/             # JWT validation, token helpers
    ├── shared-types/            # Shared TypeScript types
    └── shared-zod/              # Common Zod schemas

Each apps/ directory is an independently deployable server. The packages/ directory holds code that multiple servers share - authentication helpers, common Zod schemas, shared TypeScript types.

The createServer() Factory Pattern

Separate server construction from server startup. The createServer() factory creates and configures the server but does not connect it to any transport. The cli.ts entry point calls createServer() and then connects it to a transport.

// src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerCreateInvoice } from "./tools/create-invoice.js";
import { registerListInvoices } from "./tools/list-invoices.js";
import { registerVoidInvoice } from "./tools/void-invoice.js";
import { registerInvoiceSchema } from "./resources/invoice-schema.js";
import { registerBillingAssistant } from "./prompts/billing-assistant.js";

export interface ServerConfig {
  stripeApiKey: string;
  databaseUrl: string;
  maxInvoicesPerPage?: number;
}

export function createServer(config: ServerConfig): McpServer {
  const server = new McpServer({
    name: "billing-mcp",
    version: "1.0.0",
  });

  registerCreateInvoice(server, config);
  registerListInvoices(server, config);
  registerVoidInvoice(server, config);
  registerInvoiceSchema(server, config);
  registerBillingAssistant(server);

  return server;
}
// src/cli.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createServer } from "./server.js";

const config = {
  stripeApiKey: process.env.STRIPE_API_KEY!,
  databaseUrl: process.env.DATABASE_URL!,
  maxInvoicesPerPage: Number(process.env.MAX_INVOICES_PER_PAGE ?? 50),
};

if (!config.stripeApiKey) throw new Error("STRIPE_API_KEY is required");
if (!config.databaseUrl) throw new Error("DATABASE_URL is required");

const server = createServer(config);
const transport = new StdioServerTransport();
await server.connect(transport);

This separation means tests can call createServer({ stripeApiKey: "test_key", databaseUrl: ":memory:" }) and connect the result to an InMemoryTransport without any subprocess or network involvement. See Testing MCP Servers for the full test setup.

Tool Registration Pattern

Each tool lives in its own file and exports a register function that accepts the server and config:

// src/tools/create-invoice.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { ServerConfig } from "../server.js";

const CreateInvoiceSchema = z.object({
  customer_id: z.string().uuid().describe("The UUID of the customer to invoice"),
  amount_cents: z
    .number()
    .int()
    .positive()
    .describe("Invoice amount in cents to avoid floating point issues"),
  currency: z.enum(["usd", "eur", "gbp"]).describe("ISO 4217 currency code"),
  due_date: z
    .string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .describe("Due date in YYYY-MM-DD format"),
  description: z
    .string()
    .max(500)
    .optional()
    .describe("Optional line-item description shown on the invoice"),
});

export function registerCreateInvoice(server: McpServer, config: ServerConfig): void {
  server.tool(
    "billing_create_invoice",
    "Creates a new invoice for a customer. Use when the user wants to charge a customer or bill for services. Requires a valid customer ID - call billing_list_customers first if you only have a customer name.",
    CreateInvoiceSchema,
    async (params) => {
      // implementation
    },
  );
}

Each registration function is pure: given a server and config, it registers exactly one tool. Nothing else. This makes the files short, focused, and easy to test in isolation.

Defense in Depth

Production MCP servers need multiple overlapping protection layers. A single validation check at the boundary is not enough - each layer catches different failure modes.

┌─────────────────────────────────────┐
│  Network Layer                      │
│  TLS termination, rate limiting,    │
│  IP allowlisting for HTTP servers   │
├─────────────────────────────────────┤
│  Authentication Layer               │
│  Bearer token / OAuth validation,   │
│  token expiry, audience check       │
├─────────────────────────────────────┤
│  Authorization Layer                │
│  Scope validation per tool,         │
│  resource-level access control      │
├─────────────────────────────────────┤
│  Input Validation Layer             │
│  Zod schema parsing on every tool,  │
│  reject unknown fields              │
├─────────────────────────────────────┤
│  Business Logic Layer               │
│  Domain invariants, idempotency,    │
│  rate limits per customer           │
├─────────────────────────────────────┤
│  Monitoring Layer                   │
│  Structured logs, tool call traces, │
│  error rate alerting                │
└─────────────────────────────────────┘

Each layer should fail closed. If authentication cannot be verified, the request is rejected - not allowed through with reduced permissions. If schema validation fails, the tool returns an isError: true response with a recovery-oriented message, rather than attempting to proceed with partial data.

Build Tooling with tsdown

MCP servers distributed as npm packages or deployed to edge runtimes need bundling. tsdown (built on Rolldown) produces fast, tree-shaken output with correct ESM/CJS dual output:

// package.json
{
  "scripts": {
    "build": "tsdown src/cli.ts src/server.ts",
    "dev": "tsdown --watch src/cli.ts"
  },
  "exports": {
    ".": {
      "import": "./dist/server.js",
      "require": "./dist/server.cjs"
    }
  },
  "bin": {
    "billing-mcp": "./dist/cli.js"
  }
}
// tsdown.config.ts
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["src/cli.ts", "src/server.ts"],
  format: ["esm", "cjs"],
  dts: true,
  clean: true,
  external: ["@modelcontextprotocol/sdk"],
});

Marking @modelcontextprotocol/sdk as external prevents it from being bundled into your output - consumers install it as a peer dependency. The dts: true option generates type declarations, which allows other servers to import and re-use your ServerConfig type.

Capability Declaration

The server capabilities you declare at construction time tell clients what your server supports. Only declare capabilities you actually implement:

const server = new McpServer({
  name: "billing-mcp",
  version: "1.0.0",
  capabilities: {
    tools: {}, // Server supports tools
    resources: {
      subscribe: true, // Clients can subscribe to resource changes
      listChanged: true, // Server will notify when resource list changes
    },
    prompts: {}, // Server supports prompts
    logging: {}, // Server can send log messages to the client
  },
});

Declaring resources.subscribe: true without implementing the resources/subscribe handler will cause client errors. Declare only what you implement.

Never declare experimental_sampling in capabilities unless you have implemented the full sampling request handler. Clients that send sampling requests to an unimplemented handler will receive protocol errors that are difficult to debug.

Current 2026-07-28 behavior: capabilities are no longer negotiated once at connect time. The initialize handshake is removed, clients send their capabilities in _meta on every request, and servers may advertise theirs through an optional server/discover method. The logging capability is Deprecated (SEP-2577), and resources.subscribe gives way to opting into resourceSubscriptions on a subscriptions/listen stream. Capability objects also gain an extensions field carrying reverse-DNS identifiers such as io.modelcontextprotocol/tasks. The declaration above is a legacy 2025-11-25 compatibility example; the discipline of declaring only what you implement carries over unchanged.

Environment Variable Conventions

MCP servers launched as subprocesses inherit the parent process's environment. Servers deployed as HTTP services read from the deployment environment. In both cases, environment variables are the correct configuration mechanism - never configuration files bundled into the binary.

// A robust config loader with validation
function loadConfig(): ServerConfig {
  const required = ["STRIPE_API_KEY", "DATABASE_URL"] as const;

  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(
        `Missing required environment variable: ${key}. ` + `See README for setup instructions.`,
      );
    }
  }

  return {
    stripeApiKey: process.env.STRIPE_API_KEY!,
    databaseUrl: process.env.DATABASE_URL!,
    maxInvoicesPerPage: Number(process.env.MAX_INVOICES_PER_PAGE ?? 50),
    environment: (process.env.NODE_ENV ?? "production") as "production" | "staging" | "test",
  };
}

Throwing on startup with a clear error message is always better than proceeding with undefined values and producing cryptic errors later during tool execution.

Publishing a Server Publicly

A server built to power your own in-app agent can also become a distribution channel: the same tool registrations, served over Streamable HTTP on a stable public URL, let external clients (Claude, ChatGPT, an IDE) use your product's capabilities directly. The architecture does not change - you still build with the createServer() factory and register tools the same way - but three things become mandatory once the server is public rather than internal:

  1. Streamable HTTP, not stdio. External clients cannot launch your process as a subprocess. See Transport Selection for the stateless HTTP setup.
  2. , not a shared . A public server cannot distribute a static secret to every user. See MCP Authentication for the full server-implementer flow, including the resource-indicator and protected-resource-metadata requirements RFC 8707 and RFC 9728 impose.
  3. Discovery metadata clients can find. Publish /.well-known/oauth-protected-resource (RFC 9728) so clients can locate your authorization server - this is the MUST-level discovery mechanism in the current spec. An informal /.well-known/mcp/server-card.json document is under active proposal (SEP-2127, Server Card Working Group) but is still Experimental and not part of the ratified spec; do not build a public launch around it yet.

Register only the tools a given caller is authorized to use - Stripe's configuration.actions pattern in Real-World MCP Patterns is the reference example, and the same scope-gated registration applies whether the caller is your own frontend or an external MCP client.

To be discoverable beyond your own docs, publish an entry to the official MCP Registry (namespace verified via GitHub OAuth or a DNS TXT record) and document connection setup for the clients you expect - a claude_desktop_config.json snippet for stdio-style local config, and your server's public URL for clients that add remote MCP servers directly. The registry added Cargo (crates.io) as a package registry type in v1.8.0 (2026-07-13), alongside npm, PyPI, NuGet, and OCI; v1.8.1 (2026-08-06) closed a namespace-takeover hole by rejecting github.io domains during DNS/HTTP token-exchange verification. The registry itself is still a preview release - expect breaking changes and possible data resets before a stable release. Track external usage (, per-caller quotas) the same way you would any public ; nothing about that is MCP-specific.

On this page