Lesson 16 of 30
Authoring an MCP Server
The customer-facing boundary of your agent — what the server exposes is what the agent can touch at the customer's site. Tool schemas, transports, and the security review you will lose if you expose the wrong thing.
The last lesson was about the loop. This one is about the surface at the bottom of it — what the loop is allowed to call. As an FDE this is where you spend most of your design time, because it is the part a customer security team reads, a prompt-injection attacker reaches for, and the thing you will be explaining in a post-incident review if it goes wrong.
The protocol for that surface is MCP. If you have not met it before, the MCP lesson in the Claude Code track is the quickest introduction. This lesson is about authoring a server, not consuming one.
The server is a customer-facing boundary
The single most useful reframe when you start designing an MCP server is to stop thinking of it as a glue layer and start thinking of it as a product surface. What the server exposes is what the agent can touch. What the agent can touch is what prompt-injectable content — a ticket description, a page pulled through retrieval, a comment on a record — can touch.
That reframe decides the whole design. If the server is a product surface, each tool is a product decision. If each tool is a product decision, a customer security review is a sales conversation you prepare for the same way you would prepare for a pricing one.
Designing a tool
Every tool on a well-designed server does one thing, takes structured input, and returns something the agent can reason about and something the next step can consume. Three sentences to internalise:
One thing. A tool called manage_orders is three tools wearing a
trench coat. A tool called cancel_order(order_id, reason) is one. The
narrower a tool is, the more honestly the agent can call it, the more
cleanly an eval can score it, and the more precisely a security review can
reason about it.
Structured input. A free-text prompt parameter is a prompt-injection
channel. A parameter typed as { customer_id: string; limit: integer } is
not — the server validates it, and the tool runs against a known shape.
Dual-form output. The agent reasons cheaply over a short summary string; the next tool, or a downstream renderer, needs the structured data. Return both, and the client picks what it uses. A pattern that reads like:
{
"summary": "Found 3 overdue invoices for customer 8821, oldest 47 days.",
"data": {
"customer_id": "8821",
"overdue_invoices": [
{ "id": "INV-11042", "amount_cents": 420000, "days_overdue": 47 },
{ "id": "INV-11119", "amount_cents": 180000, "days_overdue": 22 },
{ "id": "INV-11201", "amount_cents": 95000, "days_overdue": 8 }
]
}
}The agent reads the summary for free; it only unpacks the data when the next step demands it, which is when the tokens are worth spending.
The example
Rather than describe this abstractly, here is a bounded SQL tool written the way a review-passing MCP server actually writes one. Read the shape, not the syntax — this is the pattern, and the language is incidental.
// Not run_sql(query). Four narrow tools, each over a vetted view with
// typed inputs. The agent cannot ask for an arbitrary join, union or table,
// because none of those are reachable from this server.
server.tool({
name: "list_recent_orders",
description: "List the most recent orders for one customer, newest first.",
input: {
type: "object",
properties: {
customer_id: { type: "string", pattern: "^[0-9]+$" },
limit: { type: "integer", minimum: 1, maximum: 50 },
},
required: ["customer_id", "limit"],
},
handler: async ({ customer_id, limit }) => {
const rows = await db.query(
`select order_id, placed_at, total_cents, status
from v_customer_orders
where customer_id = $1
order by placed_at desc
limit $2`,
[customer_id, limit],
);
return {
summary: `Found ${rows.length} orders for customer ${customer_id}.`,
data: rows,
};
},
});Four properties worth noticing. The query hits a view named v_customer_orders,
not a base table, so the DBA can change the join or add a mask without
breaking the tool. The inputs are typed and constrained — pattern on the
id, minimum/maximum on the limit — so the server rejects nonsense before
the database sees it. The limit is bounded even though the view is
read-only, because a loose cap is a cost bug. And the return carries both a
summary and the structured rows.
Transports
MCP has three transports in practical use and the choice falls out of where the server runs.
stdio
The server is a process the agent starts on its own machine. Simple, zero network surface, the right choice when the agent and the server are co-located.
HTTP
The server lives at a URL and the agent reaches it over the network. The right choice when the server is inside a customer's VPC, or at a vendor who exposes it as a hosted service.
SSE / streaming
A streaming layer on top of HTTP for tools whose outputs arrive over time — long-running queries, incremental results. Use when the UX needs partial progress; otherwise HTTP's request/response is cleaner.
The usual FDE deployment is a mix: an in-house set of hosted HTTP tools reachable inside the customer's network, and a small subset of stdio tools for anything that needs to run alongside a local resource like a dev database.
What not to expose
The tools you refuse to build are as much of the design as the ones you build. Three categories reliably lose a security review and are worth naming.
Broad SQL or query-language passthrough. run_sql, evaluate_jsonpath,
run_graphql_query. Each of these is one prompt-injection away from
whatever is behind it. The fix is not to sanitise; the fix is to replace
them with named, parameterised tools.
Shell or code execution. A tool that executes a string on a shell is the largest single blast radius you can expose, and the usual phrasing — "only for debugging" — does not survive the first incident. If you need to run arbitrary code, do it in a sandbox that is one of your security team's owned surfaces, not an MCP tool.
Unbounded writes. A tool that updates records needs a bound on how many records it can update per call, per minute, per actor. Without a bound, every accidental loop is a mass update you are rolling back by hand.
The review that gets this to production is the one where you run the review yourself first — read each tool, assume the worst injected input, and rewrite anything that would flinch.
What to take away
An MCP server is the agent's boundary with the customer's data, which means every tool on it is a product decision and a security decision at the same time. Tools do one thing, take structured input, return a short summary plus structured data; transports fall out of where the server lives; broad passthroughs and shell access do not belong on the server at all. The review you will fail is the review of the tool you argued yourself into shipping; the fix is to run it yourself before the customer does.
Next: sub-agents and skills, which is how you keep the loop focused when the tool surface starts to grow.
Check yourself
5 questions · pass 4/5 to unlock Sub-Agents and Skills
1.A customer security review asks you to justify a tool on your MCP server called
run_sql(query: string). What is the strongest form of the answer they are hoping for?2.You are choosing a transport for an MCP server that will run inside a customer's VPC and be called by a hosted agent. Which fits?
3.What is the honest difference between a tool that returns a small summary string and one that returns a large JSON object?
4.A customer asks why your MCP server cannot just expose the raw database connection and let the agent do what it needs. What is the clearest case to make for not doing that?
5.You add a
delete_customer(customer_id)tool to your MCP server and ship. What is the usual regret?
5 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.