@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.
- package/dist/{chunk-RUFCT6DQ.js → chunk-DIPROABB.js} +4 -3
- package/dist/{chunk-EY5K2UXG.js → chunk-PLFBTSKM.js} +1 -1
- package/dist/{chunk-DKOQXKKG.js → chunk-ZVOHKRNC.js} +45 -10
- package/dist/index.js +8 -8
- package/dist/{install-RTIXREYV.js → install-SWKIKJ5C.js} +2 -2
- package/dist/{server-NKCNUA6P.js → server-6RAC6S7E.js} +1 -1
- package/dist/{server-VJAFEGJ6.js → server-SB6RRGY7.js} +516 -92
- 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
|
@@ -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:
|
|
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:
|
|
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:
|
|
48
|
+
buckets: LATENCY_BUCKETS,
|
|
48
49
|
registers: [registry]
|
|
49
50
|
});
|
|
50
51
|
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
mcpToolCalls,
|
|
14
14
|
mcpToolDuration
|
|
15
|
-
} from "./chunk-
|
|
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
|
|
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(
|
|
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 `
|
|
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 `
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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.
|
|
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-
|
|
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-
|
|
195
|
-
const { startMetricsServer } = await import("./server-
|
|
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.
|
|
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-
|
|
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-
|
|
16
|
+
import "./chunk-DIPROABB.js";
|
|
17
17
|
|
|
18
18
|
// src/install.ts
|
|
19
19
|
import { createInterface } from "readline/promises";
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
normalizeMcpHost,
|
|
3
3
|
registerTools
|
|
4
|
-
} from "./chunk-
|
|
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-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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.
|
|
483
|
-
const plaintext = Buffer.from(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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 `${
|
|
779
|
+
return `${prefix}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
|
|
492
780
|
}
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
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
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
604
|
-
*
|
|
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.
|
|
984
|
+
const pending = this.decodeAuthorizationState(state);
|
|
608
985
|
if (!pending) {
|
|
609
|
-
logger.warn({
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
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:
|
|
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.
|
|
1243
|
-
buildId: "
|
|
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"] ??
|
|
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"] ??
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|