Skip to main content
openNTL ships an MCP server that lets an agent inspect and operate a PostgreSQL-backed node: what it has learned, whether it is still learning, and what its database looks like. It has a second purpose. It is a template. If you want a Postgres MCP server for your own schema, copy it — the auth, read-only enforcement, output bounding and test harness are the parts that take longest to get right, and they are the parts that carry over unchanged. Source: mcp/ntl-postgres-mcp-server. Its tool surface follows the shape of the Supabase MCP server, so an agent familiar with one is immediately productive with the other.

What an agent can do with it

The second row is worth noticing: a stored weight of 0.44 but a decayed weight of 0.21. Decay is applied lazily, so an operator comparing the stored value against a live routing decision would be comparing the wrong number. The tool shows both.

Tools

Read-only, always available: Write tools appear only when an operator enables them:

The safety model

This is the part worth understanding before you deploy it — or copy it.

Read-only is enforced by Postgres, not by parsing SQL

The tempting approach is to inspect the query and reject anything that looks like a write. Every implementation that does this can be talked around:
A blocklist has to anticipate all of it, and SQL is not a regular language. Postgres already knows which statements write, so read-only tools run inside BEGIN TRANSACTION READ ONLY and the database rejects the write with SQLSTATE 25006. The test suite fires sixteen real bypasses at it and then asserts the data is untouched — not merely that an error was returned.
If you copy this to a database with no equivalent of SET TRANSACTION READ ONLY, you do not have a read-only mode. Say so, rather than falling back to SQL parsing and implying a guarantee you cannot keep.

Writes are off by default, and absent rather than refusing

ALLOW_WRITES defaults to false. When it is off, write tools are not registered at all — they do not appear in the tool list. Offering a tool that can only fail wastes an agent’s turn and teaches it nothing. Enabling writes is per-environment, so it is a deliberate act against a named target rather than a variable flip on production.

Auth is required

With MCP_AUTH_TOKEN unset, the server returns 500 to every request rather than serving unauthenticated. A server holding database credentials with no auth is an open SQL console on the public internet. Token comparison is timing-safe.

Output is bounded, and says when it was cut

A tool returning a million rows does not help an agent — it exhausts the context the agent needs to reason with. Output is capped, and truncation is reported explicitly. Silent truncation is the worse failure: an agent that believes it saw a whole table will draw conclusions from a fragment.

Deploying

Connect a client:
Hyperdrive is not optional in production. Without pooling, every Worker invocation opens its own Postgres connection, and a traffic burst exhausts max_connections long before it exhausts anything else — a failure that looks like a database outage rather than a capacity problem. DATABASE_URL is for local development.

Creating the schema

The server can create the openNTL Postgres schema itself:
That schema is also the reference DDL for the Rust ntl-store-postgres backend, and is exposed as an MCP resource at ntl://schema/postgres so an agent about to write a migration can read it first. See storage backends.

Copying it as a template

Three files change; the rest carries over. Portable as-is: the safety layer, formatting and truncation, the Worker entry point with auth, the schema and SQL tools, and the whole test harness.

Testing without a database service

Tests run against PGlite — real Postgres compiled to WebAssembly, in-process — so CI needs no database. Set TEST_DATABASE_URL and the same suite also runs against a live server. Running both is not belt-and-braces. It caught two real bugs:
  • Multi-statement SQL. Parameterised statements use the extended protocol, which accepts exactly one command. Schema creation failed with SQLSTATE 42601 on a script that worked as separate statements.
  • BIGINT type instability. postgres.js returns BIGINT as a string, PGlite as a number, so a tool’s structured output changed type depending on the driver. Now normalised to a string — correct regardless, since BIGINT exceeds Number.MAX_SAFE_INTEGER and openNTL stores nanosecond timestamps in it.
Neither was visible to a single-backend suite.
If you port this to MySQL, note that ntl_apply_migration assumes transactional DDL — every statement in one transaction, rolling back together. MySQL has no such guarantee, and a migration that half-applies leaves a schema no version number describes. Apply one statement per migration there, or state plainly that rollback is not guaranteed.

Storage Backends

The multi-database matrix, including the PostgreSQL backend

Storage Interface

The normative contract every backend satisfies

Learning Model

What the health tool’s ratios actually mean

Quickstart

Get a node running first
Last modified on September 11, 2026