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 +21 -0
- package/README.md +172 -0
- package/credentials.example.json +11 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +83 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +208 -0
- package/dist/utils/markdown-to-docs.d.ts +8 -0
- package/dist/utils/markdown-to-docs.js +344 -0
- package/package.json +51 -0
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
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
|
+
}
|
package/dist/index.d.ts
ADDED
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
|
+
}
|