bewell-catalyst-mcp
by bewellit
README.md
# bewell-catalyst-mcp
An MCP (Model Context Protocol) server for managing an organization's
**activity library** on the BeWell Catalyst platform. From any MCP-capable
Claude session you can create, update, list, fetch and delete library
activities of every type, including uploading their media (images, video,
audio).
The server contains no business logic: every tool call is a thin RPC to a
Catalyst cloud function, where authentication, authorization and domain
rules live. The one thing it does locally is read media files from disk and
upload them through a signed URL.
**What publishing means:** a published activity lands in the organization's
library and appears immediately in the teachers' Manage Activities screen.
Participants see it only once a teacher **places** it in a course.
Publishing stocks the library; it does not push content to participants.
## Install
Requires Node.js 20 or newer.
Add to your `.mcp.json` (Claude Code) or MCP client configuration:
```jsonc
{
"mcpServers": {
"bewell-catalyst": {
"command": "npx",
"args": ["-y", "bewell-catalyst-mcp"],
"env": {
"BEWELL_CATALYST_API_KEY": "bwc_...",
"BEWELL_CATALYST_FUNCTIONS_BASE_URL": "https://europe-west1-bewellit-prod-eu.cloudfunctions.net"
}
}
}
}
```
## Environment variables
| Variable | Purpose |
|----------|---------|
| `BEWELL_CATALYST_API_KEY` | API key (`bwc_...`) minted by a platform admin. Sent in the `x-bewell-catalyst-api-key` header on every call. One key can manage one or more organizations. |
| `BEWELL_CATALYST_FUNCTIONS_BASE_URL` | Cloud Functions origin of the target environment (selects the region/project). |
| `BEWELL_CATALYST_ORG_LOCK` (optional) | Pin this workspace to a single `organizationId`. When set, every org-scoped tool refuses any other `organizationId` **before making a network call**. `list_organizations` stays available. See [Working with multiple organizations](#working-with-multiple-organizations). |
| Environment | Base URL |
|-------------|----------|
| EU | `https://europe-west1-bewellit-prod-eu.cloudfunctions.net` |
| US | `https://us-central1-bewellit-prod-us.cloudfunctions.net` |
API keys and storage are per-environment: a key minted for the EU
environment only works against the EU base URL, and media uploaded in one
environment cannot be referenced from the other.
## Getting an API key
Keys are minted by a **platform administrator** in the Catalyst admin UI
(there is no self-serve issuance). A key manages **one or more**
organizations and carries the `publishing` scope, which covers every tool
in this server. Keys can expire and can be revoked at any time; treat them
as secrets. If you don't know which `organizationId` values a key manages,
call [`list_organizations`](#working-with-multiple-organizations).
## Tools (15)
| Tool | Purpose |
|------|---------|
| `upload_media` | Upload a local file, get back the `finalUrl` for media fields |
| `create_text_activity` / `update_text_activity` | Markdown articles, optional hero image and journal prompts |
| `create_video_activity` / `update_video_activity` | Video activities (uploaded file + duration + thumbnail) |
| `create_audio_activity` / `update_audio_activity` | Audio activities such as guided meditations |
| `create_image_activity` / `update_image_activity` | Full-screen image activities |
| `create_simple_activity` / `update_simple_activity` | Config-only cards: `mindful_timer`, `breathing`, `cold_plunge`, `copyright` |
| `get_activity` | Fetch one activity by slug (returns `activity: null` when absent) |
| `list_activities` | Paginated summaries, optional type filter, no content bodies |
| `delete_activity` | Delete an MCP-created activity plus (best-effort) its media |
| `list_organizations` | List the organizations this key manages (`{id, name}`); takes no inputs |
## Identity and idempotency
Activities are addressed by `(organizationId, contentSlug)`. The slug
namespace is shared across **all** types: one slug names exactly one
activity per organization, and its **type can never change** after
creation. Creating with an existing slug (same type, MCP-created) acts as
an update; retries are safe.
Only activities created through this MCP can be updated or deleted.
Activities authored in-app are readable via `get_activity` /
`list_activities` but never writable here.
## Working with multiple organizations
A single key can manage several organizations. There is still **one key and
one config entry** — you choose which organization a call targets purely
through the `organizationId` argument on each tool. Switching organizations
means passing a different `organizationId`; there is no config edit and no
restart.
- **Discover** what a key manages with `list_organizations` (no inputs). It
returns `{ "organizations": [{ "id": "...", "name": "..." }, ...] }`
(`name` is the display name, or `null` if the record is unavailable).
Start here whenever you don't already know the `organizationId` to use.
- **Target** an organization by passing one of those ids as
`organizationId` to any other tool.
The server enforces membership on every call: a key may only act on the
organizations attached to it. A call for an unattached organization is
refused with `PERMISSION_DENIED` — call `list_organizations` to see the
valid ids.
### Locking a workspace to one organization (recommended)
When a workspace should only ever touch **one** organization, set
`BEWELL_CATALYST_ORG_LOCK` to that organization's id. Every org-scoped tool
then refuses any other `organizationId` **client-side, before any network
call**, naming both the locked and the attempted organization.
`list_organizations` remains available (read-only discovery is harmless).
The recommended pattern is **one `.mcp.json` entry per project**, each
locked to that project's organization — so a Claude session working in that
project physically cannot drift to another organization even though the key
could reach several:
```jsonc
{
"mcpServers": {
"bewell-catalyst": {
"command": "npx",
"args": ["-y", "bewell-catalyst-mcp"],
"env": {
"BEWELL_CATALYST_API_KEY": "bwc_...",
"BEWELL_CATALYST_FUNCTIONS_BASE_URL": "https://europe-west1-bewellit-prod-eu.cloudfunctions.net",
"BEWELL_CATALYST_ORG_LOCK": "your-org-id"
}
}
}
}
```
Multi-organization sessions (rare, operator-driven) simply omit the lock.
The lock is a convenience guard against accidental cross-organization
writes; the server-side membership check remains the hard boundary either
way.
## Per-type contracts
Common required fields: `organizationId`, `contentSlug`, `name`.
Common optional fields: `description`, `tags`, `author`, `analyticsId`,
`isPractice`, `includeInAiChat`.
| Type | Additionally required | Optional extras | Notes |
|------|----------------------|-----------------|-------|
| `text` | `contentMarkdown` (max 50k chars) | `imageUrl` (hero), `imagePosition` (`top`/`bottom`), `journalPrompts` | |
| `video` | `contentUrl`, `durationSeconds` | `thumbnailUrl` (strongly recommended), `journalPrompts` | Directly streamable file (mp4/webm); a YouTube page URL will not play |
| `audio` | `contentUrl`, `durationSeconds` | `thumbnailUrl`, `journalPrompts` | The card shows "(0 seconds)" without a real duration |
| `image` | `imageUrl` | markdown `description` | Full-screen viewer |
| `copyright` | `description` (the notice text) | none | Served by the `*_simple_activity` tools |
| `mindful_timer` | none | none | Name-only config card |
| `breathing` | none | none | Breathing pattern is a user choice at runtime |
| `cold_plunge` | none | none | Name-only config card |
`durationSeconds` is a positive integer (max 86400) and is **not** probed
server-side: measure the real duration locally (for example with
`ffprobe`) and pass it.
`analyticsId` enables completion tracking and is **write-once**: it can be
set on create (recommended value: the `contentSlug`) or added later, but
never changed or cleared once set. Without it the activity is untracked.
## Media workflow
Always `upload_media` first, then pass the returned `finalUrl` into the
create/update call. Media URL fields only accept `finalUrl` values minted
for your own organization and environment: external URLs are rejected.
| Role | Feeds | Formats | Max size |
|------|-------|---------|----------|
| `video` | `contentUrl` (video) | `.mp4` `.mov` `.webm` | 500 MB |
| `audio` | `contentUrl` (audio) | `.mp3` `.m4a` `.aac` `.wav` `.ogg` | 50 MB |
| `thumbnail` | `thumbnailUrl` (video/audio) | `.jpg` `.png` `.webp` | 5 MB |
| `image` | `imageUrl` (image activity, text hero) | `.jpg` `.png` `.webp` `.gif` | 2 MB |
| `inline` | images inside `contentMarkdown` | `.jpg` `.png` `.webp` `.gif` | 2 MB |
`filePath` must be a local path on the machine running the MCP server. The
MIME type is inferred from the extension (pass `contentType` explicitly to
override). Re-uploading with the same role **and the same file extension
overwrites** the previous file; the same role with a different extension
creates a sibling file instead. That is harmless: only the `finalUrl` you
pass to create/update is referenced, and `delete_activity`'s folder
cleanup collects any orphans. `inline` creates a new file per call.
## Update semantics (PATCH)
`update_*` tools patch, they do not replace:
- **Omitted** optional field: the stored value is **preserved**.
- **Explicit `null`**: the stored value is **cleared** (`tags: []` clears
tags).
- `name` and the type's required fields (for example `contentUrl` +
`durationSeconds` for video) must be **re-sent on every update**.
- `analyticsId`: omit to preserve. Changing or clearing an existing value
is refused.
- `type` is immutable; the update tools re-assert it and the server
verifies it matches.
## Deleting
`delete_activity` removes the activity document and best-effort deletes its
uploaded media. Two caveats:
1. **Placements are not checked.** If a teacher has placed the activity in
a course template or course, deleting it leaves a dangling reference
(the app renders nothing for it). Check with the teacher before
deleting anything that may be in use.
2. Only MCP-created activities can be deleted.
Deletion is idempotent: a missing slug returns
`{"deleted": false, "reason": "not-found"}` without error. In a successful
response, `mediaCleanedUp: false` means the document was deleted but the
media folder could not be cleaned up.
## Error glossary
| Status | Meaning | What to do |
|--------|---------|-----------|
| `INVALID_ARGUMENT` | A field failed validation; the message names it | Fix the named field. For "first-party media URL" errors, use `upload_media` and pass its `finalUrl` |
| `FAILED_PRECONDITION` "not created by MCP" | The activity was authored in-app | Leave it alone; create a new slug for your own version |
| `FAILED_PRECONDITION` "type is immutable" | The slug is taken by another type | Choose a new `contentSlug` |
| `FAILED_PRECONDITION` "analyticsId is immutable" | Attempt to change/clear a set `analyticsId` | Omit `analyticsId` from the update |
| `UNAUTHENTICATED` | Key missing, malformed, revoked or expired | Check `BEWELL_CATALYST_API_KEY`; ask a platform admin for a new key |
| `PERMISSION_DENIED` | The `organizationId` is not one this key manages, or the key is missing scope | Call `list_organizations` and pass a listed id |
| `RESOURCE_EXHAUSTED` | Rate limit (100/min per key, 30/min per IP) | Wait a minute, retry |
| `INTERNAL` | Server-side failure | Retry once; report if persistent |
## Development
```bash
npm install
npm run build # tsc -> dist/
npm run lint # eslint
npm test # vitest
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues