This is page 2 of 3. Use http://codebase.md/aaronsb/google-workspace-mcp?page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .eslintrc.json
├── .github
│ ├── config.yml
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── pull_request_template.md
│ └── workflows
│ ├── ci.yml
│ └── docker-publish.yml
├── .gitignore
├── ARCHITECTURE.md
├── cline_docs
│ ├── activeContext.md
│ ├── productContext.md
│ ├── progress.md
│ ├── systemPatterns.md
│ └── techContext.md
├── CODE_OF_CONDUCT.md
├── config
│ ├── accounts.example.json
│ ├── credentials
│ │ └── README.md
│ └── gauth.example.json
├── CONTRIBUTING.md
├── docker-entrypoint.sh
├── Dockerfile
├── Dockerfile.local
├── docs
│ ├── API.md
│ ├── assets
│ │ └── robot-assistant.png
│ ├── automatic-oauth-flow.md
│ ├── ERRORS.md
│ ├── EXAMPLES.md
│ └── TOOL_DISCOVERY.md
├── jest.config.cjs
├── jest.setup.cjs
├── LICENSE
├── llms-install.md
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── build-local.sh
│ └── local-entrypoint.sh
├── SECURITY.md
├── smithery.yaml
├── src
│ ├── __fixtures__
│ │ └── accounts.ts
│ ├── __helpers__
│ │ ├── package.json
│ │ └── testSetup.ts
│ ├── __mocks__
│ │ ├── @modelcontextprotocol
│ │ │ ├── sdk
│ │ │ │ ├── server
│ │ │ │ │ ├── index.js
│ │ │ │ │ └── stdio.js
│ │ │ │ └── types.ts
│ │ │ └── sdk.ts
│ │ ├── googleapis.ts
│ │ └── logger.ts
│ ├── __tests__
│ │ └── modules
│ │ ├── accounts
│ │ │ ├── manager.test.ts
│ │ │ └── token.test.ts
│ │ ├── attachments
│ │ │ └── index.test.ts
│ │ ├── calendar
│ │ │ └── service.test.ts
│ │ └── gmail
│ │ └── service.test.ts
│ ├── api
│ │ ├── handler.ts
│ │ ├── request.ts
│ │ └── validators
│ │ ├── endpoint.ts
│ │ └── parameter.ts
│ ├── index.ts
│ ├── modules
│ │ ├── accounts
│ │ │ ├── callback-server.ts
│ │ │ ├── index.ts
│ │ │ ├── manager.ts
│ │ │ ├── oauth.ts
│ │ │ ├── token.ts
│ │ │ └── types.ts
│ │ ├── attachments
│ │ │ ├── cleanup-service.ts
│ │ │ ├── index-service.ts
│ │ │ ├── response-transformer.ts
│ │ │ ├── service.ts
│ │ │ ├── transformer.ts
│ │ │ └── types.ts
│ │ ├── calendar
│ │ │ ├── __tests__
│ │ │ │ └── scopes.test.ts
│ │ │ ├── index.ts
│ │ │ ├── scopes.ts
│ │ │ ├── service.ts
│ │ │ └── types.ts
│ │ ├── contacts
│ │ │ ├── index.ts
│ │ │ ├── scopes.ts
│ │ │ └── types.ts
│ │ ├── drive
│ │ │ ├── __tests__
│ │ │ │ ├── scopes.test.ts
│ │ │ │ └── service.test.ts
│ │ │ ├── index.ts
│ │ │ ├── scopes.ts
│ │ │ ├── service.ts
│ │ │ └── types.ts
│ │ ├── gmail
│ │ │ ├── __tests__
│ │ │ │ ├── label.test.ts
│ │ │ │ └── scopes.test.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── scopes.ts
│ │ │ ├── service.ts
│ │ │ ├── services
│ │ │ │ ├── attachment.ts
│ │ │ │ ├── base.ts
│ │ │ │ ├── draft.ts
│ │ │ │ ├── email.ts
│ │ │ │ ├── label.ts
│ │ │ │ ├── search.ts
│ │ │ │ └── settings.ts
│ │ │ └── types.ts
│ │ └── tools
│ │ ├── __tests__
│ │ │ ├── registry.test.ts
│ │ │ └── scope-registry.test.ts
│ │ ├── registry.ts
│ │ └── scope-registry.ts
│ ├── oauth
│ │ └── client.ts
│ ├── scripts
│ │ ├── health-check.ts
│ │ ├── setup-environment.ts
│ │ └── setup-google-env.ts
│ ├── services
│ │ ├── base
│ │ │ └── BaseGoogleService.ts
│ │ ├── calendar
│ │ │ └── index.ts
│ │ ├── contacts
│ │ │ └── index.ts
│ │ ├── drive
│ │ │ └── index.ts
│ │ └── gmail
│ │ └── index.ts
│ ├── tools
│ │ ├── account-handlers.ts
│ │ ├── calendar-handlers.ts
│ │ ├── contacts-handlers.ts
│ │ ├── definitions.ts
│ │ ├── drive-handlers.ts
│ │ ├── gmail-handlers.ts
│ │ ├── server.ts
│ │ ├── type-guards.ts
│ │ └── types.ts
│ ├── types
│ │ └── modelcontextprotocol__sdk.d.ts
│ ├── types.ts
│ └── utils
│ ├── account.ts
│ ├── logger.ts
│ ├── service-initializer.ts
│ ├── token.ts
│ └── workspace.ts
├── TODO.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/__tests__/modules/attachments/index.test.ts:
--------------------------------------------------------------------------------
```typescript
import { AttachmentIndexService } from '../../../modules/attachments/index-service.js';
import { AttachmentCleanupService } from '../../../modules/attachments/cleanup-service.js';
import { AttachmentResponseTransformer } from '../../../modules/attachments/response-transformer.js';
describe('Attachment System', () => {
let indexService: AttachmentIndexService;
let cleanupService: AttachmentCleanupService;
let responseTransformer: AttachmentResponseTransformer;
beforeEach(() => {
// Reset singleton instance for test isolation
// @ts-expect-error - Accessing private static for testing
AttachmentIndexService.instance = undefined;
indexService = AttachmentIndexService.getInstance();
cleanupService = new AttachmentCleanupService(indexService);
responseTransformer = new AttachmentResponseTransformer(indexService);
});
afterEach(() => {
cleanupService.stop();
jest.clearAllTimers();
});
describe('AttachmentIndexService', () => {
it('should store and retrieve attachment metadata', () => {
const messageId = 'msg123';
const attachment = {
id: 'att123',
name: 'test.pdf',
mimeType: 'application/pdf',
size: 1024
};
indexService.addAttachment(messageId, attachment);
const metadata = indexService.getMetadata(messageId, 'test.pdf');
expect(metadata).toBeDefined();
expect(metadata?.originalId).toBe('att123');
expect(metadata?.filename).toBe('test.pdf');
expect(metadata?.mimeType).toBe('application/pdf');
expect(metadata?.size).toBe(1024);
});
it('should handle size limits', () => {
// Add max entries + 1
for (let i = 0; i < 257; i++) {
indexService.addAttachment(`msg${i}`, {
id: `att${i}`,
name: 'test.pdf',
mimeType: 'application/pdf',
size: 1024
});
}
// Size should be maintained at or below max
expect(indexService.size).toBeLessThanOrEqual(256);
});
it('should handle expiry', () => {
const messageId = 'msg123';
const attachment = {
id: 'att123',
name: 'test.pdf',
mimeType: 'application/pdf',
size: 1024
};
// Add attachment
indexService.addAttachment(messageId, attachment);
// Mock time passing
jest.useFakeTimers();
jest.advanceTimersByTime(3600000 + 1000); // 1 hour + 1 second
// Attempt to retrieve expired attachment
const metadata = indexService.getMetadata(messageId, 'test.pdf');
expect(metadata).toBeUndefined();
jest.useRealTimers();
});
});
describe('AttachmentCleanupService', () => {
it('should start and stop cleanup service', () => {
cleanupService.start();
expect(cleanupService.getCurrentInterval()).toBe(300000); // Base interval
cleanupService.stop();
expect(cleanupService.getCurrentInterval()).toBe(300000);
});
});
describe('AttachmentResponseTransformer', () => {
it('should transform attachments to simplified format', () => {
const messageId = 'msg123';
const fullResponse = {
id: messageId,
attachments: [{
id: 'att123',
name: 'test.pdf',
mimeType: 'application/pdf',
size: 1024
}]
};
// Store attachment metadata
indexService.addAttachment(messageId, fullResponse.attachments[0]);
// Transform response
const simplified = responseTransformer.transformResponse(fullResponse);
expect(simplified).toEqual({
id: messageId,
attachments: [{
name: 'test.pdf'
}]
});
});
it('should handle responses without attachments', () => {
const response = {
id: 'msg123',
subject: 'Test'
};
const simplified = responseTransformer.transformResponse(response);
expect(simplified).toEqual(response);
});
});
describe('Integration', () => {
it('should maintain attachment metadata through transform cycle', () => {
const messageId = 'msg123';
const originalAttachment = {
id: 'att123',
name: 'test.pdf',
mimeType: 'application/pdf',
size: 1024
};
// Add attachment and transform response
indexService.addAttachment(messageId, originalAttachment);
const transformed = responseTransformer.transformResponse({
id: messageId,
attachments: [originalAttachment]
});
// Verify simplified format
expect(transformed.attachments?.[0]).toEqual({
name: 'test.pdf'
});
// Verify original metadata is preserved
const metadata = indexService.getMetadata(messageId, 'test.pdf');
expect(metadata).toEqual({
messageId,
filename: 'test.pdf',
originalId: 'att123',
mimeType: 'application/pdf',
size: 1024,
timestamp: expect.any(Number)
});
});
it('should notify activity without error', () => {
cleanupService.start();
cleanupService.notifyActivity(); // Should not throw
cleanupService.stop();
});
});
});
```
--------------------------------------------------------------------------------
/src/api/validators/parameter.ts:
--------------------------------------------------------------------------------
```typescript
import { GoogleApiRequestParams, GoogleApiError } from '../../types.js';
interface ParameterConfig {
required: string[];
types: Record<string, string>;
}
export class ParameterValidator {
// Registry of endpoint-specific parameter configurations
private readonly parameterRegistry: Record<string, ParameterConfig> = {
'gmail.users.messages.attachments.get': {
required: ['userId', 'messageId', 'filename'],
types: {
userId: 'string',
messageId: 'string',
filename: 'string'
}
},
'gmail.users.messages.attachments.upload': {
required: ['userId', 'messageId', 'filename', 'content'],
types: {
userId: 'string',
messageId: 'string',
filename: 'string',
content: 'string'
}
},
'gmail.users.messages.attachments.delete': {
required: ['userId', 'messageId', 'filename'],
types: {
userId: 'string',
messageId: 'string',
filename: 'string'
}
},
'calendar.events.attachments.get': {
required: ['calendarId', 'eventId', 'filename'],
types: {
calendarId: 'string',
eventId: 'string',
filename: 'string'
}
},
'calendar.events.attachments.upload': {
required: ['calendarId', 'eventId', 'filename', 'content'],
types: {
calendarId: 'string',
eventId: 'string',
filename: 'string',
content: 'string'
}
},
'calendar.events.attachments.delete': {
required: ['calendarId', 'eventId', 'filename'],
types: {
calendarId: 'string',
eventId: 'string',
filename: 'string'
}
},
'gmail.users.messages.list': {
required: ['userId'],
types: {
userId: 'string',
maxResults: 'number',
pageToken: 'string',
q: 'string',
labelIds: 'array'
}
},
'gmail.users.messages.get': {
required: ['userId', 'id'],
types: {
userId: 'string',
id: 'string',
format: 'string'
}
},
'gmail.users.messages.send': {
required: ['userId', 'message'],
types: {
userId: 'string',
message: 'object'
}
},
'drive.files.list': {
required: [],
types: {
pageSize: 'number',
pageToken: 'string',
q: 'string',
spaces: 'string',
fields: 'string'
}
},
'drive.files.get': {
required: ['fileId'],
types: {
fileId: 'string',
fields: 'string',
acknowledgeAbuse: 'boolean'
}
}
};
async validate(params: GoogleApiRequestParams): Promise<void> {
const { api_endpoint, params: methodParams = {} } = params;
// Get parameter configuration for this endpoint
const config = this.parameterRegistry[api_endpoint];
if (!config) {
// If no specific config exists, only validate the base required params
this.validateBaseParams(params);
return;
}
// Validate required parameters
this.validateRequiredParams(api_endpoint, methodParams, config.required);
// Validate parameter types
this.validateParamTypes(api_endpoint, methodParams, config.types);
}
private validateBaseParams(params: GoogleApiRequestParams): void {
const requiredBaseParams = ['email', 'api_endpoint', 'method', 'required_scopes'];
const missingParams = requiredBaseParams.filter(param => !(param in params));
if (missingParams.length > 0) {
throw new GoogleApiError(
'Missing required parameters',
'MISSING_REQUIRED_PARAMS',
`The following parameters are required: ${missingParams.join(', ')}`
);
}
}
private validateRequiredParams(
endpoint: string,
params: Record<string, any>,
required: string[]
): void {
const missingParams = required.filter(param => !(param in params));
if (missingParams.length > 0) {
throw new GoogleApiError(
'Missing required parameters',
'MISSING_REQUIRED_PARAMS',
`The following parameters are required for ${endpoint}: ${missingParams.join(', ')}`
);
}
}
private validateParamTypes(
endpoint: string,
params: Record<string, any>,
types: Record<string, string>
): void {
for (const [param, value] of Object.entries(params)) {
const expectedType = types[param];
if (!expectedType) {
// Skip validation for parameters not in the type registry
continue;
}
const actualType = this.getType(value);
if (actualType !== expectedType) {
throw new GoogleApiError(
'Invalid parameter type',
'INVALID_PARAM_TYPE',
`Parameter "${param}" for ${endpoint} must be of type ${expectedType}, got ${actualType}`
);
}
}
}
private getType(value: any): string {
if (Array.isArray(value)) return 'array';
if (value === null) return 'null';
if (typeof value === 'object') return 'object';
return typeof value;
}
getRequiredParams(endpoint: string): string[] {
return this.parameterRegistry[endpoint]?.required || [];
}
getParamTypes(endpoint: string): Record<string, string> {
return this.parameterRegistry[endpoint]?.types || {};
}
}
```
--------------------------------------------------------------------------------
/src/oauth/client.ts:
--------------------------------------------------------------------------------
```typescript
import fs from 'fs/promises';
import path from 'path';
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import { OAuthConfig, TokenData, GoogleApiError } from '../types.js';
export class GoogleOAuthClient {
private client?: OAuth2Client;
private config?: OAuthConfig;
private initializationPromise?: Promise<void>;
async ensureInitialized(): Promise<void> {
if (!this.initializationPromise) {
this.initializationPromise = this.loadConfig().catch(error => {
// Clear the promise so we can retry initialization
this.initializationPromise = undefined;
throw error;
});
}
await this.initializationPromise;
}
private async loadConfig(): Promise<void> {
// First try environment variables
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
if (clientId && clientSecret) {
this.config = {
client_id: clientId,
client_secret: clientSecret,
auth_uri: 'https://accounts.google.com/o/oauth2/v2/auth',
token_uri: 'https://oauth2.googleapis.com/token'
};
} else {
// Fall back to config file if environment variables are not set
try {
const configPath = process.env.GAUTH_FILE || path.resolve('config', 'gauth.json');
const data = await fs.readFile(configPath, 'utf-8');
this.config = JSON.parse(data) as OAuthConfig;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
throw new GoogleApiError(
'OAuth credentials not found',
'CONFIG_NOT_FOUND',
'Please provide GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables or ensure config/gauth.json exists'
);
}
throw new GoogleApiError(
'Failed to load OAuth configuration',
'OAUTH_CONFIG_ERROR',
'Please check your environment variables or ensure gauth.json is valid'
);
}
}
if (!this.config) {
throw new GoogleApiError(
'OAuth configuration not available',
'CONFIG_NOT_FOUND',
'Please provide OAuth credentials through environment variables or config file'
);
}
this.client = new google.auth.OAuth2(
this.config.client_id,
this.config.client_secret,
'urn:ietf:wg:oauth:2.0:oob' // Use device code flow
);
}
async generateAuthUrl(scopes: string[]): Promise<string> {
await this.ensureInitialized();
if (!this.config || !this.client) {
throw new GoogleApiError(
'OAuth client not initialized',
'CLIENT_NOT_INITIALIZED',
'Please ensure the OAuth configuration is loaded'
);
}
return this.client.generateAuthUrl({
access_type: 'offline',
scope: scopes,
prompt: 'consent' // Force consent screen to ensure we get refresh token
});
}
async getTokenFromCode(code: string): Promise<TokenData> {
await this.ensureInitialized();
if (!this.client) {
throw new GoogleApiError(
'OAuth client not initialized',
'CLIENT_NOT_INITIALIZED',
'Please ensure the OAuth configuration is loaded'
);
}
try {
const { tokens } = await this.client.getToken(code);
if (!tokens.refresh_token) {
throw new GoogleApiError(
'No refresh token received',
'NO_REFRESH_TOKEN',
'Please ensure you have included offline access in your scopes'
);
}
return {
access_token: tokens.access_token!,
refresh_token: tokens.refresh_token,
scope: tokens.scope!,
token_type: tokens.token_type!,
expiry_date: tokens.expiry_date!,
last_refresh: Date.now()
};
} catch (error) {
if (error instanceof GoogleApiError) {
throw error;
}
throw new GoogleApiError(
'Failed to get token from code',
'TOKEN_EXCHANGE_ERROR',
'The authorization code may be invalid or expired'
);
}
}
async refreshToken(refreshToken: string): Promise<TokenData> {
await this.ensureInitialized();
if (!this.client) {
throw new GoogleApiError(
'OAuth client not initialized',
'CLIENT_NOT_INITIALIZED',
'Please ensure the OAuth configuration is loaded'
);
}
try {
this.client.setCredentials({
refresh_token: refreshToken
});
const { credentials } = await this.client.refreshAccessToken();
return {
access_token: credentials.access_token!,
refresh_token: refreshToken, // Keep existing refresh token
scope: credentials.scope!,
token_type: credentials.token_type!,
expiry_date: credentials.expiry_date!,
last_refresh: Date.now()
};
} catch (error) {
throw new GoogleApiError(
'Failed to refresh token',
'TOKEN_REFRESH_ERROR',
'The refresh token may be invalid or revoked'
);
}
}
async validateToken(token: TokenData): Promise<boolean> {
await this.ensureInitialized();
if (!this.client) {
throw new GoogleApiError(
'OAuth client not initialized',
'CLIENT_NOT_INITIALIZED',
'Please ensure the OAuth configuration is loaded'
);
}
try {
this.client.setCredentials({
access_token: token.access_token,
refresh_token: token.refresh_token
});
await this.client.getTokenInfo(token.access_token);
return true;
} catch (error) {
return false;
}
}
async getAuthClient(): Promise<OAuth2Client> {
await this.ensureInitialized();
if (!this.client) {
throw new GoogleApiError(
'OAuth client not initialized',
'CLIENT_NOT_INITIALIZED',
'Please ensure the OAuth configuration is loaded'
);
}
return this.client;
}
}
```
--------------------------------------------------------------------------------
/src/tools/account-handlers.ts:
--------------------------------------------------------------------------------
```typescript
import { getAccountManager } from '../modules/accounts/index.js';
import { McpToolResponse, BaseToolArguments } from './types.js';
/**
* Lists all configured Google Workspace accounts and their authentication status
* @returns List of accounts with their configuration and auth status
* @throws {McpError} If account manager fails to retrieve accounts
*/
export async function handleListWorkspaceAccounts(): Promise<McpToolResponse> {
const accounts = await getAccountManager().listAccounts();
// Filter out sensitive token data before returning to AI
const sanitizedAccounts = accounts.map(account => ({
...account,
auth_status: account.auth_status ? {
valid: account.auth_status.valid,
status: account.auth_status.status,
reason: account.auth_status.reason,
authUrl: account.auth_status.authUrl
} : undefined
}));
return {
content: [{
type: 'text',
text: JSON.stringify(sanitizedAccounts, null, 2)
}]
};
}
export interface AuthenticateAccountArgs extends BaseToolArguments {
category?: string;
description?: string;
auth_code?: string;
auto_complete?: boolean;
}
/**
* Authenticates a Google Workspace account through OAuth2
* @param args.email - Email address to authenticate
* @param args.category - Optional account category (e.g., 'work', 'personal')
* @param args.description - Optional account description
* @param args.auth_code - OAuth2 authorization code (optional for manual flow)
* @param args.auto_complete - Whether to automatically complete auth (default: true)
* @returns Auth URL and instructions for completing authentication
* @throws {McpError} If validation fails or OAuth flow errors
*/
export async function handleAuthenticateWorkspaceAccount(args: AuthenticateAccountArgs): Promise<McpToolResponse> {
const accountManager = getAccountManager();
// Validate/create account
await accountManager.validateAccount(args.email, args.category, args.description);
// If auth code is provided (manual fallback), complete the OAuth flow
if (args.auth_code) {
const tokenData = await accountManager.getTokenFromCode(args.auth_code);
await accountManager.saveToken(args.email, tokenData);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
message: 'Authentication successful! Token saved. Please retry your request.'
}, null, 2)
}]
};
}
// Generate OAuth URL and track which account is being authenticated
const authUrl = await accountManager.startAuthentication(args.email);
// Check if we should use automatic completion (default: true)
const useAutoComplete = args.auto_complete !== false;
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'auth_required',
auth_url: authUrl,
message: 'Please complete Google OAuth authentication:',
instructions: useAutoComplete ? [
'1. Click the authorization URL to open Google sign-in in your browser',
'2. Sign in with your Google account and allow the requested permissions',
'3. Authentication will complete automatically - you can start using the account immediately'
].join('\n') : [
'1. Click the authorization URL below to open Google sign-in in your browser',
'2. Sign in with your Google account and allow the requested permissions',
'3. After authorization, you will see a success page with your authorization code',
'4. Copy the authorization code from the success page',
'5. Call this tool again with the auth_code parameter: authenticate_workspace_account with auth_code="your_code_here"'
].join('\n'),
note: useAutoComplete
? 'The callback server will automatically complete authentication in the background.'
: 'The callback server is running on localhost:8080 and will display your authorization code for easy copying.',
auto_complete_enabled: useAutoComplete
}, null, 2)
}]
};
}
/**
* Completes OAuth authentication automatically by waiting for callback
* @param args.email - Email address to authenticate
* @returns Success message when authentication completes
* @throws {McpError} If authentication times out or fails
*/
export async function handleCompleteWorkspaceAuth(args: BaseToolArguments): Promise<McpToolResponse> {
const accountManager = getAccountManager();
try {
// Wait for the authorization code from the callback server
const code = await accountManager.waitForAuthorizationCode();
// Exchange code for tokens
const tokenData = await accountManager.getTokenFromCode(code);
await accountManager.saveToken(args.email, tokenData);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
message: 'Authentication completed automatically! Your account is now ready to use.'
}, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'error',
message: 'Authentication timeout or error. Please use the manual authentication flow.',
error: error instanceof Error ? error.message : 'Unknown error'
}, null, 2)
}]
};
}
}
/**
* Removes a Google Workspace account and its associated authentication tokens
* @param args.email - Email address of the account to remove
* @returns Success message if account removed
* @throws {McpError} If account removal fails
*/
export async function handleRemoveWorkspaceAccount(args: BaseToolArguments): Promise<McpToolResponse> {
await getAccountManager().removeAccount(args.email);
return {
content: [{
type: 'text',
text: JSON.stringify({
status: 'success',
message: `Successfully removed account ${args.email} and deleted associated tokens`
}, null, 2)
}]
};
}
```
--------------------------------------------------------------------------------
/src/modules/gmail/types.ts:
--------------------------------------------------------------------------------
```typescript
export interface BaseGmailAttachment {
id: string; // Gmail attachment ID
name: string; // Filename
mimeType: string; // MIME type
size: number; // Size in bytes
}
export interface IncomingGmailAttachment extends BaseGmailAttachment {
content?: string; // Base64 content when retrieved
path?: string; // Local filesystem path when downloaded
}
export interface OutgoingGmailAttachment extends BaseGmailAttachment {
content: string; // Base64 content required when sending
}
export type GmailAttachment = IncomingGmailAttachment | OutgoingGmailAttachment;
export interface Label {
id: string;
name: string;
messageListVisibility?: 'show' | 'hide';
labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';
type?: 'system' | 'user';
color?: {
textColor: string;
backgroundColor: string;
};
}
export interface DraftResponse {
id: string;
message: {
id: string;
threadId: string;
labelIds: string[];
};
updated: string;
}
export interface GetDraftsResponse {
drafts: DraftResponse[];
nextPageToken?: string;
resultSizeEstimate?: number;
}
export interface DraftEmailParams {
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
attachments?: {
driveFileId?: string;
content?: string;
name: string;
mimeType: string;
size?: number;
}[];
}
export interface GetDraftsParams {
email: string;
maxResults?: number;
pageToken?: string;
}
export interface SendDraftParams {
email: string;
draftId: string;
}
export interface GetLabelsResponse {
labels: Label[];
nextPageToken?: string;
}
export interface LabelFilter {
id: string;
labelId: string;
criteria: LabelFilterCriteria;
actions: LabelFilterActions;
}
export interface GetLabelFiltersResponse {
filters: LabelFilter[];
}
export interface CreateLabelParams {
email: string;
name: string;
messageListVisibility?: 'show' | 'hide';
labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';
color?: {
textColor: string;
backgroundColor: string;
};
}
export interface UpdateLabelParams {
email: string;
labelId: string;
name?: string;
messageListVisibility?: 'show' | 'hide';
labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';
color?: {
textColor: string;
backgroundColor: string;
};
}
export interface DeleteLabelParams {
email: string;
labelId: string;
}
export interface GetLabelsParams {
email: string;
}
export interface ModifyMessageLabelsParams {
email: string;
messageId: string;
addLabelIds: string[];
removeLabelIds: string[];
}
export interface LabelFilterCriteria {
from?: string[];
to?: string[];
subject?: string;
hasWords?: string[];
doesNotHaveWords?: string[];
hasAttachment?: boolean;
size?: {
operator: 'larger' | 'smaller';
size: number;
};
}
export interface LabelFilterActions {
addLabel?: boolean;
markImportant?: boolean;
markRead?: boolean;
archive?: boolean;
}
export interface CreateLabelFilterParams {
email: string;
labelId: string;
criteria: LabelFilterCriteria;
actions: LabelFilterActions;
}
export interface GetLabelFiltersParams {
email: string;
labelId?: string;
}
export interface UpdateLabelFilterParams {
email: string;
filterId: string;
labelId: string;
criteria: LabelFilterCriteria;
actions: LabelFilterActions;
}
export interface DeleteLabelFilterParams {
email: string;
filterId: string;
}
export interface GetGmailSettingsParams {
email: string;
}
export interface GetGmailSettingsResponse {
profile: {
emailAddress: string;
messagesTotal: number;
threadsTotal: number;
historyId: string;
};
settings: {
language?: {
displayLanguage: string;
};
autoForwarding?: {
enabled: boolean;
emailAddress?: string;
};
imap?: {
enabled: boolean;
autoExpunge?: boolean;
expungeBehavior?: string;
};
pop?: {
enabled: boolean;
accessWindow?: string;
};
vacationResponder?: {
enabled: boolean;
startTime?: string;
endTime?: string;
responseSubject?: string;
message?: string;
};
};
}
export interface GmailModuleConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string[];
}
export interface SearchCriteria {
from?: string | string[];
to?: string | string[];
subject?: string;
content?: string;
after?: string;
before?: string;
hasAttachment?: boolean;
labels?: string[];
excludeLabels?: string[];
includeSpam?: boolean;
isUnread?: boolean;
}
export interface EmailResponse {
id: string;
threadId: string;
labelIds?: string[];
snippet?: string;
subject: string;
from: string;
to: string;
date: string;
body: string;
headers?: { [key: string]: string };
isUnread: boolean;
hasAttachment: boolean;
attachments?: IncomingGmailAttachment[];
}
export interface ThreadInfo {
messages: string[];
participants: string[];
subject: string;
lastUpdated: string;
}
export interface GetEmailsParams {
email: string;
search?: SearchCriteria;
options?: {
maxResults?: number;
pageToken?: string;
format?: 'full' | 'metadata' | 'minimal';
includeHeaders?: boolean;
threadedView?: boolean;
sortOrder?: 'asc' | 'desc';
};
messageIds?: string[];
}
export interface GetEmailsResponse {
emails: EmailResponse[];
nextPageToken?: string;
resultSummary: {
total: number;
returned: number;
hasMore: boolean;
searchCriteria: SearchCriteria;
};
threads?: { [threadId: string]: ThreadInfo };
}
export interface SendEmailParams {
email: string;
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
attachments?: OutgoingGmailAttachment[];
}
export interface SendEmailResponse {
messageId: string;
threadId: string;
labelIds?: string[];
attachments?: GmailAttachment[];
}
export class GmailError extends Error implements GmailError {
code: string;
details?: string;
constructor(message: string, code: string, details?: string) {
super(message);
this.name = 'GmailError';
this.code = code;
this.details = details;
}
}
```
--------------------------------------------------------------------------------
/src/__helpers__/testSetup.ts:
--------------------------------------------------------------------------------
```typescript
import { gmail_v1 } from 'googleapis';
import type { AccountManager } from '../modules/accounts/manager.js' with { "resolution-mode": "import" };
import type { OAuth2Client } from 'google-auth-library' with { "resolution-mode": "import" };
// Basic mock token
const mockToken = {
access_token: 'mock-access-token',
refresh_token: 'mock-refresh-token',
expiry_date: Date.now() + 3600000,
};
// Simple account manager mock
export const mockAccountManager = (): jest.Mocked<Partial<AccountManager>> => {
const mockAuthClient: jest.Mocked<Partial<OAuth2Client>> = {
setCredentials: jest.fn(),
};
return {
initialize: jest.fn().mockResolvedValue(undefined),
getAuthClient: jest.fn().mockResolvedValue(mockAuthClient),
validateToken: jest.fn().mockResolvedValue({ valid: true, token: mockToken }),
withTokenRenewal: jest.fn().mockImplementation((email, operation) => operation()),
};
};
// Type for Gmail client that makes context optional but keeps users required
type MockGmailClient = Omit<gmail_v1.Gmail, 'context'> & { context?: gmail_v1.Gmail['context'] };
// Simple Gmail client mock with proper typing and success/failure cases
export const mockGmailClient: jest.Mocked<MockGmailClient> = {
users: {
messages: {
list: jest.fn()
.mockResolvedValueOnce({ // Success case with results
data: {
messages: [
{ id: 'msg-1', threadId: 'thread-1' },
{ id: 'msg-2', threadId: 'thread-1' }
],
resultSizeEstimate: 2
}
})
.mockResolvedValueOnce({ // Empty results case
data: {
messages: [],
resultSizeEstimate: 0
}
}),
get: jest.fn().mockResolvedValue({
data: {
id: 'msg-1',
threadId: 'thread-1',
labelIds: ['INBOX'],
snippet: 'Email preview...',
payload: {
headers: [
{ name: 'Subject', value: 'Test Subject' },
{ name: 'From', value: '[email protected]' },
{ name: 'To', value: '[email protected]' },
{ name: 'Date', value: new Date().toISOString() }
],
body: {
data: Buffer.from('Email body content').toString('base64')
}
}
}
}),
send: jest.fn()
.mockResolvedValueOnce({ // Success case
data: {
id: 'sent-msg-1',
threadId: 'thread-1',
labelIds: ['SENT']
}
})
.mockRejectedValueOnce(new Error('Send failed')), // Error case
},
drafts: {
create: jest.fn()
.mockResolvedValueOnce({ // Success case
data: {
id: 'draft-1',
message: {
id: 'msg-draft-1',
threadId: 'thread-1',
labelIds: ['DRAFT']
},
updated: new Date().toISOString()
}
})
.mockResolvedValueOnce({ // Reply draft success case
data: {
id: 'draft-2',
message: {
id: 'msg-draft-2',
threadId: 'thread-1',
labelIds: ['DRAFT']
},
updated: new Date().toISOString()
}
})
.mockRejectedValueOnce(new Error('Draft creation failed')), // Error case
list: jest.fn()
.mockResolvedValueOnce({ // Success case with drafts
data: {
drafts: [
{ id: 'draft-1' },
{ id: 'draft-2' }
],
nextPageToken: 'next-token',
resultSizeEstimate: 2
}
})
.mockResolvedValueOnce({ // Empty drafts case
data: {
drafts: [],
resultSizeEstimate: 0
}
}),
get: jest.fn()
.mockResolvedValue({ // Success case for draft details
data: {
id: 'draft-1',
message: {
id: 'msg-draft-1',
threadId: 'thread-1',
labelIds: ['DRAFT']
}
}
}),
send: jest.fn()
.mockResolvedValueOnce({ // Success case
data: {
id: 'sent-msg-1',
threadId: 'thread-1',
labelIds: ['SENT']
}
})
.mockRejectedValueOnce(new Error('Draft send failed')), // Error case
},
getProfile: jest.fn()
.mockResolvedValueOnce({ // Success case
data: {
emailAddress: '[email protected]',
messagesTotal: 100,
threadsTotal: 50,
historyId: '12345'
}
})
.mockRejectedValueOnce(new Error('Settings fetch failed')), // Error case
settings: {
getAutoForwarding: jest.fn().mockResolvedValue({
data: {
enabled: false,
emailAddress: undefined
}
}),
getImap: jest.fn().mockResolvedValue({
data: {
enabled: true,
autoExpunge: true,
expungeBehavior: 'archive'
}
}),
getLanguage: jest.fn().mockResolvedValue({
data: {
displayLanguage: 'en'
}
}),
getPop: jest.fn().mockResolvedValue({
data: {
enabled: false,
accessWindow: 'disabled'
}
}),
getVacation: jest.fn().mockResolvedValue({
data: {
enabled: false,
startTime: undefined,
endTime: undefined,
responseSubject: '',
message: ''
}
}),
},
} as any,
};
// Simple file system mock
export const mockFileSystem = () => {
const fs = {
mkdir: jest.fn().mockResolvedValue(undefined),
readFile: jest.fn().mockResolvedValue(Buffer.from('test content')),
writeFile: jest.fn().mockResolvedValue(undefined),
unlink: jest.fn().mockResolvedValue(undefined),
access: jest.fn().mockResolvedValue(undefined),
chmod: jest.fn().mockResolvedValue(undefined),
};
jest.mock('fs/promises', () => fs);
return { fs };
};
// Mock the account manager module
jest.mock('../modules/accounts/index.js', () => ({
initializeAccountModule: jest.fn().mockResolvedValue(mockAccountManager()),
getAccountManager: jest.fn().mockReturnValue(mockAccountManager()),
}));
// Mock googleapis
jest.mock('googleapis', () => {
const mockDrive = {
files: {
list: jest.fn(),
create: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
export: jest.fn(),
},
permissions: {
create: jest.fn(),
},
};
return {
google: {
gmail: jest.fn().mockReturnValue(mockGmailClient),
drive: jest.fn().mockReturnValue(mockDrive),
},
};
});
// Reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
```
--------------------------------------------------------------------------------
/src/modules/attachments/service.ts:
--------------------------------------------------------------------------------
```typescript
import {
AttachmentMetadata,
AttachmentResult,
AttachmentServiceConfig,
AttachmentSource,
AttachmentValidationResult,
ATTACHMENT_FOLDERS,
AttachmentFolderType
} from './types.js';
import fs from 'fs/promises';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
const DEFAULT_CONFIG: AttachmentServiceConfig = {
maxSizeBytes: 25 * 1024 * 1024, // 25MB
allowedMimeTypes: ['*/*'],
quotaLimitBytes: 1024 * 1024 * 1024, // 1GB
basePath: process.env.WORKSPACE_BASE_PATH ?
path.join(process.env.WORKSPACE_BASE_PATH, ATTACHMENT_FOLDERS.ROOT) :
'/app/workspace/attachments'
};
export class AttachmentService {
private static instance: AttachmentService;
private config: AttachmentServiceConfig;
private initialized = false;
private constructor(config: AttachmentServiceConfig = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Get the singleton instance
*/
public static getInstance(config: AttachmentServiceConfig = {}): AttachmentService {
if (!AttachmentService.instance) {
AttachmentService.instance = new AttachmentService(config);
}
return AttachmentService.instance;
}
/**
* Initialize attachment folders in local storage
*/
async initialize(email: string): Promise<void> {
try {
// Create base attachment directory
await fs.mkdir(this.config.basePath!, { recursive: true });
// Create email directory structure
const emailPath = path.join(this.config.basePath!, ATTACHMENT_FOLDERS.EMAIL);
await fs.mkdir(emailPath, { recursive: true });
await fs.mkdir(path.join(emailPath, ATTACHMENT_FOLDERS.INCOMING), { recursive: true });
await fs.mkdir(path.join(emailPath, ATTACHMENT_FOLDERS.OUTGOING), { recursive: true });
// Create calendar directory structure
const calendarPath = path.join(this.config.basePath!, ATTACHMENT_FOLDERS.CALENDAR);
await fs.mkdir(calendarPath, { recursive: true });
await fs.mkdir(path.join(calendarPath, ATTACHMENT_FOLDERS.EVENT_FILES), { recursive: true });
this.initialized = true;
} catch (error) {
throw new Error(`Failed to initialize attachment directories: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Validate attachment against configured limits
*/
private validateAttachment(source: AttachmentSource): AttachmentValidationResult {
// Check size if available
if (source.metadata.size && this.config.maxSizeBytes) {
if (source.metadata.size > this.config.maxSizeBytes) {
return {
valid: false,
error: `File size ${source.metadata.size} exceeds maximum allowed size ${this.config.maxSizeBytes}`
};
}
}
// Check MIME type if restricted
if (this.config.allowedMimeTypes &&
this.config.allowedMimeTypes[0] !== '*/*' &&
!this.config.allowedMimeTypes.includes(source.metadata.mimeType)) {
return {
valid: false,
error: `MIME type ${source.metadata.mimeType} is not allowed`
};
}
return { valid: true };
}
/**
* Process attachment and store in local filesystem
*/
async processAttachment(
email: string,
source: AttachmentSource,
parentFolder: string
): Promise<AttachmentResult> {
if (!this.initialized) {
await this.initialize(email);
}
// Validate attachment
const validation = this.validateAttachment(source);
if (!validation.valid) {
return {
success: false,
error: validation.error
};
}
try {
if (!source.content) {
throw new Error('File content not provided');
}
// Generate unique ID and create file path
const id = uuidv4();
const folderPath = path.join(this.config.basePath!, parentFolder);
const filePath = path.join(folderPath, `${id}_${source.metadata.name}`);
// Write file content
const content = Buffer.from(source.content, 'base64');
await fs.writeFile(filePath, content);
// Get actual file size
const stats = await fs.stat(filePath);
return {
success: true,
attachment: {
id,
name: source.metadata.name,
mimeType: source.metadata.mimeType,
size: stats.size,
path: filePath
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
/**
* Download attachment from local storage
*/
async downloadAttachment(
email: string,
attachmentId: string,
filePath: string
): Promise<AttachmentResult> {
if (!this.initialized) {
await this.initialize(email);
}
try {
// Verify file exists and is within workspace
if (!filePath.startsWith(this.config.basePath!)) {
throw new Error('Invalid file path');
}
const content = await fs.readFile(filePath);
const stats = await fs.stat(filePath);
return {
success: true,
attachment: {
id: attachmentId,
name: path.basename(filePath).substring(37), // Remove UUID prefix
mimeType: path.extname(filePath) ?
`application/${path.extname(filePath).substring(1)}` :
'application/octet-stream',
size: stats.size,
path: filePath
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
/**
* Delete attachment from local storage
*/
async deleteAttachment(
email: string,
attachmentId: string,
filePath: string
): Promise<AttachmentResult> {
if (!this.initialized) {
await this.initialize(email);
}
try {
// Verify file exists and is within workspace
if (!filePath.startsWith(this.config.basePath!)) {
throw new Error('Invalid file path');
}
// Get file stats before deletion
const stats = await fs.stat(filePath);
const name = path.basename(filePath).substring(37); // Remove UUID prefix
const mimeType = path.extname(filePath) ?
`application/${path.extname(filePath).substring(1)}` :
'application/octet-stream';
// Delete the file
await fs.unlink(filePath);
return {
success: true,
attachment: {
id: attachmentId,
name,
mimeType,
size: stats.size,
path: filePath
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
/**
* Get full path for a specific attachment category
*/
getAttachmentPath(folder: AttachmentFolderType): string {
return path.join(this.config.basePath!, folder);
}
}
```
--------------------------------------------------------------------------------
/src/tools/calendar-handlers.ts:
--------------------------------------------------------------------------------
```typescript
import { CalendarService } from '../modules/calendar/service.js';
import { DriveService } from '../modules/drive/service.js';
import { validateEmail } from '../utils/account.js';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { getAccountManager } from '../modules/accounts/index.js';
import { CalendarError } from '../modules/calendar/types.js';
// Singleton instances
let driveService: DriveService;
let calendarService: CalendarService;
let accountManager: ReturnType<typeof getAccountManager>;
const CALENDAR_CONFIG = {
maxAttachmentSize: 10 * 1024 * 1024, // 10MB
allowedAttachmentTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'image/jpeg',
'image/png',
'text/plain'
]
};
// Initialize services lazily
async function initializeServices() {
if (!driveService) {
driveService = new DriveService();
}
if (!calendarService) {
calendarService = new CalendarService(CALENDAR_CONFIG);
await calendarService.ensureInitialized();
}
if (!accountManager) {
accountManager = getAccountManager();
}
}
export async function handleListWorkspaceCalendarEvents(params: any) {
await initializeServices();
const { email, query, maxResults, timeMin, timeMax } = params;
if (!email) {
throw new McpError(
ErrorCode.InvalidParams,
'Email address is required'
);
}
validateEmail(email);
return accountManager.withTokenRenewal(email, async () => {
try {
return await calendarService.getEvents({
email,
query,
maxResults,
timeMin,
timeMax
});
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to list calendar events: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
});
}
export async function handleGetWorkspaceCalendarEvent(params: any) {
await initializeServices();
const { email, eventId } = params;
if (!email) {
throw new McpError(
ErrorCode.InvalidParams,
'Email address is required'
);
}
if (!eventId) {
throw new McpError(
ErrorCode.InvalidParams,
'Event ID is required'
);
}
validateEmail(email);
return accountManager.withTokenRenewal(email, async () => {
try {
return await calendarService.getEvent(email, eventId);
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to get calendar event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
});
}
export async function handleCreateWorkspaceCalendarEvent(params: any) {
await initializeServices();
const { email, summary, description, start, end, attendees, attachments } = params;
if (!email) {
throw new McpError(
ErrorCode.InvalidParams,
'Email address is required'
);
}
if (!summary) {
throw new McpError(
ErrorCode.InvalidParams,
'Event summary is required'
);
}
if (!start || !start.dateTime) {
throw new McpError(
ErrorCode.InvalidParams,
'Event start time is required'
);
}
if (!end || !end.dateTime) {
throw new McpError(
ErrorCode.InvalidParams,
'Event end time is required'
);
}
validateEmail(email);
if (attendees) {
attendees.forEach((attendee: { email: string }) => validateEmail(attendee.email));
}
return accountManager.withTokenRenewal(email, async () => {
try {
return await calendarService.createEvent({
email,
summary,
description,
start,
end,
attendees,
attachments: attachments?.map((attachment: {
driveFileId?: string;
content?: string;
name: string;
mimeType: string;
size?: number;
}) => ({
driveFileId: attachment.driveFileId,
content: attachment.content,
name: attachment.name,
mimeType: attachment.mimeType,
size: attachment.size
}))
});
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to create calendar event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
});
}
export async function handleManageWorkspaceCalendarEvent(params: any) {
await initializeServices();
const { email, eventId, action, comment, newTimes } = params;
if (!email) {
throw new McpError(
ErrorCode.InvalidParams,
'Email address is required'
);
}
if (!eventId) {
throw new McpError(
ErrorCode.InvalidParams,
'Event ID is required'
);
}
if (!action) {
throw new McpError(
ErrorCode.InvalidParams,
'Action is required'
);
}
validateEmail(email);
return accountManager.withTokenRenewal(email, async () => {
try {
return await calendarService.manageEvent({
email,
eventId,
action,
comment,
newTimes
});
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to manage calendar event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
});
}
export async function handleDeleteWorkspaceCalendarEvent(params: any) {
await initializeServices();
const { email, eventId, sendUpdates, deletionScope } = params;
if (!email) {
throw new McpError(
ErrorCode.InvalidParams,
'Email address is required'
);
}
if (!eventId) {
throw new McpError(
ErrorCode.InvalidParams,
'Event ID is required'
);
}
// Validate deletionScope if provided
if (deletionScope && !['entire_series', 'this_and_following'].includes(deletionScope)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid deletion scope. Must be one of: entire_series, this_and_following'
);
}
validateEmail(email);
return accountManager.withTokenRenewal(email, async () => {
try {
await calendarService.deleteEvent(email, eventId, sendUpdates, deletionScope);
// Return a success response object instead of void
return {
status: 'success',
message: 'Event deleted successfully',
details: deletionScope ?
`Event deleted with scope: ${deletionScope}` :
'Event deleted completely'
};
} catch (error) {
// Check if this is a CalendarError with a specific code
if (error instanceof CalendarError) {
throw new McpError(
ErrorCode.InvalidParams,
error.message,
error.details
);
}
throw new McpError(
ErrorCode.InternalError,
`Failed to delete calendar event: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
});
}
```
--------------------------------------------------------------------------------
/src/tools/types.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Standard response format for all MCP tools
* @property content - Array of content blocks to return to the client
* @property isError - Whether this response represents an error condition
* @property _meta - Optional metadata about the response
*/
export interface McpToolResponse {
content: {
type: 'text';
text: string;
}[];
isError?: boolean;
_meta?: Record<string, unknown>;
}
/**
* Base interface for all tool arguments
* Provides type safety for unknown properties
*/
export interface ToolArguments {
[key: string]: unknown;
}
/**
* Common arguments required by all workspace tools
* @property email - The Google Workspace account email address
*/
export interface BaseToolArguments extends Record<string, unknown> {
email: string;
}
/**
* Parameters for authenticating a workspace account
* @property email - Email address to authenticate
* @property category - Optional account category (e.g., 'work', 'personal')
* @property description - Optional account description
* @property auth_code - OAuth2 authorization code (required for completing auth)
*/
export interface AuthenticateAccountArgs extends BaseToolArguments {
category?: string;
description?: string;
auth_code?: string;
}
// Gmail Types
/**
* Parameters for searching Gmail messages
* @property email - The Gmail account to search
* @property search - Search criteria for filtering emails
* @property options - Search options including pagination and format
*/
export interface GmailSearchParams {
email: string;
search?: {
from?: string | string[];
to?: string | string[];
subject?: string;
content?: string;
after?: string; // YYYY-MM-DD
before?: string; // YYYY-MM-DD
hasAttachment?: boolean;
labels?: string[]; // e.g., ["INBOX", "IMPORTANT"]
excludeLabels?: string[];
includeSpam?: boolean;
isUnread?: boolean;
};
options?: {
maxResults?: number;
pageToken?: string;
includeHeaders?: boolean;
format?: 'full' | 'metadata' | 'minimal';
threadedView?: boolean;
sortOrder?: 'asc' | 'desc';
};
}
/**
* Content for sending or creating draft emails
* @property to - Array of recipient email addresses
* @property subject - Email subject line
* @property body - Email body content (supports HTML)
* @property cc - Optional CC recipients
* @property bcc - Optional BCC recipients
*/
export interface EmailContent {
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
}
/**
* Parameters for sending emails
*/
export interface SendEmailArgs extends BaseToolArguments, EmailContent {}
// Calendar Types
/**
* Parameters for listing calendar events
* @property email - Calendar owner's email
* @property query - Optional text search within events
* @property maxResults - Maximum events to return
* @property timeMin - Start of time range (ISO-8601)
* @property timeMax - End of time range (ISO-8601)
*/
export interface CalendarEventParams extends BaseToolArguments {
query?: string;
maxResults?: number; // Default: 10
timeMin?: string; // ISO-8601 format
timeMax?: string; // ISO-8601 format
}
/**
* Time specification for calendar events
* @property dateTime - Event time in ISO-8601 format
* @property timeZone - IANA timezone identifier
*/
export interface CalendarEventTime {
dateTime: string; // e.g., "2024-02-18T15:30:00-06:00"
timeZone?: string; // e.g., "America/Chicago"
}
/**
* Calendar event attendee information
* @property email - Attendee's email address
*/
export interface CalendarEventAttendee {
email: string;
}
// Drive Types
/**
* Parameters for listing Drive files
*/
export interface DriveFileListArgs extends BaseToolArguments {
options?: {
folderId?: string;
query?: string;
pageSize?: number;
orderBy?: string[];
fields?: string[];
};
}
/**
* Parameters for searching Drive files
*/
export interface DriveSearchArgs extends BaseToolArguments {
options: {
fullText?: string;
mimeType?: string;
folderId?: string;
trashed?: boolean;
query?: string;
pageSize?: number;
};
}
/**
* Parameters for uploading files to Drive
*/
export interface DriveUploadArgs extends BaseToolArguments {
options: {
name: string;
content: string;
mimeType?: string;
parents?: string[];
};
}
/**
* Parameters for downloading files from Drive
*/
export interface DriveDownloadArgs extends BaseToolArguments {
fileId: string;
mimeType?: string;
}
/**
* Parameters for creating Drive folders
*/
export interface DriveFolderArgs extends BaseToolArguments {
name: string;
parentId?: string;
}
/**
* Parameters for updating Drive permissions
*/
export interface DrivePermissionArgs extends BaseToolArguments {
options: {
fileId: string;
role: 'owner' | 'organizer' | 'fileOrganizer' | 'writer' | 'commenter' | 'reader';
type: 'user' | 'group' | 'domain' | 'anyone';
emailAddress?: string;
domain?: string;
allowFileDiscovery?: boolean;
};
}
/**
* Parameters for deleting Drive files
*/
export interface DriveDeleteArgs extends BaseToolArguments {
fileId: string;
}
// Label Types
/**
* Color settings for Gmail labels
* @property textColor - Hex color code for label text
* @property backgroundColor - Hex color code for label background
*/
export interface LabelColor {
textColor: string; // e.g., "#000000"
backgroundColor: string; // e.g., "#FFFFFF"
}
/**
* Parameters for creating labels
*/
export interface CreateLabelArgs extends BaseToolArguments {
name: string;
messageListVisibility?: 'show' | 'hide';
labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';
color?: LabelColor;
}
/**
* Parameters for updating labels
*/
export interface UpdateLabelArgs extends CreateLabelArgs {
labelId: string;
}
/**
* Parameters for deleting labels
*/
export interface DeleteLabelArgs extends BaseToolArguments {
labelId: string;
}
/**
* Parameters for modifying message labels
*/
export interface ModifyLabelsArgs extends BaseToolArguments {
messageId: string;
addLabelIds?: string[];
removeLabelIds?: string[];
}
// Label Filter Types
/**
* Filter criteria for matching emails
*/
export interface LabelFilterCriteria {
from?: string[];
to?: string[];
subject?: string;
hasWords?: string[];
doesNotHaveWords?: string[];
hasAttachment?: boolean;
size?: {
operator: 'larger' | 'smaller';
size: number;
};
}
/**
* Actions to take when filter matches
*/
export interface LabelFilterActions {
addLabel: boolean;
markImportant?: boolean;
markRead?: boolean;
archive?: boolean;
}
/**
* Parameters for creating label filters
*/
export interface CreateLabelFilterArgs extends BaseToolArguments {
labelId: string;
criteria: LabelFilterCriteria;
actions: LabelFilterActions;
}
/**
* Parameters for getting label filters
*/
export interface GetLabelFiltersArgs extends BaseToolArguments {
labelId?: string; // Optional: get filters for specific label
}
/**
* Parameters for updating label filters
*/
export interface UpdateLabelFilterArgs extends BaseToolArguments {
filterId: string;
criteria?: LabelFilterCriteria;
actions?: LabelFilterActions;
}
/**
* Parameters for deleting label filters
*/
export interface DeleteLabelFilterArgs extends BaseToolArguments {
filterId: string;
}
// Attachment Management Types
export interface ManageAttachmentParams extends BaseToolArguments {
action: 'download' | 'upload' | 'delete';
source: 'email' | 'calendar';
messageId: string;
filename: string;
content?: string; // Required for upload action
}
// Re-export consolidated management types
export {
ManageLabelParams,
ManageLabelAssignmentParams,
ManageLabelFilterParams
} from '../modules/gmail/services/label.js';
export {
ManageDraftParams,
DraftAction
} from '../modules/gmail/services/draft.js';
```
--------------------------------------------------------------------------------
/src/modules/drive/__tests__/service.test.ts:
--------------------------------------------------------------------------------
```typescript
import { DriveService } from '../service.js';
import { getAccountManager } from '../../accounts/index.js';
import { DRIVE_SCOPES } from '../scopes.js';
import { GoogleServiceError } from '../../../services/base/BaseGoogleService.js';
import { mockFileSystem } from '../../../__helpers__/testSetup.js';
jest.mock('../../accounts/index.js');
jest.mock('googleapis');
jest.mock('../../../utils/workspace.js', () => ({
workspaceManager: {
getUploadPath: jest.fn().mockResolvedValue('/tmp/test-upload.txt'),
getDownloadPath: jest.fn().mockResolvedValue('/tmp/test-download.txt'),
initializeAccountDirectories: jest.fn().mockResolvedValue(undefined)
}
}));
const { fs } = mockFileSystem();
describe('DriveService', () => {
const testEmail = '[email protected]';
let service: DriveService;
let mockDrive: any;
beforeEach(async () => {
jest.clearAllMocks();
// Mock file system operations
fs.writeFile.mockResolvedValue(undefined);
fs.readFile.mockResolvedValue(Buffer.from('test content'));
// Mock account manager
(getAccountManager as jest.Mock).mockReturnValue({
validateToken: jest.fn().mockResolvedValue({
valid: true,
token: { access_token: 'test-token' },
requiredScopes: Object.values(DRIVE_SCOPES)
}),
getAuthClient: jest.fn().mockResolvedValue({
setCredentials: jest.fn()
})
});
const { google } = require('googleapis');
mockDrive = {
files: {
list: jest.fn(),
create: jest.fn(),
get: jest.fn(),
delete: jest.fn(),
export: jest.fn()
},
permissions: {
create: jest.fn()
}
};
google.drive.mockReturnValue(mockDrive);
service = new DriveService();
});
describe('listFiles', () => {
it('should list files successfully', async () => {
const mockResponse = {
data: {
files: [
{ id: '1', name: 'test.txt' }
]
}
};
mockDrive.files.list.mockResolvedValue(mockResponse);
const result = await service.listFiles(testEmail);
expect(result.success).toBe(true);
expect(result.data).toEqual(mockResponse.data);
expect(mockDrive.files.list).toHaveBeenCalled();
});
it('should handle errors', async () => {
mockDrive.files.list.mockRejectedValue(new Error('API error'));
const result = await service.listFiles(testEmail);
expect(result.success).toBe(false);
expect(result.error).toBe('API error');
});
});
describe('uploadFile', () => {
it('should upload file successfully', async () => {
const mockResponse = {
data: {
id: '1',
name: 'test.txt',
mimeType: 'text/plain',
webViewLink: 'https://drive.google.com/file/d/1'
}
};
mockDrive.files.create.mockResolvedValue(mockResponse);
const result = await service.uploadFile(testEmail, {
name: 'test.txt',
content: 'test content',
mimeType: 'text/plain'
});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockResponse.data);
expect(mockDrive.files.create).toHaveBeenCalledWith(expect.objectContaining({
requestBody: {
name: 'test.txt',
mimeType: 'text/plain'
},
fields: 'id, name, mimeType, webViewLink'
}));
});
it('should handle upload errors', async () => {
mockDrive.files.create.mockRejectedValue(new Error('Upload failed'));
const result = await service.uploadFile(testEmail, {
name: 'test.txt',
content: 'test content'
});
expect(result.success).toBe(false);
expect(result.error).toBe('Upload failed');
});
});
describe('downloadFile', () => {
// Simplified to a single test case
it('should handle download operations', async () => {
const mockMetadata = {
data: {
name: 'test.txt',
mimeType: 'text/plain'
}
};
mockDrive.files.get.mockResolvedValue(mockMetadata);
const result = await service.downloadFile(testEmail, {
fileId: '1'
});
expect(result.success).toBeDefined();
expect(mockDrive.files.get).toHaveBeenCalled();
});
});
describe('searchFiles', () => {
// Simplified to basic functionality test
it('should handle search operations', async () => {
const mockResponse = {
data: {
files: [{ id: '1', name: 'test.txt' }]
}
};
mockDrive.files.list.mockResolvedValue(mockResponse);
const result = await service.searchFiles(testEmail, {
fullText: 'test'
});
expect(result.success).toBeDefined();
expect(mockDrive.files.list).toHaveBeenCalled();
});
});
describe('updatePermissions', () => {
it('should update permissions successfully', async () => {
const mockResponse = {
data: {
id: '1',
type: 'user',
role: 'reader'
}
};
mockDrive.permissions.create.mockResolvedValue(mockResponse);
const result = await service.updatePermissions(testEmail, {
fileId: '1',
type: 'user',
role: 'reader',
emailAddress: '[email protected]'
});
expect(result.success).toBe(true);
expect(result.data).toEqual(mockResponse.data);
expect(mockDrive.permissions.create).toHaveBeenCalledWith({
fileId: '1',
requestBody: {
role: 'reader',
type: 'user',
emailAddress: '[email protected]'
}
});
});
it('should handle permission update errors', async () => {
mockDrive.permissions.create.mockRejectedValue(new Error('Permission update failed'));
const result = await service.updatePermissions(testEmail, {
fileId: '1',
type: 'user',
role: 'reader',
emailAddress: '[email protected]'
});
expect(result.success).toBe(false);
expect(result.error).toBe('Permission update failed');
});
});
describe('deleteFile', () => {
it('should delete file successfully', async () => {
mockDrive.files.delete.mockResolvedValue({});
const result = await service.deleteFile(testEmail, '1');
expect(result.success).toBe(true);
expect(mockDrive.files.delete).toHaveBeenCalledWith({
fileId: '1'
});
});
it('should handle delete errors', async () => {
mockDrive.files.delete.mockRejectedValue(new Error('Delete failed'));
const result = await service.deleteFile(testEmail, '1');
expect(result.success).toBe(false);
expect(result.error).toBe('Delete failed');
});
});
describe('createFolder', () => {
it('should create folder successfully', async () => {
const mockResponse = {
data: {
id: '1',
name: 'Test Folder',
mimeType: 'application/vnd.google-apps.folder',
webViewLink: 'https://drive.google.com/drive/folders/1'
}
};
mockDrive.files.create.mockResolvedValue(mockResponse);
const result = await service.createFolder(testEmail, 'Test Folder', 'parent123');
expect(result.success).toBe(true);
expect(result.data).toEqual(mockResponse.data);
expect(mockDrive.files.create).toHaveBeenCalledWith({
requestBody: {
name: 'Test Folder',
mimeType: 'application/vnd.google-apps.folder',
parents: ['parent123']
},
fields: 'id, name, mimeType, webViewLink'
});
});
it('should handle folder creation errors', async () => {
mockDrive.files.create.mockRejectedValue(new Error('Folder creation failed'));
const result = await service.createFolder(testEmail, 'Test Folder');
expect(result.success).toBe(false);
expect(result.error).toBe('Folder creation failed');
});
});
});
```
--------------------------------------------------------------------------------
/src/__tests__/modules/gmail/service.test.ts:
--------------------------------------------------------------------------------
```typescript
import { GmailService } from '../../../modules/gmail/service.js';
import { gmail_v1 } from 'googleapis';
import { getAccountManager } from '../../../modules/accounts/index.js';
import { AccountManager } from '../../../modules/accounts/manager.js';
import { DraftResponse, GetDraftsResponse, SendEmailResponse } from '../../../modules/gmail/types.js';
jest.mock('../../../modules/accounts/index.js');
jest.mock('../../../modules/accounts/manager.js');
describe('GmailService', () => {
let gmailService: GmailService;
let mockGmailClient: jest.Mocked<gmail_v1.Gmail>;
let mockAccountManager: jest.Mocked<AccountManager>;
const testEmail = '[email protected]';
beforeEach(async () => {
// Simplified mock setup
mockGmailClient = {
users: {
messages: {
list: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
get: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
send: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
},
drafts: {
create: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
list: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
get: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
send: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
},
getProfile: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
settings: {
getAutoForwarding: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
getImap: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
getLanguage: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
getPop: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
getVacation: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
},
},
} as any;
mockAccountManager = {
validateToken: jest.fn().mockResolvedValue({ valid: true, token: {} }),
getAuthClient: jest.fn().mockResolvedValue({}),
withTokenRenewal: jest.fn().mockImplementation((email, operation) => operation()),
} as unknown as jest.Mocked<AccountManager>;
(getAccountManager as jest.Mock).mockReturnValue(mockAccountManager);
gmailService = new GmailService();
await gmailService.initialize();
// Mock the base service's getAuthenticatedClient method
(gmailService as any).getAuthenticatedClient = jest.fn().mockResolvedValue(mockGmailClient);
// Mock all internal services
(gmailService as any).emailService.gmailClient = mockGmailClient;
(gmailService as any).emailService.getAuthenticatedClient = jest.fn().mockResolvedValue(mockGmailClient);
(gmailService as any).draftService.gmailClient = mockGmailClient;
(gmailService as any).draftService.getAuthenticatedClient = jest.fn().mockResolvedValue(mockGmailClient);
(gmailService as any).settingsService.gmailClient = mockGmailClient;
(gmailService as any).settingsService.getAuthenticatedClient = jest.fn().mockResolvedValue(mockGmailClient);
});
describe('getEmails', () => {
it('should get emails with search criteria', async () => {
const mockMessages = [
{ id: 'msg1', threadId: 'thread1' },
{ id: 'msg2', threadId: 'thread2' }
];
(mockGmailClient.users.messages.list as jest.Mock).mockImplementation(() =>
Promise.resolve({ data: { messages: mockMessages, resultSizeEstimate: 2 } })
);
const result = await gmailService.getEmails({
email: testEmail,
search: { subject: 'test' }
});
expect(result.emails.length).toBe(2);
expect(result.resultSummary.total).toBe(2);
});
it('should handle empty results', async () => {
(mockGmailClient.users.messages.list as jest.Mock).mockImplementation(() =>
Promise.resolve({ data: { messages: [], resultSizeEstimate: 0 } })
);
const result = await gmailService.getEmails({ email: testEmail });
expect(result.emails).toEqual([]);
expect(result.resultSummary.total).toBe(0);
});
});
describe('sendEmail', () => {
const emailParams = {
email: testEmail,
to: ['[email protected]'],
subject: 'Test',
body: 'Hello'
};
it('should send email', async () => {
(mockGmailClient.users.messages.send as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: { id: 'msg1', threadId: 'thread1', labelIds: ['SENT'] }
})
);
const result = await gmailService.sendEmail(emailParams);
expect(result.messageId).toBe('msg1');
expect(result.labelIds).toContain('SENT');
});
it('should handle send failure', async () => {
(mockGmailClient.users.messages.send as jest.Mock).mockImplementation(() =>
Promise.reject(new Error('Send failed'))
);
await expect(gmailService.sendEmail(emailParams)).rejects.toThrow();
});
});
describe('manageDraft', () => {
it('should create draft', async () => {
(mockGmailClient.users.drafts.create as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
id: 'draft1',
message: { id: 'msg1' }
}
})
);
const result = await gmailService.manageDraft({
action: 'create',
email: testEmail,
data: {
to: ['[email protected]'],
subject: 'Draft',
body: 'Content'
}
}) as DraftResponse;
expect(result).toHaveProperty('id', 'draft1');
});
it('should list drafts', async () => {
// Mock the list call to return draft IDs
(mockGmailClient.users.drafts.list as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
drafts: [
{ id: 'draft1' },
{ id: 'draft2' }
],
resultSizeEstimate: 2
}
})
);
// Mock the get call for each draft
(mockGmailClient.users.drafts.get as jest.Mock)
.mockImplementationOnce(() => Promise.resolve({
data: {
id: 'draft1',
message: {
id: 'msg1',
threadId: 'thread1',
labelIds: ['DRAFT']
}
}
}))
.mockImplementationOnce(() => Promise.resolve({
data: {
id: 'draft2',
message: {
id: 'msg2',
threadId: 'thread2',
labelIds: ['DRAFT']
}
}
}));
const result = await gmailService.manageDraft({
action: 'read',
email: testEmail
}) as GetDraftsResponse;
expect(result).toHaveProperty('drafts');
expect(result.drafts.length).toBe(2);
expect(result.drafts[0]).toHaveProperty('id', 'draft1');
expect(result.drafts[1]).toHaveProperty('id', 'draft2');
});
it('should send draft', async () => {
(mockGmailClient.users.drafts.send as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
id: 'msg1',
threadId: 'thread1',
labelIds: ['SENT']
}
})
);
const result = await gmailService.manageDraft({
action: 'send',
email: testEmail,
draftId: 'draft1'
}) as SendEmailResponse;
expect(result).toHaveProperty('messageId', 'msg1');
expect(result).toHaveProperty('labelIds');
});
});
describe('getWorkspaceGmailSettings', () => {
it('should get settings', async () => {
(mockGmailClient.users.getProfile as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
emailAddress: testEmail,
messagesTotal: 100
}
})
);
const result = await gmailService.getWorkspaceGmailSettings({
email: testEmail
});
expect(result.profile.emailAddress).toBe(testEmail);
expect(result.settings).toBeDefined();
});
it('should handle settings fetch error', async () => {
(mockGmailClient.users.getProfile as jest.Mock).mockImplementation(() =>
Promise.reject(new Error('Failed to fetch'))
);
await expect(gmailService.getWorkspaceGmailSettings({
email: testEmail
})).rejects.toThrow();
});
});
});
```
--------------------------------------------------------------------------------
/src/modules/tools/registry.ts:
--------------------------------------------------------------------------------
```typescript
import { Tool } from "@modelcontextprotocol/sdk/types.js";
export interface ToolMetadata extends Tool {
category: string;
aliases?: string[];
}
export interface ToolCategory {
name: string;
description: string;
tools: ToolMetadata[];
}
export class ToolRegistry {
private tools: Map<string, ToolMetadata> = new Map();
private categories: Map<string, ToolCategory> = new Map();
private aliasMap: Map<string, string> = new Map();
constructor(tools: ToolMetadata[]) {
this.registerTools(tools);
}
private registerTools(tools: ToolMetadata[]): void {
for (const tool of tools) {
// Register the main tool
this.tools.set(tool.name, tool);
// Register category
if (!this.categories.has(tool.category)) {
this.categories.set(tool.category, {
name: tool.category,
description: '', // Could be added in future
tools: []
});
}
this.categories.get(tool.category)?.tools.push(tool);
// Register aliases
if (tool.aliases) {
for (const alias of tool.aliases) {
this.aliasMap.set(alias, tool.name);
}
}
}
}
getTool(name: string): ToolMetadata | undefined {
// Try direct lookup
const tool = this.tools.get(name);
if (tool) {
return tool;
}
// Try alias lookup
const mainName = this.aliasMap.get(name);
if (mainName) {
return this.tools.get(mainName);
}
return undefined;
}
getAllTools(): ToolMetadata[] {
return Array.from(this.tools.values());
}
getCategories(): ToolCategory[] {
return Array.from(this.categories.values());
}
private calculateLevenshteinDistance(a: string, b: string): number {
const matrix: number[][] = [];
// Initialize matrix
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
private tokenize(name: string): string[] {
return name.toLowerCase().split(/[_\s]+/);
}
private calculateSimilarityScore(searchTokens: string[], targetTokens: string[]): number {
// First try exact token matching with position awareness
const searchStr = searchTokens.join('_');
const targetStr = targetTokens.join('_');
// Perfect match
if (searchStr === targetStr) {
return 1.0;
}
// Check if tokens are the same but in different order
const searchSet = new Set(searchTokens);
const targetSet = new Set(targetTokens);
if (searchSet.size === targetSet.size &&
[...searchSet].every(token => targetSet.has(token))) {
return 0.9;
}
// Calculate token-by-token similarity
let score = 0;
const usedTargetTokens = new Set<number>();
let matchedTokens = 0;
for (const searchToken of searchTokens) {
let bestTokenScore = 0;
let bestTokenIndex = -1;
targetTokens.forEach((targetToken, index) => {
if (usedTargetTokens.has(index)) return;
// Exact match gets highest score
if (searchToken === targetToken) {
const positionPenalty = Math.abs(searchTokens.indexOf(searchToken) - index) * 0.1;
const tokenScore = Math.max(0.8, 1.0 - positionPenalty);
if (tokenScore > bestTokenScore) {
bestTokenScore = tokenScore;
bestTokenIndex = index;
}
return;
}
// Substring match gets good score
if (targetToken.includes(searchToken) || searchToken.includes(targetToken)) {
const tokenScore = 0.7;
if (tokenScore > bestTokenScore) {
bestTokenScore = tokenScore;
bestTokenIndex = index;
}
return;
}
// Levenshtein distance for fuzzy matching
const distance = this.calculateLevenshteinDistance(searchToken, targetToken);
const maxLength = Math.max(searchToken.length, targetToken.length);
const tokenScore = 1 - (distance / maxLength);
if (tokenScore > 0.6 && tokenScore > bestTokenScore) {
bestTokenScore = tokenScore;
bestTokenIndex = index;
}
});
if (bestTokenIndex !== -1) {
score += bestTokenScore;
usedTargetTokens.add(bestTokenIndex);
matchedTokens++;
}
}
// Penalize if not all tokens were matched
const matchRatio = matchedTokens / searchTokens.length;
const finalScore = (score / searchTokens.length) * matchRatio;
// Additional penalty for length mismatch
const lengthPenalty = Math.abs(searchTokens.length - targetTokens.length) * 0.1;
return Math.max(0, finalScore - lengthPenalty);
}
private isCommonTypo(a: string, b: string): boolean {
const commonTypos: { [key: string]: string[] } = {
'label': ['lable', 'labl', 'lbl'],
'email': ['emil', 'mail', 'emal'],
'calendar': ['calender', 'calander', 'caldr'],
'workspace': ['workspce', 'wrkspace', 'wrkspc'],
'create': ['creat', 'crete', 'craete'],
'message': ['mesage', 'msg', 'messge'],
'draft': ['draf', 'drft', 'darft']
};
// Check both directions (a->b and b->a)
for (const [word, typos] of Object.entries(commonTypos)) {
if ((a === word && typos.includes(b)) || (b === word && typos.includes(a))) {
return true;
}
}
return false;
}
findSimilarTools(name: string, maxSuggestions: number = 3): ToolMetadata[] {
const searchTokens = this.tokenize(name);
const matches: Array<{ tool: ToolMetadata; score: number }> = [];
for (const tool of this.getAllTools()) {
let bestScore = 0;
// Check main tool name
const nameTokens = this.tokenize(tool.name);
bestScore = this.calculateSimilarityScore(searchTokens, nameTokens);
// Check for common typos in each token
const hasCommonTypo = searchTokens.some(searchToken =>
nameTokens.some(nameToken => this.isCommonTypo(searchToken, nameToken))
);
if (hasCommonTypo) {
bestScore = Math.max(bestScore, 0.8); // Boost score for common typos
}
// Check aliases
if (tool.aliases) {
for (const alias of tool.aliases) {
const aliasTokens = this.tokenize(alias);
const aliasScore = this.calculateSimilarityScore(searchTokens, aliasTokens);
// Check for common typos in aliases too
if (searchTokens.some(searchToken =>
aliasTokens.some(aliasToken => this.isCommonTypo(searchToken, aliasToken)))) {
bestScore = Math.max(bestScore, 0.8);
}
bestScore = Math.max(bestScore, aliasScore);
}
}
// More lenient threshold (0.4 instead of 0.5) and include common typos
if (bestScore > 0.4 || hasCommonTypo) {
matches.push({ tool, score: bestScore });
}
}
// Sort by score (highest first) and return top matches
return matches
.sort((a, b) => b.score - a.score)
.slice(0, maxSuggestions)
.map(m => m.tool);
}
formatErrorWithSuggestions(invalidToolName: string): string {
const similarTools = this.findSimilarTools(invalidToolName);
const categories = this.getCategories();
let message = `Tool '${invalidToolName}' not found.\n\n`;
if (similarTools.length > 0) {
message += 'Did you mean:\n';
for (const tool of similarTools) {
message += `- ${tool.name} (${tool.category})\n`;
if (tool.aliases && tool.aliases.length > 0) {
message += ` Aliases: ${tool.aliases.join(', ')}\n`;
}
}
message += '\n';
}
message += 'Available categories:\n';
for (const category of categories) {
const toolNames = category.tools.map(t => t.name.replace('workspace_', '')).join(', ');
message += `- ${category.name}: ${toolNames}\n`;
}
return message;
}
// Helper method to get all available tool names including aliases
getAllToolNames(): string[] {
const names: string[] = [];
for (const tool of this.tools.values()) {
names.push(tool.name);
if (tool.aliases) {
names.push(...tool.aliases);
}
}
return names;
}
}
```
--------------------------------------------------------------------------------
/src/modules/gmail/__tests__/label.test.ts:
--------------------------------------------------------------------------------
```typescript
import { GmailService } from '../services/base.js';
import { gmail_v1 } from 'googleapis';
import { Label } from '../types.js';
import { getAccountManager } from '../../../modules/accounts/index.js';
import { AccountManager } from '../../../modules/accounts/manager.js';
import logger from '../../../utils/logger.js';
jest.mock('../../../modules/accounts/index.js');
jest.mock('../../../modules/accounts/manager.js');
jest.mock('../../../utils/logger.js', () => ({
default: {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn()
}
}));
describe('Gmail Label Service', () => {
let gmailService: GmailService;
let mockClient: any;
const testEmail = '[email protected]';
beforeAll(() => {
// Mock getAccountManager at module level
(getAccountManager as jest.Mock).mockReturnValue({
validateToken: jest.fn().mockResolvedValue({ valid: true, token: {} }),
getAuthClient: jest.fn().mockResolvedValue({})
});
});
beforeEach(async () => {
// Create a fresh instance for each test
gmailService = new GmailService();
// Create mock client
mockClient = {
users: {
labels: {
list: jest.fn(),
create: jest.fn(),
patch: jest.fn(),
delete: jest.fn()
},
messages: {
modify: jest.fn()
}
}
};
// Mock the Gmail client methods at service level
(gmailService as any).getGmailClient = jest.fn().mockResolvedValue(mockClient);
// Initialize the service and update label service client
await gmailService.initialize();
(gmailService as any).labelService.updateClient(mockClient);
});
describe('manageLabel - read', () => {
it('should fetch all labels', async () => {
// Simple mock response
const mockResponse = {
data: {
labels: [
{
id: 'label1',
name: 'Test Label',
type: 'user',
messageListVisibility: 'show',
labelListVisibility: 'labelShow'
}
]
}
};
// Set up mock
(mockClient.users.labels.list as jest.Mock).mockResolvedValue(mockResponse);
const result = await gmailService.manageLabel({
action: 'read',
email: testEmail
});
// Simple assertions
expect((result as any).labels).toHaveLength(1);
expect((result as any).labels[0].id).toBe('label1');
expect((result as any).labels[0].name).toBe('Test Label');
expect(mockClient.users.labels.list).toHaveBeenCalledWith({
userId: testEmail
});
});
it('should handle empty labels response', async () => {
// Simple mock for empty response
(mockClient.users.labels.list as jest.Mock).mockResolvedValue({
data: { labels: [] }
});
const result = await gmailService.manageLabel({
action: 'read',
email: testEmail
});
expect((result as any).labels).toHaveLength(0);
});
it('should handle errors when fetching labels', async () => {
// Simple error mock
(mockClient.users.labels.list as jest.Mock).mockRejectedValue(new Error('API Error'));
await expect(gmailService.manageLabel({
action: 'read',
email: testEmail
}))
.rejects
.toThrow('Failed to fetch labels');
});
});
describe('manageLabel - create', () => {
it('should create a new label', async () => {
// Simple mock response
const mockResponse = {
data: {
id: 'label1',
name: 'Test Label',
type: 'user',
messageListVisibility: 'show',
labelListVisibility: 'labelShow'
}
};
// Set up mock
(mockClient.users.labels.create as jest.Mock).mockResolvedValue(mockResponse);
const result = await gmailService.manageLabel({
action: 'create',
email: testEmail,
data: {
name: 'Test Label'
}
});
// Simple assertions
expect((result as Label).id).toBe('label1');
expect((result as Label).name).toBe('Test Label');
expect(mockClient.users.labels.create).toHaveBeenCalledWith({
userId: testEmail,
requestBody: expect.objectContaining({
name: 'Test Label'
})
});
});
it('should handle errors when creating a label', async () => {
// Simple error mock
(mockClient.users.labels.create as jest.Mock).mockRejectedValue(new Error('API Error'));
await expect(gmailService.manageLabel({
action: 'create',
email: testEmail,
data: {
name: 'Test Label'
}
})).rejects.toThrow('Failed to create label');
});
});
describe('manageLabel - update', () => {
it('should update an existing label', async () => {
// Simple mock response
const mockResponse = {
data: {
id: 'label1',
name: 'Updated Label',
type: 'user',
messageListVisibility: 'show',
labelListVisibility: 'labelShow'
}
};
// Set up mock
(mockClient.users.labels.patch as jest.Mock).mockResolvedValue(mockResponse);
const result = await gmailService.manageLabel({
action: 'update',
email: testEmail,
labelId: 'label1',
data: {
name: 'Updated Label'
}
});
// Simple assertions
expect((result as Label).id).toBe('label1');
expect((result as Label).name).toBe('Updated Label');
expect(mockClient.users.labels.patch).toHaveBeenCalledWith({
userId: testEmail,
id: 'label1',
requestBody: expect.objectContaining({
name: 'Updated Label'
})
});
});
it('should handle errors when updating a label', async () => {
// Simple error mock
(mockClient.users.labels.patch as jest.Mock).mockRejectedValue(new Error('API Error'));
await expect(gmailService.manageLabel({
action: 'update',
email: testEmail,
labelId: 'label1',
data: {
name: 'Updated Label'
}
})).rejects.toThrow('Failed to update label');
});
});
describe('manageLabel - delete', () => {
it('should delete a label', async () => {
// Simple mock response
(mockClient.users.labels.delete as jest.Mock).mockResolvedValue({});
// Execute and verify
await gmailService.manageLabel({
action: 'delete',
email: testEmail,
labelId: 'label1'
});
// Simple assertions
expect(mockClient.users.labels.delete).toHaveBeenCalledWith({
userId: testEmail,
id: 'label1'
});
});
it('should handle errors when deleting a label', async () => {
// Simple error mock
(mockClient.users.labels.delete as jest.Mock).mockRejectedValue(new Error('API Error'));
await expect(gmailService.manageLabel({
action: 'delete',
email: testEmail,
labelId: 'label1'
})).rejects.toThrow('Failed to delete label');
});
});
describe('manageLabelAssignment', () => {
it('should add labels to a message', async () => {
// Simple mock response
(mockClient.users.messages.modify as jest.Mock).mockResolvedValue({});
// Execute and verify
await gmailService.manageLabelAssignment({
action: 'add',
email: testEmail,
messageId: 'msg1',
labelIds: ['label1']
});
// Simple assertions
expect(mockClient.users.messages.modify).toHaveBeenCalledWith({
userId: testEmail,
id: 'msg1',
requestBody: {
addLabelIds: ['label1'],
removeLabelIds: []
}
});
});
it('should remove labels from a message', async () => {
// Simple mock response
(mockClient.users.messages.modify as jest.Mock).mockResolvedValue({});
// Execute and verify
await gmailService.manageLabelAssignment({
action: 'remove',
email: testEmail,
messageId: 'msg1',
labelIds: ['label2']
});
// Simple assertions
expect(mockClient.users.messages.modify).toHaveBeenCalledWith({
userId: testEmail,
id: 'msg1',
requestBody: {
addLabelIds: [],
removeLabelIds: ['label2']
}
});
});
it('should handle errors when modifying message labels', async () => {
// Simple error mock
(mockClient.users.messages.modify as jest.Mock).mockRejectedValue(new Error('API Error'));
await expect(gmailService.manageLabelAssignment({
action: 'add',
email: testEmail,
messageId: 'msg1',
labelIds: ['label1']
})).rejects.toThrow('Failed to modify message labels');
});
});
});
```
--------------------------------------------------------------------------------
/src/modules/accounts/token.ts:
--------------------------------------------------------------------------------
```typescript
import fs from 'fs/promises';
import path from 'path';
import { AccountError, TokenStatus, TokenRenewalResult } from './types.js';
import { GoogleOAuthClient } from './oauth.js';
import logger from '../../utils/logger.js';
/**
* Manages OAuth token operations.
* Focuses on basic token storage, retrieval, and refresh.
* Auth issues are handled via 401 responses rather than pre-validation.
*/
export class TokenManager {
private readonly credentialsPath: string;
private oauthClient?: GoogleOAuthClient;
private readonly TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes buffer
constructor(oauthClient?: GoogleOAuthClient) {
// Use environment variable or config, fallback to Docker default
const defaultPath = process.env.CREDENTIALS_PATH ||
(process.env.MCP_MODE ? path.resolve(process.env.HOME || '', '.mcp/google-workspace-mcp/credentials') : '/app/config/credentials');
this.credentialsPath = defaultPath;
this.oauthClient = oauthClient;
}
setOAuthClient(client: GoogleOAuthClient) {
this.oauthClient = client;
}
private getTokenPath(email: string): string {
const sanitizedEmail = email.replace(/[^a-zA-Z0-9]/g, '-');
return path.join(this.credentialsPath, `${sanitizedEmail}.token.json`);
}
async saveToken(email: string, tokenData: any): Promise<void> {
logger.info(`Saving token for account: ${email}`);
try {
// Ensure base credentials directory exists
await fs.mkdir(this.credentialsPath, { recursive: true });
const tokenPath = this.getTokenPath(email);
await fs.writeFile(tokenPath, JSON.stringify(tokenData, null, 2));
logger.debug(`Token saved successfully at: ${tokenPath}`);
} catch (error) {
throw new AccountError(
'Failed to save token',
'TOKEN_SAVE_ERROR',
'Please ensure the credentials directory is writable'
);
}
}
async loadToken(email: string): Promise<any> {
logger.debug(`Loading token for account: ${email}`);
try {
const tokenPath = this.getTokenPath(email);
const data = await fs.readFile(tokenPath, 'utf-8');
return JSON.parse(data);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// File doesn't exist - return null to trigger OAuth flow
return null;
}
throw new AccountError(
'Failed to load token',
'TOKEN_LOAD_ERROR',
'Please ensure the token file exists and is readable'
);
}
}
async deleteToken(email: string): Promise<void> {
logger.info(`Deleting token for account: ${email}`);
try {
const tokenPath = this.getTokenPath(email);
await fs.unlink(tokenPath);
logger.debug('Token file deleted successfully');
} catch (error) {
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
throw new AccountError(
'Failed to delete token',
'TOKEN_DELETE_ERROR',
'Please ensure you have permission to delete the token file'
);
}
}
}
/**
* Basic token validation - just checks if token exists and isn't expired.
* No scope validation - auth issues handled via 401 responses.
*/
/**
* Attempts to automatically renew a token if it's expired or near expiry
* Returns the renewal result and new token if successful
*/
async autoRenewToken(email: string): Promise<TokenRenewalResult> {
logger.debug(`Attempting auto-renewal for account: ${email}`);
try {
const token = await this.loadToken(email);
if (!token) {
return {
success: false,
status: 'NO_TOKEN',
reason: 'No token found'
};
}
if (!token.expiry_date) {
return {
success: false,
status: 'INVALID',
reason: 'Invalid token format'
};
}
// Check if token is expired or will expire soon
const now = Date.now();
if (token.expiry_date <= now + this.TOKEN_EXPIRY_BUFFER_MS) {
if (!token.refresh_token || !this.oauthClient) {
return {
success: false,
status: 'REFRESH_FAILED',
reason: 'No refresh token or OAuth client available'
};
}
try {
// Attempt to refresh the token
const newToken = await this.oauthClient.refreshToken(token.refresh_token);
await this.saveToken(email, newToken);
logger.info('Token refreshed successfully');
return {
success: true,
status: 'REFRESHED',
token: newToken
};
} catch (error) {
// Check if the error indicates an invalid/revoked refresh token
const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
const isRefreshTokenInvalid =
errorMessage.includes('invalid_grant') ||
errorMessage.includes('token has been revoked') ||
errorMessage.includes('token not found');
if (!isRefreshTokenInvalid) {
// If it's not a refresh token issue, try one more time
try {
logger.warn('First refresh attempt failed, trying once more');
const newToken = await this.oauthClient.refreshToken(token.refresh_token);
await this.saveToken(email, newToken);
logger.info('Token refreshed successfully on second attempt');
return {
success: true,
status: 'REFRESHED',
token: newToken
};
} catch (secondError) {
logger.error('Both refresh attempts failed, but refresh token may still be valid');
return {
success: false,
status: 'REFRESH_FAILED',
reason: 'Token refresh failed, temporary error',
canRetry: true
};
}
}
// Refresh token is invalid, need full reauth
logger.error('Refresh token is invalid or revoked');
return {
success: false,
status: 'REFRESH_FAILED',
reason: 'Refresh token is invalid or revoked',
canRetry: false
};
}
}
// Token is still valid
return {
success: true,
status: 'VALID',
token
};
} catch (error) {
logger.error('Token auto-renewal error', error as Error);
return {
success: false,
status: 'ERROR',
reason: 'Token auto-renewal failed'
};
}
}
async validateToken(email: string, skipValidationForNew: boolean = false): Promise<TokenStatus> {
logger.debug(`Validating token for account: ${email}`);
try {
const token = await this.loadToken(email);
if (!token) {
logger.debug('No token found');
return {
valid: false,
status: 'NO_TOKEN',
reason: 'No token found'
};
}
// Skip validation if this is a new account setup
if (skipValidationForNew) {
logger.debug('Skipping validation for new account setup');
return {
valid: true,
status: 'VALID',
token
};
}
if (!token.expiry_date) {
logger.debug('Token missing expiry date');
return {
valid: false,
status: 'INVALID',
reason: 'Invalid token format'
};
}
if (token.expiry_date < Date.now()) {
logger.debug('Token has expired, attempting refresh');
if (token.refresh_token && this.oauthClient) {
try {
const newToken = await this.oauthClient.refreshToken(token.refresh_token);
await this.saveToken(email, newToken);
logger.info('Token refreshed successfully');
return {
valid: true,
status: 'REFRESHED',
token: newToken,
requiredScopes: newToken.scope ? newToken.scope.split(' ') : undefined
};
} catch (error) {
logger.error('Token refresh failed', error as Error);
return {
valid: false,
status: 'REFRESH_FAILED',
reason: 'Token refresh failed'
};
}
}
logger.debug('No refresh token available');
return {
valid: false,
status: 'EXPIRED',
reason: 'Token expired and no refresh token available'
};
}
logger.debug('Token is valid');
return {
valid: true,
status: 'VALID',
token,
requiredScopes: token.scope ? token.scope.split(' ') : undefined
};
} catch (error) {
logger.error('Token validation error', error as Error);
return {
valid: false,
status: 'ERROR',
reason: 'Token validation failed'
};
}
}
}
```
--------------------------------------------------------------------------------
/src/__tests__/modules/accounts/manager.test.ts:
--------------------------------------------------------------------------------
```typescript
import { AccountManager } from '../../../modules/accounts/manager.js';
import { mockAccounts, mockTokens } from '../../../__fixtures__/accounts.js';
// Simple mocks for token and oauth
jest.mock('../../../modules/accounts/token.js', () => ({
TokenManager: jest.fn().mockImplementation(() => ({
validateToken: jest.fn().mockResolvedValue({
valid: true,
status: 'VALID',
token: { access_token: 'test-token' }
}),
saveToken: jest.fn().mockResolvedValue(undefined),
deleteToken: jest.fn().mockResolvedValue(undefined),
autoRenewToken: jest.fn().mockResolvedValue({
success: true,
status: 'VALID',
token: { access_token: 'test-token' }
})
}))
}));
jest.mock('../../../modules/accounts/oauth.js', () => ({
GoogleOAuthClient: jest.fn().mockImplementation(() => ({
ensureInitialized: jest.fn().mockResolvedValue(undefined),
getTokenFromCode: jest.fn().mockResolvedValue(mockTokens.valid),
refreshToken: jest.fn().mockResolvedValue(mockTokens.valid),
generateAuthUrl: jest.fn().mockReturnValue('https://mock-auth-url'),
getAuthClient: jest.fn().mockReturnValue({
setCredentials: jest.fn(),
getAccessToken: jest.fn().mockResolvedValue({ token: 'test-token' })
})
}))
}));
// Simple file system mock
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn().mockResolvedValue(undefined),
mkdir: jest.fn().mockResolvedValue(undefined)
}));
jest.mock('path', () => ({
resolve: jest.fn().mockReturnValue('/mock/accounts.json'),
dirname: jest.fn().mockReturnValue('/mock')
}));
describe('AccountManager', () => {
let accountManager: AccountManager;
const fs = require('fs/promises');
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
// Clear environment variables that affect path resolution
delete process.env.MCP_MODE;
delete process.env.HOME;
process.env.ACCOUNTS_PATH = '/mock/accounts.json';
// Default successful file read
fs.readFile.mockResolvedValue(JSON.stringify(mockAccounts));
// Reset TokenManager mock to default implementation
const TokenManager = require('../../../modules/accounts/token.js').TokenManager;
TokenManager.mockImplementation(() => ({
validateToken: jest.fn().mockResolvedValue({ valid: true }),
saveToken: jest.fn().mockResolvedValue(undefined),
deleteToken: jest.fn().mockResolvedValue(undefined),
autoRenewToken: jest.fn().mockResolvedValue({
success: true,
status: 'VALID',
token: { access_token: 'test-token' }
})
}));
accountManager = new AccountManager();
});
// Basic account operations
describe('account operations', () => {
it('should load accounts from file', async () => {
await accountManager.initialize();
const accounts = await accountManager.listAccounts();
expect(accounts).toHaveLength(mockAccounts.accounts.length);
expect(accounts[0].email).toBe(mockAccounts.accounts[0].email);
});
it('should add new account', async () => {
await accountManager.initialize();
const newAccount = await accountManager.addAccount(
'[email protected]',
'work',
'New Test Account'
);
expect(newAccount.email).toBe('[email protected]');
expect(fs.writeFile).toHaveBeenCalled();
});
it('should not add duplicate account', async () => {
await accountManager.initialize();
await expect(accountManager.addAccount(
mockAccounts.accounts[0].email,
'work',
'Duplicate'
)).rejects.toThrow('Account already exists');
});
});
// File system operations
describe('file operations', () => {
it('should handle missing accounts file', async () => {
// Reset all mocks and modules
jest.clearAllMocks();
jest.resetModules();
// Re-require fs after reset
const fs = require('fs/promises');
// Setup error for first read attempt
const error = new Error('File not found');
(error as any).code = 'ENOENT';
fs.readFile.mockRejectedValueOnce(error);
// Mock path module
jest.doMock('path', () => ({
resolve: jest.fn().mockReturnValue('/mock/accounts.json'),
dirname: jest.fn().mockReturnValue('/mock')
}));
// Re-require AccountManager to use fresh mocks
const { AccountManager } = require('../../../modules/accounts/manager.js');
accountManager = new AccountManager();
// Initialize with empty file system
await accountManager.initialize();
// Mock empty response for listAccounts call
fs.readFile.mockResolvedValueOnce('{"accounts":[]}');
const accounts = await accountManager.listAccounts();
// Verify results
expect(accounts).toHaveLength(0);
// Verify write was called with correct data
expect(fs.writeFile).toHaveBeenCalledTimes(1);
const [path, content] = fs.writeFile.mock.calls[0];
expect(path).toBe('/mock/accounts.json');
// Parse and re-stringify to normalize formatting
const parsedContent = JSON.parse(content);
expect(parsedContent).toEqual({ accounts: [] });
});
it('should handle invalid JSON', async () => {
fs.readFile.mockResolvedValueOnce('invalid json');
await expect(accountManager.initialize())
.rejects
.toThrow('Failed to parse accounts configuration');
});
});
// Token validation (simplified)
describe('token validation', () => {
const testEmail = mockAccounts.accounts[0].email;
it('should validate token successfully', async () => {
await accountManager.initialize();
const account = await accountManager.validateAccount(testEmail);
expect(account.email).toBe(testEmail);
expect(account.auth_status).toEqual({
valid: true,
status: 'VALID'
});
});
it('should handle token validation failure', async () => {
// Reset all mocks and modules
jest.clearAllMocks();
jest.resetModules();
// Re-require fs and setup fresh state
const fs = require('fs/promises');
fs.readFile.mockResolvedValue(JSON.stringify(mockAccounts));
// Create simple mock implementation
const mockValidateToken = jest.fn().mockResolvedValue({
valid: false,
status: 'EXPIRED',
reason: 'Token expired'
});
// Setup TokenManager with tracked mock
jest.doMock('../../../modules/accounts/token.js', () => ({
TokenManager: jest.fn().mockImplementation(() => ({
validateToken: mockValidateToken,
saveToken: jest.fn(),
deleteToken: jest.fn()
}))
}));
// Re-require AccountManager to use new mocks
const { AccountManager } = require('../../../modules/accounts/manager.js');
accountManager = new AccountManager();
await accountManager.initialize();
const account = await accountManager.validateAccount(testEmail);
expect(mockValidateToken).toHaveBeenCalledWith(testEmail, false);
expect(account.auth_status).toMatchObject({
valid: false,
status: 'EXPIRED',
reason: 'Token expired'
});
expect(account.auth_status).toHaveProperty('authUrl');
});
});
// OAuth operations (simplified)
describe('oauth operations', () => {
it('should handle token from auth code', async () => {
await accountManager.initialize();
const token = await accountManager.getTokenFromCode('test-code');
expect(token).toEqual(mockTokens.valid);
});
it('should save token for account', async () => {
// Reset all mocks and modules
jest.clearAllMocks();
jest.resetModules();
// Re-require fs and setup fresh state
const fs = require('fs/promises');
fs.readFile.mockResolvedValue(JSON.stringify(mockAccounts));
// Create mock implementation
const mockSaveToken = jest.fn().mockResolvedValue(undefined);
// Setup TokenManager with tracked mock
jest.doMock('../../../modules/accounts/token.js', () => ({
TokenManager: jest.fn().mockImplementation(() => ({
validateToken: jest.fn().mockResolvedValue({ valid: true }),
saveToken: mockSaveToken,
deleteToken: jest.fn()
}))
}));
// Re-require AccountManager to use new mocks
const { AccountManager } = require('../../../modules/accounts/manager.js');
accountManager = new AccountManager();
await accountManager.initialize();
const testEmail = '[email protected]';
await accountManager.saveToken(testEmail, mockTokens.valid);
expect(mockSaveToken).toHaveBeenCalledWith(testEmail, mockTokens.valid);
expect(mockSaveToken).toHaveBeenCalledTimes(1);
});
});
});
```
--------------------------------------------------------------------------------
/llms-install.md:
--------------------------------------------------------------------------------
```markdown
# Google Workspace MCP Server - AI Assistant Installation Guide
This guide provides step-by-step instructions for setting up the Google Workspace MCP server with AI assistants like Claude Desktop and Cline.
## Overview
The Google Workspace MCP server enables AI assistants to interact with your Google Workspace services (Gmail, Calendar, Drive, Contacts) through a secure OAuth 2.0 authentication flow. The server runs in a Docker container and handles all API interactions.
## Prerequisites
### System Requirements
- Docker installed and running
- Internet connection for Google API access
- Available port 8080 for OAuth callback handling
### Google Cloud Setup
1. **Create Google Cloud Project**:
- Visit [Google Cloud Console](https://console.cloud.google.com)
- Create a new project or select an existing one
- Note your project ID for reference
2. **Enable Required APIs**:
Navigate to "APIs & Services" > "Library" and enable:
- Gmail API
- Google Calendar API
- Google Drive API
- People API (for Contacts)
3. **Configure OAuth Consent Screen**:
- Go to "APIs & Services" > "OAuth consent screen"
- Choose "External" user type
- Fill in required application information:
- App name: "Google Workspace MCP Server" (or your preference)
- User support email: Your email address
- Developer contact information: Your email address
- Add yourself as a test user in the "Test users" section
4. **Create OAuth 2.0 Credentials**:
- Go to "APIs & Services" > "Credentials"
- Click "Create Credentials" > "OAuth 2.0 Client IDs"
- **Important**: Select "Web application" (not Desktop application)
- Set application name: "Google Workspace MCP Server"
- Add authorized redirect URI: `http://localhost:8080`
- Save and note your Client ID and Client Secret
## Installation Steps
### Step 1: Create Configuration Directory
Create a local directory for storing authentication tokens:
```bash
mkdir -p ~/.mcp/google-workspace-mcp
```
### Step 2: Configure Your MCP Client
Choose the appropriate configuration for your AI assistant:
#### For Claude Desktop
Edit your Claude Desktop configuration file:
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux**: `~/.config/Claude/claude_desktop_config.json`
Add the following configuration:
```json
{
"mcpServers": {
"google-workspace-mcp": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-p", "8080:8080",
"-v", "~/.mcp/google-workspace-mcp:/app/config",
"-v", "~/Documents/workspace-mcp-files:/app/workspace",
"-e", "GOOGLE_CLIENT_ID",
"-e", "GOOGLE_CLIENT_SECRET",
"-e", "LOG_MODE=strict",
"ghcr.io/aaronsb/google-workspace-mcp:latest"
],
"env": {
"GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
"GOOGLE_CLIENT_SECRET": "your-client-secret"
}
}
}
}
```
#### For Cline (VS Code Extension)
Edit your Cline MCP settings file:
`~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`
```json
{
"mcpServers": {
"google-workspace-mcp": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-p", "8080:8080",
"-v", "~/.mcp/google-workspace-mcp:/app/config",
"-v", "~/Documents/workspace-mcp-files:/app/workspace",
"-e", "GOOGLE_CLIENT_ID",
"-e", "GOOGLE_CLIENT_SECRET",
"-e", "LOG_MODE=strict",
"ghcr.io/aaronsb/google-workspace-mcp:latest"
],
"env": {
"GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
"GOOGLE_CLIENT_SECRET": "your-client-secret"
}
}
}
}
```
**Important Configuration Notes**:
- Replace `your-client-id.apps.googleusercontent.com` with your actual Google OAuth Client ID
- Replace `your-client-secret` with your actual Google OAuth Client Secret
- The `-p 8080:8080` port mapping is required for OAuth callback handling
- Adjust volume paths if you prefer different local directories
### Step 3: Restart Your AI Assistant
After updating the configuration:
- **Claude Desktop**: Completely quit and restart the application
- **Cline**: Restart VS Code or reload the Cline extension
## Authentication Process
### Initial Account Setup
1. **Start Authentication**:
Ask your AI assistant: "Add my Google account" or "Set up Google Workspace access"
2. **OAuth Flow**:
- The assistant will provide a Google authorization URL
- Click the URL to open it in your browser
- Sign in to your Google account
- Review and accept the requested permissions
- You'll be redirected to a success page showing your authorization code
3. **Complete Authentication**:
- Copy the authorization code from the success page
- Provide the code back to your AI assistant
- The assistant will complete the authentication and save your tokens
### Managing Multiple Accounts
You can authenticate multiple Google accounts:
- Each account is stored separately with its own tokens
- Use account categorization (e.g., "work", "personal") for organization
- Switch between accounts as needed for different operations
## Verification
### Test Your Setup
After authentication, verify the setup works:
1. **List Accounts**: Ask "List my Google accounts" to see authenticated accounts
2. **Test Gmail**: Ask "Show me my recent emails"
3. **Test Calendar**: Ask "What's on my calendar today?"
4. **Test Drive**: Ask "List files in my Google Drive"
### Common Usage Examples
- "Search for emails from [email protected] in the last week"
- "Create a calendar event for tomorrow at 2 PM"
- "Upload this document to my Google Drive"
- "Show me my contacts with 'Smith' in the name"
## Troubleshooting
### Authentication Issues
**Problem**: "Invalid OAuth credentials" error
**Solution**:
- Verify Client ID and Client Secret are correctly copied
- Ensure OAuth consent screen is properly configured
- Check that you're added as a test user
**Problem**: "Connection refused" on localhost:8080
**Solution**:
- Verify port 8080 is not blocked by firewall
- Ensure Docker has permission to bind to port 8080
- Check that no other service is using port 8080
### Configuration Issues
**Problem**: MCP server not starting
**Solution**:
- Verify Docker is running
macOS:
- Shut down Docker fully from command line with `pkill -SIGHUP -f /Applications/Docker.app 'docker serve'`
- Restart Docker Desktop
- Restart your MCP client (Claude Desktop or Cursor/Cline/etc.)
Windows:
- Open Task Manager (Ctrl+Shift+Esc)
- Find and end the "Docker Desktop" process
- Restart Docker Desktop from the Start menu
- Restart your MCP client (Claude Desktop or Cursor/Cline/etc.)
- Check that configuration directory exists and has proper permissions
- Ensure Docker image can be pulled from registry
**Problem**: "Directory not found" errors
**Solution**:
- Create the config directory: `mkdir -p ~/.mcp/google-workspace-mcp`
- Verify volume mount paths in configuration are correct
- Check file permissions on mounted directories
### API Issues
**Problem**: "API not enabled" errors
**Solution**:
- Verify all required APIs are enabled in Google Cloud Console
- Wait a few minutes after enabling APIs for changes to propagate
- Check API quotas and limits in Google Cloud Console
## Security Best Practices
1. **Credential Management**:
- Store OAuth credentials securely in MCP configuration
- Never commit credentials to version control
- Regularly rotate OAuth client secrets
2. **Access Control**:
- Use minimal required API scopes
- Regularly review and audit account access
- Remove unused accounts from authentication
3. **Network Security**:
- OAuth callback server only runs during authentication
- All API communication uses HTTPS
- Tokens are stored locally and encrypted
## Advanced Configuration
### Custom File Workspace
To use a different directory for file operations:
```json
"args": [
"run",
"--rm",
"-i",
"-p", "8080:8080",
"-v", "~/.mcp/google-workspace-mcp:/app/config",
"-v", "/path/to/your/workspace:/app/workspace",
"-e", "WORKSPACE_BASE_PATH=/app/workspace",
"ghcr.io/aaronsb/google-workspace-mcp:latest"
]
```
### Logging Configuration
For debugging, you can adjust logging levels:
```json
"env": {
"GOOGLE_CLIENT_ID": "your-client-id",
"GOOGLE_CLIENT_SECRET": "your-client-secret",
"LOG_MODE": "normal",
"LOG_LEVEL": "debug"
}
```
## Getting Help
If you encounter issues:
1. Check the [main documentation](README.md) for additional troubleshooting
2. Review [error documentation](docs/ERRORS.md) for specific error codes
3. Examine Docker logs: `docker logs <container-id>`
4. Submit issues on the project's GitHub repository
## Next Steps
Once setup is complete:
- Explore the [API documentation](docs/API.md) for detailed tool usage
- Review [examples](docs/EXAMPLES.md) for common use cases
- Consider setting up multiple accounts for different workflows
```
--------------------------------------------------------------------------------
/src/modules/accounts/callback-server.ts:
--------------------------------------------------------------------------------
```typescript
import http from 'http';
import url from 'url';
import logger from '../../utils/logger.js';
export class OAuthCallbackServer {
private static instance?: OAuthCallbackServer;
private server?: http.Server;
private port: number = 8080;
private isRunning: boolean = false;
private pendingPromises: Map<string, { resolve: (code: string) => void; reject: (error: Error) => void }> = new Map();
private authHandler?: (code: string, state: string) => Promise<void>;
private constructor() {}
static getInstance(): OAuthCallbackServer {
if (!OAuthCallbackServer.instance) {
OAuthCallbackServer.instance = new OAuthCallbackServer();
}
return OAuthCallbackServer.instance;
}
async ensureServerRunning(): Promise<void> {
if (this.isRunning) {
return;
}
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url || '', true);
// Handle the auto-complete endpoint
if (parsedUrl.pathname === '/complete-auth' && req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const { code, state } = JSON.parse(body);
// Automatically complete the authentication
if (this.authHandler) {
await this.authHandler(code, state || 'default');
logger.info('OAuth authentication completed automatically');
}
// Also resolve any pending promises (for backward compatibility)
const pending = this.pendingPromises.get(state || 'default');
if (pending) {
pending.resolve(code);
this.pendingPromises.delete(state || 'default');
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (error) {
logger.error('Failed to process auto-complete request:', error);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
}
});
return;
}
if (parsedUrl.pathname === '/') {
const code = parsedUrl.query.code as string;
const error = parsedUrl.query.error as string;
const state = parsedUrl.query.state as string || 'default';
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<h1>Authorization Failed</h1>
<p>Error: ${error}</p>
<p>You can close this window.</p>
</body>
</html>
`);
// Reject any pending promises
const pending = this.pendingPromises.get(state);
if (pending) {
pending.reject(new Error(`OAuth error: ${error}`));
this.pendingPromises.delete(state);
}
return;
}
if (code) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<head>
<title>Google OAuth Authorization Successful</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
.success-message {
background: #4CAF50;
color: white;
padding: 20px;
border-radius: 5px;
margin-bottom: 20px;
text-align: center;
}
.status {
background: #e7f3ff;
padding: 15px;
border-left: 4px solid #2196F3;
margin: 20px 0;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.code-fallback {
font-family: monospace;
background: #f5f5f5;
padding: 10px;
margin: 10px 0;
word-break: break-all;
display: none;
}
</style>
</head>
<body>
<div class="success-message">
<h1>✅ Authorization Successful!</h1>
</div>
<div class="status" id="status">
<h3>Completing authentication automatically...</h3>
<p>Please wait while we complete the authentication process <span class="loading"></span></p>
</div>
<div class="code-fallback" id="codeFallback">
<p>If automatic authentication fails, you can manually copy this code:</p>
<code>${code}</code>
</div>
<script>
// Automatically submit the authorization code to complete the flow
async function completeAuth() {
try {
// Send the code to a special endpoint that will trigger the promise resolution
const response = await fetch('/complete-auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: '${code}',
state: '${state}'
})
});
if (response.ok) {
document.getElementById('status').innerHTML =
'<h3>✅ Authentication Complete!</h3>' +
'<p>You can now close this window and return to Claude Desktop.</p>';
} else {
throw new Error('Failed to complete authentication');
}
} catch (error) {
console.error('Auto-complete failed:', error);
document.getElementById('status').innerHTML =
'<h3>⚠️ Automatic completion failed</h3>' +
'<p>Please copy the code below and paste it back to Claude Desktop:</p>';
document.getElementById('codeFallback').style.display = 'block';
}
}
// Start the auto-completion process
setTimeout(completeAuth, 500);
</script>
</body>
</html>
`);
// Immediately trigger the authentication completion
// by posting to our own complete-auth endpoint
// Don't resolve here anymore - let the auto-complete endpoint handle it
return;
}
}
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Not Found</h1></body></html>');
});
this.server.listen(this.port, () => {
this.isRunning = true;
logger.info(`OAuth callback server listening on http://localhost:${this.port}`);
resolve();
});
this.server.on('error', (err) => {
this.isRunning = false;
reject(err);
});
});
}
async waitForAuthorizationCode(sessionId: string = 'default'): Promise<string> {
await this.ensureServerRunning();
return new Promise((resolve, reject) => {
// Store the promise resolvers for this session
this.pendingPromises.set(sessionId, { resolve, reject });
// Set a timeout to avoid hanging forever
setTimeout(() => {
if (this.pendingPromises.has(sessionId)) {
this.pendingPromises.delete(sessionId);
reject(new Error('OAuth timeout - no authorization received within 5 minutes'));
}
}, 5 * 60 * 1000); // 5 minutes timeout
});
}
getCallbackUrl(): string {
return `http://localhost:${this.port}`;
}
isServerRunning(): boolean {
return this.isRunning;
}
setAuthHandler(handler: (code: string, state: string) => Promise<void>) {
this.authHandler = handler;
}
}
```
--------------------------------------------------------------------------------
/src/modules/drive/service.ts:
--------------------------------------------------------------------------------
```typescript
import { google } from 'googleapis';
import { BaseGoogleService } from '../../services/base/BaseGoogleService.js';
import { DriveOperationResult, FileDownloadOptions, FileListOptions, FileSearchOptions, FileUploadOptions, PermissionOptions } from './types.js';
import { Readable } from 'stream';
import { DRIVE_SCOPES } from './scopes.js';
import { workspaceManager } from '../../utils/workspace.js';
import fs from 'fs/promises';
import { GaxiosResponse } from 'gaxios';
export class DriveService extends BaseGoogleService<ReturnType<typeof google.drive>> {
private initialized = false;
constructor() {
super({
serviceName: 'Google Drive',
version: 'v3'
});
}
/**
* Initialize the Drive service and all dependencies
*/
public async initialize(): Promise<void> {
try {
await super.initialize();
this.initialized = true;
} catch (error) {
throw this.handleError(error, 'Failed to initialize Drive service');
}
}
/**
* Ensure the Drive service is initialized
*/
public async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initialize();
}
}
/**
* Check if the service is initialized
*/
private checkInitialized(): void {
if (!this.initialized) {
throw this.handleError(
new Error('Drive service not initialized'),
'Please ensure the service is initialized before use'
);
}
}
async listFiles(email: string, options: FileListOptions = {}): Promise<DriveOperationResult> {
try {
await this.ensureInitialized();
this.checkInitialized();
await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
const client = await this.getAuthenticatedClient(
email,
(auth) => google.drive({ version: 'v3', auth })
);
const query = [];
if (options.folderId) {
query.push(`'${options.folderId}' in parents`);
}
if (options.query) {
query.push(options.query);
}
const response = await client.files.list({
q: query.join(' and ') || undefined,
pageSize: options.pageSize,
orderBy: options.orderBy?.join(','),
fields: options.fields?.join(',') || 'files(id, name, mimeType, modifiedTime, size)',
});
return {
success: true,
data: response.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
async uploadFile(email: string, options: FileUploadOptions): Promise<DriveOperationResult> {
try {
await this.ensureInitialized();
await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
const client = await this.getAuthenticatedClient(
email,
(auth) => google.drive({ version: 'v3', auth })
);
// Save content to workspace first
const uploadPath = await workspaceManager.getUploadPath(email, options.name);
await fs.writeFile(uploadPath, options.content);
const fileContent = await fs.readFile(uploadPath);
const media = {
mimeType: options.mimeType || 'application/octet-stream',
body: Readable.from([fileContent]),
};
const response = await client.files.create({
requestBody: {
name: options.name,
mimeType: options.mimeType,
parents: options.parents,
},
media,
fields: 'id, name, mimeType, webViewLink',
});
return {
success: true,
data: response.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
async downloadFile(email: string, options: FileDownloadOptions): Promise<DriveOperationResult> {
try {
await this.ensureInitialized();
await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
const client = await this.getAuthenticatedClient(
email,
(auth) => google.drive({ version: 'v3', auth })
);
// First get file metadata to check mime type and name
const file = await client.files.get({
fileId: options.fileId,
fields: 'name,mimeType',
});
const fileName = file.data.name || options.fileId;
// Handle Google Workspace files differently
if (file.data.mimeType?.startsWith('application/vnd.google-apps')) {
let exportMimeType = options.mimeType || 'text/plain';
// Default export formats if not specified
if (!options.mimeType) {
switch (file.data.mimeType) {
case 'application/vnd.google-apps.document':
exportMimeType = 'text/markdown';
break;
case 'application/vnd.google-apps.spreadsheet':
exportMimeType = 'text/csv';
break;
case 'application/vnd.google-apps.presentation':
exportMimeType = 'text/plain';
break;
case 'application/vnd.google-apps.drawing':
exportMimeType = 'image/png';
break;
}
}
const response = await client.files.export({
fileId: options.fileId,
mimeType: exportMimeType
}, {
responseType: 'arraybuffer'
}) as unknown as GaxiosResponse<Uint8Array>;
const downloadPath = await workspaceManager.getDownloadPath(email, fileName);
await fs.writeFile(downloadPath, Buffer.from(response.data));
return {
success: true,
data: response.data,
mimeType: exportMimeType,
filePath: downloadPath
};
}
// For regular files
const response = await client.files.get({
fileId: options.fileId,
alt: 'media'
}, {
responseType: 'arraybuffer'
}) as unknown as GaxiosResponse<Uint8Array>;
const downloadPath = await workspaceManager.getDownloadPath(email, fileName);
await fs.writeFile(downloadPath, Buffer.from(response.data));
return {
success: true,
data: response.data,
filePath: downloadPath
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
async createFolder(email: string, name: string, parentId?: string): Promise<DriveOperationResult> {
try {
await this.ensureInitialized();
await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
const client = await this.getAuthenticatedClient(
email,
(auth) => google.drive({ version: 'v3', auth })
);
const response = await client.files.create({
requestBody: {
name,
mimeType: 'application/vnd.google-apps.folder',
parents: parentId ? [parentId] : undefined,
},
fields: 'id, name, mimeType, webViewLink',
});
return {
success: true,
data: response.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
async searchFiles(email: string, options: FileSearchOptions): Promise<DriveOperationResult> {
try {
await this.ensureInitialized();
await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
const client = await this.getAuthenticatedClient(
email,
(auth) => google.drive({ version: 'v3', auth })
);
const query = [];
if (options.fullText) {
const escapedQuery = options.fullText.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
query.push(`fullText contains '${escapedQuery}'`);
}
if (options.mimeType) {
query.push(`mimeType = '${options.mimeType}'`);
}
if (options.folderId) {
query.push(`'${options.folderId}' in parents`);
}
if (options.trashed !== undefined) {
query.push(`trashed = ${options.trashed}`);
}
if (options.query) {
query.push(options.query);
}
const response = await client.files.list({
q: query.join(' and ') || undefined,
pageSize: options.pageSize,
orderBy: options.orderBy?.join(','),
fields: options.fields?.join(',') || 'files(id, name, mimeType, modifiedTime, size)',
});
return {
success: true,
data: response.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
async updatePermissions(email: string, options: PermissionOptions): Promise<DriveOperationResult> {
try {
await this.ensureInitialized();
await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
const client = await this.getAuthenticatedClient(
email,
(auth) => google.drive({ version: 'v3', auth })
);
const response = await client.permissions.create({
fileId: options.fileId,
requestBody: {
role: options.role,
type: options.type,
emailAddress: options.emailAddress,
domain: options.domain,
allowFileDiscovery: options.allowFileDiscovery,
},
});
return {
success: true,
data: response.data,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
async deleteFile(email: string, fileId: string): Promise<DriveOperationResult> {
try {
await this.ensureInitialized();
await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
const client = await this.getAuthenticatedClient(
email,
(auth) => google.drive({ version: 'v3', auth })
);
await client.files.delete({
fileId,
});
return {
success: true,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
};
}
}
}
```
--------------------------------------------------------------------------------
/src/modules/gmail/services/draft.ts:
--------------------------------------------------------------------------------
```typescript
import { google } from 'googleapis';
import {
GmailError,
OutgoingGmailAttachment,
IncomingGmailAttachment
} from '../types.js';
import { GmailAttachmentService } from './attachment.js';
export type DraftAction = 'create' | 'read' | 'update' | 'delete' | 'send';
export interface ManageDraftParams {
email: string;
action: DraftAction;
draftId?: string;
data?: DraftData;
}
export interface DraftData {
to: string[];
subject: string;
body: string;
cc?: string[];
bcc?: string[];
threadId?: string; // For reply drafts
attachments?: OutgoingGmailAttachment[];
}
export class DraftService {
private gmailClient?: ReturnType<typeof google.gmail>;
constructor(private attachmentService: GmailAttachmentService) {}
async initialize(): Promise<void> {
// Initialization will be handled by Gmail service
}
updateClient(client: ReturnType<typeof google.gmail>) {
this.gmailClient = client;
}
private ensureClient(): ReturnType<typeof google.gmail> {
if (!this.gmailClient) {
throw new GmailError(
'Gmail client not initialized',
'CLIENT_ERROR',
'Please ensure the service is initialized'
);
}
return this.gmailClient;
}
async createDraft(email: string, data: DraftData) {
try {
const client = this.ensureClient();
// Validate and prepare attachments
const processedAttachments = data.attachments?.map(attachment => {
this.attachmentService.validateAttachment(attachment);
return this.attachmentService.prepareAttachment(attachment);
}) || [];
// Construct email with attachments
const boundary = `boundary_${Date.now()}`;
const messageParts = [
'MIME-Version: 1.0\n',
`Content-Type: multipart/mixed; boundary="${boundary}"\n`,
`To: ${data.to.join(', ')}\n`,
data.cc?.length ? `Cc: ${data.cc.join(', ')}\n` : '',
data.bcc?.length ? `Bcc: ${data.bcc.join(', ')}\n` : '',
`Subject: ${data.subject}\n\n`,
`--${boundary}\n`,
'Content-Type: text/plain; charset="UTF-8"\n',
'Content-Transfer-Encoding: 7bit\n\n',
data.body,
'\n'
];
// Add attachments
for (const attachment of processedAttachments) {
messageParts.push(
`--${boundary}\n`,
`Content-Type: ${attachment.mimeType}\n`,
'Content-Transfer-Encoding: base64\n',
`Content-Disposition: attachment; filename="${attachment.filename}"\n\n`,
attachment.content.toString(),
'\n'
);
}
messageParts.push(`--${boundary}--`);
const fullMessage = messageParts.join('');
// Create draft with threadId if it's a reply
const { data: draft } = await client.users.drafts.create({
userId: 'me',
requestBody: {
message: {
raw: Buffer.from(fullMessage).toString('base64'),
threadId: data.threadId // Include threadId for replies
}
}
});
return {
id: draft.id || '',
message: {
id: draft.message?.id || '',
threadId: draft.message?.threadId || '',
labelIds: draft.message?.labelIds || []
},
updated: new Date().toISOString(),
attachments: data.attachments
};
} catch (error) {
throw new GmailError(
'Failed to create draft',
'CREATE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
async listDrafts(email: string) {
try {
const client = this.ensureClient();
const { data } = await client.users.drafts.list({
userId: 'me'
});
// Get full details for each draft
const drafts = await Promise.all((data.drafts || [])
.filter((draft): draft is { id: string } => typeof draft.id === 'string')
.map(async draft => {
try {
return await this.getDraft(email, draft.id);
} catch (error) {
// Log error but continue with other drafts
console.error(`Failed to get draft ${draft.id}:`, error);
return null;
}
})
);
// Filter out any failed draft fetches
const successfulDrafts = drafts.filter((draft): draft is NonNullable<typeof draft> => draft !== null);
return {
drafts: successfulDrafts,
nextPageToken: data.nextPageToken || undefined,
resultSizeEstimate: data.resultSizeEstimate || 0
};
} catch (error) {
throw new GmailError(
'Failed to list drafts',
'LIST_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
async getDraft(email: string, draftId: string) {
try {
const client = this.ensureClient();
const { data } = await client.users.drafts.get({
userId: 'me',
id: draftId,
format: 'full'
});
if (!data.id || !data.message?.id || !data.message?.threadId) {
throw new GmailError(
'Invalid response from Gmail API',
'GET_ERROR',
'Message ID or Thread ID is missing'
);
}
return {
id: data.id,
message: {
id: data.message.id,
threadId: data.message.threadId,
labelIds: data.message.labelIds || []
},
updated: new Date().toISOString() // Gmail API doesn't provide updated time, using current time
};
} catch (error) {
throw new GmailError(
'Failed to get draft',
'GET_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
async updateDraft(email: string, draftId: string, data: DraftData) {
try {
const client = this.ensureClient();
// Validate and prepare attachments
const processedAttachments = data.attachments?.map(attachment => {
this.attachmentService.validateAttachment(attachment);
return this.attachmentService.prepareAttachment(attachment);
}) || [];
// Construct updated email
const boundary = `boundary_${Date.now()}`;
const messageParts = [
'MIME-Version: 1.0\n',
`Content-Type: multipart/mixed; boundary="${boundary}"\n`,
`To: ${data.to.join(', ')}\n`,
data.cc?.length ? `Cc: ${data.cc.join(', ')}\n` : '',
data.bcc?.length ? `Bcc: ${data.bcc.join(', ')}\n` : '',
`Subject: ${data.subject}\n\n`,
`--${boundary}\n`,
'Content-Type: text/plain; charset="UTF-8"\n',
'Content-Transfer-Encoding: 7bit\n\n',
data.body,
'\n'
];
// Add attachments
for (const attachment of processedAttachments) {
messageParts.push(
`--${boundary}\n`,
`Content-Type: ${attachment.mimeType}\n`,
'Content-Transfer-Encoding: base64\n',
`Content-Disposition: attachment; filename="${attachment.filename}"\n\n`,
attachment.content,
'\n'
);
}
messageParts.push(`--${boundary}--`);
const fullMessage = messageParts.join('');
// Update draft
const { data: draft } = await client.users.drafts.update({
userId: 'me',
id: draftId,
requestBody: {
message: {
raw: Buffer.from(fullMessage).toString('base64')
}
}
});
return {
id: draft.id || '',
message: {
id: draft.message?.id || '',
threadId: draft.message?.threadId || '',
labelIds: draft.message?.labelIds || []
},
updated: new Date().toISOString(),
attachments: data.attachments
};
} catch (error) {
throw new GmailError(
'Failed to update draft',
'UPDATE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
async deleteDraft(email: string, draftId: string) {
try {
const client = this.ensureClient();
await client.users.drafts.delete({
userId: 'me',
id: draftId
});
return;
} catch (error) {
throw new GmailError(
'Failed to delete draft',
'DELETE_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
async manageDraft(params: ManageDraftParams) {
const { email, action, draftId, data } = params;
switch (action) {
case 'create':
if (!data) {
throw new GmailError(
'Draft data is required for create action',
'INVALID_PARAMS'
);
}
return this.createDraft(email, data);
case 'read':
if (!draftId) {
return this.listDrafts(email);
}
return this.getDraft(email, draftId);
case 'update':
if (!draftId || !data) {
throw new GmailError(
'Draft ID and data are required for update action',
'INVALID_PARAMS'
);
}
return this.updateDraft(email, draftId, data);
case 'delete':
if (!draftId) {
throw new GmailError(
'Draft ID is required for delete action',
'INVALID_PARAMS'
);
}
return this.deleteDraft(email, draftId);
case 'send':
if (!draftId) {
throw new GmailError(
'Draft ID is required for send action',
'INVALID_PARAMS'
);
}
return this.sendDraft(email, draftId);
default:
throw new GmailError(
'Invalid action',
'INVALID_PARAMS',
'Supported actions are: create, read, update, delete, send'
);
}
}
async sendDraft(email: string, draftId: string) {
try {
const client = this.ensureClient();
const { data } = await client.users.drafts.send({
userId: 'me',
requestBody: {
id: draftId
}
});
if (!data.id || !data.threadId) {
throw new GmailError(
'Invalid response from Gmail API',
'SEND_ERROR',
'Message ID or Thread ID is missing'
);
}
return {
messageId: data.id,
threadId: data.threadId,
labelIds: data.labelIds || undefined
};
} catch (error) {
throw new GmailError(
'Failed to send draft',
'SEND_ERROR',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
}
```
--------------------------------------------------------------------------------
/src/__tests__/modules/calendar/service.test.ts:
--------------------------------------------------------------------------------
```typescript
import { CalendarService } from '../../../modules/calendar/service.js';
import { calendar_v3 } from 'googleapis';
import { getAccountManager } from '../../../modules/accounts/index.js';
import { AccountManager } from '../../../modules/accounts/manager.js';
import { CreateEventParams } from '../../../modules/calendar/types.js';
jest.mock('../../../modules/accounts/index.js');
jest.mock('../../../modules/accounts/manager.js');
describe('CalendarService', () => {
let calendarService: CalendarService;
let mockCalendarClient: jest.Mocked<calendar_v3.Calendar>;
let mockAccountManager: jest.Mocked<AccountManager>;
const mockEmail = '[email protected]';
beforeEach(() => {
// Simplified mock setup with proper typing
mockCalendarClient = {
events: {
list: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
get: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
insert: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
patch: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
},
} as unknown as jest.Mocked<calendar_v3.Calendar>;
mockAccountManager = {
validateToken: jest.fn().mockResolvedValue({ valid: true, token: {} }),
getAuthClient: jest.fn().mockResolvedValue({}),
} as unknown as jest.Mocked<AccountManager>;
(getAccountManager as jest.Mock).mockReturnValue(mockAccountManager);
calendarService = new CalendarService();
(calendarService as any).getCalendarClient = jest.fn().mockResolvedValue(mockCalendarClient);
});
describe('getEvents', () => {
it('should return events list', async () => {
const mockEvents = [
{ id: 'event1', summary: 'Test Event 1' },
{ id: 'event2', summary: 'Test Event 2' }
];
(mockCalendarClient.events.list as jest.Mock).mockImplementation(() =>
Promise.resolve({ data: { items: mockEvents } })
);
const result = await calendarService.getEvents({ email: mockEmail });
expect(result).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'event1' }),
expect.objectContaining({ id: 'event2' })
]));
});
it('should handle empty results', async () => {
(mockCalendarClient.events.list as jest.Mock).mockImplementation(() =>
Promise.resolve({ data: {} })
);
const result = await calendarService.getEvents({ email: mockEmail });
expect(result).toEqual([]);
});
it('should handle invalid date format', async () => {
await expect(calendarService.getEvents({
email: mockEmail,
timeMin: 'invalid-date'
})).rejects.toThrow('Invalid date format');
});
});
describe('createEvent', () => {
const mockEvent = {
email: mockEmail,
summary: 'Meeting',
start: { dateTime: '2024-01-15T10:00:00Z' },
end: { dateTime: '2024-01-15T11:00:00Z' }
};
it('should create event', async () => {
(mockCalendarClient.events.insert as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: { id: 'new-1', summary: 'Meeting', htmlLink: 'url' }
})
);
const result = await calendarService.createEvent(mockEvent);
expect(result).toEqual(expect.objectContaining({
id: 'new-1',
summary: 'Meeting'
}));
});
it('should handle creation failure', async () => {
(mockCalendarClient.events.insert as jest.Mock).mockImplementation(() =>
Promise.reject(new Error('Failed'))
);
await expect(calendarService.createEvent(mockEvent)).rejects.toThrow();
});
});
describe('manageEvent', () => {
beforeEach(() => {
(mockCalendarClient.events.get as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
id: 'event1',
summary: 'Test Event',
attendees: [{ email: mockEmail }]
}
})
);
});
it('should accept event', async () => {
(mockCalendarClient.events.patch as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: { id: 'event1', status: 'accepted' }
})
);
const result = await calendarService.manageEvent({
email: mockEmail,
eventId: 'event1',
action: 'accept'
});
expect(result.success).toBe(true);
expect(result.status).toBe('completed');
});
it('should handle invalid action', async () => {
await expect(calendarService.manageEvent({
email: mockEmail,
eventId: 'event1',
action: 'invalid_action' as any
})).rejects.toThrow();
});
it('should validate new times for propose action', async () => {
await expect(calendarService.manageEvent({
email: mockEmail,
eventId: 'event1',
action: 'propose_new_time'
})).rejects.toThrow('No proposed times provided');
});
});
describe('getEvent', () => {
it('should get single event', async () => {
(mockCalendarClient.events.get as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: { id: 'event1', summary: 'Test' }
})
);
const result = await calendarService.getEvent(mockEmail, 'event1');
expect(result).toEqual(expect.objectContaining({ id: 'event1' }));
});
it('should handle not found', async () => {
(mockCalendarClient.events.get as jest.Mock).mockImplementation(() =>
Promise.reject(new Error('Not found'))
);
await expect(calendarService.getEvent(mockEmail, 'nonexistent'))
.rejects.toThrow();
});
});
describe('deleteEvent', () => {
beforeEach(() => {
// Add delete method to mock
mockCalendarClient.events.delete = jest.fn().mockResolvedValue({});
});
it('should delete a single event with default parameters', async () => {
await calendarService.deleteEvent(mockEmail, 'event1');
expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event1',
sendUpdates: 'all'
});
});
it('should delete a single event with custom sendUpdates', async () => {
await calendarService.deleteEvent(mockEmail, 'event1', 'none');
expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event1',
sendUpdates: 'none'
});
});
it('should delete entire series of a recurring event', async () => {
await calendarService.deleteEvent(mockEmail, 'recurring1', 'all', 'entire_series');
expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'recurring1',
sendUpdates: 'all'
});
});
it('should handle "this_and_following" for a recurring event instance', async () => {
// Mock a recurring event instance
(mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
data: {
id: 'instance1',
recurringEventId: 'master1',
start: { dateTime: '2024-05-15T10:00:00Z' }
}
});
// Mock the master event
(mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
data: {
id: 'master1',
recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'],
start: { dateTime: '2024-05-01T10:00:00Z' }
}
});
await calendarService.deleteEvent(mockEmail, 'instance1', 'all', 'this_and_following');
// Should get the instance
expect(mockCalendarClient.events.get).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'instance1'
});
// Should get the master event
expect(mockCalendarClient.events.get).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'master1'
});
// Should update the master event's recurrence rule
expect(mockCalendarClient.events.patch).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: 'primary',
eventId: 'master1',
requestBody: {
recurrence: expect.arrayContaining([
expect.stringMatching(/RRULE:FREQ=WEEKLY;COUNT=10;UNTIL=\d+/)
])
}
})
);
// Should delete the instance
expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'instance1',
sendUpdates: 'all'
});
});
it('should handle "this_and_following" for a master recurring event', async () => {
// Mock a master recurring event
(mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
data: {
id: 'master1',
recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'],
start: { dateTime: '2024-05-01T10:00:00Z' }
}
});
await calendarService.deleteEvent(mockEmail, 'master1', 'all', 'this_and_following');
// Should update the recurrence rule
expect(mockCalendarClient.events.patch).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: 'primary',
eventId: 'master1',
requestBody: {
recurrence: expect.arrayContaining([
expect.stringMatching(/RRULE:FREQ=WEEKLY;COUNT=10;UNTIL=\d+/)
])
}
})
);
});
it('should throw error when using "this_and_following" on a non-recurring event', async () => {
// Mock a non-recurring event
(mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
data: {
id: 'single1',
summary: 'Single Event',
start: { dateTime: '2024-05-01T10:00:00Z' }
}
});
await expect(
calendarService.deleteEvent(mockEmail, 'single1', 'all', 'this_and_following')
).rejects.toThrow('Deletion scope can only be applied to recurring events');
});
it('should handle errors gracefully', async () => {
(mockCalendarClient.events.get as jest.Mock).mockRejectedValueOnce(new Error('API error'));
(mockCalendarClient.events.delete as jest.Mock).mockResolvedValueOnce({});
// Should fall back to simple delete
await calendarService.deleteEvent(mockEmail, 'event1', 'all', 'this_and_following');
expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
calendarId: 'primary',
eventId: 'event1',
sendUpdates: 'all'
});
});
});
});
```
--------------------------------------------------------------------------------
/src/modules/gmail/services/email.ts:
--------------------------------------------------------------------------------
```typescript
import { google, gmail_v1 } from 'googleapis';
import {
EmailResponse,
GetEmailsParams,
GetEmailsResponse,
SendEmailParams,
SendEmailResponse,
ThreadInfo,
GmailError,
IncomingGmailAttachment,
OutgoingGmailAttachment
} from '../types.js';
import { SearchService } from './search.js';
import { GmailAttachmentService } from './attachment.js';
import { AttachmentResponseTransformer } from '../../attachments/response-transformer.js';
import { AttachmentIndexService } from '../../attachments/index-service.js';
type GmailMessage = gmail_v1.Schema$Message;
export class EmailService {
private responseTransformer: AttachmentResponseTransformer;
constructor(
private searchService: SearchService,
private attachmentService: GmailAttachmentService,
private gmailClient?: ReturnType<typeof google.gmail>
) {
this.responseTransformer = new AttachmentResponseTransformer(AttachmentIndexService.getInstance());
}
/**
* Updates the Gmail client instance
* @param client - New Gmail client instance
*/
updateClient(client: ReturnType<typeof google.gmail>) {
this.gmailClient = client;
}
private ensureClient(): ReturnType<typeof google.gmail> {
if (!this.gmailClient) {
throw new GmailError(
'Gmail client not initialized',
'CLIENT_ERROR',
'Please ensure the service is initialized'
);
}
return this.gmailClient;
}
/**
* Extracts all headers into a key-value map
*/
private extractHeaders(headers: { name: string; value: string }[]): { [key: string]: string } {
return headers.reduce((acc, header) => {
acc[header.name] = header.value;
return acc;
}, {} as { [key: string]: string });
}
/**
* Groups emails by thread ID and extracts thread information
*/
private groupEmailsByThread(emails: EmailResponse[]): { [threadId: string]: ThreadInfo } {
return emails.reduce((threads, email) => {
if (!threads[email.threadId]) {
threads[email.threadId] = {
messages: [],
participants: [],
subject: email.subject,
lastUpdated: email.date
};
}
const thread = threads[email.threadId];
thread.messages.push(email.id);
if (!thread.participants.includes(email.from)) {
thread.participants.push(email.from);
}
if (email.to && !thread.participants.includes(email.to)) {
thread.participants.push(email.to);
}
const emailDate = new Date(email.date);
const threadDate = new Date(thread.lastUpdated);
if (emailDate > threadDate) {
thread.lastUpdated = email.date;
}
return threads;
}, {} as { [threadId: string]: ThreadInfo });
}
/**
* Get attachment metadata from message parts
*/
private getAttachmentMetadata(message: GmailMessage): IncomingGmailAttachment[] {
const attachments: IncomingGmailAttachment[] = [];
if (!message.payload?.parts) {
return attachments;
}
for (const part of message.payload.parts) {
if (part.filename && part.body?.attachmentId) {
attachments.push({
id: part.body.attachmentId,
name: part.filename,
mimeType: part.mimeType || 'application/octet-stream',
size: parseInt(String(part.body.size || '0'))
});
}
}
return attachments;
}
/**
* Enhanced getEmails method with support for advanced search criteria and options
*/
async getEmails({ email, search = {}, options = {}, messageIds }: GetEmailsParams): Promise<GetEmailsResponse> {
try {
const maxResults = options.maxResults || 10;
let messages;
let nextPageToken: string | undefined;
if (messageIds && messageIds.length > 0) {
messages = { messages: messageIds.map(id => ({ id })) };
} else {
// Build search query from criteria
const query = this.searchService.buildSearchQuery(search);
// List messages matching query
const client = this.ensureClient();
const { data } = await client.users.messages.list({
userId: 'me',
q: query,
maxResults,
pageToken: options.pageToken,
});
messages = data;
nextPageToken = data.nextPageToken || undefined;
}
if (!messages.messages || messages.messages.length === 0) {
return {
emails: [],
resultSummary: {
total: 0,
returned: 0,
hasMore: false,
searchCriteria: search
}
};
}
// Get full message details
const emails = await Promise.all(
messages.messages.map(async (message) => {
const client = this.ensureClient();
const { data: email } = await client.users.messages.get({
userId: 'me',
id: message.id!,
format: options.format || 'full',
});
const headers = (email.payload?.headers || []).map(h => ({
name: h.name || '',
value: h.value || ''
}));
const subject = headers.find(h => h.name === 'Subject')?.value || '';
const from = headers.find(h => h.name === 'From')?.value || '';
const to = headers.find(h => h.name === 'To')?.value || '';
const date = headers.find(h => h.name === 'Date')?.value || '';
// Get email body
let body = '';
if (email.payload?.body?.data) {
body = Buffer.from(email.payload.body.data, 'base64').toString();
} else if (email.payload?.parts) {
const textPart = email.payload.parts.find(part => part.mimeType === 'text/plain');
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString();
}
}
// Get attachment metadata if present and store in index
const hasAttachments = email.payload?.parts?.some(part => part.filename && part.filename.length > 0) || false;
let attachments;
if (hasAttachments) {
attachments = this.getAttachmentMetadata(email);
// Store each attachment's metadata in the index
attachments.forEach(attachment => {
this.attachmentService.addAttachment(email.id!, attachment);
});
}
const response: EmailResponse = {
id: email.id!,
threadId: email.threadId!,
labelIds: email.labelIds || undefined,
snippet: email.snippet || undefined,
subject,
from,
to,
date,
body,
headers: options.includeHeaders ? this.extractHeaders(headers) : undefined,
isUnread: email.labelIds?.includes('UNREAD') || false,
hasAttachment: hasAttachments,
attachments
};
return response;
})
);
// Handle threaded view if requested
const threads = options.threadedView ? this.groupEmailsByThread(emails) : undefined;
// Sort emails if requested
if (options.sortOrder) {
emails.sort((a, b) => {
const dateA = new Date(a.date).getTime();
const dateB = new Date(b.date).getTime();
return options.sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
});
}
// Transform response to simplify attachments
const transformedResponse = this.responseTransformer.transformResponse({
emails,
nextPageToken,
resultSummary: {
total: messages.resultSizeEstimate || emails.length,
returned: emails.length,
hasMore: Boolean(nextPageToken),
searchCriteria: search
},
threads
});
return transformedResponse;
} catch (error) {
if (error instanceof GmailError) {
throw error;
}
throw new GmailError(
'Failed to get emails',
'FETCH_ERROR',
`Error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
async sendEmail({ email, to, subject, body, cc = [], bcc = [], attachments = [] }: SendEmailParams): Promise<SendEmailResponse> {
try {
// Validate and prepare attachments for sending
const processedAttachments = attachments?.map(attachment => {
this.attachmentService.validateAttachment(attachment);
const prepared = this.attachmentService.prepareAttachment(attachment);
return {
id: attachment.id,
name: prepared.filename,
mimeType: prepared.mimeType,
size: attachment.size,
content: prepared.content
} as OutgoingGmailAttachment;
}) || [];
// Construct email with attachments
const boundary = `boundary_${Date.now()}`;
const messageParts = [
'MIME-Version: 1.0\n',
`Content-Type: multipart/mixed; boundary="${boundary}"\n`,
`To: ${to.join(', ')}\n`,
cc.length > 0 ? `Cc: ${cc.join(', ')}\n` : '',
bcc.length > 0 ? `Bcc: ${bcc.join(', ')}\n` : '',
`Subject: ${subject}\n\n`,
`--${boundary}\n`,
'Content-Type: text/plain; charset="UTF-8"\n',
'Content-Transfer-Encoding: 7bit\n\n',
body,
'\n'
];
// Add attachments directly from content
for (const attachment of processedAttachments) {
messageParts.push(
`--${boundary}\n`,
`Content-Type: ${attachment.mimeType}\n`,
'Content-Transfer-Encoding: base64\n',
`Content-Disposition: attachment; filename="${attachment.name}"\n\n`,
attachment.content,
'\n'
);
}
messageParts.push(`--${boundary}--`);
const fullMessage = messageParts.join('');
// Encode the email in base64
const encodedMessage = Buffer.from(fullMessage)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
// Send the email
const client = this.ensureClient();
const { data } = await client.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
const response: SendEmailResponse = {
messageId: data.id!,
threadId: data.threadId!,
labelIds: data.labelIds || undefined
};
if (processedAttachments.length > 0) {
response.attachments = processedAttachments;
}
return response;
} catch (error) {
if (error instanceof GmailError) {
throw error;
}
throw new GmailError(
'Failed to send email',
'SEND_ERROR',
`Error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
}
```
--------------------------------------------------------------------------------
/docs/API.md:
--------------------------------------------------------------------------------
```markdown
# Google Workspace MCP API Reference
IMPORTANT: Before using any workspace operations, you MUST call list_workspace_accounts first to:
1. Check for existing authenticated accounts
2. Determine which account to use if multiple exist
3. Verify required API scopes are authorized
## Account Management (Required First)
### list_workspace_accounts
List all configured Google workspace accounts and their authentication status.
This tool MUST be called first before any other workspace operations. It serves as the foundation for all account-based operations by:
1. Checking for existing authenticated accounts
2. Determining which account to use if multiple exist
3. Verifying required API scopes are authorized
Common Response Patterns:
- Valid account exists → Proceed with requested operation
- Multiple accounts exist → Ask user which to use
- Token expired → Proceed normally (auto-refresh occurs)
- No accounts exist → Start authentication flow
**Input Schema**: Empty object `{}`
**Output**: Array of account objects with authentication status
### authenticate_workspace_account
Add and authenticate a Google account for API access.
IMPORTANT: Only use this tool if list_workspace_accounts shows:
1. No existing accounts, OR
2. When using the account it seems to lack necessary auth scopes.
To prevent wasted time, DO NOT use this tool:
- Without checking list_workspace_accounts first
- When token is just expired (auto-refresh handles this)
- To re-authenticate an already valid account
**Input Schema**:
```typescript
{
email: string; // Required: Email address to authenticate
category?: string; // Optional: Account category (e.g., work, personal)
description?: string; // Optional: Account description
auth_code?: string; // Optional: OAuth code for completing authentication
}
```
### remove_workspace_account
Remove a Google account and delete associated tokens.
**Input Schema**:
```typescript
{
email: string; // Required: Email address to remove
}
```
## Gmail Operations
IMPORTANT: All Gmail operations require prior verification of account access using list_workspace_accounts.
### search_workspace_emails
Search emails with advanced filtering.
Response Format (v1.1):
- Attachments are simplified to just filename
- Full metadata is maintained internally
- Example response:
```json
{
"id": "message123",
"attachments": [{
"name": "document.pdf"
}]
}
```
Common Query Patterns:
- Meeting emails: "from:(*@zoom.us OR zoom.us OR [email protected]) subject:(meeting OR sync OR invite)"
- HR/Admin: "from:(*@workday.com OR *@adp.com) subject:(time off OR PTO OR benefits)"
- Team updates: "from:(*@company.com) -from:([email protected])"
- Newsletters: "subject:(newsletter OR digest) from:(*@company.com)"
Search Tips:
- Date format: YYYY-MM-DD (e.g., "2024-02-18")
- Labels: Case-sensitive, exact match (e.g., "INBOX", "SENT")
- Wildcards: Use * for partial matches (e.g., "*@domain.com")
- Operators: OR, -, (), has:attachment, larger:size, newer_than:date
**Input Schema**:
```typescript
{
email: string; // Required: Gmail account email
search?: { // Optional: Search criteria
from?: string | string[];
to?: string | string[];
subject?: string;
content?: string; // Complex Gmail query
after?: string; // YYYY-MM-DD
before?: string; // YYYY-MM-DD
hasAttachment?: boolean;
labels?: string[];
excludeLabels?: string[];
includeSpam?: boolean;
isUnread?: boolean;
};
maxResults?: number; // Optional: Max results to return
}
```
### send_workspace_email
Send an email.
**Input Schema**:
```typescript
{
email: string; // Required: Sender email
to: string[]; // Required: Recipients
subject: string; // Required: Email subject
body: string; // Required: Email content
cc?: string[]; // Optional: CC recipients
bcc?: string[]; // Optional: BCC recipients
}
```
### manage_workspace_draft
Manage email drafts.
**Input Schema**:
```typescript
{
email: string; // Required: Gmail account
action: 'create' | 'read' | 'update' | 'delete' | 'send';
draftId?: string; // Required for read/update/delete/send
data?: { // Required for create/update
to?: string[];
subject?: string;
body?: string;
cc?: string[];
bcc?: string[];
replyToMessageId?: string;
threadId?: string;
}
}
```
## Calendar Operations
IMPORTANT: All Calendar operations require prior verification of account access using list_workspace_accounts.
### list_workspace_calendar_events
List calendar events.
Common Usage Patterns:
- Default view: Current week's events
- Specific range: Use timeMin/timeMax
- Search: Use query for text search
Example Flows:
1. User asks "check my calendar":
- Verify account access
- Show current week by default
- Include upcoming events
2. User asks "find meetings about project":
- Check account access
- Search with relevant query
- Focus on recent/upcoming events
**Input Schema**:
```typescript
{
email: string; // Required: Calendar owner email
query?: string; // Optional: Text search
maxResults?: number; // Optional: Max events to return
timeMin?: string; // Optional: Start time (ISO string)
timeMax?: string; // Optional: End time (ISO string)
}
```
### create_workspace_calendar_event
Create a calendar event.
**Input Schema**:
```typescript
{
email: string; // Required: Calendar owner
summary: string; // Required: Event title
description?: string; // Optional: Event description
start: { // Required: Start time
dateTime: string; // ISO-8601 format
timeZone?: string; // IANA timezone
};
end: { // Required: End time
dateTime: string; // ISO-8601 format
timeZone?: string; // IANA timezone
};
attendees?: { // Optional: Event attendees
email: string;
}[];
recurrence?: string[]; // Optional: RRULE strings
}
```
## Drive Operations
IMPORTANT: All Drive operations require prior verification of account access using list_workspace_accounts.
### list_drive_files
List files in Google Drive.
Common Usage Patterns:
- List all files: No options needed
- List folder contents: Provide folderId
- Custom queries: Use query parameter
Example Flow:
1. Check account access
2. Apply any filters
3. Return file list with metadata
**Input Schema**:
```typescript
{
email: string; // Required: Drive account email
options?: { // Optional: List options
folderId?: string; // Filter by parent folder
query?: string; // Custom query string
pageSize?: number; // Max files to return
orderBy?: string[]; // Sort fields
fields?: string[]; // Response fields to include
}
}
```
### search_drive_files
Search files with advanced filtering.
**Input Schema**:
```typescript
{
email: string; // Required: Drive account email
options: { // Required: Search options
fullText?: string; // Full text search
mimeType?: string; // Filter by file type
folderId?: string; // Filter by parent folder
trashed?: boolean; // Include trashed files
query?: string; // Additional query string
pageSize?: number; // Max results
orderBy?: string[]; // Sort order
fields?: string[]; // Response fields
}
}
```
### upload_drive_file
Upload a file to Drive.
**Input Schema**:
```typescript
{
email: string; // Required: Drive account email
options: { // Required: Upload options
name: string; // Required: File name
content: string; // Required: File content (string/base64)
mimeType?: string; // Optional: Content type
parents?: string[]; // Optional: Parent folder IDs
}
}
```
### download_drive_file
Download a file from Drive.
**Input Schema**:
```typescript
{
email: string; // Required: Drive account email
fileId: string; // Required: File to download
mimeType?: string; // Optional: Export format for Google files
}
```
### create_drive_folder
Create a new folder.
**Input Schema**:
```typescript
{
email: string; // Required: Drive account email
name: string; // Required: Folder name
parentId?: string; // Optional: Parent folder ID
}
```
### update_drive_permissions
Update file/folder sharing settings.
**Input Schema**:
```typescript
{
email: string; // Required: Drive account email
options: { // Required: Permission options
fileId: string; // Required: File/folder ID
role: 'owner' | 'organizer' | 'fileOrganizer' |
'writer' | 'commenter' | 'reader';
type: 'user' | 'group' | 'domain' | 'anyone';
emailAddress?: string; // Required for user/group
domain?: string; // Required for domain
allowFileDiscovery?: boolean;
}
}
```
### delete_drive_file
Delete a file or folder.
**Input Schema**:
```typescript
{
email: string; // Required: Drive account email
fileId: string; // Required: File/folder to delete
}
```
## Label Management
### manage_workspace_label
Manage Gmail labels.
**Input Schema**:
```typescript
{
email: string; // Required: Gmail account
action: 'create' | 'read' | 'update' | 'delete';
labelId?: string; // Required for read/update/delete
data?: { // Required for create/update
name?: string; // Label name
messageListVisibility?: 'show' | 'hide';
labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';
color?: {
textColor?: string;
backgroundColor?: string;
}
}
}
```
### manage_workspace_label_assignment
Manage label assignments.
**Input Schema**:
```typescript
{
email: string; // Required: Gmail account
action: 'add' | 'remove';
messageId: string; // Required: Message to modify
labelIds: string[]; // Required: Labels to add/remove
}
```
### manage_workspace_label_filter
Manage Gmail filters.
**Input Schema**:
```typescript
{
email: string; // Required: Gmail account
action: 'create' | 'read' | 'update' | 'delete';
filterId?: string; // Required for update/delete
labelId?: string; // Required for create/update
data?: {
criteria?: {
from?: string[];
to?: string[];
subject?: string;
hasWords?: string[];
doesNotHaveWords?: string[];
hasAttachment?: boolean;
size?: {
operator: 'larger' | 'smaller';
size: number;
}
};
actions?: {
addLabel: boolean;
markImportant?: boolean;
markRead?: boolean;
archive?: boolean;
}
}
}
```
--------------------------------------------------------------------------------
/ARCHITECTURE.md:
--------------------------------------------------------------------------------
```markdown
# Architecture
## Design Philosophy: Simplest Viable Design
This project follows the "simplest viable design" principle, which emerged from our experience with AI systems' tendency toward over-engineering, particularly in OAuth scope handling. This principle addresses a pattern we term "scope fondling" - where AI systems optimize for maximum anticipated flexibility rather than minimal necessary permissions.
Key aspects of this approach:
- Minimize complexity in permission structures
- Handle auth through simple HTTP response codes (401/403)
- Move OAuth mechanics entirely into platform infrastructure
- Present simple verb-noun interfaces to AI agents
- Focus on core operational requirements over edge cases
This principle helps prevent goal misgeneralization, where AI systems might otherwise create unnecessary complexity in authentication paths, connection management, and permission hierarchies.
## System Overview
The Google Workspace MCP Server implements a modular architecture with comprehensive support for Gmail, Calendar, and Drive services. The system is built around core modules that handle authentication, account management, and service-specific operations.
```mermaid
graph TB
subgraph "Google Workspace MCP Tools"
AM[Account Management]
GM[Gmail Management]
CM[Calendar Management]
DM[Drive Management]
subgraph "Account Tools"
AM --> LA[list_workspace_accounts]
AM --> AA[authenticate_workspace_account]
AM --> RA[remove_workspace_account]
end
subgraph "Gmail Tools"
GM --> SE[search_workspace_emails]
GM --> SWE[send_workspace_email]
GM --> GS[get_workspace_gmail_settings]
GM --> MD[manage_workspace_draft]
subgraph "Label Management"
LM[Label Tools]
LM --> ML[manage_workspace_label]
LM --> MLA[manage_workspace_label_assignment]
LM --> MLF[manage_workspace_label_filter]
end
end
subgraph "Calendar Tools"
CM --> LCE[list_workspace_calendar_events]
CM --> GCE[get_workspace_calendar_event]
CM --> MCE[manage_workspace_calendar_event]
CM --> CCE[create_workspace_calendar_event]
CM --> DCE[delete_workspace_calendar_event]
end
subgraph "Drive Tools"
DM --> LDF[list_drive_files]
DM --> SDF[search_drive_files]
DM --> UDF[upload_drive_file]
DM --> DDF[download_drive_file]
DM --> CDF[create_drive_folder]
DM --> UDP[update_drive_permissions]
DM --> DEL[delete_drive_file]
end
end
%% Service Dependencies
LA -.->|Required First| SE
LA -.->|Required First| CM
LA -.->|Required First| DM
AA -.->|Auth Flow| LA
```
Key characteristics:
- Authentication-first architecture with list_workspace_accounts as the foundation
- Comprehensive service modules for Gmail, Calendar, and Drive
- Integrated label management within Gmail
- Rich tool sets for each service domain
## Core Components
### 1. Scope Registry (src/modules/tools/scope-registry.ts)
- Simple scope collection system for OAuth
- Gathers required scopes at startup
- Used for initial auth setup and validation
- Handles runtime scope verification
### 2. MCP Server (src/index.ts)
- Registers and manages available tools
- Handles request routing and validation
- Provides consistent error handling
- Manages server lifecycle
### 3. Account Module (src/modules/accounts/*)
- OAuth Client:
- Implements Google OAuth 2.0 flow
- Handles token exchange and refresh
- Provides authentication URLs
- Manages client credentials
- Token Manager:
- Handles token lifecycle
- Validates and refreshes tokens
- Manages token storage
- Account Manager:
- Manages account configurations
- Handles account persistence
- Validates account status
### 4. Service Modules
#### Attachment System
- Singleton-based attachment management:
- AttachmentIndexService: Central metadata cache with size limits
- AttachmentResponseTransformer: Handles response simplification
- AttachmentCleanupService: Manages cache expiry
- Cache Management:
- Map-based storage using messageId + filename as key
- Built-in size limit (256 entries)
- Automatic expiry handling (1 hour timeout)
- LRU-style cleanup for capacity management
- Abstraction Layers:
- Service layer for Gmail/Calendar operations
- Transformer layer for response formatting
- Index layer for metadata storage
- Cleanup layer for maintenance
#### Gmail Module (src/modules/gmail/*)
- Comprehensive email operations:
- Email search and sending
- Draft management
- Label and filter control
- Settings configuration
- Simplified attachment handling
- Manages Gmail API integration
- Handles Gmail authentication scopes
- Integrates with attachment system
#### Calendar Module (src/modules/calendar/*)
- Complete calendar operations:
- Event listing and search
- Event creation and management
- Event response handling
- Recurring event support
- Manages Calendar API integration
- Handles calendar permissions
#### Drive Module (src/modules/drive/*)
- Full file management capabilities:
- File listing and search
- Upload and download
- Folder management
- Permission control
- File operations (create, update, delete)
- Manages Drive API integration
- Handles file system operations
## Data Flows
### Operation Flow
```mermaid
sequenceDiagram
participant TR as Tool Request
participant S as Service
participant API as Google API
TR->>S: Request
S->>API: API Call
alt Success
API-->>TR: Return Response
else Auth Error (401/403)
S->>S: Refresh Token
S->>API: Retry API Call
API-->>TR: Return Response
end
```
### Auth Flow
```mermaid
sequenceDiagram
participant TR as Tool Request
participant S as Service
participant AM as Account Manager
participant API as Google API
TR->>S: Request
S->>API: API Call
alt Success
API-->>TR: Return Response
else Auth Error
S->>AM: Refresh Token
alt Refresh Success
S->>API: Retry API Call
API-->>TR: Return Response
else Refresh Failed
AM-->>TR: Request Re-auth
end
end
```
## Implementation Details
### Testing Strategy
The project follows a simplified unit testing approach that emphasizes:
```mermaid
graph TD
A[Unit Tests] --> B[Simplified Mocks]
A --> C[Focused Tests]
A --> D[Clean State]
B --> B1[Static Responses]
B --> B2[Simple File System]
B --> B3[Basic OAuth]
C --> C1[Grouped by Function]
C --> C2[Single Responsibility]
C --> C3[Clear Assertions]
D --> D1[Reset Modules]
D --> D2[Fresh Instances]
D --> D3[Tracked Mocks]
```
#### Key Testing Principles
1. **Logging Strategy**
- All logs are directed to stderr to maintain MCP protocol integrity
- Prevents log messages from corrupting stdout JSON communication
- Enables clean separation of logs and tool responses
- Logger is mocked in tests to prevent console.error noise
- Consistent logging approach across all modules
2. **Simplified Mocking**
- Use static mock responses instead of complex simulations
- Mock external dependencies with minimal implementations
- Focus on behavior verification over implementation details
- Avoid end-to-end complexity in unit tests
2. **Test Organization**
- Group tests by functional area (e.g., account operations, file operations)
- Each test verifies a single piece of functionality
- Clear test descriptions that document behavior
- Independent test cases that don't rely on shared state
3. **Mock Management**
- Reset modules and mocks between tests
- Track mock function calls explicitly
- Re-require modules after mock changes
- Verify both function calls and results
4. **File System Testing**
- Use simple JSON structures
- Focus on data correctness over formatting
- Test error scenarios explicitly
- Verify operations without implementation details
5. **Token Handling**
- Mock token validation with static responses
- Test success and failure scenarios separately
- Focus on account manager's token handling logic
- Avoid OAuth complexity in unit tests
This approach ensures tests are:
- Reliable and predictable
- Easy to maintain
- Quick to execute
- Clear in intent
- Focused on behavior
### Security
- OAuth 2.0 implementation with offline access
- Secure token storage and management
- Scope-based access control
- Environment-based configuration
- Secure credential handling
### Error Handling
- Simplified auth error handling through 401/403 responses
- Automatic token refresh on auth failures
- Service-specific error types
- Clear authentication error guidance
### Configuration
- OAuth credentials via environment variables
- Secure token storage in user's home directory
- Account configuration management
- Token persistence handling
## Project Structure
### Docker Container Structure
```
/app/
├── src/ # Application source code
│ ├── index.ts # MCP server implementation
│ ├── modules/ # Core functionality modules
│ └── scripts/ # Utility scripts
├── config/ # Mount point for persistent data
│ ├── accounts.json # Account configurations
│ └── credentials/ # Token storage
└── Dockerfile # Container definition
```
### Local Development Structure
```
project/
├── src/ # Source code (mounted in container)
├── Dockerfile # Container definition
└── docker-entrypoint.sh # Container startup script
```
### Host Machine Structure
```
~/.mcp/google-workspace-mcp/ # Persistent data directory
├── accounts.json # Account configurations
└── credentials/ # Token storage
```
## Configuration
### Container Environment Variables
```
GOOGLE_CLIENT_ID - OAuth client ID
GOOGLE_CLIENT_SECRET - OAuth client secret
GOOGLE_REDIRECT_URI - OAuth redirect URI (optional)
```
### Volume Mounts
```
~/.mcp/google-workspace-mcp:/app/config # Persistent data storage
```
### Data Directory Structure
The server uses a Docker volume mounted at `/app/config` to store:
1. Account Configuration (accounts.json):
```json
{
"accounts": [{
"email": "[email protected]",
"category": "work",
"description": "Work Account"
}]
}
```
2. Credentials Directory:
- Contains OAuth tokens for each account
- Tokens are stored securely with appropriate permissions
- Each token file is named using the account's email address
## Version History
### Version 1.1
- Simplified attachment data in responses (filename only)
- Maintained full metadata in index service
- Improved attachment system architecture
- Enhanced documentation and examples
- Verified download functionality with simplified format
## Future Extensions
### Planned Services
- Admin SDK support for workspace management
- Additional Google Workspace integrations
### Planned Features
- Rate limiting
- Response caching
- Request logging
- Performance monitoring
- Multi-account optimization
```
--------------------------------------------------------------------------------
/src/tools/server.ts:
--------------------------------------------------------------------------------
```typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import logger from '../utils/logger.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool
} from "@modelcontextprotocol/sdk/types.js";
// Get docker hash from environment
const DOCKER_HASH = process.env.DOCKER_HASH || 'unknown';
// Import tool definitions and registry
import { allTools } from './definitions.js';
import { ToolRegistry } from '../modules/tools/registry.js';
// Import handlers
import {
handleListWorkspaceAccounts,
handleAuthenticateWorkspaceAccount,
handleCompleteWorkspaceAuth,
handleRemoveWorkspaceAccount
} from './account-handlers.js';
import {
handleSearchWorkspaceEmails,
handleSendWorkspaceEmail,
handleGetWorkspaceGmailSettings,
handleManageWorkspaceDraft,
handleManageWorkspaceLabel,
handleManageWorkspaceLabelAssignment,
handleManageWorkspaceLabelFilter,
handleManageWorkspaceAttachment
} from './gmail-handlers.js';
import {
handleListWorkspaceCalendarEvents,
handleGetWorkspaceCalendarEvent,
handleManageWorkspaceCalendarEvent,
handleCreateWorkspaceCalendarEvent,
handleDeleteWorkspaceCalendarEvent
} from './calendar-handlers.js';
import {
handleListDriveFiles,
handleSearchDriveFiles,
handleUploadDriveFile,
handleDownloadDriveFile,
handleCreateDriveFolder,
handleUpdateDrivePermissions,
handleDeleteDriveFile
} from './drive-handlers.js';
// Import contact handlers
import { handleGetContacts } from './contacts-handlers.js';
// Import error types
import { AccountError } from '../modules/accounts/types.js';
import { GmailError } from '../modules/gmail/types.js';
import { CalendarError } from '../modules/calendar/types.js';
import { ContactsError } from '../modules/contacts/types.js';
// Import service initializer
import { initializeAllServices } from '../utils/service-initializer.js';
// Import types and type guards
import {
CalendarEventParams,
SendEmailArgs,
AuthenticateAccountArgs,
ManageDraftParams,
ManageAttachmentParams
} from './types.js';
import {
ManageLabelParams,
ManageLabelAssignmentParams,
ManageLabelFilterParams
} from '../modules/gmail/services/label.js';
import {
assertBaseToolArguments,
assertCalendarEventParams,
assertEmailEventIdArgs,
assertSendEmailArgs,
assertManageDraftParams,
assertManageLabelParams,
assertManageLabelAssignmentParams,
assertManageLabelFilterParams,
assertDriveFileListArgs,
assertDriveSearchArgs,
assertDriveUploadArgs,
assertDriveDownloadArgs,
assertDriveFolderArgs,
assertDrivePermissionArgs,
assertDriveDeleteArgs,
assertManageAttachmentParams,
assertGetContactsParams
} from './type-guards.js';
export class GSuiteServer {
private server: Server;
private toolRegistry: ToolRegistry;
constructor() {
this.toolRegistry = new ToolRegistry(allTools);
this.server = new Server(
{
name: "Google Workspace MCP Server",
version: "0.1.0"
},
{
capabilities: {
tools: {
list: true,
call: true
}
}
}
);
this.setupRequestHandlers();
}
private setupRequestHandlers(): void {
// Tools are registered through the ToolRegistry which serves as a single source of truth
// for both tool discovery (ListToolsRequestSchema) and execution (CallToolRequestSchema).
// Tools only need to be defined once in allTools and the registry handles making them
// available to both handlers.
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
// Get tools with categories organized
const categories = this.toolRegistry.getCategories();
const toolsByCategory: { [key: string]: Tool[] } = {};
for (const category of categories) {
// Convert ToolMetadata to Tool (strip out category and aliases for SDK compatibility)
toolsByCategory[category.name] = category.tools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema
}));
}
return {
tools: allTools.map(tool => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema
})),
_meta: {
categories: toolsByCategory,
aliases: Object.fromEntries(
allTools.flatMap(tool =>
(tool.aliases || []).map(alias => [alias, tool.name])
)
)
}
};
});
// Handle tool calls
this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
try {
const args = request.params.arguments || {};
const toolName = request.params.name;
// Look up the tool using the registry
const tool = this.toolRegistry.getTool(toolName);
if (!tool) {
// Generate helpful error message with suggestions
const errorMessage = this.toolRegistry.formatErrorWithSuggestions(toolName);
throw new Error(errorMessage);
}
let result;
// Use the canonical tool name for the switch
switch (tool.name) {
// Account Management
case 'list_workspace_accounts':
result = await handleListWorkspaceAccounts();
break;
case 'authenticate_workspace_account':
result = await handleAuthenticateWorkspaceAccount(args as AuthenticateAccountArgs);
break;
case 'complete_workspace_auth':
assertBaseToolArguments(args);
result = await handleCompleteWorkspaceAuth(args);
break;
case 'remove_workspace_account':
assertBaseToolArguments(args);
result = await handleRemoveWorkspaceAccount(args);
break;
// Gmail Operations
case 'search_workspace_emails':
assertBaseToolArguments(args);
result = await handleSearchWorkspaceEmails(args);
break;
case 'send_workspace_email':
assertSendEmailArgs(args);
result = await handleSendWorkspaceEmail(args as SendEmailArgs);
break;
case 'get_workspace_gmail_settings':
assertBaseToolArguments(args);
result = await handleGetWorkspaceGmailSettings(args);
break;
case 'manage_workspace_draft':
assertManageDraftParams(args);
result = await handleManageWorkspaceDraft(args as ManageDraftParams);
break;
case 'manage_workspace_attachment':
assertManageAttachmentParams(args);
result = await handleManageWorkspaceAttachment(args as ManageAttachmentParams);
break;
// Calendar Operations
case 'list_workspace_calendar_events':
assertCalendarEventParams(args);
result = await handleListWorkspaceCalendarEvents(args as CalendarEventParams);
break;
case 'get_workspace_calendar_event':
assertEmailEventIdArgs(args);
result = await handleGetWorkspaceCalendarEvent(args);
break;
case 'manage_workspace_calendar_event':
assertBaseToolArguments(args);
result = await handleManageWorkspaceCalendarEvent(args);
break;
case 'create_workspace_calendar_event':
assertBaseToolArguments(args);
result = await handleCreateWorkspaceCalendarEvent(args);
break;
case 'delete_workspace_calendar_event':
assertEmailEventIdArgs(args);
result = await handleDeleteWorkspaceCalendarEvent(args);
break;
// Label Management
case 'manage_workspace_label':
assertManageLabelParams(args);
result = await handleManageWorkspaceLabel(args as unknown as ManageLabelParams);
break;
case 'manage_workspace_label_assignment':
assertManageLabelAssignmentParams(args);
result = await handleManageWorkspaceLabelAssignment(args as unknown as ManageLabelAssignmentParams);
break;
case 'manage_workspace_label_filter':
assertManageLabelFilterParams(args);
result = await handleManageWorkspaceLabelFilter(args as unknown as ManageLabelFilterParams);
break;
// Drive Operations
case 'list_drive_files':
assertDriveFileListArgs(args);
result = await handleListDriveFiles(args);
break;
case 'search_drive_files':
assertDriveSearchArgs(args);
result = await handleSearchDriveFiles(args);
break;
case 'upload_drive_file':
assertDriveUploadArgs(args);
result = await handleUploadDriveFile(args);
break;
case 'download_drive_file':
assertDriveDownloadArgs(args);
result = await handleDownloadDriveFile(args);
break;
case 'create_drive_folder':
assertDriveFolderArgs(args);
result = await handleCreateDriveFolder(args);
break;
case 'update_drive_permissions':
assertDrivePermissionArgs(args);
result = await handleUpdateDrivePermissions(args);
break;
case 'delete_drive_file':
assertDriveDeleteArgs(args);
result = await handleDeleteDriveFile(args);
break;
// Contact Operations
case 'get_workspace_contacts':
assertGetContactsParams(args);
result = await handleGetContacts(args);
break;
default:
throw new Error(`Unknown tool: ${request.params.name}`);
}
// Wrap result in McpToolResponse format
// Handle undefined results (like from void functions)
const responseText = result === undefined ?
JSON.stringify({ status: 'success', message: 'Operation completed successfully' }, null, 2) :
JSON.stringify(result, null, 2);
return {
content: [{
type: 'text',
text: responseText
}],
_meta: {}
};
} catch (error) {
const response = this.formatErrorResponse(error);
return {
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
isError: true,
_meta: {}
};
}
});
}
private formatErrorResponse(error: unknown) {
if (error instanceof AccountError || error instanceof GmailError || error instanceof CalendarError || error instanceof ContactsError) {
const details = error instanceof GmailError ? error.details :
error instanceof AccountError ? error.resolution :
error instanceof CalendarError ? error.message :
error instanceof ContactsError ? error.details :
'Please try again or contact support if the issue persists';
return {
status: 'error',
error: error.message,
resolution: details
};
}
return {
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error occurred',
resolution: 'Please try again or contact support if the issue persists'
};
}
async run(): Promise<void> {
try {
// Initialize server
logger.info(`google-workspace-mcp v0.9.0 (docker: ${DOCKER_HASH})`);
// Initialize all services
await initializeAllServices();
// Set up error handler
this.server.onerror = (error) => console.error('MCP Error:', error);
// Connect transport
const transport = new StdioServerTransport();
await this.server.connect(transport);
logger.info('Google Workspace MCP server running on stdio');
} catch (error) {
logger.error('Fatal server error:', error);
throw error;
}
}
}
```
--------------------------------------------------------------------------------
/src/services/gmail/index.ts:
--------------------------------------------------------------------------------
```typescript
import { google } from 'googleapis';
import { BaseGoogleService } from '../base/BaseGoogleService.js';
import {
GetEmailsParams,
SendEmailParams,
EmailResponse,
SendEmailResponse,
GetGmailSettingsParams,
GetGmailSettingsResponse,
SearchCriteria,
GetEmailsResponse,
ThreadInfo
} from '../../modules/gmail/types.js';
/**
* Gmail service implementation extending BaseGoogleService.
* Handles Gmail-specific operations while leveraging common Google API functionality.
*/
export class GmailService extends BaseGoogleService<ReturnType<typeof google.gmail>> {
constructor() {
super({
serviceName: 'gmail',
version: 'v1'
});
}
/**
* Gets an authenticated Gmail client
*/
private async getGmailClient(email: string) {
return this.getAuthenticatedClient(
email,
(auth) => google.gmail({ version: 'v1', auth })
);
}
/**
* Extracts all headers into a key-value map
*/
private extractHeaders(headers: { name: string; value: string }[]): { [key: string]: string } {
return headers.reduce((acc, header) => {
acc[header.name] = header.value;
return acc;
}, {} as { [key: string]: string });
}
/**
* Groups emails by thread ID and extracts thread information
*/
private groupEmailsByThread(emails: EmailResponse[]): { [threadId: string]: ThreadInfo } {
return emails.reduce((threads, email) => {
if (!threads[email.threadId]) {
threads[email.threadId] = {
messages: [],
participants: [],
subject: email.subject,
lastUpdated: email.date
};
}
const thread = threads[email.threadId];
thread.messages.push(email.id);
if (!thread.participants.includes(email.from)) {
thread.participants.push(email.from);
}
if (email.to && !thread.participants.includes(email.to)) {
thread.participants.push(email.to);
}
const emailDate = new Date(email.date);
const threadDate = new Date(thread.lastUpdated);
if (emailDate > threadDate) {
thread.lastUpdated = email.date;
}
return threads;
}, {} as { [threadId: string]: ThreadInfo });
}
/**
* Builds a Gmail search query string from SearchCriteria
*/
private buildSearchQuery(criteria: SearchCriteria = {}): string {
const queryParts: string[] = [];
if (criteria.from) {
const fromAddresses = Array.isArray(criteria.from) ? criteria.from : [criteria.from];
if (fromAddresses.length === 1) {
queryParts.push(`from:${fromAddresses[0]}`);
} else {
queryParts.push(`{${fromAddresses.map(f => `from:${f}`).join(' OR ')}}`);
}
}
if (criteria.to) {
const toAddresses = Array.isArray(criteria.to) ? criteria.to : [criteria.to];
if (toAddresses.length === 1) {
queryParts.push(`to:${toAddresses[0]}`);
} else {
queryParts.push(`{${toAddresses.map(t => `to:${t}`).join(' OR ')}}`);
}
}
if (criteria.subject) {
const escapedSubject = criteria.subject.replace(/["\\]/g, '\\$&');
queryParts.push(`subject:"${escapedSubject}"`);
}
if (criteria.content) {
const escapedContent = criteria.content.replace(/["\\]/g, '\\$&');
queryParts.push(`"${escapedContent}"`);
}
if (criteria.after) {
const afterDate = new Date(criteria.after);
const afterStr = `${afterDate.getFullYear()}/${(afterDate.getMonth() + 1).toString().padStart(2, '0')}/${afterDate.getDate().toString().padStart(2, '0')}`;
queryParts.push(`after:${afterStr}`);
}
if (criteria.before) {
const beforeDate = new Date(criteria.before);
const beforeStr = `${beforeDate.getFullYear()}/${(beforeDate.getMonth() + 1).toString().padStart(2, '0')}/${beforeDate.getDate().toString().padStart(2, '0')}`;
queryParts.push(`before:${beforeStr}`);
}
if (criteria.hasAttachment) {
queryParts.push('has:attachment');
}
if (criteria.labels && criteria.labels.length > 0) {
criteria.labels.forEach(label => {
queryParts.push(`label:${label}`);
});
}
if (criteria.excludeLabels && criteria.excludeLabels.length > 0) {
criteria.excludeLabels.forEach(label => {
queryParts.push(`-label:${label}`);
});
}
if (criteria.includeSpam) {
queryParts.push('in:anywhere');
}
if (criteria.isUnread !== undefined) {
queryParts.push(criteria.isUnread ? 'is:unread' : 'is:read');
}
return queryParts.join(' ');
}
/**
* Gets emails with proper scope handling for search and content access.
*/
async getEmails({ email, search = {}, options = {}, messageIds }: GetEmailsParams): Promise<GetEmailsResponse> {
try {
const gmail = await this.getGmailClient(email);
const maxResults = options.maxResults || 10;
let messages;
let nextPageToken: string | undefined;
if (messageIds && messageIds.length > 0) {
messages = { messages: messageIds.map(id => ({ id })) };
} else {
const query = this.buildSearchQuery(search);
const { data } = await gmail.users.messages.list({
userId: 'me',
q: query,
maxResults,
pageToken: options.pageToken,
});
messages = data;
nextPageToken = data.nextPageToken || undefined;
}
if (!messages.messages || messages.messages.length === 0) {
return {
emails: [],
resultSummary: {
total: 0,
returned: 0,
hasMore: false,
searchCriteria: search
}
};
}
const emails = await Promise.all(
messages.messages.map(async (message) => {
const { data: email } = await gmail.users.messages.get({
userId: 'me',
id: message.id!,
format: options.format || 'full',
});
const headers = (email.payload?.headers || []).map(h => ({
name: h.name || '',
value: h.value || ''
}));
const subject = headers.find(h => h.name === 'Subject')?.value || '';
const from = headers.find(h => h.name === 'From')?.value || '';
const to = headers.find(h => h.name === 'To')?.value || '';
const date = headers.find(h => h.name === 'Date')?.value || '';
let body = '';
if (email.payload?.body?.data) {
body = Buffer.from(email.payload.body.data, 'base64').toString();
} else if (email.payload?.parts) {
const textPart = email.payload.parts.find(part => part.mimeType === 'text/plain');
if (textPart?.body?.data) {
body = Buffer.from(textPart.body.data, 'base64').toString();
}
}
const response: EmailResponse = {
id: email.id!,
threadId: email.threadId!,
labelIds: email.labelIds || undefined,
snippet: email.snippet || undefined,
subject,
from,
to,
date,
body,
headers: options.includeHeaders ? this.extractHeaders(headers) : undefined,
isUnread: email.labelIds?.includes('UNREAD') || false,
hasAttachment: email.payload?.parts?.some(part => part.filename && part.filename.length > 0) || false
};
return response;
})
);
const threads = options.threadedView ? this.groupEmailsByThread(emails) : undefined;
if (options.sortOrder) {
emails.sort((a, b) => {
const dateA = new Date(a.date).getTime();
const dateB = new Date(b.date).getTime();
return options.sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
});
}
return {
emails,
nextPageToken,
resultSummary: {
total: messages.resultSizeEstimate || emails.length,
returned: emails.length,
hasMore: Boolean(nextPageToken),
searchCriteria: search
},
threads
};
} catch (error) {
throw this.handleError(error, 'Failed to get emails');
}
}
/**
* Sends an email from the specified account
*/
async sendEmail({ email, to, subject, body, cc = [], bcc = [] }: SendEmailParams): Promise<SendEmailResponse> {
try {
const gmail = await this.getGmailClient(email);
const message = [
'Content-Type: text/plain; charset="UTF-8"\n',
'MIME-Version: 1.0\n',
'Content-Transfer-Encoding: 7bit\n',
`To: ${to.join(', ')}\n`,
cc.length > 0 ? `Cc: ${cc.join(', ')}\n` : '',
bcc.length > 0 ? `Bcc: ${bcc.join(', ')}\n` : '',
`Subject: ${subject}\n\n`,
body,
].join('');
const encodedMessage = Buffer.from(message)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const { data } = await gmail.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
return {
messageId: data.id!,
threadId: data.threadId!,
labelIds: data.labelIds || undefined,
};
} catch (error) {
throw this.handleError(error, 'Failed to send email');
}
}
/**
* Gets Gmail settings and profile information
*/
async getWorkspaceGmailSettings({ email }: GetGmailSettingsParams): Promise<GetGmailSettingsResponse> {
try {
const gmail = await this.getGmailClient(email);
const { data: profile } = await gmail.users.getProfile({
userId: 'me'
});
const [
{ data: autoForwarding },
{ data: imap },
{ data: language },
{ data: pop },
{ data: vacation }
] = await Promise.all([
gmail.users.settings.getAutoForwarding({ userId: 'me' }),
gmail.users.settings.getImap({ userId: 'me' }),
gmail.users.settings.getLanguage({ userId: 'me' }),
gmail.users.settings.getPop({ userId: 'me' }),
gmail.users.settings.getVacation({ userId: 'me' })
]);
const nullSafeString = (value: string | null | undefined): string | undefined =>
value === null ? undefined : value;
return {
profile: {
emailAddress: profile.emailAddress ?? '',
messagesTotal: typeof profile.messagesTotal === 'number' ? profile.messagesTotal : 0,
threadsTotal: typeof profile.threadsTotal === 'number' ? profile.threadsTotal : 0,
historyId: profile.historyId ?? ''
},
settings: {
...(language?.displayLanguage && {
language: {
displayLanguage: language.displayLanguage
}
}),
...(autoForwarding && {
autoForwarding: {
enabled: Boolean(autoForwarding.enabled),
...(autoForwarding.emailAddress && {
emailAddress: autoForwarding.emailAddress
})
}
}),
...(imap && {
imap: {
enabled: Boolean(imap.enabled),
...(typeof imap.autoExpunge === 'boolean' && {
autoExpunge: imap.autoExpunge
}),
...(imap.expungeBehavior && {
expungeBehavior: imap.expungeBehavior
})
}
}),
...(pop && {
pop: {
enabled: Boolean(pop.accessWindow),
...(pop.accessWindow && {
accessWindow: pop.accessWindow
})
}
}),
...(vacation && {
vacationResponder: {
enabled: Boolean(vacation.enableAutoReply),
...(vacation.startTime && {
startTime: vacation.startTime
}),
...(vacation.endTime && {
endTime: vacation.endTime
}),
...(vacation.responseSubject && {
responseSubject: vacation.responseSubject
}),
...((vacation.responseBodyHtml || vacation.responseBodyPlainText) && {
message: vacation.responseBodyHtml ?? vacation.responseBodyPlainText ?? ''
})
}
})
}
} as GetGmailSettingsResponse;
} catch (error) {
throw this.handleError(error, 'Failed to get Gmail settings');
}
}
}
```