friday-mcp-v2 3.1.0 → 3.1.2

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.
@@ -16,6 +16,7 @@ import { readFileSync, statSync, realpathSync, appendFileSync, mkdirSync, exists
16
16
  import { execFile } from "node:child_process";
17
17
  import { resolve as resolvePath, sep, dirname, join as joinPath, extname } from "node:path";
18
18
  import { marked } from "marked";
19
+ import { parse as parseHTML } from "node-html-parser";
19
20
  import os from "node:os";
20
21
  import { fileURLToPath } from "node:url";
21
22
  // package.json からバージョンを取得
@@ -457,6 +458,117 @@ function readHTMLFromFile(filePath, maxSizeBytes = 2 * 1024 * 1024) {
457
458
  return { html };
458
459
  }
459
460
 
461
+ // ========================================
462
+ // Markdown → Gutenberg Block Converter
463
+ // ========================================
464
+
465
+ function _convertHeading(node) {
466
+ const tag = node.tagName.toLowerCase();
467
+ const level = parseInt(tag[1]);
468
+ const attrs = level === 2 ? '' : ` {"level":${level}}`;
469
+ return `<!-- wp:heading${attrs} -->\n<${tag} class="wp-block-heading">${node.innerHTML}</${tag}>\n<!-- /wp:heading -->`;
470
+ }
471
+
472
+ function _convertParagraph(node) {
473
+ const img = node.querySelector('img');
474
+ if (img && node.childNodes.length === 1) {
475
+ return _convertImage(img);
476
+ }
477
+ return `<!-- wp:paragraph -->\n<p>${node.innerHTML}</p>\n<!-- /wp:paragraph -->`;
478
+ }
479
+
480
+ function _convertList(node) {
481
+ const tag = node.tagName.toLowerCase();
482
+ const ordered = tag === 'ol';
483
+ const attrs = ordered ? ' {"ordered":true}' : '';
484
+ const outerTag = ordered ? 'ol' : 'ul';
485
+ return `<!-- wp:list${attrs} -->\n<${outerTag} class="wp-block-list">${node.innerHTML}</${outerTag}>\n<!-- /wp:list -->`;
486
+ }
487
+
488
+ function _convertQuote(node) {
489
+ let inner = node.innerHTML.trim();
490
+ if (!inner.startsWith('<')) {
491
+ inner = `<p>${inner}</p>`;
492
+ }
493
+ return `<!-- wp:quote -->\n<blockquote class="wp-block-quote">${inner}</blockquote>\n<!-- /wp:quote -->`;
494
+ }
495
+
496
+ function _convertCode(node) {
497
+ const inner = node.innerHTML.replace(/<code\s+class="[^"]*">/g, '<code>');
498
+ return `<!-- wp:code -->\n<pre class="wp-block-code">${inner}</pre>\n<!-- /wp:code -->`;
499
+ }
500
+
501
+ function _convertTable(node) {
502
+ return `<!-- wp:table -->\n<figure class="wp-block-table"><table>${node.innerHTML}</table></figure>\n<!-- /wp:table -->`;
503
+ }
504
+
505
+ function _convertImage(node) {
506
+ const img = node.tagName?.toLowerCase() === 'img' ? node : node.querySelector('img');
507
+ if (!img) return `<!-- wp:html -->\n${node.outerHTML}\n<!-- /wp:html -->`;
508
+ const src = img.getAttribute('src') || '';
509
+ const alt = img.getAttribute('alt') || '';
510
+ return `<!-- wp:image -->\n<figure class="wp-block-image"><img src="${src}" alt="${alt}"/></figure>\n<!-- /wp:image -->`;
511
+ }
512
+
513
+ function htmlToGutenbergBlocks(html) {
514
+ const root = parseHTML(html);
515
+ const blocks = [];
516
+ for (const node of root.childNodes) {
517
+ if (node.nodeType === 3) {
518
+ const text = node.text.trim();
519
+ if (!text) continue;
520
+ blocks.push(`<!-- wp:paragraph -->\n<p>${text}</p>\n<!-- /wp:paragraph -->`);
521
+ continue;
522
+ }
523
+ if (node.nodeType !== 1) continue;
524
+ const tag = node.tagName?.toLowerCase();
525
+ switch (tag) {
526
+ case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
527
+ blocks.push(_convertHeading(node));
528
+ break;
529
+ case 'p':
530
+ blocks.push(_convertParagraph(node));
531
+ break;
532
+ case 'ul': case 'ol':
533
+ blocks.push(_convertList(node));
534
+ break;
535
+ case 'blockquote':
536
+ blocks.push(_convertQuote(node));
537
+ break;
538
+ case 'pre':
539
+ blocks.push(_convertCode(node));
540
+ break;
541
+ case 'hr':
542
+ blocks.push(`<!-- wp:separator -->\n<hr class="wp-block-separator has-alpha-channel-opacity"/>\n<!-- /wp:separator -->`);
543
+ break;
544
+ case 'table':
545
+ blocks.push(_convertTable(node));
546
+ break;
547
+ case 'figure':
548
+ case 'img':
549
+ blocks.push(_convertImage(node));
550
+ break;
551
+ default:
552
+ blocks.push(`<!-- wp:html -->\n${node.outerHTML}\n<!-- /wp:html -->`);
553
+ break;
554
+ }
555
+ }
556
+ return blocks.join('\n\n');
557
+ }
558
+
559
+ async function resolveFileToBlockHTML(filePath) {
560
+ const ext = extname(filePath).toLowerCase();
561
+ const raw = readHTMLFromFile(filePath).html;
562
+ if (ext === '.md') {
563
+ const html = await marked.parse(raw);
564
+ return htmlToGutenbergBlocks(html);
565
+ } else if (ext === '.html' || ext === '.htm') {
566
+ return raw;
567
+ } else {
568
+ throw new Error(`対応していないファイル形式です: ${ext} (.md または .html のみ)`);
569
+ }
570
+ }
571
+
460
572
  // ========================================
461
573
  // Media Utilities
462
574
  // ========================================
@@ -610,6 +722,16 @@ function generateSearchVariants(query, strip = [], prefixes = []) {
610
722
  return [...variants];
611
723
  }
612
724
 
725
+ function flattenFileBirdFolders(folders, result = []) {
726
+ for (const f of folders) {
727
+ result.push({ id: f.id, name: f.text || f.title || f.name || '', count: parseInt(f['data-count'], 10) || 0 });
728
+ if (f.children && f.children.length > 0) {
729
+ flattenFileBirdFolders(f.children, result);
730
+ }
731
+ }
732
+ return result;
733
+ }
734
+
613
735
  /**
614
736
  * 単一クライアントの Editor/Headless 判定
615
737
  * @returns {{ mode: 'editor'|'headless'|'error', postId?: number, message?: string, client?: FridayWPClient }}
@@ -2381,13 +2503,14 @@ const tools = [
2381
2503
  },
2382
2504
  {
2383
2505
  name: "list_blog_parts",
2384
- description: "List or search blog parts (reusable blocks). SWELL theme only.",
2506
+ description: "List or search blog parts (reusable blocks). When partsId is specified, returns posts/pages that directly use that blog parts. SWELL theme only.",
2385
2507
  inputSchema: {
2386
2508
  type: "object",
2387
2509
  properties: {
2388
2510
  site: siteParam,
2389
- search: { type: "string", description: "Keyword search" },
2390
- status: { type: "string", enum: ["publish", "draft", "private", "any"], description: "Status filter (default: publish)" },
2511
+ partsId: { type: "integer", description: "Blog parts ID. When specified, returns posts/pages that directly use this blog parts instead of listing blog parts." },
2512
+ search: { type: "string", description: "Keyword search (ignored when partsId is set)" },
2513
+ status: { type: "string", enum: ["publish", "draft", "private", "any"], description: "Status filter (default: publish, ignored when partsId is set)" },
2391
2514
  per_page: { type: "integer", description: "Per page (1-100)", minimum: 1, maximum: 100 },
2392
2515
  page: { type: "integer", description: "Page number", minimum: 1 },
2393
2516
  },
@@ -2411,12 +2534,12 @@ const tools = [
2411
2534
  },
2412
2535
  {
2413
2536
  name: "create_blog_parts",
2414
- description: "Create a new blog parts (reusable block). SWELL theme only. Returns ID and editor URL.",
2537
+ description: "Create a new blog parts (reusable block). SWELL theme only. Returns partsId and editor URL. To insert into an article, use insert_blog_parts with the returned partsId.",
2415
2538
  inputSchema: {
2416
2539
  type: "object",
2417
2540
  properties: {
2418
2541
  title: { type: "string", description: "Blog parts title" },
2419
- status: { type: "string", enum: ["publish", "draft", "pending", "private"], description: "Status (default: draft)" },
2542
+ status: { type: "string", enum: ["publish", "draft", "pending", "private"], description: "Status (default: publish)" },
2420
2543
  slug: { type: "string", description: "URL slug" },
2421
2544
  content: { type: "string", description: "HTML content (exclusive with filePath)" },
2422
2545
  filePath: { type: "string", description: "Local file path (.md or .html, exclusive with content)" },
@@ -2456,6 +2579,23 @@ const tools = [
2456
2579
  required: ["partsId"],
2457
2580
  },
2458
2581
  },
2582
+ {
2583
+ name: "insert_blog_parts",
2584
+ description: "Insert a blog parts block into an article. Builds the correct block HTML automatically.",
2585
+ inputSchema: {
2586
+ type: "object",
2587
+ properties: {
2588
+ partsId: { type: "integer", description: "Blog parts ID (required)" },
2589
+ postId: postIdParam,
2590
+ site: siteParam,
2591
+ snapshotId: { type: "string", description: "Snapshot ID (from get_article_structure or any write response). Required when using beforeRef/afterRef. Omit to append to end." },
2592
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
2593
+ beforeRef: { type: "string", description: "Insert before this ref." },
2594
+ afterRef: { type: "string", description: "Insert after this ref." },
2595
+ },
2596
+ required: ["partsId"],
2597
+ },
2598
+ },
2459
2599
  {
2460
2600
  name: "list_taxonomies",
2461
2601
  description: "List categories or tags.",
@@ -2504,7 +2644,7 @@ const tools = [
2504
2644
  },
2505
2645
  {
2506
2646
  name: "get_block_html",
2507
- description: "Get block HTML. Use before newHTML to safely edit custom/theme blocks.",
2647
+ description: "Get block HTML. Use before newHTML to safely edit custom/theme blocks. Blog parts (loos/blog-parts) are auto-expanded to show inner content. To edit blog parts content, use update_blog_parts.",
2508
2648
  inputSchema: {
2509
2649
  type: "object",
2510
2650
  properties: {
@@ -2701,11 +2841,21 @@ const tools = [
2701
2841
  properties: {
2702
2842
  site: siteParam,
2703
2843
  query: { type: "string", description: "Search keyword" },
2844
+ folder: { type: "string", description: "FileBird folder name (partial match). Filters media to this folder." },
2704
2845
  strip: { type: "array", items: { type: "string" }, description: "Suffixes to strip (e.g. ['先生', 'さん'])" },
2705
2846
  prefix: { type: "array", items: { type: "string" }, description: "Prefixes to add (e.g. ['エキサイト-'])" },
2706
2847
  per_page: { type: "integer", description: "Results per variant (1-20, default: 10)", minimum: 1, maximum: 20 },
2707
2848
  },
2708
- required: ["query"],
2849
+ },
2850
+ },
2851
+ {
2852
+ name: "list_media_folders",
2853
+ description: "List FileBird media folders. Returns folder names, IDs, and file counts.",
2854
+ inputSchema: {
2855
+ type: "object",
2856
+ properties: {
2857
+ site: siteParam,
2858
+ },
2709
2859
  },
2710
2860
  },
2711
2861
  {
@@ -2767,6 +2917,7 @@ const tools = [
2767
2917
  asp_url: { type: "string", description: "ASP affiliate URL (required)" },
2768
2918
  asp_url_sp: { type: "string", description: "ASP URL (B) for A/B testing (optional)" },
2769
2919
  direct_url: { type: "string", description: "Direct URL bypassing ASP (optional)" },
2920
+ permalink: { type: "string", description: "WordPress permalink slug (defaults to asp_id, then slug if omitted)" },
2770
2921
  },
2771
2922
  required: ["title", "slug", "asp_url"],
2772
2923
  },
@@ -2898,7 +3049,7 @@ async function handleUpdateBlocksTool(args, toolName) {
2898
3049
  return { content: [{ type: "text", text: "❌ filePath は attributeUpdates と併用できません。" }], isError: true };
2899
3050
  }
2900
3051
  try {
2901
- newHTML = readHTMLFromFile(args.filePath).html;
3052
+ newHTML = await resolveFileToBlockHTML(args.filePath);
2902
3053
  } catch (e) {
2903
3054
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
2904
3055
  }
@@ -3168,7 +3319,7 @@ async function handleUpdateBlocksTool(args, toolName) {
3168
3319
  }
3169
3320
 
3170
3321
  // ツール実行のハンドラ
3171
- const _WRITE_TOOLS = new Set(['update_blocks', 'delete_block', 'move_block', 'duplicate_block', 'insert_block', 'table_operations', 'update_blog_parts']);
3322
+ const _WRITE_TOOLS = new Set(['update_blocks', 'delete_block', 'move_block', 'duplicate_block', 'insert_block', 'table_operations', 'update_blog_parts', 'insert_blog_parts']);
3172
3323
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
3173
3324
  const { name, arguments: args } = request.params;
3174
3325
  const _toolLogStart = Date.now();
@@ -4230,15 +4381,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4230
4381
 
4231
4382
  if (filePath) {
4232
4383
  try {
4233
- const ext = extname(filePath).toLowerCase();
4234
- const raw = readHTMLFromFile(filePath).html;
4235
- if (ext === '.md') {
4236
- content = await marked.parse(raw);
4237
- } else if (ext === '.html' || ext === '.htm') {
4238
- content = raw;
4239
- } else {
4240
- return { content: [{ type: "text", text: `❌ 対応していないファイル形式です: ${ext} (.md または .html のみ)` }], isError: true };
4241
- }
4384
+ content = await resolveFileToBlockHTML(filePath);
4242
4385
  } catch (e) {
4243
4386
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4244
4387
  }
@@ -4360,15 +4503,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4360
4503
 
4361
4504
  if (filePath) {
4362
4505
  try {
4363
- const ext = extname(filePath).toLowerCase();
4364
- const raw = readHTMLFromFile(filePath).html;
4365
- if (ext === '.md') {
4366
- content = await marked.parse(raw);
4367
- } else if (ext === '.html' || ext === '.htm') {
4368
- content = raw;
4369
- } else {
4370
- return { content: [{ type: "text", text: `❌ 対応していないファイル形式です: ${ext} (.md または .html のみ)` }], isError: true };
4371
- }
4506
+ content = await resolveFileToBlockHTML(filePath);
4372
4507
  } catch (e) {
4373
4508
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
4374
4509
  }
@@ -4376,7 +4511,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4376
4511
 
4377
4512
  const postData = {
4378
4513
  title,
4379
- status: args?.status || 'draft',
4514
+ status: args?.status || 'publish',
4380
4515
  post_type: 'blog_parts',
4381
4516
  };
4382
4517
  if (args?.slug !== undefined) postData.slug = args.slug;
@@ -4410,6 +4545,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4410
4545
  }
4411
4546
 
4412
4547
  const bpResult = await client.searchBlogParts(args || {});
4548
+
4549
+ if (args?.partsId) {
4550
+ const usedBy = bpResult.usedBy || [];
4551
+ if (usedBy.length === 0) {
4552
+ return { content: [{ type: "text", text: `📋 ブログパーツ [${bpResult.partsId}] "${bpResult.partsTitle}" はどの記事でも使用されていません。` }] };
4553
+ }
4554
+ const usageList = usedBy.map(p =>
4555
+ ` [${p.postId}] ${p.title || '(無題)'}\n ステータス: ${p.status} | 更新: ${p.modified || '-'}`
4556
+ ).join('\n\n');
4557
+ let usageText = `📋 ブログパーツ [${bpResult.partsId}] "${bpResult.partsTitle}" の直接使用箇所 (${usedBy.length}件 / 全${bpResult.total}件)\n\n${usageList}`;
4558
+ if (bpResult.total_pages > 1) {
4559
+ const pg = args?.page || 1;
4560
+ usageText += `\n\n📄 ページ: ${pg} / ${bpResult.total_pages}`;
4561
+ if (pg < bpResult.total_pages) usageText += ` (次: page=${pg + 1})`;
4562
+ }
4563
+ return { content: [{ type: "text", text: usageText }] };
4564
+ }
4565
+
4413
4566
  const bpParts = bpResult.items || [];
4414
4567
  const bpTotal = bpResult.total || 0;
4415
4568
  const bpTotalPages = bpResult.total_pages || 0;
@@ -4423,7 +4576,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4423
4576
  return ` [${p.id}] ${p.title || '(無題)'}\n ステータス: ${p.status} | 更新: ${p.modified || '-'}`;
4424
4577
  }).join('\n\n');
4425
4578
 
4426
- let bpText = `📋 ブログ��ーツ一覧 (${bpParts.length}件 / 全${bpTotal}件)\n\n${bpList}`;
4579
+ let bpText = `📋 ブログパーツ一覧 (${bpParts.length}件 / 全${bpTotal}件)\n\n${bpList}`;
4427
4580
  if (bpTotalPages > 1) {
4428
4581
  bpText += `\n\n📄 ページ: ${bpPage} / ${bpTotalPages}`;
4429
4582
  if (bpPage < bpTotalPages) {
@@ -4629,6 +4782,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4629
4782
  return { content: [{ type: "text", text }] };
4630
4783
  }
4631
4784
 
4785
+ case "insert_blog_parts": {
4786
+ if (!args?.partsId) {
4787
+ return { content: [{ type: "text", text: "❌ partsId は必須です。" }], isError: true };
4788
+ }
4789
+ args.rawHTML = `<!-- wp:loos/blog-parts {"partsID":"${args.partsId}"} /-->`;
4790
+ // insert_block に fallthrough
4791
+ }
4792
+
4632
4793
  case "insert_block": {
4633
4794
  let { rawHTML, filePath } = (args || {});
4634
4795
 
@@ -4644,7 +4805,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4644
4805
 
4645
4806
  // filePath → rawHTML 解決
4646
4807
  if (filePath) {
4647
- try { rawHTML = readHTMLFromFile(filePath).html; }
4808
+ try { rawHTML = await resolveFileToBlockHTML(filePath); }
4648
4809
  catch (e) { return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true }; }
4649
4810
  }
4650
4811
  if (!rawHTML) {
@@ -4757,10 +4918,36 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4757
4918
  blocks = result.blocks || [];
4758
4919
  }
4759
4920
 
4921
+ // エディタモード時、ブログパーツの展開を補完
4922
+ if (mode === 'editor' && client) {
4923
+ for (const b of blocks) {
4924
+ if (b.type === 'loos/blog-parts' && !b.blogParts) {
4925
+ const pid = parseInt(b.html?.match(/partsID["\s:]+["']?(\d+)/)?.[1], 10);
4926
+ if (pid > 0) {
4927
+ try {
4928
+ const bpResult = await client.headlessGetBlockHtml(pid, {});
4929
+ b.blogParts = {
4930
+ partsId: pid,
4931
+ blocks: bpResult?.blocks || [],
4932
+ };
4933
+ } catch (_) {}
4934
+ }
4935
+ }
4936
+ }
4937
+ }
4938
+
4760
4939
  const _modeTag = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
4761
4940
  let text = `📦 ブロックHTML取得 (${blocks.length}件)\n`;
4762
4941
  for (const b of blocks) {
4763
- text += `\n[${b.index}] ${b.type}\n${b.html}\n`;
4942
+ text += `\n[${b.index}] ${b.type}\n`;
4943
+ if (b.blogParts && b.blogParts.blocks) {
4944
+ for (const inner of b.blogParts.blocks) {
4945
+ if ((inner.depth || 0) > 0) continue;
4946
+ text += `${inner.html}\n`;
4947
+ }
4948
+ } else {
4949
+ text += `${b.html}\n`;
4950
+ }
4764
4951
  }
4765
4952
  text += _modeTag;
4766
4953
  return { content: [{ type: "text", text }] };
@@ -5126,50 +5313,126 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5126
5313
  try { client = registry.get(siteName); }
5127
5314
  catch (e) { return errorResponse("search_media", e.message, siteName); }
5128
5315
 
5129
- if (!args?.query) return errorResponse("search_media", "query is required", siteName);
5316
+ if (!args?.query && !args?.folder) return errorResponse("search_media", "query または folder のどちらかは必須です", siteName);
5130
5317
 
5131
- const variants = generateSearchVariants(args.query, args?.strip, args?.prefix);
5132
5318
  const perPage = args?.per_page || 10;
5133
5319
 
5134
- const results = await Promise.allSettled(
5135
- variants.map(v => client.searchMedia(v, perPage))
5136
- );
5137
-
5138
- const seenIds = new Set();
5139
- const hits = [];
5140
- for (let i = 0; i < results.length; i++) {
5141
- if (results[i].status !== 'fulfilled') continue;
5142
- for (const item of results[i].value) {
5143
- if (seenIds.has(item.id)) continue;
5144
- seenIds.add(item.id);
5145
- hits.push({
5146
- id: item.id,
5147
- title: item.title?.rendered || '',
5148
- alt: item.alt_text || '',
5149
- url: item.source_url || '',
5150
- width: item.media_details?.width || null,
5151
- height: item.media_details?.height || null,
5152
- matched: variants[i],
5153
- });
5320
+ // --- FileBird folder filtering ---
5321
+ let folderIncludeIds = null;
5322
+ let folderInfo = '';
5323
+ if (args?.folder) {
5324
+ if (!client.filebirdToken) {
5325
+ return errorResponse("search_media", "FileBird APIキーが未設定です。connections.json に filebirdToken を追加してください", siteName);
5326
+ }
5327
+ const folders = await client.getFileBirdFolders();
5328
+ if (!folders) {
5329
+ return errorResponse("search_media", "FileBird API へのアクセスに失敗しました", siteName);
5154
5330
  }
5331
+ const flat = flattenFileBirdFolders(folders);
5332
+ const matched = flat.filter(f => f.name.includes(args.folder));
5333
+ if (matched.length === 0) {
5334
+ return errorResponse("search_media", `フォルダ "${args.folder}" が見つかりません。\n利用可能: ${flat.map(f => f.name).join(', ')}`, siteName);
5335
+ }
5336
+ if (matched.length > 1) {
5337
+ let text = `📁 "${args.folder}" に複数のフォルダがマッチしました。絞り込んでください:\n\n`;
5338
+ for (const f of matched) {
5339
+ text += ` [ID: ${f.id}] ${f.name} (${f.count}件)\n`;
5340
+ }
5341
+ return { content: [{ type: "text", text }] };
5342
+ }
5343
+ const folder = matched[0];
5344
+ const attachmentIds = await client.getFileBirdAttachmentIds(folder.id);
5345
+ if (!attachmentIds || attachmentIds.length === 0) {
5346
+ return { content: [{ type: "text", text: `📁 ${folder.name} (0件)\n\n❌ フォルダ内にメディアがありません。` }] };
5347
+ }
5348
+ folderIncludeIds = attachmentIds;
5349
+ const totalCount = attachmentIds.length;
5350
+ const limited = totalCount > 100;
5351
+ folderInfo = `📁 Folder: ${folder.name} (${totalCount}件${limited ? '、先頭100件から検索' : ''})\n`;
5155
5352
  }
5156
5353
 
5157
- let text = `🔍 Search: "${args.query}"\n`;
5158
- text += `🔄 Variants (${variants.length}): ${variants.join(', ')}\n\n`;
5354
+ // --- keyword search (with or without folder filter) ---
5355
+ if (args?.query) {
5356
+ const variants = generateSearchVariants(args.query, args?.strip, args?.prefix);
5357
+ const results = await Promise.allSettled(
5358
+ variants.map(v => client.searchMedia(v, perPage, folderIncludeIds))
5359
+ );
5360
+
5361
+ const seenIds = new Set();
5362
+ const hits = [];
5363
+ for (let i = 0; i < results.length; i++) {
5364
+ if (results[i].status !== 'fulfilled') continue;
5365
+ for (const item of results[i].value) {
5366
+ if (seenIds.has(item.id)) continue;
5367
+ seenIds.add(item.id);
5368
+ hits.push({
5369
+ id: item.id,
5370
+ title: item.title?.rendered || '',
5371
+ alt: item.alt_text || '',
5372
+ url: item.source_url || '',
5373
+ width: item.media_details?.width || null,
5374
+ height: item.media_details?.height || null,
5375
+ matched: variants[i],
5376
+ });
5377
+ }
5378
+ }
5159
5379
 
5160
- if (hits.length === 0) {
5380
+ let text = folderInfo;
5381
+ text += `🔍 Search: "${args.query}"\n`;
5382
+ text += `🔄 Variants (${variants.length}): ${variants.join(', ')}\n\n`;
5383
+
5384
+ if (hits.length === 0) {
5385
+ text += '❌ No results found.';
5386
+ } else {
5387
+ text += `📷 ${hits.length} result(s):\n\n`;
5388
+ for (const h of hits) {
5389
+ text += ` [ID: ${h.id}] ${h.title}\n`;
5390
+ text += ` Alt: ${h.alt}\n`;
5391
+ text += ` URL: ${h.url}\n`;
5392
+ if (h.width && h.height) text += ` Size: ${h.width}x${h.height}\n`;
5393
+ text += ` Matched: "${h.matched}"\n\n`;
5394
+ }
5395
+ }
5396
+ return { content: [{ type: "text", text }] };
5397
+ }
5398
+
5399
+ // --- folder only (no query) ---
5400
+ const mediaItems = await client.searchMedia(null, perPage, folderIncludeIds);
5401
+ let text = folderInfo + '\n';
5402
+ if (!mediaItems || mediaItems.length === 0) {
5161
5403
  text += '❌ No results found.';
5162
5404
  } else {
5163
- text += `📷 ${hits.length} result(s):\n\n`;
5164
- for (const h of hits) {
5165
- text += ` [ID: ${h.id}] ${h.title}\n`;
5166
- text += ` Alt: ${h.alt}\n`;
5167
- text += ` URL: ${h.url}\n`;
5168
- if (h.width && h.height) text += ` Size: ${h.width}x${h.height}\n`;
5169
- text += ` Matched: "${h.matched}"\n\n`;
5405
+ text += `📷 ${mediaItems.length} result(s):\n\n`;
5406
+ for (const item of mediaItems) {
5407
+ text += ` [ID: ${item.id}] ${item.title?.rendered || ''}\n`;
5408
+ text += ` Alt: ${item.alt_text || ''}\n`;
5409
+ text += ` URL: ${item.source_url || ''}\n`;
5410
+ const w = item.media_details?.width, h = item.media_details?.height;
5411
+ if (w && h) text += ` Size: ${w}x${h}\n`;
5412
+ text += '\n';
5170
5413
  }
5171
5414
  }
5415
+ return { content: [{ type: "text", text }] };
5416
+ }
5172
5417
 
5418
+ case "list_media_folders": {
5419
+ const siteName = args?.site || 'default';
5420
+ let client;
5421
+ try { client = registry.get(siteName); }
5422
+ catch (e) { return errorResponse("list_media_folders", e.message, siteName); }
5423
+
5424
+ if (!client.filebirdToken) {
5425
+ return { content: [{ type: "text", text: "⚠️ FileBird 未導入または APIキーが未設定です。\nconnections.json に filebirdToken を追加してください。" }] };
5426
+ }
5427
+ const folders = await client.getFileBirdFolders();
5428
+ if (!folders) {
5429
+ return errorResponse("list_media_folders", "FileBird API へのアクセスに失敗しました", siteName);
5430
+ }
5431
+ const flat = flattenFileBirdFolders(folders);
5432
+ let text = `📁 FileBird フォルダ一覧 (${flat.length}件)\n\n`;
5433
+ for (const f of flat) {
5434
+ text += ` [ID: ${f.id}] ${f.name} (${f.count}件)\n`;
5435
+ }
5173
5436
  return { content: [{ type: "text", text }] };
5174
5437
  }
5175
5438
 
@@ -5248,7 +5511,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5248
5511
  const list = items.map(item => {
5249
5512
  const lines = [
5250
5513
  ` [${item.id}] ${item.title}`,
5251
- ` 案件ID: ${item.data_id} | slug: ${item.slug}`,
5514
+ ` 案件ID: ${item.data_id} | slug: ${item.slug} | permalink: ${item.permalink}`,
5252
5515
  ];
5253
5516
  if (item.asp_url) lines.push(` ASP URL: ${item.asp_url}`);
5254
5517
  if (item.asp_url_sp) lines.push(` ASP URL(B): ${item.asp_url_sp}`);
@@ -5287,6 +5550,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5287
5550
  asp_url: args.asp_url,
5288
5551
  asp_url_sp: args?.asp_url_sp,
5289
5552
  direct_url: args?.direct_url,
5553
+ permalink: args?.permalink,
5290
5554
  });
5291
5555
  } catch (e) {
5292
5556
  return { content: [{ type: "text", text: `❌ ASPリンク登録エラー: ${e.message}` }], isError: true };
@@ -5298,6 +5562,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5298
5562
  ` ID: ${result.id}`,
5299
5563
  ` タイトル: ${result.title}`,
5300
5564
  ` slug: ${result.slug}`,
5565
+ ` permalink: ${result.permalink}`,
5301
5566
  ` 案件ID: ${result.data_id}`,
5302
5567
  ];
5303
5568
  if (result.asp_url) lines.push(` ASP URL: ${result.asp_url}`);
@@ -279,9 +279,13 @@ export class FridayWPClient {
279
279
 
280
280
  async searchBlogParts(params = {}) {
281
281
  const query = new URLSearchParams();
282
- if (params.search) query.set('search', params.search);
283
- if (params.slug) query.set('slug', params.slug);
284
- if (params.status) query.set('status', params.status);
282
+ if (params.partsId) {
283
+ query.set('parts_id', String(params.partsId));
284
+ } else {
285
+ if (params.search) query.set('search', params.search);
286
+ if (params.slug) query.set('slug', params.slug);
287
+ if (params.status) query.set('status', params.status);
288
+ }
285
289
  if (params.per_page) query.set('per_page', String(Math.min(Math.max(1, params.per_page), 100)));
286
290
  if (params.page) query.set('page', String(Math.max(1, params.page)));
287
291
  const qs = query.toString();
@@ -457,15 +461,36 @@ export class FridayWPClient {
457
461
  * @param {number} perPage 取得件数(デフォルト10)
458
462
  * @returns {object[]} メディアオブジェクト配列
459
463
  */
460
- async searchMedia(query, perPage = 10) {
464
+ async searchMedia(query, perPage = 10, includeIds = null) {
461
465
  const params = new URLSearchParams({
462
- search: query,
463
466
  media_type: 'image',
464
467
  per_page: String(Math.min(Math.max(1, perPage), 20)),
465
468
  });
469
+ if (query) params.set('search', query);
470
+ if (includeIds && includeIds.length > 0) {
471
+ params.set('include', includeIds.slice(0, 100).join(','));
472
+ }
466
473
  return this.wpCoreRequest(`/wp/v2/media?${params.toString()}`);
467
474
  }
468
475
 
476
+ async getFileBirdFolders() {
477
+ if (!this.filebirdToken) return null;
478
+ const url = `${this.wpUrl}/wp-json/filebird/public/v1/folders?token=${this.filebirdToken}`;
479
+ const res = await fetch(url);
480
+ if (!res.ok) return null;
481
+ const json = await res.json();
482
+ return json.success ? json.data.folders : null;
483
+ }
484
+
485
+ async getFileBirdAttachmentIds(folderId) {
486
+ if (!this.filebirdToken) return null;
487
+ const url = `${this.wpUrl}/wp-json/filebird/public/v1/attachment-id/?folder_id=${folderId}&token=${this.filebirdToken}`;
488
+ const res = await fetch(url);
489
+ if (!res.ok) return null;
490
+ const json = await res.json();
491
+ return json.success ? json.data.attachment_ids : null;
492
+ }
493
+
469
494
  // ========================================
470
495
  // ASP Link Management
471
496
  // ========================================
@@ -504,6 +529,7 @@ export class FridayWPClient {
504
529
  instance.wpAppPassword = config.pass;
505
530
  instance.authHeader = "Basic " + Buffer.from(`${config.user}:${config.pass}`).toString("base64");
506
531
  instance.baseUrl = config.url.replace(/\/$/, '') + '/wp-json/friday/v1';
532
+ instance.filebirdToken = config.filebirdToken || config.filebird_token || null;
507
533
  instance.bridgeClient = null;
508
534
  return instance;
509
535
  }
@@ -587,7 +613,7 @@ export class ConnectionRegistry {
587
613
  /** FRIDAY_CONN_{name}_{URL|USER|PASS} をパースして { name: { url, user, pass } } を返す */
588
614
  _parseConnEnvVars() {
589
615
  const conns = {};
590
- const pattern = /^FRIDAY_CONN_(.+?)_(URL|USER|PASS)$/;
616
+ const pattern = /^FRIDAY_CONN_(.+?)_(URL|USER|PASS|FILEBIRD_TOKEN)$/;
591
617
  for (const [key, value] of Object.entries(process.env)) {
592
618
  const m = key.match(pattern);
593
619
  if (!m) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "friday-mcp-v2",
3
- "version": "3.1.0",
3
+ "version": "3.1.2",
4
4
  "description": "WordPress MCP Server for Claude Code - REST API direct communication",
5
5
  "type": "module",
6
6
  "main": "dist/mcp-server.js",
@@ -23,8 +23,9 @@
23
23
  "license": "MIT",
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.0.0",
26
- "node-fetch": "^3.3.2",
27
26
  "marked": "^15.0.0",
27
+ "node-fetch": "^3.3.2",
28
+ "node-html-parser": "^7.1.0",
28
29
  "ws": "^8.19.0"
29
30
  }
30
31
  }