An AI can write database queries. It has never seen yours.
The whole of MCP is an answer to this problem.
An AI assistant will happily write you a database query. It has read a great deal of code, so it knows the shape of an answer. It has never seen your database.
It does not know your customer table is CUSTOMERS, that the email column is EMAIL and not email_address, or that one old table is called legacy_notes in lower case. So it guesses, confidently, and often wrongly.
Diagram scrolls sideways on a narrow screen.
The names on the left are plausible. That is exactly what makes them expensive: nothing looks wrong until the query runs. Here is the same demo database this tutorial is built on, guess against reality:
| A reasonable guess | What it is actually called | Why the guess fails |
|---|---|---|
customers.email_address | CUSTOMERS.EMAIL | Wrong column name, and the table is upper case. |
orders.status | ORDERS.ORDER_STATUS | Prefixed here. The four valid values live in a column comment. |
orders.total | ORDERS.TOTAL_AMOUNT | Close, but not close enough to run. |
notes | legacy_notes | Lower case, unlike every other table. Imported from an old system. |
No amount of general knowledge recovers those. They are facts about your database and nowhere else.
You could paste a description of your database into every chat. People do. It is slow, it goes stale, and it helps exactly one conversation.
The better answer: give the assistant a way to ask your systems directly.
MCP is a standard way to hand an AI a set of abilities
Model Context Protocol: a standard wire format for handing a model context and capabilities it did not ship with. One integration, then any client that speaks MCP can use it.
The roles are the ones you would expect, with one wrinkle worth stating: the server is usually a small local process, launched by the client as a subprocess and spoken to over standard input and output. No hosting, no network, no ports.
Diagram scrolls sideways on a narrow screen.
The chat app is the client. The piece you build is the server. Same server, same code, different pipe if you later want it remote instead of local.
Three building blocks, and the difference is who is in control
If you remember one thing a week from now, make it this.
An MCP server offers three kinds of thing. The difference has nothing to do with what they contain, and everything to do with who decides to use them.
| Building block | Who decides to use it | Everyday comparison |
|---|---|---|
| Tool | the AI decides, on its own, mid-conversation | a button the AI can press |
| Resource | a person picks it and attaches it | a file you attach to an email |
| Prompt | a person chooses it from a menu | a saved recipe |
Diagram scrolls sideways on a narrow screen.
Notice what is not there: nothing about databases, size, or complexity. The same information can be offered as a tool and a resource at once, and in the real project it is (chapter 3).
Throughout, tools are green and labelled Tool, resources blue Resource, prompts purple Prompt. The words are always there, so colour is a convenience.
The example server offers 12 tools, 4 resources (plus 2 fill-in-the-blank ones) and 6 prompts. Counted by running it.
Tool A button the AI can press
A tool is an action the AI can take mid-answer, without asking anyone first.
Think of a control panel of labelled buttons. You do not say which to press: you label them well and delegate the choice.
What the AI is given
Three things per tool: a name, a plain-English description, and the information it needs to run. It never sees how the tool works inside.
The real list the example server announces, as printed by running it:
list_tables (no arguments)
List every table and view in the database.
describe_table (table)
Get the full structure of one table.
get_table_ddl (table)
Get the CREATE TABLE statement for one table.
list_relationships (no arguments)
List every foreign key in the database.
find_sensitive_columns (no arguments)
Find columns whose NAME suggests personal or secret data.
search_columns (keyword)
Find columns whose name contains a keyword, across all tables.
Read it as the AI does: each line is a labelled button. (table) means "tell me which table". (no arguments) means "press it".
All twelve, grouped by blast radius
The project splits its tools across two files, not by subsystem but by what happens if the AI gets it wrong. That is a design decision worth stealing: it keeps the dangerous surface small and obvious to anyone reviewing it.
| tools.py, cannot read or change a row | data_tools.py, reads rows and changes data |
|---|---|
list_tablesdescribe_tableget_table_ddllist_relationshipsfind_sensitive_columnssearch_columns
|
run_query reads rowsexecute_statement any writeinsert_row writeupdate_rows writedelete_rows writeshow_audit_log reads the record
|
The anatomy of a real tool
describe_table, copied exactly from the project. One of the most used tools in the server, and fourteen lines long.
Tap any underlined part of the code to read what it does.
mcp_server/tools.py
def () :
The point is how little there is: a label, a name, one typed input, a description, and one line that delegates to code that already existed.
The description you write is the instruction the AI reads
The most important idea here, and the one most often missed.
Deciding whether to use a tool, the AI has only the description. It cannot open the code or experiment first. It reads a name and a paragraph, and commits.
So that paragraph is not documentation for a colleague. It is an instruction to someone you cannot follow up with.
Same rule for the name. describe_table says what it does. dt or get_info does not.
The project's own words, at the top of its tools file:
mcp_server/tools.py
1. The description is a prompt.
It is the only thing the model reads when deciding. A vague description gets
a tool used at the wrong moment; a precise one gets it used correctly. Write
descriptions for the model, not for a human reading the source.
What actually travels back and forth
Diagram scrolls sideways on a narrow screen.
1. The AI decides
You ask "how many orders are still unpaid?". The AI sees describe_table on its list and works out it needs the shape of ORDERS first. Nobody told it to. It chose.
2. The request goes out
Tool name plus the input it chose: describe_table, with table set to ORDERS.
3. Real data comes back
The genuine reply from the example server, trimmed for length:
{
"schema": "mcp_demo",
"table": "ORDERS",
"columns": [
{
"name": "ID",
"type": "bigint",
"nullable": false,
"default": null,
"auto_increment": true,
"comment": null
},
{
"name": "ORDER_STATUS",
"type": "varchar(50)",
"nullable": false,
"default": "pending",
"auto_increment": false,
"comment": "pending, paid, shipped, cancelled"
}
]
}
Look at what the AI now knows and could not have guessed: the status column is ORDER_STATUS, and its four accepted values sit in a column comment somebody wrote years ago. That comment just walked straight into the conversation.
That is the whole trick. The guessing stops because a button returns the truth.
Resource A file you attach to an email
A resource is content a person picks and attaches, usually before anything else happens.
Think of the paperclip on an email. You browse, pick a file, attach it. The recipient never asked; you decided it was relevant. In a chat app a resource shows up as exactly that: a paperclip or an "add context" menu.
Diagram scrolls sideways on a narrow screen.
| Tool | Resource | |
|---|---|---|
| Who chooses | the AI | a person |
| When | mid-conversation, as needed | up front, before asking |
| What it is | an action, a verb | content, a noun |
| Does anything run? | yes, it does something | no, it is read |
The project puts it in one line:
mcp_server/resources.py
Tool -> "model, fetch this when you decide you need it"
Resource -> "human, attach this before we start"
One consequence worth keeping: a resource has to be something you can meaningfully put in a list. If choosing it requires answering a question first, it wants to be a tool.
The anatomy of a real resource
A resource from the project: a readable summary of the entire database, every table, every column, how they connect.
Tap any underlined part to read what it does.
mcp_server/resources.py
@mcp.resource(
,
,
,
,
)
def () -> str:
"""A Markdown summary.
Note that a resource does not have to mirror what is stored. It can be
any representation you think is useful, here, Markdown, because it
reads well both to a model and to a human previewing the attachment.
"""
The start of what a person actually attaches when they pick it, from a real run:
# Database: mcp_demo
6 tables.
## CUSTOMERS
_People who can place orders_
| Column | Type | Null | Notes |
|---|---|---|---|
| `ID` | bigint | no | PK, auto |
| `FULL_NAME` | varchar(150) | no | - |
| `EMAIL` | varchar(255) | no | primary contact address |
| `PHONE` | varchar(30) | yes | - |
| `COUNTRY` | char(2) | yes | ISO 3166-1 alpha-2 |
| `SIGNUP_DATE` | datetime | no | - |
Attach once, and the AI knows the whole database for the rest of the conversation without pressing a single button.
Fixed addresses and fill-in-the-blank addresses
Every resource has a URI so a client can ask for it by name. The schema:// prefix here was invented by the project: there is no registry, you pick something descriptive and stay consistent.
Two shapes:
Fixed
Complete as written. There is one of each, and a person can see it in a list and click it.
schema://tables application/json Table list
schema://ddl text/plain Full schema DDL
schema://relationships application/json Foreign keys
schema://overview text/markdown Schema overview
Fill in the blank
A gap in curly brackets. A pattern rather than a single item, so it cannot appear in a list: the person supplies the missing word.
schema://table/{name} application/json Table structure
schema://table/{name}/ddl text/plain Table DDL
Why not list every table as its own fixed address? A real database might have four thousand of them, and nobody scrolls through four thousand attachments. The pattern says "give me the name and I will fetch that one", so schema://table/CUSTOMERS and schema://table/ORDERS both work without being written out in advance.
Both lists above are exactly what the server reports. Fixed addresses and patterns are announced separately, because they are used differently.
The same thing, offered twice, on purpose
The clearest illustration of who-controls-what in the project.
The server can give you the exact definition of one table, the technical text saying how it was built. It offers that two ways.
Tool get_table_ddl
The AI presses it mid-conversation, when it works out it needs the exact definition.
Resource schema://table/{name}/ddl
A person attaches it at the start, already knowing which table today is about.
Diagram scrolls sideways on a narrow screen.
Same information, same underlying code, deliberately:
mcp_server/resources.py
Deliberately the same data as the get_table_ddl TOOL. Same content, two
access paths, because the two primitives answer different questions:
the tool is for the model to call; this is for a human to attach.
The question is never "what kind of data is this?" but "who should decide to bring it in?". Sometimes the honest answer is "either, depending on the day", and then you offer both.
Check yourself: a colleague wants the AI to be able to look up today's exchange rates whenever a conversation happens to involve a foreign currency. Tool, resource, or prompt?
Prompt A saved recipe
The least used building block and, the project argues, the most underrated.
A prompt is a well-phrased request somebody worked out once, saved, and put on a menu. It appears as a menu entry or slash-command.
Worth flagging, because the word is overloaded: an MCP prompt is not "whatever you type into the box". It is a saved, named, parameterised request the server publishes and a person picks.
Tap any underlined part to read what it does.
mcp_server/prompts.py
def () -> str:
...
)
Pick Explain one table, type ORDERS, and this is the complete text handed to the AI, exactly as the real server produced it:
Explain the `ORDERS` table to someone who has never seen this database.
Call describe_table('ORDERS') first, then cover:
- What real-world thing does a row represent?
- What does each column mean? Note anything whose purpose is not obvious from its name.
- How does it connect to other tables, and what does each relationship mean in business terms?
- What would you need to know before writing a query against it, nullable columns, defaults, anything easy to get wrong?
Write for a new engineer, not a DBA. Avoid jargon where a plain word will do.
A prompt returns words, not data
This surprises almost everyone.
What came back on the last step is not a table or a number. It is a set of instructions, ending with advice about who to write for.
The prompt never touched the database. It handed over a better question and named the buttons to press: Call describe_table('ORDERS') first. The prompt sets the plan; the tools fetch.
Diagram scrolls sideways on a narrow screen.
Why not just type the question?
Three reasons, in the project's words:
mcp_server/prompts.py
* Consistency -- everyone's "audit" means the same thing, so results are
comparable across people and across runs.
* Expertise -- you encode what a good answer looks like. Most users do not
know to ask about missing primary keys or unindexed FKs.
* Discovery -- a menu entry gets used; a README example does not.
The middle one is the prize. Somebody in your organisation knows what a good answer looks like; a prompt is how that stops living in one person's head.
The server ships six, including a database health check, a guided tour for someone new, and a careful routine for changing data safely.
Check yourself: your support team keeps asking the AI to write up an incident report, and everyone words the request differently, so the write-ups do not match. What should you build?
The decision guide
Three questions, in order. They land you in the right place almost every time.
Question 1 of 3
A shortcut, not a law. When two answers both seem right, offering both is often honest, exactly as the project does with the table definition.
Worked examples from the real project
Real decisions from building the example server. The reasoning is the interesting part.
| Feature | Built as | The deciding question |
|---|---|---|
run_query | Tool | Who writes the SQL? Only the AI can, and only in the moment. |
schema://overview | Resource | Could a person pick it off a list? Yes, there is exactly one. |
audit_schema | Prompt | Is a person starting a piece of work? Yes, and the value is the wording. |
get_table_ddl | Tool and Resource | Both, honestly. So it ships as both. |
Tool run_query
Runs a read-only query and returns the rows.
Why a tool: the query is written fresh for each question. Nobody can list every possible query in advance, and only the AI knows what it needs to ask. A mid-conversation decision, which is what tools are for.
Resource schema://overview
A readable summary of the whole database.
Why a resource: there is exactly one, it needs no input, and it helps at the start of almost any conversation. Attach it once and the AI has the whole structure with no button-pressing at all.
Prompt audit_schema
Runs a full health check on the database: structure, keys, indexing, naming, sensitive data.
Why a prompt: a person deliberately starts a piece of work, and the value is in the wording. The saved version knows to ask about missing primary keys and columns that look like links but are not. Most people would not think to ask.
Tool find_sensitive_columns
Finds columns whose name suggests personal or secret data.
Why a tool: it looks like a resource, since it takes no input and has one answer. But the AI needs it mid-reasoning, when someone asks "is there anything private in here?". Note too that the description states its limit: names only, never values, so a column called NOTES full of email addresses is missed. Writing the limit down stops the AI overclaiming.
Three common mistakes
1. Making everything a tool
The most common mistake by a distance, because tools are the part everybody has heard of. The cost is real: every tool's name, description and inputs are sent on every request. Fifty tools taxes the AI's attention and your bill. Prefer a few sharp tools to many overlapping ones.
The tell: you cannot describe a situation where the AI should press it.
2. Making something a resource when the AI has to choose the input
A person attaches resources from a list. If using the thing means working out an input first, and only the AI can work it out, a resource cannot carry it. That is a tool in the wrong hat.
The tell: you wish the attachment picker could ask a question.
3. Writing prompts that try to do the work
A prompt returns words. It fetches, computes and changes nothing. Forget that and you write prompts assuming data they were never given. A good prompt describes the plan and names the tools, as explain_table does when it says to call describe_table first.
The tell: your prompt discusses results it could not have seen.
Last one: you want the AI to be able to fetch one specific customer's full record whenever it decides it needs it, by their account number. Tool, resource, or prompt?
This server can change data, so it has guardrails
Everything so far has been reading. The server can also add, update and delete rows. Different risk, so the safety design is part of the lesson rather than an afterthought.
Five controls, in the project's order of importance:
| Control | What it does | What it stops |
|---|---|---|
| 1. Schema lock | Pinned to one throwaway database; any statement naming a different one is refused. | Reaching anything else on the server. |
| 2. One statement per call | Multi-statement execution is off and a stray semicolon is refused. | A second statement riding along behind a legitimate one. |
| 3. Separate doors | The read tool refuses to write; the write tool refuses to only read. | Either tool being talked into the other's job. |
| 4. Row cap | A ceiling on rows returned, and truncation is reported honestly. | A million rows drowning the conversation. |
| 5. Audit log | Every statement recorded, readable back through a tool. | "What did you just do to my database?" going unanswered. |
These are not a substitute for proper database permissions. The project's own words: defense in depth, not the defense.
The bug that is worth the detour
The first control, the database lock, was broken in its first version.
A dotted name in SQL is ambiguous. Sometimes a.b is schema.table, which is what needed blocking. Far more often it is alias.column: SELECT c.NAME FROM CUSTOMERS c.
The first version could not tell them apart. It saw a dot, assumed the worst, refused, and so broke every join in the server. Perfectly secure, completely useless.
The fix was to stop inferring from shape and compare against reality: read the actual database names off the server, and reject a qualifier only when it names a real database that is not ours. An alias like c is not a database, so it passes.
The lesson: security has to be precise, not merely strict. A control that blocks legitimate work gets switched off, and then it protects nothing.
What to remember, and where to go next
Remember only the table below and this was worth the time.
| Building block | Who decides | Comparison | Example from the project |
|---|---|---|---|
| Tool | the AI, on its own | a button it can press | describe_table |
| Resource | a person attaches it | a file on an email | schema://overview |
| Prompt | a person picks it | a saved recipe | explain_table |
Four things to carry away
- The description is the instruction. It is all the AI has, so write it for the AI.
- Fewer, sharper tools beat many overlapping ones. Every tool costs attention on every request.
- The same content can be offered two ways, because the real question is who decides to bring it in.
- If it can change data, design the guardrails deliberately, and make it account for what it did.
Where to go next
- Read the project this was built from: roughly 1,100 lines, one building block per file, comments explaining why rather than what. Start with
server.py, about ten meaningful lines, thentools.py. - Read its tool descriptions and ask, for each, whether you could tell from the description alone when to press it.
- Then the exercise this was all building towards: take a feature somebody wants and say whether it should be a tool, a resource, or a prompt, and why.