minimal-gdocs 1.0.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) 2025 Rowan Bradley
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,172 @@
1
+ # minimal-gdocs
2
+
3
+ A minimal MCP server for creating and updating Google Docs from markdown. Built for [Claude Code](https://claude.ai/claude-code).
4
+
5
+ ## Features
6
+
7
+ - **Create Google Docs** from markdown with native formatting
8
+ - **Update existing docs** (replace or append)
9
+ - **List recent docs** with optional search
10
+ - **Full markdown support:**
11
+ - Headings (H1-H6)
12
+ - Bold, italic, and **nested *formatting***
13
+ - Bullet and numbered lists
14
+ - Links
15
+ - Horizontal rules
16
+ - Code blocks (monospace)
17
+
18
+ ## Design Philosophy
19
+
20
+ minimal-gdocs intentionally stays minimal:
21
+ - **4 core tools** covering the essential document lifecycle
22
+ - **Zero configuration** beyond OAuth credentials
23
+ - **No batch operations** or complex workflows
24
+ - **~274 tokens** of context overhead (vs ~3,000 for full-featured alternatives)
25
+
26
+ If you need advanced features (comments, sheets, drive management, sharing), consider [google-docs-mcp](https://github.com/a-bonus/google-docs-mcp) instead.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ git clone https://github.com/rowbradley/minimal-gdocs.git
32
+ cd minimal-gdocs
33
+ npm install
34
+ npm run build
35
+ ```
36
+
37
+ Or via npm (once published):
38
+ ```bash
39
+ npx minimal-gdocs
40
+ ```
41
+
42
+ ## Google Cloud Setup
43
+
44
+ You need Google OAuth credentials. Choose **one** of these methods:
45
+
46
+ ### Option A: Google Cloud Console (Web)
47
+
48
+ 1. Go to [Google Cloud Console](https://console.cloud.google.com/)
49
+ 2. Create a new project (or select existing)
50
+ 3. Enable the **Google Docs API**:
51
+ - Go to "APIs & Services" → "Library"
52
+ - Search "Google Docs API" → Enable
53
+ 4. Create OAuth credentials:
54
+ - Go to "APIs & Services" → "Credentials"
55
+ - Click "Create Credentials" → "OAuth client ID"
56
+ - Application type: **Desktop app**
57
+ - Download the JSON file
58
+ 5. Rename it to `credentials.json` and place in project root
59
+
60
+ ### Option B: gcloud CLI
61
+
62
+ ```bash
63
+ # Install gcloud if needed: https://cloud.google.com/sdk/docs/install
64
+
65
+ # Login and set project
66
+ gcloud auth login
67
+ gcloud projects create minimal-gdocs-project --name="Minimal GDocs"
68
+ gcloud config set project minimal-gdocs-project
69
+
70
+ # Enable Docs API
71
+ gcloud services enable docs.googleapis.com
72
+
73
+ # Create OAuth credentials
74
+ gcloud auth application-default login --scopes=https://www.googleapis.com/auth/documents,https://www.googleapis.com/auth/drive.file
75
+
76
+ # For MCP server, you still need OAuth client credentials:
77
+ # Go to console.cloud.google.com → APIs & Services → Credentials
78
+ # Create "OAuth client ID" → Desktop app → Download JSON
79
+ ```
80
+
81
+ ## Claude Code Configuration
82
+
83
+ Add to your Claude Code `settings.json`:
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "gdocs": {
89
+ "command": "node",
90
+ "args": ["/path/to/minimal-gdocs/dist/index.js"]
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Or if installed globally via npm:
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "gdocs": {
101
+ "command": "npx",
102
+ "args": ["minimal-gdocs"]
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ ## First Run
109
+
110
+ On first use, the server will:
111
+ 1. Open your browser for Google OAuth consent
112
+ 2. Ask you to authorize access to Google Docs
113
+ 3. Save the token locally (in `token.json`)
114
+
115
+ Subsequent runs use the saved token automatically.
116
+
117
+ ## Usage
118
+
119
+ Once configured, Claude Code can use these tools:
120
+
121
+ ### create_google_doc
122
+ ```
123
+ Create a Google Doc from markdown:
124
+ - title: Document title
125
+ - content: Markdown content
126
+ - folderId: (optional) Google Drive folder ID
127
+ ```
128
+
129
+ ### update_google_doc
130
+ ```
131
+ Update an existing doc:
132
+ - docId: The Google Doc ID
133
+ - content: New markdown content
134
+ - mode: "replace" or "append"
135
+ ```
136
+
137
+ ### list_recent_docs
138
+ ```
139
+ List your recent Google Docs:
140
+ - limit: (optional) Max docs to return
141
+ - query: (optional) Search filter
142
+ ```
143
+
144
+ ### get_doc_url
145
+ ```
146
+ Get URLs for a doc:
147
+ - docId: The Google Doc ID
148
+ Returns: edit URL, view URL, PDF export URL
149
+ ```
150
+
151
+ ## Example
152
+
153
+ In Claude Code:
154
+ ```
155
+ "Publish this to Google Docs"
156
+
157
+ # My Document
158
+
159
+ Here's some **bold** and *italic* text.
160
+
161
+ - Bullet one
162
+ - Bullet two
163
+
164
+ 1. Numbered item
165
+ 2. Another item
166
+ ```
167
+
168
+ Creates a properly formatted Google Doc with native styling.
169
+
170
+ ## License
171
+
172
+ MIT
@@ -0,0 +1,11 @@
1
+ {
2
+ "installed": {
3
+ "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
4
+ "project_id": "your-project-id",
5
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
6
+ "token_uri": "https://oauth2.googleapis.com/token",
7
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
8
+ "client_secret": "YOUR_CLIENT_SECRET",
9
+ "redirect_uris": ["http://localhost"]
10
+ }
11
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { OAuth2Client } from 'google-auth-library';
2
+ export declare function getAuthClient(): Promise<OAuth2Client>;
package/dist/auth.js ADDED
@@ -0,0 +1,83 @@
1
+ import { google } from 'googleapis';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as http from 'http';
5
+ import open from 'open';
6
+ const SCOPES = [
7
+ 'https://www.googleapis.com/auth/documents',
8
+ 'https://www.googleapis.com/auth/drive.file',
9
+ 'https://www.googleapis.com/auth/drive.metadata.readonly'
10
+ ];
11
+ const CREDENTIALS_PATH = path.join(import.meta.dirname, '..', 'credentials.json');
12
+ const TOKEN_PATH = path.join(import.meta.dirname, '..', 'token.json');
13
+ export async function getAuthClient() {
14
+ const credentials = JSON.parse(fs.readFileSync(CREDENTIALS_PATH, 'utf-8'));
15
+ const { client_id, client_secret } = credentials.installed;
16
+ const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, 'http://localhost:3000/oauth2callback');
17
+ // Check for existing token
18
+ if (fs.existsSync(TOKEN_PATH)) {
19
+ const token = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf-8'));
20
+ oAuth2Client.setCredentials(token);
21
+ // Check if token is expired and refresh if needed
22
+ if (token.expiry_date && token.expiry_date < Date.now()) {
23
+ try {
24
+ const { credentials } = await oAuth2Client.refreshAccessToken();
25
+ oAuth2Client.setCredentials(credentials);
26
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(credentials));
27
+ }
28
+ catch (error) {
29
+ // Token refresh failed, need to re-authenticate
30
+ return await authenticateWithBrowser(oAuth2Client);
31
+ }
32
+ }
33
+ return oAuth2Client;
34
+ }
35
+ // No token, need to authenticate
36
+ return await authenticateWithBrowser(oAuth2Client);
37
+ }
38
+ async function authenticateWithBrowser(oAuth2Client) {
39
+ return new Promise((resolve, reject) => {
40
+ const authUrl = oAuth2Client.generateAuthUrl({
41
+ access_type: 'offline',
42
+ scope: SCOPES,
43
+ prompt: 'consent'
44
+ });
45
+ // Create a simple server to receive the callback
46
+ const server = http.createServer(async (req, res) => {
47
+ if (req.url?.startsWith('/oauth2callback')) {
48
+ const url = new URL(req.url, 'http://localhost:3000');
49
+ const code = url.searchParams.get('code');
50
+ if (code) {
51
+ try {
52
+ const { tokens } = await oAuth2Client.getToken(code);
53
+ oAuth2Client.setCredentials(tokens);
54
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens));
55
+ res.writeHead(200, { 'Content-Type': 'text/html' });
56
+ res.end('<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>');
57
+ server.close();
58
+ resolve(oAuth2Client);
59
+ }
60
+ catch (error) {
61
+ res.writeHead(500, { 'Content-Type': 'text/html' });
62
+ res.end('<html><body><h1>Authentication failed</h1></body></html>');
63
+ server.close();
64
+ reject(error);
65
+ }
66
+ }
67
+ else {
68
+ res.writeHead(400, { 'Content-Type': 'text/html' });
69
+ res.end('<html><body><h1>No code received</h1></body></html>');
70
+ }
71
+ }
72
+ });
73
+ server.listen(3000, () => {
74
+ console.error('Opening browser for authentication...');
75
+ open(authUrl);
76
+ });
77
+ // Timeout after 2 minutes
78
+ setTimeout(() => {
79
+ server.close();
80
+ reject(new Error('Authentication timed out'));
81
+ }, 120000);
82
+ });
83
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { google } from 'googleapis';
6
+ import { getAuthClient } from './auth.js';
7
+ import { markdownToDocsRequests } from './utils/markdown-to-docs.js';
8
+ const server = new Server({ name: 'gdocs-minimal', version: '1.0.0' }, { capabilities: { tools: {} } });
9
+ // Tool definitions
10
+ const TOOLS = [
11
+ {
12
+ name: 'create_google_doc',
13
+ description: 'Create a new Google Doc from markdown content',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ title: { type: 'string', description: 'Document title' },
18
+ content: { type: 'string', description: 'Markdown content for the document' },
19
+ folderId: { type: 'string', description: 'Optional Google Drive folder ID to create the doc in' }
20
+ },
21
+ required: ['title', 'content']
22
+ }
23
+ },
24
+ {
25
+ name: 'update_google_doc',
26
+ description: 'Update an existing Google Doc with new markdown content',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ docId: { type: 'string', description: 'Google Doc ID' },
31
+ content: { type: 'string', description: 'New markdown content' },
32
+ mode: { type: 'string', enum: ['replace', 'append'], description: 'Replace all content or append to end' }
33
+ },
34
+ required: ['docId', 'content']
35
+ }
36
+ },
37
+ {
38
+ name: 'list_recent_docs',
39
+ description: 'List recent Google Docs from your Drive',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ limit: { type: 'number', description: 'Maximum number of docs to return (default 10)' },
44
+ query: { type: 'string', description: 'Optional search query to filter docs by name' }
45
+ }
46
+ }
47
+ },
48
+ {
49
+ name: 'get_doc_url',
50
+ description: 'Get the URL for a Google Doc',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ docId: { type: 'string', description: 'Google Doc ID' }
55
+ },
56
+ required: ['docId']
57
+ }
58
+ }
59
+ ];
60
+ // Register tool list handler
61
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
62
+ tools: TOOLS
63
+ }));
64
+ // Register tool call handler
65
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
66
+ const { name, arguments: args } = request.params;
67
+ try {
68
+ const auth = await getAuthClient();
69
+ const docs = google.docs({ version: 'v1', auth });
70
+ const drive = google.drive({ version: 'v3', auth });
71
+ switch (name) {
72
+ case 'create_google_doc': {
73
+ const { title, content, folderId } = args;
74
+ // Create the document
75
+ const createResponse = await docs.documents.create({
76
+ requestBody: { title }
77
+ });
78
+ const docId = createResponse.data.documentId;
79
+ // Convert markdown to Docs API requests and apply
80
+ const requests = markdownToDocsRequests(content);
81
+ if (requests.length > 0) {
82
+ await docs.documents.batchUpdate({
83
+ documentId: docId,
84
+ requestBody: { requests }
85
+ });
86
+ }
87
+ // Move to folder if specified
88
+ if (folderId) {
89
+ await drive.files.update({
90
+ fileId: docId,
91
+ addParents: folderId,
92
+ fields: 'id, parents'
93
+ });
94
+ }
95
+ const url = `https://docs.google.com/document/d/${docId}/edit`;
96
+ return {
97
+ content: [{ type: 'text', text: JSON.stringify({ docId, url, title }, null, 2) }]
98
+ };
99
+ }
100
+ case 'update_google_doc': {
101
+ const { docId, content, mode = 'replace' } = args;
102
+ if (mode === 'replace') {
103
+ // Get current document to find content length
104
+ const doc = await docs.documents.get({ documentId: docId });
105
+ const endIndex = doc.data.body?.content?.slice(-1)[0]?.endIndex || 1;
106
+ // Delete existing content (except the trailing newline)
107
+ if (endIndex > 2) {
108
+ await docs.documents.batchUpdate({
109
+ documentId: docId,
110
+ requestBody: {
111
+ requests: [{ deleteContentRange: { range: { startIndex: 1, endIndex: endIndex - 1 } } }]
112
+ }
113
+ });
114
+ }
115
+ }
116
+ // Insert new content
117
+ const requests = markdownToDocsRequests(content);
118
+ if (requests.length > 0) {
119
+ // For append mode, we need to get the current end index
120
+ if (mode === 'append') {
121
+ const doc = await docs.documents.get({ documentId: docId });
122
+ const endIndex = (doc.data.body?.content?.slice(-1)[0]?.endIndex || 1) - 1;
123
+ // Adjust all request indices
124
+ for (const req of requests) {
125
+ if (req.insertText?.location?.index != null) {
126
+ req.insertText.location.index += endIndex;
127
+ }
128
+ if (req.updateTextStyle?.range) {
129
+ req.updateTextStyle.range.startIndex += endIndex;
130
+ req.updateTextStyle.range.endIndex += endIndex;
131
+ }
132
+ if (req.updateParagraphStyle?.range) {
133
+ req.updateParagraphStyle.range.startIndex += endIndex;
134
+ req.updateParagraphStyle.range.endIndex += endIndex;
135
+ }
136
+ if (req.createParagraphBullets?.range) {
137
+ req.createParagraphBullets.range.startIndex += endIndex;
138
+ req.createParagraphBullets.range.endIndex += endIndex;
139
+ }
140
+ }
141
+ }
142
+ await docs.documents.batchUpdate({
143
+ documentId: docId,
144
+ requestBody: { requests }
145
+ });
146
+ }
147
+ const url = `https://docs.google.com/document/d/${docId}/edit`;
148
+ return {
149
+ content: [{ type: 'text', text: JSON.stringify({ success: true, docId, url }, null, 2) }]
150
+ };
151
+ }
152
+ case 'list_recent_docs': {
153
+ const { limit = 10, query } = args;
154
+ let q = "mimeType='application/vnd.google-apps.document'";
155
+ if (query) {
156
+ q += ` and name contains '${query}'`;
157
+ }
158
+ const response = await drive.files.list({
159
+ q,
160
+ pageSize: limit,
161
+ orderBy: 'modifiedTime desc',
162
+ fields: 'files(id, name, modifiedTime, webViewLink)'
163
+ });
164
+ const docs = response.data.files?.map(file => ({
165
+ id: file.id,
166
+ title: file.name,
167
+ modifiedTime: file.modifiedTime,
168
+ url: file.webViewLink
169
+ })) || [];
170
+ return {
171
+ content: [{ type: 'text', text: JSON.stringify({ docs }, null, 2) }]
172
+ };
173
+ }
174
+ case 'get_doc_url': {
175
+ const { docId } = args;
176
+ return {
177
+ content: [{
178
+ type: 'text',
179
+ text: JSON.stringify({
180
+ url: `https://docs.google.com/document/d/${docId}/edit`,
181
+ viewUrl: `https://docs.google.com/document/d/${docId}/view`,
182
+ exportPdfUrl: `https://docs.google.com/document/d/${docId}/export?format=pdf`
183
+ }, null, 2)
184
+ }]
185
+ };
186
+ }
187
+ default:
188
+ return {
189
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
190
+ isError: true
191
+ };
192
+ }
193
+ }
194
+ catch (error) {
195
+ const message = error instanceof Error ? error.message : String(error);
196
+ return {
197
+ content: [{ type: 'text', text: `Error: ${message}` }],
198
+ isError: true
199
+ };
200
+ }
201
+ });
202
+ // Start the server
203
+ async function main() {
204
+ const transport = new StdioServerTransport();
205
+ await server.connect(transport);
206
+ console.error('GDocs Minimal MCP server running');
207
+ }
208
+ main().catch(console.error);
@@ -0,0 +1,8 @@
1
+ import { docs_v1 } from 'googleapis';
2
+ type Request = docs_v1.Schema$Request;
3
+ /**
4
+ * Convert markdown to Google Docs API requests
5
+ * Two-pass approach: collect all content first, then generate requests
6
+ */
7
+ export declare function markdownToDocsRequests(markdown: string): Request[];
8
+ export {};
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Convert markdown to Google Docs API requests
3
+ * Two-pass approach: collect all content first, then generate requests
4
+ */
5
+ export function markdownToDocsRequests(markdown) {
6
+ // Pass 1: Parse markdown into blocks
7
+ const blocks = parseMarkdown(markdown);
8
+ // Pass 2: Generate requests (inserts first, then formatting)
9
+ return generateRequests(blocks);
10
+ }
11
+ function parseMarkdown(markdown) {
12
+ const blocks = [];
13
+ const lines = markdown.split('\n');
14
+ let inCodeBlock = false;
15
+ let codeBlockContent = '';
16
+ for (const line of lines) {
17
+ // Handle code blocks
18
+ if (line.startsWith('```')) {
19
+ if (inCodeBlock) {
20
+ if (codeBlockContent) {
21
+ blocks.push({
22
+ type: 'code',
23
+ cleanText: codeBlockContent,
24
+ styles: []
25
+ });
26
+ }
27
+ inCodeBlock = false;
28
+ codeBlockContent = '';
29
+ }
30
+ else {
31
+ inCodeBlock = true;
32
+ }
33
+ continue;
34
+ }
35
+ if (inCodeBlock) {
36
+ codeBlockContent += (codeBlockContent ? '\n' : '') + line;
37
+ continue;
38
+ }
39
+ // Skip empty lines - add empty paragraph
40
+ if (!line.trim()) {
41
+ blocks.push({ type: 'paragraph', cleanText: '', styles: [] });
42
+ continue;
43
+ }
44
+ // Parse the line into a block
45
+ blocks.push(parseLine(line));
46
+ }
47
+ return blocks;
48
+ }
49
+ function parseLine(line) {
50
+ // Headings: # Heading
51
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
52
+ if (headingMatch) {
53
+ const { cleanText, styles } = parseInlineFormatting(headingMatch[2]);
54
+ return { type: 'heading', level: headingMatch[1].length, cleanText, styles };
55
+ }
56
+ // Horizontal rule: --- or *** or ___
57
+ if (/^[-*_]{3,}$/.test(line.trim())) {
58
+ return { type: 'hr', cleanText: '', styles: [] };
59
+ }
60
+ // Bullet lists: - item or * item
61
+ const bulletMatch = line.match(/^(\s*)[-*]\s+(.+)$/);
62
+ if (bulletMatch) {
63
+ const { cleanText, styles } = parseInlineFormatting(bulletMatch[2]);
64
+ return { type: 'bullet', level: Math.floor(bulletMatch[1].length / 2), cleanText, styles };
65
+ }
66
+ // Numbered lists: 1. item
67
+ const numberedMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
68
+ if (numberedMatch) {
69
+ const { cleanText, styles } = parseInlineFormatting(numberedMatch[2]);
70
+ return { type: 'numbered', level: Math.floor(numberedMatch[1].length / 2), cleanText, styles };
71
+ }
72
+ // Regular paragraph
73
+ const { cleanText, styles } = parseInlineFormatting(line);
74
+ return { type: 'paragraph', cleanText, styles };
75
+ }
76
+ function parseInlineFormatting(text) {
77
+ const styles = [];
78
+ const spans = [];
79
+ // 1. Find all links: [text](url)
80
+ const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
81
+ let match;
82
+ while ((match = linkRegex.exec(text)) !== null) {
83
+ spans.push({
84
+ type: 'link',
85
+ start: match.index,
86
+ end: match.index + match[0].length,
87
+ innerStart: match.index + 1, // after [
88
+ innerEnd: match.index + 1 + match[1].length, // before ]
89
+ url: match[2]
90
+ });
91
+ }
92
+ // 2. Find bold+italic: ***text*** (must be before bold/italic)
93
+ const boldItalicRegex = /\*\*\*(.+?)\*\*\*/g;
94
+ while ((match = boldItalicRegex.exec(text)) !== null) {
95
+ // Add both bold and italic spans for the same range
96
+ spans.push({
97
+ type: 'bold',
98
+ start: match.index,
99
+ end: match.index + match[0].length,
100
+ innerStart: match.index + 3,
101
+ innerEnd: match.index + 3 + match[1].length
102
+ });
103
+ spans.push({
104
+ type: 'italic',
105
+ start: match.index,
106
+ end: match.index + match[0].length,
107
+ innerStart: match.index + 3,
108
+ innerEnd: match.index + 3 + match[1].length
109
+ });
110
+ }
111
+ // 3. Find bold: **text** (non-greedy, allows nested *)
112
+ const boldRegex = /\*\*(.+?)\*\*/g;
113
+ while ((match = boldRegex.exec(text)) !== null) {
114
+ // Skip if this overlaps with a bold+italic span
115
+ const overlaps = spans.some(s => s.type === 'bold' &&
116
+ !(match.index >= s.end || match.index + match[0].length <= s.start));
117
+ if (!overlaps) {
118
+ spans.push({
119
+ type: 'bold',
120
+ start: match.index,
121
+ end: match.index + match[0].length,
122
+ innerStart: match.index + 2,
123
+ innerEnd: match.index + 2 + match[1].length
124
+ });
125
+ }
126
+ }
127
+ // 4. Find italic: *text* (not adjacent to other *)
128
+ const italicRegex = /(?<!\*)\*([^*]+)\*(?!\*)/g;
129
+ while ((match = italicRegex.exec(text)) !== null) {
130
+ spans.push({
131
+ type: 'italic',
132
+ start: match.index,
133
+ end: match.index + match[0].length,
134
+ innerStart: match.index + 1,
135
+ innerEnd: match.index + 1 + match[1].length
136
+ });
137
+ }
138
+ // 5. Build clean text by removing all markdown syntax
139
+ // Sort spans by start position
140
+ spans.sort((a, b) => a.start - b.start);
141
+ // Build a map of characters to remove
142
+ const toRemove = new Set();
143
+ for (const span of spans) {
144
+ if (span.type === 'link') {
145
+ // Remove [ ] ( url )
146
+ toRemove.add(span.start); // [
147
+ for (let i = span.innerEnd; i < span.end; i++) {
148
+ toRemove.add(i); // ](url)
149
+ }
150
+ }
151
+ else if (span.type === 'bold') {
152
+ // Check if this is part of a *** sequence
153
+ const isBoldItalic = spans.some(s => s.type === 'italic' && s.start === span.start && s.end === span.end);
154
+ if (isBoldItalic) {
155
+ // Remove ***, not **
156
+ toRemove.add(span.start);
157
+ toRemove.add(span.start + 1);
158
+ toRemove.add(span.start + 2);
159
+ toRemove.add(span.end - 3);
160
+ toRemove.add(span.end - 2);
161
+ toRemove.add(span.end - 1);
162
+ }
163
+ else {
164
+ toRemove.add(span.start);
165
+ toRemove.add(span.start + 1);
166
+ toRemove.add(span.end - 2);
167
+ toRemove.add(span.end - 1);
168
+ }
169
+ }
170
+ else if (span.type === 'italic') {
171
+ // Skip if part of bold+italic (already handled)
172
+ const isBoldItalic = spans.some(s => s.type === 'bold' && s.start === span.start && s.end === span.end);
173
+ if (!isBoldItalic) {
174
+ toRemove.add(span.start);
175
+ toRemove.add(span.end - 1);
176
+ }
177
+ }
178
+ }
179
+ // Build clean text and position mapping
180
+ let cleanText = '';
181
+ const positionMap = []; // positionMap[cleanIndex] = originalIndex
182
+ for (let i = 0; i < text.length; i++) {
183
+ if (!toRemove.has(i)) {
184
+ positionMap.push(i);
185
+ cleanText += text[i];
186
+ }
187
+ }
188
+ // 6. Convert spans to styles with clean text positions
189
+ for (const span of spans) {
190
+ // Find where innerStart and innerEnd map to in clean text
191
+ let cleanStart = -1;
192
+ let cleanEnd = -1;
193
+ for (let i = 0; i < positionMap.length; i++) {
194
+ if (positionMap[i] >= span.innerStart && cleanStart === -1) {
195
+ cleanStart = i;
196
+ }
197
+ if (positionMap[i] < span.innerEnd) {
198
+ cleanEnd = i + 1;
199
+ }
200
+ }
201
+ if (cleanStart !== -1 && cleanEnd !== -1 && cleanStart < cleanEnd) {
202
+ // For bold+italic, we already added both spans, so skip duplicate italic
203
+ const isDuplicateBoldItalic = span.type === 'italic' && spans.some(s => s.type === 'bold' && s.start === span.start && s.end === span.end);
204
+ if (!isDuplicateBoldItalic || span.type === 'bold') {
205
+ styles.push({
206
+ type: span.type,
207
+ start: cleanStart,
208
+ end: cleanEnd,
209
+ url: span.url
210
+ });
211
+ }
212
+ // Add italic style for bold+italic combo
213
+ if (span.type === 'bold') {
214
+ const hasMatchingItalic = spans.some(s => s.type === 'italic' && s.start === span.start && s.end === span.end);
215
+ if (hasMatchingItalic) {
216
+ styles.push({
217
+ type: 'italic',
218
+ start: cleanStart,
219
+ end: cleanEnd
220
+ });
221
+ }
222
+ }
223
+ }
224
+ }
225
+ return { cleanText, styles };
226
+ }
227
+ function generateRequests(blocks) {
228
+ const insertRequests = [];
229
+ const formatRequests = [];
230
+ let currentIndex = 1; // Google Docs starts at index 1
231
+ for (const block of blocks) {
232
+ const text = block.cleanText + '\n';
233
+ const startIndex = currentIndex;
234
+ const endIndex = currentIndex + text.length;
235
+ // Insert text request
236
+ insertRequests.push({
237
+ insertText: {
238
+ location: { index: startIndex },
239
+ text
240
+ }
241
+ });
242
+ // Block-level formatting
243
+ if (block.type === 'heading' && block.level) {
244
+ formatRequests.push({
245
+ updateParagraphStyle: {
246
+ range: { startIndex, endIndex },
247
+ paragraphStyle: { namedStyleType: getHeadingStyle(block.level) },
248
+ fields: 'namedStyleType'
249
+ }
250
+ });
251
+ }
252
+ else if (block.type === 'bullet') {
253
+ formatRequests.push({
254
+ createParagraphBullets: {
255
+ range: { startIndex, endIndex },
256
+ bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE'
257
+ }
258
+ });
259
+ }
260
+ else if (block.type === 'numbered') {
261
+ formatRequests.push({
262
+ createParagraphBullets: {
263
+ range: { startIndex, endIndex },
264
+ bulletPreset: 'NUMBERED_DECIMAL_NESTED'
265
+ }
266
+ });
267
+ }
268
+ else if (block.type === 'code') {
269
+ formatRequests.push({
270
+ updateTextStyle: {
271
+ range: { startIndex, endIndex: endIndex - 1 }, // exclude trailing newline
272
+ textStyle: {
273
+ weightedFontFamily: { fontFamily: 'Courier New' },
274
+ fontSize: { magnitude: 10, unit: 'PT' }
275
+ },
276
+ fields: 'weightedFontFamily,fontSize'
277
+ }
278
+ });
279
+ }
280
+ else if (block.type === 'hr') {
281
+ // Google Docs doesn't have native HR - use bottom border on empty paragraph
282
+ formatRequests.push({
283
+ updateParagraphStyle: {
284
+ range: { startIndex, endIndex },
285
+ paragraphStyle: {
286
+ borderBottom: {
287
+ color: { color: { rgbColor: { red: 0.8, green: 0.8, blue: 0.8 } } },
288
+ width: { magnitude: 1, unit: 'PT' },
289
+ padding: { magnitude: 8, unit: 'PT' },
290
+ dashStyle: 'SOLID'
291
+ }
292
+ },
293
+ fields: 'borderBottom'
294
+ }
295
+ });
296
+ }
297
+ // Inline formatting (bold, italic, links)
298
+ for (const style of block.styles) {
299
+ const styleStart = startIndex + style.start;
300
+ const styleEnd = startIndex + style.end;
301
+ if (style.type === 'bold') {
302
+ formatRequests.push({
303
+ updateTextStyle: {
304
+ range: { startIndex: styleStart, endIndex: styleEnd },
305
+ textStyle: { bold: true },
306
+ fields: 'bold'
307
+ }
308
+ });
309
+ }
310
+ else if (style.type === 'italic') {
311
+ formatRequests.push({
312
+ updateTextStyle: {
313
+ range: { startIndex: styleStart, endIndex: styleEnd },
314
+ textStyle: { italic: true },
315
+ fields: 'italic'
316
+ }
317
+ });
318
+ }
319
+ else if (style.type === 'link' && style.url) {
320
+ formatRequests.push({
321
+ updateTextStyle: {
322
+ range: { startIndex: styleStart, endIndex: styleEnd },
323
+ textStyle: { link: { url: style.url } },
324
+ fields: 'link'
325
+ }
326
+ });
327
+ }
328
+ }
329
+ currentIndex = endIndex;
330
+ }
331
+ // Return inserts first, then formatting
332
+ return [...insertRequests, ...formatRequests];
333
+ }
334
+ function getHeadingStyle(level) {
335
+ const styles = {
336
+ 1: 'HEADING_1',
337
+ 2: 'HEADING_2',
338
+ 3: 'HEADING_3',
339
+ 4: 'HEADING_4',
340
+ 5: 'HEADING_5',
341
+ 6: 'HEADING_6'
342
+ };
343
+ return styles[level] || 'NORMAL_TEXT';
344
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "minimal-gdocs",
3
+ "version": "1.0.0",
4
+ "description": "Minimal MCP server for creating and updating Google Docs from markdown",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "minimal-gdocs": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "google-docs",
19
+ "claude",
20
+ "markdown",
21
+ "anthropic"
22
+ ],
23
+ "author": "Rowan Bradley",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/rowbradley/minimal-gdocs.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/rowbradley/minimal-gdocs/issues"
31
+ },
32
+ "homepage": "https://github.com/rowbradley/minimal-gdocs#readme",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.25.2",
35
+ "googleapis": "^140.0.0",
36
+ "open": "^10.1.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^20.0.0",
40
+ "typescript": "^5.0.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ },
45
+ "files": [
46
+ "dist",
47
+ "credentials.example.json",
48
+ "README.md",
49
+ "LICENSE"
50
+ ]
51
+ }