gdocs-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Apurwa Sarwajit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # gdocs-mcp
2
+
3
+ Open-source MCP server for Google Docs and Sheets. Give Claude (or any MCP-compatible AI) the ability to read, create, edit, and search your Google Docs — with OAuth tokens that never leave your machine.
4
+
5
+ **Like Composio, but open-source and self-hosted.**
6
+
7
+ ## Quick Start
8
+
9
+ ### 1. Set up Google Cloud credentials (one-time)
10
+
11
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com)
12
+ 2. Create a project (or use an existing one)
13
+ 3. Enable the **Google Docs API**, **Google Sheets API**, and **Google Drive API**
14
+ 4. Go to **Google Auth Platform** > **Audience**, set to External, add yourself as a test user
15
+ 5. Go to **Credentials** > **Create Credentials** > **OAuth client ID** > **Desktop app**
16
+ 6. Download the JSON file
17
+
18
+ ### 2. Authenticate
19
+
20
+ ```bash
21
+ npx gdocs-mcp auth ~/Downloads/client_secret_*.json
22
+ ```
23
+
24
+ This opens your browser for Google sign-in, saves the token to `~/.gdocs-mcp/`, and prints the MCP config.
25
+
26
+ ### 3. Add to Claude Desktop
27
+
28
+ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "gdocs": {
34
+ "command": "npx",
35
+ "args": ["gdocs-mcp"]
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Restart Claude Desktop. Ask Claude: *"List my recent Google Docs"* to verify.
42
+
43
+ ### Add to Claude Code
44
+
45
+ ```bash
46
+ claude mcp add gdocs -- npx gdocs-mcp
47
+ ```
48
+
49
+ ## Tools
50
+
51
+ ### Google Docs
52
+
53
+ | Tool | Description |
54
+ |------|-------------|
55
+ | `read_document` | Read the full content of a Google Doc |
56
+ | `search_documents` | Search Google Drive for documents by name, content, or date |
57
+ | `create_document` | Create a new Google Doc with optional Markdown content |
58
+ | `replace_all_text` | Find and replace all occurrences of text in a document |
59
+ | `replace_image` | Replace an existing image with a new image from a URI |
60
+ | `update_document_markdown` | Replace the entire document body with Markdown |
61
+ | `update_document_section_markdown` | Insert or replace a section by index with Markdown |
62
+ | `update_document_style` | Update page size, margins, text direction |
63
+ | `update_document` | Raw batchUpdate with 35+ request types |
64
+ | `unmerge_table_cells` | Unmerge previously merged table cells |
65
+
66
+ ### Google Sheets
67
+
68
+ | Tool | Description |
69
+ |------|-------------|
70
+ | `get_charts` | List all charts in a spreadsheet with IDs and specs |
71
+
72
+ ## Security
73
+
74
+ - OAuth tokens are stored locally at `~/.gdocs-mcp/token.json` with `600` permissions
75
+ - Credentials are stored at `~/.gdocs-mcp/credentials.json` with `600` permissions
76
+ - No telemetry, no data collection, no third-party token storage
77
+ - Tokens refresh automatically; if refresh fails, run `npx gdocs-mcp auth` again
78
+
79
+ ## Configuration
80
+
81
+ By default, credentials and tokens are stored in `~/.gdocs-mcp/`. Override with:
82
+
83
+ ```bash
84
+ GDOCS_CREDENTIALS=/path/to/credentials.json npx gdocs-mcp
85
+ ```
86
+
87
+ ## Requirements
88
+
89
+ - Node.js 18+
90
+ - A Google Cloud project with Docs, Sheets, and Drive APIs enabled
91
+
92
+ ## License
93
+
94
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ const args = process.argv.slice(2);
4
+
5
+ if (args[0] === 'auth') {
6
+ const authArgs = args.slice(1);
7
+ process.argv = [process.argv[0], process.argv[1], ...authArgs];
8
+ await import('../dist/auth/cli.js');
9
+ } else {
10
+ await import('../dist/index.js');
11
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time OAuth flow. Run: npx gdocs-mcp auth
4
+ * Copies credentials to ~/.gdocs-mcp/ and saves OAuth token.
5
+ */
6
+ export {};
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time OAuth flow. Run: npx gdocs-mcp auth
4
+ * Copies credentials to ~/.gdocs-mcp/ and saves OAuth token.
5
+ */
6
+ import { google } from 'googleapis';
7
+ import fs from 'fs';
8
+ import http from 'http';
9
+ import path from 'path';
10
+ import { CONFIG_DIR, TOKEN_PATH, CREDENTIALS_PATH_DEFAULT, getScopes } from './google-auth.js';
11
+ async function main() {
12
+ // Ensure config directory exists
13
+ if (!fs.existsSync(CONFIG_DIR)) {
14
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
15
+ }
16
+ // Find credentials.json
17
+ const args = process.argv.slice(2);
18
+ let inputCredPath = args[0];
19
+ if (!inputCredPath) {
20
+ // Check default location first
21
+ if (fs.existsSync(CREDENTIALS_PATH_DEFAULT)) {
22
+ console.log(`Using existing credentials at ${CREDENTIALS_PATH_DEFAULT}`);
23
+ inputCredPath = CREDENTIALS_PATH_DEFAULT;
24
+ }
25
+ else {
26
+ console.error('Usage: npx gdocs-mcp auth [path/to/credentials.json]');
27
+ console.error('');
28
+ console.error('Download credentials.json from Google Cloud Console:');
29
+ console.error(' 1. Go to console.cloud.google.com/apis/credentials');
30
+ console.error(' 2. Create OAuth 2.0 Client ID (Desktop app)');
31
+ console.error(' 3. Download JSON');
32
+ process.exit(1);
33
+ }
34
+ }
35
+ // Resolve and validate credentials
36
+ const resolvedPath = path.resolve(inputCredPath.replace('~', process.env.HOME || ''));
37
+ if (!fs.existsSync(resolvedPath)) {
38
+ console.error(`File not found: ${resolvedPath}`);
39
+ process.exit(1);
40
+ }
41
+ const raw = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
42
+ const creds = raw.installed || raw.web;
43
+ if (!creds?.client_id || !creds?.client_secret) {
44
+ console.error('Invalid credentials file. Expected OAuth 2.0 client credentials with client_id and client_secret.');
45
+ console.error('Make sure you downloaded the OAuth client JSON, not an API key or service account.');
46
+ process.exit(1);
47
+ }
48
+ // Copy credentials to config dir if not already there
49
+ if (resolvedPath !== CREDENTIALS_PATH_DEFAULT) {
50
+ fs.copyFileSync(resolvedPath, CREDENTIALS_PATH_DEFAULT);
51
+ fs.chmodSync(CREDENTIALS_PATH_DEFAULT, 0o600);
52
+ console.log(`Credentials copied to ${CREDENTIALS_PATH_DEFAULT}`);
53
+ }
54
+ // Set up OAuth client
55
+ const oAuth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, 'http://localhost:3333');
56
+ const authUrl = oAuth2Client.generateAuthUrl({
57
+ access_type: 'offline',
58
+ scope: getScopes(),
59
+ prompt: 'consent',
60
+ });
61
+ console.log('\nOpening browser for Google authorization...');
62
+ // Start local server to catch the redirect
63
+ const server = http.createServer(async (req, res) => {
64
+ const reqUrl = new URL(req.url || '', 'http://localhost:3333');
65
+ const code = reqUrl.searchParams.get('code') || undefined;
66
+ if (!code) {
67
+ res.writeHead(400, { 'Content-Type': 'text/html' });
68
+ res.end('<h2>No authorization code received.</h2>');
69
+ return;
70
+ }
71
+ res.writeHead(200, { 'Content-Type': 'text/html' });
72
+ res.end(`
73
+ <html>
74
+ <body style="font-family: system-ui; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
75
+ <div style="text-align: center;">
76
+ <h2>Auth complete!</h2>
77
+ <p>You can close this tab and return to your terminal.</p>
78
+ </div>
79
+ </body>
80
+ </html>
81
+ `);
82
+ server.close();
83
+ try {
84
+ const { tokens } = await oAuth2Client.getToken(code);
85
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2));
86
+ fs.chmodSync(TOKEN_PATH, 0o600);
87
+ console.log(`\nToken saved to ${TOKEN_PATH}`);
88
+ console.log('\nSetup complete! Add this to your MCP config:\n');
89
+ console.log(JSON.stringify({
90
+ mcpServers: {
91
+ gdocs: {
92
+ command: 'npx',
93
+ args: ['gdocs-mcp'],
94
+ }
95
+ }
96
+ }, null, 2));
97
+ console.log('');
98
+ process.exit(0);
99
+ }
100
+ catch (err) {
101
+ console.error('Failed to exchange authorization code:', err);
102
+ process.exit(1);
103
+ }
104
+ });
105
+ server.listen(3333, async () => {
106
+ // Open browser
107
+ const { exec } = await import('child_process');
108
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
109
+ exec(`${cmd} "${authUrl}"`, (err) => {
110
+ if (err) {
111
+ console.log('\nCould not open browser automatically. Open this URL:\n');
112
+ console.log(authUrl);
113
+ console.log('');
114
+ }
115
+ });
116
+ });
117
+ }
118
+ main().catch((err) => {
119
+ console.error('Auth failed:', err.message);
120
+ process.exit(1);
121
+ });
@@ -0,0 +1,9 @@
1
+ import { OAuth2Client } from 'google-auth-library';
2
+ declare const CONFIG_DIR: string;
3
+ declare const TOKEN_PATH: string;
4
+ declare const CREDENTIALS_PATH_DEFAULT: string;
5
+ export declare function getAuthClient(): OAuth2Client;
6
+ export declare function getConfigDir(): string;
7
+ export declare function getTokenPath(): string;
8
+ export declare function getScopes(): string[];
9
+ export { CONFIG_DIR, TOKEN_PATH, CREDENTIALS_PATH_DEFAULT };
@@ -0,0 +1,51 @@
1
+ import { google } from 'googleapis';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ const CONFIG_DIR = path.join(process.env.HOME || '~', '.gdocs-mcp');
5
+ const TOKEN_PATH = path.join(CONFIG_DIR, 'token.json');
6
+ const CREDENTIALS_PATH_DEFAULT = path.join(CONFIG_DIR, 'credentials.json');
7
+ const SCOPES = [
8
+ 'https://www.googleapis.com/auth/documents',
9
+ 'https://www.googleapis.com/auth/spreadsheets.readonly',
10
+ 'https://www.googleapis.com/auth/drive.readonly',
11
+ ];
12
+ function getCredentialsPath() {
13
+ return process.env.GDOCS_CREDENTIALS || CREDENTIALS_PATH_DEFAULT;
14
+ }
15
+ function loadCredentials() {
16
+ const credPath = getCredentialsPath();
17
+ if (!fs.existsSync(credPath)) {
18
+ throw new Error(`Credentials not found at ${credPath}. Run: npx gdocs-mcp auth`);
19
+ }
20
+ const raw = JSON.parse(fs.readFileSync(credPath, 'utf8'));
21
+ const creds = raw.installed || raw.web;
22
+ if (!creds) {
23
+ throw new Error('Invalid credentials.json format. Expected "installed" or "web" key.');
24
+ }
25
+ return creds;
26
+ }
27
+ export function getAuthClient() {
28
+ const creds = loadCredentials();
29
+ const oAuth2Client = new google.auth.OAuth2(creds.client_id, creds.client_secret, 'http://localhost:3333');
30
+ if (!fs.existsSync(TOKEN_PATH)) {
31
+ throw new Error('Auth required. Run: npx gdocs-mcp auth');
32
+ }
33
+ const token = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
34
+ oAuth2Client.setCredentials(token);
35
+ oAuth2Client.on('tokens', (newTokens) => {
36
+ const existing = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
37
+ const merged = { ...existing, ...newTokens };
38
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(merged, null, 2));
39
+ });
40
+ return oAuth2Client;
41
+ }
42
+ export function getConfigDir() {
43
+ return CONFIG_DIR;
44
+ }
45
+ export function getTokenPath() {
46
+ return TOKEN_PATH;
47
+ }
48
+ export function getScopes() {
49
+ return SCOPES;
50
+ }
51
+ export { CONFIG_DIR, TOKEN_PATH, CREDENTIALS_PATH_DEFAULT };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { ReadDocumentSchema, readDocument } from './tools/read-document.js';
5
+ import { SearchDocumentsSchema, searchDocuments } from './tools/search-documents.js';
6
+ import { CreateDocumentSchema, createDocument } from './tools/create-document.js';
7
+ import { ReplaceAllTextSchema, replaceAllText } from './tools/replace-all-text.js';
8
+ import { ReplaceImageSchema, replaceImage } from './tools/replace-image.js';
9
+ import { UpdateDocumentMarkdownSchema, updateDocumentMarkdown } from './tools/update-document-markdown.js';
10
+ import { UpdateDocumentSectionMarkdownSchema, updateDocumentSectionMarkdown } from './tools/update-document-section-markdown.js';
11
+ import { UpdateDocumentStyleSchema, updateDocumentStyle } from './tools/update-document-style.js';
12
+ import { UpdateDocumentSchema, updateDocument } from './tools/update-document.js';
13
+ import { UnmergeTableCellsSchema, unmergeTableCells } from './tools/unmerge-table-cells.js';
14
+ import { GetChartsSchema, getCharts } from './tools/get-charts.js';
15
+ const server = new McpServer({
16
+ name: 'gdocs-mcp',
17
+ version: '0.1.0',
18
+ });
19
+ function formatError(err) {
20
+ if (err instanceof Error) {
21
+ const message = err.message;
22
+ if (message.includes('not found') || message.includes('404')) {
23
+ return 'Document not found. Check the document ID and ensure it is shared with your account.';
24
+ }
25
+ if (message.includes('UNAUTHENTICATED') || message.includes('invalid_grant')) {
26
+ return 'Auth expired. Run: npx gdocs-mcp auth';
27
+ }
28
+ if (message.includes('RATE_LIMIT') || message.includes('429')) {
29
+ return 'Google API quota exceeded. Wait 60 seconds and retry.';
30
+ }
31
+ if (message.includes('PERMISSION_DENIED') || message.includes('403')) {
32
+ return 'Permission denied. Ensure the document is shared with your Google account.';
33
+ }
34
+ return message;
35
+ }
36
+ return String(err);
37
+ }
38
+ function registerTool(name, description, schema, handler) {
39
+ server.tool(name, description, schema.shape, async (args) => {
40
+ try {
41
+ const result = await handler(args);
42
+ return {
43
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
44
+ };
45
+ }
46
+ catch (err) {
47
+ return {
48
+ content: [{ type: 'text', text: formatError(err) }],
49
+ isError: true,
50
+ };
51
+ }
52
+ });
53
+ }
54
+ // Google Docs tools
55
+ registerTool('read_document', 'Read the full content of a Google Doc', ReadDocumentSchema, readDocument);
56
+ registerTool('search_documents', 'Search Google Drive for documents by name, content, or date', SearchDocumentsSchema, searchDocuments);
57
+ registerTool('create_document', 'Create a new Google Doc with optional Markdown content', CreateDocumentSchema, createDocument);
58
+ registerTool('replace_all_text', 'Find and replace all occurrences of text in a document', ReplaceAllTextSchema, replaceAllText);
59
+ registerTool('replace_image', 'Replace an existing image in a document with a new image from a URI', ReplaceImageSchema, replaceImage);
60
+ registerTool('update_document_markdown', 'Replace the entire document body with Markdown content', UpdateDocumentMarkdownSchema, updateDocumentMarkdown);
61
+ registerTool('update_document_section_markdown', 'Insert or replace a section of a document with Markdown content', UpdateDocumentSectionMarkdownSchema, updateDocumentSectionMarkdown);
62
+ registerTool('update_document_style', 'Update document style: page size, margins, text direction', UpdateDocumentStyleSchema, updateDocumentStyle);
63
+ registerTool('update_document', 'Apply batch updates to a document (insertText, updateTextStyle, createParagraphBullets, insertTable, etc.)', UpdateDocumentSchema, updateDocument);
64
+ registerTool('unmerge_table_cells', 'Unmerge previously merged cells in a document table', UnmergeTableCellsSchema, unmergeTableCells);
65
+ // Google Sheets tools
66
+ registerTool('get_charts', 'List all charts in a Google Sheets spreadsheet with IDs and specs', GetChartsSchema, getCharts);
67
+ async function main() {
68
+ const transport = new StdioServerTransport();
69
+ await server.connect(transport);
70
+ console.error('gdocs-mcp v0.1.0 started');
71
+ }
72
+ main().catch((err) => {
73
+ console.error('Failed to start gdocs-mcp:', err);
74
+ process.exit(1);
75
+ });
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const CreateDocumentSchema: z.ZodObject<{
3
+ title: z.ZodString;
4
+ markdown: z.ZodOptional<z.ZodString>;
5
+ }, z.core.$strip>;
6
+ export declare function createDocument(args: z.infer<typeof CreateDocumentSchema>): Promise<{
7
+ documentId: string;
8
+ title: string;
9
+ url: string;
10
+ }>;
@@ -0,0 +1,37 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const CreateDocumentSchema = z.object({
5
+ title: z.string().describe('Title for the new Google Doc'),
6
+ markdown: z.string().optional().describe('Optional Markdown content to populate the document with'),
7
+ });
8
+ export async function createDocument(args) {
9
+ const auth = getAuthClient();
10
+ const docs = google.docs({ version: 'v1', auth });
11
+ // Create empty doc
12
+ const createRes = await docs.documents.create({
13
+ requestBody: { title: args.title },
14
+ });
15
+ const documentId = createRes.data.documentId;
16
+ // If markdown provided, insert it as text content
17
+ if (args.markdown) {
18
+ await docs.documents.batchUpdate({
19
+ documentId,
20
+ requestBody: {
21
+ requests: [
22
+ {
23
+ insertText: {
24
+ location: { index: 1 },
25
+ text: args.markdown,
26
+ },
27
+ },
28
+ ],
29
+ },
30
+ });
31
+ }
32
+ return {
33
+ documentId,
34
+ title: args.title,
35
+ url: `https://docs.google.com/document/d/${documentId}/edit`,
36
+ };
37
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ export declare const GetChartsSchema: z.ZodObject<{
3
+ spreadsheetId: z.ZodString;
4
+ }, z.core.$strip>;
5
+ export declare function getCharts(args: z.infer<typeof GetChartsSchema>): Promise<{
6
+ spreadsheetId: string;
7
+ charts: any[];
8
+ count: number;
9
+ }>;
@@ -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 GetChartsSchema = z.object({
5
+ spreadsheetId: z.string().describe('The Google Sheets spreadsheet ID (from the URL)'),
6
+ });
7
+ export async function getCharts(args) {
8
+ const auth = getAuthClient();
9
+ const sheets = google.sheets({ version: 'v4', auth });
10
+ const res = await sheets.spreadsheets.get({
11
+ spreadsheetId: args.spreadsheetId,
12
+ fields: 'sheets(charts(chartId,position,spec))',
13
+ });
14
+ const allCharts = [];
15
+ for (const sheet of res.data.sheets || []) {
16
+ for (const chart of sheet.charts || []) {
17
+ allCharts.push({
18
+ chartId: chart.chartId,
19
+ title: chart.spec?.title || 'Untitled',
20
+ chartType: chart.spec?.basicChart?.chartType || chart.spec?.pieChart ? 'PIE' : 'UNKNOWN',
21
+ position: chart.position,
22
+ });
23
+ }
24
+ }
25
+ return {
26
+ spreadsheetId: args.spreadsheetId,
27
+ charts: allCharts,
28
+ count: allCharts.length,
29
+ };
30
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const ReadDocumentSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ }, z.core.$strip>;
5
+ export declare function readDocument(args: z.infer<typeof ReadDocumentSchema>): Promise<{
6
+ documentId: string;
7
+ title: string;
8
+ content: string;
9
+ revisionId: string | null | undefined;
10
+ }>;
@@ -0,0 +1,46 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const ReadDocumentSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID (from the URL)'),
6
+ });
7
+ export async function readDocument(args) {
8
+ const auth = getAuthClient();
9
+ const docs = google.docs({ version: 'v1', auth });
10
+ const res = await docs.documents.get({ documentId: args.documentId });
11
+ const doc = res.data;
12
+ const title = doc.title || 'Untitled';
13
+ const body = doc.body?.content || [];
14
+ const textContent = extractText(body);
15
+ return {
16
+ documentId: args.documentId,
17
+ title,
18
+ content: textContent,
19
+ revisionId: doc.revisionId,
20
+ };
21
+ }
22
+ function extractText(content) {
23
+ const parts = [];
24
+ for (const element of content) {
25
+ if (element.paragraph) {
26
+ const paragraphText = element.paragraph.elements
27
+ ?.map((el) => el.textRun?.content || '')
28
+ .join('') || '';
29
+ parts.push(paragraphText);
30
+ }
31
+ else if (element.table) {
32
+ for (const row of element.table.tableRows || []) {
33
+ const cells = row.tableCells?.map((cell) => {
34
+ const cellContent = cell.content || [];
35
+ return extractText(cellContent).trim();
36
+ }) || [];
37
+ parts.push('| ' + cells.join(' | ') + ' |');
38
+ }
39
+ parts.push('');
40
+ }
41
+ else if (element.sectionBreak) {
42
+ parts.push('---');
43
+ }
44
+ }
45
+ return parts.join('');
46
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ export declare const ReplaceAllTextSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ find: z.ZodString;
5
+ replaceWith: z.ZodString;
6
+ matchCase: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
7
+ }, z.core.$strip>;
8
+ export declare function replaceAllText(args: z.infer<typeof ReplaceAllTextSchema>): Promise<{
9
+ documentId: string;
10
+ occurrencesReplaced: number;
11
+ }>;
@@ -0,0 +1,34 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const ReplaceAllTextSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ find: z.string().describe('Text to find (all occurrences will be replaced)'),
7
+ replaceWith: z.string().describe('Replacement text'),
8
+ matchCase: z.boolean().optional().default(true).describe('Whether the search is case-sensitive (default: true)'),
9
+ });
10
+ export async function replaceAllText(args) {
11
+ const auth = getAuthClient();
12
+ const docs = google.docs({ version: 'v1', auth });
13
+ const res = await docs.documents.batchUpdate({
14
+ documentId: args.documentId,
15
+ requestBody: {
16
+ requests: [
17
+ {
18
+ replaceAllText: {
19
+ containsText: {
20
+ text: args.find,
21
+ matchCase: args.matchCase,
22
+ },
23
+ replaceText: args.replaceWith,
24
+ },
25
+ },
26
+ ],
27
+ },
28
+ });
29
+ const replaceResult = res.data.replies?.[0]?.replaceAllText;
30
+ return {
31
+ documentId: args.documentId,
32
+ occurrencesReplaced: replaceResult?.occurrencesChanged || 0,
33
+ };
34
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ export declare const ReplaceImageSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ imageObjectId: z.ZodString;
5
+ newImageUri: z.ZodString;
6
+ }, z.core.$strip>;
7
+ export declare function replaceImage(args: z.infer<typeof ReplaceImageSchema>): Promise<{
8
+ documentId: string;
9
+ imageObjectId: string;
10
+ replaced: boolean;
11
+ }>;
@@ -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 ReplaceImageSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ imageObjectId: z.string().describe('The object ID of the image to replace (from read_document)'),
7
+ newImageUri: z.string().url().describe('URI of the new image to insert'),
8
+ });
9
+ export async function replaceImage(args) {
10
+ const auth = getAuthClient();
11
+ const docs = google.docs({ version: 'v1', auth });
12
+ await docs.documents.batchUpdate({
13
+ documentId: args.documentId,
14
+ requestBody: {
15
+ requests: [
16
+ {
17
+ replaceImage: {
18
+ imageObjectId: args.imageObjectId,
19
+ uri: args.newImageUri,
20
+ },
21
+ },
22
+ ],
23
+ },
24
+ });
25
+ return {
26
+ documentId: args.documentId,
27
+ imageObjectId: args.imageObjectId,
28
+ replaced: true,
29
+ };
30
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const SearchDocumentsSchema: z.ZodObject<{
3
+ query: z.ZodString;
4
+ dateFrom: z.ZodOptional<z.ZodString>;
5
+ dateTo: z.ZodOptional<z.ZodString>;
6
+ maxResults: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
7
+ }, z.core.$strip>;
8
+ export declare function searchDocuments(args: z.infer<typeof SearchDocumentsSchema>): Promise<{
9
+ documentId: string | null | undefined;
10
+ title: string | null | undefined;
11
+ lastModified: string | null | undefined;
12
+ url: string | null | undefined;
13
+ owner: string;
14
+ }[]>;
@@ -0,0 +1,37 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const SearchDocumentsSchema = z.object({
5
+ query: z.string().describe('Search query — matches document title and content'),
6
+ dateFrom: z.string().optional().describe('Filter: modified after this date (ISO 8601, e.g. 2026-01-01)'),
7
+ dateTo: z.string().optional().describe('Filter: modified before this date (ISO 8601)'),
8
+ maxResults: z.number().optional().default(10).describe('Maximum number of results to return (default: 10)'),
9
+ });
10
+ export async function searchDocuments(args) {
11
+ const auth = getAuthClient();
12
+ const drive = google.drive({ version: 'v3', auth });
13
+ const queryParts = [
14
+ "mimeType='application/vnd.google-apps.document'",
15
+ `fullText contains '${args.query.replace(/'/g, "\\'")}'`,
16
+ ];
17
+ if (args.dateFrom) {
18
+ queryParts.push(`modifiedTime >= '${args.dateFrom}T00:00:00'`);
19
+ }
20
+ if (args.dateTo) {
21
+ queryParts.push(`modifiedTime <= '${args.dateTo}T23:59:59'`);
22
+ }
23
+ const res = await drive.files.list({
24
+ q: queryParts.join(' and '),
25
+ pageSize: args.maxResults,
26
+ fields: 'files(id, name, modifiedTime, webViewLink, owners)',
27
+ orderBy: 'modifiedTime desc',
28
+ });
29
+ const files = res.data.files || [];
30
+ return files.map(f => ({
31
+ documentId: f.id,
32
+ title: f.name,
33
+ lastModified: f.modifiedTime,
34
+ url: f.webViewLink,
35
+ owner: f.owners?.[0]?.emailAddress || 'unknown',
36
+ }));
37
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const UnmergeTableCellsSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ tableStartIndex: z.ZodNumber;
5
+ rowIndex: z.ZodNumber;
6
+ columnIndex: z.ZodNumber;
7
+ }, z.core.$strip>;
8
+ export declare function unmergeTableCells(args: z.infer<typeof UnmergeTableCellsSchema>): Promise<{
9
+ documentId: string;
10
+ unmerged: boolean;
11
+ tableStartIndex: number;
12
+ rowIndex: number;
13
+ columnIndex: number;
14
+ }>;
@@ -0,0 +1,40 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const UnmergeTableCellsSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ tableStartIndex: z.number().describe('The start index of the table in the document body'),
7
+ rowIndex: z.number().describe('The zero-based row index of the cell to unmerge'),
8
+ columnIndex: z.number().describe('The zero-based column index of the cell to unmerge'),
9
+ });
10
+ export async function unmergeTableCells(args) {
11
+ const auth = getAuthClient();
12
+ const docs = google.docs({ version: 'v1', auth });
13
+ await docs.documents.batchUpdate({
14
+ documentId: args.documentId,
15
+ requestBody: {
16
+ requests: [
17
+ {
18
+ unmergeTableCells: {
19
+ tableRange: {
20
+ tableCellLocation: {
21
+ tableStartLocation: { index: args.tableStartIndex },
22
+ rowIndex: args.rowIndex,
23
+ columnIndex: args.columnIndex,
24
+ },
25
+ rowSpan: 1,
26
+ columnSpan: 1,
27
+ },
28
+ },
29
+ },
30
+ ],
31
+ },
32
+ });
33
+ return {
34
+ documentId: args.documentId,
35
+ unmerged: true,
36
+ tableStartIndex: args.tableStartIndex,
37
+ rowIndex: args.rowIndex,
38
+ columnIndex: args.columnIndex,
39
+ };
40
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const UpdateDocumentMarkdownSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ markdown: z.ZodString;
5
+ }, z.core.$strip>;
6
+ export declare function updateDocumentMarkdown(args: z.infer<typeof UpdateDocumentMarkdownSchema>): Promise<{
7
+ documentId: string;
8
+ updated: boolean;
9
+ contentLength: number;
10
+ }>;
@@ -0,0 +1,45 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const UpdateDocumentMarkdownSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ markdown: z.string().describe('Markdown content to replace the entire document body with'),
7
+ });
8
+ export async function updateDocumentMarkdown(args) {
9
+ const auth = getAuthClient();
10
+ const docs = google.docs({ version: 'v1', auth });
11
+ // Get current document to find end index
12
+ const current = await docs.documents.get({ documentId: args.documentId });
13
+ const body = current.data.body;
14
+ const endIndex = body?.content
15
+ ? body.content[body.content.length - 1]?.endIndex || 1
16
+ : 1;
17
+ const requests = [];
18
+ // Delete existing content (except the trailing newline at index 1)
19
+ if (endIndex > 2) {
20
+ requests.push({
21
+ deleteContentRange: {
22
+ range: {
23
+ startIndex: 1,
24
+ endIndex: endIndex - 1,
25
+ },
26
+ },
27
+ });
28
+ }
29
+ // Insert new content
30
+ requests.push({
31
+ insertText: {
32
+ location: { index: 1 },
33
+ text: args.markdown,
34
+ },
35
+ });
36
+ await docs.documents.batchUpdate({
37
+ documentId: args.documentId,
38
+ requestBody: { requests },
39
+ });
40
+ return {
41
+ documentId: args.documentId,
42
+ updated: true,
43
+ contentLength: args.markdown.length,
44
+ };
45
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const UpdateDocumentSectionMarkdownSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ markdown: z.ZodString;
5
+ startIndex: z.ZodNumber;
6
+ endIndex: z.ZodOptional<z.ZodNumber>;
7
+ }, z.core.$strip>;
8
+ export declare function updateDocumentSectionMarkdown(args: z.infer<typeof UpdateDocumentSectionMarkdownSchema>): Promise<{
9
+ documentId: string;
10
+ updated: boolean;
11
+ startIndex: number;
12
+ endIndex: number | undefined;
13
+ insertedLength: 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 UpdateDocumentSectionMarkdownSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ markdown: z.string().describe('Markdown content to insert or replace within the section'),
7
+ startIndex: z.number().describe('Start index of the section to replace'),
8
+ endIndex: z.number().optional().describe('End index of the section. If omitted, content is inserted at startIndex without deleting.'),
9
+ });
10
+ export async function updateDocumentSectionMarkdown(args) {
11
+ const auth = getAuthClient();
12
+ const docs = google.docs({ version: 'v1', auth });
13
+ const requests = [];
14
+ // Delete the existing section if endIndex is provided
15
+ if (args.endIndex !== undefined && args.endIndex > args.startIndex) {
16
+ requests.push({
17
+ deleteContentRange: {
18
+ range: {
19
+ startIndex: args.startIndex,
20
+ endIndex: args.endIndex,
21
+ },
22
+ },
23
+ });
24
+ }
25
+ // Insert new content at the start index
26
+ requests.push({
27
+ insertText: {
28
+ location: { index: args.startIndex },
29
+ text: args.markdown,
30
+ },
31
+ });
32
+ await docs.documents.batchUpdate({
33
+ documentId: args.documentId,
34
+ requestBody: { requests },
35
+ });
36
+ return {
37
+ documentId: args.documentId,
38
+ updated: true,
39
+ startIndex: args.startIndex,
40
+ endIndex: args.endIndex,
41
+ insertedLength: args.markdown.length,
42
+ };
43
+ }
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+ export declare const UpdateDocumentStyleSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ pageSize: z.ZodOptional<z.ZodObject<{
5
+ width: z.ZodOptional<z.ZodObject<{
6
+ magnitude: z.ZodNumber;
7
+ unit: z.ZodDefault<z.ZodEnum<{
8
+ PT: "PT";
9
+ MM: "MM";
10
+ INCH: "INCH";
11
+ }>>;
12
+ }, z.core.$strip>>;
13
+ height: z.ZodOptional<z.ZodObject<{
14
+ magnitude: z.ZodNumber;
15
+ unit: z.ZodDefault<z.ZodEnum<{
16
+ PT: "PT";
17
+ MM: "MM";
18
+ INCH: "INCH";
19
+ }>>;
20
+ }, z.core.$strip>>;
21
+ }, z.core.$strip>>;
22
+ marginTop: z.ZodOptional<z.ZodObject<{
23
+ magnitude: z.ZodNumber;
24
+ unit: z.ZodDefault<z.ZodEnum<{
25
+ PT: "PT";
26
+ MM: "MM";
27
+ INCH: "INCH";
28
+ }>>;
29
+ }, z.core.$strip>>;
30
+ marginBottom: z.ZodOptional<z.ZodObject<{
31
+ magnitude: z.ZodNumber;
32
+ unit: z.ZodDefault<z.ZodEnum<{
33
+ PT: "PT";
34
+ MM: "MM";
35
+ INCH: "INCH";
36
+ }>>;
37
+ }, z.core.$strip>>;
38
+ marginLeft: z.ZodOptional<z.ZodObject<{
39
+ magnitude: z.ZodNumber;
40
+ unit: z.ZodDefault<z.ZodEnum<{
41
+ PT: "PT";
42
+ MM: "MM";
43
+ INCH: "INCH";
44
+ }>>;
45
+ }, z.core.$strip>>;
46
+ marginRight: z.ZodOptional<z.ZodObject<{
47
+ magnitude: z.ZodNumber;
48
+ unit: z.ZodDefault<z.ZodEnum<{
49
+ PT: "PT";
50
+ MM: "MM";
51
+ INCH: "INCH";
52
+ }>>;
53
+ }, z.core.$strip>>;
54
+ }, z.core.$strip>;
55
+ export declare function updateDocumentStyle(args: z.infer<typeof UpdateDocumentStyleSchema>): Promise<{
56
+ documentId: string;
57
+ updated: boolean;
58
+ reason: string;
59
+ fieldsUpdated?: undefined;
60
+ } | {
61
+ documentId: string;
62
+ updated: boolean;
63
+ fieldsUpdated: string[];
64
+ reason?: undefined;
65
+ }>;
@@ -0,0 +1,72 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ const DimensionSchema = z.object({
5
+ magnitude: z.number(),
6
+ unit: z.enum(['PT', 'MM', 'INCH']).default('PT'),
7
+ });
8
+ export const UpdateDocumentStyleSchema = z.object({
9
+ documentId: z.string().describe('The Google Doc document ID'),
10
+ pageSize: z.object({
11
+ width: DimensionSchema.optional(),
12
+ height: DimensionSchema.optional(),
13
+ }).optional().describe('Page dimensions'),
14
+ marginTop: DimensionSchema.optional().describe('Top margin'),
15
+ marginBottom: DimensionSchema.optional().describe('Bottom margin'),
16
+ marginLeft: DimensionSchema.optional().describe('Left margin'),
17
+ marginRight: DimensionSchema.optional().describe('Right margin'),
18
+ });
19
+ export async function updateDocumentStyle(args) {
20
+ const auth = getAuthClient();
21
+ const docs = google.docs({ version: 'v1', auth });
22
+ const documentStyle = {};
23
+ const fields = [];
24
+ if (args.pageSize) {
25
+ documentStyle.pageSize = {};
26
+ if (args.pageSize.width) {
27
+ documentStyle.pageSize.width = args.pageSize.width;
28
+ fields.push('pageSize.width');
29
+ }
30
+ if (args.pageSize.height) {
31
+ documentStyle.pageSize.height = args.pageSize.height;
32
+ fields.push('pageSize.height');
33
+ }
34
+ }
35
+ if (args.marginTop) {
36
+ documentStyle.marginTop = args.marginTop;
37
+ fields.push('marginTop');
38
+ }
39
+ if (args.marginBottom) {
40
+ documentStyle.marginBottom = args.marginBottom;
41
+ fields.push('marginBottom');
42
+ }
43
+ if (args.marginLeft) {
44
+ documentStyle.marginLeft = args.marginLeft;
45
+ fields.push('marginLeft');
46
+ }
47
+ if (args.marginRight) {
48
+ documentStyle.marginRight = args.marginRight;
49
+ fields.push('marginRight');
50
+ }
51
+ if (fields.length === 0) {
52
+ return { documentId: args.documentId, updated: false, reason: 'No style changes specified' };
53
+ }
54
+ await docs.documents.batchUpdate({
55
+ documentId: args.documentId,
56
+ requestBody: {
57
+ requests: [
58
+ {
59
+ updateDocumentStyle: {
60
+ documentStyle,
61
+ fields: fields.join(','),
62
+ },
63
+ },
64
+ ],
65
+ },
66
+ });
67
+ return {
68
+ documentId: args.documentId,
69
+ updated: true,
70
+ fieldsUpdated: fields,
71
+ };
72
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const UpdateDocumentSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ requests: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodAny>>;
5
+ }, z.core.$strip>;
6
+ export declare function updateDocument(args: z.infer<typeof UpdateDocumentSchema>): Promise<{
7
+ documentId: string;
8
+ repliesCount: number;
9
+ writeControl: import("googleapis").docs_v1.Schema$WriteControl | undefined;
10
+ }>;
@@ -0,0 +1,31 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const UpdateDocumentSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ requests: z.array(z.record(z.string(), z.any())).min(1).describe('Array of Google Docs API batchUpdate requests. ' +
7
+ 'Supported types include: insertText, deleteContentRange, replaceAllText, ' +
8
+ 'updateTextStyle, createParagraphBullets, insertTable, insertInlineImage, ' +
9
+ 'createHeader, createFooter, updateDocumentStyle, and more. ' +
10
+ 'See: https://developers.google.com/docs/api/reference/rest/v1/documents/request'),
11
+ });
12
+ export async function updateDocument(args) {
13
+ const auth = getAuthClient();
14
+ const docs = google.docs({ version: 'v1', auth });
15
+ // Validate each request has exactly one key (the request type)
16
+ for (const [i, req] of args.requests.entries()) {
17
+ const keys = Object.keys(req);
18
+ if (keys.length !== 1) {
19
+ throw new Error(`Request at index ${i} must have exactly one key (the request type), got: ${keys.join(', ')}`);
20
+ }
21
+ }
22
+ const res = await docs.documents.batchUpdate({
23
+ documentId: args.documentId,
24
+ requestBody: { requests: args.requests },
25
+ });
26
+ return {
27
+ documentId: args.documentId,
28
+ repliesCount: res.data.replies?.length || 0,
29
+ writeControl: res.data.writeControl,
30
+ };
31
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "gdocs-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Open-source MCP server for Google Docs and Sheets. Self-hosted, local OAuth, no third-party token storage.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "gdocs-mcp": "./bin/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "bin",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "google-docs",
22
+ "google-sheets",
23
+ "model-context-protocol",
24
+ "claude",
25
+ "ai-tools",
26
+ "google-workspace",
27
+ "document-automation"
28
+ ],
29
+ "author": "Apurwa Sarwajit",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/apurwa-sudo/gdocs-mcp"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.29.0",
40
+ "googleapis": "^171.4.0",
41
+ "zod": "^4.3.6"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^25.5.2",
45
+ "tsx": "^4.21.0",
46
+ "typescript": "^6.0.2"
47
+ }
48
+ }