@velaro/mcp-server 0.6.50 → 0.6.52

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/package.json +1 -1
  2. package/server.js +268 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/mcp-server",
3
- "version": "0.6.50",
3
+ "version": "0.6.52",
4
4
  "description": "Velaro MCP server — connect Claude and other AI agents directly to your Velaro account: KB, workflows, bots, conversations, contacts, routing, and more.",
5
5
  "type": "module",
6
6
  "bin": {
package/server.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * VELARO_JWT=<token> short-lived Velaro JWT (from velaro login, for dev use)
10
10
  *
11
11
  * API base:
12
- * VELARO_ADMIN_API=https://help.velaro.com (default, production)
12
+ * VELARO_ADMIN_API=https://api-admin-us-east.velaro.com (default, production)
13
13
  * VELARO_ADMIN_API=https://velaro-admin-staging.azurewebsites.net (staging)
14
14
  *
15
15
  * Claude Desktop config (~/.claude/claude_desktop_config.json):
@@ -49,11 +49,45 @@ import {
49
49
  ListPromptsRequestSchema,
50
50
  } from '@modelcontextprotocol/sdk/types.js';
51
51
 
52
- const API_BASE = process.env.VELARO_ADMIN_API || 'https://help.velaro.com';
52
+ // Default was 'https://help.velaro.com'. Renamed to the recognizable admin API hostname for
53
+ // clarity during the 2026-09-10 Donaldson bot Calendly status investigation
54
+ // (docs/qa/chatbot-hallucinated-booking-donaldson-bot.md) — but this is NOT a functional fix and
55
+ // does NOT explain that incident: live-verified same day that 'https://help.velaro.com' and
56
+ // 'https://api-admin-us-east.velaro.com' are the SAME deployment (identical /api/health build SHA,
57
+ // and /Calendly/superadmin/status returns 401 — auth-gated, not 404 — on both hosts). Whatever
58
+ // caused that investigation's status/reality contradiction, it was NOT this default resolving to a
59
+ // broken or different host; that theory is disproven. Left as a naming clarity improvement only.
60
+ const API_BASE = process.env.VELARO_ADMIN_API || 'https://api-admin-us-east.velaro.com';
53
61
  const MESSAGING_API = process.env.VELARO_MESSAGING_API || null;
54
62
  const MCP_KEY = process.env.VELARO_MCP_KEY;
55
63
  const JWT = process.env.VELARO_JWT;
56
64
 
65
+ // Known admin API hosts, for tools that let a caller explicitly target one environment
66
+ // regardless of what this server process's own API_BASE is configured to (see the `env` param
67
+ // on the calendly_* superadmin tools below). Deliberately NOT used as a default anywhere —
68
+ // defaulting env-selectable tools to one hardcoded environment while every other tool in this
69
+ // file follows the single global API_BASE would recreate the exact bug being fixed here: an
70
+ // operator configured for one environment would silently get a DIFFERENT environment's answer
71
+ // from just these tools with no indication anything was different.
72
+ const ADMIN_API_HOSTS = {
73
+ staging: 'https://velaro-admin-staging.azurewebsites.net',
74
+ production: 'https://api-admin-us-east.velaro.com',
75
+ };
76
+
77
+ /// Resolves an optional `env` tool argument to a target admin API base. Throws on an unrecognized
78
+ /// value instead of silently falling through to API_BASE (the JS default-parameter behavior of
79
+ /// `api(..., base)` when `base` is `undefined`) — a typo like `env: 'prod'` must not silently run
80
+ /// a DESTRUCTIVE call (calendly_disconnect) against whatever this server defaults to while telling
81
+ /// the caller it targeted a different environment. `hasOwnProperty` (not truthiness) also closes
82
+ /// the prototype-chain edge case (`env: 'constructor'`, `env: 'toString'`, etc.).
83
+ function resolveAdminBase(env) {
84
+ if (env === undefined || env === null) return API_BASE;
85
+ if (!Object.prototype.hasOwnProperty.call(ADMIN_API_HOSTS, env)) {
86
+ throw new Error(`Unknown env "${env}" — use "staging" or "production" (or omit it to use this server's configured default).`);
87
+ }
88
+ return ADMIN_API_HOSTS[env];
89
+ }
90
+
57
91
  if (!MCP_KEY && !JWT) {
58
92
  process.stderr.write('ERROR: Set VELARO_MCP_KEY=vel_live_... or VELARO_JWT=<token>\n');
59
93
  process.exit(1);
@@ -118,8 +152,8 @@ const messagingPut = (path, body) => messagingRequest('PUT', path, body);
118
152
  const messagingPatch = (path, body) => messagingRequest('PATCH', path, body);
119
153
  const messagingDel = (path) => messagingRequest('DELETE', path);
120
154
 
121
- async function api(method, path, body) {
122
- const res = await fetch(`${API_BASE}${path}`, {
155
+ async function api(method, path, body, base = API_BASE) {
156
+ const res = await fetch(`${base}${path}`, {
123
157
  method,
124
158
  headers: { Authorization: authHeader(), 'Content-Type': 'application/json' },
125
159
  body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -127,6 +161,19 @@ async function api(method, path, body) {
127
161
  });
128
162
  if (!res.ok) {
129
163
  const text = await res.text().catch(() => '');
164
+ if ((res.status === 401 || res.status === 403) && base !== API_BASE) {
165
+ // Cross-environment call with the single global credential (MCP_KEY/JWT) — this session's
166
+ // credential is issued for one environment, so pointing a tool at the OTHER one via an
167
+ // explicit `env` override will look like a real auth failure rather than "wrong environment
168
+ // selected." Surface that explicitly instead of a bare HTTP error, so a caller doesn't
169
+ // misread it as "not configured" or a generic outage.
170
+ throw new Error(
171
+ `Velaro API ${method} ${path} -> ${res.status} against ${base}: this session's credentials ` +
172
+ `may not be valid for that environment (VELARO_MCP_KEY/VELARO_JWT are single, per-environment ` +
173
+ `credentials — re-run with credentials issued for that environment, or omit "env" to use ` +
174
+ `whichever environment this server is currently configured for). Raw response: ${text.slice(0, 300)}`
175
+ );
176
+ }
130
177
  throw new Error(`Velaro API ${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`);
131
178
  }
132
179
  const text = await res.text();
@@ -449,14 +496,16 @@ const TOOLS = [
449
496
  },
450
497
  {
451
498
  name: 'kb_capture_screenshot',
452
- description: 'Log into admin or messaging staging, navigate to a route, capture a screenshot, optionally polish/annotate it, upload it, and insert it into a KB article (after a matching <h2>/<h3> heading, or appended to the end). Requires E2E_USER_EMAIL/E2E_USER_PASSWORD env vars and the optional "playwright" package installed.',
499
+ description: 'Log into admin, messaging, or livefluence (v10) staging, navigate to a route, optionally click something (e.g. to open an export menu or a popup window), capture a screenshot, optionally polish/annotate it, upload it, and insert it into a KB article (after a matching <h2>/<h3> heading, or appended to the end). Requires E2E_USER_EMAIL/E2E_USER_PASSWORD env vars and the optional "playwright" package installed.',
453
500
  inputSchema: {
454
501
  type: 'object',
455
502
  properties: {
456
- app: { type: 'string', description: 'Which app to capture from', enum: ['admin', 'messaging'] },
503
+ app: { type: 'string', description: 'Which app to capture from', enum: ['admin', 'messaging', 'livefluence'] },
457
504
  route: { type: 'string', description: 'Route to navigate to, e.g. "/Settings/Routing"' },
458
505
  articleId: { type: 'number', description: 'Target article ID' },
459
506
  heading: { type: 'string', description: 'Insert the image immediately after the <h2>/<h3> whose text contains this (case-insensitive). Falls back to appending at the end.' },
507
+ click: { type: 'string', description: 'Selector to click after the route loads, before capturing (CSS, or Playwright\'s "text=..."/"role=..." engines) — e.g. "text=Export"' },
508
+ popup: { type: 'boolean', description: 'The click target opens a NEW browser window (window.open/target=_blank) rather than something in the current page — e.g. a per-transcript export action. Capture that window instead of the original page. Requires click.' },
460
509
  annotations: {
461
510
  type: 'array',
462
511
  description: 'Optional numbered/labeled circle annotations, in raw screenshot pixel coordinates.',
@@ -1195,6 +1244,47 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1195
1244
  required: ['message'],
1196
1245
  },
1197
1246
  },
1247
+ {
1248
+ name: 'teams_push_settings_get',
1249
+ description: 'Get the Velaro Bot proactive-alert settings for this site: per-alert switches (new chat, missed chat, ticket created, ticket assigned, daily digest), the effective daily cap and the push count for today. Site is resolved server-side.',
1250
+ inputSchema: { type: 'object', properties: {}, required: [] },
1251
+ },
1252
+ {
1253
+ name: 'teams_push_settings_update',
1254
+ description: 'Change the Velaro Bot proactive-alert settings for this site. Only fields you pass are changed; omitted fields keep their value. Set resetDailyPushCap=true to clear the per-site cap and use the platform default.',
1255
+ inputSchema: {
1256
+ type: 'object',
1257
+ properties: {
1258
+ pushNewChat: { type: 'boolean', description: 'New chat alerts' },
1259
+ pushMissed: { type: 'boolean', description: 'Missed chat alerts' },
1260
+ pushTicketCreated: { type: 'boolean', description: 'Ticket created alerts' },
1261
+ pushTicketAssigned: { type: 'boolean', description: 'Ticket assigned alerts' },
1262
+ pushDailyDigest: { type: 'boolean', description: 'Daily digest (off by default)' },
1263
+ dailyPushCap: { type: 'number', description: 'Max alerts per day for this site' },
1264
+ resetDailyPushCap: { type: 'boolean', description: 'Clear the per-site cap (use platform default)' },
1265
+ },
1266
+ required: [],
1267
+ },
1268
+ },
1269
+ {
1270
+ name: 'teams_channel_config_get',
1271
+ description: 'Get this site\'s Teams Channel (customer chat) settings: whether it\'s enabled, the welcome message sent to a Teams user opening a new conversation, and the team new conversations route to (null = site default team). Site is resolved server-side.',
1272
+ inputSchema: { type: 'object', properties: {}, required: [] },
1273
+ },
1274
+ {
1275
+ name: 'teams_channel_config_update',
1276
+ description: 'Change this site\'s Teams Channel settings. Only fields you pass are changed. Enabling this is what makes free-text messages to the Velaro Teams bot open a real, agent-routed conversation instead of the default staff-command-only reply.',
1277
+ inputSchema: {
1278
+ type: 'object',
1279
+ properties: {
1280
+ enabled: { type: 'boolean', description: 'Turn Teams Channel (customer chat) on or off for this site' },
1281
+ welcomeMessage: { type: 'string', description: 'Sent to the Teams user when their message opens a new conversation. Use {{user.name}} for their display name. Max 1000 characters.' },
1282
+ teamId: { type: 'number', description: 'Route new conversations to this team id (must be a real, non-deleted team on this site)' },
1283
+ clearTeamId: { type: 'boolean', description: 'Clear the team override and fall back to the site default team' },
1284
+ },
1285
+ required: [],
1286
+ },
1287
+ },
1198
1288
  {
1199
1289
  name: 'teams_notify_card',
1200
1290
  description: 'Send a rich Adaptive Card (title + fact/value fields + optional action button) to a Teams webhook — use this for structured summaries (CI results, QA report pass/fail counts, ops status) instead of teams_notify\'s plain message.',
@@ -1727,6 +1817,38 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1727
1817
  },
1728
1818
  },
1729
1819
 
1820
+ // ── Unified Outbound Activity (Klaviyo-parity Gap 6) ──────────────────────
1821
+ {
1822
+ name: 'outbound_activity_list',
1823
+ description: 'Unified outbound email feed across ALL THREE send systems at once: transactional/system mail (password resets, alerts), sequence/drip campaign steps, and one-off broadcasts. Newest first. Use this instead of checking the broadcast, sequence and transactional views separately. Site scoping is enforced server-side.',
1824
+ inputSchema: {
1825
+ type: 'object',
1826
+ properties: {
1827
+ take: { type: 'number', description: 'Page size, 1-100 (default 25)' },
1828
+ skip: { type: 'number', description: 'Rows to skip (default 0)' },
1829
+ source: { type: 'string', enum: ['Transactional', 'Campaign', 'Broadcast'], description: 'Limit to one send system. Omit for all three.' },
1830
+ status: { type: 'string', enum: ['Sent', 'Pending', 'Failed', 'Unsubscribed', 'Skipped'], description: 'Normalized status shared across all three systems' },
1831
+ search: { type: 'string', description: 'Match recipient address or subject' },
1832
+ },
1833
+ required: [],
1834
+ },
1835
+ },
1836
+ {
1837
+ name: 'outbound_activity_for_contact',
1838
+ description: 'Every email this account has sent to ONE specific address, across transactional, sequence and broadcast sends — the "what have we sent this customer" answer that previously required checking three separate screens. The email address is REQUIRED and matched exactly; it is never optional, because an omitted address must not widen this into a site-wide listing.',
1839
+ inputSchema: {
1840
+ type: 'object',
1841
+ properties: {
1842
+ email: { type: 'string', description: 'Exact recipient email address (required)' },
1843
+ take: { type: 'number', description: 'Page size, 1-100 (default 25)' },
1844
+ skip: { type: 'number', description: 'Rows to skip (default 0)' },
1845
+ source: { type: 'string', enum: ['Transactional', 'Campaign', 'Broadcast'], description: 'Limit to one send system. Omit for all three.' },
1846
+ status: { type: 'string', enum: ['Sent', 'Pending', 'Failed', 'Unsubscribed', 'Skipped'], description: 'Normalized status shared across all three systems' },
1847
+ },
1848
+ required: ['email'],
1849
+ },
1850
+ },
1851
+
1730
1852
  // ── Conversion tracking tools ─────────────────────────────────────────────
1731
1853
  {
1732
1854
  name: 'conversion_list_goals',
@@ -2112,6 +2234,15 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2112
2234
  required: ['siteId'],
2113
2235
  },
2114
2236
  },
2237
+ {
2238
+ name: 'support_site_deployments',
2239
+ description: 'List active widget deployment embed keys and assigned survey IDs for one site. Staff-only and read-only. Use deploymentId (the string embed key) in the widget test harness, not the numeric database id.',
2240
+ inputSchema: {
2241
+ type: 'object',
2242
+ properties: { siteId: { type: 'number', description: 'Target site ID' } },
2243
+ required: ['siteId'],
2244
+ },
2245
+ },
2115
2246
  {
2116
2247
  name: 'support_site_agents',
2117
2248
  description: 'List all agents/admins on a customer site with their roles, active status, and last login date. Velaro staff only.',
@@ -2231,11 +2362,12 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2231
2362
  // ── Calendly integration management (superadmin) ──────────────────────────
2232
2363
  {
2233
2364
  name: 'calendly_get_status',
2234
- description: 'Check whether Calendly is configured for a site and show the current settings. Velaro admin only.',
2365
+ description: 'Check whether Calendly is configured for a site and show the current settings. Velaro admin only. Queries whichever admin API environment this server is currently configured for (VELARO_ADMIN_API) unless "env" is given explicitly.',
2235
2366
  inputSchema: {
2236
2367
  type: 'object',
2237
2368
  properties: {
2238
2369
  siteId: { type: 'number', description: 'Site ID to check' },
2370
+ env: { type: 'string', enum: ['staging', 'production'], description: 'Explicitly target one environment instead of this server\'s configured default (VELARO_ADMIN_API). Use this when a site is known to live in one specific environment (e.g. a staging-only test site) — omitting it can silently report "not configured" for a site that is only set up in the OTHER environment from the one this server defaults to. Requires credentials valid for that environment.' },
2239
2371
  },
2240
2372
  required: ['siteId'],
2241
2373
  },
@@ -2252,6 +2384,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2252
2384
  defaultEventTypeName: { type: 'string', description: 'Human-readable name for the default event type. Optional.' },
2253
2385
  displayName: { type: 'string', description: 'Label shown in admin UI, e.g. "Acme Demo Booking". Optional.' },
2254
2386
  useSingleUseLinks:{ type: 'boolean', description: 'true = generate single-use links per visitor (requires scheduling_links:write). false = reusable event-type URL. Default: true.' },
2387
+ env: { type: 'string', enum: ['staging', 'production'], description: 'Explicitly target one environment instead of this server\'s configured default (VELARO_ADMIN_API). Requires credentials valid for that environment.' },
2255
2388
  },
2256
2389
  required: ['siteId', 'accessToken'],
2257
2390
  },
@@ -2263,6 +2396,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2263
2396
  type: 'object',
2264
2397
  properties: {
2265
2398
  siteId: { type: 'number', description: 'Site ID to disconnect' },
2399
+ env: { type: 'string', enum: ['staging', 'production'], description: 'Explicitly target one environment instead of this server\'s configured default (VELARO_ADMIN_API). Requires credentials valid for that environment.' },
2266
2400
  },
2267
2401
  required: ['siteId'],
2268
2402
  },
@@ -3316,6 +3450,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3316
3450
  required: ['siteId', 'planKey'],
3317
3451
  },
3318
3452
  },
3453
+ { // wires EntitlementsController.SetEntitlementLock (PUT Entitlements/admin/lock/{siteId})
3454
+ name: 'entitlement_set_lock',
3455
+ description: 'Lock a site\'s entitlement resolution to a point-in-time PlanEntitlement version (freezes what plan changes the site sees), or clear an existing lock so it resolves against the current plan version again. Pass lockedAt=null to unlock. Refuses to lock to a timestamp with no defined PlanEntitlement row for the site\'s current plan. Superadmin only.',
3456
+ inputSchema: {
3457
+ type: 'object',
3458
+ properties: {
3459
+ siteId: { type: 'number', description: 'Site ID to lock or unlock.' },
3460
+ lockedAt: { type: 'string', description: 'ISO 8601 timestamp to lock entitlement resolution to. Pass null (or omit) to clear an existing lock.' },
3461
+ },
3462
+ required: ['siteId'],
3463
+ },
3464
+ },
3319
3465
  {
3320
3466
  name: 'entitlement_seed_plans',
3321
3467
  description: 'Seed PlanEntitlement rows from live PackageVersions in the messaging DB. Maps each plan×feature to its current value. Idempotent — inserts new rows, versions changed rows. Run after entitlement_seed and after any PackageVersion change. Superadmin only.',
@@ -4318,6 +4464,8 @@ async function handleTool(name, args) {
4318
4464
  articleId: args.articleId,
4319
4465
  heading: args.heading,
4320
4466
  annotations: args.annotations ?? [],
4467
+ click: args.click,
4468
+ popup: args.popup ?? false,
4321
4469
  auth: { token, apiBase: API_BASE },
4322
4470
  });
4323
4471
  return result.insertedAfterHeading
@@ -4899,6 +5047,26 @@ async function handleTool(name, args) {
4899
5047
  return `Sent to Teams webhook "${webhook.name}".`;
4900
5048
  }
4901
5049
 
5050
+ case 'teams_push_settings_get':
5051
+ return await api('GET', '/TeamsBot/PushSettings');
5052
+
5053
+ case 'teams_push_settings_update': {
5054
+ const body = {};
5055
+ for (const k of ['pushNewChat','pushMissed','pushTicketCreated','pushTicketAssigned','pushDailyDigest','dailyPushCap','resetDailyPushCap'])
5056
+ if (args[k] !== undefined) body[k] = args[k];
5057
+ return await api('PUT', '/TeamsBot/PushSettings', body);
5058
+ }
5059
+
5060
+ case 'teams_channel_config_get':
5061
+ return await api('GET', '/TeamsBot/ChannelConfig');
5062
+
5063
+ case 'teams_channel_config_update': {
5064
+ const body = {};
5065
+ for (const k of ['enabled','welcomeMessage','teamId','clearTeamId'])
5066
+ if (args[k] !== undefined) body[k] = args[k];
5067
+ return await api('PUT', '/TeamsBot/ChannelConfig', body);
5068
+ }
5069
+
4902
5070
  case 'teams_notify_card': {
4903
5071
  const webhooks = await api('GET', '/TeamsNotification');
4904
5072
  if (!Array.isArray(webhooks) || !webhooks.length)
@@ -6004,6 +6172,57 @@ async function handleTool(name, args) {
6004
6172
  return lines.join('\n');
6005
6173
  }
6006
6174
 
6175
+ // ── Unified Outbound Activity ──────────────────────────────────────────
6176
+ case 'outbound_activity_list':
6177
+ case 'outbound_activity_for_contact': {
6178
+ const forContact = name === 'outbound_activity_for_contact';
6179
+ const email = String(args.email ?? '').trim();
6180
+ // Required scoping identifier — never fall through to a site-wide listing.
6181
+ if (forContact && !email) return 'Error: email is required for outbound_activity_for_contact';
6182
+
6183
+ const params = new URLSearchParams();
6184
+ params.set('take', String(args.take ?? 25));
6185
+ params.set('skip', String(args.skip ?? 0));
6186
+ if (args.source) params.set('source', args.source);
6187
+ if (args.status) params.set('status', args.status);
6188
+ if (!forContact && args.search) params.set('search', args.search);
6189
+
6190
+ const path = forContact
6191
+ ? `/Marketing/Contacts/${encodeURIComponent(email)}/OutboundActivity?${params}`
6192
+ : `/Marketing/OutboundActivity?${params}`;
6193
+
6194
+ const r = await api('GET', path);
6195
+ const items = r?.items ?? [];
6196
+ const srcLabel = { Transactional: 'Transactional', Campaign: 'Sequence', Broadcast: 'Broadcast' };
6197
+
6198
+ const lines = [forContact ? `=== Outbound Activity: ${email} ===` : '=== Outbound Activity ==='];
6199
+
6200
+ // A partial feed is never reported as if it were complete.
6201
+ if (r?.degradedSources?.length) {
6202
+ lines.push(` ! INCOMPLETE - could not load: ${r.degradedSources.map(s => srcLabel[s] ?? s).join(', ')}. Rows from those systems are MISSING below.`);
6203
+ }
6204
+ if (!items.length) {
6205
+ lines.push(' (no outbound activity matched)');
6206
+ return lines.join('\n');
6207
+ }
6208
+
6209
+ for (const i of items) {
6210
+ const when = i.sentAt ? new Date(i.sentAt).toISOString() : 'not sent';
6211
+ lines.push(` [${i.status}] ${srcLabel[i.source] ?? i.source} ${when} -> ${i.toEmail ?? '(none)'}`);
6212
+ lines.push(` ${i.subject ?? ''}`);
6213
+ const meta = [];
6214
+ if (i.campaignOrBroadcastName) meta.push(`campaign: ${i.campaignOrBroadcastName}`);
6215
+ if (i.tag) meta.push(`tag: ${i.tag}`);
6216
+ if (i.channel && i.channel !== 'email') meta.push(`channel: ${i.channel}`);
6217
+ // Transactional sends have no open/click tracking at all — report nothing rather than a
6218
+ // zero that would read as a measured zero.
6219
+ if (i.source !== 'Transactional') meta.push(`opens ${i.openCount}, clicks ${i.clickCount}`);
6220
+ if (meta.length) lines.push(` ${meta.join(' | ')}`);
6221
+ }
6222
+ lines.push(` ${items.length} shown of ${r?.totalCountIsApproximate ? 'about ' : ''}${r?.totalCount ?? items.length}.`);
6223
+ return lines.join('\n');
6224
+ }
6225
+
6007
6226
  // ── Audit Trail ────────────────────────────────────────────────────────
6008
6227
  case 'audit_trail_list': {
6009
6228
  const params = new URLSearchParams();
@@ -6376,6 +6595,14 @@ async function handleTool(name, args) {
6376
6595
  ].join('\n');
6377
6596
  }
6378
6597
 
6598
+ case 'support_site_deployments': {
6599
+ const data = await api('GET', `/SupportTools/sites/${args.siteId}/deployments`);
6600
+ if (!data.deployments?.length) return `No active widget deployments for site ${args.siteId}.`;
6601
+ return data.deployments.map(d =>
6602
+ `[id=${d.id}] ${d.displayName || '(unnamed)'} | embed key: ${d.deploymentId} | team: ${d.teamId} | surveys: pre-chat ${d.preChatSurveyId ?? '—'}, post-chat ${d.postChatSurveyId ?? '—'}, unavailable ${d.unavailableSurveyId ?? '—'}`
6603
+ ).join('\n');
6604
+ }
6605
+
6379
6606
  case 'support_site_agents': {
6380
6607
  const agents = await api('GET', `/SupportTools/sites/${args.siteId}/agents`);
6381
6608
  if (!Array.isArray(agents) || !agents.length) return `No agents found for site ${args.siteId}.`;
@@ -6468,10 +6695,26 @@ async function handleTool(name, args) {
6468
6695
  }
6469
6696
 
6470
6697
  case 'calendly_get_status': {
6471
- const data = await api('GET', `/Calendly/superadmin/status?siteId=${args.siteId}`);
6472
- if (!data.configured) return `Site ${args.siteId}: Calendly NOT configured.`;
6698
+ // `env`, when given, targets one specific admin API environment instead of whatever
6699
+ // VELARO_ADMIN_API this server process happens to be configured with — see ADMIN_API_HOSTS
6700
+ // and resolveAdminBase() above. No default override here: an unspecified `env` means "use
6701
+ // this server's configured environment," same as every other tool in this file.
6702
+ const base = resolveAdminBase(args.env);
6703
+ const data = await api('GET', `/Calendly/superadmin/status?siteId=${args.siteId}`, undefined, base);
6704
+ const envSuffix = ` (${args.env ?? 'this server\'s configured env'}: ${base})`;
6705
+ if (!data.configured) {
6706
+ // exists === true means a row IS present but Enabled is false — a real, different state
6707
+ // from "never configured," which used to render identically (see CalendlyController.cs
6708
+ // SuperAdminStatus) and is one plausible (unconfirmed) explanation for the 2026-09-08
6709
+ // Donaldson bot Calendly status contradiction. exists === undefined means this admin API
6710
+ // build predates the field (staging/production deploy independently) — don't assert a
6711
+ // specific DB state the response didn't actually report.
6712
+ if (data.exists === true) return `Site ${args.siteId}${envSuffix}: Calendly configured but DISABLED (a config row exists with Enabled=false).`;
6713
+ if (data.exists === undefined) return `Site ${args.siteId}${envSuffix}: Calendly not enabled. (This API build does not report "exists" yet, so a disabled row can't be distinguished from no row at all — re-check once this endpoint is deployed there.)`;
6714
+ return `Site ${args.siteId}${envSuffix}: Calendly NOT configured (no config row found in this environment).`;
6715
+ }
6473
6716
  return [
6474
- `Site ${args.siteId}: Calendly configured ✓`,
6717
+ `Site ${args.siteId}${envSuffix}: Calendly configured ✓`,
6475
6718
  ` Account: ${data.userName}`,
6476
6719
  ` Default type: ${data.defaultEventTypeName || '(none)'}`,
6477
6720
  ` Single-use: ${data.useSingleUseLinks ? 'yes' : 'no'}`,
@@ -6480,20 +6723,22 @@ async function handleTool(name, args) {
6480
6723
  }
6481
6724
 
6482
6725
  case 'calendly_configure': {
6726
+ const base = resolveAdminBase(args.env);
6483
6727
  const res = await api('POST', `/Calendly/superadmin/config?siteId=${args.siteId}`, {
6484
6728
  accessToken: args.accessToken,
6485
6729
  displayName: args.displayName ?? '',
6486
6730
  defaultEventTypeUri: args.defaultEventTypeUri ?? '',
6487
6731
  defaultEventTypeName: args.defaultEventTypeName ?? '',
6488
6732
  useSingleUseLinks: args.useSingleUseLinks ?? true,
6489
- });
6733
+ }, base);
6490
6734
  if (!res?.success) throw new Error(res?.error || 'Configure failed');
6491
- return `✓ Calendly configured for site ${args.siteId}. Account: ${res.userName}`;
6735
+ return `✓ Calendly configured for site ${args.siteId} (${args.env ?? 'this server\'s configured env'}: ${base}). Account: ${res.userName}`;
6492
6736
  }
6493
6737
 
6494
6738
  case 'calendly_disconnect': {
6495
- await api('DELETE', `/Calendly/superadmin/config?siteId=${args.siteId}`);
6496
- return `✓ Calendly disconnected from site ${args.siteId}.`;
6739
+ const base = resolveAdminBase(args.env);
6740
+ await api('DELETE', `/Calendly/superadmin/config?siteId=${args.siteId}`, undefined, base);
6741
+ return `✓ Calendly disconnected from site ${args.siteId} (${args.env ?? 'this server\'s configured env'}: ${base}).`;
6497
6742
  }
6498
6743
 
6499
6744
  // ── Acuity Scheduling ─────────────────────────────────────────────────────
@@ -7641,6 +7886,15 @@ async function handleTool(name, args) {
7641
7886
  return `✅ Site ${args.siteId} comped on plan ${args.planKey}${expiry}. Use entitlement_set_override to customize features.`;
7642
7887
  }
7643
7888
 
7889
+ case 'entitlement_set_lock': {
7890
+ if (!args.siteId) throw new Error('siteId is required');
7891
+ const lockedAt = args.lockedAt ?? null;
7892
+ const result = await api('PUT', `/Entitlements/admin/lock/${args.siteId}`, { lockedAt });
7893
+ return lockedAt
7894
+ ? `✅ Site ${args.siteId} entitlements locked to ${result.entitlementLockedAt} (plan ${result.planKey}).`
7895
+ : `✅ Site ${args.siteId} entitlement lock cleared. Resolves against the current plan version again.`;
7896
+ }
7897
+
7644
7898
  case 'entitlement_seed_plans': {
7645
7899
  const result = await api('POST', '/Entitlements/admin/seed-plans');
7646
7900
  if (result.error) return `❌ ${result.error}`;