What we are building — and why it is short
The requirements fit in two lines, inherited from our thread: give the model the means to fetch the raw notes of a meeting, and to distribute the produced summary. Two tools, not one more — you know the rule since step 6: every tool is a privilege, and privileges must be justified.
The language: Python, with the protocol's official SDK. Why it, for learning: its way of declaring tools is the most readable in the ecosystem — a function, a decorator, a docstring, and the SDK builds all the rest (the JSON-RPC from step 5, discovery, schemas). The concept being identical in TypeScript and elsewhere, what you learn here transposes as is.
The skeleton: ten lines that make a server
"""The thread's MCP server: meeting notes + delivery."""
import os
from mcp.server.fastmcp import FastMCP
# The name the host and the logs will see (step 6: name clearly)
mcp = FastMCP("meeting-notes")
# Secrets come from the environment — never from the code.
# This is the "env" of the configuration seen at step 6.
NOTES_TOKEN = os.environ["NOTES_API_TOKEN"]
if __name__ == "__main__":
mcp.run() # stdio transport by default: the host runs us as a childThree observations before going further:
That really is all. Create the server object, run it. Session negotiation, answering tools/list, JSON-RPC validation: the SDK handles it. Your work concentrates where your value is — the tools.
The secret is already in the right place. os.environ["NOTES_API_TOKEN"] materializes what we have repeated since step 2: the server holds the credentials, the code does not contain them, the model will never see them. And accessing via environ[...] (rather than a silent .get(...)) makes the server crash at startup if the token is missing — a server that refuses to start misconfigured beats a server that will fail mid-task.
The transport is step 6's. mcp.run() speaks stdio: the host launches us as a child process — exactly the "local server" case of the local/remote trade-off.
Tool #1 — get_meeting_notes: the hand that looks
@mcp.tool()
def get_meeting_notes(date: str) -> str:
"""Fetches the raw notes of the meetings on a given date.
Use it to obtain the raw material for a meeting summary,
minutes or a meeting recap.
Args:
date: the meetings' date, in YYYY-MM-DD format.
"""
notes = notes_api.fetch(date=date, token=NOTES_TOKEN)
if not notes:
return (f"No notes found for {date}. "
"Check the date, or ask the user which "
"meeting they mean.")
return format_notes(notes)Let's dissect — every line applies a principle already met:
The decorator builds the bridge. @mcp.tool() turns an ordinary Python function into a tool the host discovers. The function name becomes the tool name; the type annotations (date: str) become the input schema the protocol will validate at moment ② of step 5's sequence. You write Python; the SDK publishes MCP.
The docstring is the description — hence the trigger. Reread it: it says what the tool does, when to use it, in the trade's words ("meeting summary", "minutes", "recap"). It is exactly the step-② method from step 3, applied to tools: the model picks its tools by reading their descriptions, just as it picks its skills. A vague docstring produces a tool never called — or called at the wrong time. The gate test applies here too, word for word.
The empty case is an answer, not an error. "No notes for this date" is not a failure: it is information, phrased so the model knows what to do next (check the date, ask the user). Remember the move — it becomes central two sections down.
Tool #2 — send_summary: the hand that acts
ALLOWED_RECIPIENTS = ("team@example.com", "management@example.com")
@mcp.tool()
def send_summary(recipient: str, summary: str) -> str:
"""Delivers a finalized summary to one of the team's lists.
Use only when the summary is complete and the user has
asked for it to be sent.
Args:
recipient: the mailing list (among the team's lists).
summary: the full summary, in the house format.
"""
if recipient not in ALLOWED_RECIPIENTS:
return (f"Recipient refused: {recipient}. "
f"Allowed lists: {', '.join(ALLOWED_RECIPIENTS)}. "
"Nothing was sent.")
if len(summary) < 200:
return ("Suspiciously short summary: send refused. "
"Check that the full summary was produced "
"before requesting delivery.")
mailer.send(to=recipient, body=summary, token=MAIL_TOKEN)
return f"Summary delivered to {recipient}."Here is where the difference between a demo server and a server you dare to plug in is made. Two guardrails, two philosophies:
The recipient allowlist. The tool structurally refuses to send outside the planned lists — even if the model asked, even if something read in the notes pushed it there (you remember moment ④ from step 5: what enters the context can influence the model). It is least privilege applied in the code, where no instruction, no injection, no reasoning can bypass it. A skill saying "only send to the team" is a wish; an allowlist is a control — step 4's distinction, made executable.
The plausibility check. A summary under 200 characters is probably not a summary: the tool says so and does not send. Every write tool deserves its question: "what does a call I should refuse look like?" — then the refusal gets coded. It is your last net before the irreversible.
And note the docstring: "only when the summary is complete and the user has asked for it to be sent". The when is bounded in the description itself — the trigger is a guardrail too.
Errors: what the model sees when things break
The most underrated topic in MCP development. When a tool fails, its error message enters the context window — and the model reasons on it to decide what comes next. Your error message is therefore not a developer log: it is a recovery instruction for the model. Compare:
# ❌ What the model cannot exploit:
# "KeyError: 'items' at notes_api.py line 42" — noise, plus the
# risk of exposing paths, versions, even configuration fragments
# into the context.
# ✅ What the model can exploit:
return ("The notes service is temporarily unreachable. "
"Retry shortly; if it persists, tell the user — "
"and do not invent content.")The three rules of the well-mannered error message: it says what happened (in task language, not implementation language), it says what to do next (retry, ask the user, abort cleanly), it forbids invention ("do not invent content" — the line that prevents a summary fabricated out of thin air on an outage day). And symmetrically: never a raw stack trace, never infrastructure details — everything you put in an error ends up in the context, hence potentially in a conversation.
Plugging in your own server
Your server plugs in exactly like the ones from step 6 — you have simply moved to the other side of the counter:
{
"mcpServers": {
"meeting-notes": {
"command": "python",
"args": ["/path/to/notes_server.py"],
"env": {
"NOTES_API_TOKEN": "${NOTES_API_TOKEN}",
"MAIL_TOKEN": "${MAIL_TOKEN}"
}
}
}
}And step 6's ritual applies to your own production: on host restart, check that discovery shows your two tools and nothing else; first try in read mode (get_meeting_notes on a known date); and keep the send confirmation active on send_summary — your own code deserves the same observation period as a third-party server.
The loop closes
Scroll back to the map at the top of this step: for the first time in the path, everything is lit. Let's run the thread one last time, end to end, with your pieces:
- "Write up this morning's meeting summary and send it to the team"
- The skill from step 3 loads — its description intercepted "meeting summary"
- The model calls your
get_meeting_notes— its docstring did the rest - The notes flow into the context; the model applies the house format, the edge cases, the one-page limit
- The model calls your
send_summary— the allowlist stands guard, the delivery goes out
The manual said how (steps 2-4), the hands supplied what and did (steps 5-7), the brain orchestrated — and every link, you wrote, tested, governed. What remains is armoring the whole (step 8) and knowing where the ecosystem can spare you writing the next piece (step 9).
📚Going deeper
For the geeks: stdio has one golden rule — stdout belongs to the protocol. With the stdio transport, the process's standard output is the JSON-RPC channel: the slightest debug print() injects text between the frames and corrupts the session — the most classic first-server bug, and the most disorienting ("it worked, I added a print, everything died"). Logs therefore go to stderr or a file: logging.basicConfig(stream=sys.stderr) from line one, and a print ban in code review. Second subtlety of the same barrel: the libraries you import may themselves write to stdout — a chatty SDK, a progress bar — and break the session without a single line of your code at fault; at the first erratic behavior, audit what your dependencies print. Third reflex: the input schemas generated from your type annotations are your first validation, not your last — the type says "it is a string", your code must still say "it is a plausible date". Type-check at the edge, business logic at the center.
📚Going deeper
For the geeks: testing a server a model consumes. Three storeys, fastest to fullest. Storey 1 — the bare functions: your tools are Python functions; test them as such (pytest, nominal case, empty case, guardrail-refused case) without starting any server — this is where the allowlist is verified, in three assertions. Storey 2 — the server in isolation: the protocol's official inspection tool (the Inspector) connects to your server and shows you exactly what a host would see — discovered tools, their schemas, their descriptions — and lets you call each tool by hand; it is MCP's counterpart of curl against an API, and the right place to reread your docstrings with the model's eyes. Storey 3 — triggering in real conditions: step 3's grid applies to tools — phrasings that must trigger the call, neighboring phrasings that must not, and the collision case if several servers expose similar tools. A server that passes all three storeys plugs in serenely; a server tested only "by hand in the conversation" will be debugged in production.
print() on stdio — stdout belongs to the protocol: a single debug print corrupts the session; logs go to stderr
2. ❌ The secret in the code — a hardcoded token will end up in Git: the environment, always (and the server must refuse to start without it)
3. ❌ The developer docstring — "Fetches notes via the API" triggers nothing: the docstring is written for the model, what + when, trade words
4. ❌ The guardrail in the skill — "only send to the team" as an instruction is a wish; the allowlist in the code is a control — the irreversible is protected server-side📍 Skills & MCP path — step 7/9
- 🗺️ The map before the territory
- 🔬 Anatomy of a skill
- 🛠️ Create your first skill
- 🏛️ Skills in the enterprise: governance
- 🔌 MCP: the protocol explained
- ⚡ Use an existing MCP server
- ⚙️ Build your minimal MCP server ← you are here
- 🛡️ Secure your MCP servers
- 📡 The ecosystem: where to find, where it moves
Next step → Secure your MCP servers: the full attack surface — indirect injection, confused deputy, exfiltration by chaining — and the countermeasures, from least privilege to supervision. The step your CISOs will read first.
Your server is running? The AI agent security sheet gives you a 90-second foretaste of step 8.