Resource limits and observability
- Lesson 21
- 50 min
- Module 7: Operate and maintain the integration
Lesson 21 of 22 · MCP: Connect AI Applications to Tools and DataResource limits and observability
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
Make the server safe to run all day, and easy to understand when something goes wrong. You list every limit and where it lives, and you find the ones that are missing. You give every refusal a log line without secrets. Then you watch one request on the wire, and measure what a local call costs.
Before you startWhat to know and to have ready
What you need to know
- The host’s time and size limits, from Results and failures.
- The refusals of the server (membership, scope, role), from Identity and permission.
- The contract and the matrix, from Contract and compatibility tests.
Your project
~/projects/support-mcp, with the virtual environment active:python -m pytest -qprints90 passed.- For the last part: Node.js 22.19 or later, for MCP Inspector.
A restaurant kitchen
Section titled “A restaurant kitchen”A busy kitchen runs on limits. A portion has a fixed size. Each oven has a timer. The kitchen takes only so many orders at once. Without these limits, one large table can stop the whole kitchen.
The kitchen also keeps order slips. Each slip says the table, the dish, the time and what happened: served, sent back, or “out of fish”. When a guest complains, the manager reads the slips and finds the answer in a minute. The slips never show the guest’s card number.
An MCP server needs both: limits, so that one caller cannot use everything, and slips, so that you can explain any answer later.
The problem: hidden limits and silent refusals
Section titled “The problem: hidden limits and silent refusals”The server already has many limits. They are in four places, and nobody has listed them. And some limits were never written by Amira at all. The course team sent larger and larger search queries over Streamable HTTP, with a valid token:
SDK client, query of 200 characters: isError=False, answer 234 characters, 23 msSDK client, query of 201 characters: isError=True, answer 327 characters, 1 msSDK client, query of 1,000,000 characters: isError=True, answer 327 characters, 9 msraw POST, body of 3,146,040 bytes: HTTP 200, '{"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"Error executing tool search_knowledge: 1 validation error for sear', 12 msraw POST, body of 5,243,192 bytes: HTTP 413, 'Request body too large', 3 msThe input schema refused every query over 200 characters, and the error stayed short. But the server read 3 MB before it refused. A body of 5 MB got HTTP 413 from a limit inside SDK 2.3.0: 4 MiB by default. That limit protects the server, and it was not in any of the course’s documents.
The second problem is worse. In Identity and permission, Tomás’s refused calls left no trace in the server’s log. The course team ran six kinds of refusal at the end of Module 6, and read the server’s stderr:
| Refusal | Answer to the client | Server log at the end of Module 6 |
|---|---|---|
| Tomás, no membership (stdio) | −32003 forbidden |
nothing |
Sam with knowledge:read only, get_ticket |
−32003 insufficient_scope |
nothing |
| Omar (read-only) proposes a refund | −32003 the role read_only cannot … |
tool_call, "outcome": "forbidden", no reason |
| Ines at Larkfield, no membership (HTTP) | −32003 forbidden |
nothing |
A token without tickets:read (HTTP) |
HTTP 403 | nothing |
| An expired token (HTTP) | HTTP 401 | token_refused, "reason": "expired" (twice) |
With an expired token, the host printed an 86-line traceback that ended in MCPError: Server returned an error response. The real reason, expired, was only in the server’s log. For four refusals, it was nowhere.
The idea: limits in layers, and one event per decision
Section titled “The idea: limits in layers, and one event per decision”Limits in layers. Each layer bounds what it understands best. The host bounds what it gives the model. The transport bounds the size of a request. The contract bounds each input and output. The business rule bounds what may happen. A limit that nobody has listed is a limit that nobody will check after the next upgrade.
Text description of the diagram
- The host: arguments ≤ 2,000 characters; time limit 10 s; result ≤ 4,000 characters, cut and labelled; at most 6 steps.
- The transport (HTTP): request body ≤ 4 MiB (HTTP 413, the SDK’s default); token lifetime 600 s; signing keys cached 10 minutes.
- The server’s contract: query 3–200 characters,
limit1–5; snippet ≤ 300, ticket text ≤ 1,500; last 3 messages; amount 0–10,000. - The business rule: a refund ≤ 100.00, else
over_limit; an owner approves outside the app. - Not built: requests per minute per person, calls at the same time per person, a time limit on the server’s own work, a log line for a 413.
The red row matters. Results and failures showed that a host’s timeout stops the waiting, not the work. Nothing stops one person from sending a thousand searches a minute. On a laptop, that is acceptable. A remote deployment puts these limits in front of the server, in a gateway or a reverse proxy. Write them down as open items, not as features.
One event per decision. A structured log writes each event as one JSON object on one line, with the same field names every time. A program can count and filter such lines; a person can read them. Each event answers four questions. Who: the user, organization, role and client. What: the tool or the URI. What happened: the outcome or the reason. How long: the time in milliseconds. It never holds a token, an email address or the text of a ticket.
Each refusal is a decision too, so each refusal gets one line, with its reason. The client still gets only −32003 and the message. The log gets the reason, for the people who run the server.
Add redaction and refusal events
Section titled “Add redaction and refusal events”First, see the danger. A tired developer adds a debug line that logs the request headers. Run it with the current logs.py. This debug line is constructed for the lesson:
python -c 'from idp import keys; from support_mcp import logs; t = keys.access_token("usr-sam", "knowledge:read tickets:read", "http://127.0.0.1:8000/mcp"); logs.get_logger().event("debug", headers={"authorization": "Bearer " + t, "x-support-tenant": "larkfield"}, note="call back grace@larkfield.example", reply="token " + t)'{"ts": "…", "event": "debug", "headers": {"authorization": "Bearer eyJhbGciOiJSUzI1…", "x-support-tenant": "larkfield"}, "note": "call back grace@larkfield.example", "reply": "token eyJhbGciOiJSUzI1…"}The PowerShell quoting of this command is different and was not tested. The output above is cut after 16 characters of the token. Your terminal shows the whole token: anybody who reads this log can call the server as Sam for 10 minutes. The email address is personal data, in plain text.
-
Copy this lesson’s files from the snapshot
end-of-m07:Terminal window S=../nextia-learning-labs/C28/snapshots/end-of-m07cp "$S"/support_mcp/{logs,identity,server,writes,http}.py support_mcp/cp "$S/tests/test_logs.py" tests/cp "$S"/scripts/{latency,tee}.py scripts/cp "$S/README.md" .Terminal window $S = "..\nextia-learning-labs\C28\snapshots\end-of-m07"foreach ($f in "logs", "identity", "server", "writes", "http") { Copy-Item "$S\support_mcp\$f.py" support_mcp\ }Copy-Item "$S\tests\test_logs.py" tests\Copy-Item "$S\scripts\latency.py", "$S\scripts\tee.py" scripts\Copy-Item "$S\README.md" . -
Run the same debug line again. Now
logs.pyremoves secrets before it writes:{"ts": "…", "event": "debug", "headers": {"authorization": "[redacted]", "x-support-tenant": "larkfield"}, "note": "call back [email]", "reply": "token [token]"} -
Ask as Tomás, who has no membership:
Terminal window python -m host "Draft a reply to ticket T-30002" --user usr-tomas[host] discovered 3 tools: search_knowledge, get_ticket, propose_refund {"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": "refused", "tool": "get_ticket", "user": "usr-tomas", "tenant": "larkfield", "client": "local", "reason": "no_membership"}[server] protocol error -32003: forbidden: you have no access to this organization {"code": -32003}…Each refused call now has its line, with the reason
no_membership. Tomás still sees only “forbidden”. -
Run the log tests:
Terminal window python -m pytest -q tests/test_logs.py.... [100%]4 passed in 0.29s
The redaction works on every field, at any depth. It removes a field whose name contains authorization, token, secret, password, api_key or credential, so MODEL_API_KEY goes too. It also removes a value by its form: a bearer token, a signed token that starts with eyJ, or an email address:
def redact(value): """A copy of `value` without tokens, secrets or email addresses.""" if isinstance(value, dict): return {k: "[redacted]" if SECRET_NAME.search(str(k)) else redact(v) for k, v in value.items()} if isinstance(value, list | tuple): return [redact(v) for v in value] if isinstance(value, str): value = BEARER.sub("Bearer [redacted]", value) value = JWT.sub("[token]", value) return EMAIL.sub("[email]", value) return valueRedaction is a safety net, not a design. A list of names is never complete: a key in a field called config passes through. The design is to log only the fields that you chose: IDs, outcomes and times. The net catches the day when someone logs too much.
The refusal events come from one place. Every refusal is now a Refused error, which knows its reason, the user and the organization. The server’s caller() function checks the membership and the scope, and writes one line when it refuses:
def caller(ctx: Context, scope: str | None = None, **what) -> identity.Caller: try: who = mcp.caller_of(ctx) if scope: identity.require_scope(who, scope) return who except identity.Refused as e: log.event("refused", **what, **e.log_fields()) raiseThe course team ran the six refusals again with the new files. Every one has a line now:
| Refusal | Server log at the end of Module 7 |
|---|---|
| Tomás, no membership (stdio) | refused, get_ticket, usr-tomas, larkfield, no_membership |
Sam with knowledge:read only |
refused, get_ticket, usr-sam, staff, insufficient_scope |
| Omar proposes a refund | refused, propose_refund, T-30002, 25, usr-omar, read_only, role |
| Ines at Larkfield (HTTP) | refused, search_knowledge, usr-ines, larkfield, no_membership |
A token without tickets:read (HTTP) |
refused, usr-sam, insufficient_scope, tickets:read, 403 (twice) |
| An expired token (HTTP) | token_refused, expired (twice) |
Why twice over HTTP? The SDK’s client sends server/discover. On a 401 or a 403, it tries the older initialize too, in auto mode. Two requests, two refusals, two lines. The host still prints its 86-line traceback for an expired token. That is the host’s job to fix: a good host catches the 401 and tells the person “please sign in again”. The server’s part is done: the reason is in its log.
Follow one request
Section titled “Follow one request”A log line tells you what the server decided. It does not show the messages. To see those, put a recorder between a host and the server. scripts/tee.py starts the server, passes every line unchanged in both directions, and writes each line to a file with the time and the direction.
Any host that starts stdio servers from a configuration file can use it. You change only the command. Inspector reads the same file format as many desktop hosts. Create mcp-tee.json in the project folder:
{ "mcpServers": { "support-mcp": { "command": "python", "args": ["-m", "scripts.tee", "--out", "messages.jsonl", "--", "python", "-m", "support_mcp"] } }}Then run one call through it (--config replaces the server command, so no -- is needed):
npx @modelcontextprotocol/inspector@2.10.1 --cli --config mcp-tee.json --server support-mcp --method tools/call --tool-name get_ticket --tool-arg ticket_id=T-30002The call works as before. messages.jsonl now has 7 lines, from initialize to the result (Inspector’s default era). Each line looks like this one, shortened:
{"t_ms": 262.9, "dir": "->", "bytes": 114, "message": {"method": "tools/call", "params": {"name": "get_ticket", "arguments": {"ticket_id": "T-30002"}}, "jsonrpc": "2.0", "id": 2}}The recorder copies everything, including the ticket text and the customer’s name. So treat a tee file as private data: keep it out of the repository, and delete it when you are done. In a desktop host, the configuration file is in a different place for each product; the course did not test one.
The course team recorded the host’s request “Draft a reply to ticket T-30002” in the same way. The file also holds the server’s log lines. Here are the three records of one request side by side:
Text description of the diagram
- Host: connect and discover. Wire:
server/discover(246 bytes) at 0.3 ms, its result at 248.4 ms;tools/list(241 bytes) and its result (4,460 bytes). - Host: the model wants
get_ticket T-30002; the host runs it. Wire:tools/call get_ticketat 250.6 ms. Log:tool_call get_ticket ok, Sam at Larkfield, staff, 0.05 ms in the tool. Wire: the result, 1,325 bytes, at 251.6 ms. - Host: the model wants
search_knowledge. Wire: the call at 268.8 ms. Log:tool_call search_knowledge ok, 3 results, 0.22 ms. Wire: the result, 2,220 bytes, at 269.9 ms.
Read the trace from the top. Most of the time is the start: the first answer came after 248 ms, while Python started the server process. Each tool call took about 1 ms on the wire, from the request to the answer. The tool itself used only 0.05 ms and 0.22 ms of that; the rest was the protocol work and the recorder’s pipes.
There is no shared ID that joins the host, the wire and the log. You match the three records by time, tool, user and organization. That works on one laptop. In a system with many servers, you would add a request ID to every log line; this course does not build it.
Measure a local call
Section titled “Measure a local call”How long does one call take, and does the transport matter? scripts/latency.py calls get_ticket 200 times over one connection, after 5 warm-up calls, in three passes. It does this over stdio and over Streamable HTTP, and it also times the start of a stdio server. The server’s log lines go to stderr; hide them:
python -m scripts.latency --calls 200 --passes 3 2>/dev/null200 get_ticket calls per pass, 3 passes, on arm64 Darwin 25.6.0pass 1: stdio median 0.67 ms (p95 0.72), HTTP median 1.11 ms (p95 1.19)pass 2: stdio median 0.67 ms (p95 0.72), HTTP median 1.12 ms (p95 1.2)pass 3: stdio median 0.67 ms (p95 0.72), HTTP median 1.15 ms (p95 1.25)median of the passes: stdio 0.67 ms, HTTP 1.12 msstart a stdio server and list its tools: median 294.46 ms (10 starts)The median is the middle value: half the calls were faster. The p95 is the time that 95 of 100 calls beat. Both matter: the median says what is normal, and the p95 says how bad a slow call gets.
- stdio
- Streamable HTTP
Text description of the chart
A column chart with four columns. The first run: stdio 0.68 ms, Streamable HTTP 1.16 ms. The second run, at the end of this module: stdio 0.67 ms, Streamable HTTP 1.12 ms. Starting a stdio server and listing its tools took a median of 296.5 ms in the first run and 294.46 ms in the second.
Three things stand out. A local call is fast: under 1 ms on stdio. HTTP adds about half a millisecond, for the HTTP framing and the token check. And starting a server costs about 300 ms, more than 400 calls. So a host should keep one connection open, and not start a server for each question.
These numbers are for one laptop, with other jobs running. They are not a capacity: they say nothing about 50 people at once, or a server across the internet. Measure on your own machine, and compare runs, not machines.
When to use each tool, and what it costs
Section titled “When to use each tool, and what it costs”- Limits: add one for every input that a caller controls, and write each one into the limits map. A limit costs a little: a real request that is just too large fails. Choose the number from your data, then test it.
- Structured events: log every decision: each call, each refusal, each token refusal. Lines cost disk space, and someone must keep them for a set time and then delete them. Logs are data about people: they need the same care as the data itself.
- The tee recorder: use it to debug, and to learn what a host sends. Do not leave it on: it records everything, secrets included.
- The latency script: run it before and after a change that could make the server slower. Compare with your own earlier run, never with another computer.
Common mistakes
Section titled “Common mistakes”Common mistake
Your latency numbers are much higher than the lesson's
Symptom: the stdio median is several milliseconds, or the passes differ a lot.
Cause: other programs use the computer, or the stderr log lines are written to the terminal during the measurement.
Fix: add 2>/dev/null (PowerShell: 2>$null), close heavy programs, and run again. Compare your passes with each other, not with the lesson.
Common mistake
A log line shows [email] where you expected a value
Symptom: a field that you need, such as a ticket’s subject, shows [email] or [token] in the log.
Cause: the value contained an email address or a text that looks like a token, and the redaction removed it.
Fix: this is intended. Log an ID instead of free text, for example the ticket ID, and look up the details in the data when you need them.
Reset. If your project is broken, copy the snapshot end-of-m07 as in Where MCP fits, with end-of-m07 instead of start. Then python -m pytest -q prints 94 passed.
Knowledge check
Section titled “Knowledge check”Check your understanding
Ines, who has no membership at Larkfield, calls search_knowledge over HTTP. What does she get, and what does the server's log get? Name two things that the log line must contain and two that it must never contain.
Show the answer
Ines gets −32003, “forbidden: you have no access to this organization”: the same answer for a real and a made-up organization. The log gets one line: "event": "refused", "tool": "search_knowledge", "user": "usr-ines", "tenant": "larkfield", "client": "support-host", "reason": "no_membership". It must contain who was refused and why, for example the user and the reason (the organization and the tool help too). It must never contain her access token or personal data such as an email address. It must not contain the query text either, because a query can hold customer data.
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: a host starts a new stdio server for every question, and each question needs two tool calls. Using the measurements of this lesson, roughly how long does the MCP part of one question take, and what would you change?
Show the answer
Starting the server and listing its tools took a median of about 295 ms. Two calls on stdio take about 0.67 ms each. So the MCP part takes about 296 ms, and almost all of it is the start. Keep one connection open for the whole conversation, or the whole session, and the MCP part drops to about 1.4 ms per question. These are laptop numbers with other jobs running: measure on your own machine before you decide.
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
- Limits live in layers: host, transport, contract and business rule. List them all, including the SDK’s 4 MiB body limit, and write down the ones that are missing.
- One structured event per decision: who, what, what happened, how long. Every refusal now writes a
refusedline with its reason. - No secrets in logs: log chosen fields; redaction removes tokens and email addresses as a safety net.
scripts/tee.pyrecords the wire between any stdio host and the server; its file is private data.- One local call: median 0.67 ms on stdio, 1.12 ms over HTTP; starting a server takes about 300 ms. Laptop numbers, not a capacity.
- Your project now has
94 passed.