Do not hand an AI your database schema
There are two ways to connect an AI to a database. Only one of them survives contact with a real organisation.
An AI assistant will happily write you a SQL query. It has read a great deal of code, so it knows the shape of an answer. It has never seen your database, so it guesses table and column names, and gets them wrong.
The obvious fix is to show it the schema: let it list tables, read column types, follow foreign keys. That works in a demo. In production it creates three new problems.
| Problem | What it looks like |
|---|---|
| Privacy | To describe the students table you have to name its columns, and that list includes government identifiers, visa status and contact details. The model now knows they exist and can ask for them. |
| Accuracy | Answering "how many international graduates are in Computer Science?" means joining three tables correctly. Models get complex joins wrong, confidently. |
| Coupling | Every prompt, saved query and habit the model forms is pinned to your physical tables. Rename a column and the assistant breaks. |
The alternative is a semantic layer: the model is given business entities with plain-English fields, and never learns that a table exists at all.
Diagram scrolls sideways on a narrow screen.
This tutorial builds the second design, using a working MCP server over an NYU academic database. Every snippet is copied from it unchanged.
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 10 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, and on this server it never sees any SQL.
The real list the example server announces, as printed by running it. Square brackets mark optional filters:
list_entities (no arguments)
List the business entities this server can answer questions about.
get_courses ([department], [academic_level], [open_to_international], [enrollment_status], [limit])
Search the course catalogue.
get_student_headcount ([department], [degree_level], [residency], [limit])
Student numbers per department, split by degree level and residency.
get_enrollments ([department], [term], [course], [limit])
How many students are enrolled on each course, by term.
get_research_output ([department], [year], [limit])
Publication counts and citation totals per department and year.
get_awards ([department], [year], [limit])
Awards granted, aggregated by department, year and award name.
get_finalists ([competition], [year], [department], [limit])
Competition finalists, counted by competition, year and department.
get_staffing ([department], [role], [limit])
Staff headcount per department, by role.
show_activity_log ([limit])
Show what this server has been asked to do, newest first.
set_course_enrollment_status (course_title, department_name, status)
Open, waitlist or close enrolment on one course.
Read it as the AI does: each line is a labelled button, and each argument is a business word. Not one of them is a table, a column, or a query.
Grouped by blast radius
The project splits its tools by what happens if the AI gets it wrong, not by subsystem. That keeps the dangerous surface small and obvious to a reviewer.
| Read-only, cannot change anything | Can change data |
|---|---|
list_entitiesget_coursesget_student_headcountget_enrollmentsget_research_outputget_awardsget_finalistsget_staffingshow_activity_log |
set_course_enrollment_statusOne tool. One course at a time. One field. |
The anatomy of a real tool
get_courses, copied exactly from the project. Note what it does not contain: no SQL, no table name, no column name.
Tap any underlined part of the code to read what it does.
mcp_server/entities.py
def (
,
academic_level: str | None = None,
,
enrollment_status: str | None = None,
limit: int = 50,
) :
The point is how little there is, and how much of it is business vocabulary. The AI states what it wants; deciding how to fetch it is somebody else's job.
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.
Descriptions carry the limits too. get_student_headcount says "Returns counts only. Individual students are not available through this server, so do not attempt to list or identify them." Writing that down stops the AI from hunting for another route.
The project's own words, at the top of the file that holds the tools:
mcp_server/entities.py
1. The description is a prompt.
It is the only thing the model reads when deciding. 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 which graduate Computer Science courses international students can take. The AI sees get_courses on its list and works out which filters express that. Nobody told it to. It chose.
2. The request goes out
Tool name plus the filters it chose: department is Computer Science, academic_level is graduate, open_to_international is yes.
3. Real data comes back
The genuine reply from the example server:
{
"entity": "courses",
"rows": [
{
"course_title": "Distributed Systems",
"academic_level": "graduate",
"department_name": "Computer Science",
"college_name": "Tandon School of Engineering",
"credits": 3,
"open_to_international": "yes",
"enrollment_status": "open"
},
{
"course_title": "Machine Learning",
"academic_level": "graduate",
"department_name": "Computer Science",
"college_name": "Tandon School of Engineering",
"credits": 3,
"open_to_international": "yes",
"enrollment_status": "waitlist"
}
],
"row_count": 2,
"truncated": false
}
Read what came back, and then read what did not. There is no course_id, no dept_id, no hint that a join happened. The answer is complete and the schema is still private.
Behind it, a third graduate course exists that the AI was not shown: Secure Systems Practicum is restricted, so filtering on open_to_international excluded it. The filter did that, not the model.
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: the semantic dictionary. It is the business vocabulary, in Markdown, and it is what a person attaches at the start of a conversation.
Tap any underlined part to read what it does.
mcp_server/resources.py
@mcp.resource(
,
,
,
,
)
def () -> str:
"""The business dictionary.
This is the resource to reach for at the start of a conversation:
attach it once and the model knows the whole vocabulary, with no tool
calls at all. Note what it contains and what it does not: business
meaning, and nothing about how any of it is stored.
"""
And part of what a person actually attaches when they pick it, from a real run, trimmed to one entity:
# NYU Semantic Dictionary
The vocabulary this server understands. Every entity below is a business
concept, not a database table.
... six other entities ...
## Entity: student_headcount
How many students each department has, split by degree level and residency.
- **department_name**: The academic unit.
- **college_name**: The school it belongs to.
- **degree_level**: Undergraduate or graduate.
- **residency_status**: Domestic or international.
- **total_students**: Number of students in that group.
Filter by: `department`, `degree_level`, `residency`
_Counts only. No student is identifiable, by design._
Compare that with what a schema dump of the same data would say: students, student_id, ssn, passport_num, visa_type, dept_id. Same underlying rows. One version teaches the model the business; the other teaches it your internals.
The dictionary ends by stating its own limits, which is the part people forget to write:
## Deliberately not available
Student and staff names, email addresses, government identifiers, visa numbers
and salaries are held in the underlying systems and are never exposed here.
Neither are table names, column types or keys. Questions needing any of those
cannot be answered by this server, and should be refused rather than guessed at.
Fixed addresses and fill-in-the-blank addresses
Every resource has a URI so a client can ask for it by name. The nyu:// prefix 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.
nyu://semantic/entities text/markdown Semantic dictionary
nyu://semantic/catalog application/json Entity catalogue
nyu://semantic/course-catalog application/json Course catalogue
nyu://semantic/glossary text/markdown Glossary
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.
nyu://semantic/entity/{name} text/markdown Entity definition
nyu://semantic/department/{name} application/json Department profile
Why not list every department as its own fixed address? A real university has hundreds. The pattern says "give me the name and I will assemble that one".
Note what the department profile does: it gathers courses, headcount, staffing and research output into one attachment. A resource does not have to mirror a single query. It can be the document a person actually wants.
Both lists above are exactly what the server reports. Fixed URIs and templates 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 whole course catalogue. It offers that two ways.
Tool get_courses
The AI calls it mid-conversation, with whatever filters the question implies.
Resource nyu://semantic/course-catalog
A person attaches the whole catalogue at the start, because today's conversation is about course planning.
Diagram scrolls sideways on a narrow screen.
Same content, same view underneath, deliberately:
mcp_server/resources.py
Deliberately the same content as the get_courses TOOL. Same answer, two
access paths, because the two primitives answer different questions:
the tool is for the model to call when it decides it needs it; this is
for a human to attach before the conversation starts.
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 current enrolment numbers whenever a conversation happens to touch a course. 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:
...
)
When a person picks it and types Physics, this is the text handed to the AI, exactly as the real server produced it:
Give a rounded review of the Physics department. Work through it in this order, using the available tools:
1. Size and mix. Call get_student_headcount for Physics. How large is it, and how does the domestic to international balance compare with what you would expect?
2. Teaching. Call get_courses for Physics. What is offered at each level, and is anything waitlisted or closed?
3. Demand. Call get_enrollments for Physics. Which courses are heavily subscribed, and which are quiet?
4. Staffing. Call get_staffing for Physics. Does the mix of roles look proportionate to the student numbers from step 1?
5. Research. Call get_research_output for Physics.
Finish with the two things you would look into first, most important first, and say what evidence would settle each one.
Work only from what the tools return. Do not ask for database schema, tables or columns: none are available, and none are needed.
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 a boundary: do not go looking for schema.
The prompt never touched the database. It handed over a better question and named the buttons to press. 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 "review" 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 the domestic to international balance or
about waitlisted courses.
* Discovery a menu entry gets used; a README example does not.
The middle one is the prize. Somebody knows what a good departmental review looks like; a prompt is how that stops living in one person's head.
A rule this project follows strictly: a prompt never names a view, table or column. Naming one would hand the model the very schema detail the semantic layer removes, and would break the day that view is renamed. Prompts name tools and let the layer decide the rest.
Check yourself: your deans keep asking the AI for a departmental review, and everyone words it differently, so the reviews are not comparable. 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 course catalogue.
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 |
|---|---|---|
get_student_headcount | Tool | Who picks the filters? Only the AI knows, and only mid-conversation. |
nyu://semantic/entities | Resource | Could a person pick it off a list? Yes, there is exactly one. |
capacity_planning | Prompt | Is a person starting a piece of work? Yes, and the value is the wording. |
| the course catalogue | Tool and Resource | Both, honestly. So it ships as both. |
Tool get_student_headcount
Why a tool: the filters change with every question. "How big is Physics?" and "what is the international share of graduates?" hit the same entity with different parameters, and only the AI knows which, in the moment.
The subtle part: the description states the limit out loud, that this returns counts and individuals are not available. Without that sentence the AI treats the absence as an obstacle and goes looking for another tool. With it, it explains the boundary to the user instead.
Resource nyu://semantic/entities
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 business vocabulary with no tool calls at all.
It is the semantic layer's answer to "here is the schema". Same role in the conversation, none of the exposure.
Prompt capacity_planning
Why a prompt: a person deliberately starts a piece of work, and the value is in the wording. The saved version knows to cross-reference enrolment against enrolment status, so a waitlisted course with high demand is separated from an open course nobody wants.
It also draws a line: it tells the AI to recommend opening or closing a course, and not to make the change unless asked. Judgement about when to stop belongs in the prompt.
Tool set_course_enrollment_status
Why a tool, and why only one: changing data is an action the AI takes, so it has to be a tool. But notice how narrow it is. One course, one field, three allowed values, named the way a person would name them.
Compare it with the tempting general version, execute_statement(sql). That one can do anything, which means the safety argument has to cover everything. This one can do one thing, so the argument is short enough to actually check.
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. Prefer a few sharp entity 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 a filter 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. Letting your physical schema leak through any of the three
This is the one specific to a semantic layer, and it leaks in all three shapes: a tool that takes a table name, a resource that publishes column types, a prompt that tells the model to query v_semantic_department_headcount by name.
That last one is the easiest to write by accident, because it feels helpful. It is not: it teaches the model an internal name, and it breaks the day that view is renamed. Name the tool instead and let the layer decide the rest.
The tell: a model could reconstruct part of your schema from what you published.
Last one: someone asks for a tool that returns a named list of every student on a course, so the AI can answer "who is enrolled?". What is the right response?
The semantic layer is the safety design
Everything so far has been reading. The server can also change one thing: whether a course is open, waitlisted or closed. That is a different risk, so the design is explicit.
Six controls, in order of importance:
| Control | What it does | What it stops |
|---|---|---|
| 1. Semantic layer only | Every query targets a v_semantic_* view from the registry. A view not on the list is refused before a connection opens. | The raw tables being reachable at all, not merely undocumented. |
| 2. No SQL from callers | There is no run_query and no execute_statement. Callers name an entity and pass filters. | Injection, and the model needing to know the schema in the first place. |
| 3. Filters are an allowlist | Filter names map to columns through the registry; values are bound parameters. Unknown filters and invalid values are rejected by name. | A caller reaching a column nobody chose to expose. |
| 4. PII guard at startup | Every view is read at boot and checked against a list of forbidden fields. | A careless view edit quietly leaking PII months later. |
| 5. Row cap | A ceiling on rows, with truncation reported rather than hidden. | One answer drowning the conversation. |
| 6. Audit log of intent | Records the business action and its parameters. No SQL text. | The log itself becoming a schema leak. |
These are not a substitute for database permissions. Point the server at a MySQL user granted SELECT on the views and nothing else. The project's own words: defense in depth, not the defense.
Control 4 is the one worth dwelling on
The first five controls protect against a careless caller. The fourth protects against a careless us, and that is the failure mode people forget.
A semantic view is just SQL. In a year, somebody adds a column to v_semantic_course_catalog because a report needed it, joins in the students table to get it, and ships. Nothing errors. Every tool keeps working. The layer is now leaking, and nobody finds out until a model repeats a passport number back to a user.
So the server reads every exposed view at startup and fails if one has grown a forbidden field. Adding ssn to that view produces this, immediately, at boot:
RuntimeError: Semantic view `v_semantic_course_catalog` exposes ['ssn'],
which the semantic layer promises to hide. Fix the view.
The same check also catches the opposite mistake: an entity that claims a field its view does not actually have. Both are the same class of bug, a registry and a database drifting apart.
The lesson: a boundary that is only a convention is not a boundary. Make it something that fails loudly, at the earliest possible moment, in front of the person who broke it.
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 |
|---|---|---|---|
| Tool | the AI, on its own | a button it can press | get_courses |
| Resource | a person attaches it | a file on an email | nyu://semantic/entities |
| Prompt | a person picks it | a saved recipe | analyze_department |
Five things to carry away
- The description is the instruction. It is all the AI has, so write it for the AI, limits included.
- Expose business entities, not tables. A model that never learns your schema cannot leak it, misjoin it, or be broken by refactoring it.
- 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, make it narrow enough that the safety argument is short enough to check.
Where to go next
- Read the project this was built from. One building block per file, comments explaining why rather than what. Start with
server.py, about ten meaningful lines, thensemantic.py, which is the registry everything else is generated from. - Look at
scripts/create_nyu_db.sqlto see the two layers side by side: raw tables holding the real data, and thev_semantic_*views that are the only thing the server may read. - 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. Then ask the second question, which entity does it belong to, and what must it never expose?