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, or one masking rule.
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. describe_table adds 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.
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. 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.