@velaro/mcp-server 0.6.91 → 0.6.92

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 +181 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/mcp-server",
3
- "version": "0.6.91",
3
+ "version": "0.6.92",
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
@@ -857,6 +857,46 @@ const TOOLS = [
857
857
  description: 'Roll back an approved suggestion, restoring the original canned message text.',
858
858
  inputSchema: { type: 'object', properties: { suggestionId: { type: 'number' }, notes: { type: 'string' } }, required: ['suggestionId'] },
859
859
  },
860
+ {
861
+ name: 'chat_topics_get',
862
+ description: 'Chat Intelligence topics for this site: the active topic list (hint for the AI, not a rulebook), industry templates, groups, merge/rename mapping and limits. Intelligence Suite entitlement required.',
863
+ inputSchema: { type: 'object', properties: {} },
864
+ },
865
+ {
866
+ name: 'chat_topics_set',
867
+ description: 'Replace the active topic list (max 60 topics, 60 chars each, duplicates removed). Used for new classifications only; existing history is untouched. Optionally load a template first with industry.',
868
+ inputSchema: { type: 'object', properties: { topics: { type: 'array', items: { type: 'string' } }, industry: { type: 'string', description: 'Load a template instead: insurance, healthcare, retail, ecommerce, financial, saas, government, manufacturing' } } },
869
+ },
870
+ {
871
+ name: 'chat_topics_edit',
872
+ description: 'Merge, rename, retire or group topics. Applied when reports are read, so history follows the new names with zero AI cost. op = merge|rename|retire|group; from = topic names; to = target (merge/rename); group = group name (empty clears).',
873
+ inputSchema: { type: 'object', properties: { op: { type: 'string' }, from: { type: 'array', items: { type: 'string' } }, to: { type: 'string' }, group: { type: 'string' } }, required: ['op', 'from'] },
874
+ },
875
+ {
876
+ name: 'chat_topics_group_suggestions',
877
+ description: 'Suggested groups for the current topics, computed from topic names and counts with no AI call.',
878
+ inputSchema: { type: 'object', properties: { days: { type: 'number' } } },
879
+ },
880
+ {
881
+ name: 'chat_topics_suggestions',
882
+ description: 'Suggested topics: the short topic the AI proposed for chats that fit nothing on the list, with counts and example conversation ids.',
883
+ inputSchema: { type: 'object', properties: { days: { type: 'number' } } },
884
+ },
885
+ {
886
+ name: 'chat_topics_resolve_suggestion',
887
+ description: 'Accept (adds to the list), merge or rename into an existing topic (target), or dismiss a suggested topic. History follows with no AI cost.',
888
+ inputSchema: { type: 'object', properties: { suggestion: { type: 'string' }, action: { type: 'string', description: 'accept|merge|rename|dismiss' }, target: { type: 'string' } }, required: ['suggestion', 'action'] },
889
+ },
890
+ {
891
+ name: 'chat_topics_reclassify',
892
+ description: 'Optional: re-run the classifier on older chats. Call with confirm omitted/false to get the cost estimate (scope other = only unlisted chats, all = everything; hard cap 500). Resend with confirm=true to queue it. Never needed for merge/rename/group.',
893
+ inputSchema: { type: 'object', properties: { scope: { type: 'string' }, days: { type: 'number' }, max: { type: 'number' }, confirm: { type: 'boolean' } } },
894
+ },
895
+ {
896
+ name: 'chat_topics_analysis',
897
+ description: 'Optional one-call analysis (cheap model, aggregate counts only, PII redacted, cached, one run per day per site) that proposes merges/renames/groups. confirm omitted/false returns the estimate and any cached result; confirm=true runs it. Then call with proposalId + accept to apply or reject one proposal.',
898
+ inputSchema: { type: 'object', properties: { confirm: { type: 'boolean' }, proposalId: { type: 'string' }, accept: { type: 'boolean' } } },
899
+ },
860
900
  {
861
901
  name: 'chat_intelligence_recommendations',
862
902
  description: 'List the active (non-dismissed) AI-generated improvement recommendations for this site — widget availability, bot gaps, missed-chat patterns, content-gap drafts. Each row includes id, type, priority, title, body, supportingDataBlob, plus lifecycle fields (appliedAt, appliedArtifactRef, rolledBackAt).',
@@ -3214,6 +3254,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3214
3254
  displayName: { type: 'string', description: 'Visitor-facing avatar name shown on the badge (e.g. "Moshky"). Empty string resets to "AI".' },
3215
3255
  showBadge: { type: 'boolean', description: 'Show the name badge over the avatar video. Default true.' },
3216
3256
  sizePx: { type: 'number', description: 'Max avatar panel width in px (160-640, square). Default 360.' },
3257
+ allowVisitorCollapse: { type: 'boolean', description: 'Let visitors hide or collapse the avatar panel (up/down arrow and X). Default true.' },
3217
3258
  maxAvatarMinutesPerMonth: { type: 'number', description: 'Monthly avatar video minutes cap for the site. Whole number 1-100000; never unlimited (0/negative rejected).' },
3218
3259
  maxAvatarTtsChars: { type: 'number', description: 'Monthly avatar TTS character cap (avatar_tts_chars) for the site. Whole number 1-100000000; never unlimited (0/negative rejected). Overage pricing is a platform-wide ladder, not per site.' },
3219
3260
  },
@@ -4939,6 +4980,52 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4939
4980
  description: 'List candidate applications submitted via the Recruiting Bot for this site (name, email, phone, role interest, work authorization, availability, submitted timestamp). Never contains EEOC self-ID data.',
4940
4981
  inputSchema: { type: 'object', properties: {}, required: [] },
4941
4982
  },
4983
+ {
4984
+ name: 'recruiting_ats_failures',
4985
+ description: 'List candidate applications whose ATS write-back is Pending, Failed or Abandoned (status, attempts, last error, next retry). Requires EnableRecruitingBot.',
4986
+ inputSchema: { type: 'object', properties: {}, required: [] },
4987
+ },
4988
+ {
4989
+ name: 'recruiting_ats_retry',
4990
+ description: 'Retry the ATS write-back for one candidate application right now (resets the attempt counter). Returns the new sync status.',
4991
+ inputSchema: { type: 'object', properties: { applicationId: { type: 'number' } }, required: ['applicationId'] },
4992
+ },
4993
+ {
4994
+ name: 'recruiting_apply_settings',
4995
+ description: 'Set public apply form options for this site (apply-if-present): publicApplyEnabled toggles the public apply endpoint; autoCreateCaseTypeId picks the case type auto-created per application (0 clears).',
4996
+ inputSchema: {
4997
+ type: 'object',
4998
+ properties: {
4999
+ publicApplyEnabled: { type: 'boolean' },
5000
+ autoCreateCaseTypeId: { type: 'number', description: 'Case type id; 0 clears the auto-create link' },
5001
+ },
5002
+ required: [],
5003
+ },
5004
+ },
5005
+ {
5006
+ name: 'recruiting_bot_settings',
5007
+ description: 'Get or set the recruiting bot customer controls (apply-if-present; no fields = read): pageContextEnabled (use the job from the host page Velaro context call), revealPostedDate, revealDescription, revealApplyLink, publicApplyEnabled, applyMode (both|link|inproduct), maxResults (1-10), greeting (max 500), tone (friendly|professional|concise).',
5008
+ inputSchema: {
5009
+ type: 'object',
5010
+ properties: {
5011
+ pageContextEnabled: { type: 'boolean' },
5012
+ revealPostedDate: { type: 'boolean' },
5013
+ revealDescription: { type: 'boolean' },
5014
+ revealApplyLink: { type: 'boolean' },
5015
+ publicApplyEnabled: { type: 'boolean' },
5016
+ applyMode: { type: 'string', enum: ['both', 'link', 'inproduct'] },
5017
+ maxResults: { type: 'number' },
5018
+ greeting: { type: 'string' },
5019
+ tone: { type: 'string', enum: ['friendly', 'professional', 'concise'] },
5020
+ },
5021
+ required: [],
5022
+ },
5023
+ },
5024
+ {
5025
+ name: 'recruiting_download_resume',
5026
+ description: 'Download a candidate application resume. Returns { fileName, contentType, sizeBytes, base64 }. Files over 5 MB are refused; use the CLI (velaro recruiting applications resume) for those.',
5027
+ inputSchema: { type: 'object', properties: { applicationId: { type: 'number' } }, required: ['applicationId'] },
5028
+ },
4942
5029
  // Recruiting/Applications/ExportEeoc (voluntary self-ID CSV export) is intentionally NOT
4943
5030
  // exposed as an MCP tool -- it returns sensitive EEOC/OFCCP self-identification PII in bulk.
4944
5031
  // It is administrator-only and audit-logged server-side (403 for ordinary agents); the
@@ -5815,6 +5902,28 @@ async function handleTool(name, args) {
5815
5902
  if (args.teamId != null) qs.set('teamId', String(args.teamId));
5816
5903
  return await messagingGet(`/Reports/chat-intelligence/themes?${qs.toString()}`);
5817
5904
  }
5905
+ case 'chat_topics_get':
5906
+ return await messagingGet('/ChatIntelligenceTopics');
5907
+ case 'chat_topics_set':
5908
+ return args.industry
5909
+ ? await messagingPost('/ChatIntelligenceTopics/template', { industry: args.industry })
5910
+ : await messagingPut('/ChatIntelligenceTopics', { topics: args.topics ?? [] });
5911
+ case 'chat_topics_edit':
5912
+ return await messagingPost('/ChatIntelligenceTopics/mapping', { ops: [{ op: args.op, from: args.from ?? [], to: args.to ?? null, group: args.group ?? null }] });
5913
+ case 'chat_topics_group_suggestions':
5914
+ return await messagingGet(`/ChatIntelligenceTopics/group-suggestions?days=${args.days ?? 90}`);
5915
+ case 'chat_topics_suggestions':
5916
+ return await messagingGet(`/ChatIntelligenceTopics/suggestions?days=${args.days ?? 90}`);
5917
+ case 'chat_topics_resolve_suggestion':
5918
+ return await messagingPost('/ChatIntelligenceTopics/suggestions/resolve', { suggestion: args.suggestion, action: args.action, target: args.target ?? null });
5919
+ case 'chat_topics_reclassify':
5920
+ return args.confirm
5921
+ ? await messagingPost('/ChatIntelligenceTopics/reclassify', { scope: args.scope ?? 'other', days: args.days ?? 90, max: args.max ?? null, confirm: true })
5922
+ : await messagingGet(`/ChatIntelligenceTopics/reclassify/estimate?scope=${args.scope ?? 'other'}&days=${args.days ?? 90}${args.max ? `&max=${args.max}` : ''}`);
5923
+ case 'chat_topics_analysis':
5924
+ if (args.proposalId) return await messagingPost('/ChatIntelligenceTopics/analysis/proposals', { id: args.proposalId, accept: args.accept !== false });
5925
+ if (args.confirm) return await messagingPost('/ChatIntelligenceTopics/analysis', { confirm: true });
5926
+ return { estimate: await messagingGet('/ChatIntelligenceTopics/analysis/estimate'), cached: await messagingGet('/ChatIntelligenceTopics/analysis') };
5818
5927
  case 'canned_learning_suggestions':
5819
5928
  return await messagingGet(`/CannedLearning/suggestions?state=${encodeURIComponent(args.state ?? 'pending_review')}`);
5820
5929
  case 'canned_learning_generate':
@@ -6077,7 +6186,7 @@ async function handleTool(name, args) {
6077
6186
  return await rpc('ai_config_set_reply_format', args);
6078
6187
 
6079
6188
  case 'site_identity_secret_status': {
6080
- const settings = await api('GET', '/SiteSettings');
6189
+ const settings = await api('GET', '/api/SiteSettings');
6081
6190
  const configured = settings?.identitySigningSecretConfigured ?? false;
6082
6191
  const masked = settings?.identitySigningSecretMasked ?? null;
6083
6192
  if (!configured) {
@@ -6117,7 +6226,7 @@ async function handleTool(name, args) {
6117
6226
  }
6118
6227
 
6119
6228
  case 'index_list': {
6120
- const indexes = await messagingApi('/AzureIndexes/GetIndexList');
6229
+ const indexes = await messagingApi('/AzureIndexes/List');
6121
6230
  if (!Array.isArray(indexes) || !indexes.length) {
6122
6231
  return 'No knowledge collections found for this site.';
6123
6232
  }
@@ -6449,7 +6558,8 @@ async function handleTool(name, args) {
6449
6558
  }
6450
6559
 
6451
6560
  case 'routing_delete': {
6452
- await messagingRequest('DELETE', `/rules/${args.id}`);
6561
+ // RulesController.DeleteAsync takes the rule in the BODY (DELETE /Rules {id}); DELETE /rules/{id} never existed.
6562
+ await messagingRequest('DELETE', '/Rules', { id: args.id });
6453
6563
  return `Deleted routing rule id=${args.id}`;
6454
6564
  }
6455
6565
 
@@ -6988,7 +7098,7 @@ async function handleTool(name, args) {
6988
7098
 
6989
7099
  // ── Layer 7: Admin Activity Log — recent channel/auth/integration events
6990
7100
  // Useful for detecting send failures, auth events, channel errors not in integration logs.
6991
- const activityLogP = safeJson(() => api('GET', '/AdminActivityLog?pageSize=10'));
7101
+ const activityLogP = safeJson(() => messagingRequest('GET', '/AdminActivityLog?pageSize=10'));
6992
7102
 
6993
7103
  const [health, ci, aiEx, velaroLogs, loggly, intLogPage, skillErr, activityLog] = await Promise.all([healthP, ciP, aiExP, velaroLoggerP, logglyP, intLogP, skillErrP, activityLogP]);
6994
7104
 
@@ -7113,7 +7223,7 @@ async function handleTool(name, args) {
7113
7223
  const q = args.query ?? '';
7114
7224
  const top = args.top ?? 5;
7115
7225
  if (!q) return 'query is required.';
7116
- const results = await api('GET', `/AzureIndexes/content-search?q=${encodeURIComponent(q)}&top=${top}`);
7226
+ const results = await messagingRequest('GET', `/AzureIndexes/content-search?q=${encodeURIComponent(q)}&top=${top}`);
7117
7227
  if (results?.error) return `Index search error: ${results.error}`;
7118
7228
  if (!Array.isArray(results) || !results.length) return `No results for "${q}". Index may be empty — trigger ingestion first.`;
7119
7229
  const lines = [
@@ -7176,7 +7286,7 @@ async function handleTool(name, args) {
7176
7286
  return JSON.stringify(r, null, 2);
7177
7287
  }
7178
7288
  case 'index_status': {
7179
- const sources = await api('GET', '/AzureIndexes/IntegrationSources');
7289
+ const sources = await messagingRequest('GET', '/AzureIndexes/IntegrationSources');
7180
7290
  if (!Array.isArray(sources) || !sources.length) return 'No indexed sources found. Run index_ingest to populate.';
7181
7291
  const lines = [
7182
7292
  '=== Index Status ===',
@@ -7189,7 +7299,7 @@ async function handleTool(name, args) {
7189
7299
  if (!args.jobId) return 'jobId is required.';
7190
7300
  const body = { jobId: args.jobId };
7191
7301
  if (args.indexName) body.indexName = args.indexName;
7192
- const result = await api('POST', '/AzureIndexes/IngestJobDirect', body);
7302
+ const result = await messagingRequest('POST', '/AzureIndexes/IngestJobDirect', body);
7193
7303
  if (result?.error) return `Ingestion error: ${result.error}`;
7194
7304
  return `Ingestion started for job ${args.jobId}.\n Physical index: ${result?.physicalIndex ?? 'unknown'}\n\nEmbedding runs in the background (~1-2 min). Use index_status to verify chunks appear.`;
7195
7305
  }
@@ -7214,7 +7324,7 @@ async function handleTool(name, args) {
7214
7324
  }
7215
7325
 
7216
7326
  case 'uploaded_files_list': {
7217
- const files = await api('GET', '/uploadedFiles/list/AI');
7327
+ const files = await messagingRequest('GET', '/uploadedFiles/list/AI');
7218
7328
  if (!Array.isArray(files) || !files.length) return 'No AI training files uploaded for this site.';
7219
7329
  const lines = ['=== Uploaded AI Training Files ===',
7220
7330
  ...files.map(f => ` [id=${f.id}] ${f.fileName} size=${f.fileLength ? Math.round(f.fileLength/1024)+'KB' : '?'}`)];
@@ -7227,7 +7337,7 @@ async function handleTool(name, args) {
7227
7337
  if (args.fileId) body.id = args.fileId;
7228
7338
  if (args.fileName) body.fileName = args.fileName;
7229
7339
  if (args.indexName) body.indexName = args.indexName;
7230
- const result = await api('POST', '/uploadedFiles/reindex', body);
7340
+ const result = await messagingRequest('POST', '/uploadedFiles/reindex', body);
7231
7341
  if (result?.error) return `Reindex error: ${result.error}`;
7232
7342
  return `✅ Re-indexed '${result.fileName}': ${result.chunksIndexed} chunks indexed.\n\nUse index_status to verify the chunks appear in the knowledge index.`;
7233
7343
  }
@@ -8532,6 +8642,7 @@ async function handleTool(name, args) {
8532
8642
  if (args.displayName !== undefined) body.displayName = args.displayName;
8533
8643
  if (args.showBadge !== undefined) body.showBadge = args.showBadge;
8534
8644
  if (args.sizePx !== undefined) body.sizePx = args.sizePx;
8645
+ if (args.allowVisitorCollapse !== undefined) body.allowVisitorCollapse = args.allowVisitorCollapse;
8535
8646
  for (const [arg, max] of [['maxAvatarMinutesPerMonth', 100000], ['maxAvatarTtsChars', 100000000]]) {
8536
8647
  if (args[arg] === undefined) continue;
8537
8648
  if (!Number.isInteger(args[arg]) || args[arg] < 1 || args[arg] > max) throw new Error(`${arg} must be a whole number between 1 and ${max} (never unlimited).`);
@@ -9133,7 +9244,7 @@ async function handleTool(name, args) {
9133
9244
 
9134
9245
  case 'magento_diagnose': {
9135
9246
  if (!args.siteId) return 'siteId is required.';
9136
- const r = await api('GET', `/diagnostics/test-magento?siteId=${encodeURIComponent(args.siteId)}`);
9247
+ const r = await messagingRequest('GET', `/diagnostics/test-magento?siteId=${encodeURIComponent(args.siteId)}`);
9137
9248
  return JSON.stringify(r, null, 2);
9138
9249
  }
9139
9250
 
@@ -9141,18 +9252,18 @@ async function handleTool(name, args) {
9141
9252
  case 'sap_diagnose': {
9142
9253
  if (!args.siteId) return 'siteId is required.';
9143
9254
  const deep = args.deep === false ? 'false' : 'true';
9144
- const r = await api('GET', `/diagnostics/test-sap?siteId=${encodeURIComponent(args.siteId)}&deep=${deep}`);
9255
+ const r = await messagingRequest('GET', `/diagnostics/test-sap?siteId=${encodeURIComponent(args.siteId)}&deep=${deep}`);
9145
9256
  return JSON.stringify(r, null, 2);
9146
9257
  }
9147
9258
 
9148
9259
  case 'business_action_list': {
9149
- const r = await api('GET', '/BusinessActions');
9260
+ const r = await messagingRequest('GET', '/BusinessActions');
9150
9261
  return JSON.stringify(r, null, 2);
9151
9262
  }
9152
9263
 
9153
9264
  case 'business_action_route': {
9154
9265
  if (!args.text) return 'text is required.';
9155
- const r = await api('POST', '/BusinessActions/Route', { text: args.text, max: args.max });
9266
+ const r = await messagingRequest('POST', '/BusinessActions/Route', { text: args.text, max: args.max });
9156
9267
  return JSON.stringify(r, null, 2);
9157
9268
  }
9158
9269
 
@@ -9162,7 +9273,7 @@ async function handleTool(name, args) {
9162
9273
  const qs = [];
9163
9274
  if (args.idempotencyKey) qs.push(`idempotencyKey=${encodeURIComponent(args.idempotencyKey)}`);
9164
9275
  if (args.conversationId) qs.push(`conversationId=${encodeURIComponent(args.conversationId)}`);
9165
- const r = await api('POST', `/BusinessActions/${encodeURIComponent(args.name)}/Execute${qs.length ? '?' + qs.join('&') : ''}`, args.request);
9276
+ const r = await messagingRequest('POST', `/BusinessActions/${encodeURIComponent(args.name)}/Execute${qs.length ? '?' + qs.join('&') : ''}`, args.request);
9166
9277
  return JSON.stringify(r, null, 2);
9167
9278
  }
9168
9279
 
@@ -9705,7 +9816,7 @@ async function handleTool(name, args) {
9705
9816
  }
9706
9817
 
9707
9818
  case 'billing_plan_status': {
9708
- const s = await api('GET', '/BillingPayment/plan-status', undefined, MESSAGING_API);
9819
+ const s = await api('GET', '/api/BillingPayment/plan-status');
9709
9820
  const lines = [`Site ${s.siteId} — Subscription Status`];
9710
9821
  if (s.subscription) {
9711
9822
  lines.push(`Seats: ${s.subscription.maxLicensedSeats} | Conv/mo: ${s.subscription.maxConversationsPerMonth || '∞'} | Self-serve: ${s.subscription.enableSelfServeSeats ? 'YES' : 'no'}`);
@@ -10201,6 +10312,61 @@ async function handleTool(name, args) {
10201
10312
  return JSON.stringify(listing, null, 2);
10202
10313
  }
10203
10314
 
10315
+ case 'recruiting_ats_failures': {
10316
+ const rows = await messagingGet('/Recruiting/Applications/AtsFailures');
10317
+ return JSON.stringify(rows, null, 2);
10318
+ }
10319
+ case 'recruiting_ats_retry': {
10320
+ const id = Number(args.applicationId);
10321
+ if (!Number.isInteger(id) || id <= 0) throw new Error('applicationId must be a positive integer.');
10322
+ const r = await messagingPost(`/Recruiting/Applications/${id}/AtsRetry`, {});
10323
+ return JSON.stringify(r, null, 2);
10324
+ }
10325
+ case 'recruiting_apply_settings': {
10326
+ const body = {};
10327
+ if (args.publicApplyEnabled !== undefined) body.publicApplyEnabled = Boolean(args.publicApplyEnabled);
10328
+ if (args.autoCreateCaseTypeId !== undefined) {
10329
+ const n = Number(args.autoCreateCaseTypeId);
10330
+ if (!Number.isInteger(n) || n < 0) throw new Error('autoCreateCaseTypeId must be an integer >= 0 (0 clears).');
10331
+ body.autoCreateCaseTypeId = n;
10332
+ }
10333
+ if (!Object.keys(body).length) throw new Error('Specify publicApplyEnabled and/or autoCreateCaseTypeId.');
10334
+ const r = await messagingPut('/Recruiting/ApplySettings', body);
10335
+ return JSON.stringify(r, null, 2);
10336
+ }
10337
+ case 'recruiting_bot_settings': {
10338
+ const fields = ['pageContextEnabled', 'revealPostedDate', 'revealDescription', 'revealApplyLink', 'publicApplyEnabled', 'applyMode', 'maxResults', 'greeting', 'tone'];
10339
+ const body = {};
10340
+ for (const f of fields) if (args[f] !== undefined) body[f] = args[f];
10341
+ if (body.applyMode !== undefined && !['both', 'link', 'inproduct'].includes(String(body.applyMode).toLowerCase())) throw new Error('applyMode must be both, link or inproduct.');
10342
+ if (body.tone !== undefined && !['friendly', 'professional', 'concise'].includes(String(body.tone).toLowerCase())) throw new Error('tone must be friendly, professional or concise.');
10343
+ if (body.maxResults !== undefined && !(Number.isInteger(Number(body.maxResults)) && Number(body.maxResults) >= 1 && Number(body.maxResults) <= 10)) throw new Error('maxResults must be an integer from 1 to 10.');
10344
+ const r = Object.keys(body).length ? await messagingPut('/Recruiting/BotSettings', body) : await messagingGet('/Recruiting/BotSettings');
10345
+ return JSON.stringify(r, null, 2);
10346
+ }
10347
+ case 'recruiting_download_resume': {
10348
+ const id = Number(args.applicationId);
10349
+ if (!Number.isInteger(id) || id <= 0) throw new Error('applicationId must be a positive integer.');
10350
+ if (!MESSAGING_API) throw new Error('VELARO_MESSAGING_API is not configured.');
10351
+ const res = await fetch(`${MESSAGING_API}/Recruiting/Applications/${id}/Resume`, {
10352
+ headers: { Authorization: authHeader() },
10353
+ signal: AbortSignal.timeout(30000),
10354
+ });
10355
+ if (!res.ok) {
10356
+ const text = await res.text().catch(() => '');
10357
+ throw new Error(`/Recruiting/Applications/${id}/Resume -> ${res.status}: ${text.slice(0, 300)}`);
10358
+ }
10359
+ const buf = Buffer.from(await res.arrayBuffer());
10360
+ if (buf.length > 5 * 1024 * 1024) throw new Error(`Resume is ${buf.length} bytes (over the 5 MB MCP limit); use the CLI: velaro recruiting applications resume ${id}`);
10361
+ const cd = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(res.headers.get('content-disposition') || '');
10362
+ return JSON.stringify({
10363
+ fileName: cd ? decodeURIComponent(cd[1]) : `resume-${id}`,
10364
+ contentType: res.headers.get('content-type') || 'application/octet-stream',
10365
+ sizeBytes: buf.length,
10366
+ base64: buf.toString('base64'),
10367
+ }, null, 2);
10368
+ }
10369
+
10204
10370
  case 'jobboard_move_application_stage': {
10205
10371
  const app = await messagingPut(`/Recruiting/Applications/${args.applicationId}/Stage`, { stage: args.stage });
10206
10372
  return JSON.stringify(app, null, 2);