Pyxis Logo
Home / IBM Training / Technical article

Creating a simple MCP server to interact with a Db2 database

A hands-on tutorial for building a simple MCP server that connects Claude Code to Db2 LUW for schema discovery, SQL generation, and EXPLAIN.

26 min read
Published 2026-05-17
Pyxis editorial team
Rate this article
Average rating: Not rated
Your rating: Not rated
visualizations: 0
Abstract illustration of an MCP server interacting with a Db2 database

What we are building

In the lab we will build an MCP server that connects Claude Code directly to IBM Db2 LUW 12.1 using the native Python ibm_db driver. No REST intermediary, no extra containers. The server exposes eight tools and one resource:

ToolPurpose
list_schemas()Discover schemas inside the connected database
list_tables(schema)Tables in a schema with statistics
describe_table(schema, table)Column definitions, types, nullability
list_indexes(schema, table)Indexes with cluster ratio and key columns
sample_rows(schema, table)Preview up to 20 rows
execute_query(sql)Execute any SELECT — the natural language SQL engine
explain_query(sql)EXPLAIN + plain-English interpretation of access path
search_columns(name)Find all tables containing a column by name
table_dependencies(schema, table)Objects that reference a table

Architecture

Claude Code  (natural language)
      |
      |  JSON-RPC over stdio
      v
server.py  (MCP Server)
      |
      |  TCP 50000  (ibm_db / clidriver — direct connection)
      v
IBM Db2 LUW 12.1 Community Edition  (Docker, port 50000)

The ibm_db Python package bundles the IBM CLI driver and manages the connection. No separate Db2 client installation is needed.


Prerequisites

To execute the lab you will need:

  • A machine running Linux
  • Docker and Docker Compose
  • Python 3.10 or higher
  • Claude Code

Actually you can use alternatives to Claude Code (e.g. open code, OpenAI Codex, etc ). We tested the lab with Claude.


Part 1: Docker Environment

Create the working directory and enter it before creating the docker-compose.yml file:

mkdir db2-mcp-tutorial
cd db2-mcp-tutorial

1.1 docker-compose.yml

The configuration is quite simple. Only one container running the Db2 instance.

Create the file directly from the shell:

cat > docker-compose.yml <<'EOF'
services:
  db2:
    image: icr.io/db2_community/db2:latest
    privileged: true
    environment:
      LICENSE: accept
      DB2INST1_PASSWORD: passw0rd
    ports:
      - "50000:50000"
    volumes:
      - db2_data:/database
    healthcheck:
      test: ["CMD", "su", "-", "db2inst1", "-c", "db2 list db directory"]
      interval: 30s
      timeout: 10s
      retries: 15
      start_period: 600s

volumes:
  db2_data:
EOF

1.2 Start the container

Execute to start the Db2 container.

docker compose up -d

The first start will need to pull the Db2 image from icr.io and then performs a full Db2 instance and database setup. This can take around 3–5 minutes.

Wait some time and check the container readiness:

docker ps

If it is still starting, wait about 30 seconds and check again:

sleep 30
docker ps

Do not continue to the next step until the container is healthy.

1.3 Create the SAMPLE example objects

Run db2sampl only after the container is healthy:

docker compose exec db2 su - db2inst1 -c "db2sampl -sql -force"
# Takes about 60 seconds

Verify that the database and schema objects are there:

docker compose exec db2 su - db2inst1 -c \
  "db2 connect to SAMPLE && db2 'select count(*) as employee_count from employee' && db2 connect reset"
# Expected: 42

Create the EXPLAIN tables in the schema

docker compose exec db2 su - db2inst1 -c \
  "db2 connect to SAMPLE && db2 -tf /database/config/db2inst1/sqllib/misc/EXPLAIN.DDL && db2 connect reset"

Part 2: Project Structure and Dependencies

The project has this structure.

db2-mcp-tutorial/
|- docker-compose.yml
|- server.py
|- pyproject.toml
`- .env

Create the pyproject.toml file directly from the shell:

cat > pyproject.toml <<'EOF'
[project]
name = "db2-schema-mcp"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
    "mcp[cli]>=1.25",
    "ibm-db>=3.2.0",
    "python-dotenv>=1.0",
]
EOF

Create the local Python environment and install the project dependencies:

  • uv venv creates an isolated virtual environment in .venv
  • uv pip install -e . installs the package in editable mode and pulls the dependencies declared in pyproject.toml
uv venv && uv pip install -e .

ibm_db downloads and installs the IBM CLI driver automatically. No separate Db2 client installation is required.

Create the environment file:

cat > .env <<'EOF'
DB2_HOST=localhost
DB2_PORT=50000
DB2_DBNAME=SAMPLE
DB2_USER=db2inst1
DB2_PASSWORD=passw0rd
EOF

Part 3: The MCP Server (server.py)

An MCP server is a small local process that exposes capabilities to an AI client in a structured way. Instead of letting the model invent how to reach Db2, the MCP server gives it explicit tools such as list_tables, describe_table, execute_query, and explain_query.

In practice, this means the MCP server provides two things:

  • A controlled bridge between Claude Code and the Db2 database
  • A defined tool surface that the model can call safely and predictably

For this article, server.py receives tool calls from Claude Code over MCP, opens a direct ibm_db connection to Db2, runs the requested metadata or SQL operation, and returns the result in a format the agent can reason about.

To create the server.py file, open a vi session:

vi server.py

And paste this content into the file:

"""
MCP Server — Db2 Schema Explorer
Explores tables, columns, indexes and sample data from an IBM Db2 database.
Connects directly via ibm_db driver (no REST Service required).
Compatible with Db2 LUW 12.1 Community Edition running in Docker.
"""
import os
import threading
import ibm_db
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP

load_dotenv()

# ── Configuration ─────────────────────────────────────────────────────────────

DB2_HOST   = os.getenv("DB2_HOST",     "localhost")
DB2_PORT   = os.getenv("DB2_PORT",     "50000")
DB2_DBNAME = os.getenv("DB2_DBNAME",   "SAMPLE")
DB2_USER   = os.getenv("DB2_USER",     "db2inst1")
DB2_PASS   = os.getenv("DB2_PASSWORD", "passw0rd")

_DSN = (
    f"DATABASE={DB2_DBNAME};"
    f"HOSTNAME={DB2_HOST};"
    f"PORT={DB2_PORT};"
    f"PROTOCOL=TCPIP;"
    f"UID={DB2_USER};"
    f"PWD={DB2_PASS};"
    "CONNECTTIMEOUT=30;"
    "QUERYTIMEOUT=120;"
)

# ── Connection management ─────────────────────────────────────────────────────
# ibm_db is a synchronous C extension — not thread-safe per connection.
# A global lock serialises all queries. For high-concurrency production use,
# create a new connection per request instead.

_conn_lock = threading.Lock()
_conn: object = None


def _get_conn():
    """Return the active Db2 connection, reconnecting automatically if needed."""
    global _conn
    try:
        if _conn is not None:
            ibm_db.active(_conn)   # raises if connection is dead
            return _conn
    except Exception:
        _conn = None
    _conn = ibm_db.connect(_DSN, "", "")
    return _conn


def run_sql(query: str) -> list[dict]:
    """
    Execute a SELECT statement and return the result set as a list of dicts.
    Thread-safe via global connection lock.
    """
    with _conn_lock:
        conn = _get_conn()
        stmt = ibm_db.exec_immediate(conn, query)
        rows: list[dict] = []
        row = ibm_db.fetch_assoc(stmt)
        while row:
            rows.append(dict(row))
            row = ibm_db.fetch_assoc(stmt)
        ibm_db.free_result(stmt)
        return rows


def run_admin_cmd(command: str) -> list[dict]:
    """
    Execute an administrative Db2 CLP command via SYSPROC.ADMIN_CMD.
    Returns the result set as a list of dicts.

    ADMIN_CMD runs commands like LIST DATABASE DIRECTORY, RUNSTATS, etc.
    as a stored procedure — no shell access or docker exec required.
    """
    with _conn_lock:
        conn = _get_conn()
        stmt = ibm_db.exec_immediate(
            conn, f"CALL SYSPROC.ADMIN_CMD(\'{command}\')"
        )
        rows: list[dict] = []
        row = ibm_db.fetch_assoc(stmt)
        while row:
            rows.append(dict(row))
            row = ibm_db.fetch_assoc(stmt)
        ibm_db.free_result(stmt)
        return rows

# ── MCP Server ────────────────────────────────────────────────────────────────

mcp = FastMCP(
    "db2-schema-explorer",
    instructions=(
        "You are a Db2 database assistant with tools to inspect an IBM Db2 LUW database.\n\n"

        "CRITICAL — DATABASE vs SCHEMA:\n"
        "In Db2, the database name (e.g. SAMPLE) is the connection target — it is NOT a schema. "
        "Schemas are namespaces INSIDE the database (e.g. DB2INST1, SYSCAT, SYSTOOLS). "
        "Never use the database name as a schema parameter.\n\n"

        "WORKFLOW:\n"
        "0. When the user asks what databases exist on the server, call list_databases().\n"
        "1. When the user asks about tables 'in the database' or mentions the database name "
        "(e.g. SAMPLE), ALWAYS call list_schemas() first to discover which schemas actually "
        "exist — do not assume the database name is a schema.\n"
        "2. Then call list_tables(schema) for each schema of interest.\n"
        "3. Never guess or assume a schema name. "
        "The instance owner schema is typically DB2INST1, not the database name.\n\n"

        "TOOL SUMMARY:\n"
        "- list_databases()             list all databases in the Db2 instance\n"
        "- list_schemas()               discover all schemas in the database\n"
        "- list_tables(schema)          tables in a specific schema\n"
        "- describe_table(schema,table) column definitions\n"
        "- list_indexes(schema,table)   index definitions\n"
        "- sample_rows(schema,table)    preview a few rows (max 20, no filtering)\n"
        "- execute_query(sql)           run any SELECT — USE THIS for filtering, "
        "aggregation, subqueries, joins, ranking, or any analytical question\n"
        "- explain_query(sql)           explain the access plan for a SELECT and "
        "interpret index usage, joins, sorts and cost estimates\n"
        "- search_columns(name)         find tables that have a column by name\n"
        "- table_dependencies(schema,table) objects that depend on a table\n\n"

        "WHEN TO USE execute_query:\n"
        "Whenever the user asks a question that requires SQL logic — averages, totals, "
        "comparisons, filters, GROUP BY, subqueries — generate the correct SQL and call "
        "execute_query(sql). Never approximate by sampling rows and calculating manually. "
        "Always use fully qualified table names (SCHEMA.TABLE) in the SQL.\n\n"

        "Always use uppercase for all schema and table names."
    ),
)


@mcp.tool()
def list_databases() -> str:
    """
    List all databases registered in the Db2 instance directory.

    Uses SYSPROC.ADMIN_CMD to run the Db2 CLP command
    LIST DATABASE DIRECTORY, which shows every database catalogued
    on this instance: name, alias, type (local/remote), and path.

    Call this first when the user wants to know what databases are
    available on the server, or before connecting to a specific database.
    """
    try:
        rows = run_admin_cmd("LIST DATABASE DIRECTORY")
    except Exception as exc:
        return f"Failed to list databases: {exc}"

    if not rows:
        return "No databases found in the instance directory."

    lines = [f"**Databases in instance directory** ({len(rows)} found)\n"]

    for r in rows:
        alias   = r.get("DATABASE_ALIAS",     r.get("DB_ALIAS", "?"))
        name    = r.get("DATABASE_NAME",      r.get("DB_NAME",  alias))
        db_type = r.get("DATABASE_TYPE",      r.get("TYPE",     "?"))
        path    = r.get("DATABASE_DIRECTORY", r.get("DIRECTORY", ""))
        comment = r.get("COMMENT", "")

        lines.append(f"### `{alias}`")
        if name != alias:
            lines.append(f"- Name      : `{name}`")
        lines.append(f"- Type      : {db_type}")
        if path:
            lines.append(f"- Directory : `{path}`")
        if comment:
            lines.append(f"- Comment   : {comment}")

    return "\n".join(lines)


@mcp.tool()
def list_schemas() -> str:
    """
    List all user-defined schemas in the database.

    Returns schema name, owner, and creation timestamp.
    All Db2 system schemas are excluded (SYS*, SYSIBM, NULLID, SQLJ,
    ERRORSCHEMA, SYSTOOLS, DB2GSE, ASMA, SYSPROC).

    Call this first whenever the user asks about tables or objects in the
    database — do not assume the database name is a schema.
    """
    rows = run_sql("""
        SELECT SCHEMANAME, OWNER, CREATE_TIME
        FROM   SYSCAT.SCHEMATA
        WHERE  SCHEMANAME NOT LIKE 'SYS%'
          AND  SCHEMANAME NOT IN (
                   'NULLID', 'SQLJ', 'ERRORSCHEMA',
                   'SYSTOOLS', 'DB2GSE', 'ASMA', 'SYSPROC'
               )
        ORDER BY SCHEMANAME
    """)

    if not rows:
        return "No user-defined schemas found."

    lines = [f"**Available schemas** ({len(rows)} found)\n"]
    for r in rows:
        created = str(r.get("CREATE_TIME", ""))[:10]
        lines.append(
            f"- `{r['SCHEMANAME']}` "
            f"(owner: {r.get('OWNER', '?')}, created: {created})"
        )
    return "\n".join(lines)


@mcp.tool()
def list_tables(schema: str) -> str:
    """
    List all tables in a given schema with basic statistics.

    Parameters:
        schema — schema name (e.g. SAMPLE, DB2INST1). Case-insensitive.

    Returns table name, estimated row count (CARD), page count, and last
    access date. CARD = -1 means statistics have never been collected
    (run RUNSTATS to update).
    """
    schema = schema.upper()
    rows = run_sql(f"""
        SELECT TABNAME, TYPE, CARD, NPAGES, LASTUSED
        FROM   SYSCAT.TABLES
        WHERE  TABSCHEMA = '{schema}'
          AND  TYPE = 'T'
        ORDER BY TABNAME
    """)

    if not rows:
        return f"No tables found in schema `{schema}`."

    lines = [
        f"**Tables in `{schema}`** ({len(rows)} found)\n",
        "| Table | Rows (CARD) | Pages | Last used |",
        "|-------|-------------|-------|-----------|",
    ]
    for r in rows:
        card      = r.get("CARD",     "?")
        npages    = r.get("NPAGES",   "?")
        last_used = str(r.get("LASTUSED", "—"))[:10]
        card_flag = " ⚠" if card == -1 else ""
        lines.append(
            f"| `{r['TABNAME']}` | {card}{card_flag} | {npages} | {last_used} |"
        )

    if any(r.get("CARD") == -1 for r in rows):
        lines.append(
            "\n⚠ Tables with CARD = -1 have no statistics. "
            "Run `RUNSTATS ON TABLE <schema>.<table>` to collect them."
        )
    return "\n".join(lines)


@mcp.tool()
def describe_table(schema: str, table: str) -> str:
    """
    Describe the column structure of a table.

    Parameters:
        schema — schema name (e.g. SAMPLE)
        table  — table name (e.g. EMPLOYEE)

    Returns each column's name, data type (with length/scale), nullability,
    default value, identity flag, and any column remarks.
    """
    schema = schema.upper()
    table  = table.upper()

    rows = run_sql(f"""
        SELECT COLNO, COLNAME, TYPENAME, LENGTH, SCALE,
               NULLS, DEFAULT, IDENTITY, GENERATED, REMARKS
        FROM   SYSCAT.COLUMNS
        WHERE  TABSCHEMA = '{schema}'
          AND  TABNAME   = '{table}'
        ORDER BY COLNO
    """)

    if not rows:
        return f"Table `{schema}.{table}` not found or has no columns."

    lines = [
        f"**`{schema}.{table}`** — {len(rows)} column(s)\n",
        "| # | Column | Type | Nullable | Identity | Default | Remarks |",
        "|---|--------|------|----------|----------|---------|---------|",
    ]
    for r in rows:
        length   = f"({r['LENGTH']})"      if r.get("LENGTH") else ""
        scale    = f",{r['SCALE']}"        if r.get("SCALE")  else ""
        col_type = f"{r['TYPENAME']}{length}{scale}"
        nullable = "Yes" if r.get("NULLS") == "Y" else "**No**"
        identity = "Yes" if r.get("IDENTITY") == "Y" else "—"
        default  = r.get("DEFAULT") or "—"
        remarks  = r.get("REMARKS") or "—"
        lines.append(
            f"| {r['COLNO']} | `{r['COLNAME']}` | `{col_type}` "
            f"| {nullable} | {identity} | {default} | {remarks} |"
        )
    return "\n".join(lines)


@mcp.tool()
def list_indexes(schema: str, table: str) -> str:
    """
    List all indexes defined on a table.

    Parameters:
        schema — schema name
        table  — table name

    Returns index name, type (REG/CLUS/BLOK), uniqueness rule (PK/Unique/Non-unique),
    number of leaf pages, tree levels, cluster ratio, and indexed columns
    (including ASC/DESC ordering).
    """
    schema = schema.upper()
    table  = table.upper()

    rows = run_sql(f"""
        SELECT
            I.INDNAME,
            I.UNIQUERULE,
            I.INDEXTYPE,
            I.NLEAF,
            I.NLEVELS,
            I.CLUSTERRATIO,
            LISTAGG(
                K.COLNAME || CASE K.COLORDER WHEN 'D' THEN ' DESC' ELSE '' END,
                ', '
            ) WITHIN GROUP (ORDER BY K.COLSEQ) AS COLUMNS
        FROM  SYSCAT.INDEXES     I
        JOIN  SYSCAT.INDEXCOLUSE K
          ON  I.INDSCHEMA = K.INDSCHEMA
          AND I.INDNAME   = K.INDNAME
        WHERE I.TABSCHEMA = '{schema}'
          AND I.TABNAME   = '{table}'
        GROUP BY
            I.INDNAME, I.UNIQUERULE, I.INDEXTYPE,
            I.NLEAF, I.NLEVELS, I.CLUSTERRATIO
        ORDER BY I.INDNAME
    """)

    if not rows:
        return f"No indexes found on `{schema}.{table}`."

    unique_map = {"U": "✅ Unique", "P": "✅ PK", "D": "No"}
    lines = [
        f"**Indexes on `{schema}.{table}`** ({len(rows)} found)\n",
        "| Index | Type | Unique | Leaves | Levels | Cluster% | Columns |",
        "|-------|------|--------|--------|--------|----------|---------|",
    ]
    for r in rows:
        cr      = r.get("CLUSTERRATIO", -1)
        cluster = f"{cr}%" if cr is not None and cr >= 0 else "?"
        lines.append(
            f"| `{r['INDNAME']}` "
            f"| {r.get('INDEXTYPE', '?')} "
            f"| {unique_map.get(r.get('UNIQUERULE', 'D'), '?')} "
            f"| {r.get('NLEAF', '?')} "
            f"| {r.get('NLEVELS', '?')} "
            f"| {cluster} "
            f"| `{r.get('COLUMNS', '?')}` |"
        )
    return "\n".join(lines)


@mcp.tool()
def sample_rows(schema: str, table: str, limit: int = 5) -> str:
    """
    Preview the first N rows of a table.

    Parameters:
        schema — schema name
        table  — table name
        limit  — number of rows to return, between 1 and 20 (default: 5)

    Returns the rows formatted as a Markdown table. Column values are
    truncated to 50 characters to keep the output readable.
    """
    schema = schema.upper()
    table  = table.upper()
    limit  = max(1, min(limit, 20))

    rows = run_sql(f"""
        SELECT * FROM {schema}.{table}
        FETCH FIRST {limit} ROWS ONLY
    """)

    if not rows:
        return f"Table `{schema}.{table}` is empty or does not exist."

    headers = list(rows[0].keys())
    lines   = [
        f"**`{schema}.{table}`** — first {len(rows)} row(s)\n",
        "| " + " | ".join(headers) + " |",
        "| " + " | ".join(["---"] * len(headers)) + " |",
    ]
    for r in rows:
        cells = [str(r.get(h, ""))[:50] for h in headers]
        lines.append("| " + " | ".join(cells) + " |")
    return "\n".join(lines)


@mcp.tool()
def execute_query(sql: str, max_rows: int = 100) -> str:
    """
    Execute a read-only SQL SELECT statement and return the full result set.

    Use this tool whenever the user asks a question that requires filtering,
    aggregation, joining, subqueries, or any logic beyond a simple row preview.
    Examples: averages, totals, rankings, comparisons, EXISTS, GROUP BY, HAVING.

    Parameters:
        sql      — a valid Db2 SQL SELECT statement.
                   Must be a SELECT — INSERT, UPDATE, DELETE and DDL are rejected.
                   Schema and table names must be fully qualified (e.g. DB2INST1.EMPLOYEE).
        max_rows — maximum number of rows to return (default: 100, maximum: 500).

    The tool executes the SQL exactly as provided. Generate correct, complete SQL
    before calling — do not call with placeholder or incomplete statements.

    Example queries:
        SELECT * FROM DB2INST1.EMPLOYEE
          WHERE SALARY > (SELECT AVG(SALARY) FROM DB2INST1.EMPLOYEE)
          ORDER BY SALARY DESC

        SELECT WORKDEPT, COUNT(*) AS HEADCOUNT, AVG(SALARY) AS AVG_SALARY
          FROM DB2INST1.EMPLOYEE
          GROUP BY WORKDEPT
          ORDER BY AVG_SALARY DESC
    """
    # Safety: only allow SELECT statements
    sql_stripped = sql.strip().upper()
    if not sql_stripped.startswith("SELECT") and \
       not sql_stripped.startswith("WITH"):
        return (
            "Error: only SELECT (or WITH ... SELECT) statements are allowed. "
            "INSERT, UPDATE, DELETE and DDL are not permitted."
        )

    max_rows = max(1, min(max_rows, 500))

    # Append FETCH FIRST if not already present
    if "FETCH FIRST" not in sql_stripped:
        sql = f"{sql.rstrip().rstrip(';')} FETCH FIRST {max_rows} ROWS ONLY"

    try:
        rows = run_sql(sql)
    except Exception as exc:
        return f"Query failed:\n```\n{exc}\n```"

    if not rows:
        return "Query executed successfully — no rows returned."

    headers = list(rows[0].keys())
    lines = [
        f"**Query result** — {len(rows)} row(s)\n",
        "| " + " | ".join(headers) + " |",
        "| " + " | ".join(["---"] * len(headers)) + " |",
    ]
    for r in rows:
        cells = [str(r.get(h, ""))[:60] for h in headers]
        lines.append("| " + " | ".join(cells) + " |")

    if len(rows) == max_rows:
        lines.append(
            f"\n*Result limited to {max_rows} rows. "
            "Pass a higher max_rows value to retrieve more.*"
        )
    return "\n".join(lines)


@mcp.tool()
def search_columns(column_name: str) -> str:
    """
    Find all tables that contain a column matching the given name pattern.

    Parameters:
        column_name — full or partial column name to search for.
                      The search is case-insensitive and uses SQL LIKE
                      with wildcards on both sides (e.g. 'DATE' matches
                      ORDER_DATE, HIRE_DATE, etc.).

    Returns schema, table, column name, data type, and nullability.
    Results are limited to 50 rows and exclude system schemas.
    """
    pattern = column_name.upper()
    rows = run_sql(f"""
        SELECT TABSCHEMA, TABNAME, COLNAME, TYPENAME, NULLS
        FROM   SYSCAT.COLUMNS
        WHERE  COLNAME     LIKE '%{pattern}%'
          AND  TABSCHEMA NOT LIKE 'SYS%'
        ORDER BY TABSCHEMA, TABNAME, COLNAME
        FETCH FIRST 50 ROWS ONLY
    """)

    if not rows:
        return f"No columns matching `%{pattern}%` found."

    lines = [
        f"**Columns matching `%{pattern}%`** ({len(rows)} found)\n",
        "| Schema | Table | Column | Type | Nullable |",
        "|--------|-------|--------|------|----------|",
    ]
    for r in rows:
        nullable = "Yes" if r.get("NULLS") == "Y" else "No"
        lines.append(
            f"| `{r['TABSCHEMA']}` | `{r['TABNAME']}` "
            f"| `{r['COLNAME']}` | {r['TYPENAME']} | {nullable} |"
        )
    return "\n".join(lines)


@mcp.tool()
def table_dependencies(schema: str, table: str) -> str:
    """
    List objects that depend on a given table: views, triggers, packages,
    and stored procedures that reference it.

    Parameters:
        schema — schema name
        table  — table name

    Useful before performing ALTER TABLE or DROP TABLE to understand
    the impact on dependent objects.
    """
    schema = schema.upper()
    table  = table.upper()

    rows = run_sql(f"""
        SELECT DNAME AS OBJECT_NAME, DTYPE AS OBJECT_TYPE, TABSCHEMA
        FROM   SYSCAT.PACKAGEDEP
        WHERE  BSCHEMA = '{schema}'
          AND  BNAME   = '{table}'
          AND  BTYPE   = 'T'
        UNION ALL
        SELECT VIEWNAME, 'V', VIEWSCHEMA
        FROM   SYSCAT.VIEWDEP
        WHERE  BSCHEMA = '{schema}'
          AND  BNAME   = '{table}'
        ORDER BY OBJECT_TYPE, OBJECT_NAME
    """)

    if not rows:
        return (
            f"No dependencies found for `{schema}.{table}`.\n\n"
            "This does not guarantee the table is unused — "
            "application-level references are not tracked here."
        )

    type_map = {
        "V": "View",
        "R": "Stored Procedure",
        "F": "Function",
        "T": "Trigger",
        "P": "Package",
    }
    lines = [
        f"**Dependencies of `{schema}.{table}`** ({len(rows)} found)\n",
        "| Type | Object |",
        "|------|--------|",
    ]
    for r in rows:
        obj_type = type_map.get(r.get("OBJECT_TYPE", "?"), r.get("OBJECT_TYPE", "?"))
        lines.append(f"| {obj_type} | `{r.get('OBJECT_NAME', '?')}` |")

    lines.append(
        "\n⚠ Any structural change to this table may invalidate the objects listed above."
    )
    return "\n".join(lines)


# ── EXPLAIN ───────────────────────────────────────────────────────────────────

def _to_float(value) -> float:
    """Parse a cost value returned by Db2 DECIMAL(), tolerating locale-specific
    decimal separators (e.g. '13,64' on European-locale servers)."""
    if value is None:
        return 0.0
    s = str(value).strip()
    if ',' in s and '.' in s:
        if s.rfind(',') > s.rfind('.'):
            s = s.replace('.', '').replace(',', '.')
        else:
            s = s.replace(',', '')
    else:
        s = s.replace(',', '.')
    return float(s) if s else 0.0


@mcp.tool()
def explain_query(sql: str) -> str:
    """
    Explain the access plan for a SQL SELECT statement and interpret it
    in plain English.

    Parameters:
        sql — a valid Db2 SELECT statement to explain.
              Must not contain EXPLAIN PLAN FOR — provide only the SELECT.

    What this tool does:
        1. Runs EXPLAIN PLAN FOR <sql> to populate the explain tables.
        2. Reads EXPLAIN_OPERATOR, EXPLAIN_PREDICATE and EXPLAIN_ARGUMENT
           to extract the full access plan.
        3. Returns a formatted report with an operator table, predicate
           details, and a plain-English interpretation of the access path.

    Operator types and their meaning:
        RETURN   — final output node (root of the tree)
        TBSCAN   — Table Scan: reads every row in the table (expensive for large tables)
        IXSCAN   — Index Scan: uses an index to find rows (efficient)
        FETCH    — retrieves full rows after an index lookup
        NLJOIN   — Nested Loop Join: for each row in outer, scans inner (good for small sets)
        HSJOIN   — Hash Join: builds hash table, good for large unsorted sets
        MSJOIN   — Merge Scan Join: both inputs sorted, good for sorted data
        SORT     — sorts the data (may spill to disk if SORTHEAP is small)
        GRPBY    — Group By / aggregation
        FILTER   — applies a predicate filter
        TEMP     — materialises intermediate results to a temp table
    """
    try:
        with _conn_lock:
            conn = _get_conn()
            ibm_db.exec_immediate(conn, f"EXPLAIN PLAN FOR {sql}")
    except Exception as exc:
        return f"EXPLAIN failed: {exc}"

    # Read operator tree (most recent explain for this session).
    # OUTPUT_CARD was added to EXPLAIN_OPERATOR in a later Db2 release; on
    # databases whose explain tables were created with older DDL the column is
    # absent.  We derive the same value from EXPLAIN_STREAM.STREAM_COUNT,
    # which has always existed: each outgoing stream (SOURCE_TYPE='O') carries
    # the optimizer's cardinality estimate for that operator's output.
    operators = run_sql("""
        SELECT
            E.OPERATOR_ID,
            E.OPERATOR_TYPE,
            S.OBJECT_SCHEMA,
            S.OBJECT_NAME,
            DECIMAL(E.TOTAL_COST, 15, 2)      AS TOTAL_COST,
            DECIMAL(E.IO_COST, 15, 2)         AS IO_COST,
            DECIMAL(E.CPU_COST, 15, 2)        AS CPU_COST,
            E.FIRST_ROW_COST,
            C.OUTPUT_CARD
        FROM EXPLAIN_OPERATOR E
        LEFT JOIN (
            SELECT EXPLAIN_TIME, TARGET_ID,
                   MIN(OBJECT_SCHEMA) AS OBJECT_SCHEMA,
                   MIN(OBJECT_NAME)   AS OBJECT_NAME
            FROM   EXPLAIN_STREAM
            WHERE  SOURCE_TYPE = 'D'
              AND  OBJECT_NAME IS NOT NULL
            GROUP BY EXPLAIN_TIME, TARGET_ID
        ) S ON  E.EXPLAIN_TIME = S.EXPLAIN_TIME
            AND E.OPERATOR_ID  = S.TARGET_ID
        LEFT JOIN (
            SELECT EXPLAIN_TIME, SOURCE_ID,
                   DECIMAL(MIN(STREAM_COUNT), 15, 2) AS OUTPUT_CARD
            FROM   EXPLAIN_STREAM
            WHERE  SOURCE_TYPE = 'O'
            GROUP BY EXPLAIN_TIME, SOURCE_ID
        ) C ON  E.EXPLAIN_TIME = C.EXPLAIN_TIME
            AND E.OPERATOR_ID  = C.SOURCE_ID
        WHERE E.EXPLAIN_TIME = (
            SELECT MAX(EXPLAIN_TIME) FROM EXPLAIN_INSTANCE
        )
        ORDER BY E.OPERATOR_ID
    """)

    # Step 4 — read predicates
    predicates = run_sql("""
        SELECT
            P.OPERATOR_ID,
            P.HOW_APPLIED,
            SUBSTR(P.PREDICATE_TEXT, 1, 120) AS PREDICATE_TEXT
        FROM EXPLAIN_PREDICATE P
        WHERE P.EXPLAIN_TIME = (
            SELECT MAX(EXPLAIN_TIME) FROM EXPLAIN_INSTANCE
        )
        ORDER BY P.OPERATOR_ID, P.HOW_APPLIED
    """)

    # Step 5 — read operator arguments (access type, index name, etc.)
    arguments = run_sql("""
        SELECT
            A.OPERATOR_ID,
            A.ARGUMENT_TYPE,
            SUBSTR(A.ARGUMENT_VALUE, 1, 80) AS ARGUMENT_VALUE
        FROM EXPLAIN_ARGUMENT A
        WHERE A.EXPLAIN_TIME = (
            SELECT MAX(EXPLAIN_TIME) FROM EXPLAIN_INSTANCE
        )
          AND A.ARGUMENT_TYPE IN (
              'SCANDIR','PREFETCH','ROWLOCK','TABLOCK',
              'UNIQUE','OUTERJOIN','JOIN TYPE','ACCESSTYPE'
          )
        ORDER BY A.OPERATOR_ID, A.ARGUMENT_TYPE
    """)

    if not operators:
        return "No explain plan was generated. Check that the SQL is valid."

    # Build predicate map: operator_id → list of (how_applied, text)
    pred_map: dict[int, list] = {}
    for p in predicates:
        oid = int(p.get("OPERATOR_ID", 0))
        pred_map.setdefault(oid, []).append(
            (p.get("HOW_APPLIED", "?"), p.get("PREDICATE_TEXT", ""))
        )

    # Build argument map: operator_id → dict of argument_type → value
    arg_map: dict[int, dict] = {}
    for a in arguments:
        oid = int(a.get("OPERATOR_ID", 0))
        arg_map.setdefault(oid, {})[a.get("ARGUMENT_TYPE", "")] = \
            a.get("ARGUMENT_VALUE", "")

    # Operator type descriptions
    op_desc = {
        "RETURN":  "Return (final output)",
        "TBSCAN":  "Table Scan ⚠ (full table read)",
        "IXSCAN":  "Index Scan ✅",
        "FETCH":   "Fetch (row retrieval after index)",
        "NLJOIN":  "Nested Loop Join",
        "HSJOIN":  "Hash Join",
        "MSJOIN":  "Merge Scan Join",
        "SORT":    "Sort ⚠",
        "GRPBY":   "Group By / Aggregate",
        "FILTER":  "Filter",
        "TEMP":    "Temp Materialisation",
        "UNION":   "Union",
        "EXCEPT":  "Except",
        "IXAND":   "Index AND (multiple indexes combined)",
        "RIDSCN":  "RID Scan",
    }

    # Build output
    total_cost = sum(
        _to_float(r.get("TOTAL_COST"))
        for r in operators
        if r.get("OPERATOR_TYPE") == "RETURN"
    )

    lines = [
        "**EXPLAIN — Access Plan**\n",
        f"**Estimated total cost:** {total_cost:,.2f} timerons\n",
        "| Op | Type | Object | Total Cost | IO Cost | Output Rows |",
        "|----|------|--------|-----------|---------|-------------|",
    ]

    has_tbscan  = False
    has_sort    = False
    has_ixscan  = False

    for r in operators:
        op_id   = int(r.get("OPERATOR_ID", 0))
        op_type = r.get("OPERATOR_TYPE", "?")
        obj     = ""
        if r.get("OBJECT_NAME"):
            obj = f"`{r.get('OBJECT_SCHEMA','')}.{r['OBJECT_NAME']}`"

        cost    = r.get("TOTAL_COST", "?")
        io_cost = r.get("IO_COST",    "?")
        card    = r.get("OUTPUT_CARD", "?")
        label   = op_desc.get(op_type, op_type)

        if op_type == "TBSCAN": has_tbscan = True
        if op_type == "SORT":   has_sort   = True
        if op_type == "IXSCAN": has_ixscan = True

        args   = arg_map.get(op_id, {})
        access = args.get("ACCESSTYPE", "")
        if access:
            label += f" [{access}]"

        lines.append(
            f"| {op_id} | {label} | {obj} "
            f"| {cost} | {io_cost} | {card} |"
        )

        for how, text in pred_map.get(op_id, []):
            pred_icon = "✅" if how in ("SARG", "RANGE", "MATCH") else "⚠"
            lines.append(
                f"|   | ↳ Predicate `{how}` {pred_icon} "
                f"| `{text}` | | | |"
            )

    # ── Plain-English interpretation ─────────────────────────────────────────
    lines.append("\n---\n**Interpretation:**\n")

    if has_ixscan and not has_tbscan:
        lines.append(
            "✅ **Index usage:** the query uses index scans — "
            "the optimizer found suitable indexes for the predicates."
        )
    elif has_tbscan and not has_ixscan:
        lines.append(
            "⚠ **Table scan detected:** the optimizer is reading the entire table. "
            "This is acceptable for small tables but expensive for large ones. "
            "Consider creating an index on the columns used in WHERE or JOIN conditions."
        )
    elif has_tbscan and has_ixscan:
        lines.append(
            "⚠ **Mixed access:** some tables use indexes, others use full scans. "
            "Review the TBSCAN operators — those tables may benefit from indexes."
        )

    if has_sort:
        lines.append(
            "⚠ **Sort operation present:** the query requires sorting. "
            "If this is for an ORDER BY or GROUP BY, consider an index "
            "that matches the sort order to eliminate the sort operator."
        )

    if total_cost > 100000:
        lines.append(
            f"⚠ **High estimated cost ({total_cost:,.0f} timerons):** "
            "this query may be expensive. Review table scans and sort operations. "
            "Ensure RUNSTATS has been run recently so the optimizer has accurate statistics."
        )
    elif total_cost < 100:
        lines.append(
            f"✅ **Low estimated cost ({total_cost:,.0f} timerons):** "
            "the query is expected to be fast."
        )

    lines.append(
        "\n*Note: costs are estimates based on catalog statistics. "
        "Run `RUNSTATS ON TABLE <schema>.<table> WITH DISTRIBUTION AND INDEXES ALL` "
        "if statistics are stale (CARD = -1 or outdated).*"
    )

    return "\n".join(lines)


# ── Resource — schema overview ────────────────────────────────────────────────

@mcp.resource("db2://schema/{schema}/overview")
def schema_overview(schema: str) -> str:
    """
    High-level summary of a schema: table count, total rows, and total pages.
    Loaded as background context before answering schema-level questions.
    """
    schema = schema.upper()
    rows = run_sql(f"""
        SELECT
            COUNT(*)    AS NUM_TABLES,
            SUM(CARD)   AS TOTAL_ROWS,
            SUM(NPAGES) AS TOTAL_PAGES
        FROM SYSCAT.TABLES
        WHERE TABSCHEMA = '{schema}'
          AND TYPE = 'T'
    """)

    if not rows or not rows[0].get("NUM_TABLES"):
        return f"Schema `{schema}` not found or empty."

    r = rows[0]
    return (
        f"Schema: {schema}\n"
        f"Tables: {r.get('NUM_TABLES', '?')}\n"
        f"Total rows (estimated): {r.get('TOTAL_ROWS', '?')}\n"
        f"Total pages: {r.get('TOTAL_PAGES', '?')}\n"
    )


# ── Entry point ───────────────────────────────────────────────────────────────

if __name__ == "__main__":
    print(f"Connecting to Db2: {DB2_HOST}:{DB2_PORT}/{DB2_DBNAME} as {DB2_USER}")
    try:
        test = run_sql("SELECT 1 AS OK FROM SYSIBM.SYSDUMMY1")
        print(f"Connection OK — server ready.")
    except Exception as exc:
        print(f"Connection FAILED: {exc}")
        raise
    mcp.run(transport="stdio")

The full file is now in place. The next subsections only explain the most important parts of server.py section by section.

3.1 Connection management

As ibm_db is a synchronous C extension, it does not support Python's async/await. All tool functions are therefore defined with def (not async def). A global threading.Lock() serialises queries because ibm_db is not thread-safe per connection.

_DSN = (
    f"DATABASE={DB2_DBNAME};"
    f"HOSTNAME={DB2_HOST};"
    f"PORT={DB2_PORT};"
    f"PROTOCOL=TCPIP;"
    f"UID={DB2_USER};"
    f"PWD={DB2_PASS};"
    "CONNECTTIMEOUT=30;"
    "QUERYTIMEOUT=120;"
)

_conn_lock = threading.Lock()
_conn: object = None

def _get_conn():
    global _conn
    try:
        if _conn is not None:
            ibm_db.active(_conn)
            return _conn
    except Exception:
        _conn = None
    _conn = ibm_db.connect(_DSN, "", "")
    return _conn

3.2 Two execution helpers

def run_sql(query: str) -> list[dict]:
    with _conn_lock:
        conn = _get_conn()
        stmt = ibm_db.exec_immediate(conn, query)
        rows = []
        row = ibm_db.fetch_assoc(stmt)
        while row:
            rows.append(dict(row))
            row = ibm_db.fetch_assoc(stmt)
        ibm_db.free_result(stmt)
        return rows

def run_admin_cmd(command: str) -> list[dict]:
    with _conn_lock:
        conn = _get_conn()
        safe_command = command.replace("'", "''")
        stmt = ibm_db.exec_immediate(conn, f"CALL SYSPROC.ADMIN_CMD('{safe_command}')")
        rows = []
        row = ibm_db.fetch_assoc(stmt)
        while row:
            rows.append(dict(row))
            row = ibm_db.fetch_assoc(stmt)
        ibm_db.free_result(stmt)
        return rows

run_admin_cmd uses SYSPROC.ADMIN_CMD, the IBM's stored procedure wrapper for CLP commands.

3.3 Agent instructions

The instructions parameter of FastMCP is the text Claude reads on startup. It defines how the agent should behave.

mcp = FastMCP(
    "db2-schema-explorer",
    instructions=(
        "You are a Db2 database assistant with tools to inspect an IBM Db2 LUW database.\n\n"

        "CRITICAL — DATABASE vs SCHEMA:\n"
        "In Db2, the database name (e.g. SAMPLE) is the connection target — it is NOT a schema. "
        "Schemas are namespaces INSIDE the database (e.g. DB2INST1, SYSCAT, SYSTOOLS). "
        "Never use the database name as a schema parameter.\n\n"

        "WORKFLOW:\n"
        "0. When the user asks what databases exist on the server, call list_databases().\n"
        "1. When the user asks about tables 'in the database' or mentions the database name "
        "(e.g. SAMPLE), ALWAYS call list_schemas() first to discover which schemas actually "
        "exist — do not assume the database name is a schema.\n"
        "2. Then call list_tables(schema) for each schema of interest.\n"
        "3. Never guess or assume a schema name. "
        "The instance owner schema is typically DB2INST1, not the database name.\n\n"

        "TOOL SUMMARY:\n"
        "- list_databases()             list all databases in the Db2 instance\n"
        "- list_schemas()               discover all schemas in the database\n"
        "- list_tables(schema)          tables in a specific schema\n"
        "- describe_table(schema,table) column definitions\n"
        "- list_indexes(schema,table)   index definitions\n"
        "- sample_rows(schema,table)    preview a few rows (max 20, no filtering)\n"
        "- execute_query(sql)           run any SELECT — USE THIS for filtering, "
        "aggregation, subqueries, joins, ranking, or any analytical question\n"
        "- explain_query(sql)           explain the access plan for a SELECT and "
        "interpret index usage, joins, sorts and cost estimates\n"
        "- search_columns(name)         find tables that have a column by name\n"
        "- table_dependencies(schema,table) objects that depend on a table\n\n"

        "WHEN TO USE execute_query:\n"
        "Whenever the user asks a question that requires SQL logic — averages, totals, "
        "comparisons, filters, GROUP BY, subqueries — generate the correct SQL and call "
        "execute_query(sql). Never approximate by sampling rows and calculating manually. "
        "Always use fully qualified table names (SCHEMA.TABLE) in the SQL.\n\n"

        "Always use uppercase for all schema and table names."
    ),
)

3.4 The execute_query tool

This is the core tool for natural language analytics. Once Claude has discovered the schema and understood the table structure, execute_query is the function that runs the generated SQL against Db2.

Three design choices matter here:

  • It only accepts SELECT and WITH ... SELECT statements, so the agent cannot mutate data
  • It adds FETCH FIRST n ROWS ONLY automatically when the SQL does not already include a row limit
  • It returns the result as a formatted Markdown table that Claude Code can read and summarize directly
@mcp.tool()
def execute_query(sql: str, max_rows: int = 100) -> str:
    sql_stripped = sql.strip().upper()
    if not sql_stripped.startswith("SELECT") and not sql_stripped.startswith("WITH"):
        return "Error: only SELECT or WITH ... SELECT statements are allowed."

    max_rows = max(1, min(max_rows, 500))
    if "FETCH FIRST" not in sql_stripped:
        sql = f"{sql.rstrip(';')} FETCH FIRST {max_rows} ROWS ONLY"

    try:
        rows = run_sql(sql)
    except Exception as exc:
        return f"Query failed:\n{exc}"
    return json.dumps(rows, indent=2, default=str)

3.5 The explain_query tool

The tool issues EXPLAIN PLAN FOR <sql> via exec_immediate to populate the explain tables, then queries three EXPLAIN tables in sequence: EXPLAIN_OPERATOR for the operator tree and cost estimates, EXPLAIN_PREDICATE for the predicate conditions at each node, and EXPLAIN_ARGUMENT for operator arguments such as access type and scan direction. The result is a formatted Markdown report — operator table, inline predicates, and a plain-English interpretation of index usage, join methods, sort operations, and overall cost.

The EXPLAIN tables must already exist. They are created in Part 1 of this tutorial with the db2 -tf EXPLAIN.DDL command.


Part 4: Connecting to Claude Code

If an older db2-schema entry already exists in Claude Code, remove it first:

claude mcp remove db2-schema

Then add the MCP server:

claude mcp add db2-schema uv run server.py

claude mcp list
# db2-schema   stdio   uv run server.py

claude

Part 5: Test Sessions

At this point, the MCP server is be registered in Claude Code and the sample database is available. The next sessions validate the end-to-end flow: schema discovery, SQL generation, result interpretation, and EXPLAIN-based access path analysis.

Each session gives you a prompt to paste into Claude Code and shows the kind of response you should expect back from the agent.

Session 1 - Database and Schema Discovery

Enter this in Claude Code:

What schemas exist inside SAMPLE?

Claude (calls list_schemas()) and returns something like this:


The SAMPLE database has 3 schemas:

  ┌──────────┬────────┬─────────────────────┐
  │  Schema  │ Owner  │       Created       │
  ├──────────┼────────┼─────────────────────┤
  │ DB2INST1 │ SYSIBM │ 2026-05-17 19:32:20 │
  ├──────────┼────────┼─────────────────────┤
  │ NULLID   │ SYSIBM │ 2026-05-17 19:31:06 │
  ├──────────┼────────┼─────────────────────┤
  │ SQLJ     │ SYSIBM │ 2026-05-17 19:31:06 │
  └──────────┴────────┴─────────────────────┘

  DB2INST1 is the main user schema where your application tables will live. NULLID and SQLJ are system schemas used internally by Db2. Want me to list
  the tables in DB2INST1?

Enter this in Claude Code:

List the tables in the DB2INST1 schema.

Claude (calls list_tables("DB2INST1")) and returns something like this (the way Claude actually formats the answer to you can be different):


The DB2INST1 schema has 15 tables:

  ┌────────────┬───────────┬───────┐
  │   Table    │ Row Count │ Pages │
  ├────────────┼───────────┼───────┤
  │ ACT        │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ CL_SCHED   │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ DEPARTMENT │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ EMPLOYEE   │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ EMPMDC     │ 10,000    │ 40    │
  ├────────────┼───────────┼───────┤
  │ EMPPROJACT │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ EMP_PHOTO  │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ EMP_RESUME │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ IN_TRAY    │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ ORG        │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ PROJACT    │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ PROJECT    │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ SALES      │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ STAFF      │ —         │ —     │
  ├────────────┼───────────┼───────┤
  │ STAFFG     │ —         │ —     │
  └────────────┴───────────┴───────┘

  These are the classic Db2 SAMPLE database tables. Most show -1 for row count (stats not yet collected), except EMPMDC which has 10,000 rows. Would
  you like me to describe any of these tables or run a query?

Session 2 - Table Structure and Indexes

Enter this in Claude Code:

Describe the structure of the EMPLOYEE table.

Claude (calls describe_table("DB2INST1", "EMPLOYEE"))

The DB2INST1.EMPLOYEE table has 14 columns:

  ┌─────┬───────────┬──────────────┬────────┬──────────┬─────────────────┐
  │  #  │  Column   │   Type       │ Length │ Nullable │      Notes      │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 1   │ EMPNO     │ CHAR         │ 6      │ No       │ Primary key     │                                                               
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 2   │ FIRSTNME  │ VARCHAR      │ 12     │ No       │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 3   │ MIDINIT   │ CHAR         │ 1      │ Yes      │ Middle initial  │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 4   │ LASTNAME  │ VARCHAR      │ 15     │ No       │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 5   │ WORKDEPT  │ CHAR         │ 3      │ Yes      │ FK → DEPARTMENT │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 6   │ PHONENO   │ CHAR         │ 4      │ Yes      │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 7   │ HIREDATE  │ DATE         │ —      │ Yes      │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 8   │ JOB       │ CHAR         │ 8      │ Yes      │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 9   │ EDLEVEL   │ SMALLINT     │ —      │ No       │ Education level │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 10  │ SEX       │ CHAR         │ 1      │ Yes      │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 11  │ BIRTHDATE │ DATE         │ —      │ Yes      │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 12  │ SALARY    │ DECIMAL(9,2) │ —      │ Yes      │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 13  │ BONUS     │ DECIMAL(9,2) │ —      │ Yes      │                 │
  ├─────┼───────────┼──────────────┼────────┼──────────┼─────────────────┤
  │ 14  │ COMM      │ DECIMAL(9,2) │ —      │ Yes      │ Commission      │
  └─────┴───────────┴──────────────┴────────┴──────────┴─────────────────┘

  EMPNO is the primary key (6-char fixed), WORKDEPT links to the DEPARTMENT table. The three compensation columns — SALARY, BONUS, and COMM — are all
  DECIMAL(9,2). Would you like to sample some rows or run a query against this table?

Enter this in Claude Code:

What indexes does EMPLOYEE have?

Claude (calls list_indexes("DB2INST1", "EMPLOYEE"))

The EMPLOYEE table has 2 indexes:

┌─────────────┬──────────────────────┬───────────┐
│    Index    │         Type         │ Clustered │
├─────────────┼──────────────────────┼───────────┤
│ PK_EMPLOYEE │ Primary key (unique) │ —         │
├─────────────┼──────────────────────┼───────────┤
│ XEMP2       │ Non-unique           │ —         │
└─────────────┴──────────────────────┴───────────┘

  The -1 values for NLEAF, NLEVELS, and CLUSTERFACTOR indicate statistics haven't been collected yet (RUNSTATS hasn't been run). XEMP2 is likely on
  WORKDEPT to support department-based lookups. Would you like to sample some rows or run a query to explore the data?

Session 3 - Simple Query: Subquery Filter

Enter this in Claude Code:

Get all employees who earn more than the average salary. Show the generated SQL.

Claude (calls execute_query(sql))

SQL generated:

SELECT EMPNO, FIRSTNME, LASTNAME, WORKDEPT, JOB, SALARY
FROM DB2INST1.EMPLOYEE
WHERE SALARY > (SELECT AVG(SALARY) FROM DB2INST1.EMPLOYEE)
ORDER BY SALARY DESC

Result:

16 employees earn above the average salary:

  ┌────────┬────────────────────┬──────┬──────────┬─────────────┐
  │ EMPNO  │       Name         │ Dept │   Job    │   Salary    │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000010 │ Christine Haas     │ A00  │ PRES     │ $152,750.00 │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000030 │ Sally Kwan         │ C01  │ MANAGER  │ $98,250.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000070 │ Eva Pulaski        │ D21  │ MANAGER  │ $96,170.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000020 │ Michael Thompson   │ B01  │ MANAGER  │ $94,250.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000090 │ Eileen Henderson   │ E11  │ MANAGER  │ $89,750.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000100 │ Theodore Spenser   │ E21  │ MANAGER  │ $86,150.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000050 │ John Geyer         │ E01  │ MANAGER  │ $80,175.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000130 │ Delores Quintana   │ C01  │ ANALYST  │ $73,800.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000060 │ Irving Stern       │ D11  │ MANAGER  │ $72,250.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 200220 │ Reba John          │ D11  │ DESIGNER │ $69,840.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000140 │ Heather Nicholls   │ C01  │ ANALYST  │ $68,420.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 200140 │ Kim Natz           │ C01  │ ANALYST  │ $68,420.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000210 │ William Jones      │ D11  │ DESIGNER │ $68,270.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000110 │ Vincenzo Lucchessi │ A00  │ SALESREP │ $66,500.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 200170 │ Kiyoshi Yamamoto   │ D11  │ DESIGNER │ $64,680.00  │
  ├────────┼────────────────────┼──────┼──────────┼─────────────┤
  │ 000160 │ Elizabeth Pianka   │ D11  │ DESIGNER │ $62,250.00  │
  └────────┴────────────────────┴──────┴──────────┴─────────────┘

  The results are dominated by managers and the president, with a few analysts and designers making the cut. Would you like to see the average salary
  value, or dig deeper into any of these results?

8 employees earn above the company average salary of approximately $67,932.

Session 4 - Intermediate Query: GROUP BY and HAVING

Enter this in Claude Code:

Show me the average salary, headcount, and total payroll                                                                                             
per department, but only for departments with more than 3 employees. show the generated SQL

Claude (calls execute_query(sql))

SQL generated:

SELECT WORKDEPT,
         COUNT(*)          AS HEADCOUNT,
         AVG(SALARY)       AS AVG_SALARY,
         SUM(SALARY)       AS TOTAL_PAYROLL
  FROM DB2INST1.EMPLOYEE
  GROUP BY WORKDEPT
  HAVING COUNT(*) > 3
  ORDER BY TOTAL_PAYROLL DESC

Result:

6 departments qualify (more than 3 employees):

  ┌──────┬───────────┬────────────┬───────────────┐
  │ Dept │ Headcount │ Avg Salary │ Total Payroll │
  ├──────┼───────────┼────────────┼───────────────┤
  │ D11  │        11 │ $58,783.64 │   $646,620.00 │
  ├──────┼───────────┼────────────┼───────────────┤
  │ D21  │         7 │ $51,240.00 │   $358,680.00 │
  ├──────┼───────────┼────────────┼───────────────┤
  │ A00  │         5 │ $70,850.00 │   $354,250.00 │
  ├──────┼───────────┼────────────┼───────────────┤
  │ E11  │         7 │ $45,305.71 │   $317,140.00 │
  ├──────┼───────────┼────────────┼───────────────┤
  │ C01  │         4 │ $77,222.50 │   $308,890.00 │
  ├──────┼───────────┼────────────┼───────────────┤
  │ E21  │         6 │ $47,086.67 │   $282,520.00 │
  └──────┴───────────┴────────────┴───────────────┘

  A few highlights:
  - D11 has the largest headcount (11) and highest total payroll ($646k).
  - C01 has the highest average salary ($77,222) despite only 4 employees.
  - E11 has the lowest average salary ($45,305) among qualifying departments.

  Would you like to join this with the DEPARTMENT table to get department names, or explore further?

Session 5 - Advanced Query: Window Functions and CTE

Enter this in Claude Code:

For each department, show the highest-paid employee and their                                                                                        
salary rank within the entire company. Show the generated SQL 

Claude (calls execute_query(sql))

SQL generated:

WITH RANKED AS (
    SELECT
      EMPNO, FIRSTNME, LASTNAME, WORKDEPT, SALARY,
      RANK()       OVER (ORDER BY SALARY DESC)                    AS COMPANY_RANK,
      ROW_NUMBER() OVER (PARTITION BY WORKDEPT ORDER BY SALARY DESC) AS DEPT_RANK
    FROM DB2INST1.EMPLOYEE
  )
  SELECT WORKDEPT, EMPNO, FIRSTNME, LASTNAME, SALARY, COMPANY_RANK
  FROM RANKED
  WHERE DEPT_RANK = 1
  ORDER BY SALARY DESC

Result:

Top earner per department with company-wide salary rank:

  ┌──────┬────────┬──────────────────┬─────────────┬──────────────┐
  │ Dept │ EMPNO  │       Name       │   Salary    │ Company Rank │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ A00  │ 000010 │ Christine Haas   │ $152,750.00 │      #1      │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ C01  │ 000030 │ Sally Kwan       │  $98,250.00 │      #2      │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ D21  │ 000070 │ Eva Pulaski      │  $96,170.00 │      #3      │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ B01  │ 000020 │ Michael Thompson │  $94,250.00 │      #4      │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ E11  │ 000090 │ Eileen Henderson │  $89,750.00 │      #5      │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ E21  │ 000100 │ Theodore Spenser │  $86,150.00 │      #6      │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ E01  │ 000050 │ John Geyer       │  $80,175.00 │      #7      │
  ├──────┼────────┼──────────────────┼─────────────┼──────────────┤
  │ D11  │ 000060 │ Irving Stern     │  $72,250.00 │      #9      │
  └──────┴────────┴──────────────────┴─────────────┴──────────────┘

  A few observations:
  - Every department's top earner is their manager (or president for A00).
  - D11 is notable — its top earner ranks only #9 company-wide despite being the largest department (11 employees).
  - The gap between rank #1 (Christine Haas, $152k) and #2 (Sally Kwan, $98k) is over $54k.
  - Rank #8 is missing from the results because it belongs to an employee not at the top of any department.

  Would you like to join with the DEPARTMENT table to show department names, or explore something else?

Session 6 - EXPLAIN: Simple Subquery

Enter this in Claude Code:

Explain the access path for the query that finds employees                                                                                           
earning above average salary. Show the generated SQL.

Claude (calls explain_query(sql))

SQL explained:

SELECT EMPNO, FIRSTNME, LASTNAME, SALARY
FROM DB2INST1.EMPLOYEE
WHERE SALARY > (SELECT AVG(SALARY) FROM DB2INST1.EMPLOYEE)
Query

  SELECT EMPNO, FIRSTNME, LASTNAME, SALARY
  FROM DB2INST1.EMPLOYEE
  WHERE SALARY > (SELECT AVG(SALARY) FROM DB2INST1.EMPLOYEE)

  ---
  Operator Tree (execution order — bottom up)

  RETURN (Op 1)
    └── NLJOIN (Op 2)          ← applies SALARY > avg filter
          ├── GRPBY  (Op 3)    ← computes AVG(SALARY) → 1 row
          │     └── TBSCAN (Op 4)  ← scan #1: all 42 rows, SALARY only
          └── TBSCAN (Op 5)    ← scan #2: all 42 rows, EMPNO/NAME/SALARY

  ---
  Step-by-step

  ┌──────┬───────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────┐
  │ Step │   Operator    │                                           What happens                                            │ Rows in / out │
  ├──────┼───────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────┤
  │ 1    │ TBSCAN (Op 4) │ Full table scan of EMPLOYEE, projecting only SALARY for the subquery                              │ 42 → 42       │
  ├──────┼───────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────┤
  │ 2    │ GRPBY (Op 3)  │ Computes AVG(SALARY) over those 42 rows                                                           │ 42 → 1        │
  ├──────┼───────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────┤
  │ 3    │ TBSCAN (Op 5) │ Second full table scan of EMPLOYEE, projecting EMPNO, FIRSTNME, LASTNAME, SALARY                  │ 42 → 42       │
  ├──────┼───────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────┤
  │ 4    │ NLJOIN (Op 2) │ Joins the outer scan (~42 rows) with the single AVG row; applies SALARY > avg as join predicate   │ 42 × 1 → ~12  │
  ├──────┼───────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────┤
  │ 5    │ RETURN (Op 1) │ Sends ~12 result rows to the client                                                               │ 12            │
  └──────┴───────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────┘

  ---
  Key observations

  Two table scans. Db2 reads the 42-row table twice — once to calculate the average (subquery), once to filter the outer rows. With CARD=42 on 1 page
  this costs only 2 I/Os total, which is negligible.

  No indexes involved. SALARY has no index. For a scalar comparison like SALARY > avg an index wouldn't help much anyway unless the table were large,
  because roughly half the rows would still qualify (here ~28%, ~12 of 42).

  Scalar subquery → NLJOIN. Db2's optimizer did not execute the subquery once and plug in a literal. Instead it materialized the AVG into a 1-row GRPBY
  result and used a Nested Loop Join to apply it against the outer scan. Because the inner side always produces exactly 1 row, NLJOIN degenerates to a
  simple filter — functionally equivalent to computing the average once.

  Cost profile. Total cost is 13.6 timerons, IO cost 2.0 (2 page reads), CPU cost ~307 K instructions. The CPU dominates — all the work is in scanning
  rows, computing the average, and evaluating the predicate, not in I/O.

  Scalability note. If EMPLOYEE grew to millions of rows, you would see this become expensive: two full scans with no index help. Options to improve at
  scale include a materialized view for the average, or rewriting with a window function (AVG(SALARY) OVER ()), which lets Db2 compute the average in
  a single pass.

Enter this in Claude Code:

In which tables is the department number used?

Claude (calls search_columns("DEPT"))

Department-related columns appear across many objects. Here's a clean breakdown by category:
 
  Base tables (DB2INST1)

 ┌────────────┬──────────┬──────────┬──────────┬──────────────────────────────────────────┐
  │   Table    │       Column        │   Type   │                  Notes                   │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ DEPARTMENT │ DEPTNO              │ CHAR     │ Primary key                              │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ DEPARTMENT │ ADMRDEPT            │ CHAR     │ Self-referencing FK (administering dept) │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ EMPLOYEE   │ WORKDEPT            │ CHAR     │ FK → DEPARTMENT.DEPTNO                   │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ ADEFUSR    │ WORKDEPT            │ CHAR     │                                          │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ PROJECT    │ DEPTNO              │ CHAR     │ FK → DEPARTMENT.DEPTNO                   │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ STAFF      │ DEPT                │ SMALLINT │ Numeric dept number (different model)    │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ STAFFG     │ DEPT                │ SMALLINT │ Same as STAFF                            │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ EMPMDC     │ DEPT                │ INTEGER  │ Numeric dept number                      │
  ├────────────┼─────────────────────┼──────────┼──────────────────────────────────────────┤
  │ ORG        │ DEPTNUMB / DEPTNAME │ SMALLINT │ Separate org hierarchy table             │
  └────────────┴─────────────────────┴──────────┴──────────────────────────────────────────┘

  Views (DB2INST1)

  ┌────────────┬─────────────────────────────────────┐
  │    View    │              Column(s)              │
  ├────────────┼─────────────────────────────────────┤
  │ VDEPT      │ DEPTNO, ADMRDEPT, DEPTNAME          │
  ├────────────┼─────────────────────────────────────┤
  │ VDEPMG1    │ DEPTNO, ADMRDEPT, DEPTNAME          │
  ├────────────┼─────────────────────────────────────┤
  │ VHDEPT     │ DEPTNO, ADMRDEPT, DEPTNAME          │
  ├────────────┼─────────────────────────────────────┤
  │ VEMP       │ WORKDEPT                            │
  ├────────────┼─────────────────────────────────────┤
  │ VEMPDPT1   │ DEPTNO, WORKDEPT, DEPTNAME          │
  ├────────────┼─────────────────────────────────────┤
  │ VPHONE     │ DEPTNUMBER, DEPTNAME                │
  ├────────────┼─────────────────────────────────────┤
  │ VPROJ      │ DEPTNO                              │
  ├────────────┼─────────────────────────────────────┤
  │ VASTRDE1/2 │ DEPT1NO, DEPT2NO (multi-dept views) │
  └────────────┴─────────────────────────────────────┘

  Key observations:
  - The main FK chain is EMPLOYEE.WORKDEPT → DEPARTMENT.DEPTNO → DEPARTMENT.ADMRDEPT (self-join for hierarchy).
  - STAFF, STAFFG, and EMPMDC use a numeric DEPT column — a separate, denormalized model not tied to the DEPARTMENT table by FK.
  - Several views pre-join department data, with VEMPDPT1 being the most useful for employee+department queries.

  Would you like to explore the DEPARTMENT table structure or the table dependencies?

Session 8 - Natural Language: Private Data Identification

Enter this in Claude Code:

Identify the columns in this database that likely contain
private or sensitive personal data.

Claude (calls search_columns multiple times across patterns: NAME, BIRTH, PHONE, SALARY, SEX, PHOTO, RESUME, ADDRESS — then describe_table for EMP_PHOTO and EMP_RESUME)

I'll search for common PII-related column name patterns in parallel.

  Called db2-schema 9 times (ctrl+o to expand)

...
Here is a full PII/sensitive data inventory across DB2INST1 base tables:

  ---
  High sensitivity — directly identifies individuals

  ┌──────────┬───────────┬────────────┬──────────────────────────┐
  │  Table   │  Column   │    Type    │           Risk           │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ EMPLOYEE │ FIRSTNME  │ VARCHAR    │ Full name                │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ EMPLOYEE │ LASTNAME  │ VARCHAR    │ Full name                │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ EMPLOYEE │ MIDINIT   │ CHAR       │ Full name                │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ STAFF    │ NAME      │ VARCHAR    │ Full name                │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ STAFFG   │ NAME      │ VARGRAPHIC │ Full name (graphic/DBCS) │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ EMPLOYEE │ BIRTHDATE │ DATE       │ Date of birth            │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ EMPLOYEE │ SEX       │ CHAR       │ Gender                   │
  ├──────────┼───────────┼────────────┼──────────────────────────┤
  │ EMPLOYEE │ PHONENO   │ CHAR       │ Phone number             │
  └──────────┴───────────┴────────────┴──────────────────────────┘

  ---
  High sensitivity — financial / compensation

  ┌──────────┬────────┬─────────┬───────────────────────┐
  │  Table   │ Column │  Type   │         Risk          │
  ├──────────┼────────┼─────────┼───────────────────────┤
  │ EMPLOYEE │ SALARY │ DECIMAL │ Individual salary     │
  ├──────────┼────────┼─────────┼───────────────────────┤
  │ EMPLOYEE │ BONUS  │ DECIMAL │ Individual bonus      │
  ├──────────┼────────┼─────────┼───────────────────────┤
  │ EMPLOYEE │ COMM   │ DECIMAL │ Individual commission │
  ├──────────┼────────┼─────────┼───────────────────────┤
  │ STAFF    │ SALARY │ DECIMAL │ Individual salary     │
  ├──────────┼────────┼─────────┼───────────────────────┤
  │ STAFF    │ COMM   │ DECIMAL │ Individual commission │
  ├──────────┼────────┼─────────┼───────────────────────┤
  │ STAFFG   │ SALARY │ DECIMAL │ Individual salary     │
  ├──────────┼────────┼─────────┼───────────────────────┤
  │ STAFFG   │ COMM   │ DECIMAL │ Individual commission │
  └──────────┴────────┴─────────┴───────────────────────┘

  ---
  High sensitivity — unstructured personal content (LOB)

  ┌────────────┬─────────┬────────────────┬─────────────────────────────────────┐
  │   Table    │ Column  │      Type      │                Risk                 │
  ├────────────┼─────────┼────────────────┼─────────────────────────────────────┤
  │ EMP_PHOTO  │ PICTURE │ (BLOB implied) │ Biometric / facial image            │
  ├────────────┼─────────┼────────────────┼─────────────────────────────────────┤
  │ EMP_RESUME │ RESUME  │ CLOB           │ Full CV — names, history, education │
  └────────────┴─────────┴────────────────┴─────────────────────────────────────┘

  ---
  Summary by regulation relevance

  ┌────────────────────────────┬──────────────────────────────────────────────────────────────┐
  │         Regulation         │                       Columns in scope                       │
  ├────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ GDPR / PIPEDA              │ FIRSTNME, LASTNAME, BIRTHDATE, SEX, PHONENO, PICTURE, RESUME │
  ├────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ Pay equity / HR compliance │ SALARY, BONUS, COMM                                          │
  ├────────────────────────────┼──────────────────────────────────────────────────────────────┤
  │ Biometric data laws        │ PICTURE (EMP_PHOTO)                                          │
  └────────────────────────────┴──────────────────────────────────────────────────────────────┘

  The EMP_PHOTO and EMP_RESUME tables carry the highest risk — LOB columns storing biometric images and full résumés are subject to strict data
  minimization and retention rules under most privacy frameworks. Would you like to sample any of these tables or check who has access to them?

Session 9 - Discover not obvious insights on the database data

Now let’s run a more demanding query. Enter this in Claude Code:

show me not obvious insights about the business data in this database

You will get something like this.

1. Zero employees aged 25–40 — a missing generation
  
  Out of 42 employees, not a single one is between 25 and 40 years old. There are 4 under 25 and 38 over 40. This points to a hard hiring freeze or workforce restructuring that lasted roughly 15
  years.

  ---
  2. Women out-earn men by 17% on average
  
  ┌─────┬───────────┬────────────┬────────────┐
  │ Sex │ Headcount │ Avg Salary │ Max Salary │
  ├─────┼───────────┼────────────┼────────────┤
  │ F   │ 19        │ $63,244    │ $152,750   │
  ├─────┼───────────┼────────────┼────────────┤
  │ M   │ 23        │ $53,952    │ $94,250    │
  └─────┴───────────┴────────────┴────────────┘

  The gap persists even within the MANAGER role ($94,723 F vs $83,206 M) and DESIGNER ($58,318 F vs $56,850 M). The only role where men win is SALESREP ($66,500 M vs $46,500 F — a 43% male
  premium). The ANALYST role is entirely female (3 of 3).

  ---
  3. A 23-year-old manager earning $96,170

  Eva Pulaski (D21 – Administration Systems) is 23 years old, holds a MANAGER title, and earns $96,170 — more than many employees twice her age and more than the average male manager ($83,206).
  She is the highest-paid employee in her department by a wide margin.

  ---
  4. A manager with no university-level education

  Theodore Spenser is a MANAGER earning $86,150 with education level 14 (equivalent to high school). Every other manager is at level 16 or above. He rose to management through experience, not
  credentials.

  ---
  5. Education level 17 earns less than level 16 — non-monotonic

  ┌──────────┬────────────┐
  │ Ed Level │ Avg Salary │
  ├──────────┼────────────┤
  │ 16       │ $60,561    │
  ├──────────┼────────────┤
  │ 17       │ $50,126    │
  ├──────────┼────────────┤
  │ 18       │ $78,574    │
  └──────────┴────────────┘

  The extra year of education at level 17 is actually associated with lower pay. The reason: level 16 includes several managers and an analyst, while level 17 is populated entirely by Designers
  and Clerks.

  ---
  6. Six ghost departments — exist on paper, no staff, no manager

  ┌─────────┬─────────────────────────────┐
  │  Dept   │            Name             │
  ├─────────┼─────────────────────────────┤
  │ D01     │ DEVELOPMENT CENTER          │
  ├─────────┼─────────────────────────────┤
  │ F22–J22 │ BRANCH OFFICE F2 through J2 │
  └─────────┴─────────────────────────────┘

  All six have zero employees and no assigned manager. The Development Center in particular signals either a planned expansion that never happened or a unit that was wound down without being
  deleted.

  ---
  7. The pay hierarchy is perfectly strict — no one ever out-earns their manager

  Across all 42 employees, zero cases where a direct report earns more than the department manager. This is unusually clean — in most real-world orgs, senior individual contributors regularly
  exceed their manager's salary.

  ---
  8. The President's salary distorts the entire female average
  
  Christine Haas (PRES, age 63, $152,750) is 2.3× the next-highest earner in her own department and accounts for much of the female salary advantage. Remove her and the gender gap narrows
  significantly. She is also the oldest employee at a senior role — suggesting a founder or long-tenured executive profile.

  ---
  Summary table of the key anomalies:

  ┌────────────────────────────────┬────────────────────────────────────────────┐
  │            Finding             │             Surprising because             │
  ├────────────────────────────────┼────────────────────────────────────────────┤
  │ No one aged 25–40              │ Implies ~15-year hiring gap                │
  ├────────────────────────────────┼────────────────────────────────────────────┤
  │ Women earn more on average     │ Reverses the typical expectation           │
  ├────────────────────────────────┼────────────────────────────────────────────┤
  │ 23-year-old manager at $96K    │ Extreme outlier on age vs seniority        │
  ├────────────────────────────────┼────────────────────────────────────────────┤
  │ EDLEVEL 17 < EDLEVEL 16 in pay │ Education ROI is non-linear                │
  ├────────────────────────────────┼────────────────────────────────────────────┤
  │ 6 departments with no staff    │ Org chart is out of sync with reality      │
  ├────────────────────────────────┼────────────────────────────────────────────┤
  │ Perfectly strict pay hierarchy │ Unusually rigid — no IC/manager inversions │
  └────────────────────────────────┴────────────────────────────────────────────┘

Summary

ComponentRoleWhere
IBM Db2 12.1 CommunityRelational databaseDocker (port 50000)
ibm_db + CLI driverNative Python driverInstalled by pip
server.pyMCP server — 9 tools + 1 resourceLocal, uv run
Claude CodeMCP client + LLMLocal

The simple MCP server covers the full spectrum from basic discovery to complex analytical queries and access path analysis, all driven by natural language. The agent generates SQL, executes it against the live database, explains the optimizer's access plan, and interprets the results without the user writing a single line of SQL.


Cleanup

After finishing the lab, stop the containers and remove the local project files if you no longer need them.

Stop and remove the Db2 environment, including the database volume:

docker compose down -v

If you also want to remove the local tutorial directory:

cd ..
rm -rf db2-mcp-tutorial
More in this area

More in this area

Back to training
Article category

Db2 LUW Articles

Browse all technical articles for Db2 LUW in one category page.

Open category Db2 LUW