Lesson 10 Observability, Testing, Logging
Theme:
Lesson 10 · Slide 1

Lesson 10 — Observability, Testing, Logging

Make the server tell you what it did. Then write tests so you can change it without fear.

MCP Servers Module 4

Lesson 10 · Slide 2

The Module-4 bridge

Where we are

Modules 1–3 built a 7-tool PKB. It works. We've been verifying it by hand in Inspector.

This lesson

Module 4: instrument it, test it, lock down its behaviour — so Modules 5–6 can change it without breaking it.

"Untested code is unrefactorable code. Uninstrumented code is undebugable code."

Lesson 10 · Slide 3

What MCP gives you for free (and doesn't)

Lesson 10 · Slide 4

Structured logging primer

Stdlib logging is enough for the course. Configure once at startup; pull a logger per module; log key-value pairs (not English sentences) so you can grep and aggregate.

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s %(message)s',
)
Lesson 10 · Slide 5

The log_tool_call wrapper

def log_tool_call(tool_name: str):
    """Decorator that logs entry/exit/error for any tool function."""
    def deco(fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            log = logging.getLogger(f"pkb.tool.{tool_name}")
            log.info("tool=%s phase=start", tool_name)
            t0 = time.monotonic()
            try:
                result = await fn(*args, **kwargs) if asyncio.iscoroutinefunction(fn) else fn(*args, **kwargs)
                log.info("tool=%s phase=ok dur_ms=%.1f", tool_name, (time.monotonic()-t0)*1000)
                return result
            except Exception as e:
                log.warning("tool=%s phase=err type=%s", tool_name, type(e).__name__)
                raise
        return wrapper
    return deco

One decorator, every tool gets start/ok/err lines with timing — no per-tool boilerplate.

Lesson 10 · Slide 6

What NOT to log

The redaction-by-default policy. Never log: full tool args (may contain user prompts, file contents, secrets), full LLM responses (sampling output is the user's paid token output), Authorization headers, API keys, env-var values.

DO log: tool name, phase, duration, exception type, request id, user id. The exception type, not its message — exception messages from third-party SDKs often echo the URL with the API key.

Lesson 10 · Slide 7

Tracing patterns for MCP

A single user prompt may fan out into several tool calls. Generate a trace id at the top of each tool entry and propagate it through composed calls (summarize_and_save → summarize_via_sampling → …).

For local dev: just log trace_id=abc123 and parent=def456. For production: OpenTelemetry. We show the trace-id idea here; OTel lands in Lesson 15.

Lesson 10 · Slide 8

Unit-testing tools: create_connected_server_and_client_session

from mcp.shared.memory import create_connected_server_and_client_session
from pkb.server import mcp

async def test_echo():
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.call_tool("echo", {"text": "hi"})
        assert result.content[0].text == "hi"

An in-process client/server pair — no subprocess, no transport overhead, fast enough for CI.

Lesson 10 · Slide 9

Mocking sampling in tests

Lesson 10 · Slide 10

Integration testing via mcp dev

For end-to-end checks, mcp dev pkb/server.py boots Inspector with the real server. Smoke-test from the CLI by calling each tool once; capture the request log. This is your "did anything regress" gate before shipping.

mcp dev pkb/server.py  # then click through tools in Inspector
Lesson 10 · Slide 11

What we'll build today

Lesson 10 · Slide 12

Recap & what's next

Coming up: Module 5 — Why TypeScript for production, Docker, HTTP transport, multi-tenant remote servers, production hardening.
1 / ?