import { getGoogleAPIs } from '../auth/google-auth.js';
import {
getLogger,
validateInput,
DocsCreateSchema,
DocsGetSchema,
DocsInsertTextSchema,
DocsReplaceTextSchema,
DocsFormatTextSchema,
DocsInsertImageSchema,
DocsBatchUpdateSchema,
isOperationAllowed,
OperationNotAllowedError,
GoogleAPIError,
withErrorHandling,
type DocsCreate,
type DocsGet,
type DocsInsertText,
type DocsReplaceText,
type DocsFormatText,
type DocsInsertImage,
type DocsBatchUpdate,
} from '@company-mcp/core';
const logger = getLogger();
// Types
export interface DocumentInfo {
documentId: string;
url: string;
}
export interface DocumentContent {
title: string;
bodyText: string;
}
// Helper to extract text from document body
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractBodyText(body: any): string {
if (!body?.content) return '';
const textParts: string[] = [];
for (const element of body.content) {
if (element.paragraph?.elements) {
for (const textElement of element.paragraph.elements) {
if (textElement.textRun?.content) {
textParts.push(textElement.textRun.content);
}
}
}
}
return textParts.join('');
}
// Create document
export async function docsCreateDocument(
input: unknown
): Promise<DocumentInfo> {
return withErrorHandling('docs_create_document', async () => {
// Check if write is allowed
if (!isOperationAllowed('docs_write')) {
throw new OperationNotAllowedError('docs_write');
}
const validation = validateInput(DocsCreateSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as DocsCreate;
const startTime = Date.now();
const { docs, drive } = getGoogleAPIs();
// Create document
const response = await docs.documents.create({
requestBody: {
title: params.title,
},
});
const documentId = response.data.documentId!;
// Move to parent folder if specified
if (params.parentFolderId) {
await drive.files.update({
fileId: documentId,
addParents: params.parentFolderId,
removeParents: 'root',
fields: 'id, parents',
});
}
logger.audit('docs_create_document', 'create', {
args: { title: params.title, parentFolderId: params.parentFolderId },
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
documentId,
url: `https://docs.google.com/document/d/${documentId}`,
};
});
}
// Get document
export async function docsGetDocument(
input: unknown
): Promise<DocumentContent> {
return withErrorHandling('docs_get_document', async () => {
const validation = validateInput(DocsGetSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as DocsGet;
const startTime = Date.now();
const { docs } = getGoogleAPIs();
const response = await docs.documents.get({
documentId: params.documentId,
});
logger.audit('docs_get_document', 'get', {
args: { documentId: params.documentId },
result: 'success',
duration_ms: Date.now() - startTime,
});
return {
title: response.data.title || '',
bodyText: extractBodyText(response.data.body),
};
});
}
// Insert text
export async function docsInsertText(
input: unknown
): Promise<{ ok: true }> {
return withErrorHandling('docs_insert_text', async () => {
// Check if write is allowed
if (!isOperationAllowed('docs_write')) {
throw new OperationNotAllowedError('docs_write');
}
const validation = validateInput(DocsInsertTextSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as DocsInsertText;
const startTime = Date.now();
const { docs } = getGoogleAPIs();
await docs.documents.batchUpdate({
documentId: params.documentId,
requestBody: {
requests: [
{
insertText: {
location: {
index: params.index,
},
text: params.text,
},
},
],
},
});
logger.audit('docs_insert_text', 'insert', {
args: {
documentId: params.documentId,
index: params.index,
textLength: params.text.length,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { ok: true };
});
}
// Replace text
export async function docsReplaceText(
input: unknown
): Promise<{ ok: true }> {
return withErrorHandling('docs_replace_text', async () => {
// Check if write is allowed
if (!isOperationAllowed('docs_write')) {
throw new OperationNotAllowedError('docs_write');
}
const validation = validateInput(DocsReplaceTextSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as DocsReplaceText;
const startTime = Date.now();
const { docs } = getGoogleAPIs();
await docs.documents.batchUpdate({
documentId: params.documentId,
requestBody: {
requests: [
{
replaceAllText: {
containsText: {
text: params.containsText,
matchCase: params.matchCase,
},
replaceText: params.replaceText,
},
},
],
},
});
logger.audit('docs_replace_text', 'replace', {
args: {
documentId: params.documentId,
searchText: params.containsText,
matchCase: params.matchCase,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { ok: true };
});
}
// Format text
export async function docsFormatText(
input: unknown
): Promise<{ ok: true }> {
return withErrorHandling('docs_format_text', async () => {
// Check if write is allowed
if (!isOperationAllowed('docs_write')) {
throw new OperationNotAllowedError('docs_write');
}
const validation = validateInput(DocsFormatTextSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as DocsFormatText;
const startTime = Date.now();
const { docs } = getGoogleAPIs();
// Build text style object from format parameters
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const textStyle: Record<string, any> = {};
const fields: string[] = [];
if (params.format.bold !== undefined) {
textStyle.bold = params.format.bold;
fields.push('bold');
}
if (params.format.italic !== undefined) {
textStyle.italic = params.format.italic;
fields.push('italic');
}
if (params.format.underline !== undefined) {
textStyle.underline = params.format.underline;
fields.push('underline');
}
if (params.format.strikethrough !== undefined) {
textStyle.strikethrough = params.format.strikethrough;
fields.push('strikethrough');
}
if (params.format.fontSize !== undefined) {
textStyle.fontSize = {
magnitude: params.format.fontSize,
unit: 'PT',
};
fields.push('fontSize');
}
if (params.format.foregroundColor !== undefined) {
textStyle.foregroundColor = {
color: {
rgbColor: params.format.foregroundColor,
},
};
fields.push('foregroundColor');
}
await docs.documents.batchUpdate({
documentId: params.documentId,
requestBody: {
requests: [
{
updateTextStyle: {
range: {
startIndex: params.startIndex,
endIndex: params.endIndex,
},
textStyle,
fields: fields.join(','),
},
},
],
},
});
logger.audit('docs_format_text', 'update', {
args: {
documentId: params.documentId,
startIndex: params.startIndex,
endIndex: params.endIndex,
formatFields: fields,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { ok: true };
});
}
// Insert image
export async function docsInsertImage(
input: unknown
): Promise<{ ok: true }> {
return withErrorHandling('docs_insert_image', async () => {
// Check if write is allowed
if (!isOperationAllowed('docs_write')) {
throw new OperationNotAllowedError('docs_write');
}
const validation = validateInput(DocsInsertImageSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as DocsInsertImage;
const startTime = Date.now();
const { docs } = getGoogleAPIs();
// Build object size if width or height specified
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objectSize: Record<string, any> | undefined =
params.width || params.height
? {
...(params.width && {
width: { magnitude: params.width, unit: 'PT' },
}),
...(params.height && {
height: { magnitude: params.height, unit: 'PT' },
}),
}
: undefined;
await docs.documents.batchUpdate({
documentId: params.documentId,
requestBody: {
requests: [
{
insertInlineImage: {
location: {
index: params.index,
},
uri: params.uri,
...(objectSize && { objectSize }),
},
},
],
},
});
logger.audit('docs_insert_image', 'insert', {
args: {
documentId: params.documentId,
index: params.index,
uri: params.uri,
width: params.width,
height: params.height,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { ok: true };
});
}
// Batch update
export async function docsBatchUpdate(
input: unknown
): Promise<{ ok: true }> {
return withErrorHandling('docs_batch_update', async () => {
// Check if write is allowed
if (!isOperationAllowed('docs_write')) {
throw new OperationNotAllowedError('docs_write');
}
const validation = validateInput(DocsBatchUpdateSchema, input);
if (!validation.success) {
throw new GoogleAPIError(validation.errors.join(', '), 400);
}
const params = validation.data as DocsBatchUpdate;
const startTime = Date.now();
const { docs } = getGoogleAPIs();
await docs.documents.batchUpdate({
documentId: params.documentId,
requestBody: {
requests: params.requests,
},
});
logger.audit('docs_batch_update', 'batch_update', {
args: {
documentId: params.documentId,
requestCount: params.requests.length,
},
result: 'success',
duration_ms: Date.now() - startTime,
});
return { ok: true };
});
}
// Tool definitions for MCP
export const docsTools = [
{
name: 'docs_create_document',
description:
'Create a new Google Document. Requires DOCS_WRITE_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Title for the new document',
},
parentFolderId: {
type: 'string',
description: 'ID of the Drive folder to create in (optional)',
},
},
required: ['title'],
},
},
{
name: 'docs_get_document',
description: 'Get the content of a Google Document.',
inputSchema: {
type: 'object',
properties: {
documentId: {
type: 'string',
description: 'ID of the document',
},
},
required: ['documentId'],
},
},
{
name: 'docs_insert_text',
description:
'Insert text at a specific position in a document. Requires DOCS_WRITE_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
documentId: {
type: 'string',
description: 'ID of the document',
},
index: {
type: 'number',
description: 'Character index where to insert (1 = beginning of document)',
},
text: {
type: 'string',
description: 'Text to insert',
},
},
required: ['documentId', 'index', 'text'],
},
},
{
name: 'docs_replace_text',
description:
'Replace all occurrences of text in a document. Requires DOCS_WRITE_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
documentId: {
type: 'string',
description: 'ID of the document',
},
containsText: {
type: 'string',
description: 'Text to search for',
},
replaceText: {
type: 'string',
description: 'Text to replace with',
},
matchCase: {
type: 'boolean',
description: 'Whether to match case (default: false)',
default: false,
},
},
required: ['documentId', 'containsText', 'replaceText'],
},
},
{
name: 'docs_format_text',
description:
'Apply text formatting to a range in a document. Requires DOCS_WRITE_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
documentId: {
type: 'string',
description: 'ID of the document',
},
startIndex: {
type: 'number',
description: 'Start character index of the range to format',
},
endIndex: {
type: 'number',
description: 'End character index of the range to format',
},
format: {
type: 'object',
description: 'Formatting options to apply',
properties: {
bold: {
type: 'boolean',
description: 'Make text bold',
},
italic: {
type: 'boolean',
description: 'Make text italic',
},
underline: {
type: 'boolean',
description: 'Underline text',
},
strikethrough: {
type: 'boolean',
description: 'Strikethrough text',
},
fontSize: {
type: 'number',
description: 'Font size in points',
},
foregroundColor: {
type: 'object',
description: 'Text color (RGB values 0-1)',
properties: {
red: { type: 'number', description: 'Red component (0-1)' },
green: { type: 'number', description: 'Green component (0-1)' },
blue: { type: 'number', description: 'Blue component (0-1)' },
},
},
},
},
},
required: ['documentId', 'startIndex', 'endIndex', 'format'],
},
},
{
name: 'docs_insert_image',
description:
'Insert an inline image at a specific position in a document. Requires DOCS_WRITE_ENABLED=true.',
inputSchema: {
type: 'object',
properties: {
documentId: {
type: 'string',
description: 'ID of the document',
},
index: {
type: 'number',
description: 'Character index where to insert the image',
},
uri: {
type: 'string',
description: 'URL of the image to insert',
},
width: {
type: 'number',
description: 'Width of the image in points (optional)',
},
height: {
type: 'number',
description: 'Height of the image in points (optional)',
},
},
required: ['documentId', 'index', 'uri'],
},
},
{
name: 'docs_batch_update',
description:
'Execute multiple update requests in a single batch. Requires DOCS_WRITE_ENABLED=true. Use for advanced document manipulation.',
inputSchema: {
type: 'object',
properties: {
documentId: {
type: 'string',
description: 'ID of the document',
},
requests: {
type: 'array',
description: 'Array of update request objects (Google Docs API format)',
items: {
type: 'object',
},
},
},
required: ['documentId', 'requests'],
},
},
];
// Tool handlers
export const docsHandlers: Record<
string,
(input: unknown) => Promise<unknown>
> = {
docs_create_document: docsCreateDocument,
docs_get_document: docsGetDocument,
docs_insert_text: docsInsertText,
docs_replace_text: docsReplaceText,
docs_format_text: docsFormatText,
docs_insert_image: docsInsertImage,
docs_batch_update: docsBatchUpdate,
};