gmail-workspace-mcp-server 0.4.7 → 0.4.9

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.
@@ -303,6 +303,9 @@ function createMockClient() {
303
303
  labelIds: ['SENT'],
304
304
  };
305
305
  },
306
+ async getAccountEmail() {
307
+ return 'me@example.com';
308
+ },
306
309
  };
307
310
  }
308
311
  // =============================================================================
@@ -3,11 +3,13 @@ import type { ElicitationConfig } from './types.js';
3
3
  * Reads elicitation configuration from environment variables.
4
4
  *
5
5
  * Environment variables:
6
- * ELICITATION_ENABLED - "true" (default) or "false"
7
- * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
8
- * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
9
- * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
10
- * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
11
- * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
6
+ * ELICITATION_ENABLED - "true" (default) or "false"
7
+ * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
8
+ * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
9
+ * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
10
+ * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
11
+ * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
12
+ * ELICITATION_PREFER_HTTP_FALLBACK - "true" forces HTTP fallback over native elicitation
13
+ * when both are available. Default: "false".
12
14
  */
13
15
  export declare function readElicitationConfig(env?: Record<string, string | undefined>): ElicitationConfig;
@@ -14,16 +14,20 @@ function parsePositiveInt(value, defaultValue) {
14
14
  * Reads elicitation configuration from environment variables.
15
15
  *
16
16
  * Environment variables:
17
- * ELICITATION_ENABLED - "true" (default) or "false"
18
- * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
19
- * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
20
- * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
21
- * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
22
- * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
17
+ * ELICITATION_ENABLED - "true" (default) or "false"
18
+ * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
19
+ * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
20
+ * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
21
+ * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
22
+ * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
23
+ * ELICITATION_PREFER_HTTP_FALLBACK - "true" forces HTTP fallback over native elicitation
24
+ * when both are available. Default: "false".
23
25
  */
24
26
  export function readElicitationConfig(env = process.env) {
25
27
  const enabledRaw = env.ELICITATION_ENABLED;
26
28
  const enabled = enabledRaw === undefined ? true : enabledRaw.toLowerCase() !== 'false';
29
+ const preferHttpFallbackRaw = env.ELICITATION_PREFER_HTTP_FALLBACK;
30
+ const preferHttpFallback = preferHttpFallbackRaw !== undefined && preferHttpFallbackRaw.toLowerCase() === 'true';
27
31
  const pollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, parsePositiveInt(env.ELICITATION_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS));
28
32
  return {
29
33
  enabled,
@@ -32,5 +36,6 @@ export function readElicitationConfig(env = process.env) {
32
36
  ttlMs: parsePositiveInt(env.ELICITATION_TTL_MS, DEFAULT_TTL_MS),
33
37
  pollIntervalMs,
34
38
  sessionId: env.ELICITATION_SESSION_ID,
39
+ preferHttpFallback,
35
40
  };
36
41
  }
@@ -2,12 +2,17 @@ import type { ElicitationConfig, ElicitationRequestedSchema, ElicitationResult,
2
2
  /**
3
3
  * Requests user confirmation through the best available mechanism.
4
4
  *
5
- * Decision tree:
5
+ * Decision tree (default):
6
6
  * 1. If elicitation is disabled (`ELICITATION_ENABLED=false`), returns `accept` immediately.
7
7
  * 2. If the client supports native elicitation, uses `server.elicitInput()`.
8
8
  * 3. If HTTP fallback URLs are configured, posts to the external endpoint and polls.
9
9
  * 4. Otherwise, throws an error indicating no elicitation mechanism is available.
10
10
  *
11
+ * When `cfg.preferHttpFallback` is true (set via `ELICITATION_PREFER_HTTP_FALLBACK=true`)
12
+ * AND both fallback URLs are configured, tier 3 runs before tier 2. This is intended for
13
+ * headless agent runtimes that falsely advertise elicitation capability but cannot actually
14
+ * surface the prompt to a user.
15
+ *
11
16
  * @param options - Configuration for the confirmation request.
12
17
  * @param config - Elicitation config (defaults to reading from env vars).
13
18
  * @returns The user's response.
@@ -98,15 +98,37 @@ async function pollElicitationStatus(config, requestId, expiresAt) {
98
98
  }
99
99
  return { action: 'expired' };
100
100
  }
101
+ /**
102
+ * Runs the HTTP fallback flow: POST a request, then poll until resolved or expired.
103
+ */
104
+ async function httpFallbackElicit(cfg, options) {
105
+ const clientRequestId = randomUUID();
106
+ const expiresAt = Date.now() + cfg.ttlMs;
107
+ const meta = {
108
+ 'com.pulsemcp/request-id': clientRequestId,
109
+ 'com.pulsemcp/expires-at': new Date(expiresAt).toISOString(),
110
+ ...(cfg.sessionId && { 'com.pulsemcp/session-id': cfg.sessionId }),
111
+ ...options.meta,
112
+ };
113
+ const postResponse = await postElicitationRequest(cfg, options.message, options.requestedSchema, meta);
114
+ // Use the server-provided requestId if available, otherwise fall back to the client-generated one
115
+ const requestId = postResponse.requestId || clientRequestId;
116
+ return pollElicitationStatus(cfg, requestId, expiresAt);
117
+ }
101
118
  /**
102
119
  * Requests user confirmation through the best available mechanism.
103
120
  *
104
- * Decision tree:
121
+ * Decision tree (default):
105
122
  * 1. If elicitation is disabled (`ELICITATION_ENABLED=false`), returns `accept` immediately.
106
123
  * 2. If the client supports native elicitation, uses `server.elicitInput()`.
107
124
  * 3. If HTTP fallback URLs are configured, posts to the external endpoint and polls.
108
125
  * 4. Otherwise, throws an error indicating no elicitation mechanism is available.
109
126
  *
127
+ * When `cfg.preferHttpFallback` is true (set via `ELICITATION_PREFER_HTTP_FALLBACK=true`)
128
+ * AND both fallback URLs are configured, tier 3 runs before tier 2. This is intended for
129
+ * headless agent runtimes that falsely advertise elicitation capability but cannot actually
130
+ * surface the prompt to a user.
131
+ *
110
132
  * @param options - Configuration for the confirmation request.
111
133
  * @param config - Elicitation config (defaults to reading from env vars).
112
134
  * @returns The user's response.
@@ -117,24 +139,18 @@ export async function requestConfirmation(options, config) {
117
139
  if (!cfg.enabled) {
118
140
  return { action: 'accept' };
119
141
  }
142
+ const httpFallbackAvailable = Boolean(cfg.requestUrl && cfg.pollUrl);
143
+ // Opt-in: prefer HTTP fallback over native elicitation when both are available.
144
+ if (cfg.preferHttpFallback && httpFallbackAvailable) {
145
+ return httpFallbackElicit(cfg, options);
146
+ }
120
147
  // Tier 2: Native elicitation
121
148
  if (clientSupportsElicitation(options.server)) {
122
149
  return nativeElicit(options.server, options.message, options.requestedSchema);
123
150
  }
124
151
  // Tier 3: HTTP fallback
125
- if (cfg.requestUrl && cfg.pollUrl) {
126
- const clientRequestId = randomUUID();
127
- const expiresAt = Date.now() + cfg.ttlMs;
128
- const meta = {
129
- 'com.pulsemcp/request-id': clientRequestId,
130
- 'com.pulsemcp/expires-at': new Date(expiresAt).toISOString(),
131
- ...(cfg.sessionId && { 'com.pulsemcp/session-id': cfg.sessionId }),
132
- ...options.meta,
133
- };
134
- const postResponse = await postElicitationRequest(cfg, options.message, options.requestedSchema, meta);
135
- // Use the server-provided requestId if available, otherwise fall back to the client-generated one
136
- const requestId = postResponse.requestId || clientRequestId;
137
- return pollElicitationStatus(cfg, requestId, expiresAt);
152
+ if (httpFallbackAvailable) {
153
+ return httpFallbackElicit(cfg, options);
138
154
  }
139
155
  // Tier 4: No mechanism available
140
156
  throw new Error('Elicitation is enabled but no mechanism is available. ' +
@@ -64,6 +64,16 @@ export interface ElicitationConfig {
64
64
  pollIntervalMs: number;
65
65
  /** Session identifier included as `com.pulsemcp/session-id` in `_meta` of HTTP fallback requests. */
66
66
  sessionId?: string;
67
+ /**
68
+ * When true, prefer HTTP fallback (Tier 3) over native elicitation (Tier 2)
69
+ * when both are available. Default: false.
70
+ *
71
+ * Useful for headless agent runtimes (e.g., Claude Code under Agent Orchestrator)
72
+ * that advertise the `elicitation` client capability but have no real interactive
73
+ * user — native `elicitInput()` calls auto-cancel without ever surfacing a prompt.
74
+ * Forcing the HTTP fallback routes the request to an external approval UI instead.
75
+ */
76
+ preferHttpFallback?: boolean;
67
77
  }
68
78
  /**
69
79
  * The result of an elicitation request.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulsemcp/mcp-elicitation",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Elicitation support library for PulseMCP MCP servers - provides native elicitation with HTTP fallback",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gmail-workspace-mcp-server",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "MCP server for Gmail integration with OAuth2 and service account support",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -119,6 +119,13 @@ export interface IGmailClient {
119
119
  data: string;
120
120
  size: number;
121
121
  }>;
122
+ /**
123
+ * Get the email address of the Gmail account this client reads from.
124
+ * Used to construct account-scoped Gmail web URLs so that links open in
125
+ * the correct mailbox regardless of which accounts the reader happens to
126
+ * be signed into in their browser.
127
+ */
128
+ getAccountEmail(): Promise<string>;
122
129
  }
123
130
  /**
124
131
  * Service account credentials structure
@@ -231,6 +238,7 @@ declare abstract class BaseGmailClient implements IGmailClient {
231
238
  data: string;
232
239
  size: number;
233
240
  }>;
241
+ getAccountEmail(): Promise<string>;
234
242
  }
235
243
  /**
236
244
  * Gmail API client implementation using service account with domain-wide delegation
package/shared/server.js CHANGED
@@ -108,6 +108,9 @@ class BaseGmailClient {
108
108
  const { getAttachment } = await import('./gmail-client/lib/get-attachment.js');
109
109
  return getAttachment(this.baseUrl, headers, messageId, attachmentId);
110
110
  }
111
+ async getAccountEmail() {
112
+ return this.getSenderEmail();
113
+ }
111
114
  }
112
115
  /**
113
116
  * Gmail API client implementation using service account with domain-wide delegation
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { getHeader } from '../utils/email-helpers.js';
2
+ import { buildGmailUrl, getHeader } from '../utils/email-helpers.js';
3
3
  const PARAM_DESCRIPTIONS = {
4
4
  email_id: 'The unique identifier of the email to retrieve. ' +
5
5
  'Obtain this from list_email_conversations or search_email_conversations.',
@@ -25,6 +25,7 @@ Full email details including:
25
25
  - Raw HTML body (when include_html is true and HTML content is available)
26
26
  - List of attachments (if any)
27
27
  - Labels assigned to the email
28
+ - Account-scoped Gmail web URL (resolves to the correct mailbox regardless of the reader's browser session)
28
29
 
29
30
  **Use cases:**
30
31
  - Read the full content of an email after listing conversations
@@ -143,7 +144,11 @@ function formatFullEmail(email, options) {
143
144
  let output = `# Email Details
144
145
 
145
146
  **ID:** ${email.id}
146
- **Thread ID:** ${email.threadId}
147
+ **Thread ID:** ${email.threadId}`;
148
+ if (options?.accountEmail) {
149
+ output += `\n**Gmail URL:** ${buildGmailUrl(options.accountEmail, email.id)}`;
150
+ }
151
+ output += `
147
152
 
148
153
  ## Headers
149
154
  **Subject:** ${subject}
@@ -210,11 +215,15 @@ export function getEmailConversationTool(server, clientFactory) {
210
215
  try {
211
216
  const parsed = GetEmailConversationSchema.parse(args ?? {});
212
217
  const client = clientFactory();
213
- const email = await client.getMessage(parsed.email_id, {
214
- format: 'full',
215
- });
218
+ const [email, accountEmail] = await Promise.all([
219
+ client.getMessage(parsed.email_id, {
220
+ format: 'full',
221
+ }),
222
+ client.getAccountEmail(),
223
+ ]);
216
224
  const formattedEmail = formatFullEmail(email, {
217
225
  includeHtml: parsed.include_html,
226
+ accountEmail,
218
227
  });
219
228
  return {
220
229
  content: [
@@ -37,6 +37,7 @@ A formatted list of email conversations with:
37
37
  - Sender (From)
38
38
  - Date received
39
39
  - Snippet preview
40
+ - Account-scoped Gmail web URL (resolves to the correct mailbox regardless of the reader's browser session)
40
41
 
41
42
  **Use cases:**
42
43
  - Check recent inbox activity
@@ -113,18 +114,24 @@ export function listEmailConversationsTool(server, clientFactory) {
113
114
  ],
114
115
  };
115
116
  }
116
- // Fetch full details for each message
117
- const emailDetails = await Promise.all(messages.map((msg) => client.getMessage(msg.id, {
118
- format: 'metadata',
119
- metadataHeaders: ['Subject', 'From', 'Date'],
120
- })));
117
+ // Fetch full details for each message, plus the account email for
118
+ // building account-scoped Gmail URLs.
119
+ const [emailDetails, accountEmail] = await Promise.all([
120
+ Promise.all(messages.map((msg) => client.getMessage(msg.id, {
121
+ format: 'metadata',
122
+ metadataHeaders: ['Subject', 'From', 'Date'],
123
+ }))),
124
+ client.getAccountEmail(),
125
+ ]);
121
126
  // Sort based on sort_by parameter
122
127
  const sortedEmails = [...emailDetails].sort((a, b) => {
123
128
  const dateA = parseInt(a.internalDate, 10);
124
129
  const dateB = parseInt(b.internalDate, 10);
125
130
  return parsed.sort_by === 'recent' ? dateB - dateA : dateA - dateB;
126
131
  });
127
- const formattedEmails = sortedEmails.map(formatEmail).join('\n\n---\n\n');
132
+ const formattedEmails = sortedEmails
133
+ .map((email) => formatEmail(email, accountEmail))
134
+ .join('\n\n---\n\n');
128
135
  return {
129
136
  content: [
130
137
  {
@@ -38,7 +38,7 @@ const TOOL_DESCRIPTION = `Search email conversations using Gmail's powerful sear
38
38
  - Use - to exclude: "subject:meeting -subject:canceled"
39
39
 
40
40
  **Returns:**
41
- A formatted list of matching emails with ID, Thread ID, Subject, From, Date, and snippet.
41
+ A formatted list of matching emails with ID, Thread ID, Subject, From, Date, snippet, and an account-scoped Gmail web URL (resolves to the correct mailbox regardless of the reader's browser session).
42
42
 
43
43
  **Note:** Use get_email_conversation with an email ID to retrieve full message content.`;
44
44
  export function searchEmailConversationsTool(server, clientFactory) {
@@ -79,12 +79,18 @@ export function searchEmailConversationsTool(server, clientFactory) {
79
79
  ],
80
80
  };
81
81
  }
82
- // Fetch full details for each message
83
- const emailDetails = await Promise.all(messages.map((msg) => client.getMessage(msg.id, {
84
- format: 'metadata',
85
- metadataHeaders: ['Subject', 'From', 'Date'],
86
- })));
87
- const formattedEmails = emailDetails.map(formatEmail).join('\n\n---\n\n');
82
+ // Fetch full details for each message, plus the account email for
83
+ // building account-scoped Gmail URLs.
84
+ const [emailDetails, accountEmail] = await Promise.all([
85
+ Promise.all(messages.map((msg) => client.getMessage(msg.id, {
86
+ format: 'metadata',
87
+ metadataHeaders: ['Subject', 'From', 'Date'],
88
+ }))),
89
+ client.getAccountEmail(),
90
+ ]);
91
+ const formattedEmails = emailDetails
92
+ .map((email) => formatEmail(email, accountEmail))
93
+ .join('\n\n---\n\n');
88
94
  return {
89
95
  content: [
90
96
  {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import { getHeader } from '../utils/email-helpers.js';
2
+ import { buildGmailUrl, getHeader } from '../utils/email-helpers.js';
3
3
  import { requestConfirmation, createConfirmationSchema, readElicitationConfig, } from '@pulsemcp/mcp-elicitation';
4
4
  const PARAM_DESCRIPTIONS = {
5
5
  to: 'Recipient email address(es). For multiple recipients, separate with commas.',
@@ -180,11 +180,15 @@ export function sendEmailTool(server, clientFactory) {
180
180
  // Option 2: Send a draft
181
181
  if (parsed.from_draft_id) {
182
182
  const sentEmail = await client.sendDraft(parsed.from_draft_id);
183
+ const accountEmail = await client.getAccountEmail();
183
184
  return {
184
185
  content: [
185
186
  {
186
187
  type: 'text',
187
- text: `Draft sent successfully!\n\n**Message ID:** ${sentEmail.id}\n**Thread ID:** ${sentEmail.threadId}\n\nThe draft has been sent and removed from Drafts.`,
188
+ text: `Draft sent successfully!\n\n**Message ID:** ${sentEmail.id}\n` +
189
+ `**Thread ID:** ${sentEmail.threadId}\n` +
190
+ `**Gmail URL:** ${buildGmailUrl(accountEmail, sentEmail.id)}\n\n` +
191
+ `The draft has been sent and removed from Drafts.`,
188
192
  },
189
193
  ],
190
194
  };
@@ -220,7 +224,10 @@ export function sendEmailTool(server, clientFactory) {
220
224
  inReplyTo,
221
225
  references,
222
226
  });
223
- let responseText = `Email sent successfully!\n\n**Message ID:** ${sentEmail.id}\n**Thread ID:** ${sentEmail.threadId}`;
227
+ const accountEmail = await client.getAccountEmail();
228
+ let responseText = `Email sent successfully!\n\n**Message ID:** ${sentEmail.id}\n` +
229
+ `**Thread ID:** ${sentEmail.threadId}\n` +
230
+ `**Gmail URL:** ${buildGmailUrl(accountEmail, sentEmail.id)}`;
224
231
  if (parsed.thread_id) {
225
232
  responseText += '\n\nThis email was sent as a reply in an existing conversation.';
226
233
  }
@@ -4,6 +4,22 @@ import type { Email } from '../types.js';
4
4
  */
5
5
  export declare function getHeader(email: Email, headerName: string): string | undefined;
6
6
  /**
7
- * Formats an email for display in tool output
7
+ * Builds an account-scoped Gmail web URL for a given message.
8
+ *
9
+ * Uses the `/mail/u/<account-email>/#inbox/<messageId>` path form so the link
10
+ * opens in the correct mailbox regardless of which accounts the reader is
11
+ * signed into in their browser. The user-INDEX form `/mail/u/0/` would
12
+ * otherwise open whichever account happens to be at index 0 in the reader's
13
+ * browser session, which is rarely the impersonated/OAuth account this
14
+ * server reads from.
15
+ *
16
+ * Gmail also accepts `?authuser=<email>` as a query-parameter fallback.
8
17
  */
9
- export declare function formatEmail(email: Email): string;
18
+ export declare function buildGmailUrl(accountEmail: string, messageId: string): string;
19
+ /**
20
+ * Formats an email for display in tool output.
21
+ *
22
+ * When `accountEmail` is provided, an account-scoped Gmail web URL is
23
+ * appended so the user can click through to the correct mailbox.
24
+ */
25
+ export declare function formatEmail(email: Email, accountEmail?: string): string;
@@ -6,17 +6,39 @@ export function getHeader(email, headerName) {
6
6
  ?.value;
7
7
  }
8
8
  /**
9
- * Formats an email for display in tool output
9
+ * Builds an account-scoped Gmail web URL for a given message.
10
+ *
11
+ * Uses the `/mail/u/<account-email>/#inbox/<messageId>` path form so the link
12
+ * opens in the correct mailbox regardless of which accounts the reader is
13
+ * signed into in their browser. The user-INDEX form `/mail/u/0/` would
14
+ * otherwise open whichever account happens to be at index 0 in the reader's
15
+ * browser session, which is rarely the impersonated/OAuth account this
16
+ * server reads from.
17
+ *
18
+ * Gmail also accepts `?authuser=<email>` as a query-parameter fallback.
10
19
  */
11
- export function formatEmail(email) {
20
+ export function buildGmailUrl(accountEmail, messageId) {
21
+ return `https://mail.google.com/mail/u/${encodeURIComponent(accountEmail)}/#inbox/${messageId}`;
22
+ }
23
+ /**
24
+ * Formats an email for display in tool output.
25
+ *
26
+ * When `accountEmail` is provided, an account-scoped Gmail web URL is
27
+ * appended so the user can click through to the correct mailbox.
28
+ */
29
+ export function formatEmail(email, accountEmail) {
12
30
  const subject = getHeader(email, 'Subject') || '(No Subject)';
13
31
  const from = getHeader(email, 'From') || 'Unknown';
14
32
  const date = getHeader(email, 'Date') || 'Unknown date';
15
33
  const snippet = email.snippet || '';
16
- return `**ID:** ${email.id}
34
+ let output = `**ID:** ${email.id}
17
35
  **Thread ID:** ${email.threadId}
18
36
  **Subject:** ${subject}
19
37
  **From:** ${from}
20
38
  **Date:** ${date}
21
39
  **Preview:** ${snippet}`;
40
+ if (accountEmail) {
41
+ output += `\n**Gmail URL:** ${buildGmailUrl(accountEmail, email.id)}`;
42
+ }
43
+ return output;
22
44
  }