Skip to main content
Glama
zohto

governance-query-mcp

by zohto
README.md
# 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.

## 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.

   ```bash
   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:

   ```bash
   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:

   ```json
   {
     "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`.