MCP_CUSTOM_TOOLS to a YAML file, a directory, or a comma-separated list of either. The server reads them once at startup and refuses to start when a file is invalid or a statement fails the read-only check. When QUERY_ALLOWED_SCHEMAS is set, every table reference must stay inside that list too.
An empty or unset MCP_CUSTOM_TOOLS loads nothing. The built-in tools keep working.
See examples/erp-tools for a generic pack: sales orders, purchase orders, service orders, manufacturing orders, a bill of materials, the general ledger, and item and customer master data. The statements show patterns that show up on real order files: a numeric date, a derived status, a header with jobs and lines, and a text search. Every library, table, and column name in that pack is a placeholder. Point them at your own files before you load the directory.
File format
version must be 1. A file needs at least one tool, one annotation, one masking rule, or instructions.
Tools
Parameter types are
string (optional maxLength), integer, number, boolean, date, and enum. A string parameter may also set enum to a list of allowed values. date values are YYYY-MM-DD.
A parameter with a default may be omitted, and the default is bound. required: false with no default may be omitted, and NULL is bound. Anything else must be sent by the client.
Boolean values bind as 1 and 0.
:name is replaced with a ? marker. The same name may appear more than once, and the same value is bound each time. Placeholders inside string literals, quoted identifiers, and comments are left as text. A hand-written ? is rejected so the bind order stays unambiguous.
An optional filter has to survive a NULL. Compare a cast marker, then the column:
CAST(:from_date AS DATE) when the column is a real DATE. A numeric YYYYMMDD column needs the conversion in Common patterns. The cast gives the marker a type when the argument is NULL.
Running on one system
With several systems inDB2I_PROFILES, set system: to pin a tool to one of them:
- A pinned tool has no
systemargument and always runs on its system. An unknown name stops startup. - A tool without
system:gets the same optionalsystemargument as the built-in tools, and runs on the default (first) system when the caller names none. A tool that declares its own parameter namedsystemgets no extra argument and runs on the default system. - At startup a pinned tool is checked against its system’s allowlist. Other tools are checked against the default system’s list. Every call is checked again against the system it runs on.
- An HTTP session that logged in to one system does not list tools pinned to another.
Annotations
Keys areSCHEMA.TABLE. Names are folded to uppercase.
A relation names the other
SCHEMA.TABLE, a join map of local column to remote column, an optional cardinality (one-to-one, one-to-many, many-to-one, many-to-many), and an optional description.
get_business_context returns these notes. Filter with entity, table (ORDERHDR or MYLIB.ORDERHDR), or omit both to list every annotation. The entity filter ignores case and treats spaces and hyphens as underscores. When no entity has the requested name, it returns the entities whose names partly match it (one name contains the other, or they share the most word parts, such as order and line) with partial_match: true and a hint. When nothing matches, it returns empty data with available_entities, plus available_tables for a table filter, and a hint to pick one of them or omit the filters. The table filter itself stays exact. describe_table adds filters, business_description and relations when the table is annotated, and a business_description on columns that have one. list_tables adds business_description on annotated tables.
Row filters
A filter has the SQL a query should include (sql), the columns it uses, and an optional reason. Use it for rules that are easy to miss and change the answer, such as a flag that marks deleted rows. Deleted child rows often sit under a live header, so annotate the line table too, not only the header.
get_business_contextlistsfiltersfirst in each table, anddescribe_tableputs them before the column list.- With
QUERY_PARSE_CHECKon (the default),execute_query,export_queryandvalidate_querycheck each statement against the filters of the annotated tables it reads. When none of a filter’s columns appears in the statement, qualified with that table or unqualified, the query still runs. The result lists the table inskippedFiltersand adds a warning that quotes the filter and its reason. The check looks for the column, not the exact predicate, and never blocks a query, since some questions need deleted rows. With the parse check off, there is no filter check. validate_queryreportsskippedFiltersandwarningswithout changingvalid.validate-tools --connectchecks that each filter column exists on its table.- Business SQL tools are not checked. Put the filter in their SQL.
Instructions
instructions is an optional top-level text. All files together may hold at most 4000 characters of it. The server sends it to clients as MCP server instructions: in the initialize result for 2025-era clients, and in the server/discover result on protocol 2026-07-28, which has no initialize. Either way the model has it for the whole session without calling a tool first. Use it for the few rules no query may miss, such as which flag marks a deleted row. Table and column detail belongs in annotations. Put a deleted-row flag in the table’s filters too: the instructions arrive once at the start, while a filter is checked against every query.
instructions. Texts from several files are joined in file order.
A file that also defines tools sends its text only to sessions that have at least one of those tools. MCP_TOOLS_ENABLED, MCP_TOOLS_DISABLED and a tool’s system: decide that. So write guidance about a file’s tools, such as “use search_sales_orders for order questions”, in that file, and put rules that every query must follow in a file without tools. The example pack keeps them in rules.yaml.
When custom files are loaded, the server puts a short built-in part first:
- with annotations and
get_business_contextordescribe_tableenabled: read a table’s business context with the enabled ones before writing SQL against it - with a business tool registered for the session: prefer a business tool when one answers the question
MCP_CUSTOM_TOOLS sends no instructions.
Some clients do not pass server instructions to the model. So when annotations are loaded and get_business_context or describe_table is enabled, the descriptions of execute_query and export_query also end with a sentence that asks the model to call that tool for a table before querying it. Tool descriptions reach the model in every client.
Clients read instructions when they connect, from the initialize or server/discover result. With MCP_CUSTOM_TOOLS_WATCH, a change reaches the next such request over HTTP, because the default stateless mode builds the server for each request. A stdio server, and a session in the deprecated MCP_SESSION_MODE=stateful mode, keep the text they started with. The sentence in the execute_query and export_query descriptions follows the reload, and stdio servers and stateful sessions also get notifications/tools/list_changed. Clients decide what to do with server instructions. Claude Code adds them to the model’s context. Check your client if the rules do not seem to reach the model.
Masking
Amasking section names columns the agent should not see in full. Keys are SCHEMA.TABLE. Column names are unquoted SQL names. Both are folded to uppercase. The same table and column in two files is rejected.
execute_query and YAML tools may select a masked column only as a plain item in the outer select list: EMAIL, C.EMAIL, or MYLIB.CUSTOMERS.EMAIL. SELECT * is allowed, and the matching result keys are masked. An alias, an expression, a predicate, a join, GROUP BY, ORDER BY, a subquery, and UNION, EXCEPT, or INTERSECT are rejected. An ORDER BY position (ORDER BY 2) is rejected too, because it can point at the masked column.
An unqualified EMAIL counts as the masked column whenever MYLIB.CUSTOMERS is in the statement, even when another table also has a column of that name. A view, an alias object, or a table function over a masked table is not masked unless that object is listed itself.
YAML tools are checked when the files load, including a rule that lives in a different file from the tool. execute_query uses QSYS2.PARSE_STATEMENT to see which tables the statement touches, so it refuses to run when masking is loaded and QUERY_PARSE_CHECK is off. extended metadata=true in DB2I_JDBC_OPTIONS renames result columns, and the server refuses to start with that option while masking is loaded.
profile_table applies the same rules: a masked column keeps its distinct and null counts and returns no low, high, minimum, or maximum value.
See Security for why this is a backstop and not a database control.
Common patterns
The example pack uses placeholder names (MYLIB.ORDERHDR, ORDERNO, ORDERDAT). Copy the shape of the statement, then rename every identifier to the files you actually have. The status numbers below are an example ladder, not a standard.
An optional filter has to accept NULL
An omitted optional argument is bound as NULL.NULL = NULL is unknown, so a bare comparison drops every row. Test the cast first:
:name in both places. The same value is bound each time. TRIM matters when the column is a fixed-length character field with trailing blanks.
A date stored as a number
Many order files store the day as an integerYYYYMMDD, not as a DATE. A date parameter arrives as text YYYY-MM-DD. Build the number with SUBSTR and compare it to the column:
DIGITS, which zero-pads it to the width of the column:
REPLACE to strip the dashes. The read-only check treats REPLACE as a data-changing statement and the server will not start. TRANSLATE is safe if you prefer it, and so is the SUBSTR form above.
When the column is a real DATE, compare it directly. The general-ledger example does this with TRANSDATE:
Derived status
A single column rarely matches the word a person uses (“open”, “closed”, “invoiced”). Compute it in the statement and document the codes on the annotation. The sales example treatsSTATFLG = 'E' as an error, status 60 as invoiced, status 40 and above as picked, and anything else as open. Deleted rows (STATFLG = 'D') are filtered out rather than counted.
A service order is often “closed” only when every job under it is closed. That needs the jobs in the same statement:
GROUP BY.
Header, job, and line
A service order in the example pack is three files:SVCHDRis the header, and it joinsORDTYPEwhereSVCFLAG = 'Y'so ordinary sales types stay outSVCJOBis one job package and points at an installed unit withITEMNOandSERIALNOSVCLINEis a labor or part line on that job
get_service_order returns one row per job. list_service_order_lines returns the lines. Put that shape in the annotations too, so get_business_context can explain a join the catalog does not declare.
Text search
Match several columns, and useEXISTS when the value lives on a child row. Fold case on both sides:
% does not use an index. Keep maxRows small, and require the text argument so a client cannot scan the whole file by accident.
Row caps
LeaveLIMIT and FETCH FIRST out of the YAML. Set maxRows on the tool. The server appends FETCH FIRST n ROWS ONLY and will not go above QUERY_MAX_LIMIT.
What is still enforced
Custom tools use the read-only connection, the rate limiter, and the read-only tool hints. At startup the server runs the same read-only check asexecute_query. When QUERY_ALLOWED_SCHEMAS is set, it also checks every table reference, and refuses to start if a statement names another library or cannot be parsed. Qualify tables with a library (MYLIB.ORDERHDR) so the check does not depend on the session schema. At call time the allowlist is checked again with that session’s default schema, which matters for unqualified names.
When QUERY_PARSE_CHECK is on, the first call of each tool asks QSYS2.PARSE_STATEMENT whether the statement is a query. That result is cached for the life of the process. A missing function rejects the tool until you set QUERY_PARSE_CHECK=false.
mcp-server-db2i validate-tools <path...> runs those startup checks and exits, without a database. Add --connect to run the PARSE_STATEMENT check as well, and to check that annotation filter columns exist. That needs credentials and a reachable host. See Validating tool files.
MCP_CUSTOM_TOOLS_WATCH=true runs the same checks again when a watched file changes. A valid set replaces the registry and clients are told to refresh tools/list. A bad save is logged and does not replace the tools that are already running. The default is off. Watching with an empty MCP_CUSTOM_TOOLS stops startup.
MCP_TOOLS_ENABLED and MCP_TOOLS_DISABLED accept a custom tool name or toolset:<name>, as well as the built-in names. A toolset selector does not match built-in tools. An unknown name or toolset stops startup.
get_business_context, and leaves execute_query unregistered.
IBM i object authority on the user profile is the last line of defense. The profile should be able to read the business files and should not be able to change them. A read-only database connection and the statement checks sit in front of that. They do not replace it.
Docker
Mount the YAML directory read-only and setMCP_CUSTOM_TOOLS to the path inside the container. See the Docker guide.