fluxmail 0.22.0 → 0.24.0

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.
@@ -4,12 +4,12 @@ import { randomUUID } from 'node:crypto';
4
4
  import { z } from 'zod';
5
5
  import { CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, McpServer, ResourceTemplate, } from '@modelcontextprotocol/server';
6
6
  import { isEmailError, mergeEmailQueries, parseEmailSearch, parseSingleAddress, } from '@fluxmail/core';
7
- import { ATTACHMENT_URI_TEMPLATE, attachmentUri, bodyPage, MAX_BODY_CHARS, MAX_THREAD_PAGE_TOKEN_CHARS, selectBody, sharedTool, threadPage, threadPageOffset, toModifyAction, } from '@fluxmail/core/mcp';
8
- import { batchFailureCause } from '../service/emailService.js';
7
+ import { ATTACHMENT_URI_TEMPLATE, attachmentUri, bodyPage, MAX_BODY_CHARS, MAX_THREAD_PAGE_TOKEN_CHARS, selectBody, sharedTool, sharedToolTarget, threadPage, threadPageOffset, toModifyAction, } from '@fluxmail/core/mcp';
8
+ import { batchFailureCause, modifyFailure, } from '../service/emailService.js';
9
9
  import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
10
10
  import { DEFAULT_PERMISSION_POLICY, MCP_CAPABILITIES, MCP_CAPABILITY_DESCRIPTIONS, enableCapabilitiesInstructions, hasCapability, normalizePermissionPolicy, } from '../permissions.js';
11
11
  import { captureOperation } from '../telemetry.js';
12
- import { logFailure } from '../logging.js';
12
+ import { logCodedFailure, logFailure } from '../logging.js';
13
13
  import { attachmentTooLarge, ClientInputError, isAttachmentSizeError, MissingCapabilityError, publicError, } from '../service/publicErrors.js';
14
14
  import { sendAtSchema } from '../sendAt.js';
15
15
  import { outputSchemas } from './outputSchemas.js';
@@ -32,6 +32,8 @@ function sizeLabel(bytes) {
32
32
  const TELEMETRY_ERROR = Symbol('telemetryError');
33
33
  /** The error behind a partial failure, read only for telemetry failure detail. */
34
34
  const TELEMETRY_FAILURE = Symbol('telemetryFailure');
35
+ /** The telemetry error code for a partial failure. Defaults to `account_failure`. */
36
+ const TELEMETRY_ERROR_CODE = Symbol('telemetryErrorCode');
35
37
  const EMPTY_INPUT_SCHEMA = {
36
38
  type: 'object',
37
39
  properties: {},
@@ -74,15 +76,76 @@ function toolJsonSchema(shape, pipeStrategy) {
74
76
  delete schema.$schema;
75
77
  return schema;
76
78
  }
79
+ const MAX_REPORTED_ARGUMENT_PROBLEMS = 10;
80
+ const MAX_REPORTED_ARGUMENT_CHARS = 100;
81
+ function argumentPath(path) {
82
+ const formatted = path
83
+ .map((step, index) => {
84
+ if (typeof step === 'number')
85
+ return `[${step}]`;
86
+ return index ? `.${String(step)}` : String(step);
87
+ })
88
+ .join('');
89
+ return formatted.slice(0, MAX_REPORTED_ARGUMENT_CHARS);
90
+ }
91
+ /** Tool arguments that do not match the input schema. The message names client-supplied keys. */
92
+ class InvalidToolArgumentsError extends ClientInputError {
93
+ constructor(tool, issues) {
94
+ // A misspelled argument often causes the other problems, so unknown arguments come first.
95
+ const problems = [
96
+ ...issues.flatMap((issue) => issue.code === 'unrecognized_keys'
97
+ ? issue.keys.map((key) => `unknown argument ${JSON.stringify(argumentPath([...issue.path, key]))}`)
98
+ : []),
99
+ ...issues
100
+ .filter((issue) => issue.code !== 'unrecognized_keys')
101
+ .map((issue) => `${issue.path.length ? argumentPath(issue.path) : 'arguments'}: ${issue.message}`),
102
+ ];
103
+ const shown = problems.slice(0, MAX_REPORTED_ARGUMENT_PROBLEMS);
104
+ const more = problems.length - shown.length;
105
+ super('invalid_request', `Invalid arguments for ${tool}: ${shown.join('; ')}${more ? `; and ${more} more` : ''}. ` +
106
+ "Check the tool's input schema.");
107
+ }
108
+ }
109
+ /**
110
+ * Raw tool arguments, parsed by the tool handler after its quota gate so that every
111
+ * schema problem becomes one invalid_request error.
112
+ */
113
+ class ToolInput {
114
+ tool;
115
+ raw;
116
+ schema;
117
+ constructor(tool, raw, schema) {
118
+ this.tool = tool;
119
+ this.raw = raw;
120
+ this.schema = schema;
121
+ }
122
+ async parse() {
123
+ const result = await this.schema.safeParseAsync(this.raw ?? {});
124
+ if (!result.success)
125
+ throw new InvalidToolArgumentsError(this.tool, result.error.issues);
126
+ return result.data;
127
+ }
128
+ }
129
+ /**
130
+ * Tools with arguments reject undeclared ones, as their schemas advertise, even when every
131
+ * argument is optional. Dropping one could widen a search. Tools without arguments advertise
132
+ * an open schema and ignore extras, which some clients send to tools that take no arguments.
133
+ */
134
+ function toolInputSchema(shape) {
135
+ return Object.keys(shape).length ? z.object(shape).strict() : z.object(shape);
136
+ }
77
137
  // Retain the existing validators, transforms and JSON Schemas at the SDK boundary.
78
- // The SDK consumes only Standard Schema here.
79
- function mcpSchema(shape, strict = false) {
80
- const schema = strict ? z.object(shape).strict() : z.object(shape);
138
+ // The SDK consumes only Standard Schema here. Tool handlers parse inputs themselves, so the
139
+ // input validator passes arguments through unchanged.
140
+ function mcpSchema(shape, io) {
141
+ const schema = io === 'output' ? z.object(shape) : undefined;
81
142
  return {
82
143
  '~standard': {
83
144
  version: 1,
84
145
  vendor: 'fluxmail',
85
146
  validate: async (value) => {
147
+ if (!schema)
148
+ return { value };
86
149
  const result = await schema.safeParseAsync(value);
87
150
  return result.success ? { value: result.data } : { issues: result.error.issues };
88
151
  },
@@ -117,21 +180,11 @@ const addressList = z
117
180
  .max(MAX_RECIPIENTS)
118
181
  .describe('Recipients, each "Name <a@x.com>" or "a@x.com"');
119
182
  const searchString = z.string().max(MAX_SEARCH_CHARS);
120
- const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
121
183
  const queryShape = {
122
184
  folder: searchString
123
185
  .min(1)
124
186
  .optional()
125
187
  .describe("Folder role (inbox, sent, drafts, trash, spam, starred, archive, all) or a label/folder name. Use all or omit this field to search all mail except Spam and Trash. An IMAP server's \\All mailbox may use different rules."),
126
- text: searchString.optional().describe('Literal full-text search terms'),
127
- from: searchString.optional(),
128
- to: searchString.optional(),
129
- subject: searchString.optional(),
130
- read: z.boolean().optional(),
131
- starred: z.boolean().optional(),
132
- hasAttachment: z.boolean().optional(),
133
- after: isoDate.optional().describe('YYYY-MM-DD received date, inclusive in UTC'),
134
- before: isoDate.optional().describe('YYYY-MM-DD received date, exclusive in UTC'),
135
188
  rawProviderQuery: searchString
136
189
  .optional()
137
190
  .describe('Provider-native Gmail syntax or Outlook KQL for one compatible account'),
@@ -141,8 +194,14 @@ const queryShape = {
141
194
  includeSearchContext: z
142
195
  .boolean()
143
196
  .optional()
144
- .describe('Include a match-centered body excerpt; requires a portable text query'),
197
+ .describe('Include a match-centered body excerpt; requires search words in query'),
145
198
  };
199
+ const sendAtParam = sendAtSchema
200
+ .optional()
201
+ .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
202
+ '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
203
+ 'for delivery by fluxmail serve or fluxmail scheduled run using the same store. Stdio does not ' +
204
+ 'deliver schedules. Overdue mail may send when a worker starts. Returns a scheduleId for list/cancel.');
146
205
  const draftShape = {
147
206
  accountId: accountIdParam,
148
207
  from: z.string().email().optional().describe('Connected address or an available send-as address'),
@@ -157,7 +216,7 @@ const draftShape = {
157
216
  bodyHtml: z.string().optional().describe('HTML body'),
158
217
  replyToMessageId: idParam
159
218
  .optional()
160
- .describe('Message being replied to; threads correctly, quotes it below the body and computes recipients if "to" is omitted'),
219
+ .describe('Message being replied to; threads correctly, quotes it below the body and computes recipients if "to" is omitted. To continue a conversation, reply to the first message get_thread returns with order newest'),
161
220
  replyAll: z.boolean().optional().describe('With replyToMessageId: reply to all original recipients'),
162
221
  timeZone: z
163
222
  .string()
@@ -170,7 +229,8 @@ const draftShape = {
170
229
  .optional()
171
230
  .describe('With replyToMessageId: set to false to leave the original message out of the reply. Defaults to true.'),
172
231
  attachments: z
173
- .array(z.object({
232
+ .array(z
233
+ .object({
174
234
  filename: z.string().min(1).max(MAX_ATTACHMENT_FIELD_CHARS),
175
235
  mimeType: z.string().min(1).max(MAX_ATTACHMENT_FIELD_CHARS),
176
236
  content: z.string().describe('base64'),
@@ -181,7 +241,8 @@ const draftShape = {
181
241
  .optional()
182
242
  .describe('Content-ID for an inline image, referenced from bodyHtml as cid:<contentId>'),
183
243
  disposition: z.enum(['inline', 'attachment']).optional().describe('Defaults to attachment'),
184
- }))
244
+ })
245
+ .strict())
185
246
  .optional(),
186
247
  };
187
248
  function parseAddresses(raw) {
@@ -289,7 +350,12 @@ function toolFeatureProperties(tool, args) {
289
350
  reply_all: input.replyAll === true,
290
351
  };
291
352
  case 'forward_email':
292
- return { include_attachments: input.includeAttachments !== false };
353
+ return { include_attachments: input.includeAttachments !== false, scheduled: input.sendAt !== undefined };
354
+ case 'list_emails':
355
+ case 'search_emails':
356
+ case 'list_emails_batch':
357
+ case 'search_emails_batch':
358
+ return { typed_query: typeof input.query === 'string' && input.query.trim() !== '' };
293
359
  case 'download_attachment':
294
360
  return {
295
361
  destination: input.inline === true ? 'inline' : typeof input.pages === 'string' ? 'pdf_pages' : 'resource',
@@ -304,14 +370,21 @@ function toolTelemetryProperties(tool, args, options) {
304
370
  ...options.clientProperties?.(args[1]),
305
371
  };
306
372
  }
373
+ /** Handlers built by handleResult, which parse their tool input. Only these can be registered. */
374
+ const toolHandlers = new WeakSet();
307
375
  function handleResult(tool, fn, gate, options = {}) {
308
- return async (...args) => {
376
+ const handler = async (...received) => {
309
377
  const finishActivity = options.telemetry?.beginActivity?.();
310
378
  const startedAt = performance.now();
379
+ const input = received[0] instanceof ToolInput ? received[0] : undefined;
380
+ // Until the input parses, telemetry reports no properties derived from it.
381
+ let args = (input ? [undefined, ...received.slice(1)] : received);
311
382
  try {
312
383
  // The gate throws when a lapsed license leaves the instance over quota,
313
384
  // and yields a renewal warning to attach while the license is in grace.
314
385
  const warning = gate?.();
386
+ if (input)
387
+ args = [await input.parse(), ...received.slice(1)];
315
388
  const result = await fn(...args);
316
389
  if (warning)
317
390
  result.content.push({ type: 'text', text: `Note: ${warning}` });
@@ -321,7 +394,10 @@ function handleResult(tool, fn, gate, options = {}) {
321
394
  operation: tool,
322
395
  outcome: telemetryFailed ? 'error' : 'success',
323
396
  ...(telemetryFailed
324
- ? { errorCode: 'account_failure', error: result[TELEMETRY_FAILURE] }
397
+ ? {
398
+ errorCode: result[TELEMETRY_ERROR_CODE] ?? 'account_failure',
399
+ error: result[TELEMETRY_FAILURE],
400
+ }
325
401
  : {}),
326
402
  durationMs: performance.now() - startedAt,
327
403
  transport: options.transport ?? 'unknown',
@@ -331,12 +407,18 @@ function handleResult(tool, fn, gate, options = {}) {
331
407
  }
332
408
  catch (err) {
333
409
  const requestId = randomUUID();
334
- logFailure(options.logger, 'mcp.operation_failed', err, {
410
+ const logContext = {
335
411
  productSurface: 'mcp',
336
412
  operation: tool,
337
413
  durationMs: performance.now() - startedAt,
338
414
  details: { request_id: requestId },
339
- });
415
+ };
416
+ // The client sees which arguments were wrong; the log omits their names.
417
+ if (err instanceof InvalidToolArgumentsError) {
418
+ logCodedFailure(options.logger, 'mcp.operation_failed', err.code, 'Tool arguments did not match the input schema', logContext);
419
+ }
420
+ else
421
+ logFailure(options.logger, 'mcp.operation_failed', err, logContext);
340
422
  captureOperation(options.telemetry, {
341
423
  productSurface: 'mcp',
342
424
  operation: tool,
@@ -353,6 +435,8 @@ function handleResult(tool, fn, gate, options = {}) {
353
435
  finishActivity?.();
354
436
  }
355
437
  };
438
+ toolHandlers.add(handler);
439
+ return handler;
356
440
  }
357
441
  function handle(tool, fn, gate, options = {}) {
358
442
  return handleResult(tool, async (...args) => ok(await fn(...args)), gate, options);
@@ -365,20 +449,26 @@ function pageOpts(args) {
365
449
  ...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
366
450
  };
367
451
  }
452
+ function invalidSearch(diagnostics) {
453
+ return new ClientInputError('invalid_request', diagnostics.map((item) => item.message).join(' '), { diagnostics });
454
+ }
455
+ /**
456
+ * Parses a typed query and merges it with the structured filters sent beside it. Rejects either step's errors.
457
+ * A missing or blank query leaves the structured filters as they are.
458
+ */
459
+ function typedSearch(query, structured) {
460
+ if (query === undefined || query.trim() === '')
461
+ return { query: structured, warnings: [] };
462
+ const parsed = parseEmailSearch(query);
463
+ if (!parsed.valid)
464
+ throw invalidSearch(parsed.diagnostics);
465
+ const merged = mergeEmailQueries(parsed.query, structured);
466
+ if (!merged.success)
467
+ throw invalidSearch(merged.diagnostics);
468
+ return { query: merged.query, warnings: parsed.diagnostics };
469
+ }
368
470
  function emailQuery(args) {
369
- const keys = [
370
- 'folder',
371
- 'text',
372
- 'from',
373
- 'to',
374
- 'subject',
375
- 'read',
376
- 'starred',
377
- 'hasAttachment',
378
- 'after',
379
- 'before',
380
- 'rawProviderQuery',
381
- ];
471
+ const keys = ['folder', 'rawProviderQuery'];
382
472
  const q = {};
383
473
  for (const key of keys) {
384
474
  const value = args[key];
@@ -422,6 +512,7 @@ const PRODUCT_DESCRIPTIONS = {
422
512
  list_accounts: 'Each entry includes the account ID, provider, email address, status and capabilities.',
423
513
  list_emails: 'Use get_email for full bodies.',
424
514
  get_email: 'Use get_email_body to read the rest.',
515
+ forward_email: 'Add sendAt to schedule the forward instead of sending it now.',
425
516
  send_email: 'Confirm with the user when the intent is ambiguous. Add sendAt to any mode to schedule the message instead ' +
426
517
  'of sending it now.',
427
518
  delete_emails: "Gmail accounts connected through Fluxmail's built-in Google app cannot delete permanently; move their " +
@@ -526,14 +617,19 @@ export function buildMcpServer(service, options = {}) {
526
617
  const supportsResourceLinks = (ctx) => (protocolVersionOf(ctx) ?? '2025-03-26') >= '2025-06-18';
527
618
  const listedTools = [];
528
619
  const registerTool = (name, config, callback) => {
529
- const outputSchema = outputSchemas[name];
620
+ // An alias returns the same result as the tool it names.
621
+ const outputSchema = outputSchemas[sharedToolTarget(name)];
530
622
  const inputShape = config.inputSchema;
531
623
  const inputSchema = inputShape && (name === 'send_feedback' ? inputShape : nullableOptionalInputs(inputShape));
624
+ if (!toolHandlers.has(callback))
625
+ throw new Error(`${name} must be registered with a tool handler`);
626
+ const run = callback;
627
+ const parser = toolInputSchema(inputSchema ?? {});
532
628
  const registered = server.registerTool(name, {
533
629
  ...config,
534
- inputSchema: mcpSchema(inputSchema ?? {}, name === 'send_feedback'),
535
- outputSchema: mcpSchema(outputSchema),
536
- }, callback);
630
+ inputSchema: mcpSchema(inputSchema ?? {}, 'input'),
631
+ outputSchema: mcpSchema(outputSchema, 'output'),
632
+ }, ((args, ctx) => run(new ToolInput(name, args, parser), ctx)));
537
633
  listedTools.push({
538
634
  name,
539
635
  title: config.title,
@@ -546,7 +642,7 @@ export function buildMcpServer(service, options = {}) {
546
642
  return registered;
547
643
  };
548
644
  /** Registers a shared tool with its contract metadata when the connection has the tool's scopes. */
549
- const registerSharedTool = (name, inputSchema, callback, productDescription = PRODUCT_DESCRIPTIONS[name]) => {
645
+ const registerSharedTool = (name, inputSchema, callback, productDescription = PRODUCT_DESCRIPTIONS[sharedToolTarget(name)]) => {
550
646
  if (!canAll(toolScopes(name)))
551
647
  return;
552
648
  const tool = sharedTool(name);
@@ -565,7 +661,7 @@ export function buildMcpServer(service, options = {}) {
565
661
  description: 'Account connection and scheduled-send status, including accounts that need re-authentication, and ' +
566
662
  'the time zone for the date above the quoted message in replies. Administrators also see plan details.',
567
663
  inputSchema: {},
568
- annotations: { readOnlyHint: true },
664
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
569
665
  }, allowed('get_status', async () => {
570
666
  const update = options.availableUpdate?.();
571
667
  return { ...(await service.status()), ...(update ? { update } : {}) };
@@ -573,58 +669,28 @@ export function buildMcpServer(service, options = {}) {
573
669
  registerSharedTool('list_folders', { accountId: accountIdParam }, gated('list_folders', async (args) => service.listFolders(args.accountId)));
574
670
  registerSharedTool('list_labels', { accountId: accountIdParam }, gated('list_labels', async (args) => service.listLabels(args.accountId)));
575
671
  registerSharedTool('list_send_as', { accountId: accountIdParam }, gated('list_send_as', async (args) => service.listSendAs(args.accountId)));
576
- registerSharedTool('list_emails', { accountId: accountIdParam, ...queryShape }, gated('list_emails', async (args) => service.listMessages(args.accountId, emailQuery(args), pageOpts(args))));
577
- const { text: _text, ...searchFilterShape } = queryShape;
578
- registerSharedTool('search_emails', {
579
- accountId: accountIdParam,
580
- query: searchString.min(1).describe('Typed portable search syntax'),
581
- ...searchFilterShape,
582
- }, gated('search_emails', async (args) => {
583
- const parsed = parseEmailSearch(args.query);
584
- if (!parsed.valid) {
585
- throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
586
- diagnostics: parsed.diagnostics,
587
- });
588
- }
589
- const merged = mergeEmailQueries(parsed.query, emailQuery(args));
590
- if (!merged.success) {
591
- throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
592
- diagnostics: merged.diagnostics,
593
- });
594
- }
595
- const result = await service.listMessages(args.accountId, merged.query, pageOpts(args));
596
- return {
597
- ...result,
598
- ...([...parsed.diagnostics, ...(result.diagnostics ?? [])].length
599
- ? { diagnostics: [...parsed.diagnostics, ...(result.diagnostics ?? [])] }
600
- : {}),
601
- };
602
- }));
603
- const { pageToken: _batchPageToken, rawProviderQuery: _batchRawProviderQuery, ...batchSearchFilterShape } = searchFilterShape;
604
- registerSharedTool('search_emails_batch', {
605
- accounts: z
606
- .array(z.object(nullableOptionalInputs({ accountId: idParam, pageToken: z.string().min(1).optional() })).strict())
607
- .min(1)
608
- .max(20),
609
- query: searchString.min(1).describe('Typed portable search syntax'),
610
- ...batchSearchFilterShape,
611
- folder: z.enum(['inbox', 'sent', 'drafts', 'archive', 'spam', 'trash', 'all']).optional(),
612
- }, gatedResult('search_emails_batch', async (args) => {
613
- const parsed = parseEmailSearch(args.query);
614
- if (!parsed.valid) {
615
- throw new ClientInputError('invalid_request', parsed.diagnostics.map((item) => item.message).join(' '), {
616
- diagnostics: parsed.diagnostics,
617
- });
618
- }
619
- const merged = mergeEmailQueries(parsed.query, emailQuery(args));
620
- if (!merged.success) {
621
- throw new ClientInputError('invalid_request', merged.diagnostics.map((item) => item.message).join(' '), {
622
- diagnostics: merged.diagnostics,
623
- });
624
- }
672
+ const typedQuery = searchString
673
+ .optional()
674
+ .describe('Typed search. Plain words search the message text; quote a phrase or a value that has spaces. Filters: ' +
675
+ 'from:, to:, subject:, in:inbox|sent|drafts|archive|spam|trash|all, is:read, is:unread, is:starred, ' +
676
+ 'is:unstarred, has:attachment, -has:attachment, after:YYYY-MM-DD (inclusive) and before:YYYY-MM-DD ' +
677
+ '(exclusive), in UTC. Terms combine with AND; OR and parentheses are searched as text.');
678
+ const listEmails = async (args) => {
679
+ const { query, warnings } = typedSearch(args.query, emailQuery(args));
680
+ const result = await service.listMessages(args.accountId, query, pageOpts(args));
681
+ const diagnostics = [...warnings, ...(result.diagnostics ?? [])];
682
+ return { ...result, ...(diagnostics.length ? { diagnostics } : {}) };
683
+ };
684
+ const listEmailsShape = { accountId: accountIdParam, query: typedQuery, ...queryShape };
685
+ // search_emails is an alias of list_emails. It reports its own operation name so telemetry can tell them apart.
686
+ for (const name of ['list_emails', 'search_emails']) {
687
+ registerSharedTool(name, listEmailsShape, gated(name, listEmails));
688
+ }
689
+ const listEmailsBatch = async (args) => {
690
+ const { query, warnings } = typedSearch(args.query, emailQuery(args));
625
691
  const result = await service.searchMessagesBatch({
626
692
  accounts: args.accounts,
627
- query: merged.query,
693
+ query: query,
628
694
  ...(args.pageSize !== undefined ? { pageSize: args.pageSize } : {}),
629
695
  ...(args.includeSnippet !== undefined ? { includeSnippet: args.includeSnippet } : {}),
630
696
  ...(args.includeSearchContext !== undefined ? { includeSearchContext: args.includeSearchContext } : {}),
@@ -634,7 +700,7 @@ export function buildMcpServer(service, options = {}) {
634
700
  groups: result.groups.map((group) => {
635
701
  if (!group.page)
636
702
  return group;
637
- const diagnostics = [...parsed.diagnostics, ...(group.page.diagnostics ?? [])];
703
+ const diagnostics = [...warnings, ...(group.page.diagnostics ?? [])];
638
704
  return {
639
705
  ...group,
640
706
  page: { ...group.page, ...(diagnostics.length ? { diagnostics } : {}) },
@@ -648,7 +714,24 @@ export function buildMcpServer(service, options = {}) {
648
714
  response[TELEMETRY_FAILURE] = batchFailureCause(result.groups);
649
715
  }
650
716
  return response;
651
- }));
717
+ };
718
+ const { pageSize, includeSnippet, includeSearchContext } = queryShape;
719
+ const listEmailsBatchShape = {
720
+ accounts: z
721
+ .array(z.object(nullableOptionalInputs({ accountId: idParam, pageToken: z.string().min(1).optional() })).strict())
722
+ .min(1)
723
+ .max(20),
724
+ query: typedQuery,
725
+ // Custom folder names differ between accounts, so a batch takes only a folder role.
726
+ folder: z.enum(['inbox', 'sent', 'drafts', 'archive', 'spam', 'trash', 'all']).optional(),
727
+ pageSize,
728
+ includeSnippet,
729
+ includeSearchContext,
730
+ };
731
+ // search_emails_batch is an alias of list_emails_batch, with its own operation name in telemetry.
732
+ for (const name of ['list_emails_batch', 'search_emails_batch']) {
733
+ registerSharedTool(name, listEmailsBatchShape, gatedResult(name, listEmailsBatch));
734
+ }
652
735
  registerSharedTool('get_email', {
653
736
  accountId: accountIdParam,
654
737
  messageId: idParam,
@@ -661,12 +744,12 @@ export function buildMcpServer(service, options = {}) {
661
744
  registerSharedTool('get_email_body', {
662
745
  accountId: accountIdParam,
663
746
  messageId: idParam,
664
- format: z.enum(['text', 'html']),
747
+ bodyFormat: z.enum(['text', 'html']).describe('The body part to read'),
665
748
  offset: z.number().int().min(0).optional(),
666
749
  maxChars: z.number().int().min(1).max(MAX_BODY_CHARS).optional(),
667
750
  }, gated('get_email_body', async (args) => {
668
751
  const message = await service.getMessage(args.accountId, args.messageId);
669
- return bodyPage(message, args.format, args.offset, args.maxChars);
752
+ return bodyPage(message, args.bodyFormat, args.offset, args.maxChars);
670
753
  }));
671
754
  registerSharedTool('get_thread', {
672
755
  accountId: accountIdParam,
@@ -674,9 +757,13 @@ export function buildMcpServer(service, options = {}) {
674
757
  pageSize: z.number().int().min(1).max(25).optional(),
675
758
  pageToken: z.string().min(1).max(MAX_THREAD_PAGE_TOKEN_CHARS).optional(),
676
759
  bodyFormat: z.enum(['text', 'html', 'both', 'none']).optional(),
760
+ order: z
761
+ .enum(['oldest', 'newest'])
762
+ .optional()
763
+ .describe('oldest (default) starts with the first message; newest starts with the latest message and pages back in time'),
677
764
  }, gated('get_thread', async (args) => {
678
765
  const thread = await service.getThread(args.accountId, args.threadId);
679
- const offset = threadPageOffset(args.threadId, args.pageToken);
766
+ const offset = threadPageOffset(args.threadId, args.pageToken, args.order);
680
767
  if (offset === undefined)
681
768
  throw new ClientInputError('invalid_request', 'Invalid thread page token.');
682
769
  return threadPage(thread, {
@@ -684,6 +771,7 @@ export function buildMcpServer(service, options = {}) {
684
771
  offset,
685
772
  pageSize: args.pageSize,
686
773
  bodyFormat: args.bodyFormat,
774
+ order: args.order,
687
775
  });
688
776
  }));
689
777
  registerSharedTool('get_draft', { accountId: accountIdParam, draftId: idParam }, gated('get_draft', async (args) => service.getDraft(args.accountId, args.draftId)));
@@ -715,12 +803,7 @@ export function buildMcpServer(service, options = {}) {
715
803
  .describe('Reuse this key when retrying the same delivery'),
716
804
  draftId: idParam.optional().describe('Send this existing draft'),
717
805
  ...draftShape,
718
- sendAt: sendAtSchema
719
- .optional()
720
- .describe('Schedule delivery instead of sending now: ISO 8601 with timezone offset or Z ' +
721
- '(e.g. 2026-07-11T09:00:00-07:00). Fluxmail saves the message as a real draft in the mailbox ' +
722
- 'for delivery by fluxmail serve or fluxmail scheduled run using the same store. Stdio does not ' +
723
- 'deliver schedules. Overdue mail may send when a worker starts. Returns a scheduleId for list/cancel.'),
806
+ sendAt: sendAtParam,
724
807
  }, handleResult('send_email', async (args) => {
725
808
  requireToolScopes('send_email');
726
809
  if (args.replyToMessageId !== undefined)
@@ -737,16 +820,16 @@ export function buildMcpServer(service, options = {}) {
737
820
  'For failed entries, lastError says what went wrong. Pending sends only fire while the ' +
738
821
  'Fluxmail server is running.',
739
822
  inputSchema: { accountId: accountIdParam },
740
- annotations: { readOnlyHint: true },
823
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
741
824
  }, gated('list_scheduled_emails', async (args) => service.listScheduled(args.accountId)));
742
- registerSharedTool('get_delivery_operation', { accountId: idParam, operationId: idParam }, allowed('get_delivery_operation', async (args) => service.getDelivery(args.accountId, args.operationId)));
825
+ registerSharedTool('get_delivery_operation', { accountId: accountIdParam, operationId: idParam }, allowed('get_delivery_operation', async (args) => service.getDelivery(args.accountId, args.operationId)));
743
826
  if (canAll(toolScopes('cancel_scheduled_email')))
744
827
  registerTool('cancel_scheduled_email', {
745
828
  title: 'Cancel scheduled email',
746
- description: 'Cancel a pending scheduled send by scheduleId (from send_email with sendAt, or list_scheduled_emails). ' +
829
+ description: 'Cancel a pending scheduled send by scheduleId (from send_email or forward_email with sendAt, or list_scheduled_emails). ' +
747
830
  'The draft stays in the Drafts folder, so the content is not lost.',
748
831
  inputSchema: { scheduleId: idParam },
749
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
832
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
750
833
  }, gated('cancel_scheduled_email', async (args) => service.cancelScheduled(args.scheduleId)));
751
834
  registerSharedTool('forward_email', {
752
835
  idempotencyKey: z
@@ -758,23 +841,30 @@ export function buildMcpServer(service, options = {}) {
758
841
  from: z.string().email().optional().describe('Connected address or an available send-as address'),
759
842
  to: addressList.min(1),
760
843
  cc: addressList.optional(),
844
+ bcc: addressList.optional(),
761
845
  comment: z
762
846
  .string()
763
847
  .optional()
764
848
  .describe('Comment above the forwarded message. Keep prose paragraphs on one continuous line and separate paragraphs with blank lines.'),
765
849
  includeAttachments: z.boolean().optional().describe('Default true'),
850
+ sendAt: sendAtParam,
766
851
  }, handleResult('forward_email', async (args) => {
767
852
  requireToolScopes('forward_email');
768
853
  const to = parseAddresses(args.to) ?? [];
769
854
  const cc = parseAddresses(args.cc);
770
- return deliveryResult(await service.deliverForward(args.accountId, {
855
+ const bcc = parseAddresses(args.bcc);
856
+ const forward = {
771
857
  messageId: args.messageId,
772
858
  to,
773
859
  ...(cc?.length ? { cc } : {}),
860
+ ...(bcc?.length ? { bcc } : {}),
774
861
  ...(args.comment !== undefined ? { comment: args.comment } : {}),
775
862
  ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
776
863
  ...(args.from !== undefined ? { from: args.from } : {}),
777
- }, args.idempotencyKey));
864
+ };
865
+ return deliveryResult(await (args.sendAt !== undefined
866
+ ? service.scheduleDelivery(args.accountId, { forward }, args.sendAt, args.idempotencyKey)
867
+ : service.deliverForward(args.accountId, forward, args.idempotencyKey)));
778
868
  }, () => service.enforceQuota(), toolOptions));
779
869
  const modifyEmails = async (action, args) => {
780
870
  requireToolScopes(MODIFY_TOOLS[action].name);
@@ -783,9 +873,12 @@ export function buildMcpServer(service, options = {}) {
783
873
  throw new ClientInputError('invalid_request', mapped.error);
784
874
  const result = await service.modify(args.accountId, args.messageIds, mapped.action);
785
875
  const response = ok({ ...result, action });
786
- if (result.failed.length || result.uncertainIds.length) {
876
+ const failure = modifyFailure(result);
877
+ if (failure) {
787
878
  response.isError = true;
788
879
  response[TELEMETRY_ERROR] = true;
880
+ response[TELEMETRY_ERROR_CODE] = failure.errorCode;
881
+ response[TELEMETRY_FAILURE] = failure.cause;
789
882
  }
790
883
  return response;
791
884
  };
@@ -836,10 +929,17 @@ export function buildMcpServer(service, options = {}) {
836
929
  }), `The size limit is ${sizeLabel(maxAttachmentBytes)}.`);
837
930
  if (can('mail.read'))
838
931
  server.registerResource('attachment', new ResourceTemplate(ATTACHMENT_URI_TEMPLATE, { list: undefined }), { description: 'Read one attachment from an authorized mailbox.' }, async (uri, variables) => {
839
- requireCapabilities(['mail.read']);
840
- service.enforceQuota();
841
- const { meta, content } = await downloadAttachment(decodeURIComponent(String(variables.accountId)), decodeURIComponent(String(variables.messageId)), decodeURIComponent(String(variables.attachmentId)));
842
- return { contents: [attachmentContents(uri.href, meta.mimeType || 'application/octet-stream', content)] };
932
+ // Like a tool call, a read in progress holds off shutdown so its provider connection stays open.
933
+ const finishActivity = options.telemetry?.beginActivity?.();
934
+ try {
935
+ requireCapabilities(['mail.read']);
936
+ service.enforceQuota();
937
+ const { meta, content } = await downloadAttachment(decodeURIComponent(String(variables.accountId)), decodeURIComponent(String(variables.messageId)), decodeURIComponent(String(variables.attachmentId)));
938
+ return { contents: [attachmentContents(uri.href, meta.mimeType || 'application/octet-stream', content)] };
939
+ }
940
+ finally {
941
+ finishActivity?.();
942
+ }
843
943
  });
844
944
  if (can('mail.read'))
845
945
  server.server.registerCapabilities({ resources: { listChanged: false } });