mcp-google-multi 6.0.0-beta.4 โ 6.0.0-beta.5
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/README.md +15 -1
- package/dist/discover.js +2 -3
- package/dist/fs-atomic.js +98 -12
- package/dist/hint-provider.d.ts +4 -1
- package/dist/mcp-token.d.ts +37 -3
- package/dist/mcp-token.js +105 -12
- package/dist/oauth-as.d.ts +5 -0
- package/dist/oauth-as.js +13 -5
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +59 -10
- package/dist/tools/accounts-tool.d.ts +7 -0
- package/dist/tools/accounts-tool.js +17 -6
- package/dist/tools/admin.js +6 -1
- package/dist/tools/analytics.js +28 -11
- package/dist/tools/calendar.d.ts +5 -0
- package/dist/tools/calendar.js +59 -0
- package/dist/tools/contacts.js +11 -5
- package/dist/tools/docs.js +41 -12
- package/dist/tools/drive.js +54 -12
- package/dist/tools/generated/gmail.js +0 -14
- package/dist/tools/generated/method-map.js +1 -1
- package/dist/tools/gmail.d.ts +1 -1
- package/dist/tools/gmail.js +109 -12
- package/dist/tools/sheets.d.ts +10 -0
- package/dist/tools/sheets.js +109 -2
- package/dist/write-control.js +15 -2
- package/package.json +4 -2
- package/skills/google-workspace/SKILL.md +162 -0
- package/skills/google-workspace/reference/admin.md +105 -0
- package/skills/google-workspace/reference/analytics.md +70 -0
- package/skills/google-workspace/reference/appsmarket.md +5 -0
- package/skills/google-workspace/reference/calendar.md +175 -0
- package/skills/google-workspace/reference/chat.md +59 -0
- package/skills/google-workspace/reference/classroom.md +5 -0
- package/skills/google-workspace/reference/cloudidentity.md +5 -0
- package/skills/google-workspace/reference/cloudsearch.md +5 -0
- package/skills/google-workspace/reference/contacts.md +138 -0
- package/skills/google-workspace/reference/docs.md +395 -0
- package/skills/google-workspace/reference/drive.md +485 -0
- package/skills/google-workspace/reference/driveactivity.md +5 -0
- package/skills/google-workspace/reference/drivelabels.md +5 -0
- package/skills/google-workspace/reference/forms.md +92 -0
- package/skills/google-workspace/reference/gmail.md +305 -0
- package/skills/google-workspace/reference/groupsmigration.md +5 -0
- package/skills/google-workspace/reference/groupssettings.md +5 -0
- package/skills/google-workspace/reference/keep.md +5 -0
- package/skills/google-workspace/reference/licensing.md +5 -0
- package/skills/google-workspace/reference/meet.md +68 -0
- package/skills/google-workspace/reference/postmaster.md +5 -0
- package/skills/google-workspace/reference/reseller.md +5 -0
- package/skills/google-workspace/reference/script.md +5 -0
- package/skills/google-workspace/reference/searchconsole.md +128 -0
- package/skills/google-workspace/reference/sheets.md +430 -0
- package/skills/google-workspace/reference/slides.md +67 -0
- package/skills/google-workspace/reference/tasks.md +167 -0
- package/skills/google-workspace/reference/vault.md +5 -0
- package/skills/google-workspace/reference/workspaceevents.md +5 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The most complete **local Google Workspace MCP server**: Gmail, Drive, Calendar,
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/mcp-google-multi)
|
|
6
6
|
|
|
7
|
-
- ๐งฐ **Exhaustive** โ
|
|
7
|
+
- ๐งฐ **Exhaustive** โ 942 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else โ [COVERAGE.md](./COVERAGE.md)
|
|
8
8
|
- ๐ **Multi-account** โ drive any number of Google accounts by alias, or fan one call out across all of them
|
|
9
9
|
- ๐ **Private by design** โ your own OAuth app, tokens encrypted at rest (AES-256-GCM), writes deny-by-default, no telemetry, no metering โ it talks only to Google
|
|
10
10
|
- ๐ **Local or remote** โ runs locally over stdio, or self-hosted over HTTP with its own built-in OAuth 2.1 server (Claude Code's `/mcp` login and the claude.ai connector, zero custom UI). Pull-and-up Docker Compose with optional automatic HTTPS โ [remote setup](./docs/http-setup.md)
|
|
@@ -55,6 +55,20 @@ New to all this? It's written for someone who just installed Claude Code and has
|
|
|
55
55
|
|
|
56
56
|
**Go deeper:** [Configuration reference](./docs/configuration.md) ยท [What's covered](./COVERAGE.md) ยท [Features tour](./docs/features.md) ยท [Remote / HTTP setup](./docs/http-setup.md) ยท [Secrets in a vault](./docs/secrets.md) ยท [Local usage metrics](./docs/usage-metrics.md) ยท [Migrating to v6](./MIGRATION-v6.md) ยท [Security policy](./SECURITY.md) ยท [Roadmap](https://github.com/bakissation/mcp-google-multi/milestones)
|
|
57
57
|
|
|
58
|
+
## Agent skill
|
|
59
|
+
|
|
60
|
+
The repo and the npm package both ship an [Agent Skill](https://code.claude.com/docs/en/skills) at `skills/google-workspace/`: a hand-written `SKILL.md` covering account selection, the discover step, incremental Gmail sweeps, keeping results small, write profiles, and the escape hatch, plus a `reference/` folder with a generated argument table per service. Loading it gets an agent calling this server's tools correctly without guessing argument names.
|
|
61
|
+
|
|
62
|
+
To install it for Claude Code, copy or symlink the `skills/google-workspace` folder into `~/.claude/skills/` (every project) or a project's own `.claude/skills/` (that project only):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# from a cloned copy of this repo
|
|
66
|
+
ln -s "$(pwd)/skills/google-workspace" ~/.claude/skills/google-workspace
|
|
67
|
+
|
|
68
|
+
# from the installed npm package
|
|
69
|
+
ln -s "$(npm root -g)/mcp-google-multi/skills/google-workspace" ~/.claude/skills/google-workspace
|
|
70
|
+
```
|
|
71
|
+
|
|
58
72
|
## Maintainer, sponsor & license
|
|
59
73
|
|
|
60
74
|
Built and maintained by **Abdelbaki Berkati** โ [berkati.xyz](https://berkati.xyz) ยท [@bakissation](https://github.com/bakissation). [Read the case study โ](https://berkati.xyz/case-studies/mcp-google-multi/)
|
package/dist/discover.js
CHANGED
|
@@ -96,9 +96,8 @@ export function registerDiscoverTools(registry, policy) {
|
|
|
96
96
|
service,
|
|
97
97
|
operations,
|
|
98
98
|
writeControl: rendered?.hint ?? describePolicy(policy),
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
: `No ${service} operation matches "${query}". Call again without query for the full catalog.`,
|
|
99
|
+
// catalog() falls back to a full one-line listing when nothing matches, so this is never empty.
|
|
100
|
+
next: 'Call the chosen tool by its exact name. A client that loads tools on demand may need its own tool-search or load step before the call succeeds.',
|
|
102
101
|
}),
|
|
103
102
|
},
|
|
104
103
|
],
|
package/dist/fs-atomic.js
CHANGED
|
@@ -10,6 +10,9 @@ const RENAME_RETRY_MS = 20;
|
|
|
10
10
|
const LOCK_LEASE_MS = 60_000;
|
|
11
11
|
// Breaking a lock takes microseconds, so an older breaker file was left by a crash.
|
|
12
12
|
const BREAKER_STALE_MS = 2_000;
|
|
13
|
+
// The no-hard-link fallback writes the lock in two syscalls (create, then the
|
|
14
|
+
// pid); a reader can catch it empty in between. See docs/internals.md.
|
|
15
|
+
const LOCK_WRITE_GRACE_MS = 2_000;
|
|
13
16
|
const PROCESS_START_MS = performance.timeOrigin;
|
|
14
17
|
const OWNER_NAME = /^(\d+)\.(\d+|host)\.(\d+)\.([0-9a-f]{8}|0)\.[0-9a-f]{12}\.owner$/;
|
|
15
18
|
function startOf(pid) {
|
|
@@ -63,11 +66,19 @@ function sameFile(a, b) {
|
|
|
63
66
|
function isEnoent(error) {
|
|
64
67
|
return error.code === 'ENOENT';
|
|
65
68
|
}
|
|
69
|
+
function bindingRecord(id) {
|
|
70
|
+
return `${id.dev}:${id.ino}:${id.mtimeNs}`;
|
|
71
|
+
}
|
|
72
|
+
function matchesBinding(content, id) {
|
|
73
|
+
const m = /^(\d+):(\d+):(\d+)$/.exec(content);
|
|
74
|
+
return m !== null && BigInt(m[1]) === id.dev && BigInt(m[2]) === id.ino && BigInt(m[3]) === id.mtimeNs;
|
|
75
|
+
}
|
|
66
76
|
// Undefined when the lock changed hands while it was being read.
|
|
67
77
|
function readHolder(lock) {
|
|
68
78
|
const before = fs.lstatSync(lock, { bigint: true });
|
|
69
79
|
const body = before.isFile() ? fs.readFileSync(lock, 'utf8') : '';
|
|
70
80
|
let owner;
|
|
81
|
+
let ownerPending = false;
|
|
71
82
|
if (before.isFile()) {
|
|
72
83
|
const dir = path.dirname(lock);
|
|
73
84
|
const prefix = `${path.basename(lock)}.`;
|
|
@@ -79,10 +90,12 @@ function readHolder(lock) {
|
|
|
79
90
|
continue;
|
|
80
91
|
const ownerPath = path.join(dir, name);
|
|
81
92
|
try {
|
|
82
|
-
|
|
93
|
+
const ownerStat = fs.lstatSync(ownerPath, { bigint: true });
|
|
94
|
+
if (sameFile(ownerStat, before) || matchesBinding(fs.readFileSync(ownerPath, 'utf8'), before)) {
|
|
83
95
|
owner = { path: ownerPath, ns: m[2], start: m[3], boot: m[4] };
|
|
84
96
|
break;
|
|
85
97
|
}
|
|
98
|
+
ownerPending = true;
|
|
86
99
|
}
|
|
87
100
|
catch (error) {
|
|
88
101
|
if (!isEnoent(error))
|
|
@@ -93,7 +106,7 @@ function readHolder(lock) {
|
|
|
93
106
|
const after = fs.lstatSync(lock, { bigint: true });
|
|
94
107
|
if (!sameFile(before, after))
|
|
95
108
|
return undefined;
|
|
96
|
-
return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner };
|
|
109
|
+
return { body, ino: after.ino, dev: after.dev, mtimeNs: after.mtimeNs, mtimeMs: Number(after.mtimeNs) / 1e6, owner, ownerPending };
|
|
97
110
|
}
|
|
98
111
|
// The owner file's random name tells two holders apart even when the second
|
|
99
112
|
// lock reuses the first one's freed inode.
|
|
@@ -115,10 +128,21 @@ function probeDead(pid) {
|
|
|
115
128
|
return false;
|
|
116
129
|
}
|
|
117
130
|
}
|
|
131
|
+
// Symmetric: a clock step back must not make a write-in-progress lock look
|
|
132
|
+
// permanently fresh (see docs/internals.md).
|
|
133
|
+
function withinWriteGrace(now, mtimeMs) {
|
|
134
|
+
return Math.abs(now - mtimeMs) < LOCK_WRITE_GRACE_MS;
|
|
135
|
+
}
|
|
118
136
|
function holderDead(holder, now) {
|
|
137
|
+
if (holder.body === '' && withinWriteGrace(now, holder.mtimeMs))
|
|
138
|
+
return false;
|
|
119
139
|
const pid = Number(holder.body);
|
|
120
140
|
if (!(Number.isSafeInteger(pid) && pid > 0))
|
|
121
141
|
return true;
|
|
142
|
+
// A same-pid owner file exists but is not yet bound (the fallback lock's two
|
|
143
|
+
// writes race a reader); give it the same grace as an empty lock.
|
|
144
|
+
if (!holder.owner && holder.ownerPending && withinWriteGrace(now, holder.mtimeMs))
|
|
145
|
+
return false;
|
|
122
146
|
const leaseOver = now - holder.mtimeMs >= LOCK_LEASE_MS;
|
|
123
147
|
const owner = holder.owner;
|
|
124
148
|
// No owner file: an older writer, whose namespace is unknown.
|
|
@@ -196,6 +220,53 @@ function releaseBreaker(breaker, token) {
|
|
|
196
220
|
throw error;
|
|
197
221
|
}
|
|
198
222
|
}
|
|
223
|
+
// No hard links here (Android/Termux denies link() in private storage, some
|
|
224
|
+
// filesystems lack it entirely): the lock is created directly with O_EXCL,
|
|
225
|
+
// carrying the bare pid a linked owner file would carry, and the owner file
|
|
226
|
+
// is bound to it by a recorded identity instead of a hard link. See docs/internals.md.
|
|
227
|
+
function takeFallbackLock(lock, ownerFile) {
|
|
228
|
+
const fd = fs.openSync(lock, 'wx', 0o600);
|
|
229
|
+
let acquired;
|
|
230
|
+
try {
|
|
231
|
+
fs.writeSync(fd, String(process.pid));
|
|
232
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
233
|
+
acquired = { dev: stat.dev, ino: stat.ino, mtimeNs: stat.mtimeNs };
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
// The lock is left empty; remove it only if it is still the one we just
|
|
237
|
+
// created, then rethrow so the caller never retries a write failure.
|
|
238
|
+
const stat = fs.fstatSync(fd, { bigint: true });
|
|
239
|
+
fs.closeSync(fd);
|
|
240
|
+
try {
|
|
241
|
+
if (sameFile(fs.lstatSync(lock, { bigint: true }), stat))
|
|
242
|
+
fs.rmSync(lock, { force: true });
|
|
243
|
+
}
|
|
244
|
+
catch (cleanupError) {
|
|
245
|
+
if (!isEnoent(cleanupError))
|
|
246
|
+
throw cleanupError;
|
|
247
|
+
}
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
fs.closeSync(fd);
|
|
251
|
+
try {
|
|
252
|
+
fs.writeFileSync(ownerFile, bindingRecord(acquired));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
// The lock already exists holding our live pid; without this cleanup
|
|
256
|
+
// every other writer sees an ownerless holder and waits out the full lease.
|
|
257
|
+
try {
|
|
258
|
+
const current = fs.lstatSync(lock, { bigint: true });
|
|
259
|
+
if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs)
|
|
260
|
+
fs.rmSync(lock, { force: true });
|
|
261
|
+
}
|
|
262
|
+
catch (cleanupError) {
|
|
263
|
+
if (!isEnoent(cleanupError))
|
|
264
|
+
throw cleanupError;
|
|
265
|
+
}
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
return acquired;
|
|
269
|
+
}
|
|
199
270
|
// Hardlink-based advisory lock; the lock body is the bare pid, as older
|
|
200
271
|
// readers expect. See docs/internals.md.
|
|
201
272
|
export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
@@ -206,17 +277,31 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
206
277
|
fs.writeFileSync(ownerFile, String(process.pid), { mode: 0o600, flag: 'wx' });
|
|
207
278
|
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
208
279
|
let linked = false;
|
|
280
|
+
let acquired;
|
|
209
281
|
try {
|
|
210
282
|
while (true) {
|
|
211
283
|
try {
|
|
212
284
|
fs.linkSync(ownerFile, lock);
|
|
213
285
|
linked = true;
|
|
286
|
+
acquired = fs.lstatSync(lock, { bigint: true });
|
|
214
287
|
break;
|
|
215
288
|
}
|
|
216
289
|
catch (error) {
|
|
217
290
|
const err = error;
|
|
218
|
-
if (err.code
|
|
291
|
+
if (err.code === 'EACCES' || err.code === 'EPERM') {
|
|
292
|
+
try {
|
|
293
|
+
acquired = takeFallbackLock(lock, ownerFile);
|
|
294
|
+
linked = true;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
catch (fbError) {
|
|
298
|
+
if (fbError.code !== 'EEXIST')
|
|
299
|
+
throw fbError;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (err.code !== 'EEXIST') {
|
|
219
303
|
throw error;
|
|
304
|
+
}
|
|
220
305
|
try {
|
|
221
306
|
const holder = readHolder(lock);
|
|
222
307
|
if (holder && holderDead(holder, Date.now()) && breakLock(lock, holder))
|
|
@@ -238,23 +323,24 @@ export function withFileLock(absPath, fn, label = `lock: ${absPath}`) {
|
|
|
238
323
|
if (!linked)
|
|
239
324
|
fs.rmSync(ownerFile, { force: true });
|
|
240
325
|
}
|
|
241
|
-
let bound = false;
|
|
242
326
|
try {
|
|
243
|
-
// Some network and FUSE filesystems give each name of one file its own
|
|
244
|
-
// inode number; there the owner file cannot vouch for the lock.
|
|
245
|
-
bound = sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true }));
|
|
246
327
|
return fn();
|
|
247
328
|
}
|
|
248
329
|
finally {
|
|
249
|
-
releaseLock(lock, ownerFile,
|
|
330
|
+
releaseLock(lock, ownerFile, acquired);
|
|
250
331
|
}
|
|
251
332
|
}
|
|
252
333
|
// A holder whose lock was broken (its owner file removed with it) must not
|
|
253
|
-
// remove its successor's lock
|
|
254
|
-
|
|
334
|
+
// remove its successor's lock: only drop the lock if it is still the exact
|
|
335
|
+
// file this call created, and our own owner file is still there to vouch for it.
|
|
336
|
+
function releaseLock(lock, ownerFile, acquired) {
|
|
255
337
|
try {
|
|
256
|
-
if (
|
|
257
|
-
fs.
|
|
338
|
+
if (acquired) {
|
|
339
|
+
const current = fs.lstatSync(lock, { bigint: true });
|
|
340
|
+
if (sameFile(current, acquired) && current.mtimeNs === acquired.mtimeNs && fs.existsSync(ownerFile)) {
|
|
341
|
+
fs.rmSync(lock, { force: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
258
344
|
}
|
|
259
345
|
catch (error) {
|
|
260
346
|
if (!isEnoent(error))
|
package/dist/hint-provider.d.ts
CHANGED
package/dist/mcp-token.d.ts
CHANGED
|
@@ -107,12 +107,31 @@ export interface RefreshRecord {
|
|
|
107
107
|
issuedAt: number;
|
|
108
108
|
family: string;
|
|
109
109
|
}
|
|
110
|
+
/** A gen-1-behind token presented within this long of the rotation that
|
|
111
|
+
* retired it, from the same client, can be a concurrent duplicate rather
|
|
112
|
+
* than theft. See docs/internals.md. */
|
|
113
|
+
export declare const REPLAY_WINDOW_MS = 30000;
|
|
114
|
+
/** The store's own MAC key for `masterKey`, memoized. Exported so a caller
|
|
115
|
+
* that never touches a RefreshStore (the AS computing a request's
|
|
116
|
+
* clientFingerprint) can key it the same way the store does. */
|
|
117
|
+
export declare function refreshMacKey(masterKey: string): Buffer;
|
|
110
118
|
/**
|
|
111
119
|
* The family tag of a refresh token this `masterKey`'s store minted, or null
|
|
112
120
|
* for anything else. Derives the key once; the returned function does no I/O,
|
|
113
121
|
* so a host can key a pre-auth rate limit on it.
|
|
114
122
|
*/
|
|
115
123
|
export declare function refreshFamilyTagger(masterKey: string): (token: string) => string | null;
|
|
124
|
+
/**
|
|
125
|
+
* A correlation tag for the client presenting a refresh token:
|
|
126
|
+
* HMAC-SHA256(key, client_id + "\n" + user-agent + "\n" + address), truncated
|
|
127
|
+
* to 16 bytes and base64url-encoded. `key` is the store's own MAC key
|
|
128
|
+
* (refreshMacKey), so computing it needs MASTER_KEY: a reader of the store
|
|
129
|
+
* file alone (`by` is stored, not these inputs) cannot brute-force the
|
|
130
|
+
* client_id/user-agent/address that produced it. It is still not a secret:
|
|
131
|
+
* anyone who can see a live request can send the same inputs and get the
|
|
132
|
+
* same tag. Pure, no I/O.
|
|
133
|
+
*/
|
|
134
|
+
export declare function clientFingerprint(clientId: string, userAgent: string, address: string, key: Buffer): string;
|
|
116
135
|
/** Boot check for a refresh store file: returns when it is absent or this
|
|
117
136
|
* release can read it; throws when it does not decrypt under `masterKey`
|
|
118
137
|
* (E_REFRESH_STORE_UNREADABLE) or a newer release wrote it
|
|
@@ -167,7 +186,13 @@ export declare class RefreshStore {
|
|
|
167
186
|
private emit;
|
|
168
187
|
private enforceFamilyCap;
|
|
169
188
|
private newFamilyId;
|
|
170
|
-
issue(nowMs: number, sub: string): string;
|
|
189
|
+
issue(nowMs: number, sub: string, fingerprint?: string): string;
|
|
190
|
+
/** A presented token exactly one generation behind `f`, inside the replay
|
|
191
|
+
* window, from the fingerprint that retired it, whose derived successor
|
|
192
|
+
* matches `f.cur`: a concurrent duplicate request rather than theft (see
|
|
193
|
+
* docs/internals.md). Null for anything else at that generation, including
|
|
194
|
+
* a different rand (a genuinely different gen-1 token) or no fingerprint. */
|
|
195
|
+
private concurrentDuplicate;
|
|
171
196
|
/** Rotate a presented refresh token; null if it is unknown, malformed, or
|
|
172
197
|
* rotated away (reuse => the family is revoked). The record's sub is copied
|
|
173
198
|
* forward and returned so the caller can mint the matching access token
|
|
@@ -176,8 +201,17 @@ export declare class RefreshStore {
|
|
|
176
201
|
* `accept` (optional) is called inside the store lock, before any mutation,
|
|
177
202
|
* with the record's sub. false: every session of that sub is dropped and
|
|
178
203
|
* null returned. A throw: nothing is written and the error propagates, so
|
|
179
|
-
* the presented token stays valid. Absent: unchanged.
|
|
180
|
-
|
|
204
|
+
* the presented token stays valid. Absent: unchanged.
|
|
205
|
+
*
|
|
206
|
+
* `fingerprint` (optional) names the presenting client (clientFingerprint).
|
|
207
|
+
* Absent: a rotated-away token is always reuse, as before. Present: a
|
|
208
|
+
* token exactly one generation behind, from the same fingerprint, presented
|
|
209
|
+
* no earlier than and at most REPLAY_WINDOW_MS after the rotation that
|
|
210
|
+
* retired it, is a concurrent duplicate: it gets back the SAME successor
|
|
211
|
+
* token, with no change to the family (not even `usedAt`). Anything else
|
|
212
|
+
* rotated-away is still reuse, including a clock that has stepped back
|
|
213
|
+
* past `usedAt`, which cannot be told apart from a genuinely stale replay. */
|
|
214
|
+
rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean, fingerprint?: string): {
|
|
181
215
|
token: string;
|
|
182
216
|
sub: string;
|
|
183
217
|
} | null;
|
package/dist/mcp-token.js
CHANGED
|
@@ -192,6 +192,12 @@ const R1_LENGTH = 94;
|
|
|
192
192
|
const R1_BODY = 36;
|
|
193
193
|
const R1_BYTES = 68;
|
|
194
194
|
const R1_MAC_DOMAIN = Buffer.from('mcp-google-multi refresh r1\n');
|
|
195
|
+
// Domain-separated from R1_MAC_DOMAIN: a successor derived under this key
|
|
196
|
+
// must never verify as a current token's own MAC, or vice versa.
|
|
197
|
+
const R1_SUCCESSOR_DOMAIN = Buffer.from('mcp-google-multi refresh r1 successor\n');
|
|
198
|
+
// Domain-separated from both of the above: a fingerprint must never verify as
|
|
199
|
+
// either a token MAC or a successor derivation under the same key.
|
|
200
|
+
const R1_FINGERPRINT_DOMAIN = Buffer.from('mcp-google-multi refresh r1 fingerprint\n');
|
|
195
201
|
const R1_KEY_INFO = 'mcp-google-multi:refresh-mac:v1';
|
|
196
202
|
// A passphrase key (deriveKey's bare-sha256 branch) would otherwise let one
|
|
197
203
|
// leaked r1 token test guesses offline at hash speed; scrypt adds a work factor.
|
|
@@ -201,13 +207,20 @@ const GEN_MAX = 0xffffffff;
|
|
|
201
207
|
const LEGACY_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
202
208
|
/** With a lifetime set, a stored time further ahead than this is pulled back to the clock. */
|
|
203
209
|
const CLOCK_SKEW_MS = 5 * 60_000;
|
|
210
|
+
/** A gen-1-behind token presented within this long of the rotation that
|
|
211
|
+
* retired it, from the same client, can be a concurrent duplicate rather
|
|
212
|
+
* than theft. See docs/internals.md. */
|
|
213
|
+
export const REPLAY_WINDOW_MS = 30_000;
|
|
204
214
|
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
205
215
|
const sha256 = (b) => createHash('sha256').update(b).digest();
|
|
206
216
|
/** A non-secret tag for log lines and rate-limit keys: never the token. */
|
|
207
217
|
const familyTag = (family) => sha256(family).toString('hex').slice(0, 8);
|
|
208
218
|
// Keyed on the master key: scrypt runs once per process, not once per store.
|
|
209
219
|
const macKeyMemo = new Map();
|
|
210
|
-
|
|
220
|
+
/** The store's own MAC key for `masterKey`, memoized. Exported so a caller
|
|
221
|
+
* that never touches a RefreshStore (the AS computing a request's
|
|
222
|
+
* clientFingerprint) can key it the same way the store does. */
|
|
223
|
+
export function refreshMacKey(masterKey) {
|
|
211
224
|
const hit = macKeyMemo.get(masterKey);
|
|
212
225
|
if (hit)
|
|
213
226
|
return hit;
|
|
@@ -233,7 +246,7 @@ function parseR1(t, key) {
|
|
|
233
246
|
if (!timingSafeEqual(buf.subarray(R1_BODY), r1Mac(key, buf.subarray(0, R1_BODY))))
|
|
234
247
|
return null;
|
|
235
248
|
const fidBytes = buf.subarray(0, 16);
|
|
236
|
-
return { fidBytes, fid: fidBytes.toString('base64url'), gen: buf.readUInt32BE(16), digest: sha256(buf) };
|
|
249
|
+
return { fidBytes, fid: fidBytes.toString('base64url'), gen: buf.readUInt32BE(16), digest: sha256(buf), body: buf.subarray(0, R1_BODY) };
|
|
237
250
|
}
|
|
238
251
|
function mintR1(key, fid, gen) {
|
|
239
252
|
const body = Buffer.alloc(R1_BODY);
|
|
@@ -243,6 +256,24 @@ function mintR1(key, fid, gen) {
|
|
|
243
256
|
const buf = Buffer.concat([body, r1Mac(key, body)]);
|
|
244
257
|
return { token: R1_PREFIX + buf.toString('base64url'), cur: sha256(buf).toString('base64url') };
|
|
245
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* The token a rotation of `presentedBody` (a parsed token's fid||gen||rand)
|
|
261
|
+
* mints: generation + 1, and a random part derived (not drawn) from the
|
|
262
|
+
* presented body, so re-deriving it from a retired token and comparing the
|
|
263
|
+
* digest against a family's stored `cur` tells a concurrent duplicate from a
|
|
264
|
+
* different gen-1 token. Domain-separated from `r1Mac` so neither function's
|
|
265
|
+
* output can be read as the other's. See docs/internals.md.
|
|
266
|
+
*/
|
|
267
|
+
function mintR1Successor(key, presentedBody) {
|
|
268
|
+
const rand = createHmac('sha256', key).update(R1_SUCCESSOR_DOMAIN).update(presentedBody).digest().subarray(0, 16);
|
|
269
|
+
const body = Buffer.alloc(R1_BODY);
|
|
270
|
+
presentedBody.copy(body, 0, 0, 16); // fid
|
|
271
|
+
body.writeUInt32BE(presentedBody.readUInt32BE(16) + 1, 16); // gen + 1
|
|
272
|
+
rand.copy(body, 20);
|
|
273
|
+
const buf = Buffer.concat([body, r1Mac(key, body)]);
|
|
274
|
+
const digest = sha256(buf);
|
|
275
|
+
return { token: R1_PREFIX + buf.toString('base64url'), cur: digest.toString('base64url'), digest };
|
|
276
|
+
}
|
|
246
277
|
/**
|
|
247
278
|
* The family tag of a refresh token this `masterKey`'s store minted, or null
|
|
248
279
|
* for anything else. Derives the key once; the returned function does no I/O,
|
|
@@ -255,6 +286,24 @@ export function refreshFamilyTagger(masterKey) {
|
|
|
255
286
|
return p ? familyTag(p.fidBytes) : null;
|
|
256
287
|
};
|
|
257
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* A correlation tag for the client presenting a refresh token:
|
|
291
|
+
* HMAC-SHA256(key, client_id + "\n" + user-agent + "\n" + address), truncated
|
|
292
|
+
* to 16 bytes and base64url-encoded. `key` is the store's own MAC key
|
|
293
|
+
* (refreshMacKey), so computing it needs MASTER_KEY: a reader of the store
|
|
294
|
+
* file alone (`by` is stored, not these inputs) cannot brute-force the
|
|
295
|
+
* client_id/user-agent/address that produced it. It is still not a secret:
|
|
296
|
+
* anyone who can see a live request can send the same inputs and get the
|
|
297
|
+
* same tag. Pure, no I/O.
|
|
298
|
+
*/
|
|
299
|
+
export function clientFingerprint(clientId, userAgent, address, key) {
|
|
300
|
+
return createHmac('sha256', key).update(R1_FINGERPRINT_DOMAIN).update(`${clientId}\n${userAgent}\n${address}`).digest().subarray(0, 16).toString('base64url');
|
|
301
|
+
}
|
|
302
|
+
function timingSafeStringEqual(a, b) {
|
|
303
|
+
const ab = Buffer.from(a, 'utf-8');
|
|
304
|
+
const bb = Buffer.from(b, 'utf-8');
|
|
305
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
306
|
+
}
|
|
258
307
|
function ownEntries(o) {
|
|
259
308
|
// An own `__proto__` key (JSON.parse makes one) would set the prototype when copied.
|
|
260
309
|
return o !== null && typeof o === 'object' && !Array.isArray(o) ? Object.entries(o).filter(([k]) => k !== '__proto__') : [];
|
|
@@ -277,7 +326,8 @@ function isFamilyRecord(key, r) {
|
|
|
277
326
|
Number.isInteger(x.gen) && x.gen >= 0 && x.gen <= GEN_MAX &&
|
|
278
327
|
typeof x.cur === 'string' && x.cur.length === 43 && isCanonicalB64(x.cur, 32) &&
|
|
279
328
|
Number.isFinite(x.createdAt) && Number.isFinite(x.usedAt) &&
|
|
280
|
-
(x.legacy === undefined || typeof x.legacy === 'string')
|
|
329
|
+
(x.legacy === undefined || typeof x.legacy === 'string') &&
|
|
330
|
+
(x.by === undefined || typeof x.by === 'string'));
|
|
281
331
|
}
|
|
282
332
|
function formatError(file, format) {
|
|
283
333
|
const name = basename(file);
|
|
@@ -305,7 +355,11 @@ function decodeData(d) {
|
|
|
305
355
|
let dropped = 0;
|
|
306
356
|
for (const [k, f] of ownEntries(x.families)) {
|
|
307
357
|
if (isFamilyRecord(k, f)) {
|
|
308
|
-
data.families[k] = {
|
|
358
|
+
data.families[k] = {
|
|
359
|
+
sub: f.sub, gen: f.gen, cur: f.cur, createdAt: f.createdAt, usedAt: f.usedAt,
|
|
360
|
+
...(f.legacy === undefined ? {} : { legacy: f.legacy }),
|
|
361
|
+
...(f.by === undefined ? {} : { by: f.by }),
|
|
362
|
+
};
|
|
309
363
|
}
|
|
310
364
|
else
|
|
311
365
|
dropped += 1;
|
|
@@ -540,7 +594,7 @@ export class RefreshStore {
|
|
|
540
594
|
while (Object.hasOwn(data.families, fid.toString('base64url')));
|
|
541
595
|
return fid;
|
|
542
596
|
}
|
|
543
|
-
issue(nowMs, sub) {
|
|
597
|
+
issue(nowMs, sub, fingerprint) {
|
|
544
598
|
if (typeof sub !== 'string' || sub === '')
|
|
545
599
|
throw new Error('RefreshStore.issue: sub must be a non-empty string');
|
|
546
600
|
const key = this.key();
|
|
@@ -550,7 +604,7 @@ export class RefreshStore {
|
|
|
550
604
|
const fid = this.newFamilyId(data);
|
|
551
605
|
const fidKey = fid.toString('base64url');
|
|
552
606
|
const { token, cur } = mintR1(key, fid, 0);
|
|
553
|
-
data.families[fidKey] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs };
|
|
607
|
+
data.families[fidKey] = { sub, gen: 0, cur, createdAt: nowMs, usedAt: nowMs, ...(fingerprint === undefined ? {} : { by: fingerprint }) };
|
|
554
608
|
if (this.maxFamiliesPerSub !== undefined)
|
|
555
609
|
this.enforceFamilyCap(data, sub, this.maxFamiliesPerSub, lines, fidKey);
|
|
556
610
|
this.save(data);
|
|
@@ -558,6 +612,25 @@ export class RefreshStore {
|
|
|
558
612
|
return token;
|
|
559
613
|
});
|
|
560
614
|
}
|
|
615
|
+
/** A presented token exactly one generation behind `f`, inside the replay
|
|
616
|
+
* window, from the fingerprint that retired it, whose derived successor
|
|
617
|
+
* matches `f.cur`: a concurrent duplicate request rather than theft (see
|
|
618
|
+
* docs/internals.md). Null for anything else at that generation, including
|
|
619
|
+
* a different rand (a genuinely different gen-1 token) or no fingerprint. */
|
|
620
|
+
concurrentDuplicate(r1, f, nowMs, fingerprint, key) {
|
|
621
|
+
if (fingerprint === undefined || f.by === undefined)
|
|
622
|
+
return null;
|
|
623
|
+
if (r1.gen !== f.gen - 1)
|
|
624
|
+
return null;
|
|
625
|
+
if (nowMs < f.usedAt || nowMs - f.usedAt > REPLAY_WINDOW_MS)
|
|
626
|
+
return null;
|
|
627
|
+
if (!timingSafeStringEqual(fingerprint, f.by))
|
|
628
|
+
return null;
|
|
629
|
+
const succ = mintR1Successor(key, r1.body);
|
|
630
|
+
if (!timingSafeEqual(succ.digest, Buffer.from(f.cur, 'base64url')))
|
|
631
|
+
return null;
|
|
632
|
+
return { token: succ.token };
|
|
633
|
+
}
|
|
561
634
|
/** Rotate a presented refresh token; null if it is unknown, malformed, or
|
|
562
635
|
* rotated away (reuse => the family is revoked). The record's sub is copied
|
|
563
636
|
* forward and returned so the caller can mint the matching access token
|
|
@@ -566,8 +639,17 @@ export class RefreshStore {
|
|
|
566
639
|
* `accept` (optional) is called inside the store lock, before any mutation,
|
|
567
640
|
* with the record's sub. false: every session of that sub is dropped and
|
|
568
641
|
* null returned. A throw: nothing is written and the error propagates, so
|
|
569
|
-
* the presented token stays valid. Absent: unchanged.
|
|
570
|
-
|
|
642
|
+
* the presented token stays valid. Absent: unchanged.
|
|
643
|
+
*
|
|
644
|
+
* `fingerprint` (optional) names the presenting client (clientFingerprint).
|
|
645
|
+
* Absent: a rotated-away token is always reuse, as before. Present: a
|
|
646
|
+
* token exactly one generation behind, from the same fingerprint, presented
|
|
647
|
+
* no earlier than and at most REPLAY_WINDOW_MS after the rotation that
|
|
648
|
+
* retired it, is a concurrent duplicate: it gets back the SAME successor
|
|
649
|
+
* token, with no change to the family (not even `usedAt`). Anything else
|
|
650
|
+
* rotated-away is still reuse, including a clock that has stepped back
|
|
651
|
+
* past `usedAt`, which cannot be told apart from a genuinely stale replay. */
|
|
652
|
+
rotate(oldToken, nowMs, accept, fingerprint) {
|
|
571
653
|
const key = this.key();
|
|
572
654
|
const r1 = parseR1(oldToken, key);
|
|
573
655
|
if (!r1 && !(typeof oldToken === 'string' && LEGACY_TOKEN.test(oldToken) && !this.legacyEmpty))
|
|
@@ -595,9 +677,16 @@ export class RefreshStore {
|
|
|
595
677
|
lines.push(`refresh family ${why === 'reuse' ? 'revoked' : 'dropped'}: ${why} tag=${familyTag(r1.fidBytes)}`);
|
|
596
678
|
return done(null, true);
|
|
597
679
|
};
|
|
598
|
-
// Older: a rotated-away token
|
|
599
|
-
if (r1.gen < f.gen)
|
|
600
|
-
|
|
680
|
+
// Older: a rotated-away token verified by MAC as server-minted, unless a concurrent duplicate.
|
|
681
|
+
if (r1.gen < f.gen) {
|
|
682
|
+
const duplicate = this.concurrentDuplicate(r1, f, nowMs, fingerprint, key);
|
|
683
|
+
if (!duplicate)
|
|
684
|
+
return drop('reuse');
|
|
685
|
+
if (accept && !accept(f.sub))
|
|
686
|
+
return refuse(f.sub);
|
|
687
|
+
lines.push(`refresh family replayed tag=${familyTag(r1.fidBytes)}`);
|
|
688
|
+
return done({ token: duplicate.token, sub: f.sub }, dirty);
|
|
689
|
+
}
|
|
601
690
|
// Newer, or the same generation with another random part: the store
|
|
602
691
|
// lost a write the client saw. Not evidence of theft.
|
|
603
692
|
if (r1.gen > f.gen)
|
|
@@ -608,11 +697,15 @@ export class RefreshStore {
|
|
|
608
697
|
return refuse(f.sub);
|
|
609
698
|
if (f.gen === GEN_MAX)
|
|
610
699
|
return drop('overflow');
|
|
611
|
-
const next =
|
|
700
|
+
const next = mintR1Successor(key, r1.body);
|
|
612
701
|
f.gen += 1;
|
|
613
702
|
f.cur = next.cur;
|
|
614
703
|
// now() is read before the lock wait, so a later writer can hold an earlier time.
|
|
615
704
|
f.usedAt = Math.max(f.usedAt, nowMs);
|
|
705
|
+
if (fingerprint === undefined)
|
|
706
|
+
delete f.by;
|
|
707
|
+
else
|
|
708
|
+
f.by = fingerprint;
|
|
616
709
|
return done({ token: next.token, sub: f.sub }, true);
|
|
617
710
|
}
|
|
618
711
|
const rec = Object.hasOwn(data.active, oldToken) ? data.active[oldToken] : undefined;
|
package/dist/oauth-as.d.ts
CHANGED
|
@@ -91,6 +91,11 @@ export interface AuthServerDeps {
|
|
|
91
91
|
}>;
|
|
92
92
|
replayGuard?: ReplayGuard;
|
|
93
93
|
refreshStore?: RefreshStore;
|
|
94
|
+
/** The presenting client's address, for the refresh-rotation fingerprint
|
|
95
|
+
* (clientFingerprint) that tells a concurrent duplicate request from a
|
|
96
|
+
* different holder of the same token. Default: the socket's remote
|
|
97
|
+
* address. A host behind a trusted proxy wires its own resolver here. */
|
|
98
|
+
clientAddress?: (req: IncomingMessage) => string | undefined;
|
|
94
99
|
now?: () => number;
|
|
95
100
|
/** Server-side log (never sent to callers) for auth failures / SSRF blocks. */
|
|
96
101
|
log?: (line: string) => void;
|
package/dist/oauth-as.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { isIPv4 } from 'node:net';
|
|
5
5
|
import { decodeJwt } from 'jose';
|
|
6
|
-
import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
|
|
6
|
+
import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, clientFingerprint, refreshMacKey, } from './mcp-token.js';
|
|
7
7
|
import { fetchCimdDocument } from './ssrf-guard.js';
|
|
8
8
|
import { CimdClientCache } from './cimd-cache.js';
|
|
9
9
|
import { logSafe } from './trim.js';
|
|
@@ -189,6 +189,14 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
189
189
|
const registered = deps.registeredClients ?? new Map();
|
|
190
190
|
const now = deps.now ?? (() => Date.now());
|
|
191
191
|
const log = deps.log ?? (() => undefined);
|
|
192
|
+
const clientAddress = deps.clientAddress ?? ((req) => req.socket.remoteAddress);
|
|
193
|
+
// Not used for authorization, only to tell a concurrent duplicate /token
|
|
194
|
+
// request from a different holder of the same refresh token; keyed on the
|
|
195
|
+
// refresh store's own MAC key so a reader of the store file cannot
|
|
196
|
+
// brute-force the client_id/user-agent/address that produced a stored `by`.
|
|
197
|
+
// See docs/internals.md.
|
|
198
|
+
const fingerprintKey = refreshMacKey(config.masterKey);
|
|
199
|
+
const fingerprintOf = (req, clientId) => clientFingerprint(clientId, req.headers['user-agent'] ?? '', clientAddress(req) ?? '', fingerprintKey);
|
|
192
200
|
// Silently dropping a lifetime the caller asked for would leave sessions unlimited.
|
|
193
201
|
if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined || config.refreshMaxFamiliesPerSub !== undefined)) {
|
|
194
202
|
throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec, refreshIdleSec and refreshMaxFamiliesPerSub configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
|
|
@@ -417,7 +425,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
417
425
|
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
418
426
|
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
419
427
|
`<h2>Authorize access?</h2>` +
|
|
420
|
-
`<p>
|
|
428
|
+
`<p>An application (<code>${escapeHtml(clientId)}</code>) asks to connect to this server as you.</p>` +
|
|
421
429
|
`<p>If approved, it will receive an authorization redirected to:<br><code>${escapeHtml(redirectUri)}</code></p>` +
|
|
422
430
|
`<p><strong>If you did not start this, close this window.</strong></p>` +
|
|
423
431
|
`<form method="POST" action="/authorize"><input type="hidden" name="pending" value="${escapeHtml(pendingToken)}">` +
|
|
@@ -490,7 +498,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
490
498
|
}
|
|
491
499
|
log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
|
|
492
500
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
493
|
-
res.end(`<!doctype html><meta charset=utf-8><p>
|
|
501
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Linked "${escapeHtml(st.alias)}" to this server. Start a new chat to use it.</p>`);
|
|
494
502
|
return true;
|
|
495
503
|
}
|
|
496
504
|
if (st.flow === 'alias_reauth') {
|
|
@@ -599,7 +607,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
599
607
|
return json(res, 400, { error: 'invalid_grant', message: 'the subject is no longer provisioned' });
|
|
600
608
|
}
|
|
601
609
|
}
|
|
602
|
-
const refreshToken = refresh.issue(now(), code.sub);
|
|
610
|
+
const refreshToken = refresh.issue(now(), code.sub, fingerprintOf(req, form.client_id ?? ''));
|
|
603
611
|
log('token issued grant=authorization_code');
|
|
604
612
|
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
|
|
605
613
|
}
|
|
@@ -621,7 +629,7 @@ export function buildAuthServer(config, deps = {}) {
|
|
|
621
629
|
: undefined;
|
|
622
630
|
let next;
|
|
623
631
|
try {
|
|
624
|
-
next = refresh.rotate(form.refresh_token ?? '', now(), accept);
|
|
632
|
+
next = refresh.rotate(form.refresh_token ?? '', now(), accept, fingerprintOf(req, form.client_id ?? ''));
|
|
625
633
|
}
|
|
626
634
|
catch (e) {
|
|
627
635
|
if (!(e instanceof SubjectCheckUnavailable))
|
package/dist/registry.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface CatalogOperation {
|
|
|
38
38
|
* real scope error rather than a bare "not found". */
|
|
39
39
|
unreachable?: true;
|
|
40
40
|
}
|
|
41
|
+
/** The leading sentence of a (possibly multi-sentence) tool description. */
|
|
42
|
+
export declare function firstSentence(text: string): string;
|
|
41
43
|
/** The service a tool name belongs to: its prefix, unless overridden. */
|
|
42
44
|
export declare function serviceOf(name: string): string;
|
|
43
45
|
export declare function inferCud(name: string): Cud;
|