@plaud-ai/mcp 0.3.9 → 0.3.10

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.
@@ -2,11 +2,12 @@
2
2
  import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics } from "prom-client";
3
3
  var registry = new Registry();
4
4
  collectDefaultMetrics({ register: registry });
5
+ var LATENCY_BUCKETS = [0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5, 10, 30];
5
6
  var httpRequestDuration = new Histogram({
6
7
  name: "http_request_duration_seconds",
7
8
  help: "HTTP request latency in seconds",
8
9
  labelNames: ["method", "handler", "status"],
9
- buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
10
+ buckets: LATENCY_BUCKETS,
10
11
  registers: [registry]
11
12
  });
12
13
  var httpRequestsInProgress = new Gauge({
@@ -25,7 +26,7 @@ var mcpToolDuration = new Histogram({
25
26
  name: "mcp_tool_duration_seconds",
26
27
  help: "MCP tool execution latency in seconds",
27
28
  labelNames: ["tool"],
28
- buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
29
+ buckets: LATENCY_BUCKETS,
29
30
  registers: [registry]
30
31
  });
31
32
  var oauthTokenRefresh = new Counter({
@@ -44,7 +45,7 @@ var httpClientDuration = new Histogram({
44
45
  name: "http_client_duration_seconds",
45
46
  help: "Outbound HTTP request latency in seconds",
46
47
  labelNames: ["host", "endpoint"],
47
- buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
48
+ buckets: LATENCY_BUCKETS,
48
49
  registers: [registry]
49
50
  });
50
51
 
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  httpClientDuration,
7
7
  httpClientRequests
8
- } from "./chunk-RUFCT6DQ.js";
8
+ } from "./chunk-DIPROABB.js";
9
9
 
10
10
  // src/config.ts
11
11
  function buildExtraHeaders() {
@@ -12,7 +12,7 @@ import {
12
12
  import {
13
13
  mcpToolCalls,
14
14
  mcpToolDuration
15
- } from "./chunk-RUFCT6DQ.js";
15
+ } from "./chunk-DIPROABB.js";
16
16
 
17
17
  // src/tools/index.ts
18
18
  import { z } from "zod";
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  getClient
4
- } from "./chunk-EY5K2UXG.js";
4
+ } from "./chunk-PLFBTSKM.js";
5
5
  import {
6
6
  loadSkills
7
7
  } from "./chunk-242FRP4P.js";
8
8
  import {
9
9
  normalizeMcpHost,
10
10
  registerTools
11
- } from "./chunk-FSNFNJXH.js";
11
+ } from "./chunk-ZVOHKRNC.js";
12
12
  import {
13
13
  capture,
14
14
  classifyError,
@@ -23,7 +23,7 @@ import {
23
23
  shutdown
24
24
  } from "./chunk-5NWKLF3V.js";
25
25
  import "./chunk-NPCCDRWQ.js";
26
- import "./chunk-RUFCT6DQ.js";
26
+ import "./chunk-DIPROABB.js";
27
27
 
28
28
  // src/index.ts
29
29
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -31,7 +31,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
31
31
  import open from "open";
32
32
  var server = new McpServer({
33
33
  name: "plaud",
34
- version: "0.3.9"
34
+ version: "0.3.10"
35
35
  });
36
36
  var CALLBACK_PORT = 8199;
37
37
  var LOGIN_TIMEOUT_MS = 12e4;
@@ -158,7 +158,7 @@ async function main() {
158
158
  const sub = process.argv[2];
159
159
  const sub2 = process.argv[3];
160
160
  if (sub === "install") {
161
- const { runInstall } = await import("./install-RTIXREYV.js");
161
+ const { runInstall } = await import("./install-SWKIKJ5C.js");
162
162
  const args = process.argv.slice(3);
163
163
  const yes = args.some((a) => a === "--yes" || a === "-y");
164
164
  const noLogin = args.some((a) => a === "--no-login");
@@ -191,8 +191,8 @@ async function main() {
191
191
  return;
192
192
  }
193
193
  if (sub === "http") {
194
- const { startHttpServer } = await import("./server-SYSE62NO.js");
195
- const { startMetricsServer } = await import("./server-NKCNUA6P.js");
194
+ const { startHttpServer } = await import("./server-SB6RRGY7.js");
195
+ const { startMetricsServer } = await import("./server-6RAC6S7E.js");
196
196
  startMetricsServer();
197
197
  startHttpServer();
198
198
  return;
@@ -217,7 +217,7 @@ Usage:
217
217
  try {
218
218
  await initTelemetry({
219
219
  surface: "mcp",
220
- appVersion: "0.3.9",
220
+ appVersion: "0.3.10",
221
221
  transport: "stdio"
222
222
  });
223
223
  } catch {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getClient
3
- } from "./chunk-EY5K2UXG.js";
3
+ } from "./chunk-PLFBTSKM.js";
4
4
  import {
5
5
  commandPathIsStale,
6
6
  copyToClipboard,
@@ -13,7 +13,7 @@ import {
13
13
  import {
14
14
  runOAuthCallback
15
15
  } from "./chunk-5NWKLF3V.js";
16
- import "./chunk-RUFCT6DQ.js";
16
+ import "./chunk-DIPROABB.js";
17
17
 
18
18
  // src/install.ts
19
19
  import { createInterface } from "readline/promises";
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-NPCCDRWQ.js";
4
4
  import {
5
5
  registry
6
- } from "./chunk-RUFCT6DQ.js";
6
+ } from "./chunk-DIPROABB.js";
7
7
 
8
8
  // src/metrics/server.ts
9
9
  import express from "express";
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  normalizeMcpHost,
3
3
  registerTools
4
- } from "./chunk-FSNFNJXH.js";
4
+ } from "./chunk-ZVOHKRNC.js";
5
5
  import {
6
- PlaudClient
6
+ PlaudClient,
7
+ classifyError
7
8
  } from "./chunk-5NWKLF3V.js";
8
9
  import {
9
10
  logger
@@ -12,12 +13,12 @@ import {
12
13
  httpRequestDuration,
13
14
  httpRequestsInProgress,
14
15
  oauthTokenRefresh
15
- } from "./chunk-RUFCT6DQ.js";
16
+ } from "./chunk-DIPROABB.js";
16
17
 
17
18
  // src/http/server.ts
18
19
  import express from "express";
19
20
  import { createServer } from "http";
20
- import { randomUUID as randomUUID3 } from "crypto";
21
+ import { randomUUID as randomUUID2 } from "crypto";
21
22
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
23
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
23
24
  import { createOAuthMetadata, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
@@ -25,12 +26,13 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew
25
26
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
26
27
 
27
28
  // src/http/oauth-provider.ts
28
- import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
29
+ import { createCipheriv, createDecipheriv, createHash as createHash2, createHmac, randomBytes, timingSafeEqual } from "crypto";
29
30
  import { ProxyOAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js";
30
31
  import {
31
32
  InvalidGrantError,
32
33
  InvalidTokenError,
33
- ServerError as McpServerError
34
+ ServerError as McpServerError,
35
+ TooManyRequestsError
34
36
  } from "@modelcontextprotocol/sdk/server/auth/errors.js";
35
37
 
36
38
  // src/http/cimd.ts
@@ -266,6 +268,245 @@ function validateMetadata(parsed, expectedClientIdUrl) {
266
268
  };
267
269
  }
268
270
 
271
+ // src/http/token-verifier.ts
272
+ import { createHash } from "crypto";
273
+ function decodeJwtClaims(token) {
274
+ const parts = token.split(".");
275
+ if (parts.length !== 3) return null;
276
+ const payload = parts[1];
277
+ if (!payload) return null;
278
+ try {
279
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
280
+ if (!parsed || typeof parsed !== "object") return null;
281
+ return parsed;
282
+ } catch {
283
+ return null;
284
+ }
285
+ }
286
+ function sha256(value) {
287
+ return createHash("sha256").update(value).digest("base64url");
288
+ }
289
+ var Lru = class {
290
+ constructor(max) {
291
+ this.max = max;
292
+ }
293
+ max;
294
+ map = /* @__PURE__ */ new Map();
295
+ get(key) {
296
+ const value = this.map.get(key);
297
+ if (value === void 0) return void 0;
298
+ this.map.delete(key);
299
+ this.map.set(key, value);
300
+ return value;
301
+ }
302
+ set(key, value) {
303
+ if (this.map.has(key)) {
304
+ this.map.delete(key);
305
+ } else if (this.map.size >= this.max) {
306
+ const oldest = this.map.keys().next().value;
307
+ if (oldest !== void 0) this.map.delete(oldest);
308
+ }
309
+ this.map.set(key, value);
310
+ }
311
+ delete(key) {
312
+ this.map.delete(key);
313
+ }
314
+ get size() {
315
+ return this.map.size;
316
+ }
317
+ };
318
+ var TokenBucket = class {
319
+ constructor(capacity, refillPerSec, nowMs) {
320
+ this.capacity = capacity;
321
+ this.refillPerSec = refillPerSec;
322
+ this.tokens = capacity;
323
+ this.lastRefillMs = nowMs;
324
+ }
325
+ capacity;
326
+ refillPerSec;
327
+ tokens;
328
+ lastRefillMs;
329
+ take(nowMs) {
330
+ const elapsedSec = (nowMs - this.lastRefillMs) / 1e3;
331
+ if (elapsedSec > 0) {
332
+ this.tokens = Math.min(this.capacity, this.tokens + elapsedSec * this.refillPerSec);
333
+ this.lastRefillMs = nowMs;
334
+ }
335
+ if (this.tokens >= 1) {
336
+ this.tokens -= 1;
337
+ return true;
338
+ }
339
+ return false;
340
+ }
341
+ };
342
+ var DEFAULTS = {
343
+ cacheMaxEntries: 5e3,
344
+ positiveTtlMs: 6e4,
345
+ negativeTtlMs: 3e4,
346
+ strangerBurst: 40,
347
+ strangerRatePerSec: 20,
348
+ knownBurst: 10,
349
+ knownRatePerSec: 5,
350
+ knownSubMaxEntries: 2e4
351
+ };
352
+ var TokenVerifier = class {
353
+ cache;
354
+ subBuckets;
355
+ strangerBucket;
356
+ opts;
357
+ constructor(options) {
358
+ const now = options.now ?? (() => Date.now());
359
+ this.opts = {
360
+ verifyUpstream: options.verifyUpstream,
361
+ cacheEnabled: options.cacheEnabled ?? true,
362
+ cacheMaxEntries: options.cacheMaxEntries ?? DEFAULTS.cacheMaxEntries,
363
+ positiveTtlMs: options.positiveTtlMs ?? DEFAULTS.positiveTtlMs,
364
+ negativeTtlMs: options.negativeTtlMs ?? DEFAULTS.negativeTtlMs,
365
+ strangerBurst: options.strangerBurst ?? DEFAULTS.strangerBurst,
366
+ strangerRatePerSec: options.strangerRatePerSec ?? DEFAULTS.strangerRatePerSec,
367
+ knownBurst: options.knownBurst ?? DEFAULTS.knownBurst,
368
+ knownRatePerSec: options.knownRatePerSec ?? DEFAULTS.knownRatePerSec,
369
+ knownSubMaxEntries: options.knownSubMaxEntries ?? DEFAULTS.knownSubMaxEntries,
370
+ now
371
+ };
372
+ this.cache = new Lru(this.opts.cacheMaxEntries);
373
+ this.subBuckets = new Lru(this.opts.knownSubMaxEntries);
374
+ this.strangerBucket = new TokenBucket(
375
+ this.opts.strangerBurst,
376
+ this.opts.strangerRatePerSec,
377
+ now()
378
+ );
379
+ }
380
+ /**
381
+ * Seeds the cache with a token that was just minted upstream (OAuth code
382
+ * exchange or refresh).
383
+ *
384
+ * This is what keeps normal users out of the rate-limit tier entirely: their
385
+ * first request after logging in or rotating a token is already a cache hit,
386
+ * so it never draws from the budget that unrecognised tokens consume.
387
+ *
388
+ * clientId is taken from the JWT `sub` rather than the upstream user.id: we
389
+ * have not called upstream at this point and do not need to, since the token
390
+ * came straight from the token endpoint. Downstream only uses clientId for
391
+ * logging, and server.ts derives ctxUserId from `sub` first anyway.
392
+ */
393
+ prewarm(token) {
394
+ if (!this.opts.cacheEnabled) return;
395
+ const claims = decodeJwtClaims(token);
396
+ if (!claims) return;
397
+ const sub = typeof claims.sub === "string" ? claims.sub : void 0;
398
+ const exp = typeof claims.exp === "number" ? claims.exp : void 0;
399
+ if (!sub) return;
400
+ const nowMs = this.opts.now();
401
+ const expiresAt = exp ?? Math.floor(nowMs / 1e3) + 3600;
402
+ this.putPositive(sha256(token), sub, expiresAt, nowMs);
403
+ this.noteKnownSub(sub, nowMs);
404
+ logger.info({ event: "token_prewarmed", cache_size: this.cache.size });
405
+ }
406
+ async verify(token) {
407
+ const nowMs = this.opts.now();
408
+ const nowSec = Math.floor(nowMs / 1e3);
409
+ const claims = decodeJwtClaims(token);
410
+ if (!claims) {
411
+ logger.warn({ event: "token_rejected_local", reason: "malformed" });
412
+ return { ok: false, kind: "malformed" };
413
+ }
414
+ const sub = typeof claims.sub === "string" && claims.sub ? claims.sub : void 0;
415
+ const exp = typeof claims.exp === "number" ? claims.exp : void 0;
416
+ if (exp !== void 0 && exp <= nowSec) {
417
+ logger.warn({ event: "token_rejected_local", reason: "expired" });
418
+ return { ok: false, kind: "expired" };
419
+ }
420
+ const key = sha256(token);
421
+ if (this.opts.cacheEnabled) {
422
+ const hit = this.cache.get(key);
423
+ if (hit && hit.cacheExpiresAtMs > nowMs) {
424
+ return hit.valid ? { ok: true, clientId: hit.clientId, expiresAt: hit.expiresAt, source: "cache" } : { ok: false, kind: "invalid" };
425
+ }
426
+ if (hit) this.cache.delete(key);
427
+ }
428
+ const looksKnown = sub !== void 0 && sub.startsWith("client_user_");
429
+ const bucket = sub !== void 0 && looksKnown ? this.subBuckets.get(sub) : void 0;
430
+ const allowed = bucket ? bucket.take(nowMs) : this.strangerBucket.take(nowMs);
431
+ if (!allowed) {
432
+ logger.warn({
433
+ event: "token_verify_rate_limited",
434
+ scope: bucket ? "known_sub" : "stranger"
435
+ });
436
+ return { ok: false, kind: "rate_limited" };
437
+ }
438
+ const verdict = await this.opts.verifyUpstream(token);
439
+ if (verdict.kind === "valid") {
440
+ const expiresAt = exp ?? nowSec + 3600;
441
+ if (this.opts.cacheEnabled) this.putPositive(key, verdict.clientId, expiresAt, nowMs);
442
+ if (sub !== void 0) this.noteKnownSub(sub, nowMs);
443
+ logger.info({ event: "token_verified", client_id: verdict.clientId, expires_at: expiresAt });
444
+ return { ok: true, clientId: verdict.clientId, expiresAt, source: "upstream" };
445
+ }
446
+ if (verdict.kind === "invalid") {
447
+ if (this.opts.cacheEnabled) {
448
+ this.cache.set(key, {
449
+ valid: false,
450
+ clientId: "",
451
+ expiresAt: 0,
452
+ cacheExpiresAtMs: nowMs + this.opts.negativeTtlMs
453
+ });
454
+ }
455
+ logger.warn({ event: "token_verify_failed", reason: "invalid" });
456
+ return { ok: false, kind: "invalid" };
457
+ }
458
+ logger.error({ event: "token_verify_unavailable", reason: verdict.reason });
459
+ return { ok: false, kind: "unavailable" };
460
+ }
461
+ /** For /health and metrics — counts only, no tokens or user identifiers. */
462
+ stats() {
463
+ return {
464
+ cacheSize: this.cache.size,
465
+ knownSubs: this.subBuckets.size,
466
+ cacheEnabled: this.opts.cacheEnabled
467
+ };
468
+ }
469
+ putPositive(key, clientId, expiresAt, nowMs) {
470
+ const remainingMs = expiresAt * 1e3 - nowMs;
471
+ const ttlMs = Math.min(this.opts.positiveTtlMs, Math.max(0, remainingMs));
472
+ if (ttlMs <= 0) return;
473
+ this.cache.set(key, { valid: true, clientId, expiresAt, cacheExpiresAtMs: nowMs + ttlMs });
474
+ }
475
+ noteKnownSub(sub, nowMs) {
476
+ if (this.subBuckets.get(sub) === void 0) {
477
+ this.subBuckets.set(
478
+ sub,
479
+ new TokenBucket(this.opts.knownBurst, this.opts.knownRatePerSec, nowMs)
480
+ );
481
+ }
482
+ }
483
+ };
484
+ function tokenVerifierOptionsFromEnv() {
485
+ const num = (name, fallback) => {
486
+ const raw = process.env[name];
487
+ if (raw === void 0 || raw === "") return fallback;
488
+ const parsed = Number(raw);
489
+ if (!Number.isFinite(parsed) || parsed <= 0) {
490
+ logger.warn({ event: "token_verifier_bad_env", name, value: raw, using: fallback });
491
+ return fallback;
492
+ }
493
+ return parsed;
494
+ };
495
+ return {
496
+ cacheEnabled: !["0", "false", "no", "off"].includes(
497
+ (process.env["PLAUD_TOKEN_CACHE_ENABLED"] ?? "").toLowerCase()
498
+ ),
499
+ cacheMaxEntries: num("PLAUD_TOKEN_CACHE_MAX", DEFAULTS.cacheMaxEntries),
500
+ positiveTtlMs: num("PLAUD_TOKEN_CACHE_TTL_MS", DEFAULTS.positiveTtlMs),
501
+ negativeTtlMs: num("PLAUD_TOKEN_NEGATIVE_TTL_MS", DEFAULTS.negativeTtlMs),
502
+ strangerBurst: num("PLAUD_TOKEN_VERIFY_BURST", DEFAULTS.strangerBurst),
503
+ strangerRatePerSec: num("PLAUD_TOKEN_VERIFY_RATE", DEFAULTS.strangerRatePerSec),
504
+ knownBurst: num("PLAUD_TOKEN_KNOWN_BURST", DEFAULTS.knownBurst),
505
+ knownRatePerSec: num("PLAUD_TOKEN_KNOWN_RATE", DEFAULTS.knownRatePerSec),
506
+ knownSubMaxEntries: num("PLAUD_TOKEN_KNOWN_SUB_MAX", DEFAULTS.knownSubMaxEntries)
507
+ };
508
+ }
509
+
269
510
  // src/http/oauth-provider.ts
270
511
  function subFromJwt(token) {
271
512
  if (!token) return void 0;
@@ -301,6 +542,8 @@ var DEFAULT_TOKEN_URL = "https://platform.plaud.ai/developer/api/oauth/third-par
301
542
  var DEFAULT_REFRESH_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token/refresh";
302
543
  var STATELESS_CODE_PREFIX = "pc1_";
303
544
  var AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1e3;
545
+ var STATELESS_STATE_PREFIX = "st1_";
546
+ var AUTHORIZATION_STATE_TTL_MS = 30 * 60 * 1e3;
304
547
  function urlParts(value) {
305
548
  if (!value || !URL.canParse(value)) {
306
549
  return { host: null, path: null };
@@ -318,57 +561,73 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
318
561
  _debugOAuthLogs;
319
562
  _cimdLoader;
320
563
  _tracker;
564
+ /** Local pre-checks + cache + tiered rate limit + upstream call, all in one
565
+ * place. See token-verifier.ts. */
566
+ _verifier;
321
567
  _registeredClients = /* @__PURE__ */ new Map();
322
568
  // HMAC secret used to sign DCR-issued client_ids so we can recover them
323
569
  // across container restarts without persistent storage. See verifyAndRecover.
324
570
  _clientIdSecret;
325
- // internalState client redirect, original client state, and resource indicator
326
- // We generate our own state to track the pending flow regardless of whether the client sent one.
327
- _pendingStates = /* @__PURE__ */ new Map();
571
+ // NOTE: there is deliberately no in-process store for pending authorizations.
572
+ // This class used to keep `_pendingStates: Map<state, {clientRedirectUri, …}>`,
573
+ // which meant /authorize and /auth/callback had to be served by the same
574
+ // process — the callback looked the state up and hard-failed with 400 if it
575
+ // wasn't there. That single map is what pinned the deployment to one replica
576
+ // (and it was never swept, so abandoned authorizations accumulated for the
577
+ // lifetime of the pod). The state parameter is now a sealed, self-contained
578
+ // envelope instead; see encodeAuthorizationState.
579
+ //
580
+ // Everything else here was already replica-safe: client_ids are HMAC-signed
581
+ // and recoverable (verifyAndRecover), and authorization codes are sealed the
582
+ // same way. This was the last piece holding it back.
328
583
  constructor(options) {
329
584
  const authUrl = options.authUrl ?? "https://web.plaud.ai/platform/oauth";
330
585
  const tokenUrl = options.tokenUrl ?? DEFAULT_TOKEN_URL;
331
586
  const refreshUrl = options.refreshUrl ?? DEFAULT_REFRESH_URL;
332
587
  const apiBase = options.apiBase ?? "https://platform.plaud.ai/developer/api";
588
+ const holder = {};
589
+ const verifyUpstream = async (token) => {
590
+ const client = new PlaudClient({
591
+ clientId: options.clientId,
592
+ clientSecret: "",
593
+ redirectUri: "",
594
+ apiBase,
595
+ staticToken: token
596
+ });
597
+ try {
598
+ const user = await client.getCurrentUser();
599
+ return { kind: "valid", clientId: String(user.id ?? "unknown") };
600
+ } catch (err) {
601
+ const type = classifyError(err);
602
+ if (type === "auth") return { kind: "invalid" };
603
+ return { kind: "unavailable", reason: type };
604
+ }
605
+ };
333
606
  super({
334
607
  endpoints: {
335
608
  authorizationUrl: authUrl,
336
609
  tokenUrl
337
610
  },
338
611
  verifyAccessToken: async (token) => {
339
- const client = new PlaudClient({
340
- clientId: options.clientId,
341
- clientSecret: "",
342
- redirectUri: "",
343
- apiBase,
344
- staticToken: token
345
- });
346
- try {
347
- const user = await client.getCurrentUser();
348
- let expiresAt;
349
- try {
350
- const payload = JSON.parse(
351
- Buffer.from(token.split(".")[1], "base64url").toString()
352
- );
353
- expiresAt = typeof payload.exp === "number" ? payload.exp : Math.floor(Date.now() / 1e3) + 3600;
354
- } catch {
355
- expiresAt = Math.floor(Date.now() / 1e3) + 3600;
356
- }
357
- const authInfo = {
358
- token,
359
- clientId: String(user.id ?? "unknown"),
360
- scopes: [],
361
- expiresAt
362
- };
363
- logger.info({ event: "token_verified", client_id: authInfo.clientId, expires_at: expiresAt });
364
- return authInfo;
365
- } catch (err) {
366
- logger.warn({ event: "token_verify_failed", error: String(err) });
367
- throw new InvalidTokenError("Invalid or expired token");
612
+ const verifier = holder.verifier;
613
+ if (!verifier) throw new McpServerError("Token verifier not initialised");
614
+ const outcome = await verifier.verify(token);
615
+ if (outcome.ok) {
616
+ return { token, clientId: outcome.clientId, scopes: [], expiresAt: outcome.expiresAt };
617
+ }
618
+ switch (outcome.kind) {
619
+ case "unavailable":
620
+ throw new McpServerError("Upstream token verification temporarily unavailable");
621
+ case "rate_limited":
622
+ throw new TooManyRequestsError("Token verification rate limited");
623
+ default:
624
+ throw new InvalidTokenError("Invalid or expired token");
368
625
  }
369
626
  },
370
627
  getClient: async (id) => this._registeredClients.get(id)
371
628
  });
629
+ holder.verifier = new TokenVerifier({ ...tokenVerifierOptionsFromEnv(), verifyUpstream });
630
+ this._verifier = holder.verifier;
372
631
  this._plaudClientId = options.clientId;
373
632
  this._plaudClientSecret = options.clientSecret ?? "";
374
633
  this._plaudTokenUrl = tokenUrl;
@@ -390,6 +649,10 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
390
649
  }
391
650
  this.skipLocalPkceValidation = true;
392
651
  }
652
+ /** For /health and metrics — counts only, no tokens or user identifiers. */
653
+ get verifierStats() {
654
+ return this._verifier.stats();
655
+ }
393
656
  // Override clientsStore. registerClient signs the issued client_id with HMAC
394
657
  // so verifyAndRecover can later resurrect it across container restarts.
395
658
  get clientsStore() {
@@ -495,49 +758,116 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
495
758
  if (actual.length !== expected.length) return false;
496
759
  return timingSafeEqual(actual, expected);
497
760
  }
498
- authorizationCodeKey() {
499
- return createHmac("sha256", this._clientIdSecret).update("plaud-mcp-authorization-code").digest();
761
+ // Key is derived per purpose so a value sealed for one hop can never be
762
+ // unsealed as the other — an authorization code presented as a state (or vice
763
+ // versa) fails the GCM tag check rather than decoding into the wrong shape.
764
+ envelopeKey(purpose) {
765
+ return createHmac("sha256", this._clientIdSecret).update(purpose).digest();
500
766
  }
501
- encodeAuthorizationCode(payload) {
767
+ // AES-256-GCM envelope: prefix + base64url(iv[12] | tag[16] | ciphertext).
768
+ // Self-contained, so any pod can open what any other pod sealed — that is what
769
+ // lets us run more than one replica without a shared store.
770
+ seal(purpose, prefix, payload, ttlMs) {
502
771
  const iv = randomBytes(12);
503
- const cipher = createCipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
504
- const plaintext = Buffer.from(JSON.stringify({
505
- upstreamCode: payload.upstreamCode,
506
- upstreamState: payload.upstreamState,
507
- resource: payload.resource,
508
- expiresAt: Date.now() + AUTHORIZATION_CODE_TTL_MS
509
- }), "utf8");
772
+ const cipher = createCipheriv("aes-256-gcm", this.envelopeKey(purpose), iv);
773
+ const plaintext = Buffer.from(
774
+ JSON.stringify({ ...payload, expiresAt: Date.now() + ttlMs }),
775
+ "utf8"
776
+ );
510
777
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
511
778
  const tag = cipher.getAuthTag();
512
- return `${STATELESS_CODE_PREFIX}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
779
+ return `${prefix}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
513
780
  }
514
- decodeAuthorizationCode(code) {
515
- if (!code.startsWith(STATELESS_CODE_PREFIX)) return null;
781
+ // Returns null for anything that isn't ours, has been tampered with, or has
782
+ // expired. Callers still validate the payload's own shape.
783
+ unseal(purpose, prefix, value) {
784
+ if (typeof value !== "string" || !value.startsWith(prefix)) return null;
516
785
  try {
517
- const encoded = code.slice(STATELESS_CODE_PREFIX.length);
518
- const data = Buffer.from(encoded, "base64url");
786
+ const data = Buffer.from(value.slice(prefix.length), "base64url");
519
787
  if (data.length <= 28) return null;
520
- const iv = data.subarray(0, 12);
521
- const tag = data.subarray(12, 28);
522
- const ciphertext = data.subarray(28);
523
- const decipher = createDecipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
524
- decipher.setAuthTag(tag);
525
- const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
788
+ const decipher = createDecipheriv("aes-256-gcm", this.envelopeKey(purpose), data.subarray(0, 12));
789
+ decipher.setAuthTag(data.subarray(12, 28));
790
+ const plaintext = Buffer.concat([
791
+ decipher.update(data.subarray(28)),
792
+ decipher.final()
793
+ ]).toString("utf8");
526
794
  const payload = JSON.parse(plaintext);
527
- if (typeof payload.upstreamCode !== "string" || typeof payload.upstreamState !== "string" || payload.resource !== void 0 && typeof payload.resource !== "string" || typeof payload.expiresAt !== "number") {
795
+ if (typeof payload["expiresAt"] !== "number" || Date.now() > payload["expiresAt"]) {
528
796
  return null;
529
797
  }
530
- if (Date.now() > payload.expiresAt) {
531
- return null;
532
- }
533
- return {
798
+ return payload;
799
+ } catch {
800
+ return null;
801
+ }
802
+ }
803
+ encodeAuthorizationCode(payload) {
804
+ return this.seal(
805
+ "plaud-mcp-authorization-code",
806
+ STATELESS_CODE_PREFIX,
807
+ {
534
808
  upstreamCode: payload.upstreamCode,
535
809
  upstreamState: payload.upstreamState,
536
810
  resource: payload.resource
537
- };
538
- } catch {
811
+ },
812
+ AUTHORIZATION_CODE_TTL_MS
813
+ );
814
+ }
815
+ decodeAuthorizationCode(code) {
816
+ const payload = this.unseal(
817
+ "plaud-mcp-authorization-code",
818
+ STATELESS_CODE_PREFIX,
819
+ code
820
+ );
821
+ if (!payload) return null;
822
+ if (typeof payload.upstreamCode !== "string" || typeof payload.upstreamState !== "string" || payload.resource !== void 0 && typeof payload.resource !== "string") {
823
+ return null;
824
+ }
825
+ return {
826
+ upstreamCode: payload.upstreamCode,
827
+ upstreamState: payload.upstreamState,
828
+ resource: payload.resource
829
+ };
830
+ }
831
+ // The authorize→callback hop. Everything the callback needs travels inside the
832
+ // state parameter itself, so the pod that handles the callback need not be the
833
+ // pod that started the flow. This is what replaced the in-process
834
+ // _pendingStates map — see the class docstring for why that map capped us at
835
+ // one replica.
836
+ encodeAuthorizationState(payload) {
837
+ return this.seal(
838
+ "plaud-mcp-authorization-state",
839
+ STATELESS_STATE_PREFIX,
840
+ {
841
+ clientRedirectUri: payload.clientRedirectUri,
842
+ originalState: payload.originalState,
843
+ resource: payload.resource
844
+ },
845
+ AUTHORIZATION_STATE_TTL_MS
846
+ );
847
+ }
848
+ // Stable short handle for correlating authorize and callback log lines.
849
+ // Not a secret and not reversible — purely a join key for reading logs.
850
+ stateHandle(state) {
851
+ if (typeof state !== "string") return "non-string";
852
+ return createHash2("sha256").update(state).digest("base64url").slice(0, 12);
853
+ }
854
+ decodeAuthorizationState(state) {
855
+ const payload = this.unseal(
856
+ "plaud-mcp-authorization-state",
857
+ STATELESS_STATE_PREFIX,
858
+ state
859
+ );
860
+ if (!payload) return null;
861
+ if (typeof payload.clientRedirectUri !== "string" || !URL.canParse(payload.clientRedirectUri)) {
539
862
  return null;
540
863
  }
864
+ if (payload.originalState !== void 0 && typeof payload.originalState !== "string") return null;
865
+ if (payload.resource !== void 0 && typeof payload.resource !== "string") return null;
866
+ return {
867
+ clientRedirectUri: payload.clientRedirectUri,
868
+ originalState: payload.originalState,
869
+ resource: payload.resource
870
+ };
541
871
  }
542
872
  // Recover a client_id that the registry doesn't know about. Directory clients
543
873
  // (OpenAI Apps, Claude directory) cache the client_id they got from /register;
@@ -601,8 +931,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
601
931
  * Store the client's original redirect_uri keyed by state so we can forward after Plaud calls back.
602
932
  */
603
933
  async authorize(_client, params, res) {
604
- const internalState = randomUUID();
605
- this._pendingStates.set(internalState, {
934
+ const internalState = this.encodeAuthorizationState({
606
935
  clientRedirectUri: params.redirectUri,
607
936
  originalState: params.state,
608
937
  resource: params.resource?.href
@@ -610,7 +939,10 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
610
939
  const redirectParts = urlParts(params.redirectUri);
611
940
  logger.info({
612
941
  event: "oauth_authorize_start",
613
- internal_state: internalState,
942
+ // Short digest, not the sealed blob — it is 300+ chars and would bloat
943
+ // every line. Same value appears on the matching callback, so the two
944
+ // still correlate.
945
+ state_handle: this.stateHandle(internalState),
614
946
  redirect_uri: params.redirectUri,
615
947
  ...this._debugOAuthLogs ? {
616
948
  redirect_uri_host: redirectParts.host,
@@ -618,7 +950,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
618
950
  has_original_state: !!params.state,
619
951
  has_resource: !!params.resource,
620
952
  resource: params.resource?.href ?? null,
621
- pending_states_count: this._pendingStates.size
953
+ state_len: internalState.length
622
954
  } : {}
623
955
  });
624
956
  const targetUrl = new URL(this._endpoints.authorizationUrl);
@@ -644,17 +976,25 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
644
976
  * Called when Plaud redirects to our /auth/callback (CALLBACK_PATH in
645
977
  * http/server.ts). NOT /oauth/callback — this comment said so until 2026-08-19
646
978
  * and a downstream client registration was configured from it by mistake.
647
- * Looks up the original client redirect_uri and forwards the code+state to it.
979
+ * Opens the sealed state to recover the client's redirect_uri and forwards the
980
+ * code+state to it. No lookup — the state is the storage, which is what allows
981
+ * this to be served by a pod that never saw the /authorize request.
648
982
  */
649
983
  handleCallback(code, state, res) {
650
- const pending = this._pendingStates.get(state);
984
+ const pending = this.decodeAuthorizationState(state);
651
985
  if (!pending) {
652
- logger.warn({ event: "oauth_callback_unknown_state", state });
986
+ logger.warn({
987
+ event: "oauth_callback_unknown_state",
988
+ state_handle: this.stateHandle(state),
989
+ // typeof-guarded: this is the one log line reached by a request whose
990
+ // parameter shape we do not control.
991
+ state_len: typeof state === "string" ? state.length : null,
992
+ sealed_shape: typeof state === "string" && state.startsWith(STATELESS_STATE_PREFIX)
993
+ });
653
994
  res.status(400).send("Unknown state \u2014 authorization request not found");
654
995
  return;
655
996
  }
656
- this._pendingStates.delete(state);
657
- logger.info({ event: "oauth_callback_received", internal_state: state });
997
+ logger.info({ event: "oauth_callback_received", state_handle: this.stateHandle(state) });
658
998
  const pendingCode = {
659
999
  upstreamCode: code,
660
1000
  upstreamState: state,
@@ -667,10 +1007,12 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
667
1007
  target.searchParams.set("state", pending.originalState);
668
1008
  }
669
1009
  const redirectParts = urlParts(pending.clientRedirectUri);
1010
+ const isCustomScheme = !/^https?:$/.test(new URL(target.toString()).protocol);
670
1011
  logger.info({
671
1012
  event: "oauth_callback_redirect",
672
- internal_state: state,
1013
+ state_handle: this.stateHandle(state),
673
1014
  redirect_uri: pending.clientRedirectUri,
1015
+ custom_scheme: isCustomScheme,
674
1016
  has_original_state: !!pending.originalState,
675
1017
  original_state_len: pending.originalState?.length ?? 0,
676
1018
  upstream_code_len: code.length,
@@ -685,7 +1027,37 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
685
1027
  redirect_has_state: target.searchParams.has("state")
686
1028
  } : {}
687
1029
  });
688
- res.redirect(target.toString());
1030
+ if (!isCustomScheme) {
1031
+ res.redirect(target.toString());
1032
+ return;
1033
+ }
1034
+ res.status(200).type("html").send(this.appHandoffPage(target.toString()));
1035
+ }
1036
+ // Landing page for native-app callbacks. The URL goes into a data attribute
1037
+ // rather than inline JS so nothing from `clientRedirectUri` can be parsed as
1038
+ // script; it is only ever assigned to location.href.
1039
+ appHandoffPage(redirectUrl) {
1040
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1041
+ const href = esc(redirectUrl);
1042
+ return `<!doctype html>
1043
+ <html lang="zh-CN"><head><meta charset="utf-8">
1044
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1045
+ <title>\u6388\u6743\u6210\u529F \xB7 Plaud</title></head>
1046
+ <body style="font-family:system-ui,-apple-system,sans-serif;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fafafa;color:#111">
1047
+ <main id="p" data-href="${href}" style="text-align:center;padding:2rem;max-width:28rem">
1048
+ <div style="font-size:2.5rem;line-height:1">\u2713</div>
1049
+ <h1 style="font-size:1.25rem;margin:.75rem 0 .5rem">\u6388\u6743\u6210\u529F</h1>
1050
+ <p style="color:#666;margin:0 0 1.5rem">\u6B63\u5728\u8FD4\u56DE\u5E94\u7528\uFF0C\u53EF\u4EE5\u5173\u95ED\u6B64\u9875\u9762\u3002</p>
1051
+ <p style="font-size:.875rem;color:#888;margin:0">
1052
+ \u6CA1\u6709\u81EA\u52A8\u8DF3\u8F6C\uFF1F<a id="m" href="#" style="color:#0066cc">\u70B9\u6B64\u624B\u52A8\u6253\u5F00</a>
1053
+ </p>
1054
+ </main>
1055
+ <script>
1056
+ var u = document.getElementById("p").dataset.href;
1057
+ document.getElementById("m").href = u;
1058
+ location.href = u;
1059
+ </script>
1060
+ </body></html>`;
689
1061
  }
690
1062
  // Override to use PKCE public-client flow — no Basic auth, client_id sent in body.
691
1063
  async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, _redirectUri, resource) {
@@ -762,6 +1134,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
762
1134
  throw new McpServerError("Token endpoint returned non-JSON response");
763
1135
  }
764
1136
  logger.info({ event: "oauth_token_exchange_ok", has_refresh_token: !!data.refresh_token });
1137
+ this._verifier.prewarm(data.access_token);
765
1138
  const authorizedUserId = subFromJwt(data.access_token);
766
1139
  this._tracker?.track({
767
1140
  name: "auth.oauth_callback_success",
@@ -834,6 +1207,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
834
1207
  throw new McpServerError("Refresh endpoint returned non-JSON response");
835
1208
  }
836
1209
  oauthTokenRefresh.inc({ result: "success" });
1210
+ this._verifier.prewarm(data.access_token);
837
1211
  this._tracker?.track({
838
1212
  name: "auth.token_refresh_success",
839
1213
  actorType: "user",
@@ -856,7 +1230,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
856
1230
  };
857
1231
 
858
1232
  // src/telemetry-server/tracker.ts
859
- import { randomUUID as randomUUID2 } from "crypto";
1233
+ import { randomUUID } from "crypto";
860
1234
 
861
1235
  // src/telemetry-server/transport.ts
862
1236
  import { PostHog } from "posthog-node";
@@ -1018,7 +1392,7 @@ var WarehouseTracker = class {
1018
1392
  service_version: this.common.serviceVersion,
1019
1393
  ...this.common.buildId ? { build_id: this.common.buildId } : {},
1020
1394
  // timing / tracing
1021
- event_id: randomUUID2(),
1395
+ event_id: randomUUID(),
1022
1396
  ...input.requestId ? { request_id: input.requestId } : {},
1023
1397
  // (trace_id/span_id omitted — no OpenTelemetry; spec forbids fabricating)
1024
1398
  // non-user actors: don't create a PostHog person
@@ -1283,8 +1657,8 @@ function startHttpServer() {
1283
1657
  common: {
1284
1658
  serviceName: process.env.PLAUD_WAREHOUSE_SERVICE_NAME ?? "plaudmcp",
1285
1659
  // matches §1.3 key label `plaudmcp:TRACKING_KEY_PLAUDMCP`; confirm exact string with David
1286
- serviceVersion: "0.3.9",
1287
- buildId: "ee2ff6d",
1660
+ serviceVersion: "0.3.10",
1661
+ buildId: "e5eb255",
1288
1662
  // mcp tsup TODO: inject git short SHA (like CLI)
1289
1663
  region: process.env.PLAUD_REGION ?? "US",
1290
1664
  env: process.env.PLAUD_ENV ?? process.env.NODE_ENV ?? "development"
@@ -1374,7 +1748,10 @@ function startHttpServer() {
1374
1748
  server_url: process.env.PLAUD_SERVER_URL ?? "(default:localhost)",
1375
1749
  oauth_debug_logs: OAUTH_DEBUG_LOGS,
1376
1750
  cimd_enabled: CIMD_ENABLED
1377
- }
1751
+ },
1752
+ // Size of the token-verification cache, to confirm it is actually in
1753
+ // effect. Counts only — no tokens or user identifiers.
1754
+ token_verifier: provider.verifierStats
1378
1755
  });
1379
1756
  });
1380
1757
  app.get("/", (_req, res) => {
@@ -1382,8 +1759,8 @@ function startHttpServer() {
1382
1759
  });
1383
1760
  if (process.env.PLAUD_CALLBACK_URL) {
1384
1761
  app.get(CALLBACK_PATH, (req, res) => {
1385
- const code = req.query["code"];
1386
- const state = req.query["state"];
1762
+ const code = queryValue(req.query["code"]);
1763
+ const state = queryValue(req.query["state"]);
1387
1764
  if (!code || !state) {
1388
1765
  logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
1389
1766
  res.status(400).send("Missing code or state");
@@ -1393,7 +1770,7 @@ function startHttpServer() {
1393
1770
  });
1394
1771
  }
1395
1772
  app.use((req, res, next) => {
1396
- const reqId = req.headers["x-request-id"] ?? randomUUID3();
1773
+ const reqId = req.headers["x-request-id"] ?? randomUUID2();
1397
1774
  const startMs = Date.now();
1398
1775
  res.locals["reqId"] = reqId;
1399
1776
  res.on("finish", () => {
@@ -1545,7 +1922,7 @@ function startHttpServer() {
1545
1922
  requireBearerAuth({ verifier: provider, resourceMetadataUrl: protectedResourceMetadataUrl }),
1546
1923
  async (req, res) => {
1547
1924
  const token = req.auth.token;
1548
- const reqId = res.locals["reqId"] ?? randomUUID3();
1925
+ const reqId = res.locals["reqId"] ?? randomUUID2();
1549
1926
  const reqLog = logger.child({ req_id: reqId });
1550
1927
  const startMs = Date.now();
1551
1928
  reqLog.info({ event: "mcp_request_start", client_id: req.auth.clientId });
@@ -1556,7 +1933,7 @@ function startHttpServer() {
1556
1933
  apiBase,
1557
1934
  staticToken: token
1558
1935
  });
1559
- const mcpServer = new McpServer({ name: "plaud", version: "0.3.9" });
1936
+ const mcpServer = new McpServer({ name: "plaud", version: "0.3.10" });
1560
1937
  registerTools(mcpServer, client, warehouseToolHooks);
1561
1938
  const transport = new StreamableHTTPServerTransport({
1562
1939
  sessionIdGenerator: void 0,
@@ -1594,8 +1971,8 @@ function startHttpServer() {
1594
1971
  if (!process.env.PLAUD_CALLBACK_URL) {
1595
1972
  const callbackApp = express();
1596
1973
  callbackApp.get(CALLBACK_PATH, (req, res) => {
1597
- const code = req.query["code"];
1598
- const state = req.query["state"];
1974
+ const code = queryValue(req.query["code"]);
1975
+ const state = queryValue(req.query["state"]);
1599
1976
  if (!code || !state) {
1600
1977
  logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
1601
1978
  res.status(400).send("Missing code or state");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plaud-ai/mcp",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {