friday-mcp-v2 2.0.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.
@@ -0,0 +1,439 @@
1
+ /**
2
+ * F.R.I.D.A.Y. WP Client
3
+ * WordPress REST API (HTTPS, App Password) 経由で通信する
4
+ * SharedState / Bridge Server を完全に置換
5
+ */
6
+ import fetch from "node-fetch";
7
+ import { readFileSync } from "node:fs";
8
+
9
+ export class FridayWPClient {
10
+ constructor() {
11
+ this.wpUrl = process.env.FRIDAY_WP_URL;
12
+ this.wpUser = process.env.FRIDAY_WP_USER;
13
+ this.wpAppPassword = process.env.FRIDAY_WP_APP_PASSWORD;
14
+
15
+ if (!this.wpUrl || !this.wpUser || !this.wpAppPassword) {
16
+ throw new Error(
17
+ "環境変数が未設定です。以下を設定してください:\n" +
18
+ " FRIDAY_WP_URL (例: https://example.com)\n" +
19
+ " FRIDAY_WP_USER (WordPressユーザー名)\n" +
20
+ " FRIDAY_WP_APP_PASSWORD (アプリケーションパスワード)"
21
+ );
22
+ }
23
+
24
+ this.authHeader = "Basic " + Buffer.from(`${this.wpUser}:${this.wpAppPassword}`).toString("base64");
25
+ this.baseUrl = this.wpUrl.replace(/\/$/, '') + '/wp-json/friday/v1';
26
+ }
27
+
28
+ /**
29
+ * REST API リクエスト(App Password 認証付き)
30
+ */
31
+ async request(endpoint, options = {}) {
32
+ const url = `${this.baseUrl}${endpoint}`;
33
+ const headers = {
34
+ 'Authorization': this.authHeader,
35
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
36
+ };
37
+ const res = await fetch(url, { ...options, headers });
38
+ if (!res.ok) {
39
+ let errorText;
40
+ try {
41
+ const errorJson = await res.json();
42
+ errorText = errorJson.message || JSON.stringify(errorJson);
43
+ } catch {
44
+ errorText = await res.text();
45
+ }
46
+ throw new Error(`API ${res.status}: ${errorText}`);
47
+ }
48
+ return res.json();
49
+ }
50
+
51
+ // ========================================
52
+ // Status
53
+ // ========================================
54
+
55
+ async getStatus() {
56
+ return this.request('/status');
57
+ }
58
+
59
+ // ========================================
60
+ // Editor State
61
+ // ========================================
62
+
63
+ /**
64
+ * エディタの全状態を取得
65
+ * Returns: { editorConnected, postId, selectedBlock, allBlocks, headings, blockSummary }
66
+ */
67
+ async getEditorState() {
68
+ return this.request('/editor/state');
69
+ }
70
+
71
+ // ========================================
72
+ // Editor Command(コマンド送信 + 結果ポーリング)
73
+ // ========================================
74
+
75
+ /**
76
+ * エディタコマンドを送信し、結果を待つ
77
+ * @param {string} command コマンド名
78
+ * @param {object} params パラメータ
79
+ * @param {number} maxWaitMs 最大待機時間(ms)
80
+ * @returns {object|null} 結果。タイムアウト時は null
81
+ */
82
+ async sendEditorCommand(command, params = {}, maxWaitMs = 6000) {
83
+ const { commandId } = await this.request('/editor/command', {
84
+ method: 'POST',
85
+ body: JSON.stringify({ command, params }),
86
+ });
87
+
88
+ if (!commandId) throw new Error("commandId not returned");
89
+
90
+ const pollInterval = 500;
91
+ const maxAttempts = Math.ceil(maxWaitMs / pollInterval);
92
+
93
+ for (let i = 0; i < maxAttempts; i++) {
94
+ await new Promise(r => setTimeout(r, pollInterval));
95
+ try {
96
+ const data = await this.request(`/editor/command/result/${commandId}`);
97
+ if (data?.status === 'completed' && data?.result !== undefined) {
98
+ return data.result;
99
+ }
100
+ // status: "pending" → continue polling
101
+ } catch (_) {
102
+ // retry
103
+ }
104
+ }
105
+ return null; // timeout
106
+ }
107
+
108
+ // ========================================
109
+ // Select Block
110
+ // ========================================
111
+
112
+ async selectBlock(criteria) {
113
+ return this.request('/editor/select', {
114
+ method: 'POST',
115
+ body: JSON.stringify(criteria),
116
+ });
117
+ }
118
+
119
+ // ========================================
120
+ // Search Blocks(送信 + 結果ポーリング)
121
+ // ========================================
122
+
123
+ async searchBlocks(query, blockType, maxWaitMs = 5000) {
124
+ const { requestId } = await this.request('/editor/search', {
125
+ method: 'POST',
126
+ body: JSON.stringify({ query, blockType }),
127
+ });
128
+
129
+ const pollInterval = 500;
130
+ const maxAttempts = Math.ceil(maxWaitMs / pollInterval);
131
+
132
+ for (let i = 0; i < maxAttempts; i++) {
133
+ await new Promise(r => setTimeout(r, pollInterval));
134
+ try {
135
+ const data = await this.request(`/editor/search/result/${requestId}`);
136
+ if (data?.status === 'completed') {
137
+ return data.results;
138
+ }
139
+ } catch (_) {
140
+ // retry
141
+ }
142
+ }
143
+ return null; // timeout
144
+ }
145
+
146
+ // ========================================
147
+ // Headless API
148
+ // ========================================
149
+
150
+ async headlessGetStructure(postId) {
151
+ return this.request(`/headless/post/${postId}/structure`);
152
+ }
153
+
154
+ async headlessGetBlocks(postId) {
155
+ return this.request(`/headless/post/${postId}/blocks`);
156
+ }
157
+
158
+ async headlessGetBlock(postId, index) {
159
+ return this.request(`/headless/post/${postId}/block/${index}`);
160
+ }
161
+
162
+ async headlessGetMeta(postId) {
163
+ return this.request(`/headless/post/${postId}/meta`);
164
+ }
165
+
166
+ async headlessGetBlockHtml(postId, params) {
167
+ return this.request(`/headless/post/${postId}/block-html`, {
168
+ method: 'POST', body: JSON.stringify(params)
169
+ });
170
+ }
171
+
172
+ async headlessUpdate(postId, params) {
173
+ return this.request(`/headless/post/${postId}/update`, {
174
+ method: 'POST', body: JSON.stringify(params)
175
+ });
176
+ }
177
+
178
+ async headlessInsert(postId, params) {
179
+ return this.request(`/headless/post/${postId}/insert`, {
180
+ method: 'POST', body: JSON.stringify(params)
181
+ });
182
+ }
183
+
184
+ async headlessDelete(postId, index, count) {
185
+ return this.request(`/headless/post/${postId}/block/${index}`, {
186
+ method: 'DELETE',
187
+ ...(count > 1 ? { body: JSON.stringify({ count }) } : {}),
188
+ });
189
+ }
190
+
191
+ async headlessDeleteMultiple(postId, indices) {
192
+ return this.request(`/headless/post/${postId}/delete-multiple`, {
193
+ method: 'POST', body: JSON.stringify({ indices }),
194
+ });
195
+ }
196
+
197
+ async headlessMove(postId, from, to) {
198
+ return this.request(`/headless/post/${postId}/move`, {
199
+ method: 'POST', body: JSON.stringify({ from, to })
200
+ });
201
+ }
202
+
203
+ async headlessMoveFlat(postId, fromFlat, toFlat) {
204
+ return this.request(`/headless/post/${postId}/move-flat`, {
205
+ method: 'POST', body: JSON.stringify({ fromFlat, toFlat })
206
+ });
207
+ }
208
+
209
+ async headlessDuplicate(postId, index) {
210
+ return this.request(`/headless/post/${postId}/duplicate`, {
211
+ method: 'POST', body: JSON.stringify({ index })
212
+ });
213
+ }
214
+
215
+ async headlessTableOperation(postId, params) {
216
+ return this.request(`/headless/post/${postId}/table`, {
217
+ method: 'POST', body: JSON.stringify(params)
218
+ });
219
+ }
220
+
221
+ // ========================================
222
+ // WP Core REST API (wp/v2)
223
+ // ========================================
224
+
225
+ /**
226
+ * WordPress コア REST API リクエスト
227
+ * @param {string} endpoint 例: '/wp/v2/posts/123'
228
+ * @param {object} options fetch options
229
+ * @param {boolean} returnHeaders true の場合 { data, total, totalPages } を返す
230
+ */
231
+ async wpCoreRequest(endpoint, options = {}, returnHeaders = false) {
232
+ const url = `${this.wpUrl.replace(/\/$/, '')}/wp-json${endpoint}`;
233
+ const headers = {
234
+ 'Authorization': this.authHeader,
235
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
236
+ };
237
+ const res = await fetch(url, { ...options, headers });
238
+ if (!res.ok) {
239
+ let errorText;
240
+ try {
241
+ const errorJson = await res.json();
242
+ errorText = errorJson.message || JSON.stringify(errorJson);
243
+ } catch {
244
+ errorText = await res.text();
245
+ }
246
+ throw new Error(`API ${res.status}: ${errorText}`);
247
+ }
248
+ if (returnHeaders) {
249
+ const data = await res.json();
250
+ return {
251
+ data,
252
+ total: parseInt(res.headers.get('X-WP-Total') || '0', 10),
253
+ totalPages: parseInt(res.headers.get('X-WP-TotalPages') || '0', 10),
254
+ };
255
+ }
256
+ return res.json();
257
+ }
258
+
259
+ /**
260
+ * 投稿メタ情報を更新(WP コア API 経由)
261
+ */
262
+ async updatePostMeta(postId, data) {
263
+ return this.wpCoreRequest(`/wp/v2/posts/${postId}`, {
264
+ method: 'POST',
265
+ body: JSON.stringify(data),
266
+ });
267
+ }
268
+
269
+ /**
270
+ * 投稿一覧を取得(WP コア API 経由)
271
+ * @returns {{ data: object[], total: number, totalPages: number }}
272
+ */
273
+ async listPosts(params = {}) {
274
+ const query = new URLSearchParams();
275
+ if (params.search) query.set('search', params.search);
276
+ if (params.status) query.set('status', params.status);
277
+ if (params.categories) query.set('categories', Array.isArray(params.categories) ? params.categories.join(',') : String(params.categories));
278
+ if (params.tags) query.set('tags', Array.isArray(params.tags) ? params.tags.join(',') : String(params.tags));
279
+ if (params.per_page) query.set('per_page', String(Math.min(Math.max(1, params.per_page), 100)));
280
+ if (params.page) query.set('page', String(Math.max(1, params.page)));
281
+ if (params.orderby) query.set('orderby', params.orderby);
282
+ if (params.order) query.set('order', params.order);
283
+ const qs = query.toString();
284
+ return this.wpCoreRequest(`/wp/v2/posts${qs ? '?' + qs : ''}`, {}, true);
285
+ }
286
+
287
+ /**
288
+ * タクソノミー(カテゴリ/タグ)一覧を取得(WP コア API 経由)
289
+ * @param {string} type 'category' or 'tag'
290
+ * @param {object} params search, per_page, page, orderby, order, hide_empty
291
+ * @returns {{ data: object[], total: number, totalPages: number }}
292
+ */
293
+ async listTaxonomies(type, params = {}) {
294
+ const endpoint = type === 'tag' ? '/wp/v2/tags' : '/wp/v2/categories';
295
+ const query = new URLSearchParams();
296
+ if (params.search) query.set('search', params.search);
297
+ if (params.per_page) query.set('per_page', String(Math.min(Math.max(1, params.per_page), 100)));
298
+ if (params.page) query.set('page', String(Math.max(1, params.page)));
299
+ if (params.orderby) query.set('orderby', params.orderby);
300
+ if (params.order) query.set('order', params.order);
301
+ if (params.hide_empty !== undefined) query.set('hide_empty', params.hide_empty ? 'true' : 'false');
302
+ const qs = query.toString();
303
+ return this.wpCoreRequest(`${endpoint}${qs ? '?' + qs : ''}`, {}, true);
304
+ }
305
+
306
+ /**
307
+ * 設定オブジェクトからインスタンスを生成(ConnectionRegistry 用)
308
+ * @param {{ url: string, user: string, pass: string }} config
309
+ */
310
+ static fromConfig(config) {
311
+ if (!config.url || !config.user || !config.pass) {
312
+ throw new Error(
313
+ `接続設定に必須項目が不足しています: ${JSON.stringify(config)}\n` +
314
+ `必須: url, user, pass`
315
+ );
316
+ }
317
+ const instance = Object.create(FridayWPClient.prototype);
318
+ instance.wpUrl = config.url;
319
+ instance.wpUser = config.user;
320
+ instance.wpAppPassword = config.pass;
321
+ instance.authHeader = "Basic " + Buffer.from(`${config.user}:${config.pass}`).toString("base64");
322
+ instance.baseUrl = config.url.replace(/\/$/, '') + '/wp-json/friday/v1';
323
+ return instance;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * 複数接続を管理するレジストリ
329
+ *
330
+ * 優先順位(排他):
331
+ * 1. FRIDAY_CONNECTIONS_FILE — 外部 JSON ファイルパス
332
+ * 2. FRIDAY_CONN_{name}_{URL|USER|PASS} — 個別環境変数
333
+ * 3. FRIDAY_CONNECTIONS — JSON 文字列
334
+ * 4. FRIDAY_WP_URL / FRIDAY_WP_USER / FRIDAY_WP_APP_PASSWORD — 旧形式(単一接続)
335
+ */
336
+ export class ConnectionRegistry {
337
+ constructor() {
338
+ this.connections = new Map();
339
+
340
+ if (process.env.FRIDAY_CONNECTIONS_FILE) {
341
+ // 形式1: 外部 JSON ファイル
342
+ const filePath = process.env.FRIDAY_CONNECTIONS_FILE;
343
+ let raw;
344
+ try {
345
+ raw = readFileSync(filePath, 'utf-8');
346
+ } catch (e) {
347
+ throw new Error(`FRIDAY_CONNECTIONS_FILE の読み込みに失敗: ${filePath}\n${e.message}`);
348
+ }
349
+ let conns;
350
+ try {
351
+ conns = JSON.parse(raw);
352
+ } catch (e) {
353
+ throw new Error(`FRIDAY_CONNECTIONS_FILE の JSON パースに失敗: ${filePath}\n${e.message}`);
354
+ }
355
+ for (const [name, cfg] of Object.entries(conns)) {
356
+ this.connections.set(name, FridayWPClient.fromConfig(cfg));
357
+ }
358
+ } else if (this._hasConnEnvVars()) {
359
+ // 形式2: FRIDAY_CONN_{name}_{URL|USER|PASS} 個別環境変数
360
+ const connMap = this._parseConnEnvVars();
361
+ for (const [name, cfg] of Object.entries(connMap)) {
362
+ this.connections.set(name, FridayWPClient.fromConfig(cfg));
363
+ }
364
+ } else if (process.env.FRIDAY_CONNECTIONS) {
365
+ // 形式3: JSON 文字列
366
+ let conns;
367
+ try {
368
+ conns = JSON.parse(process.env.FRIDAY_CONNECTIONS);
369
+ } catch (e) {
370
+ throw new Error(`FRIDAY_CONNECTIONS の JSON パースに失敗: ${e.message}`);
371
+ }
372
+ for (const [name, cfg] of Object.entries(conns)) {
373
+ this.connections.set(name, FridayWPClient.fromConfig(cfg));
374
+ }
375
+ } else if (process.env.FRIDAY_WP_URL) {
376
+ // 形式4: 旧形式(単一接続)
377
+ this.connections.set('default', new FridayWPClient());
378
+ }
379
+
380
+ // default エイリアス設定
381
+ if (!this.connections.has('default') && this.connections.size > 0) {
382
+ const firstName = [...this.connections.keys()][0];
383
+ this.connections.set('default', this.connections.get(firstName));
384
+ }
385
+
386
+ if (this.connections.size === 0) {
387
+ throw new Error(
388
+ "接続が設定されていません。以下のいずれかを設定してください:\n" +
389
+ " 1. FRIDAY_CONNECTIONS_FILE (JSON ファイルパス)\n" +
390
+ " 2. FRIDAY_CONN_{name}_URL / _USER / _PASS (個別環境変数)\n" +
391
+ " 3. FRIDAY_CONNECTIONS (JSON 文字列)\n" +
392
+ " 4. FRIDAY_WP_URL, FRIDAY_WP_USER, FRIDAY_WP_APP_PASSWORD (旧形式)"
393
+ );
394
+ }
395
+ }
396
+
397
+ /** FRIDAY_CONN_* 形式の環境変数があるか判定 */
398
+ _hasConnEnvVars() {
399
+ return Object.keys(process.env).some(k => /^FRIDAY_CONN_[^_]+_URL$/.test(k));
400
+ }
401
+
402
+ /** FRIDAY_CONN_{name}_{URL|USER|PASS} をパースして { name: { url, user, pass } } を返す */
403
+ _parseConnEnvVars() {
404
+ const conns = {};
405
+ const pattern = /^FRIDAY_CONN_(.+?)_(URL|USER|PASS)$/;
406
+ for (const [key, value] of Object.entries(process.env)) {
407
+ const m = key.match(pattern);
408
+ if (!m) continue;
409
+ const name = m[1].toLowerCase();
410
+ const field = m[2].toLowerCase();
411
+ if (!conns[name]) conns[name] = {};
412
+ conns[name][field] = value;
413
+ }
414
+ return conns;
415
+ }
416
+
417
+ get(name) {
418
+ if (!name) return this.connections.get('default');
419
+ const client = this.connections.get(name);
420
+ if (!client) {
421
+ const available = [...this.connections.keys()].join(', ');
422
+ throw new Error(`接続 "${name}" が見つかりません。利用可能: ${available}`);
423
+ }
424
+ return client;
425
+ }
426
+
427
+ list() {
428
+ const seen = new Map();
429
+ const result = [];
430
+ for (const [name, c] of this.connections) {
431
+ if (seen.has(c)) continue;
432
+ seen.set(c, name);
433
+ result.push({ name, url: c.wpUrl, user: c.wpUser });
434
+ }
435
+ return result;
436
+ }
437
+
438
+ get size() { return this.connections.size; }
439
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "friday-mcp-v2",
3
+ "version": "2.0.0",
4
+ "description": "WordPress MCP Server for Claude Code - REST API direct communication",
5
+ "type": "module",
6
+ "main": "dist/mcp-server.js",
7
+ "bin": {
8
+ "friday-mcp-v2": "./dist/mcp-server.js"
9
+ },
10
+ "files": [
11
+ "dist/"
12
+ ],
13
+ "scripts": {
14
+ "start": "node dist/mcp-server.js"
15
+ },
16
+ "keywords": ["mcp", "wordpress", "claude", "ai"],
17
+ "author": "",
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.0.0",
21
+ "node-fetch": "^3.3.2"
22
+ }
23
+ }