Skip to main content
Glama
les-k

pg-readonly-mcp

by les-k

pg-readonly-mcp

Сервер MCP для Postgres, работающий только на чтение, который разбирает SQL, а не ищет совпадения по шаблону — потому что альтернатива уже однажды провалилась публично, и стоит сказать об этом подробно.

Обход, который этот инструмент закрывает

Эталонный сервер MCP для Postgres от Anthropic обеспечивал «только чтение», оборачивая каждый запрос в транзакцию только для чтения. Он также принимал ввод с несколькими операторами, разделёнными точкой с запятой. Такое сочетание можно было эксплуатировать:

SELECT 1; COMMIT; DROP SCHEMA public CASCADE;

COMMIT завершает транзакцию только для чтения досрочно. Всё, что идёт после него, выполняется с полными привилегиями сессии. Лаборатория безопасности Datadog раскрыла эту уязвимость в 2026 году; сервер был объявлен устаревшим и архивирован — а уязвимый пакет всё ещё скачивался 21 000 раз в неделю после этого.

Транзакция только для чтения — это свойство того, как выполняется запрос. Она ничего не говорит о том, что это за запрос, и именно поэтому из неё можно было «выпроситься» обратно. Этот сервер проверяет второе.

Related MCP server: postgres-mcp-readonly

Два независимых уровня

Любой из них по отдельности предотвратил бы раскрытый обход. Оба существуют потому, что ошибка в одном не должна быть единственной преградой между агентом и записью.

1. SQL разбирается, а не сканируется. guard.py использует sqlglot для построения реального синтаксического дерева и применяет три проверки:

  • Ровно один оператор. sqlglot.parse разбивает на операторы по точке с запятой так же, как это делает драйвер, поэтому приведённая выше полезная нагрузка становится тремя операторами и отклоняется до того, как любой из них достигнет соединения.

  • Внешний оператор — это конструкция для чтенияSELECT, UNION, INTERSECT, EXCEPT или CTE, построенное из них. DROP TABLE users отклоняется здесь.

  • Никаких записей в любом месте дерева — полный обход. Это проверка, которую не могут заменить две другие: Postgres допускает, чтобы CTE содержало оператор модификации данных, поэтому WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x на внешнем уровне является SELECT. Проверка, смотрящая только на внешнюю форму, полностью это упускает. Обход каждого узла находит DELETE независимо от того, на сколько уровней вглубь он спрятан.

Неопознанная форма оператора — всё, для чего у sqlglot нет специального правила — отклоняется тем же правилом, что и всё остальное. Незнакомое не равно безопасному.

2. Само соединение не может выполнять запись, независимо от того, что его просят выполнить. check_connection_is_readonly отказывается запускаться, если подключённая роль является суперпользователем, может создавать базы данных или роли, может обходить защиту на уровне строк или имеет любые разрешения, кроме SELECT, на любую таблицу, которую она видит. Не исчерпывающе — привилегии Postgres также могут быть получены через владение, разрешения PUBLIC или политики RLS, которые эта проверка не перечисляет — но она ловит два наиболее распространённых способа проявления неправильной конфигурации, и они явно указаны, а не подразумеваются.

От чего это не защищает

  • Переданная ему всё равно привилегированная строка подключения. Проверка при запуске ловит распространённые формы избыточных привилегий; это не исчерпывающий аудит привилегий, и об этом сказано выше, а не подразумевается иное.

  • Истощение ресурсов в заданных пределах. Запрос, который легально возвращает 1000 строк очень широких данных или который легитимно дорог в планировании, всё равно стоит столько, сколько стоит. Ограничения на количество строк и тайм-аут оператора ограничивают ущерб; они не делают дорогое чтение бесплатным.

  • Что агент делает с данными, получив их. Это шлюз для запросов, а не инструмент предотвращения утечки данных. Доступ на чтение к таблице — это доступ на чтение ко всему, что в ней есть.

  • Разногласия парсеров. sqlglot и собственный парсер Postgres — две независимые реализации одной грамматики. Не доказано, что они совпадают во всех пограничных случаях, которые принимает Postgres — это реальный, хоть и узкий, пробел в проекте, вся суть которого в том, чтобы не доверять одному уровню. Второй уровень существует отчасти и поэтому: даже если расхождение в парсерах пропустит что-то нежелательное через защиту, лежащее в основе соединение всё равно не сможет выполнять запись.

Результаты сканирования

Запуск на agent-audit 0.19.2 от 18 августа 2026 г.: 15 находок, 11 автоматически подавлено, 4 практических — 1 BLOCK, 3 WARN.

Находка BLOCK — самая интересная, её стоит прочитать полностью. server.py:156, уверенность 1.0: cur.execute(sql) — помечено как SQL-инъекция через непараметризованное выполнение. Эта строка реальна. Но это также самая защищённая строка в этом коде: к тому моменту, как sql достигает её, validate_readonly() уже разобрала его, подтвердила, что это ровно один оператор, подтвердила, что этот оператор является конструкцией для чтения, и обошла каждый узел в нём в поисках записи. Сканер не имеет возможности увидеть всё это — это поиск по шаблону в одном файле, а проверка происходит в другой функции, в другом модуле, несколькими строками ранее. Он правильно идентифицирует форму, которая опасна в общем случае, но не видит, что форма уже была проверена.

Также её нельзя было бы удовлетворить, сделав то, что она предлагает. Параметризованные запросы защищают значения, подставляемые в фиксированную форму запроса — WHERE id = %s. Они неприменимы здесь, потому что структура запроса — это как раз тот ввод, который и принимает этот инструмент. Не существует схемы параметризации только для значений, которая бы покрыла «выполни любой SQL на чтение, который запросит вызывающий». Исправление для такого класса инструментов — это проверка структуры, для чего и предназначена остальная часть этого файла.

WARN на определении query() (AGENT-034, «отсутствует проверка ввода в теле функции») — это та же слепая зона с другого ракурса: первая строка функции вызывает validate_readonly(sql) внутри try/except. Вызов импортированной функции — это не тот шаблон, который сканер засчитывает как проверку.

Два WARN на жёстко заданные учётные данные находятся в tests/conftest.py: стандартная строка подключения администратора (postgres:postgres@localhost:5432/postgres, стандартное значение по умолчанию для локальной/CI Postgres) и буквальный пароль для ролей, которые набор тестов создаёт и удаляет в рамках одного теста. Оба правильно идентифицированы как строки, похожие на учётные данные; ни один из них не является учётными данными, которые что-то охраняют — один указывает на одноразовую локальную базу данных, другой живёт в течение одного теста.

Остальные 11 находок — все AGENT-041, все в тестовых фикстурах, которые строят операторы CREATE SCHEMA / GRANT / DROP ROLE из имён, полученных через uuid.uuid4() — уже были автоматически подавлены самим сканером.

Сканер по шаблону — это датчик дыма, а не судья. Публикация того, что он находит и почему, стоит больше, чем просто чистое число.

Установка

pip install -e .

Настройка

Требуется строка подключения, заданная либо через --dsn, либо через переменную окружения PG_READONLY_MCP_DSN — переменная окружения существует, чтобы пароль не появлялся в командной строке или в конфигурационном файле клиента, который может быть случайно закоммичен:

{
  "mcpServers": {
    "pg-readonly-mcp": {
      "command": "pg-readonly-mcp",
      "env": { "PG_READONLY_MCP_DSN": "postgresql://readonly_role:...@host:5432/db" }
    }
  }
}

Роль в этой строке подключения не должна иметь ничего, кроме SELECT. Сервер проверяет это сам и отказывается запускаться в противном случае — см. check_connection_is_readonly выше.

Инструмент

Один инструмент, намеренно. Сервер, чьё единственное ценностное предложение — «мы отказываем всему, кроме чтения» — не нуждается во второй поверхности, которую тоже нужно правильно реализовать.

Инструмент

Что он делает

query

Выполняет один оператор вида SELECT. Разбирается и обходится до того, как коснётся соединения. Ограничен параметрами --max-rows (по умолчанию 1000) и --timeout-ms (по умолчанию 5000)

Тесты

37 тестов. 25 не требуют базы данных и выполняются где угодно — это полный набор тестов для guard.py, чистая строка на входе, результат на выходе. Остальные 12 требуют работающего Postgres и по замыслу являются интеграционными: смысл check_connection_is_readonly в том, как она работает с реальными атрибутами ролей и реальными разрешениями, а замокированное соединение прошло бы независимо от того, что сервер на самом деле делает с реальным.

pytest -q --cov=pg_readonly_mcp

CI запускается на реальном сервисном контейнере postgres:16 и ломает сборку, если тесты, опирающиеся на базу данных, сообщают о пропуске там — то же правило, которое sweep-mcp применяет к своим тестам символических ссылок, по той же причине: тест, который молча ничего не делает, хуже, чем отсутствие теста.

Среди 12: воспроизведение вживую точной полезной нагрузки Datadog, выполняемое через реальный вызов инструмента MCP, а не напрямую через guard.py — и утверждение, что целевая схема всё ещё существует после этого, а не просто что было вызвано исключение. Также покрыто: запись, спрятанная внутри CTE через тот же путь вызова, ограничение на количество строк, тайм-аут оператора, что отменённый запрос оставляет соединение пригодным для следующего, и что роль с CREATEDB и нулевыми разрешениями на таблицы отклоняется только на основании атрибута роли.

Покрытие на CI против реальной Postgres: 78%, guard.py на 100%. Пробел в server.py — это общий перехват psycopg.Error — ничто в наборе намеренно не провоцирует ошибку базы данных, не являющуюся отменой — и main() с argparsе и проводкой транспорта, которые набор тестирует через прямой вызов build_server; транспорт — это часть, которую меньше всего стоит мокировать и где с наименьшей вероятностью скрывается настоящая ошибка.

Первая версия, которая была загружена, не прошла. Две ошибки проявились только тогда, когда реальный контейнер Postgres впервые выполнил набор, и ни одна из них не была видна по одному только коду: SET statement_timeout = %s дошёл до Postgres как SET statement_timeout = $1 и не смог распарситься, потому что SET — это служебный оператор и не принимает параметр привязки, как SELECT — каждый реальный вызов инструмента потерпел бы точно такую же неудачу. Кроме того, тестовые фикстуры пытались выполнить DROP ROLE для роли, у которой всё ещё были активные разрешения, что Postgres запрещает; сначала нужно выполнить DROP OWNED BY. Обе ошибки исправлены, и последующие запуски — это те, которые описываются этими цифрами. Оставлено в тексте, потому что набор, который сообщает только об успехах, — это набор, за которым никто не наблюдал сбоев.

Структура

src/pg_readonly_mcp/
  guard.py    parses and walks the tree. Opens no connection. 135 lines.
  server.py   the MCP tool, the connection check, the timeout and row cap.
tests/
  test_guard.py    25 tests - no database, run anywhere
  test_server.py   12 tests - live Postgres required, CI-enforced

guard.py ничего не знает о MCP или psycopg. Если запрос когда-либо отклоняется по причине, находящейся в server.py, это ошибка — решение должно быть на уровень ниже, где его можно проверить только строкой и ничем более.

Лицензия

MIT.

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    -
    quality
    C
    maintenance
    Read-only PostgreSQL MCP server that enables running SELECT queries, listing tables and schemas, and describing columns, with built-in protection against writes and malicious SQL attacks.
    539
    MIT
  • F
    license
    -
    quality
    B
    maintenance
    Read-only MCP server for PostgreSQL enabling schema introspection and SELECT queries via MCP clients like Claude, with multi-layered write protection.
  • A
    license
    -
    quality
    C
    maintenance
    Provides a read-only PostgreSQL MCP server with schema introspection. Enforces least-privilege database roles to prevent any writes, even from malicious SQL.
    MIT

View all related MCP servers

Related MCP Connectors

  • MCP server for managing Prisma Postgres.

  • Read-only MCP server for ClassQuill, a tutoring-business-management platform.

  • MCP server for interacting with the Supabase platform

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/les-k/pg-readonly-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server