Skip to main content
This guide covers security features and best practices for mcp-server-db2i.

Security Features

  • Read-only access: Only SELECT statements are permitted, and the driver connection is opened read only (JDBC access=read only for jt400 and mapepire, ODBC CONNTYPE=2) unless DB2I_JDBC_OPTIONS sets access or DB2I_ODBC_OPTIONS sets CONNTYPE
  • SSH host key check: The mapepire driver refuses an IBM i whose SSH host key does not match a pinned fingerprint or known_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), or none (trusted networks)

Credential Management

The server supports multiple methods for providing credentials, listed from most to least secure. A DB2I_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. Docker secrets provide the most secure credential management. Secrets are mounted as files and never exposed in environment variables or process listings.
  1. Create secret files:
  1. Configure docker-compose.yml to use secrets:
For Docker Swarm or Kubernetes, use their native secret management instead of file-based 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
These systems can populate the *_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 inspect output
  • Process listings (ps aux)
  • Shell history
  • Log files
Warning: Never commit .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
When the rate limit is exceeded, queries return an error with 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
Before the keyword scan, string literals, comments, and the quotes around delimited identifiers are removed. A literal or a quoted name earlier in the statement cannot hide a later call. Words that appear only inside a literal or a comment are ignored. The driver connection is a second layer. JT400 and the Mapepire server (which uses JT400 on the IBM i) use 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. A SELECT 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:
The tool is then never registered, so validation bypasses cannot reach it. Business SQL tools loaded from 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_SCHEMA when 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.ORDERS is rejected unless OUTSIDELIB is 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 UPPER and COALESCE are found. SET statements 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 example QCMDEXC) still apply to qualified and unqualified calls.
  • The list comes from the server environment. A schema chosen at /auth changes where unqualified names resolve. It does not add libraries to the list.
  • With DB2I_PROFILES, each profile can set its own allowedSchemas. A profile without one uses QUERY_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 types NCHAR, NVARCHAR, NCLOB, CLOB, DBCLOB, GRAPHIC, VARGRAPHIC and DECFLOAT, as in CAST(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 SCHEMA and the other CURRENT registers, with or without an alias, and a duration after a number or a parenthesis, as in CURRENT DATE - 30 DAYS. A duration after a column name, such as ORDERDATE + LEADDAYS DAYS, is not read yet.
  • QSYS2 and SYSIBM are allowed only when you add them.
This does not replace IBM i object authority. A view or alias in an allowed library can still point at another library. Use a user profile that has access only to the libraries in the list.

HTTP Transport Security

When using HTTP transport, additional security measures apply:

Authentication

  • required (default): clients exchange IBM i credentials at POST /auth. Those credentials are not taken from the environment. Tokens expire after 1 hour by default (MCP_TOKEN_EXPIRY).
  • token and none: the server uses DB2I_* environment credentials. token still requires MCP_AUTH_TOKEN. Use none only on a trusted network. A non-loopback bind with MCP_AUTH_MODE=none refuses to start unless MCP_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 /auth token, 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-Policy with default-src 'none' (plus img-src data: for the inline favicon), frame-ancestors 'none' and a form-action limited to this origin and the client’s redirect origin, plus Cache-Control: no-store and Referrer-Policy: same-origin (not no-referrer, which makes browsers post the form with Origin: 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’s cursor:// 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’s form-action allows 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 S256 is accepted. Codes are single use and expire after 60 seconds. A code presented a second time revokes every token it issued. The resource parameter, 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. Rotating MCP_OAUTH_SECRET invalidates 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_unavailable and 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=0 turns 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 from MCP_OAUTH_SECRET with 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 mode 0600. The file requires an explicit MCP_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.
Before exposing the server on the internet, set 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_PROXY so the limits see each client’s address instead of the proxy’s. Without it, every client shares the proxy’s budget
Both limits use express-rate-limit with an in-memory store.
Note: These limits cannot be turned off, and RATE_LIMIT_ENABLED=false does not disable them. Each value must be a positive whole number, and a window can be at most 2147483647 ms (about 24.8 days). See Rate Limiting in the configuration reference.

TLS/HTTPS

For production HTTP deployments:
Or run behind a reverse proxy (nginx, Caddy, cloud load balancer) that handles TLS termination.

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 from execute_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 allows EXPORT_MAX_DOWNLOADS downloads, 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. HEAD requests do not count as downloads, and the default of 3 downloads leaves room for a preview that uses GET. EXPORT_MAX_DOWNLOADS=1 makes 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 with 0600, 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_query returns. EXPORT_MAX_ROWS (100,000), EXPORT_MAX_BYTES (100 MB), EXPORT_TIMEOUT and EXPORT_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 rowCount and bytes. 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/:id allows 30 requests per minute per IP address.

Mapepire driver (SSH)

With DB2I_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 hostKey or an entry in known_hosts before the password is sent. insecureHostKey=true skips 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/.mapepire in 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. Set serverPath to run an installed JAR instead. Delete $HOME/.mapepire to 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 secure option is not needed.
  • Keys. privateKeyFile logs 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 with MCP_AUTH_MODE=required, /auth sessions 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=true in DB2I_JDBC_OPTIONS (or SSL=1 in DB2I_ODBC_OPTIONS) after the IBM i host servers are configured for SSL
  • With the mapepire driver, pin hostKey or keep the host in known_hosts, and leave insecureHostKey unset
  • Set MCP_ALLOWED_HOSTS to the public hostname when the HTTP server is not loopback-only
  • With MCP_OAUTH_ENABLED, set MCP_OAUTH_SECRET, serve MCP_PUBLIC_URL over HTTPS, and keep MCP_OAUTH_REDIRECT_URIS to the clients you use
  • With MCP_OAUTH_STATE_FILE, keep the file and MCP_OAUTH_SECRET in separate places, and out of shared backups
  • Leave access (JDBC) and CONNTYPE (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_TIMEOUT on. With jt400 or mapepire, check that the user profile can run QSYS2.CANCEL_SQL, or use odbc
  • Disable tools clients don’t need (e.g. MCP_TOOLS_DISABLED=execute_query)
  • Set QUERY_ALLOWED_SCHEMAS when execute_query or business SQL tools are enabled, and limit the IBM i user profile to those libraries
  • Use info or 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 .env file (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:
  1. Do not open a public GitHub issue
  2. Use GitHub’s private vulnerability reporting to submit your report
  3. Include steps to reproduce the issue
  4. Allow time for a fix before public disclosure