Content
# Ministry Platform MCP Server
An [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server designed to give Claude direct access to [Ministry Platform's](https://www.ministryplatform.com) REST API. Connect Claude Desktop to your MP instance and query contacts, events, groups, and other church data conversationally. The connector is read-only by default. Writes can be enabled in the allowlist, but no currently shipped tools write — adding write capability is at your own risk.
Users authenticate with their own MP credentials via OIDC, so they can only see data their MP security role allows. If writes are ever added, they would be gated by both the user's role and the table's `write` flag in the allowlist.
## Quick start (Docker)
### 1. Set up the reverse proxy
You'll need a public DNS hostname for Claude to reach your MCP server. Reverse-proxy that HTTPS hostname to port 3000 of your container — see [Public HTTPS](#1-set-up-public-https) for examples.
### 2. Create the MP API Client
[In MP under **Administration → API Clients**](#2-configure-oidc), create a client and set the **Redirect URIs** to:
```
<PUBLIC_URL>/auth/callback;https://claude.ai/api/mcp/auth_callback;
```
Note the **Client ID** and **Client Secret** for the next step.
### 3. Configure and start the container
In the directory where you want the deployment to live:
```bash
curl -fsSL https://raw.githubusercontent.com/The-Moody-Church/mp-mcp/main/docker-compose.example.yml -o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/The-Moody-Church/mp-mcp/main/.env.example -o .env
mkdir -p config && curl -fsSL https://raw.githubusercontent.com/The-Moody-Church/mp-mcp/main/config/table-access.example.json -o config/table-access.json
# Edit .env (MP_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, PUBLIC_URL):
$EDITOR .env
# Start:
docker compose up -d
```
### 4. Add the connector in Claude's organization settings
In claude.ai, go to [**Organization Settings → Connectors**](https://claude.ai/admin-settings/connectors) and click **Add custom web connector**. Set the **Remote MCP server URL** to `<PUBLIC_URL>/mcp`. See [Connecting Claude](#connecting-claude) for the screenshot and full field reference.
### 5. Each user connects from their personal settings
Each staff user opens [their personal connector settings](https://claude.ai/settings/connectors), finds the Ministry Platform connector, clicks **Connect**, and signs in with their MP credentials.
## Contents
- [Quick start (Docker)](#quick-start-docker)
- [1. Set up the reverse proxy](#1-set-up-the-reverse-proxy)
- [2. Create the MP API Client](#2-create-the-mp-api-client)
- [3. Configure and start the container](#3-configure-and-start-the-container)
- [4. Add the connector in Claude's organization settings](#4-add-the-connector-in-claudes-organization-settings)
- [5. Each user connects from their personal settings](#5-each-user-connects-from-their-personal-settings)
- [Features](#features)
- [Setup](#setup)
- [1. Set up public HTTPS](#1-set-up-public-https)
- [2. Configure OIDC](#2-configure-oidc)
- [Deployment](#deployment)
- [Option A: Docker (recommended)](#option-a-docker-recommended)
- [Compose options](#compose-options)
- [Networking](#networking)
- [Option B: Node.js (no Docker)](#option-b-nodejs-no-docker)
- [Vercel and other serverless platforms](#vercel-and-other-serverless-platforms)
- [Configuration](#configuration)
- [1. Environment variables](#1-environment-variables)
- [2. Table allowlist](#2-table-allowlist)
- [Start the server](#start-the-server)
- [Connecting Claude](#connecting-claude)
- [1. Add the connector at the organization level](#1-add-the-connector-at-the-organization-level)
- [2. Each user enables and signs in](#2-each-user-enables-and-signs-in)
- [Available Tools](#available-tools)
- [Security](#security)
- [Authentication](#authentication)
- [Permission model](#permission-model)
- [Table allowlist](#table-allowlist)
- [No secrets on client machines](#no-secrets-on-client-machines)
- [Further reading](#further-reading)
- [Endpoints](#endpoints)
- [Releases](#releases)
- [Channels](#channels)
- [Cutting a release](#cutting-a-release)
- [Troubleshooting](#troubleshooting)
- [License](#license)
## Features
- **Read-only — no writes implemented today** — every shipped tool calls MP via `GET`. The allowlist's per-table `write` flag is informational only; flipping it to `true` doesn't enable writes (no tool consults it as a gate, and no write code path exists). Adding write capability requires new tool implementations.
- **Per-user OIDC auth** — each user signs in with their Ministry Platform credentials
- **Table allowlist** — configurable cap on which tables are exposed, independent of MP security roles
- **Concurrency limiting** — respects MP's connection limits
- **URL length handling** — automatically switches long GET requests to POST fallback
## Setup
> **Reference values for The Moody Church's deployment** (used as examples throughout this section):
>
> - `MP_BASE_URL` → `https://moody.ministryplatform.com`
> - `PUBLIC_URL` → `https://mcp.moodychurch.app`
>
> Substitute your own values where you see `<MP_BASE_URL>` / `<PUBLIC_URL>` placeholders or generic examples like `your-church.ministryplatform.com`.
### 1. Set up public HTTPS
Claude.ai needs to reach this server over HTTPS, so port 3000 must sit behind a reverse proxy with TLS termination. The proxy's public hostname is what you'll set as `PUBLIC_URL`. Three common options:
**Cloudflare Tunnel** — no port forwarding required; Cloudflare handles the cert.
> This section assumes you already have a Cloudflare Tunnel running on your network (with `cloudflared` connected) and Cloudflare managing your public DNS. If you're starting from scratch, follow [Cloudflare's quickstart for creating a remote tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-remote-tunnel/) first, then come back here to publish the route.
Two ways to configure the route:
1. **Dashboard-based** (Cloudflare Zero Trust → Networks → tunnel → Published application routes). If you're running cloudflared as a Docker container on the same network as mp-mcp, set the **Service URL** to the container name and port (e.g., `mp-mcp:3000`) — Docker DNS resolves it inside the network and you don't expose port 3000 on the host at all.

2. **YAML-based** (standalone cloudflared on the host).
```yaml
# ~/.cloudflared/config.yml
tunnel: <your-tunnel-id>
credentials-file: /path/to/<your-tunnel-id>.json
ingress:
- hostname: mcp.your-church.com
service: http://localhost:3000
- service: http_status:404
```
**Caddy** — auto cert via Let's Encrypt.
```caddyfile
mcp.your-church.com {
reverse_proxy localhost:3000
}
```
**nginx** — bring your own cert (e.g., certbot).
```nginx
server {
listen 443 ssl;
server_name mcp.your-church.com;
# ssl_certificate / ssl_certificate_key directives here
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_buffering off; # MCP uses streamable HTTP
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### 2. Configure OIDC
Ministry Platform's OAuth/OIDC clients live under **Administration → API Clients** (search "api" in the MP admin to find it quickly). Create a new API Client for the MCP server using the following settings:

| Field | Value | Notes |
|---|---|---|
| **Display Name** | `MCP Server` | Any human-readable label. |
| **Client ID** | your choice (e.g., `mcp`) | Used in two places: as `OIDC_CLIENT_ID` in `.env`, **and** as the OAuth Client ID in [Claude's Add custom connector dialog](#1-add-the-connector-at-the-organization-level). |
| **Client Secret** | (auto-generated by MP) | Used in two places: as `OIDC_CLIENT_SECRET` in `.env`, **and** as the OAuth Client Secret in [Claude's Add custom connector dialog](#1-add-the-connector-at-the-organization-level). |
| **Client User** | `APIUser` (or your install's standard API user) | Acts as the upper-bound **ceiling** on what *any* signed-in user can do through this connector — MP won't let mp-mcp make calls beyond what this user's role permits, regardless of the signed-in user's own role. Pick a user whose role meets or exceeds the most permissive access you want surfaced through Claude. Individual API calls are attributed to the actual signed-in user in MP's audit log, not to this user — per-user accountability holds even with writes enabled. **If you set `ALLOWED_USER_GROUP_IDS`**, this user's role must also have Read on `dp_Users` and `dp_User_User_Groups` — that's the identity used for the group-membership lookup. |
| **Authentication Flow** | must include **Authorization Code** (and **Client Credentials** if `ALLOWED_USER_GROUP_IDS` is set) | Authorization Code is what end users go through. Client Credentials is used server-to-server for the `ALLOWED_USER_GROUP_IDS` membership lookup, so end users don't need Read on `dp_Users` / `dp_User_User_Groups`. |
| **Redirect URIs** | `<PUBLIC_URL>/auth/callback;https://claude.ai/api/mcp/auth_callback;`<br>(for TMC: `https://mcp.moodychurch.app/auth/callback;https://claude.ai/api/mcp/auth_callback;`) | Semicolon-separated. The first entry must exactly match the `PUBLIC_URL` you'll set in `.env`; the second is the Claude.ai callback for dynamic client registration. Must end with a **semicolon**. |
| **Post Logout Redirect URIs** | `<PUBLIC_URL>/;` | Optional — only used if you implement an explicit logout flow. Must end with a **semicolon**. |
| **Access Token Lifetime** | `60` (minutes) | One hour. Lower means more frequent silent re-auth. |
| **Identity Token Lifetime** | `5` (minutes) | Default is fine. |
| **Refresh Token Lifetime** | `43200` (minutes = 30 days) | Default is fine. |
| **Authorization Code Lifetime** | `5` (minutes) | Default is fine. |
| **Is Enabled** | `Yes` | Required. |
| **Is Rotating Refresh Token** | `No` | Default is fine; flip to `Yes` if your security posture requires it. |
**Scopes** — not visible on the General tab. mp-mcp uses `openid`, `offline_access`, and `http://www.thinkministry.com/dataplatform/scopes/all`. If your MP install requires explicit scope authorization on the API Client (typically a separate tab or section), enable all three. Login will fail with a scope-related error if any are missing.
## Deployment
### Option A: Docker (recommended)
You don't need to clone the repository — the image is pulled from GHCR. (Cloning the repo is fine if you want to keep the source handy or build locally; it's just not required.) In the directory where you want the deployment to live, grab the example config files:
```bash
curl -fsSL https://raw.githubusercontent.com/The-Moody-Church/mp-mcp/main/docker-compose.example.yml -o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/The-Moody-Church/mp-mcp/main/.env.example -o .env
mkdir -p config && curl -fsSL https://raw.githubusercontent.com/The-Moody-Church/mp-mcp/main/config/table-access.example.json -o config/table-access.json
```
Or build the image locally instead of pulling from GHCR:
```bash
docker build -t mp-mcp .
```
Once the files are in place, [continue to Configuration](#configuration) →
#### Compose options
What's in the example `docker-compose.yml` and what you might change:
| Setting | Default | When to change it |
|---|---|---|
| `image:` | `ghcr.io/the-moody-church/mp-mcp:latest` | Pin to a specific version (`:0.1.0`) or channel (`:0.1`, `:main`, `:dev`) — see [Releases](#releases). |
| `ports:` | `"3000:3000"` | Drop this entirely if your reverse proxy reaches mp-mcp via a shared Docker network (see [Networking](#networking) below). |
| `volumes:` | `./config/table-access.json` (read-only) and `./data` (read-write) | Allowlist mount is required. `./data` is only needed if `TOOL_LOG_PATH` is set in `.env`. |
| `env_file:` | `.env` | All required env vars live in `.env` — see [Configuration → Environment variables](#1-environment-variables). |
| `restart:` | `unless-stopped` | Keep this — auto-recovers from crashes and host reboots. |
| `build:` | (commented out) | Uncomment if you'd rather build the image locally than pull from GHCR. |
#### Networking
The example file doesn't declare an explicit Docker network. Pick the pattern that matches where your reverse proxy lives:
**A. Reverse proxy on the host** (cloudflared / nginx / Caddy as a system service). The default `ports: "3000:3000"` mapping is sufficient — your proxy points at `http://localhost:3000` or the host's IP.
**B. Reverse proxy in Docker on the same host** (cloudflared / Caddy / nginx running as a container). Attach mp-mcp to the proxy's external network so the proxy can resolve it by container name, and drop the `ports:` stanza so port 3000 isn't exposed on the host at all:
```yaml
# Add to docker-compose.yml:
networks:
cloudflared:
external: true
name: cloudflared_containers # whatever your reverse-proxy network is called
services:
mp-mcp:
# ... (rest of the service definition)
networks:
- cloudflared
# Remove the `ports:` block — the reverse proxy reaches us via the network.
```
For TMC the reverse-proxy network is `cloudflared_containers` and cloudflared points at `http://mp-mcp:3000` — exactly what the [Cloudflare Tunnel route screenshot](#1-set-up-public-https) shows.
### Option B: Node.js (no Docker)
Requires Node.js 22 or later. Clone the repo, install dependencies, and build:
```bash
git clone https://github.com/The-Moody-Church/mp-mcp.git
cd mp-mcp
npm install
npm run build
```
Copy the example config files to their target names:
```bash
cp .env.example .env
cp config/table-access.example.json config/table-access.json
```
Once the files are in place, [continue to Configuration](#configuration) →
### Vercel and other serverless platforms
**Don't deploy mp-mcp to Vercel, AWS Lambda, Cloudflare Workers, or similar function-as-a-service platforms.** It won't work reliably, and the failure modes are subtle.
mp-mcp keeps several pieces of state in process memory that are required for correctness across requests:
- **MCP session transports** — each user holds a `StreamableHTTPServerTransport` keyed by their user ID and the session ID the SDK assigns on initialize. Subsequent tool calls have to land on the same process or the SDK returns 404 "Session not found" and the client has to reinitialize.
- **OAuth dynamic client registration** — Claude calls `/register` to mint a client, then `/authorize` with that client ID. If the two requests hit different workers, `/authorize` fails because the client doesn't exist there.
- **Verified-token cache** — without a shared cache, every tool call re-hits MP's `userinfo` endpoint (and the `dp_Users` / `dp_User_User_Groups` lookup when `ALLOWED_USER_GROUP_IDS` is set), amplifying MP API traffic by a large multiple.
- **Idle-sweep `setInterval`** — relies on a long-lived process; serverless invocations end when the request finishes.
Serverless platforms route requests across ephemeral workers and don't guarantee any of those invariants, even with "always-on" / "fluid" tiers. Use a host that runs a single long-lived Node.js process: a Docker host (any VPS, Fly.io, Railway, Render, ECS, etc.) or a managed Node service. The [Docker](#option-a-docker-recommended) and [Node.js](#option-b-nodejs-no-docker) deployments above cover the common paths.
## Configuration
After getting the files in place via Deployment, edit them to match your install.
### 1. Environment variables
Edit `.env` to fill in your values:
| Variable | Description |
|----------|-------------|
| `MP_BASE_URL` | Your MP base URL — no trailing slash, no `/ministryplatformapi` suffix (the server appends that prefix automatically when calling the REST API).<br>Example: `https://your-church.ministryplatform.com`<br>For TMC: `https://moody.ministryplatform.com` |
| `OIDC_CLIENT_ID` | The OIDC client ID — matches the **Client ID** field on your MP API Client (e.g., `mcp`, `TM.Widgets`) |
| `OIDC_CLIENT_SECRET` | The OIDC client secret — copied from the **Client Secret** field on your MP API Client |
| `PUBLIC_URL` | The public URL where this server is hosted.<br>Example: `https://mcp.yourchurch.com`<br>For TMC: `https://mcp.moodychurch.app` |
| `PORT` | Server port (default: `3000`) |
| `ALLOWED_USER_GROUP_IDS` | (Optional) Comma-separated MP User Group IDs. Only users in these groups can log in. Leave empty to allow any authenticated MP user. **When set:** each user must be added to one of the listed groups in MP admin → Administration → User Groups *before* they hit Connect. The membership lookup itself runs on the API client's own token (not the user's), so the API client must have **Client Credentials** enabled and its **Client User** must have Read on `dp_Users` and `dp_User_User_Groups`. End users do **not** need those reads. See [Permission model](#permission-model). |
| `ALLOWED_REDIRECT_URIS` | (Optional) Comma-separated https URIs accepted for dynamic OAuth client registration in addition to the built-in `https://claude.ai/api/mcp/auth_callback`. |
| `MEMBER_FILTER` | (Optional) SQL filter snippet identifying "members" at this church (e.g., `Member_Status_ID = 1` or `Participant_Type_ID = 4`). Surfaced to Claude as a domain convention so it doesn't have to guess. Leave empty to make Claude ask before assuming. |
| `TOOL_LOG_PATH` | (Optional) Path to a JSONL file. When set, every tool call appends `{ ts, user_id, user_name, tool, args, duration_ms, ok, error? }`. Args are logged in full — keep this on a host-local volume. Leave empty to disable. |
### 2. Table allowlist
Edit `config/table-access.json` to include only the tables you want accessible through Claude. Each table has a `read` flag (gating the current tools) and a `write` flag (reserved for future write tools — currently unused):
```json
{
"Contacts": { "read": true, "write": false },
"Events": { "read": true, "write": false }
}
```
Tables not listed are blocked entirely, regardless of the user's MP security role.
**Sensitive tables excluded from the example** — you can add these back if you need them, but they carry extra risk:
- `dp_Users` — auth metadata including password-reset tokens and hash columns. Does **not** need to be in this allowlist for the `ALLOWED_USER_GROUP_IDS` membership check; that lookup runs server-side on the API client's own token, outside this allowlist. See [Permission model](#permission-model).
- `Background_Checks` — criminal-history data. High downside if a misconfigured role exposes them through the REST API.
- `Form_Responses` — freeform user-submitted text. High PII density and a prompt-injection surface for anything downstream of Claude.
### Snapshot the allowlisted schema (optional)
`docs/allowlisted-table-schema.json` is a checked-in snapshot of the columns on the tables you've allowlisted, useful as Claude-context seed material or as a contributor reference. Regenerate it after changing the allowlist:
```bash
npm run build:schema
```
The script uses the same `MP_BASE_URL` / `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` from `.env` that the server uses, hits MP's `/tables` metadata endpoint once per allowlisted table, and writes the file. Captured per column: name, abstract data type with size, primary-key / required / read-only / computed flags, and FK target table when MP marks the column as a foreign key. The `label_column` overlay (which column on the lookup table is the canonical display value) comes from this repo's `FK_CATALOG`, since MP doesn't surface that. The Client User backing the OIDC client must have read access on every allowlisted table; tables it can't see show up as misses in the script's output and cause a non-zero exit.
### Start the server
Once your `.env` and `config/table-access.json` are filled in, start the server:
```bash
docker compose up -d # Docker
# or
npm start # Node.js (production)
# or
npm run dev # Node.js dev server with auto-reload (not for production)
```
For Node.js production deployments, run under a process manager so the server auto-restarts on crashes and host reboots:
```bash
# With PM2
npm install -g pm2
pm2 start dist/index.js --name mp-mcp
# Or with systemd — create a unit file pointing at `node dist/index.js`
```
Smoke-test the server:
```bash
curl https://your-mcp-domain.example.com/health
# {"status":"ok"}
```
If the health check fails, inspect logs (`docker compose logs mp-mcp` for Docker; whatever you've wired up for Node.js).
## Connecting Claude
Setup is two stages, and the flow is the same in claude.ai (web) and Claude Desktop — both share the connectors model.
> **Note:** mp-mcp is designed for use in regular Claude conversations via the connectors UI in claude.ai and Claude Desktop. It can hypothetically be wired into Claude Code via local MCP config, but that path is untested.
### 1. Add the connector at the organization level
Go to [**Organization Settings → Connectors**](https://claude.ai/admin-settings/connectors) and click **Add custom web connector**. Fill in the dialog:

| Field | Value |
|---|---|
| **Name** | Anything readable, e.g., `Ministry Platform` |
| **Remote MCP server URL** | `<PUBLIC_URL>/mcp` — for TMC: `https://mcp.moodychurch.app/mcp` |
| **OAuth Client ID** (Advanced settings, **required**) | The same value as `OIDC_CLIENT_ID` in your `.env` — your MP API Client's Client ID (e.g., `mcp`) |
| **OAuth Client Secret** (Advanced settings, **required**) | The same value as `OIDC_CLIENT_SECRET` in your `.env` — your MP API Client's Client Secret |
This makes the Ministry Platform connector available to everyone in the org. It does not sign anyone in.
### 2. Each user enables and signs in
> **Before they hit Connect:** if you set `ALLOWED_USER_GROUP_IDS`, every new staff member must be added to one of those MP User Groups in MP admin → Administration → User Groups. Without that, MP's OIDC login still succeeds but the MCP fails-closed and Claude shows a generic "Authorization with the MCP server failed". See [Permission model](#permission-model) for the recommended `MCP Connector` role.
Each staff user opens [their personal connector settings](https://claude.ai/settings/connectors), finds the Ministry Platform connector in the list, and clicks **Connect**:

That opens MP's standard sign-in page in a browser; after a successful login they're returned to Claude, and the connector's settings page now shows it as **Connected** along with the list of available tools.
Each user signs in with their own MP credentials, so the data they see through Claude matches what their MP security role already permits in the MP web UI.
## Available Tools
Domain tools (preferred — they bake in the right FK joins and disambiguation):
| Tool | Description |
|------|-------------|
| `find_people` | Search contacts by name, email, or phone |
| `get_person_details` | Full profile: contact info, group memberships, recent attendance |
| `search_groups` | Search groups by name, type, or ministry |
| `get_group_roster` | Members of a group with roles and dates |
| `get_group_attendance_summary` | Per-participant attendance over one or two date windows; supports drift-detection thresholds |
| `search_events` | Search events by date range, name, or program |
| `get_event_attendance` | Attendees + pivoted Event_Metrics for an event |
| `get_schedule` | Events on a date / range with rooms already joined; accepts `today` / `tomorrow` / `this_sunday` / `this_week` |
| `get_attendance_summary` | Aggregate Event_Metrics for a recurring service across year / month / week / per-service buckets |
Aggregation helpers (use these instead of pulling rows to count them):
| Tool | Description |
|------|-------------|
| `count_rows` | `{ count: N }` for a table + filter — paginates server-side |
| `group_by_count` | `{ groups: [{value, count}, ...], total }` — bucket by any column or FK join |
| `birth_date_range_for_age` | Convert an age range into a Date_of_Birth filter snippet (handles the calculated-Age problem) |
Generic fallbacks (power-user / ad-hoc):
| Tool | Description |
|------|-------------|
| `list_tables` | List allowlisted tables |
| `describe_table` | Field names and types; surfaces `fk_join_prefix` / `lookup_table` for FK columns |
| `query_table` | Raw filtered query; response wrapped as `{ data, row_count, has_more, next_skip }` |
| `get_record` | Fetch a single record by ID |
### Query examples
Claude can use these tools naturally. For example:
- "What's on the schedule tomorrow?"
- "Year-over-year attendance for the Sunday Morning Service"
- "`<Group Name>` group members who came consistently last fall but haven't this spring"
- "How many active members are 65–69?"
- "Look up the contact record for John Smith"
### Query syntax
The `query_table` tool supports Ministry Platform's query parameters:
- **`$filter`** — SQL WHERE syntax: `Display_Name LIKE '%Smith%'`, `Event_Start_Date > GETDATE()`
- **`$select`** — Column names: `Contact_ID, Display_Name, Email_Address`
- **`$orderby`** — Sort: `Display_Name` or `Event_Start_Date DESC`
- **`$top`** / **`$skip`** — Pagination (max 1000 per request)
- **FK joins** — `Contact_ID_Table.Display_Name`, `Event_ID_Table.Event_Title`
## Security
### Authentication
Users authenticate via OIDC with their Ministry Platform credentials. **Every tool call** uses the user's own access token, so MP's security roles enforce what data each user can see — the same permissions they have in the MP web UI. The only exception is the `ALLOWED_USER_GROUP_IDS` membership lookup itself, which uses a server-side `client_credentials` token narrowly scoped to two reads (`dp_Users` and `dp_User_User_Groups`); this token is never used for tool calls and never reaches the user-visible code path. See [Permission model](#permission-model) for the layered ceilings on actual data access.
### Permission model
Three independent ceilings constrain what any signed-in user can do through mp-mcp; effective access is the **intersection**:
1. **The API Client's Client User** (configured in MP) — caps what *any* user authenticating through this connector can do, regardless of the signed-in user's own role.
2. **The signed-in user's MP Security Role** — standard per-user role-based access control. Each user only sees what their MP role permits, exactly as in the MP web UI.
3. **`config/table-access.json`** — the MCP server's own allowlist. Even if MP would permit a call, mp-mcp blocks it for tables that aren't listed.
To keep these layers aligned, the recommended pattern is to create a dedicated **MP Security Role** — for example, *MCP Connector* — that grants **Read** on every table in `config/table-access.json`. Then:
- Assign the role to the **Client User** on the API Client (raises the layer-1 ceiling).
- Assign the role (or roll it into a parent role) to every **staff user** who should use Claude (clears layer 2 for them).
<details>
<summary><strong>Tables to grant Read on</strong> (mirrors the sections in <code>config/table-access.example.json</code> — drop rows for any sections you remove from the allowlist)</summary>
| Section | Tables |
|---|---|
| **Required** (built-in domain tools) | `Contacts`, `Groups`, `Group_Participants`, `Events`, `Event_Participants`, `Event_Metrics`, `Event_Rooms` |
| Person + household lookups | `Contact_Statuses`, `Genders`, `Marital_Statuses`, `Prefixes`, `Suffixes`, `Life_Stages`, `Household_Positions`, `Household_Sources`, `Household_Types`, `Congregations` |
| Participant / engagement / membership | `Participants`, `Participant_Engagement`, `Participant_Milestones`, `Participant_Certifications`, `Participant_Types`, `Participation_Statuses`, `Member_Statuses`, `Milestones`, `Contact_Log`, `Contact_Log_Types` |
| Group context | `Group_Types`, `Group_Roles`, `Group_Role_Types`, `Group_Ended_Reasons`, `Group_Focuses`, `Meeting_Days`, `Meeting_Frequencies`, `Meeting_Durations` |
| Event / room context | `Event_Types`, `Metrics`, `Rooms`, `Room_Layouts`, `Buildings` |
| Programs / ministries | `Priorities`, `Programs`, `Program_Types`, `Service_Types`, `Ministries` |
| User / background-check lookups | `Background_Check_Types`, `dp_User_Roles` |
| Activity tracking | `Activity_Log` |
</details>
When you later expand the allowlist, you only need to add the new table to this role and to the JSON file — everyone with the role picks it up automatically.
**Extra grants for the API Client's Client User only (not staff users):** if you set `ALLOWED_USER_GROUP_IDS`, the membership lookup runs on the API Client's own token (Client Credentials). Grant Read on `dp_Users` and `dp_User_User_Groups` to the **Client User's** role — not to your staff role. End users never need to read those tables.
> **Terminology heads-up:** in MP, table-level permissions live on **Security Roles**, not **User Groups**. The `ALLOWED_USER_GROUP_IDS` env var filters *who* can sign in to mp-mcp; it does **not** grant or deny table access. Security Roles govern table access.
### Table allowlist
`config/table-access.json` is the layer-3 ceiling described above — the place to opt out of sensitive tables (e.g., `Donations`, `Background_Checks`, `Form_Responses`) even when a user's MP role would otherwise grant access. See [Configuration → Table allowlist](#2-table-allowlist) for the file format and the tables intentionally excluded from the example.
### No secrets on client machines
The MCP server URL is the only thing configured on staff machines. All credentials and tokens are managed server-side.
### Further reading
See [`docs/security-posture.md`](docs/security-posture.md) for the full control inventory, `query_table` power-user guidance, and documented accepted risks.
## Endpoints
| Path | Method | Description |
|------|--------|-------------|
| `/mcp` | POST/GET/DELETE | MCP streamable HTTP endpoint |
| `/auth/login` | GET | Initiates OIDC login flow |
| `/auth/callback` | GET | OIDC redirect callback |
| `/auth/logout` | GET | Ends session |
| `/health` | GET | Health check |
## Releases
Images are published to `ghcr.io/the-moody-church/mp-mcp` on every push to any branch and on every git tag matching `v*`.
### Channels
| Tag | Updates on | Use for |
|-----|-----------|---------|
| `:latest` | a new stable release is tagged | production (default) |
| `:0`, `:0.2` | a release in that major/minor is tagged | production, pinned to a major or minor line |
| `:0.2.0` | never (immutable) | production, pinned to an exact release |
| `:main` | every push to `main` | testing the latest merged commit |
| `:dev` | every push to any non-`main` branch | previewing a PR before it merges |
| `:sha-abc1234` | never (immutable) | reproducing a specific commit's behavior |
`:latest` only moves when a release is cut — it does **not** track every push to `main`. The default `image:` line in `docker-compose.example.yml` uses `:latest`, which is fine for most deployments. If you want a slower roll, pin to a minor (`:0.1` — auto-picks up patch releases) or to an exact version (`:0.1.0`, immutable).
Restarting the container to pick up a new image is non-disruptive in normal use — Claude clients reconnect to the MCP server on the next tool call, so users typically just retry their next prompt.
`:dev` is single-tenant — whichever non-`main` branch was pushed most recently wins. If you have multiple PRs in flight and need to test a specific one, use that PR's `:sha-<short>` tag instead.
### Cutting a release
Releases are git-tag driven. To cut `v0.2.0`:
```bash
git tag -a v0.2.0 -m "v0.2.0"
git push origin v0.2.0
```
The push triggers the workflow, which builds the image and tags it `:0.2.0`, `:0.2`, `:0`, and `:latest`. Pre-release identifiers (`v0.2.0-rc.1`) are also accepted by `docker/metadata-action`'s semver matcher and produce only the exact tag (no `:latest` move).
Keep `package.json` `version` in sync with the git tag when you cut one.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Every MP API call returns 404 | `MP_BASE_URL` has a trailing slash, or includes `/ministryplatformapi` | Set the bare URL: `https://your-church.ministryplatform.com` (no slash, no path) |
| OIDC login fails: "redirect_uri not registered" | The redirect URI configured in MP admin doesn't exactly match `PUBLIC_URL/auth/callback` | Add `https://your-mcp-domain.example.com/auth/callback` to your OIDC client in MP admin |
| OIDC login redirects to the wrong host | `PUBLIC_URL` doesn't match the public hostname your reverse proxy serves | Set `PUBLIC_URL` to the public HTTPS hostname, no trailing slash |
| Login succeeds but no tools work / Claude can't list tools | Reverse proxy is buffering streamable HTTP responses | Disable buffering in the proxy (e.g., `proxy_buffering off` in nginx) |
| `Table 'X' is not allowed` from a tool call | Table missing from `config/table-access.json` | Add it with `"X": { "read": true, "write": false }` and restart the container |
| `ALLOWED_USER_GROUP_IDS` blocks every login | Typo in the comma-separated IDs, or no current user is in any of the listed groups | Verify IDs in MP admin (System Setup → User Groups) and that the user is a member |
| One user can't sign in but others can — Claude shows "Authorization with the MCP server failed … `ofid_…`" | The `ofid_…` is a Claude.ai correlation ID, not in our logs. In `docker compose logs mp-mcp` you'll see `[verifyAccessToken] userinfo OK` with no following `[MCP] authenticated user:` line — the membership check threw. The user is not in any `ALLOWED_USER_GROUP_IDS` group | Add the user to the group in MP admin → Administration → User Groups. No restart needed — denials aren't cached |
| Every login fails with `[serverToken] client_credentials failed …` in the logs | The API client doesn't have **Client Credentials** enabled, or the Client User's role lacks Read on `dp_Users` / `dp_User_User_Groups` | Enable Client Credentials on the API client, and grant Read on both tables to the Client User's role (see [Permission model](#permission-model)) |
| `curl /health` works locally but Claude can't reach the server | DNS / proxy not actually routing the public hostname to port 3000 | Test from outside the network: `curl https://your-mcp-domain/health` |
| Container starts then exits immediately | Missing required env var, or `config/table-access.json` not mounted | Run `docker compose logs mp-mcp` — the error message names the missing piece |
## License
MIT
Connection Info
You Might Also Like
cc-switch
All-in-One Assistant for Claude Code, Codex & Gemini CLI across platforms.
awesome-claude-skills
A curated list of awesome Claude Skills, resources, and tools for...
claude-flow
Claude-Flow v2.7.0 is an enterprise AI orchestration platform.
ai-native-pm-os
The exhaustive guide to mastering Claude for Product Managers. Build your...
Train-in-Silence
The first Task-Aware MCP server and automated VRAM calculator for LLM...
stacklit
108,000 lines of code. 4,000 tokens of index. One command makes any repo...