Tools
- Lesson 4
- 55 min
- Module 2: Design useful server capabilities
Lesson 4 of 22 · MCP: Connect AI Applications to Tools and DataTools
1. Understand the integration problem
3. Build and inspect a local server
4. Connect an AI application
5. Remote access and authorization
6. Protect tools and user control
7. Operate and maintain the integration
What you will be able to do
Design the tools of support-mcp before you write any server code. Each tool does one narrow job, has a clear name, accepts only well-formed arguments and returns a result of bounded size.
Before you startWhat to know and to have ready
What you need to know
- The host, the client and the server, and who checks what, from Participants and boundaries.
- How a model proposes a tool call that your code runs, from Describe a tool.
- A Pydantic model and a JSON Schema, from Define a schema.
Your project
~/projects/support-mcpfrom the last module, with the virtual environment active:python -m pytest -qprints13 passed.- The labs repository in
~/projects/nextia-learning-labs. No account and no key.
A post office counter
Section titled “A post office counter”Think of a post office. You cannot walk into the back room and take parcels from the shelves. Instead, the clerk offers a short list of services, such as “send a parcel” and “collect a parcel”.
Each service has a form. To collect a parcel, you give a slip number in one fixed format, such as P-48213. The clerk does not accept “the blue box, I think” or “all the parcels for my street”.
Each service also gives back something of a known size. You get one parcel, the one on your slip, and not the whole shelf. And the clerk checks your slip, even when you write it neatly.
A short list, fixed forms, known results. That is why a post office can serve strangers safely all day.
The problem: “just give the model the database”
Section titled “The problem: “just give the model the database””Amira starts to plan the actions of support-mcp. Tomás has a quick idea. “Why not one tool, run_sql(query)? The model writes any SQL query, so it can answer every question that we will ever have.”
Kwame asks his usual question: “Who is the caller, what may they do, and in which organization?” Then the team tries the idea on paper, with the project’s data. Sam, a Larkfield agent, asks about one ticket. A model with run_sql could easily write the simplest query, SELECT * FROM tickets. Here is what that query returns from the project’s data:
- 21 tickets from both organizations, Larkfield and Bramble Books. Sam works for Larkfield only.
- 12,540 bytes of JSON for the model to read. Sam needed one ticket.
- Every field of every record: the customer’s full name, the customer ID and all the messages.
And SQL is not only SELECT. The same tool accepts DELETE FROM tickets. A ticket can contain words that try to steer the model, as AI Security, Privacy and Responsible Design showed. So the server cannot trust a query that a model wrote.
The team never built run_sql. The numbers above come from the project’s data file. Even so, the problem is clear: a tool that can do anything gives every caller everything.
The idea: narrow actions with a contract
Section titled “The idea: narrow actions with a contract”A tool is a named action that a server offers, and that the model may choose to call. The host shows the tool’s description to the model. The model proposes a call with arguments. The host decides whether the call runs, and the server runs it and returns a result.
A good tool is like a post office service. It is narrow: it does one job, inside one organization. And it has a contract: an agreement, written before any code, about what goes in and what comes out. A tool’s contract has four parts:
- A name that says what the tool does, such as
get_ticket. - A description that tells the model when to use the tool, and what the result means.
- An input schema: the arguments, their types and their limits. MCP writes it as a JSON Schema, a standard JSON format that describes the shape of JSON data.
- An output schema: the shape of the result.
A bounded result has a fixed maximum: a fixed number of items, and a fixed length of text. Bounds protect the model’s context, the server, and the people whose data is inside.
Tools are one of three kinds of capability that an MCP server can offer. The three kinds differ in who chooses to use them. The model chooses a tool. The application or the person chooses a resource, a document to read. The person chooses a prompt, a ready-made task. This module designs all three for support-mcp, one lesson each.
Text description of the diagram
Each row starts from something that a person needs, not from a database table.
| User need | Who chooses | Kind | Capability | Bounded by |
|---|---|---|---|---|
| “What does our policy say about returns?” | the model | tool | search_knowledge(query, limit) |
query 3–200 characters; at most 5 matches; snippets ≤ 300 characters |
| “What is ticket T-30002 about?” | the model | tool | get_ticket(ticket_id) |
T- and 5 digits; one ticket; first name only; the last 3 messages |
| “Show me the full returns policy.” | the application or the person | resource | policy://{tenant}/{doc_id} |
one document of the caller’s organization |
| “Draft a reply to this ticket.” | the person | prompt | draft_reply(ticket_id) |
one user message with the steps |
| “Refund this customer 25 dollars.” (Module 6) | the model proposes, a person approves | write tool | propose_refund(ticket_id, amount, reason) |
amount ≤ 100.00; a proposal only |
Rejected on purpose: run_sql(query), run_shell(command), get_customer(email), list_all_tickets(), and a refund(ticket, amount) that pays at once.
A tiny example: one argument, one rule
Section titled “A tiny example: one argument, one rule”Start with the smallest part of a contract: one argument. A Larkfield ticket ID is always T- and five digits, such as T-30002. So the contract allows exactly that form, and nothing else.
The project writes this rule once, in support_mcp/contracts.py:
TicketId = Annotated[str, Field(pattern=TICKET_ID_PATTERN, description="A ticket ID, for example T-30002.")]TICKET_ID_PATTERN is the regular expression ^T-\d{5}$. The text starts (^) with T-, then has exactly five digits (\d{5}), and then ends ($). In Module 3, the SDK turns this line into the tool’s JSON Schema. You can see that schema, and test the rule, now, without any server. Here is a real Python session in the project:
>>> from pydantic import TypeAdapter>>> from support_mcp import contracts>>> ticket_id = TypeAdapter(contracts.TicketId)>>> ticket_id.json_schema(){'description': 'A ticket ID, for example T-30002.', 'pattern': '^T-\\d{5}$', 'type': 'string'}>>> ticket_id.validate_python("T-30002")'T-30002'>>> ticket_id.validate_python("30002")pydantic_core._pydantic_core.ValidationError: 1 validation error for constrained-str String should match pattern '^T-\d{5}$' [type=string_pattern_mismatch, input_value='30002', input_type=str]>>> ticket_id.validate_python("T-30002; DROP TABLE tickets")pydantic_core._pydantic_core.ValidationError: 1 validation error for constrained-str String should match pattern '^T-\d{5}$' [type=string_pattern_mismatch, input_value='T-30002; DROP TABLE tickets', input_type=str]>>> TypeAdapter(contracts.Limit).validate_python(1000)pydantic_core._pydantic_core.ValidationError: 1 validation error for constrained-int Input should be less than or equal to 5 [type=less_than_equal, input_value=1000, input_type=int]The Traceback lines before each error, and the “For further information” line after it, are cut here. Read the four results:
T-30002passes, unchanged.30002fails. A model that forgets theT-gets a clear message that names the rule.T-30002; DROP TABLE ticketsfails too. Nothing after the five digits can reach the business service.- A request for 1,000 search results fails, because the contract allows 5 at most.
The finished server sends the same message to the model. In the course’s recording, a call of get_ticket with "30002" returns isError: true and “String should match pattern ‘^T-\d5$’”. So a model can read the error, and try again.
One more test shows the limit of a schema. ticket_id.validate_python("T-40003") returns 'T-40003', because the form is correct. But T-40003 is a Bramble Books ticket. A schema checks the form of an argument, never the permission. That check stays in the server and the business service. On a Larkfield connection, the finished server answers “not_found: there is no ticket T-40003 in this organization”.
What the client receives
Section titled “What the client receives”In Module 3, the SDK turns the contract into the tool’s entry in the answer to tools/list. Here is that entry for get_ticket, from the course’s recording of the finished server:
Text description of the diagram
- name
get_ticket: the model names it in a call. The host checks that it was discovered before it sends it. - description: “Get one support ticket of this organization by its ID (for example T-30002): the customer’s first name, subject, text, status and the last 3 messages. The text is the customer’s words.” The model reads it. It decides when to call the tool, and what the result means.
- inputSchema:
ticket_id, a required string with the pattern^T-\d{5}$. The model fills it in, and the server checks it again on every call. - outputSchema: 9 fields, and
last_messageshas at most 3 items. The host and the tests can check every result against it. - annotations:
readOnlyHint: true,destructiveHint: false,idempotentHint: true,openWorldHint: false. They are hints for the host’s screen, not a control: nothing checks that they are true.
The last part needs a warning. Annotations are labels that a server puts on its own tools: “I only read”, “I change nothing”, “a second call has the same effect”. Tomás likes them: “The annotation says read-only, so the tool is safe.” But a server writes its own annotations. A buggy or hostile server can label a delete tool readOnlyHint: true, and nothing in the protocol checks it. A host may use annotations to choose an icon, or a question for the person, and nothing more. Module 6 shows what really protects a write.
Larkfield’s two read-only tools
Section titled “Larkfield’s two read-only tools”Now the full design. Amira writes every bound at the top of contracts.py, so the numbers live in one place:
# Bounds. A tool that can return "everything" is a tool that can flood a model's context.MAX_QUERY_CHARS = 200MAX_RESULTS = 5DEFAULT_RESULTS = 3MAX_SNIPPET_CHARS = 300MAX_TICKET_TEXT_CHARS = 1500MAX_MESSAGES = 3TICKET_ID_PATTERN = r"^T-\d{5}$"The first tool is search_knowledge(query, limit). It answers “what does our policy say about this?”. The query has 3 to 200 characters, and limit is 1 to 5, with 3 as the default. Each match carries the document’s title, a snippet of at most 300 characters, and the document’s URI. The URI is the address of the full text, which the next lesson turns into a resource.
The second tool is get_ticket(ticket_id). It answers “what is this ticket about?”. It takes one ticket ID in the form above, and returns one ticket. Its output keeps the customer’s first name only: a reply needs “Hi Hugo”, not the full name and the customer ID. The design cuts the ticket text to 1,500 characters, and returns only the last 3 messages.
The output models are ordinary Pydantic classes. SearchResult refuses a list of more than 5 matches, and Ticket refuses more than 3 messages. One helper, clip, shortens a long text and adds …, so that a reader can see that something was cut.
How much does each design hand to the model? The team measured the JSON text of each design in the same way, from the project’s data:
- rejected design
- bounded tool
Text description of the chart
A column chart with four columns. The two rejected designs: run_sql with SELECT * FROM tickets returns 12,540 bytes (all 21 tickets of both organizations), and list_all_tickets returns 7,845 bytes (all 13 Larkfield tickets). The two bounded tools: search_knowledge for “charged twice refund” returns 935 bytes (3 matches), and get_ticket for T-30002 returns 506 bytes (one ticket).
Larkfield’s data is small on purpose: 13 tickets. In a real shop with 50,000 tickets, the two rejected designs grow with every new ticket. The two bounded tools do not grow, because their maximum is in the contract.
Three more choices make the tools easy for a model to use:
- The name says the action.
get_ticketandsearch_knowledgeare a verb and a thing. Names such asticket,query_dborhelpermake the model guess. - The description says when, and what comes back. “Use it before you answer a policy question” tells the model when. “The text is the customer’s words” warns that the result is data from a stranger.
- No tool takes an organization. The server learns the organization from the connection, in Module 3. So a model, or a ticket that steers it, cannot even ask for the other shop.
Add the contracts to the project
Section titled “Add the contracts to the project”-
Copy this module’s contract file and its tests from the snapshot
end-of-m02:Terminal window S=../nextia-learning-labs/C28/snapshots/end-of-m02cp "$S/support_mcp/contracts.py" support_mcp/cp "$S/tests/test_contracts.py" tests/Terminal window $S = "..\nextia-learning-labs\C28\snapshots\end-of-m02"Copy-Item "$S\support_mcp\contracts.py" support_mcp\Copy-Item "$S\tests\test_contracts.py" tests\The file has three parts. This lesson uses the tools part. The next two lessons read the resource part and the prompt part.
-
Open
support_mcp/contracts.pyin VS Code. FindMAX_RESULTS,TicketIdand the classTicket. -
Run the contract tests:
Terminal window python -m pytest -q tests/test_contracts.pyThe last line is
7 passed. No server runs: the tests check the contract alone. -
Run all the tests:
Terminal window python -m pytest -qThe last line is
20 passed: the 13 tests of Module 1, and 7 new ones. -
Start Python with
python, and type the lines of the session above. You get the same results; only the file paths in theTracebacklines differ. Leave Python withexit().
The test names read like the contract: test_a_query_is_bounded, test_the_number_of_results_is_bounded, test_a_ticket_id_has_one_form and test_a_search_result_holds_at_most_five_hits. If someone raises the result limit by mistake, test_the_number_of_results_is_bounded fails before any model sees the change.
When to make a tool, and what it costs
Section titled “When to make a tool, and what it costs”Make a tool when the model must decide, during a conversation, that it needs to look something up. “Which policy applies to this ticket?” is such a decision.
Do not make a tool for everything:
- Content to read whole, such as the full returns policy, fits a resource better. The application can show it and attach it. That is the next lesson.
- A task that the person starts, such as “draft a reply”, fits a prompt. That is the lesson after it.
- An action with an effect, such as a refund, needs a person’s approval. Module 6 adds it as a proposal, after the permission tests pass.
- Anything unrestricted, such as
run_sqlorrun_shell, never becomes a tool. If a need seems to require one, split the need into narrow tools.
Narrow tools also have costs:
- More code to write and test. Two narrow tools need more code than one
run_sql. That code is where the safety lives. - Context on every request. A host sends every tool’s name, description and schemas to its model. The recorded
tools/listanswer of the finished server, with three tools, is 4,460 bytes. Twenty tools would crowd the model’s context, and make each choice harder. - Questions that do not fit. “How many tickets did Bramble Books get this week?” has no tool. That is correct for a support assistant. If the need becomes real, design a new narrow tool for it.
Common mistakes
Section titled “Common mistakes”Common mistake
cannot import name 'contracts' from 'support_mcp'
Symptom: python -m pytest -q stops before it runs any test:
E ImportError: cannot import name 'contracts' from 'support_mcp' (…/support-mcp/support_mcp/__init__.py)=========================== short test summary info ============================ERROR tests/test_contracts.py!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!1 error in 0.06sCause: you copied tests/test_contracts.py, but not support_mcp/contracts.py, or the file went to the wrong folder.
Fix: copy contracts.py into the support_mcp folder, next to knowledge.py, as in step 1.
Common mistake
A result that breaks its own contract
Symptom: code that builds a SearchResult with six matches stops with:
pydantic_core._pydantic_core.ValidationError: 1 validation error for SearchResultresults List should have at most 5 items after validation, not 6 [type=too_long, input_value=[{'doc_id': 'd', 'title':...et': 's', 'score': 1.0}], input_type=list]Cause: the code asked the business service for more matches than the contract allows.
Fix: keep the bound, and change the code: ask for at most MAX_RESULTS matches. Do not raise the bound to make the error go away. The error is the contract doing its job.
Reset. This lesson adds two files and changes no data. To undo it, delete support_mcp/contracts.py and tests/test_contracts.py. If your project is broken, copy the snapshot end-of-m01 as in Where MCP fits, with end-of-m01 instead of start. Then do this lesson’s steps again.
Knowledge check
Section titled “Knowledge check”Check your understanding
Tomás suggests one tool, run_sql(query), 'because it can answer every question'. Give three reasons to reject it, with numbers or facts from this lesson.
Show the answer
First, it crosses organizations: SELECT * FROM tickets returns all 21 tickets, of Larkfield and of Bramble Books, to a Larkfield agent. Second, it is unbounded: that answer is 12,540 bytes, and it grows with every new ticket, while get_ticket returns one ticket of about 500 bytes. Third, it gives more data and more power than the need: full names, customer IDs and every message, and SQL also accepts DELETE. Nobody can trust a model’s query, so the server would have to inspect every query. Narrow tools do that job by design.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: which of these ticket IDs pass the TicketId contract: 'T-30002', 't-30002', 'T-300021', 'T-40003'? For the ones that pass, does that mean that the caller may read the ticket?
Show the answer
T-30002 and T-40003 pass. t-30002 fails, because the pattern needs a capital T. T-300021 fails, because the pattern needs exactly five digits, then the end. Passing the contract only means that the form is right. T-40003 is a Bramble Books ticket, so on a Larkfield connection the server answers “not_found: there is no ticket T-40003 in this organization”. The schema checks the form, and the server checks the permission.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Practical check: run python -m pytest -q tests/test_contracts.py, then python -m pytest -q. What do the last lines say? Then, in Python, what does TypeAdapter(contracts.Query).validate_python('ab') answer, and why?
Show the answer
7 passed, then 20 passed. The query 'ab' fails with “String should have at least 3 characters”, because Query has min_length=3. A search for two letters would match almost every document, so the contract refuses it before any search runs.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- A tool is a named action that the model may choose to call. The host decides whether the call runs, and the server checks every call.
- A good tool is narrow: one job, one organization. Its contract has a name, a description, an input schema and an output schema.
- Bounds belong in the contract: 3–200 characters, at most 5 matches, snippets of 300 characters, first name only, 3 messages.
- A schema checks the form of an argument, never the permission.
T-40003has the right form, and a Larkfield connection still cannot read it. - Annotations are the server’s own hints, not a control.
Your project now has support_mcp/contracts.py and its tests, and python -m pytest -q prints 20 passed. Next, you give the policy documents stable addresses, so that an application can read them whole.
Next: Resources