@gravitykit/block-mcp 2.0.0-beta
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/.env.example +15 -0
- package/LICENSE +26 -0
- package/README.md +592 -0
- package/dist/index.cjs +52721 -0
- package/package.json +70 -0
- package/src/__tests__/fixtures/block-trees.ts +199 -0
- package/src/__tests__/fixtures/error-envelopes.ts +115 -0
- package/src/__tests__/fixtures/rest-responses.ts +280 -0
- package/src/__tests__/helpers/mock-client.ts +185 -0
- package/src/__tests__/helpers/request-matchers.ts +88 -0
- package/src/__tests__/helpers/schema-asserts.ts +132 -0
- package/src/__tests__/integration/concurrency.test.ts +129 -0
- package/src/__tests__/integration/dual-storage.test.ts +156 -0
- package/src/__tests__/integration/error-envelopes.test.ts +238 -0
- package/src/__tests__/integration/global-setup.ts +17 -0
- package/src/__tests__/integration/rate-limit.test.ts +88 -0
- package/src/__tests__/integration/read-edit-read.test.ts +141 -0
- package/src/__tests__/integration/ref-stability.test.ts +175 -0
- package/src/__tests__/integration/setup.ts +201 -0
- package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
- package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
- package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
- package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
- package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
- package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
- package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
- package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
- package/src/__tests__/tools/media/upload_media.test.ts +123 -0
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
- package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
- package/src/__tests__/tools/posts/create_post.test.ts +84 -0
- package/src/__tests__/tools/posts/update_post.test.ts +93 -0
- package/src/__tests__/tools/read/get_block.test.ts +96 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
- package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
- package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
- package/src/__tests__/tools/write/delete_block.test.ts +91 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
- package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
- package/src/__tests__/tools/write/update_block.test.ts +206 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
- package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
- package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
- package/src/__tests__/unit/instructions.test.ts +374 -0
- package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
- package/src/client.ts +964 -0
- package/src/connect.ts +877 -0
- package/src/enrichers.ts +348 -0
- package/src/error-translator.ts +156 -0
- package/src/index.ts +450 -0
- package/src/instructions.ts +270 -0
- package/src/preferences.ts +273 -0
- package/src/tools/discovery.ts +251 -0
- package/src/tools/media.ts +75 -0
- package/src/tools/mutate.ts +243 -0
- package/src/tools/patterns.ts +94 -0
- package/src/tools/posts.ts +200 -0
- package/src/tools/read.ts +201 -0
- package/src/tools/terms.ts +44 -0
- package/src/tools/write.ts +542 -0
- package/src/tools/yoast.ts +224 -0
- package/src/types.ts +862 -0
package/src/connect.ts
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connect — Browser-Approve handoff CLI for @gravitykit/block-mcp.
|
|
3
|
+
*
|
|
4
|
+
* Starts a loopback HTTP server, opens the WordPress admin authorize URL in
|
|
5
|
+
* the browser, waits for the admin to click Approve, then writes the chosen
|
|
6
|
+
* AI client's MCP config with the returned credentials.
|
|
7
|
+
*
|
|
8
|
+
* The app password is only written to stdout when the user explicitly opts in
|
|
9
|
+
* via `--reveal` or `--client print`. The default invocation redacts it.
|
|
10
|
+
*
|
|
11
|
+
* Each connection is registered under an MCP server name so several sites can
|
|
12
|
+
* coexist in one client config. The name defaults to a host-derived
|
|
13
|
+
* `block-mcp-<host-label>` (e.g. block-mcp-gravitykit) and can be overridden
|
|
14
|
+
* with `--name`. Connecting a second site adds a new entry rather than
|
|
15
|
+
* overwriting the first.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as http from 'node:http';
|
|
19
|
+
import * as crypto from 'node:crypto';
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import * as cp from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export type ClientTarget =
|
|
28
|
+
| 'claude-code'
|
|
29
|
+
| 'cursor'
|
|
30
|
+
| 'chatgpt-desktop'
|
|
31
|
+
| 'claude-desktop'
|
|
32
|
+
| 'print';
|
|
33
|
+
|
|
34
|
+
/** Accepted `--client` values. Both `--client <v>` and `--client=<v>` validate against this. */
|
|
35
|
+
const VALID_CLIENTS: ClientTarget[] = [
|
|
36
|
+
'claude-code',
|
|
37
|
+
'cursor',
|
|
38
|
+
'chatgpt-desktop',
|
|
39
|
+
'claude-desktop',
|
|
40
|
+
'print',
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Validate a raw `--client` value against the allowlist; throws on a typo. */
|
|
44
|
+
function assertValidClient(val: string): ClientTarget {
|
|
45
|
+
if (!VALID_CLIENTS.includes(val as ClientTarget)) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Invalid --client value "${val}". Must be one of: ${VALID_CLIENTS.join(', ')}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return val as ClientTarget;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConnectArgs {
|
|
54
|
+
site: string;
|
|
55
|
+
client: ClientTarget;
|
|
56
|
+
port: number | null;
|
|
57
|
+
open: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Opt-in to printing the cleartext app password to stdout. False by default
|
|
60
|
+
* so the secret stays out of shell scrollback / CI logs; set by `--reveal`
|
|
61
|
+
* or by an explicit `--client print`.
|
|
62
|
+
*/
|
|
63
|
+
reveal: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* MCP server name this connection is registered under. Lets several sites
|
|
66
|
+
* coexist in one client config instead of overwriting a single entry. Set by
|
|
67
|
+
* `--name`; defaults to a host-derived `block-mcp-<host-label>`.
|
|
68
|
+
*/
|
|
69
|
+
name: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Credentials {
|
|
73
|
+
site: string;
|
|
74
|
+
user: string;
|
|
75
|
+
password: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface McpServerEntry {
|
|
79
|
+
command: string;
|
|
80
|
+
args: string[];
|
|
81
|
+
env: Record<string, string>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface McpConfig {
|
|
85
|
+
mcpServers: Record<string, McpServerEntry>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Arg parsing ───────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse the argv slice after `connect` into a ConnectArgs object.
|
|
92
|
+
* Throws a descriptive Error for missing or invalid input.
|
|
93
|
+
*/
|
|
94
|
+
export function parseConnectArgs(argv: string[]): ConnectArgs {
|
|
95
|
+
let site: string | undefined;
|
|
96
|
+
let client: ClientTarget = 'print';
|
|
97
|
+
let port: number | null = null;
|
|
98
|
+
let open = true;
|
|
99
|
+
let reveal = false;
|
|
100
|
+
let name: string | undefined;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < argv.length; i++) {
|
|
103
|
+
const arg = argv[i];
|
|
104
|
+
if (arg === '--site') {
|
|
105
|
+
site = argv[++i];
|
|
106
|
+
} else if (arg === '--client') {
|
|
107
|
+
const val = assertValidClient(argv[++i]);
|
|
108
|
+
client = val;
|
|
109
|
+
// An explicit `--client print` is itself an opt-in to reveal the secret;
|
|
110
|
+
// the implicit default 'print' does not reveal.
|
|
111
|
+
if (val === 'print') {
|
|
112
|
+
reveal = true;
|
|
113
|
+
}
|
|
114
|
+
} else if (arg === '--port') {
|
|
115
|
+
const raw = argv[++i];
|
|
116
|
+
const n = parseInt(raw, 10);
|
|
117
|
+
if (isNaN(n) || n < 1 || n > 65535) {
|
|
118
|
+
throw new Error(`Invalid --port value "${raw}". Must be 1–65535.`);
|
|
119
|
+
}
|
|
120
|
+
port = n;
|
|
121
|
+
} else if (arg === '--no-open') {
|
|
122
|
+
open = false;
|
|
123
|
+
} else if (arg === '--reveal') {
|
|
124
|
+
reveal = true;
|
|
125
|
+
} else if (arg === '--name') {
|
|
126
|
+
name = argv[++i];
|
|
127
|
+
} else if (arg.startsWith('--name=')) {
|
|
128
|
+
name = arg.slice('--name='.length);
|
|
129
|
+
} else if (arg.startsWith('--site=')) {
|
|
130
|
+
site = arg.slice('--site='.length);
|
|
131
|
+
} else if (arg.startsWith('--client=')) {
|
|
132
|
+
const val = assertValidClient(arg.slice('--client='.length));
|
|
133
|
+
client = val;
|
|
134
|
+
if (val === 'print') {
|
|
135
|
+
reveal = true;
|
|
136
|
+
}
|
|
137
|
+
} else if (arg.startsWith('--port=')) {
|
|
138
|
+
const n = parseInt(arg.slice('--port='.length), 10);
|
|
139
|
+
if (isNaN(n) || n < 1 || n > 65535) {
|
|
140
|
+
throw new Error(`Invalid --port value. Must be 1–65535.`);
|
|
141
|
+
}
|
|
142
|
+
port = n;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!site) {
|
|
147
|
+
throw new Error('--site <url> is required. Example: --site https://example.com');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const resolvedName = name && name.trim() !== '' ? name.trim() : defaultServerName(site);
|
|
151
|
+
|
|
152
|
+
return { site, client, port, open, reveal, name: resolvedName };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Derive a default MCP server name from a site URL: `block-mcp-<sanitized-host>`.
|
|
157
|
+
*
|
|
158
|
+
* Uses the URL's full host authority — hostname AND any non-default port —
|
|
159
|
+
* verbatim, lowercased, with each run of non-alphanumerics collapsed to a
|
|
160
|
+
* single hyphen and stray hyphens trimmed. Nothing is stripped, truncated, or
|
|
161
|
+
* collapsed, so every distinct host gets a distinct name and no two sites
|
|
162
|
+
* silently share one entry: https://www.gravitykit.com → block-mcp-www-gravitykit-com
|
|
163
|
+
* (≠ https://gravitykit.com → block-mcp-gravitykit-com), https://dev.test →
|
|
164
|
+
* block-mcp-dev-test, http://localhost:7701 → block-mcp-localhost-7701. Falls
|
|
165
|
+
* back to `block-mcp` when the site has no parseable host. Override with `--name`.
|
|
166
|
+
*/
|
|
167
|
+
export function defaultServerName(site: string): string {
|
|
168
|
+
let host: string;
|
|
169
|
+
try {
|
|
170
|
+
host = new URL(site).host.toLowerCase();
|
|
171
|
+
} catch {
|
|
172
|
+
return 'block-mcp';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const slug = host.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
176
|
+
return slug ? `block-mcp-${slug}` : 'block-mcp';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Site URL normalisation ────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* True for loopback / local-dev hostnames where plain http:// is acceptable.
|
|
183
|
+
*
|
|
184
|
+
* The connector POSTs a single-use code to the site and gets the credential
|
|
185
|
+
* back in the response body, so the transport must be confidential for any
|
|
186
|
+
* non-local host. Loopback (127.0.0.1 / ::1 / localhost) and the dev TLDs
|
|
187
|
+
* `.local` / `.test` / `.localhost` are the only places plain http is allowed.
|
|
188
|
+
*/
|
|
189
|
+
export function isLoopbackOrDevHost(hostname: string): boolean {
|
|
190
|
+
const h = hostname.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 brackets
|
|
191
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '::1') {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (h.startsWith('127.')) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
return h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.test');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Normalise a site URL: strip trailing slashes, require an http/https scheme,
|
|
202
|
+
* reject shell metacharacters, and require https:// for any non-local host.
|
|
203
|
+
*
|
|
204
|
+
* The site URL is later handed to the OS browser-open command, so parsing it
|
|
205
|
+
* with `new URL()` and rejecting `[\s"'`<>|&^$\\]` keeps a crafted --site from
|
|
206
|
+
* smuggling a second command. And because the credential-bearing exchange runs
|
|
207
|
+
* against this host, plain http:// is refused for non-loopback/non-dev hosts so
|
|
208
|
+
* the code + returned password can't travel in cleartext. Throws a descriptive
|
|
209
|
+
* Error on any invalid input.
|
|
210
|
+
*/
|
|
211
|
+
export function normalizeSite(raw: string): string {
|
|
212
|
+
const trimmed = raw.replace(/\/+$/, '');
|
|
213
|
+
|
|
214
|
+
let url: URL;
|
|
215
|
+
try {
|
|
216
|
+
url = new URL(trimmed);
|
|
217
|
+
} catch {
|
|
218
|
+
throw new Error(`--site must start with http:// or https://. Got: "${raw}"`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
222
|
+
throw new Error(`--site must start with http:// or https://. Got: "${raw}"`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (/[\s"'`<>|&^$\\]/.test(trimmed)) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`--site contains characters that are not allowed in a site URL: "${raw}"`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (url.protocol === 'http:' && !isLoopbackOrDevHost(url.hostname)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`--site must use https:// for a public host (got http://${url.hostname}). ` +
|
|
234
|
+
'Plain http would send your connection credential in cleartext. ' +
|
|
235
|
+
'Use https://, or a local host (localhost / *.local / *.test) for development.'
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return trimmed;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Authorize URL builder ─────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
export interface AuthorizeUrlParams {
|
|
245
|
+
site: string;
|
|
246
|
+
callback: string;
|
|
247
|
+
state: string;
|
|
248
|
+
client: ClientTarget;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build the WordPress admin authorize URL that the admin visits to approve.
|
|
253
|
+
* All variable query params are URL-encoded.
|
|
254
|
+
*/
|
|
255
|
+
export function buildAuthorizeUrl(params: AuthorizeUrlParams): string {
|
|
256
|
+
const { site, callback, state, client } = params;
|
|
257
|
+
const base = `${site}/wp-admin/options-general.php`;
|
|
258
|
+
const query = new URLSearchParams({
|
|
259
|
+
page: 'gk-block-api-settings',
|
|
260
|
+
tab: 'connect',
|
|
261
|
+
gk_authorize: '1',
|
|
262
|
+
callback,
|
|
263
|
+
state,
|
|
264
|
+
client,
|
|
265
|
+
});
|
|
266
|
+
return `${base}?${query.toString()}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Callback handler ──────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
export interface CallbackResult {
|
|
272
|
+
ok: true;
|
|
273
|
+
code: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface CallbackError {
|
|
277
|
+
ok: false;
|
|
278
|
+
reason: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Parse and validate the loopback callback URL.
|
|
283
|
+
*
|
|
284
|
+
* Verifies the CSRF state and extracts the single-use exchange code. The
|
|
285
|
+
* callback never carries the credential itself — only the code, which the
|
|
286
|
+
* caller then exchanges for the credential over a direct request to the site.
|
|
287
|
+
*/
|
|
288
|
+
export function handleCallback(
|
|
289
|
+
reqUrl: string,
|
|
290
|
+
expectedState: string
|
|
291
|
+
): CallbackResult | CallbackError {
|
|
292
|
+
let parsed: URL;
|
|
293
|
+
try {
|
|
294
|
+
// reqUrl may be just a path+query; prepend a dummy base to parse it
|
|
295
|
+
parsed = new URL(reqUrl, 'http://127.0.0.1');
|
|
296
|
+
} catch {
|
|
297
|
+
return { ok: false, reason: 'Could not parse callback URL' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const state = parsed.searchParams.get('state');
|
|
301
|
+
if (!state || state !== expectedState) {
|
|
302
|
+
return { ok: false, reason: 'State mismatch — possible CSRF. Connection rejected.' };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const code = parsed.searchParams.get('code');
|
|
306
|
+
if (!code) {
|
|
307
|
+
return { ok: false, reason: 'Callback missing the exchange code.' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ok: true, code };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Credential exchange ───────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Parse the exchange endpoint's JSON response into a Credentials object.
|
|
317
|
+
*
|
|
318
|
+
* The endpoint replies with the WordPress `wp_send_json_success` envelope
|
|
319
|
+
* `{ success: true, data: { site, user, password } }`. Throws a descriptive
|
|
320
|
+
* Error on any other shape or a missing field.
|
|
321
|
+
*/
|
|
322
|
+
export function parseExchangeResponse(json: unknown): Credentials {
|
|
323
|
+
const root = json as { success?: unknown; data?: unknown } | null;
|
|
324
|
+
if (!root || typeof root !== 'object' || root.success !== true || typeof root.data !== 'object' || root.data === null) {
|
|
325
|
+
throw new Error('Exchange failed: the site did not return a valid credential response.');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const data = root.data as { site?: unknown; user?: unknown; password?: unknown };
|
|
329
|
+
const { site, user, password } = data;
|
|
330
|
+
if (typeof site !== 'string' || typeof user !== 'string' || typeof password !== 'string' || !site || !user || !password) {
|
|
331
|
+
throw new Error('Exchange response is missing the site, user, or password.');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return { site, user, password };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Default timeout (ms) for the credential-exchange request. */
|
|
338
|
+
export const EXCHANGE_FETCH_TIMEOUT_MS = 15_000;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Exchange a single-use code for the credential set.
|
|
342
|
+
*
|
|
343
|
+
* POSTs the code to the site's REST exchange route and returns the credential
|
|
344
|
+
* once, in a direct response body — never in a URL — so it stays out of browser
|
|
345
|
+
* history and Referer headers.
|
|
346
|
+
*
|
|
347
|
+
* The transport is the REST API, NOT admin-post.php: admin-post.php is routinely
|
|
348
|
+
* 30x'd before the handler runs by canonical/SSL redirects, the Redirection
|
|
349
|
+
* plugin, and security plugins on real sites — which surfaced as "fetch failed"
|
|
350
|
+
* under the old `redirect:'error'`. REST routes escape those front-end rules.
|
|
351
|
+
*
|
|
352
|
+
* The URL uses the `?rest_route=` form — the permalink-independent form that
|
|
353
|
+
* WordPress's rest_url() itself falls back to. The connector is Node and cannot
|
|
354
|
+
* call rest_url(), and hardcoding `/wp-json/` would break on plain permalinks or
|
|
355
|
+
* a custom rest_url_prefix; `?rest_route=` works on every permalink configuration.
|
|
356
|
+
*
|
|
357
|
+
* Redirects are handled manually: up to 3 SAME-ORIGIN hops are followed by
|
|
358
|
+
* re-POSTing the body (so a canonical/SSL 30x still delivers the code), while a
|
|
359
|
+
* cross-origin redirect is REFUSED — the credential must never be POSTed
|
|
360
|
+
* off-origin. A timeout bounds the whole exchange. `fetchFn` is injectable for
|
|
361
|
+
* testing.
|
|
362
|
+
*/
|
|
363
|
+
export async function exchangeCode(
|
|
364
|
+
site: string,
|
|
365
|
+
code: string,
|
|
366
|
+
fetchFn: typeof fetch = fetch,
|
|
367
|
+
timeoutMs: number = EXCHANGE_FETCH_TIMEOUT_MS
|
|
368
|
+
): Promise<Credentials> {
|
|
369
|
+
const origin = new URL(site).origin;
|
|
370
|
+
let url = `${site}/?rest_route=/gk-block-api/v1/connect/exchange`;
|
|
371
|
+
|
|
372
|
+
const controller = new AbortController();
|
|
373
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
374
|
+
|
|
375
|
+
let res!: Response;
|
|
376
|
+
try {
|
|
377
|
+
for (let hops = 0; ; hops++) {
|
|
378
|
+
try {
|
|
379
|
+
res = await fetchFn(url, {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
382
|
+
body: JSON.stringify({ code }),
|
|
383
|
+
redirect: 'manual',
|
|
384
|
+
signal: controller.signal,
|
|
385
|
+
});
|
|
386
|
+
} catch (err) {
|
|
387
|
+
throw new Error(`Exchange failed: could not reach ${url} (${(err as Error).message}).`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 2xx (or any non-redirect) → done.
|
|
391
|
+
if (res.status < 300 || res.status >= 400) {
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Follow only same-origin redirects, re-POSTing the body.
|
|
396
|
+
const location = res.headers.get('location');
|
|
397
|
+
if (!location || hops >= 3) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Exchange failed: the site redirected the request (HTTP ${res.status}); ensure --site is the canonical site URL.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
const next = new URL(location, url);
|
|
403
|
+
if (next.origin !== origin) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Exchange failed: the site redirected to a different origin (${next.origin}); refusing to send the credential off-site.`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
url = next.toString();
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
clearTimeout(timer);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let json: unknown;
|
|
415
|
+
try {
|
|
416
|
+
json = await res.json();
|
|
417
|
+
} catch {
|
|
418
|
+
throw new Error(`Exchange failed: the site returned a non-JSON response (HTTP ${res.status}).`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return parseExchangeResponse(json);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── MCP config builders ───────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/** Build the mcpServers entry for @gravitykit/block-mcp. */
|
|
427
|
+
export function buildMcpEntry(creds: Credentials): McpServerEntry {
|
|
428
|
+
return {
|
|
429
|
+
command: 'npx',
|
|
430
|
+
args: ['-y', '@gravitykit/block-mcp'],
|
|
431
|
+
env: {
|
|
432
|
+
WORDPRESS_URL: creds.site,
|
|
433
|
+
WORDPRESS_USER: creds.user,
|
|
434
|
+
WORDPRESS_APP_PASSWORD: creds.password,
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Merge a block-mcp entry into an existing mcpServers config object under the
|
|
441
|
+
* given server name. Existing servers (including other sites registered under
|
|
442
|
+
* different names) are preserved, so several sites coexist in one config.
|
|
443
|
+
*/
|
|
444
|
+
export function mergeMcpServers(
|
|
445
|
+
existing: McpConfig,
|
|
446
|
+
creds: Credentials,
|
|
447
|
+
name: string = 'block-mcp'
|
|
448
|
+
): McpConfig {
|
|
449
|
+
return {
|
|
450
|
+
...existing,
|
|
451
|
+
mcpServers: {
|
|
452
|
+
...existing.mcpServers,
|
|
453
|
+
[name]: buildMcpEntry(creds),
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Build the full cursor config object (for unit testing). */
|
|
459
|
+
export function cursorConfig(creds: Credentials, name: string = 'block-mcp'): McpConfig {
|
|
460
|
+
return mergeMcpServers({ mcpServers: {} }, creds, name);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── Platform-specific config paths ───────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Return the claude_desktop_config.json path for the given platform string.
|
|
467
|
+
* `platform` should be a `process.platform` value: 'darwin', 'win32', 'linux'.
|
|
468
|
+
*/
|
|
469
|
+
export function claudeDesktopConfigPath(platform: string): string {
|
|
470
|
+
switch (platform) {
|
|
471
|
+
case 'darwin':
|
|
472
|
+
return path.join(
|
|
473
|
+
os.homedir(),
|
|
474
|
+
'Library',
|
|
475
|
+
'Application Support',
|
|
476
|
+
'Claude',
|
|
477
|
+
'claude_desktop_config.json'
|
|
478
|
+
);
|
|
479
|
+
case 'win32': {
|
|
480
|
+
const appData = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming');
|
|
481
|
+
return path.join(appData, 'Claude', 'claude_desktop_config.json');
|
|
482
|
+
}
|
|
483
|
+
default:
|
|
484
|
+
// Linux and anything else
|
|
485
|
+
return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Return the cursor MCP config path (~/.cursor/mcp.json).
|
|
491
|
+
*/
|
|
492
|
+
export function cursorConfigPath(): string {
|
|
493
|
+
return path.join(os.homedir(), '.cursor', 'mcp.json');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ── claude mcp add argv builder ───────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Build the argv array for `claude mcp add` WITHOUT a shell.
|
|
500
|
+
*
|
|
501
|
+
* The credentials are discrete array elements (spawn with shell:false), so they
|
|
502
|
+
* are kept out of the shell command string and shell history. They are NOT
|
|
503
|
+
* fully hidden: `claude mcp add` accepts a secret only as an inline
|
|
504
|
+
* `-e KEY=value` argument (no environment-inheritance or stdin channel), so the
|
|
505
|
+
* app password is briefly visible in the child's process arguments
|
|
506
|
+
* (`ps aux` / `/proc/<pid>/cmdline`) for the duration of the one-shot spawn.
|
|
507
|
+
* That residual exposure is inherent to the `claude mcp add` interface; the
|
|
508
|
+
* config it then writes is owned and protected by Claude Code.
|
|
509
|
+
*/
|
|
510
|
+
export function claudeCodeAddArgs(creds: Credentials, name: string = 'block-mcp'): string[] {
|
|
511
|
+
return [
|
|
512
|
+
'mcp',
|
|
513
|
+
'add',
|
|
514
|
+
name,
|
|
515
|
+
'--scope',
|
|
516
|
+
'user',
|
|
517
|
+
'--env',
|
|
518
|
+
`WORDPRESS_URL=${creds.site}`,
|
|
519
|
+
'--env',
|
|
520
|
+
`WORDPRESS_USER=${creds.user}`,
|
|
521
|
+
'--env',
|
|
522
|
+
`WORDPRESS_APP_PASSWORD=${creds.password}`,
|
|
523
|
+
'--',
|
|
524
|
+
'npx',
|
|
525
|
+
'-y',
|
|
526
|
+
'@gravitykit/block-mcp',
|
|
527
|
+
];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── Config file writers ───────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
/** Read a JSON file, return default if missing or unparseable. */
|
|
533
|
+
function readJsonFile(filePath: string, defaultValue: McpConfig): McpConfig {
|
|
534
|
+
try {
|
|
535
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
536
|
+
return JSON.parse(raw) as McpConfig;
|
|
537
|
+
} catch {
|
|
538
|
+
return defaultValue;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Write a JSON file, creating parent directories as needed.
|
|
544
|
+
*
|
|
545
|
+
* The file embeds the WordPress Application Password in cleartext, so it is
|
|
546
|
+
* created owner-only (0600) under an owner-only parent directory (0700). The
|
|
547
|
+
* `mode` option only applies when the file/dir is newly created, so an
|
|
548
|
+
* explicit chmod also tightens a pre-existing loose-perm file. chmod is
|
|
549
|
+
* best-effort: on filesystems without POSIX modes it is a no-op and any
|
|
550
|
+
* error is ignored.
|
|
551
|
+
*/
|
|
552
|
+
function writeJsonFile(filePath: string, data: McpConfig): void {
|
|
553
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
554
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
555
|
+
try {
|
|
556
|
+
fs.chmodSync(filePath, 0o600);
|
|
557
|
+
} catch {
|
|
558
|
+
// POSIX file modes unavailable (e.g. Windows) — nothing to tighten.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Write to the Cursor MCP config, preserving existing servers. */
|
|
563
|
+
export function writeCursorConfig(creds: Credentials, name: string = 'block-mcp'): void {
|
|
564
|
+
const configPath = cursorConfigPath();
|
|
565
|
+
const existing = readJsonFile(configPath, { mcpServers: {} });
|
|
566
|
+
const updated = mergeMcpServers(existing, creds, name);
|
|
567
|
+
writeJsonFile(configPath, updated);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Write to the Claude Desktop config, preserving existing servers. */
|
|
571
|
+
export function writeClaudeDesktopConfig(
|
|
572
|
+
creds: Credentials,
|
|
573
|
+
platform: string = process.platform,
|
|
574
|
+
name: string = 'block-mcp'
|
|
575
|
+
): void {
|
|
576
|
+
const configPath = claudeDesktopConfigPath(platform);
|
|
577
|
+
const existing = readJsonFile(configPath, { mcpServers: {} });
|
|
578
|
+
const updated = mergeMcpServers(existing, creds, name);
|
|
579
|
+
writeJsonFile(configPath, updated);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Run `claude mcp add` via spawnSync (no shell — args array only). */
|
|
583
|
+
export function runClaudeCodeAdd(
|
|
584
|
+
creds: Credentials,
|
|
585
|
+
name: string = 'block-mcp'
|
|
586
|
+
): { success: boolean; error?: string } {
|
|
587
|
+
const args = claudeCodeAddArgs(creds, name);
|
|
588
|
+
try {
|
|
589
|
+
const result = cp.spawnSync('claude', args, {
|
|
590
|
+
stdio: 'inherit',
|
|
591
|
+
shell: false,
|
|
592
|
+
encoding: 'utf8',
|
|
593
|
+
});
|
|
594
|
+
if (result.error) {
|
|
595
|
+
// Binary not found
|
|
596
|
+
return { success: false, error: (result.error as Error).message };
|
|
597
|
+
}
|
|
598
|
+
if (result.status !== 0) {
|
|
599
|
+
return { success: false, error: `claude exited with status ${result.status}` };
|
|
600
|
+
}
|
|
601
|
+
return { success: true };
|
|
602
|
+
} catch (err) {
|
|
603
|
+
return { success: false, error: (err as Error).message };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Print the mcpServers JSON block for manual paste.
|
|
609
|
+
*
|
|
610
|
+
* The cleartext app password is printed only when `reveal` is true (explicit
|
|
611
|
+
* `--reveal` / `--client print`). Otherwise it is replaced with a placeholder
|
|
612
|
+
* so the secret never lands in stdout / shell scrollback / CI logs.
|
|
613
|
+
*/
|
|
614
|
+
export function printConfig(creds: Credentials, reveal: boolean, name: string = 'block-mcp'): void {
|
|
615
|
+
const shown: Credentials = reveal
|
|
616
|
+
? creds
|
|
617
|
+
: { ...creds, password: '<hidden — re-run with --reveal to print it>' };
|
|
618
|
+
const entry = buildMcpEntry(shown);
|
|
619
|
+
const block: McpConfig = { mcpServers: { [name]: entry } };
|
|
620
|
+
console.log('\nAdd this to your MCP client config:\n');
|
|
621
|
+
console.log(JSON.stringify(block, null, 2));
|
|
622
|
+
console.log(
|
|
623
|
+
'\nFor Claude Desktop: paste into ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)'
|
|
624
|
+
);
|
|
625
|
+
console.log('For Cursor: paste into ~/.cursor/mcp.json\n');
|
|
626
|
+
if (!reveal) {
|
|
627
|
+
console.log(
|
|
628
|
+
'The app password was hidden. Re-run with --reveal (or --client print) to print it.\n'
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Browser opener ────────────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Resolve the platform-appropriate command + argv to open a URL in the default
|
|
637
|
+
* browser. Pure (no side effects) so the argv can be asserted in tests.
|
|
638
|
+
*
|
|
639
|
+
* On Windows this uses `rundll32 url.dll,FileProtocolHandler <url>` rather than
|
|
640
|
+
* `cmd /c start`: cmd.exe re-parses `& | ^ < > "` as metacharacters, so a URL
|
|
641
|
+
* routed through it could smuggle a second command. rundll32 takes the URL as a
|
|
642
|
+
* single argument with no shell re-parsing.
|
|
643
|
+
*/
|
|
644
|
+
export function browserOpenCommand(
|
|
645
|
+
url: string,
|
|
646
|
+
platform: string
|
|
647
|
+
): { cmd: string; args: string[] } {
|
|
648
|
+
switch (platform) {
|
|
649
|
+
case 'darwin':
|
|
650
|
+
return { cmd: 'open', args: [url] };
|
|
651
|
+
case 'win32':
|
|
652
|
+
return { cmd: 'rundll32', args: ['url.dll,FileProtocolHandler', url] };
|
|
653
|
+
default:
|
|
654
|
+
return { cmd: 'xdg-open', args: [url] };
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Open a URL in the default browser using the platform-appropriate command.
|
|
660
|
+
* Uses spawn (not exec/shell) to avoid shell escaping issues. A spawn failure
|
|
661
|
+
* (no opener on the box, command missing) emits an async 'error' event that
|
|
662
|
+
* would otherwise crash the connector; we catch it and fall back to printing
|
|
663
|
+
* the URL so the user can open it manually and the connect still completes.
|
|
664
|
+
*/
|
|
665
|
+
export function openBrowser(url: string): void {
|
|
666
|
+
const { cmd, args } = browserOpenCommand(url, process.platform);
|
|
667
|
+
const child = cp.spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
668
|
+
child.on('error', (e) => {
|
|
669
|
+
console.error(
|
|
670
|
+
`Could not open a browser automatically (${e.message}). Open this URL manually:\n\n ${url}\n`
|
|
671
|
+
);
|
|
672
|
+
});
|
|
673
|
+
child.unref();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── HTML response ─────────────────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
679
|
+
<html lang="en">
|
|
680
|
+
<head><meta charset="utf-8"><title>Connected</title>
|
|
681
|
+
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;text-align:center}
|
|
682
|
+
h1{color:#2d6a4f}p{color:#555}</style></head>
|
|
683
|
+
<body><h1>✓ Connected</h1>
|
|
684
|
+
<p>You can close this tab and return to your terminal.</p></body>
|
|
685
|
+
</html>`;
|
|
686
|
+
|
|
687
|
+
const ERROR_HTML = (msg: string) => `<!DOCTYPE html>
|
|
688
|
+
<html lang="en">
|
|
689
|
+
<head><meta charset="utf-8"><title>Error</title>
|
|
690
|
+
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;text-align:center}
|
|
691
|
+
h1{color:#c0392b}p{color:#555}</style></head>
|
|
692
|
+
<body><h1>Connection Error</h1><p>${msg}</p></body>
|
|
693
|
+
</html>`;
|
|
694
|
+
|
|
695
|
+
// ── Main orchestrator ─────────────────────────────────────────────────────────
|
|
696
|
+
|
|
697
|
+
export interface RunConnectOptions {
|
|
698
|
+
/** Override process.platform for config path resolution. */
|
|
699
|
+
platform?: string;
|
|
700
|
+
/** Override the browser-open function (injectable for tests). */
|
|
701
|
+
openBrowserFn?: (url: string) => void;
|
|
702
|
+
/** Override the timeout in milliseconds (default 300_000 ms = 5 min). */
|
|
703
|
+
timeoutMs?: number;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Main entry point for the `connect` subcommand.
|
|
708
|
+
* `argv` is the slice of process.argv after 'connect'.
|
|
709
|
+
*/
|
|
710
|
+
export async function runConnect(
|
|
711
|
+
argv: string[],
|
|
712
|
+
opts: RunConnectOptions = {}
|
|
713
|
+
): Promise<void> {
|
|
714
|
+
const {
|
|
715
|
+
platform = process.platform,
|
|
716
|
+
openBrowserFn = openBrowser,
|
|
717
|
+
timeoutMs = 300_000,
|
|
718
|
+
} = opts;
|
|
719
|
+
|
|
720
|
+
// ── 1. Parse args ──────────────────────────────────────────────────────
|
|
721
|
+
let args: ConnectArgs;
|
|
722
|
+
try {
|
|
723
|
+
args = parseConnectArgs(argv);
|
|
724
|
+
args = { ...args, site: normalizeSite(args.site) };
|
|
725
|
+
} catch (err) {
|
|
726
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
727
|
+
process.exit(1);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ── 2. Generate state ──────────────────────────────────────────────────
|
|
731
|
+
const state = crypto.randomUUID();
|
|
732
|
+
|
|
733
|
+
// ── 3. Start loopback server ───────────────────────────────────────────
|
|
734
|
+
// The callback promise is resolve-only: a malformed / wrong-state / stray
|
|
735
|
+
// request gets a 400 but does NOT settle it, so a probe or an attacker's
|
|
736
|
+
// forged callback can't kill the pending connect. Only a valid, state-matching
|
|
737
|
+
// callback resolves it; otherwise the timeout below fires.
|
|
738
|
+
let resolveCallback!: (code: string) => void;
|
|
739
|
+
|
|
740
|
+
const callbackPromise = new Promise<string>((resolve) => {
|
|
741
|
+
resolveCallback = resolve;
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const server = http.createServer((req, res) => {
|
|
745
|
+
if (!req.url?.startsWith('/callback')) {
|
|
746
|
+
res.writeHead(404);
|
|
747
|
+
res.end('Not found');
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const result = handleCallback(req.url, state);
|
|
752
|
+
|
|
753
|
+
if (!result.ok) {
|
|
754
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
755
|
+
res.end(ERROR_HTML(result.reason));
|
|
756
|
+
console.error(`Ignoring an invalid callback (${result.reason}); still waiting…`);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
761
|
+
res.end(SUCCESS_HTML);
|
|
762
|
+
resolveCallback(result.code);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
await new Promise<void>((resolve, reject) => {
|
|
766
|
+
const listenPort = args.port ?? 0;
|
|
767
|
+
server.listen(listenPort, '127.0.0.1', () => resolve());
|
|
768
|
+
server.once('error', reject);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
const address = server.address() as { port: number };
|
|
772
|
+
const port = address.port;
|
|
773
|
+
const callbackUrl = `http://127.0.0.1:${port}/callback`;
|
|
774
|
+
|
|
775
|
+
// ── 4. Build and open authorize URL ────────────────────────────────────
|
|
776
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
777
|
+
site: args.site,
|
|
778
|
+
callback: callbackUrl,
|
|
779
|
+
state,
|
|
780
|
+
client: args.client,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
if (args.open) {
|
|
784
|
+
console.error(`Opening browser to authorize URL…`);
|
|
785
|
+
openBrowserFn(authorizeUrl);
|
|
786
|
+
} else {
|
|
787
|
+
console.log(`\nOpen this URL in your browser to authorize:\n\n ${authorizeUrl}\n`);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
console.error(`Waiting for approval (timeout ${timeoutMs / 1000}s)…`);
|
|
791
|
+
|
|
792
|
+
// ── 5. Wait for callback with timeout ──────────────────────────────────
|
|
793
|
+
// Keep the timer handle so we can clear it once a callback wins the race —
|
|
794
|
+
// otherwise the pending setTimeout keeps the event loop alive after success.
|
|
795
|
+
let timeoutHandle: ReturnType<typeof setTimeout>;
|
|
796
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
797
|
+
timeoutHandle = setTimeout(
|
|
798
|
+
() => reject(new Error(`Timed out after ${timeoutMs / 1000}s waiting for browser approval.`)),
|
|
799
|
+
timeoutMs
|
|
800
|
+
);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
let code: string;
|
|
804
|
+
try {
|
|
805
|
+
code = await Promise.race([callbackPromise, timeoutPromise]);
|
|
806
|
+
} catch (err) {
|
|
807
|
+
server.close();
|
|
808
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
} finally {
|
|
811
|
+
clearTimeout(timeoutHandle!);
|
|
812
|
+
server.close();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ── 6. Exchange the single-use code for the credential ──────────────────
|
|
816
|
+
// The callback delivered only a code; the credential itself comes back in
|
|
817
|
+
// this direct response body, never in a URL.
|
|
818
|
+
console.error(`Approved. Retrieving credentials…`);
|
|
819
|
+
let creds: Credentials;
|
|
820
|
+
try {
|
|
821
|
+
creds = await exchangeCode(args.site, code);
|
|
822
|
+
} catch (err) {
|
|
823
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
824
|
+
process.exit(1);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── 7. Write config ─────────────────────────────────────────────────────
|
|
828
|
+
try {
|
|
829
|
+
switch (args.client) {
|
|
830
|
+
case 'cursor':
|
|
831
|
+
writeCursorConfig(creds, args.name);
|
|
832
|
+
console.log(`\n✓ Connected! Wrote "${args.name}" to ~/.cursor/mcp.json`);
|
|
833
|
+
console.log(` Site: ${creds.site} User: ${creds.user}`);
|
|
834
|
+
console.log(` Restart Cursor to pick up the new server.\n`);
|
|
835
|
+
break;
|
|
836
|
+
|
|
837
|
+
case 'claude-desktop':
|
|
838
|
+
writeClaudeDesktopConfig(creds, platform, args.name);
|
|
839
|
+
console.log(`\n✓ Connected! Wrote "${args.name}" to ${claudeDesktopConfigPath(platform)}`);
|
|
840
|
+
console.log(` Site: ${creds.site} User: ${creds.user}`);
|
|
841
|
+
console.log(` Restart Claude Desktop to pick up the new server.\n`);
|
|
842
|
+
break;
|
|
843
|
+
|
|
844
|
+
case 'claude-code': {
|
|
845
|
+
const result = runClaudeCodeAdd(creds, args.name);
|
|
846
|
+
if (result.success) {
|
|
847
|
+
console.log(`\n✓ Connected! Registered "${args.name}" via 'claude mcp add'.`);
|
|
848
|
+
console.log(` Site: ${creds.site} User: ${creds.user}\n`);
|
|
849
|
+
} else {
|
|
850
|
+
console.error(
|
|
851
|
+
`\nWarning: 'claude' binary not found or failed (${result.error}).`
|
|
852
|
+
);
|
|
853
|
+
console.log(`\nFall back — add this to your Claude Code MCP config manually:`);
|
|
854
|
+
// Explicit fallback for a client the user chose: the secret is needed.
|
|
855
|
+
printConfig(creds, true, args.name);
|
|
856
|
+
}
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
case 'chatgpt-desktop': {
|
|
861
|
+
// ChatGPT Desktop does not have a standardised config path yet.
|
|
862
|
+
// Print the JSON block so the user can paste it (secret needed here).
|
|
863
|
+
console.log(`\n✓ Authorized! Paste the following into ChatGPT Desktop's MCP config:\n`);
|
|
864
|
+
printConfig(creds, true, args.name);
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
case 'print':
|
|
869
|
+
default:
|
|
870
|
+
printConfig(creds, args.reveal, args.name);
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
} catch (err) {
|
|
874
|
+
console.error(`Error writing config: ${(err as Error).message}`);
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
}
|