google-tools-mcp 1.2.4 → 1.2.6
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 +2 -0
- package/dist/clients.js +17 -0
- package/dist/readTracker.js +145 -84
- package/dist/tools/docs/appendToGoogleDoc.js +7 -1
- package/dist/tools/docs/deleteRange.js +7 -1
- package/dist/tools/docs/findAndReplace.js +7 -1
- package/dist/tools/docs/modifyText.js +7 -1
- package/dist/tools/docs/readGoogleDoc.js +49 -3
- package/dist/tools/drive/addPermission.js +88 -0
- package/dist/tools/drive/index.js +8 -0
- package/dist/tools/drive/listPermissions.js +43 -0
- package/dist/tools/drive/removePermission.js +40 -0
- package/dist/tools/drive/updatePermission.js +50 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/tasks/completeTask.js +43 -0
- package/dist/tools/tasks/createTask.js +53 -0
- package/dist/tools/tasks/createTaskList.js +25 -0
- package/dist/tools/tasks/deleteTask.js +30 -0
- package/dist/tools/tasks/deleteTaskList.js +29 -0
- package/dist/tools/tasks/index.js +19 -0
- package/dist/tools/tasks/listTaskLists.js +26 -0
- package/dist/tools/tasks/listTasks.js +69 -0
- package/dist/tools/tasks/updateTask.js +68 -0
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +7 -2
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +7 -2
- package/package.json +2 -1
package/dist/auth.js
CHANGED
|
@@ -54,6 +54,8 @@ const SCOPES = [
|
|
|
54
54
|
'https://www.googleapis.com/auth/forms.responses.readonly',
|
|
55
55
|
// Slides
|
|
56
56
|
'https://www.googleapis.com/auth/presentations',
|
|
57
|
+
// Tasks
|
|
58
|
+
'https://www.googleapis.com/auth/tasks',
|
|
57
59
|
];
|
|
58
60
|
|
|
59
61
|
// ---------------------------------------------------------------------------
|
package/dist/clients.js
CHANGED
|
@@ -14,6 +14,7 @@ let gmailClient = null;
|
|
|
14
14
|
let calendarClient = null;
|
|
15
15
|
let formsClient = null;
|
|
16
16
|
let slidesClient = null;
|
|
17
|
+
let tasksClient = null;
|
|
17
18
|
|
|
18
19
|
async function ensureAuth() {
|
|
19
20
|
if (authClient) return;
|
|
@@ -46,6 +47,7 @@ async function reauthorize() {
|
|
|
46
47
|
calendarClient = null;
|
|
47
48
|
formsClient = null;
|
|
48
49
|
slidesClient = null;
|
|
50
|
+
tasksClient = null;
|
|
49
51
|
authClient = await authorize();
|
|
50
52
|
logger.info('Re-authorization successful.');
|
|
51
53
|
}
|
|
@@ -120,6 +122,7 @@ export function resetClients() {
|
|
|
120
122
|
calendarClient = null;
|
|
121
123
|
formsClient = null;
|
|
122
124
|
slidesClient = null;
|
|
125
|
+
tasksClient = null;
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
/**
|
|
@@ -194,3 +197,17 @@ export async function getSlidesClient() {
|
|
|
194
197
|
if (!slides) throw new UserError('Google Slides client is not initialized.');
|
|
195
198
|
return slides;
|
|
196
199
|
}
|
|
200
|
+
|
|
201
|
+
// --- Tasks client ---
|
|
202
|
+
export async function initializeTasksClient() {
|
|
203
|
+
if (tasksClient) return { authClient, tasksClient };
|
|
204
|
+
await ensureAuth();
|
|
205
|
+
if (!tasksClient) tasksClient = google.tasks({ version: 'v1', auth: authClient });
|
|
206
|
+
return { authClient, tasksClient };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function getTasksClient() {
|
|
210
|
+
const { tasksClient: tasks } = await initializeTasksClient();
|
|
211
|
+
if (!tasks) throw new UserError('Google Tasks client is not initialized.');
|
|
212
|
+
return tasks;
|
|
213
|
+
}
|
package/dist/readTracker.js
CHANGED
|
@@ -1,84 +1,145 @@
|
|
|
1
|
-
// In-memory tracker for read-before-edit guards (issue #18)
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
1
|
+
// In-memory tracker for read-before-edit guards (issue #18) and diff-aware
|
|
2
|
+
// reading/editing (issue #21).
|
|
3
|
+
// Tracks which files have been read in this session, when, and optionally the
|
|
4
|
+
// content snapshot at the time of the last read so blocked mutations can return
|
|
5
|
+
// a unified diff instead of a bare "modified externally" error.
|
|
6
|
+
import { UserError } from 'fastmcp';
|
|
7
|
+
import { createPatch } from 'diff';
|
|
8
|
+
import { getDriveClient } from './clients.js';
|
|
9
|
+
import { logger } from './logger.js';
|
|
10
|
+
|
|
11
|
+
// Map of fileId → { readAt: Date, modifiedTime: string|null, content: string|null }
|
|
12
|
+
// content is only populated for file types that opt in (currently Google Docs).
|
|
13
|
+
// Sheets and raw Drive files still track read/modifiedTime only.
|
|
14
|
+
const readLog = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Record that a file was read. Call from all read tools.
|
|
18
|
+
* @param fileId
|
|
19
|
+
* @param modifiedTime Drive API modifiedTime (ISO string) at read time, or null
|
|
20
|
+
* @param content Optional content snapshot (e.g. markdown for docs) used for diffs
|
|
21
|
+
*/
|
|
22
|
+
export function trackRead(fileId, modifiedTime, content) {
|
|
23
|
+
readLog.set(fileId, {
|
|
24
|
+
readAt: new Date(),
|
|
25
|
+
modifiedTime: modifiedTime || null,
|
|
26
|
+
content: typeof content === 'string' ? content : null,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Guard a mutation. Call from all mutating tools before making changes.
|
|
32
|
+
* Throws UserError if the file hasn't been read, or if it was modified externally.
|
|
33
|
+
*
|
|
34
|
+
* @param fileId - The file/document/spreadsheet ID
|
|
35
|
+
* @param opts.skipExternalCheck - If true, skip the Drive API modifiedTime check (for performance)
|
|
36
|
+
* @param opts.contentFetcher - Optional async () => string. If provided and an
|
|
37
|
+
* external-change conflict is detected, the fetcher is used to grab current
|
|
38
|
+
* content and the UserError message will include a unified diff plus rebase
|
|
39
|
+
* instructions rather than a plain error.
|
|
40
|
+
*/
|
|
41
|
+
export async function guardMutation(fileId, opts) {
|
|
42
|
+
const entry = readLog.get(fileId);
|
|
43
|
+
if (!entry) {
|
|
44
|
+
throw new UserError(
|
|
45
|
+
`This file (${fileId}) has not been read in this session. ` +
|
|
46
|
+
'Read it first before making changes to ensure you have current content. ' +
|
|
47
|
+
'Use readDocument, readSpreadsheet, readFile, or readDriveFile.'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Optionally check if file was modified externally since last read
|
|
52
|
+
if (!opts?.skipExternalCheck) {
|
|
53
|
+
try {
|
|
54
|
+
const drive = await getDriveClient();
|
|
55
|
+
const res = await drive.files.get({
|
|
56
|
+
fileId,
|
|
57
|
+
fields: 'modifiedTime',
|
|
58
|
+
supportsAllDrives: true,
|
|
59
|
+
});
|
|
60
|
+
const currentModifiedTime = res.data.modifiedTime;
|
|
61
|
+
if (entry.modifiedTime && currentModifiedTime !== entry.modifiedTime) {
|
|
62
|
+
const readAt = entry.readAt.toISOString();
|
|
63
|
+
// If we have a stored snapshot and the caller can fetch the
|
|
64
|
+
// current content, return a diff instead of a bare error so the
|
|
65
|
+
// model can rebase its edit on top of the new version.
|
|
66
|
+
if (entry.content && typeof opts?.contentFetcher === 'function') {
|
|
67
|
+
let currentContent;
|
|
68
|
+
try {
|
|
69
|
+
currentContent = await opts.contentFetcher();
|
|
70
|
+
} catch (fetchError) {
|
|
71
|
+
logger.warn(`contentFetcher failed for ${fileId}: ${fetchError.message}`);
|
|
72
|
+
}
|
|
73
|
+
if (typeof currentContent === 'string') {
|
|
74
|
+
const patch = createPatch(
|
|
75
|
+
fileId,
|
|
76
|
+
entry.content,
|
|
77
|
+
currentContent,
|
|
78
|
+
'last read',
|
|
79
|
+
'current',
|
|
80
|
+
{ context: 3 }
|
|
81
|
+
);
|
|
82
|
+
// Refresh the snapshot so a subsequent read/mutation
|
|
83
|
+
// works against the new baseline.
|
|
84
|
+
entry.content = currentContent;
|
|
85
|
+
entry.modifiedTime = currentModifiedTime;
|
|
86
|
+
throw new UserError(
|
|
87
|
+
`This file was modified externally since you last read it ` +
|
|
88
|
+
`(last read: ${readAt}, last modified: ${currentModifiedTime}).\n\n` +
|
|
89
|
+
'Do NOT re-apply your original edit blindly. Build your new edit on ' +
|
|
90
|
+
'top of the current version. Below is a unified diff from your last ' +
|
|
91
|
+
'read to the current document (old → current). If you need the full ' +
|
|
92
|
+
'current content, call readDocument again.\n\n' +
|
|
93
|
+
'--- DIFF (last read → current) ---\n' +
|
|
94
|
+
patch +
|
|
95
|
+
'--- END DIFF ---'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new UserError(
|
|
100
|
+
`This file was modified externally since you last read it ` +
|
|
101
|
+
`(last read: ${readAt}, last modified: ${currentModifiedTime}). ` +
|
|
102
|
+
'Read the file again before editing to ensure you have current content.'
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
// Update modifiedTime on successful check
|
|
106
|
+
entry.modifiedTime = currentModifiedTime;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error instanceof UserError) throw error;
|
|
109
|
+
// If we can't check, log warning but allow the mutation
|
|
110
|
+
logger.warn(`Could not verify modifiedTime for ${fileId}: ${error.message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Update the read tracker after a successful mutation (so subsequent mutations
|
|
117
|
+
* don't fail the external-change check against our own changes).
|
|
118
|
+
*/
|
|
119
|
+
export function trackMutation(fileId) {
|
|
120
|
+
const entry = readLog.get(fileId);
|
|
121
|
+
if (entry) {
|
|
122
|
+
entry.readAt = new Date();
|
|
123
|
+
// Clear modifiedTime — it will be stale after our mutation.
|
|
124
|
+
// The next guardMutation call will fetch fresh modifiedTime.
|
|
125
|
+
entry.modifiedTime = null;
|
|
126
|
+
// Content is also stale after our mutation; clear so a future diff
|
|
127
|
+
// doesn't show our own edits as "external" changes.
|
|
128
|
+
entry.content = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if a file has been read (without throwing).
|
|
134
|
+
*/
|
|
135
|
+
export function hasBeenRead(fileId) {
|
|
136
|
+
return readLog.has(fileId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Return the content snapshot from the last read, or null if none stored.
|
|
141
|
+
*/
|
|
142
|
+
export function getLastReadContent(fileId) {
|
|
143
|
+
const entry = readLog.get(fileId);
|
|
144
|
+
return entry?.content ?? null;
|
|
145
|
+
}
|
|
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { getDocsClient } from '../../clients.js';
|
|
5
5
|
import { DocumentIdParameter, NotImplementedError } from '../../types.js';
|
|
6
6
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
7
|
+
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
7
8
|
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
8
9
|
export function register(server) {
|
|
9
10
|
server.addTool({
|
|
@@ -24,8 +25,13 @@ export function register(server) {
|
|
|
24
25
|
.describe('The ID of the specific tab to append to. If not specified, appends to the first tab (or legacy document.body for documents without tabs).'),
|
|
25
26
|
}),
|
|
26
27
|
execute: async (args, { log }) => {
|
|
27
|
-
await guardMutation(args.documentId);
|
|
28
28
|
const docs = await getDocsClient();
|
|
29
|
+
await guardMutation(args.documentId, {
|
|
30
|
+
contentFetcher: async () => {
|
|
31
|
+
const current = await docs.documents.get({ documentId: args.documentId });
|
|
32
|
+
return docsJsonToMarkdown(current.data);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
29
35
|
// Resolve text content from filePath or inline parameter
|
|
30
36
|
let text = args.text;
|
|
31
37
|
if (args.filePath) {
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getDocsClient } from '../../clients.js';
|
|
4
4
|
import { DocumentIdParameter } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
6
7
|
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
7
8
|
export function register(server) {
|
|
8
9
|
server.addTool({
|
|
@@ -28,8 +29,13 @@ export function register(server) {
|
|
|
28
29
|
path: ['endIndex'],
|
|
29
30
|
}),
|
|
30
31
|
execute: async (args, { log }) => {
|
|
31
|
-
await guardMutation(args.documentId);
|
|
32
32
|
const docs = await getDocsClient();
|
|
33
|
+
await guardMutation(args.documentId, {
|
|
34
|
+
contentFetcher: async () => {
|
|
35
|
+
const current = await docs.documents.get({ documentId: args.documentId });
|
|
36
|
+
return docsJsonToMarkdown(current.data);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
33
39
|
log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
34
40
|
if (args.endIndex <= args.startIndex) {
|
|
35
41
|
throw new UserError('End index must be greater than start index for deletion.');
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getDocsClient } from '../../clients.js';
|
|
4
4
|
import { DocumentIdParameter } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
6
7
|
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
7
8
|
const FindAndReplaceParameters = DocumentIdParameter.extend({
|
|
8
9
|
findText: z.string().min(1).describe('The text to search for in the document.'),
|
|
@@ -25,8 +26,13 @@ export function register(server) {
|
|
|
25
26
|
'Returns the number of replacements made. Use an empty replaceText to delete all matches.',
|
|
26
27
|
parameters: FindAndReplaceParameters,
|
|
27
28
|
execute: async (args, { log }) => {
|
|
28
|
-
await guardMutation(args.documentId);
|
|
29
29
|
const docs = await getDocsClient();
|
|
30
|
+
await guardMutation(args.documentId, {
|
|
31
|
+
contentFetcher: async () => {
|
|
32
|
+
const current = await docs.documents.get({ documentId: args.documentId });
|
|
33
|
+
return docsJsonToMarkdown(current.data);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
30
36
|
log.info(`findAndReplace in doc ${args.documentId}: "${args.findText}" → "${args.replaceText}"` +
|
|
31
37
|
`${args.matchCase ? ' (case-sensitive)' : ''}` +
|
|
32
38
|
`${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
@@ -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 { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
6
7
|
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
7
8
|
const RangeTarget = z
|
|
8
9
|
.object({
|
|
@@ -104,8 +105,13 @@ export function register(server) {
|
|
|
104
105
|
'To add content to the end of a doc, use appendMarkdown or appendText.',
|
|
105
106
|
parameters: ModifyTextParameters,
|
|
106
107
|
execute: async (args, { log }) => {
|
|
107
|
-
await guardMutation(args.documentId);
|
|
108
108
|
const docs = await getDocsClient();
|
|
109
|
+
await guardMutation(args.documentId, {
|
|
110
|
+
contentFetcher: async () => {
|
|
111
|
+
const current = await docs.documents.get({ documentId: args.documentId });
|
|
112
|
+
return docsJsonToMarkdown(current.data);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
109
115
|
log.info(`modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
|
|
110
116
|
`${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` +
|
|
111
117
|
`${args.style ? `, style=${JSON.stringify(args.style)}` : ''}` +
|
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
import { UserError } from 'fastmcp';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { createPatch } from 'diff';
|
|
3
4
|
import { getDocsClient, getDriveClient } from '../../clients.js';
|
|
4
5
|
import { DocumentIdParameter, NotImplementedError } from '../../types.js';
|
|
5
6
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
7
|
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
7
|
-
import { trackRead } from '../../readTracker.js';
|
|
8
|
+
import { trackRead, getLastReadContent } from '../../readTracker.js';
|
|
9
|
+
|
|
10
|
+
async function fetchModifiedTime(documentId) {
|
|
11
|
+
try {
|
|
12
|
+
const drive = await getDriveClient();
|
|
13
|
+
const res = await drive.files.get({
|
|
14
|
+
fileId: documentId,
|
|
15
|
+
fields: 'modifiedTime',
|
|
16
|
+
supportsAllDrives: true,
|
|
17
|
+
});
|
|
18
|
+
return res.data.modifiedTime || null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
export function register(server) {
|
|
9
25
|
server.addTool({
|
|
10
26
|
name: 'readDocument',
|
|
11
|
-
description: "Reads the content of a Google Document. Returns markdown by default (formatted content suitable for editing and re-uploading with replaceDocumentWithMarkdown). Use format='text' for plain text, or format='json' for the raw document structure.",
|
|
27
|
+
description: "Reads the content of a Google Document. Returns markdown by default (formatted content suitable for editing and re-uploading with replaceDocumentWithMarkdown). Use format='text' for plain text, or format='json' for the raw document structure. Set diffFromLastRead=true (markdown only) to get a unified diff from your previous read in this session instead of the full content.",
|
|
12
28
|
parameters: DocumentIdParameter.extend({
|
|
13
29
|
format: z
|
|
14
30
|
.enum(['text', 'json', 'markdown'])
|
|
@@ -23,6 +39,11 @@ export function register(server) {
|
|
|
23
39
|
.string()
|
|
24
40
|
.optional()
|
|
25
41
|
.describe('The ID of the specific tab to read. If not specified, reads the first tab (or legacy document.body for documents without tabs).'),
|
|
42
|
+
diffFromLastRead: z
|
|
43
|
+
.boolean()
|
|
44
|
+
.optional()
|
|
45
|
+
.default(false)
|
|
46
|
+
.describe('If true and this document has already been read in this session (with format=markdown), returns a unified diff from the previous read to the current document instead of the full content. Ignored on first read or when format is not markdown.'),
|
|
26
47
|
}),
|
|
27
48
|
execute: async (args, { log }) => {
|
|
28
49
|
const docs = await getDocsClient();
|
|
@@ -39,7 +60,7 @@ export function register(server) {
|
|
|
39
60
|
fields: needsTabsContent ? '*' : fields, // Get full document if using tabs
|
|
40
61
|
});
|
|
41
62
|
log.info(`Fetched doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
42
|
-
|
|
63
|
+
const modifiedTime = await fetchModifiedTime(args.documentId);
|
|
43
64
|
// If tabId is specified, find the specific tab
|
|
44
65
|
let contentSource;
|
|
45
66
|
if (args.tabId) {
|
|
@@ -58,6 +79,10 @@ export function register(server) {
|
|
|
58
79
|
contentSource = res.data;
|
|
59
80
|
}
|
|
60
81
|
if (args.format === 'json') {
|
|
82
|
+
if (args.diffFromLastRead) {
|
|
83
|
+
log.info('diffFromLastRead ignored: only supported for format=markdown');
|
|
84
|
+
}
|
|
85
|
+
trackRead(args.documentId, modifiedTime);
|
|
61
86
|
const jsonContent = JSON.stringify(contentSource, null, 2);
|
|
62
87
|
// Apply length limit to JSON if specified
|
|
63
88
|
if (args.maxLength && jsonContent.length > args.maxLength) {
|
|
@@ -70,6 +95,23 @@ export function register(server) {
|
|
|
70
95
|
const markdownContent = docsJsonToMarkdown(contentSource);
|
|
71
96
|
const totalLength = markdownContent.length;
|
|
72
97
|
log.info(`Generated markdown: ${totalLength} characters`);
|
|
98
|
+
if (args.diffFromLastRead) {
|
|
99
|
+
const previous = getLastReadContent(args.documentId);
|
|
100
|
+
if (previous !== null) {
|
|
101
|
+
const patch = createPatch(
|
|
102
|
+
args.documentId,
|
|
103
|
+
previous,
|
|
104
|
+
markdownContent,
|
|
105
|
+
'last read',
|
|
106
|
+
'current',
|
|
107
|
+
{ context: 3 }
|
|
108
|
+
);
|
|
109
|
+
trackRead(args.documentId, modifiedTime, markdownContent);
|
|
110
|
+
return patch;
|
|
111
|
+
}
|
|
112
|
+
log.info('diffFromLastRead requested but no prior snapshot exists; returning full content');
|
|
113
|
+
}
|
|
114
|
+
trackRead(args.documentId, modifiedTime, markdownContent);
|
|
73
115
|
// Apply length limit to markdown if specified
|
|
74
116
|
if (args.maxLength && totalLength > args.maxLength) {
|
|
75
117
|
const truncatedContent = markdownContent.substring(0, args.maxLength);
|
|
@@ -78,6 +120,9 @@ export function register(server) {
|
|
|
78
120
|
return markdownContent;
|
|
79
121
|
}
|
|
80
122
|
// Default: Text format - extract all text content
|
|
123
|
+
if (args.diffFromLastRead) {
|
|
124
|
+
log.info('diffFromLastRead ignored: only supported for format=markdown');
|
|
125
|
+
}
|
|
81
126
|
let textContent = '';
|
|
82
127
|
let elementCount = 0;
|
|
83
128
|
// Process all content elements from contentSource
|
|
@@ -106,6 +151,7 @@ export function register(server) {
|
|
|
106
151
|
});
|
|
107
152
|
}
|
|
108
153
|
});
|
|
154
|
+
trackRead(args.documentId, modifiedTime);
|
|
109
155
|
if (!textContent.trim())
|
|
110
156
|
return 'Document found, but appears empty.';
|
|
111
157
|
const totalLength = textContent.length;
|
|
@@ -0,0 +1,88 @@
|
|
|
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: 'addPermission',
|
|
7
|
+
description: 'Shares a Drive file or folder by adding a new sharing permission. Supports sharing with a user, group, entire domain, or anyone with the link. Use listPermissions to see existing shares, updatePermission to change a role, or removePermission to revoke access.',
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
fileId: z.string().describe('The ID of the file or folder to share.'),
|
|
10
|
+
type: z
|
|
11
|
+
.enum(['user', 'group', 'domain', 'anyone'])
|
|
12
|
+
.describe("Grantee type. 'user' and 'group' require emailAddress; 'domain' requires domain; 'anyone' is link sharing."),
|
|
13
|
+
role: z
|
|
14
|
+
.enum(['reader', 'commenter', 'writer', 'fileOrganizer', 'organizer', 'owner'])
|
|
15
|
+
.describe('Access level granted. Use owner only with transferOwnership=true.'),
|
|
16
|
+
emailAddress: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Email address of the user or group (required when type is 'user' or 'group')."),
|
|
20
|
+
domain: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Domain to share with (required when type is 'domain')."),
|
|
24
|
+
sendNotificationEmail: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe('Whether Google should send a notification email (default: true for user/group, false for domain/anyone).'),
|
|
28
|
+
emailMessage: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Custom message to include in the notification email (only applies when sendNotificationEmail is true).'),
|
|
32
|
+
transferOwnership: z
|
|
33
|
+
.boolean()
|
|
34
|
+
.optional()
|
|
35
|
+
.default(false)
|
|
36
|
+
.describe("Set to true when transferring ownership (role must be 'owner')."),
|
|
37
|
+
allowFileDiscovery: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe("For type 'domain' or 'anyone': whether the file can be discovered via search. Defaults to false (link-only)."),
|
|
41
|
+
}),
|
|
42
|
+
execute: async (args, { log }) => {
|
|
43
|
+
if ((args.type === 'user' || args.type === 'group') && !args.emailAddress) {
|
|
44
|
+
throw new UserError(`emailAddress is required when type is '${args.type}'.`);
|
|
45
|
+
}
|
|
46
|
+
if (args.type === 'domain' && !args.domain) {
|
|
47
|
+
throw new UserError("domain is required when type is 'domain'.");
|
|
48
|
+
}
|
|
49
|
+
if (args.role === 'owner' && !args.transferOwnership) {
|
|
50
|
+
throw new UserError("Role 'owner' requires transferOwnership=true.");
|
|
51
|
+
}
|
|
52
|
+
const drive = await getDriveClient();
|
|
53
|
+
log.info(`Adding permission to ${args.fileId}: type=${args.type}, role=${args.role}`);
|
|
54
|
+
const requestBody = {
|
|
55
|
+
type: args.type,
|
|
56
|
+
role: args.role,
|
|
57
|
+
};
|
|
58
|
+
if (args.emailAddress) requestBody.emailAddress = args.emailAddress;
|
|
59
|
+
if (args.domain) requestBody.domain = args.domain;
|
|
60
|
+
if (args.allowFileDiscovery !== undefined) {
|
|
61
|
+
requestBody.allowFileDiscovery = args.allowFileDiscovery;
|
|
62
|
+
}
|
|
63
|
+
const defaultNotify = args.type === 'user' || args.type === 'group';
|
|
64
|
+
const sendNotify = args.sendNotificationEmail ?? defaultNotify;
|
|
65
|
+
try {
|
|
66
|
+
const response = await drive.permissions.create({
|
|
67
|
+
fileId: args.fileId,
|
|
68
|
+
requestBody,
|
|
69
|
+
sendNotificationEmail: sendNotify,
|
|
70
|
+
emailMessage: sendNotify ? args.emailMessage : undefined,
|
|
71
|
+
transferOwnership: args.transferOwnership,
|
|
72
|
+
supportsAllDrives: true,
|
|
73
|
+
fields: 'id,type,role,emailAddress,domain,displayName,allowFileDiscovery,pendingOwner',
|
|
74
|
+
});
|
|
75
|
+
return JSON.stringify(response.data, null, 2);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
log.error(`Error adding permission: ${error.message || error}`);
|
|
79
|
+
if (error.code === 404)
|
|
80
|
+
throw new UserError(`File not found (ID: ${args.fileId}).`);
|
|
81
|
+
if (error.code === 403)
|
|
82
|
+
throw new UserError('Permission denied. You need writer+ access (or be the owner) to share this file.');
|
|
83
|
+
const apiMsg = error.response?.data?.error?.message || error.message || 'Unknown error';
|
|
84
|
+
throw new UserError(`Failed to add permission: ${apiMsg}`);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -15,6 +15,10 @@ 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
17
|
import { register as uploadFile } from './uploadFile.js';
|
|
18
|
+
import { register as listPermissions } from './listPermissions.js';
|
|
19
|
+
import { register as addPermission } from './addPermission.js';
|
|
20
|
+
import { register as removePermission } from './removePermission.js';
|
|
21
|
+
import { register as updatePermission } from './updatePermission.js';
|
|
18
22
|
export function registerDriveTools(server) {
|
|
19
23
|
listDriveFiles(server);
|
|
20
24
|
searchGoogleDocs(server);
|
|
@@ -33,4 +37,8 @@ export function registerDriveTools(server) {
|
|
|
33
37
|
downloadFile(server);
|
|
34
38
|
getFilePath(server);
|
|
35
39
|
uploadFile(server);
|
|
40
|
+
listPermissions(server);
|
|
41
|
+
addPermission(server);
|
|
42
|
+
removePermission(server);
|
|
43
|
+
updatePermission(server);
|
|
36
44
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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: 'listPermissions',
|
|
7
|
+
description: 'Lists all sharing permissions on a Drive file or folder. Shows who the file is shared with (users, groups, domains, anyone) along with their roles (reader, commenter, writer, owner, etc.). Use addPermission/updatePermission/removePermission to change sharing.',
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
fileId: z.string().describe('The ID of the file or folder whose permissions to list.'),
|
|
10
|
+
}),
|
|
11
|
+
execute: async (args, { log }) => {
|
|
12
|
+
const drive = await getDriveClient();
|
|
13
|
+
log.info(`Listing permissions for file: ${args.fileId}`);
|
|
14
|
+
try {
|
|
15
|
+
const response = await drive.permissions.list({
|
|
16
|
+
fileId: args.fileId,
|
|
17
|
+
fields: 'permissions(id,type,role,emailAddress,domain,displayName,allowFileDiscovery,deleted,pendingOwner)',
|
|
18
|
+
supportsAllDrives: true,
|
|
19
|
+
});
|
|
20
|
+
const permissions = (response.data.permissions || []).map((p) => ({
|
|
21
|
+
id: p.id,
|
|
22
|
+
type: p.type,
|
|
23
|
+
role: p.role,
|
|
24
|
+
emailAddress: p.emailAddress || null,
|
|
25
|
+
domain: p.domain || null,
|
|
26
|
+
displayName: p.displayName || null,
|
|
27
|
+
allowFileDiscovery: p.allowFileDiscovery ?? null,
|
|
28
|
+
deleted: p.deleted ?? false,
|
|
29
|
+
pendingOwner: p.pendingOwner ?? false,
|
|
30
|
+
}));
|
|
31
|
+
return JSON.stringify({ fileId: args.fileId, permissions }, null, 2);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
log.error(`Error listing permissions: ${error.message || error}`);
|
|
35
|
+
if (error.code === 404)
|
|
36
|
+
throw new UserError(`File not found (ID: ${args.fileId}).`);
|
|
37
|
+
if (error.code === 403)
|
|
38
|
+
throw new UserError('Permission denied. You need access to the file to list its permissions.');
|
|
39
|
+
throw new UserError(`Failed to list permissions: ${error.message || 'Unknown error'}`);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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: 'removePermission',
|
|
7
|
+
description: 'Revokes a specific sharing permission from a Drive file or folder. Use listPermissions first to get the permissionId you want to remove.',
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
fileId: z.string().describe('The ID of the file or folder.'),
|
|
10
|
+
permissionId: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe('The ID of the permission to remove (from listPermissions).'),
|
|
13
|
+
}),
|
|
14
|
+
execute: async (args, { log }) => {
|
|
15
|
+
const drive = await getDriveClient();
|
|
16
|
+
log.info(`Removing permission ${args.permissionId} from file ${args.fileId}`);
|
|
17
|
+
try {
|
|
18
|
+
await drive.permissions.delete({
|
|
19
|
+
fileId: args.fileId,
|
|
20
|
+
permissionId: args.permissionId,
|
|
21
|
+
supportsAllDrives: true,
|
|
22
|
+
});
|
|
23
|
+
return JSON.stringify({
|
|
24
|
+
fileId: args.fileId,
|
|
25
|
+
permissionId: args.permissionId,
|
|
26
|
+
removed: true,
|
|
27
|
+
}, null, 2);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
log.error(`Error removing permission: ${error.message || error}`);
|
|
31
|
+
if (error.code === 404)
|
|
32
|
+
throw new UserError(`Permission or file not found (fileId: ${args.fileId}, permissionId: ${args.permissionId}).`);
|
|
33
|
+
if (error.code === 403)
|
|
34
|
+
throw new UserError('Permission denied. You need writer+ access (or be the owner) to modify sharing.');
|
|
35
|
+
const apiMsg = error.response?.data?.error?.message || error.message || 'Unknown error';
|
|
36
|
+
throw new UserError(`Failed to remove permission: ${apiMsg}`);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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: 'updatePermission',
|
|
7
|
+
description: 'Changes the role (access level) of an existing sharing permission. Use listPermissions first to get the permissionId.',
|
|
8
|
+
parameters: z.object({
|
|
9
|
+
fileId: z.string().describe('The ID of the file or folder.'),
|
|
10
|
+
permissionId: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe('The ID of the permission to update (from listPermissions).'),
|
|
13
|
+
role: z
|
|
14
|
+
.enum(['reader', 'commenter', 'writer', 'fileOrganizer', 'organizer', 'owner'])
|
|
15
|
+
.describe('New access level. Use owner only with transferOwnership=true.'),
|
|
16
|
+
transferOwnership: z
|
|
17
|
+
.boolean()
|
|
18
|
+
.optional()
|
|
19
|
+
.default(false)
|
|
20
|
+
.describe("Set to true when transferring ownership (role must be 'owner')."),
|
|
21
|
+
}),
|
|
22
|
+
execute: async (args, { log }) => {
|
|
23
|
+
if (args.role === 'owner' && !args.transferOwnership) {
|
|
24
|
+
throw new UserError("Role 'owner' requires transferOwnership=true.");
|
|
25
|
+
}
|
|
26
|
+
const drive = await getDriveClient();
|
|
27
|
+
log.info(`Updating permission ${args.permissionId} on ${args.fileId} to role=${args.role}`);
|
|
28
|
+
try {
|
|
29
|
+
const response = await drive.permissions.update({
|
|
30
|
+
fileId: args.fileId,
|
|
31
|
+
permissionId: args.permissionId,
|
|
32
|
+
requestBody: { role: args.role },
|
|
33
|
+
transferOwnership: args.transferOwnership,
|
|
34
|
+
supportsAllDrives: true,
|
|
35
|
+
fields: 'id,type,role,emailAddress,domain,displayName,allowFileDiscovery,pendingOwner',
|
|
36
|
+
});
|
|
37
|
+
return JSON.stringify(response.data, null, 2);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
log.error(`Error updating permission: ${error.message || error}`);
|
|
41
|
+
if (error.code === 404)
|
|
42
|
+
throw new UserError(`Permission or file not found (fileId: ${args.fileId}, permissionId: ${args.permissionId}).`);
|
|
43
|
+
if (error.code === 403)
|
|
44
|
+
throw new UserError('Permission denied. You need writer+ access (or be the owner) to modify sharing.');
|
|
45
|
+
const apiMsg = error.response?.data?.error?.message || error.message || 'Unknown error';
|
|
46
|
+
throw new UserError(`Failed to update permission: ${apiMsg}`);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -178,6 +178,12 @@ const CATEGORIES = {
|
|
|
178
178
|
registerSlidesTools(server);
|
|
179
179
|
},
|
|
180
180
|
},
|
|
181
|
+
tasks: {
|
|
182
|
+
async loader(server) {
|
|
183
|
+
const { registerTasksTools } = await import('./tasks/index.js');
|
|
184
|
+
registerTasksTools(server);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
181
187
|
};
|
|
182
188
|
|
|
183
189
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'completeTask',
|
|
8
|
+
description: 'Marks a task as completed.',
|
|
9
|
+
parameters: z.object({
|
|
10
|
+
taskListId: z.string().describe('ID of the task list containing the task'),
|
|
11
|
+
taskId: z.string().describe('ID of the task to complete'),
|
|
12
|
+
}),
|
|
13
|
+
execute: async (args, { log }) => {
|
|
14
|
+
const tasks = await getTasksClient();
|
|
15
|
+
log.info(`Completing task ${args.taskId} in list: ${args.taskListId}`);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const response = await tasks.tasks.patch({
|
|
19
|
+
tasklist: args.taskListId,
|
|
20
|
+
task: args.taskId,
|
|
21
|
+
requestBody: { status: 'completed', completed: new Date().toISOString() },
|
|
22
|
+
});
|
|
23
|
+
const t = response.data;
|
|
24
|
+
return JSON.stringify({
|
|
25
|
+
id: t.id,
|
|
26
|
+
taskListId: args.taskListId,
|
|
27
|
+
title: t.title || '',
|
|
28
|
+
notes: t.notes || null,
|
|
29
|
+
status: t.status,
|
|
30
|
+
due: t.due || null,
|
|
31
|
+
completed: t.completed || null,
|
|
32
|
+
}, null, 2);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
log.error(`Error completing task: ${error.message || error}`);
|
|
35
|
+
if (error.code === 404)
|
|
36
|
+
throw new UserError(`Task not found (taskId: ${args.taskId}, listId: ${args.taskListId}).`);
|
|
37
|
+
if (error.code === 403)
|
|
38
|
+
throw new UserError('Permission denied. Make sure you have access to this task.');
|
|
39
|
+
throw new UserError(`Failed to complete task: ${error.message || 'Unknown error'}`);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'createTask',
|
|
8
|
+
description: 'Creates a new task in a Google Tasks task list.',
|
|
9
|
+
parameters: z.object({
|
|
10
|
+
taskListId: z.string().describe('ID of the task list to add the task to'),
|
|
11
|
+
title: z.string().describe('Title of the task'),
|
|
12
|
+
notes: z.string().optional().describe('Optional notes or description for the task'),
|
|
13
|
+
due: z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe('Optional due date in RFC 3339 or plain date format (e.g. "2025-04-30")'),
|
|
17
|
+
}),
|
|
18
|
+
execute: async (args, { log }) => {
|
|
19
|
+
const tasks = await getTasksClient();
|
|
20
|
+
log.info(`Creating task "${args.title}" in list: ${args.taskListId}`);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const body = { title: args.title };
|
|
24
|
+
if (args.notes) body.notes = args.notes;
|
|
25
|
+
if (args.due) body.due = normalizeDate(args.due);
|
|
26
|
+
|
|
27
|
+
const response = await tasks.tasks.insert({ tasklist: args.taskListId, requestBody: body });
|
|
28
|
+
const t = response.data;
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
id: t.id,
|
|
31
|
+
taskListId: args.taskListId,
|
|
32
|
+
title: t.title || '',
|
|
33
|
+
notes: t.notes || null,
|
|
34
|
+
status: t.status,
|
|
35
|
+
due: t.due || null,
|
|
36
|
+
completed: t.completed || null,
|
|
37
|
+
}, null, 2);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
log.error(`Error creating task: ${error.message || error}`);
|
|
40
|
+
if (error.code === 404)
|
|
41
|
+
throw new UserError(`Task list not found (ID: ${args.taskListId}).`);
|
|
42
|
+
if (error.code === 403)
|
|
43
|
+
throw new UserError('Permission denied. Make sure you have access to this task list.');
|
|
44
|
+
throw new UserError(`Failed to create task: ${error.message || 'Unknown error'}`);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeDate(value) {
|
|
51
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'createTaskList',
|
|
8
|
+
description: 'Creates a new Google Tasks task list with the given title.',
|
|
9
|
+
parameters: z.object({
|
|
10
|
+
title: z.string().describe('Title for the new task list'),
|
|
11
|
+
}),
|
|
12
|
+
execute: async (args, { log }) => {
|
|
13
|
+
const tasks = await getTasksClient();
|
|
14
|
+
log.info(`Creating task list: ${args.title}`);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const response = await tasks.tasklists.insert({ requestBody: { title: args.title } });
|
|
18
|
+
return JSON.stringify({ id: response.data.id, title: response.data.title }, null, 2);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
log.error(`Error creating task list: ${error.message || error}`);
|
|
21
|
+
throw new UserError(`Failed to create task list: ${error.message || 'Unknown error'}`);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'deleteTask',
|
|
8
|
+
description: 'Permanently deletes a task.',
|
|
9
|
+
parameters: z.object({
|
|
10
|
+
taskListId: z.string().describe('ID of the task list containing the task'),
|
|
11
|
+
taskId: z.string().describe('ID of the task to delete'),
|
|
12
|
+
}),
|
|
13
|
+
execute: async (args, { log }) => {
|
|
14
|
+
const tasks = await getTasksClient();
|
|
15
|
+
log.info(`Deleting task ${args.taskId} from list: ${args.taskListId}`);
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
await tasks.tasks.delete({ tasklist: args.taskListId, task: args.taskId });
|
|
19
|
+
return JSON.stringify({ deleted: true, taskId: args.taskId, taskListId: args.taskListId }, null, 2);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
log.error(`Error deleting task: ${error.message || error}`);
|
|
22
|
+
if (error.code === 404)
|
|
23
|
+
throw new UserError(`Task not found (taskId: ${args.taskId}, listId: ${args.taskListId}).`);
|
|
24
|
+
if (error.code === 403)
|
|
25
|
+
throw new UserError('Permission denied. Make sure you have access to this task.');
|
|
26
|
+
throw new UserError(`Failed to delete task: ${error.message || 'Unknown error'}`);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'deleteTaskList',
|
|
8
|
+
description: 'Permanently deletes a task list and all tasks within it.',
|
|
9
|
+
parameters: z.object({
|
|
10
|
+
taskListId: z.string().describe('ID of the task list to delete'),
|
|
11
|
+
}),
|
|
12
|
+
execute: async (args, { log }) => {
|
|
13
|
+
const tasks = await getTasksClient();
|
|
14
|
+
log.info(`Deleting task list: ${args.taskListId}`);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await tasks.tasklists.delete({ tasklist: args.taskListId });
|
|
18
|
+
return JSON.stringify({ deleted: true, taskListId: args.taskListId }, null, 2);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
log.error(`Error deleting task list: ${error.message || error}`);
|
|
21
|
+
if (error.code === 404)
|
|
22
|
+
throw new UserError(`Task list not found (ID: ${args.taskListId}).`);
|
|
23
|
+
if (error.code === 403)
|
|
24
|
+
throw new UserError('Permission denied. You can only delete task lists you own.');
|
|
25
|
+
throw new UserError(`Failed to delete task list: ${error.message || 'Unknown error'}`);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { register as listTaskLists } from './listTaskLists.js';
|
|
2
|
+
import { register as createTaskList } from './createTaskList.js';
|
|
3
|
+
import { register as deleteTaskList } from './deleteTaskList.js';
|
|
4
|
+
import { register as listTasks } from './listTasks.js';
|
|
5
|
+
import { register as createTask } from './createTask.js';
|
|
6
|
+
import { register as updateTask } from './updateTask.js';
|
|
7
|
+
import { register as completeTask } from './completeTask.js';
|
|
8
|
+
import { register as deleteTask } from './deleteTask.js';
|
|
9
|
+
|
|
10
|
+
export function registerTasksTools(server) {
|
|
11
|
+
listTaskLists(server);
|
|
12
|
+
createTaskList(server);
|
|
13
|
+
deleteTaskList(server);
|
|
14
|
+
listTasks(server);
|
|
15
|
+
createTask(server);
|
|
16
|
+
updateTask(server);
|
|
17
|
+
completeTask(server);
|
|
18
|
+
deleteTask(server);
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'listTaskLists',
|
|
8
|
+
description: 'Lists all Google Task lists for the authenticated user. Returns the id and title of each list.',
|
|
9
|
+
parameters: z.object({}),
|
|
10
|
+
execute: async (_args, { log }) => {
|
|
11
|
+
const tasks = await getTasksClient();
|
|
12
|
+
log.info('Listing task lists');
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const response = await tasks.tasklists.list();
|
|
16
|
+
const lists = response.data.items || [];
|
|
17
|
+
return JSON.stringify(lists.map((l) => ({ id: l.id, title: l.title })), null, 2);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
log.error(`Error listing task lists: ${error.message || error}`);
|
|
20
|
+
if (error.code === 401)
|
|
21
|
+
throw new UserError('Authentication failed. Try logging out and re-authenticating.');
|
|
22
|
+
throw new UserError(`Failed to list task lists: ${error.message || 'Unknown error'}`);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'listTasks',
|
|
8
|
+
description:
|
|
9
|
+
'Lists tasks in a Google Tasks task list. Optionally filter by completion status or due date range.',
|
|
10
|
+
parameters: z.object({
|
|
11
|
+
taskListId: z.string().describe('ID of the task list to read from'),
|
|
12
|
+
showCompleted: z
|
|
13
|
+
.boolean()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('Whether to include completed tasks (default: true)'),
|
|
16
|
+
dueMin: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Lower bound for due date filter, RFC 3339 or plain date (e.g. "2025-04-01")'),
|
|
20
|
+
dueMax: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Upper bound for due date filter, RFC 3339 or plain date (e.g. "2025-04-30")'),
|
|
24
|
+
}),
|
|
25
|
+
execute: async (args, { log }) => {
|
|
26
|
+
const tasks = await getTasksClient();
|
|
27
|
+
const showCompleted = args.showCompleted ?? true;
|
|
28
|
+
log.info(`Listing tasks in list: ${args.taskListId}`);
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const params = {
|
|
32
|
+
tasklist: args.taskListId,
|
|
33
|
+
showCompleted,
|
|
34
|
+
showHidden: showCompleted,
|
|
35
|
+
};
|
|
36
|
+
if (args.dueMin) params.dueMin = normalizeDate(args.dueMin);
|
|
37
|
+
if (args.dueMax) params.dueMax = normalizeDate(args.dueMax);
|
|
38
|
+
|
|
39
|
+
const response = await tasks.tasks.list(params);
|
|
40
|
+
const items = response.data.items || [];
|
|
41
|
+
return JSON.stringify(items.map((t) => formatTask(t, args.taskListId)), null, 2);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
log.error(`Error listing tasks: ${error.message || error}`);
|
|
44
|
+
if (error.code === 404)
|
|
45
|
+
throw new UserError(`Task list not found (ID: ${args.taskListId}).`);
|
|
46
|
+
if (error.code === 403)
|
|
47
|
+
throw new UserError('Permission denied. Make sure you have access to this task list.');
|
|
48
|
+
throw new UserError(`Failed to list tasks: ${error.message || 'Unknown error'}`);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatTask(t, taskListId) {
|
|
55
|
+
return {
|
|
56
|
+
id: t.id,
|
|
57
|
+
taskListId,
|
|
58
|
+
title: t.title || '',
|
|
59
|
+
notes: t.notes || null,
|
|
60
|
+
status: t.status,
|
|
61
|
+
due: t.due || null,
|
|
62
|
+
completed: t.completed || null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeDate(value) {
|
|
67
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { UserError } from 'fastmcp';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getTasksClient } from '../../clients.js';
|
|
4
|
+
|
|
5
|
+
export function register(server) {
|
|
6
|
+
server.addTool({
|
|
7
|
+
name: 'updateTask',
|
|
8
|
+
description:
|
|
9
|
+
'Updates fields on an existing task. Only provided fields are changed; omitted fields are left as-is.',
|
|
10
|
+
parameters: z.object({
|
|
11
|
+
taskListId: z.string().describe('ID of the task list containing the task'),
|
|
12
|
+
taskId: z.string().describe('ID of the task to update'),
|
|
13
|
+
title: z.string().optional().describe('New title for the task'),
|
|
14
|
+
notes: z.string().optional().describe('New notes for the task'),
|
|
15
|
+
due: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('New due date in RFC 3339 or plain date format (e.g. "2025-04-30")'),
|
|
19
|
+
status: z
|
|
20
|
+
.enum(['needsAction', 'completed'])
|
|
21
|
+
.optional()
|
|
22
|
+
.describe('New status for the task'),
|
|
23
|
+
}),
|
|
24
|
+
execute: async (args, { log }) => {
|
|
25
|
+
const tasks = await getTasksClient();
|
|
26
|
+
log.info(`Updating task ${args.taskId} in list: ${args.taskListId}`);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const body = {};
|
|
30
|
+
if (args.title !== undefined) body.title = args.title;
|
|
31
|
+
if (args.notes !== undefined) body.notes = args.notes;
|
|
32
|
+
if (args.due !== undefined) body.due = normalizeDate(args.due);
|
|
33
|
+
if (args.status !== undefined) {
|
|
34
|
+
body.status = args.status;
|
|
35
|
+
if (args.status === 'completed') body.completed = new Date().toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const response = await tasks.tasks.patch({
|
|
39
|
+
tasklist: args.taskListId,
|
|
40
|
+
task: args.taskId,
|
|
41
|
+
requestBody: body,
|
|
42
|
+
});
|
|
43
|
+
const t = response.data;
|
|
44
|
+
return JSON.stringify({
|
|
45
|
+
id: t.id,
|
|
46
|
+
taskListId: args.taskListId,
|
|
47
|
+
title: t.title || '',
|
|
48
|
+
notes: t.notes || null,
|
|
49
|
+
status: t.status,
|
|
50
|
+
due: t.due || null,
|
|
51
|
+
completed: t.completed || null,
|
|
52
|
+
}, null, 2);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
log.error(`Error updating task: ${error.message || error}`);
|
|
55
|
+
if (error.code === 404)
|
|
56
|
+
throw new UserError(`Task not found (taskId: ${args.taskId}, listId: ${args.taskListId}).`);
|
|
57
|
+
if (error.code === 403)
|
|
58
|
+
throw new UserError('Permission denied. Make sure you have access to this task.');
|
|
59
|
+
throw new UserError(`Failed to update task: ${error.message || 'Unknown error'}`);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeDate(value) {
|
|
66
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { getDocsClient } from '../../clients.js';
|
|
5
5
|
import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
|
|
6
6
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
7
|
-
import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
|
|
7
|
+
import { insertMarkdown, formatInsertResult, docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
8
8
|
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
9
9
|
export function register(server) {
|
|
10
10
|
server.addTool({
|
|
@@ -32,8 +32,13 @@ export function register(server) {
|
|
|
32
32
|
.describe('If true, the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title.'),
|
|
33
33
|
}),
|
|
34
34
|
execute: async (args, { log }) => {
|
|
35
|
-
await guardMutation(args.documentId);
|
|
36
35
|
const docs = await getDocsClient();
|
|
36
|
+
await guardMutation(args.documentId, {
|
|
37
|
+
contentFetcher: async () => {
|
|
38
|
+
const current = await docs.documents.get({ documentId: args.documentId });
|
|
39
|
+
return docsJsonToMarkdown(current.data);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
37
42
|
// Resolve markdown content from filePath or inline parameter
|
|
38
43
|
let markdown = args.markdown;
|
|
39
44
|
if (args.filePath) {
|
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { getDocsClient } from '../../clients.js';
|
|
5
5
|
import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
|
|
6
6
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
7
|
-
import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
|
|
7
|
+
import { insertMarkdown, formatInsertResult, docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
8
8
|
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
9
9
|
export function register(server) {
|
|
10
10
|
server.addTool({
|
|
@@ -33,8 +33,13 @@ export function register(server) {
|
|
|
33
33
|
.describe('If true (default), the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title. Set to false if the first H1 should remain a Heading 1.'),
|
|
34
34
|
}),
|
|
35
35
|
execute: async (args, { log }) => {
|
|
36
|
-
await guardMutation(args.documentId);
|
|
37
36
|
const docs = await getDocsClient();
|
|
37
|
+
await guardMutation(args.documentId, {
|
|
38
|
+
contentFetcher: async () => {
|
|
39
|
+
const current = await docs.documents.get({ documentId: args.documentId });
|
|
40
|
+
return docsJsonToMarkdown(current.data);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
38
43
|
// Resolve markdown content from filePath or inline parameter
|
|
39
44
|
let markdown = args.markdown;
|
|
40
45
|
if (args.filePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "google-tools-mcp",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.6",
|
|
4
4
|
"description": "The easiest MCP server for Google Workspace — Drive, Docs, Sheets, Gmail, Calendar, and Forms. 153 tools with one-click browser auth. Read Word docs, PDFs, and spreadsheets straight from Drive.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@clack/prompts": "^1.2.0",
|
|
66
66
|
"chalk": "^5.6.2",
|
|
67
|
+
"diff": "^7.0.0",
|
|
67
68
|
"fastmcp": "^3.24.0",
|
|
68
69
|
"google-auth-library": "^10.5.0",
|
|
69
70
|
"googleapis": "^171.4.0",
|