Server Architecture
By the Syncfusion DevTutorials team · Last updated
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:
- Why each server should cover one business domain.
- How to split a server into modular handlers.
- How atomic tools reduce risk and confusion.
- How least privilege limits damage.
- 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.
Inside a well-built server: the SDK handles the protocol, handlers validate input, services apply business rules, and backend clients use narrow credentials.
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.
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:
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.
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:
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:
- Handler: validates arguments, shapes the result, and turns errors into
isErrorresults. - Service: holds the business rules, such as "only unshipped orders can be cancelled".
- 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.
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:
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.
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:
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_orderdoes.
Unknown tools should be treated conservatively and require approval.
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:
run
ask: Cancel order A-1002? This can't be undone.
ask: Run delete_everything?
A tool that confirms by itself
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.