@noleemits/vision-builder-control-mcp 4.114.0 → 4.119.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.
Files changed (2) hide show
  1. package/index.js +246 -5
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -111,7 +111,7 @@ process.on('SIGINT', () => {
111
111
  // CONFIG
112
112
  // ================================================================
113
113
 
114
- const VERSION = '4.114.0';
114
+ const VERSION = '4.119.0';
115
115
  const MIN_PLUGIN_VERSION = '4.13.0'; // Minimum WP plugin version required by this MCP server
116
116
 
117
117
  // v4.110.0: shared styling philosophy appended to every build/style tool so the
@@ -1742,6 +1742,7 @@ async function apiCall(endpoint, method = 'GET', body = null) {
1742
1742
  'Content-Type': 'application/json',
1743
1743
  'Accept': 'application/json',
1744
1744
  'User-Agent': `Vision-Builder-Control/${VERSION}`,
1745
+ 'Connection': 'close',
1745
1746
  },
1746
1747
  signal: controller.signal,
1747
1748
  };
@@ -1907,6 +1908,17 @@ function getToolDefinitions() {
1907
1908
  description: 'Probe the connected WordPress site for the actual plugin version + every REST route the plugin has registered. Use when a tool returns "Backend route missing on this site" / "No route was found" — this confirms the site is running an older plugin version than the MCP server expects. Output includes a mismatch report comparing MCP-required routes against what the backend actually exposes, so callers can decide whether to update the site plugin. v4.46.0+.',
1908
1909
  inputSchema: { type: 'object', properties: {} }
1909
1910
  },
1911
+ {
1912
+ name: 'search_tools',
1913
+ description: 'Search available MCP tools by keyword. Returns matching tool names and one-line descriptions. Call this early in a session to discover which tools cover a given task — e.g. "css classes", "page", "template", "seo", "image", "nav". Pass an empty string to list every tool.',
1914
+ inputSchema: {
1915
+ type: 'object',
1916
+ properties: {
1917
+ query: { type: 'string', description: 'Keyword(s) to match against tool names and descriptions. Empty string returns all tools.' }
1918
+ },
1919
+ required: ['query']
1920
+ }
1921
+ },
1910
1922
  {
1911
1923
  name: 'get_design_tokens',
1912
1924
  description: 'Get all design tokens (colors, typography, spacing, URLs, buttons, globals_map). Tokens are cached for 5 minutes.',
@@ -2464,7 +2476,7 @@ function getToolDefinitions() {
2464
2476
  },
2465
2477
  {
2466
2478
  name: 'create_post',
2467
- description: 'Create a WordPress post, page, or CPT. Supports content, excerpt, taxonomies, featured image, parent linking (by slug or ID), an explicit publish date (for migrations), Elementor setup, and initial RankMath SEO data.',
2479
+ description: 'Create a WordPress post, page, or CPT. Supports content, excerpt, taxonomies, featured image, parent linking (by slug or ID), an explicit publish date (for migrations), Elementor setup, and initial RankMath SEO data. Accepts author (ID, login, slug, or email; requires edit_others_posts).',
2468
2480
  inputSchema: {
2469
2481
  type: 'object',
2470
2482
  properties: {
@@ -2473,9 +2485,10 @@ function getToolDefinitions() {
2473
2485
  content: { type: 'string', description: 'Post content (block HTML or plain text)' },
2474
2486
  excerpt: { type: 'string', description: 'Post excerpt' },
2475
2487
  status: { type: 'string', enum: ['draft', 'publish', 'private', 'pending'], description: 'Post status (default: draft)' },
2488
+ slug: { type: 'string', description: 'URL slug (auto-generated if omitted)' },
2489
+ author: { type: ['string', 'number'], description: 'Author to assign: user ID, login, slug, or email. Requires edit_others_posts.' },
2476
2490
  date: { type: 'string', description: 'Explicit publish date in site-local time, "YYYY-MM-DD HH:MM:SS" (or any strtotime-parseable string). Use to PRESERVE original dates when migrating posts — omit and the post gets the current date.' },
2477
2491
  date_gmt: { type: 'string', description: 'Optional explicit UTC date. If omitted, derived from `date` via the site timezone.' },
2478
- slug: { type: 'string', description: 'URL slug (auto-generated if omitted)' },
2479
2492
  parent: { type: 'number', description: 'Parent post ID (for hierarchical types). Use this OR parent_slug.' },
2480
2493
  parent_slug: { type: 'string', description: 'Parent slug — looked up against the same post_type. Saves a follow-up update_post call.' },
2481
2494
  taxonomies: { type: 'object', description: 'Object: {"category": [1,2], "post_tag": ["seo","health"]}' },
@@ -2490,7 +2503,7 @@ function getToolDefinitions() {
2490
2503
  },
2491
2504
  {
2492
2505
  name: 'update_post',
2493
- description: 'Update any field on a WordPress post/page. Only provided fields are changed. Supports title, content, status, slug, excerpt, parent (ID or slug), taxonomies, featured image (ID or basename), and RankMath SEO. Pass preserve_modified_date=true to keep the original last-modified date.',
2506
+ description: 'Update any field on a WordPress post/page. Only provided fields are changed. Supports title, content, status, slug, excerpt, parent (ID or slug), taxonomies, featured image (ID or basename), and RankMath SEO. Pass preserve_modified_date=true to keep the original last-modified date. Accepts author (ID, login, slug, or email; requires edit_others_posts).',
2494
2507
  inputSchema: {
2495
2508
  type: 'object',
2496
2509
  properties: {
@@ -2500,6 +2513,7 @@ function getToolDefinitions() {
2500
2513
  excerpt: { type: 'string' },
2501
2514
  status: { type: 'string', enum: ['draft', 'publish', 'private', 'pending', 'trash'] },
2502
2515
  slug: { type: 'string' },
2516
+ author: { type: ['string', 'number'], description: 'Author to assign: user ID, login, slug, or email. Requires edit_others_posts.' },
2503
2517
  parent: { type: 'number', description: 'Parent post/page ID (for hierarchical types like pages). Changes URL structure.' },
2504
2518
  parent_slug: { type: 'string', description: 'Parent slug — looked up against this post\'s post_type. Use instead of parent when you don\'t know the parent ID.' },
2505
2519
  taxonomies: { type: 'object', description: '{"category": [1,2]}' },
@@ -2536,6 +2550,46 @@ function getToolDefinitions() {
2536
2550
  required: ['slug']
2537
2551
  }
2538
2552
  },
2553
+ {
2554
+ name: 'list_users',
2555
+ description: 'List WordPress users (authors/editors/etc). Returns id, login, slug, display_name, email, roles, and published post_count. Use to discover an author ID/login before assigning posts with create_post/update_post/set_post_author/reassign_author. Requires the list_users capability.',
2556
+ inputSchema: {
2557
+ type: 'object',
2558
+ properties: {
2559
+ role: { type: 'string', description: 'Filter by role slug (e.g. "author", "editor", "administrator").' },
2560
+ search: { type: 'string', description: 'Substring match on login, name, or email.' },
2561
+ has_posts: { type: 'boolean', description: 'If true, only users with at least one published post.' },
2562
+ per_page: { type: 'number', description: 'Max results (default 100, cap 200).' }
2563
+ }
2564
+ }
2565
+ },
2566
+ {
2567
+ name: 'set_post_author',
2568
+ description: 'Assign one author to a specific list of posts (bulk). Pass post_ids and an author (ID, login, slug, or email). Returns which posts were updated vs skipped. Requires edit_others_posts.',
2569
+ inputSchema: {
2570
+ type: 'object',
2571
+ properties: {
2572
+ post_ids: { type: 'array', items: { type: 'number' }, description: 'Post IDs to reassign.' },
2573
+ author: { type: ['string', 'number'], description: 'Target author: ID, login, slug, or email.' }
2574
+ },
2575
+ required: ['post_ids', 'author']
2576
+ }
2577
+ },
2578
+ {
2579
+ name: 'reassign_author',
2580
+ description: 'Reassign ALL posts from one author to another (e.g. offboarding a writer). from/to accept ID, login, slug, or email. Optionally filter by post_type and status. Pass dry_run:true first to see how many posts would move without changing anything. Requires edit_others_posts.',
2581
+ inputSchema: {
2582
+ type: 'object',
2583
+ properties: {
2584
+ from: { type: ['string', 'number'], description: 'Source author (ID, login, slug, or email).' },
2585
+ to: { type: ['string', 'number'], description: 'Destination author (ID, login, slug, or email).' },
2586
+ post_type: { type: 'string', description: 'Limit to one post type (default: all).' },
2587
+ status: { type: 'array', items: { type: 'string' }, description: 'Statuses to include (default: publish,draft,pending,private,future).' },
2588
+ dry_run: { type: 'boolean', description: 'If true, only count + list the post IDs that would move. ALWAYS run this first on a large reassignment.' }
2589
+ },
2590
+ required: ['from', 'to']
2591
+ }
2592
+ },
2539
2593
  {
2540
2594
  name: 'change_post_type',
2541
2595
  description: 'Migrate a WordPress post to a different post type IN PLACE — preserves post ID, post_meta (SEO, featured image, custom fields), comments, attachments, post_date, and any taxonomy terms registered on both types. Atomic operation (single DB write to the post_type column, no save_post re-fire). Optionally rewrites internal links across all post_content and creates a RankMath 301 redirect from the old permalink. Use this instead of create_post + delete_post when moving a page to a CPT — it is faster, lossless, and keeps the URL history.',
@@ -3633,6 +3687,31 @@ function getToolDefinitions() {
3633
3687
  required: ['post_id', 'fields']
3634
3688
  }
3635
3689
  },
3690
+ {
3691
+ name: 'get_post_meta',
3692
+ description: 'Read RAW WordPress post meta for any post (not ACF-specific — get_acf_fields cannot see plain meta written by themes, page builders, or generators like Mosaic). Returns stored values including HTML. Distinguishes missing keys (in `missing`) from present-but-empty (value === ""). Omit `keys` to return all non-protected meta.',
3693
+ inputSchema: {
3694
+ type: 'object',
3695
+ properties: {
3696
+ post_id: { type: 'number', description: 'WordPress post ID' },
3697
+ keys: { type: 'array', items: { type: 'string' }, description: 'Optional list of meta keys. Omit to return all non-protected meta.' }
3698
+ },
3699
+ required: ['post_id']
3700
+ }
3701
+ },
3702
+ {
3703
+ name: 'update_post_meta',
3704
+ description: 'Write RAW WordPress post meta (HTML-safe, no wpautop mangling). Defaults to dry_run=true — set dry_run=false to apply. Protected meta (keys starting with "_") is skipped. Use for seeding/correcting plain custom fields. Note: update returns "unchanged_or_failed" when the new value equals the stored value.',
3705
+ inputSchema: {
3706
+ type: 'object',
3707
+ properties: {
3708
+ post_id: { type: 'number', description: 'WordPress post ID' },
3709
+ meta: { type: 'object', description: 'Object of { metaKey: value }. Values stored raw (HTML preserved).' },
3710
+ dry_run: { type: 'boolean', description: 'Default true. Set false to write.' }
3711
+ },
3712
+ required: ['post_id', 'meta']
3713
+ }
3714
+ },
3636
3715
  {
3637
3716
  name: 'create_acf_field_group',
3638
3717
  description: 'Create a new ACF field group with fields and post type assignments. Supports field types: text, textarea, wysiwyg, number, url, image, select, true_false, repeater, group.',
@@ -3717,6 +3796,48 @@ function getToolDefinitions() {
3717
3796
  required: ['source_template_id', 'title']
3718
3797
  }
3719
3798
  },
3799
+ {
3800
+ name: 'duplicate_post',
3801
+ description: 'Duplicate any post into a new post in one call. Can change post_type (e.g. clone a CPT post into a standalone page). Copies _elementor_data with regenerated element IDs, the WP page template, selected post meta, and RankMath SEO by default. Returns the new ID + preview URL. NOTE: this copies structure + meta but does NOT resolve dynamic tags — a clone of a template-driven CPT post still renders via dynamic tags unless you also flatten_dynamic_tags or update_post_meta the clone.',
3802
+ inputSchema: {
3803
+ type: 'object',
3804
+ properties: {
3805
+ source_id: { type: 'number', description: 'Post ID to duplicate' },
3806
+ new_title: { type: 'string', description: 'Title for the copy (default: "<source> (copy)")' },
3807
+ new_slug: { type: 'string', description: 'Optional slug (auto from title if omitted)' },
3808
+ status: { type: 'string', enum: ['publish', 'draft', 'private', 'pending'], description: 'Default draft' },
3809
+ target_post_type: { type: 'string', description: 'Optional — clone into a different post type, e.g. "page". Default: same as source.' },
3810
+ include: {
3811
+ type: 'object',
3812
+ description: 'What to copy. Defaults all true. meta can be true (all non-protected) or an array of specific keys. Set seo:false to skip rank_math_* when copying all meta.',
3813
+ properties: {
3814
+ elementor_data: { type: 'boolean' },
3815
+ page_template: { type: 'boolean' },
3816
+ meta: {},
3817
+ seo: { type: 'boolean' }
3818
+ }
3819
+ }
3820
+ },
3821
+ required: ['source_id']
3822
+ }
3823
+ },
3824
+ {
3825
+ name: 'flatten_dynamic_tags',
3826
+ description: 'Resolve Elementor dynamic tags on a post to their literal rendered values and write the result back (in place, or to a different target). Resolves ANY bound tag via Elementor itself — honors fallback/after exactly as the front end. meta_context_id lets you bake post X\'s content onto a generic clone. Action tags (popup) and functional widgets (jet-form-builder-form) are skipped by default — extend via skip_tags / skip_widgets. Defaults dry_run=true: review the per-binding report, then set dry_run=false. This is the one-shot de-dynamification primitive for converting template-driven CPT posts into self-contained static pages.',
3827
+ inputSchema: {
3828
+ type: 'object',
3829
+ properties: {
3830
+ source_id: { type: 'number', description: 'Post whose dynamic tags to resolve (path param)' },
3831
+ target_id: { type: 'number', description: 'Write resolved data here. Omit = in place (= source).' },
3832
+ meta_context_id: { type: 'number', description: 'Resolve post-aware tags against THIS post\'s context/meta. Omit = source. CRITICAL when source is a shared template (which has no per-item meta): set this to the specific CPT post whose content you want baked, or every tag resolves to its fallback.' },
3833
+ strip_bindings: { type: 'boolean', description: 'Remove __dynamic__ entries after inlining. Default true.' },
3834
+ dry_run: { type: 'boolean', description: 'Default true. Set false to write.' },
3835
+ skip_tags: { type: 'array', items: { type: 'string' }, description: 'Extra tag names to leave untouched (popup always skipped).' },
3836
+ skip_widgets: { type: 'array', items: { type: 'string' }, description: 'Extra widget types to leave untouched (jet-form-builder-form always skipped).' }
3837
+ },
3838
+ required: ['source_id']
3839
+ }
3840
+ },
3720
3841
  {
3721
3842
  name: 'create_template',
3722
3843
  description: 'Create a BLANK Theme Builder template in one call — then fill it with append_section / add_element. Atomically sets post_type=elementor_library, the elementor_library_type TAXONOMY TERM (required for loop context — setting only the meta silently fails for loop-item), the _elementor_template_type meta, and builder edit-mode. Use this instead of a raw wp/v2/elementor_library POST (which omits the taxonomy term and silently produces a loop template with no post context). For a template BASED ON an existing design, use clone_template instead. v4.111.0.',
@@ -5222,6 +5343,42 @@ async function handleToolCall(name, args) {
5222
5343
  return ok(out);
5223
5344
  }
5224
5345
 
5346
+ case 'list_users': {
5347
+ const params = new URLSearchParams();
5348
+ if (args.role) params.set('role', args.role);
5349
+ if (args.search) params.set('search', args.search);
5350
+ if (args.has_posts) params.set('has_posts', '1');
5351
+ if (args.per_page) params.set('per_page', args.per_page);
5352
+ const qs = params.toString() ? `?${params.toString()}` : '';
5353
+ const r = await apiCall(`/users${qs}`);
5354
+ if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
5355
+ let out = `${r.total} users\n${'─'.repeat(50)}\n`;
5356
+ r.users.forEach(u => {
5357
+ out += `[${u.id}] ${u.display_name} (${u.login})\n`;
5358
+ out += ` Roles: ${u.roles.join(', ') || 'none'} | Posts: ${u.post_count} | ${u.email}\n`;
5359
+ });
5360
+ return ok(out);
5361
+ }
5362
+
5363
+ case 'set_post_author': {
5364
+ const r = await apiCall('/posts/set-author', 'POST', { post_ids: args.post_ids, author: args.author });
5365
+ if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
5366
+ let out = `Set author → ${r.author} (#${r.author_id})\nUpdated ${r.count}: ${r.updated.join(', ')}`;
5367
+ if (r.skipped.length) out += `\nSkipped ${r.skipped.length}: ${r.skipped.map(s => `${s.id} (${s.reason})`).join(', ')}`;
5368
+ return ok(out);
5369
+ }
5370
+
5371
+ case 'reassign_author': {
5372
+ const body = { from: args.from, to: args.to };
5373
+ if (args.post_type) body.post_type = args.post_type;
5374
+ if (args.status) body.status = args.status;
5375
+ if (args.dry_run) body.dry_run = true;
5376
+ const r = await apiCall('/users/reassign-author', 'POST', body);
5377
+ if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
5378
+ if (r.dry_run) return ok(`DRY RUN: ${r.would_reassign} posts would move from #${r.from_id} → #${r.to_id}.\nIDs: ${r.post_ids.join(', ')}`);
5379
+ return ok(`Reassigned ${r.count} posts: ${r.from} (#${r.from_id}) → ${r.to} (#${r.to_id})`);
5380
+ }
5381
+
5225
5382
  case 'get_post': {
5226
5383
  const r = await apiCall(`/posts/${args.post_id}`);
5227
5384
  if (!r.success) return ok(`Failed: ${r.message || 'Not found'}`);
@@ -5254,7 +5411,7 @@ async function handleToolCall(name, args) {
5254
5411
  case 'create_post': {
5255
5412
  const body = { title: args.title };
5256
5413
  const passthrough = ['post_type', 'content', 'excerpt', 'status', 'slug',
5257
- 'parent', 'parent_slug', 'taxonomies', 'featured_image_id',
5414
+ 'author', 'parent', 'parent_slug', 'taxonomies', 'featured_image_id',
5258
5415
  'featured_image_basename', 'elementor', 'seo', 'dedupe_by_title',
5259
5416
  'date', 'date_gmt'];
5260
5417
  for (const k of passthrough) if (args[k] !== undefined) body[k] = args[k];
@@ -7020,6 +7177,39 @@ async function handleToolCall(name, args) {
7020
7177
  return ok(out);
7021
7178
  }
7022
7179
 
7180
+ case 'get_post_meta': {
7181
+ let ep = `/post-meta?post_id=${args.post_id}`;
7182
+ if (Array.isArray(args.keys) && args.keys.length) {
7183
+ ep += `&keys=${encodeURIComponent(args.keys.join(','))}`;
7184
+ }
7185
+ const r = await apiCall(ep);
7186
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7187
+ let out = `=== POST META: ${r.post_title} (ID:${r.post_id}) ===\n`;
7188
+ const keys = Object.keys(r.meta || {});
7189
+ if (!keys.length) {
7190
+ out += 'No meta found.\n';
7191
+ } else {
7192
+ keys.forEach(k => {
7193
+ const m = r.meta[k];
7194
+ const val = typeof m.value === 'object' ? JSON.stringify(m.value) : (m.value === '' ? '(empty)' : m.value);
7195
+ out += `\n ${k}${m.count > 1 ? ` [×${m.count}]` : ''}: ${val}\n`;
7196
+ });
7197
+ }
7198
+ if (r.missing && r.missing.length) out += `\nMISSING (not present): ${r.missing.join(', ')}\n`;
7199
+ return ok(out);
7200
+ }
7201
+
7202
+ case 'update_post_meta': {
7203
+ const body = { post_id: args.post_id, meta: args.meta };
7204
+ if (typeof args.dry_run === 'boolean') body.dry_run = args.dry_run;
7205
+ const r = await apiCall('/post-meta', 'POST', body);
7206
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7207
+ let out = `${r.dry_run ? 'DRY RUN' : 'WROTE'} — ${r.updated} key(s) on "${r.post_title}" (ID:${r.post_id})\n`;
7208
+ (r.results || []).forEach(x => { out += ` ${x.key}: ${x.status}\n`; });
7209
+ out += `\n${r.note}\n`;
7210
+ return ok(out);
7211
+ }
7212
+
7023
7213
  case 'create_acf_field_group': {
7024
7214
  const body = { title: args.title, fields: args.fields };
7025
7215
  if (args.post_types) body.post_types = args.post_types;
@@ -7154,6 +7344,42 @@ async function handleToolCall(name, args) {
7154
7344
  return ok(msg);
7155
7345
  }
7156
7346
 
7347
+ case 'duplicate_post': {
7348
+ if (!args.source_id) return ok('Failed: source_id is required.');
7349
+ const body = { source_id: args.source_id };
7350
+ if (args.new_title) body.new_title = args.new_title;
7351
+ if (args.new_slug) body.new_slug = args.new_slug;
7352
+ if (args.status) body.status = args.status;
7353
+ if (args.target_post_type) body.target_post_type = args.target_post_type;
7354
+ if (args.include) body.include = args.include;
7355
+ const r = await apiCall('/posts/duplicate', 'POST', body);
7356
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7357
+ let out = `Duplicated post ${r.source_id} (${r.source_type}) → ${r.new_id} (${r.new_post_type}, ${r.status})\n`;
7358
+ out += `Sections: ${r.sections} | Meta copied: ${r.meta_copied.length}\n`;
7359
+ out += `Preview: ${r.preview_url}\nEdit: ${r.edit_url}\n`;
7360
+ return ok(out);
7361
+ }
7362
+
7363
+ case 'flatten_dynamic_tags': {
7364
+ if (!args.source_id) return ok('Failed: source_id is required.');
7365
+ const body = {};
7366
+ if (args.target_id) body.target_id = args.target_id;
7367
+ if (args.meta_context_id) body.meta_context_id = args.meta_context_id;
7368
+ if (typeof args.strip_bindings === 'boolean') body.strip_bindings = args.strip_bindings;
7369
+ if (typeof args.dry_run === 'boolean') body.dry_run = args.dry_run;
7370
+ if (Array.isArray(args.skip_tags)) body.skip_tags = args.skip_tags;
7371
+ if (Array.isArray(args.skip_widgets)) body.skip_widgets = args.skip_widgets;
7372
+ const r = await apiCall(`/pages/${args.source_id}/flatten-dynamic-tags`, 'POST', body);
7373
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7374
+ let out = `${r.dry_run ? 'DRY RUN' : 'FLATTENED'} — ${r.resolved} binding(s) | source ${r.source_id} → target ${r.target_id} | ctx ${r.meta_context_id}\n`;
7375
+ out += `Skipped tags: ${r.skipped_tags.join(', ')} | Skipped widgets: ${r.skipped_widgets.join(', ')}\n\n`;
7376
+ (r.report || []).forEach(x => {
7377
+ out += ` [${x.action}] ${x.id || ''} ${x.field ? `.${x.field}` : ''} ${x.tag ? `(${x.tag})` : ''}${x.preview ? ` → ${x.preview}` : ''}\n`;
7378
+ });
7379
+ out += `\n${r.note}\n`;
7380
+ return ok(out);
7381
+ }
7382
+
7157
7383
  case 'create_template': {
7158
7384
  if (!args.type || !args.title) {
7159
7385
  return ok('Failed: type and title are required.');
@@ -8268,6 +8494,21 @@ async function handleToolCall(name, args) {
8268
8494
  return ok(msg);
8269
8495
  }
8270
8496
 
8497
+ case 'search_tools': {
8498
+ const q = (args.query || '').toLowerCase().trim();
8499
+ const all = getToolDefinitions();
8500
+ const matches = q
8501
+ ? all.filter(t => t.name.includes(q) || t.description.toLowerCase().includes(q))
8502
+ : all;
8503
+ if (!matches.length) return ok(`No tools match "${args.query}". Try a broader term.`);
8504
+ const lines = matches.map(t => {
8505
+ const firstSentence = t.description.split(/[.\n]/)[0].trim();
8506
+ return `${t.name} — ${firstSentence}`;
8507
+ });
8508
+ const header = q ? `${matches.length} tool(s) matched "${args.query}"` : `${matches.length} tools available`;
8509
+ return ok(`${header}\n\n${lines.join('\n')}`);
8510
+ }
8511
+
8271
8512
  default:
8272
8513
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
8273
8514
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noleemits/vision-builder-control-mcp",
3
- "version": "4.114.0",
3
+ "version": "4.119.0",
4
4
  "description": "Vision Builder Control MCP server - design token-driven page builder tools for WordPress/Elementor websites",
5
5
  "type": "module",
6
6
  "main": "index.js",