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:
| Tool | Purpose |
|---|---|
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-tutorial1.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:
EOF1.2 Start the container
Execute to start the Db2 container.
docker compose up -dThe 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 psIf it is still starting, wait about 30 seconds and check again:
sleep 30
docker psDo 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 secondsVerify 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: 42Create 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
`- .envCreate 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",
]
EOFCreate the local Python environment and install the project dependencies:
uv venvcreates an isolated virtual environment in.venvuv pip install -e .installs the package in editable mode and pulls the dependencies declared inpyproject.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
EOFPart 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.pyAnd 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 _conn3.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 rowsrun_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
SELECTandWITH ... SELECTstatements, so the agent cannot mutate data - It adds
FETCH FIRST n ROWS ONLYautomatically 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-schemaThen add the MCP server:
claude mcp add db2-schema uv run server.py
claude mcp list
# db2-schema stdio uv run server.py
claudePart 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 DESCResult:
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 SQLClaude (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 DESCResult:
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 DESCResult:
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.Session 7 - Natural Language: Cross-Table Column Search
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 databaseYou 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
| Component | Role | Where |
|---|---|---|
| IBM Db2 12.1 Community | Relational database | Docker (port 50000) |
ibm_db + CLI driver | Native Python driver | Installed by pip |
server.py | MCP server — 9 tools + 1 resource | Local, uv run |
| Claude Code | MCP client + LLM | Local |
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 -vIf you also want to remove the local tutorial directory:
cd ..
rm -rf db2-mcp-tutorial