gogcli-mcp 2.20.0 → 2.21.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +130 -29
- package/dist/lib.js +91 -9
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/connector-runtime.ts +7 -2
- package/src/google-token.ts +225 -0
- package/src/remote-runner.ts +8 -1
- package/src/tools/auth.ts +6 -1
- package/src/tools/utils.ts +25 -3
- package/src/worker.ts +1 -1
- package/tests/google-token.test.ts +304 -0
- package/tests/sdk-single-copy.test.ts +48 -0
- package/tests/tools/auth.test.ts +21 -0
- package/tests/tools/utils.test.ts +44 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.21.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "gogcli",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
18
|
-
"version": "2.
|
|
18
|
+
"version": "2.21.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/dist/index.js
CHANGED
|
@@ -4264,8 +4264,8 @@ var require_core = __commonJS({
|
|
|
4264
4264
|
return this;
|
|
4265
4265
|
}
|
|
4266
4266
|
case "object": {
|
|
4267
|
-
const
|
|
4268
|
-
this._cache.delete(
|
|
4267
|
+
const cacheKey2 = schemaKeyRef;
|
|
4268
|
+
this._cache.delete(cacheKey2);
|
|
4269
4269
|
let id = schemaKeyRef[this.opts.schemaId];
|
|
4270
4270
|
if (id) {
|
|
4271
4271
|
id = (0, resolve_1.normalizeId)(id);
|
|
@@ -23068,17 +23068,33 @@ function normalizeObjectSchema(schema) {
|
|
|
23068
23068
|
}
|
|
23069
23069
|
return void 0;
|
|
23070
23070
|
}
|
|
23071
|
+
function getDotPath(path) {
|
|
23072
|
+
if (path.length === 0) {
|
|
23073
|
+
return "object root";
|
|
23074
|
+
}
|
|
23075
|
+
return path.reduce((acc, seg, index) => {
|
|
23076
|
+
if (index === 0) {
|
|
23077
|
+
return String(seg);
|
|
23078
|
+
}
|
|
23079
|
+
if (typeof seg === "number") {
|
|
23080
|
+
return `${acc}[${seg}]`;
|
|
23081
|
+
}
|
|
23082
|
+
return `${acc}.${seg}`;
|
|
23083
|
+
}, "");
|
|
23084
|
+
}
|
|
23071
23085
|
function getParseErrorMessage(error51) {
|
|
23072
23086
|
if (error51 && typeof error51 === "object") {
|
|
23087
|
+
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
23088
|
+
return error51.issues.map((i) => {
|
|
23089
|
+
if (!i.path?.length) {
|
|
23090
|
+
return i.message;
|
|
23091
|
+
}
|
|
23092
|
+
return `${i.message} at ${getDotPath(i.path)}`;
|
|
23093
|
+
}).join("\n");
|
|
23094
|
+
}
|
|
23073
23095
|
if ("message" in error51 && typeof error51.message === "string") {
|
|
23074
23096
|
return error51.message;
|
|
23075
23097
|
}
|
|
23076
|
-
if ("issues" in error51 && Array.isArray(error51.issues) && error51.issues.length > 0) {
|
|
23077
|
-
const firstIssue = error51.issues[0];
|
|
23078
|
-
if (firstIssue && typeof firstIssue === "object" && "message" in firstIssue) {
|
|
23079
|
-
return String(firstIssue.message);
|
|
23080
|
-
}
|
|
23081
|
-
}
|
|
23082
23098
|
try {
|
|
23083
23099
|
return JSON.stringify(error51);
|
|
23084
23100
|
} catch {
|
|
@@ -29693,16 +29709,7 @@ var Server = class extends Protocol {
|
|
|
29693
29709
|
if (!methodSchema) {
|
|
29694
29710
|
throw new Error("Schema is missing a method literal");
|
|
29695
29711
|
}
|
|
29696
|
-
|
|
29697
|
-
if (isZ4Schema(methodSchema)) {
|
|
29698
|
-
const v4Schema = methodSchema;
|
|
29699
|
-
const v4Def = v4Schema._zod?.def;
|
|
29700
|
-
methodValue = v4Def?.value ?? v4Schema.value;
|
|
29701
|
-
} else {
|
|
29702
|
-
const v3Schema = methodSchema;
|
|
29703
|
-
const legacyDef = v3Schema._def;
|
|
29704
|
-
methodValue = legacyDef?.value ?? v3Schema.value;
|
|
29705
|
-
}
|
|
29712
|
+
const methodValue = getLiteralValue(methodSchema);
|
|
29706
29713
|
if (typeof methodValue !== "string") {
|
|
29707
29714
|
throw new Error("Schema method literal must be a string");
|
|
29708
29715
|
}
|
|
@@ -30890,8 +30897,17 @@ var EMPTY_COMPLETION_RESULT = {
|
|
|
30890
30897
|
import process3 from "node:process";
|
|
30891
30898
|
|
|
30892
30899
|
// ../../node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
|
|
30900
|
+
var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
30893
30901
|
var ReadBuffer = class {
|
|
30902
|
+
constructor(options) {
|
|
30903
|
+
this._maxBufferSize = options?.maxBufferSize ?? STDIO_DEFAULT_MAX_BUFFER_SIZE;
|
|
30904
|
+
}
|
|
30894
30905
|
append(chunk) {
|
|
30906
|
+
const newSize = (this._buffer?.length ?? 0) + chunk.length;
|
|
30907
|
+
if (newSize > this._maxBufferSize) {
|
|
30908
|
+
this.clear();
|
|
30909
|
+
throw new Error(`ReadBuffer exceeded maximum size of ${this._maxBufferSize} bytes`);
|
|
30910
|
+
}
|
|
30895
30911
|
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
|
|
30896
30912
|
}
|
|
30897
30913
|
readMessage() {
|
|
@@ -30919,18 +30935,24 @@ function serializeMessage(message) {
|
|
|
30919
30935
|
|
|
30920
30936
|
// ../../node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
|
|
30921
30937
|
var StdioServerTransport = class {
|
|
30922
|
-
constructor(_stdin = process3.stdin, _stdout = process3.stdout) {
|
|
30938
|
+
constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
|
|
30923
30939
|
this._stdin = _stdin;
|
|
30924
30940
|
this._stdout = _stdout;
|
|
30925
|
-
this._readBuffer = new ReadBuffer();
|
|
30926
30941
|
this._started = false;
|
|
30927
30942
|
this._ondata = (chunk) => {
|
|
30928
|
-
|
|
30929
|
-
|
|
30943
|
+
try {
|
|
30944
|
+
this._readBuffer.append(chunk);
|
|
30945
|
+
this.processReadBuffer();
|
|
30946
|
+
} catch (error51) {
|
|
30947
|
+
this.onerror?.(error51);
|
|
30948
|
+
this.close().catch(() => {
|
|
30949
|
+
});
|
|
30950
|
+
}
|
|
30930
30951
|
};
|
|
30931
30952
|
this._onerror = (error51) => {
|
|
30932
30953
|
this.onerror?.(error51);
|
|
30933
30954
|
};
|
|
30955
|
+
this._readBuffer = new ReadBuffer({ maxBufferSize: options?.maxBufferSize });
|
|
30934
30956
|
}
|
|
30935
30957
|
/**
|
|
30936
30958
|
* Starts listening for messages on stdin.
|
|
@@ -31567,7 +31589,9 @@ function registerRunTool(server, options) {
|
|
|
31567
31589
|
function errorText(err) {
|
|
31568
31590
|
return err instanceof Error ? `Error: ${err.message}` : String(err);
|
|
31569
31591
|
}
|
|
31570
|
-
var
|
|
31592
|
+
var DEFINITE_AUTH_PATTERN = /\b(401|unauthorized|invalid_grant)\b/i;
|
|
31593
|
+
var STALE_TOKEN_PATTERN = /\b(?:access[ _-]?)?token\b[^.;\n]{0,40}\b(?:has\s+)?(?:been\s+)?(?:expired|revoked)\b|\b(?:expired|revoked)\s+(?:access[ _-]?)?token\b/i;
|
|
31594
|
+
var AUTH_ERROR_PATTERN = new RegExp(`${DEFINITE_AUTH_PATTERN.source}|${STALE_TOKEN_PATTERN.source}`, "i");
|
|
31571
31595
|
var INVALID_GRANT_PATTERN = /invalid_grant|token has been expired or revoked/i;
|
|
31572
31596
|
var TRANSIENT_ERROR_PATTERN = /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
|
|
31573
31597
|
var GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
|
|
@@ -31588,8 +31612,8 @@ function formatAccountList(raw) {
|
|
|
31588
31612
|
async function diagnose(err) {
|
|
31589
31613
|
const errText = errorText(err);
|
|
31590
31614
|
const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
|
|
31591
|
-
const
|
|
31592
|
-
const
|
|
31615
|
+
const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
31616
|
+
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
31593
31617
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
31594
31618
|
const hint = isInvalidGrant ? INVALID_GRANT_HINT : isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
|
|
31595
31619
|
try {
|
|
@@ -31710,7 +31734,7 @@ function registerApiTools(server) {
|
|
|
31710
31734
|
function registerAuthToolsWith(server, defaultServices) {
|
|
31711
31735
|
const servicesDescribe = `Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "${defaultServices}". Prefer the narrowest set you need \u2014 requesting a service whose Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request with invalid_scope.`;
|
|
31712
31736
|
server.registerTool("gog_auth_list", {
|
|
31713
|
-
description: "List
|
|
31737
|
+
description: "List the Google accounts stored in gogcli, with their scopes. This reads local configuration only \u2014 it does not contact Google and does NOT tell you whether an account still works: a signed-out account whose refresh token expired or was revoked is listed here exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account can actually authenticate.",
|
|
31714
31738
|
annotations: { readOnlyHint: true },
|
|
31715
31739
|
inputSchema: {}
|
|
31716
31740
|
}, async () => {
|
|
@@ -33079,7 +33103,7 @@ function registerTasksTools(server) {
|
|
|
33079
33103
|
}
|
|
33080
33104
|
|
|
33081
33105
|
// src/server.ts
|
|
33082
|
-
var VERSION = true ? "2.
|
|
33106
|
+
var VERSION = true ? "2.21.0" : "0.0.0";
|
|
33083
33107
|
var BASE_TOOL_REGISTRARS = [
|
|
33084
33108
|
registerApiTools,
|
|
33085
33109
|
registerAuthTools,
|
|
@@ -33094,6 +33118,83 @@ var BASE_TOOL_REGISTRARS = [
|
|
|
33094
33118
|
registerTasksTools
|
|
33095
33119
|
];
|
|
33096
33120
|
|
|
33121
|
+
// src/google-token.ts
|
|
33122
|
+
var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
33123
|
+
var EXPIRY_MARGIN_MS = 12e4;
|
|
33124
|
+
var cache = /* @__PURE__ */ new Map();
|
|
33125
|
+
var inFlight = /* @__PURE__ */ new Map();
|
|
33126
|
+
async function cacheKey(refreshToken, clientId) {
|
|
33127
|
+
const data = new TextEncoder().encode(`${clientId}\0${refreshToken}`);
|
|
33128
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
33129
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
33130
|
+
}
|
|
33131
|
+
function makeAccessTokenSource(env) {
|
|
33132
|
+
const direct = readEnvVar("GOG_ACCESS_TOKEN", { env });
|
|
33133
|
+
if (direct) return async () => direct;
|
|
33134
|
+
const refreshToken = readEnvVar("GOG_REFRESH_TOKEN", { env });
|
|
33135
|
+
if (!refreshToken) return void 0;
|
|
33136
|
+
const clientId = readEnvVar("GOG_CLIENT_ID", { env });
|
|
33137
|
+
const clientSecret = readEnvVar("GOG_CLIENT_SECRET", { env });
|
|
33138
|
+
if (!clientId || !clientSecret) {
|
|
33139
|
+
const missing = [!clientId && "GOG_CLIENT_ID", !clientSecret && "GOG_CLIENT_SECRET"].filter(Boolean).join(" and ");
|
|
33140
|
+
return async () => {
|
|
33141
|
+
throw new Error(
|
|
33142
|
+
`GOG_REFRESH_TOKEN is set but ${missing} is not, so no access token can be minted. Set the OAuth client alongside the refresh token, or unset GOG_REFRESH_TOKEN to use the backend\u2019s own identity.`
|
|
33143
|
+
);
|
|
33144
|
+
};
|
|
33145
|
+
}
|
|
33146
|
+
return async () => {
|
|
33147
|
+
const key = await cacheKey(refreshToken, clientId);
|
|
33148
|
+
const hit = cache.get(key);
|
|
33149
|
+
if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return hit.accessToken;
|
|
33150
|
+
let pending = inFlight.get(key);
|
|
33151
|
+
if (!pending) {
|
|
33152
|
+
pending = exchange(refreshToken, clientId, clientSecret).then((minted2) => {
|
|
33153
|
+
cache.set(key, minted2);
|
|
33154
|
+
return minted2;
|
|
33155
|
+
}).finally(() => inFlight.delete(key));
|
|
33156
|
+
inFlight.set(key, pending);
|
|
33157
|
+
}
|
|
33158
|
+
const minted = await pending;
|
|
33159
|
+
return minted.accessToken;
|
|
33160
|
+
};
|
|
33161
|
+
}
|
|
33162
|
+
async function exchange(refreshToken, clientId, clientSecret) {
|
|
33163
|
+
let res;
|
|
33164
|
+
try {
|
|
33165
|
+
res = await fetch(TOKEN_ENDPOINT, {
|
|
33166
|
+
method: "POST",
|
|
33167
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
33168
|
+
body: new URLSearchParams({
|
|
33169
|
+
grant_type: "refresh_token",
|
|
33170
|
+
refresh_token: refreshToken,
|
|
33171
|
+
client_id: clientId,
|
|
33172
|
+
client_secret: clientSecret
|
|
33173
|
+
}).toString()
|
|
33174
|
+
});
|
|
33175
|
+
} catch (err) {
|
|
33176
|
+
throw new Error(
|
|
33177
|
+
`the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`
|
|
33178
|
+
);
|
|
33179
|
+
}
|
|
33180
|
+
const body = await res.json().catch(() => ({}));
|
|
33181
|
+
if (!res.ok) {
|
|
33182
|
+
if (body.error === "invalid_grant") {
|
|
33183
|
+
throw new Error(
|
|
33184
|
+
'the stored refresh token has expired or been revoked, so this account must be re-authorized (commonly the 7-day limit on OAuth consent screens still in "Testing" mode). Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.'
|
|
33185
|
+
);
|
|
33186
|
+
}
|
|
33187
|
+
throw new Error(
|
|
33188
|
+
`the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ""})`
|
|
33189
|
+
);
|
|
33190
|
+
}
|
|
33191
|
+
if (!body.access_token) {
|
|
33192
|
+
throw new Error("the access token could not be refreshed: Google returned no access_token");
|
|
33193
|
+
}
|
|
33194
|
+
const expiresInMs = (body.expires_in ?? 3600) * 1e3;
|
|
33195
|
+
return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
|
|
33196
|
+
}
|
|
33197
|
+
|
|
33097
33198
|
// src/connector-runtime.ts
|
|
33098
33199
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
33099
33200
|
var DEADLINE_GRACE_MS = 5e3;
|
|
@@ -33102,7 +33203,7 @@ var RUNNER_DRAINING = 503;
|
|
|
33102
33203
|
function makeFlyExecutor(endpoint, key, readAccessToken) {
|
|
33103
33204
|
return async (args, opts) => {
|
|
33104
33205
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
33105
|
-
const accessToken = readAccessToken?.();
|
|
33206
|
+
const accessToken = await readAccessToken?.();
|
|
33106
33207
|
let res;
|
|
33107
33208
|
try {
|
|
33108
33209
|
res = await fetch(endpoint + "/run", {
|
|
@@ -33153,7 +33254,7 @@ function useRemoteGogRunner(env = process.env) {
|
|
|
33153
33254
|
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
33154
33255
|
if (!endpoint || !key) return false;
|
|
33155
33256
|
setDefaultGogExecutor(
|
|
33156
|
-
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, (
|
|
33257
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, makeAccessTokenSource(env))
|
|
33157
33258
|
);
|
|
33158
33259
|
return true;
|
|
33159
33260
|
}
|
package/dist/lib.js
CHANGED
|
@@ -4263,8 +4263,8 @@ var require_core = __commonJS({
|
|
|
4263
4263
|
return this;
|
|
4264
4264
|
}
|
|
4265
4265
|
case "object": {
|
|
4266
|
-
const
|
|
4267
|
-
this._cache.delete(
|
|
4266
|
+
const cacheKey2 = schemaKeyRef;
|
|
4267
|
+
this._cache.delete(cacheKey2);
|
|
4268
4268
|
let id = schemaKeyRef[this.opts.schemaId];
|
|
4269
4269
|
if (id) {
|
|
4270
4270
|
id = (0, resolve_1.normalizeId)(id);
|
|
@@ -22922,6 +22922,9 @@ var McpZodTypeKind;
|
|
|
22922
22922
|
McpZodTypeKind2["Completable"] = "McpCompletable";
|
|
22923
22923
|
})(McpZodTypeKind || (McpZodTypeKind = {}));
|
|
22924
22924
|
|
|
22925
|
+
// ../../node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
|
|
22926
|
+
var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
22927
|
+
|
|
22925
22928
|
// ../../node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
22926
22929
|
var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
22927
22930
|
var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
@@ -23470,7 +23473,9 @@ function registerRunTool(server, options) {
|
|
|
23470
23473
|
function errorText(err) {
|
|
23471
23474
|
return err instanceof Error ? `Error: ${err.message}` : String(err);
|
|
23472
23475
|
}
|
|
23473
|
-
var
|
|
23476
|
+
var DEFINITE_AUTH_PATTERN = /\b(401|unauthorized|invalid_grant)\b/i;
|
|
23477
|
+
var STALE_TOKEN_PATTERN = /\b(?:access[ _-]?)?token\b[^.;\n]{0,40}\b(?:has\s+)?(?:been\s+)?(?:expired|revoked)\b|\b(?:expired|revoked)\s+(?:access[ _-]?)?token\b/i;
|
|
23478
|
+
var AUTH_ERROR_PATTERN = new RegExp(`${DEFINITE_AUTH_PATTERN.source}|${STALE_TOKEN_PATTERN.source}`, "i");
|
|
23474
23479
|
var INVALID_GRANT_PATTERN = /invalid_grant|token has been expired or revoked/i;
|
|
23475
23480
|
var TRANSIENT_ERROR_PATTERN = /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
|
|
23476
23481
|
var GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
|
|
@@ -23491,8 +23496,8 @@ function formatAccountList(raw) {
|
|
|
23491
23496
|
async function diagnose(err) {
|
|
23492
23497
|
const errText = errorText(err);
|
|
23493
23498
|
const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
|
|
23494
|
-
const
|
|
23495
|
-
const
|
|
23499
|
+
const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
23500
|
+
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
23496
23501
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
23497
23502
|
const hint = isInvalidGrant ? INVALID_GRANT_HINT : isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
|
|
23498
23503
|
try {
|
|
@@ -23613,7 +23618,7 @@ function registerApiTools(server) {
|
|
|
23613
23618
|
function registerAuthToolsWith(server, defaultServices) {
|
|
23614
23619
|
const servicesDescribe = `Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "${defaultServices}". Prefer the narrowest set you need \u2014 requesting a service whose Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request with invalid_scope.`;
|
|
23615
23620
|
server.registerTool("gog_auth_list", {
|
|
23616
|
-
description: "List
|
|
23621
|
+
description: "List the Google accounts stored in gogcli, with their scopes. This reads local configuration only \u2014 it does not contact Google and does NOT tell you whether an account still works: a signed-out account whose refresh token expired or was revoked is listed here exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account can actually authenticate.",
|
|
23617
23622
|
annotations: { readOnlyHint: true },
|
|
23618
23623
|
inputSchema: {}
|
|
23619
23624
|
}, async () => {
|
|
@@ -24985,7 +24990,7 @@ function registerTasksTools(server) {
|
|
|
24985
24990
|
}
|
|
24986
24991
|
|
|
24987
24992
|
// src/server.ts
|
|
24988
|
-
var VERSION = true ? "2.
|
|
24993
|
+
var VERSION = true ? "2.21.0" : "0.0.0";
|
|
24989
24994
|
var BASE_TOOL_REGISTRARS = [
|
|
24990
24995
|
registerApiTools,
|
|
24991
24996
|
registerAuthTools,
|
|
@@ -25000,6 +25005,83 @@ var BASE_TOOL_REGISTRARS = [
|
|
|
25000
25005
|
registerTasksTools
|
|
25001
25006
|
];
|
|
25002
25007
|
|
|
25008
|
+
// src/google-token.ts
|
|
25009
|
+
var TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
25010
|
+
var EXPIRY_MARGIN_MS = 12e4;
|
|
25011
|
+
var cache = /* @__PURE__ */ new Map();
|
|
25012
|
+
var inFlight = /* @__PURE__ */ new Map();
|
|
25013
|
+
async function cacheKey(refreshToken, clientId) {
|
|
25014
|
+
const data = new TextEncoder().encode(`${clientId}\0${refreshToken}`);
|
|
25015
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
25016
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
25017
|
+
}
|
|
25018
|
+
function makeAccessTokenSource(env) {
|
|
25019
|
+
const direct = readEnvVar("GOG_ACCESS_TOKEN", { env });
|
|
25020
|
+
if (direct) return async () => direct;
|
|
25021
|
+
const refreshToken = readEnvVar("GOG_REFRESH_TOKEN", { env });
|
|
25022
|
+
if (!refreshToken) return void 0;
|
|
25023
|
+
const clientId = readEnvVar("GOG_CLIENT_ID", { env });
|
|
25024
|
+
const clientSecret = readEnvVar("GOG_CLIENT_SECRET", { env });
|
|
25025
|
+
if (!clientId || !clientSecret) {
|
|
25026
|
+
const missing = [!clientId && "GOG_CLIENT_ID", !clientSecret && "GOG_CLIENT_SECRET"].filter(Boolean).join(" and ");
|
|
25027
|
+
return async () => {
|
|
25028
|
+
throw new Error(
|
|
25029
|
+
`GOG_REFRESH_TOKEN is set but ${missing} is not, so no access token can be minted. Set the OAuth client alongside the refresh token, or unset GOG_REFRESH_TOKEN to use the backend\u2019s own identity.`
|
|
25030
|
+
);
|
|
25031
|
+
};
|
|
25032
|
+
}
|
|
25033
|
+
return async () => {
|
|
25034
|
+
const key = await cacheKey(refreshToken, clientId);
|
|
25035
|
+
const hit = cache.get(key);
|
|
25036
|
+
if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return hit.accessToken;
|
|
25037
|
+
let pending = inFlight.get(key);
|
|
25038
|
+
if (!pending) {
|
|
25039
|
+
pending = exchange(refreshToken, clientId, clientSecret).then((minted2) => {
|
|
25040
|
+
cache.set(key, minted2);
|
|
25041
|
+
return minted2;
|
|
25042
|
+
}).finally(() => inFlight.delete(key));
|
|
25043
|
+
inFlight.set(key, pending);
|
|
25044
|
+
}
|
|
25045
|
+
const minted = await pending;
|
|
25046
|
+
return minted.accessToken;
|
|
25047
|
+
};
|
|
25048
|
+
}
|
|
25049
|
+
async function exchange(refreshToken, clientId, clientSecret) {
|
|
25050
|
+
let res;
|
|
25051
|
+
try {
|
|
25052
|
+
res = await fetch(TOKEN_ENDPOINT, {
|
|
25053
|
+
method: "POST",
|
|
25054
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
25055
|
+
body: new URLSearchParams({
|
|
25056
|
+
grant_type: "refresh_token",
|
|
25057
|
+
refresh_token: refreshToken,
|
|
25058
|
+
client_id: clientId,
|
|
25059
|
+
client_secret: clientSecret
|
|
25060
|
+
}).toString()
|
|
25061
|
+
});
|
|
25062
|
+
} catch (err) {
|
|
25063
|
+
throw new Error(
|
|
25064
|
+
`the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`
|
|
25065
|
+
);
|
|
25066
|
+
}
|
|
25067
|
+
const body = await res.json().catch(() => ({}));
|
|
25068
|
+
if (!res.ok) {
|
|
25069
|
+
if (body.error === "invalid_grant") {
|
|
25070
|
+
throw new Error(
|
|
25071
|
+
'the stored refresh token has expired or been revoked, so this account must be re-authorized (commonly the 7-day limit on OAuth consent screens still in "Testing" mode). Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.'
|
|
25072
|
+
);
|
|
25073
|
+
}
|
|
25074
|
+
throw new Error(
|
|
25075
|
+
`the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ""})`
|
|
25076
|
+
);
|
|
25077
|
+
}
|
|
25078
|
+
if (!body.access_token) {
|
|
25079
|
+
throw new Error("the access token could not be refreshed: Google returned no access_token");
|
|
25080
|
+
}
|
|
25081
|
+
const expiresInMs = (body.expires_in ?? 3600) * 1e3;
|
|
25082
|
+
return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
|
|
25083
|
+
}
|
|
25084
|
+
|
|
25003
25085
|
// src/connector-runtime.ts
|
|
25004
25086
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
25005
25087
|
var DEADLINE_GRACE_MS = 5e3;
|
|
@@ -25008,7 +25090,7 @@ var RUNNER_DRAINING = 503;
|
|
|
25008
25090
|
function makeFlyExecutor(endpoint, key, readAccessToken) {
|
|
25009
25091
|
return async (args, opts) => {
|
|
25010
25092
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
25011
|
-
const accessToken = readAccessToken?.();
|
|
25093
|
+
const accessToken = await readAccessToken?.();
|
|
25012
25094
|
let res;
|
|
25013
25095
|
try {
|
|
25014
25096
|
res = await fetch(endpoint + "/run", {
|
|
@@ -25059,7 +25141,7 @@ function useRemoteGogRunner(env = process.env) {
|
|
|
25059
25141
|
const key = readEnvVar("GOG_RUNNER_KEY", { env });
|
|
25060
25142
|
if (!endpoint || !key) return false;
|
|
25061
25143
|
setDefaultGogExecutor(
|
|
25062
|
-
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, (
|
|
25144
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ""), key, makeAccessTokenSource(env))
|
|
25063
25145
|
);
|
|
25064
25146
|
return true;
|
|
25065
25147
|
}
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp",
|
|
5
5
|
"display_name": "gogcli",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.21.0",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp",
|
|
5
5
|
"description": "MCP server wrapping gogcli for Google service access",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chrischall/mcp-utils": "^0.
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
44
|
+
"@chrischall/mcp-utils": "^0.14.0",
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
46
|
"zod": "^4.4.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.21.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.21.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/connector-runtime.ts
CHANGED
|
@@ -78,11 +78,16 @@ const RUNNER_DRAINING = 503;
|
|
|
78
78
|
export function makeFlyExecutor(
|
|
79
79
|
endpoint: string,
|
|
80
80
|
key: string,
|
|
81
|
-
readAccessToken?: () => string | undefined
|
|
81
|
+
readAccessToken?: () => string | undefined | Promise<string | undefined>,
|
|
82
82
|
): GogExecutor {
|
|
83
83
|
return async (args: GogArg[], opts) => {
|
|
84
84
|
const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
|
|
85
|
-
|
|
85
|
+
// Awaited, because the token may have to be MINTED (#241): a refresh token
|
|
86
|
+
// is what a registration stores, and the access token it yields lives about
|
|
87
|
+
// an hour. Deliberately NOT caught here — if the source throws, the call
|
|
88
|
+
// fails, because the alternative is running it as the backend's identity
|
|
89
|
+
// and handing this caller someone else's account.
|
|
90
|
+
const accessToken = await readAccessToken?.();
|
|
86
91
|
let res: Response;
|
|
87
92
|
try {
|
|
88
93
|
res = await fetch(endpoint + '/run', {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mint short-lived Google access tokens from a long-lived refresh token, so a
|
|
5
|
+
* hosted gog's identity belongs to the REGISTRATION rather than to the machine
|
|
6
|
+
* the binary runs on (#241).
|
|
7
|
+
*
|
|
8
|
+
* The shape of the problem: `gog` reads credentials from a keyring at
|
|
9
|
+
* `GOG_HOME`, on the box where it executes — which is why one Fly volume ended
|
|
10
|
+
* up being every registration's identity. But `gog --access-token` bypasses the
|
|
11
|
+
* keyring entirely, and #235 already carries such a token to the box per
|
|
12
|
+
* request. The only missing piece was that an access token lives about an hour,
|
|
13
|
+
* so it cannot be the thing you STORE. A refresh token can.
|
|
14
|
+
*
|
|
15
|
+
* So the refresh token stays here, in the child's environment, and only a
|
|
16
|
+
* one-hour access token ever crosses the wire. That is strictly better than the
|
|
17
|
+
* arrangement it replaces, where a permanent credential sat on a shared volume.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Replace a token this long before it actually expires. A token that dies
|
|
24
|
+
* mid-flight is a failure the caller can do nothing about, and the exchange is
|
|
25
|
+
* cheap next to a failed tool call.
|
|
26
|
+
*/
|
|
27
|
+
const EXPIRY_MARGIN_MS = 120_000;
|
|
28
|
+
|
|
29
|
+
interface CachedToken {
|
|
30
|
+
accessToken: string;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Keyed by the CREDENTIAL, never a single "current token".
|
|
36
|
+
*
|
|
37
|
+
* A module-level current-token would be correct for one stdio process and
|
|
38
|
+
* silently wrong everywhere else: a Worker isolate serves many callers, so the
|
|
39
|
+
* first caller's identity would be handed to everyone after them. That is the
|
|
40
|
+
* same failure as the captured executor in #235 and the ambient store in #233 —
|
|
41
|
+
* three bugs, one shape, which is why this one is keyed from the start.
|
|
42
|
+
*
|
|
43
|
+
* The key is a hash rather than the token itself so that nothing which dumps or
|
|
44
|
+
* iterates this map (a heap snapshot, a debugger, a future logging line) puts a
|
|
45
|
+
* live credential in front of someone.
|
|
46
|
+
*/
|
|
47
|
+
const cache = new Map<string, CachedToken>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exchanges currently in flight, so concurrent callers share ONE of them.
|
|
51
|
+
*
|
|
52
|
+
* Without this, `get` → `await exchange` → `set` has an await between the miss
|
|
53
|
+
* and the fill: every caller that arrives during that window also misses, and
|
|
54
|
+
* they all hit Google's token endpoint together. One process per caller hides
|
|
55
|
+
* it, but a Worker isolate serving many callers — or simply several tool calls
|
|
56
|
+
* in flight — turns a single refresh into a stampede, and being rate-limited
|
|
57
|
+
* for it produces exactly the intermittent auth failures this was meant to end.
|
|
58
|
+
*
|
|
59
|
+
* Keyed identically to `cache`, so two different credentials never wait on each
|
|
60
|
+
* other's exchange.
|
|
61
|
+
*/
|
|
62
|
+
const inFlight = new Map<string, Promise<CachedToken>>();
|
|
63
|
+
|
|
64
|
+
/** Test seam: both maps are process-wide, so they do not unwind between tests. */
|
|
65
|
+
export function clearAccessTokenCache(): void {
|
|
66
|
+
cache.clear();
|
|
67
|
+
inFlight.clear();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* WebCrypto rather than `node:crypto`: this module is reachable from the Worker
|
|
72
|
+
* build, which has no node builtins. Both runtimes expose `crypto.subtle`.
|
|
73
|
+
*/
|
|
74
|
+
async function cacheKey(refreshToken: string, clientId: string): Promise<string> {
|
|
75
|
+
// NUL-separated, spelled as an escape so this source file stays text: it
|
|
76
|
+
// keeps a (clientId, refreshToken) pair from colliding with a different
|
|
77
|
+
// pair whose concatenation happens to match. Neither value can contain a
|
|
78
|
+
// NUL, which is what makes the boundary unambiguous.
|
|
79
|
+
const data = new TextEncoder().encode(`${clientId}\u0000${refreshToken}`);
|
|
80
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
81
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a token source is: something that answers "who is this call acting as",
|
|
86
|
+
* or throws trying. It never answers `undefined` after being configured —
|
|
87
|
+
* see the failure note below.
|
|
88
|
+
*/
|
|
89
|
+
export type AccessTokenSource = () => Promise<string | undefined>;
|
|
90
|
+
|
|
91
|
+
export interface TokenEnv {
|
|
92
|
+
GOG_ACCESS_TOKEN?: string;
|
|
93
|
+
GOG_REFRESH_TOKEN?: string;
|
|
94
|
+
GOG_CLIENT_ID?: string;
|
|
95
|
+
GOG_CLIENT_SECRET?: string;
|
|
96
|
+
[key: string]: string | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the token source for this environment, or `undefined` when nothing is
|
|
101
|
+
* configured — which leaves the backend acting as itself, exactly as every
|
|
102
|
+
* registration did before this existed.
|
|
103
|
+
*
|
|
104
|
+
* Precedence puts a directly-supplied `GOG_ACCESS_TOKEN` first: someone who
|
|
105
|
+
* already holds a token should not need an OAuth client to use it, and it keeps
|
|
106
|
+
* the #230 path working untouched.
|
|
107
|
+
*/
|
|
108
|
+
export function makeAccessTokenSource(env: TokenEnv): AccessTokenSource | undefined {
|
|
109
|
+
const direct = readEnvVar('GOG_ACCESS_TOKEN', { env });
|
|
110
|
+
if (direct) return async () => direct;
|
|
111
|
+
|
|
112
|
+
const refreshToken = readEnvVar('GOG_REFRESH_TOKEN', { env });
|
|
113
|
+
if (!refreshToken) return undefined;
|
|
114
|
+
|
|
115
|
+
const clientId = readEnvVar('GOG_CLIENT_ID', { env });
|
|
116
|
+
const clientSecret = readEnvVar('GOG_CLIENT_SECRET', { env });
|
|
117
|
+
|
|
118
|
+
// A refresh token with no OAuth client cannot mint anything, and the WRONG
|
|
119
|
+
// repair is to treat it as unconfigured: that falls back to the backend's own
|
|
120
|
+
// identity, which is the precise confusion this feature exists to remove. So
|
|
121
|
+
// the source exists and throws when used — `tools/list` still works, the
|
|
122
|
+
// server still starts, and the first tool call says what is missing.
|
|
123
|
+
if (!clientId || !clientSecret) {
|
|
124
|
+
const missing = [!clientId && 'GOG_CLIENT_ID', !clientSecret && 'GOG_CLIENT_SECRET']
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join(' and ');
|
|
127
|
+
return async () => {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`GOG_REFRESH_TOKEN is set but ${missing} is not, so no access token can be minted. ` +
|
|
130
|
+
'Set the OAuth client alongside the refresh token, or unset GOG_REFRESH_TOKEN to use the backend’s own identity.',
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return async () => {
|
|
136
|
+
const key = await cacheKey(refreshToken, clientId);
|
|
137
|
+
const hit = cache.get(key);
|
|
138
|
+
if (hit && hit.expiresAt - EXPIRY_MARGIN_MS > Date.now()) return hit.accessToken;
|
|
139
|
+
|
|
140
|
+
// Join the exchange already running for this credential, or start the one
|
|
141
|
+
// everyone else will join.
|
|
142
|
+
let pending = inFlight.get(key);
|
|
143
|
+
if (!pending) {
|
|
144
|
+
pending = exchange(refreshToken, clientId, clientSecret)
|
|
145
|
+
.then((minted) => {
|
|
146
|
+
cache.set(key, minted);
|
|
147
|
+
return minted;
|
|
148
|
+
})
|
|
149
|
+
// Dropped whether it resolved OR threw. Keeping a rejected promise here
|
|
150
|
+
// would make one transient failure permanent for every later caller —
|
|
151
|
+
// the opposite of the "failures are not cached" rule above.
|
|
152
|
+
.finally(() => inFlight.delete(key));
|
|
153
|
+
inFlight.set(key, pending);
|
|
154
|
+
}
|
|
155
|
+
const minted = await pending;
|
|
156
|
+
return minted.accessToken;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Exchange refresh -> access.
|
|
162
|
+
*
|
|
163
|
+
* THROWS on every failure, and never returns `undefined`. Returning nothing
|
|
164
|
+
* would let the call proceed as the backend's identity, and the caller would
|
|
165
|
+
* read someone else's mailbox while everything looked like success — the same
|
|
166
|
+
* reasoning that made a malformed token a 400 rather than an ignore in #235.
|
|
167
|
+
*
|
|
168
|
+
* Nothing here is cached on failure either, so a transient Google outage does
|
|
169
|
+
* not become a sticky one.
|
|
170
|
+
*/
|
|
171
|
+
async function exchange(refreshToken: string, clientId: string, clientSecret: string): Promise<CachedToken> {
|
|
172
|
+
let res: Response;
|
|
173
|
+
try {
|
|
174
|
+
res = await fetch(TOKEN_ENDPOINT, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
177
|
+
body: new URLSearchParams({
|
|
178
|
+
grant_type: 'refresh_token',
|
|
179
|
+
refresh_token: refreshToken,
|
|
180
|
+
client_id: clientId,
|
|
181
|
+
client_secret: clientSecret,
|
|
182
|
+
}).toString(),
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`the Google token exchange could not be reached: ${err instanceof Error ? err.message : String(err)}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
191
|
+
access_token?: string;
|
|
192
|
+
expires_in?: number;
|
|
193
|
+
error?: string;
|
|
194
|
+
error_description?: string;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
// invalid_grant is the one worth naming, because it is not a bug and not
|
|
199
|
+
// transient: the credential is gone and a human has to enrol again. Google
|
|
200
|
+
// expires refresh tokens after 7 days while a consent screen is still in
|
|
201
|
+
// "Testing" mode, which is how this fleet has usually met it.
|
|
202
|
+
if (body.error === 'invalid_grant') {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'the stored refresh token has expired or been revoked, so this account must be re-authorized ' +
|
|
205
|
+
'(commonly the 7-day limit on OAuth consent screens still in "Testing" mode). ' +
|
|
206
|
+
'Re-enrol with gog_auth_add_url + gog_auth_add_complete and store the new refresh token.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
// The refresh token is deliberately absent from this message — it is a
|
|
210
|
+
// long-lived credential and an error string travels into logs and model
|
|
211
|
+
// context.
|
|
212
|
+
throw new Error(
|
|
213
|
+
`the access token could not be refreshed (HTTP ${res.status}${body.error ? `, ${body.error}` : ''})`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!body.access_token) {
|
|
218
|
+
throw new Error('the access token could not be refreshed: Google returned no access_token');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Default to an hour if Google omits expires_in; the margin above covers the
|
|
222
|
+
// difference between that guess and reality.
|
|
223
|
+
const expiresInMs = (body.expires_in ?? 3600) * 1000;
|
|
224
|
+
return { accessToken: body.access_token, expiresAt: Date.now() + expiresInMs };
|
|
225
|
+
}
|
package/src/remote-runner.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
2
2
|
import { setDefaultGogExecutor } from './runner.js';
|
|
3
|
+
import { makeAccessTokenSource } from './google-token.js';
|
|
3
4
|
import { makeFlyExecutor } from './connector-runtime.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -63,8 +64,14 @@ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolea
|
|
|
63
64
|
// Read per call rather than captured here, because the executor outlives any
|
|
64
65
|
// one request and the claim being made is about a request. Absent when unset,
|
|
65
66
|
// which is every registration that predates per-caller auth.
|
|
67
|
+
//
|
|
68
|
+
// The source also covers the case where the registration stores a REFRESH
|
|
69
|
+
// token instead (#241) — the identity then belongs to the registration rather
|
|
70
|
+
// than to the backend's volume, and the short-lived token it mints is the
|
|
71
|
+
// only thing that crosses the wire. `undefined` when neither is configured,
|
|
72
|
+
// which leaves the backend acting as itself exactly as before.
|
|
66
73
|
setDefaultGogExecutor(
|
|
67
|
-
makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, (
|
|
74
|
+
makeFlyExecutor(endpoint.replace(/\/+$/, ''), key, makeAccessTokenSource(env)),
|
|
68
75
|
);
|
|
69
76
|
return true;
|
|
70
77
|
}
|
package/src/tools/auth.ts
CHANGED
|
@@ -14,7 +14,12 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
|
|
|
14
14
|
`Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
|
|
15
15
|
`with invalid_scope.`;
|
|
16
16
|
server.registerTool('gog_auth_list', {
|
|
17
|
-
description:
|
|
17
|
+
description:
|
|
18
|
+
'List the Google accounts stored in gogcli, with their scopes. This reads local ' +
|
|
19
|
+
'configuration only — it does not contact Google and does NOT tell you whether an account ' +
|
|
20
|
+
'still works: a signed-out account whose refresh token expired or was revoked is listed here ' +
|
|
21
|
+
'exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account ' +
|
|
22
|
+
'can actually authenticate.',
|
|
18
23
|
annotations: { readOnlyHint: true },
|
|
19
24
|
inputSchema: {},
|
|
20
25
|
}, async () => {
|
package/src/tools/utils.ts
CHANGED
|
@@ -135,7 +135,18 @@ export function errorText(err: unknown): string {
|
|
|
135
135
|
return err instanceof Error ? `Error: ${err.message}` : String(err);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// Google saying "not authenticated" in its own words. Definitive: a retry
|
|
139
|
+
// cannot turn a 401 into a success, so this outranks the transient signal below.
|
|
140
|
+
const DEFINITE_AUTH_PATTERN = /\b(401|unauthorized|invalid_grant)\b/i;
|
|
141
|
+
|
|
142
|
+
// A message that TALKS about an expired token. Suggestive, not definitive — and
|
|
143
|
+
// it used to be `/token.*(expired|revoked)/`, whose greedy `.*` matched a token
|
|
144
|
+
// mentioned anywhere and an expiry mentioned anywhere later in the same line
|
|
145
|
+
// ("page token accepted; the export link has expired"). Now the two words must
|
|
146
|
+
// actually be about each other.
|
|
147
|
+
const STALE_TOKEN_PATTERN = /\b(?:access[ _-]?)?token\b[^.;\n]{0,40}\b(?:has\s+)?(?:been\s+)?(?:expired|revoked)\b|\b(?:expired|revoked)\s+(?:access[ _-]?)?token\b/i;
|
|
148
|
+
|
|
149
|
+
const AUTH_ERROR_PATTERN = new RegExp(`${DEFINITE_AUTH_PATTERN.source}|${STALE_TOKEN_PATTERN.source}`, 'i');
|
|
139
150
|
|
|
140
151
|
// A DEAD refresh token — the whole account is signed out, not just a stale
|
|
141
152
|
// access token that would refresh silently. This is the recurring account-wide
|
|
@@ -207,8 +218,19 @@ export function formatAccountList(raw: string): string {
|
|
|
207
218
|
export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
208
219
|
const errText = errorText(err);
|
|
209
220
|
const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
|
|
210
|
-
|
|
211
|
-
|
|
221
|
+
|
|
222
|
+
// Precedence, and the reason for it. Reporting needs-auth is EXPENSIVE to be
|
|
223
|
+
// wrong about: re-authorization is a manual, human step, and it does not fix
|
|
224
|
+
// a 429. So a transient signal beats a merely SUGGESTIVE auth signal (a
|
|
225
|
+
// message that mentions an expired token) — the reported bug was servers
|
|
226
|
+
// flapping into needs-auth and working again seconds later, which is what
|
|
227
|
+
// being told to re-auth over a rate-limit looks like.
|
|
228
|
+
//
|
|
229
|
+
// It does NOT beat a definitive one. A literal 401 or invalid_grant is Google
|
|
230
|
+
// saying the credential will not work, and calling that "retry" would loop a
|
|
231
|
+
// caller forever against a request that can never succeed.
|
|
232
|
+
const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
233
|
+
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
212
234
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
213
235
|
const hint = isInvalidGrant
|
|
214
236
|
? INVALID_GRANT_HINT
|
package/src/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
|
|
|
38
38
|
// connector with all ~360 tools at once. Add whichever paths you want as separate
|
|
39
39
|
// connectors in claude.ai (each authorizes with the same connector key).
|
|
40
40
|
|
|
41
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.21.0'; // x-release-please-version
|
|
42
42
|
|
|
43
43
|
// Build an McpAgent subclass whose init() registers `registrars` onto its server,
|
|
44
44
|
// each handler wrapped in the ALS scope carrying the per-session Fly executor.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
2
|
+
import { makeAccessTokenSource, clearAccessTokenCache } from '../src/google-token.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minting a short-lived access token from a long-lived refresh token is what
|
|
6
|
+
* lets a hosted gog's identity belong to the REGISTRATION instead of to the Fly
|
|
7
|
+
* box's volume (#241). The access token is what crosses the wire; the refresh
|
|
8
|
+
* token never leaves this process.
|
|
9
|
+
*
|
|
10
|
+
* Two properties carry the whole design, and both are about NOT quietly doing
|
|
11
|
+
* the wrong thing:
|
|
12
|
+
*
|
|
13
|
+
* 1. One caller's token must never be served to another. The cache is keyed
|
|
14
|
+
* by the credential, not held as a single "current token" — a Worker
|
|
15
|
+
* isolate serves many callers, and a global would hand the first caller's
|
|
16
|
+
* identity to everyone after them.
|
|
17
|
+
* 2. A failure must fail the CALL. Returning nothing would run the command as
|
|
18
|
+
* the box's identity, and the caller would read someone else's mailbox
|
|
19
|
+
* while everything looked like success.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const CLIENT = { GOG_CLIENT_ID: 'cid.apps.googleusercontent.com', GOG_CLIENT_SECRET: 'cs' };
|
|
23
|
+
|
|
24
|
+
function tokenResponse(accessToken: string, expiresIn = 3600) {
|
|
25
|
+
return new Response(JSON.stringify({ access_token: accessToken, expires_in: expiresIn }), {
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => clearAccessTokenCache());
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
vi.useRealTimers();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('makeAccessTokenSource', () => {
|
|
38
|
+
it('is absent when nothing is configured, so the box keeps acting as itself', () => {
|
|
39
|
+
expect(makeAccessTokenSource({})).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('passes through a directly-supplied access token without contacting Google', async () => {
|
|
43
|
+
// The #230 path. Still supported: a caller who already holds a token should
|
|
44
|
+
// not need an OAuth client to use it.
|
|
45
|
+
const fetchMock = vi.fn();
|
|
46
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
47
|
+
const source = makeAccessTokenSource({ GOG_ACCESS_TOKEN: 'ya29.direct' })!;
|
|
48
|
+
expect(await source()).toBe('ya29.direct');
|
|
49
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('mints an access token from the refresh token, then serves it from cache', async () => {
|
|
53
|
+
const fetchMock = vi.fn(async () => tokenResponse('ya29.minted'));
|
|
54
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
55
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
56
|
+
|
|
57
|
+
expect(await source()).toBe('ya29.minted');
|
|
58
|
+
expect(await source()).toBe('ya29.minted');
|
|
59
|
+
// A token exchange per tool call would be both slow and a good way to get
|
|
60
|
+
// rate-limited by Google.
|
|
61
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
62
|
+
|
|
63
|
+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
64
|
+
expect(url).toBe('https://oauth2.googleapis.com/token');
|
|
65
|
+
const sent = new URLSearchParams(init.body as string);
|
|
66
|
+
expect(sent.get('grant_type')).toBe('refresh_token');
|
|
67
|
+
expect(sent.get('refresh_token')).toBe('rt-1');
|
|
68
|
+
expect(sent.get('client_id')).toBe(CLIENT.GOG_CLIENT_ID);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('never serves one credential holder the other one’s token', async () => {
|
|
72
|
+
// THE one that matters. A module-level "current access token" passes every
|
|
73
|
+
// other test in this file and fails this one — and on the Worker path,
|
|
74
|
+
// where a single isolate serves many callers, that is a cross-account leak
|
|
75
|
+
// rather than a bug.
|
|
76
|
+
const fetchMock = vi
|
|
77
|
+
.fn()
|
|
78
|
+
.mockResolvedValueOnce(tokenResponse('ya29.alice'))
|
|
79
|
+
.mockResolvedValueOnce(tokenResponse('ya29.bob'));
|
|
80
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
81
|
+
|
|
82
|
+
const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
|
|
83
|
+
const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
|
|
84
|
+
|
|
85
|
+
expect(await alice()).toBe('ya29.alice');
|
|
86
|
+
expect(await bob()).toBe('ya29.bob');
|
|
87
|
+
// And each keeps its own on a second read.
|
|
88
|
+
expect(await alice()).toBe('ya29.alice');
|
|
89
|
+
expect(await bob()).toBe('ya29.bob');
|
|
90
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('re-mints once the token is close to expiring', async () => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
const fetchMock = vi
|
|
96
|
+
.fn()
|
|
97
|
+
.mockResolvedValueOnce(tokenResponse('ya29.first', 3600))
|
|
98
|
+
.mockResolvedValueOnce(tokenResponse('ya29.second', 3600));
|
|
99
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
100
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
101
|
+
|
|
102
|
+
expect(await source()).toBe('ya29.first');
|
|
103
|
+
// Just inside the safety margin: a token that expires mid-flight is a
|
|
104
|
+
// failure the caller cannot do anything about, so it is replaced early.
|
|
105
|
+
vi.advanceTimersByTime((3600 - 60) * 1000);
|
|
106
|
+
expect(await source()).toBe('ya29.second');
|
|
107
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('THROWS when the exchange fails, rather than returning nothing', async () => {
|
|
111
|
+
// Returning undefined here would run the command as the box's identity.
|
|
112
|
+
vi.stubGlobal(
|
|
113
|
+
'fetch',
|
|
114
|
+
vi.fn(async () => new Response(JSON.stringify({ error: 'server_error' }), { status: 500 })),
|
|
115
|
+
);
|
|
116
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
117
|
+
await expect(source()).rejects.toThrow(/could not be refreshed|token exchange/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('explains an invalid_grant instead of surfacing a bare OAuth error', async () => {
|
|
121
|
+
vi.stubGlobal(
|
|
122
|
+
'fetch',
|
|
123
|
+
vi.fn(
|
|
124
|
+
async () =>
|
|
125
|
+
new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'Token has been expired or revoked.' }), {
|
|
126
|
+
status: 400,
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-dead' })!;
|
|
131
|
+
await expect(source()).rejects.toThrow(/expired or been revoked|re-?enrol|re-?authoriz/i);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not cache a failure, so a transient outage is not sticky', async () => {
|
|
135
|
+
const fetchMock = vi
|
|
136
|
+
.fn()
|
|
137
|
+
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
|
|
138
|
+
.mockResolvedValueOnce(tokenResponse('ya29.recovered'));
|
|
139
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
140
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
141
|
+
|
|
142
|
+
await expect(source()).rejects.toThrow();
|
|
143
|
+
expect(await source()).toBe('ya29.recovered');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('fails loudly on a half-configured credential instead of acting as the box', async () => {
|
|
147
|
+
// A refresh token with no OAuth client cannot mint anything. Treating it as
|
|
148
|
+
// "unconfigured" would silently fall back to the box's identity, which is
|
|
149
|
+
// the exact confusion this feature exists to remove — so the source exists
|
|
150
|
+
// and throws when used, leaving tools/list working and the reason visible.
|
|
151
|
+
for (const partial of [
|
|
152
|
+
{ GOG_REFRESH_TOKEN: 'rt-1' },
|
|
153
|
+
{ GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_ID: 'cid' },
|
|
154
|
+
{ GOG_REFRESH_TOKEN: 'rt-1', GOG_CLIENT_SECRET: 'cs' },
|
|
155
|
+
]) {
|
|
156
|
+
const source = makeAccessTokenSource(partial);
|
|
157
|
+
expect(source).toBeTypeOf('function');
|
|
158
|
+
await expect(source!()).rejects.toThrow(/GOG_CLIENT_ID|GOG_CLIENT_SECRET/);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('says the exchange was unreachable when the network itself fails', async () => {
|
|
163
|
+
// Distinct from a rejection BY Google: nothing was evaluated, so the
|
|
164
|
+
// credential may be perfectly good and the right response is to retry, not
|
|
165
|
+
// to tell the owner to re-enrol.
|
|
166
|
+
vi.stubGlobal(
|
|
167
|
+
'fetch',
|
|
168
|
+
vi.fn(async () => {
|
|
169
|
+
throw new TypeError('fetch failed');
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
173
|
+
await expect(source()).rejects.toThrow(/could not be reached.*fetch failed/i);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('survives a thrown non-Error without masking it with a TypeError', async () => {
|
|
177
|
+
// `err.message` on a thrown string is undefined, and the template would
|
|
178
|
+
// then hide the real failure behind a crash inside the error path.
|
|
179
|
+
vi.stubGlobal(
|
|
180
|
+
'fetch',
|
|
181
|
+
vi.fn(async () => {
|
|
182
|
+
throw 'socket closed';
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
186
|
+
await expect(source()).rejects.toThrow(/could not be reached.*socket closed/i);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('refuses a 200 that carries no access_token', async () => {
|
|
190
|
+
// A success status with nothing usable in it would otherwise cache
|
|
191
|
+
// `undefined` and send no token at all — a silent downgrade to the
|
|
192
|
+
// backend's identity, wearing a 200.
|
|
193
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ expires_in: 3600 }), { status: 200 })));
|
|
194
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
195
|
+
await expect(source()).rejects.toThrow(/no access_token/i);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('assumes an hour when Google omits expires_in', async () => {
|
|
199
|
+
vi.useFakeTimers();
|
|
200
|
+
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ access_token: 'ya29.nolife' }), { status: 200 }));
|
|
201
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
202
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
203
|
+
|
|
204
|
+
expect(await source()).toBe('ya29.nolife');
|
|
205
|
+
// Still cached half an hour later — i.e. it did not treat "no expiry" as
|
|
206
|
+
// "already expired" and re-mint on every single call.
|
|
207
|
+
vi.advanceTimersByTime(1800 * 1000);
|
|
208
|
+
expect(await source()).toBe('ya29.nolife');
|
|
209
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('reports the status when the body is not JSON at all', async () => {
|
|
213
|
+
// An edge proxy answering a 502 with an HTML error page is the realistic
|
|
214
|
+
// case. Parsing that would throw inside the error path and bury the status,
|
|
215
|
+
// which is the only useful thing such a response carries.
|
|
216
|
+
vi.stubGlobal(
|
|
217
|
+
'fetch',
|
|
218
|
+
vi.fn(async () => new Response('<html>502 Bad Gateway</html>', { status: 502 })),
|
|
219
|
+
);
|
|
220
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
221
|
+
await expect(source()).rejects.toThrow(/could not be refreshed \(HTTP 502/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('coalesces concurrent callers into ONE token exchange', async () => {
|
|
225
|
+
// The reported bug was several per-service servers flapping into needs-auth
|
|
226
|
+
// independently. This is the version of that failure that lives in OUR
|
|
227
|
+
// code: `get` → `await exchange` → `set` has an await between the miss and
|
|
228
|
+
// the fill, so N calls arriving together all miss and all exchange.
|
|
229
|
+
//
|
|
230
|
+
// One process per caller hides it; a Worker isolate serving many callers,
|
|
231
|
+
// or simply several tool calls in flight at once, turns one refresh into N
|
|
232
|
+
// simultaneous hits on Google's token endpoint — which is a good way to be
|
|
233
|
+
// rate-limited into exactly the intermittent auth errors being debugged.
|
|
234
|
+
let inFlight = 0;
|
|
235
|
+
let maxConcurrent = 0;
|
|
236
|
+
const fetchMock = vi.fn(async () => {
|
|
237
|
+
inFlight += 1;
|
|
238
|
+
maxConcurrent = Math.max(maxConcurrent, inFlight);
|
|
239
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
240
|
+
inFlight -= 1;
|
|
241
|
+
return tokenResponse('ya29.shared');
|
|
242
|
+
});
|
|
243
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
244
|
+
|
|
245
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
246
|
+
const results = await Promise.all(Array.from({ length: 8 }, () => source()));
|
|
247
|
+
|
|
248
|
+
expect(results).toEqual(Array(8).fill('ya29.shared'));
|
|
249
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
250
|
+
expect(maxConcurrent).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('lets a later caller retry after a concurrent exchange failed', async () => {
|
|
254
|
+
// Coalescing must not make one failure permanent for everyone: the shared
|
|
255
|
+
// attempt is dropped when it settles, so the next call starts a fresh one.
|
|
256
|
+
// The failure has to take a tick. An exchange that rejects instantly can
|
|
257
|
+
// finish and clear itself before the second caller even looks, so that
|
|
258
|
+
// caller correctly starts its own attempt — which would make this test pass
|
|
259
|
+
// without any sharing having happened. A real exchange is a network round
|
|
260
|
+
// trip, so the shared-failure case is the one worth pinning.
|
|
261
|
+
const fetchMock = vi
|
|
262
|
+
.fn()
|
|
263
|
+
.mockImplementationOnce(async () => {
|
|
264
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
265
|
+
throw new Error('boom');
|
|
266
|
+
})
|
|
267
|
+
.mockResolvedValueOnce(tokenResponse('ya29.after'));
|
|
268
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
269
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-1' })!;
|
|
270
|
+
|
|
271
|
+
const settled = await Promise.allSettled([source(), source()]);
|
|
272
|
+
expect(settled.every((s) => s.status === 'rejected')).toBe(true);
|
|
273
|
+
// Both shared ONE failed exchange rather than each making their own...
|
|
274
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
275
|
+
// ...and the failure was not cached, so the next caller recovers.
|
|
276
|
+
expect(await source()).toBe('ya29.after');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('does not let two different credentials share one in-flight exchange', async () => {
|
|
280
|
+
const fetchMock = vi
|
|
281
|
+
.fn()
|
|
282
|
+
.mockResolvedValueOnce(tokenResponse('ya29.alice'))
|
|
283
|
+
.mockResolvedValueOnce(tokenResponse('ya29.bob'));
|
|
284
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
285
|
+
const alice = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-alice' })!;
|
|
286
|
+
const bob = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-bob' })!;
|
|
287
|
+
|
|
288
|
+
const [a, b] = await Promise.all([alice(), bob()]);
|
|
289
|
+
expect(a).toBe('ya29.alice');
|
|
290
|
+
expect(b).toBe('ya29.bob');
|
|
291
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('never puts the refresh token in the error it throws', async () => {
|
|
295
|
+
vi.stubGlobal(
|
|
296
|
+
'fetch',
|
|
297
|
+
vi.fn(async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })),
|
|
298
|
+
);
|
|
299
|
+
const source = makeAccessTokenSource({ ...CLIENT, GOG_REFRESH_TOKEN: 'rt-super-secret-value' })!;
|
|
300
|
+
await expect(source()).rejects.toThrow(
|
|
301
|
+
expect.not.stringContaining('rt-super-secret-value') as unknown as string,
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// Guards the invariant that broke dependabot #220: the whole monorepo must
|
|
7
|
+
// resolve ONE copy of @modelcontextprotocol/sdk.
|
|
8
|
+
//
|
|
9
|
+
// `McpServer` carries a `private _serverInfo`, so TypeScript compares it
|
|
10
|
+
// NOMINALLY, not structurally. Two installed copies therefore become two
|
|
11
|
+
// mutually-unassignable classes, and every `ToolRegistrar` in server.ts fails
|
|
12
|
+
// with TS2322 — with no API change and no source change anywhere. #220 split
|
|
13
|
+
// the tree exactly that way: `agents` (a root devDependency, the Worker
|
|
14
|
+
// connector's McpAgent) exact-pins the SDK to 1.29.0 and so takes the hoisted
|
|
15
|
+
// root slot that `@chrischall/mcp-utils` resolves its peer from, while the
|
|
16
|
+
// workspaces asking for ^1.30.0 each nested their own copy.
|
|
17
|
+
//
|
|
18
|
+
// This asserts resolution identity rather than a version string: the failure is
|
|
19
|
+
// "two copies", not "the wrong version", and pinning a version here would just
|
|
20
|
+
// have to be edited on every future bump.
|
|
21
|
+
describe('@modelcontextprotocol/sdk is installed exactly once', () => {
|
|
22
|
+
const here = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
// An exported subpath — the SDK's `exports` map does not expose package.json.
|
|
25
|
+
const SDK_SUBPATH = '@modelcontextprotocol/sdk/server/mcp.js';
|
|
26
|
+
|
|
27
|
+
// `import.meta.resolve`, not `require.resolve`, to reach the dependency's own
|
|
28
|
+
// entry: these packages are ESM-only, so their `exports` maps carry no
|
|
29
|
+
// `require` condition and CJS resolution of the bare specifier throws.
|
|
30
|
+
const resolveFrom = (specifier: string): string =>
|
|
31
|
+
realpathSync(
|
|
32
|
+
createRequire(fileURLToPath(import.meta.resolve(specifier))).resolve(SDK_SUBPATH),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
it('resolves to the same file for this package and for @chrischall/mcp-utils', () => {
|
|
36
|
+
// mcp-utils declares the SDK as a peer and hands our registrars the
|
|
37
|
+
// McpServer it built, so its copy is the one they must be typed against.
|
|
38
|
+
expect(resolveFrom('@chrischall/mcp-utils')).toBe(
|
|
39
|
+
realpathSync(here.resolve(SDK_SUBPATH)),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('resolves to the same file for `agents`, which exact-pins the SDK', () => {
|
|
44
|
+
// The Worker connector builds its McpServer via McpAgent from `agents`.
|
|
45
|
+
// An exact pin there is what captured the root hoist slot in #220.
|
|
46
|
+
expect(resolveFrom('agents')).toBe(realpathSync(here.resolve(SDK_SUBPATH)));
|
|
47
|
+
});
|
|
48
|
+
});
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -25,6 +25,27 @@ describe('gog_auth_list', () => {
|
|
|
25
25
|
expect(result.content[0].text).toBe('Error: No accounts configured');
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
it('does not advertise itself as proof the account still works', async () => {
|
|
29
|
+
// `gog auth list` reads the keyring. No network, no validation — it lists a
|
|
30
|
+
// full scope set for an account whose refresh token died days ago.
|
|
31
|
+
//
|
|
32
|
+
// This description used to say "check which accounts are configured and
|
|
33
|
+
// available", and "available" is exactly the wrong word: it was read as a
|
|
34
|
+
// liveness check while per-service servers were flapping into needs-auth,
|
|
35
|
+
// which pointed a whole debugging session away from an expired grant.
|
|
36
|
+
// gog_auth_health is the tool that actually probes Google.
|
|
37
|
+
const harness = await setupHandlers();
|
|
38
|
+
const { tools } = await harness.client.listTools();
|
|
39
|
+
const desc = tools.find((t) => t.name === 'gog_auth_list')!.description!;
|
|
40
|
+
|
|
41
|
+
expect(desc).not.toMatch(/\bavailable\b/i);
|
|
42
|
+
// It must say what it does NOT do, and where to go instead — a reader who
|
|
43
|
+
// wants liveness has to be sent somewhere, or they will use this anyway.
|
|
44
|
+
expect(desc).toMatch(/does not|without/i);
|
|
45
|
+
expect(desc).toContain('gog_auth_health');
|
|
46
|
+
await harness.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
28
49
|
it('handles non-Error rejection', async () => {
|
|
29
50
|
vi.mocked(runner.run).mockRejectedValue('something went wrong');
|
|
30
51
|
const harness = await setupHandlers();
|
|
@@ -190,6 +190,50 @@ describe('runOrDiagnose', () => {
|
|
|
190
190
|
expect(result.content[0].text).toContain('gog_auth_add');
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('calls a rate-limited failure transient even though it says "token expired"', async () => {
|
|
194
|
+
// The reported symptom was servers flapping into a needs-auth state and
|
|
195
|
+
// then working seconds later. Telling someone to re-authorize an account
|
|
196
|
+
// whose credential is fine is the expensive kind of wrong: re-auth is
|
|
197
|
+
// manual, and it does not fix a 429.
|
|
198
|
+
//
|
|
199
|
+
// `invalid_grant` is exempt from this and stays auth (below) — it is the
|
|
200
|
+
// one signal that definitively means the refresh token is dead.
|
|
201
|
+
vi.mocked(runner.run)
|
|
202
|
+
.mockRejectedValueOnce(new Error('429 rateLimitExceeded: the access token expired mid-request, retry'))
|
|
203
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
204
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
205
|
+
const text = result.content[0].text as string;
|
|
206
|
+
expect(text).toContain('often transient');
|
|
207
|
+
expect(text).not.toContain('gog_auth_add');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('still calls an explicit 401 an auth error even alongside a transient signal', async () => {
|
|
211
|
+
// The exemption above is for the LOOSE match only. A literal 401 is Google
|
|
212
|
+
// saying "not authenticated", and downgrading that to "retry" would loop a
|
|
213
|
+
// caller forever against a request that can never succeed.
|
|
214
|
+
vi.mocked(runner.run)
|
|
215
|
+
.mockRejectedValueOnce(new Error('401 unauthorized (quota project unset)'))
|
|
216
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
217
|
+
const result = await runOrDiagnose(['sheets', 'get', 'A1'], {});
|
|
218
|
+
expect(result.content[0].text).toContain('gog_auth_add');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('does not read "token" and "expired" as auth when they are unrelated sentences', async () => {
|
|
222
|
+
// The pattern was /token.*(expired|revoked)/ with a greedy `.*`, so any
|
|
223
|
+
// message mentioning a token anywhere and an expiry anywhere later — across
|
|
224
|
+
// whole paragraphs — was reported as an auth failure.
|
|
225
|
+
vi.mocked(runner.run)
|
|
226
|
+
// Deliberately ONE line: `.` does not cross newlines, so a multi-line
|
|
227
|
+
// message would pass this test without the greedy match ever being
|
|
228
|
+
// exercised — and gog's real errors are frequently one long line.
|
|
229
|
+
.mockRejectedValueOnce(
|
|
230
|
+
new Error('page token accepted; the requested export link has expired and must be regenerated'),
|
|
231
|
+
)
|
|
232
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
233
|
+
const result = await runOrDiagnose(['drive', 'export', 'abc'], {});
|
|
234
|
+
expect(result.content[0].text).not.toContain('gog_auth_add');
|
|
235
|
+
});
|
|
236
|
+
|
|
193
237
|
it('gives invalid_grant a richer hint than a plain 401: cause + durable fix + both re-auth paths', async () => {
|
|
194
238
|
vi.mocked(runner.run)
|
|
195
239
|
.mockRejectedValueOnce(new Error('oauth2: "invalid_grant" "Token has been expired or revoked."'))
|