Skip to main content
Glama
zohto

governance-query-mcp

by zohto

governance-query-mcp

A read-only Model Context Protocol server that exposes two Notion databases, an audit trail and an action queue, as four query tools. It runs locally over stdio and talks to the Notion REST API directly.

What it is

I keep the governance record for BREALLE, the automation practice I run, in two Notion databases: an audit trail of dated, reasoned changes (CB-DB) and an action queue of open items (AQ). The standard Notion connectors for Claude have no row-query primitive. A question like "which audit rows for this subsystem are still in force" degrades to semantic search or a fetch of the whole database page, and both are slow, lossy and metered.

This server closes that gap. Each tool builds a server-side Notion filter from its parameters, sends it to POST /v1/databases/{id}/query, and maps each page object to a flat JSON row. Every enum parameter is checked against the live database schema before a query runs, so a typo or a renamed option comes back as an error with the allowed values instead of an empty result.

The server never writes to Notion. The integration token stays on the server side; it is never returned in a response and never written to a log.

Related MCP server: Notion Remote MCP Server

The four tools

cb_db_query

Filtered read of the audit trail, sorted by Effective Date descending. All parameters are optional.

Parameter

Type

Meaning

subsystem

string

Subsystem option, validated live

status

string

Status option, validated live

in_force

boolean

Keep only rows whose In Force formula matches; default true

since

string

ISO 8601 date; Effective Date on or after this day

change_type

string

Change Type option, validated live

artifact_type

string

Artifact Type option, validated live

authorized_by

string

Authorized By option, validated live

limit

integer

Rows to return; default 20, maximum 100

Returns { results, count, truncated, query_echo }. Each result carries the row's id, description, effective date, subsystem, change type, artifact URL and type, sections affected, summary, reasoning, authorizer, source session, supersedes, status, in-force flag and Notion URL.

cb_db_get

One audit-trail row by id. The single parameter cb_id accepts the prefixed form or the bare number. Returns { result } or a not_found error.

aq_query

Filtered read of the action queue, sorted by Created descending. All parameters are optional.

Parameter

Type

Meaning

owner

string

Owner option, validated live

status

string

Status option, validated live

priority

string

Priority option, validated live

category

string

Category option, validated live

type

string

Type option, validated live

since

string

ISO 8601 date; Created on or after this day

limit

integer

Rows to return; default 20, maximum 100

Returns { results, count, truncated, query_echo }. Each result carries the item's id, action title, status, owner, priority, category, type, created and resolved dates, context link, blocked-by text, decision notes and Notion URL.

aq_get

One action-queue row by id. The single parameter aq_id accepts the prefixed form or the bare number. Returns { result } or a not_found error.

Errors

Every error has the same shape: { error: { code, message, details } }. The codes are invalid_parameter, not_found, unauthorized, rate_limited, upstream_notion_error and internal. An internal error carries a correlation_id that matches one server-side log line; Notion's own error text is logged there and never passed through to the caller.

Enum validation against the live schema

No option value is hardcoded anywhere in the server. On first use it calls GET /v1/databases/{id}, reads the option names of every select and status property, and caches them for SCHEMA_CACHE_TTL_SECONDS (default 300). A parameter such as change_type is checked against that set before the filter is built.

The reason is how Notion answers a filter on an option that does not exist: it does not reject it, it returns an empty result set, which is indistinguishable from "no rows matched". An agent that reads that answer will conclude a change was never filed. With validation, an unknown value returns invalid_parameter with the current allowed set in details.allowed, so the caller can correct itself in one step. It also means that when I rename or add an option in Notion the server follows it on the next cache refresh with no code change.

The one filter that cannot be pushed to Notion is in_force. Notion refuses to filter on a formula property of unknown type, so cb_db_query streams the server-filtered, date-sorted pages and applies the in-force predicate to each row's formula output until it has limit matches. See Limits below.

Running it locally over stdio

You need Node.js 20 or later and a Notion internal integration that has been shared with the two databases. Read access is enough.

  1. Install and build.

    npm install
    npm run build
  2. Copy .env.example to .env and fill it in: the integration token, and the two database ids. The ids are the 32-character strings in each database's URL; the file ships with placeholders.

  3. Register the server with a client. For Claude Code:

    claude mcp add -s user governance-query -- node --env-file=/path/to/governance-query-mcp/.env /path/to/governance-query-mcp/dist/stdio.js

    For Claude Desktop, add an entry to mcpServers in its config file:

    {
      "mcpServers": {
        "governance-query": {
          "command": "node",
          "args": [
            "--env-file=/path/to/governance-query-mcp/.env",
            "/path/to/governance-query-mcp/dist/stdio.js"
          ]
        }
      }
    }

The client spawns the process on demand and it exits with the session; nothing listens on a port. Under stdio, stdout is the JSON-RPC channel, so every log line goes to stderr.

npm test runs the unit tests for the row mappers with no network. npm run typecheck and npm run lint cover the rest. docs/clients.md has the client configuration in more detail and docs/smoke-tests.md has the acceptance fixtures.

One quirk: the config loader requires MCP_BEARER_TOKEN to be set even under stdio, where it is not used. Any non-empty value satisfies it.

The HTTP entrypoint

dist/index.js, the Dockerfile and docker-compose.yml are the hosted mode: Streamable HTTP at POST /mcp, legacy SSE at GET /sse plus POST /messages, an unauthenticated /health, and a bearer token compared in constant time on every other request. It is kept in the tree and still builds, but it is not how I run the server now. docs/auth.md has the history.

Notion API version

The server uses the classic single-data-source endpoints, GET /v1/databases/{id} and POST /v1/databases/{id}/query, with Notion-Version pinned to 2022-06-28. The CB_DB_ID and AQ_DB_ID variables therefore carry database ids, not data-source ids. If Notion retires those semantics, the change is to switch NOTION_VERSION and move the query to POST /v1/data_sources/{id}/query.

Deployment history

Ran May to September 2026 as a hosted service behind TLS and a bearer token, on a server that was retired when the client work ended; local over stdio since.

Limits

  • No pagination. There is no cursor parameter. A query returns at most 100 rows (limit is clamped to 100) and reports truncated: true when more rows matched, but there is no way to ask for the next page.

  • Enumeration ceiling. Because of the point above, the newest 100 rows that match a filter are all that can be listed. For a full enumeration I use a Notion view instead.

  • In-force filtering is client-side. cb_db_query fetches pages of 100 and drops rows whose formula does not match, up to 20 pages. On a database of several hundred rows this is usually one request, because the scan stops as soon as it has limit matches; a filter that most rows fail would take more pages, and on a much larger database the cap could cut the scan short.

  • One request at a time. Calls to Notion are serialized with a 350 ms minimum spacing to stay under the integration rate limit, and a 429 is retried up to three times honoring Retry-After. Throughput is low by design.

  • Schema cache lag. An option added in Notion is not accepted until the cache expires, at most SCHEMA_CACHE_TTL_SECONDS later.

  • Local clients only. A stdio server is reachable from the machine it runs on. The hosted HTTP mode could not be used from the claude.ai web connector either, because custom connectors there require OAuth 2.1 with PKCE and this server only ever implemented a static bearer token.

Authorship

I specified this server and directed the build; the code was written by AI coding agents under my direction, and I own and run it daily.

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers