# Server Architecture

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Server Development
Source: https://www.devtutorials.ai/learn/mcp/server-development/server-architecture

> Designing MCP servers: one domain per server, modular handlers with handler, service and backend layers, atomic tools, least privilege, and explicit approval contracts for risky tools.

A good kitchen is organized by stations. One cook handles salads, another handles the grill, and another handles dessert. Each station has only the tools and ingredients it needs. That reduces confusion and limits damage when something goes wrong.

A well-designed MCP server works in much the same way. A server should do one job well, use a small set of tools, ask for only the permissions it needs, and stop risky actions behind a clear approval step.

**The main idea:** design servers around **one domain**, split code into **modules**, keep tools **atomic**, give the server **least privilege**, and define **approval contracts** whenever a tool could change something important.

## A Real-World Example

Brightgrid started with a single server that had about 40 tools: order tracking, marketing emails, password resets, payroll reporting, and more. It ran with a full admin database account.

Then a prompt-injection test showed the danger. A malicious support ticket tricked the model into calling `send_newsletter` against every customer. The tool worked because the server had broad permissions and no clear boundaries.

The team rebuilt it into three servers:

- **orders**
- **marketing**
- **accounts**

Each server had a small scope, a narrow database account, and only the tools that matched its job. The orders server could not send email. The marketing server could not touch payroll. Risky actions were wrapped in explicit confirmation rules.

**In simple words:** a server with a small job and narrow permissions is much harder to misuse.

## What You Will Learn

In this lesson, you will learn:

1. Why each server should cover one business domain.
2. How to split a server into modular handlers.
3. How atomic tools reduce risk and confusion.
4. How least privilege limits damage.
5. How approval contracts make risky actions safer.

The MCP Lab help-desk server is built around one domain: orders and returns. Open the server code and look at how each tool is registered as its own `registerTool` call. That is the starting point for splitting the server into modules.

## Single Domain

### One Server, One Area

A **domain** is a business area such as orders, billing, documents, or customer accounts. A good MCP server keeps to one domain.

That gives you several benefits:

- the tool list stays short and more relevant,
- permissions stay narrow,
- one team can own and evolve the server safely,
- hosts can enable only the domains a user needs.

When a server mixes unrelated concerns, the model has more choices, permissions become broader, and debugging becomes harder.

```js runnable title="Spot a server with too many domains"
const tools = [
  { name: "lookup_order", system: "orders" },
  { name: "cancel_order", system: "orders" },
  { name: "book_return", system: "orders" },
  { name: "send_newsletter", system: "marketing" },
  { name: "reset_password", system: "accounts" },
];

const bySystem = {};
for (const t of tools) {
  (bySystem[t.system] ??= []).push(t.name);
}

for (const [system, names] of Object.entries(bySystem)) {
  console.log(system.padEnd(10), names.join(", "));
}
const systems = Object.keys(bySystem).length;
console.log(
  systems === 1
    ? "one domain: good"
    : `${systems} domains: split into ${systems} servers`,
);
```

Expected output:

```text
orders     lookup_order, cancel_order, book_return
marketing  send_newsletter
accounts   reset_password
3 domains: split into 3 servers
```

This is the right design. The server should not try to be the whole company.

## Modular Handlers

### Split the Code

As a server grows, putting every tool in one file makes it harder to understand, test, and change safely. Modules keep related functionality together and reduce accidental coupling.

A large server becomes easier to maintain when each group of tools, resources, or prompts lives in its own module. Each module receives the shared server and the services it needs, then registers its own tools.

The main server file should mostly do setup: load the modules and choose the transport.

```js runnable title="Modules that register themselves"
const registered = [];
const server = {
  registerTool: (name, def, handler) => registered.push({ name, def, handler }),
};

const ordersModule = (s, db) => {
  s.registerTool(
    "lookup_order",
    { readOnly: true },
    ({ order_id }) => db.orders[order_id] ?? null,
  );
};

const returnsModule = (s, db) => {
  s.registerTool(
    "book_return",
    { readOnly: false },
    ({ order_id }) => `return booked for ${order_id}`,
  );
};

const db = { orders: { "A-1001": "shipped" } };
for (const mod of [ordersModule, returnsModule]) {
  mod(server, db);
}

console.log("tools:", registered.map((t) => t.name).join(", "));
console.log("lookup_order ->", registered[0].handler({ order_id: "A-1001" }));
```

Expected output:

```text
tools: lookup_order, book_return
lookup_order -> shipped
```

This pattern keeps responsibilities separated without forcing a giant single file.

### Layers Inside a Handler

Each tool should follow a clean internal structure:

1. **Handler**: validates arguments, shapes the result, and turns errors into `isError` results.
2. **Service**: holds the business rules, such as "only unshipped orders can be cancelled".
3. **Backend client**: talks to the database or outside API with narrow access.

This separation matters because a service should decide what is allowed, not a prompt or a model. It is easy to leak internal fields if a handler simply returns raw database records, so the service should shape the public result.

```js runnable title="Handler, service and backend layers"
const backend = {
  getOrder: (id) => ({ "A-1001": { status: "shipped", internal_cost: 41.2 } })[id],
};

const orderService = {
  status(id) {
    const o = backend.getOrder(id);
    if (!o) {
      throw new Error(`No order ${id}`);
    }
    return { order_id: id, status: o.status };
  },
};

function lookupOrderHandler(args) {
  if (!/^A-\d{4}$/.test(args.order_id ?? "")) {
    return {
      content: [{ type: "text", text: "order_id must look like A-1001" }],
      isError: true,
    };
  }
  try {
    return {
      content: [
        { type: "text", text: JSON.stringify(orderService.status(args.order_id)) },
      ],
    };
  } catch (e) {
    return { content: [{ type: "text", text: e.message }], isError: true };
  }
}

for (const id of ["A-1001", "A-9999", "1001"]) {
  console.log(id.padEnd(7), JSON.stringify(lookupOrderHandler({ order_id: id })));
}
```

Expected output:

```text
A-1001  {"content":[{"type":"text","text":"{\"order_id\":\"A-1001\",\"status\":\"shipped\"}"}]}
A-9999  {"content":[{"type":"text","text":"No order A-9999"}],"isError":true}
1001    {"content":[{"type":"text","text":"order_id must look like A-1001"}],"isError":true}
```

The key idea is not just organization. It is that each layer has a different responsibility and a different trust boundary.

## Atomic Tools

### Design Rules Recap

This lesson continues the design rules from the earlier chapters: one verb per tool, strict schemas, honest annotations, and clear separation between read and write actions.

That also means:

- read tools and write tools should be separate,
- dangerous actions get their own tool, not a hidden flag on a generic tool,
- bulk actions like "cancel everything" are usually a bad idea.

A tool is safer when it does one action, with one purpose, and a clear result.

Before you add a tool, ask: "What is the worst thing a confused or tricked model could do with this?" If the answer is scary, narrow the tool, require confirmation, or don't build it.

## Least Privilege

### Only the Rights It Needs

**Least privilege** means the server gets only the rights it needs to do its job. It should not run as an all-powerful admin.

Good practice:

- read-only database users for read-only tools,
- separate narrow credentials for write actions,
- no access to unrelated domains,
- scoped API keys with only the actions the server needs.

If a bad prompt or a model mistake happens, the server can still do damage only inside the narrow scope it was allowed.

```js runnable title="Find extra and missing permissions"
const needs = {
  lookup_order: ["orders:read"],
  book_return: ["orders:read", "returns:write"],
  export_report: ["orders:read"],
};
const granted = [
  "orders:read",
  "orders:write",
  "returns:write",
  "customers:read",
  "payroll:read",
];

const needed = [...new Set(Object.values(needs).flat())];
const extra = granted.filter((g) => !needed.includes(g));
const missing = needed.filter((n) => !granted.includes(n));

console.log("needed: ", needed.join(", "));
console.log("remove: ", extra.join(", ") || "nothing");
console.log("missing:", missing.join(", ") || "nothing");
```

Expected output:

```text
needed:  orders:read, returns:write
remove:  orders:write, customers:read, payroll:read
missing: nothing
```

This is the real security boundary. The model may be tricked, but the permissions decide how far that trick can go.

A server running with an admin account converts a prompt-injection bug into a full system breach. The model can be tricked, but the server's permissions are the real limit.

## Explicit Approval Contracts

### Say What Must Be Confirmed

A host can ask the user before running a risky tool, but it must know exactly what the user is approving. That is where an **approval contract** helps.

For each risky tool, record:

- whether approval is always required,
- the exact text the user should confirm,
- whether the server should confirm itself with MRTR elicitation as `cancel_order` does.

Unknown tools should be treated conservatively and require approval.

```js runnable title="Approval contracts"
const contracts = {
  cancel_order: {
    approval: "always",
    message: (a) => `Cancel order ${a.order_id}? This can't be undone.`,
  },
  book_return: {
    approval: "always",
    message: (a) => `Book a courier pickup for order ${a.order_id}?`,
  },
  lookup_order: { approval: "never" },
};

function beforeCall(name, args) {
  const c = contracts[name] ?? { approval: "always", message: () => `Run ${name}?` };
  return c.approval === "never" ? "run" : "ask: " + c.message(args);
}

console.log(beforeCall("lookup_order", { order_id: "A-1001" }));
console.log(beforeCall("cancel_order", { order_id: "A-1002" }));
console.log(beforeCall("delete_everything", {}));
```

Expected output:

```text
run
ask: Cancel order A-1002? This can't be undone.
ask: Run delete_everything?
```

The server-side confirmation is a second safety layer. It protects users even when the host forgets to ask.

## Common mistakes

- **The everything server.** When everything lives in one server, the tool list grows and permissions widen.
- **Leaky handlers.** Returning whole database rows exposes internal data. A service should shape the public output.
- **Shared admin keys.** One leaked credential exposes the entire system.
- **Bulk power tools.** Features like “delete all” or “email everyone” are usually not worth the risk.
- **Approval by hope.** Saying “ask the user first” in the description is not a real control. The approval step needs a real contract and a check in code.

## Exercises

Each exercise ends with a value on the last line. Press **Check** to compare it with the expected answer.

## What to Remember

- **One domain per server**: short tool lists, narrow rights, and clear ownership.
- **Modules** should register their own tools, while handlers, services, and backend clients stay in separate layers.
- Separate read and write tools; avoid bulk power tools.
- **Least privilege** means each server receives only the permissions it needs.
- **Approval contracts** define when the host must ask and what the user is confirming.

The pattern is simple: smaller servers, smaller permissions, clearer rules, safer outcomes.

Next: **Development Environment**, where you learn about the SDKs, the MCP Inspector, project setup, and the MCP Registry.
