Client integration
- Lesson 11
- 50 min
- Module 4: Connect an AI application
Lesson 11 of 22 · MCP: Connect AI Applications to Tools and DataClient integration
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
Build the AI application that uses Larkfield’s server. You add a small host with one MCP client and a mock model, so every run gives the same messages at no cost. You keep all model-specific code in one file. Then, if you want, you swap in a real model and see what changes and what does not.
Before you startWhat to know and to have ready
What you need to know
- Host, client and server, from Participants and boundaries.
- What a tool call is, from Describe a tool in the course Building Reliable Applications with LLM APIs.
- How a host starts a stdio server with the SDK’s client, from Local transport.
Your project
~/projects/support-mcpafter Inspect behaviour, with the virtual environment active:python -m pytest -qprints40 passed.- The labs repository in
~/projects/nextia-learning-labs. Get the newest files first:git -C nextia-learning-labs pull. - No account and no key. A real model is optional, at the end of the lesson.
A rehearsal with a stand-in
Section titled “A rehearsal with a stand-in”A theatre crew prepares a new play. The lights must change when the actor says “Look, the storm!”. The star actor is expensive, and only comes for the last week. So the crew works with a stand-in, who reads the lines from the script, the same way every time.
The stand-in is not as good as the star. That does not matter, because the crew is not testing the acting. They are testing the cues, the lights and the doors. When a cue fails, they know it is the crew’s fault, because the stand-in never changes a line.
When the star arrives, the stage does not change. Only the person on the spot changes, and the star may say a line a little differently.
The problem: a real model in every test
Section titled “The problem: a real model in every test”Amira has a working server. Now the help desk needs an AI application that uses it. Sam types “Draft a reply to ticket T-30002”, and the assistant looks up the ticket and the policies. Amira could connect a real model at once. Two things make that a bad first step.
First, a real model does not give the same answer twice. The course team asked the same question twice, with the same model, the same host and the same server. In the first run, the model asked for two tools at once, and named the policy “Manual refunds and goodwill credits”. In the second run, it made three calls, chose its own search words, and named the “Refunds and payments policy”. If the second run had failed, Amira could not tell whether her code or the model was the cause. A real model also needs an account, a key, a network and money.
Second, every model provider has its own format. An MCP server describes a tool with a name, a description and an inputSchema. The chat API that the course uses wants the same tool as {"type": "function", "function": {"name", "description", "parameters"}}. If this conversion is spread across the application, a change of provider means a change everywhere.
The idea: one host, one client, one model interface
Section titled “The idea: one host, one client, one model interface”The host is the AI application: the program that the person uses. It owns the conversation, and it decides what runs. Inside it, an MCP client keeps one connection to one server. You met both names in Participants and boundaries. This lesson adds the third part, the model, behind one small interface:
decide(messages, tools) -> DecisionThe host gives the model the conversation so far and the tools, in the provider’s format. The model gives back a decision: either tool calls to make, or a final answer. Nothing else in the host knows which model it talks to. The code that knows a provider’s format is the model adapter, and it lives in one file, host/models.py.
The first adapter is a mock model: a stand-in that follows fixed rules instead of thinking. “Mock” means an imitation, made for tests. Larkfield’s mock model has three rules. If the question has a ticket ID, it asks for get_ticket. If it has not searched yet, it asks for search_knowledge. Otherwise, it writes an answer from the results. Like the stand-in, it is not clever, but it never changes a line. So when something breaks, the cause is in your host, your client or your server.
Text description of the diagram
Sam types a question into the host, python -m host. The host has four parts:
__main__.pyreads the question and the options. It asks the person when the host needs a yes.app.pyholds the loop and the host’s checks: discover, ask the model, check, call, and bound the result.models.pyholdsdecide(messages, tools), which returns aDecision. It has two models.MockModelhas fixed rules and no network.OpenAICompatibleModeluses your own key, and talks HTTPS to a model provider’s API.- The MCP client is the SDK’s
Client, one for each server.StdioServerParameterstells it the command, the arguments and the environment (env=).
The client talks JSON-RPC on stdin and stdout to support-mcp, a child process with SUPPORT_USER and SUPPORT_TENANT in its environment. Change the model, and only models.py changes. Change the server, and only the client’s settings change. The loop and the checks in app.py stay the same.
A tiny example: the mock model decides
Section titled “A tiny example: the mock model decides”Before you connect anything, watch the mock model make two decisions. The tools here have empty descriptions, because the mock model reads only their names. Read it now; after the steps below, you can paste it into python in your project folder:
from host.models import MockModel
tools = [{"type": "function", "function": {"name": "get_ticket", "description": "", "parameters": {}}}, {"type": "function", "function": {"name": "search_knowledge", "description": "", "parameters": {}}}]messages = [{"role": "system", "content": "You are the help desk's assistant."}, {"role": "user", "content": "Draft a reply to ticket T-30002"}]model = MockModel()print(model.decide(messages, tools))messages.append({"role": "tool", "tool_call_id": "call_1", "content": '{"tool": "get_ticket", "result": {"ticket_id": "T-30002", "status": "open", "subject": "Charged twice for order LK-182074"}}'})print(model.decide(messages, tools))Decision(tool_calls=[ToolCall(id='call_1', name='get_ticket', arguments={'ticket_id': 'T-30002'})], answer=None, usage={}, raw=None)Decision(tool_calls=[ToolCall(id='call_2', name='search_knowledge', arguments={'query': 'Charged twice for order LK-182074'})], answer=None, usage={}, raw=None)Read the two lines. The first time, the question holds T-30002, so the rule “ticket ID → get_ticket” fires. Then the conversation has a ticket result but no search yet, so the mock model searches with the ticket’s subject. It does not call anything itself: a Decision is only a request, written as data. The host decides what to do with it, in the next lesson. usage is empty because no tokens were used, and raw is empty because no provider answered.
Larkfield: add the host
Section titled “Larkfield: add the host”-
In
~/projects/support-mcp, with the virtual environment active, copy the host from the snapshotend-of-m04:Terminal window S=../nextia-learning-labs/C28/snapshots/end-of-m04mkdir -p hostcp "$S"/host/{__init__,models,app,__main__}.py host/cp "$S/.env.example" "$S/README.md" .Terminal window $S = "..\nextia-learning-labs\C28\snapshots\end-of-m04"New-Item -ItemType Directory -Force host | Out-Nullforeach ($f in "__init__", "models", "app", "__main__") { Copy-Item "$S\host\$f.py" host\ }Copy-Item "$S\.env.example", "$S\README.md" .The host needs no new package. It uses the SDK that you installed in Module 3, and Python’s own
urllibfor a real model. -
Ask the host one question:
Terminal window python -m host "Draft a reply to ticket T-30002"[host] discovered 2 tools: search_knowledge, get_ticket {"protocol": "2026-07-28", "server": "support-mcp"}[model] wants get_ticket {"arguments": {"ticket_id": "T-30002"}}[host] read-only tool on the allow-list: run it {"decision": "auto"}{"ts": "…", "event": "tool_call", "user": "usr-sam", "tenant": "larkfield", "role": "staff", "client": "local", "tool": "get_ticket", "outcome": "ok", "ms": 0.06}[server] result: 558 characters {"ms": 24.2, "clipped": false}[model] wants search_knowledge {"arguments": {"query": "Charged twice for order LK-182074"}}[host] read-only tool on the allow-list: run it {"decision": "auto"}{"ts": "…", "event": "tool_call", "user": "usr-sam", "tenant": "larkfield", "role": "staff", "client": "local", "tool": "search_knowledge", "outcome": "ok", "results": 3, "ms": 0.24}[server] result: 1025 characters {"ms": 2.5, "clipped": false}[model] final answerTicket T-30002 (open): Charged twice for order LK-182074.From "Refunds and payments policy" (policy://larkfield/refunds-and-payments): … Refunds to a gift card appear at once. ## Double charges If you were charged twice for the same order, we refund the extra charge …Your
msvalues differ. The whole run took about 0.8 seconds on the course team’s laptop. -
Check that nothing else changed:
python -m pytest -qstill prints40 passed. The host’s tests come in Results and failures.
Each line starts with who acted. [host] lines are decisions of the host. [model] lines are the model’s decisions, here the mock model’s. [server] lines are what came back through the MCP client. The JSON lines in between are the server’s own logs. The SDK’s client passes the server’s stderr through to your terminal, as Local transport showed. The last two lines are the answer: the mock model copies the best search result, with its title and its URI.
The host found two tools and the protocol 2026-07-28 without any handshake: the SDK’s Client sent server/discover and tools/list. The next lesson follows those messages one by one.
Read the host
Section titled “Read the host”Start with the interface in host/models.py. A decision is two small data classes:
@dataclassclass ToolCall: id: str name: str arguments: dict
@dataclassclass Decision: tool_calls: list[ToolCall] = field(default_factory=list) answer: str | None = None usage: dict = field(default_factory=dict) raw: dict | None = NoneEvery model class has one method, decide(messages, tools), that returns a Decision. MockModel.decide follows its three rules, with a regular expression that finds ticket IDs. OpenAICompatibleModel.decide sends the same messages and tools to a chat completions API with urllib. Then it turns the provider’s tool_calls back into ToolCall objects. That is the whole provider-specific part: about 40 lines. make_model(name) picks the class from the --model option: mock (the default) or openai.
Now the MCP side, in host/app.py. The host says how to start the server:
def server_params(user: str, tenant: str, scopes: str | None = None) -> StdioServerParameters: """How the host starts the local server: a child process that speaks MCP on stdin/stdout.""" env = {**os.environ, "SUPPORT_USER": user, "SUPPORT_TENANT": tenant, "PYTHONUNBUFFERED": "1"} if scopes: env["SUPPORT_SCOPES"] = scopes return StdioServerParameters(command=sys.executable, args=["-m", "support_mcp"], env=env)This is the env= from Local transport. Without it, SDK 2.3.0 passes only six variables to the server: HOME, LOGNAME, PATH, SHELL, TERM and USER. The server would never see the person and the organization. The options --user and --tenant fill them, with Sam at Larkfield as the default. PYTHONUNBUFFERED=1 makes the server write each log line at once.
Note that this host copies its whole environment, so the server would also see MODEL_API_KEY if you set it. Here, both programs are yours. For a server that you did not write, pass only what it needs. Credential boundaries returns to this.
__main__.py opens the connection with async with Client(app.server_params(...)) as client:. The client starts the child process, and stops it when the block ends. One host, one client, one server.
Last, the conversion between the two formats:
def tool_spec(tool) -> dict: """An MCP tool, as an OpenAI-style function definition for the model.""" return { "type": "function", "function": {"name": tool.name, "description": tool.description or "", "parameters": tool.input_schema}, }For get_ticket, the result holds the name and the one-line description that you fixed in Module 3. It also holds the input schema, with the pattern ^T-\d{5}$. The output schema and the annotations are not passed on, so the model never sees them. Both tools together come to 1,168 characters of tool definitions. A provider that wants another shape needs only another tool_spec and another adapter.
Optional: the same host with a real model
Section titled “Optional: the same host with a real model”You can skip this part. Nothing later in the course needs a real model.
You need a key for an OpenAI-compatible chat API, or a local model server with such an API. Then you can run the same host with it. Set a spending limit with your provider first. Then set three variables in your shell, and add --model openai:
export MODEL_BASE_URL="https://api.example.com/v1" # your provider's URL, ending in /v1export MODEL_NAME="your-model"read -rs MODEL_API_KEY && export MODEL_API_KEY # paste the key, then press Enterpython -m host "Draft a reply to ticket T-30002." --model openai$env:MODEL_BASE_URL = "https://api.example.com/v1" # your provider's URL, ending in /v1$env:MODEL_NAME = "your-model"$env:MODEL_API_KEY = Read-Host "Key" -MaskInputpython -m host "Draft a reply to ticket T-30002." --model openairead -rs and -MaskInput keep the key off the screen and out of your shell history. The adapter sends the key in two headers, api-key and Authorization: Azure reads the first one, and most other services read the second. Some models refuse to use tools unless you also set MODEL_REASONING_EFFORT=none. The course’s model was one of them.
The course team ran this with chat-small, a small model on Azure. This is the second run from the problem section, as the host printed it, without the server’s log lines:
[host] discovered 2 tools: search_knowledge, get_ticket {"protocol": "2026-07-28", "server": "support-mcp"}[model] wants get_ticket {"arguments": {"ticket_id": "T-30002"}}[host] read-only tool on the allow-list: run it {"decision": "auto"}[server] result: 558 characters {"ms": 43.6, "clipped": false}[model] wants search_knowledge {"arguments": {"query": "duplicate charge payment refund duplicate payments billing receipt screenshot processing", "limit": 3}}[host] read-only tool on the allow-list: run it {"decision": "auto"}[server] result: 1252 characters {"ms": 4.3, "clipped": false}[model] final answer
Hi Hugo,
I’m sorry you may have been charged twice for order LK-182074. Please send us a receipt or screenshot showing both $449.00 charges so we can verify them with our payments team. If we confirm a duplicate charge, Finance will refund the extra payment within 3 business days.
Policy used: **Refunds and payments policy**.Compare it with the mock run. The host’s lines are the same kind of lines, and so are the server’s results. Only the [model] lines changed: the real model chose its own search words and its own limit, and it wrote a real reply. The reply is grounded in the data: the ticket says 449.00 dollars, and the policy says “within 3 business days”.
Text description of the diagram
The same question, “Draft a reply to ticket T-30002”, in three runs:
| Mock model (rules) | chat-small, run A | chat-small, run B | |
|---|---|---|---|
| Model calls | 0 over the network | 2 | 3, one tool each |
| Tools asked for | get_ticket T-30002, then search_knowledge “Charged twice for order LK-182074” |
get_ticket T-30002 and search_knowledge “customer support response policy …” together, in the first call |
get_ticket T-30002, then search_knowledge “duplicate charge payment refund duplicate payments …” |
| Answer | the ticket’s line and the snippet of “Refunds and payments policy” | a reply to Hugo, naming “Manual refunds and goodwill credits” | a reply to Hugo, naming “Refunds and payments policy” |
| Tokens in / out | none | 532 + 1,053 / 61 + 80 | 352 + 554 + 877 / 21 + 31 + 76 |
The host, the MCP client and the server were the same. Run A was recorded with the course’s finished server, which also offers a third tool.
What did it cost? Run B used 1,783 tokens in and 128 out: about US$0.00024 at the prices that the course team paid. Over five runs of this question, the median was 3 model calls, 2,309 tokens in and 146 out. Each question cost US$0.0003 to 0.0004. One model call took a median of 1.2 seconds. All the real-model runs for this course, 70 calls, cost US$0.0074. Prices change: check your provider’s current page.
When a mock model is enough, and what it costs
Section titled “When a mock model is enough, and what it costs”Use the mock model for everything that is not about the model’s judgement: the connection, discovery, the host’s checks, failures and tests. It is free, it needs no network, and it gives the same messages every time. That makes a failing test mean something. The course uses it in every lesson, and the tests in Results and failures use it too.
Use a real model when the question is about the model. Does it pick the right tool? Does it write good search words? Does it follow the server’s instructions? Does it resist a ticket that tries to trick it? A mock cannot answer those questions.
The separation also has costs:
- A mock hides real behaviour. Run A asked for two tools in one decision: a parallel tool call. The mock never does that. Your host must handle it anyway, and the next lesson shows how.
- One more file to keep up to date. When a provider changes its format,
models.pychanges. That is the point: onlymodels.pychanges. - The interface is small on purpose.
decide()passes messages and tools, nothing more. A feature of one provider, such as streaming the answer word by word, needs its own work in the adapter.
Common mistakes
Section titled “Common mistakes”Common mistake
ValueError: unknown model 'nonsense': use mock or openai
Symptom: the host stops before it connects, and the last line of a long traceback is ValueError: unknown model 'nonsense': use mock or openai.
Cause: the value of --model is not one of the names that make_model() knows.
Fix: use --model mock (the default) or --model openai. If you add your own adapter, add its name to make_model() in host/models.py, the one file that knows the models.
Common mistake
KeyError: 'MODEL_BASE_URL'
Symptom: with --model openai, the traceback ends with KeyError: 'MODEL_BASE_URL'.
Cause: the variable is not set in this shell. The project reads the environment, not .env.example, and a new terminal starts without your earlier export.
Fix: set MODEL_BASE_URL, MODEL_NAME and MODEL_API_KEY in the same terminal, as above. Check the URL with echo $MODEL_BASE_URL (PowerShell: $env:MODEL_BASE_URL). Never print the key.
Reset. If your project is broken, copy the snapshot end-of-m04 as in Where MCP fits, with end-of-m04 instead of start. It also has this module’s tests.
Knowledge check
Section titled “Knowledge check”Check your understanding
Name the three parts inside Larkfield's host besides the command line, and say which one must change when the help desk moves to another model provider.
Show the answer
app.py holds the loop and the host’s checks. The MCP client is the SDK’s Client, with its StdioServerParameters. The model adapter is in models.py, behind decide(messages, tools) -> Decision. A new provider changes only models.py: a new adapter class, and its name in make_model(). If the provider wants tools in another shape, tool_spec changes too. The loop, the checks and the client stay the same.
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: Sam asks the mock model 'What is the return window?'. There is no ticket ID. What is the mock model's first decision, and what does it decide after the result comes back?
Show the answer
First, search_knowledge with the query What is the return window?. There is no ticket ID, so the rule for get_ticket does not fire, and nothing has been searched yet. After the result, all its rules are used, so it gives a final answer: the best result’s title, URI and snippet, From "Returns policy" (policy://larkfield/returns-policy): …. The host’s output shows one [model] wants search_knowledge line, then [model] final answer.
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 host with the question 'Draft a reply to ticket T-30002' and --user usr-omar. Omar is read-only at Larkfield. Which [host] and [model] lines appear, and what is in the server's log lines?
Show the answer
The same lines as for Sam: discovered 2 tools, wants get_ticket with T-30002, and the allow-list decision. Then wants search_knowledge with the ticket’s subject, and the final answer with the ticket line and the refunds policy. Read-only members may read tickets and search. The server’s log lines now say "user": "usr-omar" and "role": "read_only". The host passed SUPPORT_USER=usr-omar to the server with env=, and the server found the role in its own membership table.
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
- The host is the AI application. It holds one MCP client per server, and it talks to the model through one interface:
decide(messages, tools) -> Decision. - A mock model follows fixed rules. Use it to test the connection, the host’s checks and failures, free and the same every time.
- Keep provider-specific code in one model adapter (
models.py). A new provider changes that file, not the host or the client. - The host starts the server with
env=, because the SDK’s client passes only six variables by default. - A real model is optional. It makes its own choices: two real runs of the same question differed.
Your project now has host/, and python -m host answers with the mock model. Next, you follow every message of one request, and every decision that the host makes on the way.
Next: Discovery and execution