Security Features
- Read-only access: Only SELECT statements are permitted, and the driver connection is opened read only (JDBC
access=read onlyforjt400andmapepire, ODBCCONNTYPE=2) unlessDB2I_JDBC_OPTIONSsetsaccessorDB2I_ODBC_OPTIONSsetsCONNTYPE - SSH host key check: The
mapepiredriver refuses an IBM i whose SSH host key does not match a pinned fingerprint orknown_hosts, so a spoofed host never receives the password - No credentials in code: All sensitive data via environment variables or file-based secrets
- Query validation: AST-based SQL parsing plus regex validation blocks dangerous operations
- Result limiting: Queries return 1000 rows unless the caller asks for more (
QUERY_DEFAULT_LIMIT), and never more than 10000 (QUERY_MAX_LIMIT) - Query timeout: A statement that runs longer than
QUERY_TIMEOUT(default 120 seconds) is cancelled on the IBM i - Query exports: Off by default. When on, files stay on the server host and HTTP callers get a link that allows a few downloads and expires. See Query exports
- Rate limiting: Configurable request throttling to prevent abuse (100 req/15 min default)
- Structured logging: Automatic redaction of sensitive fields like passwords
- HTTP auth:
required(per-user credentials via/auth),token(static bearer), ornone(trusted networks)
Credential Management
The server supports multiple methods for providing credentials, listed from most to least secure. ADB2I_PROFILES file never holds a password. Each profile’s password must be a "${ENV_VAR}" reference, or passwordFile must point at a file such as a Docker secret. The server refuses to start if a profile has a literal password.
Option 1: Docker Secrets (Recommended for Production)
Docker secrets provide the most secure credential management. Secrets are mounted as files and never exposed in environment variables or process listings.- Create secret files:
- Configure docker-compose.yml to use secrets:
Option 2: External Secret Management
For enterprise deployments, integrate with secret management systems:- HashiCorp Vault: Inject secrets at runtime
- AWS Secrets Manager: Use IAM roles for access
- Azure Key Vault: Integrate with managed identities
- Google Secret Manager: Use service account authentication
*_FILE environment variables or inject secrets directly.
Option 3: Environment Variables (Development Only)
Plain environment variables are convenient for development but expose credentials through:docker inspectoutput- Process listings (
ps aux) - Shell history
- Log files
.env files or credentials to version control.
File-Based Secret Variables
Rate Limiting
The server includes built-in rate limiting to protect the IBM i database from excessive queries.Configuration
Behavior
- Default: 100 requests per 15-minute window
- Scope: Per server instance (for stdio transport, this means per-client since each MCP client spawns its own server process)
- HTTP transport: Rate limiting applies per authenticated token
waitTimeSeconds indicating when to retry:
Query Validation
The server validates all SQL queries before execution using multiple layers:AST-based Validation
Queries are parsed into an Abstract Syntax Tree (AST) to verify:- Only SELECT statements are allowed
- No DDL (CREATE, ALTER, DROP)
- No DML (INSERT, UPDATE, DELETE)
- No DCL (GRANT, REVOKE)
Regex Validation
Additional regex patterns block:- Command execution attempts
- System procedure calls
- Dangerous functions, including schema-qualified calls. The check uses the unqualified name
- IBM i services that send data off the system or write outside the database: HTTP services, IFS write services, spreadsheet generation, and email
access=read only unless DB2I_JDBC_OPTIONS sets access; the ODBC driver uses CONNTYPE=2 unless DB2I_ODBC_OPTIONS sets CONNTYPE. An explicit override is logged at startup.
Statement parse check
execute_query asks IBM i to parse the statement with QSYS2.PARSE_STATEMENT before it runs. The query is rejected when the statement does not parse, or when it is not a query. This catches Db2 for i syntax that the local parser accepts.
The check is on unless QUERY_PARSE_CHECK is false or 0. It adds one round trip, often a few hundred milliseconds, on every execute_query call. Business SQL tools run the same check the first time each tool is called, then cache the result. If QSYS2.PARSE_STATEMENT is not installed, the query is rejected and the error tells you to turn the check off. A missing function does not skip the check on its own.
validate_query runs the same parse, then checks tables, columns, and qualified routines against the catalog. It reports findings and does not execute the statement.
get_object_ddl calls QSYS2.GENERATE_SQL on a separate connection that is not marked read-only, because that procedure is rejected on a read-only connection. That connection runs only the procedure call. It does not execute the DDL it returns. The connection used by execute_query stays read-only.
Result Limiting
Query results are automatically limited to prevent memory exhaustion:Query Timeout
The row limit caps what a query returns, not the work the IBM i does to produce it. ASELECT that scans a large table, or joins on columns without an index, can hold a CPU for a long time. The IBM i also keeps running a statement after its client disconnects or is killed. QUERY_TIMEOUT (seconds, default 120) cancels such a statement on the IBM i, not only on the client side.
It applies to every statement a tool runs: execute_query, business SQL tools, the catalog tools and get_object_ddl. A profile can set its own queryTimeout, and 0 turns the limit off. The tool returns an error that says the query was cancelled after N seconds and suggests narrowing the filter. The connection goes back to the pool.
How each driver cancels:
All three cancel by elapsed time, not by the optimizer’s estimate. A read-only connection rejects
CALL QSYS2.CANCEL_SQL, so jt400 and mapepire make that call on the separate connection get_object_ddl uses, which is not marked read-only and runs only fixed statements. Once a statement has used half its limit, that connection is opened in the background, so the cancel does not wait for a new Mapepire job to start.
If the cancel fails, for example because the user profile lacks that authority, the tool still returns after the limit, with an error saying the statement could not be cancelled and may still be running on the IBM i. The server logs a warning the first time this happens on each system. The mapepire driver then closes the job, which frees the client but does not stop the statement on the IBM i; it runs until it finishes. With a low-privilege profile, the odbc driver is the one that cancels on the IBM i.
Metadata-Only Mode
If clients only need to browse schemas, tables, and columns, turn off free-form SQL entirely:MCP_CUSTOM_TOOLS stay available, and they go through the same read-only check, schema allowlist, and parse check. See Business SQL tools. See Tool Selection for the full allowlist and denylist syntax.
Schema Allowlist
QUERY_ALLOWED_SCHEMAS rejects an execute_query call whose tables or qualified functions are outside that list. The check runs after the read-only validation and before the parse check and the query. The same list applies to validate_query, get_object_ddl, get_related_objects, get_journal_info, index_advice, profile_table, list_routines, describe_routine, the catalog browsing tools (list_tables, describe_table, list_views, list_indexes, get_table_constraints), the resources and prompts, and business SQL tools. list_schemas returns only libraries in the list. get_related_objects omits dependents whose schema is outside the list. A business tool that fails the check is rejected at startup, and again when it is called. search_ibmi_services reads only the fixed service catalog QSYS2.SERVICES_INFO, which lists service names and examples and no business data, so the list does not apply to it.
- Unqualified names resolve to the session schema, or to
DB2I_SCHEMAwhen the session has none. If that schema is missing or not in the list, the query is rejected. - A schema-qualified function call must name a library in the list, like a table. A user-defined function can read its own library, so
SELECT OUTSIDELIB.F(ORDERNO) FROM MYLIB.ORDERSis rejected unlessOUTSIDELIBis listed. - Unqualified function calls are not checked. They resolve through the SQL path, which is the job’s library list under the default system naming, and that is how built-ins such as
UPPERandCOALESCEare found.SETstatements are rejected, so a client cannot change the path. Keep libraries outside the list off the user profile’s library list, and rely on object authority for the rest. The dangerous-function checks (for exampleQCMDEXC) still apply to qualified and unqualified calls. - The list comes from the server environment. A schema chosen at
/authchanges where unqualified names resolve. It does not add libraries to the list. - With
DB2I_PROFILES, each profile can set its ownallowedSchemas. A profile without one usesQUERY_ALLOWED_SCHEMAS. Every call is checked against the list of the system it runs on. - Queries that cannot be parsed are rejected while the list is set. System naming (
LIB/FILE),TABLE(...)table functions and named arguments fall into that group. The error says where parsing stopped. - Db2 for i casts are accepted:
CCSID n,FOR BIT DATA, and the typesNCHAR,NVARCHAR,NCLOB,CLOB,DBCLOB,GRAPHIC,VARGRAPHICandDECFLOAT, as inCAST(NOTE AS VARCHAR(60) CCSID 1208). The check reads a rewritten copy of the statement. The statement sent to the IBM i is unchanged. - Special registers and labeled durations are accepted:
CURRENT DATE,CURRENT TIMESTAMP(n),CURRENT USER,CURRENT SCHEMAand the otherCURRENTregisters, with or without an alias, and a duration after a number or a parenthesis, as inCURRENT DATE - 30 DAYS. A duration after a column name, such asORDERDATE + LEADDAYS DAYS, is not read yet. QSYS2andSYSIBMare allowed only when you add them.
HTTP Transport Security
When using HTTP transport, additional security measures apply:Authentication
required(default): clients exchange IBM i credentials atPOST /auth. Those credentials are not taken from the environment. Tokens expire after 1 hour by default (MCP_TOKEN_EXPIRY).tokenandnone: the server usesDB2I_*environment credentials.tokenstill requiresMCP_AUTH_TOKEN. Usenoneonly on a trusted network. A non-loopback bind withMCP_AUTH_MODE=nonerefuses to start unlessMCP_ALLOW_UNAUTHENTICATED_HTTP=true.
POST /auth opens a database connection to test the credentials. By default that host must be DB2I_HOSTNAME. Set MCP_AUTH_ALLOWED_DB_HOSTS to a comma-separated list to allow more than one. When neither value is set, any host is accepted and a warning is logged. A rejected host returns 400 and does not open a connection. It still counts toward the /auth rate limit.
Every request is checked against an allowlist of Host values before it is routed. Loopback names are always allowed. Add public names with MCP_ALLOWED_HOSTS when the server is reached by a hostname other than the bind address. A rejected Host returns 403. The rejected value is logged and is not echoed in the response. This blocks a page that rebinds its name onto the loopback address and sends that name in both Host and Origin.
Browser requests with an Origin header must be same-origin or listed in MCP_CORS_ORIGINS. Others get 403. A listed origin is echoed in Access-Control-Allow-Origin with Vary: Origin, and MCP_CORS_ORIGINS='*' answers with a literal *. The server never sends Access-Control-Allow-Credentials, because tokens travel in the Authorization header rather than in cookies.
See HTTP Transport for the request shapes. Protocol sessions (Mcp-Session-Id) are deprecated; pools stay isolated by auth token in the default stateless mode.
OAuth Authorization Server
MCP_OAUTH_ENABLED=true adds a sign-in page and an OAuth 2.1 authorization server, so remote clients such as claude.ai can connect. See Remote Clients (OAuth). The design choices that matter for security:
- Users sign in as themselves. The page asks for an IBM i user profile and password and tests them on the chosen system. The token carries those credentials, like a
/authtoken, so object authority on the IBM i still applies. There is no shared service profile. - The page is served by this server only. It sends
Content-Security-Policywithdefault-src 'none'(plusimg-src data:for the inline favicon),frame-ancestors 'none'and aform-actionlimited to this origin and the client’s redirect origin, plusCache-Control: no-storeandReferrer-Policy: same-origin(notno-referrer, which makes browsers post the form withOrigin: null, and the Origin check refuses that). The password is never echoed back, and a failed sign-in shows a generic message, not the driver error. - Redirect URIs are allowlisted. Dynamic registration accepts only URIs from
MCP_OAUTH_REDIRECT_URIS(default: the Claude connector callbacks and Cursor’scursor://callback) and loopback. Like loopback, an app-scheme callback returns the code to an app on the user’s own machine, and the sign-in page’sform-actionallows that scheme rather than an origin. This stops a third party from registering a client that sends codes to their own site. The page names the client and the host it returns to, and asks the user to continue only if they started the connection. Prefix entries must end in/*, and URIs are compared after normalization. - PKCE is mandatory. Only
S256is accepted. Codes are single use and expire after 60 seconds. A code presented a second time revokes every token it issued. Theresourceparameter, when sent, must name this server (RFC 8707). - Signed state instead of stored state. Client IDs and the pending sign-in request are HMAC-SHA256 signed with
MCP_OAUTH_SECRET, each for its own purpose, and every registration gets a random nonce. A confidential client’s secret is derived from its ID with the same key. RotatingMCP_OAUTH_SECRETinvalidates every registration and every open sign-in page. - Refresh tokens rotate and re-check. Each refresh token works once. Each refresh opens a test connection with the stored credentials, so a disabled profile or a new password ends the grant and its access tokens. When the IBM i cannot be reached at all, the refresh answers 503
temporarily_unavailableand the grant stays, so an outage does not sign everyone out. An error the server cannot classify counts as a rejection, so a stored password the IBM i refused is never retried. One user profile holds at most 10 refresh grants per system; past that its own oldest grant ends, never another user’s. - Revocation ends the whole sign-in. Revoking either the access token or the refresh token ends both, and every access token refreshed from the same sign-in.
MCP_OAUTH_REFRESH_EXPIRY=0turns refresh tokens off, and users then sign in again when the access token expires (MCP_TOKEN_EXPIRY). - Memory only, unless you choose a state file. By default, credentials, codes and refresh tokens are never written to disk, and a restart signs everyone out. With
MCP_OAUTH_STATE_FILE, refresh grants are written to that file so users stay signed in across restarts. Each grant includes the user’s IBM i password, which a refresh needs to open a connection. Entries are encrypted with AES-256-GCM under a key derived fromMCP_OAUTH_SECRETwith HKDF, and each entry is bound to its ID, so entries cannot be altered or swapped unnoticed. The file keeps a SHA-256 hash of each refresh token, never the token. It is written with mode0600. The file requires an explicitMCP_OAUTH_SECRET, and anyone holding both the file and the secret can recover the passwords, so store them apart and keep both out of shared backups. Rotating the secret makes the file unreadable and signs everyone out. - TLS is required for
MCP_PUBLIC_URL, except on loopback. Terminate TLS at a reverse proxy or tunnel and bind the server to loopback behind it.
QUERY_ALLOWED_SCHEMAS or a profile allowedSchemas, keep MCP_TOOLS_ENABLED to what users need, and limit what the IBM i user profiles can read.
Auth Endpoint Rate Limiting
The/auth endpoint and the OAuth sign-in form share additional rate limiting to prevent brute-force attacks:
Behavior:
- Authentication attempts are tracked per IP address and counted when they arrive, so parallel requests cannot get past the limit while earlier attempts are still testing their credentials
- After the maximum number of attempts within the window (5 in 60 seconds by default), further requests from that IP get 429 until the window ends
- A successful login does not count, but it does not clear earlier failures either, so one valid profile cannot be used to reset the count while guessing another profile’s password
- Lockout automatically expires after the window period
- With OAuth on, the sign-in form uses the same budget, and all
/oauth/*endpoints together are limited to 120 requests per minute per IP by default - Behind a reverse proxy or tunnel, set
MCP_TRUST_PROXYso the limits see each client’s address instead of the proxy’s. Without it, every client shares the proxy’s budget
Note: These limits cannot be turned off, andRATE_LIMIT_ENABLED=falsedoes not disable them. Each value must be a positive whole number, and a window can be at most2147483647ms (about 24.8 days). See Rate Limiting in the configuration reference.
TLS/HTTPS
For production HTTP deployments:Session Limits
Control concurrent sessions to prevent resource exhaustion:Column masking
Db2 row and column access control (RCAC) is the control that actually holds. A mask in this server only changes what an agent receives fromexecute_query, from YAML tools, and from profile_table. It does not change what the database user can read with another client.
Rules live in the YAML masking section described in Business SQL tools. redact replaces a value. last4 keeps the last four characters.
The server rejects a statement that uses a masked column as anything other than a plain selected column, so an alias or UPPER(EMAIL) cannot carry the value out under another name. That check needs QSYS2.PARSE_STATEMENT for execute_query. When masking is loaded and QUERY_PARSE_CHECK is off, execute_query refuses to run. A mask the server cannot enforce would be worse than no mask. YAML tools are checked from the statement text at load time and do not depend on that setting.
profile_table writes its own statements, so the select-list check does not apply to it. It never selects MIN or MAX of a masked column when it scans, and it drops the stored low and high values of a masked column. Distinct and null counts are still returned, marked with masked and the rule. With compute: true the generated aggregate goes to the audit log like any other SQL.
extended metadata=true in DB2I_JDBC_OPTIONS makes JT400 label result keys with LABEL ON text instead of the column name. Masking would miss those keys, so the server refuses to start when that option is set and a masking rule is loaded. The same check applies to the mapepire driver, which reads the same JDBC options.
A view, an alias, or a table function that reads a masked table is not covered unless the view itself is listed in masking.
Query exports
export_query writes query results to files in EXPORT_DIR on the server host. It is off until EXPORT_ENABLED and EXPORT_DIR are set. Things to know before turning it on:
- Same checks as
execute_query. The statement goes through the SQL validator,QUERY_ALLOWED_SCHEMAS, the parse check, and column masking. Masked columns are masked in the file and in the sample rows. If a masked column the statement selects is missing from the result, the export fails and no file is kept. - A download link works like a password. Over HTTP the link is
<MCP_PUBLIC_URL>/exports/<id>, where the id is 256 random bits. Anyone who has the link can download the file until it expires (EXPORT_TTL_MINUTES, 15 by default), with no bearer token, because a browser following a link from a chat cannot send one. A link allowsEXPORT_MAX_DOWNLOADSdownloads, 3 by default. Treat a chat that holds a link as holding the data. - Link previews. Chat apps and email security scanners often fetch a link before the user clicks it.
HEADrequests do not count as downloads, and the default of 3 downloads leaves room for a preview that usesGET.EXPORT_MAX_DOWNLOADS=1makes links single use, at the cost of links that a preview has already spent. - Files on the host. Exports are files on the MCP host, readable by the user the server runs as. The directory is created with mode
0700, files with0600, and the server refuses a directory that is a symbolic link or belongs to another user. Files are deleted after the last allowed download, when they expire, at shutdown, and at the next startup. - Load on the IBM i. Exports read many more rows than
execute_queryreturns.EXPORT_MAX_ROWS(100,000),EXPORT_MAX_BYTES(100 MB),EXPORT_TIMEOUTandEXPORT_MAX_CONCURRENT(2) bound that. Keep them low on a production system. - Formulas. XLSX files hold values only, never formulas. In CSV files a text value that starts with
=,+,-or@gets a leading', so a spreadsheet does not run it as a formula. - Audit. The export is recorded like any tool call, with
rowCountandbytes. Each download request adds a line with"event":"export_download", the first 8 characters of the id, the IP address, who ran the export, and whether it succeeded. The full link is never logged. - Rate limit.
/exports/:idallows 30 requests per minute per IP address.
Mapepire driver (SSH)
WithDB2I_DRIVER=mapepire the server logs in to the IBM i with SSH and runs the Mapepire server inside that session. Some things to know:
- Host key. The host key must match
hostKeyor an entry inknown_hostsbefore the password is sent.insecureHostKey=trueskips the check and logs a warning at startup. Do not use it across a network you don’t trust. - Files on the IBM i. On first use, mapepire-js uploads its bundled server JAR to
$HOME/.mapepirein the user’s home directory and checks its SHA-256. Later connections reuse it, or a JAR that Code for i left in$HOME/.vscode. SetserverPathto run an installed JAR instead. Delete$HOME/.mapepireto remove it. - SSH access. The user profile needs SSH login, which also allows a shell. Give the MCP server a dedicated, low-privilege profile, as you would for the other drivers. If sshd allows it, limit what that profile can do over SSH.
- Encryption. SSH encrypts the whole session, so the JDBC
secureoption is not needed. - Keys.
privateKeyFilelogs in with a key instead of the password. The key file must not have a passphrase, so protect it like a password file. Over HTTP withMCP_AUTH_MODE=required,/authsessions ignore the key and log in over SSH with the caller’s password, so the key cannot stand in for a caller’s credentials.
Audit log
MCP_AUDIT_LOG writes one JSON line for every tool call: who ran it, which tool, a hash of the SQL (or the text when MCP_AUDIT_SQL=full), how many parameters were bound, the row count, how long it took, and whether it succeeded, failed, or was rate limited. HTTP calls record the IBM i username. Stdio calls record stdio. Each line also records the system the call ran on (default when DB2I_PROFILES is unset), and its args leave out the system argument.
Resource reads and the write_query prompt query the catalog too, so they are recorded the same way. Their tool is resource:table, resource:table_ddl, or prompt:write_query, and args holds the schema and table. Reading db2i://business-context and completing names are not recorded.
Hashing is the default because the statement often contains customer values, and an audit file should not become a second copy of the data. Set MCP_AUDIT_PARAMS=true only when you need the bound values and the file is protected like a credential.
The audit log also records why the server stopped, as a line such as {"time":"...","event":"shutdown","reason":"stdin closed"}. The reason is SIGINT, SIGTERM, SIGHUP, or stdin closed (the stdio client went away). A second line with reason deadline means shutdown ran past 5 seconds and the process exited while a pool was still closing. Lines with an event field have no tool.
The pino log is not this record. At info it does not keep the SQL, and at debug it is a diagnostic trace, not an answer to who ran what. A failed audit write is reported once and does not fail the tool call.
Logging Security
The structured logger automatically redacts sensitive fields:- Passwords are never logged
- Connection strings are sanitized
- Query parameters with sensitive names are masked
Log Levels
In production, use JSON logging for better parsing:
Security Checklist
Production Deployment
- Use Docker secrets or external secret management
- Enable TLS for HTTP transport
- Set
secure=trueinDB2I_JDBC_OPTIONS(orSSL=1inDB2I_ODBC_OPTIONS) after the IBM i host servers are configured for SSL - With the
mapepiredriver, pinhostKeyor keep the host inknown_hosts, and leaveinsecureHostKeyunset - Set
MCP_ALLOWED_HOSTSto the public hostname when the HTTP server is not loopback-only - With
MCP_OAUTH_ENABLED, setMCP_OAUTH_SECRET, serveMCP_PUBLIC_URLover HTTPS, and keepMCP_OAUTH_REDIRECT_URISto the clients you use - With
MCP_OAUTH_STATE_FILE, keep the file andMCP_OAUTH_SECRETin separate places, and out of shared backups - Leave
access(JDBC) andCONNTYPE(ODBC) unset so the connection stays read only, or treat an explicit value as a deliberate override - Set appropriate rate limits
- Configure query limits, and keep
QUERY_TIMEOUTon. Withjt400ormapepire, check that the user profile can runQSYS2.CANCEL_SQL, or useodbc - Disable tools clients don’t need (e.g.
MCP_TOOLS_DISABLED=execute_query) - Set
QUERY_ALLOWED_SCHEMASwhenexecute_queryor business SQL tools are enabled, and limit the IBM i user profile to those libraries - Use
infoor higher log level - Run as non-root user (Docker image does this by default)
- Restrict network access to IBM i system
- Monitor logs for suspicious activity
Development
- Use
.envfile (add to.gitignore) - Enable debug logging if needed
- Test with production-like rate limits
- Verify query validation works as expected
Reporting Security Issues
If you discover a security vulnerability, please report it responsibly:- Do not open a public GitHub issue
- Use GitHub’s private vulnerability reporting to submit your report
- Include steps to reproduce the issue
- Allow time for a fix before public disclosure