google-tools-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.js +275 -0
- package/dist/cachedToolsList.js +52 -0
- package/dist/clients.js +97 -0
- package/dist/googleDocsApiHelpers.js +883 -0
- package/dist/googleSheetsApiHelpers.js +808 -0
- package/dist/helpers.js +210 -0
- package/dist/index.js +55 -0
- package/dist/logger.js +45 -0
- package/dist/markdown-transformer/docsToMarkdown.js +259 -0
- package/dist/markdown-transformer/index.js +126 -0
- package/dist/markdown-transformer/markdownToDocs.js +834 -0
- package/dist/tools/docs/addTab.js +92 -0
- package/dist/tools/docs/appendToGoogleDoc.js +81 -0
- package/dist/tools/docs/comments/addComment.js +83 -0
- package/dist/tools/docs/comments/deleteComment.js +30 -0
- package/dist/tools/docs/comments/getComment.js +45 -0
- package/dist/tools/docs/comments/index.js +14 -0
- package/dist/tools/docs/comments/listComments.js +43 -0
- package/dist/tools/docs/comments/replyToComment.js +35 -0
- package/dist/tools/docs/comments/resolveComment.js +55 -0
- package/dist/tools/docs/deleteRange.js +72 -0
- package/dist/tools/docs/findAndReplace.js +54 -0
- package/dist/tools/docs/formatting/applyParagraphStyle.js +83 -0
- package/dist/tools/docs/formatting/applyTextStyle.js +49 -0
- package/dist/tools/docs/formatting/index.js +6 -0
- package/dist/tools/docs/index.js +38 -0
- package/dist/tools/docs/insertImage.js +122 -0
- package/dist/tools/docs/insertPageBreak.js +58 -0
- package/dist/tools/docs/insertTable.js +53 -0
- package/dist/tools/docs/insertTableWithData.js +135 -0
- package/dist/tools/docs/insertText.js +61 -0
- package/dist/tools/docs/listDocumentTabs.js +60 -0
- package/dist/tools/docs/modifyText.js +158 -0
- package/dist/tools/docs/readGoogleDoc.js +165 -0
- package/dist/tools/docs/renameTab.js +61 -0
- package/dist/tools/drive/copyFile.js +63 -0
- package/dist/tools/drive/createDocument.js +89 -0
- package/dist/tools/drive/createFolder.js +48 -0
- package/dist/tools/drive/createFromTemplate.js +82 -0
- package/dist/tools/drive/deleteFile.js +72 -0
- package/dist/tools/drive/getDocumentInfo.js +48 -0
- package/dist/tools/drive/getFolderInfo.js +48 -0
- package/dist/tools/drive/index.js +30 -0
- package/dist/tools/drive/listFolderContents.js +82 -0
- package/dist/tools/drive/listGoogleDocs.js +67 -0
- package/dist/tools/drive/listSharedDrives.js +52 -0
- package/dist/tools/drive/listSharedWithMe.js +97 -0
- package/dist/tools/drive/moveFile.js +54 -0
- package/dist/tools/drive/renameFile.js +39 -0
- package/dist/tools/drive/searchGoogleDocs.js +98 -0
- package/dist/tools/extras/index.js +7 -0
- package/dist/tools/extras/readFile.js +82 -0
- package/dist/tools/extras/searchFileContents.js +81 -0
- package/dist/tools/gmail/drafts.js +165 -0
- package/dist/tools/gmail/labels.js +103 -0
- package/dist/tools/gmail/messages.js +448 -0
- package/dist/tools/gmail/settings.js +528 -0
- package/dist/tools/gmail/threads.js +145 -0
- package/dist/tools/index.js +163 -0
- package/dist/tools/sheets/addConditionalFormatting.js +143 -0
- package/dist/tools/sheets/addSpreadsheetSheet.js +34 -0
- package/dist/tools/sheets/appendSpreadsheetRows.js +43 -0
- package/dist/tools/sheets/appendTableRows.js +50 -0
- package/dist/tools/sheets/autoResizeColumns.js +67 -0
- package/dist/tools/sheets/batchWrite.js +59 -0
- package/dist/tools/sheets/clearSpreadsheetRange.js +31 -0
- package/dist/tools/sheets/copyFormatting.js +59 -0
- package/dist/tools/sheets/createSpreadsheet.js +71 -0
- package/dist/tools/sheets/createTable.js +120 -0
- package/dist/tools/sheets/deleteChart.js +41 -0
- package/dist/tools/sheets/deleteSheet.js +43 -0
- package/dist/tools/sheets/deleteTable.js +56 -0
- package/dist/tools/sheets/duplicateSheet.js +53 -0
- package/dist/tools/sheets/formatCells.js +106 -0
- package/dist/tools/sheets/freezeRowsAndColumns.js +58 -0
- package/dist/tools/sheets/getSpreadsheetInfo.js +44 -0
- package/dist/tools/sheets/getTable.js +48 -0
- package/dist/tools/sheets/groupRows.js +62 -0
- package/dist/tools/sheets/index.js +66 -0
- package/dist/tools/sheets/insertChart.js +225 -0
- package/dist/tools/sheets/listGoogleSheets.js +62 -0
- package/dist/tools/sheets/listTables.js +55 -0
- package/dist/tools/sheets/readCellFormat.js +143 -0
- package/dist/tools/sheets/readSpreadsheet.js +36 -0
- package/dist/tools/sheets/renameSheet.js +48 -0
- package/dist/tools/sheets/setColumnWidths.js +43 -0
- package/dist/tools/sheets/setDropdownValidation.js +51 -0
- package/dist/tools/sheets/ungroupAllRows.js +66 -0
- package/dist/tools/sheets/updateTableRange.js +51 -0
- package/dist/tools/sheets/writeSpreadsheet.js +43 -0
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +93 -0
- package/dist/tools/utils/index.js +6 -0
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +154 -0
- package/dist/types.js +186 -0
- package/package.json +30 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDocsClient } from '../../clients.js';
|
|
4
|
+
import { DocumentIdParameter } from '../../types.js';
|
|
5
|
+
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
export function register(server) {
|
|
7
|
+
server.addTool({
|
|
8
|
+
name: 'listTabs',
|
|
9
|
+
description: "Lists all tabs in a document with their IDs and hierarchy. Use the returned tab IDs with other tools' tabId parameter to target a specific tab.",
|
|
10
|
+
parameters: DocumentIdParameter.extend({
|
|
11
|
+
includeContent: z
|
|
12
|
+
.boolean()
|
|
13
|
+
.optional()
|
|
14
|
+
.default(false)
|
|
15
|
+
.describe('Whether to include a content summary for each tab (character count).'),
|
|
16
|
+
}),
|
|
17
|
+
execute: async (args, { log }) => {
|
|
18
|
+
const docs = await getDocsClient();
|
|
19
|
+
log.info(`Listing tabs for document: ${args.documentId}`);
|
|
20
|
+
try {
|
|
21
|
+
// Get document with tabs structure
|
|
22
|
+
const res = await docs.documents.get({
|
|
23
|
+
documentId: args.documentId,
|
|
24
|
+
includeTabsContent: true,
|
|
25
|
+
// Only get essential fields for tab listing
|
|
26
|
+
fields: args.includeContent
|
|
27
|
+
? 'title,tabs' // Get all tab data if we need content summary
|
|
28
|
+
: 'title,tabs(tabProperties,childTabs)', // Otherwise just structure
|
|
29
|
+
});
|
|
30
|
+
const docTitle = res.data.title || 'Untitled Document';
|
|
31
|
+
// Get all tabs in a flat list with hierarchy info
|
|
32
|
+
const allTabs = GDocsHelpers.getAllTabs(res.data);
|
|
33
|
+
const tabs = allTabs.map((tab) => {
|
|
34
|
+
const tabProperties = tab.tabProperties || {};
|
|
35
|
+
const tabObj = {
|
|
36
|
+
id: tabProperties.tabId || null,
|
|
37
|
+
title: tabProperties.title || null,
|
|
38
|
+
index: tabProperties.index ?? null,
|
|
39
|
+
};
|
|
40
|
+
if (tabProperties.parentTabId) {
|
|
41
|
+
tabObj.parentTabId = tabProperties.parentTabId;
|
|
42
|
+
}
|
|
43
|
+
if (args.includeContent && tab.documentTab) {
|
|
44
|
+
tabObj.characterCount = GDocsHelpers.getTabTextLength(tab.documentTab);
|
|
45
|
+
}
|
|
46
|
+
return tabObj;
|
|
47
|
+
});
|
|
48
|
+
return JSON.stringify({ documentTitle: docTitle, tabs }, null, 2);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
log.error(`Error listing tabs for doc ${args.documentId}: ${error.message || error}`);
|
|
52
|
+
if (error.code === 404)
|
|
53
|
+
throw new UserError(`Document not found (ID: ${args.documentId}).`);
|
|
54
|
+
if (error.code === 403)
|
|
55
|
+
throw new UserError(`Permission denied for document (ID: ${args.documentId}).`);
|
|
56
|
+
throw new UserError(`Failed to list tabs: ${error.message || 'Unknown error'}`);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDocsClient } from '../../clients.js';
|
|
4
|
+
import { DocumentIdParameter, TextFindParameter, TextStyleParameters } from '../../types.js';
|
|
5
|
+
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
const RangeTarget = z
|
|
7
|
+
.object({
|
|
8
|
+
startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
|
|
9
|
+
endIndex: z.number().int().min(1).describe('End of range (exclusive).'),
|
|
10
|
+
})
|
|
11
|
+
.refine((d) => d.endIndex > d.startIndex, {
|
|
12
|
+
message: 'endIndex must be greater than startIndex',
|
|
13
|
+
path: ['endIndex'],
|
|
14
|
+
});
|
|
15
|
+
const InsertionTarget = z.object({
|
|
16
|
+
insertionIndex: z.number().int().min(1).describe('Index to insert at (1-based).'),
|
|
17
|
+
});
|
|
18
|
+
const ModifyTextParameters = DocumentIdParameter.extend({
|
|
19
|
+
target: z
|
|
20
|
+
.union([RangeTarget, TextFindParameter, InsertionTarget])
|
|
21
|
+
.describe('Target by range indices, text search, or insertion index.'),
|
|
22
|
+
text: z.string().optional().describe('New text to insert or replace with.'),
|
|
23
|
+
style: TextStyleParameters.optional().describe('Text formatting to apply.'),
|
|
24
|
+
tabId: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('The ID of the specific tab to operate on. If not specified, operates on the first tab.'),
|
|
28
|
+
})
|
|
29
|
+
.refine((args) => args.text !== undefined || args.style !== undefined, {
|
|
30
|
+
message: 'At least one of text or style must be provided.',
|
|
31
|
+
})
|
|
32
|
+
.refine((args) => {
|
|
33
|
+
if ('insertionIndex' in args.target && args.text === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
return true;
|
|
36
|
+
}, { message: 'text is required when using insertionIndex target (no existing range to format).' });
|
|
37
|
+
/**
|
|
38
|
+
* Pure, sync function that builds the array of Google Docs API requests for a
|
|
39
|
+
* modifyText operation. Indices must already be resolved (no text-search here).
|
|
40
|
+
*/
|
|
41
|
+
export function buildModifyTextRequests(opts) {
|
|
42
|
+
const { startIndex, endIndex, text, style, tabId } = opts;
|
|
43
|
+
const requests = [];
|
|
44
|
+
if (!text && !style)
|
|
45
|
+
return requests;
|
|
46
|
+
// 1. Delete existing content (only when replacing, not insert-only)
|
|
47
|
+
if (endIndex !== undefined && text !== undefined) {
|
|
48
|
+
const range = { startIndex, endIndex };
|
|
49
|
+
if (tabId)
|
|
50
|
+
range.tabId = tabId;
|
|
51
|
+
requests.push({ deleteContentRange: { range } });
|
|
52
|
+
}
|
|
53
|
+
// 2. Insert new text
|
|
54
|
+
if (text !== undefined) {
|
|
55
|
+
const location = { index: startIndex };
|
|
56
|
+
if (tabId)
|
|
57
|
+
location.tabId = tabId;
|
|
58
|
+
requests.push({ insertText: { location, text } });
|
|
59
|
+
}
|
|
60
|
+
// 3. Apply formatting
|
|
61
|
+
if (style) {
|
|
62
|
+
const formatStart = startIndex;
|
|
63
|
+
const formatEnd = text !== undefined
|
|
64
|
+
? startIndex + text.length
|
|
65
|
+
: endIndex !== undefined
|
|
66
|
+
? endIndex
|
|
67
|
+
: startIndex;
|
|
68
|
+
if (formatEnd > formatStart) {
|
|
69
|
+
const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(formatStart, formatEnd, style, tabId);
|
|
70
|
+
if (requestInfo) {
|
|
71
|
+
requests.push(requestInfo.request);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return requests;
|
|
76
|
+
}
|
|
77
|
+
export function register(server) {
|
|
78
|
+
server.addTool({
|
|
79
|
+
name: 'modifyText',
|
|
80
|
+
description: 'Combines text replacement/insertion and formatting in one atomic operation. ' +
|
|
81
|
+
'Can insert text at a position, replace a range or found text, apply formatting, ' +
|
|
82
|
+
"or any combination. Use readGoogleDoc with format='json' to determine indices.",
|
|
83
|
+
parameters: ModifyTextParameters,
|
|
84
|
+
execute: async (args, { log }) => {
|
|
85
|
+
const docs = await getDocsClient();
|
|
86
|
+
log.info(`modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
|
|
87
|
+
`${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` +
|
|
88
|
+
`${args.style ? `, style=${JSON.stringify(args.style)}` : ''}` +
|
|
89
|
+
`${args.tabId ? `, tab=${args.tabId}` : ''}`);
|
|
90
|
+
try {
|
|
91
|
+
// Verify tab exists if specified
|
|
92
|
+
if (args.tabId) {
|
|
93
|
+
const docInfo = await docs.documents.get({
|
|
94
|
+
documentId: args.documentId,
|
|
95
|
+
includeTabsContent: true,
|
|
96
|
+
fields: 'tabs(tabProperties,documentTab)',
|
|
97
|
+
});
|
|
98
|
+
const targetTab = GDocsHelpers.findTabById(docInfo.data, args.tabId);
|
|
99
|
+
if (!targetTab) {
|
|
100
|
+
throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
|
|
101
|
+
}
|
|
102
|
+
if (!targetTab.documentTab) {
|
|
103
|
+
throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Resolve target to numeric indices
|
|
107
|
+
let startIndex;
|
|
108
|
+
let endIndex;
|
|
109
|
+
if ('insertionIndex' in args.target) {
|
|
110
|
+
startIndex = args.target.insertionIndex;
|
|
111
|
+
endIndex = undefined;
|
|
112
|
+
}
|
|
113
|
+
else if ('textToFind' in args.target) {
|
|
114
|
+
const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance, args.tabId);
|
|
115
|
+
if (!range) {
|
|
116
|
+
throw new UserError(`Could not find instance ${args.target.matchInstance ?? 1} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.`);
|
|
117
|
+
}
|
|
118
|
+
startIndex = range.startIndex;
|
|
119
|
+
endIndex = range.endIndex;
|
|
120
|
+
log.info(`Found text "${args.target.textToFind}" at range ${startIndex}-${endIndex}`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
startIndex = args.target.startIndex;
|
|
124
|
+
endIndex = args.target.endIndex;
|
|
125
|
+
}
|
|
126
|
+
// Clamp to minimum 1 (index 0 is the document section break)
|
|
127
|
+
if (startIndex < 1)
|
|
128
|
+
startIndex = 1;
|
|
129
|
+
const requests = buildModifyTextRequests({
|
|
130
|
+
startIndex,
|
|
131
|
+
endIndex,
|
|
132
|
+
text: args.text,
|
|
133
|
+
style: args.style,
|
|
134
|
+
tabId: args.tabId,
|
|
135
|
+
});
|
|
136
|
+
if (requests.length === 0) {
|
|
137
|
+
return 'No operations to perform.';
|
|
138
|
+
}
|
|
139
|
+
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, requests);
|
|
140
|
+
// Build descriptive result
|
|
141
|
+
const actions = [];
|
|
142
|
+
if (endIndex !== undefined && args.text !== undefined)
|
|
143
|
+
actions.push('replaced text');
|
|
144
|
+
else if (args.text !== undefined)
|
|
145
|
+
actions.push('inserted text');
|
|
146
|
+
if (args.style)
|
|
147
|
+
actions.push('applied formatting');
|
|
148
|
+
return `Successfully ${actions.join(' and ')} at range ${startIndex}-${endIndex ?? startIndex + (args.text?.length ?? 0)}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
log.error(`Error in modifyText for doc ${args.documentId}: ${error.message || error}`);
|
|
152
|
+
if (error instanceof UserError)
|
|
153
|
+
throw error;
|
|
154
|
+
throw new UserError(`Failed to modify text: ${error.message || 'Unknown error'}`);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDocsClient, getDriveClient } from '../../clients.js';
|
|
4
|
+
import { DocumentIdParameter, NotImplementedError } from '../../types.js';
|
|
5
|
+
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
7
|
+
export function register(server) {
|
|
8
|
+
server.addTool({
|
|
9
|
+
name: 'readDocument',
|
|
10
|
+
description: "Reads the content of a Google Document. Returns plain text by default. Use format='markdown' to get formatted content suitable for editing and re-uploading with replaceDocumentWithMarkdown, or format='json' for the raw document structure.",
|
|
11
|
+
parameters: DocumentIdParameter.extend({
|
|
12
|
+
format: z
|
|
13
|
+
.enum(['text', 'json', 'markdown'])
|
|
14
|
+
.optional()
|
|
15
|
+
.default('text')
|
|
16
|
+
.describe("Output format: 'text' (plain text), 'json' (raw API structure, complex), 'markdown' (experimental conversion)."),
|
|
17
|
+
maxLength: z
|
|
18
|
+
.number()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Maximum character limit for text output. If not specified, returns full document content. Use this to limit very large documents.'),
|
|
21
|
+
tabId: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('The ID of the specific tab to read. If not specified, reads the first tab (or legacy document.body for documents without tabs).'),
|
|
25
|
+
}),
|
|
26
|
+
execute: async (args, { log }) => {
|
|
27
|
+
const docs = await getDocsClient();
|
|
28
|
+
log.info(`Reading Google Doc: ${args.documentId}, Format: ${args.format}${args.tabId ? `, Tab: ${args.tabId}` : ''}`);
|
|
29
|
+
try {
|
|
30
|
+
// Determine if we need tabs content
|
|
31
|
+
const needsTabsContent = !!args.tabId;
|
|
32
|
+
const fields = args.format === 'json' || args.format === 'markdown'
|
|
33
|
+
? '*' // Get everything for structure analysis
|
|
34
|
+
: 'body(content(paragraph(elements(textRun(content)))))'; // Just text content
|
|
35
|
+
const res = await docs.documents.get({
|
|
36
|
+
documentId: args.documentId,
|
|
37
|
+
includeTabsContent: needsTabsContent,
|
|
38
|
+
fields: needsTabsContent ? '*' : fields, // Get full document if using tabs
|
|
39
|
+
});
|
|
40
|
+
log.info(`Fetched doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
41
|
+
// If tabId is specified, find the specific tab
|
|
42
|
+
let contentSource;
|
|
43
|
+
if (args.tabId) {
|
|
44
|
+
const targetTab = GDocsHelpers.findTabById(res.data, args.tabId);
|
|
45
|
+
if (!targetTab) {
|
|
46
|
+
throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
|
|
47
|
+
}
|
|
48
|
+
if (!targetTab.documentTab) {
|
|
49
|
+
throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`);
|
|
50
|
+
}
|
|
51
|
+
contentSource = { body: targetTab.documentTab.body };
|
|
52
|
+
log.info(`Using content from tab: ${targetTab.tabProperties?.title || 'Untitled'}`);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// Use the document body (backward compatible)
|
|
56
|
+
contentSource = res.data;
|
|
57
|
+
}
|
|
58
|
+
if (args.format === 'json') {
|
|
59
|
+
const jsonContent = JSON.stringify(contentSource, null, 2);
|
|
60
|
+
// Apply length limit to JSON if specified
|
|
61
|
+
if (args.maxLength && jsonContent.length > args.maxLength) {
|
|
62
|
+
return (jsonContent.substring(0, args.maxLength) +
|
|
63
|
+
`\n... [JSON truncated: ${jsonContent.length} total chars]`);
|
|
64
|
+
}
|
|
65
|
+
return jsonContent;
|
|
66
|
+
}
|
|
67
|
+
if (args.format === 'markdown') {
|
|
68
|
+
const markdownContent = docsJsonToMarkdown(contentSource);
|
|
69
|
+
const totalLength = markdownContent.length;
|
|
70
|
+
log.info(`Generated markdown: ${totalLength} characters`);
|
|
71
|
+
// Apply length limit to markdown if specified
|
|
72
|
+
if (args.maxLength && totalLength > args.maxLength) {
|
|
73
|
+
const truncatedContent = markdownContent.substring(0, args.maxLength);
|
|
74
|
+
return `${truncatedContent}\n\n... [Markdown truncated to ${args.maxLength} chars of ${totalLength} total. Use maxLength parameter to adjust limit or remove it to get full content.]`;
|
|
75
|
+
}
|
|
76
|
+
return markdownContent;
|
|
77
|
+
}
|
|
78
|
+
// Default: Text format - extract all text content
|
|
79
|
+
let textContent = '';
|
|
80
|
+
let elementCount = 0;
|
|
81
|
+
// Process all content elements from contentSource
|
|
82
|
+
contentSource.body?.content?.forEach((element) => {
|
|
83
|
+
elementCount++;
|
|
84
|
+
// Handle paragraphs
|
|
85
|
+
if (element.paragraph?.elements) {
|
|
86
|
+
element.paragraph.elements.forEach((pe) => {
|
|
87
|
+
if (pe.textRun?.content) {
|
|
88
|
+
textContent += pe.textRun.content;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Handle tables
|
|
93
|
+
if (element.table?.tableRows) {
|
|
94
|
+
element.table.tableRows.forEach((row) => {
|
|
95
|
+
row.tableCells?.forEach((cell) => {
|
|
96
|
+
cell.content?.forEach((cellElement) => {
|
|
97
|
+
cellElement.paragraph?.elements?.forEach((pe) => {
|
|
98
|
+
if (pe.textRun?.content) {
|
|
99
|
+
textContent += pe.textRun.content;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
if (!textContent.trim())
|
|
108
|
+
return 'Document found, but appears empty.';
|
|
109
|
+
const totalLength = textContent.length;
|
|
110
|
+
log.info(`Document contains ${totalLength} characters across ${elementCount} elements`);
|
|
111
|
+
log.info(`maxLength parameter: ${args.maxLength || 'not specified'}`);
|
|
112
|
+
// Apply length limit only if specified
|
|
113
|
+
if (args.maxLength && totalLength > args.maxLength) {
|
|
114
|
+
const truncatedContent = textContent.substring(0, args.maxLength);
|
|
115
|
+
log.info(`Truncating content from ${totalLength} to ${args.maxLength} characters`);
|
|
116
|
+
return `Content (truncated to ${args.maxLength} chars of ${totalLength} total):\n---\n${truncatedContent}\n\n... [Document continues for ${totalLength - args.maxLength} more characters. Use maxLength parameter to adjust limit or remove it to get full content.]`;
|
|
117
|
+
}
|
|
118
|
+
// Return full content
|
|
119
|
+
const fullResponse = `Content (${totalLength} characters):\n---\n${textContent}`;
|
|
120
|
+
const responseLength = fullResponse.length;
|
|
121
|
+
log.info(`Returning full content: ${responseLength} characters in response (${totalLength} content + ${responseLength - totalLength} metadata)`);
|
|
122
|
+
return fullResponse;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
log.error(`Error reading doc ${args.documentId}: ${error.message || error}`);
|
|
126
|
+
log.error(`Error details: ${JSON.stringify(error.response?.data || error)}`);
|
|
127
|
+
// Handle errors thrown by helpers or API directly
|
|
128
|
+
if (error instanceof UserError)
|
|
129
|
+
throw error;
|
|
130
|
+
if (error instanceof NotImplementedError)
|
|
131
|
+
throw error;
|
|
132
|
+
// Generic fallback for API errors not caught by helpers
|
|
133
|
+
if (error.code === 404)
|
|
134
|
+
throw new UserError(`Doc not found (ID: ${args.documentId}).`);
|
|
135
|
+
if (error.code === 403) {
|
|
136
|
+
// The Docs API may be blocked by Workspace admin policy even when the Drive API is
|
|
137
|
+
// accessible. Fall back to drive.files.export() for plain-text format, which uses
|
|
138
|
+
// the Drive API and respects supportsAllDrives for Shared Drive documents.
|
|
139
|
+
if (!args.format || args.format === 'text') {
|
|
140
|
+
try {
|
|
141
|
+
log.info(`Docs API returned 403, falling back to Drive export for ${args.documentId}`);
|
|
142
|
+
const drive = await getDriveClient();
|
|
143
|
+
const exportRes = await drive.files.export({ fileId: args.documentId, mimeType: 'text/plain' }, { responseType: 'text' });
|
|
144
|
+
const textContent = exportRes.data;
|
|
145
|
+
if (!textContent?.trim())
|
|
146
|
+
return 'Document found, but appears empty.';
|
|
147
|
+
if (args.maxLength && textContent.length > args.maxLength) {
|
|
148
|
+
return `Content (truncated to ${args.maxLength} chars of ${textContent.length} total):\n---\n${textContent.substring(0, args.maxLength)}\n\n... [Document continues. Use maxLength parameter to adjust limit or remove it to get full content.]`;
|
|
149
|
+
}
|
|
150
|
+
return `Content (${textContent.length} characters):\n---\n${textContent}`;
|
|
151
|
+
}
|
|
152
|
+
catch (exportError) {
|
|
153
|
+
log.error(`Drive export fallback also failed: ${exportError.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw new UserError(`Permission denied for doc (ID: ${args.documentId}). The Google Docs API may be restricted by your Workspace admin.`);
|
|
157
|
+
}
|
|
158
|
+
// Extract detailed error information from Google API response
|
|
159
|
+
const errorDetails = error.response?.data?.error?.message || error.message || 'Unknown error';
|
|
160
|
+
const errorCode = error.response?.data?.error?.code || error.code;
|
|
161
|
+
throw new UserError(`Failed to read doc: ${errorDetails}${errorCode ? ` (Code: ${errorCode})` : ''}`);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDocsClient } from '../../clients.js';
|
|
4
|
+
import { DocumentIdParameter } from '../../types.js';
|
|
5
|
+
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
export function register(server) {
|
|
7
|
+
server.addTool({
|
|
8
|
+
name: 'renameTab',
|
|
9
|
+
description: 'Renames a tab in a Google Docs document. Use listDocumentTabs to get tab IDs first.',
|
|
10
|
+
parameters: DocumentIdParameter.extend({
|
|
11
|
+
tabId: z
|
|
12
|
+
.string()
|
|
13
|
+
.describe('The ID of the tab to rename. Use listDocumentTabs to get tab IDs.'),
|
|
14
|
+
newTitle: z.string().min(1).describe('The new title for the tab.'),
|
|
15
|
+
}),
|
|
16
|
+
execute: async (args, { log }) => {
|
|
17
|
+
const docs = await getDocsClient();
|
|
18
|
+
log.info(`Renaming tab ${args.tabId} to "${args.newTitle}" in doc ${args.documentId}`);
|
|
19
|
+
try {
|
|
20
|
+
// Verify the tab exists
|
|
21
|
+
const docInfo = await docs.documents.get({
|
|
22
|
+
documentId: args.documentId,
|
|
23
|
+
includeTabsContent: true,
|
|
24
|
+
fields: 'tabs(tabProperties,documentTab)',
|
|
25
|
+
});
|
|
26
|
+
const targetTab = GDocsHelpers.findTabById(docInfo.data, args.tabId);
|
|
27
|
+
if (!targetTab) {
|
|
28
|
+
throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
|
|
29
|
+
}
|
|
30
|
+
const oldTitle = targetTab.tabProperties?.title || '(untitled)';
|
|
31
|
+
await docs.documents.batchUpdate({
|
|
32
|
+
documentId: args.documentId,
|
|
33
|
+
requestBody: {
|
|
34
|
+
requests: [
|
|
35
|
+
{
|
|
36
|
+
updateDocumentTabProperties: {
|
|
37
|
+
tabProperties: {
|
|
38
|
+
tabId: args.tabId,
|
|
39
|
+
title: args.newTitle,
|
|
40
|
+
},
|
|
41
|
+
fields: 'title',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return `Successfully renamed tab from "${oldTitle}" to "${args.newTitle}".`;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
log.error(`Error renaming tab ${args.tabId} in doc ${args.documentId}: ${error.message || error}`);
|
|
51
|
+
if (error instanceof UserError)
|
|
52
|
+
throw error;
|
|
53
|
+
if (error.code === 404)
|
|
54
|
+
throw new UserError(`Document not found (ID: ${args.documentId}).`);
|
|
55
|
+
if (error.code === 403)
|
|
56
|
+
throw new UserError(`Permission denied for document (ID: ${args.documentId}).`);
|
|
57
|
+
throw new UserError(`Failed to rename tab: ${error.message || 'Unknown error'}`);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'copyFile',
|
|
7
|
+
description: "Creates a copy of a file or document in Google Drive. Returns the new copy's ID and URL.",
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
fileId: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe('The file or folder ID from a Google Drive URL or a previous tool result.'),
|
|
12
|
+
newName: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('Name for the copied file. If not provided, will use "Copy of [original name]".'),
|
|
16
|
+
parentFolderId: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('ID of folder where copy should be placed. If not provided, places in same location as original.'),
|
|
20
|
+
}),
|
|
21
|
+
execute: async (args, { log }) => {
|
|
22
|
+
const drive = await getDriveClient();
|
|
23
|
+
log.info(`Copying file ${args.fileId} ${args.newName ? `as "${args.newName}"` : ''}`);
|
|
24
|
+
try {
|
|
25
|
+
// Get original file info
|
|
26
|
+
const originalFile = await drive.files.get({
|
|
27
|
+
fileId: args.fileId,
|
|
28
|
+
fields: 'name,parents',
|
|
29
|
+
supportsAllDrives: true,
|
|
30
|
+
});
|
|
31
|
+
const copyMetadata = {
|
|
32
|
+
name: args.newName || `Copy of ${originalFile.data.name}`,
|
|
33
|
+
};
|
|
34
|
+
if (args.parentFolderId) {
|
|
35
|
+
copyMetadata.parents = [args.parentFolderId];
|
|
36
|
+
}
|
|
37
|
+
else if (originalFile.data.parents) {
|
|
38
|
+
copyMetadata.parents = originalFile.data.parents;
|
|
39
|
+
}
|
|
40
|
+
const response = await drive.files.copy({
|
|
41
|
+
fileId: args.fileId,
|
|
42
|
+
requestBody: copyMetadata,
|
|
43
|
+
fields: 'id,name,webViewLink',
|
|
44
|
+
supportsAllDrives: true,
|
|
45
|
+
});
|
|
46
|
+
const copiedFile = response.data;
|
|
47
|
+
return JSON.stringify({
|
|
48
|
+
id: copiedFile.id,
|
|
49
|
+
name: copiedFile.name,
|
|
50
|
+
url: copiedFile.webViewLink,
|
|
51
|
+
}, null, 2);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
log.error(`Error copying file: ${error.message || error}`);
|
|
55
|
+
if (error.code === 404)
|
|
56
|
+
throw new UserError('Original file or destination folder not found. Check the IDs.');
|
|
57
|
+
if (error.code === 403)
|
|
58
|
+
throw new UserError('Permission denied. Make sure you have read access to the original file and write access to the destination.');
|
|
59
|
+
throw new UserError(`Failed to copy file: ${error.message || 'Unknown error'}`);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient, getDocsClient } from '../../clients.js';
|
|
4
|
+
import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'createDocument',
|
|
8
|
+
description: 'Creates a new empty Google Document. Optionally places it in a specific folder and adds initial text content.',
|
|
9
|
+
parameters: z.object({
|
|
10
|
+
title: z.string().min(1).describe('Title for the new document.'),
|
|
11
|
+
parentFolderId: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe('ID of folder where document should be created. If not provided, creates in Drive root.'),
|
|
15
|
+
initialContent: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Initial content to add to the document. By default, markdown syntax is converted to formatted Google Docs content (headings, bold, italic, links, lists, etc.).'),
|
|
19
|
+
contentFormat: z
|
|
20
|
+
.enum(['markdown', 'raw'])
|
|
21
|
+
.optional()
|
|
22
|
+
.default('markdown')
|
|
23
|
+
.describe("How to interpret initialContent. 'markdown' (default) converts markdown to formatted Google Docs content. 'raw' inserts the text as-is without any conversion."),
|
|
24
|
+
}),
|
|
25
|
+
execute: async (args, { log }) => {
|
|
26
|
+
const drive = await getDriveClient();
|
|
27
|
+
log.info(`Creating new document "${args.title}"`);
|
|
28
|
+
try {
|
|
29
|
+
const documentMetadata = {
|
|
30
|
+
name: args.title,
|
|
31
|
+
mimeType: 'application/vnd.google-apps.document',
|
|
32
|
+
};
|
|
33
|
+
if (args.parentFolderId) {
|
|
34
|
+
documentMetadata.parents = [args.parentFolderId];
|
|
35
|
+
}
|
|
36
|
+
const response = await drive.files.create({
|
|
37
|
+
requestBody: documentMetadata,
|
|
38
|
+
fields: 'id,name,webViewLink',
|
|
39
|
+
supportsAllDrives: true,
|
|
40
|
+
});
|
|
41
|
+
const document = response.data;
|
|
42
|
+
// Add initial content if provided
|
|
43
|
+
if (args.initialContent) {
|
|
44
|
+
try {
|
|
45
|
+
const docs = await getDocsClient();
|
|
46
|
+
if (args.contentFormat === 'raw') {
|
|
47
|
+
await docs.documents.batchUpdate({
|
|
48
|
+
documentId: document.id,
|
|
49
|
+
requestBody: {
|
|
50
|
+
requests: [
|
|
51
|
+
{
|
|
52
|
+
insertText: {
|
|
53
|
+
location: { index: 1 },
|
|
54
|
+
text: args.initialContent,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const result = await insertMarkdown(docs, document.id, args.initialContent, {
|
|
63
|
+
startIndex: 1,
|
|
64
|
+
firstHeadingAsTitle: true,
|
|
65
|
+
});
|
|
66
|
+
log.info(formatInsertResult(result));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (contentError) {
|
|
70
|
+
log.warn(`Document created but failed to add initial content: ${contentError.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return JSON.stringify({
|
|
74
|
+
id: document.id,
|
|
75
|
+
name: document.name,
|
|
76
|
+
url: document.webViewLink,
|
|
77
|
+
}, null, 2);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
log.error(`Error creating document: ${error.message || error}`);
|
|
81
|
+
if (error.code === 404)
|
|
82
|
+
throw new UserError('Parent folder not found. Check the folder ID.');
|
|
83
|
+
if (error.code === 403)
|
|
84
|
+
throw new UserError('Permission denied. Make sure you have write access to the destination folder.');
|
|
85
|
+
throw new UserError(`Failed to create document: ${error.message || 'Unknown error'}`);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
export function register(server) {
|
|
5
|
+
server.addTool({
|
|
6
|
+
name: 'createFolder',
|
|
7
|
+
description: 'Creates a new folder in Google Drive. Optionally places it inside an existing parent folder.',
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
name: z.string().min(1).describe('Name for the new folder.'),
|
|
10
|
+
parentFolderId: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe('Parent folder ID. If not provided, creates folder in Drive root.'),
|
|
14
|
+
}),
|
|
15
|
+
execute: async (args, { log }) => {
|
|
16
|
+
const drive = await getDriveClient();
|
|
17
|
+
log.info(`Creating folder "${args.name}" ${args.parentFolderId ? `in parent ${args.parentFolderId}` : 'in root'}`);
|
|
18
|
+
try {
|
|
19
|
+
const folderMetadata = {
|
|
20
|
+
name: args.name,
|
|
21
|
+
mimeType: 'application/vnd.google-apps.folder',
|
|
22
|
+
};
|
|
23
|
+
if (args.parentFolderId) {
|
|
24
|
+
folderMetadata.parents = [args.parentFolderId];
|
|
25
|
+
}
|
|
26
|
+
const response = await drive.files.create({
|
|
27
|
+
requestBody: folderMetadata,
|
|
28
|
+
fields: 'id,name,parents,webViewLink',
|
|
29
|
+
supportsAllDrives: true,
|
|
30
|
+
});
|
|
31
|
+
const folder = response.data;
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
id: folder.id,
|
|
34
|
+
name: folder.name,
|
|
35
|
+
url: folder.webViewLink,
|
|
36
|
+
}, null, 2);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
log.error(`Error creating folder: ${error.message || error}`);
|
|
40
|
+
if (error.code === 404)
|
|
41
|
+
throw new UserError('Parent folder not found. Check the parent folder ID.');
|
|
42
|
+
if (error.code === 403)
|
|
43
|
+
throw new UserError('Permission denied. Make sure you have write access to the parent folder.');
|
|
44
|
+
throw new UserError(`Failed to create folder: ${error.message || 'Unknown error'}`);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|