Lesson 10 Observability, Testing, Logging
Theme:

Lab 10 — Instrument PKB and run its first test suite

Lesson 10 · MCP Servers · ~40 min hands-on

By the end of this lab, every PKB tool call emits a structured log line (phase=start, then phase=ok dur_ms=… or phase=err type=…); no tool arguments and no LLM sampling output appear in the logs; and pytest pkb/tests/ passes locally against the in-memory MCP client/server harness. You are giving PKB the two cheap habits that let Modules 5–6 (and your future self) change it without breaking it: logs you can read and tests that fail when behaviour drifts.

Prereqs

Step 1 — Create pkb/observability.py

Create the file mcp-servers/sample-server/python/pkb/observability.py. This module is PKB's single source of logging configuration, the per-tool log decorator, the trace-id generator, and the redaction helper. Every other module pulls from it — no per-tool logging boilerplate elsewhere.

"""Observability primitives for PKB.

Provides:
- _configure_logging(): single-call logging setup used by server.py at startup.
- log_tool_call(name): decorator that wraps any tool function with start/ok/err
  log lines and a duration in milliseconds. Works with both sync and async tools.
- redact(value): mask helper for fields that might contain user data or secrets.
- new_trace_id(): short random id for per-tool tracing.
"""

from __future__ import annotations

import asyncio
import functools
import logging
import secrets
import time
from typing import Any, Callable


def _configure_logging(level: int = logging.INFO) -> None:
    """Idempotent logging setup. Safe to call multiple times."""
    if logging.getLogger().handlers:
        return
    logging.basicConfig(
        level=level,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
    )


def new_trace_id() -> str:
    """8-char hex trace id — short enough to scan in logs, unique enough for a session."""
    return secrets.token_hex(4)


def redact(value: Any, max_len: int = 200) -> str:
    """Return a safe-to-log representation of `value`."""
    if isinstance(value, str):
        if len(value) > max_len:
            return f"<str len={len(value)} preview={value[:32]!r}...>"
        return value
    if isinstance(value, (bytes, bytearray)):
        return f"<bytes len={len(value)}>"
    s = repr(value)
    if len(s) > max_len:
        return f"<{type(value).__name__} len={len(s)}>"
    return s


def log_tool_call(tool_name: str) -> Callable:
    """Decorator: log start / ok / err for the wrapped tool function."""
    def deco(fn: Callable) -> Callable:
        log = logging.getLogger(f"pkb.tool.{tool_name}")

        if asyncio.iscoroutinefunction(fn):
            @functools.wraps(fn)
            async def awrapper(*args, **kwargs):
                trace = new_trace_id()
                log.info("tool=%s trace=%s phase=start", tool_name, trace)
                t0 = time.monotonic()
                try:
                    result = await fn(*args, **kwargs)
                    log.info(
                        "tool=%s trace=%s phase=ok dur_ms=%.1f result_type=%s",
                        tool_name, trace, (time.monotonic() - t0) * 1000,
                        type(result).__name__,
                    )
                    return result
                except Exception as e:
                    log.warning(
                        "tool=%s trace=%s phase=err dur_ms=%.1f type=%s",
                        tool_name, trace, (time.monotonic() - t0) * 1000,
                        type(e).__name__,
                    )
                    raise
            return awrapper

        @functools.wraps(fn)
        def swrapper(*args, **kwargs):
            trace = new_trace_id()
            log.info("tool=%s trace=%s phase=start", tool_name, trace)
            t0 = time.monotonic()
            try:
                result = fn(*args, **kwargs)
                log.info(
                    "tool=%s trace=%s phase=ok dur_ms=%.1f result_type=%s",
                    tool_name, trace, (time.monotonic() - t0) * 1000,
                    type(result).__name__,
                )
                return result
            except Exception as e:
                log.warning(
                    "tool=%s trace=%s phase=err dur_ms=%.1f type=%s",
                    tool_name, trace, (time.monotonic() - t0) * 1000,
                    type(e).__name__,
                )
                raise
        return swrapper

    return deco

What each piece is doing:

Step 2 — Wire _configure_logging into pkb/server.py

Open pkb/server.py. Add two lines near the top, before any @mcp.tool() decorator runs:

from pkb.observability import _configure_logging, log_tool_call

_configure_logging()

Calling _configure_logging() at the module body means it runs once, at import time, before any tool registration and before any client connects. By the time FastMCP starts wiring tools, the root logger already has a handler and a format. If you defer this to a main() or to the first request, your startup-time tool registrations will log to a half-configured root logger (or, worse, get silently dropped).

Step 3 — Stack @log_tool_call("name") on every tool

For each @mcp.tool() registration in server.py, stack @log_tool_call("<name>") between @mcp.tool() and the function definition:

@mcp.tool()
@log_tool_call("echo")
def echo(text: str) -> str:
    return text

Order matters. @mcp.tool() must be the outermost decorator — it inspects the underlying signature to generate FastMCP's tool schema. @log_tool_call wraps the function before FastMCP sees it, but because the wrapper uses functools.wraps(fn), the wrapped function carries the original __name__, __doc__, and signature. FastMCP's schema generator sees the original signature; the tool's runtime is the wrapped version that emits the log lines.

Apply the decoration to all 7 tools:

Double-check that the string argument to log_tool_call matches the function name. The logger name is built from it (pkb.tool.<name>), and a mismatch will make grepping by tool harder to reason about later.

Step 4 — Run the server and watch the logs

Boot the server through mcp dev so you can poke it from Inspector while watching the log stream:

cd mcp-servers/sample-server/python
PKB_USER=local mcp dev pkb/server.py

Inspector opens in your browser. In the Tools tab, call echo with arguments {"text": "smoke"}. Switch back to your terminal — you should see two new log lines:

2026-05-25 12:00:01,123 INFO pkb.tool.echo tool=echo trace=a1b2c3d4 phase=start
2026-05-25 12:00:01,124 INFO pkb.tool.echo tool=echo trace=a1b2c3d4 phase=ok dur_ms=0.3 result_type=str

Now exercise the error path. Call fetch with {"id": "no-such-note"}. Expected output in the log stream:

2026-05-25 12:00:05,567 INFO pkb.tool.fetch tool=fetch trace=e5f6a7b8 phase=start
2026-05-25 12:00:05,568 WARNING pkb.tool.fetch tool=fetch trace=e5f6a7b8 phase=err dur_ms=0.5 type=McpError

Two things to notice. First, the trace ids (a1b2c3d4, e5f6a7b8) tie the entry line to its corresponding exit line. Second — and this is the entire point of the redaction policy — nowhere does the log contain the literal text "smoke" or the literal id "no-such-note". Only metadata: tool name, trace id, phase, duration, result type, exception type.

Step 5 — Create the pkb/tests/ package

Create an empty file pkb/tests/__init__.py to mark the directory as a Python package. Then create the two test modules below.

pkb/tests/test_tools.py

"""Unit tests for PKB tools via the in-memory MCP client/server harness."""

from __future__ import annotations

import pytest

from mcp.shared.memory import create_connected_server_and_client_session

from pkb import tools as pkb_tools
from pkb.server import mcp


@pytest.fixture
def fresh_notes(monkeypatch):
    """Each test gets its own copy of the seed notes so writes don't leak across tests."""
    seed = {"hello": "Hello note body", "todo": "Pick up groceries",
            "ideas": "Build an MCP server tutorial"}
    monkeypatch.setattr(pkb_tools, "NOTES", dict(seed))
    return seed


@pytest.mark.asyncio
async def test_echo_round_trip():
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.call_tool("echo", {"text": "hello world"})
        assert result.content[0].text == "hello world"
        assert result.isError is False


@pytest.mark.asyncio
async def test_search_returns_known_titles(fresh_notes):
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.call_tool("search", {"args": {"query": "hello", "limit": 5}})
        assert "hello" in result.content[0].text


@pytest.mark.asyncio
async def test_fetch_known_id(fresh_notes):
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.call_tool("fetch", {"id": "hello"})
        assert "Hello note body" in result.content[0].text
        assert result.isError is False


@pytest.mark.asyncio
async def test_fetch_unknown_id_is_error(fresh_notes):
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.call_tool("fetch", {"id": "no-such-note"})
        assert result.isError is True
        assert "unknown note id" in result.content[0].text


@pytest.mark.asyncio
async def test_write_then_fetch(fresh_notes):
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        written = await client.call_tool(
            "write",
            {"args": {"title": "lab10", "body": "from L10 unit test"}},
        )
        assert "saved: lab10" in written.content[0].text

        fetched = await client.call_tool("fetch", {"id": "lab10"})
        assert "from L10 unit test" in fetched.content[0].text


@pytest.mark.asyncio
async def test_summarize_uses_sampling_helper(monkeypatch, fresh_notes):
    """Patch the sampling-dependent helper so the test doesn't need a real LLM."""
    async def fake(note_id, ctx):
        return f"SUMMARY OF {note_id}"
    monkeypatch.setattr(pkb_tools, "summarize_via_sampling", fake)

    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.call_tool("summarize", {"id": "hello"})
        assert result.content[0].text == "SUMMARY OF hello"

pkb/tests/test_resources.py

"""Unit tests for PKB resources via the in-memory client/server harness."""

from __future__ import annotations

import pytest

from mcp.shared.memory import create_connected_server_and_client_session

from pkb import tools as pkb_tools
from pkb.server import mcp


@pytest.fixture
def fresh_notes(monkeypatch):
    seed = {"hello": "Hello note body", "todo": "Pick up groceries",
            "ideas": "Build an MCP server tutorial"}
    monkeypatch.setattr(pkb_tools, "NOTES", dict(seed))
    return seed


@pytest.mark.asyncio
async def test_about_resource_returns_text():
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.read_resource("pkb://about")
        assert any("pkb" in c.text.lower() for c in result.contents)


@pytest.mark.asyncio
async def test_notes_resource_known(fresh_notes):
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        result = await client.read_resource("pkb://notes/hello")
        assert any("Hello note body" in c.text for c in result.contents)


@pytest.mark.asyncio
async def test_notes_resource_unknown(fresh_notes):
    async with create_connected_server_and_client_session(mcp._mcp_server) as client:
        with pytest.raises(Exception):
            await client.read_resource("pkb://notes/does-not-exist")

The patterns worth internalising:

Step 6 — Run the tests

cd mcp-servers/sample-server/python
pytest pkb/tests/ -q

Expected output: a row of dots followed by something like 9 passed in 0.42s. The whole suite should finish in well under a second — the in-memory harness has no transport overhead, and the mocked sampling call returns instantly.

If test_summarize_uses_sampling_helper fails because of an SDK signature mismatch on summarize_via_sampling, mark it skipped with a clear reason and continue — see the Troubleshooting box below for the exact fix path.

Step 7 — Read the audit checklist

What you should NOT see in your logs

Verify your own code passes the audit. From the sample-server tree:

cd mcp-servers/sample-server/python
grep -nE "log\.(info|warning|debug|error)\(" pkb/*.py pkb/tests/*.py

Read every match. Every log.…() call should pass only metadata — names, lengths, types, ids, durations — never values. The PKB code shipped in Task 4 already passes this audit; the point of running it manually is to internalise the pattern so your next server passes it too.

What you should leave with

Troubleshooting

Common stumbles