@noleemits/vision-builder-control-mcp 4.114.1 → 4.120.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 +390 -22
  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.1';
114
+ const VERSION = '4.120.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
@@ -1908,6 +1908,17 @@ function getToolDefinitions() {
1908
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+.',
1909
1909
  inputSchema: { type: 'object', properties: {} }
1910
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
+ },
1911
1922
  {
1912
1923
  name: 'get_design_tokens',
1913
1924
  description: 'Get all design tokens (colors, typography, spacing, URLs, buttons, globals_map). Tokens are cached for 5 minutes.',
@@ -2465,7 +2476,7 @@ function getToolDefinitions() {
2465
2476
  },
2466
2477
  {
2467
2478
  name: 'create_post',
2468
- 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).',
2469
2480
  inputSchema: {
2470
2481
  type: 'object',
2471
2482
  properties: {
@@ -2474,9 +2485,10 @@ function getToolDefinitions() {
2474
2485
  content: { type: 'string', description: 'Post content (block HTML or plain text)' },
2475
2486
  excerpt: { type: 'string', description: 'Post excerpt' },
2476
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.' },
2477
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.' },
2478
2491
  date_gmt: { type: 'string', description: 'Optional explicit UTC date. If omitted, derived from `date` via the site timezone.' },
2479
- slug: { type: 'string', description: 'URL slug (auto-generated if omitted)' },
2480
2492
  parent: { type: 'number', description: 'Parent post ID (for hierarchical types). Use this OR parent_slug.' },
2481
2493
  parent_slug: { type: 'string', description: 'Parent slug — looked up against the same post_type. Saves a follow-up update_post call.' },
2482
2494
  taxonomies: { type: 'object', description: 'Object: {"category": [1,2], "post_tag": ["seo","health"]}' },
@@ -2491,7 +2503,7 @@ function getToolDefinitions() {
2491
2503
  },
2492
2504
  {
2493
2505
  name: 'update_post',
2494
- 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).',
2495
2507
  inputSchema: {
2496
2508
  type: 'object',
2497
2509
  properties: {
@@ -2501,6 +2513,7 @@ function getToolDefinitions() {
2501
2513
  excerpt: { type: 'string' },
2502
2514
  status: { type: 'string', enum: ['draft', 'publish', 'private', 'pending', 'trash'] },
2503
2515
  slug: { type: 'string' },
2516
+ author: { type: ['string', 'number'], description: 'Author to assign: user ID, login, slug, or email. Requires edit_others_posts.' },
2504
2517
  parent: { type: 'number', description: 'Parent post/page ID (for hierarchical types like pages). Changes URL structure.' },
2505
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.' },
2506
2519
  taxonomies: { type: 'object', description: '{"category": [1,2]}' },
@@ -2537,6 +2550,46 @@ function getToolDefinitions() {
2537
2550
  required: ['slug']
2538
2551
  }
2539
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
+ },
2540
2593
  {
2541
2594
  name: 'change_post_type',
2542
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.',
@@ -3111,15 +3164,18 @@ function getToolDefinitions() {
3111
3164
  },
3112
3165
  {
3113
3166
  name: 'add_dynamic_field',
3114
- description: 'Add a dynamic-data widget to a loop-item / single / archive template — a STANDARD widget (heading/text-editor/image) carrying the correct Elementor __dynamic__ tag. Use this INSTEAD of the theme-post-* widgets (theme-post-title/-excerpt/-featured-image), which are unreliable when authored via REST (they render placeholders). Types: title→heading+post-title, excerpt→text-editor+post-excerpt, content→text-editor+post-content, featured_image→image+post-featured-image, acf→heading+acf (pass field = the ACF field key/name). The widget renders the current post\'s data inside loop/single context.',
3167
+ description: 'Add a dynamic-data widget to a loop-item / single / archive template — a STANDARD widget (heading/text-editor/image) carrying the correct Elementor __dynamic__ tag. Use this INSTEAD of the theme-post-* widgets (theme-post-title/-excerpt/-featured-image), which are unreliable when authored via REST (they render placeholders). Types: title→heading+post-title, excerpt→text-editor+post-excerpt, content→text-editor+post-content, featured_image→image+post-featured-image, acf→typed ACF tag (acf-text/acf-number/acf-image/acf-url). For type=acf set `field` (the ACF field NAME) and optionally `field_type` (default text) and `source`: "post" (default — current/queried post) or "options" (an ACF OPTIONS page → renders the same site-wide value on EVERY page). Requires the field group to be attached to an options page (see update_acf_field_group).',
3115
3168
  inputSchema: {
3116
3169
  type: 'object',
3117
3170
  properties: {
3118
3171
  page_id: { type: 'number', description: 'Template (or page) ID to add into' },
3119
3172
  parent_id: { type: 'string', description: 'Elementor ID of the parent container' },
3120
- type: { type: 'string', enum: ['title', 'excerpt', 'content', 'featured_image', 'acf'], description: 'Which post field to bind.' },
3121
- field: { type: 'string', description: 'For type=acf: the ACF field key or name to bind.' },
3122
- tag: { type: 'string', description: 'Override the dynamic tag name (advanced e.g. a custom registered tag).' },
3173
+ type: { type: 'string', enum: ['title', 'excerpt', 'content', 'featured_image', 'acf'], description: 'Which field to bind.' },
3174
+ field: { type: 'string', description: 'For type=acf: the ACF field NAME to bind (e.g. "stat_success").' },
3175
+ field_type:{ type: 'string', enum: ['text', 'number', 'url', 'image'], description: 'For type=acf: ACF field type picks the native tag (acf-text/acf-number/acf-url/acf-image) and host widget. Default "text".' },
3176
+ source: { type: 'string', enum: ['post', 'options'], description: 'For type=acf: "post" (default) resolves against the current/queried post; "options" resolves against the ACF options page → same value site-wide.' },
3177
+ field_key: { type: 'string', description: 'For type=acf + source=post: optional exact ACF field key (e.g. "field_abc123") for a precise binding (key encoded as "<field_key>:<field>"). Ignored when source=options.' },
3178
+ tag: { type: 'string', description: 'Override the dynamic tag name for non-acf types (advanced — e.g. a custom registered tag).' },
3123
3179
  settings: { type: 'object', description: 'Extra widget settings to merge (e.g. typography, header_size, link). additionalProperties.', additionalProperties: true },
3124
3180
  position: { type: 'number', description: 'Optional 0-based insert index. Omit to append.' },
3125
3181
  force: { type: 'boolean', description: 'Override edit locks.' }
@@ -3612,28 +3668,53 @@ function getToolDefinitions() {
3612
3668
  },
3613
3669
  {
3614
3670
  name: 'get_acf_fields',
3615
- description: 'Get all ACF field values for a specific post. Returns field names, labels, types, and current values. Supports all ACF field types including repeater and group fields.',
3671
+ description: 'Get all ACF field values for a specific post — OR for an ACF options page. Returns field names, labels, types, and current values. Supports all ACF field types including repeater and group fields. For a site-wide ACF options page, pass post_id="options" (or a custom options post_id). Use list_acf_option_pages to discover registered options pages.',
3616
3672
  inputSchema: {
3617
3673
  type: 'object',
3618
3674
  properties: {
3619
- post_id: { type: 'number', description: 'WordPress post ID to get ACF fields for.' }
3675
+ post_id: { type: ['number', 'string'], description: 'WordPress post ID (number) to read, OR the string "options" for the ACF options page (or a custom options post_id).' }
3620
3676
  },
3621
3677
  required: ['post_id']
3622
3678
  }
3623
3679
  },
3624
3680
  {
3625
3681
  name: 'set_acf_fields',
3626
- description: 'Set/update ACF field values on a post. Accepts a JSON object of field_name: value pairs. Always dry_run=true first to preview.',
3682
+ description: 'Set/update ACF field values on a post — OR on an ACF options page (site-wide). Accepts a JSON object of field_name: value pairs. Always dry_run=true first to preview. For a site-wide options page, pass post_id="options".',
3627
3683
  inputSchema: {
3628
3684
  type: 'object',
3629
3685
  properties: {
3630
- post_id: { type: 'number', description: 'WordPress post ID to update.' },
3686
+ post_id: { type: ['number', 'string'], description: 'WordPress post ID (number) to update, OR the string "options" for the ACF options page (or a custom options post_id).' },
3631
3687
  fields: { type: 'object', description: 'Object of field_name: value pairs. Example: {"hero_title": "New Title", "show_sidebar": true}' },
3632
3688
  dry_run: { type: 'boolean', description: 'Preview changes without saving (default: true). Set false to apply.' }
3633
3689
  },
3634
3690
  required: ['post_id', 'fields']
3635
3691
  }
3636
3692
  },
3693
+ {
3694
+ name: 'get_post_meta',
3695
+ 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.',
3696
+ inputSchema: {
3697
+ type: 'object',
3698
+ properties: {
3699
+ post_id: { type: 'number', description: 'WordPress post ID' },
3700
+ keys: { type: 'array', items: { type: 'string' }, description: 'Optional list of meta keys. Omit to return all non-protected meta.' }
3701
+ },
3702
+ required: ['post_id']
3703
+ }
3704
+ },
3705
+ {
3706
+ name: 'update_post_meta',
3707
+ 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.',
3708
+ inputSchema: {
3709
+ type: 'object',
3710
+ properties: {
3711
+ post_id: { type: 'number', description: 'WordPress post ID' },
3712
+ meta: { type: 'object', description: 'Object of { metaKey: value }. Values stored raw (HTML preserved).' },
3713
+ dry_run: { type: 'boolean', description: 'Default true. Set false to write.' }
3714
+ },
3715
+ required: ['post_id', 'meta']
3716
+ }
3717
+ },
3637
3718
  {
3638
3719
  name: 'create_acf_field_group',
3639
3720
  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.',
@@ -3671,6 +3752,40 @@ function getToolDefinitions() {
3671
3752
  required: ['group_key']
3672
3753
  }
3673
3754
  },
3755
+ {
3756
+ name: 'list_acf_option_pages',
3757
+ description: 'List registered ACF options pages (ACF Pro). Returns each page title, menu_slug, and the post_id string to pass to get_acf_fields/set_acf_fields (default "options"). Use this to confirm a site-wide settings page exists before reading/writing options data. If none exist, create one with register_acf_option_page.',
3758
+ inputSchema: { type: 'object', properties: {} }
3759
+ },
3760
+ {
3761
+ name: 'register_acf_option_page',
3762
+ description: 'Register (and persist) an ACF options page so site-wide fields can be stored once and read on every page (ACF Pro). Idempotent by menu_slug. The page is persisted in a WP option and re-registered on every load via acf/init — so it survives. After creating, attach field groups to it with update_acf_field_group (options_page=<menu_slug>), then read/write values with post_id "options".',
3763
+ inputSchema: {
3764
+ type: 'object',
3765
+ properties: {
3766
+ page_title: { type: 'string', description: 'Display title, e.g. "Site Settings".' },
3767
+ menu_slug: { type: 'string', description: 'Optional admin menu slug. Defaults to a slug of page_title.' },
3768
+ parent_slug: { type: 'string', description: 'Optional parent menu slug to nest under (e.g. "options-general.php" or another options page slug).' }
3769
+ },
3770
+ required: ['page_title']
3771
+ }
3772
+ },
3773
+ {
3774
+ name: 'update_acf_field_group',
3775
+ description: 'Patch an EXISTING ACF field group\'s metadata (title / active / location) without deleting+recreating it — so field keys that Elementor dynamic tags reference are preserved. Use to attach an orphaned group to an options page (options_page=<menu_slug>) or to post types. Pass a full ACF `location` array for advanced rules, or use post_types[]/options_page convenience builders.',
3776
+ inputSchema: {
3777
+ type: 'object',
3778
+ properties: {
3779
+ group_key: { type: 'string', description: 'ACF field group key (e.g. "group_6a2315c28164f"). Use list_acf_field_groups to discover.' },
3780
+ title: { type: 'string', description: 'Optional new title.' },
3781
+ active: { type: 'boolean', description: 'Optional active flag.' },
3782
+ options_page: { type: 'string', description: 'Attach the group to this options-page menu_slug (builds an options_page location rule).' },
3783
+ post_types: { type: 'array', items: { type: 'string' }, description: 'Attach the group to these post types (builds post_type location rules).' },
3784
+ location: { type: 'array', description: 'Advanced: a full ACF location rule array (overrides post_types/options_page). Shape: [[{param,operator,value}]].' }
3785
+ },
3786
+ required: ['group_key']
3787
+ }
3788
+ },
3674
3789
  // ── Audits ──
3675
3790
  {
3676
3791
  name: 'audit_orphan_pages',
@@ -3718,6 +3833,48 @@ function getToolDefinitions() {
3718
3833
  required: ['source_template_id', 'title']
3719
3834
  }
3720
3835
  },
3836
+ {
3837
+ name: 'duplicate_post',
3838
+ 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.',
3839
+ inputSchema: {
3840
+ type: 'object',
3841
+ properties: {
3842
+ source_id: { type: 'number', description: 'Post ID to duplicate' },
3843
+ new_title: { type: 'string', description: 'Title for the copy (default: "<source> (copy)")' },
3844
+ new_slug: { type: 'string', description: 'Optional slug (auto from title if omitted)' },
3845
+ status: { type: 'string', enum: ['publish', 'draft', 'private', 'pending'], description: 'Default draft' },
3846
+ target_post_type: { type: 'string', description: 'Optional — clone into a different post type, e.g. "page". Default: same as source.' },
3847
+ include: {
3848
+ type: 'object',
3849
+ 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.',
3850
+ properties: {
3851
+ elementor_data: { type: 'boolean' },
3852
+ page_template: { type: 'boolean' },
3853
+ meta: {},
3854
+ seo: { type: 'boolean' }
3855
+ }
3856
+ }
3857
+ },
3858
+ required: ['source_id']
3859
+ }
3860
+ },
3861
+ {
3862
+ name: 'flatten_dynamic_tags',
3863
+ 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.',
3864
+ inputSchema: {
3865
+ type: 'object',
3866
+ properties: {
3867
+ source_id: { type: 'number', description: 'Post whose dynamic tags to resolve (path param)' },
3868
+ target_id: { type: 'number', description: 'Write resolved data here. Omit = in place (= source).' },
3869
+ 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.' },
3870
+ strip_bindings: { type: 'boolean', description: 'Remove __dynamic__ entries after inlining. Default true.' },
3871
+ dry_run: { type: 'boolean', description: 'Default true. Set false to write.' },
3872
+ skip_tags: { type: 'array', items: { type: 'string' }, description: 'Extra tag names to leave untouched (popup always skipped).' },
3873
+ skip_widgets: { type: 'array', items: { type: 'string' }, description: 'Extra widget types to leave untouched (jet-form-builder-form always skipped).' }
3874
+ },
3875
+ required: ['source_id']
3876
+ }
3877
+ },
3721
3878
  {
3722
3879
  name: 'create_template',
3723
3880
  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.',
@@ -5223,6 +5380,42 @@ async function handleToolCall(name, args) {
5223
5380
  return ok(out);
5224
5381
  }
5225
5382
 
5383
+ case 'list_users': {
5384
+ const params = new URLSearchParams();
5385
+ if (args.role) params.set('role', args.role);
5386
+ if (args.search) params.set('search', args.search);
5387
+ if (args.has_posts) params.set('has_posts', '1');
5388
+ if (args.per_page) params.set('per_page', args.per_page);
5389
+ const qs = params.toString() ? `?${params.toString()}` : '';
5390
+ const r = await apiCall(`/users${qs}`);
5391
+ if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
5392
+ let out = `${r.total} users\n${'─'.repeat(50)}\n`;
5393
+ r.users.forEach(u => {
5394
+ out += `[${u.id}] ${u.display_name} (${u.login})\n`;
5395
+ out += ` Roles: ${u.roles.join(', ') || 'none'} | Posts: ${u.post_count} | ${u.email}\n`;
5396
+ });
5397
+ return ok(out);
5398
+ }
5399
+
5400
+ case 'set_post_author': {
5401
+ const r = await apiCall('/posts/set-author', 'POST', { post_ids: args.post_ids, author: args.author });
5402
+ if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
5403
+ let out = `Set author → ${r.author} (#${r.author_id})\nUpdated ${r.count}: ${r.updated.join(', ')}`;
5404
+ if (r.skipped.length) out += `\nSkipped ${r.skipped.length}: ${r.skipped.map(s => `${s.id} (${s.reason})`).join(', ')}`;
5405
+ return ok(out);
5406
+ }
5407
+
5408
+ case 'reassign_author': {
5409
+ const body = { from: args.from, to: args.to };
5410
+ if (args.post_type) body.post_type = args.post_type;
5411
+ if (args.status) body.status = args.status;
5412
+ if (args.dry_run) body.dry_run = true;
5413
+ const r = await apiCall('/users/reassign-author', 'POST', body);
5414
+ if (!r.success) return ok(`Failed: ${r.message || 'Unknown error'}`);
5415
+ 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(', ')}`);
5416
+ return ok(`Reassigned ${r.count} posts: ${r.from} (#${r.from_id}) → ${r.to} (#${r.to_id})`);
5417
+ }
5418
+
5226
5419
  case 'get_post': {
5227
5420
  const r = await apiCall(`/posts/${args.post_id}`);
5228
5421
  if (!r.success) return ok(`Failed: ${r.message || 'Not found'}`);
@@ -5255,7 +5448,7 @@ async function handleToolCall(name, args) {
5255
5448
  case 'create_post': {
5256
5449
  const body = { title: args.title };
5257
5450
  const passthrough = ['post_type', 'content', 'excerpt', 'status', 'slug',
5258
- 'parent', 'parent_slug', 'taxonomies', 'featured_image_id',
5451
+ 'author', 'parent', 'parent_slug', 'taxonomies', 'featured_image_id',
5259
5452
  'featured_image_basename', 'elementor', 'seo', 'dedupe_by_title',
5260
5453
  'date', 'date_gmt'];
5261
5454
  for (const k of passthrough) if (args[k] !== undefined) body[k] = args[k];
@@ -5879,17 +6072,60 @@ async function handleToolCall(name, args) {
5879
6072
  excerpt: { widgetType: 'text-editor', control: 'editor', tag: 'post-excerpt', base: { editor: '' } },
5880
6073
  content: { widgetType: 'text-editor', control: 'editor', tag: 'post-content', base: { editor: '' } },
5881
6074
  featured_image: { widgetType: 'image', control: 'image', tag: 'post-featured-image', base: { image: { url: '', id: '' } } },
5882
- acf: { widgetType: 'heading', control: 'title', tag: 'acf', base: { title: '', header_size: 'h2' } },
5883
6075
  };
6076
+
6077
+ // ── ACF: native Elementor Pro ACF dynamic tag ──────────────────────────
6078
+ // Elementor's ACF tags are typed: acf-text / acf-number / acf-image / etc.
6079
+ // (NOT a generic "acf" tag). The value resolves from settings.key, which
6080
+ // Elementor splits on ":" — `Dynamic_Value_Provider::get_value()`:
6081
+ // key = "options:<field_name>" → get_field_object(<field_name>, 'options') [site-wide]
6082
+ // key = "<field_key>:<field_name>" → resolves against the current/queried post
6083
+ // key = "<field_name>" (bare) → resolves by name against the queried post
6084
+ // So options-context binding is simply the "options:" prefix.
6085
+ if (args.type === 'acf') {
6086
+ if (!args.field) return ok('Failed: type=acf requires a `field` (the ACF field name).');
6087
+ const ACF_TAGS = {
6088
+ text: { tag: 'acf-text', widgetType: 'heading', control: 'title', base: { title: '', header_size: 'h2' } },
6089
+ number: { tag: 'acf-number', widgetType: 'heading', control: 'title', base: { title: '', header_size: 'h2' } },
6090
+ url: { tag: 'acf-url', widgetType: 'text-editor', control: 'editor', base: { editor: '' } },
6091
+ image: { tag: 'acf-image', widgetType: 'image', control: 'image', base: { image: { url: '', id: '' } } },
6092
+ };
6093
+ const ft = (args.field_type || 'text').toLowerCase();
6094
+ const a = ACF_TAGS[ft];
6095
+ if (!a) return ok(`Failed: unknown field_type "${ft}". Use one of: ${Object.keys(ACF_TAGS).join(', ')}.`);
6096
+
6097
+ const source = (args.source || 'post').toLowerCase();
6098
+ let key;
6099
+ if (source === 'options') key = `options:${args.field}`;
6100
+ else if (args.field_key) key = `${args.field_key}:${args.field}`;
6101
+ else key = args.field; // bare name → resolves against the queried post
6102
+
6103
+ const tagId = Array.from({ length: 7 }, () => 'abcdef0123456789'[Math.floor(Math.random() * 16)]).join('');
6104
+ const enc = encodeURIComponent(JSON.stringify({ key }));
6105
+ const dynamicValue = `[elementor-tag id="${tagId}" name="${a.tag}" settings="${enc}"]`;
6106
+
6107
+ const settings = Object.assign({}, a.base, args.settings || {}, {
6108
+ __dynamic__: Object.assign({}, (args.settings && args.settings.__dynamic__) || {}, { [a.control]: dynamicValue }),
6109
+ });
6110
+ const element = { elType: 'widget', widgetType: a.widgetType, settings };
6111
+
6112
+ const body = { parent_id: args.parent_id, element };
6113
+ if (args.position !== undefined) body.position = args.position;
6114
+ if (args.force) body.force = true;
6115
+ const r = await apiCall(`/pages/${args.page_id}/add-element`, 'POST', body);
6116
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
6117
+ const ctx = source === 'options'
6118
+ ? 'It resolves against the ACF OPTIONS context, so it renders the same site-wide value on EVERY page.'
6119
+ : 'It renders the current/queried post\'s ACF value (loop/single context).';
6120
+ return ok(`Dynamic ACF field added!\nField: ${args.field} (${ft}) | source: ${source}\nTag: ${a.tag} → ${a.widgetType} widget | key: "${key}"\nElement ID: ${r.element_id} (parent ${r.parent_id})\n${ctx}`);
6121
+ }
6122
+
5884
6123
  const m = MAP[args.type];
5885
- if (!m) return ok(`Failed: unknown type "${args.type}". Use one of: ${Object.keys(MAP).join(', ')}.`);
5886
- if (args.type === 'acf' && !args.field) return ok('Failed: type=acf requires a `field` (the ACF field key/name).');
6124
+ if (!m) return ok(`Failed: unknown type "${args.type}". Use one of: ${Object.keys(MAP).join(', ')}, acf.`);
5887
6125
 
5888
6126
  // Elementor dynamic-tag shortcode: [elementor-tag id="<7-char>" name="<tag>" settings="<urlencoded-json>"]
5889
6127
  const tagId = Array.from({ length: 7 }, () => 'abcdef0123456789'[Math.floor(Math.random() * 16)]).join('');
5890
- const tagSettings = {};
5891
- if (args.type === 'acf') tagSettings.key = args.field;
5892
- const enc = encodeURIComponent(JSON.stringify(tagSettings));
6128
+ const enc = encodeURIComponent(JSON.stringify({}));
5893
6129
  const dynamicValue = `[elementor-tag id="${tagId}" name="${args.tag || m.tag}" settings="${enc}"]`;
5894
6130
 
5895
6131
  const settings = Object.assign({}, m.base, args.settings || {}, {
@@ -5902,7 +6138,7 @@ async function handleToolCall(name, args) {
5902
6138
  if (args.force) body.force = true;
5903
6139
  const r = await apiCall(`/pages/${args.page_id}/add-element`, 'POST', body);
5904
6140
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
5905
- return ok(`Dynamic field added!\nType: ${args.type} → ${m.widgetType} widget bound to "${args.tag || m.tag}"${args.field ? ` (field: ${args.field})` : ''}\nElement ID: ${r.element_id} (parent ${r.parent_id})\nIt renders the current post's data in loop/single context.`);
6141
+ return ok(`Dynamic field added!\nType: ${args.type} → ${m.widgetType} widget bound to "${args.tag || m.tag}"\nElement ID: ${r.element_id} (parent ${r.parent_id})\nIt renders the current post's data in loop/single context.`);
5906
6142
  }
5907
6143
 
5908
6144
  case 'get_element': {
@@ -6974,6 +7210,8 @@ async function handleToolCall(name, args) {
6974
7210
  r.field_groups.forEach(g => {
6975
7211
  out += `\n[${g.key}] ${g.title} ${g.active ? '' : '(INACTIVE)'}\n`;
6976
7212
  if (g.post_types?.length) out += ` Post types: ${g.post_types.join(', ')}\n`;
7213
+ if (g.option_pages?.length) out += ` Options pages: ${g.option_pages.join(', ')}\n`;
7214
+ if (!g.post_types?.length && !g.option_pages?.length) out += ` Location: (none — orphaned)\n`;
6977
7215
  if (g.fields?.length) {
6978
7216
  g.fields.forEach(f => {
6979
7217
  out += ` • ${f.name} (${f.type}) — "${f.label}"${f.required ? ' *required' : ''}\n`;
@@ -6990,9 +7228,9 @@ async function handleToolCall(name, args) {
6990
7228
  }
6991
7229
 
6992
7230
  case 'get_acf_fields': {
6993
- const r = await apiCall(`/acf-fields?post_id=${args.post_id}`);
7231
+ const r = await apiCall(`/acf-fields?post_id=${encodeURIComponent(args.post_id)}`);
6994
7232
  if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
6995
- let out = `=== ACF FIELDS: ${r.post_title} (ID:${r.post_id}) ===\n`;
7233
+ let out = `=== ACF FIELDS: ${r.post_title} (${r.is_options ? 'options' : 'ID:' + r.post_id}) ===\n`;
6996
7234
  if (!r.fields?.length) {
6997
7235
  out += 'No ACF fields found for this post.\n';
6998
7236
  } else {
@@ -7021,6 +7259,39 @@ async function handleToolCall(name, args) {
7021
7259
  return ok(out);
7022
7260
  }
7023
7261
 
7262
+ case 'get_post_meta': {
7263
+ let ep = `/post-meta?post_id=${args.post_id}`;
7264
+ if (Array.isArray(args.keys) && args.keys.length) {
7265
+ ep += `&keys=${encodeURIComponent(args.keys.join(','))}`;
7266
+ }
7267
+ const r = await apiCall(ep);
7268
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7269
+ let out = `=== POST META: ${r.post_title} (ID:${r.post_id}) ===\n`;
7270
+ const keys = Object.keys(r.meta || {});
7271
+ if (!keys.length) {
7272
+ out += 'No meta found.\n';
7273
+ } else {
7274
+ keys.forEach(k => {
7275
+ const m = r.meta[k];
7276
+ const val = typeof m.value === 'object' ? JSON.stringify(m.value) : (m.value === '' ? '(empty)' : m.value);
7277
+ out += `\n ${k}${m.count > 1 ? ` [×${m.count}]` : ''}: ${val}\n`;
7278
+ });
7279
+ }
7280
+ if (r.missing && r.missing.length) out += `\nMISSING (not present): ${r.missing.join(', ')}\n`;
7281
+ return ok(out);
7282
+ }
7283
+
7284
+ case 'update_post_meta': {
7285
+ const body = { post_id: args.post_id, meta: args.meta };
7286
+ if (typeof args.dry_run === 'boolean') body.dry_run = args.dry_run;
7287
+ const r = await apiCall('/post-meta', 'POST', body);
7288
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7289
+ let out = `${r.dry_run ? 'DRY RUN' : 'WROTE'} — ${r.updated} key(s) on "${r.post_title}" (ID:${r.post_id})\n`;
7290
+ (r.results || []).forEach(x => { out += ` ${x.key}: ${x.status}\n`; });
7291
+ out += `\n${r.note}\n`;
7292
+ return ok(out);
7293
+ }
7294
+
7024
7295
  case 'create_acf_field_group': {
7025
7296
  const body = { title: args.title, fields: args.fields };
7026
7297
  if (args.post_types) body.post_types = args.post_types;
@@ -7057,6 +7328,52 @@ async function handleToolCall(name, args) {
7057
7328
  return ok(msg);
7058
7329
  }
7059
7330
 
7331
+ case 'list_acf_option_pages': {
7332
+ const r = await apiCall('/acf-option-pages');
7333
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7334
+ let out = `=== ACF OPTIONS PAGES (${r.count}) ===\n`;
7335
+ if (!r.option_pages?.length) {
7336
+ out += 'No ACF options pages registered.\n';
7337
+ } else {
7338
+ r.option_pages.forEach(p => {
7339
+ out += `\n• ${p.page_title}\n menu_slug: ${p.menu_slug}\n post_id: ${p.post_id}\n`;
7340
+ if (p.parent_slug) out += ` parent: ${p.parent_slug}\n`;
7341
+ });
7342
+ }
7343
+ if (r.note) out += `\n${r.note}\n`;
7344
+ return ok(out);
7345
+ }
7346
+
7347
+ case 'register_acf_option_page': {
7348
+ const body = { page_title: args.page_title };
7349
+ if (args.menu_slug) body.menu_slug = args.menu_slug;
7350
+ if (args.parent_slug) body.parent_slug = args.parent_slug;
7351
+ const r = await apiCall('/acf-option-pages', 'POST', body);
7352
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7353
+ let out = r.created ? `=== ACF OPTIONS PAGE REGISTERED ===\n` : `=== ACF OPTIONS PAGE (already existed) ===\n`;
7354
+ out += `Title: ${r.page_title}\nmenu_slug: ${r.menu_slug}\npost_id: ${r.post_id}\n`;
7355
+ if (r.note) out += `\n${r.note}\n`;
7356
+ return ok(out);
7357
+ }
7358
+
7359
+ case 'update_acf_field_group': {
7360
+ const body = { group_key: args.group_key };
7361
+ if (args.title !== undefined) body.title = args.title;
7362
+ if (args.active !== undefined) body.active = args.active;
7363
+ if (args.options_page !== undefined) body.options_page = args.options_page;
7364
+ if (args.post_types !== undefined) body.post_types = args.post_types;
7365
+ if (args.location !== undefined) body.location = args.location;
7366
+ const r = await apiCall('/acf-field-group', 'PATCH', body);
7367
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7368
+ let out = `=== ACF FIELD GROUP UPDATED ===\n`;
7369
+ out += `[${r.key}] ${r.title}${r.active ? '' : ' (INACTIVE)'}\n`;
7370
+ out += `Changed: ${(r.changed || []).join(', ')}\n`;
7371
+ if (r.post_types?.length) out += `Post types: ${r.post_types.join(', ')}\n`;
7372
+ if (r.option_pages?.length) out += `Options pages: ${r.option_pages.join(', ')}\n`;
7373
+ if (r.note) out += `\n${r.note}\n`;
7374
+ return ok(out);
7375
+ }
7376
+
7060
7377
  case 'audit_orphan_pages': {
7061
7378
  const r = await apiCall('/audit-orphan-pages');
7062
7379
  let msg = `=== ORPHAN PAGES ===\nTotal pages: ${r.total_pages} | Linked: ${r.linked_pages} | Orphans: ${r.orphan_count}\n`;
@@ -7155,6 +7472,42 @@ async function handleToolCall(name, args) {
7155
7472
  return ok(msg);
7156
7473
  }
7157
7474
 
7475
+ case 'duplicate_post': {
7476
+ if (!args.source_id) return ok('Failed: source_id is required.');
7477
+ const body = { source_id: args.source_id };
7478
+ if (args.new_title) body.new_title = args.new_title;
7479
+ if (args.new_slug) body.new_slug = args.new_slug;
7480
+ if (args.status) body.status = args.status;
7481
+ if (args.target_post_type) body.target_post_type = args.target_post_type;
7482
+ if (args.include) body.include = args.include;
7483
+ const r = await apiCall('/posts/duplicate', 'POST', body);
7484
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7485
+ let out = `Duplicated post ${r.source_id} (${r.source_type}) → ${r.new_id} (${r.new_post_type}, ${r.status})\n`;
7486
+ out += `Sections: ${r.sections} | Meta copied: ${r.meta_copied.length}\n`;
7487
+ out += `Preview: ${r.preview_url}\nEdit: ${r.edit_url}\n`;
7488
+ return ok(out);
7489
+ }
7490
+
7491
+ case 'flatten_dynamic_tags': {
7492
+ if (!args.source_id) return ok('Failed: source_id is required.');
7493
+ const body = {};
7494
+ if (args.target_id) body.target_id = args.target_id;
7495
+ if (args.meta_context_id) body.meta_context_id = args.meta_context_id;
7496
+ if (typeof args.strip_bindings === 'boolean') body.strip_bindings = args.strip_bindings;
7497
+ if (typeof args.dry_run === 'boolean') body.dry_run = args.dry_run;
7498
+ if (Array.isArray(args.skip_tags)) body.skip_tags = args.skip_tags;
7499
+ if (Array.isArray(args.skip_widgets)) body.skip_widgets = args.skip_widgets;
7500
+ const r = await apiCall(`/pages/${args.source_id}/flatten-dynamic-tags`, 'POST', body);
7501
+ if (r.code || r.error) return ok(`Failed: ${r.message || r.error}`);
7502
+ 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`;
7503
+ out += `Skipped tags: ${r.skipped_tags.join(', ')} | Skipped widgets: ${r.skipped_widgets.join(', ')}\n\n`;
7504
+ (r.report || []).forEach(x => {
7505
+ out += ` [${x.action}] ${x.id || ''} ${x.field ? `.${x.field}` : ''} ${x.tag ? `(${x.tag})` : ''}${x.preview ? ` → ${x.preview}` : ''}\n`;
7506
+ });
7507
+ out += `\n${r.note}\n`;
7508
+ return ok(out);
7509
+ }
7510
+
7158
7511
  case 'create_template': {
7159
7512
  if (!args.type || !args.title) {
7160
7513
  return ok('Failed: type and title are required.');
@@ -8269,6 +8622,21 @@ async function handleToolCall(name, args) {
8269
8622
  return ok(msg);
8270
8623
  }
8271
8624
 
8625
+ case 'search_tools': {
8626
+ const q = (args.query || '').toLowerCase().trim();
8627
+ const all = getToolDefinitions();
8628
+ const matches = q
8629
+ ? all.filter(t => t.name.includes(q) || t.description.toLowerCase().includes(q))
8630
+ : all;
8631
+ if (!matches.length) return ok(`No tools match "${args.query}". Try a broader term.`);
8632
+ const lines = matches.map(t => {
8633
+ const firstSentence = t.description.split(/[.\n]/)[0].trim();
8634
+ return `${t.name} — ${firstSentence}`;
8635
+ });
8636
+ const header = q ? `${matches.length} tool(s) matched "${args.query}"` : `${matches.length} tools available`;
8637
+ return ok(`${header}\n\n${lines.join('\n')}`);
8638
+ }
8639
+
8272
8640
  default:
8273
8641
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
8274
8642
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noleemits/vision-builder-control-mcp",
3
- "version": "4.114.1",
3
+ "version": "4.120.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",