@velaro/mcp-server 0.6.51 → 0.6.53

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 +305 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/mcp-server",
3
- "version": "0.6.51",
3
+ "version": "0.6.53",
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);
@@ -94,6 +128,17 @@ async function messagingApi(path) {
94
128
  return res.json();
95
129
  }
96
130
 
131
+ // FIXED 2026-09-24: velaro-messaging's /superadmin/logs/search used to return a bare JSON array
132
+ // for some queries and {entries, nextCursor} for others, depending on which internal code path
133
+ // served the request (see that repo's LogsController.cs ENVELOPE SHAPE comment). It now always
134
+ // returns {entries, nextCursor} -- this helper also accepts a bare array so callers degrade
135
+ // gracefully rather than throwing if ever pointed at an older deploy.
136
+ function extractLogEntries(response) {
137
+ if (Array.isArray(response)) return response;
138
+ if (response && Array.isArray(response.entries)) return response.entries;
139
+ return [];
140
+ }
141
+
97
142
  async function messagingRequest(method, path, body) {
98
143
  if (!MESSAGING_API) throw new Error(
99
144
  'VELARO_MESSAGING_API is not configured. Add "VELARO_MESSAGING_API": "https://velaro-messaging-api-staging.azurewebsites.net" to your MCP env config.'
@@ -118,8 +163,8 @@ const messagingPut = (path, body) => messagingRequest('PUT', path, body);
118
163
  const messagingPatch = (path, body) => messagingRequest('PATCH', path, body);
119
164
  const messagingDel = (path) => messagingRequest('DELETE', path);
120
165
 
121
- async function api(method, path, body) {
122
- const res = await fetch(`${API_BASE}${path}`, {
166
+ async function api(method, path, body, base = API_BASE) {
167
+ const res = await fetch(`${base}${path}`, {
123
168
  method,
124
169
  headers: { Authorization: authHeader(), 'Content-Type': 'application/json' },
125
170
  body: body !== undefined ? JSON.stringify(body) : undefined,
@@ -127,6 +172,19 @@ async function api(method, path, body) {
127
172
  });
128
173
  if (!res.ok) {
129
174
  const text = await res.text().catch(() => '');
175
+ if ((res.status === 401 || res.status === 403) && base !== API_BASE) {
176
+ // Cross-environment call with the single global credential (MCP_KEY/JWT) — this session's
177
+ // credential is issued for one environment, so pointing a tool at the OTHER one via an
178
+ // explicit `env` override will look like a real auth failure rather than "wrong environment
179
+ // selected." Surface that explicitly instead of a bare HTTP error, so a caller doesn't
180
+ // misread it as "not configured" or a generic outage.
181
+ throw new Error(
182
+ `Velaro API ${method} ${path} -> ${res.status} against ${base}: this session's credentials ` +
183
+ `may not be valid for that environment (VELARO_MCP_KEY/VELARO_JWT are single, per-environment ` +
184
+ `credentials — re-run with credentials issued for that environment, or omit "env" to use ` +
185
+ `whichever environment this server is currently configured for). Raw response: ${text.slice(0, 300)}`
186
+ );
187
+ }
130
188
  throw new Error(`Velaro API ${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`);
131
189
  }
132
190
  const text = await res.text();
@@ -449,14 +507,16 @@ const TOOLS = [
449
507
  },
450
508
  {
451
509
  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.',
510
+ 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
511
  inputSchema: {
454
512
  type: 'object',
455
513
  properties: {
456
- app: { type: 'string', description: 'Which app to capture from', enum: ['admin', 'messaging'] },
514
+ app: { type: 'string', description: 'Which app to capture from', enum: ['admin', 'messaging', 'livefluence'] },
457
515
  route: { type: 'string', description: 'Route to navigate to, e.g. "/Settings/Routing"' },
458
516
  articleId: { type: 'number', description: 'Target article ID' },
459
517
  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.' },
518
+ click: { type: 'string', description: 'Selector to click after the route loads, before capturing (CSS, or Playwright\'s "text=..."/"role=..." engines) — e.g. "text=Export"' },
519
+ 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
520
  annotations: {
461
521
  type: 'array',
462
522
  description: 'Optional numbered/labeled circle annotations, in raw screenshot pixel coordinates.',
@@ -1195,6 +1255,47 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1195
1255
  required: ['message'],
1196
1256
  },
1197
1257
  },
1258
+ {
1259
+ name: 'teams_push_settings_get',
1260
+ 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.',
1261
+ inputSchema: { type: 'object', properties: {}, required: [] },
1262
+ },
1263
+ {
1264
+ name: 'teams_push_settings_update',
1265
+ 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.',
1266
+ inputSchema: {
1267
+ type: 'object',
1268
+ properties: {
1269
+ pushNewChat: { type: 'boolean', description: 'New chat alerts' },
1270
+ pushMissed: { type: 'boolean', description: 'Missed chat alerts' },
1271
+ pushTicketCreated: { type: 'boolean', description: 'Ticket created alerts' },
1272
+ pushTicketAssigned: { type: 'boolean', description: 'Ticket assigned alerts' },
1273
+ pushDailyDigest: { type: 'boolean', description: 'Daily digest (off by default)' },
1274
+ dailyPushCap: { type: 'number', description: 'Max alerts per day for this site' },
1275
+ resetDailyPushCap: { type: 'boolean', description: 'Clear the per-site cap (use platform default)' },
1276
+ },
1277
+ required: [],
1278
+ },
1279
+ },
1280
+ {
1281
+ name: 'teams_channel_config_get',
1282
+ 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.',
1283
+ inputSchema: { type: 'object', properties: {}, required: [] },
1284
+ },
1285
+ {
1286
+ name: 'teams_channel_config_update',
1287
+ 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.',
1288
+ inputSchema: {
1289
+ type: 'object',
1290
+ properties: {
1291
+ enabled: { type: 'boolean', description: 'Turn Teams Channel (customer chat) on or off for this site' },
1292
+ 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.' },
1293
+ teamId: { type: 'number', description: 'Route new conversations to this team id (must be a real, non-deleted team on this site)' },
1294
+ clearTeamId: { type: 'boolean', description: 'Clear the team override and fall back to the site default team' },
1295
+ },
1296
+ required: [],
1297
+ },
1298
+ },
1198
1299
  {
1199
1300
  name: 'teams_notify_card',
1200
1301
  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 +1828,38 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1727
1828
  },
1728
1829
  },
1729
1830
 
1831
+ // ── Unified Outbound Activity (Klaviyo-parity Gap 6) ──────────────────────
1832
+ {
1833
+ name: 'outbound_activity_list',
1834
+ 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.',
1835
+ inputSchema: {
1836
+ type: 'object',
1837
+ properties: {
1838
+ take: { type: 'number', description: 'Page size, 1-100 (default 25)' },
1839
+ skip: { type: 'number', description: 'Rows to skip (default 0)' },
1840
+ source: { type: 'string', enum: ['Transactional', 'Campaign', 'Broadcast'], description: 'Limit to one send system. Omit for all three.' },
1841
+ status: { type: 'string', enum: ['Sent', 'Pending', 'Failed', 'Unsubscribed', 'Skipped'], description: 'Normalized status shared across all three systems' },
1842
+ search: { type: 'string', description: 'Match recipient address or subject' },
1843
+ },
1844
+ required: [],
1845
+ },
1846
+ },
1847
+ {
1848
+ name: 'outbound_activity_for_contact',
1849
+ 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.',
1850
+ inputSchema: {
1851
+ type: 'object',
1852
+ properties: {
1853
+ email: { type: 'string', description: 'Exact recipient email address (required)' },
1854
+ take: { type: 'number', description: 'Page size, 1-100 (default 25)' },
1855
+ skip: { type: 'number', description: 'Rows to skip (default 0)' },
1856
+ source: { type: 'string', enum: ['Transactional', 'Campaign', 'Broadcast'], description: 'Limit to one send system. Omit for all three.' },
1857
+ status: { type: 'string', enum: ['Sent', 'Pending', 'Failed', 'Unsubscribed', 'Skipped'], description: 'Normalized status shared across all three systems' },
1858
+ },
1859
+ required: ['email'],
1860
+ },
1861
+ },
1862
+
1730
1863
  // ── Conversion tracking tools ─────────────────────────────────────────────
1731
1864
  {
1732
1865
  name: 'conversion_list_goals',
@@ -1822,13 +1955,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1822
1955
  inputSchema: {
1823
1956
  type: 'object',
1824
1957
  properties: {
1825
- q: { type: 'string', description: 'Text to search for in log messages (plain or regex)' },
1826
- level: { type: 'string', enum: ['ERROR', 'WARN', 'INFO', 'DEBUG'], description: 'Filter by log level' },
1827
- integration: { type: 'string', description: 'Integration tag filter (HubSpot, [Skill], NetSuite, etc.)' },
1828
- siteId: { type: 'number', description: 'Filter by site ID' },
1829
- last: { type: 'string', description: 'Time window: 30m, 2h, 1d (default: 2h)' },
1830
- take: { type: 'number', description: 'Max results (default: 50, max: 500)' },
1831
- regex: { type: 'boolean', description: 'Treat q as a regex pattern (default: false)' },
1958
+ q: { type: 'string', description: 'Text to search for in log messages (plain or regex)' },
1959
+ level: { type: 'string', enum: ['ERROR', 'WARN', 'INFO', 'DEBUG'], description: 'Filter by log level' },
1960
+ logger: { type: 'string', description: 'Logger name filter (substring match, or regex when regex=true)' },
1961
+ integration: { type: 'string', description: 'Integration tag filter (HubSpot, [Skill], NetSuite, etc.)' },
1962
+ siteId: { type: 'number', description: 'Filter by site ID' },
1963
+ source: { type: 'string', description: 'Source/app-tag filter, e.g. velaro-messaging-Staging' },
1964
+ platform: { type: 'string', enum: ['v10', 'v20', 'admin'], description: 'Filter by platform' },
1965
+ environment: { type: 'string', enum: ['Production', 'Staging', 'Development'] },
1966
+ correlationId: { type: 'string', description: 'Exact correlation/trace ID filter' },
1967
+ last: { type: 'string', description: 'Time window: 30m, 2h, 1d (default: 2h)' },
1968
+ take: { type: 'number', description: 'Max results (default: 50, max: 500)' },
1969
+ regex: { type: 'boolean', description: 'Treat q/logger as a regex pattern (default: false)' },
1832
1970
  },
1833
1971
  },
1834
1972
  },
@@ -2112,6 +2250,15 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2112
2250
  required: ['siteId'],
2113
2251
  },
2114
2252
  },
2253
+ {
2254
+ name: 'support_site_deployments',
2255
+ 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.',
2256
+ inputSchema: {
2257
+ type: 'object',
2258
+ properties: { siteId: { type: 'number', description: 'Target site ID' } },
2259
+ required: ['siteId'],
2260
+ },
2261
+ },
2115
2262
  {
2116
2263
  name: 'support_site_agents',
2117
2264
  description: 'List all agents/admins on a customer site with their roles, active status, and last login date. Velaro staff only.',
@@ -2231,11 +2378,12 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2231
2378
  // ── Calendly integration management (superadmin) ──────────────────────────
2232
2379
  {
2233
2380
  name: 'calendly_get_status',
2234
- description: 'Check whether Calendly is configured for a site and show the current settings. Velaro admin only.',
2381
+ 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
2382
  inputSchema: {
2236
2383
  type: 'object',
2237
2384
  properties: {
2238
2385
  siteId: { type: 'number', description: 'Site ID to check' },
2386
+ 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
2387
  },
2240
2388
  required: ['siteId'],
2241
2389
  },
@@ -2252,6 +2400,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2252
2400
  defaultEventTypeName: { type: 'string', description: 'Human-readable name for the default event type. Optional.' },
2253
2401
  displayName: { type: 'string', description: 'Label shown in admin UI, e.g. "Acme Demo Booking". Optional.' },
2254
2402
  useSingleUseLinks:{ type: 'boolean', description: 'true = generate single-use links per visitor (requires scheduling_links:write). false = reusable event-type URL. Default: true.' },
2403
+ 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
2404
  },
2256
2405
  required: ['siteId', 'accessToken'],
2257
2406
  },
@@ -2263,6 +2412,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2263
2412
  type: 'object',
2264
2413
  properties: {
2265
2414
  siteId: { type: 'number', description: 'Site ID to disconnect' },
2415
+ 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
2416
  },
2267
2417
  required: ['siteId'],
2268
2418
  },
@@ -3316,6 +3466,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3316
3466
  required: ['siteId', 'planKey'],
3317
3467
  },
3318
3468
  },
3469
+ { // wires EntitlementsController.SetEntitlementLock (PUT Entitlements/admin/lock/{siteId})
3470
+ name: 'entitlement_set_lock',
3471
+ 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.',
3472
+ inputSchema: {
3473
+ type: 'object',
3474
+ properties: {
3475
+ siteId: { type: 'number', description: 'Site ID to lock or unlock.' },
3476
+ lockedAt: { type: 'string', description: 'ISO 8601 timestamp to lock entitlement resolution to. Pass null (or omit) to clear an existing lock.' },
3477
+ },
3478
+ required: ['siteId'],
3479
+ },
3480
+ },
3319
3481
  {
3320
3482
  name: 'entitlement_seed_plans',
3321
3483
  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 +4480,8 @@ async function handleTool(name, args) {
4318
4480
  articleId: args.articleId,
4319
4481
  heading: args.heading,
4320
4482
  annotations: args.annotations ?? [],
4483
+ click: args.click,
4484
+ popup: args.popup ?? false,
4321
4485
  auth: { token, apiBase: API_BASE },
4322
4486
  });
4323
4487
  return result.insertedAfterHeading
@@ -4899,6 +5063,26 @@ async function handleTool(name, args) {
4899
5063
  return `Sent to Teams webhook "${webhook.name}".`;
4900
5064
  }
4901
5065
 
5066
+ case 'teams_push_settings_get':
5067
+ return await api('GET', '/TeamsBot/PushSettings');
5068
+
5069
+ case 'teams_push_settings_update': {
5070
+ const body = {};
5071
+ for (const k of ['pushNewChat','pushMissed','pushTicketCreated','pushTicketAssigned','pushDailyDigest','dailyPushCap','resetDailyPushCap'])
5072
+ if (args[k] !== undefined) body[k] = args[k];
5073
+ return await api('PUT', '/TeamsBot/PushSettings', body);
5074
+ }
5075
+
5076
+ case 'teams_channel_config_get':
5077
+ return await api('GET', '/TeamsBot/ChannelConfig');
5078
+
5079
+ case 'teams_channel_config_update': {
5080
+ const body = {};
5081
+ for (const k of ['enabled','welcomeMessage','teamId','clearTeamId'])
5082
+ if (args[k] !== undefined) body[k] = args[k];
5083
+ return await api('PUT', '/TeamsBot/ChannelConfig', body);
5084
+ }
5085
+
4902
5086
  case 'teams_notify_card': {
4903
5087
  const webhooks = await api('GET', '/TeamsNotification');
4904
5088
  if (!Array.isArray(webhooks) || !webhooks.length)
@@ -5301,7 +5485,8 @@ async function handleTool(name, args) {
5301
5485
  const velaroLoggerP = safeJson(async () => {
5302
5486
  if (!MESSAGING_API) return { _error: 'VELARO_MESSAGING_API not set — add it to MCP env config' };
5303
5487
  const q = encodeURIComponent('ERROR|WF-AI-ROUTE|PERF-SLOW');
5304
- return messagingApi(`/superadmin/logs/search?q=${q}&from=${encodeURIComponent(from1h)}&take=30&regex=true`);
5488
+ const res = await messagingApi(`/superadmin/logs/search?q=${q}&from=${encodeURIComponent(from1h)}&take=30&regex=true`);
5489
+ return extractLogEntries(res);
5305
5490
  });
5306
5491
 
5307
5492
  // ── Layer 4b: Loggly ERROR + SLOW + AI-routing logs (last 1h) — SECONDARY (migrating away)
@@ -6004,6 +6189,57 @@ async function handleTool(name, args) {
6004
6189
  return lines.join('\n');
6005
6190
  }
6006
6191
 
6192
+ // ── Unified Outbound Activity ──────────────────────────────────────────
6193
+ case 'outbound_activity_list':
6194
+ case 'outbound_activity_for_contact': {
6195
+ const forContact = name === 'outbound_activity_for_contact';
6196
+ const email = String(args.email ?? '').trim();
6197
+ // Required scoping identifier — never fall through to a site-wide listing.
6198
+ if (forContact && !email) return 'Error: email is required for outbound_activity_for_contact';
6199
+
6200
+ const params = new URLSearchParams();
6201
+ params.set('take', String(args.take ?? 25));
6202
+ params.set('skip', String(args.skip ?? 0));
6203
+ if (args.source) params.set('source', args.source);
6204
+ if (args.status) params.set('status', args.status);
6205
+ if (!forContact && args.search) params.set('search', args.search);
6206
+
6207
+ const path = forContact
6208
+ ? `/Marketing/Contacts/${encodeURIComponent(email)}/OutboundActivity?${params}`
6209
+ : `/Marketing/OutboundActivity?${params}`;
6210
+
6211
+ const r = await api('GET', path);
6212
+ const items = r?.items ?? [];
6213
+ const srcLabel = { Transactional: 'Transactional', Campaign: 'Sequence', Broadcast: 'Broadcast' };
6214
+
6215
+ const lines = [forContact ? `=== Outbound Activity: ${email} ===` : '=== Outbound Activity ==='];
6216
+
6217
+ // A partial feed is never reported as if it were complete.
6218
+ if (r?.degradedSources?.length) {
6219
+ lines.push(` ! INCOMPLETE - could not load: ${r.degradedSources.map(s => srcLabel[s] ?? s).join(', ')}. Rows from those systems are MISSING below.`);
6220
+ }
6221
+ if (!items.length) {
6222
+ lines.push(' (no outbound activity matched)');
6223
+ return lines.join('\n');
6224
+ }
6225
+
6226
+ for (const i of items) {
6227
+ const when = i.sentAt ? new Date(i.sentAt).toISOString() : 'not sent';
6228
+ lines.push(` [${i.status}] ${srcLabel[i.source] ?? i.source} ${when} -> ${i.toEmail ?? '(none)'}`);
6229
+ lines.push(` ${i.subject ?? ''}`);
6230
+ const meta = [];
6231
+ if (i.campaignOrBroadcastName) meta.push(`campaign: ${i.campaignOrBroadcastName}`);
6232
+ if (i.tag) meta.push(`tag: ${i.tag}`);
6233
+ if (i.channel && i.channel !== 'email') meta.push(`channel: ${i.channel}`);
6234
+ // Transactional sends have no open/click tracking at all — report nothing rather than a
6235
+ // zero that would read as a measured zero.
6236
+ if (i.source !== 'Transactional') meta.push(`opens ${i.openCount}, clicks ${i.clickCount}`);
6237
+ if (meta.length) lines.push(` ${meta.join(' | ')}`);
6238
+ }
6239
+ lines.push(` ${items.length} shown of ${r?.totalCountIsApproximate ? 'about ' : ''}${r?.totalCount ?? items.length}.`);
6240
+ return lines.join('\n');
6241
+ }
6242
+
6007
6243
  // ── Audit Trail ────────────────────────────────────────────────────────
6008
6244
  case 'audit_trail_list': {
6009
6245
  const params = new URLSearchParams();
@@ -6102,13 +6338,18 @@ async function handleTool(name, args) {
6102
6338
  case 'velaro_logs_search': {
6103
6339
  const from = new Date(Date.now() - parseDuration(args.last));
6104
6340
  const params = new URLSearchParams({ from: from.toISOString(), take: String(args.take ?? 50) });
6105
- if (args.q) params.set('q', args.q);
6106
- if (args.level) params.set('level', args.level.toUpperCase());
6107
- if (args.integration) params.set('integration', args.integration);
6108
- if (args.siteId) params.set('siteId', String(args.siteId));
6109
- if (args.regex) params.set('regex', 'true');
6110
- const entries = await messagingApi(`/superadmin/logs/search?${params}`);
6111
- if (!Array.isArray(entries) || !entries.length) return 'No results.';
6341
+ if (args.q) params.set('q', args.q);
6342
+ if (args.level) params.set('level', args.level.toUpperCase());
6343
+ if (args.logger) params.set('logger', args.logger);
6344
+ if (args.integration) params.set('integration', args.integration);
6345
+ if (args.siteId) params.set('siteId', String(args.siteId));
6346
+ if (args.source) params.set('source', args.source);
6347
+ if (args.platform) params.set('platform', args.platform);
6348
+ if (args.environment) params.set('environment', args.environment);
6349
+ if (args.correlationId) params.set('correlationId', args.correlationId);
6350
+ if (args.regex) params.set('regex', 'true');
6351
+ const entries = extractLogEntries(await messagingApi(`/superadmin/logs/search?${params}`));
6352
+ if (!entries.length) return 'No results.';
6112
6353
  const lines = [`${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC\n`];
6113
6354
  for (const e of entries) {
6114
6355
  const ts = new Date(e.timestamp ?? e.LogTimestamp).toISOString().replace('T', ' ').slice(0, 19);
@@ -6376,6 +6617,14 @@ async function handleTool(name, args) {
6376
6617
  ].join('\n');
6377
6618
  }
6378
6619
 
6620
+ case 'support_site_deployments': {
6621
+ const data = await api('GET', `/SupportTools/sites/${args.siteId}/deployments`);
6622
+ if (!data.deployments?.length) return `No active widget deployments for site ${args.siteId}.`;
6623
+ return data.deployments.map(d =>
6624
+ `[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 ?? '—'}`
6625
+ ).join('\n');
6626
+ }
6627
+
6379
6628
  case 'support_site_agents': {
6380
6629
  const agents = await api('GET', `/SupportTools/sites/${args.siteId}/agents`);
6381
6630
  if (!Array.isArray(agents) || !agents.length) return `No agents found for site ${args.siteId}.`;
@@ -6468,10 +6717,26 @@ async function handleTool(name, args) {
6468
6717
  }
6469
6718
 
6470
6719
  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.`;
6720
+ // `env`, when given, targets one specific admin API environment instead of whatever
6721
+ // VELARO_ADMIN_API this server process happens to be configured with — see ADMIN_API_HOSTS
6722
+ // and resolveAdminBase() above. No default override here: an unspecified `env` means "use
6723
+ // this server's configured environment," same as every other tool in this file.
6724
+ const base = resolveAdminBase(args.env);
6725
+ const data = await api('GET', `/Calendly/superadmin/status?siteId=${args.siteId}`, undefined, base);
6726
+ const envSuffix = ` (${args.env ?? 'this server\'s configured env'}: ${base})`;
6727
+ if (!data.configured) {
6728
+ // exists === true means a row IS present but Enabled is false — a real, different state
6729
+ // from "never configured," which used to render identically (see CalendlyController.cs
6730
+ // SuperAdminStatus) and is one plausible (unconfirmed) explanation for the 2026-09-08
6731
+ // Donaldson bot Calendly status contradiction. exists === undefined means this admin API
6732
+ // build predates the field (staging/production deploy independently) — don't assert a
6733
+ // specific DB state the response didn't actually report.
6734
+ if (data.exists === true) return `Site ${args.siteId}${envSuffix}: Calendly configured but DISABLED (a config row exists with Enabled=false).`;
6735
+ 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.)`;
6736
+ return `Site ${args.siteId}${envSuffix}: Calendly NOT configured (no config row found in this environment).`;
6737
+ }
6473
6738
  return [
6474
- `Site ${args.siteId}: Calendly configured ✓`,
6739
+ `Site ${args.siteId}${envSuffix}: Calendly configured ✓`,
6475
6740
  ` Account: ${data.userName}`,
6476
6741
  ` Default type: ${data.defaultEventTypeName || '(none)'}`,
6477
6742
  ` Single-use: ${data.useSingleUseLinks ? 'yes' : 'no'}`,
@@ -6480,20 +6745,22 @@ async function handleTool(name, args) {
6480
6745
  }
6481
6746
 
6482
6747
  case 'calendly_configure': {
6748
+ const base = resolveAdminBase(args.env);
6483
6749
  const res = await api('POST', `/Calendly/superadmin/config?siteId=${args.siteId}`, {
6484
6750
  accessToken: args.accessToken,
6485
6751
  displayName: args.displayName ?? '',
6486
6752
  defaultEventTypeUri: args.defaultEventTypeUri ?? '',
6487
6753
  defaultEventTypeName: args.defaultEventTypeName ?? '',
6488
6754
  useSingleUseLinks: args.useSingleUseLinks ?? true,
6489
- });
6755
+ }, base);
6490
6756
  if (!res?.success) throw new Error(res?.error || 'Configure failed');
6491
- return `✓ Calendly configured for site ${args.siteId}. Account: ${res.userName}`;
6757
+ return `✓ Calendly configured for site ${args.siteId} (${args.env ?? 'this server\'s configured env'}: ${base}). Account: ${res.userName}`;
6492
6758
  }
6493
6759
 
6494
6760
  case 'calendly_disconnect': {
6495
- await api('DELETE', `/Calendly/superadmin/config?siteId=${args.siteId}`);
6496
- return `✓ Calendly disconnected from site ${args.siteId}.`;
6761
+ const base = resolveAdminBase(args.env);
6762
+ await api('DELETE', `/Calendly/superadmin/config?siteId=${args.siteId}`, undefined, base);
6763
+ return `✓ Calendly disconnected from site ${args.siteId} (${args.env ?? 'this server\'s configured env'}: ${base}).`;
6497
6764
  }
6498
6765
 
6499
6766
  // ── Acuity Scheduling ─────────────────────────────────────────────────────
@@ -7641,6 +7908,15 @@ async function handleTool(name, args) {
7641
7908
  return `✅ Site ${args.siteId} comped on plan ${args.planKey}${expiry}. Use entitlement_set_override to customize features.`;
7642
7909
  }
7643
7910
 
7911
+ case 'entitlement_set_lock': {
7912
+ if (!args.siteId) throw new Error('siteId is required');
7913
+ const lockedAt = args.lockedAt ?? null;
7914
+ const result = await api('PUT', `/Entitlements/admin/lock/${args.siteId}`, { lockedAt });
7915
+ return lockedAt
7916
+ ? `✅ Site ${args.siteId} entitlements locked to ${result.entitlementLockedAt} (plan ${result.planKey}).`
7917
+ : `✅ Site ${args.siteId} entitlement lock cleared. Resolves against the current plan version again.`;
7918
+ }
7919
+
7644
7920
  case 'entitlement_seed_plans': {
7645
7921
  const result = await api('POST', '/Entitlements/admin/seed-plans');
7646
7922
  if (result.error) return `❌ ${result.error}`;