@lmdat/google-sheets-oauth-mcp 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.
Files changed (3) hide show
  1. package/README.md +208 -0
  2. package/dist/index.js +13 -0
  3. package/package.json +44 -0
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # google-sheets-oauth-mcp
2
+
3
+ Local MCP server (Node.js + TypeScript) đọc/ghi/tạo Google Sheets, kèm tạo chart, thông qua
4
+ **OAuth** — server hoạt động đại diện chính tài khoản Google đang đăng nhập, không cần share
5
+ thủ công từng file như cách dùng Service Account.
6
+
7
+ ## Yêu cầu
8
+
9
+ - Node.js 18+
10
+ - Tài khoản Google (cá nhân hoặc Workspace)
11
+ - 1 project trên Google Cloud Console
12
+
13
+ ## 1. Setup Google Cloud Console
14
+
15
+ 1. Vào [console.cloud.google.com](https://console.cloud.google.com), tạo hoặc chọn 1 project.
16
+ 2. **APIs & Services → Library** → enable **Google Sheets API** và **Google Drive API**.
17
+ 3. **APIs & Services → OAuth consent screen**:
18
+ - User type: chọn **External** (tài khoản Gmail cá nhân chỉ có lựa chọn này; **Internal**
19
+ chỉ dành cho tài khoản Google Workspace).
20
+ - Điền App name và support email (giá trị tùy ý, không ảnh hưởng chức năng).
21
+ - Sau khi tạo, vào tab **Audience** → bấm **Publish App** → Confirm. Bước này nên làm ngay
22
+ từ đầu — nếu bỏ qua, app ở trạng thái Testing và refresh token sẽ tự hết hạn sau 7 ngày.
23
+ 4. **APIs & Services → Credentials → Create Credentials → OAuth client ID**:
24
+ - Application type: **Desktop app** (không chọn Web application).
25
+ - Lưu lại `Client ID` và `Client secret` hiện ra sau khi tạo.
26
+
27
+ ## 2. Cài đặt
28
+
29
+ ```bash
30
+ npm install
31
+ ```
32
+
33
+ ## 3. Build
34
+
35
+ | Lệnh | Output | Dùng khi nào |
36
+ |---|---|---|
37
+ | `npm run build` | `build/index.js` + `build/auth.js` | Phát triển/debug, code dễ đọc |
38
+ | `npm run build:min` | `dist/index.js` (1 file, đã gộp + minify) | Dùng để chạy thật / publish |
39
+ | `npm run build:obfuscate` | `dist/index.js` (đã obfuscate) | Muốn code khó đọc hơn khi chia sẻ (không phải mã hóa, chỉ gây khó đọc) |
40
+
41
+ Build tối thiểu 1 lần trước khi cấu hình opencode ở bước tiếp theo.
42
+
43
+ ## 4. Cấu hình credential
44
+
45
+ ```bash
46
+ export GOOGLE_OAUTH_CLIENT_ID="xxx.apps.googleusercontent.com"
47
+ export GOOGLE_OAUTH_CLIENT_SECRET="GOCSPX-xxx"
48
+ ```
49
+
50
+ Hoặc khai báo trực tiếp trong config opencode ở bước 5 (mục `environment`), không cần export
51
+ ra shell.
52
+
53
+ **Biến môi trường optional:**
54
+
55
+ | Var | Default | Khi nào cần đổi |
56
+ |---|---|---|
57
+ | `GOOGLE_OAUTH_REDIRECT_PORT` | `53682` | Port bị chương trình khác chiếm dụng |
58
+ | `MCP_GSHEETS_TOKEN_DIR` | `~/.mcp-google-sheets` | Muốn lưu file token ở vị trí khác |
59
+
60
+ ## 5. Cấu hình trong opencode
61
+
62
+ Có 2 cách trỏ tới MCP server, chọn 1 trong 2.
63
+
64
+ ### Cách 1 — Trỏ trực tiếp đến file (đơn giản, không cần publish)
65
+
66
+ ```json
67
+ "google-sheets": {
68
+ "type": "local",
69
+ "command": [
70
+ "node",
71
+ "/duong-dan-tuyet-doi/mcp-google-sheets-oauth/dist/index.js"
72
+ ],
73
+ "enabled": true,
74
+ "environment": {
75
+ "GOOGLE_OAUTH_CLIENT_ID": "xxx.apps.googleusercontent.com",
76
+ "GOOGLE_OAUTH_CLIENT_SECRET": "GOCSPX-xxx"
77
+ }
78
+ }
79
+ ```
80
+
81
+ Thay `/duong-dan-tuyet-doi/...` bằng đường dẫn thật trên máy đang chạy opencode. Phù hợp khi
82
+ chỉ dùng trên 1 máy, không cần chia sẻ cho người khác.
83
+
84
+ ### Cách 2 — Dùng qua npm package (cần publish trước)
85
+
86
+ Nếu package đã được publish lên npm registry (xem mục 8), cấu hình gọn hơn, không cần biết
87
+ đường dẫn cụ thể trên máy:
88
+
89
+ ```json
90
+ "google-sheets": {
91
+ "type": "local",
92
+ "command": [
93
+ "npx",
94
+ "-y",
95
+ "ten-package@latest"
96
+ ],
97
+ "enabled": true,
98
+ "environment": {
99
+ "GOOGLE_OAUTH_CLIENT_ID": "xxx.apps.googleusercontent.com",
100
+ "GOOGLE_OAUTH_CLIENT_SECRET": "GOCSPX-xxx"
101
+ }
102
+ }
103
+ ```
104
+
105
+ Thay `ten-package` bằng tên thật trong `package.json` (ví dụ `@username/mcp-google-sheets-oauth`
106
+ nếu dùng scoped package). Phù hợp khi muốn dùng trên nhiều máy hoặc chia sẻ cho người khác mà
107
+ không cần gửi kèm source code.
108
+
109
+ ## 6. Lần đầu sử dụng — cần đăng nhập 1 lần
110
+
111
+ Khi opencode gọi tool đầu tiên (ví dụ `drive_list_spreadsheets`), server sẽ:
112
+
113
+ 1. Tự mở browser (hoặc in URL ra log nếu máy không tự mở được trình duyệt — copy URL đó dán
114
+ vào browser thủ công).
115
+ 2. Đăng nhập Google và bấm Allow.
116
+ 3. Browser hiện thông báo đăng nhập thành công → token được lưu vào
117
+ `~/.mcp-google-sheets/token.json`.
118
+
119
+ Từ lần sau không cần đăng nhập lại — token tự refresh ngầm.
120
+
121
+ > Flow này cần trình duyệt **trên cùng máy** đang chạy MCP server, vì server mở 1 HTTP server
122
+ > tạm trên `127.0.0.1` để nhận redirect từ Google. Nếu server được chạy trên môi trường không
123
+ > có giao diện (ví dụ VPS headless), flow này sẽ không hoạt động và cần cách xác thực khác.
124
+
125
+ ## 7. Đổi scope sau này → phải đăng nhập lại
126
+
127
+ Nếu chỉnh sửa danh sách scope trong `src/auth.ts`, cần xóa file token cũ
128
+ (`~/.mcp-google-sheets/token.json`) rồi chạy lại để đăng nhập lại. Google gắn cố định scope vào
129
+ thời điểm cấp quyền — token cũ không tự nhận thêm quyền mới.
130
+
131
+ ## 8. (Tùy chọn) Publish lên npm để dùng theo Cách 2
132
+
133
+ ```bash
134
+ npm login # nếu chưa đăng nhập npm
135
+ npm run build:min
136
+ npm publish --access public # bắt buộc thêm flag này nếu dùng scoped package (@username/...)
137
+ ```
138
+
139
+ Kiểm tra trước khi publish thật:
140
+
141
+ ```bash
142
+ npm run build:min && npm pack --dry-run
143
+ ```
144
+
145
+ ## 9. Danh sách tool
146
+
147
+ | Tool | Chức năng |
148
+ |---|---|
149
+ | `drive_list_spreadsheets` | Tìm sheet theo tên (Drive API), không cần biết ID trước |
150
+ | `sheets_create` | Tạo sheet mới, tùy chọn đặt vào folder cụ thể và tạo sẵn nhiều tab |
151
+ | `sheets_read` | Đọc 1 range, trả về mảng 2 chiều |
152
+ | `sheets_write` | Ghi đè giá trị trong 1 range |
153
+ | `sheets_append` | Thêm hàng vào cuối bảng dữ liệu hiện có |
154
+ | `sheets_clear` | Xóa giá trị trong 1 range (giữ nguyên format) |
155
+ | `sheets_list_tabs` | Liệt kê tên các tab trong file |
156
+ | `sheets_create_chart` | Tạo chart COLUMN/BAR/LINE/AREA/SCATTER/PIE từ dữ liệu có sẵn |
157
+ | `sheets_create_candlestick_chart` | Tạo chart nến (OHLC) cho dữ liệu giá |
158
+
159
+ Mọi tool đọc/ghi (trừ `drive_list_spreadsheets` và `sheets_create`) đều cần `spreadsheet_id` —
160
+ lấy từ URL của sheet hoặc dùng `drive_list_spreadsheets` để tìm theo tên.
161
+
162
+ ### Lưu ý khi dùng 2 tool tạo chart
163
+
164
+ - Mọi range truyền vào **phải ghi đủ tên sheet**, theo dạng `"TenSheet!A2:E50"`. Range thiếu
165
+ tên sheet không được hỗ trợ.
166
+ - `sheets_create_candlestick_chart` yêu cầu `data_range` có **đúng 5 cột liên tiếp**, theo thứ
167
+ tự cố định: **Date, Open, High, Low, Close** — đúng convention chuẩn của Google Sheets khi
168
+ chọn data bằng tay qua Insert > Chart. Sai thứ tự cột (ví dụ đảo Open/Close) **không gây ra
169
+ lỗi** — chart vẫn được tạo nhưng màu nến hiển thị ngược nghĩa, cần tự kiểm tra bằng mắt sau
170
+ khi tạo.
171
+ - `anchor_cell` (vị trí đặt chart) là optional ở cả 2 tool — nếu không truyền, chart tự đặt
172
+ ngay bên phải vùng dữ liệu nguồn.
173
+
174
+ ## 10. Scope OAuth đang sử dụng
175
+
176
+ ```typescript
177
+ const SCOPES = [
178
+ "https://www.googleapis.com/auth/spreadsheets", // đọc/ghi/tạo sheet
179
+ "https://www.googleapis.com/auth/drive.readonly", // tìm/list toàn bộ sheet user có quyền
180
+ "https://www.googleapis.com/auth/drive.file", // move file mới tạo vào folder
181
+ ];
182
+ ```
183
+
184
+ `drive.file` chỉ cho phép truy cập file do chính app này tạo ra (hoặc do user chọn qua
185
+ Picker) — không đủ để di chuyển hay sửa file cũ không phải do app tạo. Vì vậy cần thêm
186
+ `drive.readonly` riêng cho việc tìm/list sheet có sẵn. Scope `drive` (toàn quyền) không được
187
+ sử dụng vì rộng hơn mức cần thiết.
188
+
189
+ ## 11. Ví dụ câu lệnh trong opencode
190
+
191
+ ```
192
+ Tìm sheet có chữ "Ngân sách"
193
+ Tạo sheet mới tên "Theo dõi cổ phiếu 2026" với 2 tab: Giao dịch, Tổng hợp
194
+ Đọc A1:E20 trong tab Giao dịch của sheet [tên/id vừa tạo]
195
+ Append 1 hàng vào tab Giao dịch: 2026-06-25, VNM, Buy, 1000, 42500
196
+ Tạo candlestick chart "Giá VNM Q2 2026" từ range Data!A2:E50
197
+ ```
198
+
199
+ ## 12. So sánh với bản dùng Service Account
200
+
201
+ | | Service Account | OAuth (bản này) |
202
+ |---|---|---|
203
+ | Truy cập sheet có sẵn | Phải share thủ công email service account | Tự thấy mọi sheet user đã có quyền |
204
+ | Tìm sheet theo tên | Không hỗ trợ | Có, qua `drive_list_spreadsheets` |
205
+ | File mới tạo | Thuộc Drive của service account | Thuộc Drive của chính user |
206
+ | Setup ban đầu | Nhanh, không cần đăng nhập trình duyệt | Cần đăng nhập trình duyệt 1 lần, cấu hình OAuth consent screen |
207
+ | Phù hợp | Vài sheet cố định, biết trước ID | Cần linh hoạt nhiều sheet, tạo/tìm theo tên |
208
+
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import{McpServer as ee}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as te}from"@modelcontextprotocol/sdk/server/stdio.js";import{z as s}from"zod";import{OAuth2Client as K}from"google-auth-library";import w from"node:fs";import A from"node:path";import V from"node:os";import J from"node:http";import{URL as j}from"node:url";import{exec as z}from"node:child_process";var _=process.env.MCP_GSHEETS_TOKEN_DIR||A.join(V.homedir(),".mcp-google-sheets"),T=A.join(_,"token.json"),v=process.env.GOOGLE_OAUTH_CLIENT_ID,$=process.env.GOOGLE_OAUTH_CLIENT_SECRET,b=Number(process.env.GOOGLE_OAUTH_REDIRECT_PORT||53682),O=`http://127.0.0.1:${b}/oauth2callback`,q=["https://www.googleapis.com/auth/spreadsheets","https://www.googleapis.com/auth/drive.readonly","https://www.googleapis.com/auth/drive.file"];function W(){if(!v||!$)throw new Error("Thi\u1EBFu GOOGLE_OAUTH_CLIENT_ID ho\u1EB7c GOOGLE_OAUTH_CLIENT_SECRET trong env. L\u1EA5y 2 gi\xE1 tr\u1ECB n\xE0y t\u1EEB OAuth Client (lo\u1EA1i 'Desktop app') trong Google Cloud Console.");return new K({clientId:v,clientSecret:$,redirectUri:O})}function X(e){if(!w.existsSync(T))return!1;let t=JSON.parse(w.readFileSync(T,"utf-8"));return e.setCredentials(t),!0}function L(e){w.mkdirSync(_,{recursive:!0}),w.writeFileSync(T,JSON.stringify(e,null,2),{mode:384})}function Z(e){let t=process.platform,n=t==="darwin"?`open "${e}"`:t==="win32"?`start "" "${e}"`:`xdg-open "${e}"`;z(n,()=>{})}async function Q(e){let t=e.generateAuthUrl({access_type:"offline",scope:q,prompt:"consent"});console.error(`
3
+ [mcp-google-sheets-oauth] Ch\u01B0a c\xF3 token. M\u1EDF URL sau \u0111\u1EC3 \u0111\u0103ng nh\u1EADp (\u0111ang t\u1EF1 m\u1EDF browser...):
4
+ ${t}
5
+ `),Z(t);let n=await new Promise((o,c)=>{let a=J.createServer((d,i)=>{i.setHeader("Content-Type","text/html; charset=utf-8");let h=new j(d.url||"",O),l=h.searchParams.get("code"),I=h.searchParams.get("error");if(I){i.end("\u0110\u0103ng nh\u1EADp b\u1ECB t\u1EEB ch\u1ED1i ho\u1EB7c l\u1ED7i. \u0110\xF3ng tab n\xE0y, xem log \u1EDF terminal."),a.close(),c(new Error(`Google tr\u1EA3 l\u1ED7i OAuth: ${I}`));return}if(l){i.end("<h2>\u0110\u0103ng nh\u1EADp th\xE0nh c\xF4ng!</h2><p>\u0110\xF3ng tab n\xE0y v\xE0 quay l\u1EA1i terminal/opencode.</p>"),a.close(),o(l);return}i.end("Thi\u1EBFu tham s\u1ED1 code/error trong redirect.")});a.listen(b,"127.0.0.1")}),{tokens:r}=await e.getToken(n);e.setCredentials(r),L(r),console.error(`[mcp-google-sheets-oauth] \u0110\xE3 l\u01B0u token v\xE0o: ${T}`)}var E=null;async function Y(){if(E)return E;let e=W();return X(e)||await Q(e),e.on("tokens",n=>{let r={...e.credentials,...n};L(r)}),E=e,e}async function G(){let e=await Y(),{token:t}=await e.getAccessToken();if(!t)throw new Error("Kh\xF4ng l\u1EA5y \u0111\u01B0\u1EE3c access token t\u1EEB OAuth client.");return t}var ne="https://sheets.googleapis.com/v4/spreadsheets",k="https://www.googleapis.com/drive/v3";async function re(e){try{let t=await e.json();if(t?.error?.message){let n=`Google API l\u1ED7i (${e.status}): ${t.error.message}`;return e.status===403&&(n+=`
6
+ \u2192 Kh\u1EA3 n\u0103ng cao: t\xE0i kho\u1EA3n anh ch\u01B0a c\xF3 quy\u1EC1n Edit/Viewer tr\xEAn sheet n\xE0y, ho\u1EB7c scope OAuth \u0111\xE3 xin ch\u01B0a \u0111\u1EE7 (c\u1EA7n re-consent v\u1EDBi scope m\u1EDBi).`),e.status===404&&(n+=`
7
+ \u2192 Ki\u1EC3m tra l\u1EA1i spreadsheet_id, c\xF3 th\u1EC3 sai ho\u1EB7c anh kh\xF4ng c\xF3 quy\u1EC1n truy c\u1EADp.`),n}return`Google API l\u1ED7i (${e.status}): ${JSON.stringify(t)}`}catch{return`Google API l\u1ED7i (${e.status}): ${e.statusText}`}}async function S(e,t,n){let r=await G(),o=await fetch(e,{method:t,headers:{Authorization:`Bearer ${r}`,"Content-Type":"application/json"},body:n!==void 0?JSON.stringify(n):void 0});if(!o.ok)throw new Error(await re(o));return o.json()}var u=(e,t,n)=>S(`${ne}${e}`,t,n),oe=s.union([s.string(),s.number(),s.boolean(),s.null()]),N=s.array(s.array(oe));function m(){return s.string().min(1).describe("ID c\u1EE7a Google Sheet \u2014 l\u1EA5y t\u1EEB URL (docs.google.com/spreadsheets/d/<ID>/edit) ho\u1EB7c d\xF9ng tool drive_list_spreadsheets \u0111\u1EC3 t\xECm theo t\xEAn.")}function y(e){return s.string().min(1).describe(`Range theo A1 notation, v\xED d\u1EE5 "${e}". C\xF3 th\u1EC3 ch\u1EC9 ghi t\xEAn sheet (vd "Sheet1") \u0111\u1EC3 l\u1EA5y/ghi c\u1EA3 sheet.`)}function x(e){let t=0;for(let n of e.toUpperCase())t=t*26+(n.charCodeAt(0)-64);return t-1}function se(e){let t=e.match(/^([^!]+)!([A-Za-z]+)(\d+):([A-Za-z]+)(\d+)$/);if(!t)throw new Error(`Range "${e}" kh\xF4ng \u0111\xFAng format. C\u1EA7n d\u1EA1ng "TenSheet!A1:E20" (b\u1EAFt bu\u1ED9c c\xF3 t\xEAn sheet, \u0111\u1EE7 2 g\xF3c range).`);let[,n,r,o,c,a]=t;return{sheetName:n,startRowIndex:parseInt(o,10)-1,endRowIndex:parseInt(a,10),startColumnIndex:x(r),endColumnIndex:x(c)+1}}function ae(e){let t=e.match(/^([^!]+)!([A-Za-z]+)(\d+)$/);if(!t)throw new Error(`Cell "${e}" kh\xF4ng \u0111\xFAng format. C\u1EA7n d\u1EA1ng "TenSheet!F2".`);let[,n,r,o]=t;return{sheetName:n,rowIndex:parseInt(o,10)-1,columnIndex:x(r)}}async function P(e,t,n){let r=n.get(t);if(r!==void 0)return r;let c=((await u(`/${e}?fields=sheets.properties`,"GET")).sheets||[]).find(a=>a.properties.title===t);if(!c)throw new Error(`Kh\xF4ng t\xECm th\u1EA5y tab "${t}" trong spreadsheet. D\xF9ng tool sheets_list_tabs \u0111\u1EC3 xem t\xEAn tab \u0111\xFAng.`);return n.set(t,c.properties.sheetId),c.properties.sheetId}async function R(e,t,n){let r=se(t),o=await P(e,r.sheetName,n);return{sheetName:r.sheetName,sheetId:o,gridRange:{sheetId:o,startRowIndex:r.startRowIndex,endRowIndex:r.endRowIndex,startColumnIndex:r.startColumnIndex,endColumnIndex:r.endColumnIndex}}}async function D(e,t,n){let r=ae(t);return{sheetId:await P(e,r.sheetName,n),rowIndex:r.rowIndex,columnIndex:r.columnIndex}}async function U(e,t,n){let r={requests:[{addChart:{chart:{spec:t,position:{overlayPosition:{anchorCell:{sheetId:n.sheetId,rowIndex:n.rowIndex,columnIndex:n.columnIndex},widthPixels:600,heightPixels:371}}}}}]},c=(await u(`/${e}:batchUpdate`,"POST",r)).replies?.[0]?.addChart?.chart?.chartId;if(c===void 0)throw new Error("T\u1EA1o chart kh\xF4ng tr\u1EA3 v\u1EC1 chartId \u2014 ki\u1EC3m tra l\u1EA1i response Google API.");return c}var g=new ee({name:"google-sheets-oauth",version:"1.0.0"});g.registerTool("drive_list_spreadsheets",{title:"T\xECm Google Sheet theo t\xEAn",description:"List c\xE1c Google Sheet trong Drive c\u1EE7a ch\xEDnh user \u0111ang login (kh\xF4ng c\u1EA7n share th\u1EE7 c\xF4ng, v\xEC OAuth \u0111\u1EA1i di\u1EC7n ch\xEDnh user \u0111\xF3). D\xF9ng \u0111\u1EC3 t\xECm spreadsheet_id theo t\xEAn thay v\xEC copy URL. Tr\u1EA3 v\u1EC1 JSON array, m\u1ED7i item: {name, id, url, modifiedTime, owner}.",inputSchema:{name_contains:s.string().optional().describe('L\u1ECDc theo t\xEAn file ch\u1EE9a chu\u1ED7i n\xE0y (vd "Ng\xE2n s\xE1ch"). B\u1ECF tr\u1ED1ng \u0111\u1EC3 l\u1EA5y g\u1EA7n \u0111\xE2y nh\u1EA5t.'),max_results:s.number().int().min(1).max(50).default(10)}},async({name_contains:e,max_results:t})=>{try{let n=["mimeType='application/vnd.google-apps.spreadsheet'","trashed=false"];if(e){let i=e.replace(/'/g,"\\'");n.push(`name contains '${i}'`)}let r=encodeURIComponent(n.join(" and ")),o=encodeURIComponent("files(id,name,modifiedTime,webViewLink,owners(displayName))"),d=((await S(`${k}/files?q=${r}&fields=${o}&orderBy=modifiedTime desc&pageSize=${t}`,"GET")).files||[]).map(i=>({name:i.name,id:i.id,url:i.webViewLink,modifiedTime:i.modifiedTime,owner:i.owners?.[0]?.displayName??null}));return{content:[{type:"text",text:JSON.stringify(d,null,2)}]}}catch(n){return{content:[{type:"text",text:`L\u1ED7i: ${n.message}`}],isError:!0}}});g.registerTool("sheets_create",{title:"T\u1EA1o Google Sheet m\u1EDBi",description:"T\u1EA1o 1 spreadsheet m\u1EDBi (r\u1ED7ng). M\u1EB7c \u0111\u1ECBnh n\u1EB1m \u1EDF root My Drive c\u1EE7a account \u0111ang login. Truy\u1EC1n folder_id n\u1EBFu mu\u1ED1n \u0111\u1EB7t lu\xF4n v\xE0o 1 folder c\u1EE5 th\u1EC3.",inputSchema:{title:s.string().min(1).describe("T\xEAn file Google Sheet m\u1EDBi."),folder_id:s.string().optional().describe("ID folder Drive mu\u1ED1n \u0111\u1EB7t file v\xE0o (l\u1EA5y t\u1EEB URL folder tr\xEAn Drive). B\u1ECF tr\u1ED1ng -> file n\u1EB1m \u1EDF root My Drive."),sheet_titles:s.array(s.string()).optional().describe('T\xEAn c\xE1c tab mu\u1ED1n t\u1EA1o s\u1EB5n, v\xED d\u1EE5 ["Thu","Chi"]. B\u1ECF tr\u1ED1ng -> 1 tab m\u1EB7c \u0111\u1ECBnh "Sheet1".')}},async({title:e,folder_id:t,sheet_titles:n})=>{try{let r={properties:{title:e}};n&&n.length>0&&(r.sheets=n.map(d=>({properties:{title:d}})));let o=await u("","POST",r),c=o.spreadsheetId,a=o.spreadsheetUrl;return t&&await S(`${k}/files/${c}?addParents=${t}&removeParents=root&fields=id,parents`,"PATCH"),{content:[{type:"text",text:`\u0110\xE3 t\u1EA1o sheet "${e}".
8
+ spreadsheet_id: ${c}
9
+ URL: ${a}${t?`
10
+ \u0110\xE3 move v\xE0o folder: ${t}`:""}`}]}}catch(r){return{content:[{type:"text",text:`L\u1ED7i: ${r.message}`}],isError:!0}}});g.registerTool("sheets_read",{title:"\u0110\u1ECDc d\u1EEF li\u1EC7u t\u1EEB Google Sheet",description:"\u0110\u1ECDc gi\xE1 tr\u1ECB 1 v\xF9ng (range) trong Google Sheet, tr\u1EA3 v\u1EC1 d\u1EA1ng m\u1EA3ng 2 chi\u1EC1u (h\xE0ng x c\u1ED9t).",inputSchema:{spreadsheet_id:m(),range:y("Sheet1!A1:D10")}},async({spreadsheet_id:e,range:t})=>{try{let r=(await u(`/${e}/values/${encodeURIComponent(t)}`,"GET")).values||[];return{content:[{type:"text",text:r.length===0?"Range tr\u1ED1ng, kh\xF4ng c\xF3 d\u1EEF li\u1EC7u.":JSON.stringify(r,null,2)}]}}catch(n){return{content:[{type:"text",text:`L\u1ED7i: ${n.message}`}],isError:!0}}});g.registerTool("sheets_write",{title:"Ghi \u0111\xE8 d\u1EEF li\u1EC7u v\xE0o Google Sheet",description:"Ghi \u0111\xE8 gi\xE1 tr\u1ECB v\xE0o 1 range c\u1EE5 th\u1EC3. D\u1EEF li\u1EC7u c\u0169 trong range s\u1EBD b\u1ECB thay th\u1EBF ho\xE0n to\xE0n.",inputSchema:{spreadsheet_id:m(),range:y("Sheet1!A1:C3"),values:N.describe('M\u1EA3ng 2 chi\u1EC1u, m\u1ED7i m\u1EA3ng con l\xE0 1 h\xE0ng. V\xED d\u1EE5: [["T\xEAn","Tu\u1ED5i"],["\u0110\u1EA1t",30]]'),value_input_option:s.enum(["RAW","USER_ENTERED"]).default("USER_ENTERED").describe("USER_ENTERED: Sheet t\u1EF1 parse nh\u01B0 khi anh g\xF5 tay (c\xF4ng th\u1EE9c, ng\xE0y th\xE1ng...). RAW: gi\u1EEF nguy\xEAn string.")}},async({spreadsheet_id:e,range:t,values:n,value_input_option:r})=>{try{let o=await u(`/${e}/values/${encodeURIComponent(t)}?valueInputOption=${r}`,"PUT",{values:n});return{content:[{type:"text",text:`\u0110\xE3 ghi ${o.updatedCells??"?"} cell v\xE0o range ${o.updatedRange}.`}]}}catch(o){return{content:[{type:"text",text:`L\u1ED7i: ${o.message}`}],isError:!0}}});g.registerTool("sheets_append",{title:"Append h\xE0ng m\u1EDBi v\xE0o Google Sheet",description:"Th\xEAm h\xE0ng m\u1EDBi v\xE0o cu\u1ED1i b\u1EA3ng d\u1EEF li\u1EC7u hi\u1EC7n c\xF3 (kh\xF4ng \u0111\xE8 d\u1EEF li\u1EC7u c\u0169).",inputSchema:{spreadsheet_id:m(),range:y("Sheet1 (ch\u1EC9 c\u1EA7n t\xEAn sheet)"),values:N.describe("M\u1EA3ng 2 chi\u1EC1u, m\u1ED7i m\u1EA3ng con l\xE0 1 h\xE0ng c\u1EA7n th\xEAm."),value_input_option:s.enum(["RAW","USER_ENTERED"]).default("USER_ENTERED")}},async({spreadsheet_id:e,range:t,values:n,value_input_option:r})=>{try{let c=(await u(`/${e}/values/${encodeURIComponent(t)}:append?valueInputOption=${r}&insertDataOption=INSERT_ROWS`,"POST",{values:n})).updates;return{content:[{type:"text",text:`\u0110\xE3 append ${c?.updatedRows??n.length} h\xE0ng v\xE0o ${c?.updatedRange??t}.`}]}}catch(o){return{content:[{type:"text",text:`L\u1ED7i: ${o.message}`}],isError:!0}}});g.registerTool("sheets_clear",{title:"X\xF3a gi\xE1 tr\u1ECB trong 1 range",description:"X\xF3a n\u1ED9i dung (gi\xE1 tr\u1ECB) trong 1 range, kh\xF4ng x\xF3a format/border.",inputSchema:{spreadsheet_id:m(),range:y("Sheet1!A2:D100")}},async({spreadsheet_id:e,range:t})=>{try{return await u(`/${e}/values/${encodeURIComponent(t)}:clear`,"POST",{}),{content:[{type:"text",text:`\u0110\xE3 x\xF3a d\u1EEF li\u1EC7u trong range ${t}.`}]}}catch(n){return{content:[{type:"text",text:`L\u1ED7i: ${n.message}`}],isError:!0}}});g.registerTool("sheets_list_tabs",{title:"List c\xE1c tab/sheet trong file",description:"Li\u1EC7t k\xEA t\xEAn + ID c\xE1c tab (sheet con) trong 1 Google Sheet file.",inputSchema:{spreadsheet_id:m()}},async({spreadsheet_id:e})=>{try{let t=await u(`/${e}?fields=properties.title,sheets.properties`,"GET"),n=(t.sheets||[]).map(r=>({title:r.properties.title,sheetId:r.properties.sheetId,rowCount:r.properties.gridProperties?.rowCount,columnCount:r.properties.gridProperties?.columnCount}));return{content:[{type:"text",text:`File: ${t.properties?.title}
11
+
12
+ Tabs:
13
+ ${JSON.stringify(n,null,2)}`}]}}catch(t){return{content:[{type:"text",text:`L\u1ED7i: ${t.message}`}],isError:!0}}});g.registerTool("sheets_create_chart",{title:"T\u1EA1o chart c\u01A1 b\u1EA3n trong Google Sheet",description:'T\u1EA1o 1 chart COLUMN/BAR/LINE/AREA/SCATTER/PIE t\u1EEB data c\xF3 s\u1EB5n, chart \u0111\u01B0\u1EE3c embed th\u1EB3ng v\xE0o tab. M\u1ECDi range PH\u1EA2I ghi \u0111\u1EE7 t\xEAn sheet, d\u1EA1ng "TenSheet!A2:A10" \u2014 kh\xF4ng h\u1ED7 tr\u1EE3 range thi\u1EBFu t\xEAn sheet.',inputSchema:{spreadsheet_id:m(),chart_type:s.enum(["COLUMN","BAR","LINE","AREA","SCATTER","PIE"]),title:s.string().min(1).describe("Ti\xEAu \u0111\u1EC1 chart."),domain_range:s.string().describe('Range nh\xE3n tr\u1EE5c X (category/labels), d\u1EA1ng "TenSheet!A2:A10". V\u1EDBi PIE \u0111\xE2y l\xE0 nh\xE3n t\u1EEBng ph\u1EA7n.'),series_ranges:s.array(s.string()).min(1).describe('Range gi\xE1 tr\u1ECB, m\u1ED7i string l\xE0 1 series, d\u1EA1ng "TenSheet!B2:B10". PIE ch\u1EC9 nh\u1EADn \u0111\xFAng 1 series.'),stacked:s.boolean().default(!1).describe("Ch\u1EC9 c\xF3 \xFD ngh\u0129a v\u1EDBi COLUMN/BAR/AREA. true = stacked chart."),anchor_cell:s.string().optional().describe('V\u1ECB tr\xED g\xF3c tr\xEAn-tr\xE1i \u0111\u1EB7t chart, d\u1EA1ng "TenSheet!F2". B\u1ECF tr\u1ED1ng -> t\u1EF1 \u0111\u1EB7t ngay b\xEAn ph\u1EA3i domain_range.')}},async({spreadsheet_id:e,chart_type:t,title:n,domain_range:r,series_ranges:o,stacked:c,anchor_cell:a})=>{try{if(t==="PIE"&&o.length!==1)throw new Error("PIE chart ch\u1EC9 nh\u1EADn \u0111\xFAng 1 series_ranges, \u0111ang truy\u1EC1n "+o.length+".");let d=new Map,i=await R(e,r,d),h=await Promise.all(o.map(p=>R(e,p,d))),l;t==="PIE"?l={title:n,pieChart:{legendPosition:"BOTTOM_LEGEND",domain:{sourceRange:{sources:[i.gridRange]}},series:{sourceRange:{sources:[h[0].gridRange]}}}}:l={title:n,basicChart:{chartType:t,legendPosition:"BOTTOM_LEGEND",domains:[{domain:{sourceRange:{sources:[i.gridRange]}}}],series:h.map(p=>({series:{sourceRange:{sources:[p.gridRange]}}})),...["COLUMN","BAR","AREA"].includes(t)&&c?{stackedType:"STACKED"}:{}}};let I=a?await D(e,a,d):{sheetId:i.sheetId,rowIndex:i.gridRange.startRowIndex,columnIndex:Math.max(i.gridRange.endColumnIndex,...h.map(p=>p.gridRange.endColumnIndex))+1},f=await U(e,l,I);return{content:[{type:"text",text:`\u0110\xE3 t\u1EA1o ${t} chart "${n}" (chartId: ${f}) trong tab "${i.sheetName}".`}]}}catch(d){return{content:[{type:"text",text:`L\u1ED7i: ${d.message}`}],isError:!0}}});g.registerTool("sheets_create_candlestick_chart",{title:"T\u1EA1o candlestick chart (n\u1EBFn) trong Google Sheet",description:'T\u1EA1o chart n\u1EBFn cho d\u1EEF li\u1EC7u gi\xE1 (OHLC). data_range PH\u1EA2I c\xF3 \u0110\xDANG 5 c\u1ED9t li\xEAn ti\u1EBFp theo th\u1EE9 t\u1EF1 c\u1ED1 \u0111\u1ECBnh: Date, Open, High, Low, Close \u2014 \u0111\xFAng convention chu\u1EA9n c\u1EE7a Google Sheets (gi\u1ED1ng khi t\u1EF1 ch\u1ECDn data tr\xEAn Insert > Chart b\u1EB1ng tay). V\xED d\u1EE5: "Data!A2:E50" (kh\xF4ng g\u1ED3m d\xF2ng header).',inputSchema:{spreadsheet_id:m(),title:s.string().min(1).describe("Ti\xEAu \u0111\u1EC1 chart."),data_range:s.string().describe('Range 5 c\u1ED9t li\xEAn ti\u1EBFp, th\u1EE9 t\u1EF1 C\u1ED0 \u0110\u1ECANH Date-Open-High-Low-Close, d\u1EA1ng "TenSheet!A2:E50". Kh\xF4ng g\u1ED3m d\xF2ng header.'),anchor_cell:s.string().optional().describe('V\u1ECB tr\xED \u0111\u1EB7t chart, d\u1EA1ng "TenSheet!G2". B\u1ECF tr\u1ED1ng -> t\u1EF1 \u0111\u1EB7t b\xEAn ph\u1EA3i data_range.')}},async({spreadsheet_id:e,title:t,data_range:n,anchor_cell:r})=>{try{let o=new Map,c=await R(e,n,o),{gridRange:a,sheetName:d}=c,i=a.endColumnIndex-a.startColumnIndex;if(i!==5)throw new Error(`data_range ph\u1EA3i c\xF3 \u0110\xDANG 5 c\u1ED9t theo th\u1EE9 t\u1EF1 Date, Open, High, Low, Close \u2014 hi\u1EC7n t\u1EA1i \u0111ang c\xF3 ${i} c\u1ED9t. Ki\u1EC3m tra l\u1EA1i range "${n}".`);let h=C=>({sheetId:a.sheetId,startRowIndex:a.startRowIndex,endRowIndex:a.endRowIndex,startColumnIndex:a.startColumnIndex+C,endColumnIndex:a.startColumnIndex+C+1}),l=h(0),I=h(1),f=h(2),p=h(3),B=h(4),M={title:t,candlestickChart:{domain:{data:{sourceRange:{sources:[l]}}},data:[{lowSeries:{data:{sourceRange:{sources:[p]}}},openSeries:{data:{sourceRange:{sources:[I]}}},closeSeries:{data:{sourceRange:{sources:[B]}}},highSeries:{data:{sourceRange:{sources:[f]}}}}]}},H=r?await D(e,r,o):{sheetId:a.sheetId,rowIndex:a.startRowIndex,columnIndex:a.endColumnIndex+1},F=await U(e,M,H);return{content:[{type:"text",text:`\u0110\xE3 t\u1EA1o candlestick chart "${t}" (chartId: ${F}) trong tab "${d}".`}]}}catch(o){return{content:[{type:"text",text:`L\u1ED7i: ${o.message}`}],isError:!0}}});async function ie(){let e=new te;await g.connect(e),console.error("[mcp-google-sheets-oauth] Server \u0111ang ch\u1EA1y (stdio).")}ie().catch(e=>{console.error("[mcp-google-sheets-oauth] Fatal error:",e),process.exit(1)});
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@lmdat/google-sheets-oauth-mcp",
3
+ "version": "1.0.0",
4
+ "description": "Local MCP server đọc/ghi/tạo Google Sheets qua OAuth (đại diện chính user, không cần share thủ công).",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "bin": {
11
+ "google-sheets-oauth-mcp": "dist/index.js"
12
+ },
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "start": "node build/index.js",
16
+ "dev": "tsc --watch",
17
+ "prepublishOnly": "npm run build:min",
18
+ "build:bundle": "npm run build && esbuild build/index.js --bundle --platform=node --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/sdk --external:zod --external:google-auth-library",
19
+ "build:min": "npm run build && esbuild build/index.js --bundle --platform=node --format=esm --outfile=dist/index.js --minify --external:@modelcontextprotocol/sdk --external:zod --external:google-auth-library",
20
+ "build:obfuscate": "npm run build:min && javascript-obfuscator dist/index.js --output dist/index.js --compact true --string-array true --string-array-encoding base64 --rename-globals false --self-defending false"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "keywords": [
27
+ "mcp",
28
+ "google-sheets",
29
+ "oauth"
30
+ ],
31
+ "author": "",
32
+ "license": "ISC",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "google-auth-library": "^10.9.0",
36
+ "zod": "^4.4.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^26.0.0",
40
+ "esbuild": "^0.28.1",
41
+ "javascript-obfuscator": "^5.4.3",
42
+ "typescript": "^6.0.3"
43
+ }
44
+ }