@bettercms-ai/mcp 0.27.0 → 0.29.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/dist/index.d.ts +3 -123
- package/dist/index.js +37 -305
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,126 +1,6 @@
|
|
|
1
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
|
-
/** The single live poller for the current device code (see runDeviceFlow). */
|
|
82
|
-
private pollTask;
|
|
83
|
-
constructor(config: McpConfig, store: TokenStore, deps?: DeviceAuthDeps);
|
|
84
|
-
/** Return a valid access token, doing the least work necessary. Single-flighted. */
|
|
85
|
-
getAccessToken(): Promise<string>;
|
|
86
|
-
private resolveToken;
|
|
87
|
-
/**
|
|
88
|
-
* Resume a still-live authorization if one is persisted, otherwise start a
|
|
89
|
-
* fresh one; then grace-poll. Throws {@link DeviceAuthPendingError} (carrying
|
|
90
|
-
* the activation link) if the user hasn't approved within the grace window.
|
|
91
|
-
*/
|
|
92
|
-
private runDeviceFlow;
|
|
93
|
-
/** Keep redeeming this code until it expires, detached from any tool call. One per code. */
|
|
94
|
-
private pollInBackground;
|
|
95
|
-
/** Request a fresh device code, persist it as pending, and log a breadcrumb. */
|
|
96
|
-
private startDeviceFlow;
|
|
97
|
-
/**
|
|
98
|
-
* Poll the token endpoint until `deadline`. Returns the access token on
|
|
99
|
-
* approval, or null if the deadline passes while still pending. Throws
|
|
100
|
-
* {@link DeviceAuthError} on a terminal outcome (denied / expired).
|
|
101
|
-
*/
|
|
102
|
-
private pollForApproval;
|
|
103
|
-
/**
|
|
104
|
-
* Exchange the stored refresh token for a new access token. Single-flighted:
|
|
105
|
-
* the device `/refresh` endpoint is single-use (it rotates the refresh token
|
|
106
|
-
* and revokes the prior access key), so a burst of concurrent 401s must NOT
|
|
107
|
-
* each fire their own refresh — the first would rotate, and the rest would
|
|
108
|
-
* send the now-stale token, get `invalid_grant`, and wipe the freshly-minted
|
|
109
|
-
* credentials. Collapsing them into one in-flight rotation keeps the session
|
|
110
|
-
* alive without a needless re-auth.
|
|
111
|
-
*/
|
|
112
|
-
refresh(): Promise<string | null>;
|
|
113
|
-
/**
|
|
114
|
-
* Forget the cached credentials and start a fresh device flow. Called when the
|
|
115
|
-
* bound project was deleted server-side (a key bound to a dead project can never
|
|
116
|
-
* succeed again) — clearing lets the user re-authorize against a LIVE project.
|
|
117
|
-
* Returns a new token if approval is fast, else throws {@link DeviceAuthPendingError}
|
|
118
|
-
* carrying the activation link (the next tool call resumes into the new project).
|
|
119
|
-
*/
|
|
120
|
-
resetAndReauthorize(): Promise<string>;
|
|
121
|
-
private doRefresh;
|
|
122
|
-
private persist;
|
|
123
|
-
}
|
|
2
|
+
import { DeviceAuthClient } from '@bettercms-ai/device-auth';
|
|
3
|
+
export { DeviceAuthClient, loadConfig } from '@bettercms-ai/device-auth';
|
|
124
4
|
|
|
125
5
|
interface BuildServerDeps {
|
|
126
6
|
auth: DeviceAuthClient;
|
|
@@ -133,4 +13,4 @@ interface BuildServerDeps {
|
|
|
133
13
|
*/
|
|
134
14
|
declare function buildServer(deps: BuildServerDeps): McpServer;
|
|
135
15
|
|
|
136
|
-
export {
|
|
16
|
+
export { buildServer };
|
package/dist/index.js
CHANGED
|
@@ -4,309 +4,7 @@
|
|
|
4
4
|
import { realpathSync } from "fs";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
-
|
|
8
|
-
// src/config.ts
|
|
9
|
-
import { homedir } from "os";
|
|
10
|
-
import { join } from "path";
|
|
11
|
-
var DEFAULT_API_URL = "https://api.bettercms.ai";
|
|
12
|
-
function loadConfig(env = process.env) {
|
|
13
|
-
const apiUrl = (env.BETTERCMS_API_URL?.trim() || DEFAULT_API_URL).replace(/\/+$/, "");
|
|
14
|
-
return {
|
|
15
|
-
apiUrl,
|
|
16
|
-
deviceBaseUrl: `${apiUrl}/api/v1/auth/device`,
|
|
17
|
-
managementBaseUrl: `${apiUrl}/api/v1`,
|
|
18
|
-
credentialsPath: env.BETTERCMS_MCP_CREDENTIALS?.trim() || join(homedir(), ".bettercms", "mcp-credentials.json"),
|
|
19
|
-
clientName: env.BETTERCMS_MCP_CLIENT_NAME?.trim() || "BetterCMS MCP"
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// src/token-store.ts
|
|
24
|
-
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
25
|
-
import { dirname } from "path";
|
|
26
|
-
var FileTokenStore = class {
|
|
27
|
-
constructor(path, key) {
|
|
28
|
-
this.path = path;
|
|
29
|
-
this.key = key;
|
|
30
|
-
this.pendingKey = `${key}::pending`;
|
|
31
|
-
}
|
|
32
|
-
path;
|
|
33
|
-
key;
|
|
34
|
-
/** Pending authorizations live under a sibling key so they never shadow creds. */
|
|
35
|
-
pendingKey;
|
|
36
|
-
async readAll() {
|
|
37
|
-
try {
|
|
38
|
-
const raw = await readFile(this.path, "utf-8");
|
|
39
|
-
const parsed = JSON.parse(raw);
|
|
40
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
41
|
-
} catch {
|
|
42
|
-
return {};
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
async writeAll(all) {
|
|
46
|
-
await mkdir(dirname(this.path), { recursive: true });
|
|
47
|
-
await writeFile(this.path, JSON.stringify(all, null, 2), { mode: 384 });
|
|
48
|
-
}
|
|
49
|
-
async read() {
|
|
50
|
-
const all = await this.readAll();
|
|
51
|
-
return all[this.key] ?? null;
|
|
52
|
-
}
|
|
53
|
-
async write(creds) {
|
|
54
|
-
const all = await this.readAll();
|
|
55
|
-
all[this.key] = creds;
|
|
56
|
-
await this.writeAll(all);
|
|
57
|
-
}
|
|
58
|
-
async clear() {
|
|
59
|
-
const all = await this.readAll();
|
|
60
|
-
delete all[this.key];
|
|
61
|
-
await this.writeAll(all);
|
|
62
|
-
}
|
|
63
|
-
async readPending() {
|
|
64
|
-
const all = await this.readAll();
|
|
65
|
-
return all[this.pendingKey] ?? null;
|
|
66
|
-
}
|
|
67
|
-
async writePending(pending) {
|
|
68
|
-
const all = await this.readAll();
|
|
69
|
-
all[this.pendingKey] = pending;
|
|
70
|
-
await this.writeAll(all);
|
|
71
|
-
}
|
|
72
|
-
async clearPending() {
|
|
73
|
-
const all = await this.readAll();
|
|
74
|
-
delete all[this.pendingKey];
|
|
75
|
-
await this.writeAll(all);
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
// src/device-auth.ts
|
|
80
|
-
var EXPIRY_SKEW_MS = 6e4;
|
|
81
|
-
var GRACE_POLL_MS = 25e3;
|
|
82
|
-
var DeviceAuthError = class extends Error {
|
|
83
|
-
constructor(message) {
|
|
84
|
-
super(message);
|
|
85
|
-
this.name = "DeviceAuthError";
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
var DeviceAuthPendingError = class extends Error {
|
|
89
|
-
verificationUri;
|
|
90
|
-
verificationUriComplete;
|
|
91
|
-
userCode;
|
|
92
|
-
expiresAt;
|
|
93
|
-
constructor(pending) {
|
|
94
|
-
super("Authorization pending \u2014 approve in the browser, then retry.");
|
|
95
|
-
this.name = "DeviceAuthPendingError";
|
|
96
|
-
this.verificationUri = pending.verificationUri;
|
|
97
|
-
this.verificationUriComplete = pending.verificationUriComplete;
|
|
98
|
-
this.userCode = pending.userCode;
|
|
99
|
-
this.expiresAt = pending.expiresAt;
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
var DeviceAuthClient = class {
|
|
103
|
-
constructor(config, store, deps = {}) {
|
|
104
|
-
this.config = config;
|
|
105
|
-
this.store = store;
|
|
106
|
-
this.fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
107
|
-
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
108
|
-
this.log = deps.log ?? ((m) => process.stderr.write(`${m}
|
|
109
|
-
`));
|
|
110
|
-
this.now = deps.now ?? (() => Date.now());
|
|
111
|
-
}
|
|
112
|
-
config;
|
|
113
|
-
store;
|
|
114
|
-
fetchImpl;
|
|
115
|
-
sleep;
|
|
116
|
-
log;
|
|
117
|
-
now;
|
|
118
|
-
inFlight = null;
|
|
119
|
-
refreshInFlight = null;
|
|
120
|
-
/** The single live poller for the current device code (see runDeviceFlow). */
|
|
121
|
-
pollTask = null;
|
|
122
|
-
/** Return a valid access token, doing the least work necessary. Single-flighted. */
|
|
123
|
-
async getAccessToken() {
|
|
124
|
-
if (this.inFlight) return this.inFlight;
|
|
125
|
-
this.inFlight = this.resolveToken().finally(() => {
|
|
126
|
-
this.inFlight = null;
|
|
127
|
-
});
|
|
128
|
-
return this.inFlight;
|
|
129
|
-
}
|
|
130
|
-
async resolveToken() {
|
|
131
|
-
const creds = await this.store.read();
|
|
132
|
-
if (creds && creds.accessTokenExpiresAt - this.now() > EXPIRY_SKEW_MS) {
|
|
133
|
-
return creds.accessToken;
|
|
134
|
-
}
|
|
135
|
-
if (creds?.refreshToken) {
|
|
136
|
-
const refreshed = await this.refresh();
|
|
137
|
-
if (refreshed) return refreshed;
|
|
138
|
-
}
|
|
139
|
-
return this.runDeviceFlow();
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Resume a still-live authorization if one is persisted, otherwise start a
|
|
143
|
-
* fresh one; then grace-poll. Throws {@link DeviceAuthPendingError} (carrying
|
|
144
|
-
* the activation link) if the user hasn't approved within the grace window.
|
|
145
|
-
*/
|
|
146
|
-
async runDeviceFlow() {
|
|
147
|
-
let pending = await this.store.readPending();
|
|
148
|
-
if (pending && pending.expiresAt - this.now() <= EXPIRY_SKEW_MS) {
|
|
149
|
-
await this.store.clearPending();
|
|
150
|
-
pending = null;
|
|
151
|
-
}
|
|
152
|
-
if (!pending) {
|
|
153
|
-
pending = await this.startDeviceFlow();
|
|
154
|
-
}
|
|
155
|
-
const graceDeadline = Math.min(this.now() + GRACE_POLL_MS, pending.expiresAt);
|
|
156
|
-
const token = this.pollTask ? await Promise.race([this.pollTask, this.sleep(GRACE_POLL_MS).then(() => null)]) : await this.pollForApproval(pending, graceDeadline);
|
|
157
|
-
if (token) return token;
|
|
158
|
-
this.pollInBackground(pending);
|
|
159
|
-
throw new DeviceAuthPendingError(pending);
|
|
160
|
-
}
|
|
161
|
-
/** Keep redeeming this code until it expires, detached from any tool call. One per code. */
|
|
162
|
-
pollInBackground(pending) {
|
|
163
|
-
if (this.pollTask) return;
|
|
164
|
-
const task = this.pollForApproval(pending, pending.expiresAt);
|
|
165
|
-
this.pollTask = task;
|
|
166
|
-
void task.catch(() => {
|
|
167
|
-
}).finally(() => {
|
|
168
|
-
if (this.pollTask === task) this.pollTask = null;
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
/** Request a fresh device code, persist it as pending, and log a breadcrumb. */
|
|
172
|
-
async startDeviceFlow() {
|
|
173
|
-
const start = await this.fetchImpl(`${this.config.deviceBaseUrl}/code`, {
|
|
174
|
-
method: "POST",
|
|
175
|
-
headers: { "Content-Type": "application/json" },
|
|
176
|
-
body: JSON.stringify({ client_name: this.config.clientName })
|
|
177
|
-
});
|
|
178
|
-
if (!start.ok) {
|
|
179
|
-
throw new DeviceAuthError(
|
|
180
|
-
`Failed to start device authorization (HTTP ${start.status}).`
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
const code = await start.json();
|
|
184
|
-
const pending = {
|
|
185
|
-
deviceCode: code.device_code,
|
|
186
|
-
userCode: code.user_code,
|
|
187
|
-
verificationUri: code.verification_uri,
|
|
188
|
-
verificationUriComplete: code.verification_uri_complete ?? `${code.verification_uri}?code=${encodeURIComponent(code.user_code)}`,
|
|
189
|
-
intervalSeconds: code.interval,
|
|
190
|
-
expiresAt: this.now() + code.expires_in * 1e3
|
|
191
|
-
};
|
|
192
|
-
await this.store.writePending(pending);
|
|
193
|
-
this.pollTask = null;
|
|
194
|
-
this.log("");
|
|
195
|
-
this.log("\u250C\u2500 BetterCMS authorization required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
196
|
-
this.log(`\u2502 Visit: ${pending.verificationUri}`);
|
|
197
|
-
this.log(`\u2502 Enter code: ${pending.userCode}`);
|
|
198
|
-
this.log(`\u2502 Or open: ${pending.verificationUriComplete}`);
|
|
199
|
-
this.log("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
200
|
-
return pending;
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Poll the token endpoint until `deadline`. Returns the access token on
|
|
204
|
-
* approval, or null if the deadline passes while still pending. Throws
|
|
205
|
-
* {@link DeviceAuthError} on a terminal outcome (denied / expired).
|
|
206
|
-
*/
|
|
207
|
-
async pollForApproval(pending, deadline) {
|
|
208
|
-
let intervalMs = pending.intervalSeconds * 1e3;
|
|
209
|
-
while (this.now() < deadline) {
|
|
210
|
-
await this.sleep(intervalMs);
|
|
211
|
-
if (this.now() >= deadline) break;
|
|
212
|
-
const res = await this.fetchImpl(`${this.config.deviceBaseUrl}/token`, {
|
|
213
|
-
method: "POST",
|
|
214
|
-
headers: { "Content-Type": "application/json" },
|
|
215
|
-
body: JSON.stringify({
|
|
216
|
-
device_code: pending.deviceCode,
|
|
217
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
218
|
-
})
|
|
219
|
-
});
|
|
220
|
-
if (res.ok) {
|
|
221
|
-
const body = await res.json();
|
|
222
|
-
await this.store.clearPending();
|
|
223
|
-
this.log("[bettercms-mcp] authorized \u2713");
|
|
224
|
-
return this.persist(body);
|
|
225
|
-
}
|
|
226
|
-
const err = await res.json().catch(() => ({}));
|
|
227
|
-
switch (err.error) {
|
|
228
|
-
case "authorization_pending":
|
|
229
|
-
continue;
|
|
230
|
-
case "slow_down":
|
|
231
|
-
intervalMs += 5e3;
|
|
232
|
-
continue;
|
|
233
|
-
case "access_denied":
|
|
234
|
-
await this.store.clearPending();
|
|
235
|
-
throw new DeviceAuthError("Authorization was denied.");
|
|
236
|
-
case "expired_token":
|
|
237
|
-
await this.store.clearPending();
|
|
238
|
-
throw new DeviceAuthError("The device code expired before approval. Try again.");
|
|
239
|
-
default:
|
|
240
|
-
throw new DeviceAuthError(
|
|
241
|
-
`Device authorization failed: ${err.error ?? `HTTP ${res.status}`}.`
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Exchange the stored refresh token for a new access token. Single-flighted:
|
|
249
|
-
* the device `/refresh` endpoint is single-use (it rotates the refresh token
|
|
250
|
-
* and revokes the prior access key), so a burst of concurrent 401s must NOT
|
|
251
|
-
* each fire their own refresh — the first would rotate, and the rest would
|
|
252
|
-
* send the now-stale token, get `invalid_grant`, and wipe the freshly-minted
|
|
253
|
-
* credentials. Collapsing them into one in-flight rotation keeps the session
|
|
254
|
-
* alive without a needless re-auth.
|
|
255
|
-
*/
|
|
256
|
-
async refresh() {
|
|
257
|
-
if (this.refreshInFlight) return this.refreshInFlight;
|
|
258
|
-
this.refreshInFlight = this.doRefresh().finally(() => {
|
|
259
|
-
this.refreshInFlight = null;
|
|
260
|
-
});
|
|
261
|
-
return this.refreshInFlight;
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Forget the cached credentials and start a fresh device flow. Called when the
|
|
265
|
-
* bound project was deleted server-side (a key bound to a dead project can never
|
|
266
|
-
* succeed again) — clearing lets the user re-authorize against a LIVE project.
|
|
267
|
-
* Returns a new token if approval is fast, else throws {@link DeviceAuthPendingError}
|
|
268
|
-
* carrying the activation link (the next tool call resumes into the new project).
|
|
269
|
-
*/
|
|
270
|
-
async resetAndReauthorize() {
|
|
271
|
-
await this.store.clear();
|
|
272
|
-
await this.store.clearPending();
|
|
273
|
-
return this.getAccessToken();
|
|
274
|
-
}
|
|
275
|
-
async doRefresh() {
|
|
276
|
-
const creds = await this.store.read();
|
|
277
|
-
if (!creds?.refreshToken) return null;
|
|
278
|
-
let res;
|
|
279
|
-
try {
|
|
280
|
-
res = await this.fetchImpl(`${this.config.deviceBaseUrl}/refresh`, {
|
|
281
|
-
method: "POST",
|
|
282
|
-
headers: { "Content-Type": "application/json" },
|
|
283
|
-
body: JSON.stringify({ refresh_token: creds.refreshToken })
|
|
284
|
-
});
|
|
285
|
-
} catch {
|
|
286
|
-
return null;
|
|
287
|
-
}
|
|
288
|
-
if (res.ok) {
|
|
289
|
-
const body = await res.json();
|
|
290
|
-
return this.persist(body);
|
|
291
|
-
}
|
|
292
|
-
const err = await res.json().catch(() => ({}));
|
|
293
|
-
if (err.error === "invalid_grant" || res.status === 401 || res.status === 403) {
|
|
294
|
-
await this.store.clear();
|
|
295
|
-
}
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
async persist(body) {
|
|
299
|
-
const creds = {
|
|
300
|
-
accessToken: body.access_token,
|
|
301
|
-
refreshToken: body.refresh_token,
|
|
302
|
-
accessTokenExpiresAt: this.now() + body.expires_in * 1e3,
|
|
303
|
-
workspaceId: body.workspace_id,
|
|
304
|
-
projectId: body.project_id
|
|
305
|
-
};
|
|
306
|
-
await this.store.write(creds);
|
|
307
|
-
return creds.accessToken;
|
|
308
|
-
}
|
|
309
|
-
};
|
|
7
|
+
import { loadConfig, FileTokenStore, DeviceAuthClient } from "@bettercms-ai/device-auth";
|
|
310
8
|
|
|
311
9
|
// src/server.ts
|
|
312
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -2282,6 +1980,7 @@ var LAYOUT_SECTION_ICON_SET = new Set(LAYOUT_SECTION_ICONS);
|
|
|
2282
1980
|
|
|
2283
1981
|
// src/tools.ts
|
|
2284
1982
|
import { BetterCMSError } from "@bettercms-ai/sdk";
|
|
1983
|
+
import { DeviceAuthPendingError } from "@bettercms-ai/device-auth";
|
|
2285
1984
|
var FRAMEWORK_CHOICES = ["astro", "next", "react-ts", "other"];
|
|
2286
1985
|
var FRAMEWORK_LABELS = {
|
|
2287
1986
|
astro: "Astro \u2014 recommended default, static by default and fastest to publish",
|
|
@@ -3326,6 +3025,24 @@ function buildToolDefs(deps) {
|
|
|
3326
3025
|
z.object({}).shape,
|
|
3327
3026
|
async (c) => ok("Approved conversion plan.", await data(c, "GET", `/management/projects/current/conversion-plan`))
|
|
3328
3027
|
),
|
|
3028
|
+
def(
|
|
3029
|
+
"get_componentize_plan",
|
|
3030
|
+
"Get the plan for turning this site's sections into components",
|
|
3031
|
+
"What this site's SECTIONS would become as components \u2014 a proposal that creates nothing, changes nothing and is computed live on every call. For a site whose pages were DERIVED at import (the site get_conversion_brief describes), each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import already folded (`group-*`). Per page it returns each section's `groupKey`, its `sectionType` family (Hero, FAQ, CTA, Features, Social proof\u2026), its leaf `fields` (key, path, type, the value the CMS holds), a `shapeHash`, and either the component that already renders it (`reuse.componentId`) or the one this plan proposes (`reuse.proposedSlug`) \u2014 and the components themselves under `components`: a NEW one carries the exact `props` and `blockJson` create_component would take, while a row for a component that ALREADY EXISTS carries its `componentId`, `slug` and `name` and no definition, because nothing will be written for it. Groups with the SAME shape across pages collapse into ONE component with several placements. `pending` says why a group is not offered: `NO_GROUP_ROOT` (the page's field keys are still the derive lane's own \u2014 `h1-welcome`, `p-we-build-things` \u2014 so there is no family to group by; rename them into families first), `NOT_A_SECTION` (a lone scalar with no family, or the page's own metadata \u2014 a section is a group field, a repeater, or a family two or more leaves share, so a legal page of `title`/`metaDescription`/`intro` proposes nothing), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (a repeater inside a repeater \u2014 one prop cannot describe two levels of rows), `PAGE_NOT_EMPTY` (the page holds blocks this lane does not own and will not overwrite). Chrome is NEVER a section: `nav-`/`footer-` keys and everything promoted into the project Layout are edited through the Layout. Keep the `digest` \u2014 componentize_sections refuses any other.",
|
|
3032
|
+
z.object({}).shape,
|
|
3033
|
+
async (c) => ok("Componentize plan.", await data(c, "GET", `/management/projects/current/componentize-plan`))
|
|
3034
|
+
),
|
|
3035
|
+
def(
|
|
3036
|
+
"componentize_sections",
|
|
3037
|
+
"Turn this site's derived sections into components",
|
|
3038
|
+
"Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT (its `sectionType` family, category 'section', placeable on any page) and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Call it with `dryRun: true` first \u2014 same receipt, nothing written. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`.",
|
|
3039
|
+
z.object({
|
|
3040
|
+
digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
|
|
3041
|
+
pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
|
|
3042
|
+
dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first.")
|
|
3043
|
+
}).shape,
|
|
3044
|
+
async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, dryRun: a.dryRun }))
|
|
3045
|
+
),
|
|
3329
3046
|
def(
|
|
3330
3047
|
"get_analytics_overview",
|
|
3331
3048
|
"Get traffic overview",
|
|
@@ -4110,6 +3827,20 @@ sibling calls it \`headline\` silently drops that content on the swap.
|
|
|
4110
3827
|
Pair \`sectionType\` with a library \`category\` (hero, content, social-proof, conversion) or
|
|
4111
3828
|
the component never appears in the editor's "Add a section" picker.
|
|
4112
3829
|
|
|
3830
|
+
**An IMPORTED site gets there without you writing any of that.** Its pages were derived at
|
|
3831
|
+
import \u2014 flat field groups (\`hero-title\`, \`faq-question\`, the repeaters folded as \`group-*\`)
|
|
3832
|
+
and an empty \`blockJson\` \u2014 so there is nothing in the picker and no section to swap. Read
|
|
3833
|
+
\`get_componentize_plan\`: it groups those fields into sections, names each family, and proposes
|
|
3834
|
+
one component per SHAPE (the same hero on two pages is one component with two placements),
|
|
3835
|
+
with every string, link and image already declared as a prop. Show the user the plan and
|
|
3836
|
+
**confirm before writing**, then \`componentize_sections { digest, dryRun: true }\` and, once the
|
|
3837
|
+
receipt reads right, without \`dryRun\`. Each page's blocks become ordered \`component\`
|
|
3838
|
+
instances carrying \`props.bind\` \u2014 the field group keeps the copy, so nothing moves and
|
|
3839
|
+
click-to-edit keeps working. Then run \`npx @bettercms-ai/convert --componentize\` in the repo
|
|
3840
|
+
so its templates render those sections from \`pages[].blocks\`, and finish with the publishes:
|
|
3841
|
+
\`publish_component\` every new component and publish the pages, or the site renders the
|
|
3842
|
+
sections as empty strings.
|
|
3843
|
+
|
|
4113
3844
|
## 5. Blocks and modular fields
|
|
4114
3845
|
|
|
4115
3846
|
A \`kind:'block'\` model holds no entries of its own; it exists to be stacked inside another
|
|
@@ -4924,6 +4655,7 @@ function buildServer(deps) {
|
|
|
4924
4655
|
}
|
|
4925
4656
|
|
|
4926
4657
|
// src/index.ts
|
|
4658
|
+
import { DeviceAuthClient as DeviceAuthClient2, loadConfig as loadConfig2 } from "@bettercms-ai/device-auth";
|
|
4927
4659
|
async function main() {
|
|
4928
4660
|
const config = loadConfig();
|
|
4929
4661
|
const store = new FileTokenStore(config.credentialsPath, config.apiUrl);
|
|
@@ -4951,8 +4683,8 @@ if (isMainModule()) {
|
|
|
4951
4683
|
});
|
|
4952
4684
|
}
|
|
4953
4685
|
export {
|
|
4954
|
-
DeviceAuthClient,
|
|
4686
|
+
DeviceAuthClient2 as DeviceAuthClient,
|
|
4955
4687
|
buildServer,
|
|
4956
|
-
loadConfig
|
|
4688
|
+
loadConfig2 as loadConfig
|
|
4957
4689
|
};
|
|
4958
4690
|
//# sourceMappingURL=index.js.map
|