Skip to main content

MCP Server

The B1 API ships an MCP (Model Context Protocol) server at /mcp. Any MCP-aware AI client — Claude Code, Claude Desktop, the OpenAI Agents SDK, Cursor, or your own — can connect to it and call the underlying REST API on behalf of an authenticated church user. It's a thin, generic wrapper: there are three tools, and they expose the whole API surface dynamically rather than hand-modeling each endpoint.

Before You Begin

  • A B1 API key (cak_…) with the scopes the client should have
  • A reachable B1 API host — https://api.churchapps.org for hosted churches, or your own deployment
  • An MCP client. See Claude and ChatGPT for end-user setup

Endpoint

POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer cak_<prefix>.<secret>
AspectValue
Path/mcp (relative to the API host)
MethodPOST only — request/response and SSE streaming both happen on the same endpoint
TransportMCP Streamable HTTP
Session modelStateless. A fresh MCP server instance is built per request — no session id, no resumption
AuthBearer token. cak_… API keys and B1 JWTs both work; resolution is the same as any other authenticated endpoint

A request whose Authorization header is missing or invalid returns:

{ "error": "Unauthorized — MCP requires a valid bearer token (cak_* API key or JWT)." }

with HTTP 401.

Tools

Three tools, all generic. The model uses list_endpoints for discovery, describe_endpoint to learn a payload shape, and api_call to actually invoke the API.

list_endpoints

Returns the full inventory of registered REST routes, filtered by an optional substring and/or HTTP verb. Each entry includes the controller name and the API key scopes most likely needed.

Input:

FieldTypeDescription
filterstring (optional)Case-insensitive substring matched against the path, e.g. "people"
methodenum (optional)GET / POST / PUT / DELETE / PATCH

Output: a JSON document of the form

{
"total": 24,
"endpoints": [
{
"method": "GET",
"path": "/membership/people",
"controller": "PersonController.getAll",
"likelyScopes": ["people:read", "people:write"]
}
]
}

The inventory is built once at API startup from the live route table — anything you can hit with curl appears here.

describe_endpoint

Returns a short summary plus, where available, a hand-curated request body and response sample for one endpoint.

Input:

FieldTypeDescription
methodstringHTTP verb
pathstringFull path as returned by list_endpoints

Output: for curated endpoints, an example with summary, requestBody, and responseSample. For un-curated endpoints, a fallback message instructing the model to call GET first to see the shape. About a dozen high-traffic routes (people, groups, donations, attendance, funds) are curated.

api_call

Invokes the chosen REST endpoint, in-process, through the same Express middleware stack as a normal HTTP request — auth, body parsing, audit logging, and per-church scoping all apply.

Input:

FieldTypeDescription
methodenumGET / POST / PUT / DELETE / PATCH
pathstringPath including any module prefix, e.g. /membership/people
queryobject (optional)Flat object of query-string parameters
bodyany (optional)JSON request body — typically an array of model objects for POST

Output:

{
"status": 200,
"truncated": false,
"body": [ /* the controller's JSON response */ ]
}

Tool result is marked isError: true for any response with status ≥ 400.

Auth Model

The MCP request itself runs through CustomAuthProvider.getUser() — the same path every authenticated B1 endpoint uses. A cak_… bearer resolves to a Principal whose permissions are the issuing person's current RBAC, intersected with the key's granted scopes. This intersection is recomputed on every request, so:

  • Removing a scope from a key (by deleting and recreating it) cuts access on the next call.
  • Removing a permission from the underlying person in B1Admin cuts access on the next call, even if the key still exists.

For nested api_call invocations, the original Authorization header is copied onto the synthetic request, so CustomAuthProvider runs again and the scope intersection is re-applied per call. There is no token caching.

Path Blocklist

A small set of routes are not reachable via api_call, even with a valid key:

PatternWhy
/giving/donate/webhook/*Provider webhook endpoints expect raw, signature-verified bodies from Stripe/PayPal — not general callers
/membership/oauth/clients*OAuth client registration is operator-only
/membership/people/apiEmailsGated by the operator jwtSecret, not user permissions
Any route expecting multipart/form-dataFile uploads are not JSON-RPC-friendly

A blocked path returns an isError: true tool result with a descriptive message; the underlying route is never invoked.

Response Size Cap

Each api_call response body is capped at 64 KB of captured output. If a query exceeds the cap, the response carries "truncated": true and the model is expected to retry with narrower query parameters. This keeps a single tool response from blowing out the client's context window.

Rate Limiting

There is no application-level rate limit on /mcp. Throttling is deferred to API Gateway / Lambda concurrency in production, and to whatever your reverse proxy enforces in self-hosted deployments.

OAuth Discovery

The MCP server does not advertise OAuth 2.1 metadata (/.well-known/oauth-authorization-server, dynamic client registration, PKCE flow). Clients that require OAuth-discovered MCP servers — notably Claude.ai's "Add custom connector" UI and ChatGPT's "Connectors" feature — cannot connect without that surface.

Clients that accept a static bearer token in their config — Claude Code, Claude Desktop, OpenAI Agents SDK, Cursor, custom code — work today. The existing OAuthController already issues tokens via authorization-code + PKCE for third-party apps; an MCP-spec-compliant discovery layer on top of it would close the gap.

Local Development

The MCP endpoint mounts alongside everything else when the API runs locally:

cd Api
npm run dev
# Server listening on http://localhost:8084

On startup the log line 📡 MCP server ready at /mcp — N routes in inventory confirms the inventory was built.

Probe it with the MCP Inspector:

npx @modelcontextprotocol/inspector

In the Inspector UI, point it at http://localhost:8084/mcp and set the Authorization header to Bearer cak_<prefix>.<secret>. Call list_endpoints first; you should see the full route list. Then api_call({ method: "GET", path: "/membership/people" }) should return your local seed people.

Code Layout

The MCP server lives at src/modules/mcp/ in the Api repo. Notable files:

FilePurpose
McpController.ts@controller("/mcp"); wires StreamableHTTPServerTransport per request
McpServer.tsBuilds an MCP Server, registers the three tools
RouteInventory.tsWalks inversify-express-utils metadata at startup to enumerate routes
internalDispatch.tsSynthetic req/res that re-enters the Express app in-process
tools/listEndpoints.ts, describeEndpoint.ts, apiCall.ts
examples.tsCurated request/response samples for high-traffic endpoints