-
Notifications
You must be signed in to change notification settings - Fork 148
Expand file tree
/
Copy pathrepomix-output.txt.xml
More file actions
4447 lines (3792 loc) · 192 KB
/
repomix-output.txt.xml
File metadata and controls
4447 lines (3792 loc) · 192 KB
1
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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
This file is a merged representation of the entire codebase, combined into a single document by Repomix.
================================================================
File Summary
================================================================
Purpose:
--------
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
File Format:
------------
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Multiple file entries, each consisting of:
a. A separator line (================)
b. The file path (File: path/to/file)
c. Another separator line
d. The full contents of the file
e. A blank line
Usage Guidelines:
-----------------
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
Notes:
------
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
Additional Info:
----------------
================================================================
Directory Structure
================================================================
.repomix/
bundles.json
docs/
index.html
src/
auth.ts
googleDocsApiHelpers.ts
server.ts
types.ts
tests/
helpers.test.js
types.test.js
.gitignore
claude.md
LICENSE
package.json
README.md
SAMPLE_TASKS.md
tsconfig.json
vscode.md
================================================================
Files
================================================================
================
File: .repomix/bundles.json
================
{
"bundles": {}
}
================
File: docs/index.html
================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FastMCP Google Docs Server Docs</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
body { font-family: sans-serif; line-height: 1.6; padding: 20px; }
pre { background-color: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }
code { font-family: monospace; }
h1, h2, h3 { border-bottom: 1px solid #eee; padding-bottom: 5px; margin-top: 20px; }
</style>
</head>
<body>
<div id="content"></div>
<script type="text/markdown" id="markdown-content">
# FastMCP Google Docs Server
Connect Claude Desktop (or other MCP clients) to your Google Docs!
This server uses the Model Context Protocol (MCP) and the `fastmcp` library to provide tools for reading and appending text to Google Documents. It acts as a bridge, allowing AI assistants like Claude to interact with your documents programmatically.
**Features:**
- **Read Documents:** Provides a `readGoogleDoc` tool to fetch the text content of a specified Google Doc.
- **Append to Documents:** Provides an `appendToGoogleDoc` tool to add text to the end of a specified Google Doc.
- **Google Authentication:** Handles the OAuth 2.0 flow to securely authorize access to your Google Account.
- **MCP Compliant:** Designed for use with MCP clients like Claude Desktop.
---
## Prerequisites
Before you start, make sure you have:
1. **Node.js and npm:** A recent version of Node.js (which includes npm) installed on your computer. You can download it from [nodejs.org](https://nodejs.org/). (Version 18 or higher recommended).
2. **Git:** Required for cloning this repository. ([Download Git](https://git-scm.com/downloads)).
3. **A Google Account:** The account that owns or has access to the Google Docs you want to interact with.
4. **Command Line Familiarity:** Basic comfort using a terminal or command prompt (like Terminal on macOS/Linux, or Command Prompt/PowerShell on Windows).
5. **Claude Desktop (Optional):** If your goal is to connect this server to Claude, you'll need the Claude Desktop application installed.
---
## Setup Instructions
Follow these steps carefully to get your own instance of the server running.
### Step 1: Google Cloud Project & Credentials (The Important Bit!)
This server needs permission to talk to Google APIs on your behalf. You'll create special "keys" (credentials) that only your server will use.
1. **Go to Google Cloud Console:** Open your web browser and go to the [Google Cloud Console](https://console.cloud.google.com/). You might need to log in with your Google Account.
2. **Create or Select a Project:**
- If you don't have a project, click the project dropdown near the top and select "NEW PROJECT". Give it a name (e.g., "My MCP Docs Server") and click "CREATE".
- If you have existing projects, you can select one or create a new one.
3. **Enable APIs:** You need to turn on the specific Google services this server uses.
- In the search bar at the top, type "APIs & Services" and select "Library".
- Search for "**Google Docs API**" and click on it. Then click the "**ENABLE**" button.
- Search for "**Google Drive API**" and click on it. Then click the "**ENABLE**" button (this is often needed for finding files or permissions).
4. **Configure OAuth Consent Screen:** This screen tells users (usually just you) what your app wants permission for.
- On the left menu, click "APIs & Services" -> "**OAuth consent screen**".
- Choose User Type: Select "**External**" and click "CREATE".
- Fill in App Information:
- **App name:** Give it a name users will see (e.g., "Claude Docs MCP Access").
- **User support email:** Select your email address.
- **Developer contact information:** Enter your email address.
- Click "**SAVE AND CONTINUE**".
- **Scopes:** Click "**ADD OR REMOVE SCOPES**". Search for and add the following scopes:
- `https://www.googleapis.com/auth/documents` (Allows reading/writing docs)
- `https://www.googleapis.com/auth/drive.file` (Allows access to specific files opened/created by the app)
- Click "**UPDATE**".
- Click "**SAVE AND CONTINUE**".
- **Test Users:** Click "**ADD USERS**". Enter the same Google email address you are logged in with. Click "**ADD**". This allows _you_ to use the app while it's in "testing" mode.
- Click "**SAVE AND CONTINUE**". Review the summary and click "**BACK TO DASHBOARD**".
5. **Create Credentials (The Keys!):**
- On the left menu, click "APIs & Services" -> "**Credentials**".
- Click "**+ CREATE CREDENTIALS**" at the top and choose "**OAuth client ID**".
- **Application type:** Select "**Desktop app**" from the dropdown.
- **Name:** Give it a name (e.g., "MCP Docs Desktop Client").
- Click "**CREATE**".
6. **⬇️ DOWNLOAD THE CREDENTIALS FILE:** A box will pop up showing your Client ID. Click the "**DOWNLOAD JSON**" button.
- Save this file. It will likely be named something like `client_secret_....json`.
- **IMPORTANT:** Rename the downloaded file to exactly `credentials.json`.
7. ⚠️ **SECURITY WARNING:** Treat this `credentials.json` file like a password! Do not share it publicly, and **never commit it to GitHub.** Anyone with this file could potentially pretend to be _your application_ (though they'd still need user consent to access data).
### Step 2: Get the Server Code
1. **Clone the Repository:** Open your terminal/command prompt and run:
```bash
git clone https://github.com/a-bonus/google-docs-mcp.git mcp-googledocs-server
```
2. **Navigate into Directory:**
```bash
cd mcp-googledocs-server
```
3. **Place Credentials:** Move or copy the `credentials.json` file you downloaded and renamed (from Step 1.6) directly into this `mcp-googledocs-server` folder.
### Step 3: Install Dependencies
Your server needs some helper libraries specified in the `package.json` file.
1. In your terminal (make sure you are inside the `mcp-googledocs-server` directory), run:
```bash
npm install
```
This will download and install all the necessary packages into a `node_modules` folder.
### Step 4: Build the Server Code
The server is written in TypeScript (`.ts`), but we need to compile it into JavaScript (`.js`) that Node.js can run directly.
1. In your terminal, run:
```bash
npm run build
```
This uses the TypeScript compiler (`tsc`) to create a `dist` folder containing the compiled JavaScript files.
### Step 5: First Run & Google Authorization (One Time Only)
Now you need to run the server once manually to grant it permission to access your Google account data. This will create a `token.json` file that saves your permission grant.
1. In your terminal, run the _compiled_ server using `node`:
```bash
node ./dist/server.js
```
2. **Watch the Terminal:** The script will print:
- Status messages (like "Attempting to authorize...").
- An "Authorize this app by visiting this url:" message followed by a long `https://accounts.google.com/...` URL.
3. **Authorize in Browser:**
- Copy the entire long URL from the terminal.
- Paste the URL into your web browser and press Enter.
- Log in with the **same Google account** you added as a Test User in Step 1.4.
- Google will show a screen asking for permission for your app ("Claude Docs MCP Access" or similar) to access Google Docs/Drive. Review and click "**Allow**" or "**Grant**".
4. **Get the Authorization Code:**
- After clicking Allow, your browser will likely try to redirect to `http://localhost` and show a **"This site can't be reached" error**. **THIS IS NORMAL!**
- Look **carefully** at the URL in your browser's address bar. It will look like `http://localhost/?code=4/0Axxxxxxxxxxxxxx&scope=...`
- Copy the long string of characters **between `code=` and the `&scope` part**. This is your single-use authorization code.
5. **Paste Code into Terminal:** Go back to your terminal where the script is waiting ("Enter the code from that page here:"). Paste the code you just copied.
6. **Press Enter.**
7. **Success!** The script should print:
- "Authentication successful!"
- "Token stored to .../token.json"
- It will then finish starting and likely print "Awaiting MCP client connection via stdio..." or similar, and then exit (or you can press `Ctrl+C` to stop it).
8. ✅ **Check:** You should now see a new file named `token.json` in your `mcp-googledocs-server` folder.
9. ⚠️ **SECURITY WARNING:** This `token.json` file contains the key that allows the server to access your Google account _without_ asking again. Protect it like a password. **Do not commit it to GitHub.** The included `.gitignore` file should prevent this automatically.
### Step 6: Configure Claude Desktop (Optional)
If you want to use this server with Claude Desktop, you need to tell Claude how to run it.
1. **Find Your Absolute Path:** You need the full path to the server code.
- In your terminal, make sure you are still inside the `mcp-googledocs-server` directory.
- Run the `pwd` command (on macOS/Linux) or `cd` (on Windows, just displays the path).
- Copy the full path (e.g., `/Users/yourname/projects/mcp-googledocs-server` or `C:\Users\yourname\projects\mcp-googledocs-server`).
2. **Locate `mcp_config.json`:** Find Claude's configuration file:
- **macOS:** `~/Library/Application Support/Claude/mcp_config.json` (You might need to use Finder's "Go" -> "Go to Folder..." menu and paste `~/Library/Application Support/Claude/`)
- **Windows:** `%APPDATA%\Claude\mcp_config.json` (Paste `%APPDATA%\Claude` into File Explorer's address bar)
- **Linux:** `~/.config/Claude/mcp_config.json`
- _If the `Claude` folder or `mcp_config.json` file doesn't exist, create them._
3. **Edit `mcp_config.json`:** Open the file in a text editor. Add or modify the `mcpServers` section like this, **replacing `/PATH/TO/YOUR/CLONED/REPO` with the actual absolute path you copied in Step 6.1**:
```json
{
"mcpServers": {
"google-docs-mcp": {
"command": "node",
"args": [
"/PATH/TO/YOUR/CLONED/REPO/mcp-googledocs-server/dist/server.js"
],
"env": {}
}
// Add commas here if you have other servers defined
}
// Other Claude settings might be here
}
```
- **Make sure the path in `"args"` is correct and absolute!**
- If the file already existed, carefully merge this entry into the existing `mcpServers` object. Ensure the JSON is valid (check commas!).
4. **Save `mcp_config.json`.**
5. **Restart Claude Desktop:** Close Claude completely and reopen it.
---
## Usage with Claude Desktop
Once configured, you should be able to use the tools in your chats with Claude:
- "Use the `google-docs-mcp` server to read the document with ID `YOUR_GOOGLE_DOC_ID`."
- "Can you get the content of Google Doc `YOUR_GOOGLE_DOC_ID`?"
- "Append 'This was added by Claude!' to document `YOUR_GOOGLE_DOC_ID` using the `google-docs-mcp` tool."
Remember to replace `YOUR_GOOGLE_DOC_ID` with the actual ID from a Google Doc's URL (the long string between `/d/` and `/edit`).
Claude will automatically launch your server in the background when needed using the command you provided. You do **not** need to run `node ./dist/server.js` manually anymore.
---
## Security & Token Storage
- **`.gitignore`:** This repository includes a `.gitignore` file which should prevent you from accidentally committing your sensitive `credentials.json` and `token.json` files. **Do not remove these lines from `.gitignore`**.
- **Token Storage:** This server stores the Google authorization token (`token.json`) directly in the project folder for simplicity during setup. In production or more security-sensitive environments, consider storing this token more securely, such as using system keychains, encrypted files, or dedicated secret management services.
---
## Troubleshooting
- **Claude shows "Failed" or "Could not attach":**
- Double-check the absolute path in `mcp_config.json`.
- Ensure you ran `npm run build` successfully and the `dist` folder exists.
- Try running the command from `mcp_config.json` manually in your terminal: `node /PATH/TO/YOUR/CLONED/REPO/mcp-googledocs-server/dist/server.js`. Look for any errors printed.
- Check the Claude Desktop logs (see the official MCP debugging guide).
- Make sure all `console.log` status messages in the server code were changed to `console.error`.
- **Google Authorization Errors:**
- Ensure you enabled the correct APIs (Docs, Drive).
- Make sure you added your email as a Test User on the OAuth Consent Screen.
- Verify the `credentials.json` file is correctly placed in the project root.
---
## License
This project is licensed under the MIT License - see the `LICENSE` file for details. (Note: You should add a `LICENSE` file containing the MIT License text to your repository).
---
================
File: src/auth.ts
================
// src/auth.ts
import { google } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as readline from 'readline/promises';
import { fileURLToPath } from 'url';
// --- Calculate paths relative to this script file (ESM way) ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRootDir = path.resolve(__dirname, '..');
const TOKEN_PATH = path.join(projectRootDir, 'token.json');
const CREDENTIALS_PATH = path.join(projectRootDir, 'credentials.json');
// --- End of path calculation ---
const SCOPES = [
'https://www.googleapis.com/auth/documents',
'https://www.googleapis.com/auth/drive' // Full Drive access for listing, searching, and document discovery
];
async function loadSavedCredentialsIfExist(): Promise<OAuth2Client | null> {
try {
const content = await fs.readFile(TOKEN_PATH);
const credentials = JSON.parse(content.toString());
const { client_secret, client_id, redirect_uris } = await loadClientSecrets();
const client = new google.auth.OAuth2(client_id, client_secret, redirect_uris?.[0]);
client.setCredentials(credentials);
return client;
} catch (err) {
return null;
}
}
async function loadClientSecrets() {
const content = await fs.readFile(CREDENTIALS_PATH);
const keys = JSON.parse(content.toString());
const key = keys.installed || keys.web;
if (!key) throw new Error("Could not find client secrets in credentials.json.");
return {
client_id: key.client_id,
client_secret: key.client_secret,
redirect_uris: key.redirect_uris
};
}
async function saveCredentials(client: OAuth2Client): Promise<void> {
const { client_secret, client_id } = await loadClientSecrets();
const payload = JSON.stringify({
type: 'authorized_user',
client_id: client_id,
client_secret: client_secret,
refresh_token: client.credentials.refresh_token,
});
await fs.writeFile(TOKEN_PATH, payload);
console.error('Token stored to', TOKEN_PATH);
}
async function authenticate(): Promise<OAuth2Client> {
const { client_secret, client_id, redirect_uris } = await loadClientSecrets();
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris?.[0]);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const authorizeUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES.join(' '),
});
console.error('Authorize this app by visiting this url:', authorizeUrl);
const code = await rl.question('Enter the code from that page here: ');
rl.close();
try {
const { tokens } = await oAuth2Client.getToken(code);
oAuth2Client.setCredentials(tokens);
if (tokens.refresh_token) { // Save only if we got a refresh token
await saveCredentials(oAuth2Client);
} else {
console.error("Did not receive refresh token. Token might expire.");
}
console.error('Authentication successful!');
return oAuth2Client;
} catch (err) {
console.error('Error retrieving access token', err);
throw new Error('Authentication failed');
}
}
export async function authorize(): Promise<OAuth2Client> {
let client = await loadSavedCredentialsIfExist();
if (client) {
// Optional: Add token refresh logic here if needed, though library often handles it.
console.error('Using saved credentials.');
return client;
}
console.error('Starting authentication flow...');
client = await authenticate();
return client;
}
================
File: src/googleDocsApiHelpers.ts
================
// src/googleDocsApiHelpers.ts
import { google, docs_v1 } from 'googleapis';
import { OAuth2Client } from 'google-auth-library';
import { UserError } from 'fastmcp';
import { TextStyleArgs, ParagraphStyleArgs, hexToRgbColor, NotImplementedError } from './types.js';
type Docs = docs_v1.Docs; // Alias for convenience
// --- Constants ---
const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
// --- Core Helper to Execute Batch Updates ---
export async function executeBatchUpdate(docs: Docs, documentId: string, requests: docs_v1.Schema$Request[]): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
if (!requests || requests.length === 0) {
// console.warn("executeBatchUpdate called with no requests.");
return {}; // Nothing to do
}
// TODO: Consider splitting large request arrays into multiple batches if needed
if (requests.length > MAX_BATCH_UPDATE_REQUESTS) {
console.warn(`Attempting batch update with ${requests.length} requests, exceeding typical limits. May fail.`);
}
try {
const response = await docs.documents.batchUpdate({
documentId: documentId,
requestBody: { requests },
});
return response.data;
} catch (error: any) {
console.error(`Google API batchUpdate Error for doc ${documentId}:`, error.response?.data || error.message);
// Translate common API errors to UserErrors
if (error.code === 400 && error.message.includes('Invalid requests')) {
// Try to extract more specific info if available
const details = error.response?.data?.error?.details;
let detailMsg = '';
if (details && Array.isArray(details)) {
detailMsg = details.map(d => d.description || JSON.stringify(d)).join('; ');
}
throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || error.message}`);
}
if (error.code === 404) throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`);
if (error.code === 403) throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`);
// Generic internal error for others
throw new Error(`Google API Error (${error.code}): ${error.message}`);
}
}
// --- Text Finding Helper ---
// This improved version is more robust in handling various text structure scenarios
export async function findTextRange(docs: Docs, documentId: string, textToFind: string, instance: number = 1): Promise<{ startIndex: number; endIndex: number } | null> {
try {
// Request more detailed information about the document structure
const res = await docs.documents.get({
documentId,
// Request more fields to handle various container types (not just paragraphs)
fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
});
if (!res.data.body?.content) {
console.warn(`No content found in document ${documentId}`);
return null;
}
// More robust text collection and index tracking
let fullText = '';
const segments: { text: string, start: number, end: number }[] = [];
// Process all content elements, including structural ones
const collectTextFromContent = (content: any[]) => {
content.forEach(element => {
// Handle paragraph elements
if (element.paragraph?.elements) {
element.paragraph.elements.forEach((pe: any) => {
if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
const content = pe.textRun.content;
fullText += content;
segments.push({
text: content,
start: pe.startIndex,
end: pe.endIndex
});
}
});
}
// Handle table elements - this is simplified and might need expansion
if (element.table && element.table.tableRows) {
element.table.tableRows.forEach((row: any) => {
if (row.tableCells) {
row.tableCells.forEach((cell: any) => {
if (cell.content) {
collectTextFromContent(cell.content);
}
});
}
});
}
// Add handling for other structural elements as needed
});
};
collectTextFromContent(res.data.body.content);
// Sort segments by starting position to ensure correct ordering
segments.sort((a, b) => a.start - b.start);
console.log(`Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.`);
// Find the specified instance of the text
let startIndex = -1;
let endIndex = -1;
let foundCount = 0;
let searchStartIndex = 0;
while (foundCount < instance) {
const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
if (currentIndex === -1) {
console.log(`Search text "${textToFind}" not found for instance ${foundCount + 1} (requested: ${instance})`);
break;
}
foundCount++;
console.log(`Found instance ${foundCount} of "${textToFind}" at position ${currentIndex} in full text`);
if (foundCount === instance) {
const targetStartInFullText = currentIndex;
const targetEndInFullText = currentIndex + textToFind.length;
let currentPosInFullText = 0;
console.log(`Target text range in full text: ${targetStartInFullText}-${targetEndInFullText}`);
for (const seg of segments) {
const segStartInFullText = currentPosInFullText;
const segTextLength = seg.text.length;
const segEndInFullText = segStartInFullText + segTextLength;
// Map from reconstructed text position to actual document indices
if (startIndex === -1 && targetStartInFullText >= segStartInFullText && targetStartInFullText < segEndInFullText) {
startIndex = seg.start + (targetStartInFullText - segStartInFullText);
console.log(`Mapped start to segment ${seg.start}-${seg.end}, position ${startIndex}`);
}
if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
endIndex = seg.start + (targetEndInFullText - segStartInFullText);
console.log(`Mapped end to segment ${seg.start}-${seg.end}, position ${endIndex}`);
break;
}
currentPosInFullText = segEndInFullText;
}
if (startIndex === -1 || endIndex === -1) {
console.warn(`Failed to map text "${textToFind}" instance ${instance} to actual document indices`);
// Reset and try next occurrence
startIndex = -1;
endIndex = -1;
searchStartIndex = currentIndex + 1;
foundCount--;
continue;
}
console.log(`Successfully mapped "${textToFind}" to document range ${startIndex}-${endIndex}`);
return { startIndex, endIndex };
}
// Prepare for next search iteration
searchStartIndex = currentIndex + 1;
}
console.warn(`Could not find instance ${instance} of text "${textToFind}" in document ${documentId}`);
return null; // Instance not found or mapping failed for all attempts
} catch (error: any) {
console.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message || 'Unknown error'}`);
if (error.code === 404) throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
if (error.code === 403) throw new UserError(`Permission denied while searching text in doc ${documentId}.`);
throw new Error(`Failed to retrieve doc for text searching: ${error.message || 'Unknown error'}`);
}
}
// --- Paragraph Boundary Helper ---
// Enhanced version to handle document structural elements more robustly
export async function getParagraphRange(docs: Docs, documentId: string, indexWithin: number): Promise<{ startIndex: number; endIndex: number } | null> {
try {
console.log(`Finding paragraph containing index ${indexWithin} in document ${documentId}`);
// Request more detailed document structure to handle nested elements
const res = await docs.documents.get({
documentId,
// Request more comprehensive structure information
fields: 'body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))',
});
if (!res.data.body?.content) {
console.warn(`No content found in document ${documentId}`);
return null;
}
// Find paragraph containing the index
// We'll look at all structural elements recursively
const findParagraphInContent = (content: any[]): { startIndex: number; endIndex: number } | null => {
for (const element of content) {
// Check if we have element boundaries defined
if (element.startIndex !== undefined && element.endIndex !== undefined) {
// Check if index is within this element's range first
if (indexWithin >= element.startIndex && indexWithin < element.endIndex) {
// If it's a paragraph, we've found our target
if (element.paragraph) {
console.log(`Found paragraph containing index ${indexWithin}, range: ${element.startIndex}-${element.endIndex}`);
return {
startIndex: element.startIndex,
endIndex: element.endIndex
};
}
// If it's a table, we need to check cells recursively
if (element.table && element.table.tableRows) {
console.log(`Index ${indexWithin} is within a table, searching cells...`);
for (const row of element.table.tableRows) {
if (row.tableCells) {
for (const cell of row.tableCells) {
if (cell.content) {
const result = findParagraphInContent(cell.content);
if (result) return result;
}
}
}
}
}
// For other structural elements, we didn't find a paragraph
// but we know the index is within this element
console.warn(`Index ${indexWithin} is within element (${element.startIndex}-${element.endIndex}) but not in a paragraph`);
}
}
}
return null;
};
const paragraphRange = findParagraphInContent(res.data.body.content);
if (!paragraphRange) {
console.warn(`Could not find paragraph containing index ${indexWithin}`);
} else {
console.log(`Returning paragraph range: ${paragraphRange.startIndex}-${paragraphRange.endIndex}`);
}
return paragraphRange;
} catch (error: any) {
console.error(`Error getting paragraph range for index ${indexWithin} in doc ${documentId}: ${error.message || 'Unknown error'}`);
if (error.code === 404) throw new UserError(`Document not found while finding paragraph (ID: ${documentId}).`);
if (error.code === 403) throw new UserError(`Permission denied while accessing doc ${documentId}.`);
throw new Error(`Failed to find paragraph: ${error.message || 'Unknown error'}`);
}
}
// --- Style Request Builders ---
export function buildUpdateTextStyleRequest(
startIndex: number,
endIndex: number,
style: TextStyleArgs
): { request: docs_v1.Schema$Request, fields: string[] } | null {
const textStyle: docs_v1.Schema$TextStyle = {};
const fieldsToUpdate: string[] = [];
if (style.bold !== undefined) { textStyle.bold = style.bold; fieldsToUpdate.push('bold'); }
if (style.italic !== undefined) { textStyle.italic = style.italic; fieldsToUpdate.push('italic'); }
if (style.underline !== undefined) { textStyle.underline = style.underline; fieldsToUpdate.push('underline'); }
if (style.strikethrough !== undefined) { textStyle.strikethrough = style.strikethrough; fieldsToUpdate.push('strikethrough'); }
if (style.fontSize !== undefined) { textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' }; fieldsToUpdate.push('fontSize'); }
if (style.fontFamily !== undefined) { textStyle.weightedFontFamily = { fontFamily: style.fontFamily }; fieldsToUpdate.push('weightedFontFamily'); }
if (style.foregroundColor !== undefined) {
const rgbColor = hexToRgbColor(style.foregroundColor);
if (!rgbColor) throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`);
textStyle.foregroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('foregroundColor');
}
if (style.backgroundColor !== undefined) {
const rgbColor = hexToRgbColor(style.backgroundColor);
if (!rgbColor) throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`);
textStyle.backgroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('backgroundColor');
}
if (style.linkUrl !== undefined) {
textStyle.link = { url: style.linkUrl }; fieldsToUpdate.push('link');
}
// TODO: Handle clearing formatting
if (fieldsToUpdate.length === 0) return null; // No styles to apply
const request: docs_v1.Schema$Request = {
updateTextStyle: {
range: { startIndex, endIndex },
textStyle: textStyle,
fields: fieldsToUpdate.join(','),
}
};
return { request, fields: fieldsToUpdate };
}
export function buildUpdateParagraphStyleRequest(
startIndex: number,
endIndex: number,
style: ParagraphStyleArgs
): { request: docs_v1.Schema$Request, fields: string[] } | null {
// Create style object and track which fields to update
const paragraphStyle: docs_v1.Schema$ParagraphStyle = {};
const fieldsToUpdate: string[] = [];
console.log(`Building paragraph style request for range ${startIndex}-${endIndex} with options:`, style);
// Process alignment option (LEFT, CENTER, RIGHT, JUSTIFIED)
if (style.alignment !== undefined) {
paragraphStyle.alignment = style.alignment;
fieldsToUpdate.push('alignment');
console.log(`Setting alignment to ${style.alignment}`);
}
// Process indentation options
if (style.indentStart !== undefined) {
paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' };
fieldsToUpdate.push('indentStart');
console.log(`Setting left indent to ${style.indentStart}pt`);
}
if (style.indentEnd !== undefined) {
paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' };
fieldsToUpdate.push('indentEnd');
console.log(`Setting right indent to ${style.indentEnd}pt`);
}
// Process spacing options
if (style.spaceAbove !== undefined) {
paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' };
fieldsToUpdate.push('spaceAbove');
console.log(`Setting space above to ${style.spaceAbove}pt`);
}
if (style.spaceBelow !== undefined) {
paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' };
fieldsToUpdate.push('spaceBelow');
console.log(`Setting space below to ${style.spaceBelow}pt`);
}
// Process named style types (headings, etc.)
if (style.namedStyleType !== undefined) {
paragraphStyle.namedStyleType = style.namedStyleType;
fieldsToUpdate.push('namedStyleType');
console.log(`Setting named style to ${style.namedStyleType}`);
}
// Process page break control
if (style.keepWithNext !== undefined) {
paragraphStyle.keepWithNext = style.keepWithNext;
fieldsToUpdate.push('keepWithNext');
console.log(`Setting keepWithNext to ${style.keepWithNext}`);
}
// Verify we have styles to apply
if (fieldsToUpdate.length === 0) {
console.warn("No paragraph styling options were provided");
return null; // No styles to apply
}
// Build the request object
const request: docs_v1.Schema$Request = {
updateParagraphStyle: {
range: { startIndex, endIndex },
paragraphStyle: paragraphStyle,
fields: fieldsToUpdate.join(','),
}
};
console.log(`Created paragraph style request with fields: ${fieldsToUpdate.join(', ')}`);
return { request, fields: fieldsToUpdate };
}
// --- Specific Feature Helpers ---
export async function createTable(docs: Docs, documentId: string, rows: number, columns: number, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
if (rows < 1 || columns < 1) {
throw new UserError("Table must have at least 1 row and 1 column.");
}
const request: docs_v1.Schema$Request = {
insertTable: {
location: { index },
rows: rows,
columns: columns,
}
};
return executeBatchUpdate(docs, documentId, [request]);
}
export async function insertText(docs: Docs, documentId: string, text: string, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
if (!text) return {}; // Nothing to insert
const request: docs_v1.Schema$Request = {
insertText: {
location: { index },
text: text,
}
};
return executeBatchUpdate(docs, documentId, [request]);
}
// --- Complex / Stubbed Helpers ---
export async function findParagraphsMatchingStyle(
docs: Docs,
documentId: string,
styleCriteria: any // Define a proper type for criteria (e.g., { fontFamily: 'Arial', bold: true })
): Promise<{ startIndex: number; endIndex: number }[]> {
// TODO: Implement logic
// 1. Get document content with paragraph elements and their styles.
// 2. Iterate through paragraphs.
// 3. For each paragraph, check if its computed style matches the criteria.
// 4. Return ranges of matching paragraphs.
console.warn("findParagraphsMatchingStyle is not implemented.");
throw new NotImplementedError("Finding paragraphs by style criteria is not yet implemented.");
// return [];
}
export async function detectAndFormatLists(
docs: Docs,
documentId: string,
startIndex?: number,
endIndex?: number
): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
// TODO: Implement complex logic
// 1. Get document content (paragraphs, text runs) in the specified range (or whole doc).
// 2. Iterate through paragraphs.
// 3. Identify sequences of paragraphs starting with list-like markers (e.g., "-", "*", "1.", "a)").
// 4. Determine nesting levels based on indentation or marker patterns.
// 5. Generate CreateParagraphBulletsRequests for the identified sequences.
// 6. Potentially delete the original marker text.
// 7. Execute the batch update.
console.warn("detectAndFormatLists is not implemented.");
throw new NotImplementedError("Automatic list detection and formatting is not yet implemented.");
// return {};
}
export async function addCommentHelper(docs: Docs, documentId: string, text: string, startIndex: number, endIndex: number): Promise<void> {
// NOTE: Adding comments typically requires the Google Drive API v3 and different scopes!
// 'https://www.googleapis.com/auth/drive' or more specific comment scopes.
// This helper is a placeholder assuming Drive API client (`drive`) is available and authorized.
/*
const drive = google.drive({version: 'v3', auth: authClient}); // Assuming authClient is available
await drive.comments.create({
fileId: documentId,
requestBody: {
content: text,
anchor: JSON.stringify({ // Anchor format might need verification
'type': 'workbook#textAnchor', // Or appropriate type for Docs
'refs': [{
'docRevisionId': 'head', // Or specific revision
'range': {
'start': startIndex,
'end': endIndex,
}
}]
})
},
fields: 'id'
});
*/
console.warn("addCommentHelper requires Google Drive API and is not implemented.");
throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented.");
}
================
File: src/server.ts
================
// src/server.ts
import { FastMCP, UserError } from 'fastmcp';
import { z } from 'zod';
import { google, docs_v1, drive_v3 } from 'googleapis';
import { authorize } from './auth.js';
import { OAuth2Client } from 'google-auth-library';
// Import types and helpers
import {
DocumentIdParameter,
RangeParameters,
OptionalRangeParameters,
TextFindParameter,
TextStyleParameters,
TextStyleArgs,
ParagraphStyleParameters,
ParagraphStyleArgs,
ApplyTextStyleToolParameters, ApplyTextStyleToolArgs,
ApplyParagraphStyleToolParameters, ApplyParagraphStyleToolArgs,
NotImplementedError
} from './types.js';
import * as GDocsHelpers from './googleDocsApiHelpers.js';
let authClient: OAuth2Client | null = null;
let googleDocs: docs_v1.Docs | null = null;
let googleDrive: drive_v3.Drive | null = null;
// --- Initialization ---
async function initializeGoogleClient() {
if (googleDocs && googleDrive) return { authClient, googleDocs, googleDrive };
if (!authClient) { // Check authClient instead of googleDocs to allow re-attempt
try {
console.error("Attempting to authorize Google API client...");
const client = await authorize();
authClient = client; // Assign client here
googleDocs = google.docs({ version: 'v1', auth: authClient });
googleDrive = google.drive({ version: 'v3', auth: authClient });
console.error("Google API client authorized successfully.");
} catch (error) {
console.error("FATAL: Failed to initialize Google API client:", error);
authClient = null; // Reset on failure
googleDocs = null;
googleDrive = null;
// Decide if server should exit or just fail tools
throw new Error("Google client initialization failed. Cannot start server tools.");
}
}
// Ensure googleDocs and googleDrive are set if authClient is valid
if (authClient && !googleDocs) {
googleDocs = google.docs({ version: 'v1', auth: authClient });
}
if (authClient && !googleDrive) {
googleDrive = google.drive({ version: 'v3', auth: authClient });
}
if (!googleDocs || !googleDrive) {
throw new Error("Google Docs and Drive clients could not be initialized.");
}
return { authClient, googleDocs, googleDrive };
}
// Set up process-level unhandled error/rejection handlers to prevent crashes
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
// Don't exit process, just log the error and continue
// This will catch timeout errors that might otherwise crash the server
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Promise Rejection:', reason);
// Don't exit process, just log the error and continue
});
const server = new FastMCP({
name: 'Ultimate Google Docs MCP Server',
version: '1.0.0'
});
// --- Helper to get Docs client within tools ---
async function getDocsClient() {
const { googleDocs: docs } = await initializeGoogleClient();
if (!docs) {
throw new UserError("Google Docs client is not initialized. Authentication might have failed during startup or lost connection.");
}
return docs;
}
// --- Helper to get Drive client within tools ---
async function getDriveClient() {
const { googleDrive: drive } = await initializeGoogleClient();
if (!drive) {
throw new UserError("Google Drive client is not initialized. Authentication might have failed during startup or lost connection.");
}
return drive;
}
// === TOOL DEFINITIONS ===
// --- Foundational Tools ---
server.addTool({
name: 'readGoogleDoc',
description: 'Reads the content of a specific Google Document, optionally returning structured data.',
parameters: DocumentIdParameter.extend({
format: z.enum(['text', 'json', 'markdown']).optional().default('text')