;
+ let mockSlidesAPI: any;
+ let mockDriveAPI: any;
+
+ beforeEach(() => {
+ // Clear all mocks before each test
+ jest.clearAllMocks();
+
+ // Create mock AuthManager
+ mockAuthManager = {
+ getAuthenticatedClient: jest.fn(),
+ } as any;
+
+
+ // Create mock Slides API
+ mockSlidesAPI = {
+ presentations: {
+ get: jest.fn(),
+ },
+ };
+
+ mockDriveAPI = {
+ files: {
+ list: jest.fn(),
+ },
+ };
+
+ // Mock the google constructors
+ (google.slides as jest.Mock) = jest.fn().mockReturnValue(mockSlidesAPI);
+ (google.drive as jest.Mock) = jest.fn().mockReturnValue(mockDriveAPI);
+
+ // Create SlidesService instance
+ slidesService = new SlidesService(mockAuthManager);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('initialize', () => {
+ it('should initialize Slides and Drive API clients', async () => {
+ const mockAuthClient = { access_token: 'test-token' };
+ mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any);
+
+ await slidesService.initialize();
+
+ expect(mockAuthManager.getAuthenticatedClient).toHaveBeenCalledTimes(1);
+ expect(google.slides).toHaveBeenCalledWith(
+ expect.objectContaining({
+ version: 'v1',
+ auth: mockAuthClient,
+ })
+ );
+ expect(google.drive).toHaveBeenCalledWith(
+ expect.objectContaining({
+ version: 'v3',
+ auth: mockAuthClient,
+ })
+ );
+ });
+ });
+
+ describe('getText', () => {
+ beforeEach(async () => {
+ const mockAuthClient = { access_token: 'test-token' };
+ mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any);
+ await slidesService.initialize();
+ });
+
+ it('should extract text from a presentation', async () => {
+ const mockPresentation = {
+ data: {
+ title: 'Test Presentation',
+ slides: [
+ {
+ pageElements: [
+ {
+ shape: {
+ text: {
+ textElements: [
+ { textRun: { content: 'Slide 1 Title' } },
+ { paragraphMarker: {} },
+ { textRun: { content: 'Slide 1 Content' } },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ {
+ pageElements: [
+ {
+ table: {
+ tableRows: [
+ {
+ tableCells: [
+ {
+ text: {
+ textElements: [
+ { textRun: { content: 'Cell 1' } },
+ ],
+ },
+ },
+ {
+ text: {
+ textElements: [
+ { textRun: { content: 'Cell 2' } },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);
+
+ const result = await slidesService.getText({ presentationId: 'test-presentation-id' });
+
+ expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({
+ presentationId: 'test-presentation-id',
+ fields: 'title,slides(pageElements(shape(text,shapeProperties),table(tableRows(tableCells(text)))))',
+ });
+
+ expect(result.content[0].type).toBe('text');
+ expect(result.content[0].text).toContain('Test Presentation');
+ expect(result.content[0].text).toContain('Slide 1 Title');
+ expect(result.content[0].text).toContain('Slide 1 Content');
+ expect(result.content[0].text).toContain('Cell 1 | Cell 2');
+ });
+
+ it('should handle presentations with no slides', async () => {
+ const mockPresentation = {
+ data: {
+ title: 'Empty Presentation',
+ slides: [],
+ },
+ };
+
+ mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);
+
+ const result = await slidesService.getText({ presentationId: 'empty-presentation-id' });
+
+ expect(result.content[0].type).toBe('text');
+ expect(result.content[0].text).toContain('Empty Presentation');
+ });
+
+ it('should handle errors gracefully', async () => {
+ mockSlidesAPI.presentations.get.mockRejectedValue(new Error('API Error'));
+
+ const result = await slidesService.getText({ presentationId: 'error-presentation-id' });
+
+ expect(result.content[0].type).toBe('text');
+ const response = JSON.parse(result.content[0].text);
+ expect(response.error).toBe('API Error');
+ });
+ });
+
+ describe('find', () => {
+ beforeEach(async () => {
+ const mockAuthClient = { access_token: 'test-token' };
+ mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any);
+ await slidesService.initialize();
+ });
+
+ it('should find presentations by query', async () => {
+ const mockResponse = {
+ data: {
+ files: [
+ { id: 'pres1', name: 'Presentation 1' },
+ { id: 'pres2', name: 'Presentation 2' },
+ ],
+ nextPageToken: 'next-token',
+ },
+ };
+
+ mockDriveAPI.files.list.mockResolvedValue(mockResponse);
+
+ const result = await slidesService.find({ query: 'test query' });
+ const response = JSON.parse(result.content[0].text);
+
+ expect(mockDriveAPI.files.list).toHaveBeenCalledWith({
+ pageSize: 10,
+ fields: 'nextPageToken, files(id, name)',
+ q: "mimeType='application/vnd.google-apps.presentation' and fullText contains 'test query'",
+ pageToken: undefined,
+ });
+
+ expect(response.files).toHaveLength(2);
+ expect(response.files[0].name).toBe('Presentation 1');
+ expect(response.nextPageToken).toBe('next-token');
+ });
+
+ it('should handle title-specific searches', async () => {
+ const mockResponse = {
+ data: {
+ files: [{ id: 'pres1', name: 'Specific Title' }],
+ },
+ };
+
+ mockDriveAPI.files.list.mockResolvedValue(mockResponse);
+
+ const result = await slidesService.find({ query: 'title:"Specific Title"' });
+ const response = JSON.parse(result.content[0].text);
+
+ expect(mockDriveAPI.files.list).toHaveBeenCalledWith(
+ expect.objectContaining({
+ q: "mimeType='application/vnd.google-apps.presentation' and name contains 'Specific Title'",
+ })
+ );
+
+ expect(response.files).toHaveLength(1);
+ expect(response.files[0].name).toBe('Specific Title');
+ });
+ });
+
+ describe('getMetadata', () => {
+ beforeEach(async () => {
+ const mockAuthClient = { access_token: 'test-token' };
+ mockAuthManager.getAuthenticatedClient.mockResolvedValue(mockAuthClient as any);
+ await slidesService.initialize();
+ });
+
+ it('should retrieve presentation metadata', async () => {
+ const mockPresentation = {
+ data: {
+ presentationId: 'test-id',
+ title: 'Test Presentation',
+ slides: [{ objectId: 'slide1' }, { objectId: 'slide2' }],
+ pageSize: { width: { magnitude: 10 }, height: { magnitude: 7.5 } },
+ masters: [{ objectId: 'master1' }],
+ layouts: [{ objectId: 'layout1' }],
+ notesMaster: { objectId: 'notesMaster1' },
+ },
+ };
+
+ mockSlidesAPI.presentations.get.mockResolvedValue(mockPresentation);
+
+ const result = await slidesService.getMetadata({ presentationId: 'test-id' });
+ const metadata = JSON.parse(result.content[0].text);
+
+ expect(mockSlidesAPI.presentations.get).toHaveBeenCalledWith({
+ presentationId: 'test-id',
+ fields: 'presentationId,title,slides(objectId),pageSize,notesMaster,masters,layouts',
+ });
+
+ expect(metadata.presentationId).toBe('test-id');
+ expect(metadata.title).toBe('Test Presentation');
+ expect(metadata.slideCount).toBe(2);
+ expect(metadata.hasMasters).toBe(true);
+ expect(metadata.hasLayouts).toBe(true);
+ expect(metadata.hasNotesMaster).toBe(true);
+ });
+
+ it('should handle errors gracefully', async () => {
+ mockSlidesAPI.presentations.get.mockRejectedValue(new Error('Metadata Error'));
+
+ const result = await slidesService.getMetadata({ presentationId: 'error-id' });
+ const response = JSON.parse(result.content[0].text);
+
+ expect(response.error).toBe('Metadata Error');
+ });
+ });
+});
diff --git a/workspace-mcp-server/src/__tests__/services/TimeService.test.ts b/workspace-mcp-server/src/__tests__/services/TimeService.test.ts
new file mode 100644
index 00000000..2b3a8452
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/services/TimeService.test.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { TimeService } from '../../services/TimeService';
+
+describe('TimeService', () => {
+ let timeService: TimeService;
+ const mockDate = new Date('2025-08-19T12:34:56Z');
+
+ beforeEach(() => {
+ timeService = new TimeService();
+ jest.useFakeTimers();
+ jest.setSystemTime(mockDate);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('getCurrentDate', () => {
+ it('should return the current date in YYYY-MM-DD format', async () => {
+ const result = await timeService.getCurrentDate();
+ expect(result.content[0].text).toEqual(JSON.stringify({ date: '2025-08-19' }));
+ });
+ });
+
+ describe('getCurrentTime', () => {
+ it('should return the current time in HH:MM:SS format', async () => {
+ const result = await timeService.getCurrentTime();
+ expect(result.content[0].text).toEqual(JSON.stringify({ time: '12:34:56' }));
+ });
+ });
+
+ describe('getTimeZone', () => {
+ it('should return the local timezone', async () => {
+ const result = await timeService.getTimeZone();
+ const expectedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ expect(result.content[0].text).toEqual(JSON.stringify({ timeZone: expectedTimeZone }));
+ });
+ });
+});
diff --git a/workspace-mcp-server/src/__tests__/setup.ts b/workspace-mcp-server/src/__tests__/setup.ts
new file mode 100644
index 00000000..7274d642
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/setup.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Test setup file for Jest
+// This file runs before all tests
+import { jest } from '@jest/globals';
+
+// Mock console methods to reduce noise in test output
+global.console = {
+ ...console,
+ // Keep errors and warnings
+ error: jest.fn(console.error),
+ warn: jest.fn(console.warn),
+ // Silence other logs during tests unless explicitly needed
+ log: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+};
+
+// Set test environment variables
+process.env.NODE_ENV = 'test';
+
+// Increase timeout for integration tests if needed
+jest.setTimeout(10000);
+
+// Clean up after all tests
+afterAll(() => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+});
\ No newline at end of file
diff --git a/workspace-mcp-server/src/__tests__/utils/DriveQueryBuilder.test.ts b/workspace-mcp-server/src/__tests__/utils/DriveQueryBuilder.test.ts
new file mode 100644
index 00000000..ff4a78d7
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/utils/DriveQueryBuilder.test.ts
@@ -0,0 +1,77 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from '@jest/globals';
+import { buildDriveSearchQuery, MIME_TYPES } from '../../utils/DriveQueryBuilder';
+
+describe('DriveQueryBuilder', () => {
+ describe('buildDriveSearchQuery', () => {
+ it('should build fullText query for regular search', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, 'test query');
+ expect(query).toBe("mimeType='application/vnd.google-apps.document' and fullText contains 'test query'");
+ });
+
+ it('should build name query for title-prefixed search', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, 'title:My Presentation');
+ expect(query).toBe("mimeType='application/vnd.google-apps.presentation' and name contains 'My Presentation'");
+ });
+
+ it('should handle quoted title searches', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, 'title:"Budget 2024"');
+ expect(query).toBe("mimeType='application/vnd.google-apps.spreadsheet' and name contains 'Budget 2024'");
+ });
+
+ it('should handle single-quoted title searches', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, "title:'Q4 Report'");
+ expect(query).toBe("mimeType='application/vnd.google-apps.document' and name contains 'Q4 Report'");
+ });
+
+ it('should escape special characters in query', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, "test's query\\path");
+ expect(query).toBe("mimeType='application/vnd.google-apps.document' and fullText contains 'test\\'s query\\\\path'");
+ });
+
+ it('should escape special characters in title search', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, "title:John's Presentation\\2024");
+ expect(query).toBe("mimeType='application/vnd.google-apps.presentation' and name contains 'John\\'s Presentation\\\\2024'");
+ });
+
+ it('should handle empty strings', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, '');
+ expect(query).toBe("mimeType='application/vnd.google-apps.spreadsheet' and fullText contains ''");
+ });
+
+ it('should handle whitespace-only queries', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.DOCUMENT, ' ');
+ expect(query).toBe("mimeType='application/vnd.google-apps.document' and fullText contains ' '");
+ });
+
+ it('should handle title prefix with whitespace', () => {
+ const query = buildDriveSearchQuery(MIME_TYPES.PRESENTATION, ' title: "My Doc" ');
+ expect(query).toBe("mimeType='application/vnd.google-apps.presentation' and name contains 'My Doc'");
+ });
+
+ it('should work with all MIME types', () => {
+ expect(buildDriveSearchQuery(MIME_TYPES.DOCUMENT, 'test'))
+ .toContain('application/vnd.google-apps.document');
+ expect(buildDriveSearchQuery(MIME_TYPES.PRESENTATION, 'test'))
+ .toContain('application/vnd.google-apps.presentation');
+ expect(buildDriveSearchQuery(MIME_TYPES.SPREADSHEET, 'test'))
+ .toContain('application/vnd.google-apps.spreadsheet');
+ expect(buildDriveSearchQuery(MIME_TYPES.FOLDER, 'test'))
+ .toContain('application/vnd.google-apps.folder');
+ });
+ });
+
+ describe('MIME_TYPES constants', () => {
+ it('should have correct MIME type values', () => {
+ expect(MIME_TYPES.DOCUMENT).toBe('application/vnd.google-apps.document');
+ expect(MIME_TYPES.PRESENTATION).toBe('application/vnd.google-apps.presentation');
+ expect(MIME_TYPES.SPREADSHEET).toBe('application/vnd.google-apps.spreadsheet');
+ expect(MIME_TYPES.FOLDER).toBe('application/vnd.google-apps.folder');
+ });
+ });
+});
diff --git a/workspace-mcp-server/src/__tests__/utils/IdUtils.test.ts b/workspace-mcp-server/src/__tests__/utils/IdUtils.test.ts
new file mode 100644
index 00000000..9e52d48e
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/utils/IdUtils.test.ts
@@ -0,0 +1,116 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from '@jest/globals';
+import { extractDocId } from '../../utils/IdUtils';
+
+describe('IdUtils', () => {
+ describe('extractDocId', () => {
+ it('should extract document ID from a full Google Docs URL', () => {
+ const url = 'https://docs.google.com/document/d/1a2b3c4d5e6f7g8h9i0j/edit';
+ const result = extractDocId(url);
+ expect(result).toBe('1a2b3c4d5e6f7g8h9i0j');
+ });
+
+ it('should extract document ID from URL with additional parameters', () => {
+ const url = 'https://docs.google.com/document/d/abc123-XYZ_789/edit?usp=sharing';
+ const result = extractDocId(url);
+ expect(result).toBe('abc123-XYZ_789');
+ });
+
+ it('should extract document ID from URL with preview path', () => {
+ const url = 'https://docs.google.com/document/d/test-doc-id-123/preview';
+ const result = extractDocId(url);
+ expect(result).toBe('test-doc-id-123');
+ });
+
+ it('should extract document ID from URL without protocol', () => {
+ const url = 'docs.google.com/document/d/my_document_id/view';
+ const result = extractDocId(url);
+ expect(result).toBe('my_document_id');
+ });
+
+ it('should return undefined when raw document ID is passed directly', () => {
+ const docId = '1a2b3c4d5e6f7g8h9i0j';
+ const result = extractDocId(docId);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for document ID with underscores and hyphens', () => {
+ const docId = 'doc_id-with-special_chars_123';
+ const result = extractDocId(docId);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined if no pattern matches', () => {
+ const randomString = 'not a doc id or url';
+ const result = extractDocId(randomString);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for empty string', () => {
+ const result = extractDocId('');
+ expect(result).toBeUndefined();
+ });
+
+ it('should extract from partial URL path', () => {
+ const partialPath = '/document/d/abc123xyz/';
+ const result = extractDocId(partialPath);
+ expect(result).toBe('abc123xyz');
+ });
+
+ it('should handle URL with multiple document paths (edge case)', () => {
+ // Should extract the first match
+ const url = '/document/d/first123/document/d/second456/';
+ const result = extractDocId(url);
+ expect(result).toBe('first123');
+ });
+
+ it('should handle very long document IDs', () => {
+ const longId = 'a'.repeat(100) + '_' + 'b'.repeat(50);
+ const url = `https://docs.google.com/document/d/${longId}/edit`;
+ const result = extractDocId(url);
+ expect(result).toBe(longId);
+ });
+
+ it('should handle document ID with only numbers', () => {
+ const url = 'https://docs.google.com/document/d/1234567890/edit';
+ const result = extractDocId(url);
+ expect(result).toBe('1234567890');
+ });
+
+ it('should handle document ID with only letters', () => {
+ const url = 'https://docs.google.com/document/d/abcdefghij/edit';
+ const result = extractDocId(url);
+ expect(result).toBe('abcdefghij');
+ });
+
+ it('should handle malformed URLs gracefully', () => {
+ const malformedUrl = 'https://docs.google.com/document/edit';
+ const result = extractDocId(malformedUrl);
+ // Should return the input as-is when pattern doesn't match
+ expect(result).toBeUndefined();
+ });
+
+ it('should be case sensitive for document IDs', () => {
+ const url = 'https://docs.google.com/document/d/AbCdEfGhIj/edit';
+ const result = extractDocId(url);
+ expect(result).toBe('AbCdEfGhIj');
+ });
+
+ it('should extract document ID from a complex URL with resourcekey', () => {
+ const url = 'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI/edit?resourcekey=0-X_p2TPxpk0visLTHHMF7Yg&tab=t.0';
+ const result = extractDocId(url);
+ expect(result).toBe('1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI');
+ });
+
+ it('should extract document ID from a URL without a trailing slash', () => {
+ const url = 'https://docs.google.com/document/d/1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI';
+ const result = extractDocId(url);
+ expect(result).toBe('1MGqTbt5joTs40QS-YZTP9QH1-TxQ5tij7RgXPFWMPiI');
+ });
+ });
+});
diff --git a/workspace-mcp-server/src/__tests__/utils/MimeHelper.test.ts b/workspace-mcp-server/src/__tests__/utils/MimeHelper.test.ts
new file mode 100644
index 00000000..c5106b8d
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/utils/MimeHelper.test.ts
@@ -0,0 +1,382 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from '@jest/globals';
+import { MimeHelper } from '../../utils/MimeHelper';
+
+describe('MimeHelper', () => {
+ describe('createMimeMessage', () => {
+ it('should create a basic plain text email', () => {
+ const encoded = MimeHelper.createMimeMessage({
+ to: 'recipient@example.com',
+ subject: 'Test Subject',
+ body: 'This is a test email body.',
+ });
+
+ // Decode the message to verify its structure
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('To: recipient@example.com');
+ expect(decoded).toContain('Subject: =?utf-8?B?VGVzdCBTdWJqZWN0?=');
+ expect(decoded).toContain('Content-Type: text/plain; charset=utf-8');
+ expect(decoded).toContain('This is a test email body.');
+ });
+
+ it('should create an HTML email', () => {
+ const encoded = MimeHelper.createMimeMessage({
+ to: 'recipient@example.com',
+ subject: 'HTML Email',
+ body: 'Hello World
This is HTML content.
',
+ isHtml: true,
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('Content-Type: text/html; charset=utf-8');
+ expect(decoded).toContain('Hello World
');
+ });
+
+ it('should include optional headers when provided', () => {
+ const encoded = MimeHelper.createMimeMessage({
+ to: 'recipient@example.com',
+ subject: 'Full Headers Test',
+ body: 'Test body',
+ from: 'sender@example.com',
+ cc: 'cc@example.com',
+ bcc: 'bcc@example.com',
+ replyTo: 'reply@example.com',
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('From: sender@example.com');
+ expect(decoded).toContain('To: recipient@example.com');
+ expect(decoded).toContain('Cc: cc@example.com');
+ expect(decoded).toContain('Bcc: bcc@example.com');
+ expect(decoded).toContain('Reply-To: reply@example.com');
+ });
+
+ it('should handle UTF-8 subjects correctly', () => {
+ const encoded = MimeHelper.createMimeMessage({
+ to: 'recipient@example.com',
+ subject: 'Test with emoji 🎉 and special chars é ñ',
+ body: 'Test body',
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ // The subject should be base64 encoded
+ expect(decoded).toContain('Subject: =?utf-8?B?');
+
+ // Decode the subject to verify it's correct
+ const subjectMatch = decoded.match(/Subject: =\?utf-8\?B\?([^?]+)\?=/);
+ if (subjectMatch) {
+ const decodedSubject = Buffer.from(subjectMatch[1], 'base64').toString('utf-8');
+ expect(decodedSubject).toBe('Test with emoji 🎉 and special chars é ñ');
+ }
+ });
+
+ it('should properly format the MIME message with CRLF line endings', () => {
+ const encoded = MimeHelper.createMimeMessage({
+ to: 'recipient@example.com',
+ subject: 'CRLF Test',
+ body: 'Line 1\nLine 2',
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ // Should use CRLF (\r\n) as line separators
+ expect(decoded).toContain('\r\n');
+ expect(decoded.split('\r\n').length).toBeGreaterThan(3);
+ });
+
+ it('should handle multiple recipients in to field', () => {
+ const encoded = MimeHelper.createMimeMessage({
+ to: 'recipient1@example.com, recipient2@example.com',
+ subject: 'Multiple Recipients',
+ body: 'Test body',
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('To: recipient1@example.com, recipient2@example.com');
+ });
+
+ it('should encode to base64url format (no padding, URL-safe characters)', () => {
+ const encoded = MimeHelper.createMimeMessage({
+ to: 'recipient@example.com',
+ subject: 'Base64URL Test',
+ body: 'Test content that should be encoded',
+ });
+
+ // Check that it doesn't contain standard base64 characters
+ expect(encoded).not.toContain('+');
+ expect(encoded).not.toContain('/');
+ expect(encoded).not.toContain('=');
+
+ // Should only contain base64url characters
+ expect(encoded).toMatch(/^[A-Za-z0-9\-_]+$/);
+ });
+ });
+
+ describe('createMimeMessageWithAttachments', () => {
+ it('should create a message without attachments when none provided', () => {
+ const encoded = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'No Attachments',
+ body: 'Simple message',
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ // Should not contain multipart boundary
+ expect(decoded).not.toContain('Content-Type: multipart/mixed');
+ expect(decoded).toContain('Content-Type: text/plain; charset=utf-8');
+ });
+
+ it('should create a multipart message with attachments', () => {
+ const attachments = [
+ {
+ filename: 'test.txt',
+ content: Buffer.from('Hello, World!'),
+ contentType: 'text/plain',
+ },
+ ];
+
+ const encoded = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'With Attachment',
+ body: 'Message with attachment',
+ attachments,
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('Content-Type: multipart/mixed; boundary=');
+ expect(decoded).toContain('Content-Disposition: attachment; filename="test.txt"');
+ expect(decoded).toContain('Content-Type: text/plain');
+ expect(decoded).toContain('Content-Transfer-Encoding: base64');
+ });
+
+ it('should handle multiple attachments', () => {
+ const attachments = [
+ {
+ filename: 'file1.txt',
+ content: 'First file content',
+ contentType: 'text/plain',
+ },
+ {
+ filename: 'file2.pdf',
+ content: Buffer.from('PDF content'),
+ contentType: 'application/pdf',
+ },
+ ];
+
+ const encoded = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'Multiple Attachments',
+ body: 'Message with multiple attachments',
+ attachments,
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('filename="file1.txt"');
+ expect(decoded).toContain('filename="file2.pdf"');
+ expect(decoded).toContain('Content-Type: text/plain');
+ expect(decoded).toContain('Content-Type: application/pdf');
+ });
+
+ it('should use default content type for attachments without specified type', () => {
+ const attachments = [
+ {
+ filename: 'unknown.bin',
+ content: Buffer.from('Binary content'),
+ },
+ ];
+
+ const encoded = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'Default Content Type',
+ body: 'Message',
+ attachments,
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('Content-Type: application/octet-stream');
+ });
+
+ it('should properly format attachment content in 76-character lines', () => {
+ const longContent = 'a'.repeat(200); // Long content that needs to be wrapped
+ const attachments = [
+ {
+ filename: 'long.txt',
+ content: Buffer.from(longContent),
+ },
+ ];
+
+ const encoded = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'Long Attachment',
+ body: 'Message',
+ attachments,
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ // Find the base64 encoded attachment content
+ const lines = decoded.split('\r\n');
+ const attachmentStart = lines.findIndex(line =>
+ line.includes('Content-Transfer-Encoding: base64')
+ );
+
+ if (attachmentStart !== -1) {
+ // Check lines after the attachment header
+ for (let i = attachmentStart + 2; i < lines.length; i++) {
+ const line = lines[i];
+ if (line.startsWith('--')) break; // Reached boundary
+ if (line.length > 0) {
+ expect(line.length).toBeLessThanOrEqual(76);
+ }
+ }
+ }
+ });
+
+ it('should handle HTML body with attachments', () => {
+ const attachments = [
+ {
+ filename: 'doc.html',
+ content: 'HTML Doc',
+ contentType: 'text/html',
+ },
+ ];
+
+ const encoded = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'HTML with Attachment',
+ body: 'HTML Message Body
',
+ isHtml: true,
+ attachments,
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ // Body should be HTML
+ expect(decoded).toMatch(/Content-Type: text\/html; charset=utf-8\r\n\r\nHTML Message Body<\/p>/);
+ // Attachment should also be present
+ expect(decoded).toContain('filename="doc.html"');
+ });
+
+ it('should include all optional headers with attachments', () => {
+ const attachments = [
+ {
+ filename: 'test.txt',
+ content: 'Test',
+ },
+ ];
+
+ const encoded = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'Full Headers with Attachments',
+ body: 'Test body',
+ from: 'sender@example.com',
+ cc: 'cc@example.com',
+ bcc: 'bcc@example.com',
+ attachments,
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(encoded);
+
+ expect(decoded).toContain('From: sender@example.com');
+ expect(decoded).toContain('Cc: cc@example.com');
+ expect(decoded).toContain('Bcc: bcc@example.com');
+ expect(decoded).toContain('MIME-Version: 1.0');
+ });
+
+ it('should create unique boundary for each message', () => {
+ const attachments = [
+ {
+ filename: 'test.txt',
+ content: 'Test',
+ },
+ ];
+
+ const encoded1 = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'Message 1',
+ body: 'Body 1',
+ attachments,
+ });
+
+ const encoded2 = MimeHelper.createMimeMessageWithAttachments({
+ to: 'recipient@example.com',
+ subject: 'Message 2',
+ body: 'Body 2',
+ attachments,
+ });
+
+ const decoded1 = MimeHelper.decodeBase64Url(encoded1);
+ const decoded2 = MimeHelper.decodeBase64Url(encoded2);
+
+ const boundary1Match = decoded1.match(/boundary="([^"]+)"/);
+ const boundary2Match = decoded2.match(/boundary="([^"]+)"/);
+
+ expect(boundary1Match).toBeTruthy();
+ expect(boundary2Match).toBeTruthy();
+ expect(boundary1Match![1]).not.toBe(boundary2Match![1]);
+ });
+ });
+
+ describe('decodeBase64Url', () => {
+ it('should decode base64url encoded strings', () => {
+ const original = 'Hello, World! This is a test.';
+ const base64url = Buffer.from(original)
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+
+ const decoded = MimeHelper.decodeBase64Url(base64url);
+
+ expect(decoded).toBe(original);
+ });
+
+ it('should handle strings without padding', () => {
+ const base64url = 'SGVsbG8'; // "Hello" without padding
+ const decoded = MimeHelper.decodeBase64Url(base64url);
+
+ expect(decoded).toBe('Hello');
+ });
+
+ it('should convert URL-safe characters back to standard base64', () => {
+ const base64url = 'SGVsbG8-V29ybGRfIQ'; // Contains - and _
+ const decoded = MimeHelper.decodeBase64Url(base64url);
+
+ expect(decoded).toBeTruthy();
+ expect(typeof decoded).toBe('string');
+ });
+
+ it('should handle empty strings', () => {
+ const decoded = MimeHelper.decodeBase64Url('');
+
+ expect(decoded).toBe('');
+ });
+
+ it('should properly decode a complete MIME message', () => {
+ const mimeMessage = MimeHelper.createMimeMessage({
+ to: 'test@example.com',
+ subject: 'Test',
+ body: 'Test body',
+ });
+
+ const decoded = MimeHelper.decodeBase64Url(mimeMessage);
+
+ expect(decoded).toContain('To: test@example.com');
+ expect(decoded).toContain('Test body');
+ });
+ });
+});
\ No newline at end of file
diff --git a/workspace-mcp-server/src/__tests__/utils/logger.test.ts b/workspace-mcp-server/src/__tests__/utils/logger.test.ts
new file mode 100644
index 00000000..6d00ac1e
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/utils/logger.test.ts
@@ -0,0 +1,235 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
+import * as path from 'node:path';
+
+// Mock fs/promises module BEFORE any imports that use it
+jest.mock('fs/promises');
+
+describe('logger', () => {
+ let consoleErrorSpy: any;
+ let logToFile: (message: string) => void;
+ let setLoggingEnabled: (enabled: boolean) => void;
+ let fs: any;
+
+ async function setupLogger(appendFileMock?: any) {
+ jest.resetModules();
+ jest.doMock('fs/promises', () => ({
+ mkdir: jest.fn(() => Promise.resolve()),
+ appendFile: appendFileMock || jest.fn(() => Promise.resolve()),
+ }));
+
+ fs = await import('node:fs/promises');
+ const loggerModule = await import('../../utils/logger');
+ logToFile = loggerModule.logToFile;
+ setLoggingEnabled = loggerModule.setLoggingEnabled;
+ setLoggingEnabled(true);
+ jest.clearAllMocks();
+ }
+
+ beforeEach(() => {
+ // Clear all mocks
+ jest.clearAllMocks();
+
+ // Clear module cache to ensure fresh imports
+ jest.resetModules();
+
+ // Spy on console.error
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('module initialization', () => {
+ it('should create log directory on module load', async () => {
+ // Set up mocks
+ jest.doMock('fs/promises', () => ({
+ mkdir: jest.fn(() => Promise.resolve()),
+ appendFile: jest.fn(() => Promise.resolve()),
+ }));
+
+ // Import the module (this triggers initialization)
+ await import('../../utils/logger');
+
+ // Get the mocked fs module
+ fs = await import('node:fs/promises');
+
+ // Wait for async initialization
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(fs.mkdir).toHaveBeenCalledWith(
+ expect.stringContaining('logs'),
+ { recursive: true }
+ );
+ });
+
+ it('should handle directory creation errors gracefully', async () => {
+ const mkdirError = new Error('Permission denied');
+
+ // Set up mocks
+ jest.doMock('fs/promises', () => ({
+ mkdir: jest.fn(() => Promise.reject(mkdirError)),
+ appendFile: jest.fn(() => Promise.resolve()),
+ }));
+
+ // Import the module
+ await import('../../utils/logger');
+
+ // Wait for async initialization
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Could not create log directory:',
+ mkdirError
+ );
+ });
+ });
+
+ describe('logToFile', () => {
+ beforeEach(async () => {
+ await setupLogger();
+ });
+
+ it('should append message with timestamp to log file', async () => {
+ const testMessage = 'Test log message';
+ const mockDate = new Date('2024-01-01T12:00:00.000Z');
+ jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
+
+ logToFile(testMessage);
+
+ // Wait for async operation
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(fs.appendFile).toHaveBeenCalledWith(
+ expect.stringContaining('server.log'),
+ '2024-01-01T12:00:00.000Z - Test log message\n'
+ );
+ });
+
+ it('should handle multiple log messages', async () => {
+ logToFile('First message');
+ logToFile('Second message');
+ logToFile('Third message');
+
+ // Wait for async operations
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(fs.appendFile).toHaveBeenCalledTimes(3);
+ expect(fs.appendFile).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('server.log'),
+ expect.stringContaining('First message')
+ );
+ expect(fs.appendFile).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('server.log'),
+ expect.stringContaining('Second message')
+ );
+ expect(fs.appendFile).toHaveBeenNthCalledWith(
+ 3,
+ expect.stringContaining('server.log'),
+ expect.stringContaining('Third message')
+ );
+ });
+
+ it('should log to console.error when file write fails', async () => {
+ const writeError = new Error('Disk full');
+ await setupLogger(jest.fn(() => Promise.reject(writeError)));
+
+ logToFile('Failed write test');
+
+ // Wait for async operation
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Failed to write to log file:',
+ writeError
+ );
+ });
+
+ it('should format log message correctly', async () => {
+ const mockDate = new Date('2024-12-25T18:30:45.123Z');
+ jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
+
+ logToFile('Holiday log entry');
+
+ // Wait for async operation
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ const expectedMessage = '2024-12-25T18:30:45.123Z - Holiday log entry\n';
+ expect(fs.appendFile).toHaveBeenCalledWith(
+ expect.any(String),
+ expectedMessage
+ );
+ });
+
+ it('should handle empty messages', async () => {
+ const mockDate = new Date('2024-01-01T12:00:00.000Z');
+ jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
+ logToFile('');
+
+ // Wait for async operation
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(fs.appendFile).toHaveBeenCalledWith(
+ expect.stringContaining('server.log'),
+ '2024-01-01T12:00:00.000Z - \n'
+ );
+ });
+
+ it('should handle special characters in messages', async () => {
+ const specialMessage = 'Message with \n newline, \t tab, and "quotes"';
+
+ logToFile(specialMessage);
+
+ // Wait for async operation
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(fs.appendFile).toHaveBeenCalledWith(
+ expect.stringContaining('server.log'),
+ expect.stringContaining(specialMessage)
+ );
+ });
+
+ it('should use correct log file path', async () => {
+ logToFile('Path test');
+
+ // Wait for async operation
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ const callArgs = (fs.appendFile as jest.Mock).mock.calls[0];
+ const logPath = callArgs[0] as string;
+
+ expect(logPath).toContain('logs');
+ expect(logPath).toContain('server.log');
+ expect(path.isAbsolute(logPath)).toBe(true);
+ });
+
+ it('should not throw when appendFile fails', async () => {
+ await setupLogger(jest.fn(() => Promise.reject(new Error('Write failed'))));
+
+ // Should not throw
+ expect(() => logToFile('Test message')).not.toThrow();
+
+ // Wait for async operation
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
+
+ it('should not log when logging is disabled', () => {
+ setLoggingEnabled(false);
+ const testMessage = 'Test log message';
+
+ logToFile(testMessage);
+
+ expect(fs.appendFile).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/workspace-mcp-server/src/__tests__/utils/markdownToDocsRequests.test.ts b/workspace-mcp-server/src/__tests__/utils/markdownToDocsRequests.test.ts
new file mode 100644
index 00000000..4301be45
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/utils/markdownToDocsRequests.test.ts
@@ -0,0 +1,247 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from '@jest/globals';
+import { parseMarkdownToDocsRequests, processMarkdownLineBreaks } from '../../utils/markdownToDocsRequests';
+
+describe('markdownToDocsRequests', () => {
+ describe('parseMarkdownToDocsRequests', () => {
+ // Skip tests that rely on marked working if it's not functioning in test environment
+ // We'll check markedWorks inside each test instead of using a variable
+
+ it('should handle bold text', () => {
+ const markdown = 'This is **bold** text';
+ const startIndex = 10;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('This is bold text');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0]).toEqual({
+ updateTextStyle: {
+ range: {
+ startIndex: 18, // 10 + 8 (position of "bold")
+ endIndex: 22, // 10 + 12 (end of "bold")
+ },
+ textStyle: {
+ bold: true,
+ },
+ fields: 'bold'
+ }
+ });
+ });
+
+ it('should handle italic text with asterisks', () => {
+ const markdown = 'This is *italic* text';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('This is italic text');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0]).toEqual({
+ updateTextStyle: {
+ range: {
+ startIndex: 8,
+ endIndex: 14,
+ },
+ textStyle: {
+ italic: true,
+ },
+ fields: 'italic'
+ }
+ });
+ });
+
+ it('should handle italic text with underscores', () => {
+ const markdown = 'This is _italic_ text';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('This is italic text');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0].updateTextStyle?.textStyle?.italic).toBe(true);
+ });
+
+ it('should handle inline code', () => {
+ const markdown = 'This is `code` text';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('This is code text');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0]).toEqual({
+ updateTextStyle: {
+ range: {
+ startIndex: 8,
+ endIndex: 12,
+ },
+ textStyle: {
+ weightedFontFamily: {
+ fontFamily: 'Courier New',
+ weight: 400
+ },
+ backgroundColor: {
+ color: {
+ rgbColor: {
+ red: 0.95,
+ green: 0.95,
+ blue: 0.95
+ }
+ }
+ }
+ },
+ fields: 'weightedFontFamily,backgroundColor'
+ }
+ });
+ });
+
+ it('should handle multiple formatting in one text', () => {
+ const markdown = 'Text with **bold**, *italic*, and `code` formatting';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('Text with bold, italic, and code formatting');
+ expect(result.formattingRequests).toHaveLength(3);
+ });
+
+ it('should handle text with no formatting', () => {
+ const markdown = 'Plain text without any formatting';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('Plain text without any formatting');
+ expect(result.formattingRequests).toHaveLength(0);
+ });
+
+ it('should handle overlapping formatting (keeps first)', () => {
+ const markdown = '**bold and text**';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ // The bold formatting should be applied
+ expect(result.plainText).toBe('bold and text');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0].updateTextStyle?.textStyle?.bold).toBe(true);
+ });
+
+ it('should respect the startIndex parameter', () => {
+ const markdown = '**bold**';
+ const startIndex = 100;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('bold');
+ expect(result.formattingRequests[0]).toEqual({
+ updateTextStyle: {
+ range: {
+ startIndex: 100,
+ endIndex: 104,
+ },
+ textStyle: {
+ bold: true,
+ },
+ fields: 'bold'
+ }
+ });
+ });
+
+ it('should handle heading 1', () => {
+ const markdown = '# Main Title';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('Main Title');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0].updateParagraphStyle?.paragraphStyle?.namedStyleType).toBe('HEADING_1');
+ expect(result.formattingRequests[0].updateParagraphStyle?.range?.startIndex).toBe(0);
+ expect(result.formattingRequests[0].updateParagraphStyle?.range?.endIndex).toBe(10);
+ });
+
+ it('should handle heading 2', () => {
+ const markdown = '## Section Title';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('Section Title');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0].updateParagraphStyle?.paragraphStyle?.namedStyleType).toBe('HEADING_2');
+ expect(result.formattingRequests[0].updateParagraphStyle?.range?.startIndex).toBe(0);
+ expect(result.formattingRequests[0].updateParagraphStyle?.range?.endIndex).toBe(13);
+ });
+
+ it('should handle heading 3', () => {
+ const markdown = '### Subsection';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('Subsection');
+ expect(result.formattingRequests).toHaveLength(1);
+ expect(result.formattingRequests[0].updateParagraphStyle?.paragraphStyle?.namedStyleType).toBe('HEADING_3');
+ expect(result.formattingRequests[0].updateParagraphStyle?.range?.startIndex).toBe(0);
+ expect(result.formattingRequests[0].updateParagraphStyle?.range?.endIndex).toBe(10);
+ });
+
+ it('should handle mixed headings and text', () => {
+ const markdown = '# Title\n\nSome text\n\n## Section\n\nMore text';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toContain('Title');
+ expect(result.plainText).toContain('Some text');
+ expect(result.plainText).toContain('Section');
+ expect(result.plainText).toContain('More text');
+
+ // Should have formatting for both headings
+ const headingFormats = result.formattingRequests.filter(req =>
+ req.updateParagraphStyle?.paragraphStyle?.namedStyleType !== undefined
+ );
+ expect(headingFormats).toHaveLength(2);
+ });
+
+ it('should handle inline formatting within headings', () => {
+ const markdown = '# Main **bold** Title';
+ const startIndex = 0;
+ const result = parseMarkdownToDocsRequests(markdown, startIndex);
+
+ expect(result.plainText).toBe('Main bold Title');
+
+ // Should have both heading and bold formatting
+ const headingFormat = result.formattingRequests.find(req =>
+ req.updateParagraphStyle?.paragraphStyle?.namedStyleType !== undefined
+ );
+ const boldFormat = result.formattingRequests.find(req =>
+ req.updateTextStyle?.textStyle?.bold === true
+ );
+
+ expect(headingFormat).toBeDefined();
+ expect(boldFormat).toBeDefined();
+ });
+ });
+
+ describe('processMarkdownLineBreaks', () => {
+ it('should preserve single line breaks', () => {
+ const text = 'Line 1\nLine 2';
+ const result = processMarkdownLineBreaks(text);
+ expect(result).toBe('Line 1\nLine 2');
+ });
+
+ it('should convert double line breaks to double', () => {
+ const text = 'Paragraph 1\n\nParagraph 2';
+ const result = processMarkdownLineBreaks(text);
+ expect(result).toBe('Paragraph 1\n\nParagraph 2');
+ });
+
+ it('should convert multiple line breaks to double', () => {
+ const text = 'Paragraph 1\n\n\n\nParagraph 2';
+ const result = processMarkdownLineBreaks(text);
+ expect(result).toBe('Paragraph 1\n\nParagraph 2');
+ });
+
+ it('should handle text without line breaks', () => {
+ const text = 'Single line of text';
+ const result = processMarkdownLineBreaks(text);
+ expect(result).toBe('Single line of text');
+ });
+ });
+});
\ No newline at end of file
diff --git a/workspace-mcp-server/src/__tests__/utils/secure-browser-launcher.test.ts b/workspace-mcp-server/src/__tests__/utils/secure-browser-launcher.test.ts
new file mode 100644
index 00000000..141e6214
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/utils/secure-browser-launcher.test.ts
@@ -0,0 +1,312 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ describe,
+ it,
+ expect,
+ beforeEach,
+ jest,
+ afterEach,
+} from '@jest/globals';
+import { openBrowserSecurely } from '../../utils/secure-browser-launcher';
+import { platform } from 'node:os';
+import { EventEmitter } from 'node:events';
+import { ChildProcess } from 'node:child_process';
+
+jest.mock('node:os');
+
+const mockPlatform = platform as jest.Mock;
+
+describe('secure-browser-launcher', () => {
+ let mockChild: EventEmitter;
+ let mockExecFile: jest.Mock;
+
+ beforeEach(() => {
+ mockChild = new EventEmitter();
+ mockExecFile = jest.fn().mockReturnValue(mockChild as ChildProcess);
+ mockPlatform.mockReturnValue('darwin'); // Default to macOS
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ function simulateSuccess() {
+ process.nextTick(() => {
+ mockChild.emit('exit', 0);
+ });
+ }
+
+ function simulateFailure(error = new Error('Command failed')) {
+ process.nextTick(() => {
+ mockChild.emit('error', error);
+ });
+ }
+
+ describe('URL validation', () => {
+ it('should allow valid HTTP URLs', async () => {
+ const openPromise = openBrowserSecurely(
+ 'http://example.com',
+ mockExecFile as any
+ );
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'open',
+ ['http://example.com'],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+
+ it('should allow valid HTTPS URLs', async () => {
+ const openPromise = openBrowserSecurely(
+ 'https://example.com',
+ mockExecFile as any
+ );
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'open',
+ ['https://example.com'],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+
+ it('should reject non-HTTP(S) protocols', async () => {
+ await expect(
+ openBrowserSecurely('file:///etc/passwd', mockExecFile as any)
+ ).rejects.toThrow('Unsafe protocol');
+ await expect(
+ openBrowserSecurely('javascript:alert(1)', mockExecFile as any)
+ ).rejects.toThrow('Unsafe protocol');
+ await expect(
+ openBrowserSecurely('ftp://example.com', mockExecFile as any)
+ ).rejects.toThrow('Unsafe protocol');
+ });
+
+ it('should reject invalid URLs', async () => {
+ await expect(
+ openBrowserSecurely('not-a-url', mockExecFile as any)
+ ).rejects.toThrow('Invalid URL');
+ await expect(
+ openBrowserSecurely('', mockExecFile as any)
+ ).rejects.toThrow('Invalid URL');
+ });
+
+ it('should reject URLs with control characters', async () => {
+ await expect(
+ openBrowserSecurely(
+ 'http://example.com\nmalicious-command',
+ mockExecFile as any
+ )
+ ).rejects.toThrow('invalid characters');
+ await expect(
+ openBrowserSecurely(
+ 'http://example.com\rmalicious-command',
+ mockExecFile as any
+ )
+ ).rejects.toThrow('invalid characters');
+ await expect(
+ openBrowserSecurely('http://example.com\x00', mockExecFile as any)
+ ).rejects.toThrow('invalid characters');
+ });
+ });
+
+ describe('Command injection prevention', () => {
+ it('should prevent PowerShell command injection on Windows', async () => {
+ mockPlatform.mockReturnValue('win32');
+ const maliciousUrl =
+ "http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))";
+
+ const openPromise = openBrowserSecurely(maliciousUrl, mockExecFile as any);
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'powershell.exe',
+ [
+ '-NoProfile',
+ '-NonInteractive',
+ '-WindowStyle',
+ 'Hidden',
+ '-Command',
+ `Start-Process '${maliciousUrl.replace(/'/g, "''")}'`,
+ ],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+
+ it('should handle URLs with special shell characters safely', async () => {
+ const urlsWithSpecialChars = [
+ 'http://example.com/path?param=value&other=$value',
+ 'http://example.com/path#fragment;command',
+ 'http://example.com/$(whoami)',
+ 'http://example.com/`command`',
+ 'http://example.com/|pipe',
+ 'http://example.com/>redirect',
+ ];
+
+ for (const url of urlsWithSpecialChars) {
+ const openPromise = openBrowserSecurely(url, mockExecFile as any);
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'open',
+ [url],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ }
+ });
+
+ it('should properly escape single quotes in URLs on Windows', async () => {
+ mockPlatform.mockReturnValue('win32');
+ const urlWithSingleQuotes =
+ "http://example.com/path?name=O'Brien&test='value'";
+
+ const openPromise = openBrowserSecurely(
+ urlWithSingleQuotes,
+ mockExecFile as any
+ );
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'powershell.exe',
+ [
+ '-NoProfile',
+ '-NonInteractive',
+ '-WindowStyle',
+ 'Hidden',
+ '-Command',
+ `Start-Process 'http://example.com/path?name=O''Brien&test=''value'''`,
+ ],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+ });
+
+ describe('Platform-specific behavior', () => {
+ it('should use correct command on macOS', async () => {
+ const openPromise = openBrowserSecurely(
+ 'https://example.com',
+ mockExecFile as any
+ );
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'open',
+ ['https://example.com'],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+
+ it('should use PowerShell on Windows', async () => {
+ mockPlatform.mockReturnValue('win32');
+ const openPromise = openBrowserSecurely(
+ 'https://example.com',
+ mockExecFile as any
+ );
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'powershell.exe',
+ [
+ '-NoProfile',
+ '-NonInteractive',
+ '-WindowStyle',
+ 'Hidden',
+ '-Command',
+ `Start-Process 'https://example.com'`,
+ ],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+
+ it('should use xdg-open on Linux', async () => {
+ mockPlatform.mockReturnValue('linux');
+ const openPromise = openBrowserSecurely(
+ 'https://example.com',
+ mockExecFile as any
+ );
+ simulateSuccess();
+ await expect(openPromise).resolves.toBeUndefined();
+ expect(mockExecFile).toHaveBeenCalledWith(
+ 'xdg-open',
+ ['https://example.com'],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+
+ it('should throw on unsupported platforms', async () => {
+ mockPlatform.mockReturnValue('aix');
+ await expect(
+ openBrowserSecurely('https://example.com', mockExecFile as any)
+ ).rejects.toThrow('Unsupported platform');
+ });
+ });
+
+ describe('Error handling', () => {
+ it('should handle browser launch failures gracefully', async () => {
+ const openPromise = openBrowserSecurely(
+ 'https://example.com',
+ mockExecFile as any
+ );
+ simulateFailure();
+ await expect(openPromise).rejects.toThrow('Failed to open browser');
+ });
+
+ it('should try fallback browsers on Linux', async () => {
+ mockPlatform.mockReturnValue('linux');
+
+ const mockChild2 = new EventEmitter();
+ mockExecFile.mockImplementationOnce(() => {
+ // Defer the emit call to allow the 'on' handlers to be set up.
+ process.nextTick(() => {
+ mockChild.emit('error', new Error('xdg-open not found'));
+ });
+ return mockChild as ChildProcess;
+ });
+ mockExecFile.mockImplementationOnce(() => {
+ process.nextTick(() => {
+ mockChild2.emit('exit', 0);
+ });
+ return mockChild2 as ChildProcess;
+ });
+
+ const openPromise = openBrowserSecurely(
+ 'https://example.com',
+ mockExecFile as any
+ );
+
+ await expect(openPromise).resolves.toBeUndefined();
+
+ expect(mockExecFile).toHaveBeenCalledTimes(2);
+ expect(mockExecFile).toHaveBeenNthCalledWith(
+ 1,
+ 'xdg-open',
+ ['https://example.com'],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ expect(mockExecFile).toHaveBeenNthCalledWith(
+ 2,
+ 'gnome-open',
+ ['https://example.com'],
+ expect.any(Object),
+ expect.any(Function)
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/workspace-mcp-server/src/__tests__/utils/validation.test.ts b/workspace-mcp-server/src/__tests__/utils/validation.test.ts
new file mode 100644
index 00000000..10fb1671
--- /dev/null
+++ b/workspace-mcp-server/src/__tests__/utils/validation.test.ts
@@ -0,0 +1,148 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from '@jest/globals';
+import {
+ validateEmail,
+ validateDateTime,
+ validateDocumentId,
+ extractDocumentId,
+ emailSchema,
+ emailArraySchema,
+ searchQuerySchema,
+ ValidationError
+} from '../../utils/validation';
+
+describe('Validation Utilities', () => {
+ describe('Email Validation', () => {
+ it('should validate correct email addresses', () => {
+ expect(validateEmail('user@example.com')).toEqual({ success: true });
+ expect(validateEmail('john.doe+tag@company.co.uk')).toEqual({ success: true });
+ });
+
+ it('should reject invalid email addresses', () => {
+ expect(validateEmail('invalid')).toMatchObject({ success: false });
+ expect(validateEmail('@example.com')).toMatchObject({ success: false });
+ expect(validateEmail('user@')).toMatchObject({ success: false });
+ expect(validateEmail('user @example.com')).toMatchObject({ success: false });
+ });
+
+ it('should handle email arrays', () => {
+ const result1 = emailSchema.safeParse('user@example.com');
+ expect(result1.success).toBe(true);
+
+ const result2 = emailSchema.safeParse(['user1@example.com', 'user2@example.com']);
+ expect(result2.success).toBe(false); // Single schema doesn't accept arrays
+ });
+
+ it('should validate emailArraySchema with single email', () => {
+ const result = emailArraySchema.safeParse('user@example.com');
+ expect(result.success).toBe(true);
+ });
+
+ it('should validate emailArraySchema with array of emails', () => {
+ const result = emailArraySchema.safeParse(['user1@example.com', 'user2@example.com']);
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject emailArraySchema with invalid emails in array', () => {
+ const result = emailArraySchema.safeParse(['valid@example.com', 'invalid-email']);
+ expect(result.success).toBe(false);
+ });
+ });
+
+ describe('DateTime Validation', () => {
+ it('should validate correct ISO 8601 datetime formats', () => {
+ expect(validateDateTime('2024-01-15T10:30:00Z')).toEqual({ success: true });
+ expect(validateDateTime('2024-01-15T10:30:00.000Z')).toEqual({ success: true });
+ expect(validateDateTime('2024-01-15T10:30:00-05:00')).toEqual({ success: true });
+ expect(validateDateTime('2024-01-15T10:30:00+09:30')).toEqual({ success: true });
+ });
+
+ it('should reject invalid datetime formats', () => {
+ expect(validateDateTime('2024-01-15')).toMatchObject({ success: false });
+ expect(validateDateTime('10:30:00')).toMatchObject({ success: false });
+ expect(validateDateTime('2024-01-15 10:30:00')).toMatchObject({ success: false });
+ expect(validateDateTime('not a date')).toMatchObject({ success: false });
+ });
+
+ it('should reject invalid dates', () => {
+ expect(validateDateTime('2024-13-01T10:30:00Z')).toMatchObject({ success: false }); // Invalid month
+ // Note: JavaScript Date constructor accepts Feb 30 and converts it to March 1st or 2nd
+ // So this test would pass as valid. We'd need more complex validation for this.
+ expect(validateDateTime('2024-00-01T10:30:00Z')).toMatchObject({ success: false }); // Invalid month (0)
+ });
+ });
+
+ describe('Document ID Validation', () => {
+ it('should validate correct document IDs', () => {
+ expect(validateDocumentId('1a2b3c4d5e6f7g8h9i0j')).toEqual({ success: true });
+ expect(validateDocumentId('abc-123_XYZ')).toEqual({ success: true });
+ expect(validateDocumentId('Document_ID-123')).toEqual({ success: true });
+ });
+
+ it('should reject invalid document IDs', () => {
+ expect(validateDocumentId('doc id with spaces')).toMatchObject({ success: false });
+ expect(validateDocumentId('doc#id')).toMatchObject({ success: false });
+ expect(validateDocumentId('doc/id')).toMatchObject({ success: false });
+ expect(validateDocumentId('')).toMatchObject({ success: false });
+ });
+ });
+
+ describe('Document ID Extraction', () => {
+ it('should extract ID from Google Docs URLs', () => {
+ const url = 'https://docs.google.com/document/d/1a2b3c4d5e6f/edit';
+ expect(extractDocumentId(url)).toBe('1a2b3c4d5e6f');
+ });
+
+ it('should extract ID from Google Drive URLs', () => {
+ const url = 'https://drive.google.com/file/d/abc123XYZ/view';
+ expect(extractDocumentId(url)).toBe('abc123XYZ');
+ });
+
+ it('should extract ID from Google Sheets URLs', () => {
+ const url = 'https://sheets.google.com/spreadsheets/d/sheet_id_123/edit';
+ expect(extractDocumentId(url)).toBe('sheet_id_123');
+ });
+
+ it('should return ID if already valid', () => {
+ const id = 'valid_document_id_123';
+ expect(extractDocumentId(id)).toBe(id);
+ });
+
+ it('should throw error for invalid input', () => {
+ expect(() => extractDocumentId('not a valid url or id')).toThrow();
+ expect(() => extractDocumentId('https://example.com/doc')).toThrow();
+ });
+ });
+
+ describe('Search Query Sanitization', () => {
+ it('should escape potentially dangerous characters', () => {
+ const result = searchQuerySchema.parse("test' OR '1'='1");
+ expect(result).toBe("test\\' OR \\'1\\'=\\'1"); // Quotes are escaped
+ });
+
+ it('should escape quotes while preserving search functionality', () => {
+ const result = searchQuerySchema.parse('search for "exact phrase"');
+ expect(result).toBe('search for \\"exact phrase\\"');
+ });
+
+ it('should preserve safe characters', () => {
+ const result = searchQuerySchema.parse('test query with spaces and-dashes');
+ expect(result).toBe('test query with spaces and-dashes');
+ });
+ });
+
+ describe('ValidationError', () => {
+ it('should create proper error with field and value', () => {
+ const error = new ValidationError('Invalid email', 'email', 'bad@');
+ expect(error.message).toBe('Invalid email');
+ expect(error.field).toBe('email');
+ expect(error.value).toBe('bad@');
+ expect(error.name).toBe('ValidationError');
+ });
+ });
+});
\ No newline at end of file
diff --git a/workspace-mcp-server/src/auth/AuthManager.ts b/workspace-mcp-server/src/auth/AuthManager.ts
new file mode 100644
index 00000000..5cfaa65a
--- /dev/null
+++ b/workspace-mcp-server/src/auth/AuthManager.ts
@@ -0,0 +1,259 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { google, Auth } from 'googleapis';
+import crypto from 'node:crypto';
+import * as http from 'node:http';
+import * as net from 'node:net';
+import * as url from 'node:url';
+import { logToFile } from '../utils/logger';
+import open from '../utils/open-wrapper';
+import { shouldLaunchBrowser } from '../utils/secure-browser-launcher';
+import { OAuthCredentialStorage } from './token-storage/oauth-credential-storage';
+
+// The Client ID for the OAuth flow.
+// The secret is handled by the cloud function, not in the client.
+const CLIENT_ID = '338689075775-o75k922vn5fdl18qergr96rp8g63e4d7.apps.googleusercontent.com';
+
+/**
+ * An Authentication URL for updating the credentials of a Oauth2Client
+ * as well as a promise that will resolve when the credentials have
+ * been refreshed (or which throws error when refreshing credentials failed).
+ */
+interface OauthWebLogin {
+ authUrl: string;
+ loginCompletePromise: Promise;
+}
+
+export class AuthManager {
+ private client: Auth.OAuth2Client | null = null;
+ private scopes: string[];
+
+ constructor(scopes: string[]) {
+ this.scopes = scopes;
+ }
+
+ private async loadCachedCredentials(client: Auth.OAuth2Client): Promise {
+ const credentials = await OAuthCredentialStorage.loadCredentials();
+
+ if (credentials) {
+ // Check if saved token has required scopes
+ const savedScopes = new Set(credentials.scope?.split(' ') ?? []);
+ logToFile(`Cached token has scopes: ${[...savedScopes].join(', ')}`);
+ logToFile(`Required scopes: ${this.scopes.join(', ')}`);
+
+ const missingScopes = this.scopes.filter(scope => !savedScopes.has(scope));
+
+ if (missingScopes.length > 0) {
+ logToFile(`Token cache missing required scopes: ${missingScopes.join(', ')}`);
+ logToFile('Removing cached token to force re-authentication...');
+ await OAuthCredentialStorage.clearCredentials();
+ return false;
+ } else {
+ client.setCredentials(credentials);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public async getAuthenticatedClient(): Promise {
+ logToFile('getAuthenticatedClient called');
+
+ // Check if we have a cached client with valid credentials
+ if (this.client && this.client.credentials && this.client.credentials.refresh_token) {
+ logToFile('Returning existing cached client with valid credentials');
+ return this.client;
+ }
+
+ // Note: No clientSecret is provided here. The secret is only known by the cloud function.
+ const options: Auth.OAuth2ClientOptions = {
+ clientId: CLIENT_ID,
+ };
+ const oAuth2Client = new google.auth.OAuth2(options);
+
+ logToFile('No valid cached client, checking for saved credentials...');
+ if (await this.loadCachedCredentials(oAuth2Client)) {
+ logToFile('Loaded saved credentials, caching and returning client');
+ this.client = oAuth2Client;
+ return this.client;
+ }
+
+ const webLogin = await this.authWithWeb(oAuth2Client);
+ await open(webLogin.authUrl);
+ console.log('Waiting for authentication...');
+
+ // Add timeout to prevent infinite waiting when browser tab gets stuck
+ const authTimeout = 5 * 60 * 1000; // 5 minutes timeout
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(() => {
+ reject(
+ new Error(
+ 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. ' +
+ 'Please try again.',
+ ),
+ );
+ }, authTimeout);
+ });
+ await Promise.race([webLogin.loginCompletePromise, timeoutPromise]);
+
+ await OAuthCredentialStorage.saveCredentials(oAuth2Client.credentials);
+ this.client = oAuth2Client;
+ return this.client;
+ }
+
+ private async getAvailablePort(): Promise {
+ return new Promise((resolve, reject) => {
+ let port = 0;
+ try {
+ const portStr = process.env['OAUTH_CALLBACK_PORT'];
+ if (portStr) {
+ port = parseInt(portStr, 10);
+ if (isNaN(port) || port <= 0 || port > 65535) {
+ return reject(
+ new Error(`Invalid value for OAUTH_CALLBACK_PORT: "${portStr}"`),
+ );
+ }
+ return resolve(port);
+ }
+ const server = net.createServer();
+ server.listen(0, () => {
+ const address = server.address()! as net.AddressInfo;
+ port = address.port;
+ });
+ server.on('listening', () => {
+ server.close();
+ server.unref();
+ });
+ server.on('error', (e) => reject(e));
+ server.on('close', () => resolve(port));
+ } catch (e) {
+ reject(e);
+ }
+ });
+ }
+
+ private async authWithWeb(client: Auth.OAuth2Client): Promise {
+ logToFile(`Requesting authentication with scopes: ${this.scopes.join(', ')}`);
+
+ const port = await this.getAvailablePort();
+ const host = process.env['OAUTH_CALLBACK_HOST'] || 'localhost';
+
+ const localRedirectUri = `http://${host}:${port}/oauth2callback`;
+
+ const isGuiAvailable = shouldLaunchBrowser();
+
+ // SECURITY: Generate a random token for CSRF protection.
+ const csrfToken = crypto.randomBytes(32).toString('hex');
+
+ // The state now contains a JSON payload indicating the flow mode and CSRF token.
+ const statePayload = {
+ uri: isGuiAvailable ? localRedirectUri : undefined,
+ manual: !isGuiAvailable,
+ csrf: csrfToken,
+ };
+ const state = Buffer.from(JSON.stringify(statePayload)).toString('base64');
+
+ // The redirect URI for Google's auth server is the cloud function
+ const cloudFunctionRedirectUri = 'https://google-workspace-extension.geminicli.com';
+
+ const authUrl = client.generateAuthUrl({
+ redirect_uri: cloudFunctionRedirectUri, // Tell Google to go to the cloud function
+ access_type: 'offline',
+ scope: this.scopes,
+ state: state, // Pass our JSON payload in the state
+ prompt: 'consent', // Make sure we get a refresh token
+ });
+
+ const loginCompletePromise = new Promise((resolve, reject) => {
+ const server = http.createServer(async (req, res) => {
+ try {
+ // Use startsWith for more robust path checking.
+ if (!req.url || !req.url.startsWith('/oauth2callback')) {
+ res.end();
+ reject(
+ new Error(
+ 'OAuth callback not received. Unexpected request: ' + req.url,
+ ),
+ );
+ return;
+ }
+
+ const qs = new url.URL(req.url, `http://${host}:${port}`)
+ .searchParams;
+
+ // SECURITY: Validate the state parameter to prevent CSRF attacks.
+ const returnedState = qs.get('state');
+ if (returnedState !== csrfToken) {
+ res.end('State mismatch. Possible CSRF attack.');
+ reject(new Error('OAuth state mismatch. Possible CSRF attack.'));
+ return;
+ }
+
+ if (qs.get('error')) {
+ const errorCode = qs.get('error');
+ const errorDescription =
+ qs.get('error_description') || 'No additional details provided';
+ res.end();
+ reject(
+ new Error(
+ `Google OAuth error: ${errorCode}. ${errorDescription}`,
+ ),
+ );
+ return;
+ }
+
+ const access_token = qs.get('access_token');
+ const refresh_token = qs.get('refresh_token');
+ const scope = qs.get('scope');
+ const token_type = qs.get('token_type');
+ const expiry_date_str = qs.get('expiry_date');
+
+ if (access_token && expiry_date_str) {
+ const tokens: Auth.Credentials = {
+ access_token: access_token,
+ refresh_token: refresh_token || null,
+ scope: scope || undefined,
+ token_type: (token_type as 'Bearer') || undefined,
+ expiry_date: parseInt(expiry_date_str, 10),
+ };
+ client.setCredentials(tokens);
+ res.end('Authentication successful! Please return to the console.');
+ resolve();
+ } else {
+ reject(
+ new Error(
+ 'Authentication failed: Did not receive tokens from callback.',
+ ),
+ );
+ }
+ } catch (e) {
+ reject(e);
+ } finally {
+ server.close();
+ }
+ })
+
+ server.listen(port, host, () => {
+ // Server started successfully
+ });
+
+ server.on('error', (err) => {
+ reject(
+ new Error(
+ `OAuth callback server error: ${err}`,
+ ),
+ );
+ });
+ });
+
+ return {
+ authUrl,
+ loginCompletePromise,
+ };
+ }
+}
diff --git a/workspace-mcp-server/src/auth/token-storage/base-token-storage.ts b/workspace-mcp-server/src/auth/token-storage/base-token-storage.ts
new file mode 100644
index 00000000..8a9c44f5
--- /dev/null
+++ b/workspace-mcp-server/src/auth/token-storage/base-token-storage.ts
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ OAuthCredentials,
+ TokenStorage
+} from './types';
+
+export abstract class BaseTokenStorage implements TokenStorage {
+ protected readonly serviceName: string;
+
+ constructor(serviceName: string) {
+ this.serviceName = serviceName;
+ }
+
+ abstract getCredentials(serverName: string): Promise;
+ abstract setCredentials(credentials: OAuthCredentials): Promise;
+ abstract deleteCredentials(serverName: string): Promise;
+ abstract listServers(): Promise;
+ abstract getAllCredentials(): Promise