@plaud-ai/mcp 0.3.8 → 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.
@@ -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
@@ -8,7 +8,7 @@ import {
8
8
  import {
9
9
  normalizeMcpHost,
10
10
  registerTools
11
- } from "./chunk-DKOQXKKG.js";
11
+ } from "./chunk-FSNFNJXH.js";
12
12
  import {
13
13
  capture,
14
14
  classifyError,
@@ -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.9"
35
35
  });
36
36
  var CALLBACK_PORT = 8199;
37
37
  var LOGIN_TIMEOUT_MS = 12e4;
@@ -191,7 +191,7 @@ async function main() {
191
191
  return;
192
192
  }
193
193
  if (sub === "http") {
194
- const { startHttpServer } = await import("./server-VJAFEGJ6.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.8",
220
+ appVersion: "0.3.9",
221
221
  transport: "stdio"
222
222
  });
223
223
  } catch {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  normalizeMcpHost,
3
3
  registerTools
4
- } from "./chunk-DKOQXKKG.js";
4
+ } from "./chunk-FSNFNJXH.js";
5
5
  import {
6
6
  PlaudClient
7
7
  } from "./chunk-5NWKLF3V.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_";
@@ -532,12 +553,25 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
532
553
  if (redirectUri && !existing.redirect_uris.includes(redirectUri)) {
533
554
  existing.redirect_uris = [...existing.redirect_uris, redirectUri];
534
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
+ }
535
568
  return true;
536
569
  }
537
570
  if (!this.verifyClientIdSignature(clientId)) {
538
571
  return false;
539
572
  }
540
573
  const recoveredName = this.decodeClientName(clientId);
574
+ const derivedName = recoveredName ?? clientNameFromRedirectUri(redirectUri);
541
575
  this._registeredClients.set(clientId, {
542
576
  client_id: clientId,
543
577
  client_id_issued_at: Math.floor(Date.now() / 1e3),
@@ -545,13 +579,20 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
545
579
  grant_types: ["authorization_code", "refresh_token"],
546
580
  response_types: ["code"],
547
581
  token_endpoint_auth_method: "none",
548
- client_name: recoveredName ?? "auto-recovered client"
582
+ client_name: derivedName ?? AUTO_RECOVERED_CLIENT_NAME
549
583
  });
550
584
  logger.info({
551
585
  event: "oauth_client_recovered",
552
586
  client_id: clientId,
553
587
  redirect_uri: redirectUri ?? null,
554
- name_recovered: recoveredName != 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
555
596
  });
556
597
  return true;
557
598
  }
@@ -600,7 +641,9 @@ var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
600
641
  res.redirect(targetUrl.toString());
601
642
  }
602
643
  /**
603
- * 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.
604
647
  * Looks up the original client redirect_uri and forwards the code+state to it.
605
648
  */
606
649
  handleCallback(code, state, res) {
@@ -1183,6 +1226,7 @@ var OPENAI_APPS_CHALLENGE_TOKEN = "bBlB7N7fF7YaQnONtEQNAGP7SLaqpJT3a3DS5Zv1F8s";
1183
1226
  var OAUTH_DEBUG_LOGS = !["0", "false", "no", "off"].includes(
1184
1227
  (process.env.PLAUD_OAUTH_DEBUG_LOGS ?? "").toLowerCase()
1185
1228
  );
1229
+ var ROOT_REDIRECT_URL = process.env.PLAUD_ROOT_REDIRECT_URL ?? "https://www.plaud.ai";
1186
1230
  var CIMD_ENABLED = ["1", "true", "yes", "on"].includes(
1187
1231
  (process.env.PLAUD_CIMD_ENABLED ?? "").toLowerCase()
1188
1232
  );
@@ -1239,8 +1283,8 @@ function startHttpServer() {
1239
1283
  common: {
1240
1284
  serviceName: process.env.PLAUD_WAREHOUSE_SERVICE_NAME ?? "plaudmcp",
1241
1285
  // matches §1.3 key label `plaudmcp:TRACKING_KEY_PLAUDMCP`; confirm exact string with David
1242
- serviceVersion: "0.3.8",
1243
- buildId: "be0dfaa",
1286
+ serviceVersion: "0.3.9",
1287
+ buildId: "ee2ff6d",
1244
1288
  // mcp tsup TODO: inject git short SHA (like CLI)
1245
1289
  region: process.env.PLAUD_REGION ?? "US",
1246
1290
  env: process.env.PLAUD_ENV ?? process.env.NODE_ENV ?? "development"
@@ -1333,6 +1377,9 @@ function startHttpServer() {
1333
1377
  }
1334
1378
  });
1335
1379
  });
1380
+ app.get("/", (_req, res) => {
1381
+ res.redirect(302, ROOT_REDIRECT_URL);
1382
+ });
1336
1383
  if (process.env.PLAUD_CALLBACK_URL) {
1337
1384
  app.get(CALLBACK_PATH, (req, res) => {
1338
1385
  const code = req.query["code"];
@@ -1509,7 +1556,7 @@ function startHttpServer() {
1509
1556
  apiBase,
1510
1557
  staticToken: token
1511
1558
  });
1512
- const mcpServer = new McpServer({ name: "plaud", version: "0.3.8" });
1559
+ const mcpServer = new McpServer({ name: "plaud", version: "0.3.9" });
1513
1560
  registerTools(mcpServer, client, warehouseToolHooks);
1514
1561
  const transport = new StreamableHTTPServerTransport({
1515
1562
  sessionIdGenerator: void 0,
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.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.8",
4
- "description": "Access your Plaud recordings in Claude",
5
- "author": {
6
- "name": "Plaud AI"
7
- }
8
- }