@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.
- package/dist/{chunk-OYI4PXYP.js → chunk-5NWKLF3V.js} +58 -4
- package/dist/{chunk-3K3M2X74.js → chunk-EY5K2UXG.js} +1 -1
- package/dist/{chunk-VLZPO2CO.js → chunk-FSNFNJXH.js} +54 -23
- package/dist/index.js +7 -7
- package/dist/{install-3E63G6QX.js → install-RTIXREYV.js} +2 -2
- package/dist/{server-2HG4R4LT.js → server-SYSE62NO.js} +111 -10
- package/package.json +2 -3
- package/skills/plaud-read/SKILL.md +4 -3
- package/skills/plaud-shared/SKILL.md +25 -9
- package/plugin.json +0 -8
|
@@ -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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
capture,
|
|
3
3
|
classifyError,
|
|
4
|
-
loadBlockContent
|
|
5
|
-
|
|
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 =
|
|
119
|
-
const
|
|
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 =
|
|
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
|
|
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(
|
|
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 `
|
|
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 `
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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-
|
|
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-
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
4
|
+
} from "./chunk-FSNFNJXH.js";
|
|
5
5
|
import {
|
|
6
6
|
PlaudClient
|
|
7
|
-
} from "./chunk-
|
|
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 =
|
|
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:
|
|
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 /
|
|
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.
|
|
1209
|
-
buildId: "
|
|
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.
|
|
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.
|
|
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(
|
|
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` |
|
|
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
|
|