@bettercms-ai/mcp 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -0
- package/SKILL.md +65 -0
- package/dist/index.d.ts +132 -0
- package/dist/index.js +1266 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @bettercms-ai/mcp
|
|
2
|
+
|
|
3
|
+
BetterCMS [Model Context Protocol](https://modelcontextprotocol.io) server. Lets an
|
|
4
|
+
AI assistant author your content schema and entries through the BetterCMS Management
|
|
5
|
+
API, authenticated with a short-lived, per-project `content:manage` key minted via
|
|
6
|
+
the OAuth 2.0 Device Authorization Grant (RFC 8628) — no pasted long-lived key.
|
|
7
|
+
|
|
8
|
+
## Tools
|
|
9
|
+
|
|
10
|
+
| Tool | Description |
|
|
11
|
+
|------|-------------|
|
|
12
|
+
| `create_model` | Create a content model (schema) with optional initial fields. |
|
|
13
|
+
| `add_field` | Append a field to an existing model (read-modify-write; never removes fields). |
|
|
14
|
+
| `create_entry` | Create a content entry; applies `data`/`status` via a follow-up update. |
|
|
15
|
+
| `create_form` / `update_form` | Author a form (fields + settings) to embed with `<BcmsForm>`. |
|
|
16
|
+
| `create_component` / `update_component` | Author a reusable component (blockJson tree + props) to render with `<BcmsBlocks>`. |
|
|
17
|
+
| `list_forms` / `get_form` / `list_components` / `get_component` | Discover forms & components to wire into site code. |
|
|
18
|
+
|
|
19
|
+
See [SKILL.md](./SKILL.md) for how an AI agent should author pages, forms, and components.
|
|
20
|
+
|
|
21
|
+
All operations are **additive** — there are no destructive tools. Deleting models,
|
|
22
|
+
fields, or entries is dashboard-only by design.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Add to your MCP client config (e.g. Claude Desktop / Claude Code):
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"bettercms": {
|
|
32
|
+
"command": "npx",
|
|
33
|
+
"args": ["-y", "@bettercms-ai/mcp"],
|
|
34
|
+
"env": { "BETTERCMS_API_URL": "https://api.bettercms.ai" }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Registry auth (current)
|
|
41
|
+
|
|
42
|
+
This package is published to **GitHub Packages**, which requires authentication
|
|
43
|
+
**even for public scoped packages**. For `npx @bettercms-ai/mcp` to resolve, the
|
|
44
|
+
host machine needs an `~/.npmrc` with:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
@bettercms-ai:registry=https://npm.pkg.github.com/
|
|
48
|
+
//npm.pkg.github.com/:_authToken=<a GitHub PAT with read:packages>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
> **TODO (future):** publish `@bettercms-ai/mcp` (and `types`/`sdk`) to the public
|
|
52
|
+
> **npmjs.org** registry so `npx @bettercms-ai/mcp` works with zero `.npmrc` setup
|
|
53
|
+
> for any host/user. Until then the GitHub Packages auth above is required.
|
|
54
|
+
|
|
55
|
+
On the first tool call the server prints an authorization prompt to **stderr**
|
|
56
|
+
(visible in your MCP client's logs):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
┌─ BetterCMS authorization required ─────────────────────────
|
|
60
|
+
│ Visit: https://demo.bettercms.ai/activate
|
|
61
|
+
│ Enter code: WDJB-MJHT
|
|
62
|
+
│ Or open: https://demo.bettercms.ai/activate?code=WDJB-MJHT
|
|
63
|
+
└────────────────────────────────────────────────────────────
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Approve it in the dashboard (pick a workspace/project) and the server caches the
|
|
67
|
+
minted token. It auto-refreshes on expiry; if the refresh token is revoked it
|
|
68
|
+
re-runs the device flow.
|
|
69
|
+
|
|
70
|
+
## Environment
|
|
71
|
+
|
|
72
|
+
| Var | Default | Purpose |
|
|
73
|
+
|-----|---------|---------|
|
|
74
|
+
| `BETTERCMS_API_URL` | `https://api.bettercms.ai` | API origin (device-auth + Management API). |
|
|
75
|
+
| `BETTERCMS_MCP_CREDENTIALS` | `~/.bettercms/mcp-credentials.json` | Token cache path (0600). |
|
|
76
|
+
| `BETTERCMS_MCP_CLIENT_NAME` | `BetterCMS MCP` | Label shown on the approval screen. |
|
|
77
|
+
|
|
78
|
+
## Develop
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bun install
|
|
82
|
+
bun run --filter @bettercms-ai/mcp typecheck
|
|
83
|
+
bun run --filter @bettercms-ai/mcp test
|
|
84
|
+
bun run --filter @bettercms-ai/mcp build
|
|
85
|
+
```
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# BetterCMS MCP — authoring skill
|
|
2
|
+
|
|
3
|
+
How an AI agent should author content, schema, **forms**, and **components** in
|
|
4
|
+
BetterCMS through this MCP. The MCP is bound to ONE project (the API key's project);
|
|
5
|
+
everything you create lands there.
|
|
6
|
+
|
|
7
|
+
The guided prompts ship with the server — prefer them: `studio` (router),
|
|
8
|
+
`propose_schema`, `new_page`, `new_form`, `new_component`. This file is the reference
|
|
9
|
+
behind them.
|
|
10
|
+
|
|
11
|
+
## Golden rules
|
|
12
|
+
- **Confirm before creating.** Propose the shape (fields / block tree) and get the
|
|
13
|
+
user's approval (AskUserQuestion) before any create/update. Never silently guess.
|
|
14
|
+
- **Read before you edit.** Use the `list_*` / `get_*` tools first so you keep existing
|
|
15
|
+
keys/blocks and don't duplicate.
|
|
16
|
+
- **Additive where possible.** `add_field`/`add_page_field` append; passing `fields`
|
|
17
|
+
to `update_form` or `blockJson` to `update_component` REPLACES — include everything
|
|
18
|
+
you want to keep.
|
|
19
|
+
|
|
20
|
+
## Pages & schema
|
|
21
|
+
Design the project's schema from its code — the connected repo, or, for an uploaded
|
|
22
|
+
project, its source in your working directory. Destructure each page into a tree:
|
|
23
|
+
plain fields, **group** (one nested object), **repeater** (a repeatable array). Then
|
|
24
|
+
`create_page` per page. (See the `propose_schema` prompt.) Pages created here show up
|
|
25
|
+
in the dashboard Content tab.
|
|
26
|
+
|
|
27
|
+
## Forms
|
|
28
|
+
Tools: `list_forms`, `get_form`, `create_form`, `update_form`.
|
|
29
|
+
|
|
30
|
+
1. Collect the fields with the user. Field: `{ key, label, type, required?, placeholder?,
|
|
31
|
+
options?, defaultValue?, showIf? }`. Types: `text, email, textarea, select` (needs
|
|
32
|
+
`options`), `checkbox, number, phone, date, url, consent, hidden`. `showIf:
|
|
33
|
+
{ field, equals }` shows a field conditionally.
|
|
34
|
+
2. Settings: `name` (the handle for `getForm('Name')`), `submitLabel?`, `successMessage?`,
|
|
35
|
+
`redirectUrl?`.
|
|
36
|
+
3. Confirm → `create_form { name, fields, ... }` (returns the id) or
|
|
37
|
+
`update_form { formId, ... }`.
|
|
38
|
+
4. **Wire it into the site**: in a `@bettercms-ai/next` site,
|
|
39
|
+
`import { getForm } from "@bettercms-ai/next"` then render
|
|
40
|
+
`<BcmsForm form={await getForm("Name")} />` where the user wants it.
|
|
41
|
+
|
|
42
|
+
## Components (reusable Webflow-style symbols)
|
|
43
|
+
Tools: `list_components`, `get_component`, `create_component`, `update_component`.
|
|
44
|
+
|
|
45
|
+
1. Design the `blockJson` tree — an array of blocks `{ type, id, props }`. Types:
|
|
46
|
+
`heading {text, level}`, `text {text}`, `image {src, alt}`, `button {text, href}`,
|
|
47
|
+
`spacer {height}`, `video {url}`, and `columns {columns: block[][], gap}` which nests
|
|
48
|
+
child blocks. Every block needs a stable unique `id`.
|
|
49
|
+
2. Optional `props` — overridable fields `{ key, label, target: { blockId, path }, type,
|
|
50
|
+
defaultValue? }` (type: `text|richtext|image|url|boolean`) so each instance can be
|
|
51
|
+
customized.
|
|
52
|
+
3. Confirm the structure → `create_component { name, slug, category?, blockJson, props? }`
|
|
53
|
+
(returns the id) or `update_component { componentId, ... }`. **Updating a component
|
|
54
|
+
re-bakes every page that embeds it.**
|
|
55
|
+
4. **Render it**: `<BcmsBlocks blocks={component.blockJson} components={...} />` from
|
|
56
|
+
`@bettercms-ai/next`.
|
|
57
|
+
|
|
58
|
+
Authoring `blockJson` blind is error-prone — keep trees small, confirm with the user,
|
|
59
|
+
and read back with `get_component` to verify.
|
|
60
|
+
|
|
61
|
+
## Errors
|
|
62
|
+
- `401/403` → the MCP key needs (re)authorizing (re-run "Connect your AI").
|
|
63
|
+
- `409 slug_taken` → offer a different slug.
|
|
64
|
+
- `create_form`/`create_component` missing → the host has a stale cached MCP; tell the
|
|
65
|
+
user to `rm -rf ~/.npm/_npx` and restart, or that their connector predates this skill.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolved configuration for the BetterCMS MCP server.
|
|
5
|
+
*
|
|
6
|
+
* A single `BETTERCMS_API_URL` (origin, no path) drives both the device-auth
|
|
7
|
+
* endpoints and the Management API base the SDK targets:
|
|
8
|
+
* device: {apiUrl}/api/v1/auth/device/*
|
|
9
|
+
* management: {apiUrl}/api/v1 (SDK appends /management/content/*)
|
|
10
|
+
*/
|
|
11
|
+
interface McpConfig {
|
|
12
|
+
apiUrl: string;
|
|
13
|
+
deviceBaseUrl: string;
|
|
14
|
+
managementBaseUrl: string;
|
|
15
|
+
credentialsPath: string;
|
|
16
|
+
clientName: string;
|
|
17
|
+
}
|
|
18
|
+
declare function loadConfig(env?: NodeJS.ProcessEnv): McpConfig;
|
|
19
|
+
|
|
20
|
+
/** Credentials cached between runs so the device flow runs only once per env. */
|
|
21
|
+
interface StoredCredentials {
|
|
22
|
+
accessToken: string;
|
|
23
|
+
refreshToken: string;
|
|
24
|
+
/** Epoch ms when the access token expires. */
|
|
25
|
+
accessTokenExpiresAt: number;
|
|
26
|
+
workspaceId: string | null;
|
|
27
|
+
projectId: string | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* An authorization the user has been sent off to approve but hasn't yet.
|
|
31
|
+
* Persisted so a later tool call can *resume* polling that same code instead of
|
|
32
|
+
* minting a fresh one — this is what lets the flow survive across the
|
|
33
|
+
* "return the link → user approves → retry" round-trip in clients (VS Code)
|
|
34
|
+
* that never surface the server's stderr prompt.
|
|
35
|
+
*/
|
|
36
|
+
interface PendingDevice {
|
|
37
|
+
deviceCode: string;
|
|
38
|
+
userCode: string;
|
|
39
|
+
verificationUri: string;
|
|
40
|
+
/** verification_uri with `?code=` prefilled — the link we hand the user. */
|
|
41
|
+
verificationUriComplete: string;
|
|
42
|
+
intervalSeconds: number;
|
|
43
|
+
/** Epoch ms when the device code expires. */
|
|
44
|
+
expiresAt: number;
|
|
45
|
+
}
|
|
46
|
+
/** Persistence boundary for credentials (file-backed in prod, in-memory in tests). */
|
|
47
|
+
interface TokenStore {
|
|
48
|
+
read(): Promise<StoredCredentials | null>;
|
|
49
|
+
write(creds: StoredCredentials): Promise<void>;
|
|
50
|
+
clear(): Promise<void>;
|
|
51
|
+
/** In-progress device authorization awaiting approval, if any. */
|
|
52
|
+
readPending(): Promise<PendingDevice | null>;
|
|
53
|
+
writePending(pending: PendingDevice): Promise<void>;
|
|
54
|
+
clearPending(): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Injectable seams so tests can run without real timers / network / stderr. */
|
|
58
|
+
interface DeviceAuthDeps {
|
|
59
|
+
fetch?: typeof fetch;
|
|
60
|
+
sleep?: (ms: number) => Promise<void>;
|
|
61
|
+
log?: (message: string) => void;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Drives the OAuth 2.0 Device Authorization Grant (RFC 8628) against the
|
|
66
|
+
* BetterCMS backend and hands the SDK a valid `content:manage` access token.
|
|
67
|
+
*
|
|
68
|
+
* - `getAccessToken()` returns a usable token: cached if fresh, refreshed if
|
|
69
|
+
* expired, or freshly minted via the full device flow if there's nothing valid.
|
|
70
|
+
* - All human-facing output goes to stderr — stdout is the MCP JSON-RPC channel.
|
|
71
|
+
*/
|
|
72
|
+
declare class DeviceAuthClient {
|
|
73
|
+
private readonly config;
|
|
74
|
+
private readonly store;
|
|
75
|
+
private readonly fetchImpl;
|
|
76
|
+
private readonly sleep;
|
|
77
|
+
private readonly log;
|
|
78
|
+
private readonly now;
|
|
79
|
+
private inFlight;
|
|
80
|
+
private refreshInFlight;
|
|
81
|
+
constructor(config: McpConfig, store: TokenStore, deps?: DeviceAuthDeps);
|
|
82
|
+
/** Return a valid access token, doing the least work necessary. Single-flighted. */
|
|
83
|
+
getAccessToken(): Promise<string>;
|
|
84
|
+
private resolveToken;
|
|
85
|
+
/**
|
|
86
|
+
* Resume a still-live authorization if one is persisted, otherwise start a
|
|
87
|
+
* fresh one; then grace-poll. Throws {@link DeviceAuthPendingError} (carrying
|
|
88
|
+
* the activation link) if the user hasn't approved within the grace window.
|
|
89
|
+
*/
|
|
90
|
+
private runDeviceFlow;
|
|
91
|
+
/** Request a fresh device code, persist it as pending, and log a breadcrumb. */
|
|
92
|
+
private startDeviceFlow;
|
|
93
|
+
/**
|
|
94
|
+
* Poll the token endpoint until `deadline`. Returns the access token on
|
|
95
|
+
* approval, or null if the deadline passes while still pending. Throws
|
|
96
|
+
* {@link DeviceAuthError} on a terminal outcome (denied / expired).
|
|
97
|
+
*/
|
|
98
|
+
private pollForApproval;
|
|
99
|
+
/**
|
|
100
|
+
* Exchange the stored refresh token for a new access token. Single-flighted:
|
|
101
|
+
* the device `/refresh` endpoint is single-use (it rotates the refresh token
|
|
102
|
+
* and revokes the prior access key), so a burst of concurrent 401s must NOT
|
|
103
|
+
* each fire their own refresh — the first would rotate, and the rest would
|
|
104
|
+
* send the now-stale token, get `invalid_grant`, and wipe the freshly-minted
|
|
105
|
+
* credentials. Collapsing them into one in-flight rotation keeps the session
|
|
106
|
+
* alive without a needless re-auth.
|
|
107
|
+
*/
|
|
108
|
+
refresh(): Promise<string | null>;
|
|
109
|
+
/**
|
|
110
|
+
* Forget the cached credentials and start a fresh device flow. Called when the
|
|
111
|
+
* bound project was deleted server-side (a key bound to a dead project can never
|
|
112
|
+
* succeed again) — clearing lets the user re-authorize against a LIVE project.
|
|
113
|
+
* Returns a new token if approval is fast, else throws {@link DeviceAuthPendingError}
|
|
114
|
+
* carrying the activation link (the next tool call resumes into the new project).
|
|
115
|
+
*/
|
|
116
|
+
resetAndReauthorize(): Promise<string>;
|
|
117
|
+
private doRefresh;
|
|
118
|
+
private persist;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface BuildServerDeps {
|
|
122
|
+
auth: DeviceAuthClient;
|
|
123
|
+
managementBaseUrl: string;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the BetterCMS MCP server with its tools registered. Auth is lazy — the
|
|
127
|
+
* device flow runs on the first tool call, not at connect time, so the MCP
|
|
128
|
+
* handshake/tool-listing never blocks on user approval.
|
|
129
|
+
*/
|
|
130
|
+
declare function buildServer(deps: BuildServerDeps): McpServer;
|
|
131
|
+
|
|
132
|
+
export { DeviceAuthClient, buildServer, loadConfig };
|