google-tools-mcp 1.0.17 → 1.1.2
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/googleDocsApiHelpers.js +220 -108
- package/dist/markdown-transformer/index.js +23 -3
- package/dist/markdown-transformer/markdownToDocs.js +25 -0
- package/dist/readTracker.js +84 -0
- package/dist/tools/docs/appendToGoogleDoc.js +22 -3
- package/dist/tools/docs/deleteRange.js +3 -0
- package/dist/tools/docs/findAndReplace.js +3 -0
- package/dist/tools/docs/formatting/applyParagraphStyle.js +2 -2
- package/dist/tools/docs/modifyText.js +25 -10
- package/dist/tools/docs/modifyText.test.js +90 -0
- package/dist/tools/docs/readGoogleDoc.js +2 -0
- package/dist/tools/drive/deleteFile.js +2 -0
- package/dist/tools/drive/index.js +2 -0
- package/dist/tools/drive/uploadFile.js +111 -0
- package/dist/tools/extras/index.js +2 -0
- package/dist/tools/extras/readDriveFile.js +206 -0
- package/dist/tools/extras/readFile.js +2 -0
- package/dist/tools/index.js +117 -11
- package/dist/tools/sheets/batchWrite.js +2 -0
- package/dist/tools/sheets/clearSpreadsheetRange.js +2 -0
- package/dist/tools/sheets/getSpreadsheetInfo.js +2 -0
- package/dist/tools/sheets/readSpreadsheet.js +2 -0
- package/dist/tools/sheets/writeSpreadsheet.js +2 -0
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +26 -5
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +27 -5
- package/dist/types.js +1 -2
- package/package.json +51 -4
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getDocsClient } from '../../clients.js';
|
|
4
4
|
import { DocumentIdParameter, TextFindParameter, TextStyleParameters, ParagraphStyleParameters } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
6
7
|
const RangeTarget = z
|
|
7
8
|
.object({
|
|
8
9
|
startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
|
|
@@ -42,17 +43,17 @@ const ModifyTextParameters = DocumentIdParameter.extend({
|
|
|
42
43
|
export function buildModifyTextRequests(opts) {
|
|
43
44
|
const { startIndex, endIndex, text, style, paragraphStyle, tabId } = opts;
|
|
44
45
|
const requests = [];
|
|
45
|
-
if (
|
|
46
|
+
if (text === undefined && !style && !paragraphStyle)
|
|
46
47
|
return requests;
|
|
47
|
-
// 1. Delete existing content (
|
|
48
|
+
// 1. Delete existing content (when replacing or deleting)
|
|
48
49
|
if (endIndex !== undefined && text !== undefined) {
|
|
49
50
|
const range = { startIndex, endIndex };
|
|
50
51
|
if (tabId)
|
|
51
52
|
range.tabId = tabId;
|
|
52
53
|
requests.push({ deleteContentRange: { range } });
|
|
53
54
|
}
|
|
54
|
-
// 2. Insert new text
|
|
55
|
-
if (text !== undefined) {
|
|
55
|
+
// 2. Insert new text (skip if empty string — that means "delete only")
|
|
56
|
+
if (text !== undefined && text !== '') {
|
|
56
57
|
const location = { index: startIndex };
|
|
57
58
|
if (tabId)
|
|
58
59
|
location.tabId = tabId;
|
|
@@ -93,11 +94,17 @@ export function buildModifyTextRequests(opts) {
|
|
|
93
94
|
export function register(server) {
|
|
94
95
|
server.addTool({
|
|
95
96
|
name: 'modifyText',
|
|
96
|
-
description: '
|
|
97
|
-
'Can insert text at a position, replace a range or found text,
|
|
98
|
-
|
|
97
|
+
description: 'Best for small, targeted, single-location changes within a line or paragraph. ' +
|
|
98
|
+
'Can insert text at a position, replace a range or found text, delete text (replace with empty string ""), ' +
|
|
99
|
+
'apply text styling (bold, italic, etc.), apply paragraph styling (alignment, headings, spacing, etc.), or any combination. ' +
|
|
100
|
+
"Use readDocument with format='json' to determine indices. " +
|
|
101
|
+
'Supports \\n for line breaks and \\t for tabs in replacement text. ' +
|
|
102
|
+
'When using textToFind, if multiple matches exist the tool returns all instances with context so you can specify matchInstance. ' +
|
|
103
|
+
'For multi-line or section-level rewrites, use replaceDocumentWithMarkdown instead. ' +
|
|
104
|
+
'To add content to the end of a doc, use appendMarkdown or appendText.',
|
|
99
105
|
parameters: ModifyTextParameters,
|
|
100
106
|
execute: async (args, { log }) => {
|
|
107
|
+
await guardMutation(args.documentId);
|
|
101
108
|
const docs = await getDocsClient();
|
|
102
109
|
log.info(`modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
|
|
103
110
|
`${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` +
|
|
@@ -130,7 +137,7 @@ export function register(server) {
|
|
|
130
137
|
else if ('textToFind' in args.target) {
|
|
131
138
|
const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance, args.tabId);
|
|
132
139
|
if (!range) {
|
|
133
|
-
throw new UserError(`Could not find
|
|
140
|
+
throw new UserError(`Could not find text "${args.target.textToFind}"${args.target.matchInstance ? ` (instance ${args.target.matchInstance})` : ''}${args.tabId ? ` in tab ${args.tabId}` : ''}.`);
|
|
134
141
|
}
|
|
135
142
|
startIndex = range.startIndex;
|
|
136
143
|
endIndex = range.endIndex;
|
|
@@ -143,10 +150,15 @@ export function register(server) {
|
|
|
143
150
|
// Clamp to minimum 1 (index 0 is the document section break)
|
|
144
151
|
if (startIndex < 1)
|
|
145
152
|
startIndex = 1;
|
|
153
|
+
// Normalize escape sequences so literal \n / \t in the input
|
|
154
|
+
// are converted to real newline / tab characters (issue #9).
|
|
155
|
+
const normalizedText = args.text
|
|
156
|
+
?.replace(/\\n/g, '\n')
|
|
157
|
+
.replace(/\\t/g, '\t');
|
|
146
158
|
const requests = buildModifyTextRequests({
|
|
147
159
|
startIndex,
|
|
148
160
|
endIndex,
|
|
149
|
-
text:
|
|
161
|
+
text: normalizedText,
|
|
150
162
|
style: args.style,
|
|
151
163
|
paragraphStyle: args.paragraphStyle,
|
|
152
164
|
tabId: args.tabId,
|
|
@@ -155,9 +167,12 @@ export function register(server) {
|
|
|
155
167
|
return 'No operations to perform.';
|
|
156
168
|
}
|
|
157
169
|
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, requests);
|
|
170
|
+
trackMutation(args.documentId);
|
|
158
171
|
// Build descriptive result
|
|
159
172
|
const actions = [];
|
|
160
|
-
if (endIndex !== undefined &&
|
|
173
|
+
if (endIndex !== undefined && normalizedText === '')
|
|
174
|
+
actions.push('deleted text');
|
|
175
|
+
else if (endIndex !== undefined && args.text !== undefined)
|
|
161
176
|
actions.push('replaced text');
|
|
162
177
|
else if (args.text !== undefined)
|
|
163
178
|
actions.push('inserted text');
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildModifyTextRequests } from './modifyText.js';
|
|
4
|
+
|
|
5
|
+
describe('buildModifyTextRequests', () => {
|
|
6
|
+
it('generates deleteContentRange + insertText for a replacement', () => {
|
|
7
|
+
const reqs = buildModifyTextRequests({
|
|
8
|
+
startIndex: 5,
|
|
9
|
+
endIndex: 10,
|
|
10
|
+
text: 'hello',
|
|
11
|
+
});
|
|
12
|
+
assert.equal(reqs.length, 2);
|
|
13
|
+
assert.deepEqual(reqs[0], {
|
|
14
|
+
deleteContentRange: { range: { startIndex: 5, endIndex: 10 } },
|
|
15
|
+
});
|
|
16
|
+
assert.deepEqual(reqs[1], {
|
|
17
|
+
insertText: { location: { index: 5 }, text: 'hello' },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('generates only insertText for an insertion (no endIndex)', () => {
|
|
22
|
+
const reqs = buildModifyTextRequests({
|
|
23
|
+
startIndex: 3,
|
|
24
|
+
endIndex: undefined,
|
|
25
|
+
text: 'hi',
|
|
26
|
+
});
|
|
27
|
+
assert.equal(reqs.length, 1);
|
|
28
|
+
assert.deepEqual(reqs[0], {
|
|
29
|
+
insertText: { location: { index: 3 }, text: 'hi' },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns empty array when no text/style/paragraphStyle given', () => {
|
|
34
|
+
const reqs = buildModifyTextRequests({
|
|
35
|
+
startIndex: 1,
|
|
36
|
+
endIndex: 5,
|
|
37
|
+
});
|
|
38
|
+
assert.equal(reqs.length, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('includes tabId in range and location when provided', () => {
|
|
42
|
+
const reqs = buildModifyTextRequests({
|
|
43
|
+
startIndex: 1,
|
|
44
|
+
endIndex: 4,
|
|
45
|
+
text: 'x',
|
|
46
|
+
tabId: 'tab1',
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(reqs[0].deleteContentRange.range.tabId, 'tab1');
|
|
49
|
+
assert.deepEqual(reqs[1].insertText.location.tabId, 'tab1');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('escape sequence normalization (issue #9)', () => {
|
|
54
|
+
// Simulate the normalization that happens in the execute handler
|
|
55
|
+
function normalize(text) {
|
|
56
|
+
return text?.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('converts literal \\n to real newline', () => {
|
|
60
|
+
const input = 'line one\\nline two\\nline three';
|
|
61
|
+
const result = normalize(input);
|
|
62
|
+
assert.equal(result, 'line one\nline two\nline three');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('converts literal \\t to real tab', () => {
|
|
66
|
+
const input = 'col1\\tcol2\\tcol3';
|
|
67
|
+
const result = normalize(input);
|
|
68
|
+
assert.equal(result, 'col1\tcol2\tcol3');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('handles mixed \\n and \\t', () => {
|
|
72
|
+
const input = 'row1\\tcol2\\nrow2\\tcol2';
|
|
73
|
+
const result = normalize(input);
|
|
74
|
+
assert.equal(result, 'row1\tcol2\nrow2\tcol2');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('leaves text without escape sequences unchanged', () => {
|
|
78
|
+
const input = 'no escapes here';
|
|
79
|
+
assert.equal(normalize(input), 'no escapes here');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('preserves real newlines that are already in the string', () => {
|
|
83
|
+
const input = 'already\nreal';
|
|
84
|
+
assert.equal(normalize(input), 'already\nreal');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns undefined for undefined input', () => {
|
|
88
|
+
assert.equal(normalize(undefined), undefined);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -4,6 +4,7 @@ import { getDocsClient, getDriveClient } from '../../clients.js';
|
|
|
4
4
|
import { DocumentIdParameter, NotImplementedError } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
6
|
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
7
|
+
import { trackRead } from '../../readTracker.js';
|
|
7
8
|
export function register(server) {
|
|
8
9
|
server.addTool({
|
|
9
10
|
name: 'readDocument',
|
|
@@ -38,6 +39,7 @@ export function register(server) {
|
|
|
38
39
|
fields: needsTabsContent ? '*' : fields, // Get full document if using tabs
|
|
39
40
|
});
|
|
40
41
|
log.info(`Fetched doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
42
|
+
trackRead(args.documentId);
|
|
41
43
|
// If tabId is specified, find the specific tab
|
|
42
44
|
let contentSource;
|
|
43
45
|
if (args.tabId) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UserError } from 'fastmcp';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
import { guardMutation } from '../../readTracker.js';
|
|
4
5
|
export function register(server) {
|
|
5
6
|
server.addTool({
|
|
6
7
|
name: 'deleteFile',
|
|
@@ -16,6 +17,7 @@ export function register(server) {
|
|
|
16
17
|
.describe('If true, permanently deletes the file instead of moving it to trash.'),
|
|
17
18
|
}),
|
|
18
19
|
execute: async (args, { log }) => {
|
|
20
|
+
await guardMutation(args.fileId, { skipExternalCheck: true });
|
|
19
21
|
const drive = await getDriveClient();
|
|
20
22
|
log.info(`Deleting file ${args.fileId} ${args.permanent ? '(permanent)' : '(to trash)'}`);
|
|
21
23
|
try {
|
|
@@ -14,6 +14,7 @@ import { register as listSharedDrives } from './listSharedDrives.js';
|
|
|
14
14
|
import { register as listSharedWithMe } from './listSharedWithMe.js';
|
|
15
15
|
import { register as downloadFile } from './downloadFile.js';
|
|
16
16
|
import { register as getFilePath } from './getFilePath.js';
|
|
17
|
+
import { register as uploadFile } from './uploadFile.js';
|
|
17
18
|
export function registerDriveTools(server) {
|
|
18
19
|
listDriveFiles(server);
|
|
19
20
|
searchGoogleDocs(server);
|
|
@@ -31,4 +32,5 @@ export function registerDriveTools(server) {
|
|
|
31
32
|
listSharedWithMe(server);
|
|
32
33
|
downloadFile(server);
|
|
33
34
|
getFilePath(server);
|
|
35
|
+
uploadFile(server);
|
|
34
36
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { UserError } from 'fastmcp';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { getDriveClient } from '../../clients.js';
|
|
6
|
+
|
|
7
|
+
// Common MIME type mappings for file extensions
|
|
8
|
+
const MIME_TYPES = {
|
|
9
|
+
'.pdf': 'application/pdf',
|
|
10
|
+
'.doc': 'application/msword',
|
|
11
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
12
|
+
'.xls': 'application/vnd.ms-excel',
|
|
13
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
14
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
15
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
16
|
+
'.txt': 'text/plain',
|
|
17
|
+
'.csv': 'text/csv',
|
|
18
|
+
'.json': 'application/json',
|
|
19
|
+
'.xml': 'application/xml',
|
|
20
|
+
'.html': 'text/html',
|
|
21
|
+
'.htm': 'text/html',
|
|
22
|
+
'.md': 'text/markdown',
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.jpg': 'image/jpeg',
|
|
25
|
+
'.jpeg': 'image/jpeg',
|
|
26
|
+
'.gif': 'image/gif',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
'.mp3': 'audio/mpeg',
|
|
30
|
+
'.mp4': 'video/mp4',
|
|
31
|
+
'.zip': 'application/zip',
|
|
32
|
+
'.gz': 'application/gzip',
|
|
33
|
+
'.tar': 'application/x-tar',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function register(server) {
|
|
37
|
+
server.addTool({
|
|
38
|
+
name: 'uploadFile',
|
|
39
|
+
description:
|
|
40
|
+
'Uploads a local file from the filesystem to Google Drive. ' +
|
|
41
|
+
'Auto-detects MIME type from file extension. ' +
|
|
42
|
+
'Returns the uploaded file\'s Drive ID and URL.',
|
|
43
|
+
parameters: z.object({
|
|
44
|
+
localPath: z.string().describe('Absolute path to the local file to upload.'),
|
|
45
|
+
name: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Custom name for the file in Drive. If not provided, uses the local filename.'),
|
|
49
|
+
parentFolderId: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe('ID of the Drive folder to upload into. If not provided, uploads to the root of My Drive.'),
|
|
53
|
+
mimeType: z
|
|
54
|
+
.string()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe('MIME type override. If not provided, auto-detected from file extension.'),
|
|
57
|
+
}),
|
|
58
|
+
execute: async (args, { log }) => {
|
|
59
|
+
const drive = await getDriveClient();
|
|
60
|
+
|
|
61
|
+
// Validate file exists
|
|
62
|
+
if (!fs.existsSync(args.localPath)) {
|
|
63
|
+
throw new UserError(`File not found: ${args.localPath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stats = fs.statSync(args.localPath);
|
|
67
|
+
if (!stats.isFile()) {
|
|
68
|
+
throw new UserError(`Path is not a file: ${args.localPath}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fileName = args.name || path.basename(args.localPath);
|
|
72
|
+
const ext = path.extname(args.localPath).toLowerCase();
|
|
73
|
+
const mimeType = args.mimeType || MIME_TYPES[ext] || 'application/octet-stream';
|
|
74
|
+
|
|
75
|
+
log.info(`Uploading "${args.localPath}" as "${fileName}" (${mimeType}, ${stats.size} bytes)`);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const fileMetadata = { name: fileName };
|
|
79
|
+
if (args.parentFolderId) {
|
|
80
|
+
fileMetadata.parents = [args.parentFolderId];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const response = await drive.files.create({
|
|
84
|
+
requestBody: fileMetadata,
|
|
85
|
+
media: {
|
|
86
|
+
mimeType,
|
|
87
|
+
body: fs.createReadStream(args.localPath),
|
|
88
|
+
},
|
|
89
|
+
fields: 'id,name,webViewLink,mimeType,size',
|
|
90
|
+
supportsAllDrives: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const file = response.data;
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
id: file.id,
|
|
96
|
+
name: file.name,
|
|
97
|
+
url: file.webViewLink,
|
|
98
|
+
mimeType: file.mimeType,
|
|
99
|
+
size: file.size,
|
|
100
|
+
}, null, 2);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
log.error(`Error uploading file: ${error.message || error}`);
|
|
103
|
+
if (error.code === 404)
|
|
104
|
+
throw new UserError('Destination folder not found. Check the parentFolderId.');
|
|
105
|
+
if (error.code === 403)
|
|
106
|
+
throw new UserError('Permission denied. Make sure you have write access to the destination folder.');
|
|
107
|
+
throw new UserError(`Failed to upload file: ${error.message || 'Unknown error'}`);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { register as readFile } from './readFile.js';
|
|
2
2
|
import { register as searchFileContents } from './searchFileContents.js';
|
|
3
|
+
import { register as readDriveFile } from './readDriveFile.js';
|
|
3
4
|
|
|
4
5
|
export function registerExtrasTools(server) {
|
|
5
6
|
readFile(server);
|
|
6
7
|
searchFileContents(server);
|
|
8
|
+
readDriveFile(server);
|
|
7
9
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getDriveClient, getDocsClient, getSheetsClient } from '../../clients.js';
|
|
4
|
+
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
5
|
+
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { extractText, downloadBuffer } from './readFile.js';
|
|
7
|
+
import { trackRead } from '../../readTracker.js';
|
|
8
|
+
|
|
9
|
+
// Google-native MIME types
|
|
10
|
+
const GOOGLE_DOC = 'application/vnd.google-apps.document';
|
|
11
|
+
const GOOGLE_SHEET = 'application/vnd.google-apps.spreadsheet';
|
|
12
|
+
const GOOGLE_SLIDES = 'application/vnd.google-apps.presentation';
|
|
13
|
+
|
|
14
|
+
// Capabilities by file type category
|
|
15
|
+
const CAPABILITIES = {
|
|
16
|
+
googleDoc: {
|
|
17
|
+
fileType: 'Google Doc',
|
|
18
|
+
capabilities: ['read', 'readDocument', 'modifyText', 'replaceDocumentWithMarkdown', 'appendMarkdown', 'appendText', 'findAndReplace', 'deleteRange'],
|
|
19
|
+
},
|
|
20
|
+
googleSheet: {
|
|
21
|
+
fileType: 'Google Sheet',
|
|
22
|
+
capabilities: ['read', 'readSpreadsheet', 'writeSpreadsheet', 'appendRows', 'formatCells'],
|
|
23
|
+
},
|
|
24
|
+
docx: {
|
|
25
|
+
fileType: 'Word Document (.docx)',
|
|
26
|
+
capabilities: ['read'],
|
|
27
|
+
note: 'This is a Word document. Editing tools (modifyText, appendMarkdown, etc.) are not available unless converted to a Google Doc via copyFile or Drive UI.',
|
|
28
|
+
},
|
|
29
|
+
pdf: {
|
|
30
|
+
fileType: 'PDF',
|
|
31
|
+
capabilities: ['read'],
|
|
32
|
+
note: 'This is a PDF file. Editing tools are not available. Convert to a Google Doc first if editing is needed.',
|
|
33
|
+
},
|
|
34
|
+
other: {
|
|
35
|
+
fileType: 'Unknown',
|
|
36
|
+
capabilities: ['download'],
|
|
37
|
+
note: 'This file type cannot be read as text. Use downloadFile to download it.',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function register(server) {
|
|
42
|
+
server.addTool({
|
|
43
|
+
name: 'readDriveFile',
|
|
44
|
+
description:
|
|
45
|
+
'Unified read tool: accepts any Google Drive file ID, auto-detects the file type (Google Doc, Sheet, Word .docx, PDF), ' +
|
|
46
|
+
'and routes to the appropriate reader. Returns content along with metadata about available capabilities. ' +
|
|
47
|
+
'Use this when you have a file ID but don\'t know the file type, or when readDocument fails with a 400 error.',
|
|
48
|
+
parameters: z.object({
|
|
49
|
+
fileId: z
|
|
50
|
+
.string()
|
|
51
|
+
.describe('The Google Drive file ID (the long string between /d/ and /edit in a Drive URL).'),
|
|
52
|
+
format: z
|
|
53
|
+
.enum(['text', 'json', 'markdown'])
|
|
54
|
+
.optional()
|
|
55
|
+
.default('markdown')
|
|
56
|
+
.describe("Output format for Google Docs: 'markdown' (default), 'text' (plain text), 'json' (raw structure). Ignored for non-Doc files."),
|
|
57
|
+
range: z
|
|
58
|
+
.string()
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("A1 notation range for Google Sheets (e.g., 'Sheet1!A1:C10'). Required for spreadsheets."),
|
|
61
|
+
}),
|
|
62
|
+
execute: async (args, { log }) => {
|
|
63
|
+
const drive = await getDriveClient();
|
|
64
|
+
|
|
65
|
+
// 1. Get file metadata to determine type
|
|
66
|
+
let fileMeta;
|
|
67
|
+
try {
|
|
68
|
+
const res = await drive.files.get({
|
|
69
|
+
fileId: args.fileId,
|
|
70
|
+
fields: 'id,name,mimeType,modifiedTime,size',
|
|
71
|
+
supportsAllDrives: true,
|
|
72
|
+
});
|
|
73
|
+
fileMeta = res.data;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code === 404) throw new UserError(`File not found: ${args.fileId}`);
|
|
76
|
+
if (error.code === 403) throw new UserError('Permission denied. Check that the file is shared with this account.');
|
|
77
|
+
throw new UserError(`Failed to get file info: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log.info(`File "${fileMeta.name}" is ${fileMeta.mimeType}`);
|
|
81
|
+
trackRead(args.fileId, fileMeta.modifiedTime);
|
|
82
|
+
|
|
83
|
+
// 2. Route based on MIME type
|
|
84
|
+
if (fileMeta.mimeType === GOOGLE_DOC) {
|
|
85
|
+
return await readGoogleDoc(args, fileMeta, log);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (fileMeta.mimeType === GOOGLE_SHEET) {
|
|
89
|
+
return await readGoogleSheet(args, fileMeta, log);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Try reading as docx/pdf
|
|
93
|
+
const text = await tryReadBinaryFile(drive, args.fileId, fileMeta, log);
|
|
94
|
+
if (text !== null) {
|
|
95
|
+
return text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Unsupported type
|
|
99
|
+
const capInfo = CAPABILITIES.other;
|
|
100
|
+
capInfo.fileType = fileMeta.mimeType;
|
|
101
|
+
return JSON.stringify({
|
|
102
|
+
file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
|
|
103
|
+
...capInfo,
|
|
104
|
+
content: null,
|
|
105
|
+
}, null, 2);
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readGoogleDoc(args, fileMeta, log) {
|
|
111
|
+
const docs = await getDocsClient();
|
|
112
|
+
const res = await docs.documents.get({
|
|
113
|
+
documentId: args.fileId,
|
|
114
|
+
fields: args.format === 'text' ? 'body(content(paragraph(elements(textRun(content)))))' : '*',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
let content;
|
|
118
|
+
if (args.format === 'json') {
|
|
119
|
+
content = JSON.stringify(res.data, null, 2);
|
|
120
|
+
} else if (args.format === 'markdown') {
|
|
121
|
+
content = docsJsonToMarkdown(res.data);
|
|
122
|
+
} else {
|
|
123
|
+
// text
|
|
124
|
+
let textContent = '';
|
|
125
|
+
res.data.body?.content?.forEach((element) => {
|
|
126
|
+
if (element.paragraph?.elements) {
|
|
127
|
+
element.paragraph.elements.forEach((pe) => {
|
|
128
|
+
if (pe.textRun?.content) textContent += pe.textRun.content;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (element.table?.tableRows) {
|
|
132
|
+
element.table.tableRows.forEach((row) => {
|
|
133
|
+
row.tableCells?.forEach((cell) => {
|
|
134
|
+
cell.content?.forEach((cellElement) => {
|
|
135
|
+
cellElement.paragraph?.elements?.forEach((pe) => {
|
|
136
|
+
if (pe.textRun?.content) textContent += pe.textRun.content;
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
content = textContent || '(empty document)';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return JSON.stringify({
|
|
147
|
+
file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
|
|
148
|
+
...CAPABILITIES.googleDoc,
|
|
149
|
+
content,
|
|
150
|
+
}, null, 2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readGoogleSheet(args, fileMeta, log) {
|
|
154
|
+
if (!args.range) {
|
|
155
|
+
// Return sheet info without data
|
|
156
|
+
const sheets = await getSheetsClient();
|
|
157
|
+
const info = await sheets.spreadsheets.get({
|
|
158
|
+
spreadsheetId: args.fileId,
|
|
159
|
+
fields: 'sheets(properties(sheetId,title,gridProperties))',
|
|
160
|
+
});
|
|
161
|
+
const sheetList = info.data.sheets?.map(s => ({
|
|
162
|
+
title: s.properties.title,
|
|
163
|
+
sheetId: s.properties.sheetId,
|
|
164
|
+
rows: s.properties.gridProperties?.rowCount,
|
|
165
|
+
cols: s.properties.gridProperties?.columnCount,
|
|
166
|
+
}));
|
|
167
|
+
return JSON.stringify({
|
|
168
|
+
file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
|
|
169
|
+
...CAPABILITIES.googleSheet,
|
|
170
|
+
note: 'Provide a range parameter (e.g., "Sheet1!A1:C10") to read data. Available sheets listed below.',
|
|
171
|
+
sheets: sheetList,
|
|
172
|
+
}, null, 2);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const sheets = await getSheetsClient();
|
|
176
|
+
const response = await sheets.spreadsheets.values.get({
|
|
177
|
+
spreadsheetId: args.fileId,
|
|
178
|
+
range: args.range,
|
|
179
|
+
});
|
|
180
|
+
return JSON.stringify({
|
|
181
|
+
file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
|
|
182
|
+
...CAPABILITIES.googleSheet,
|
|
183
|
+
range: args.range,
|
|
184
|
+
values: response.data.values || [],
|
|
185
|
+
}, null, 2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function tryReadBinaryFile(drive, fileId, fileMeta, log) {
|
|
189
|
+
const { mimeType, name } = fileMeta;
|
|
190
|
+
const isDocxFile = mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || name?.endsWith('.docx');
|
|
191
|
+
const isPdfFile = mimeType === 'application/pdf' || name?.endsWith('.pdf');
|
|
192
|
+
|
|
193
|
+
if (!isDocxFile && !isPdfFile) return null;
|
|
194
|
+
|
|
195
|
+
const buffer = await downloadBuffer(drive, fileId);
|
|
196
|
+
const text = await extractText(buffer, mimeType, name);
|
|
197
|
+
|
|
198
|
+
if (text === null) return null;
|
|
199
|
+
|
|
200
|
+
const capInfo = isDocxFile ? CAPABILITIES.docx : CAPABILITIES.pdf;
|
|
201
|
+
return JSON.stringify({
|
|
202
|
+
file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
|
|
203
|
+
...capInfo,
|
|
204
|
+
content: text,
|
|
205
|
+
}, null, 2);
|
|
206
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UserError } from 'fastmcp';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
import { trackRead } from '../../readTracker.js';
|
|
4
5
|
import mammoth from 'mammoth';
|
|
5
6
|
|
|
6
7
|
function isDocx(mimeType, name) {
|
|
@@ -58,6 +59,7 @@ export function register(server) {
|
|
|
58
59
|
});
|
|
59
60
|
|
|
60
61
|
const { name, mimeType } = meta.data;
|
|
62
|
+
trackRead(fileId);
|
|
61
63
|
const buffer = await downloadBuffer(drive, fileId);
|
|
62
64
|
const text = await extractText(buffer, mimeType, name);
|
|
63
65
|
|