@plaud-ai/mcp 0.3.8 → 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";
@@ -21,7 +21,29 @@ var toolHooks = null;
21
21
  var MAX_FILTER_PAGES = 5;
22
22
  var FILTER_PAGE_SIZE = 100;
23
23
  var TRANSCRIPT_PAGE_SIZE = 50;
24
- var TRANSCRIPT_BLOCKS = ["transaction", "outline", "transaction_polish"];
24
+ var TRANSCRIPT_BLOCKS = ["transaction", "outline", "transaction_polish", "mark_memo"];
25
+ var BLOCK_ITEMS_KEY = { mark_memo: "marks" };
26
+ function linkBackedTypes(list) {
27
+ return (list ?? []).filter(
28
+ (b) => !(typeof b.data_content === "string" && b.data_content.length > 0) && typeof b.data_link === "string" && b.data_link.length > 0
29
+ ).map((b) => typeof b.data_type === "string" ? b.data_type : "(untyped)");
30
+ }
31
+ async function resolveNotes(noteList) {
32
+ return Promise.all(
33
+ noteList.map(async (note) => {
34
+ if (typeof note.data_content === "string" && note.data_content.length > 0) return note;
35
+ if (typeof note.data_link !== "string" || note.data_link.length === 0) return note;
36
+ try {
37
+ return { ...note, data_content: await loadBlockContent(note) };
38
+ } catch (err) {
39
+ return {
40
+ ...note,
41
+ data_content_error: err instanceof Error ? err.message : "Failed to fetch content from data_link"
42
+ };
43
+ }
44
+ })
45
+ );
46
+ }
25
47
  function encodeTranscriptCursor(offset) {
26
48
  return Buffer.from(JSON.stringify({ o: offset }), "utf8").toString("base64url");
27
49
  }
@@ -175,6 +197,18 @@ function registerTools(server, client, hooks) {
175
197
  const synced = !!(file.duration || file.source_list && file.source_list.length);
176
198
  text += synced ? "\n\nNote: `presigned_url` (audio download URL) is null for this otherwise-synced recording. This is usually a transient backend signing issue \u2014 retry get_file in a few minutes to obtain the URL." : "\n\nNote: `presigned_url` (audio download URL) is null \u2014 audio may not yet be available for this recording.";
177
199
  }
200
+ const linkedBlocks = linkBackedTypes(file.source_list);
201
+ const linkedNotes = linkBackedTypes(file.note_list);
202
+ if (linkedBlocks.length) {
203
+ text += `
204
+
205
+ Note: source_list ${linkedBlocks.map((t) => `\`${t}\``).join(", ")} returned an empty \`data_content\` \u2014 the body lives behind \`data_link\`, not missing. Call get_transcript with the matching \`block\` to read it (it fetches the link).`;
206
+ }
207
+ if (linkedNotes.length) {
208
+ text += `
209
+
210
+ Note: note_list ${linkedNotes.map((t) => `\`${t}\``).join(", ")} returned an empty \`data_content\` \u2014 the body lives behind \`data_link\`, not missing. Call get_note to read it (it fetches the link).`;
211
+ }
178
212
  recordToolMetric("get_file", "success", Date.now() - start, requestId, { file_id });
179
213
  logger.info({ event: "tool_call_end", tool: "get_file", duration_ms: Date.now() - start, presigned_url_present: !!file.presigned_url });
180
214
  return { content: [{ type: "text", text }] };
@@ -188,7 +222,7 @@ function registerTools(server, client, hooks) {
188
222
  server.registerTool(
189
223
  "get_note",
190
224
  {
191
- description: "Fetch AI-generated notes for a Plaud recording \u2014 compact summary, action items, and key topics",
225
+ description: "Fetch the notes on a Plaud recording \u2014 one entry per tab in the app, so usually more than one: the AI summary (action items, key topics), any template tab, a saved Ask Plaud answer, and the highlights note when the user pressed the highlight button while recording.",
192
226
  annotations: {
193
227
  title: "Get recording notes",
194
228
  readOnlyHint: true,
@@ -203,9 +237,10 @@ function registerTools(server, client, hooks) {
203
237
  const requestId = emitToolClick("get_note", { file_id });
204
238
  try {
205
239
  const file = await client.getFile(file_id);
240
+ const notes = await resolveNotes(file.note_list ?? []);
206
241
  recordToolMetric("get_note", "success", Date.now() - start, requestId, { file_id });
207
- logger.info({ event: "tool_call_end", tool: "get_note", duration_ms: Date.now() - start });
208
- return { content: [{ type: "text", text: JSON.stringify(file.note_list ?? [], null, 2) }] };
242
+ logger.info({ event: "tool_call_end", tool: "get_note", duration_ms: Date.now() - start, notes: notes.length });
243
+ return { content: [{ type: "text", text: JSON.stringify(notes, null, 2) }] };
209
244
  } catch (err) {
210
245
  recordToolMetric("get_note", "error", Date.now() - start, requestId, { file_id }, err);
211
246
  logger.error({ event: "tool_call_error", tool: "get_note", duration_ms: Date.now() - start, error: String(err) });
@@ -216,7 +251,7 @@ function registerTools(server, client, hooks) {
216
251
  server.registerTool(
217
252
  "get_transcript",
218
253
  {
219
- description: "Fetch the timestamped transcript with speaker attribution for a Plaud recording. Defaults to the `transaction` block (real speaker names + timestamps), returned one page of utterances at a time to stay within client size limits \u2014 call again with the returned `next_cursor` to fetch the next page. Set `block` to `outline` or `transaction_polish` to fetch those blocks instead.",
254
+ description: "Fetch the timestamped transcript with speaker attribution for a Plaud recording. Defaults to the `transaction` block (real speaker names + timestamps), returned one page of utterances at a time to stay within client size limits \u2014 call again with the returned `next_cursor` to fetch the next page. Set `block` to `outline`, `transaction_polish`, or `mark_memo` (the moments the user flagged with the device's highlight button) to fetch those blocks instead.",
220
255
  annotations: {
221
256
  title: "Get recording transcript",
222
257
  readOnlyHint: true,
@@ -226,10 +261,10 @@ function registerTools(server, client, hooks) {
226
261
  inputSchema: {
227
262
  file_id: z.string().describe("The file ID to retrieve transcript for"),
228
263
  block: z.enum(TRANSCRIPT_BLOCKS).optional().describe(
229
- "Which source block to fetch: `transaction` (default; raw transcript, speaker + timestamps), `transaction_polish` (AI-cleaned transcript; same per-utterance shape, keeps speaker + timestamps), or `outline`."
264
+ "Which source block to fetch: `transaction` (default; raw transcript, speaker + timestamps), `transaction_polish` (AI-cleaned transcript; same per-utterance shape, keeps speaker + timestamps), `outline`, or `mark_memo` (highlights: the moments flagged by pressing the button on the device during recording; present only when the button was pressed, and returned under `marks`). Pair `mark_memo` with a `transaction` call to map each flagged moment onto what was said there."
230
265
  ),
231
266
  cursor: z.string().optional().describe("Opaque pagination cursor from a previous call's `next_cursor`. Omit to start from the first utterance."),
232
- limit: z.number().int().min(1).max(500).optional().describe(`Max utterances to return in this page (default ${TRANSCRIPT_PAGE_SIZE}). Only applies to blocks returned as an utterance list.`)
267
+ limit: z.number().int().min(1).max(500).optional().describe(`Max items to return in this page (default ${TRANSCRIPT_PAGE_SIZE}). Only applies to list-shaped blocks.`)
233
268
  }
234
269
  },
235
270
  async ({ file_id, block, cursor, limit }) => {
@@ -267,7 +302,7 @@ function registerTools(server, client, hooks) {
267
302
  if (!segments) {
268
303
  recordToolMetric("get_transcript", "success", Date.now() - start, requestId, { file_id });
269
304
  logger.info({ event: "tool_call_end", tool: "get_transcript", duration_ms: Date.now() - start, block: blockType, paginated: false });
270
- const text = content || `Block "${blockType}" has no content for this recording yet.`;
305
+ const text = content || (blockType === "mark_memo" ? "This recording has a highlights block, but it is empty \u2014 no highlight was recorded." : `Block "${blockType}" has no content for this recording yet.`);
271
306
  return { content: [{ type: "text", text }] };
272
307
  }
273
308
  let offset = 0;
@@ -295,7 +330,7 @@ function registerTools(server, client, hooks) {
295
330
  limit: pageSize,
296
331
  returned: page.length,
297
332
  next_cursor: hasMore ? encodeTranscriptCursor(nextOffset) : null,
298
- segments: page
333
+ [BLOCK_ITEMS_KEY[blockType] ?? "segments"]: page
299
334
  };
300
335
  recordToolMetric("get_transcript", "success", Date.now() - start, requestId, { file_id });
301
336
  logger.info({ event: "tool_call_end", tool: "get_transcript", duration_ms: Date.now() - start, block: blockType, paginated: true, total: segments.length, offset, returned: page.length });
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-DKOQXKKG.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.8"
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-VJAFEGJ6.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.8",
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-DKOQXKKG.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;
@@ -276,10 +517,33 @@ function subFromJwt(token) {
276
517
  return void 0;
277
518
  }
278
519
  }
520
+ var AUTO_RECOVERED_CLIENT_NAME = "auto-recovered client";
521
+ var REDIRECT_HOST_CLIENT_NAMES = {
522
+ "chatgpt.com": "chatgpt",
523
+ "openai.com": "chatgpt",
524
+ "claude.ai": "claude",
525
+ "claude.com": "claude"
526
+ };
527
+ function clientNameFromRedirectUri(redirectUri) {
528
+ if (!redirectUri) return void 0;
529
+ let host;
530
+ try {
531
+ host = new URL(redirectUri).hostname.toLowerCase().replace(/^www\./, "");
532
+ } catch {
533
+ return void 0;
534
+ }
535
+ if (!host) return void 0;
536
+ for (const [domain, name] of Object.entries(REDIRECT_HOST_CLIENT_NAMES)) {
537
+ if (host === domain || host.endsWith(`.${domain}`)) return name;
538
+ }
539
+ return host;
540
+ }
279
541
  var DEFAULT_TOKEN_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token";
280
542
  var DEFAULT_REFRESH_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token/refresh";
281
543
  var STATELESS_CODE_PREFIX = "pc1_";
282
544
  var AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1e3;
545
+ var STATELESS_STATE_PREFIX = "st1_";
546
+ var AUTHORIZATION_STATE_TTL_MS = 30 * 60 * 1e3;
283
547
  function urlParts(value) {
284
548
  if (!value || !URL.canParse(value)) {
285
549
  return { host: null, path: null };
@@ -297,57 +561,73 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
297
561
  _debugOAuthLogs;
298
562
  _cimdLoader;
299
563
  _tracker;
564
+ /** Local pre-checks + cache + tiered rate limit + upstream call, all in one
565
+ * place. See token-verifier.ts. */
566
+ _verifier;
300
567
  _registeredClients = /* @__PURE__ */ new Map();
301
568
  // HMAC secret used to sign DCR-issued client_ids so we can recover them
302
569
  // across container restarts without persistent storage. See verifyAndRecover.
303
570
  _clientIdSecret;
304
- // internalState client redirect, original client state, and resource indicator
305
- // We generate our own state to track the pending flow regardless of whether the client sent one.
306
- _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.
307
583
  constructor(options) {
308
584
  const authUrl = options.authUrl ?? "https://web.plaud.ai/platform/oauth";
309
585
  const tokenUrl = options.tokenUrl ?? DEFAULT_TOKEN_URL;
310
586
  const refreshUrl = options.refreshUrl ?? DEFAULT_REFRESH_URL;
311
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
+ };
312
606
  super({
313
607
  endpoints: {
314
608
  authorizationUrl: authUrl,
315
609
  tokenUrl
316
610
  },
317
611
  verifyAccessToken: async (token) => {
318
- const client = new PlaudClient({
319
- clientId: options.clientId,
320
- clientSecret: "",
321
- redirectUri: "",
322
- apiBase,
323
- staticToken: token
324
- });
325
- try {
326
- const user = await client.getCurrentUser();
327
- let expiresAt;
328
- try {
329
- const payload = JSON.parse(
330
- Buffer.from(token.split(".")[1], "base64url").toString()
331
- );
332
- expiresAt = typeof payload.exp === "number" ? payload.exp : Math.floor(Date.now() / 1e3) + 3600;
333
- } catch {
334
- expiresAt = Math.floor(Date.now() / 1e3) + 3600;
335
- }
336
- const authInfo = {
337
- token,
338
- clientId: String(user.id ?? "unknown"),
339
- scopes: [],
340
- expiresAt
341
- };
342
- logger.info({ event: "token_verified", client_id: authInfo.clientId, expires_at: expiresAt });
343
- return authInfo;
344
- } catch (err) {
345
- logger.warn({ event: "token_verify_failed", error: String(err) });
346
- 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");
347
625
  }
348
626
  },
349
627
  getClient: async (id) => this._registeredClients.get(id)
350
628
  });
629
+ holder.verifier = new TokenVerifier({ ...tokenVerifierOptionsFromEnv(), verifyUpstream });
630
+ this._verifier = holder.verifier;
351
631
  this._plaudClientId = options.clientId;
352
632
  this._plaudClientSecret = options.clientSecret ?? "";
353
633
  this._plaudTokenUrl = tokenUrl;
@@ -369,6 +649,10 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
369
649
  }
370
650
  this.skipLocalPkceValidation = true;
371
651
  }
652
+ /** For /health and metrics — counts only, no tokens or user identifiers. */
653
+ get verifierStats() {
654
+ return this._verifier.stats();
655
+ }
372
656
  // Override clientsStore. registerClient signs the issued client_id with HMAC
373
657
  // so verifyAndRecover can later resurrect it across container restarts.
374
658
  get clientsStore() {
@@ -474,49 +758,116 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
474
758
  if (actual.length !== expected.length) return false;
475
759
  return timingSafeEqual(actual, expected);
476
760
  }
477
- authorizationCodeKey() {
478
- 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();
479
766
  }
480
- 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) {
481
771
  const iv = randomBytes(12);
482
- const cipher = createCipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
483
- const plaintext = Buffer.from(JSON.stringify({
484
- upstreamCode: payload.upstreamCode,
485
- upstreamState: payload.upstreamState,
486
- resource: payload.resource,
487
- expiresAt: Date.now() + AUTHORIZATION_CODE_TTL_MS
488
- }), "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
+ );
489
777
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
490
778
  const tag = cipher.getAuthTag();
491
- return `${STATELESS_CODE_PREFIX}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
779
+ return `${prefix}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
492
780
  }
493
- decodeAuthorizationCode(code) {
494
- 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;
495
785
  try {
496
- const encoded = code.slice(STATELESS_CODE_PREFIX.length);
497
- const data = Buffer.from(encoded, "base64url");
786
+ const data = Buffer.from(value.slice(prefix.length), "base64url");
498
787
  if (data.length <= 28) return null;
499
- const iv = data.subarray(0, 12);
500
- const tag = data.subarray(12, 28);
501
- const ciphertext = data.subarray(28);
502
- const decipher = createDecipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
503
- decipher.setAuthTag(tag);
504
- 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");
505
794
  const payload = JSON.parse(plaintext);
506
- if (typeof payload.upstreamCode !== "string" || typeof payload.upstreamState !== "string" || payload.resource !== void 0 && typeof payload.resource !== "string" || typeof payload.expiresAt !== "number") {
507
- return null;
508
- }
509
- if (Date.now() > payload.expiresAt) {
795
+ if (typeof payload["expiresAt"] !== "number" || Date.now() > payload["expiresAt"]) {
510
796
  return null;
511
797
  }
512
- 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
+ {
513
808
  upstreamCode: payload.upstreamCode,
514
809
  upstreamState: payload.upstreamState,
515
810
  resource: payload.resource
516
- };
517
- } 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") {
518
823
  return null;
519
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)) {
862
+ return null;
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
+ };
520
871
  }
521
872
  // Recover a client_id that the registry doesn't know about. Directory clients
522
873
  // (OpenAI Apps, Claude directory) cache the client_id they got from /register;
@@ -532,12 +883,25 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
532
883
  if (redirectUri && !existing.redirect_uris.includes(redirectUri)) {
533
884
  existing.redirect_uris = [...existing.redirect_uris, redirectUri];
534
885
  }
886
+ if (existing.client_name === AUTO_RECOVERED_CLIENT_NAME) {
887
+ const upgradedName = clientNameFromRedirectUri(redirectUri);
888
+ if (upgradedName) {
889
+ existing.client_name = upgradedName;
890
+ logger.info({
891
+ event: "oauth_client_name_upgraded",
892
+ client_id: clientId,
893
+ name_source: "redirect_uri",
894
+ mcp_host: normalizeMcpHost(upgradedName) ?? null
895
+ });
896
+ }
897
+ }
535
898
  return true;
536
899
  }
537
900
  if (!this.verifyClientIdSignature(clientId)) {
538
901
  return false;
539
902
  }
540
903
  const recoveredName = this.decodeClientName(clientId);
904
+ const derivedName = recoveredName ?? clientNameFromRedirectUri(redirectUri);
541
905
  this._registeredClients.set(clientId, {
542
906
  client_id: clientId,
543
907
  client_id_issued_at: Math.floor(Date.now() / 1e3),
@@ -545,13 +909,20 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
545
909
  grant_types: ["authorization_code", "refresh_token"],
546
910
  response_types: ["code"],
547
911
  token_endpoint_auth_method: "none",
548
- client_name: recoveredName ?? "auto-recovered client"
912
+ client_name: derivedName ?? AUTO_RECOVERED_CLIENT_NAME
549
913
  });
550
914
  logger.info({
551
915
  event: "oauth_client_recovered",
552
916
  client_id: clientId,
553
917
  redirect_uri: redirectUri ?? null,
554
- name_recovered: recoveredName != null
918
+ // Unchanged semantics: true only when the name came out of the client_id
919
+ // itself — this is the field used to verify the #72 fix in prod.
920
+ name_recovered: recoveredName != null,
921
+ name_source: recoveredName != null ? "client_id" : derivedName != null ? "redirect_uri" : null,
922
+ // The bucket this recovery will put on every downstream auth event. Lets us
923
+ // verify attribution straight from prod logs (the warehouse events are
924
+ // batched, so they're not a live signal), and enumerate real hosts.
925
+ mcp_host: normalizeMcpHost(derivedName) ?? null
555
926
  });
556
927
  return true;
557
928
  }
@@ -560,8 +931,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
560
931
  * Store the client's original redirect_uri keyed by state so we can forward after Plaud calls back.
561
932
  */
562
933
  async authorize(_client, params, res) {
563
- const internalState = randomUUID();
564
- this._pendingStates.set(internalState, {
934
+ const internalState = this.encodeAuthorizationState({
565
935
  clientRedirectUri: params.redirectUri,
566
936
  originalState: params.state,
567
937
  resource: params.resource?.href
@@ -569,7 +939,10 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
569
939
  const redirectParts = urlParts(params.redirectUri);
570
940
  logger.info({
571
941
  event: "oauth_authorize_start",
572
- 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),
573
946
  redirect_uri: params.redirectUri,
574
947
  ...this._debugOAuthLogs ? {
575
948
  redirect_uri_host: redirectParts.host,
@@ -577,7 +950,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
577
950
  has_original_state: !!params.state,
578
951
  has_resource: !!params.resource,
579
952
  resource: params.resource?.href ?? null,
580
- pending_states_count: this._pendingStates.size
953
+ state_len: internalState.length
581
954
  } : {}
582
955
  });
583
956
  const targetUrl = new URL(this._endpoints.authorizationUrl);
@@ -600,18 +973,28 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
600
973
  res.redirect(targetUrl.toString());
601
974
  }
602
975
  /**
603
- * Called when Plaud redirects to our /oauth/callback.
604
- * Looks up the original client redirect_uri and forwards the code+state to it.
976
+ * Called when Plaud redirects to our /auth/callback (CALLBACK_PATH in
977
+ * http/server.ts). NOT /oauth/callback this comment said so until 2026-08-19
978
+ * and a downstream client registration was configured from it by mistake.
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.
605
982
  */
606
983
  handleCallback(code, state, res) {
607
- const pending = this._pendingStates.get(state);
984
+ const pending = this.decodeAuthorizationState(state);
608
985
  if (!pending) {
609
- 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
+ });
610
994
  res.status(400).send("Unknown state \u2014 authorization request not found");
611
995
  return;
612
996
  }
613
- this._pendingStates.delete(state);
614
- logger.info({ event: "oauth_callback_received", internal_state: state });
997
+ logger.info({ event: "oauth_callback_received", state_handle: this.stateHandle(state) });
615
998
  const pendingCode = {
616
999
  upstreamCode: code,
617
1000
  upstreamState: state,
@@ -624,10 +1007,12 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
624
1007
  target.searchParams.set("state", pending.originalState);
625
1008
  }
626
1009
  const redirectParts = urlParts(pending.clientRedirectUri);
1010
+ const isCustomScheme = !/^https?:$/.test(new URL(target.toString()).protocol);
627
1011
  logger.info({
628
1012
  event: "oauth_callback_redirect",
629
- internal_state: state,
1013
+ state_handle: this.stateHandle(state),
630
1014
  redirect_uri: pending.clientRedirectUri,
1015
+ custom_scheme: isCustomScheme,
631
1016
  has_original_state: !!pending.originalState,
632
1017
  original_state_len: pending.originalState?.length ?? 0,
633
1018
  upstream_code_len: code.length,
@@ -642,7 +1027,37 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
642
1027
  redirect_has_state: target.searchParams.has("state")
643
1028
  } : {}
644
1029
  });
645
- 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>`;
646
1061
  }
647
1062
  // Override to use PKCE public-client flow — no Basic auth, client_id sent in body.
648
1063
  async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, _redirectUri, resource) {
@@ -719,6 +1134,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
719
1134
  throw new McpServerError("Token endpoint returned non-JSON response");
720
1135
  }
721
1136
  logger.info({ event: "oauth_token_exchange_ok", has_refresh_token: !!data.refresh_token });
1137
+ this._verifier.prewarm(data.access_token);
722
1138
  const authorizedUserId = subFromJwt(data.access_token);
723
1139
  this._tracker?.track({
724
1140
  name: "auth.oauth_callback_success",
@@ -791,6 +1207,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
791
1207
  throw new McpServerError("Refresh endpoint returned non-JSON response");
792
1208
  }
793
1209
  oauthTokenRefresh.inc({ result: "success" });
1210
+ this._verifier.prewarm(data.access_token);
794
1211
  this._tracker?.track({
795
1212
  name: "auth.token_refresh_success",
796
1213
  actorType: "user",
@@ -813,7 +1230,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
813
1230
  };
814
1231
 
815
1232
  // src/telemetry-server/tracker.ts
816
- import { randomUUID as randomUUID2 } from "crypto";
1233
+ import { randomUUID } from "crypto";
817
1234
 
818
1235
  // src/telemetry-server/transport.ts
819
1236
  import { PostHog } from "posthog-node";
@@ -975,7 +1392,7 @@ var WarehouseTracker = class {
975
1392
  service_version: this.common.serviceVersion,
976
1393
  ...this.common.buildId ? { build_id: this.common.buildId } : {},
977
1394
  // timing / tracing
978
- event_id: randomUUID2(),
1395
+ event_id: randomUUID(),
979
1396
  ...input.requestId ? { request_id: input.requestId } : {},
980
1397
  // (trace_id/span_id omitted — no OpenTelemetry; spec forbids fabricating)
981
1398
  // non-user actors: don't create a PostHog person
@@ -1183,6 +1600,7 @@ var OPENAI_APPS_CHALLENGE_TOKEN = "bBlB7N7fF7YaQnONtEQNAGP7SLaqpJT3a3DS5Zv1F8s";
1183
1600
  var OAUTH_DEBUG_LOGS = !["0", "false", "no", "off"].includes(
1184
1601
  (process.env.PLAUD_OAUTH_DEBUG_LOGS ?? "").toLowerCase()
1185
1602
  );
1603
+ var ROOT_REDIRECT_URL = process.env.PLAUD_ROOT_REDIRECT_URL ?? "https://www.plaud.ai";
1186
1604
  var CIMD_ENABLED = ["1", "true", "yes", "on"].includes(
1187
1605
  (process.env.PLAUD_CIMD_ENABLED ?? "").toLowerCase()
1188
1606
  );
@@ -1239,8 +1657,8 @@ function startHttpServer() {
1239
1657
  common: {
1240
1658
  serviceName: process.env.PLAUD_WAREHOUSE_SERVICE_NAME ?? "plaudmcp",
1241
1659
  // matches §1.3 key label `plaudmcp:TRACKING_KEY_PLAUDMCP`; confirm exact string with David
1242
- serviceVersion: "0.3.8",
1243
- buildId: "be0dfaa",
1660
+ serviceVersion: "0.3.10",
1661
+ buildId: "e5eb255",
1244
1662
  // mcp tsup TODO: inject git short SHA (like CLI)
1245
1663
  region: process.env.PLAUD_REGION ?? "US",
1246
1664
  env: process.env.PLAUD_ENV ?? process.env.NODE_ENV ?? "development"
@@ -1330,13 +1748,19 @@ function startHttpServer() {
1330
1748
  server_url: process.env.PLAUD_SERVER_URL ?? "(default:localhost)",
1331
1749
  oauth_debug_logs: OAUTH_DEBUG_LOGS,
1332
1750
  cimd_enabled: CIMD_ENABLED
1333
- }
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
1334
1755
  });
1335
1756
  });
1757
+ app.get("/", (_req, res) => {
1758
+ res.redirect(302, ROOT_REDIRECT_URL);
1759
+ });
1336
1760
  if (process.env.PLAUD_CALLBACK_URL) {
1337
1761
  app.get(CALLBACK_PATH, (req, res) => {
1338
- const code = req.query["code"];
1339
- const state = req.query["state"];
1762
+ const code = queryValue(req.query["code"]);
1763
+ const state = queryValue(req.query["state"]);
1340
1764
  if (!code || !state) {
1341
1765
  logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
1342
1766
  res.status(400).send("Missing code or state");
@@ -1346,7 +1770,7 @@ function startHttpServer() {
1346
1770
  });
1347
1771
  }
1348
1772
  app.use((req, res, next) => {
1349
- const reqId = req.headers["x-request-id"] ?? randomUUID3();
1773
+ const reqId = req.headers["x-request-id"] ?? randomUUID2();
1350
1774
  const startMs = Date.now();
1351
1775
  res.locals["reqId"] = reqId;
1352
1776
  res.on("finish", () => {
@@ -1498,7 +1922,7 @@ function startHttpServer() {
1498
1922
  requireBearerAuth({ verifier: provider, resourceMetadataUrl: protectedResourceMetadataUrl }),
1499
1923
  async (req, res) => {
1500
1924
  const token = req.auth.token;
1501
- const reqId = res.locals["reqId"] ?? randomUUID3();
1925
+ const reqId = res.locals["reqId"] ?? randomUUID2();
1502
1926
  const reqLog = logger.child({ req_id: reqId });
1503
1927
  const startMs = Date.now();
1504
1928
  reqLog.info({ event: "mcp_request_start", client_id: req.auth.clientId });
@@ -1509,7 +1933,7 @@ function startHttpServer() {
1509
1933
  apiBase,
1510
1934
  staticToken: token
1511
1935
  });
1512
- const mcpServer = new McpServer({ name: "plaud", version: "0.3.8" });
1936
+ const mcpServer = new McpServer({ name: "plaud", version: "0.3.10" });
1513
1937
  registerTools(mcpServer, client, warehouseToolHooks);
1514
1938
  const transport = new StreamableHTTPServerTransport({
1515
1939
  sessionIdGenerator: void 0,
@@ -1547,8 +1971,8 @@ function startHttpServer() {
1547
1971
  if (!process.env.PLAUD_CALLBACK_URL) {
1548
1972
  const callbackApp = express();
1549
1973
  callbackApp.get(CALLBACK_PATH, (req, res) => {
1550
- const code = req.query["code"];
1551
- const state = req.query["state"];
1974
+ const code = queryValue(req.query["code"]);
1975
+ const state = queryValue(req.query["state"]);
1552
1976
  if (!code || !state) {
1553
1977
  logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
1554
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.8",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -8,7 +8,6 @@
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
- "plugin.json",
12
11
  ".mcp.json",
13
12
  "skills"
14
13
  ],
@@ -18,7 +17,7 @@
18
17
  "build": "tsup",
19
18
  "dev": "tsup --watch",
20
19
  "clean": "rm -rf dist skills",
21
- "prepublishOnly": "node -e \"const fs=require('fs'),v=require('./package.json').version,p=JSON.parse(fs.readFileSync('./plugin.json','utf8'));p.version=v;fs.writeFileSync('./plugin.json',JSON.stringify(p,null,2)+'\\n');\""
20
+ "prepublishOnly": "node -e \"const fs=require('fs'),v=require('./package.json').version,f='../../plugin.json',p=JSON.parse(fs.readFileSync(f,'utf8'));p.version=v;fs.writeFileSync(f,JSON.stringify(p,null,2)+'\\n');\""
22
21
  },
23
22
  "dependencies": {
24
23
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: plaud-read
3
3
  version: 1.0.0
4
- description: "Read the transcript, AI summary, notes, or download audio for a specific Plaud recording. Use when the user says 'show the transcript', 'summarize this', 'what was said', 'get audio', 'the notes from', or names a specific recording to dig into. Also covers extracting structured fields from a recording."
4
+ description: "Read the transcript, AI summary, notes, highlights, or download audio for a specific Plaud recording. Use when the user says 'show the transcript', 'summarize this', 'what was said', 'get audio', 'the notes from', 'my highlights', 'the moments I flagged', 'what I marked while recording', or names a specific recording to dig into. Also covers extracting structured fields from a recording."
5
5
  metadata:
6
6
  requires:
7
7
  bins: []
@@ -14,7 +14,7 @@ metadata:
14
14
  ## When to use
15
15
 
16
16
  - User names a specific recording (by name or ID) and wants to read its content.
17
- - User asks for "transcript", "summary", "action items", "audio", "who said what", or a structured extraction ("action items, decisions, attendees").
17
+ - User asks for "transcript", "summary", "action items", "audio", "who said what", "my highlights" / "the moments I flagged", or a structured extraction ("action items, decisions, attendees").
18
18
  - If the user did **not** specify a recording, hand off to `plaud-find` (by topic) or `plaud-browse` (by recency) first.
19
19
 
20
20
  ## Tool selection matrix
@@ -23,8 +23,9 @@ metadata:
23
23
  |---|---|---|
24
24
  | AI summary, TL;DR, action items | `get_note` | Returns Markdown; usually enough — try this before `get_transcript` |
25
25
  | Verbatim quotes, full dialogue | `get_transcript` | Timestamped; paginated — follow `next_cursor` for long recordings |
26
+ | Highlights — "the moments I flagged", "what I marked while recording" | `get_transcript` with `block: "mark_memo"` | Returned under `marks`. Exists only if the user pressed the device's highlight button; also surfaced as the `high_light` note by `get_note`. To turn marks into quotes, pair with a `transaction` call and match on the timestamps |
26
27
  | Audio download link | `get_file` then use `presigned_url` | Link expires in 24h |
27
- | Full metadata + availability flags | `get_file` | Check `source_list` / `note_list` populated before claiming content exists |
28
+ | Full metadata + availability flags | `get_file` | Lists which blocks exist. **An entry with `data_content: ""` and a `data_link` is not empty** its body is link-backed; read it with `get_transcript` / `get_note`, which resolve the link. Never report such a block as missing data |
28
29
 
29
30
  ## Structured extraction workflow
30
31
 
@@ -19,15 +19,31 @@ metadata:
19
19
 
20
20
  ## Tool inventory
21
21
 
22
- | Tool | Purpose |
23
- |---|---|
24
- | `login` | Open browser for OAuth; blocks until callback or 2-min timeout |
25
- | `logout` | Revoke and clear tokens |
26
- | `get_current_user` | Verify who is signed in |
27
- | `list_files` | Browse, paginate, filter recordings (supports `query`, `date_from`, `date_to`) |
28
- | `get_file` | Full record incl. `presigned_url`, `source_list`, `note_list` |
29
- | `get_note` | AI-generated summary and action items |
30
- | `get_transcript` | Timestamped transcript with speaker labels |
22
+ | Tool | Purpose | Local | Remote |
23
+ |---|---|---|---|
24
+ | `login` | Open browser for OAuth; blocks until callback or 2-min timeout | ✅ | — |
25
+ | `logout` | Revoke and clear tokens | ✅ | — |
26
+ | `get_current_user` | Verify who is signed in | ✅ | ✅ |
27
+ | `list_files` | Browse, paginate, filter recordings (supports `query`, `date_from`, `date_to`) | ✅ | ✅ |
28
+ | `get_file` | Full record incl. `presigned_url`, `source_list`, `note_list` | ✅ | ✅ |
29
+ | `get_note` | AI-generated summary and action items | ✅ | ✅ |
30
+ | `get_transcript` | Timestamped transcript with speaker labels | ✅ | ✅ |
31
+
32
+ **Local** = the stdio server (`npx @plaud-ai/mcp`). **Remote** = a hosted connector
33
+ the host dials by URL. Remote has no `login` / `logout`: authorization runs as a
34
+ browser OAuth redirect driven by the host, not as a tool call. On a remote
35
+ connector, treat "sign in" as something the user does in the host's UI.
36
+
37
+ ### Tool names may carry a host-applied prefix
38
+
39
+ The names above are the canonical ones the server registers. Some hosts namespace
40
+ them with the connector name the user chose. WorkBuddy, for example, turns a
41
+ connector named `plaudcn` into `plaudcn_list_files`, `plaudcn_get_transcript`, and
42
+ so on — the prefix follows the user's own config, so it is not predictable.
43
+
44
+ **Always resolve tools from the list you were actually given, matching on the
45
+ canonical suffix** (`…list_files`, `…get_transcript`). Never assume a bare name is
46
+ callable, and never hard-code a prefix.
31
47
 
32
48
  ## Error semantics
33
49
 
package/plugin.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "name": "plaud",
3
- "version": "0.3.8",
4
- "description": "Access your Plaud recordings in Claude",
5
- "author": {
6
- "name": "Plaud AI"
7
- }
8
- }