@noleemits/vision-builder-control-mcp 4.45.2 → 4.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +252 -26
- package/package.json +2 -1
- package/parent-watchdog.js +163 -0
package/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Noleemits Vision Builder Control MCP Server
|
|
4
4
|
*
|
|
5
|
-
* Provides
|
|
5
|
+
* Provides 71 tools for building and managing WordPress/Elementor sites.
|
|
6
|
+
* v4.46.0: Saxon-feedback batch — capability_check tool (probe site plugin version + registered routes); replace_widget_content tool (full-setting replacement scoped by widget type, with exclude_match for idempotency — kills the "swap form HTML on 32 pages" use case in one call); list_elements flat-filter mode (widget_type / container_title / settings_contains — no more 5–15k-token tree dumps for ID discovery); find_replace now scans html + shortcode widget content; replace_text + audit_text auto-scope (post_type=any + no limit defaults to 200/store instead of 1000 to bound runtime); check_page_lock surfaces elementor_data_size + recently-expired _edit_lock to diagnose "hang despite unlocked" (slow save_post cascade, not lock); set_time_limit(300) on bulk ops + remove_section; clearer 404 errors that point at capability_check; list_posts accepts `limit` as alias for `per_page`.
|
|
6
7
|
* v4.45.0: patch_kit_global_css — surgical CSS operations (remove_rule, remove_selector_from_rules, find_replace) without full-payload round-trip; solves 30k-token MCP param-size barrier for large kits.
|
|
7
8
|
* v4.42.0: audit_text + replace_text — unified search/replace across Elementor + post columns with literal/regex/fuzzy modes. Fuzzy mode tolerates NBSP, curly quotes/dashes, ideographic punctuation — fixes the "copy-pasted needle won't match" bug.
|
|
8
9
|
* v4.35.0: PixelVault image integration — pixelvault_status, find_images, generate_image, get_batch_status, insert_image tools; proxy REST endpoints at /nvbc/v1/pixelvault/*.
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
|
|
51
52
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
52
53
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
54
|
+
import { startParentWatchdog } from "./parent-watchdog.js";
|
|
53
55
|
import {
|
|
54
56
|
CallToolRequestSchema,
|
|
55
57
|
ListToolsRequestSchema,
|
|
@@ -109,7 +111,7 @@ process.on('SIGINT', () => {
|
|
|
109
111
|
// CONFIG
|
|
110
112
|
// ================================================================
|
|
111
113
|
|
|
112
|
-
const VERSION = '4.
|
|
114
|
+
const VERSION = '4.46.1';
|
|
113
115
|
const MIN_PLUGIN_VERSION = '4.13.0'; // Minimum WP plugin version required by this MCP server
|
|
114
116
|
|
|
115
117
|
// ================================================================
|
|
@@ -1741,12 +1743,28 @@ async function apiCall(endpoint, method = 'GET', body = null) {
|
|
|
1741
1743
|
if (body) options.body = JSON.stringify(body);
|
|
1742
1744
|
|
|
1743
1745
|
const response = await fetch(url, options);
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
+
let data;
|
|
1747
|
+
try { data = await response.json(); } catch (_e) { data = {}; }
|
|
1748
|
+
if (!response.ok) {
|
|
1749
|
+
// v4.46.0+: clearer error when the backend doesn't have this route registered
|
|
1750
|
+
// (almost always means the site's NVBC plugin is older than the MCP server expects).
|
|
1751
|
+
if (response.status === 404 && (data.code === 'rest_no_route' || /No route was found/i.test(data.message || ''))) {
|
|
1752
|
+
const err = new Error(
|
|
1753
|
+
`Backend route missing on this site: ${method} ${endpoint}. ` +
|
|
1754
|
+
`The connected WordPress site is running an older version of noleemits-vision-builder-control ` +
|
|
1755
|
+
`that doesn't expose this endpoint. Run the capability_check tool to see the site's plugin version ` +
|
|
1756
|
+
`+ registered routes, then update the site plugin if needed.`
|
|
1757
|
+
);
|
|
1758
|
+
err.code = 'rest_no_route';
|
|
1759
|
+
throw err;
|
|
1760
|
+
}
|
|
1761
|
+
throw new Error(data.message || `HTTP ${response.status}`);
|
|
1762
|
+
}
|
|
1746
1763
|
return data;
|
|
1747
1764
|
} catch (err) {
|
|
1748
1765
|
if (err.name === 'AbortError') {
|
|
1749
|
-
throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS / 1000}s: ${method} ${endpoint}`
|
|
1766
|
+
throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS / 1000}s: ${method} ${endpoint}. ` +
|
|
1767
|
+
`For bulk tools, narrow scope with post_type / limit / chunk_size and retry.`);
|
|
1750
1768
|
}
|
|
1751
1769
|
throw err;
|
|
1752
1770
|
} finally {
|
|
@@ -1879,6 +1897,11 @@ function getToolDefinitions() {
|
|
|
1879
1897
|
description: 'Check connection to Vision Builder Control WordPress plugin',
|
|
1880
1898
|
inputSchema: { type: 'object', properties: {} }
|
|
1881
1899
|
},
|
|
1900
|
+
{
|
|
1901
|
+
name: 'capability_check',
|
|
1902
|
+
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+.',
|
|
1903
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1904
|
+
},
|
|
1882
1905
|
{
|
|
1883
1906
|
name: 'get_design_tokens',
|
|
1884
1907
|
description: 'Get all design tokens (colors, typography, spacing, URLs, buttons, globals_map). Tokens are cached for 5 minutes.',
|
|
@@ -2091,13 +2114,16 @@ function getToolDefinitions() {
|
|
|
2091
2114
|
},
|
|
2092
2115
|
{
|
|
2093
2116
|
name: 'list_elements',
|
|
2094
|
-
description: 'List the element tree inside a page or specific section. Shows IDs, types, widget names, and text previews for every container and widget. Use to find element IDs before remove_element.',
|
|
2117
|
+
description: 'List the element tree inside a page or specific section. Shows IDs, types, widget names, and text previews for every container and widget. Use to find element IDs before remove_element. v4.46.0+: pass any of widget_type / container_title / settings_contains to switch to FLAT-FILTER MODE — returns only matching widget stubs ({id, widget, path, parent_id, label}) instead of the full tree. Strongly recommended for ID lookup on large pages to avoid 5–15K-token tree dumps.',
|
|
2095
2118
|
inputSchema: {
|
|
2096
2119
|
type: 'object',
|
|
2097
2120
|
properties: {
|
|
2098
2121
|
page_id: { type: 'number', description: 'WordPress page ID' },
|
|
2099
|
-
section_index: { type: 'number', description: 'Only show this section (0-based). Omit to show all sections.' },
|
|
2100
|
-
depth: { type: 'number', description: 'Max nesting depth (default: 10). When exceeded, returns children_ids stubs with {id, type, widget}.' }
|
|
2122
|
+
section_index: { type: 'number', description: 'Only show this section (0-based). Omit to show all sections. Ignored in flat-filter mode.' },
|
|
2123
|
+
depth: { type: 'number', description: 'Max nesting depth (default: 10). When exceeded, returns children_ids stubs with {id, type, widget}. Ignored in flat-filter mode.' },
|
|
2124
|
+
widget_type: { type: 'string', description: 'FLAT-FILTER. Match elements with this widgetType (e.g. "html", "heading", "button", "loop-grid"). Case-insensitive. v4.46.0+.' },
|
|
2125
|
+
container_title: { type: 'string', description: 'FLAT-FILTER. Match elements whose own or ancestor section/container settings._title contains this substring (case-insensitive). E.g. "SF contact form" finds widgets inside any container labeled "SF contact form". v4.46.0+.' },
|
|
2126
|
+
settings_contains: { type: 'string', description: 'FLAT-FILTER. Match elements whose serialized settings JSON contains this substring (case-insensitive). Use to find widgets by content predicate, e.g. settings_contains:"webto.salesforce.com". v4.46.0+.' }
|
|
2101
2127
|
},
|
|
2102
2128
|
required: ['page_id']
|
|
2103
2129
|
}
|
|
@@ -2133,7 +2159,7 @@ function getToolDefinitions() {
|
|
|
2133
2159
|
},
|
|
2134
2160
|
{
|
|
2135
2161
|
name: 'check_page_lock',
|
|
2136
|
-
description: 'Check if a page is being edited
|
|
2162
|
+
description: 'Check if a page is being edited. Reports the same two locks that write operations (remove_section, update_element, etc.) actually enforce: WP _edit_lock postmeta within its 150s active window + nvbc_mcp_lock transient. v4.46.0+: also returns elementor_data_size_bytes — a multi-MB tree is the #1 cause of "hangs" that look like lock contention but are actually slow save_post / Elementor CSS regeneration after the write commits. If this tool reports unlocked but a write op still appears to stall, the cause is downstream of the lock check (post-save hook chain on a big page), not the lock itself — force=true on the write op will not help with that.',
|
|
2137
2163
|
inputSchema: {
|
|
2138
2164
|
type: 'object',
|
|
2139
2165
|
properties: {
|
|
@@ -2204,7 +2230,7 @@ function getToolDefinitions() {
|
|
|
2204
2230
|
},
|
|
2205
2231
|
{
|
|
2206
2232
|
name: 'update_element',
|
|
2207
|
-
description: 'Update any element\'s settings by its Elementor ID or path. Patch widget properties like hover_color, background_color, __globals__ overrides, text, link URLs, etc. Supports dot-notation for nested keys (e.g. "__globals__.hover_color"). Supports element_path as alternative to element_id (e.g. "sectionId/1/0" = section → child 1 → child 0). Use list_elements or export_page first to find the element ID and current settings. IMPORTANT: Prefer settings_json (JSON string) over settings (object) for reliable MCP transport. AUTO-INJECTED KEYS: background_background:"classic" auto-set when you set background_color; typography_typography:"custom" auto-set when you set font properties; flex_grow expands to all 3 required keys; grid column strings auto-wrapped in object format. ICON WIDGET: For view="default" use "icon_size" for dimensions. For view="stacked" or "framed" (circle/square background) use "size" instead — "icon_size" is silently ignored in stacked/framed mode. LOOP-GRID WIDGET: query settings use the prefix "post_query_" (post_query_post_type, post_query_orderby, post_query_order, post_query_posts_per_page) — NOT "posts_post_type". Setting "posts_post_type" is silently accepted but ignored. RACE CONDITION: when patching multiple elements on the SAME page in parallel, use batch_update_elements instead — parallel update_element calls can clobber each other. ATOMIC SCHEMA VALIDATION (v4.42.5+): writes that violate the Elementor V4 atomic schema are rejected up front (HTTP 400 invalid_atomic_setting) instead of silently corrupting the page. Currently enforced: e-heading.tag ∈ {h1..h6}, e-paragraph.tag ∈ {p, span}. If you need a non-heading visual element styled like an eyebrow, use e-paragraph (tag=p or span) — not e-heading with tag=div. NATIVE GRID/FLEX TIP: for container layout (grid columns, flex direction, gap), prefer set_container_layout — it produces WYSIWYG-editable native settings instead of forcing you to assemble the raw Elementor schema.',
|
|
2233
|
+
description: 'Update any element\'s settings by its Elementor ID or path. Patch widget properties like hover_color, background_color, __globals__ overrides, text, link URLs, etc. Supports dot-notation for nested keys (e.g. "__globals__.hover_color"). Supports element_path as alternative to element_id (e.g. "sectionId/1/0" = section → child 1 → child 0). Use list_elements or export_page first to find the element ID and current settings. IMPORTANT: Prefer settings_json (JSON string) over settings (object) for reliable MCP transport. AUTO-INJECTED KEYS: background_background:"classic" auto-set when you set background_color; typography_typography:"custom" auto-set when you set font properties; flex_grow expands to all 3 required keys; grid column strings auto-wrapped in object format. ICON WIDGET: For view="default" use "icon_size" for dimensions. For view="stacked" or "framed" (circle/square background) use "size" instead — "icon_size" is silently ignored in stacked/framed mode. LOOP-GRID WIDGET: query settings use the prefix "post_query_" (post_query_post_type, post_query_orderby, post_query_order, post_query_posts_per_page) — NOT "posts_post_type". Setting "posts_post_type" is silently accepted but ignored. LOOP-CAROUSEL WIDGET: also uses "post_query_" prefix — NOT the older Elementor "query_" prefix. Both loop-grid and loop-carousel share the same query-control prefix; if you used "query_post_type" the write is silently accepted and ignored. Pull current settings via get_element first to confirm the prefix before patching. RACE CONDITION: when patching multiple elements on the SAME page in parallel, use batch_update_elements instead — parallel update_element calls can clobber each other. ATOMIC SCHEMA VALIDATION (v4.42.5+): writes that violate the Elementor V4 atomic schema are rejected up front (HTTP 400 invalid_atomic_setting) instead of silently corrupting the page. Currently enforced: e-heading.tag ∈ {h1..h6}, e-paragraph.tag ∈ {p, span}. If you need a non-heading visual element styled like an eyebrow, use e-paragraph (tag=p or span) — not e-heading with tag=div. NATIVE GRID/FLEX TIP: for container layout (grid columns, flex direction, gap), prefer set_container_layout — it produces WYSIWYG-editable native settings instead of forcing you to assemble the raw Elementor schema.',
|
|
2208
2234
|
inputSchema: {
|
|
2209
2235
|
type: 'object',
|
|
2210
2236
|
properties: {
|
|
@@ -2298,7 +2324,7 @@ function getToolDefinitions() {
|
|
|
2298
2324
|
},
|
|
2299
2325
|
{
|
|
2300
2326
|
name: 'find_replace',
|
|
2301
|
-
description: 'Global find & replace across all Elementor pages. Searches heading titles, text-editor HTML, button text, image-box titles/descriptions, icon-box titles/descriptions, icon-list items, toggle titles/content, and image alt text. Supports literal text or regex. Never changes post_modified date (Elementor meta only). Always preview with dry_run=true first.',
|
|
2327
|
+
description: 'Global find & replace across all Elementor pages. Searches heading titles, text-editor HTML, button text, html widget content (v4.46.0+), shortcode widget content (v4.46.0+), image-box titles/descriptions, icon-box titles/descriptions, icon-list items, toggle titles/content, and image alt text. Also rewrites URL fields (button_link, image.url, icon-list link.url, image.alt) and href attributes inside text-editor HTML. Supports literal text or regex. Never changes post_modified date (Elementor meta only). Always preview with dry_run=true first.',
|
|
2302
2328
|
inputSchema: {
|
|
2303
2329
|
type: 'object',
|
|
2304
2330
|
properties: {
|
|
@@ -2367,7 +2393,8 @@ function getToolDefinitions() {
|
|
|
2367
2393
|
category: { type: 'string', description: 'Filter by category: ID (number) or slug (string). Only applies to post types with categories.' },
|
|
2368
2394
|
date_after: { type: 'string', description: 'Filter posts after this date (YYYY-MM-DD)' },
|
|
2369
2395
|
date_before: { type: 'string', description: 'Filter posts before this date (YYYY-MM-DD)' },
|
|
2370
|
-
per_page: { type: 'number', description: 'Results per page, max 100 (default: 50)' },
|
|
2396
|
+
per_page: { type: 'number', description: 'Results per page, max 100 (default: 50). `limit` is accepted as an alias (v4.46.0+).' },
|
|
2397
|
+
limit: { type: 'number', description: 'Alias for per_page (v4.46.0+).' },
|
|
2371
2398
|
page: { type: 'number', description: 'Page number (default: 1)' }
|
|
2372
2399
|
}
|
|
2373
2400
|
}
|
|
@@ -3142,16 +3169,16 @@ function getToolDefinitions() {
|
|
|
3142
3169
|
mode: { type: 'string', enum: ['literal', 'regex', 'fuzzy'], description: '"literal" (exact bytes), "regex" (PHP regex without delimiters, u flag forced), or "fuzzy" (tolerates NBSP, curly quotes/dashes/periods, ideographic whitespace). Default: literal.' },
|
|
3143
3170
|
case_insensitive: { type: 'boolean', description: 'Ignore case. Default: false.' },
|
|
3144
3171
|
stores: { type: 'array', items: { type: 'string', enum: ['elementor', 'post_content', 'post_title', 'post_excerpt'] }, description: 'Which stores to scan. Default: all four. Pass [\"elementor\"] to limit scope.' },
|
|
3145
|
-
post_type: { type: 'string', description: '"post", "page", "any", or a specific CPT slug. Default: any.' },
|
|
3172
|
+
post_type: { type: 'string', description: '"post", "page", "any", or a specific CPT slug. Default: any. Note: with post_type=any + no explicit limit, the per-store cap is 200 (v4.46.0+); pass limit explicitly for broader sweeps.' },
|
|
3146
3173
|
include_drafts: { type: 'boolean', description: 'Include draft/private/pending posts. Default: true.' },
|
|
3147
|
-
limit: { type: 'number', description: 'Max posts to scan per store
|
|
3174
|
+
limit: { type: 'number', description: 'Max posts to scan per store. Default 1000 for narrow post_type, 200 when post_type=any. Max 5000.' }
|
|
3148
3175
|
},
|
|
3149
3176
|
required: ['search']
|
|
3150
3177
|
}
|
|
3151
3178
|
},
|
|
3152
3179
|
{
|
|
3153
3180
|
name: 'replace_text',
|
|
3154
|
-
description: 'Unified bulk find/replace across Elementor (entire settings tree), post_content, post_title, and post_excerpt. Same matching modes as audit_text — fuzzy mode is the fix for the "copy-pasted needle won\'t match" class of bug. ALWAYS dry_run=true first. Never changes post_modified date.',
|
|
3181
|
+
description: 'Unified bulk find/replace across Elementor (entire settings tree — including html widget content, text-editor HTML, headings, button text, every nested setting), post_content, post_title, and post_excerpt. Same matching modes as audit_text — fuzzy mode is the fix for the "copy-pasted needle won\'t match" class of bug. ALWAYS dry_run=true first. Never changes post_modified date. v4.46.0+: when post_type=any and limit is omitted, per-store cap drops to 200 (was 1000) — pass an explicit `limit` to scan more. If this tool returns 404/"Backend route missing", run capability_check to see if the site\'s plugin is older than v4.42.0.',
|
|
3155
3182
|
inputSchema: {
|
|
3156
3183
|
type: 'object',
|
|
3157
3184
|
properties: {
|
|
@@ -3160,14 +3187,34 @@ function getToolDefinitions() {
|
|
|
3160
3187
|
mode: { type: 'string', enum: ['literal', 'regex', 'fuzzy'], description: '"literal", "regex", or "fuzzy". Default: literal.' },
|
|
3161
3188
|
case_insensitive: { type: 'boolean', description: 'Ignore case. Default: false.' },
|
|
3162
3189
|
stores: { type: 'array', items: { type: 'string', enum: ['elementor', 'post_content', 'post_title', 'post_excerpt'] }, description: 'Stores to operate on. Default: all four.' },
|
|
3163
|
-
post_type: { type: 'string', description: '"post", "page", "any", CPT slug. Default: any.' },
|
|
3190
|
+
post_type: { type: 'string', description: '"post", "page", "any", CPT slug. Default: any. Note: with "any" + no explicit limit, the per-store cap is 200; pass limit explicitly for broader sweeps.' },
|
|
3164
3191
|
include_drafts: { type: 'boolean', description: 'Include draft/private/pending. Default: true.' },
|
|
3165
|
-
limit: { type: 'number', description: 'Max posts per store. Default 1000,
|
|
3192
|
+
limit: { type: 'number', description: 'Max posts per store. Default 1000 for narrow post_type, 200 when post_type=any. Max 5000. Pass an explicit number to widen unbounded scans.' },
|
|
3166
3193
|
dry_run: { type: 'boolean', description: 'Preview without saving. Default: true (SAFE).' }
|
|
3167
3194
|
},
|
|
3168
3195
|
required: ['search', 'replace']
|
|
3169
3196
|
}
|
|
3170
3197
|
},
|
|
3198
|
+
{
|
|
3199
|
+
name: 'replace_widget_content',
|
|
3200
|
+
description: 'Replace an entire widget setting (full overwrite, not substring) on every widget that matches a content predicate. Migration tool for tasks like swapping a Salesforce form HTML in every html widget that still contains "webto.salesforce.com" — substring tools like replace_text or find_replace cannot do this safely because you need to overwrite the whole setting, not splice the new value into the old one. Use exclude_match for idempotency (skip widgets already migrated to the new content). ALWAYS dry_run=true first to preview which widgets would be touched. v4.46.0+.',
|
|
3201
|
+
inputSchema: {
|
|
3202
|
+
type: 'object',
|
|
3203
|
+
properties: {
|
|
3204
|
+
widget_type: { type: 'string', description: 'Elementor widgetType to target, e.g. "html", "shortcode", "text-editor". Case-insensitive.' },
|
|
3205
|
+
setting: { type: 'string', description: 'Which setting on that widget to read+overwrite, e.g. "html" for the html widget, "editor" for text-editor, "shortcode" for shortcode widget.' },
|
|
3206
|
+
search: { type: 'string', description: 'Content predicate — only widgets whose setting contains this substring match.' },
|
|
3207
|
+
replace_with: { type: 'string', description: 'The COMPLETE new value for the setting (not a substring replacement — the whole setting value gets overwritten with this).' },
|
|
3208
|
+
exclude_match: { type: 'string', description: 'Optional. Skip the widget if its setting already contains this substring. Use for idempotency, e.g. exclude_match:"saxon-contact-form" to avoid re-migrating already-migrated widgets.' },
|
|
3209
|
+
post_type: { type: 'string', description: '"post" | "page" | "any" (default) | CPT slug.' },
|
|
3210
|
+
include_drafts: { type: 'boolean', description: 'Include draft/private/pending. Default: true.' },
|
|
3211
|
+
limit: { type: 'number', description: 'Max posts to scan. Default 200 when post_type=any, 1000 otherwise. Max 5000.' },
|
|
3212
|
+
case_insensitive: { type: 'boolean', description: 'Case-insensitive match for search + exclude_match. Default: false.' },
|
|
3213
|
+
dry_run: { type: 'boolean', description: 'Preview without saving. Default: true (SAFE).' }
|
|
3214
|
+
},
|
|
3215
|
+
required: ['widget_type', 'setting', 'search', 'replace_with']
|
|
3216
|
+
}
|
|
3217
|
+
},
|
|
3171
3218
|
{
|
|
3172
3219
|
name: 'set_faq_schema',
|
|
3173
3220
|
description: 'Set FAQPage JSON-LD schema on a single post. Stores in post meta and auto-injects into page head. Use for adding FAQ rich snippets to posts with Q&A content.',
|
|
@@ -3857,6 +3904,70 @@ async function handleToolCall(name, args) {
|
|
|
3857
3904
|
}
|
|
3858
3905
|
}
|
|
3859
3906
|
|
|
3907
|
+
case 'capability_check': {
|
|
3908
|
+
try {
|
|
3909
|
+
const r = await apiCall('/health?include_routes=1');
|
|
3910
|
+
const routes = r.routes || {};
|
|
3911
|
+
const exposedPaths = new Set(Object.keys(routes));
|
|
3912
|
+
|
|
3913
|
+
// Required-route map: which backend route each MCP tool depends on.
|
|
3914
|
+
// Keep this in sync with the most commonly-failing routes — not exhaustive.
|
|
3915
|
+
const requiredRoutes = {
|
|
3916
|
+
replace_text: '/nvbc/v1/replace-text',
|
|
3917
|
+
audit_text: '/nvbc/v1/audit-text',
|
|
3918
|
+
find_replace: '/nvbc/v1/find-replace',
|
|
3919
|
+
replace_widget_content: '/nvbc/v1/replace-widget-content',
|
|
3920
|
+
list_elements: '/nvbc/v1/pages/{id}/elements',
|
|
3921
|
+
remove_section: '/nvbc/v1/pages/{id}/remove-section',
|
|
3922
|
+
update_element: '/nvbc/v1/pages/{id}/update-element',
|
|
3923
|
+
check_page_lock: '/nvbc/v1/pages/{id}/lock-status',
|
|
3924
|
+
patch_kit_global_css: '/nvbc/v1/kit-global-css/patch',
|
|
3925
|
+
search_kit_global_css: '/nvbc/v1/search-kit-global-css',
|
|
3926
|
+
audit_unused_kit_css: '/nvbc/v1/audit-unused-kit-css',
|
|
3927
|
+
get_widget_schema: '/nvbc/v1/widget-schema',
|
|
3928
|
+
list_widget_types: '/nvbc/v1/widget-types',
|
|
3929
|
+
};
|
|
3930
|
+
|
|
3931
|
+
// Backend paths use literal regex placeholders like (?P<id>\d+); strip them for compare
|
|
3932
|
+
const normalize = (path) => path
|
|
3933
|
+
.replace(/\(\?P<\w+>[^)]+\)/g, '{id}')
|
|
3934
|
+
.replace(/^\/wp-json/, '');
|
|
3935
|
+
|
|
3936
|
+
const exposedNormalized = new Set();
|
|
3937
|
+
Object.keys(routes).forEach(p => exposedNormalized.add(normalize(p)));
|
|
3938
|
+
|
|
3939
|
+
const available = [];
|
|
3940
|
+
const missing = [];
|
|
3941
|
+
Object.entries(requiredRoutes).forEach(([tool, route]) => {
|
|
3942
|
+
if (exposedNormalized.has(route)) available.push(tool);
|
|
3943
|
+
else missing.push({ tool, route });
|
|
3944
|
+
});
|
|
3945
|
+
|
|
3946
|
+
let msg = `=== CAPABILITY CHECK ===\n`;
|
|
3947
|
+
msg += `Plugin: v${r.version}\n`;
|
|
3948
|
+
msg += `MCP server: v${VERSION}\n`;
|
|
3949
|
+
msg += `Total routes registered: ${Object.keys(routes).length}\n\n`;
|
|
3950
|
+
|
|
3951
|
+
if (missing.length === 0) {
|
|
3952
|
+
msg += `✅ All ${available.length} probed MCP tools have matching backend routes.\n`;
|
|
3953
|
+
} else {
|
|
3954
|
+
msg += `⚠️ ${missing.length} MCP tool(s) have NO matching backend route on this site:\n`;
|
|
3955
|
+
missing.forEach(m => { msg += ` • ${m.tool} (needs ${m.route})\n`; });
|
|
3956
|
+
msg += `\nThese tools will return 404 / "Backend route missing" until the site's NVBC plugin is updated.\n`;
|
|
3957
|
+
msg += `Available tools (${available.length}): ${available.join(', ')}\n`;
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
msg += `\n--- Full route list (${Object.keys(routes).length}) ---\n`;
|
|
3961
|
+
Object.entries(routes).sort().forEach(([route, methods]) => {
|
|
3962
|
+
msg += ` ${(methods || []).join(',').padEnd(12)} ${route}\n`;
|
|
3963
|
+
});
|
|
3964
|
+
|
|
3965
|
+
return ok(msg);
|
|
3966
|
+
} catch (e) {
|
|
3967
|
+
return ok(`capability_check failed: ${e.message}\n\nThis likely means the site is unreachable, the plugin is older than v4.46.0 (no /health?include_routes endpoint), or credentials are wrong.`);
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3860
3971
|
case 'get_design_tokens': {
|
|
3861
3972
|
const tokens = await getDesignTokens();
|
|
3862
3973
|
return ok(`=== DESIGN TOKENS ===\n\n${JSON.stringify(tokens, null, 2)}`);
|
|
@@ -4133,10 +4244,32 @@ async function handleToolCall(name, args) {
|
|
|
4133
4244
|
const params = new URLSearchParams();
|
|
4134
4245
|
if (args.section_index !== undefined) params.set('section_index', args.section_index);
|
|
4135
4246
|
if (args.depth !== undefined) params.set('depth', args.depth);
|
|
4247
|
+
if (args.widget_type) params.set('widget_type', args.widget_type);
|
|
4248
|
+
if (args.container_title) params.set('container_title', args.container_title);
|
|
4249
|
+
if (args.settings_contains) params.set('settings_contains', args.settings_contains);
|
|
4136
4250
|
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
4137
4251
|
const r = await apiCall(`/pages/${args.page_id}/elements${qs}`);
|
|
4138
4252
|
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
4139
4253
|
|
|
4254
|
+
// Flat-filter mode (v4.46.0+)
|
|
4255
|
+
if (r.mode === 'filtered') {
|
|
4256
|
+
const filt = r.filter || {};
|
|
4257
|
+
let msg = `Page ${args.page_id}: ${r.total_sections} sections — filter ${JSON.stringify(filt)}\n`;
|
|
4258
|
+
msg += `Matches: ${r.match_count}\n${'─'.repeat(50)}\n`;
|
|
4259
|
+
if (!r.elements.length) {
|
|
4260
|
+
msg += '(no matches)\n';
|
|
4261
|
+
return ok(msg);
|
|
4262
|
+
}
|
|
4263
|
+
r.elements.forEach(el => {
|
|
4264
|
+
const label = el.widget ? `[${el.widget}]` : `<${el.type}>`;
|
|
4265
|
+
msg += `${label} id:${el.id}`;
|
|
4266
|
+
if (el.label) msg += ` "${el.label}"`;
|
|
4267
|
+
if (el.parent_id) msg += ` (parent ${el.parent_id})`;
|
|
4268
|
+
msg += `\n path: ${el.path}\n`;
|
|
4269
|
+
});
|
|
4270
|
+
return ok(msg);
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4140
4273
|
function formatTree(el, indent) {
|
|
4141
4274
|
let line = ' '.repeat(indent);
|
|
4142
4275
|
if (el.type === 'widget') {
|
|
@@ -4201,15 +4334,33 @@ async function handleToolCall(name, args) {
|
|
|
4201
4334
|
case 'check_page_lock': {
|
|
4202
4335
|
const r = await apiCall(`/pages/${args.page_id}/lock-status`);
|
|
4203
4336
|
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4337
|
+
|
|
4338
|
+
const sizeKB = r.elementor_data_size_bytes != null
|
|
4339
|
+
? Math.round(r.elementor_data_size_bytes / 1024)
|
|
4340
|
+
: null;
|
|
4341
|
+
const big = sizeKB != null && sizeKB > 500; // >500KB tree often triggers slow save cascade
|
|
4342
|
+
|
|
4343
|
+
let msg = '';
|
|
4344
|
+
if (!r.locked) {
|
|
4345
|
+
msg += `Page ${args.page_id}: Not locked — safe to edit.\n`;
|
|
4346
|
+
} else {
|
|
4347
|
+
msg += `Page ${args.page_id}: LOCKED\n`;
|
|
4348
|
+
if (r.wp_lock) msg += ` WP Editor: ${r.wp_lock.user} (${r.wp_lock.age_seconds}s ago, expires in ${r.wp_lock.expires_in_seconds}s)\n`;
|
|
4349
|
+
if (r.mcp_lock) msg += ` MCP Tool: ${r.mcp_lock.tool} (started ${r.mcp_lock.time})\n`;
|
|
4350
|
+
msg += `Use force=true on write operations to override.\n`;
|
|
4208
4351
|
}
|
|
4209
|
-
|
|
4210
|
-
|
|
4352
|
+
|
|
4353
|
+
// v4.46.0+: surface a recently-expired _edit_lock + page size so callers can
|
|
4354
|
+
// diagnose "hang despite unlocked" symptoms (almost always slow post-save cascade).
|
|
4355
|
+
if (r.wp_lock_raw && !r.wp_lock_raw.considered_active) {
|
|
4356
|
+
msg += `\nRecently-expired WP _edit_lock: user=${r.wp_lock_raw.user}, age=${r.wp_lock_raw.age_seconds}s (past the 150s active window). Not enforced, but Elementor may still be writing autosaves in the background.\n`;
|
|
4357
|
+
}
|
|
4358
|
+
if (sizeKB != null) {
|
|
4359
|
+
msg += `\nElementor data: ${sizeKB} KB`;
|
|
4360
|
+
if (big) {
|
|
4361
|
+
msg += ` ⚠️ Large tree — expect 10–120s wait on write operations (remove_section/update_element) due to save_post → CSS regen cascade. This is NOT a lock; force=true won't help. Use the MCP fetch timeout, not retries.`;
|
|
4362
|
+
}
|
|
4211
4363
|
}
|
|
4212
|
-
msg += `\nUse force=true on write operations to override.`;
|
|
4213
4364
|
return ok(msg);
|
|
4214
4365
|
}
|
|
4215
4366
|
|
|
@@ -4592,7 +4743,9 @@ async function handleToolCall(name, args) {
|
|
|
4592
4743
|
if (args.category) params.set('category', args.category);
|
|
4593
4744
|
if (args.date_after) params.set('date_after', args.date_after);
|
|
4594
4745
|
if (args.date_before) params.set('date_before', args.date_before);
|
|
4595
|
-
|
|
4746
|
+
// v4.46.0+: accept `limit` as alias for `per_page` — callers frequently mistype it
|
|
4747
|
+
const perPage = args.per_page ?? args.limit;
|
|
4748
|
+
if (perPage) params.set('per_page', perPage);
|
|
4596
4749
|
if (args.page) params.set('page', args.page);
|
|
4597
4750
|
const qs = params.toString() ? `?${params.toString()}` : '';
|
|
4598
4751
|
const r = await apiCall(`/posts${qs}`);
|
|
@@ -5628,6 +5781,11 @@ async function handleToolCall(name, args) {
|
|
|
5628
5781
|
let out = `=== REPLACE TEXT (${tag} · ${r.mode || 'literal'}${r.case_insensitive ? ', ci' : ''}) ===\n`;
|
|
5629
5782
|
out += `Search: "${r.search}"\nReplace: "${r.replace}"\n`;
|
|
5630
5783
|
out += `Stores: ${(r.stores || []).join(', ')}\n`;
|
|
5784
|
+
if (r.scope) {
|
|
5785
|
+
out += `Scope: post_type=${r.scope.post_type}, limit_per_store=${r.scope.limit_per_store}`;
|
|
5786
|
+
if (r.scope.limit_was_default) out += ' (default)';
|
|
5787
|
+
out += `\n`;
|
|
5788
|
+
}
|
|
5631
5789
|
out += `Total matches: ${s.total_matches} | Posts affected: ${s.posts_affected}\n`;
|
|
5632
5790
|
if (s.by_store && Object.keys(s.by_store).length) {
|
|
5633
5791
|
out += `By store: ${Object.entries(s.by_store).map(([k,v]) => `${k}=${v}`).join(', ')}\n`;
|
|
@@ -5635,6 +5793,9 @@ async function handleToolCall(name, args) {
|
|
|
5635
5793
|
if (!r.matches?.length) {
|
|
5636
5794
|
out += '\nNo matches found.';
|
|
5637
5795
|
if (r.mode !== 'fuzzy') out += '\n💡 Try mode="fuzzy" to tolerate NBSP / curly punctuation.';
|
|
5796
|
+
if (r.scope && r.scope.limit_was_default && r.scope.post_type === 'any') {
|
|
5797
|
+
out += `\n💡 Default limit (${r.scope.limit_per_store}) may have truncated the scan. Pass limit:5000 to widen.`;
|
|
5798
|
+
}
|
|
5638
5799
|
return ok(out);
|
|
5639
5800
|
}
|
|
5640
5801
|
const groups = {};
|
|
@@ -5657,6 +5818,63 @@ async function handleToolCall(name, args) {
|
|
|
5657
5818
|
return ok(out);
|
|
5658
5819
|
}
|
|
5659
5820
|
|
|
5821
|
+
case 'replace_widget_content': {
|
|
5822
|
+
const body = {
|
|
5823
|
+
widget_type: args.widget_type,
|
|
5824
|
+
setting: args.setting,
|
|
5825
|
+
search: stripCDATA(args.search),
|
|
5826
|
+
replace_with: stripCDATA(args.replace_with),
|
|
5827
|
+
dry_run: parseBool(args.dry_run, true),
|
|
5828
|
+
};
|
|
5829
|
+
if (args.exclude_match) body.exclude_match = stripCDATA(args.exclude_match);
|
|
5830
|
+
if (args.post_type) body.post_type = args.post_type;
|
|
5831
|
+
if (args.include_drafts !== undefined) body.include_drafts = parseBool(args.include_drafts, true);
|
|
5832
|
+
if (args.limit) body.limit = args.limit;
|
|
5833
|
+
if (args.case_insensitive) body.case_insensitive = parseBool(args.case_insensitive, false);
|
|
5834
|
+
|
|
5835
|
+
const r = await apiCall('/replace-widget-content', 'POST', body);
|
|
5836
|
+
if (r.code || r.error) return ok(`Failed: ${r.message || r.error || 'Unknown error'}`);
|
|
5837
|
+
|
|
5838
|
+
const s = r.summary || {};
|
|
5839
|
+
const tag = r.dry_run ? 'DRY RUN' : 'APPLIED';
|
|
5840
|
+
let out = `=== REPLACE WIDGET CONTENT (${tag}) ===\n`;
|
|
5841
|
+
out += `Widget: ${r.widget_type}.${r.setting}\n`;
|
|
5842
|
+
out += `Search: "${r.search}"\n`;
|
|
5843
|
+
if (r.exclude_match) out += `Exclude: "${r.exclude_match}"\n`;
|
|
5844
|
+
out += `Replace with: ${(r.replace_with || '').slice(0, 120)}${(r.replace_with || '').length > 120 ? '…' : ''}\n`;
|
|
5845
|
+
if (r.scope) {
|
|
5846
|
+
out += `Scope: post_type=${r.scope.post_type}, limit=${r.scope.limit}`;
|
|
5847
|
+
if (r.scope.limit_was_default) out += ' (default)';
|
|
5848
|
+
out += `, posts_scanned=${r.scope.posts_scanned}\n`;
|
|
5849
|
+
}
|
|
5850
|
+
out += `Widgets matched: ${s.widgets_matched || 0}`;
|
|
5851
|
+
if (r.dry_run) out += ` | would_replace: ${s.widgets_matched - (s.widgets_skipped_excluded || 0)}`;
|
|
5852
|
+
else out += ` | replaced: ${s.widgets_replaced || 0}`;
|
|
5853
|
+
out += ` | skipped (excluded): ${s.widgets_skipped_excluded || 0}\n`;
|
|
5854
|
+
out += `Posts affected: ${s.posts_affected || 0}\n`;
|
|
5855
|
+
|
|
5856
|
+
if (!r.matches?.length) {
|
|
5857
|
+
out += `\nNo widgets matched.`;
|
|
5858
|
+
if (r.scope && r.scope.limit_was_default && r.scope.post_type === 'any') {
|
|
5859
|
+
out += `\n💡 Default limit (${r.scope.limit}) may have truncated. Pass limit:5000 to widen.`;
|
|
5860
|
+
}
|
|
5861
|
+
return ok(out);
|
|
5862
|
+
}
|
|
5863
|
+
|
|
5864
|
+
r.matches.slice(0, 50).forEach(m => {
|
|
5865
|
+
out += `\n--- [${m.post_id}] ${m.post_title} — ${m.hits.length} widget(s) ---\n`;
|
|
5866
|
+
m.hits.slice(0, 5).forEach(h => {
|
|
5867
|
+
out += ` ${h.status} | id:${h.element_id}\n`;
|
|
5868
|
+
out += ` old: ${(h.preview_old || '').replace(/\n/g, ' ').slice(0, 100)}\n`;
|
|
5869
|
+
if (h.preview_new) out += ` new: ${(h.preview_new || '').replace(/\n/g, ' ').slice(0, 100)}\n`;
|
|
5870
|
+
});
|
|
5871
|
+
if (m.hits.length > 5) out += ` … and ${m.hits.length - 5} more\n`;
|
|
5872
|
+
});
|
|
5873
|
+
if (r.matches.length > 50) out += `\n…and ${r.matches.length - 50} more posts (truncated)`;
|
|
5874
|
+
if (r.dry_run) out += `\n\n(Dry run — no changes saved. Set dry_run=false to apply.)`;
|
|
5875
|
+
return ok(out);
|
|
5876
|
+
}
|
|
5877
|
+
|
|
5660
5878
|
case 'set_faq_schema': {
|
|
5661
5879
|
const body = { post_id: args.post_id, faqs: args.faqs };
|
|
5662
5880
|
if (args.dry_run !== undefined) body.dry_run = args.dry_run;
|
|
@@ -7034,9 +7252,17 @@ let _server = null;
|
|
|
7034
7252
|
let _transport = null;
|
|
7035
7253
|
|
|
7036
7254
|
async function startStdio() {
|
|
7037
|
-
// Keepalive FIRST — ensures event loop never empties, even if stdin closes
|
|
7255
|
+
// Keepalive FIRST — ensures event loop never empties, even if stdin closes.
|
|
7256
|
+
// The parent-watchdog below intentionally bypasses this with process.exit(0)
|
|
7257
|
+
// when it detects the owning host session is gone.
|
|
7038
7258
|
global._mcpKeepAlive = setInterval(() => {}, 30000);
|
|
7039
7259
|
|
|
7260
|
+
// Parent-watchdog: walks ancestry to find Claude Code / VS Code / Cursor /
|
|
7261
|
+
// owning terminal, then heartbeats it. Catches force-killed parents on
|
|
7262
|
+
// Windows where stdin close + SIGTERM never arrive. Disable with
|
|
7263
|
+
// NVBC_MCP_PARENT_WATCH=0. No-op in HTTP mode (only called from stdio).
|
|
7264
|
+
startParentWatchdog();
|
|
7265
|
+
|
|
7040
7266
|
// Monitor stdin/stdout lifecycle (diagnostic)
|
|
7041
7267
|
process.stdin.on('end', () => {
|
|
7042
7268
|
console.error('[LIFECYCLE] stdin ended');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noleemits/vision-builder-control-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.46.1",
|
|
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",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"index.js",
|
|
12
|
+
"parent-watchdog.js",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"scripts": {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-watchdog for the stdio MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Walks the parent-process chain at startup to find the "owning" session
|
|
5
|
+
* (typically Claude Code / VS Code / Cursor / a terminal), then heartbeats
|
|
6
|
+
* its existence every few seconds. When the owner dies — whether cleanly
|
|
7
|
+
* or via SIGKILL / taskkill /F / OS crash — the watchdog terminates this
|
|
8
|
+
* process. Solves the orphan-accumulation leak where the global keepalive
|
|
9
|
+
* interval in index.js was preventing exit after the host crashed.
|
|
10
|
+
*
|
|
11
|
+
* Works regardless of intermediate wrappers (npx, npm.cmd, cmd.exe) because
|
|
12
|
+
* the chain walk skips over them and locks onto the last non-system ancestor.
|
|
13
|
+
*
|
|
14
|
+
* Disable per-process via NVBC_MCP_PARENT_WATCH=0.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execSync } from 'node:child_process';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { platform } from 'node:os';
|
|
20
|
+
|
|
21
|
+
const CHECK_INTERVAL_MS = 7000;
|
|
22
|
+
const MAX_CHAIN_DEPTH = 32;
|
|
23
|
+
|
|
24
|
+
const SYSTEM_ROOTS_WIN = new Set([
|
|
25
|
+
'explorer.exe', 'services.exe', 'svchost.exe', 'wininit.exe',
|
|
26
|
+
'smss.exe', 'csrss.exe', 'lsass.exe', 'winlogon.exe',
|
|
27
|
+
'system', 'system idle process', 'registry', 'memory compression',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Snapshot every process on the system in one syscall.
|
|
32
|
+
* Returns Map<pid, { pid, ppid, name }>.
|
|
33
|
+
* Spawning PowerShell once is ~400ms; spawning it N times is N × 400ms,
|
|
34
|
+
* which is why per-step queries were timing out before the watchdog could arm.
|
|
35
|
+
*/
|
|
36
|
+
function snapshotWindows() {
|
|
37
|
+
const out = execSync(
|
|
38
|
+
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress"',
|
|
39
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }
|
|
40
|
+
);
|
|
41
|
+
const data = JSON.parse(out);
|
|
42
|
+
const map = new Map();
|
|
43
|
+
for (const row of (Array.isArray(data) ? data : [data])) {
|
|
44
|
+
map.set(row.ProcessId, {
|
|
45
|
+
pid: row.ProcessId,
|
|
46
|
+
ppid: row.ParentProcessId,
|
|
47
|
+
name: String(row.Name || ''),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return map;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function snapshotPosix() {
|
|
54
|
+
// ps is universal. Single fork, all processes, parseable.
|
|
55
|
+
const out = execSync('ps -axo pid=,ppid=,comm=', {
|
|
56
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10000, maxBuffer: 32 * 1024 * 1024,
|
|
57
|
+
});
|
|
58
|
+
const map = new Map();
|
|
59
|
+
for (const line of out.split('\n')) {
|
|
60
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
61
|
+
if (!m) continue;
|
|
62
|
+
const pid = parseInt(m[1], 10);
|
|
63
|
+
const ppid = parseInt(m[2], 10);
|
|
64
|
+
map.set(pid, { pid, ppid, name: m[3].trim() });
|
|
65
|
+
}
|
|
66
|
+
return map;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Walk up from process.pid using an in-memory snapshot. Returns the last
|
|
71
|
+
* non-system ancestor PID, or null.
|
|
72
|
+
*/
|
|
73
|
+
function findOwningAncestor() {
|
|
74
|
+
const os = platform();
|
|
75
|
+
let snapshot;
|
|
76
|
+
try {
|
|
77
|
+
snapshot = os === 'win32' ? snapshotWindows() : snapshotPosix();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
throw new Error(`process snapshot failed: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let cur = process.pid;
|
|
83
|
+
let lastNonSystem = null;
|
|
84
|
+
let depth = 0;
|
|
85
|
+
|
|
86
|
+
while (depth++ < MAX_CHAIN_DEPTH) {
|
|
87
|
+
const info = snapshot.get(cur);
|
|
88
|
+
if (!info || !info.ppid) break;
|
|
89
|
+
|
|
90
|
+
const ppid = info.ppid;
|
|
91
|
+
if (ppid === 0) break; // Windows pseudo-root.
|
|
92
|
+
if (os !== 'win32' && ppid === 1) break; // POSIX init/systemd/launchd.
|
|
93
|
+
|
|
94
|
+
if (os === 'win32') {
|
|
95
|
+
const parentInfo = snapshot.get(ppid);
|
|
96
|
+
if (!parentInfo) break;
|
|
97
|
+
if (SYSTEM_ROOTS_WIN.has((parentInfo.name || '').toLowerCase())) break;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lastNonSystem = ppid;
|
|
101
|
+
cur = ppid;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return lastNonSystem;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isAlive(pid) {
|
|
108
|
+
try {
|
|
109
|
+
process.kill(pid, 0);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (e.code === 'ESRCH') return false;
|
|
113
|
+
// EPERM: process exists, we just can't signal it. Still alive.
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Start the parent-watchdog. Should only be called from stdio mode.
|
|
120
|
+
*
|
|
121
|
+
* @returns {{ ownerPid: number|null, enabled: boolean, reason?: string }}
|
|
122
|
+
*/
|
|
123
|
+
export function startParentWatchdog() {
|
|
124
|
+
if (process.env.NVBC_MCP_PARENT_WATCH === '0') {
|
|
125
|
+
console.error('[WATCHDOG] disabled via NVBC_MCP_PARENT_WATCH=0');
|
|
126
|
+
return { ownerPid: null, enabled: false, reason: 'env_disabled' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let ownerPid = null;
|
|
130
|
+
|
|
131
|
+
// Explicit override — watch this PID instead of walking the chain.
|
|
132
|
+
// Escape hatch for unusual process trees (tmux, screen, supervisor).
|
|
133
|
+
const override = parseInt(process.env.NVBC_MCP_WATCHDOG_OWNER_PID || '', 10);
|
|
134
|
+
if (Number.isFinite(override) && override > 0) {
|
|
135
|
+
ownerPid = override;
|
|
136
|
+
} else {
|
|
137
|
+
try {
|
|
138
|
+
ownerPid = findOwningAncestor();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error(`[WATCHDOG] ancestry walk failed: ${err.message} — watchdog disabled`);
|
|
141
|
+
return { ownerPid: null, enabled: false, reason: 'walk_failed' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!ownerPid) {
|
|
146
|
+
console.error('[WATCHDOG] no non-system ancestor found — watchdog disabled (daemonized run?)');
|
|
147
|
+
return { ownerPid: null, enabled: false, reason: 'no_ancestor' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.error(`[WATCHDOG] watching parent session pid=${ownerPid} (interval=${CHECK_INTERVAL_MS}ms)`);
|
|
151
|
+
|
|
152
|
+
const interval = setInterval(() => {
|
|
153
|
+
if (!isAlive(ownerPid)) {
|
|
154
|
+
console.error(`[WATCHDOG] parent pid=${ownerPid} is gone — shutting down`);
|
|
155
|
+
// Bypass the global keepalive + beforeExit safety net in index.js.
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
}, CHECK_INTERVAL_MS);
|
|
159
|
+
|
|
160
|
+
interval.unref();
|
|
161
|
+
|
|
162
|
+
return { ownerPid, enabled: true };
|
|
163
|
+
}
|