@velaro/mcp-server 0.6.103 → 0.6.104

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/server.js CHANGED
@@ -40,7 +40,7 @@
40
40
  * VELARO_MCP_MODE=http PORT=3000 node server.js
41
41
  */
42
42
 
43
- import { describeRawMeta, fetchRetry429 } from './log-search.js';
43
+ import { buildRawLogQuery, describeRawMeta, fetchRetry429, resolveLogWindow } from './log-search.js';
44
44
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
45
45
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
46
46
  import {
@@ -49,6 +49,8 @@ import {
49
49
  GetPromptRequestSchema,
50
50
  ListPromptsRequestSchema,
51
51
  } from '@modelcontextprotocol/sdk/types.js';
52
+ import { withAnnotations } from './tool-annotations.js';
53
+ import { resolveMessagingApi } from './messaging-base.js';
52
54
 
53
55
  // Default was 'https://help.velaro.com'. Renamed to the recognizable admin API hostname for
54
56
  // clarity during the 2026-09-10 Donaldson bot Calendly status investigation
@@ -59,7 +61,7 @@ import {
59
61
  // caused that investigation's status/reality contradiction, it was NOT this default resolving to a
60
62
  // broken or different host; that theory is disproven. Left as a naming clarity improvement only.
61
63
  const API_BASE = process.env.VELARO_ADMIN_API || 'https://api-admin-us-east.velaro.com';
62
- const MESSAGING_API = process.env.VELARO_MESSAGING_API || null;
64
+ const MESSAGING_API = resolveMessagingApi(process.env.VELARO_MESSAGING_API, API_BASE);
63
65
  const MCP_KEY = process.env.VELARO_MCP_KEY;
64
66
  const JWT = process.env.VELARO_JWT;
65
67
 
@@ -320,7 +322,7 @@ async function gatewayInit() {
320
322
  // Tools served by velaro-messaging's admin-tools gateway (McpAdminGatewayController, POST /mcp/v1/admin-tools/call,
321
323
  // no session). The admin /mcp gateway only serves kb_* tools, so these used to fail there.
322
324
  const MESSAGING_ADMIN_TOOLS = new Set([
323
- 'workflow_create', 'workflow_list', 'workflow_get', 'workflow_toggle',
325
+ 'workflow_create', 'workflow_list', 'workflow_get', 'workflow_toggle', 'workflow_run_now',
324
326
  'bot_create', 'bot_list', 'bot_get', 'bot_update_prompt', 'bot_set_knowledge',
325
327
  'ai_config_attach_index', 'ai_config_set_reply_format', 'call_pop_settings_get', 'call_pop_settings_set',
326
328
  'crm_mobile_fields_get', 'crm_mobile_fields_set',
@@ -488,7 +490,7 @@ const TOOLS = [
488
490
  },
489
491
  {
490
492
  name: 'notifications_get',
491
- description: "Get the calling agent's own notification/SMS alert settings (new conversation, missed chat, unassign warning, text-alert toggles, SMS phone number).",
493
+ description: "Get the calling agent's own notification/SMS alert settings (new conversation, missed chat, unassign warning, text-alert toggles, SMS phone number) plus alertRouting: which Channel (web, sms, whatsapp, appleMessages, email, phone) x Handling (mine, unassigned, bot, teammates) x Kind (conversations, tickets, calls, visitors) alerts fire, and ivrBotAlerts (opt-in for inbound IVR bot chats).",
492
494
  inputSchema: { type: 'object', properties: {}, required: [] },
493
495
  },
494
496
  {
@@ -508,6 +510,35 @@ const TOOLS = [
508
510
  smsOnUnassignWarning: { type: 'boolean', description: 'Send an SMS on unassign warning' },
509
511
  useProfilePhone: { type: 'boolean', description: 'Use the profile phone number for SMS alerts' },
510
512
  smsAlertPhone: { type: 'string', description: 'Phone number for SMS alerts (E.164, e.g. +14155551234)' },
513
+ alertRouting: {
514
+ type: 'object',
515
+ description: 'Alert routing, partial update (omitted keys unchanged). An alert fires only if its channel, handling and kind are all on. Example: {"channels":{"sms":true,"web":false},"handling":{"mine":true,"unassigned":false},"kinds":{"tickets":false},"ivrBotAlerts":true}',
516
+ properties: {
517
+ channels: { type: 'object', description: 'Keys: web, sms, whatsapp, appleMessages, email, phone (booleans)' },
518
+ handling: { type: 'object', description: 'Keys: mine, unassigned, bot, teammates (booleans, multi-select)' },
519
+ kinds: { type: 'object', description: 'Keys: conversations, tickets, calls, visitors (booleans)' },
520
+ ivrBotAlerts: { type: 'boolean', description: 'Alert for inbound IVR / phone bot chats (default off)' },
521
+ },
522
+ },
523
+ },
524
+ },
525
+ },
526
+ {
527
+ name: 'signature_policy_get',
528
+ description: "Get the company email signature policy for the calling admin's site: the company-wide row (teamId 0), any per-team overrides, and the suggested legal disclaimer text. Modes: locked (users cannot use personal signatures), company-default (company signature is the default, personal allowed), personal-only (the default when nothing is set). The legal disclaimer is off unless turned on. Admin only.",
529
+ inputSchema: { type: 'object', properties: {}, required: [] },
530
+ },
531
+ {
532
+ name: 'signature_policy_set',
533
+ description: "Update the company email signature policy (admin only). Only the fields passed are changed; the current row is read first and merged. teamId 0 (default) is the company-wide policy; a team id sets a per-team override. Pass mode 'default' or null to clear a value back to following the company-wide setting. Use signature_policy_get first.",
534
+ inputSchema: {
535
+ type: 'object',
536
+ properties: {
537
+ teamId: { type: 'integer', description: '0 = company-wide (default), otherwise the team id for a per-team override' },
538
+ mode: { type: 'string', enum: ['locked', 'company-default', 'personal-only', 'default'], description: 'locked | company-default | personal-only | default (follow company-wide)' },
539
+ corporateSignature: { type: ['string', 'null'], description: 'Company signature HTML, max 4000 characters. Empty or null clears it.' },
540
+ disclaimerEnabled: { type: ['boolean', 'null'], description: 'Turn the legal disclaimer on or off. null follows the company-wide choice. Off by default.' },
541
+ disclaimerText: { type: ['string', 'null'], description: 'Legal disclaimer text, max 2000 characters. Empty or null clears it.' },
511
542
  },
512
543
  },
513
544
  },
@@ -1058,6 +1089,18 @@ const TOOLS = [
1058
1089
  required: ['id', 'enabled'],
1059
1090
  },
1060
1091
  },
1092
+ {
1093
+ name: 'workflow_run_now',
1094
+ description: 'Queue an enabled workflow to run now, optionally seeded with a shallow JSON context object (max 50 keys, 4KB) that becomes the run\'s workflow attributes.',
1095
+ inputSchema: {
1096
+ type: 'object',
1097
+ properties: {
1098
+ id: { type: 'number', description: 'Workflow ID' },
1099
+ context: { type: 'object', description: 'Optional shallow object of values to seed the run, e.g. {"customerId":"123"}' },
1100
+ },
1101
+ required: ['id'],
1102
+ },
1103
+ },
1061
1104
  {
1062
1105
  name: 'workflow_create',
1063
1106
  description: 'Create a new workflow with optional pre-built nodes. Use this to build a workflow for the user from a description. Pass initialNodesBlob as a JSON string of the nodes object (keys are string IDs "1","2","3"..., values are node objects). For integration AI workflows, use 3 nodes: trigger (type:"trigger", trigger:"conversation_opened", overrideRouting:true, channels:[], triggerExpression:null), sendMessage (type:"action", actionType:"sendMessage", text:"greeting", child:3), and the AI node (type:"action", actionType:"dynamicsAI"|"netsuiteAI"|"hubSpotAI"|"shopifyAI"|"bigCommerceAI"|"woocommerceAI", configId fields null, aiConfigId:null, mcpServerIds:[]). The workflow is created disabled — activate it separately with workflow_toggle.',
@@ -1087,6 +1130,8 @@ Key node shapes:
1087
1130
  propName:"myAnswerVar" } // variable that stores the result as JSON array string
1088
1131
  • condition: { type:"action", actionType:"condition", conditionOptions:[...] }
1089
1132
  • transferToAI: { type:"action", actionType:"transferToAI", aiConfigId:N }
1133
+ • sendSurvey: { type:"action", actionType:"sendSurvey", sendSurveyId:N, surveyDeliveryMode:"conversational"|"form", closeAfterSurvey:true|false, child:N }
1134
+ closeAfterSurvey (optional, default false): when true the chat closes once the visitor completes the survey (use for Unavailable/after-hours surveys that replace a live chat). Omit to keep the old behaviour.
1090
1135
  AI follow-up suggestions ("AI mode"): add suggestFollowUps:true|false (omit = use the account default, set via ai_followups_site_set), suggestFollowUpCount:2-4, suggestFollowUpInstruction:"<=300 chars". After each web-chat bot reply the bot offers short tappable follow-up replies; typing stays available.
1091
1136
 
1092
1137
  For multiSelect dynamic options: the variable named in dynamicOptionsVariable must contain a JSON array like [{"label":"Red","value":"red"},{"label":"Blue","value":"blue"}]. Populate it via an HTTP Request node before the ask node.`,
@@ -1471,6 +1516,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1471
1516
  required: ['id'],
1472
1517
  },
1473
1518
  },
1519
+ {
1520
+ name: 'copilot_studio_ask',
1521
+ description: 'Live diagnostic: ask a connected Copilot Studio / Azure AI agent a real question and classify the reply as answered, vendor_limit (usage limit / unavailable) or no_answer (fallback topic). Use to prove the agent actually answers, not just that credentials work.',
1522
+ inputSchema: {
1523
+ type: 'object',
1524
+ properties: {
1525
+ id: { type: 'number', description: 'Config ID from copilot_studio_list' },
1526
+ question: { type: 'string', description: 'A real data-bound question for the agent' },
1527
+ },
1528
+ required: ['id', 'question'],
1529
+ },
1530
+ },
1474
1531
 
1475
1532
  // ── Monday.com field mapping / CRM search ────────────────────────────────
1476
1533
  {
@@ -1661,6 +1718,11 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1661
1718
  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.' },
1662
1719
  teamId: { type: 'number', description: 'Route new conversations to this team id (must be a real, non-deleted team on this site)' },
1663
1720
  clearTeamId: { type: 'boolean', description: 'Clear the team override and fall back to the site default team' },
1721
+ prechatEnabled: { type: 'boolean', description: 'Ask the Teams user prechat questions before a conversation opens' },
1722
+ prechatFields: { type: 'array', description: 'Replaces the prechat questions (max 6). Key "email" is validated as an email.', items: { type: 'object', properties: { key: { type: 'string' }, label: { type: 'string' }, required: { type: 'boolean' } }, required: ['key', 'label'] } },
1723
+ quickReplies: { type: 'array', description: 'Replaces the quick-reply buttons (max 6, 40 chars each); empty array clears them', items: { type: 'string' } },
1724
+ postChatEnabled:{ type: 'boolean', description: 'Send postChatMessage when an agent closes the conversation' },
1725
+ postChatMessage:{ type: 'string', description: 'Post-chat text, {{user.name}} = first name, max 1000 chars; empty string clears it' },
1664
1726
  },
1665
1727
  required: [],
1666
1728
  },
@@ -2381,6 +2443,24 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2381
2443
  required: ['siteId', 'indexName'],
2382
2444
  },
2383
2445
  },
2446
+ {
2447
+ name: 'index_set_profile',
2448
+ description: 'Velaro staff only. Set the ingestion profile of ONE named index on ANY site (chunk strategy, chunk size/overlap, purpose, search size, excludeFromAllSources). Apply-if-present: only fields you pass change. Changing the profile versions the content hash so the next ingest re-chunks. Wraps PUT KnowledgeSetup/Staff/{siteId}/index-profile (velaro-messaging).',
2449
+ inputSchema: {
2450
+ type: 'object',
2451
+ properties: {
2452
+ siteId: { type: 'number', description: 'Target site ID' },
2453
+ indexName: { type: 'string', description: 'Index name on that site, e.g. "100140-troubleshooting"' },
2454
+ chunkStrategy: { type: 'string', enum: ['procedure', 'section'], description: 'Chunk strategy' },
2455
+ chunkSize: { type: 'number', description: 'Chunk size' },
2456
+ chunkOverlap: { type: 'number', description: 'Chunk overlap' },
2457
+ purpose: { type: 'string', enum: ['general', 'troubleshooting', 'catalog'], description: 'Index purpose' },
2458
+ searchSize: { type: 'number', description: 'Results per search' },
2459
+ excludeFromAllSources: { type: 'boolean', description: 'Keep out of the ["*"] all-sources search' },
2460
+ },
2461
+ required: ['siteId', 'indexName'],
2462
+ },
2463
+ },
2384
2464
  {
2385
2465
  name: 'uploaded_file_reindex',
2386
2466
  description: 'Re-index an already-uploaded AI training file from blob storage — use when a file shows 0 chunks (e.g. PDF uploaded before PDF support was added). Reads from blob and re-runs full ingestion. Use index_status after ~30s to verify chunks appear.',
@@ -2626,6 +2706,29 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2626
2706
  required: ['startDate', 'endDate'],
2627
2707
  },
2628
2708
  },
2709
+ {
2710
+ name: 'report_performance_by_country_get',
2711
+ description: 'Performance by Country report (US vs Canada vs Europe vs Other): engagements (handled, missed, abandoned), performance (first response, handle time, bot containment, transfers, service level) and scores (CSAT, sentiment) per country bucket and per agent. attribution: visitor (visitor GeoIP country, default) | agent (country on the agent profile) | group (the site saved group-to-bucket map).',
2712
+ inputSchema: {
2713
+ type: 'object',
2714
+ properties: {
2715
+ startDate: { type: 'string', description: 'ISO 8601 start. Default: 30 days ago' },
2716
+ endDate: { type: 'string', description: 'ISO 8601 end. Default: now' },
2717
+ attribution: { type: 'string', enum: ['visitor', 'agent', 'group'], description: 'Default: visitor' },
2718
+ channels: { type: 'array', items: { type: 'string' }, description: 'Optional ConversationSource filter, e.g. ["Web","TwilioSms"]. Default: all channels' },
2719
+ },
2720
+ },
2721
+ },
2722
+ {
2723
+ name: 'report_performance_by_country_set_groups',
2724
+ description: 'Read or set the per-site mapping of Velaro groups (teams) to Performance by Country buckets. With no map, returns the site groups and the current mapping. With map ({"<teamId>": "US|Canada|Europe|any label"}) it REPLACES the mapping; unmapped groups report as Other. Only the site own groups are kept.',
2725
+ inputSchema: {
2726
+ type: 'object',
2727
+ properties: {
2728
+ map: { type: 'object', description: 'teamId to bucket label. Omit to just read.', additionalProperties: { type: 'string' } },
2729
+ },
2730
+ },
2731
+ },
2629
2732
  {
2630
2733
  name: 'report_campaign_performance',
2631
2734
  description: 'Email campaign performance: open rate, click rate, delivery rate, complaint rate per campaign + aggregate KPIs.',
@@ -2906,7 +3009,9 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2906
3009
  platform: { type: 'string', enum: ['v10', 'v20', 'admin'], description: 'Filter by platform' },
2907
3010
  environment: { type: 'string', enum: ['Production', 'Staging', 'Development'] },
2908
3011
  correlationId: { type: 'string', description: 'Exact correlation/trace ID filter' },
2909
- last: { type: 'string', description: 'Time window: 30m, 2h, 1d (default: 2h)' },
3012
+ last: { type: 'string', description: 'Time window ending at `to`/now: 30m, 2h, 1d (default: 2h)' },
3013
+ from: { type: 'string', description: 'Window start: ISO timestamp (2026-10-20T00:00:00Z) or relative (3h). Overrides `last`.' },
3014
+ to: { type: 'string', description: 'Window end: ISO timestamp or relative (1h = 1h ago). Default: now.' },
2910
3015
  take: { type: 'number', description: 'Max results (default: 50, max: 500)' },
2911
3016
  regex: { type: 'boolean', description: 'Treat q/logger as a regex pattern (default: false)' },
2912
3017
  },
@@ -3359,7 +3464,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3359
3464
  inputSchema: {
3360
3465
  type: 'object',
3361
3466
  properties: {
3362
- report: { type: 'string', enum: ['program_breakdown', 'agent_scorecard', 'qa_coverage', 'coaching_queue', 'sentiment_breakdown', 'topic_radar', 'weekly_brief', 'weekly_brief_send_test', 'repeat_contact', 'demand_coverage', 'bot_containment', 'effort_index', 'promise_ledger', 'promise_dismiss', 'year_in_review'] },
3467
+ report: { type: 'string', enum: ['program_breakdown', 'agent_scorecard', 'qa_coverage', 'coaching_queue', 'sentiment_breakdown', 'topic_radar', 'weekly_brief', 'weekly_brief_send_test', 'repeat_contact', 'demand_coverage', 'bot_containment', 'effort_index', 'promise_ledger', 'promise_dismiss', 'new_agent_ramp', 'year_in_review'] },
3363
3468
  from: { type: 'string', description: 'yyyy-MM-dd start (default: 30 days ago).' },
3364
3469
  to: { type: 'string', description: 'yyyy-MM-dd end, inclusive (default: today).' },
3365
3470
  groupBy: { type: 'string', description: 'program_breakdown: website|team|platform|device|entrypage. sentiment_breakdown: website|team|agent|theme.' },
@@ -3384,16 +3489,17 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3384
3489
  },
3385
3490
  {
3386
3491
  name: 'report_backfill',
3387
- description: 'Backfill report data for the caller site. Preview by default (reports "would fill N", writes nothing); apply=true writes. Idempotent. type "csat" fills missing survey ratings (SurveySubmission.Rating) from existing data; it does NOT touch ticket CSAT. Site managers only; scoped to the caller site.',
3492
+ description: 'Backfill report data for the caller site. Preview by default (reports "would fill N", writes nothing); apply=true writes. Idempotent. type "csat" fills missing survey ratings (SurveySubmission.Rating) from existing data; it does NOT touch ticket CSAT. type "csat-demo" adds tagged demo CSAT assigned to the demo agents (allow-listed demo sites only); type "csat-demo-undo" over the same from/to deletes exactly those tagged rows and nothing else. type "history-classification-sample" tags 20 recent conversations and reports the line-of-business and intent mix it found (the proposed tags); type "history-classification" then tags and sentiment-scores past resolved conversations in the range (up to 100 per run, run again to continue) so Enterprise Reports and Year in Review show classified history instead of Unclassified / Insufficient data. For both history types, apply=true is QUEUED (the server returns 202 with a runId and runs it in the background; one run per site at a time, a repeat call returns the active run); pass status=true to read the latest run (queued, running, completed with result, or failed). Site managers only; scoped to the caller site.',
3388
3493
  inputSchema: {
3389
3494
  type: 'object',
3390
3495
  properties: {
3391
- type: { type: 'string', description: 'Backfill type, e.g. csat.' },
3392
- from: { type: 'string', description: 'yyyy-MM-dd start.' },
3393
- to: { type: 'string', description: 'yyyy-MM-dd end.' },
3394
- apply: { type: 'boolean', description: 'true = write. Default false (preview only).' },
3496
+ type: { type: 'string', description: 'Backfill type: csat, csat-demo, csat-demo-undo, history-classification-sample or history-classification.' },
3497
+ from: { type: 'string', description: 'yyyy-MM-dd start (required unless status=true).' },
3498
+ to: { type: 'string', description: 'yyyy-MM-dd end (required unless status=true).' },
3499
+ apply: { type: 'boolean', description: 'true = write. Default false (preview only). For history types this queues a background run.' },
3500
+ status: { type: 'boolean', description: 'true = return the latest queued/running/finished apply run for this type instead of starting anything.' },
3395
3501
  },
3396
- required: ['type', 'from', 'to'],
3502
+ required: ['type'],
3397
3503
  },
3398
3504
  },
3399
3505
  // -- Staff: schedule adherence by site + role (SupportTools-style: explicit siteId, never auth-derived) --
@@ -3561,6 +3667,17 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3561
3667
  required: ['siteId'],
3562
3668
  },
3563
3669
  },
3670
+ {
3671
+ name: 'avatar_session_readiness',
3672
+ description: 'Velaro staff diagnostic: runs the real avatar session gates for a site (plan, config, provider key, billing authority, concurrency) without claiming a slot or calling the provider, so it costs nothing. Use when a visitor avatar session returns 503 or will not start. Calls velaro-messaging GET /avatar/diagnostics/session-readiness (staff only).',
3673
+ inputSchema: {
3674
+ type: 'object',
3675
+ properties: {
3676
+ siteId: { type: 'number', description: 'Site ID to check' },
3677
+ },
3678
+ required: ['siteId'],
3679
+ },
3680
+ },
3564
3681
  {
3565
3682
  name: 'avatar_set_config',
3566
3683
  description: 'Set up or update the AI Video Avatar for a customer site (partial update — only send the fields you want to change). Velaro admin only.',
@@ -3573,11 +3690,13 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3573
3690
  avatarId: { type: 'string', description: 'Avatar ID. For provider "velaro" this is a Velaro Avatar library slug. Other providers: their own face/avatar ID. Omit for provider default.' },
3574
3691
  voiceId: { type: 'string', description: 'ElevenLabs voice ID override. Omit to use the site default.' },
3575
3692
  showCallMeOption: { type: 'boolean', description: 'Show the "Call me instead" option (requires EnableClickToCall).' },
3693
+ enabledOnAgentTransfer: { type: 'boolean', description: 'Keep the avatar running after a human agent joins the conversation (default off).' },
3576
3694
  callbackConfirmWindowMinutes: { type: 'number', description: 'Minutes to confirm a callback request before it expires. Default 10.' },
3577
3695
  displayName: { type: 'string', description: 'Visitor-facing avatar name shown on the badge (e.g. "Moshky"). Empty string resets to "AI".' },
3578
3696
  showBadge: { type: 'boolean', description: 'Show the name badge over the avatar video. Default true.' },
3579
3697
  sizePx: { type: 'number', description: 'Max avatar panel width in px (160-640, square). Default 360.' },
3580
3698
  allowVisitorCollapse: { type: 'boolean', description: 'Let visitors hide or collapse the avatar panel (up/down arrow and X). Default true.' },
3699
+ heyGenEnabled: { type: 'boolean', description: 'Grant or revoke the HeyGen provider entitlement (EnableAvatarHeyGen) for this site. Off by default; HeyGen is hidden from the customer UI until granted.' },
3581
3700
  motionLevel: { type: 'string', enum: ['', 'off', 'subtle', 'natural', 'expressive'], description: 'Body motion preset layered over the lip-sync (shoulders, breathing, head, brows). Empty string resets to the platform default (natural). Visitors with reduced-motion enabled always get a still avatar.' },
3582
3701
  motionIntensityPercent: { type: 'integer', minimum: 25, maximum: 150, description: 'Body motion strength, whole percent 25-150 (default 100); multiplies every motion amplitude.' },
3583
3702
  maxAvatarMinutesPerMonth: { type: 'number', description: 'Monthly avatar video minutes cap for the site. Whole number 1-100000; never unlimited (0/negative rejected).' },
@@ -3675,6 +3794,31 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3675
3794
  description: 'Reset ALL group and workflow overrides for the calling site back to the site default.',
3676
3795
  inputSchema: { type: 'object', properties: {} },
3677
3796
  },
3797
+ {
3798
+ name: 'avatar_set_workflow_switch',
3799
+ description: 'Switch the AI avatar on, off, or back to the site rules for ONE workflow of the calling site. mode "on" shows the avatar, "off" hides it, "rules" clears the switch so the site rules decide. Only the switch changes; the rest of the workflow override is kept. Velaro admin or site auth.',
3800
+ inputSchema: {
3801
+ type: 'object',
3802
+ properties: {
3803
+ workflowId: { type: 'number', description: 'Workflow id (must belong to the calling site).' },
3804
+ mode: { type: 'string', enum: ['on', 'off', 'rules'], description: 'on | off | rules' },
3805
+ },
3806
+ required: ['workflowId', 'mode'],
3807
+ },
3808
+ },
3809
+ {
3810
+ name: 'avatar_bulk_set_switch',
3811
+ description: 'Set the AI avatar switch (on | off | rules) for many workflows and/or teams of the calling site in one call. Select rows with workflowIds and/or groupIds (at least one; max 500 rows). Each changed row is audited. Velaro admin or site auth.',
3812
+ inputSchema: {
3813
+ type: 'object',
3814
+ properties: {
3815
+ mode: { type: 'string', enum: ['on', 'off', 'rules'], description: 'on | off | rules' },
3816
+ workflowIds: { type: 'array', items: { type: 'number' }, description: 'Workflow ids of the calling site.' },
3817
+ groupIds: { type: 'array', items: { type: 'number' }, description: 'Team (group) ids of the calling site.' },
3818
+ },
3819
+ required: ['mode'],
3820
+ },
3821
+ },
3678
3822
 
3679
3823
  // ── Calendly integration management (superadmin) ──────────────────────────
3680
3824
  {
@@ -4028,6 +4172,26 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4028
4172
  required: ['shop'],
4029
4173
  },
4030
4174
  },
4175
+ {
4176
+ name: 'shopify_get_write_policy',
4177
+ description: 'Get the Shopify write-tier policy for the site: enableShopifyWrite (master toggle for the 11 Shopify write skills; read skills are unaffected), requireConfirmation, and the effective caps maxRefundAmount, maxDiscountPercent, maxDiscountFixedAmount (defaults filled in: 250 / 20 / 50). Site is resolved server-side from the caller\'s auth context. Lives in velaro-messaging (GET /api/Shopify/WritePolicy) - requires VELARO_MESSAGING_API.',
4178
+ inputSchema: { type: 'object', properties: {}, required: [] },
4179
+ },
4180
+ {
4181
+ name: 'shopify_set_write_policy',
4182
+ description: 'Update the Shopify write-tier policy for the site (apply-if-present, partial: omitted fields keep their stored value). Pass null for a cap to return it to the default. enableShopifyWrite=false hides the 11 Shopify write skills (refund, discount, etc.) without touching the read skills. Validated server-side. Lives in velaro-messaging (PUT /api/Shopify/WritePolicy) - requires VELARO_MESSAGING_API.',
4183
+ inputSchema: {
4184
+ type: 'object',
4185
+ properties: {
4186
+ enableShopifyWrite: { type: 'boolean', description: 'Master toggle for the Shopify write skills.' },
4187
+ requireConfirmation: { type: 'boolean', description: 'Require customer confirmation before a write skill executes.' },
4188
+ maxRefundAmount: { type: ['number', 'null'], description: 'Max refund amount per call. null = default (250).' },
4189
+ maxDiscountPercent: { type: ['number', 'null'], description: 'Max percentage discount. null = default (20).' },
4190
+ maxDiscountFixedAmount: { type: ['number', 'null'], description: 'Max fixed-amount discount. null = default (50).' },
4191
+ },
4192
+ required: [],
4193
+ },
4194
+ },
4031
4195
  {
4032
4196
  name: 'appointments_manage',
4033
4197
  description: 'Velaro Appointments — reschedules or cancels an existing appointment that was booked via appointments_book. Requires the provider slug and event_id from the original booking. Set intent="reschedule" with a new start (and optional duration) to move it, or intent="cancel" to remove it. Cancel is destructive and is additionally gated by the EnableDestructiveAiSkills subscription flag. Marketing page: /appointments.',
@@ -4220,8 +4384,9 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4220
4384
  properties: {
4221
4385
  storeUrl: { type: 'string', description: 'Magento store base URL, e.g. "https://mystore.com"' },
4222
4386
  accessToken: { type: 'string', description: 'Magento REST API bearer token (Admin Integration token)' },
4387
+ configId: { type: 'number', description: 'Test an already-connected store with its stored token instead (storeUrl/accessToken then not needed)' },
4223
4388
  },
4224
- required: ['storeUrl', 'accessToken'],
4389
+ required: [],
4225
4390
  },
4226
4391
  },
4227
4392
  {
@@ -4296,6 +4461,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4296
4461
  },
4297
4462
 
4298
4463
  // ── Business actions + SAP (one definition, many surfaces) ─────────────────────
4464
+ {
4465
+ name: 'salesforce_diagnose',
4466
+ description: 'Run the Salesforce live diagnostic for a site: configuration completeness, OAuth token acquisition and, when deep, a read-only SOQL probe per object the skills use (contacts, leads, accounts, tasks, notes, email history, opportunities, cases, products, orders) with its package tier (Core/Sales/Service/Commerce). Never writes; write capabilities are never reported as verified; secrets are never returned. Calls messaging GET diagnostics/test-salesforce?siteId=<id>&deep=<bool>. Velaro staff only.',
4467
+ inputSchema: {
4468
+ type: 'object',
4469
+ properties: {
4470
+ siteId: { type: 'number', description: 'Site to diagnose (e.g. 1032)' },
4471
+ deep: { type: 'boolean', description: 'Probe every object (default true). false = configuration + token only.' },
4472
+ },
4473
+ required: ['siteId'],
4474
+ },
4475
+ },
4299
4476
  {
4300
4477
  name: 'sap_diagnose',
4301
4478
  description: 'Run the SAP live diagnostic for a site: configuration completeness, authentication (real HTTP status) and, when deep, a per-capability read-only probe compared with what the capability matrix documents (S/4HANA on-prem/private/public, Business One, ECC via gateway). Never writes; a write capability is never reported as verified; secrets are never returned. Calls messaging GET diagnostics/test-sap?siteId=<id>&deep=<bool>. Velaro staff only.',
@@ -4404,6 +4581,35 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4404
4581
  },
4405
4582
  },
4406
4583
 
4584
+ {
4585
+ name: 'netsuite_suiteapp_download',
4586
+ description: 'Download the NetSuite screen-pop SuiteApp (SDF project zip) to a local file. With includeKey=true (default) a new crm-embed-only key is minted and baked into the zip, so NO NetSuite API Secret and no key paste is needed. With includeKey=false the zip is key-less and the admin must create a NetSuite API Secret: type ID velaro_api_key in the form (NetSuite adds the prefix, giving custsecret_velaro_api_key). Lives in velaro-messaging - requires VELARO_MESSAGING_API.',
4587
+ inputSchema: { type: 'object', properties: { includeKey: { type: 'boolean', description: 'Default true.' }, outputPath: { type: 'string', description: 'Local file path to write (default: temp dir).' } }, required: [] },
4588
+ },
4589
+ {
4590
+ name: 'netsuite_config_delete',
4591
+ description: 'Delete a NetSuite connection (config) from the site. Removes the stored credentials; mappings/workflows referencing it will stop working. Destructive. Lives in velaro-messaging (DELETE Netsuite/Integrations/{id}) - requires VELARO_MESSAGING_API.',
4592
+ inputSchema: {
4593
+ type: 'object',
4594
+ properties: { configId: { type: 'number', description: 'NetSuite config ID (see netsuite_list_configs).' } },
4595
+ required: ['configId'],
4596
+ },
4597
+ },
4598
+ {
4599
+ name: 'netsuite_m2m_clear_legacy',
4600
+ description: 'Disconnect the old consumer-key/token credentials from a NetSuite config that already runs on a saved M2M certificate (explicit, never automatic; the connection row, mappings and workflows are untouched). Fails if M2M is not saved/active. Lives in velaro-messaging (POST NetSuite/M2M/ClearLegacy) - requires VELARO_MESSAGING_API.',
4601
+ inputSchema: {
4602
+ type: 'object',
4603
+ properties: { configId: { type: 'number', description: 'NetSuite config ID (see netsuite_list_configs).' } },
4604
+ required: ['configId'],
4605
+ },
4606
+ },
4607
+ {
4608
+ name: 'netsuite_embed_status',
4609
+ description: 'NetSuite SuiteApp call-pop (CRM embed) status for the site: is a crm_embed API key set, and the last heartbeat (when it last polled, connected or not) per key. Lives in velaro-messaging (GET api/v1/CrmEmbed/embed-status; fields hasEmbedKey, connected, lastPolledAt) - requires VELARO_MESSAGING_API.',
4610
+ inputSchema: { type: 'object', properties: {}, required: [] },
4611
+ },
4612
+
4407
4613
  {
4408
4614
  name: 'netsuite_m2m_init',
4409
4615
  description: 'Start NetSuite machine-to-machine (OAuth 2.0 Client Credentials) setup: generates the Velaro public certificate PEM to upload in NetSuite (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup) and returns the steps. Omit configId for a new connection (company then required). Lives in velaro-messaging - requires VELARO_MESSAGING_API.',
@@ -4897,6 +5103,11 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4897
5103
  description: 'Show the Enterprise Reports package (enterprise-internal, default OFF): every item with its feature key, what it builds on, and, when siteId is given, whether it is ON for that site. Superadmin only.',
4898
5104
  inputSchema: { type: 'object', properties: { siteId: { type: 'number', description: 'Optional site ID to show which items are on' } } },
4899
5105
  },
5106
+ {
5107
+ name: 'entitlement_report_packages',
5108
+ description: 'Show the five report category packages (Conversations & Channels, Workforce & Operations, Customer Experience, AI & Intelligence, Business & Executive) nested under the top Enterprise Reports package (default OFF): every report with its package slug and entitlement key and, when siteId is given, whether each report and each whole category is ON for that site. Superadmin only.',
5109
+ inputSchema: { type: 'object', properties: { siteId: { type: 'number', description: 'Optional site ID to show which reports are on' } } },
5110
+ },
4900
5111
  {
4901
5112
  name: 'entitlement_set_override',
4902
5113
  description: 'Set a per-site feature override — enable or disable a specific feature for one site, independent of their plan. Superadmin only. Use entitlement_list_features to find valid featureKey values.',
@@ -5091,11 +5302,11 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5091
5302
  },
5092
5303
  {
5093
5304
  name: 'focus_update_triage_rules',
5094
- description: 'Enable or disable one or more of the 5 fixed AI triage labels (urgent, needs_reply, offer, fyi, waiting) for this site. Apply-if-present -- only labels included are changed. Rejects any label name outside the fixed set. Disabling all 5 fails safe back to all-enabled server-side rather than breaking triage. Optionally set triageMode: "economy" classifies obvious messages (auto-replies, clear urgency) without an AI call, lowering AI usage; "off" keeps current behavior. Unspecified fields are preserved.',
5305
+ description: 'Enable or disable one or more of the 5 fixed AI triage labels (urgent, needs_reply, offer, fyi, waiting) for this site. Apply-if-present -- only labels included are changed. Rejects any label name outside the fixed set. Disabling all 5 fails safe back to all-enabled server-side rather than breaking triage. Optionally set triageMode: "economy" classifies obvious messages (auto-replies, clear urgency) without an AI call, lowering AI usage; "off" makes this site always use the AI call (platform default is economy). Unspecified fields are preserved.',
5095
5306
  inputSchema: {
5096
5307
  type: 'object',
5097
5308
  properties: {
5098
- triageMode: { type: 'string', enum: ['off', 'economy'], description: 'Optional. "economy" skips the AI call for obvious messages; "off" (default) keeps current behavior.' },
5309
+ triageMode: { type: 'string', enum: ['off', 'economy'], description: 'Optional. "economy" skips the AI call for obvious messages; "off" makes this site always use the AI call. Unset inherits the platform default, which is economy.' },
5099
5310
  labels: {
5100
5311
  type: 'array',
5101
5312
  description: 'Labels to update.',
@@ -5201,7 +5412,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5201
5412
  type: 'object',
5202
5413
  properties: {
5203
5414
  start: { type: 'string', description: 'ISO 8601 start of range.' },
5204
- end: { type: 'string', description: 'ISO 8601 end of range. Must be at most 90 days after start.' },
5415
+ end: { type: 'string', description: 'End of range. A plain YYYY-MM-DD date includes that whole day (the server end is exclusive, so it is sent as the start of the next day); a full ISO 8601 datetime is an exact exclusive instant. Must be at most 90 days after start.' },
5205
5416
  },
5206
5417
  required: ['start', 'end'],
5207
5418
  },
@@ -5331,6 +5542,28 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5331
5542
  },
5332
5543
  },
5333
5544
 
5545
+ // -- Apple Pay display settings --
5546
+ // Backend: velaro-messaging ApplePayConfigsController (GET/POST ApplePayConfig, PUT ApplePayConfig/{id}).
5547
+ // Display + test mode only; merchant id / certificate (which affect the charge) are intentionally not settable here.
5548
+ {
5549
+ name: 'apple_pay_get_config',
5550
+ description: 'Get this site\'s Apple Pay settings: enabled, test mode (sandbox: Velaro Stripe test account, no real money, real Wallet cards decline), business name shown on the Apple Pay sheet, whether a custom payment-bubble logo is set, and whether the site uses Velaro\'s platform merchant. id is 0 when the site has no row yet.',
5551
+ inputSchema: { type: 'object', properties: {}, required: [] },
5552
+ },
5553
+ {
5554
+ name: 'apple_pay_update_config',
5555
+ description: 'Create or update this site\'s Apple Pay DISPLAY settings and test mode. Apply-if-present: omit a field to leave it unchanged. These never affect the charge (the merchant id and certificate do, and are managed by secure Velaro admin provisioning). Creates the row when the site has none.',
5556
+ inputSchema: {
5557
+ type: 'object',
5558
+ properties: {
5559
+ merchantDisplayName: { type: 'string', description: 'Business name shown on the Apple Pay sheet' },
5560
+ isTestMode: { type: 'boolean', description: 'Sandbox mode: Velaro Stripe test account, no real money moves, real Wallet cards decline' },
5561
+ bubbleLogoBase64: { type: 'string', description: 'PNG/JPEG base64 (max 500 KB) for the payment bubble. Empty string clears it back to the default image.' },
5562
+ },
5563
+ required: [],
5564
+ },
5565
+ },
5566
+
5334
5567
  // -- Recruiting Bot --
5335
5568
  // Backend: server/Velaro.Messaging/Controllers/RecruitingController.cs.
5336
5569
  // [EnableRecruitingBot] gated server-side; siteId always derives from the
@@ -6036,6 +6269,14 @@ function parseBusinessHours(input) {
6036
6269
 
6037
6270
  // ── Tool handlers ─────────────────────────────────────────────────────────────
6038
6271
 
6272
+ // Report end params are exclusive server-side: a plain YYYY-MM-DD end means "through that whole
6273
+ // day", so send the start of the next day (mirrors cli/lib/report-range.js exclusiveEndParam).
6274
+ function exclusiveEnd(raw) {
6275
+ if (raw == null || raw === '') return raw;
6276
+ const d = new Date(raw);
6277
+ if (Number.isNaN(d.getTime()) || !/^\d{4}-\d{2}-\d{2}$/.test(String(raw).trim())) return raw;
6278
+ return new Date(d.getTime() + 24 * 60 * 60 * 1000).toISOString();
6279
+ }
6039
6280
  // -- Agent inactivity chain helpers (velaro-messaging PR #1471, v20 ONLY) ------------------------
6040
6281
  // Terminal action ints are persisted by messaging (append-only enum). The API returns/accepts the NAME.
6041
6282
  const TERMINAL_ACTION_NAMES = ['Survey','CreateTicket','SurveyAndCreateTicket','Queue','OfflineMessage','NoOp','EndChat','NotifyManager','RunWorkflow','ReturnToBot'];
@@ -6166,7 +6407,9 @@ async function handleTool(name, args) {
6166
6407
  }
6167
6408
 
6168
6409
  case 'notifications_get': {
6169
- return JSON.stringify(await messagingGet('/NotificationSettings'), null, 2);
6410
+ const base = await messagingGet('/NotificationSettings');
6411
+ const routing = await messagingGet('/api/v1/alert-routing').catch(() => null);
6412
+ return JSON.stringify({ ...base, alertRouting: routing?.data ?? null }, null, 2);
6170
6413
  }
6171
6414
 
6172
6415
  case 'notifications_set': {
@@ -6180,7 +6423,47 @@ async function handleTool(name, args) {
6180
6423
  if (args[f] !== undefined) current[f] = args[f];
6181
6424
  }
6182
6425
  await messagingPost('/NotificationSettings', current);
6183
- return JSON.stringify(await messagingGet('/NotificationSettings'), null, 2);
6426
+ if (args.alertRouting && typeof args.alertRouting === 'object') {
6427
+ await messagingPut('/api/v1/alert-routing', args.alertRouting);
6428
+ }
6429
+ const routing = await messagingGet('/api/v1/alert-routing').catch(() => null);
6430
+ return JSON.stringify({ ...(await messagingGet('/NotificationSettings')), alertRouting: routing?.data ?? null }, null, 2);
6431
+ }
6432
+
6433
+ case 'signature_policy_get': {
6434
+ return JSON.stringify(await messagingGet('/Signatures/policy'), null, 2);
6435
+ }
6436
+
6437
+ case 'signature_policy_set': {
6438
+ // PUT replaces the row and silently turns an unknown mode into "follow company-wide", so validate and merge here.
6439
+ // Same rules as cli/lib/signature-policy.js (keep the two in step).
6440
+ const modes = { 'locked': 'admin_locked', 'company-default': 'company_default', 'personal-only': 'personal_only' };
6441
+ const teamId = args.teamId === undefined || args.teamId === null ? 0 : args.teamId;
6442
+ if (!Number.isInteger(teamId) || teamId < 0) throw new Error('teamId must be 0 (company-wide) or a team id');
6443
+ const data = await messagingGet('/Signatures/policy');
6444
+ const cur = (Array.isArray(data?.policies) ? data.policies : []).find((p) => p.teamId === teamId) ?? {};
6445
+ const body = {
6446
+ teamId,
6447
+ mode: cur.mode ?? null,
6448
+ corporateSignature: cur.corporateSignature || null,
6449
+ disclaimerEnabled: cur.disclaimerEnabled ?? null,
6450
+ disclaimerText: cur.disclaimerText || null,
6451
+ };
6452
+ if (args.mode !== undefined) {
6453
+ if (args.mode === null || args.mode === 'default') body.mode = null;
6454
+ else if (modes[args.mode]) body.mode = modes[args.mode];
6455
+ else throw new Error('mode must be locked, company-default, personal-only or default');
6456
+ }
6457
+ if (args.corporateSignature !== undefined) body.corporateSignature = args.corporateSignature || null;
6458
+ if (args.disclaimerEnabled !== undefined) {
6459
+ if (args.disclaimerEnabled !== null && typeof args.disclaimerEnabled !== 'boolean') throw new Error('disclaimerEnabled must be true, false or null');
6460
+ body.disclaimerEnabled = args.disclaimerEnabled;
6461
+ }
6462
+ if (args.disclaimerText !== undefined) body.disclaimerText = args.disclaimerText || null;
6463
+ if (body.corporateSignature && body.corporateSignature.length > 4000) throw new Error('corporateSignature is limited to 4000 characters');
6464
+ if (body.disclaimerText && body.disclaimerText.length > 2000) throw new Error('disclaimerText is limited to 2000 characters');
6465
+ await messagingPut('/Signatures/policy', body);
6466
+ return JSON.stringify(await messagingGet('/Signatures/policy'), null, 2);
6184
6467
  }
6185
6468
 
6186
6469
  case 'admin_startup_diagnostics': {
@@ -6469,7 +6752,7 @@ async function handleTool(name, args) {
6469
6752
  case 'chat_intelligence_themes': {
6470
6753
  const qs = new URLSearchParams();
6471
6754
  if (args.startDate) qs.set('startDate', args.startDate);
6472
- if (args.endDate) qs.set('endDate', args.endDate);
6755
+ if (args.endDate) qs.set('endDate', exclusiveEnd(args.endDate));
6473
6756
  qs.set('groupBy', args.groupBy ?? 'month');
6474
6757
  if (args.deploymentId != null) qs.set('deploymentId', String(args.deploymentId));
6475
6758
  if (args.teamId != null) qs.set('teamId', String(args.teamId));
@@ -6563,6 +6846,8 @@ async function handleTool(name, args) {
6563
6846
 
6564
6847
  case 'workflow_toggle':
6565
6848
  return await rpc('workflow_toggle', args);
6849
+ case 'workflow_run_now':
6850
+ return await rpc('workflow_run_now', args);
6566
6851
 
6567
6852
  case 'workflow_create':
6568
6853
  return await rpc('workflow_create', args);
@@ -6942,6 +7227,13 @@ async function handleTool(name, args) {
6942
7227
  return `Connection test FAILED for agent ${args.id}: ${result?.error ?? 'unknown error'}`;
6943
7228
  }
6944
7229
 
7230
+ case 'copilot_studio_ask': {
7231
+ if (!args.id || !args.question) return 'id and question are required.';
7232
+ const r = await api('POST', '/CopilotStudio/message', { configId: args.id, message: args.question });
7233
+ return `kind: ${r?.kind}
7234
+ reply: ${r?.reply}`;
7235
+ }
7236
+
6945
7237
  // ── Monday.com field mapping / CRM search ──────────────────────────────
6946
7238
  // Wired to MondayController.cs actions: GetBoardColumns, GetFieldMapping,
6947
7239
  // UpdateFieldMapping, SearchContacts (routes under /Monday/...).
@@ -7094,7 +7386,7 @@ async function handleTool(name, args) {
7094
7386
 
7095
7387
  case 'teams_channel_config_update': {
7096
7388
  const body = {};
7097
- for (const k of ['enabled','welcomeMessage','teamId','clearTeamId'])
7389
+ for (const k of ['enabled','welcomeMessage','teamId','clearTeamId','prechatEnabled','prechatFields','quickReplies','postChatEnabled','postChatMessage'])
7098
7390
  if (args[k] !== undefined) body[k] = args[k];
7099
7391
  return await api('PUT', '/TeamsBot/ChannelConfig', body);
7100
7392
  }
@@ -8066,6 +8358,17 @@ async function handleTool(name, args) {
8066
8358
  return `Index ready on site ${args.siteId}: ${idx?.name} (id=${idx?.id}, purpose=${idx?.purpose}, excludeFromAllSources=${!!idx?.excludeFromAllSources}).`;
8067
8359
  }
8068
8360
 
8361
+ case 'index_set_profile': {
8362
+ if (!args.siteId || !args.indexName) return 'siteId and indexName are required.';
8363
+ const body = { indexName: args.indexName };
8364
+ for (const k of ['chunkStrategy', 'chunkSize', 'chunkOverlap', 'purpose', 'searchSize', 'excludeFromAllSources']) {
8365
+ if (args[k] !== undefined) body[k] = args[k];
8366
+ }
8367
+ const idx = await messagingRequest('PUT', `/KnowledgeSetup/Staff/${encodeURIComponent(args.siteId)}/index-profile`, body);
8368
+ if (idx?.error) return `Set index profile error: ${idx.error}`;
8369
+ return `Profile updated on site ${args.siteId}: ${idx?.name} (purpose=${idx?.purpose}, strategy=${idx?.chunkStrategy}, size=${idx?.chunkSize}, overlap=${idx?.chunkOverlap}, searchSize=${idx?.searchSize}, excludeFromAllSources=${!!idx?.excludeFromAllSources}). Re-ingest to apply to existing content.`;
8370
+ }
8371
+
8069
8372
  case 'uploaded_files_list': {
8070
8373
  const files = await messagingRequest('GET', '/uploadedFiles/list/AI');
8071
8374
  if (!Array.isArray(files) || !files.length) return 'No AI training files uploaded for this site.';
@@ -8089,7 +8392,7 @@ async function handleTool(name, args) {
8089
8392
  case 'service_level_report': {
8090
8393
  const qs = [
8091
8394
  args.startDate ? `startDate=${args.startDate}` : '',
8092
- args.endDate ? `endDate=${args.endDate}` : '',
8395
+ args.endDate ? `endDate=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8093
8396
  args.granularity ? `granularity=${args.granularity}` : '',
8094
8397
  ].filter(Boolean).join('&');
8095
8398
  const r = await api('GET', `/ServiceLevel${qs ? '?' + qs : ''}`);
@@ -8139,7 +8442,7 @@ async function handleTool(name, args) {
8139
8442
  case 'report_agent_utilization': {
8140
8443
  const qs = [
8141
8444
  args.startDate ? `startDate=${args.startDate}` : '',
8142
- args.endDate ? `endDate=${args.endDate}` : '',
8445
+ args.endDate ? `endDate=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8143
8446
  ].filter(Boolean).join('&');
8144
8447
  const rows = await api('GET', `/Reports/AgentUtilization${qs ? '?' + qs : ''}`);
8145
8448
  if (!Array.isArray(rows) || !rows.length) return 'No agent utilization data for this period.';
@@ -8260,10 +8563,36 @@ async function handleTool(name, args) {
8260
8563
  return `Export queued (id ${result.exportId}). ${result.message}`;
8261
8564
  }
8262
8565
 
8566
+ case 'report_performance_by_country_get': {
8567
+ const end = args.endDate ? new Date(args.endDate) : new Date();
8568
+ const start = args.startDate ? new Date(args.startDate) : new Date(end.getTime() - 30 * 86400000);
8569
+ if (isNaN(start) || isNaN(end)) return 'Error: startDate/endDate must be valid ISO 8601 dates.';
8570
+ const r = await messagingPost('/reports/performance-by-country/query', {
8571
+ start: start.toISOString(), end: end.toISOString(),
8572
+ attribution: args.attribution || 'visitor', channels: Array.isArray(args.channels) ? args.channels : [],
8573
+ });
8574
+ const f = (v, s = '') => (v == null ? '-' : `${v}${s}`);
8575
+ const row = x => `${(x.agentName || x.bucket).padEnd(24)} eng ${x.engagements} | handled ${x.handled} | missed ${x.missed} | abandoned ${x.abandoned} | bot containment ${f(x.botContainmentPct, '%')} | transfers ${x.transfersToAgent} | 1st resp ${f(x.avgFirstResponseSeconds, 's')} | handle ${f(x.avgHandleTimeMinutes, 'm')} | SL ${f(x.serviceLevelPct, '%')} | CSAT ${f(x.csatAvg)} (${x.csatCount}) | positive ${f(x.sentimentPositivePct, '%')}`;
8576
+ return [`=== Performance by Country (${r.attribution}) ===`, ...(r.buckets || []).map(row), '', 'By agent:',
8577
+ ...((r.agents || []).map(a => row(a) + (a.agentCountryNotSet ? ' [agent country not set]' : '')))].join('\n');
8578
+ }
8579
+
8580
+ case 'report_performance_by_country_set_groups': {
8581
+ const groups = await messagingGet('/reports/performance-by-country/groups');
8582
+ let current;
8583
+ if (args.map && typeof args.map === 'object') {
8584
+ current = await messagingPut('/reports/performance-by-country/group-map', { map: args.map });
8585
+ } else {
8586
+ current = await messagingGet('/reports/performance-by-country/group-map');
8587
+ }
8588
+ const map = current?.map ?? {};
8589
+ return ['Groups:', ...(Array.isArray(groups) ? groups : []).map(g => ` ${g.id} ${g.name} -> ${map[g.id] ?? '(unmapped, reports as Other)'}`)].join('\n');
8590
+ }
8591
+
8263
8592
  case 'call_center_report': {
8264
8593
  const qs = [
8265
8594
  args.startDate ? `start=${args.startDate}` : '',
8266
- args.endDate ? `end=${args.endDate}` : '',
8595
+ args.endDate ? `end=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8267
8596
  ].filter(Boolean).join('&');
8268
8597
  const r = await api('GET', `/CallCenterAnalytics/Report${qs ? '?' + qs : ''}`);
8269
8598
  const v = r.volume ?? {};
@@ -8301,7 +8630,7 @@ async function handleTool(name, args) {
8301
8630
  case 'call_center_team_breakdown': {
8302
8631
  const qs = [
8303
8632
  args.startDate ? `start=${args.startDate}` : '',
8304
- args.endDate ? `end=${args.endDate}` : '',
8633
+ args.endDate ? `end=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8305
8634
  args.slaThresholdSeconds ? `slaThresholdSeconds=${args.slaThresholdSeconds}` : '',
8306
8635
  ].filter(Boolean).join('&');
8307
8636
  let r;
@@ -8324,7 +8653,7 @@ async function handleTool(name, args) {
8324
8653
  case 'call_center_channel_breakdown': {
8325
8654
  const qs = [
8326
8655
  args.startDate ? `start=${args.startDate}` : '',
8327
- args.endDate ? `end=${args.endDate}` : '',
8656
+ args.endDate ? `end=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8328
8657
  args.slaThresholdSeconds ? `slaThresholdSeconds=${args.slaThresholdSeconds}` : '',
8329
8658
  ].filter(Boolean).join('&');
8330
8659
  let r;
@@ -8831,23 +9160,13 @@ async function handleTool(name, args) {
8831
9160
  }
8832
9161
 
8833
9162
  case 'velaro_logs_search': {
8834
- const from = new Date(Date.now() - parseDuration(args.last));
8835
- const params = new URLSearchParams({ from: from.toISOString(), take: String(args.take ?? 50) });
8836
- if (args.q) params.set('q', args.q);
8837
- if (args.level) params.set('level', args.level.toUpperCase());
8838
- if (args.logger) params.set('logger', args.logger);
8839
- if (args.integration) params.set('integration', args.integration);
8840
- if (args.siteId) params.set('siteId', String(args.siteId));
8841
- if (args.source) params.set('source', args.source);
8842
- if (args.platform) params.set('platform', args.platform);
8843
- if (args.environment) params.set('environment', args.environment);
8844
- if (args.correlationId) params.set('correlationId', args.correlationId);
8845
- if (args.regex) params.set('regex', 'true');
9163
+ const { from, to } = resolveLogWindow(args);
9164
+ const params = buildRawLogQuery(args, from, to);
8846
9165
  const resp = await messagingApi(`/superadmin/logs/search?${params}`, { timeoutMs: 40000 });
8847
9166
  const entries = extractLogEntries(resp);
8848
9167
  const meta = describeRawMeta(resp); // searchPath/scannedRows/truncated: a 0 or short list is never ambiguous
8849
9168
  if (!entries.length) return `No results. ${meta}`;
8850
- const lines = [`${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC. ${meta}\n`];
9169
+ const lines = [`${entries.length} result(s) from ${from.toISOString().slice(0, 16)} to ${to.toISOString().slice(0, 16)} UTC. ${meta}\n`];
8851
9170
  for (const e of entries) {
8852
9171
  const ts = new Date(e.timestamp ?? e.LogTimestamp).toISOString().replace('T', ' ').slice(0, 19);
8853
9172
  const lvl = (e.Level ?? e.level ?? '?').padEnd(5);
@@ -9248,7 +9567,7 @@ async function handleTool(name, args) {
9248
9567
  coaching_queue: 'coaching-queue', sentiment_breakdown: 'sentiment-breakdown', topic_radar: 'topic-radar',
9249
9568
  weekly_brief: 'weekly-brief', weekly_brief_send_test: 'weekly-brief/send-test',
9250
9569
  repeat_contact: 'repeat-contact', demand_coverage: 'demand-coverage', bot_containment: 'bot-containment',
9251
- effort_index: 'effort-index', promise_ledger: 'promise-ledger',
9570
+ effort_index: 'effort-index', promise_ledger: 'promise-ledger', new_agent_ramp: 'new-agent-ramp',
9252
9571
  };
9253
9572
  if (args.report === 'promise_dismiss') {
9254
9573
  if (!args.promiseId) throw new Error('promiseId is required for promise_dismiss');
@@ -9325,8 +9644,11 @@ async function handleTool(name, args) {
9325
9644
  }
9326
9645
 
9327
9646
  case 'report_backfill': {
9647
+ if (args.status === true) return JSON.stringify(await messagingGet(`/Reports/backfill/${encodeURIComponent(args.type)}/status`), null, 2);
9648
+ if (!args.from || !args.to) throw new Error('from and to are required (yyyy-MM-dd) unless status=true');
9328
9649
  const qs = new URLSearchParams({ from: String(args.from), to: String(args.to), apply: String(args.apply === true) });
9329
9650
  const r = await messagingPost(`/Reports/backfill/${encodeURIComponent(args.type)}?${qs}`);
9651
+ if (args.apply === true && r && r.runId) return (r.alreadyRunning ? `A run is already ${r.status} for this site; not starting another.\n` : 'Backfill QUEUED (runs in the background; call again with status=true to read progress)\n') + JSON.stringify(r, null, 2);
9330
9652
  return (args.apply === true ? 'Backfill APPLIED\n' : 'Backfill PREVIEW (nothing written; pass apply=true to write)\n') + JSON.stringify(r, null, 2);
9331
9653
  }
9332
9654
 
@@ -9421,15 +9743,25 @@ async function handleTool(name, args) {
9421
9743
  ].join('\n');
9422
9744
  }
9423
9745
 
9746
+ case 'avatar_session_readiness': {
9747
+ const data = await messagingRequest('GET', `/avatar/diagnostics/session-readiness?siteId=${encodeURIComponent(args.siteId)}`);
9748
+ return JSON.stringify(data, null, 2);
9749
+ }
9750
+
9424
9751
  case 'avatar_get_config': {
9425
9752
  const data = await api('GET', `/Avatar/superadmin/status?siteId=${args.siteId}`);
9426
- if (!data.configured) return `Site ${args.siteId}: AI Video Avatar NOT configured (no saved config).`;
9753
+ let heyGenLine = 'unknown';
9754
+ try { const ent = await api('GET', `/Entitlements/${args.siteId}`); heyGenLine = ent?.features?.EnableAvatarHeyGen ? 'yes' : 'no'; } catch { /* entitlement lookup is best-effort */ }
9755
+ if (!data.configured) return `Site ${args.siteId}: AI Video Avatar NOT configured (no saved config). HeyGen entitled: ${heyGenLine}`;
9427
9756
  return [
9428
9757
  `Site ${args.siteId}: AI Video Avatar ${data.enabled ? 'ENABLED' : 'configured but DISABLED'}`,
9429
9758
  ` Provider: ${data.providerSlug}`,
9430
9759
  ` Avatar ID: ${data.avatarId || '(provider default)'}`,
9760
+ ` Face per provider: ${data.avatarIdByProvider && Object.keys(data.avatarIdByProvider).length ? JSON.stringify(data.avatarIdByProvider) : '(none saved)'} (read-only)`,
9761
+ ` Keep running on agent join: ${data.enabledOnAgentTransfer ? 'yes' : 'no'}`,
9431
9762
  ` Voice ID: ${data.voiceId || '(site default)'}`,
9432
9763
  ` Enabled: ${data.enabled}`,
9764
+ ` HeyGen entitled: ${heyGenLine}`,
9433
9765
  ` "Call me": ${data.showCallMeOption}`,
9434
9766
  ` Callback window: ${data.callbackConfirmWindowMinutes} min`,
9435
9767
  ` Display name: ${data.displayName || '(AI)'}`,
@@ -9446,12 +9778,17 @@ async function handleTool(name, args) {
9446
9778
  if (args.voiceId !== undefined) body.voiceId = args.voiceId;
9447
9779
  if (args.enabled !== undefined) body.enabled = args.enabled;
9448
9780
  if (args.showCallMeOption !== undefined) body.showCallMeOption = args.showCallMeOption;
9781
+ if (args.enabledOnAgentTransfer !== undefined) body.enabledOnAgentTransfer = args.enabledOnAgentTransfer;
9449
9782
  if (args.callbackConfirmWindowMinutes !== undefined) body.callbackConfirmWindowMinutes = args.callbackConfirmWindowMinutes;
9450
9783
  if (args.displayName !== undefined) body.displayName = args.displayName;
9451
9784
  if (args.showBadge !== undefined) body.showBadge = args.showBadge;
9452
9785
  if (args.sizePx !== undefined) body.sizePx = args.sizePx;
9453
9786
  if (args.allowVisitorCollapse !== undefined) body.allowVisitorCollapse = args.allowVisitorCollapse;
9787
+ if (args.heyGenEnabled !== undefined) {
9788
+ await api('POST', `/Entitlements/${args.siteId}/override`, { featureKey: 'EnableAvatarHeyGen', value: args.heyGenEnabled, reason: 'avatar_set_config heyGenEnabled' });
9789
+ }
9454
9790
  if (args.motionLevel !== undefined) body.motionLevel = parseAvatarMotionLevel(args.motionLevel);
9791
+ if (args.heyGenEnabled !== undefined && !Object.keys(body).length && args.motionIntensityPercent === undefined && args.maxAvatarMinutesPerMonth === undefined && args.maxAvatarTtsChars === undefined) return `Site ${args.siteId}: EnableAvatarHeyGen=${args.heyGenEnabled}`;
9455
9792
  if (args.motionIntensityPercent !== undefined) {
9456
9793
  if (!Number.isInteger(args.motionIntensityPercent) || args.motionIntensityPercent < 25 || args.motionIntensityPercent > 150) throw new Error('motionIntensityPercent must be a whole number between 25 and 150.');
9457
9794
  body.motionIntensityPercent = args.motionIntensityPercent;
@@ -9485,6 +9822,22 @@ async function handleTool(name, args) {
9485
9822
  return JSON.stringify(await messagingRequest('PUT', `/avatar/workflows/${Number(args.workflowId)}/override`, avatarOverrideBody(args)), null, 2);
9486
9823
  case 'avatar_reset_workflow':
9487
9824
  return JSON.stringify(await messagingRequest('DELETE', `/avatar/workflows/${Number(args.workflowId)}/override`), null, 2);
9825
+ case 'avatar_set_workflow_switch': {
9826
+ const mode = String(args.mode ?? '').trim().toLowerCase();
9827
+ if (!['on', 'off', 'rules'].includes(mode)) throw new Error('mode must be one of: on, off, rules');
9828
+ const id = Number(args.workflowId);
9829
+ if (!Number.isInteger(id) || id <= 0) throw new Error('workflowId must be a positive whole number');
9830
+ return JSON.stringify(await messagingRequest('PUT', `/avatar/workflows/${id}/enabled`, { mode }), null, 2);
9831
+ }
9832
+ case 'avatar_bulk_set_switch': {
9833
+ const mode = String(args.mode ?? '').trim().toLowerCase();
9834
+ if (!['on', 'off', 'rules'].includes(mode)) throw new Error('mode must be one of: on, off, rules');
9835
+ const workflowIds = (args.workflowIds ?? []).map(Number);
9836
+ const groupIds = (args.groupIds ?? []).map(Number);
9837
+ if (workflowIds.length + groupIds.length === 0) throw new Error('Pass workflowIds and/or groupIds');
9838
+ if (workflowIds.length + groupIds.length > 500) throw new Error('Too many rows in one call (max 500)');
9839
+ return JSON.stringify(await messagingRequest('PUT', '/avatar/overrides/enabled', { mode, workflowIds, groupIds }), null, 2);
9840
+ }
9488
9841
  case 'avatar_reset_all_overrides':
9489
9842
  return JSON.stringify(await messagingRequest('POST', '/avatar/group-overrides/reset-all', {}), null, 2);
9490
9843
 
@@ -10008,7 +10361,11 @@ async function handleTool(name, args) {
10008
10361
  }
10009
10362
 
10010
10363
  case 'magento_test_connection': {
10011
- const r = await api('POST', '/Magento/Configs/Test', { storeUrl: args.storeUrl, accessToken: args.accessToken });
10364
+ if (!args.configId && (!args.storeUrl || !args.accessToken)) return 'Provide configId, or both storeUrl and accessToken.';
10365
+ const r = args.configId
10366
+ ? await api('POST', `/Magento/Configs/${encodeURIComponent(args.configId)}/Test`)
10367
+ : await api('POST', '/Magento/Configs/Test', { storeUrl: args.storeUrl, accessToken: args.accessToken });
10368
+ if (r && r.valid === false) return `❌ Connection failed: ${r.error}`;
10012
10369
  return `✅ Connected to "${r.storeName}" (${r.edition} ${r.version})`;
10013
10370
  }
10014
10371
 
@@ -10074,6 +10431,13 @@ async function handleTool(name, args) {
10074
10431
  }
10075
10432
 
10076
10433
  // ── Business actions + SAP ────────────────────────────────────────────────
10434
+ case 'salesforce_diagnose': {
10435
+ if (!args.siteId) return 'siteId is required.';
10436
+ const deep = args.deep === false ? 'false' : 'true';
10437
+ const r = await messagingRequest('GET', `/diagnostics/test-salesforce?siteId=${encodeURIComponent(args.siteId)}&deep=${deep}`);
10438
+ return JSON.stringify(r, null, 2);
10439
+ }
10440
+
10077
10441
  case 'sap_diagnose': {
10078
10442
  if (!args.siteId) return 'siteId is required.';
10079
10443
  const deep = args.deep === false ? 'false' : 'true';
@@ -10162,6 +10526,37 @@ async function handleTool(name, args) {
10162
10526
  return `CTI token generated/rotated.\nDial URL: ${urls.dialUrlTemplate}\nScreen-pop URL: ${urls.screenPopUrl}`;
10163
10527
  }
10164
10528
 
10529
+ case 'netsuite_suiteapp_download': {
10530
+ const includeKey = args.includeKey !== false;
10531
+ const r = await messagingGet(`/api/v1/CrmEmbed/suiteapp-package?includeKey=${includeKey}&format=json`);
10532
+ const d = r?.data ?? r;
10533
+ if (!d?.contentBase64) throw new Error('SuiteApp package was not returned.');
10534
+ const { writeFileSync } = await import('node:fs');
10535
+ const { tmpdir } = await import('node:os');
10536
+ const out = args.outputPath || _join(tmpdir(), d.fileName || 'velaro-netsuite-screenpop-suiteapp.zip');
10537
+ writeFileSync(out, Buffer.from(d.contentBase64, 'base64'));
10538
+ return `Saved ${out}. ` + (d.keyIncluded
10539
+ ? 'Your Velaro key is already inside: no NetSuite API Secret and nothing to paste. Deploy with suitecloud (see SETUP.txt in the zip). Keep the zip private.'
10540
+ : `Key-less zip. In NetSuite create an API Secret: type ID ${d.secretFormId} (NetSuite adds the prefix, giving ${d.secretFullId}), paste your crm_embed_ key as Password.`);
10541
+ }
10542
+ case 'netsuite_config_delete': {
10543
+ await messagingDel(`/Netsuite/Integrations/${args.configId}`);
10544
+ return `Deleted NetSuite config ${args.configId} (a missing config is a no-op).`;
10545
+ }
10546
+ case 'netsuite_m2m_clear_legacy': {
10547
+ const r = await messagingPost('/NetSuite/M2M/ClearLegacy', { id: args.configId });
10548
+ return `Legacy consumer secret/token cleared for config ${r?.id ?? args.configId} (authType ${r?.authType ?? 'm2m'}).`;
10549
+ }
10550
+ case 'netsuite_embed_status': {
10551
+ const r = await messagingGet('/api/v1/CrmEmbed/embed-status');
10552
+ const d = r?.data ?? r;
10553
+ if (!d?.hasEmbedKey) return 'No embed key exists for this site yet. The SuiteApp cannot connect until one is created.';
10554
+ if (d.connected) return `SuiteApp connected. Last polled ${d.lastPolledAt}.`;
10555
+ return d.lastPolledAt
10556
+ ? `Embed key exists but the SuiteApp is not connected (last polled ${d.lastPolledAt}, outside the connected window).`
10557
+ : 'Embed key exists but the SuiteApp has not polled yet.';
10558
+ }
10559
+
10165
10560
  case 'netsuite_m2m_init': {
10166
10561
  const r = await messagingPost('/NetSuite/M2M/Init', { id: args.configId ?? null, company: args.company, displayName: args.displayName });
10167
10562
  return `Config ${r.id ?? args.configId ?? 'new'}. Upload this certificate in NetSuite (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup):\n${r.certificatePem ?? '(none returned)'}\n${Array.isArray(r.steps) ? r.steps.map((x, i) => `${i + 1}. ${x}`).join('\n') : ''}`;
@@ -10210,6 +10605,20 @@ async function handleTool(name, args) {
10210
10605
  return `${result.success ? 'Success' : 'Failed'}: ${result.message ?? '(no message)'}`;
10211
10606
  }
10212
10607
 
10608
+ // ── Shopify write policy (velaro-messaging /api/Shopify/WritePolicy) ───────
10609
+ case 'shopify_get_write_policy': {
10610
+ return JSON.stringify(await messagingGet('/Shopify/WritePolicy'), null, 2);
10611
+ }
10612
+ case 'shopify_set_write_policy': {
10613
+ const body = {};
10614
+ for (const k of ['enableShopifyWrite', 'requireConfirmation', 'maxRefundAmount', 'maxDiscountPercent', 'maxDiscountFixedAmount']) {
10615
+ if (args[k] !== undefined) body[k] = args[k];
10616
+ }
10617
+ if (!Object.keys(body).length) throw new Error('Provide at least one field to change.');
10618
+ const result = await messagingPut('/Shopify/WritePolicy', body);
10619
+ return `Shopify write policy updated.
10620
+ ${JSON.stringify(result, null, 2)}`;
10621
+ }
10213
10622
  // ── Webflow (config CRUD) ──────────────────────────────────────────────────
10214
10623
  case 'webflow_get_config': {
10215
10624
  if (args.configId) {
@@ -10770,6 +11179,19 @@ async function handleTool(name, args) {
10770
11179
  return lines.join(String.fromCharCode(10));
10771
11180
  }
10772
11181
 
11182
+ case 'entitlement_report_packages': {
11183
+ const q = args && args.siteId ? `?siteId=${encodeURIComponent(args.siteId)}` : '';
11184
+ const pkg = await api('GET', `/Entitlements/report-packages${q}`);
11185
+ const lines = [`${pkg.top.name} (${pkg.top.slug}) - plan ${pkg.plan}, default OFF${pkg.siteId ? `, site ${pkg.siteId}${pkg.top.enabled ? ' [ALL ON]' : ''}` : ''}`, ` ${pkg.top.tagline}`];
11186
+ for (const c of pkg.categories) {
11187
+ lines.push('');
11188
+ lines.push(`${c.name} (${c.slug})${pkg.siteId ? ` [${c.enabled ? 'ON ' : 'off'}] ${c.enabledCount}/${c.reportCount} reports` : ` ${c.reportCount} reports`}`);
11189
+ lines.push(` ${c.tagline}`);
11190
+ for (const r of c.reports) lines.push(` ${pkg.siteId ? (r.enabled ? '[ON ] ' : '[off] ') : ''}${r.name} (${r.id}) - package ${r.packageSlug}, key ${r.packageKey}`);
11191
+ }
11192
+ return lines.join(String.fromCharCode(10));
11193
+ }
11194
+
10773
11195
  case 'entitlement_list_features': {
10774
11196
  const features = await api('GET', '/Entitlements/features');
10775
11197
  const byCategory = {};
@@ -10974,7 +11396,12 @@ async function handleTool(name, args) {
10974
11396
  throw new Error('Date range too large. Worked Hours can be queried for at most 90 days at a time.');
10975
11397
  }
10976
11398
  }
10977
- return await messagingPost('/Reports/worked-hours-breakdown', { Start: args.start, End: args.end });
11399
+ // Server End is exclusive: a date-only end means "through the end of that day".
11400
+ let endValue = args.end;
11401
+ if (/^\d{4}-\d{2}-\d{2}$/.test(String(args.end).trim()) && !Number.isNaN(endMs)) {
11402
+ endValue = new Date(endMs + 24 * 60 * 60 * 1000).toISOString();
11403
+ }
11404
+ return await messagingPost('/Reports/worked-hours-breakdown', { Start: args.start, End: endValue });
10978
11405
  }
10979
11406
 
10980
11407
  // -- Pickup & Reminder Texting --
@@ -11022,6 +11449,25 @@ async function handleTool(name, args) {
11022
11449
  return JSON.stringify(result, null, 2);
11023
11450
  }
11024
11451
 
11452
+ case 'apple_pay_get_config': {
11453
+ const config = await messagingGet('/ApplePayConfig');
11454
+ return JSON.stringify(config, null, 2);
11455
+ }
11456
+
11457
+ case 'apple_pay_update_config': {
11458
+ // Field names must match ApplePayConfigUpdateRequest exactly (apply-if-present: undefined is dropped by JSON).
11459
+ const body = {
11460
+ merchantDisplayName: args.merchantDisplayName,
11461
+ isTestMode: args.isTestMode,
11462
+ bubbleLogoBase64: args.bubbleLogoBase64,
11463
+ };
11464
+ const existing = await messagingGet('/ApplePayConfig');
11465
+ const result = existing && existing.id > 0
11466
+ ? await messagingPut(`/ApplePayConfig/${existing.id}`, body)
11467
+ : await messagingPost('/ApplePayConfig', body);
11468
+ return JSON.stringify(result, null, 2);
11469
+ }
11470
+
11025
11471
  // -- Payment Recovery --
11026
11472
  case 'payment_recovery_send': {
11027
11473
  // Field names must match SendPaymentReminderRequest exactly.
@@ -11686,7 +12132,7 @@ const server = new Server(
11686
12132
  { capabilities: { tools: {}, prompts: {} } }
11687
12133
  );
11688
12134
 
11689
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
12135
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS.map(withAnnotations) }));
11690
12136
 
11691
12137
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
11692
12138