gdocs-mcp 0.3.0 → 0.4.1
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/README.md +99 -15
- package/dist/auth/google-auth.d.ts +1 -0
- package/dist/auth/google-auth.js +21 -2
- package/dist/index.js +20 -1
- package/dist/tools/add-comment.d.ts +14 -0
- package/dist/tools/add-comment.js +36 -0
- package/dist/tools/copy-document.d.ts +12 -0
- package/dist/tools/copy-document.js +28 -0
- package/dist/tools/delete-document.d.ts +12 -0
- package/dist/tools/delete-document.js +36 -0
- package/dist/tools/export-document.d.ts +26 -0
- package/dist/tools/export-document.js +43 -0
- package/dist/tools/extract-document-styles.d.ts +2 -0
- package/dist/tools/extract-document-styles.js +181 -50
- package/dist/tools/get-comments.d.ts +25 -0
- package/dist/tools/get-comments.js +44 -0
- package/dist/tools/insert-image.d.ts +14 -0
- package/dist/tools/insert-image.js +43 -0
- package/dist/tools/insert-link.d.ts +14 -0
- package/dist/tools/insert-link.js +38 -0
- package/dist/tools/insert-page-break.d.ts +10 -0
- package/dist/tools/insert-page-break.js +30 -0
- package/dist/tools/insert-table.d.ts +14 -0
- package/dist/tools/insert-table.js +36 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gdocs-mcp
|
|
2
2
|
|
|
3
|
-
Open-source MCP server for Google Docs and Sheets. Give Claude (or any MCP-compatible AI) the ability to read, create, edit,
|
|
3
|
+
Open-source MCP server for Google Docs and Sheets. Give Claude (or any MCP-compatible AI) the ability to read, create, edit, style, export, and collaborate on your Google Docs — with OAuth tokens that never leave your machine.
|
|
4
4
|
|
|
5
5
|
**Open-source. Self-hosted. Your tokens never leave your machine.**
|
|
6
6
|
|
|
@@ -46,9 +46,9 @@ Restart Claude Desktop. Ask Claude: *"List my recent Google Docs"* to verify.
|
|
|
46
46
|
claude mcp add gdocs -- npx gdocs-mcp
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
## Tools (
|
|
49
|
+
## Tools (28)
|
|
50
50
|
|
|
51
|
-
### Google Docs
|
|
51
|
+
### Google Docs — Read & Write
|
|
52
52
|
|
|
53
53
|
| Tool | Description |
|
|
54
54
|
|------|-------------|
|
|
@@ -63,11 +63,29 @@ claude mcp add gdocs -- npx gdocs-mcp
|
|
|
63
63
|
| `update_document` | Raw batchUpdate with 35+ request types |
|
|
64
64
|
| `unmerge_table_cells` | Unmerge previously merged table cells |
|
|
65
65
|
|
|
66
|
-
###
|
|
66
|
+
### Content Insertion
|
|
67
67
|
|
|
68
68
|
| Tool | Description |
|
|
69
69
|
|------|-------------|
|
|
70
|
-
| `
|
|
70
|
+
| `insert_image` | Insert an image from a URL with optional width/height sizing |
|
|
71
|
+
| `insert_table` | Create a table with specified rows (1-20) and columns (1-20) |
|
|
72
|
+
| `insert_page_break` | Insert a page break at a specific position |
|
|
73
|
+
| `insert_link` | Add a hyperlink to an existing text range |
|
|
74
|
+
|
|
75
|
+
### Document Management
|
|
76
|
+
|
|
77
|
+
| Tool | Description |
|
|
78
|
+
|------|-------------|
|
|
79
|
+
| `export_document` | Export as PDF, DOCX, TXT, or HTML. Save to file or return base64 |
|
|
80
|
+
| `copy_document` | Duplicate a doc with a new title. Great for templates |
|
|
81
|
+
| `delete_document` | Move to trash. Requires exact title confirmation to prevent accidents |
|
|
82
|
+
|
|
83
|
+
### Comments
|
|
84
|
+
|
|
85
|
+
| Tool | Description |
|
|
86
|
+
|------|-------------|
|
|
87
|
+
| `get_comments` | List comments with author, content, resolved status, and replies |
|
|
88
|
+
| `add_comment` | Add a comment anchored to specific text in the document |
|
|
71
89
|
|
|
72
90
|
### Style Presets
|
|
73
91
|
|
|
@@ -81,7 +99,28 @@ Define how your documents look once, apply everywhere.
|
|
|
81
99
|
| `set_active_preset` | Set the default preset for new documents |
|
|
82
100
|
| `delete_style_preset` | Delete a custom preset |
|
|
83
101
|
|
|
84
|
-
|
|
102
|
+
### Formatting
|
|
103
|
+
|
|
104
|
+
| Tool | Description |
|
|
105
|
+
|------|-------------|
|
|
106
|
+
| `update_header_footer` | Create or update header/footer content and styling |
|
|
107
|
+
| `format_list` | Apply bullet, numbered, or remove list formatting (6 glyph presets) |
|
|
108
|
+
|
|
109
|
+
### Google Sheets
|
|
110
|
+
|
|
111
|
+
| Tool | Description |
|
|
112
|
+
|------|-------------|
|
|
113
|
+
| `get_charts` | List all charts in a spreadsheet with IDs and specs |
|
|
114
|
+
|
|
115
|
+
### Automation
|
|
116
|
+
|
|
117
|
+
| Tool | Description |
|
|
118
|
+
|------|-------------|
|
|
119
|
+
| `execute_script` | Run a function in a deployed Google Apps Script project |
|
|
120
|
+
|
|
121
|
+
## Style Presets
|
|
122
|
+
|
|
123
|
+
### Built-in Presets
|
|
85
124
|
|
|
86
125
|
| Preset | Font | Headings | Body |
|
|
87
126
|
|--------|------|----------|------|
|
|
@@ -90,9 +129,9 @@ Define how your documents look once, apply everywhere.
|
|
|
90
129
|
| `classic` | Georgia + Garamond | Dark blue, serif | 11pt, 1.4x spacing |
|
|
91
130
|
| `minimal` | Roboto | Black, light weight | 10.5pt, 1.5x spacing |
|
|
92
131
|
|
|
93
|
-
|
|
132
|
+
### Extract + Apply Workflow
|
|
94
133
|
|
|
95
|
-
|
|
134
|
+
Point to a Google Doc that already looks the way you want:
|
|
96
135
|
|
|
97
136
|
```
|
|
98
137
|
You: "Extract styles from my brand doc and save as 'brand'"
|
|
@@ -104,19 +143,41 @@ You: "Apply brand styles to this report"
|
|
|
104
143
|
|
|
105
144
|
One-step capture, one-step apply. No JSON editing needed.
|
|
106
145
|
|
|
107
|
-
|
|
146
|
+
### Style Properties
|
|
108
147
|
|
|
109
148
|
Presets support every text and paragraph property the Google Docs API exposes:
|
|
110
149
|
|
|
111
150
|
**Text:** font family, font size, bold, italic, underline, strikethrough, small caps, text color, background color, baseline offset
|
|
112
151
|
|
|
113
|
-
**Paragraph:** alignment (left/center/right/justified), line spacing, space above/below, first line indent, start/end indent, keep lines together, keep with next, direction (LTR/RTL), paragraph borders
|
|
152
|
+
**Paragraph:** alignment (left/center/right/justified), line spacing, space above/below, first line indent, start/end indent, keep lines together, keep with next, direction (LTR/RTL), paragraph borders
|
|
114
153
|
|
|
115
|
-
**Document:** page margins
|
|
154
|
+
**Document:** page margins, page size
|
|
116
155
|
|
|
117
156
|
**Tables:** header row background and text color, bold headers, border color and width, cell padding, alternating row backgrounds
|
|
118
157
|
|
|
119
|
-
Only properties you specify are applied. Omitted properties are left unchanged. Config
|
|
158
|
+
Only properties you specify are applied. Omitted properties are left unchanged. Config stored at `~/.gdocs-mcp/styles.json`.
|
|
159
|
+
|
|
160
|
+
## Upgrading to v0.4
|
|
161
|
+
|
|
162
|
+
v0.4 expands the Drive scope from `drive.readonly` to `drive` (needed for export, copy, delete, and comments). You must re-authenticate:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
npx gdocs-mcp auth
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If you don't need the new tools and want to keep the old scope:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
GDOCS_MCP_READONLY=1 npx gdocs-mcp
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Scope Justification
|
|
175
|
+
|
|
176
|
+
| Scope | Tools |
|
|
177
|
+
|-------|-------|
|
|
178
|
+
| `documents` | read, create, replace, update, insert image/table/link/page break, style presets, header/footer, format list |
|
|
179
|
+
| `spreadsheets.readonly` | get_charts |
|
|
180
|
+
| `drive` | search, export, copy, delete, get/add comments |
|
|
120
181
|
|
|
121
182
|
## Limitations
|
|
122
183
|
|
|
@@ -126,8 +187,22 @@ These are Google Docs API limitations, not gdocs-mcp limitations:
|
|
|
126
187
|
- **Custom named styles** cannot be created — only the 9 built-in types (Title, Subtitle, Heading 1-6, Normal Text)
|
|
127
188
|
- **Conditional formatting** does not exist in Google Docs
|
|
128
189
|
- **Multi-column layout** is not exposed via the API
|
|
129
|
-
- **Bullet/numbered list glyph types** are per-paragraph, not configurable via style presets
|
|
130
190
|
- **Table header detection** assumes row 0 is the header — multi-row headers are not detected
|
|
191
|
+
- **Suggested edits** (propose mode) are not yet supported
|
|
192
|
+
- **Comment replies** are not yet supported — only top-level comments
|
|
193
|
+
- **Image URLs** are passed directly to Google's API — the MCP server does not fetch them
|
|
194
|
+
|
|
195
|
+
## Performance Logging
|
|
196
|
+
|
|
197
|
+
Every tool call logs latency to stderr:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
[gdocs-mcp] read_document: 342ms (api: 298ms)
|
|
201
|
+
[gdocs-mcp] apply_style_preset: 1247ms (api: 1102ms, 1593 requests)
|
|
202
|
+
[gdocs-mcp] list_style_presets: 2ms (local)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Disable with `GDOCS_MCP_QUIET=1`.
|
|
131
206
|
|
|
132
207
|
## Security
|
|
133
208
|
|
|
@@ -136,16 +211,25 @@ These are Google Docs API limitations, not gdocs-mcp limitations:
|
|
|
136
211
|
- Style presets stored at `~/.gdocs-mcp/styles.json`
|
|
137
212
|
- No telemetry, no data collection, no third-party token storage
|
|
138
213
|
- Tokens refresh automatically; if refresh fails, run `npx gdocs-mcp auth` again
|
|
214
|
+
- `delete_document` requires exact title match — not a boolean flag
|
|
139
215
|
|
|
140
216
|
## Configuration
|
|
141
217
|
|
|
142
|
-
Credentials and tokens
|
|
218
|
+
Credentials and tokens stored in `~/.gdocs-mcp/` by default. Override with:
|
|
143
219
|
|
|
144
220
|
```bash
|
|
145
221
|
GDOCS_CREDENTIALS=/path/to/credentials.json npx gdocs-mcp
|
|
146
222
|
```
|
|
147
223
|
|
|
148
|
-
|
|
224
|
+
Environment variables:
|
|
225
|
+
|
|
226
|
+
| Variable | Effect |
|
|
227
|
+
|----------|--------|
|
|
228
|
+
| `GDOCS_CREDENTIALS` | Override credentials.json path |
|
|
229
|
+
| `GDOCS_MCP_READONLY` | Set to `1` to disable write tools (export, copy, delete, comments) |
|
|
230
|
+
| `GDOCS_MCP_QUIET` | Set to `1` to disable performance logging |
|
|
231
|
+
|
|
232
|
+
For faster startup, install globally:
|
|
149
233
|
|
|
150
234
|
```bash
|
|
151
235
|
npm install -g gdocs-mcp
|
|
@@ -3,6 +3,7 @@ declare const CONFIG_DIR: string;
|
|
|
3
3
|
declare const TOKEN_PATH: string;
|
|
4
4
|
declare const CREDENTIALS_PATH_DEFAULT: string;
|
|
5
5
|
export declare function getAuthClient(): OAuth2Client;
|
|
6
|
+
export declare function isReadOnlyMode(): boolean;
|
|
6
7
|
export declare function getConfigDir(): string;
|
|
7
8
|
export declare function getTokenPath(): string;
|
|
8
9
|
export declare function getScopes(): string[];
|
package/dist/auth/google-auth.js
CHANGED
|
@@ -4,7 +4,13 @@ import path from 'path';
|
|
|
4
4
|
const CONFIG_DIR = path.join(process.env.HOME || '~', '.gdocs-mcp');
|
|
5
5
|
const TOKEN_PATH = path.join(CONFIG_DIR, 'token.json');
|
|
6
6
|
const CREDENTIALS_PATH_DEFAULT = path.join(CONFIG_DIR, 'credentials.json');
|
|
7
|
-
const
|
|
7
|
+
const READONLY = process.env.GDOCS_MCP_READONLY === '1';
|
|
8
|
+
const FULL_SCOPES = [
|
|
9
|
+
'https://www.googleapis.com/auth/documents',
|
|
10
|
+
'https://www.googleapis.com/auth/spreadsheets.readonly',
|
|
11
|
+
'https://www.googleapis.com/auth/drive',
|
|
12
|
+
];
|
|
13
|
+
const READONLY_SCOPES = [
|
|
8
14
|
'https://www.googleapis.com/auth/documents',
|
|
9
15
|
'https://www.googleapis.com/auth/spreadsheets.readonly',
|
|
10
16
|
'https://www.googleapis.com/auth/drive.readonly',
|
|
@@ -31,6 +37,16 @@ export function getAuthClient() {
|
|
|
31
37
|
throw new Error('Auth required. Run: npx gdocs-mcp auth');
|
|
32
38
|
}
|
|
33
39
|
const token = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
|
|
40
|
+
// Check if token has sufficient scopes for full mode
|
|
41
|
+
if (!READONLY && token.scope) {
|
|
42
|
+
const grantedScopes = token.scope.split(' ');
|
|
43
|
+
const hasDriveWrite = grantedScopes.some((s) => s === 'https://www.googleapis.com/auth/drive');
|
|
44
|
+
if (!hasDriveWrite) {
|
|
45
|
+
throw new Error('Auth scopes insufficient. v0.4 requires expanded Drive access for export, copy, delete, and comments.\n' +
|
|
46
|
+
'Run: npx gdocs-mcp auth\n' +
|
|
47
|
+
'Or set GDOCS_MCP_READONLY=1 to use read-only mode with the original scope.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
34
50
|
oAuth2Client.setCredentials(token);
|
|
35
51
|
oAuth2Client.on('tokens', (newTokens) => {
|
|
36
52
|
const existing = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
|
|
@@ -39,6 +55,9 @@ export function getAuthClient() {
|
|
|
39
55
|
});
|
|
40
56
|
return oAuth2Client;
|
|
41
57
|
}
|
|
58
|
+
export function isReadOnlyMode() {
|
|
59
|
+
return READONLY;
|
|
60
|
+
}
|
|
42
61
|
export function getConfigDir() {
|
|
43
62
|
return CONFIG_DIR;
|
|
44
63
|
}
|
|
@@ -46,6 +65,6 @@ export function getTokenPath() {
|
|
|
46
65
|
return TOKEN_PATH;
|
|
47
66
|
}
|
|
48
67
|
export function getScopes() {
|
|
49
|
-
return
|
|
68
|
+
return READONLY ? READONLY_SCOPES : FULL_SCOPES;
|
|
50
69
|
}
|
|
51
70
|
export { CONFIG_DIR, TOKEN_PATH, CREDENTIALS_PATH_DEFAULT };
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,16 @@ import { DeleteStylePresetSchema, deleteStylePreset } from './tools/delete-style
|
|
|
20
20
|
import { UpdateHeaderFooterSchema, updateHeaderFooter } from './tools/update-header-footer.js';
|
|
21
21
|
import { FormatListSchema, formatList } from './tools/format-list.js';
|
|
22
22
|
import { ExecuteScriptSchema, executeScript } from './tools/execute-script.js';
|
|
23
|
-
|
|
23
|
+
import { InsertImageSchema, insertImage } from './tools/insert-image.js';
|
|
24
|
+
import { InsertTableSchema, insertTable } from './tools/insert-table.js';
|
|
25
|
+
import { InsertPageBreakSchema, insertPageBreak } from './tools/insert-page-break.js';
|
|
26
|
+
import { InsertLinkSchema, insertLink } from './tools/insert-link.js';
|
|
27
|
+
import { ExportDocumentSchema, exportDocument } from './tools/export-document.js';
|
|
28
|
+
import { CopyDocumentSchema, copyDocument } from './tools/copy-document.js';
|
|
29
|
+
import { DeleteDocumentSchema, deleteDocument } from './tools/delete-document.js';
|
|
30
|
+
import { GetCommentsSchema, getComments } from './tools/get-comments.js';
|
|
31
|
+
import { AddCommentSchema, addComment } from './tools/add-comment.js';
|
|
32
|
+
const VERSION = '0.4.0';
|
|
24
33
|
const QUIET = process.env.GDOCS_MCP_QUIET === '1';
|
|
25
34
|
const server = new McpServer({
|
|
26
35
|
name: 'gdocs-mcp',
|
|
@@ -106,6 +115,16 @@ registerTool('delete_style_preset', 'Delete a user-defined style preset. Cannot
|
|
|
106
115
|
registerTool('update_header_footer', 'Create or update header/footer content and styling. Supports page number insertion.', UpdateHeaderFooterSchema, updateHeaderFooter);
|
|
107
116
|
registerTool('format_list', 'Apply bullet, numbered, or remove list formatting on a paragraph range. Supports 6 glyph presets.', FormatListSchema, formatList);
|
|
108
117
|
registerTool('execute_script', 'Execute a function in a deployed Google Apps Script project. Returns JSON result.', ExecuteScriptSchema, executeScript);
|
|
118
|
+
// v0.4 content tools
|
|
119
|
+
registerTool('insert_image', 'Insert an image from a URL at a specific position. Supports optional width/height sizing in PT.', InsertImageSchema, insertImage);
|
|
120
|
+
registerTool('insert_table', 'Create a table with specified rows (1-20) and columns (1-20) at a position.', InsertTableSchema, insertTable);
|
|
121
|
+
registerTool('insert_page_break', 'Insert a page break at a specific position.', InsertPageBreakSchema, insertPageBreak);
|
|
122
|
+
registerTool('insert_link', 'Add a hyperlink to an existing text range. The text must already exist in the document.', InsertLinkSchema, insertLink);
|
|
123
|
+
registerTool('export_document', 'Export a Google Doc as PDF, DOCX, TXT, or HTML. Saves to file or returns base64.', ExportDocumentSchema, exportDocument);
|
|
124
|
+
registerTool('copy_document', 'Create a copy of a Google Doc with a new title. Inherits content and formatting.', CopyDocumentSchema, copyDocument);
|
|
125
|
+
registerTool('delete_document', 'Move a Google Doc to trash. Requires confirmTitle matching the exact document title.', DeleteDocumentSchema, deleteDocument);
|
|
126
|
+
registerTool('get_comments', 'List comments on a document with author, content, resolved status, and replies.', GetCommentsSchema, getComments);
|
|
127
|
+
registerTool('add_comment', 'Add a comment anchored to specific text in the document. Anchors to first occurrence of quotedText.', AddCommentSchema, addComment);
|
|
109
128
|
async function main() {
|
|
110
129
|
const transport = new StdioServerTransport();
|
|
111
130
|
await server.connect(transport);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const AddCommentSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
content: z.ZodString;
|
|
5
|
+
quotedText: z.ZodString;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
export declare function addComment(args: z.infer<typeof AddCommentSchema>): Promise<{
|
|
8
|
+
documentId: string;
|
|
9
|
+
commentId: string | null | undefined;
|
|
10
|
+
content: string | null | undefined;
|
|
11
|
+
quotedText: string | undefined;
|
|
12
|
+
createdTime: string | null | undefined;
|
|
13
|
+
_apiMs: number;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
|
|
4
|
+
export const AddCommentSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
6
|
+
content: z.string().describe('The comment text'),
|
|
7
|
+
quotedText: z.string().describe('Text in the document to anchor the comment to. Google matches the first occurrence.'),
|
|
8
|
+
});
|
|
9
|
+
export async function addComment(args) {
|
|
10
|
+
if (isReadOnlyMode()) {
|
|
11
|
+
throw new Error('add_comment is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
|
|
12
|
+
}
|
|
13
|
+
const auth = getAuthClient();
|
|
14
|
+
const drive = google.drive({ version: 'v3', auth });
|
|
15
|
+
const apiStart = performance.now();
|
|
16
|
+
const res = await drive.comments.create({
|
|
17
|
+
fileId: args.documentId,
|
|
18
|
+
fields: 'id,author(displayName),content,quotedFileContent,createdTime',
|
|
19
|
+
requestBody: {
|
|
20
|
+
content: args.content,
|
|
21
|
+
quotedFileContent: {
|
|
22
|
+
value: args.quotedText,
|
|
23
|
+
mimeType: 'text/html',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
28
|
+
return {
|
|
29
|
+
documentId: args.documentId,
|
|
30
|
+
commentId: res.data.id,
|
|
31
|
+
content: res.data.content,
|
|
32
|
+
quotedText: res.data.quotedFileContent?.value,
|
|
33
|
+
createdTime: res.data.createdTime,
|
|
34
|
+
_apiMs: apiMs,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const CopyDocumentSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
newTitle: z.ZodString;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export declare function copyDocument(args: z.infer<typeof CopyDocumentSchema>): Promise<{
|
|
7
|
+
originalDocumentId: string;
|
|
8
|
+
newDocumentId: string | null | undefined;
|
|
9
|
+
title: string | null | undefined;
|
|
10
|
+
url: string | null | undefined;
|
|
11
|
+
_apiMs: number;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
|
|
4
|
+
export const CopyDocumentSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID to copy'),
|
|
6
|
+
newTitle: z.string().describe('Title for the copy'),
|
|
7
|
+
});
|
|
8
|
+
export async function copyDocument(args) {
|
|
9
|
+
if (isReadOnlyMode()) {
|
|
10
|
+
throw new Error('copy_document is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
|
|
11
|
+
}
|
|
12
|
+
const auth = getAuthClient();
|
|
13
|
+
const drive = google.drive({ version: 'v3', auth });
|
|
14
|
+
const apiStart = performance.now();
|
|
15
|
+
const res = await drive.files.copy({
|
|
16
|
+
fileId: args.documentId,
|
|
17
|
+
requestBody: { name: args.newTitle },
|
|
18
|
+
fields: 'id, name, webViewLink',
|
|
19
|
+
});
|
|
20
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
21
|
+
return {
|
|
22
|
+
originalDocumentId: args.documentId,
|
|
23
|
+
newDocumentId: res.data.id,
|
|
24
|
+
title: res.data.name,
|
|
25
|
+
url: res.data.webViewLink,
|
|
26
|
+
_apiMs: apiMs,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const DeleteDocumentSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
confirmTitle: z.ZodString;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export declare function deleteDocument(args: z.infer<typeof DeleteDocumentSchema>): Promise<{
|
|
7
|
+
documentId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
trashed: boolean;
|
|
10
|
+
message: string;
|
|
11
|
+
_apiMs: number;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
|
|
4
|
+
export const DeleteDocumentSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID to trash'),
|
|
6
|
+
confirmTitle: z.string().describe('Must match the exact document title to confirm deletion. This prevents accidental trashing.'),
|
|
7
|
+
});
|
|
8
|
+
export async function deleteDocument(args) {
|
|
9
|
+
if (isReadOnlyMode()) {
|
|
10
|
+
throw new Error('delete_document is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
|
|
11
|
+
}
|
|
12
|
+
const auth = getAuthClient();
|
|
13
|
+
const drive = google.drive({ version: 'v3', auth });
|
|
14
|
+
const apiStart = performance.now();
|
|
15
|
+
// Get the actual document title
|
|
16
|
+
const file = await drive.files.get({
|
|
17
|
+
fileId: args.documentId,
|
|
18
|
+
fields: 'name',
|
|
19
|
+
});
|
|
20
|
+
const actualTitle = file.data.name;
|
|
21
|
+
if (args.confirmTitle !== actualTitle) {
|
|
22
|
+
throw new Error(`Confirmation failed. To trash this document, pass confirmTitle: "${actualTitle}"`);
|
|
23
|
+
}
|
|
24
|
+
await drive.files.update({
|
|
25
|
+
fileId: args.documentId,
|
|
26
|
+
requestBody: { trashed: true },
|
|
27
|
+
});
|
|
28
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
29
|
+
return {
|
|
30
|
+
documentId: args.documentId,
|
|
31
|
+
title: actualTitle,
|
|
32
|
+
trashed: true,
|
|
33
|
+
message: 'Document moved to trash. It can be recovered from Google Drive trash.',
|
|
34
|
+
_apiMs: apiMs,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const ExportDocumentSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
format: z.ZodEnum<{
|
|
5
|
+
pdf: "pdf";
|
|
6
|
+
docx: "docx";
|
|
7
|
+
txt: "txt";
|
|
8
|
+
html: "html";
|
|
9
|
+
}>;
|
|
10
|
+
outputPath: z.ZodOptional<z.ZodString>;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export declare function exportDocument(args: z.infer<typeof ExportDocumentSchema>): Promise<{
|
|
13
|
+
documentId: string;
|
|
14
|
+
format: "pdf" | "docx" | "txt" | "html";
|
|
15
|
+
savedTo: string;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
_apiMs: number;
|
|
18
|
+
content?: undefined;
|
|
19
|
+
} | {
|
|
20
|
+
documentId: string;
|
|
21
|
+
format: "pdf" | "docx" | "txt" | "html";
|
|
22
|
+
content: string;
|
|
23
|
+
sizeBytes: number;
|
|
24
|
+
_apiMs: number;
|
|
25
|
+
savedTo?: undefined;
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
|
|
5
|
+
const MIME_TYPES = {
|
|
6
|
+
pdf: 'application/pdf',
|
|
7
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
8
|
+
txt: 'text/plain',
|
|
9
|
+
html: 'text/html',
|
|
10
|
+
};
|
|
11
|
+
export const ExportDocumentSchema = z.object({
|
|
12
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
13
|
+
format: z.enum(['pdf', 'docx', 'txt', 'html']).describe('Export format: pdf, docx, txt, or html'),
|
|
14
|
+
outputPath: z.string().optional().describe('Local file path to save the export. If omitted, returns base64-encoded content.'),
|
|
15
|
+
});
|
|
16
|
+
export async function exportDocument(args) {
|
|
17
|
+
if (isReadOnlyMode()) {
|
|
18
|
+
throw new Error('export_document is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
|
|
19
|
+
}
|
|
20
|
+
const auth = getAuthClient();
|
|
21
|
+
const drive = google.drive({ version: 'v3', auth });
|
|
22
|
+
const apiStart = performance.now();
|
|
23
|
+
const res = await drive.files.export({ fileId: args.documentId, mimeType: MIME_TYPES[args.format] }, { responseType: 'arraybuffer' });
|
|
24
|
+
const buffer = Buffer.from(res.data);
|
|
25
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
26
|
+
if (args.outputPath) {
|
|
27
|
+
fs.writeFileSync(args.outputPath, buffer);
|
|
28
|
+
return {
|
|
29
|
+
documentId: args.documentId,
|
|
30
|
+
format: args.format,
|
|
31
|
+
savedTo: args.outputPath,
|
|
32
|
+
sizeBytes: buffer.length,
|
|
33
|
+
_apiMs: apiMs,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
documentId: args.documentId,
|
|
38
|
+
format: args.format,
|
|
39
|
+
content: buffer.toString('base64'),
|
|
40
|
+
sizeBytes: buffer.length,
|
|
41
|
+
_apiMs: apiMs,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -10,11 +10,23 @@ export const ExtractDocumentStylesSchema = z.object({
|
|
|
10
10
|
export async function extractDocumentStyles(args) {
|
|
11
11
|
const auth = getAuthClient();
|
|
12
12
|
const docs = google.docs({ version: 'v1', auth });
|
|
13
|
+
const apiStart = performance.now();
|
|
13
14
|
const doc = await docs.documents.get({ documentId: args.documentId });
|
|
15
|
+
const bodyContent = doc.data.body?.content || [];
|
|
16
|
+
// Step 1: Extract named style definitions (baseline)
|
|
17
|
+
const namedStyleDefs = extractNamedStyleDefinitions(doc.data.namedStyles);
|
|
18
|
+
// Step 2: Extract actual rendered styles from paragraphs (majority vote)
|
|
19
|
+
const renderedStyles = extractRenderedStyles(bodyContent);
|
|
20
|
+
// Step 3: Merge — rendered values take priority, collect warnings
|
|
21
|
+
const warnings = [];
|
|
22
|
+
const mergedStyles = mergeStyles(namedStyleDefs, renderedStyles, warnings);
|
|
23
|
+
// Step 4: Propagate inherited properties from NORMAL_TEXT to headings
|
|
24
|
+
propagateInheritedProperties(mergedStyles);
|
|
25
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
14
26
|
const preset = {
|
|
15
27
|
document: extractDocumentStyle(doc.data.documentStyle),
|
|
16
|
-
styles:
|
|
17
|
-
table: extractTableStyles(
|
|
28
|
+
styles: mergedStyles,
|
|
29
|
+
table: extractTableStyles(bodyContent),
|
|
18
30
|
};
|
|
19
31
|
if (args.saveAs) {
|
|
20
32
|
savePreset(args.saveAs, preset);
|
|
@@ -23,7 +35,9 @@ export async function extractDocumentStyles(args) {
|
|
|
23
35
|
documentId: args.documentId,
|
|
24
36
|
title: doc.data.title,
|
|
25
37
|
preset,
|
|
38
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
26
39
|
savedAs: args.saveAs || null,
|
|
40
|
+
_apiMs: apiMs,
|
|
27
41
|
};
|
|
28
42
|
}
|
|
29
43
|
function extractDocumentStyle(docStyle) {
|
|
@@ -42,7 +56,51 @@ function extractDocumentStyle(docStyle) {
|
|
|
42
56
|
config.pageHeight = docStyle.pageSize.height.magnitude;
|
|
43
57
|
return config;
|
|
44
58
|
}
|
|
45
|
-
function
|
|
59
|
+
function extractStyleFromRaw(ts, ps) {
|
|
60
|
+
const config = {};
|
|
61
|
+
if (ts.weightedFontFamily?.fontFamily)
|
|
62
|
+
config.fontFamily = ts.weightedFontFamily.fontFamily;
|
|
63
|
+
if (ts.fontSize?.magnitude !== undefined)
|
|
64
|
+
config.fontSize = ts.fontSize.magnitude;
|
|
65
|
+
if (ts.bold !== undefined)
|
|
66
|
+
config.bold = ts.bold;
|
|
67
|
+
if (ts.italic !== undefined)
|
|
68
|
+
config.italic = ts.italic;
|
|
69
|
+
if (ts.underline !== undefined)
|
|
70
|
+
config.underline = ts.underline;
|
|
71
|
+
if (ts.strikethrough !== undefined)
|
|
72
|
+
config.strikethrough = ts.strikethrough;
|
|
73
|
+
if (ts.smallCaps !== undefined)
|
|
74
|
+
config.smallCaps = ts.smallCaps;
|
|
75
|
+
if (ts.foregroundColor?.color?.rgbColor)
|
|
76
|
+
config.color = rgbToHex(ts.foregroundColor.color.rgbColor);
|
|
77
|
+
if (ts.backgroundColor?.color?.rgbColor)
|
|
78
|
+
config.backgroundColor = rgbToHex(ts.backgroundColor.color.rgbColor);
|
|
79
|
+
if (ts.baselineOffset)
|
|
80
|
+
config.baselineOffset = ts.baselineOffset;
|
|
81
|
+
if (ps.alignment)
|
|
82
|
+
config.alignment = ps.alignment;
|
|
83
|
+
if (ps.lineSpacing !== undefined)
|
|
84
|
+
config.lineSpacing = ps.lineSpacing;
|
|
85
|
+
if (ps.spaceAbove?.magnitude !== undefined)
|
|
86
|
+
config.spaceAbove = ps.spaceAbove.magnitude;
|
|
87
|
+
if (ps.spaceBelow?.magnitude !== undefined)
|
|
88
|
+
config.spaceBelow = ps.spaceBelow.magnitude;
|
|
89
|
+
if (ps.indentFirstLine?.magnitude !== undefined)
|
|
90
|
+
config.indentFirstLine = ps.indentFirstLine.magnitude;
|
|
91
|
+
if (ps.indentStart?.magnitude !== undefined)
|
|
92
|
+
config.indentStart = ps.indentStart.magnitude;
|
|
93
|
+
if (ps.indentEnd?.magnitude !== undefined)
|
|
94
|
+
config.indentEnd = ps.indentEnd.magnitude;
|
|
95
|
+
if (ps.keepLinesTogether !== undefined)
|
|
96
|
+
config.keepLinesTogether = ps.keepLinesTogether;
|
|
97
|
+
if (ps.keepWithNext !== undefined)
|
|
98
|
+
config.keepWithNext = ps.keepWithNext;
|
|
99
|
+
if (ps.direction)
|
|
100
|
+
config.direction = ps.direction;
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
function extractNamedStyleDefinitions(namedStyles) {
|
|
46
104
|
const result = {};
|
|
47
105
|
if (!namedStyles?.styles)
|
|
48
106
|
return result;
|
|
@@ -50,59 +108,122 @@ function extractNamedStyles(namedStyles) {
|
|
|
50
108
|
const type = style.namedStyleType;
|
|
51
109
|
if (!VALID_NAMED_STYLE_TYPES.includes(type))
|
|
52
110
|
continue;
|
|
53
|
-
const config = {};
|
|
54
|
-
const ts = style.textStyle || {};
|
|
55
|
-
const ps = style.paragraphStyle || {};
|
|
56
|
-
// Text style
|
|
57
|
-
if (ts.weightedFontFamily?.fontFamily)
|
|
58
|
-
config.fontFamily = ts.weightedFontFamily.fontFamily;
|
|
59
|
-
if (ts.fontSize?.magnitude !== undefined)
|
|
60
|
-
config.fontSize = ts.fontSize.magnitude;
|
|
61
|
-
if (ts.bold !== undefined)
|
|
62
|
-
config.bold = ts.bold;
|
|
63
|
-
if (ts.italic !== undefined)
|
|
64
|
-
config.italic = ts.italic;
|
|
65
|
-
if (ts.underline !== undefined)
|
|
66
|
-
config.underline = ts.underline;
|
|
67
|
-
if (ts.strikethrough !== undefined)
|
|
68
|
-
config.strikethrough = ts.strikethrough;
|
|
69
|
-
if (ts.smallCaps !== undefined)
|
|
70
|
-
config.smallCaps = ts.smallCaps;
|
|
71
|
-
if (ts.foregroundColor?.color?.rgbColor)
|
|
72
|
-
config.color = rgbToHex(ts.foregroundColor.color.rgbColor);
|
|
73
|
-
if (ts.backgroundColor?.color?.rgbColor)
|
|
74
|
-
config.backgroundColor = rgbToHex(ts.backgroundColor.color.rgbColor);
|
|
75
|
-
if (ts.baselineOffset)
|
|
76
|
-
config.baselineOffset = ts.baselineOffset;
|
|
77
|
-
// Paragraph style
|
|
78
|
-
if (ps.alignment)
|
|
79
|
-
config.alignment = ps.alignment;
|
|
80
|
-
if (ps.lineSpacing !== undefined)
|
|
81
|
-
config.lineSpacing = ps.lineSpacing;
|
|
82
|
-
if (ps.spaceAbove?.magnitude !== undefined)
|
|
83
|
-
config.spaceAbove = ps.spaceAbove.magnitude;
|
|
84
|
-
if (ps.spaceBelow?.magnitude !== undefined)
|
|
85
|
-
config.spaceBelow = ps.spaceBelow.magnitude;
|
|
86
|
-
if (ps.indentFirstLine?.magnitude !== undefined)
|
|
87
|
-
config.indentFirstLine = ps.indentFirstLine.magnitude;
|
|
88
|
-
if (ps.indentStart?.magnitude !== undefined)
|
|
89
|
-
config.indentStart = ps.indentStart.magnitude;
|
|
90
|
-
if (ps.indentEnd?.magnitude !== undefined)
|
|
91
|
-
config.indentEnd = ps.indentEnd.magnitude;
|
|
92
|
-
if (ps.keepLinesTogether !== undefined)
|
|
93
|
-
config.keepLinesTogether = ps.keepLinesTogether;
|
|
94
|
-
if (ps.keepWithNext !== undefined)
|
|
95
|
-
config.keepWithNext = ps.keepWithNext;
|
|
96
|
-
if (ps.direction)
|
|
97
|
-
config.direction = ps.direction;
|
|
111
|
+
const config = extractStyleFromRaw(style.textStyle || {}, style.paragraphStyle || {});
|
|
98
112
|
if (Object.keys(config).length > 0) {
|
|
99
113
|
result[type] = config;
|
|
100
114
|
}
|
|
101
115
|
}
|
|
102
116
|
return result;
|
|
103
117
|
}
|
|
118
|
+
// Majority vote: returns the most common value for a property across samples
|
|
119
|
+
function majorityVote(values) {
|
|
120
|
+
const counts = new Map();
|
|
121
|
+
for (const v of values) {
|
|
122
|
+
if (v === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
const key = JSON.stringify(v);
|
|
125
|
+
const existing = counts.get(key);
|
|
126
|
+
if (existing) {
|
|
127
|
+
existing.count++;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
counts.set(key, { value: v, count: 1 });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
let winner = undefined;
|
|
134
|
+
let maxCount = 0;
|
|
135
|
+
for (const { value, count } of counts.values()) {
|
|
136
|
+
if (count > maxCount) {
|
|
137
|
+
maxCount = count;
|
|
138
|
+
winner = value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return winner;
|
|
142
|
+
}
|
|
143
|
+
function extractRenderedStyles(content) {
|
|
144
|
+
const result = {};
|
|
145
|
+
// Collect up to 10 paragraphs per style type
|
|
146
|
+
const samplesByType = {};
|
|
147
|
+
for (const element of content) {
|
|
148
|
+
if (!element.paragraph)
|
|
149
|
+
continue;
|
|
150
|
+
const styleType = element.paragraph.paragraphStyle?.namedStyleType;
|
|
151
|
+
if (!styleType || !VALID_NAMED_STYLE_TYPES.includes(styleType))
|
|
152
|
+
continue;
|
|
153
|
+
const text = element.paragraph.elements
|
|
154
|
+
?.map((el) => el.textRun?.content || '').join('').trim();
|
|
155
|
+
if (!text)
|
|
156
|
+
continue;
|
|
157
|
+
if (!samplesByType[styleType])
|
|
158
|
+
samplesByType[styleType] = [];
|
|
159
|
+
if (samplesByType[styleType].length >= 10)
|
|
160
|
+
continue;
|
|
161
|
+
// Read paragraph-level text style from first text run
|
|
162
|
+
const firstRunTextStyle = element.paragraph.elements?.[0]?.textRun?.textStyle || {};
|
|
163
|
+
const paragraphStyle = element.paragraph.paragraphStyle || {};
|
|
164
|
+
const config = extractStyleFromRaw(firstRunTextStyle, paragraphStyle);
|
|
165
|
+
samplesByType[styleType].push(config);
|
|
166
|
+
}
|
|
167
|
+
// Majority vote per property across sampled paragraphs
|
|
168
|
+
const allProperties = [
|
|
169
|
+
'fontFamily', 'fontSize', 'bold', 'italic', 'underline', 'strikethrough',
|
|
170
|
+
'smallCaps', 'color', 'backgroundColor', 'baselineOffset',
|
|
171
|
+
'alignment', 'lineSpacing', 'spaceAbove', 'spaceBelow',
|
|
172
|
+
'indentFirstLine', 'indentStart', 'indentEnd',
|
|
173
|
+
'keepLinesTogether', 'keepWithNext', 'direction',
|
|
174
|
+
];
|
|
175
|
+
for (const [styleType, samples] of Object.entries(samplesByType)) {
|
|
176
|
+
const merged = {};
|
|
177
|
+
for (const prop of allProperties) {
|
|
178
|
+
const values = samples.map(s => s[prop]);
|
|
179
|
+
const winner = majorityVote(values);
|
|
180
|
+
if (winner !== undefined) {
|
|
181
|
+
;
|
|
182
|
+
merged[prop] = winner;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (Object.keys(merged).length > 0) {
|
|
186
|
+
result[styleType] = merged;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
function mergeStyles(definitions, rendered, warnings) {
|
|
192
|
+
const merged = {};
|
|
193
|
+
const allTypes = new Set([...Object.keys(definitions), ...Object.keys(rendered)]);
|
|
194
|
+
for (const type of allTypes) {
|
|
195
|
+
const def = definitions[type] || {};
|
|
196
|
+
const ren = rendered[type] || {};
|
|
197
|
+
// Check for differences and log warnings
|
|
198
|
+
for (const [key, renValue] of Object.entries(ren)) {
|
|
199
|
+
const defValue = def[key];
|
|
200
|
+
if (defValue !== undefined && renValue !== undefined && JSON.stringify(defValue) !== JSON.stringify(renValue)) {
|
|
201
|
+
warnings.push(`${type}.${key}: definition says ${JSON.stringify(defValue)}, rendered shows ${JSON.stringify(renValue)} (using rendered)`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Rendered values override definitions
|
|
205
|
+
merged[type] = { ...def, ...ren };
|
|
206
|
+
}
|
|
207
|
+
return merged;
|
|
208
|
+
}
|
|
209
|
+
// Propagate fontFamily, color, bold, italic from NORMAL_TEXT to headings that don't specify them
|
|
210
|
+
function propagateInheritedProperties(styles) {
|
|
211
|
+
const normalText = styles['NORMAL_TEXT'];
|
|
212
|
+
if (!normalText)
|
|
213
|
+
return;
|
|
214
|
+
const propertiesToPropagate = ['fontFamily', 'color', 'bold', 'italic'];
|
|
215
|
+
for (const [type, config] of Object.entries(styles)) {
|
|
216
|
+
if (type === 'NORMAL_TEXT')
|
|
217
|
+
continue;
|
|
218
|
+
for (const prop of propertiesToPropagate) {
|
|
219
|
+
if (config[prop] === undefined && normalText[prop] !== undefined) {
|
|
220
|
+
;
|
|
221
|
+
config[prop] = normalText[prop];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
104
226
|
function extractTableStyles(content) {
|
|
105
|
-
// Find first table and extract its styling as the template
|
|
106
227
|
for (const element of content) {
|
|
107
228
|
if (!element.table)
|
|
108
229
|
continue;
|
|
@@ -127,7 +248,17 @@ function extractTableStyles(content) {
|
|
|
127
248
|
config.cellPadding = cs.paddingTop.magnitude;
|
|
128
249
|
}
|
|
129
250
|
}
|
|
130
|
-
// Check
|
|
251
|
+
// Check header text style
|
|
252
|
+
const headerTextRun = firstRow.tableCells?.[0]?.content?.[0]?.paragraph?.elements?.[0]?.textRun;
|
|
253
|
+
if (headerTextRun?.textStyle) {
|
|
254
|
+
const hts = headerTextRun.textStyle;
|
|
255
|
+
if (hts.bold !== undefined)
|
|
256
|
+
config.headerBold = hts.bold;
|
|
257
|
+
if (hts.foregroundColor?.color?.rgbColor) {
|
|
258
|
+
config.headerTextColor = rgbToHex(hts.foregroundColor.color.rgbColor);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Check third row for alternating background
|
|
131
262
|
if (table.tableRows?.length > 2) {
|
|
132
263
|
const thirdRow = table.tableRows[2];
|
|
133
264
|
const thirdCell = thirdRow?.tableCells?.[0];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const GetCommentsSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
includeResolved: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export declare function getComments(args: z.infer<typeof GetCommentsSchema>): Promise<{
|
|
7
|
+
documentId: string;
|
|
8
|
+
comments: {
|
|
9
|
+
id: string | null | undefined;
|
|
10
|
+
author: string;
|
|
11
|
+
authorEmail: string | null | undefined;
|
|
12
|
+
content: string | null | undefined;
|
|
13
|
+
quotedText: string | null;
|
|
14
|
+
resolved: boolean;
|
|
15
|
+
createdTime: string | null | undefined;
|
|
16
|
+
modifiedTime: string | null | undefined;
|
|
17
|
+
replies: {
|
|
18
|
+
author: string;
|
|
19
|
+
content: string | null | undefined;
|
|
20
|
+
createdTime: string | null | undefined;
|
|
21
|
+
}[];
|
|
22
|
+
}[];
|
|
23
|
+
count: number;
|
|
24
|
+
_apiMs: number;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
|
|
4
|
+
export const GetCommentsSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
6
|
+
includeResolved: z.boolean().optional().default(false).describe('Include resolved comments (default: false, only open comments)'),
|
|
7
|
+
});
|
|
8
|
+
export async function getComments(args) {
|
|
9
|
+
if (isReadOnlyMode()) {
|
|
10
|
+
throw new Error('get_comments is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
|
|
11
|
+
}
|
|
12
|
+
const auth = getAuthClient();
|
|
13
|
+
const drive = google.drive({ version: 'v3', auth });
|
|
14
|
+
const apiStart = performance.now();
|
|
15
|
+
const res = await drive.comments.list({
|
|
16
|
+
fileId: args.documentId,
|
|
17
|
+
fields: 'comments(id,author(displayName,emailAddress),content,quotedFileContent,resolved,createdTime,modifiedTime,replies(author(displayName),content,createdTime))',
|
|
18
|
+
pageSize: 100,
|
|
19
|
+
});
|
|
20
|
+
const comments = (res.data.comments || [])
|
|
21
|
+
.filter(c => args.includeResolved || !c.resolved)
|
|
22
|
+
.map(c => ({
|
|
23
|
+
id: c.id,
|
|
24
|
+
author: c.author?.displayName || 'Unknown',
|
|
25
|
+
authorEmail: c.author?.emailAddress,
|
|
26
|
+
content: c.content,
|
|
27
|
+
quotedText: c.quotedFileContent?.value || null,
|
|
28
|
+
resolved: c.resolved || false,
|
|
29
|
+
createdTime: c.createdTime,
|
|
30
|
+
modifiedTime: c.modifiedTime,
|
|
31
|
+
replies: (c.replies || []).map(r => ({
|
|
32
|
+
author: r.author?.displayName || 'Unknown',
|
|
33
|
+
content: r.content,
|
|
34
|
+
createdTime: r.createdTime,
|
|
35
|
+
})),
|
|
36
|
+
}));
|
|
37
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
38
|
+
return {
|
|
39
|
+
documentId: args.documentId,
|
|
40
|
+
comments,
|
|
41
|
+
count: comments.length,
|
|
42
|
+
_apiMs: apiMs,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const InsertImageSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
uri: z.ZodString;
|
|
5
|
+
index: z.ZodNumber;
|
|
6
|
+
width: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
height: z.ZodOptional<z.ZodNumber>;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
export declare function insertImage(args: z.infer<typeof InsertImageSchema>): Promise<{
|
|
10
|
+
documentId: string;
|
|
11
|
+
inlineObjectId: string | null | undefined;
|
|
12
|
+
index: number;
|
|
13
|
+
_apiMs: number;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
|
+
export const InsertImageSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
6
|
+
uri: z.string().url().describe('Public URL of the image to insert'),
|
|
7
|
+
index: z.number().describe('Position in the document to insert the image'),
|
|
8
|
+
width: z.number().optional().describe('Image width in PT. If only width is set, height scales proportionally.'),
|
|
9
|
+
height: z.number().optional().describe('Image height in PT. If only height is set, width scales proportionally.'),
|
|
10
|
+
});
|
|
11
|
+
export async function insertImage(args) {
|
|
12
|
+
const auth = getAuthClient();
|
|
13
|
+
const docs = google.docs({ version: 'v1', auth });
|
|
14
|
+
const apiStart = performance.now();
|
|
15
|
+
const objectSize = {};
|
|
16
|
+
if (args.width !== undefined) {
|
|
17
|
+
objectSize.width = { magnitude: args.width, unit: 'PT' };
|
|
18
|
+
}
|
|
19
|
+
if (args.height !== undefined) {
|
|
20
|
+
objectSize.height = { magnitude: args.height, unit: 'PT' };
|
|
21
|
+
}
|
|
22
|
+
const request = {
|
|
23
|
+
insertInlineImage: {
|
|
24
|
+
location: { index: args.index },
|
|
25
|
+
uri: args.uri,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
if (Object.keys(objectSize).length > 0) {
|
|
29
|
+
request.insertInlineImage.objectSize = objectSize;
|
|
30
|
+
}
|
|
31
|
+
const res = await docs.documents.batchUpdate({
|
|
32
|
+
documentId: args.documentId,
|
|
33
|
+
requestBody: { requests: [request] },
|
|
34
|
+
});
|
|
35
|
+
const inlineObjectId = res.data.replies?.[0]?.insertInlineImage?.objectId;
|
|
36
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
37
|
+
return {
|
|
38
|
+
documentId: args.documentId,
|
|
39
|
+
inlineObjectId,
|
|
40
|
+
index: args.index,
|
|
41
|
+
_apiMs: apiMs,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const InsertLinkSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
url: z.ZodString;
|
|
5
|
+
startIndex: z.ZodNumber;
|
|
6
|
+
endIndex: z.ZodNumber;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export declare function insertLink(args: z.infer<typeof InsertLinkSchema>): Promise<{
|
|
9
|
+
documentId: string;
|
|
10
|
+
url: string;
|
|
11
|
+
startIndex: number;
|
|
12
|
+
endIndex: number;
|
|
13
|
+
_apiMs: number;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
|
+
export const InsertLinkSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
6
|
+
url: z.string().url().describe('The URL to link to'),
|
|
7
|
+
startIndex: z.number().describe('Start index of the text range to make into a link'),
|
|
8
|
+
endIndex: z.number().describe('End index of the text range to make into a link'),
|
|
9
|
+
});
|
|
10
|
+
export async function insertLink(args) {
|
|
11
|
+
const auth = getAuthClient();
|
|
12
|
+
const docs = google.docs({ version: 'v1', auth });
|
|
13
|
+
const apiStart = performance.now();
|
|
14
|
+
await docs.documents.batchUpdate({
|
|
15
|
+
documentId: args.documentId,
|
|
16
|
+
requestBody: {
|
|
17
|
+
requests: [
|
|
18
|
+
{
|
|
19
|
+
updateTextStyle: {
|
|
20
|
+
range: { startIndex: args.startIndex, endIndex: args.endIndex },
|
|
21
|
+
textStyle: {
|
|
22
|
+
link: { url: args.url },
|
|
23
|
+
},
|
|
24
|
+
fields: 'link',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
31
|
+
return {
|
|
32
|
+
documentId: args.documentId,
|
|
33
|
+
url: args.url,
|
|
34
|
+
startIndex: args.startIndex,
|
|
35
|
+
endIndex: args.endIndex,
|
|
36
|
+
_apiMs: apiMs,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const InsertPageBreakSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
index: z.ZodNumber;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export declare function insertPageBreak(args: z.infer<typeof InsertPageBreakSchema>): Promise<{
|
|
7
|
+
documentId: string;
|
|
8
|
+
index: number;
|
|
9
|
+
_apiMs: number;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
|
+
export const InsertPageBreakSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
6
|
+
index: z.number().describe('Position in the document to insert the page break'),
|
|
7
|
+
});
|
|
8
|
+
export async function insertPageBreak(args) {
|
|
9
|
+
const auth = getAuthClient();
|
|
10
|
+
const docs = google.docs({ version: 'v1', auth });
|
|
11
|
+
const apiStart = performance.now();
|
|
12
|
+
await docs.documents.batchUpdate({
|
|
13
|
+
documentId: args.documentId,
|
|
14
|
+
requestBody: {
|
|
15
|
+
requests: [
|
|
16
|
+
{
|
|
17
|
+
insertPageBreak: {
|
|
18
|
+
location: { index: args.index },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
25
|
+
return {
|
|
26
|
+
documentId: args.documentId,
|
|
27
|
+
index: args.index,
|
|
28
|
+
_apiMs: apiMs,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const InsertTableSchema: z.ZodObject<{
|
|
3
|
+
documentId: z.ZodString;
|
|
4
|
+
rows: z.ZodNumber;
|
|
5
|
+
columns: z.ZodNumber;
|
|
6
|
+
index: z.ZodNumber;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export declare function insertTable(args: z.infer<typeof InsertTableSchema>): Promise<{
|
|
9
|
+
documentId: string;
|
|
10
|
+
rows: number;
|
|
11
|
+
columns: number;
|
|
12
|
+
index: number;
|
|
13
|
+
_apiMs: number;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getAuthClient } from '../auth/google-auth.js';
|
|
4
|
+
export const InsertTableSchema = z.object({
|
|
5
|
+
documentId: z.string().describe('The Google Doc document ID'),
|
|
6
|
+
rows: z.number().min(1).max(20).describe('Number of rows (1-20)'),
|
|
7
|
+
columns: z.number().min(1).max(20).describe('Number of columns (1-20)'),
|
|
8
|
+
index: z.number().describe('Position in the document to insert the table'),
|
|
9
|
+
});
|
|
10
|
+
export async function insertTable(args) {
|
|
11
|
+
const auth = getAuthClient();
|
|
12
|
+
const docs = google.docs({ version: 'v1', auth });
|
|
13
|
+
const apiStart = performance.now();
|
|
14
|
+
await docs.documents.batchUpdate({
|
|
15
|
+
documentId: args.documentId,
|
|
16
|
+
requestBody: {
|
|
17
|
+
requests: [
|
|
18
|
+
{
|
|
19
|
+
insertTable: {
|
|
20
|
+
location: { index: args.index },
|
|
21
|
+
rows: args.rows,
|
|
22
|
+
columns: args.columns,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const apiMs = Math.round(performance.now() - apiStart);
|
|
29
|
+
return {
|
|
30
|
+
documentId: args.documentId,
|
|
31
|
+
rows: args.rows,
|
|
32
|
+
columns: args.columns,
|
|
33
|
+
index: args.index,
|
|
34
|
+
_apiMs: apiMs,
|
|
35
|
+
};
|
|
36
|
+
}
|
package/package.json
CHANGED