fluxmail 0.15.0 → 0.17.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.
Files changed (82) hide show
  1. package/dist/auth.d.ts +22 -1
  2. package/dist/auth.d.ts.map +1 -1
  3. package/dist/auth.js +29 -3
  4. package/dist/auth.js.map +1 -1
  5. package/dist/authorization.d.ts +2 -0
  6. package/dist/authorization.d.ts.map +1 -1
  7. package/dist/authorization.js +9 -4
  8. package/dist/authorization.js.map +1 -1
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +93 -5
  11. package/dist/cli.js.map +1 -1
  12. package/dist/cliMail.d.ts.map +1 -1
  13. package/dist/cliMail.js +21 -1
  14. package/dist/cliMail.js.map +1 -1
  15. package/dist/deploymentConfig.d.ts +1 -1
  16. package/dist/deploymentConfig.d.ts.map +1 -1
  17. package/dist/deploymentConfig.js +1 -1
  18. package/dist/deploymentConfig.js.map +1 -1
  19. package/dist/http/app.d.ts +3 -0
  20. package/dist/http/app.d.ts.map +1 -1
  21. package/dist/http/app.js +37 -22
  22. package/dist/http/app.js.map +1 -1
  23. package/dist/http/identity.d.ts +20 -0
  24. package/dist/http/identity.d.ts.map +1 -1
  25. package/dist/http/identity.js +146 -0
  26. package/dist/http/identity.js.map +1 -1
  27. package/dist/http/oauth.d.ts +28 -0
  28. package/dist/http/oauth.d.ts.map +1 -0
  29. package/dist/http/oauth.js +552 -0
  30. package/dist/http/oauth.js.map +1 -0
  31. package/dist/http/oauthPages.d.ts +36 -0
  32. package/dist/http/oauthPages.d.ts.map +1 -0
  33. package/dist/http/oauthPages.js +213 -0
  34. package/dist/http/oauthPages.js.map +1 -0
  35. package/dist/http/rest.d.ts.map +1 -1
  36. package/dist/http/rest.js +14 -8
  37. package/dist/http/rest.js.map +1 -1
  38. package/dist/mcp/attachmentContent.d.ts +1 -1
  39. package/dist/mcp/attachmentContent.d.ts.map +1 -1
  40. package/dist/mcp/buildServer.d.ts +15 -2
  41. package/dist/mcp/buildServer.d.ts.map +1 -1
  42. package/dist/mcp/buildServer.js +214 -106
  43. package/dist/mcp/buildServer.js.map +1 -1
  44. package/dist/mcp/clientTelemetry.d.ts +19 -0
  45. package/dist/mcp/clientTelemetry.d.ts.map +1 -0
  46. package/dist/mcp/clientTelemetry.js +152 -0
  47. package/dist/mcp/clientTelemetry.js.map +1 -0
  48. package/dist/mcp/outputSchemas.d.ts +457 -7
  49. package/dist/mcp/outputSchemas.d.ts.map +1 -1
  50. package/dist/mcp/outputSchemas.js +11 -1
  51. package/dist/mcp/outputSchemas.js.map +1 -1
  52. package/dist/oauth/clientMetadata.d.ts +39 -0
  53. package/dist/oauth/clientMetadata.d.ts.map +1 -0
  54. package/dist/oauth/clientMetadata.js +234 -0
  55. package/dist/oauth/clientMetadata.js.map +1 -0
  56. package/dist/oauth/redirectUris.d.ts +13 -0
  57. package/dist/oauth/redirectUris.d.ts.map +1 -0
  58. package/dist/oauth/redirectUris.js +78 -0
  59. package/dist/oauth/redirectUris.js.map +1 -0
  60. package/dist/permissions.d.ts +4 -0
  61. package/dist/permissions.d.ts.map +1 -1
  62. package/dist/permissions.js +10 -0
  63. package/dist/permissions.js.map +1 -1
  64. package/dist/service/emailService.d.ts.map +1 -1
  65. package/dist/service/emailService.js +4 -1
  66. package/dist/service/emailService.js.map +1 -1
  67. package/dist/service/publicErrors.d.ts +4 -0
  68. package/dist/service/publicErrors.d.ts.map +1 -1
  69. package/dist/service/publicErrors.js +16 -1
  70. package/dist/service/publicErrors.js.map +1 -1
  71. package/dist/storage/db.d.ts +824 -2
  72. package/dist/storage/db.d.ts.map +1 -1
  73. package/dist/storage/db.js +142 -1
  74. package/dist/storage/db.js.map +1 -1
  75. package/dist/storage/members.d.ts.map +1 -1
  76. package/dist/storage/members.js +2 -0
  77. package/dist/storage/members.js.map +1 -1
  78. package/dist/storage/oauth.d.ts +178 -0
  79. package/dist/storage/oauth.d.ts.map +1 -0
  80. package/dist/storage/oauth.js +556 -0
  81. package/dist/storage/oauth.js.map +1 -0
  82. package/package.json +9 -6
@@ -2,16 +2,16 @@ import { VERSION } from '../version.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { z } from 'zod';
4
4
  import { zodToJsonSchema } from 'zod-to-json-schema';
5
- import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
6
- import { isInitializeRequest, LATEST_PROTOCOL_VERSION, ListToolsRequestSchema, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/sdk/types.js';
7
- import { EmailError, isEmailError, mergeEmailQueries, parseEmailSearch, parseSingleAddress, } from '@fluxmail/core';
5
+ import { CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, McpServer, ResourceTemplate, } from '@modelcontextprotocol/server';
6
+ import { isEmailError, mergeEmailQueries, parseEmailSearch, parseSingleAddress, } from '@fluxmail/core';
8
7
  import { batchFailureCause } from '../service/emailService.js';
9
8
  import { DEFAULT_MAX_ATTACHMENT_BYTES } from '../config.js';
10
9
  import { DEFAULT_PERMISSION_POLICY, MCP_CAPABILITIES, MCP_CAPABILITY_DESCRIPTIONS, enableCapabilitiesInstructions, hasCapability, normalizePermissionPolicy, } from '../permissions.js';
11
10
  import { captureOperation } from '../telemetry.js';
12
11
  import { logFailure } from '../logging.js';
13
- import { ClientInputError, MissingCapabilityError, publicError } from '../service/publicErrors.js';
12
+ import { attachmentTooLarge, ClientInputError, isAttachmentSizeError, MissingCapabilityError, publicError, } from '../service/publicErrors.js';
14
13
  import { outputSchemas } from './outputSchemas.js';
14
+ import { mcpClientProperties, trackMcpInitialization } from './clientTelemetry.js';
15
15
  import { attachmentContents, inlineContent, mediaType, pageRange, pdfText, pdfTextContent, } from './attachmentContent.js';
16
16
  const MAX_BODY_CHARS = 50_000;
17
17
  const BYTES_PER_MEGABYTE = 1024 * 1024;
@@ -33,6 +33,25 @@ function toolJsonSchema(shape, pipeStrategy) {
33
33
  delete schema.$schema;
34
34
  return schema;
35
35
  }
36
+ // Retain the existing validators, transforms and JSON Schemas at the SDK boundary.
37
+ // The rest of the server also uses Zod 3; the SDK consumes only Standard Schema here.
38
+ function mcpSchema(shape) {
39
+ const schema = z.object(shape);
40
+ return {
41
+ '~standard': {
42
+ version: 1,
43
+ vendor: 'fluxmail',
44
+ validate: async (value) => {
45
+ const result = await schema.safeParseAsync(value);
46
+ return result.success ? { value: result.data } : { issues: result.error.issues };
47
+ },
48
+ jsonSchema: {
49
+ input: () => toolJsonSchema(shape, 'input'),
50
+ output: () => toolJsonSchema(shape, 'output'),
51
+ },
52
+ },
53
+ };
54
+ }
36
55
  function nullableOptionalInputs(shape) {
37
56
  return Object.fromEntries(Object.entries(shape).map(([name, schema]) => {
38
57
  if (!(schema instanceof z.ZodOptional))
@@ -210,6 +229,9 @@ function toolError(err, requestId) {
210
229
  return { isError: true, content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
211
230
  }
212
231
  function toolFeatureProperties(tool, args) {
232
+ const modifyAction = MODIFY_ACTION_BY_TOOL.get(tool);
233
+ if (modifyAction)
234
+ return { action: modifyAction };
213
235
  if (!args || typeof args !== 'object')
214
236
  return {};
215
237
  const input = args;
@@ -224,8 +246,6 @@ function toolFeatureProperties(tool, args) {
224
246
  };
225
247
  case 'forward_email':
226
248
  return { include_attachments: input.includeAttachments !== false };
227
- case 'modify_emails':
228
- return typeof input.action === 'string' ? { action: input.action } : {};
229
249
  case 'download_attachment':
230
250
  return {
231
251
  destination: input.inline === true ? 'inline' : typeof input.pages === 'string' ? 'pdf_pages' : 'resource',
@@ -234,6 +254,12 @@ function toolFeatureProperties(tool, args) {
234
254
  return {};
235
255
  }
236
256
  }
257
+ function toolTelemetryProperties(tool, args, options) {
258
+ return {
259
+ ...toolFeatureProperties(tool, args[0]),
260
+ ...options.clientProperties?.(args[1]),
261
+ };
262
+ }
237
263
  function handleResult(tool, fn, gate, options = {}) {
238
264
  return async (...args) => {
239
265
  const finishActivity = options.telemetry?.beginActivity?.();
@@ -255,7 +281,7 @@ function handleResult(tool, fn, gate, options = {}) {
255
281
  : {}),
256
282
  durationMs: performance.now() - startedAt,
257
283
  transport: options.transport ?? 'unknown',
258
- properties: toolFeatureProperties(tool, args[0]),
284
+ properties: toolTelemetryProperties(tool, args, options),
259
285
  });
260
286
  return result;
261
287
  }
@@ -275,7 +301,7 @@ function handleResult(tool, fn, gate, options = {}) {
275
301
  error: err,
276
302
  durationMs: performance.now() - startedAt,
277
303
  transport: options.transport ?? 'unknown',
278
- properties: toolFeatureProperties(tool, args[0]),
304
+ properties: toolTelemetryProperties(tool, args, options),
279
305
  });
280
306
  return toolError(err, requestId);
281
307
  }
@@ -310,24 +336,103 @@ function emailQuery(args) {
310
336
  'rawProviderQuery',
311
337
  ];
312
338
  const q = {};
313
- for (const key of keys)
314
- if (args[key] !== undefined)
315
- q[key] = args[key];
339
+ for (const key of keys) {
340
+ const value = args[key];
341
+ // Agents often fill filters they mean to leave out with blank strings.
342
+ if (value === undefined || (typeof value === 'string' && value.trim() === ''))
343
+ continue;
344
+ q[key] = value;
345
+ }
316
346
  return q;
317
347
  }
318
- const MODIFY_CAPABILITIES = {
319
- markRead: 'mail.organize',
320
- markUnread: 'mail.organize',
321
- star: 'mail.organize',
322
- unstar: 'mail.organize',
323
- archive: 'mail.organize',
324
- trash: 'mail.trash',
325
- untrash: 'mail.trash',
326
- delete: 'mail.delete',
327
- move: 'mail.organize',
328
- addLabels: 'mail.organize',
329
- removeLabels: 'mail.organize',
348
+ /** One MCP tool per modify action, all served by the same implementation. */
349
+ export const MODIFY_TOOLS = {
350
+ markRead: {
351
+ name: 'mark_emails_read',
352
+ title: 'Mark emails read',
353
+ description: 'Mark messages as read.',
354
+ capability: 'mail.organize',
355
+ destructive: false,
356
+ },
357
+ markUnread: {
358
+ name: 'mark_emails_unread',
359
+ title: 'Mark emails unread',
360
+ description: 'Mark messages as unread.',
361
+ capability: 'mail.organize',
362
+ destructive: false,
363
+ },
364
+ star: {
365
+ name: 'star_emails',
366
+ title: 'Star emails',
367
+ description: 'Star messages.',
368
+ capability: 'mail.organize',
369
+ destructive: false,
370
+ },
371
+ unstar: {
372
+ name: 'unstar_emails',
373
+ title: 'Unstar emails',
374
+ description: 'Remove the star from messages.',
375
+ capability: 'mail.organize',
376
+ destructive: false,
377
+ },
378
+ archive: {
379
+ name: 'archive_emails',
380
+ title: 'Archive emails',
381
+ description: 'Move messages out of the inbox into the archive.',
382
+ capability: 'mail.organize',
383
+ destructive: false,
384
+ },
385
+ // Spam and Junk folders delete their mail after a while, so a move can lose it.
386
+ move: {
387
+ name: 'move_emails',
388
+ title: 'Move emails',
389
+ description: 'Move messages to a folder other than the archive or trash.',
390
+ capability: 'mail.organize',
391
+ destructive: true,
392
+ input: 'folder',
393
+ },
394
+ addLabels: {
395
+ name: 'add_labels',
396
+ title: 'Add labels',
397
+ description: 'Add labels, or Outlook categories, to messages.',
398
+ capability: 'mail.organize',
399
+ destructive: false,
400
+ input: 'labels',
401
+ },
402
+ removeLabels: {
403
+ name: 'remove_labels',
404
+ title: 'Remove labels',
405
+ description: 'Remove labels, or Outlook categories, from messages.',
406
+ capability: 'mail.organize',
407
+ destructive: false,
408
+ input: 'labels',
409
+ },
410
+ // Providers empty the trash automatically, so trashed mail is eventually gone.
411
+ trash: {
412
+ name: 'trash_emails',
413
+ title: 'Move emails to trash',
414
+ description: 'Move messages to the trash, where they can be restored until the provider empties it.',
415
+ capability: 'mail.trash',
416
+ destructive: true,
417
+ },
418
+ untrash: {
419
+ name: 'restore_emails',
420
+ title: 'Restore emails from trash',
421
+ description: 'Move messages out of the trash.',
422
+ capability: 'mail.trash',
423
+ destructive: false,
424
+ },
425
+ delete: {
426
+ name: 'delete_emails',
427
+ title: 'Delete emails permanently',
428
+ description: "Permanently delete messages. This cannot be undone. Gmail accounts connected through Fluxmail's built-in " +
429
+ 'Google app cannot delete permanently; move their messages to the trash instead.',
430
+ capability: 'mail.delete',
431
+ destructive: true,
432
+ },
330
433
  };
434
+ const MODIFY_TOOL_ENTRIES = Object.entries(MODIFY_TOOLS);
435
+ const MODIFY_ACTION_BY_TOOL = new Map(MODIFY_TOOL_ENTRIES.map(([action, tool]) => [tool.name, action]));
331
436
  const PROTECTED_MOVE_DESTINATIONS = new Set(['archive', 'trash']);
332
437
  function disabledCapabilitiesInstructions(permissions, source) {
333
438
  const disabled = MCP_CAPABILITIES.filter((capability) => !hasCapability(permissions, capability));
@@ -348,7 +453,6 @@ export function buildMcpServer(service, options = {}) {
348
453
  const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
349
454
  const can = (capability) => hasCapability(permissions, capability);
350
455
  const canAll = (capabilities) => capabilities.every(can);
351
- const canAny = (capabilities) => capabilities.some(can);
352
456
  const requireCapabilities = (capabilities) => {
353
457
  const missing = capabilities.filter((capability) => !can(capability));
354
458
  if (missing.length) {
@@ -357,28 +461,42 @@ export function buildMcpServer(service, options = {}) {
357
461
  }
358
462
  };
359
463
  const downloadAttachment = async (accountId, messageId, attachmentId) => {
360
- const attachment = await service.getAttachment(accountId, messageId, attachmentId, maxAttachmentBytes);
464
+ const attachment = await service
465
+ .getAttachment(accountId, messageId, attachmentId, maxAttachmentBytes)
466
+ .catch((error) => {
467
+ if (!isAttachmentSizeError(error))
468
+ throw error;
469
+ const sizeBytes = error.data?.sizeBytes;
470
+ throw attachmentTooLarge('MCP', maxAttachmentBytes, typeof sizeBytes === 'number' ? sizeBytes : undefined);
471
+ });
361
472
  if (attachment.content.length > maxAttachmentBytes) {
362
- throw new ClientInputError('invalid_request', 'Attachment is too large to return through MCP.', {
363
- sizeBytes: attachment.content.length,
364
- maxBytes: maxAttachmentBytes,
365
- });
473
+ throw attachmentTooLarge('MCP', maxAttachmentBytes, attachment.content.length);
366
474
  }
367
475
  return attachment;
368
476
  };
477
+ // Modern requests carry the client in their envelope; legacy stdio keeps it from initialization.
478
+ const toolOptions = {
479
+ ...options,
480
+ clientProperties: (ctx) => {
481
+ const context = ctx;
482
+ // The SDK types the lifted envelope as empty, but it holds the reserved _meta keys.
483
+ const envelope = context?.mcpReq.envelope;
484
+ return mcpClientProperties(envelope?.[CLIENT_INFO_META_KEY] ?? server.server.getClientVersion(), envelope?.[PROTOCOL_VERSION_META_KEY] ?? (context ? protocolVersionOf(context) : undefined));
485
+ },
486
+ };
369
487
  // Every tool except get_status (the diagnostic way out) enforces the plan quota.
370
488
  const gated = (tool, capability, fn) => handle(tool, async (...args) => {
371
489
  requireCapabilities([capability]);
372
490
  return fn(...args);
373
- }, () => service.enforceQuota(), options);
491
+ }, () => service.enforceQuota(), toolOptions);
374
492
  const allowed = (tool, capability, fn) => handle(tool, async (...args) => {
375
493
  requireCapabilities([capability]);
376
494
  return fn(...args);
377
- }, undefined, options);
495
+ }, undefined, toolOptions);
378
496
  const gatedResult = (tool, capability, fn) => handleResult(tool, async (...args) => {
379
497
  requireCapabilities([capability]);
380
498
  return fn(...args);
381
- }, () => service.enforceQuota(), options);
499
+ }, () => service.enforceQuota(), toolOptions);
382
500
  const server = new McpServer({ name: 'fluxmail', title: 'Fluxmail Email', version: VERSION }, {
383
501
  instructions: "Fluxmail is the user's email integration, already authenticated against their real mailboxes. " +
384
502
  'Tool results are structured for you, not for display. Message ids, thread ids, draft ids, and ' +
@@ -388,40 +506,26 @@ export function buildMcpServer(service, options = {}) {
388
506
  'than echoing raw payloads, ids, or field names.' +
389
507
  disabledCapabilitiesInstructions(permissions, permissionSource),
390
508
  });
391
- // resource_link arrived in MCP 2025-06-18. Streamable HTTP sends the version on each request;
392
- // stdio negotiates it once, so remember it. The SDK has no public accessor for the negotiated
393
- // version, so observe the initialize request. The SDK installs onmessage before starting the
394
- // transport, and this wrapper runs before the SDK handles each message.
395
- let negotiatedProtocolVersion;
509
+ // Observe successful handshakes/discovery without interpreting protocol negotiation ourselves.
396
510
  const connect = server.connect.bind(server);
397
511
  server.connect = (transport) => {
398
512
  const start = transport.start.bind(transport);
399
513
  transport.start = () => {
400
- const onmessage = transport.onmessage;
401
- transport.onmessage = (message, extra) => {
402
- if (isInitializeRequest(message)) {
403
- const requested = message.params.protocolVersion;
404
- negotiatedProtocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
405
- ? requested
406
- : LATEST_PROTOCOL_VERSION;
407
- }
408
- onmessage?.(message, extra);
409
- };
514
+ if (options.telemetry && (options.transport === 'stdio' || options.transport === 'http')) {
515
+ trackMcpInitialization(transport, options.telemetry, options.transport);
516
+ }
410
517
  return start();
411
518
  };
412
519
  return connect(transport);
413
520
  };
414
- const supportsResourceLinks = (extra) => {
415
- const header = extra.requestInfo?.headers['mcp-protocol-version'];
416
- const version = (typeof header === 'string' ? header : undefined) ?? negotiatedProtocolVersion ?? '2025-03-26';
417
- return version >= '2025-06-18';
418
- };
521
+ const protocolVersionOf = (ctx) => ctx.http?.req?.headers.get('mcp-protocol-version') ?? server.server.getNegotiatedProtocolVersion();
522
+ const supportsResourceLinks = (ctx) => (protocolVersionOf(ctx) ?? '2025-03-26') >= '2025-06-18';
419
523
  const listedTools = [];
420
- const registerTool = ((name, config, callback) => {
524
+ const registerTool = (name, config, callback) => {
421
525
  const outputSchema = outputSchemas[name];
422
526
  const inputShape = config.inputSchema;
423
527
  const inputSchema = inputShape ? nullableOptionalInputs(inputShape) : undefined;
424
- const registered = server.registerTool(name, { ...config, ...(inputSchema ? { inputSchema } : {}), outputSchema }, callback);
528
+ const registered = server.registerTool(name, { ...config, inputSchema: mcpSchema(inputSchema ?? {}), outputSchema: mcpSchema(outputSchema) }, callback);
425
529
  listedTools.push({
426
530
  name,
427
531
  title: config.title,
@@ -430,10 +534,9 @@ export function buildMcpServer(service, options = {}) {
430
534
  outputSchema: toolJsonSchema(outputSchema, 'output'),
431
535
  annotations: config.annotations,
432
536
  execution: { taskSupport: 'forbidden' },
433
- _meta: config._meta,
434
537
  });
435
538
  return registered;
436
- });
539
+ };
437
540
  if (can('mail.read'))
438
541
  registerTool('list_accounts', {
439
542
  title: 'List accounts',
@@ -733,7 +836,7 @@ export function buildMcpServer(service, options = {}) {
733
836
  return deliveryResult(await (sendAt !== undefined
734
837
  ? service.scheduleDelivery(args.accountId, toSendRequest(sendArgs), sendAt, idempotencyKey)
735
838
  : service.deliver(args.accountId, toSendRequest(sendArgs), idempotencyKey)));
736
- }, () => service.enforceQuota(), options));
839
+ }, () => service.enforceQuota(), toolOptions));
737
840
  if (can('mail.read'))
738
841
  registerTool('list_scheduled_emails', {
739
842
  title: 'List scheduled emails',
@@ -792,59 +895,61 @@ export function buildMcpServer(service, options = {}) {
792
895
  ...(args.includeAttachments !== undefined ? { includeAttachments: args.includeAttachments } : {}),
793
896
  ...(args.from !== undefined ? { from: args.from } : {}),
794
897
  }, args.idempotencyKey));
795
- }, () => service.enforceQuota(), options));
796
- const modifyActions = Object.keys(MODIFY_CAPABILITIES).filter((action) => can(MODIFY_CAPABILITIES[action]));
797
- if (modifyActions.length) {
798
- const allowedModifyActions = new Set(modifyActions);
799
- registerTool('modify_emails', {
800
- title: 'Modify emails',
801
- description: 'Batch-modify emails using the actions allowed for this connection. Moving requires folder; labels require labels.',
898
+ }, () => service.enforceQuota(), toolOptions));
899
+ const modifyEmails = async (action, args) => {
900
+ requireCapabilities([MODIFY_TOOLS[action].capability]);
901
+ let modifyAction;
902
+ if (action === 'move') {
903
+ if (!args.folder)
904
+ throw new ClientInputError('invalid_request', 'Moving messages requires "folder"');
905
+ if (PROTECTED_MOVE_DESTINATIONS.has(args.folder.trim().toLowerCase())) {
906
+ throw new ClientInputError('invalid_request', 'Archive or trash these messages instead of moving them to this folder.');
907
+ }
908
+ modifyAction = { move: args.folder };
909
+ }
910
+ else if (action === 'addLabels' || action === 'removeLabels') {
911
+ if (!args.labels?.length)
912
+ throw new ClientInputError('invalid_request', `${action} requires "labels"`);
913
+ modifyAction = action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
914
+ }
915
+ else {
916
+ modifyAction = action;
917
+ }
918
+ const result = await service.modify(args.accountId, args.messageIds, modifyAction);
919
+ const response = ok({ ...result, action });
920
+ if (result.failed.length || result.uncertainIds.length) {
921
+ response.isError = true;
922
+ response[TELEMETRY_ERROR] = true;
923
+ }
924
+ return response;
925
+ };
926
+ const messageIdsParam = z
927
+ .array(idParam)
928
+ .min(1)
929
+ .refine((ids) => new Set(ids).size <= 100, 'At most 100 distinct message IDs are allowed.')
930
+ .describe('Messages to change, up to 100 distinct IDs');
931
+ const modifyInputs = {
932
+ folder: { folder: z.string().min(1).describe('Destination folder name or role') },
933
+ labels: { labels: z.array(z.string().min(1)).min(1).max(100).describe('Gmail labels or Outlook categories') },
934
+ };
935
+ for (const [action, tool] of MODIFY_TOOL_ENTRIES) {
936
+ if (!can(tool.capability))
937
+ continue;
938
+ registerTool(tool.name, {
939
+ title: tool.title,
940
+ description: tool.description,
802
941
  inputSchema: {
803
942
  accountId: accountIdParam,
804
- messageIds: z
805
- .array(idParam)
806
- .min(1)
807
- .refine((ids) => new Set(ids).size <= 100, 'At most 100 distinct message IDs are allowed.'),
808
- action: z.enum(modifyActions),
809
- folder: z.string().min(1).optional().describe('Target folder for action=move'),
810
- labels: z.array(z.string().min(1)).max(100).optional().describe('Labels for addLabels/removeLabels'),
943
+ messageIds: messageIdsParam,
944
+ ...(tool.input ? modifyInputs[tool.input] : {}),
811
945
  },
812
946
  annotations: {
813
947
  readOnlyHint: false,
814
- destructiveHint: canAny(['mail.trash', 'mail.delete']),
948
+ destructiveHint: tool.destructive,
815
949
  idempotentHint: true,
950
+ openWorldHint: false,
816
951
  },
817
- }, handleResult('modify_emails', async (args) => {
818
- if (!allowedModifyActions.has(args.action)) {
819
- throw new EmailError('permission_denied', `This MCP connection does not allow action=${args.action}.`);
820
- }
821
- requireCapabilities([MODIFY_CAPABILITIES[args.action]]);
822
- let action;
823
- if (args.action === 'move') {
824
- if (!args.folder)
825
- throw new ClientInputError('invalid_request', 'action=move requires "folder"');
826
- if (PROTECTED_MOVE_DESTINATIONS.has(args.folder.trim().toLowerCase())) {
827
- throw new ClientInputError('invalid_request', 'Use the dedicated archive or trash action for this folder');
828
- }
829
- action = { move: args.folder };
830
- }
831
- else if (args.action === 'addLabels' || args.action === 'removeLabels') {
832
- if (!args.labels?.length) {
833
- throw new ClientInputError('invalid_request', `action=${args.action} requires "labels"`);
834
- }
835
- action = args.action === 'addLabels' ? { addLabels: args.labels } : { removeLabels: args.labels };
836
- }
837
- else {
838
- action = args.action;
839
- }
840
- const result = await service.modify(args.accountId, args.messageIds, action);
841
- const response = ok({ ...result, action: args.action });
842
- if (result.failed.length || result.uncertainIds.length) {
843
- response.isError = true;
844
- response[TELEMETRY_ERROR] = true;
845
- }
846
- return response;
847
- }, () => service.enforceQuota(), options));
952
+ }, handleResult(tool.name, async (args) => modifyEmails(action, args), () => service.enforceQuota(), toolOptions));
848
953
  }
849
954
  if (can('mail.read'))
850
955
  registerTool('download_attachment', {
@@ -868,7 +973,7 @@ export function buildMcpServer(service, options = {}) {
868
973
  throw new ClientInputError('invalid_request', 'Pages apply only to PDF attachments.');
869
974
  }
870
975
  const extracted = pdf && (args.inline || args.pages)
871
- ? await pdfText(content, extra.signal, args.pages, (error) => options.logger?.warn('mcp.pdf_text_failed', 'PDF text extraction failed.', error, {
976
+ ? await pdfText(content, extra.mcpReq.signal, args.pages, (error) => options.logger?.warn('mcp.pdf_text_failed', 'PDF text extraction failed.', error, {
872
977
  productSurface: 'mcp',
873
978
  operation: 'download_attachment',
874
979
  }))
@@ -899,9 +1004,12 @@ export function buildMcpServer(service, options = {}) {
899
1004
  const { meta, content } = await downloadAttachment(decodeURIComponent(String(variables.accountId)), decodeURIComponent(String(variables.messageId)), decodeURIComponent(String(variables.attachmentId)));
900
1005
  return { contents: [attachmentContents(uri.href, meta.mimeType || 'application/octet-stream', content)] };
901
1006
  });
1007
+ if (can('mail.read'))
1008
+ server.server.registerCapabilities({ resources: { listChanged: false } });
902
1009
  if (listedTools.length) {
903
- // The SDK's list handler declares draft-07 for output schemas, which strict clients reject.
904
- server.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: listedTools }));
1010
+ server.server.registerCapabilities({ tools: { listChanged: false } });
1011
+ // Preserve the advertised schemas, including empty inputs and output schema dialects.
1012
+ server.server.setRequestHandler('tools/list', () => ({ tools: listedTools }));
905
1013
  }
906
1014
  return server;
907
1015
  }