mcp-google-multi 6.0.0-beta.4 โ†’ 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +15 -1
  2. package/dist/discover.js +2 -3
  3. package/dist/fs-atomic.js +98 -12
  4. package/dist/hint-provider.d.ts +4 -1
  5. package/dist/mcp-token.d.ts +37 -3
  6. package/dist/mcp-token.js +105 -12
  7. package/dist/oauth-as.d.ts +5 -0
  8. package/dist/oauth-as.js +13 -5
  9. package/dist/registry.d.ts +2 -0
  10. package/dist/registry.js +59 -10
  11. package/dist/tools/accounts-tool.d.ts +7 -0
  12. package/dist/tools/accounts-tool.js +17 -6
  13. package/dist/tools/admin.js +6 -1
  14. package/dist/tools/analytics.js +28 -11
  15. package/dist/tools/calendar.d.ts +5 -0
  16. package/dist/tools/calendar.js +59 -0
  17. package/dist/tools/contacts.js +11 -5
  18. package/dist/tools/docs.js +41 -12
  19. package/dist/tools/drive.js +54 -12
  20. package/dist/tools/generated/gmail.js +0 -14
  21. package/dist/tools/generated/method-map.js +1 -1
  22. package/dist/tools/gmail.d.ts +1 -1
  23. package/dist/tools/gmail.js +109 -12
  24. package/dist/tools/sheets.d.ts +10 -0
  25. package/dist/tools/sheets.js +109 -2
  26. package/dist/write-control.js +15 -2
  27. package/package.json +4 -2
  28. package/skills/google-workspace/SKILL.md +162 -0
  29. package/skills/google-workspace/reference/admin.md +105 -0
  30. package/skills/google-workspace/reference/analytics.md +70 -0
  31. package/skills/google-workspace/reference/appsmarket.md +5 -0
  32. package/skills/google-workspace/reference/calendar.md +175 -0
  33. package/skills/google-workspace/reference/chat.md +59 -0
  34. package/skills/google-workspace/reference/classroom.md +5 -0
  35. package/skills/google-workspace/reference/cloudidentity.md +5 -0
  36. package/skills/google-workspace/reference/cloudsearch.md +5 -0
  37. package/skills/google-workspace/reference/contacts.md +138 -0
  38. package/skills/google-workspace/reference/docs.md +395 -0
  39. package/skills/google-workspace/reference/drive.md +485 -0
  40. package/skills/google-workspace/reference/driveactivity.md +5 -0
  41. package/skills/google-workspace/reference/drivelabels.md +5 -0
  42. package/skills/google-workspace/reference/forms.md +92 -0
  43. package/skills/google-workspace/reference/gmail.md +305 -0
  44. package/skills/google-workspace/reference/groupsmigration.md +5 -0
  45. package/skills/google-workspace/reference/groupssettings.md +5 -0
  46. package/skills/google-workspace/reference/keep.md +5 -0
  47. package/skills/google-workspace/reference/licensing.md +5 -0
  48. package/skills/google-workspace/reference/meet.md +68 -0
  49. package/skills/google-workspace/reference/postmaster.md +5 -0
  50. package/skills/google-workspace/reference/reseller.md +5 -0
  51. package/skills/google-workspace/reference/script.md +5 -0
  52. package/skills/google-workspace/reference/searchconsole.md +128 -0
  53. package/skills/google-workspace/reference/sheets.md +430 -0
  54. package/skills/google-workspace/reference/slides.md +67 -0
  55. package/skills/google-workspace/reference/tasks.md +167 -0
  56. package/skills/google-workspace/reference/vault.md +5 -0
  57. 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
  [![npm](https://img.shields.io/npm/v/mcp-google-multi?label=npm&color=cb3837)](https://www.npmjs.com/package/mcp-google-multi)
6
6
 
7
- - ๐Ÿงฐ **Exhaustive** โ€” 940 tools across 29 services, now including Google Analytics (GA4), + an escape hatch for anything else โ†’ [COVERAGE.md](./COVERAGE.md)
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
- next: operations.length > 0
100
- ? 'Call the chosen tool by name; it is now listed and callable.'
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
- if (sameFile(fs.lstatSync(ownerPath, { bigint: true }), before)) {
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 !== 'EEXIST')
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, bound);
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
- function releaseLock(lock, ownerFile, bound) {
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 (!bound || sameFile(fs.lstatSync(ownerFile, { bigint: true }), fs.lstatSync(lock, { bigint: true })))
257
- fs.rmSync(lock, { force: true });
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))
@@ -58,7 +58,10 @@ export type HintCase = {
58
58
  entries: string[];
59
59
  } | {
60
60
  kind: 'no_default_account';
61
- aliases: string[];
61
+ accounts: {
62
+ alias: string;
63
+ email: string;
64
+ }[];
62
65
  } | {
63
66
  kind: 'no_accounts';
64
67
  } | {
@@ -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
- rotate(oldToken: string, nowMs: number, accept?: (sub: string) => boolean): {
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
- function refreshMacKey(masterKey) {
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] = { sub: f.sub, gen: f.gen, cur: f.cur, createdAt: f.createdAt, usedAt: f.usedAt, ...(f.legacy === undefined ? {} : { legacy: f.legacy }) };
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
- rotate(oldToken, nowMs, accept) {
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, and the MAC proves this server minted it.
599
- if (r1.gen < f.gen)
600
- return drop('reuse');
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 = mintR1(key, r1.fidBytes, f.gen + 1);
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;
@@ -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>A newly-registered application (<code>${escapeHtml(clientId)}</code>) is requesting access to your Google Workspace through this server.</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>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</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))
@@ -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;