@plaud-ai/mcp 0.3.7 → 0.3.9

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.
@@ -446,6 +446,49 @@ async function loadBlockContent(block) {
446
446
  return "";
447
447
  }
448
448
 
449
+ // ../shared/dist/time.js
450
+ var HAS_TIMEZONE = /(?:[Zz]|[+-]\d{2}(?::?\d{2})?)$/;
451
+ var HAS_TIME = /\d{2}:\d{2}/;
452
+ var DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
453
+ function parseApiTimestamp(value) {
454
+ if (!value)
455
+ return null;
456
+ const trimmed = value.trim();
457
+ if (!trimmed)
458
+ return null;
459
+ const normalised = HAS_TIME.test(trimmed) && !HAS_TIMEZONE.test(trimmed) ? `${trimmed}Z` : trimmed;
460
+ const ms = new Date(normalised).getTime();
461
+ return Number.isNaN(ms) ? null : ms;
462
+ }
463
+ function localDayStart(value) {
464
+ const parts = parseDateOnly(value);
465
+ if (!parts)
466
+ return null;
467
+ const [y, m, d] = parts;
468
+ return new Date(y, m - 1, d, 0, 0, 0, 0).getTime();
469
+ }
470
+ function localDayEnd(value) {
471
+ const parts = parseDateOnly(value);
472
+ if (!parts)
473
+ return null;
474
+ const [y, m, d] = parts;
475
+ return new Date(y, m - 1, d + 1, 0, 0, 0, 0).getTime() - 1;
476
+ }
477
+ function parseDateOnly(value) {
478
+ if (!value)
479
+ return null;
480
+ const match = DATE_ONLY.exec(value.trim());
481
+ if (!match)
482
+ return null;
483
+ const y = Number(match[1]);
484
+ const m = Number(match[2]);
485
+ const d = Number(match[3]);
486
+ const probe = new Date(y, m - 1, d);
487
+ if (probe.getFullYear() !== y || probe.getMonth() !== m - 1 || probe.getDate() !== d)
488
+ return null;
489
+ return [y, m, d];
490
+ }
491
+
449
492
  // ../telemetry/dist/client.js
450
493
  import { PostHog } from "posthog-node";
451
494
 
@@ -510,10 +553,18 @@ async function shutdown() {
510
553
  return;
511
554
  const client = cachedClient;
512
555
  cachedClient = null;
513
- await Promise.race([
514
- client.shutdown(),
515
- new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS))
516
- ]);
556
+ let timer;
557
+ try {
558
+ await Promise.race([
559
+ client.shutdown(),
560
+ new Promise((resolve) => {
561
+ timer = setTimeout(resolve, SHUTDOWN_TIMEOUT_MS);
562
+ })
563
+ ]);
564
+ } finally {
565
+ if (timer)
566
+ clearTimeout(timer);
567
+ }
517
568
  }
518
569
 
519
570
  // ../telemetry/dist/api.js
@@ -859,6 +910,9 @@ export {
859
910
  PlaudClient,
860
911
  runOAuthCallback,
861
912
  loadBlockContent,
913
+ parseApiTimestamp,
914
+ localDayStart,
915
+ localDayEnd,
862
916
  shutdown,
863
917
  initTelemetry,
864
918
  setUser,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  PlaudClient,
3
3
  capture
4
- } from "./chunk-OYI4PXYP.js";
4
+ } from "./chunk-5NWKLF3V.js";
5
5
  import {
6
6
  httpClientDuration,
7
7
  httpClientRequests
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  capture,
3
3
  classifyError,
4
- loadBlockContent
5
- } from "./chunk-OYI4PXYP.js";
4
+ loadBlockContent,
5
+ localDayEnd,
6
+ localDayStart,
7
+ parseApiTimestamp
8
+ } from "./chunk-5NWKLF3V.js";
6
9
  import {
7
10
  logger
8
11
  } from "./chunk-NPCCDRWQ.js";
@@ -18,7 +21,29 @@ var toolHooks = null;
18
21
  var MAX_FILTER_PAGES = 5;
19
22
  var FILTER_PAGE_SIZE = 100;
20
23
  var TRANSCRIPT_PAGE_SIZE = 50;
21
- 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
+ }
22
47
  function encodeTranscriptCursor(offset) {
23
48
  return Buffer.from(JSON.stringify({ o: offset }), "utf8").toString("base64url");
24
49
  }
@@ -33,12 +58,6 @@ function decodeTranscriptCursor(cursor) {
33
58
  return null;
34
59
  }
35
60
  }
36
- function parseDate(s) {
37
- if (!s) return null;
38
- const d = new Date(s);
39
- if (Number.isNaN(d.getTime())) return null;
40
- return d.getTime();
41
- }
42
61
  function normalizeMcpHost(name) {
43
62
  if (!name) return void 0;
44
63
  const n = name.trim().toLowerCase();
@@ -98,8 +117,8 @@ function registerTools(server, client, hooks) {
98
117
  page: z.number().optional().default(1).describe("Page number (ignored when filters are set)"),
99
118
  page_size: z.number().optional().default(20).describe("Items per page (ignored when filters are set)"),
100
119
  query: z.string().optional().describe("Case-insensitive substring match on recording name"),
101
- date_from: z.string().optional().describe("Start date inclusive, YYYY-MM-DD"),
102
- date_to: z.string().optional().describe("End date inclusive, YYYY-MM-DD")
120
+ date_from: z.string().optional().describe("Start date inclusive, YYYY-MM-DD, interpreted in the server's timezone"),
121
+ date_to: z.string().optional().describe("End date inclusive, YYYY-MM-DD, interpreted in the server's timezone")
103
122
  }
104
123
  },
105
124
  async ({ page, page_size, query, date_from, date_to }) => {
@@ -115,9 +134,8 @@ function registerTools(server, client, hooks) {
115
134
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
116
135
  }
117
136
  const q = query?.toLowerCase();
118
- const from = parseDate(date_from);
119
- const toRaw = parseDate(date_to);
120
- const to = toRaw !== null ? toRaw + 24 * 60 * 60 * 1e3 - 1 : null;
137
+ const from = localDayStart(date_from);
138
+ const to = localDayEnd(date_to);
121
139
  const matches = [];
122
140
  let scanned = 0;
123
141
  let truncated = false;
@@ -128,7 +146,7 @@ function registerTools(server, client, hooks) {
128
146
  for (const item of items) {
129
147
  if (q && !(item.name ?? "").toLowerCase().includes(q)) continue;
130
148
  if (from !== null || to !== null) {
131
- const created = parseDate(item.created_at);
149
+ const created = parseApiTimestamp(item.created_at);
132
150
  if (created === null) continue;
133
151
  if (from !== null && created < from) continue;
134
152
  if (to !== null && created > to) continue;
@@ -179,6 +197,18 @@ function registerTools(server, client, hooks) {
179
197
  const synced = !!(file.duration || file.source_list && file.source_list.length);
180
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.";
181
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
+ }
182
212
  recordToolMetric("get_file", "success", Date.now() - start, requestId, { file_id });
183
213
  logger.info({ event: "tool_call_end", tool: "get_file", duration_ms: Date.now() - start, presigned_url_present: !!file.presigned_url });
184
214
  return { content: [{ type: "text", text }] };
@@ -192,7 +222,7 @@ function registerTools(server, client, hooks) {
192
222
  server.registerTool(
193
223
  "get_note",
194
224
  {
195
- 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.",
196
226
  annotations: {
197
227
  title: "Get recording notes",
198
228
  readOnlyHint: true,
@@ -207,9 +237,10 @@ function registerTools(server, client, hooks) {
207
237
  const requestId = emitToolClick("get_note", { file_id });
208
238
  try {
209
239
  const file = await client.getFile(file_id);
240
+ const notes = await resolveNotes(file.note_list ?? []);
210
241
  recordToolMetric("get_note", "success", Date.now() - start, requestId, { file_id });
211
- logger.info({ event: "tool_call_end", tool: "get_note", duration_ms: Date.now() - start });
212
- 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) }] };
213
244
  } catch (err) {
214
245
  recordToolMetric("get_note", "error", Date.now() - start, requestId, { file_id }, err);
215
246
  logger.error({ event: "tool_call_error", tool: "get_note", duration_ms: Date.now() - start, error: String(err) });
@@ -220,7 +251,7 @@ function registerTools(server, client, hooks) {
220
251
  server.registerTool(
221
252
  "get_transcript",
222
253
  {
223
- 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.",
224
255
  annotations: {
225
256
  title: "Get recording transcript",
226
257
  readOnlyHint: true,
@@ -230,10 +261,10 @@ function registerTools(server, client, hooks) {
230
261
  inputSchema: {
231
262
  file_id: z.string().describe("The file ID to retrieve transcript for"),
232
263
  block: z.enum(TRANSCRIPT_BLOCKS).optional().describe(
233
- "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."
234
265
  ),
235
266
  cursor: z.string().optional().describe("Opaque pagination cursor from a previous call's `next_cursor`. Omit to start from the first utterance."),
236
- 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.`)
237
268
  }
238
269
  },
239
270
  async ({ file_id, block, cursor, limit }) => {
@@ -271,7 +302,7 @@ function registerTools(server, client, hooks) {
271
302
  if (!segments) {
272
303
  recordToolMetric("get_transcript", "success", Date.now() - start, requestId, { file_id });
273
304
  logger.info({ event: "tool_call_end", tool: "get_transcript", duration_ms: Date.now() - start, block: blockType, paginated: false });
274
- 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.`);
275
306
  return { content: [{ type: "text", text }] };
276
307
  }
277
308
  let offset = 0;
@@ -299,7 +330,7 @@ function registerTools(server, client, hooks) {
299
330
  limit: pageSize,
300
331
  returned: page.length,
301
332
  next_cursor: hasMore ? encodeTranscriptCursor(nextOffset) : null,
302
- segments: page
333
+ [BLOCK_ITEMS_KEY[blockType] ?? "segments"]: page
303
334
  };
304
335
  recordToolMetric("get_transcript", "success", Date.now() - start, requestId, { file_id });
305
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-3K3M2X74.js";
4
+ } from "./chunk-EY5K2UXG.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-VLZPO2CO.js";
11
+ } from "./chunk-FSNFNJXH.js";
12
12
  import {
13
13
  capture,
14
14
  classifyError,
@@ -21,7 +21,7 @@ import {
21
21
  setMcpHost,
22
22
  setUser,
23
23
  shutdown
24
- } from "./chunk-OYI4PXYP.js";
24
+ } from "./chunk-5NWKLF3V.js";
25
25
  import "./chunk-NPCCDRWQ.js";
26
26
  import "./chunk-RUFCT6DQ.js";
27
27
 
@@ -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.7"
34
+ version: "0.3.9"
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-3E63G6QX.js");
161
+ const { runInstall } = await import("./install-RTIXREYV.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,7 +191,7 @@ async function main() {
191
191
  return;
192
192
  }
193
193
  if (sub === "http") {
194
- const { startHttpServer } = await import("./server-2HG4R4LT.js");
194
+ const { startHttpServer } = await import("./server-SYSE62NO.js");
195
195
  const { startMetricsServer } = await import("./server-NKCNUA6P.js");
196
196
  startMetricsServer();
197
197
  startHttpServer();
@@ -217,7 +217,7 @@ Usage:
217
217
  try {
218
218
  await initTelemetry({
219
219
  surface: "mcp",
220
- appVersion: "0.3.7",
220
+ appVersion: "0.3.9",
221
221
  transport: "stdio"
222
222
  });
223
223
  } catch {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getClient
3
- } from "./chunk-3K3M2X74.js";
3
+ } from "./chunk-EY5K2UXG.js";
4
4
  import {
5
5
  commandPathIsStale,
6
6
  copyToClipboard,
@@ -12,7 +12,7 @@ import {
12
12
  } from "./chunk-242FRP4P.js";
13
13
  import {
14
14
  runOAuthCallback
15
- } from "./chunk-OYI4PXYP.js";
15
+ } from "./chunk-5NWKLF3V.js";
16
16
  import "./chunk-RUFCT6DQ.js";
17
17
 
18
18
  // src/install.ts
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  normalizeMcpHost,
3
3
  registerTools
4
- } from "./chunk-VLZPO2CO.js";
4
+ } from "./chunk-FSNFNJXH.js";
5
5
  import {
6
6
  PlaudClient
7
- } from "./chunk-OYI4PXYP.js";
7
+ } from "./chunk-5NWKLF3V.js";
8
8
  import {
9
9
  logger
10
10
  } from "./chunk-NPCCDRWQ.js";
@@ -276,6 +276,27 @@ function subFromJwt(token) {
276
276
  return void 0;
277
277
  }
278
278
  }
279
+ var AUTO_RECOVERED_CLIENT_NAME = "auto-recovered client";
280
+ var REDIRECT_HOST_CLIENT_NAMES = {
281
+ "chatgpt.com": "chatgpt",
282
+ "openai.com": "chatgpt",
283
+ "claude.ai": "claude",
284
+ "claude.com": "claude"
285
+ };
286
+ function clientNameFromRedirectUri(redirectUri) {
287
+ if (!redirectUri) return void 0;
288
+ let host;
289
+ try {
290
+ host = new URL(redirectUri).hostname.toLowerCase().replace(/^www\./, "");
291
+ } catch {
292
+ return void 0;
293
+ }
294
+ if (!host) return void 0;
295
+ for (const [domain, name] of Object.entries(REDIRECT_HOST_CLIENT_NAMES)) {
296
+ if (host === domain || host.endsWith(`.${domain}`)) return name;
297
+ }
298
+ return host;
299
+ }
279
300
  var DEFAULT_TOKEN_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token";
280
301
  var DEFAULT_REFRESH_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token/refresh";
281
302
  var STATELESS_CODE_PREFIX = "pc1_";
@@ -380,7 +401,7 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
380
401
  return this._registeredClients.get(id);
381
402
  },
382
403
  registerClient: async (client) => {
383
- const rawId = randomBytes(16).toString("base64url");
404
+ const rawId = this.encodeRawId(client.client_name);
384
405
  const tokenEndpointAuthMethod = client.token_endpoint_auth_method ?? "none";
385
406
  const full = {
386
407
  ...client,
@@ -428,6 +449,34 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
428
449
  const sig = createHmac("sha256", this._clientIdSecret).update(rawId).digest().subarray(0, 16);
429
450
  return `${rawId}.${sig.toString("base64url")}`;
430
451
  }
452
+ // Self-describing client_id: we can't persist the DCR registry across
453
+ // container restarts (compliance — no storage), so we encode the DCR
454
+ // client_name into the signed client_id itself. verifyAndRecover decodes the
455
+ // real name back instead of a placeholder, keeping mcp_host attribution
456
+ // correct across restarts (fixes the "auto-recovered client" bucket). The
457
+ // payload holds the app's self-reported name (not PII, capped) plus a nonce
458
+ // so repeat registrations get distinct ids. base64url contains no ".", so it
459
+ // never collides with the "<rawId>.<sig>" separator.
460
+ encodeRawId(clientName) {
461
+ const name = typeof clientName === "string" && clientName.length > 0 ? clientName.slice(0, 64) : null;
462
+ const payload = JSON.stringify({ v: 1, n: name, r: randomBytes(8).toString("base64url") });
463
+ return Buffer.from(payload, "utf8").toString("base64url");
464
+ }
465
+ // Decode the client_name embedded by encodeRawId. Returns undefined for
466
+ // legacy ids (random rawId issued before this fix) or anything malformed —
467
+ // callers fall back to the placeholder, so recovery still degrades gracefully.
468
+ decodeClientName(clientId) {
469
+ const idx = clientId.lastIndexOf(".");
470
+ const rawId = idx > 0 ? clientId.slice(0, idx) : clientId;
471
+ try {
472
+ const payload = JSON.parse(Buffer.from(rawId, "base64url").toString("utf8"));
473
+ if (payload && payload.v === 1 && typeof payload.n === "string" && payload.n.length > 0) {
474
+ return payload.n;
475
+ }
476
+ } catch {
477
+ }
478
+ return void 0;
479
+ }
431
480
  // Verify a previously-issued client_id. Returns true only for ids whose HMAC
432
481
  // signature matches our secret — i.e. ids this server (or its predecessor with
433
482
  // the same PLAUD_DCR_HMAC_SECRET) issued via registerClient.
@@ -504,11 +553,25 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
504
553
  if (redirectUri && !existing.redirect_uris.includes(redirectUri)) {
505
554
  existing.redirect_uris = [...existing.redirect_uris, redirectUri];
506
555
  }
556
+ if (existing.client_name === AUTO_RECOVERED_CLIENT_NAME) {
557
+ const upgradedName = clientNameFromRedirectUri(redirectUri);
558
+ if (upgradedName) {
559
+ existing.client_name = upgradedName;
560
+ logger.info({
561
+ event: "oauth_client_name_upgraded",
562
+ client_id: clientId,
563
+ name_source: "redirect_uri",
564
+ mcp_host: normalizeMcpHost(upgradedName) ?? null
565
+ });
566
+ }
567
+ }
507
568
  return true;
508
569
  }
509
570
  if (!this.verifyClientIdSignature(clientId)) {
510
571
  return false;
511
572
  }
573
+ const recoveredName = this.decodeClientName(clientId);
574
+ const derivedName = recoveredName ?? clientNameFromRedirectUri(redirectUri);
512
575
  this._registeredClients.set(clientId, {
513
576
  client_id: clientId,
514
577
  client_id_issued_at: Math.floor(Date.now() / 1e3),
@@ -516,9 +579,21 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
516
579
  grant_types: ["authorization_code", "refresh_token"],
517
580
  response_types: ["code"],
518
581
  token_endpoint_auth_method: "none",
519
- client_name: "auto-recovered client"
582
+ client_name: derivedName ?? AUTO_RECOVERED_CLIENT_NAME
583
+ });
584
+ logger.info({
585
+ event: "oauth_client_recovered",
586
+ client_id: clientId,
587
+ redirect_uri: redirectUri ?? null,
588
+ // Unchanged semantics: true only when the name came out of the client_id
589
+ // itself — this is the field used to verify the #72 fix in prod.
590
+ name_recovered: recoveredName != null,
591
+ name_source: recoveredName != null ? "client_id" : derivedName != null ? "redirect_uri" : null,
592
+ // The bucket this recovery will put on every downstream auth event. Lets us
593
+ // verify attribution straight from prod logs (the warehouse events are
594
+ // batched, so they're not a live signal), and enumerate real hosts.
595
+ mcp_host: normalizeMcpHost(derivedName) ?? null
520
596
  });
521
- logger.info({ event: "oauth_client_recovered", client_id: clientId, redirect_uri: redirectUri ?? null });
522
597
  return true;
523
598
  }
524
599
  /**
@@ -566,7 +641,9 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
566
641
  res.redirect(targetUrl.toString());
567
642
  }
568
643
  /**
569
- * Called when Plaud redirects to our /oauth/callback.
644
+ * Called when Plaud redirects to our /auth/callback (CALLBACK_PATH in
645
+ * http/server.ts). NOT /oauth/callback — this comment said so until 2026-08-19
646
+ * and a downstream client registration was configured from it by mistake.
570
647
  * Looks up the original client redirect_uri and forwards the code+state to it.
571
648
  */
572
649
  handleCallback(code, state, res) {
@@ -1149,6 +1226,7 @@ var OPENAI_APPS_CHALLENGE_TOKEN = "bBlB7N7fF7YaQnONtEQNAGP7SLaqpJT3a3DS5Zv1F8s";
1149
1226
  var OAUTH_DEBUG_LOGS = !["0", "false", "no", "off"].includes(
1150
1227
  (process.env.PLAUD_OAUTH_DEBUG_LOGS ?? "").toLowerCase()
1151
1228
  );
1229
+ var ROOT_REDIRECT_URL = process.env.PLAUD_ROOT_REDIRECT_URL ?? "https://www.plaud.ai";
1152
1230
  var CIMD_ENABLED = ["1", "true", "yes", "on"].includes(
1153
1231
  (process.env.PLAUD_CIMD_ENABLED ?? "").toLowerCase()
1154
1232
  );
@@ -1205,8 +1283,8 @@ function startHttpServer() {
1205
1283
  common: {
1206
1284
  serviceName: process.env.PLAUD_WAREHOUSE_SERVICE_NAME ?? "plaudmcp",
1207
1285
  // matches §1.3 key label `plaudmcp:TRACKING_KEY_PLAUDMCP`; confirm exact string with David
1208
- serviceVersion: "0.3.7",
1209
- buildId: "43da41f",
1286
+ serviceVersion: "0.3.9",
1287
+ buildId: "ee2ff6d",
1210
1288
  // mcp tsup TODO: inject git short SHA (like CLI)
1211
1289
  region: process.env.PLAUD_REGION ?? "US",
1212
1290
  env: process.env.PLAUD_ENV ?? process.env.NODE_ENV ?? "development"
@@ -1299,6 +1377,9 @@ function startHttpServer() {
1299
1377
  }
1300
1378
  });
1301
1379
  });
1380
+ app.get("/", (_req, res) => {
1381
+ res.redirect(302, ROOT_REDIRECT_URL);
1382
+ });
1302
1383
  if (process.env.PLAUD_CALLBACK_URL) {
1303
1384
  app.get(CALLBACK_PATH, (req, res) => {
1304
1385
  const code = req.query["code"];
@@ -1436,7 +1517,27 @@ function startHttpServer() {
1436
1517
  // register…" once new-registration rate exceeds ~20/hour. Real auth still
1437
1518
  // happens upstream at Plaud; abuse/unbounded-growth should be bounded on
1438
1519
  // the in-memory client store (size/TTL cap) rather than via this per-IP limiter.
1439
- clientRegistrationOptions: { rateLimit: false }
1520
+ clientRegistrationOptions: { rateLimit: false },
1521
+ // Same failure mode on the two handshake endpoints — /register was only
1522
+ // the first one users hit. The SDK also defaults /token to 50 requests per
1523
+ // 15 min and /authorize to 100 per 15 min, both keyed per client IP, and
1524
+ // behind our proxy every request presents the same upstream address, so
1525
+ // the per-IP bucket degenerates into ONE bucket shared by all users
1526
+ // worldwide. A reporter measured this from two continents at once: two
1527
+ // requests 2s apart from Cyprus and Germany saw the same counter (13 and
1528
+ // 11 remaining, reset 671s/672s), and the bucket drained ~13 req/min from
1529
+ // other traffic alone, i.e. it is empty almost continuously.
1530
+ //
1531
+ // /token is the worst place to cap: it is the only handshake endpoint that
1532
+ // repeats after login, because every silent refresh_token renewal goes
1533
+ // through it. Exhausting it does not just block new logins — it breaks
1534
+ // token renewal for users who are already connected.
1535
+ //
1536
+ // Real authentication and abuse control live upstream at Plaud (and at the
1537
+ // edge); this in-process per-IP limiter cannot see past the proxy, so it
1538
+ // can only produce false positives.
1539
+ tokenOptions: { rateLimit: false },
1540
+ authorizationOptions: { rateLimit: false }
1440
1541
  })
1441
1542
  );
1442
1543
  app.post(
@@ -1455,7 +1556,7 @@ function startHttpServer() {
1455
1556
  apiBase,
1456
1557
  staticToken: token
1457
1558
  });
1458
- const mcpServer = new McpServer({ name: "plaud", version: "0.3.7" });
1559
+ const mcpServer = new McpServer({ name: "plaud", version: "0.3.9" });
1459
1560
  registerTools(mcpServer, client, warehouseToolHooks);
1460
1561
  const transport = new StreamableHTTPServerTransport({
1461
1562
  sessionIdGenerator: void 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plaud-ai/mcp",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
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.7",
4
- "description": "Access your Plaud recordings in Claude",
5
- "author": {
6
- "name": "Plaud AI"
7
- }
8
- }