google-sheet-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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/docs/setup-google.md +122 -0
- package/docs/setup-oauth2.md +177 -0
- package/examples/claude-mcp.json +12 -0
- package/examples/codex-mcp.json +12 -0
- package/examples/cursor-mcp.json +12 -0
- package/examples/vscode-mcp.json +13 -0
- package/package.json +53 -0
- package/src/cli/cli.mjs +87 -0
- package/src/cli/commands/append.mjs +55 -0
- package/src/cli/commands/config.mjs +93 -0
- package/src/cli/commands/create.mjs +45 -0
- package/src/cli/commands/helpers/show-mcp-config.mjs +71 -0
- package/src/cli/commands/init.mjs +415 -0
- package/src/cli/commands/list.mjs +50 -0
- package/src/cli/commands/read.mjs +64 -0
- package/src/cli/commands/test.mjs +99 -0
- package/src/cli/commands/token-status.mjs +114 -0
- package/src/config/config.mjs +166 -0
- package/src/server/oauth2-client.mjs +155 -0
- package/src/server/server.mjs +524 -0
- package/src/server/sheets-client.mjs +106 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* token-status ā Check OAuth2 refresh token health.
|
|
3
|
+
*
|
|
4
|
+
* Validates the refresh token by attempting to get a fresh access token.
|
|
5
|
+
* Useful for:
|
|
6
|
+
* - Debugging "access denied" errors
|
|
7
|
+
* - Checking if token was revoked
|
|
8
|
+
* - Pre-flight check before CI/CD usage
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
import ora from "ora";
|
|
13
|
+
import { loadConfig } from "../../config/config.mjs";
|
|
14
|
+
import {
|
|
15
|
+
validateRefreshToken,
|
|
16
|
+
getTokenInfo,
|
|
17
|
+
} from "../../server/oauth2-client.mjs";
|
|
18
|
+
|
|
19
|
+
export async function tokenStatusCommand() {
|
|
20
|
+
console.log(chalk.bold.cyan("\nš Google Sheet MCP ā Token Status\n"));
|
|
21
|
+
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
if (!config) {
|
|
24
|
+
console.error(
|
|
25
|
+
chalk.red(
|
|
26
|
+
"ā No configuration found. Run `npx google-sheet-mcp init --auth oauth` first."
|
|
27
|
+
)
|
|
28
|
+
);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(chalk.gray(` Config source: ${config.source}`));
|
|
33
|
+
console.log(chalk.gray(` Auth type: ${config.authType || "service-account"}`));
|
|
34
|
+
console.log();
|
|
35
|
+
|
|
36
|
+
if (config.authType !== "oauth2") {
|
|
37
|
+
console.log(
|
|
38
|
+
chalk.yellow(
|
|
39
|
+
"ā ļø Token-status is only relevant for OAuth2 authentication.\n" +
|
|
40
|
+
" Service accounts use JSON keys that don't expire."
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Show stored token info
|
|
47
|
+
const info = getTokenInfo(config.oauth2);
|
|
48
|
+
console.log(chalk.bold("Stored token:"));
|
|
49
|
+
console.log(chalk.white(` Status: ${info.status}`));
|
|
50
|
+
if (info.clientId) {
|
|
51
|
+
console.log(chalk.white(` Client ID: ${info.clientId}`));
|
|
52
|
+
}
|
|
53
|
+
if (info.refreshTokenPrefix) {
|
|
54
|
+
console.log(
|
|
55
|
+
chalk.white(` Refresh: ${info.refreshTokenPrefix}`)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (info.issues) {
|
|
59
|
+
for (const issue of info.issues) {
|
|
60
|
+
console.log(chalk.red(` ā ${issue}`));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
console.log();
|
|
64
|
+
|
|
65
|
+
if (info.status !== "configured") {
|
|
66
|
+
console.log(
|
|
67
|
+
chalk.red(
|
|
68
|
+
"ā Token is incomplete. Run `npx google-sheet-mcp init --auth oauth` to reconfigure."
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate by refreshing
|
|
75
|
+
const spinner = ora("Validating refresh token...").start();
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const result = await validateRefreshToken(config.oauth2);
|
|
79
|
+
|
|
80
|
+
if (result.valid) {
|
|
81
|
+
spinner.succeed("Token is valid");
|
|
82
|
+
console.log();
|
|
83
|
+
console.log(chalk.green("ā
Refresh token is healthy"));
|
|
84
|
+
console.log(chalk.gray(` Access token expires: ${result.expiresAt}`));
|
|
85
|
+
console.log(chalk.gray(` Scopes: ${result.scopes}`));
|
|
86
|
+
console.log();
|
|
87
|
+
console.log(
|
|
88
|
+
chalk.white(
|
|
89
|
+
" AI agents can now access your Google Sheets on your behalf."
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
spinner.fail(`Token is invalid: ${result.error}`);
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(chalk.yellow(` Hint: ${result.hint}`));
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(
|
|
98
|
+
chalk.white(" To replace the token, run:")
|
|
99
|
+
);
|
|
100
|
+
console.log(
|
|
101
|
+
chalk.cyan(" npx google-sheet-mcp init --auth oauth")
|
|
102
|
+
);
|
|
103
|
+
console.log(
|
|
104
|
+
chalk.gray(
|
|
105
|
+
" This will walk you through the OAuth flow and save a new refresh token."
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
spinner.fail(`Validation error: ${err.message}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration management.
|
|
3
|
+
*
|
|
4
|
+
* Supports two auth types:
|
|
5
|
+
* 1. Service Account: GOOGLE_SPREADSHEET_ID + GOOGLE_APPLICATION_CREDENTIALS
|
|
6
|
+
* 2. OAuth2: GOOGLE_SPREADSHEET_ID + GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + GOOGLE_REFRESH_TOKEN
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. ENV vars
|
|
10
|
+
* 2. .google-sheet-mcp.json in CWD
|
|
11
|
+
* 3. ~/.google-sheet-mcp.json (global)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
15
|
+
import { homedir } from "os";
|
|
16
|
+
import { join, resolve } from "path";
|
|
17
|
+
|
|
18
|
+
const CONFIG_FILENAME = ".google-sheet-mcp.json";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect auth type from env vars.
|
|
22
|
+
*/
|
|
23
|
+
function detectAuthFromEnv() {
|
|
24
|
+
const spreadsheetId = process.env.GOOGLE_SPREADSHEET_ID;
|
|
25
|
+
if (!spreadsheetId) return null;
|
|
26
|
+
|
|
27
|
+
// Service account
|
|
28
|
+
const saCreds = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
29
|
+
if (saCreds) {
|
|
30
|
+
return {
|
|
31
|
+
spreadsheetId,
|
|
32
|
+
authType: "service-account",
|
|
33
|
+
credentialsPath: saCreds,
|
|
34
|
+
source: "env",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// OAuth2
|
|
39
|
+
const clientId = process.env.GOOGLE_CLIENT_ID;
|
|
40
|
+
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
41
|
+
const refreshToken = process.env.GOOGLE_REFRESH_TOKEN;
|
|
42
|
+
if (clientId && clientSecret && refreshToken) {
|
|
43
|
+
return {
|
|
44
|
+
spreadsheetId,
|
|
45
|
+
authType: "oauth2",
|
|
46
|
+
oauth2: {
|
|
47
|
+
client_id: clientId,
|
|
48
|
+
client_secret: clientSecret,
|
|
49
|
+
refresh_token: refreshToken,
|
|
50
|
+
},
|
|
51
|
+
source: "env",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate a config object has all required fields.
|
|
60
|
+
*/
|
|
61
|
+
function isValidConfig(config) {
|
|
62
|
+
if (!config || !config.spreadsheetId) return false;
|
|
63
|
+
|
|
64
|
+
if (config.authType === "service-account") {
|
|
65
|
+
return !!config.credentialsPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (config.authType === "oauth2") {
|
|
69
|
+
return !!(
|
|
70
|
+
config.oauth2?.client_id &&
|
|
71
|
+
config.oauth2?.client_secret &&
|
|
72
|
+
config.oauth2?.refresh_token
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load config from local file, global file, or env vars.
|
|
81
|
+
*/
|
|
82
|
+
export function loadConfig() {
|
|
83
|
+
// 1. ENV vars ā highest priority
|
|
84
|
+
const envConfig = detectAuthFromEnv();
|
|
85
|
+
if (envConfig) return envConfig;
|
|
86
|
+
|
|
87
|
+
// 2. Local config (.google-sheet-mcp.json in CWD)
|
|
88
|
+
const localPath = resolve(process.cwd(), CONFIG_FILENAME);
|
|
89
|
+
if (existsSync(localPath)) {
|
|
90
|
+
const config = JSON.parse(readFileSync(localPath, "utf-8"));
|
|
91
|
+
if (isValidConfig(config)) {
|
|
92
|
+
config._path = localPath;
|
|
93
|
+
config.source = "local";
|
|
94
|
+
return config;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. Global config (~/.google-sheet-mcp.json)
|
|
99
|
+
const globalPath = join(homedir(), CONFIG_FILENAME);
|
|
100
|
+
if (existsSync(globalPath)) {
|
|
101
|
+
const config = JSON.parse(readFileSync(globalPath, "utf-8"));
|
|
102
|
+
if (isValidConfig(config)) {
|
|
103
|
+
config._path = globalPath;
|
|
104
|
+
config.source = "global";
|
|
105
|
+
return config;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Save config to local file (CWD) or global (~/).
|
|
114
|
+
*
|
|
115
|
+
* Config shape:
|
|
116
|
+
* Service Account: { spreadsheetId, authType: "service-account", credentialsPath, sheets }
|
|
117
|
+
* OAuth2: { spreadsheetId, authType: "oauth2", oauth2: { client_id, client_secret, refresh_token }, sheets }
|
|
118
|
+
*/
|
|
119
|
+
export function saveConfig(
|
|
120
|
+
{ spreadsheetId, authType, credentialsPath, oauth2, sheets },
|
|
121
|
+
global = false
|
|
122
|
+
) {
|
|
123
|
+
const configPath = global
|
|
124
|
+
? join(homedir(), CONFIG_FILENAME)
|
|
125
|
+
: resolve(process.cwd(), CONFIG_FILENAME);
|
|
126
|
+
|
|
127
|
+
const existing = existsSync(configPath)
|
|
128
|
+
? JSON.parse(readFileSync(configPath, "utf-8"))
|
|
129
|
+
: {};
|
|
130
|
+
|
|
131
|
+
const config = {
|
|
132
|
+
...existing,
|
|
133
|
+
spreadsheetId,
|
|
134
|
+
authType: authType || existing.authType || "service-account",
|
|
135
|
+
sheets: sheets || existing.sheets || [],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (authType === "oauth2" || existing.authType === "oauth2") {
|
|
139
|
+
config.oauth2 = oauth2 || existing.oauth2 || {};
|
|
140
|
+
delete config.credentialsPath;
|
|
141
|
+
} else {
|
|
142
|
+
config.credentialsPath = credentialsPath || existing.credentialsPath;
|
|
143
|
+
delete config.oauth2;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
147
|
+
return configPath;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract spreadsheetId from Google Sheets URL.
|
|
152
|
+
*/
|
|
153
|
+
export function extractSpreadsheetId(url) {
|
|
154
|
+
// Formats:
|
|
155
|
+
// https://docs.google.com/spreadsheets/d/<ID>/edit
|
|
156
|
+
// https://docs.google.com/spreadsheets/d/<ID>/edit#gid=0
|
|
157
|
+
const match = url.match(
|
|
158
|
+
/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/
|
|
159
|
+
);
|
|
160
|
+
if (match) return match[1];
|
|
161
|
+
|
|
162
|
+
// Maybe it's already a raw ID
|
|
163
|
+
if (/^[a-zA-Z0-9_-]{20,}$/.test(url)) return url;
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 Google Sheets client.
|
|
3
|
+
*
|
|
4
|
+
* Uses refresh token ā no browser login needed after initial setup.
|
|
5
|
+
* Token auto-refreshes via googleapis library.
|
|
6
|
+
*
|
|
7
|
+
* Setup flow (one-time):
|
|
8
|
+
* 1. Create OAuth2 credentials in Google Cloud Console
|
|
9
|
+
* 2. Run `npx google-sheet-mcp init --auth oauth`
|
|
10
|
+
* 3. Follow browser OAuth flow
|
|
11
|
+
* 4. Refresh token is saved and auto-refreshed forever
|
|
12
|
+
*
|
|
13
|
+
* Token health:
|
|
14
|
+
* - `npx google-sheet-mcp token-status` ā check if token is valid
|
|
15
|
+
* - Invalid token ā run `npx google-sheet-mcp init --auth oauth` again
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { google } from "googleapis";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create OAuth2 client from stored refresh token.
|
|
22
|
+
* Automatically refreshes access token if expired.
|
|
23
|
+
*/
|
|
24
|
+
export function createOAuth2Client(credentials) {
|
|
25
|
+
const { client_id, client_secret, refresh_token } = credentials;
|
|
26
|
+
|
|
27
|
+
if (!client_id || !client_secret) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
"Missing OAuth2 credentials: client_id and client_secret are required.\n" +
|
|
30
|
+
"Get them from Google Cloud Console ā APIs & Services ā Credentials ā Create OAuth client ID."
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!refresh_token) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"Missing refresh_token. Run `npx google-sheet-mcp init --auth oauth` to complete OAuth2 setup."
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const auth = new google.auth.OAuth2(client_id, client_secret);
|
|
41
|
+
auth.setCredentials({ refresh_token });
|
|
42
|
+
|
|
43
|
+
// Auto-refresh: googleapis handles this transparently.
|
|
44
|
+
// Every API call checks if the access token is expired and refreshes if needed.
|
|
45
|
+
|
|
46
|
+
return auth;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create sheets client using OAuth2.
|
|
51
|
+
*/
|
|
52
|
+
export function createOAuth2SheetsClient(credentials) {
|
|
53
|
+
const auth = createOAuth2Client(credentials);
|
|
54
|
+
return google.sheets({ version: "v4", auth });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate the OAuth2 authorization URL.
|
|
59
|
+
* User visits this URL, grants access, gets a code.
|
|
60
|
+
*/
|
|
61
|
+
export function generateAuthUrl(client_id, client_secret) {
|
|
62
|
+
const auth = new google.auth.OAuth2(
|
|
63
|
+
client_id,
|
|
64
|
+
client_secret,
|
|
65
|
+
"http://localhost:3000/oauth2callback"
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return auth.generateAuthUrl({
|
|
69
|
+
access_type: "offline", // ā CRITICAL: forces refresh_token to be returned
|
|
70
|
+
prompt: "consent", // ā CRITICAL: always show consent (ensures refresh_token every time)
|
|
71
|
+
scope: ["https://www.googleapis.com/auth/spreadsheets"],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Exchange authorization code for tokens.
|
|
77
|
+
* Returns { access_token, refresh_token, expiry_date }.
|
|
78
|
+
*/
|
|
79
|
+
export async function exchangeCodeForTokens(
|
|
80
|
+
client_id,
|
|
81
|
+
client_secret,
|
|
82
|
+
code
|
|
83
|
+
) {
|
|
84
|
+
const auth = new google.auth.OAuth2(
|
|
85
|
+
client_id,
|
|
86
|
+
client_secret,
|
|
87
|
+
"http://localhost:3000/oauth2callback"
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const { tokens } = await auth.getToken(code);
|
|
91
|
+
return tokens;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate a refresh token by trying to get a fresh access token.
|
|
96
|
+
*
|
|
97
|
+
* Returns { valid: true } or { valid: false, error: string }.
|
|
98
|
+
*/
|
|
99
|
+
export async function validateRefreshToken(credentials) {
|
|
100
|
+
const { client_id, client_secret, refresh_token } = credentials;
|
|
101
|
+
|
|
102
|
+
if (!client_id || !client_secret || !refresh_token) {
|
|
103
|
+
return {
|
|
104
|
+
valid: false,
|
|
105
|
+
error: "Missing credentials (client_id, client_secret, or refresh_token).",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const auth = new google.auth.OAuth2(client_id, client_secret);
|
|
111
|
+
auth.setCredentials({ refresh_token });
|
|
112
|
+
|
|
113
|
+
// Force token refresh ā this will fail if the refresh_token is invalid
|
|
114
|
+
const { credentials: newCreds } = await auth.refreshAccessToken();
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
valid: true,
|
|
118
|
+
expiresAt: newCreds.expiry_date
|
|
119
|
+
? new Date(newCreds.expiry_date).toISOString()
|
|
120
|
+
: "unknown",
|
|
121
|
+
scopes: newCreds.scope || "unknown",
|
|
122
|
+
};
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return {
|
|
125
|
+
valid: false,
|
|
126
|
+
error: err.message,
|
|
127
|
+
hint:
|
|
128
|
+
"The refresh token is invalid or revoked. Run `npx google-sheet-mcp init --auth oauth` to re-authenticate.",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get token info without making an API call.
|
|
135
|
+
* Just checks if the stored token exists and looks valid.
|
|
136
|
+
*/
|
|
137
|
+
export function getTokenInfo(credentials) {
|
|
138
|
+
const { client_id, client_secret, refresh_token } = credentials;
|
|
139
|
+
|
|
140
|
+
const issues = [];
|
|
141
|
+
|
|
142
|
+
if (!client_id) issues.push("Missing client_id");
|
|
143
|
+
if (!client_secret) issues.push("Missing client_secret");
|
|
144
|
+
if (!refresh_token) issues.push("Missing refresh_token");
|
|
145
|
+
|
|
146
|
+
if (issues.length > 0) {
|
|
147
|
+
return { status: "missing", issues };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
status: "configured",
|
|
152
|
+
clientId: `${client_id.substring(0, 12)}...`,
|
|
153
|
+
refreshTokenPrefix: `${refresh_token.substring(0, 12)}...`,
|
|
154
|
+
};
|
|
155
|
+
}
|