@plaud-ai/mcp 0.2.2 → 0.2.3
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-SNSGVRCU.js → chunk-3XRFIJUG.js} +119 -1
- package/dist/{chunk-IZKXHQM3.js → chunk-BZOIX6WC.js} +31 -1
- package/dist/{chunk-7KGB7GSZ.js → chunk-GZ7QPRKV.js} +1 -1
- package/dist/index.js +46 -76
- package/dist/{install-EEQXUUG3.js → install-C7WMVIPG.js} +38 -58
- package/dist/server-ORMBAVW6.js +1039 -0
- package/package.json +12 -11
- package/plugin.json +1 -1
- package/dist/server-QWAZPBHU.js +0 -293
|
@@ -0,0 +1,1039 @@
|
|
|
1
|
+
import {
|
|
2
|
+
logger,
|
|
3
|
+
registerTools
|
|
4
|
+
} from "./chunk-BZOIX6WC.js";
|
|
5
|
+
import {
|
|
6
|
+
PlaudClient
|
|
7
|
+
} from "./chunk-3XRFIJUG.js";
|
|
8
|
+
|
|
9
|
+
// src/http/server.ts
|
|
10
|
+
import express from "express";
|
|
11
|
+
import { createServer } from "http";
|
|
12
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
|
+
import { createOAuthMetadata, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
|
|
16
|
+
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
|
|
17
|
+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
|
18
|
+
|
|
19
|
+
// src/http/oauth-provider.ts
|
|
20
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
21
|
+
import { ProxyOAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js";
|
|
22
|
+
import {
|
|
23
|
+
InvalidGrantError,
|
|
24
|
+
InvalidTokenError,
|
|
25
|
+
ServerError as McpServerError
|
|
26
|
+
} from "@modelcontextprotocol/sdk/server/auth/errors.js";
|
|
27
|
+
|
|
28
|
+
// src/http/cimd.ts
|
|
29
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
30
|
+
import { isIP, isIPv4, isIPv6 } from "net";
|
|
31
|
+
var FETCH_TIMEOUT_MS = 5e3;
|
|
32
|
+
var MAX_DOCUMENT_SIZE_BYTES = 10 * 1024;
|
|
33
|
+
var DEFAULT_TTL_MS = 60 * 60 * 1e3;
|
|
34
|
+
var CACHE_MAX_ENTRIES = 1e3;
|
|
35
|
+
var FORBIDDEN_AUTH_METHODS = /* @__PURE__ */ new Set([
|
|
36
|
+
"client_secret_post",
|
|
37
|
+
"client_secret_basic",
|
|
38
|
+
"client_secret_jwt"
|
|
39
|
+
]);
|
|
40
|
+
function isValidCimdUrl(value) {
|
|
41
|
+
if (typeof value !== "string" || !URL.canParse(value)) return false;
|
|
42
|
+
const url = new URL(value);
|
|
43
|
+
if (url.protocol !== "https:") return false;
|
|
44
|
+
if (url.username || url.password) return false;
|
|
45
|
+
if (url.hash) return false;
|
|
46
|
+
if (!url.pathname || url.pathname === "/") return false;
|
|
47
|
+
if (url.pathname.split("/").some((seg) => seg === "." || seg === "..")) return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
function isPrivateIp(ip) {
|
|
51
|
+
if (isIPv4(ip)) {
|
|
52
|
+
const parts = ip.split(".").map((p) => Number(p));
|
|
53
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
|
|
54
|
+
const [a, b] = parts;
|
|
55
|
+
if (a === 0) return true;
|
|
56
|
+
if (a === 10) return true;
|
|
57
|
+
if (a === 127) return true;
|
|
58
|
+
if (a === 169 && b === 254) return true;
|
|
59
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
60
|
+
if (a === 192 && b === 168) return true;
|
|
61
|
+
if (a >= 224) return true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (isIPv6(ip)) {
|
|
65
|
+
const lower = ip.toLowerCase();
|
|
66
|
+
if (lower === "::" || lower === "::1") return true;
|
|
67
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
68
|
+
if (lower.startsWith("fe80:")) return true;
|
|
69
|
+
if (lower.startsWith("ff")) return true;
|
|
70
|
+
if (lower.startsWith("::ffff:")) {
|
|
71
|
+
const v4 = lower.slice("::ffff:".length);
|
|
72
|
+
if (isIPv4(v4)) return isPrivateIp(v4);
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
var CimdLoader = class {
|
|
79
|
+
cache = /* @__PURE__ */ new Map();
|
|
80
|
+
allowPrivateIp;
|
|
81
|
+
fetchImpl;
|
|
82
|
+
lookupImpl;
|
|
83
|
+
ttlMs;
|
|
84
|
+
constructor(options = {}) {
|
|
85
|
+
this.allowPrivateIp = options.allowPrivateIp ?? false;
|
|
86
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
87
|
+
this.lookupImpl = options.lookupImpl ?? dnsLookup;
|
|
88
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a CIMD client_id URL into an OAuthClientInformationFull, fetching
|
|
92
|
+
* and validating the metadata document if necessary. Returns undefined on
|
|
93
|
+
* any failure (logged at warn level).
|
|
94
|
+
*/
|
|
95
|
+
async load(clientIdUrl) {
|
|
96
|
+
if (!isValidCimdUrl(clientIdUrl)) {
|
|
97
|
+
logger.warn({ event: "cimd_invalid_url", client_id_url: clientIdUrl });
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
const cached = this.cache.get(clientIdUrl);
|
|
101
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
102
|
+
return cached.client;
|
|
103
|
+
}
|
|
104
|
+
if (cached) this.cache.delete(clientIdUrl);
|
|
105
|
+
if (!this.allowPrivateIp) {
|
|
106
|
+
const safe = await this.checkHostAllowed(new URL(clientIdUrl).hostname);
|
|
107
|
+
if (!safe) return void 0;
|
|
108
|
+
}
|
|
109
|
+
const client = await this.fetchAndValidate(clientIdUrl);
|
|
110
|
+
if (!client) return void 0;
|
|
111
|
+
this.put(clientIdUrl, client);
|
|
112
|
+
logger.info({
|
|
113
|
+
event: "cimd_client_loaded",
|
|
114
|
+
client_id_url: clientIdUrl,
|
|
115
|
+
client_name: client.client_name ?? null,
|
|
116
|
+
redirect_uri_count: client.redirect_uris.length
|
|
117
|
+
});
|
|
118
|
+
return client;
|
|
119
|
+
}
|
|
120
|
+
async checkHostAllowed(hostname) {
|
|
121
|
+
if (isIP(hostname) !== 0) {
|
|
122
|
+
logger.warn({ event: "cimd_ip_literal_blocked", hostname });
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const results = await this.lookupImpl(hostname, { all: true });
|
|
127
|
+
for (const r of results) {
|
|
128
|
+
if (isPrivateIp(r.address)) {
|
|
129
|
+
logger.warn({ event: "cimd_private_ip_blocked", hostname, ip: r.address });
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logger.warn({
|
|
136
|
+
event: "cimd_dns_failed",
|
|
137
|
+
hostname,
|
|
138
|
+
error: err instanceof Error ? err.message : String(err)
|
|
139
|
+
});
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async fetchAndValidate(clientIdUrl) {
|
|
144
|
+
const ctrl = new AbortController();
|
|
145
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
146
|
+
try {
|
|
147
|
+
const res = await this.fetchImpl(clientIdUrl, {
|
|
148
|
+
method: "GET",
|
|
149
|
+
headers: { Accept: "application/json" },
|
|
150
|
+
signal: ctrl.signal,
|
|
151
|
+
redirect: "error"
|
|
152
|
+
// CIMD URL identifies the client; redirects not allowed
|
|
153
|
+
});
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
logger.warn({ event: "cimd_fetch_non_ok", client_id_url: clientIdUrl, status: res.status });
|
|
156
|
+
return void 0;
|
|
157
|
+
}
|
|
158
|
+
const declared = Number(res.headers.get("content-length") ?? "0");
|
|
159
|
+
if (declared > MAX_DOCUMENT_SIZE_BYTES) {
|
|
160
|
+
logger.warn({
|
|
161
|
+
event: "cimd_document_too_large",
|
|
162
|
+
client_id_url: clientIdUrl,
|
|
163
|
+
content_length: declared
|
|
164
|
+
});
|
|
165
|
+
return void 0;
|
|
166
|
+
}
|
|
167
|
+
const text = await res.text();
|
|
168
|
+
if (text.length > MAX_DOCUMENT_SIZE_BYTES) {
|
|
169
|
+
logger.warn({
|
|
170
|
+
event: "cimd_document_too_large",
|
|
171
|
+
client_id_url: clientIdUrl,
|
|
172
|
+
actual_size: text.length
|
|
173
|
+
});
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(text);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logger.warn({
|
|
181
|
+
event: "cimd_invalid_json",
|
|
182
|
+
client_id_url: clientIdUrl,
|
|
183
|
+
error: err instanceof Error ? err.message : String(err)
|
|
184
|
+
});
|
|
185
|
+
return void 0;
|
|
186
|
+
}
|
|
187
|
+
return validateMetadata(parsed, clientIdUrl);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logger.warn({
|
|
190
|
+
event: "cimd_fetch_error",
|
|
191
|
+
client_id_url: clientIdUrl,
|
|
192
|
+
error: err instanceof Error ? err.message : String(err)
|
|
193
|
+
});
|
|
194
|
+
return void 0;
|
|
195
|
+
} finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
put(key, client) {
|
|
200
|
+
if (this.cache.size >= CACHE_MAX_ENTRIES) {
|
|
201
|
+
const oldest = this.cache.keys().next().value;
|
|
202
|
+
if (oldest !== void 0) this.cache.delete(oldest);
|
|
203
|
+
}
|
|
204
|
+
this.cache.set(key, { client, expiresAt: Date.now() + this.ttlMs });
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
function validateMetadata(parsed, expectedClientIdUrl) {
|
|
208
|
+
if (!parsed || typeof parsed !== "object") {
|
|
209
|
+
logger.warn({ event: "cimd_invalid_shape", client_id_url: expectedClientIdUrl });
|
|
210
|
+
return void 0;
|
|
211
|
+
}
|
|
212
|
+
const m = parsed;
|
|
213
|
+
if (m["client_id"] !== expectedClientIdUrl) {
|
|
214
|
+
logger.warn({
|
|
215
|
+
event: "cimd_client_id_mismatch",
|
|
216
|
+
client_id_url: expectedClientIdUrl,
|
|
217
|
+
document_client_id: typeof m["client_id"] === "string" ? m["client_id"] : null
|
|
218
|
+
});
|
|
219
|
+
return void 0;
|
|
220
|
+
}
|
|
221
|
+
const redirects = m["redirect_uris"];
|
|
222
|
+
if (!Array.isArray(redirects) || redirects.length === 0 || !redirects.every((u) => typeof u === "string")) {
|
|
223
|
+
logger.warn({ event: "cimd_invalid_redirect_uris", client_id_url: expectedClientIdUrl });
|
|
224
|
+
return void 0;
|
|
225
|
+
}
|
|
226
|
+
const authMethod = m["token_endpoint_auth_method"];
|
|
227
|
+
if (typeof authMethod === "string" && FORBIDDEN_AUTH_METHODS.has(authMethod)) {
|
|
228
|
+
logger.warn({
|
|
229
|
+
event: "cimd_forbidden_auth_method",
|
|
230
|
+
client_id_url: expectedClientIdUrl,
|
|
231
|
+
token_endpoint_auth_method: authMethod
|
|
232
|
+
});
|
|
233
|
+
return void 0;
|
|
234
|
+
}
|
|
235
|
+
const optString = (key) => typeof m[key] === "string" ? m[key] : void 0;
|
|
236
|
+
const optStringArray = (key) => {
|
|
237
|
+
const v = m[key];
|
|
238
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string") ? v : void 0;
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
client_id: expectedClientIdUrl,
|
|
242
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3),
|
|
243
|
+
redirect_uris: redirects,
|
|
244
|
+
token_endpoint_auth_method: typeof authMethod === "string" ? authMethod : "none",
|
|
245
|
+
grant_types: optStringArray("grant_types"),
|
|
246
|
+
response_types: optStringArray("response_types"),
|
|
247
|
+
client_name: optString("client_name"),
|
|
248
|
+
client_uri: optString("client_uri"),
|
|
249
|
+
logo_uri: optString("logo_uri"),
|
|
250
|
+
scope: optString("scope"),
|
|
251
|
+
contacts: optStringArray("contacts"),
|
|
252
|
+
tos_uri: optString("tos_uri"),
|
|
253
|
+
policy_uri: optString("policy_uri"),
|
|
254
|
+
jwks_uri: optString("jwks_uri"),
|
|
255
|
+
software_id: optString("software_id"),
|
|
256
|
+
software_version: optString("software_version"),
|
|
257
|
+
software_statement: optString("software_statement")
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/http/oauth-provider.ts
|
|
262
|
+
var DEFAULT_TOKEN_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token";
|
|
263
|
+
var DEFAULT_REFRESH_URL = "https://platform.plaud.ai/developer/api/oauth/third-party/access-token/refresh";
|
|
264
|
+
var STATELESS_CODE_PREFIX = "pc1_";
|
|
265
|
+
var AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1e3;
|
|
266
|
+
function urlParts(value) {
|
|
267
|
+
if (!value || !URL.canParse(value)) {
|
|
268
|
+
return { host: null, path: null };
|
|
269
|
+
}
|
|
270
|
+
const url = new URL(value);
|
|
271
|
+
return { host: url.host, path: url.pathname };
|
|
272
|
+
}
|
|
273
|
+
var PlaudOAuthProvider = class extends ProxyOAuthServerProvider {
|
|
274
|
+
_plaudClientId;
|
|
275
|
+
_plaudClientSecret;
|
|
276
|
+
_plaudTokenUrl;
|
|
277
|
+
_plaudRefreshUrl;
|
|
278
|
+
_plaudApiBase;
|
|
279
|
+
_callbackUrl;
|
|
280
|
+
_debugOAuthLogs;
|
|
281
|
+
_cimdLoader;
|
|
282
|
+
_registeredClients = /* @__PURE__ */ new Map();
|
|
283
|
+
// HMAC secret used to sign DCR-issued client_ids so we can recover them
|
|
284
|
+
// across container restarts without persistent storage. See verifyAndRecover.
|
|
285
|
+
_clientIdSecret;
|
|
286
|
+
// internalState → client redirect, original client state, and resource indicator
|
|
287
|
+
// We generate our own state to track the pending flow regardless of whether the client sent one.
|
|
288
|
+
_pendingStates = /* @__PURE__ */ new Map();
|
|
289
|
+
constructor(options) {
|
|
290
|
+
const authUrl = options.authUrl ?? "https://web.plaud.ai/platform/oauth";
|
|
291
|
+
const tokenUrl = options.tokenUrl ?? DEFAULT_TOKEN_URL;
|
|
292
|
+
const refreshUrl = options.refreshUrl ?? DEFAULT_REFRESH_URL;
|
|
293
|
+
const apiBase = options.apiBase ?? "https://platform.plaud.ai/developer/api";
|
|
294
|
+
super({
|
|
295
|
+
endpoints: {
|
|
296
|
+
authorizationUrl: authUrl,
|
|
297
|
+
tokenUrl
|
|
298
|
+
},
|
|
299
|
+
verifyAccessToken: async (token) => {
|
|
300
|
+
const client = new PlaudClient({
|
|
301
|
+
clientId: options.clientId,
|
|
302
|
+
clientSecret: "",
|
|
303
|
+
redirectUri: "",
|
|
304
|
+
apiBase,
|
|
305
|
+
staticToken: token
|
|
306
|
+
});
|
|
307
|
+
try {
|
|
308
|
+
const user = await client.getCurrentUser();
|
|
309
|
+
let expiresAt;
|
|
310
|
+
try {
|
|
311
|
+
const payload = JSON.parse(
|
|
312
|
+
Buffer.from(token.split(".")[1], "base64url").toString()
|
|
313
|
+
);
|
|
314
|
+
expiresAt = typeof payload.exp === "number" ? payload.exp : Math.floor(Date.now() / 1e3) + 3600;
|
|
315
|
+
} catch {
|
|
316
|
+
expiresAt = Math.floor(Date.now() / 1e3) + 3600;
|
|
317
|
+
}
|
|
318
|
+
const authInfo = {
|
|
319
|
+
token,
|
|
320
|
+
clientId: String(user.id ?? "unknown"),
|
|
321
|
+
scopes: [],
|
|
322
|
+
expiresAt
|
|
323
|
+
};
|
|
324
|
+
logger.info({ event: "token_verified", client_id: authInfo.clientId, expires_at: expiresAt });
|
|
325
|
+
return authInfo;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
logger.warn({ event: "token_verify_failed", error: String(err) });
|
|
328
|
+
throw new InvalidTokenError("Invalid or expired token");
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
getClient: async (id) => this._registeredClients.get(id)
|
|
332
|
+
});
|
|
333
|
+
this._plaudClientId = options.clientId;
|
|
334
|
+
this._plaudClientSecret = options.clientSecret ?? "";
|
|
335
|
+
this._plaudTokenUrl = tokenUrl;
|
|
336
|
+
this._plaudRefreshUrl = refreshUrl;
|
|
337
|
+
this._plaudApiBase = apiBase;
|
|
338
|
+
this._callbackUrl = options.callbackUrl;
|
|
339
|
+
this._debugOAuthLogs = options.debugOAuthLogs ?? false;
|
|
340
|
+
this._cimdLoader = options.cimdLoader;
|
|
341
|
+
const secret = process.env["PLAUD_DCR_HMAC_SECRET"];
|
|
342
|
+
if (secret && secret.length >= 32) {
|
|
343
|
+
this._clientIdSecret = Buffer.from(secret, "utf-8");
|
|
344
|
+
} else {
|
|
345
|
+
this._clientIdSecret = randomBytes(32);
|
|
346
|
+
logger.warn({
|
|
347
|
+
event: "oauth_dcr_secret_missing",
|
|
348
|
+
message: "PLAUD_DCR_HMAC_SECRET unset or too short (<32 chars); using ephemeral per-process secret. Cached client_ids will not survive restarts."
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
this.skipLocalPkceValidation = true;
|
|
352
|
+
}
|
|
353
|
+
// Override clientsStore. registerClient signs the issued client_id with HMAC
|
|
354
|
+
// so verifyAndRecover can later resurrect it across container restarts.
|
|
355
|
+
get clientsStore() {
|
|
356
|
+
return {
|
|
357
|
+
getClient: async (id) => {
|
|
358
|
+
if (this._cimdLoader && isValidCimdUrl(id)) {
|
|
359
|
+
return await this._cimdLoader.load(id);
|
|
360
|
+
}
|
|
361
|
+
return this._registeredClients.get(id);
|
|
362
|
+
},
|
|
363
|
+
registerClient: async (client) => {
|
|
364
|
+
const rawId = randomBytes(16).toString("base64url");
|
|
365
|
+
const tokenEndpointAuthMethod = client.token_endpoint_auth_method ?? "none";
|
|
366
|
+
const full = {
|
|
367
|
+
...client,
|
|
368
|
+
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
|
369
|
+
client_secret: tokenEndpointAuthMethod === "none" ? void 0 : client.client_secret,
|
|
370
|
+
client_secret_expires_at: tokenEndpointAuthMethod === "none" ? void 0 : client.client_secret_expires_at,
|
|
371
|
+
client_id: this.signClientId(rawId),
|
|
372
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3)
|
|
373
|
+
};
|
|
374
|
+
this._registeredClients.set(full.client_id, full);
|
|
375
|
+
if (this._debugOAuthLogs) {
|
|
376
|
+
logger.info({
|
|
377
|
+
event: "oauth_client_registered",
|
|
378
|
+
client_id_len: full.client_id.length,
|
|
379
|
+
client_name: client.client_name ?? null,
|
|
380
|
+
client_uri: client.client_uri ?? null,
|
|
381
|
+
logo_uri: client.logo_uri ?? null,
|
|
382
|
+
redirect_uri_count: full.redirect_uris.length,
|
|
383
|
+
redirect_uri_hosts: [...new Set(full.redirect_uris.map((uri) => urlParts(uri).host).filter(Boolean))],
|
|
384
|
+
redirect_uri_paths: full.redirect_uris.map((uri) => urlParts(uri).path).filter(Boolean),
|
|
385
|
+
token_endpoint_auth_method: full.token_endpoint_auth_method ?? null,
|
|
386
|
+
grant_types: full.grant_types ?? null,
|
|
387
|
+
response_types: full.response_types ?? null,
|
|
388
|
+
has_client_secret: !!full.client_secret,
|
|
389
|
+
client_secret_expires_at: full.client_secret_expires_at ?? null,
|
|
390
|
+
registered_clients_count: this._registeredClients.size
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return full;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// Sign a raw id so the resulting client_id is `<rawId>.<base64url(hmac)>`.
|
|
398
|
+
// Truncated to 16 bytes (128 bits) — plenty for non-cryptographic forgery
|
|
399
|
+
// resistance, keeps the client_id short.
|
|
400
|
+
signClientId(rawId) {
|
|
401
|
+
const sig = createHmac("sha256", this._clientIdSecret).update(rawId).digest().subarray(0, 16);
|
|
402
|
+
return `${rawId}.${sig.toString("base64url")}`;
|
|
403
|
+
}
|
|
404
|
+
// Verify a previously-issued client_id. Returns true only for ids whose HMAC
|
|
405
|
+
// signature matches our secret — i.e. ids this server (or its predecessor with
|
|
406
|
+
// the same PLAUD_DCR_HMAC_SECRET) issued via registerClient.
|
|
407
|
+
verifyClientIdSignature(signedId) {
|
|
408
|
+
const idx = signedId.lastIndexOf(".");
|
|
409
|
+
if (idx <= 0 || idx >= signedId.length - 1) return false;
|
|
410
|
+
const rawId = signedId.slice(0, idx);
|
|
411
|
+
const sigPart = signedId.slice(idx + 1);
|
|
412
|
+
const expected = createHmac("sha256", this._clientIdSecret).update(rawId).digest().subarray(0, 16);
|
|
413
|
+
let actual;
|
|
414
|
+
try {
|
|
415
|
+
actual = Buffer.from(sigPart, "base64url");
|
|
416
|
+
} catch {
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
if (actual.length !== expected.length) return false;
|
|
420
|
+
return timingSafeEqual(actual, expected);
|
|
421
|
+
}
|
|
422
|
+
authorizationCodeKey() {
|
|
423
|
+
return createHmac("sha256", this._clientIdSecret).update("plaud-mcp-authorization-code").digest();
|
|
424
|
+
}
|
|
425
|
+
encodeAuthorizationCode(payload) {
|
|
426
|
+
const iv = randomBytes(12);
|
|
427
|
+
const cipher = createCipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
|
|
428
|
+
const plaintext = Buffer.from(JSON.stringify({
|
|
429
|
+
upstreamCode: payload.upstreamCode,
|
|
430
|
+
upstreamState: payload.upstreamState,
|
|
431
|
+
resource: payload.resource,
|
|
432
|
+
expiresAt: Date.now() + AUTHORIZATION_CODE_TTL_MS
|
|
433
|
+
}), "utf8");
|
|
434
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
435
|
+
const tag = cipher.getAuthTag();
|
|
436
|
+
return `${STATELESS_CODE_PREFIX}${Buffer.concat([iv, tag, ciphertext]).toString("base64url")}`;
|
|
437
|
+
}
|
|
438
|
+
decodeAuthorizationCode(code) {
|
|
439
|
+
if (!code.startsWith(STATELESS_CODE_PREFIX)) return null;
|
|
440
|
+
try {
|
|
441
|
+
const encoded = code.slice(STATELESS_CODE_PREFIX.length);
|
|
442
|
+
const data = Buffer.from(encoded, "base64url");
|
|
443
|
+
if (data.length <= 28) return null;
|
|
444
|
+
const iv = data.subarray(0, 12);
|
|
445
|
+
const tag = data.subarray(12, 28);
|
|
446
|
+
const ciphertext = data.subarray(28);
|
|
447
|
+
const decipher = createDecipheriv("aes-256-gcm", this.authorizationCodeKey(), iv);
|
|
448
|
+
decipher.setAuthTag(tag);
|
|
449
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
450
|
+
const payload = JSON.parse(plaintext);
|
|
451
|
+
if (typeof payload.upstreamCode !== "string" || typeof payload.upstreamState !== "string" || payload.resource !== void 0 && typeof payload.resource !== "string" || typeof payload.expiresAt !== "number") {
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
if (Date.now() > payload.expiresAt) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
upstreamCode: payload.upstreamCode,
|
|
459
|
+
upstreamState: payload.upstreamState,
|
|
460
|
+
resource: payload.resource
|
|
461
|
+
};
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Recover a client_id that the registry doesn't know about. Directory clients
|
|
467
|
+
// (OpenAI Apps, Claude directory) cache the client_id they got from /register;
|
|
468
|
+
// when this stateless container restarts, the in-memory map is empty and the
|
|
469
|
+
// SDK auth handlers reject the cached client_id with InvalidClientError.
|
|
470
|
+
// We synthesize an entry on demand — but only for client_ids whose HMAC
|
|
471
|
+
// signature verifies against our secret, so this server can't be coaxed into
|
|
472
|
+
// accepting forged ids. Real auth still happens upstream at Plaud.
|
|
473
|
+
// Returns true if the caller should proceed (client is now in the registry).
|
|
474
|
+
verifyAndRecover(clientId, redirectUri) {
|
|
475
|
+
const existing = this._registeredClients.get(clientId);
|
|
476
|
+
if (existing) {
|
|
477
|
+
if (redirectUri && !existing.redirect_uris.includes(redirectUri)) {
|
|
478
|
+
existing.redirect_uris = [...existing.redirect_uris, redirectUri];
|
|
479
|
+
}
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
if (!this.verifyClientIdSignature(clientId)) {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
this._registeredClients.set(clientId, {
|
|
486
|
+
client_id: clientId,
|
|
487
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3),
|
|
488
|
+
redirect_uris: redirectUri ? [redirectUri] : [],
|
|
489
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
490
|
+
response_types: ["code"],
|
|
491
|
+
token_endpoint_auth_method: "none",
|
|
492
|
+
client_name: "auto-recovered client"
|
|
493
|
+
});
|
|
494
|
+
logger.info({ event: "oauth_client_recovered", client_id: clientId, redirect_uri: redirectUri ?? null });
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Redirect to Plaud using our own registered callback URL.
|
|
499
|
+
* Store the client's original redirect_uri keyed by state so we can forward after Plaud calls back.
|
|
500
|
+
*/
|
|
501
|
+
async authorize(_client, params, res) {
|
|
502
|
+
const internalState = randomUUID();
|
|
503
|
+
this._pendingStates.set(internalState, {
|
|
504
|
+
clientRedirectUri: params.redirectUri,
|
|
505
|
+
originalState: params.state,
|
|
506
|
+
resource: params.resource?.href
|
|
507
|
+
});
|
|
508
|
+
const redirectParts = urlParts(params.redirectUri);
|
|
509
|
+
logger.info({
|
|
510
|
+
event: "oauth_authorize_start",
|
|
511
|
+
internal_state: internalState,
|
|
512
|
+
redirect_uri: params.redirectUri,
|
|
513
|
+
...this._debugOAuthLogs ? {
|
|
514
|
+
redirect_uri_host: redirectParts.host,
|
|
515
|
+
redirect_uri_path: redirectParts.path,
|
|
516
|
+
has_original_state: !!params.state,
|
|
517
|
+
has_resource: !!params.resource,
|
|
518
|
+
resource: params.resource?.href ?? null,
|
|
519
|
+
pending_states_count: this._pendingStates.size
|
|
520
|
+
} : {}
|
|
521
|
+
});
|
|
522
|
+
const targetUrl = new URL(this._endpoints.authorizationUrl);
|
|
523
|
+
const searchParams = new URLSearchParams({
|
|
524
|
+
client_id: this._plaudClientId,
|
|
525
|
+
response_type: "code",
|
|
526
|
+
redirect_uri: this._callbackUrl,
|
|
527
|
+
code_challenge: params.codeChallenge,
|
|
528
|
+
code_challenge_method: "S256",
|
|
529
|
+
state: internalState
|
|
530
|
+
// always send our internal state to Plaud
|
|
531
|
+
});
|
|
532
|
+
if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" "));
|
|
533
|
+
if (_client.client_name) searchParams.set("mcp_display_name", _client.client_name);
|
|
534
|
+
if (_client.redirect_uris?.[0]) searchParams.set("mcp_redirect_uri", _client.redirect_uris[0]);
|
|
535
|
+
if (_client.logo_uri) searchParams.set("mcp_logo_uri", _client.logo_uri);
|
|
536
|
+
if (_client.client_uri) searchParams.set("mcp_client_uri", _client.client_uri);
|
|
537
|
+
targetUrl.search = searchParams.toString();
|
|
538
|
+
logger.info({ event: "oauth_authorize_redirect", url: targetUrl.toString(), scopes: params.scopes ?? [] });
|
|
539
|
+
res.redirect(targetUrl.toString());
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Called when Plaud redirects to our /oauth/callback.
|
|
543
|
+
* Looks up the original client redirect_uri and forwards the code+state to it.
|
|
544
|
+
*/
|
|
545
|
+
handleCallback(code, state, res) {
|
|
546
|
+
const pending = this._pendingStates.get(state);
|
|
547
|
+
if (!pending) {
|
|
548
|
+
logger.warn({ event: "oauth_callback_unknown_state", state });
|
|
549
|
+
res.status(400).send("Unknown state \u2014 authorization request not found");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
this._pendingStates.delete(state);
|
|
553
|
+
logger.info({ event: "oauth_callback_received", internal_state: state });
|
|
554
|
+
const pendingCode = {
|
|
555
|
+
upstreamCode: code,
|
|
556
|
+
upstreamState: state,
|
|
557
|
+
resource: pending.resource
|
|
558
|
+
};
|
|
559
|
+
const clientCode = this.encodeAuthorizationCode(pendingCode);
|
|
560
|
+
const target = new URL(pending.clientRedirectUri);
|
|
561
|
+
target.searchParams.set("code", clientCode);
|
|
562
|
+
if (pending.originalState) {
|
|
563
|
+
target.searchParams.set("state", pending.originalState);
|
|
564
|
+
}
|
|
565
|
+
const redirectParts = urlParts(pending.clientRedirectUri);
|
|
566
|
+
logger.info({
|
|
567
|
+
event: "oauth_callback_redirect",
|
|
568
|
+
internal_state: state,
|
|
569
|
+
redirect_uri: pending.clientRedirectUri,
|
|
570
|
+
has_original_state: !!pending.originalState,
|
|
571
|
+
original_state_len: pending.originalState?.length ?? 0,
|
|
572
|
+
upstream_code_len: code.length,
|
|
573
|
+
client_code_len: clientCode.length,
|
|
574
|
+
stateless_code: true,
|
|
575
|
+
redirect_url_len: target.toString().length,
|
|
576
|
+
...this._debugOAuthLogs ? {
|
|
577
|
+
redirect_uri_host: redirectParts.host,
|
|
578
|
+
redirect_uri_path: redirectParts.path,
|
|
579
|
+
redirect_has_existing_query: pending.clientRedirectUri.includes("?"),
|
|
580
|
+
redirect_has_code: target.searchParams.has("code"),
|
|
581
|
+
redirect_has_state: target.searchParams.has("state")
|
|
582
|
+
} : {}
|
|
583
|
+
});
|
|
584
|
+
res.redirect(target.toString());
|
|
585
|
+
}
|
|
586
|
+
// Override to use PKCE public-client flow — no Basic auth, client_id sent in body.
|
|
587
|
+
async exchangeAuthorizationCode(_client, authorizationCode, codeVerifier, _redirectUri, resource) {
|
|
588
|
+
const pendingCode = this.decodeAuthorizationCode(authorizationCode);
|
|
589
|
+
if (!pendingCode) {
|
|
590
|
+
logger.warn({ event: "oauth_authorization_code_unknown", code_len: authorizationCode.length });
|
|
591
|
+
throw new InvalidGrantError("Unknown or expired authorization code");
|
|
592
|
+
}
|
|
593
|
+
if (pendingCode.resource && resource && resource.href !== pendingCode.resource) {
|
|
594
|
+
logger.warn({
|
|
595
|
+
event: "oauth_resource_mismatch",
|
|
596
|
+
expected_resource: pendingCode.resource,
|
|
597
|
+
received_resource: resource.href
|
|
598
|
+
});
|
|
599
|
+
throw new InvalidGrantError("Mismatched resource");
|
|
600
|
+
}
|
|
601
|
+
const body = {
|
|
602
|
+
grant_type: "authorization_code",
|
|
603
|
+
client_id: this._plaudClientId,
|
|
604
|
+
code: pendingCode.upstreamCode,
|
|
605
|
+
redirect_uri: this._callbackUrl
|
|
606
|
+
};
|
|
607
|
+
body.state = pendingCode.upstreamState;
|
|
608
|
+
if (codeVerifier) body.code_verifier = codeVerifier;
|
|
609
|
+
const basicAuth = Buffer.from(`${this._plaudClientId}:${this._plaudClientSecret}`).toString("base64");
|
|
610
|
+
logger.info({
|
|
611
|
+
event: "oauth_token_exchange_attempt",
|
|
612
|
+
token_url: this._plaudTokenUrl,
|
|
613
|
+
client_id: this._plaudClientId,
|
|
614
|
+
has_secret: !!this._plaudClientSecret,
|
|
615
|
+
has_code_verifier: !!body.code_verifier,
|
|
616
|
+
redirect_uri: body.redirect_uri
|
|
617
|
+
});
|
|
618
|
+
const fetchRes = await fetch(this._plaudTokenUrl, {
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: {
|
|
621
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
622
|
+
Accept: "application/json",
|
|
623
|
+
Authorization: `Basic ${basicAuth}`
|
|
624
|
+
},
|
|
625
|
+
body: new URLSearchParams(body)
|
|
626
|
+
});
|
|
627
|
+
if (!fetchRes.ok) {
|
|
628
|
+
let text;
|
|
629
|
+
try {
|
|
630
|
+
text = await fetchRes.text();
|
|
631
|
+
} catch {
|
|
632
|
+
text = "<unreadable>";
|
|
633
|
+
}
|
|
634
|
+
logger.error({ event: "oauth_token_exchange_failed", status: fetchRes.status, body: text.slice(0, 500) });
|
|
635
|
+
if (fetchRes.status >= 500) {
|
|
636
|
+
throw new McpServerError(`Upstream token endpoint error: ${fetchRes.status} ${text.slice(0, 200)}`);
|
|
637
|
+
}
|
|
638
|
+
throw new InvalidGrantError(`Plaud token exchange: ${fetchRes.status} ${text.slice(0, 200)}`);
|
|
639
|
+
}
|
|
640
|
+
let data;
|
|
641
|
+
try {
|
|
642
|
+
data = await fetchRes.json();
|
|
643
|
+
} catch (err) {
|
|
644
|
+
logger.error({ event: "oauth_token_json_parse_failed", error: String(err) });
|
|
645
|
+
throw new McpServerError("Token endpoint returned non-JSON response");
|
|
646
|
+
}
|
|
647
|
+
logger.info({ event: "oauth_token_exchange_ok", has_refresh_token: !!data.refresh_token });
|
|
648
|
+
return {
|
|
649
|
+
access_token: data.access_token,
|
|
650
|
+
token_type: data.token_type ?? "Bearer",
|
|
651
|
+
refresh_token: data.refresh_token,
|
|
652
|
+
expires_in: data.expires_in
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// Override refresh too: the SDK proxy implementation would forward the local
|
|
656
|
+
// DCR client_id and RFC 8707 resource to Plaud, but Plaud uses its dedicated
|
|
657
|
+
// refresh endpoint and does not support resource indicators.
|
|
658
|
+
async exchangeRefreshToken(_client, refreshToken, scopes, resource) {
|
|
659
|
+
logger.info({
|
|
660
|
+
event: "oauth_token_refresh_attempt",
|
|
661
|
+
token_url: this._plaudRefreshUrl,
|
|
662
|
+
has_scope: !!scopes?.length,
|
|
663
|
+
has_resource: !!resource,
|
|
664
|
+
...this._debugOAuthLogs ? { resource: resource?.href ?? null } : {}
|
|
665
|
+
});
|
|
666
|
+
const fetchRes = await fetch(this._plaudRefreshUrl, {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: {
|
|
669
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
670
|
+
Accept: "application/json"
|
|
671
|
+
},
|
|
672
|
+
body: new URLSearchParams({ refresh_token: refreshToken })
|
|
673
|
+
});
|
|
674
|
+
if (!fetchRes.ok) {
|
|
675
|
+
let text;
|
|
676
|
+
try {
|
|
677
|
+
text = await fetchRes.text();
|
|
678
|
+
} catch {
|
|
679
|
+
text = "<unreadable>";
|
|
680
|
+
}
|
|
681
|
+
logger.error({ event: "oauth_token_refresh_failed", status: fetchRes.status, body: text.slice(0, 500) });
|
|
682
|
+
if (fetchRes.status >= 500) {
|
|
683
|
+
throw new McpServerError(`Upstream refresh endpoint error: ${fetchRes.status} ${text.slice(0, 200)}`);
|
|
684
|
+
}
|
|
685
|
+
throw new InvalidGrantError(`Plaud token refresh: ${fetchRes.status} ${text.slice(0, 200)}`);
|
|
686
|
+
}
|
|
687
|
+
let data;
|
|
688
|
+
try {
|
|
689
|
+
data = await fetchRes.json();
|
|
690
|
+
} catch (err) {
|
|
691
|
+
logger.error({ event: "oauth_refresh_json_parse_failed", error: String(err) });
|
|
692
|
+
throw new McpServerError("Refresh endpoint returned non-JSON response");
|
|
693
|
+
}
|
|
694
|
+
logger.info({
|
|
695
|
+
event: "oauth_token_refresh_ok",
|
|
696
|
+
has_refresh_token: !!data.refresh_token,
|
|
697
|
+
ignored_scope: !!scopes?.length,
|
|
698
|
+
ignored_resource: !!resource
|
|
699
|
+
});
|
|
700
|
+
return {
|
|
701
|
+
access_token: data.access_token,
|
|
702
|
+
token_type: data.token_type ?? "Bearer",
|
|
703
|
+
refresh_token: data.refresh_token ?? refreshToken,
|
|
704
|
+
expires_in: data.expires_in
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// src/http/server.ts
|
|
710
|
+
var HTTP_PORT = Number(process.env.PLAUD_HTTP_PORT ?? 3e3);
|
|
711
|
+
var HTTP_HOST = process.env.PLAUD_HTTP_HOST ?? "0.0.0.0";
|
|
712
|
+
var CALLBACK_PORT = 8199;
|
|
713
|
+
var CALLBACK_PATH = "/auth/callback";
|
|
714
|
+
var CALLBACK_URL = process.env.PLAUD_CALLBACK_URL ?? `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
715
|
+
var DEFAULT_ALLOWED_ORIGINS = [
|
|
716
|
+
"https://claude.ai",
|
|
717
|
+
"https://claude.com",
|
|
718
|
+
"https://chatgpt.com",
|
|
719
|
+
"https://chat.openai.com",
|
|
720
|
+
"https://platform.openai.com"
|
|
721
|
+
];
|
|
722
|
+
var ALLOWED_ORIGINS = (process.env.PLAUD_ALLOWED_ORIGINS ?? DEFAULT_ALLOWED_ORIGINS.join(",")).split(",").map((s) => s.trim()).filter(Boolean);
|
|
723
|
+
var OPENAI_APPS_CHALLENGE_TOKEN = "bBlB7N7fF7YaQnONtEQNAGP7SLaqpJT3a3DS5Zv1F8s";
|
|
724
|
+
var OAUTH_DEBUG_LOGS = !["0", "false", "no", "off"].includes(
|
|
725
|
+
(process.env.PLAUD_OAUTH_DEBUG_LOGS ?? "").toLowerCase()
|
|
726
|
+
);
|
|
727
|
+
var CIMD_ENABLED = ["1", "true", "yes", "on"].includes(
|
|
728
|
+
(process.env.PLAUD_CIMD_ENABLED ?? "").toLowerCase()
|
|
729
|
+
);
|
|
730
|
+
var CIMD_ALLOW_PRIVATE_IP = ["1", "true", "yes", "on"].includes(
|
|
731
|
+
(process.env.PLAUD_CIMD_ALLOW_PRIVATE_IP ?? "").toLowerCase()
|
|
732
|
+
);
|
|
733
|
+
function queryValue(value) {
|
|
734
|
+
return typeof value === "string" ? value : void 0;
|
|
735
|
+
}
|
|
736
|
+
function arrayValue(value) {
|
|
737
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
|
|
738
|
+
}
|
|
739
|
+
function urlParts2(value) {
|
|
740
|
+
if (!value || !URL.canParse(value)) {
|
|
741
|
+
return { host: null, path: null };
|
|
742
|
+
}
|
|
743
|
+
const url = new URL(value);
|
|
744
|
+
return { host: url.host, path: url.pathname };
|
|
745
|
+
}
|
|
746
|
+
function basicAuthCredentials(value) {
|
|
747
|
+
if (typeof value !== "string" || !value.startsWith("Basic ")) {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const decoded = Buffer.from(value.slice("Basic ".length), "base64").toString("utf8");
|
|
752
|
+
const separator = decoded.indexOf(":");
|
|
753
|
+
if (separator < 0) return null;
|
|
754
|
+
return {
|
|
755
|
+
clientId: decoded.slice(0, separator),
|
|
756
|
+
clientSecret: decoded.slice(separator + 1) || void 0
|
|
757
|
+
};
|
|
758
|
+
} catch {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function isDiagnosticPath(path) {
|
|
763
|
+
return path === "/mcp" || path === "/register" || path === "/authorize" || path === "/token" || path.startsWith("/.well-known/");
|
|
764
|
+
}
|
|
765
|
+
function startHttpServer() {
|
|
766
|
+
const clientId = process.env.PLAUD_MCP_CLIENT_ID ?? "client_37d250cb-50f8-4af1-8cd6-bc6711c5d684";
|
|
767
|
+
const clientSecret = process.env.PLAUD_MCP_CLIENT_SECRET;
|
|
768
|
+
const apiBase = process.env.PLAUD_API_BASE;
|
|
769
|
+
const serverUrl = process.env.PLAUD_SERVER_URL ?? `http://localhost:${HTTP_PORT}`;
|
|
770
|
+
const cimdLoader = CIMD_ENABLED ? new CimdLoader({ allowPrivateIp: CIMD_ALLOW_PRIVATE_IP }) : void 0;
|
|
771
|
+
if (CIMD_ENABLED) {
|
|
772
|
+
logger.info({
|
|
773
|
+
event: "cimd_enabled",
|
|
774
|
+
allow_private_ip: CIMD_ALLOW_PRIVATE_IP
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const provider = new PlaudOAuthProvider({
|
|
778
|
+
clientId,
|
|
779
|
+
clientSecret,
|
|
780
|
+
callbackUrl: CALLBACK_URL,
|
|
781
|
+
authUrl: process.env.PLAUD_AUTH_URL,
|
|
782
|
+
tokenUrl: process.env.PLAUD_TOKEN_URL,
|
|
783
|
+
refreshUrl: process.env.PLAUD_REFRESH_URL,
|
|
784
|
+
apiBase,
|
|
785
|
+
debugOAuthLogs: OAUTH_DEBUG_LOGS,
|
|
786
|
+
cimdLoader
|
|
787
|
+
});
|
|
788
|
+
const issuerUrl = new URL(serverUrl);
|
|
789
|
+
const trimmedServerUrl = serverUrl.replace(/\/$/, "");
|
|
790
|
+
const mcpResourceUrl = `${trimmedServerUrl}/mcp`;
|
|
791
|
+
const protectedResourceMetadataUrl = `${trimmedServerUrl}/.well-known/oauth-protected-resource/mcp`;
|
|
792
|
+
const protectedResourceMetadata = {
|
|
793
|
+
resource: mcpResourceUrl,
|
|
794
|
+
authorization_servers: [issuerUrl.href],
|
|
795
|
+
resource_name: "Plaud MCP Server",
|
|
796
|
+
bearer_methods_supported: ["header"]
|
|
797
|
+
};
|
|
798
|
+
const oauthMetadata = {
|
|
799
|
+
...createOAuthMetadata({ provider, issuerUrl, baseUrl: issuerUrl }),
|
|
800
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
801
|
+
// Only advertise CIMD support when actually enabled. Advertising while the
|
|
802
|
+
// backing fetch path is disabled would mislead clients into using CIMD and
|
|
803
|
+
// failing.
|
|
804
|
+
...CIMD_ENABLED ? { client_id_metadata_document_supported: true } : {}
|
|
805
|
+
};
|
|
806
|
+
const app = createMcpExpressApp({ host: HTTP_HOST });
|
|
807
|
+
app.set("trust proxy", 1);
|
|
808
|
+
app.get("/.well-known/openai-apps-challenge", (_req, res) => {
|
|
809
|
+
res.type("text/plain").send(OPENAI_APPS_CHALLENGE_TOKEN);
|
|
810
|
+
});
|
|
811
|
+
app.get("/.well-known/oauth-protected-resource", (_req, res) => {
|
|
812
|
+
res.json(protectedResourceMetadata);
|
|
813
|
+
});
|
|
814
|
+
app.get("/.well-known/oauth-protected-resource/mcp", (_req, res) => {
|
|
815
|
+
res.json(protectedResourceMetadata);
|
|
816
|
+
});
|
|
817
|
+
app.get("/.well-known/oauth-authorization-server", (_req, res) => {
|
|
818
|
+
res.json(oauthMetadata);
|
|
819
|
+
});
|
|
820
|
+
app.get("/.well-known/openid-configuration", (_req, res) => {
|
|
821
|
+
res.json(oauthMetadata);
|
|
822
|
+
});
|
|
823
|
+
app.get("/health", (_req, res) => {
|
|
824
|
+
res.json({
|
|
825
|
+
status: "ok",
|
|
826
|
+
uptime_s: Math.floor(process.uptime()),
|
|
827
|
+
config: {
|
|
828
|
+
has_client_id: !!process.env.PLAUD_MCP_CLIENT_ID,
|
|
829
|
+
has_client_secret: !!process.env.PLAUD_MCP_CLIENT_SECRET,
|
|
830
|
+
callback_url: process.env.PLAUD_CALLBACK_URL ?? "(default:localhost)",
|
|
831
|
+
server_url: process.env.PLAUD_SERVER_URL ?? "(default:localhost)",
|
|
832
|
+
oauth_debug_logs: OAUTH_DEBUG_LOGS,
|
|
833
|
+
cimd_enabled: CIMD_ENABLED
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
if (process.env.PLAUD_CALLBACK_URL) {
|
|
838
|
+
app.get(CALLBACK_PATH, (req, res) => {
|
|
839
|
+
const code = req.query["code"];
|
|
840
|
+
const state = req.query["state"];
|
|
841
|
+
if (!code || !state) {
|
|
842
|
+
logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
|
|
843
|
+
res.status(400).send("Missing code or state");
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
provider.handleCallback(code, state, res);
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
app.use((req, res, next) => {
|
|
850
|
+
const reqId = req.headers["x-request-id"] ?? randomUUID2();
|
|
851
|
+
const startMs = Date.now();
|
|
852
|
+
res.locals["reqId"] = reqId;
|
|
853
|
+
res.on("finish", () => {
|
|
854
|
+
if (OAUTH_DEBUG_LOGS && isDiagnosticPath(req.path)) {
|
|
855
|
+
logger.info({
|
|
856
|
+
event: "http_response",
|
|
857
|
+
req_id: reqId,
|
|
858
|
+
method: req.method,
|
|
859
|
+
path: req.path,
|
|
860
|
+
status_code: res.statusCode,
|
|
861
|
+
duration_ms: Date.now() - startMs
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
logger.info({ event: "http_request", req_id: reqId, method: req.method, path: req.url, user_agent: req.headers["user-agent"] ?? null });
|
|
866
|
+
next();
|
|
867
|
+
});
|
|
868
|
+
app.use("/mcp", (req, res, next) => {
|
|
869
|
+
const origin = req.headers["origin"];
|
|
870
|
+
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
|
|
871
|
+
logger.warn({ event: "origin_rejected", origin, req_id: res.locals["reqId"] });
|
|
872
|
+
res.status(403).json({ error: "forbidden_origin", origin });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
next();
|
|
876
|
+
});
|
|
877
|
+
app.post("/token", express.urlencoded({ extended: false }), (req, res, next) => {
|
|
878
|
+
const basicAuth = basicAuthCredentials(req.headers.authorization);
|
|
879
|
+
if (basicAuth) {
|
|
880
|
+
req.body ??= {};
|
|
881
|
+
req.body.client_id ??= basicAuth.clientId;
|
|
882
|
+
req.body.client_secret ??= basicAuth.clientSecret;
|
|
883
|
+
}
|
|
884
|
+
const resource = queryValue(req.body?.resource);
|
|
885
|
+
const resourceParts = urlParts2(resource);
|
|
886
|
+
if (OAUTH_DEBUG_LOGS) {
|
|
887
|
+
logger.info({
|
|
888
|
+
event: "oauth_token_request_received",
|
|
889
|
+
req_id: res.locals["reqId"],
|
|
890
|
+
grant_type: req.body?.grant_type ?? null,
|
|
891
|
+
has_code: !!req.body?.code,
|
|
892
|
+
has_resource: !!req.body?.resource,
|
|
893
|
+
resource_host: resourceParts.host,
|
|
894
|
+
resource_path: resourceParts.path,
|
|
895
|
+
has_client_id: !!req.body?.client_id,
|
|
896
|
+
has_client_secret: !!req.body?.client_secret,
|
|
897
|
+
has_code_verifier: !!req.body?.code_verifier,
|
|
898
|
+
has_redirect_uri: !!req.body?.redirect_uri
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
if (req.body?.resource && !URL.canParse(req.body.resource)) {
|
|
902
|
+
delete req.body.resource;
|
|
903
|
+
}
|
|
904
|
+
const clientId2 = queryValue(req.body?.client_id);
|
|
905
|
+
const redirectUri = queryValue(req.body?.redirect_uri);
|
|
906
|
+
if (clientId2) {
|
|
907
|
+
provider.verifyAndRecover(clientId2, redirectUri && URL.canParse(redirectUri) ? redirectUri : void 0);
|
|
908
|
+
}
|
|
909
|
+
next();
|
|
910
|
+
});
|
|
911
|
+
if (OAUTH_DEBUG_LOGS) {
|
|
912
|
+
app.post("/register", express.json(), (req, res, next) => {
|
|
913
|
+
const redirectUris = arrayValue(req.body?.redirect_uris) ?? [];
|
|
914
|
+
logger.info({
|
|
915
|
+
event: "oauth_register_request_received",
|
|
916
|
+
req_id: res.locals["reqId"],
|
|
917
|
+
redirect_uri_count: redirectUris.length,
|
|
918
|
+
redirect_uri_hosts: [...new Set(redirectUris.map((uri) => urlParts2(uri).host).filter(Boolean))],
|
|
919
|
+
redirect_uri_paths: redirectUris.map((uri) => urlParts2(uri).path).filter(Boolean),
|
|
920
|
+
token_endpoint_auth_method: queryValue(req.body?.token_endpoint_auth_method) ?? null,
|
|
921
|
+
grant_types: arrayValue(req.body?.grant_types) ?? null,
|
|
922
|
+
response_types: arrayValue(req.body?.response_types) ?? null,
|
|
923
|
+
has_scope: typeof req.body?.scope === "string",
|
|
924
|
+
has_jwks_uri: typeof req.body?.jwks_uri === "string",
|
|
925
|
+
has_jwks: !!req.body?.jwks,
|
|
926
|
+
client_name: queryValue(req.body?.client_name) ?? null
|
|
927
|
+
});
|
|
928
|
+
next();
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
app.use("/authorize", express.urlencoded({ extended: false }), (req, _res, next) => {
|
|
932
|
+
const params = (req.method === "POST" ? req.body : req.query) ?? {};
|
|
933
|
+
const clientId2 = typeof params.client_id === "string" ? params.client_id : void 0;
|
|
934
|
+
const redirectUri = typeof params.redirect_uri === "string" ? params.redirect_uri : void 0;
|
|
935
|
+
const resource = typeof params.resource === "string" ? params.resource : void 0;
|
|
936
|
+
const redirectParts = urlParts2(redirectUri);
|
|
937
|
+
const resourceParts = urlParts2(resource);
|
|
938
|
+
if (OAUTH_DEBUG_LOGS) {
|
|
939
|
+
logger.info({
|
|
940
|
+
event: "oauth_authorize_request_received",
|
|
941
|
+
req_id: _res.locals["reqId"],
|
|
942
|
+
response_type: queryValue(params.response_type) ?? null,
|
|
943
|
+
client_id_len: clientId2?.length ?? 0,
|
|
944
|
+
redirect_uri_host: redirectParts.host,
|
|
945
|
+
redirect_uri_path: redirectParts.path,
|
|
946
|
+
resource_host: resourceParts.host,
|
|
947
|
+
resource_path: resourceParts.path,
|
|
948
|
+
has_state: typeof params.state === "string",
|
|
949
|
+
state_prefix: typeof params.state === "string" ? params.state.slice(0, 28) : null,
|
|
950
|
+
has_code_challenge: typeof params.code_challenge === "string",
|
|
951
|
+
code_challenge_method: queryValue(params.code_challenge_method) ?? null,
|
|
952
|
+
has_scope: typeof params.scope === "string"
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
if (clientId2 && redirectUri && URL.canParse(redirectUri)) {
|
|
956
|
+
provider.verifyAndRecover(clientId2, redirectUri);
|
|
957
|
+
}
|
|
958
|
+
next();
|
|
959
|
+
});
|
|
960
|
+
app.use(
|
|
961
|
+
mcpAuthRouter({
|
|
962
|
+
provider,
|
|
963
|
+
issuerUrl,
|
|
964
|
+
baseUrl: issuerUrl,
|
|
965
|
+
resourceServerUrl: new URL(mcpResourceUrl),
|
|
966
|
+
resourceName: "Plaud MCP Server"
|
|
967
|
+
})
|
|
968
|
+
);
|
|
969
|
+
app.post(
|
|
970
|
+
"/mcp",
|
|
971
|
+
requireBearerAuth({ verifier: provider, resourceMetadataUrl: protectedResourceMetadataUrl }),
|
|
972
|
+
async (req, res) => {
|
|
973
|
+
const token = req.auth.token;
|
|
974
|
+
const reqId = res.locals["reqId"] ?? randomUUID2();
|
|
975
|
+
const reqLog = logger.child({ req_id: reqId });
|
|
976
|
+
const startMs = Date.now();
|
|
977
|
+
reqLog.info({ event: "mcp_request_start", client_id: req.auth.clientId });
|
|
978
|
+
const client = new PlaudClient({
|
|
979
|
+
clientId,
|
|
980
|
+
clientSecret: "",
|
|
981
|
+
redirectUri: "",
|
|
982
|
+
apiBase,
|
|
983
|
+
staticToken: token
|
|
984
|
+
});
|
|
985
|
+
const mcpServer = new McpServer({ name: "plaud", version: "0.2.3" });
|
|
986
|
+
registerTools(mcpServer, client);
|
|
987
|
+
const transport = new StreamableHTTPServerTransport({
|
|
988
|
+
sessionIdGenerator: void 0,
|
|
989
|
+
// stateless
|
|
990
|
+
enableDnsRebindingProtection: true,
|
|
991
|
+
allowedOrigins: ALLOWED_ORIGINS
|
|
992
|
+
});
|
|
993
|
+
try {
|
|
994
|
+
await mcpServer.connect(transport);
|
|
995
|
+
await transport.handleRequest(req, res, req.body);
|
|
996
|
+
const clientVersion = mcpServer.server.getClientVersion();
|
|
997
|
+
if (clientVersion) {
|
|
998
|
+
reqLog.info({ event: "mcp_client_info", client_name: clientVersion.name, client_version: clientVersion.version });
|
|
999
|
+
}
|
|
1000
|
+
reqLog.info({ event: "mcp_request_end", duration_ms: Date.now() - startMs });
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
reqLog.error({ event: "mcp_request_error", error: String(err), duration_ms: Date.now() - startMs });
|
|
1003
|
+
if (!res.headersSent) {
|
|
1004
|
+
res.status(500).json({ error: String(err) });
|
|
1005
|
+
}
|
|
1006
|
+
} finally {
|
|
1007
|
+
await mcpServer.close();
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
);
|
|
1011
|
+
if (!process.env.PLAUD_CALLBACK_URL) {
|
|
1012
|
+
const callbackApp = express();
|
|
1013
|
+
callbackApp.get(CALLBACK_PATH, (req, res) => {
|
|
1014
|
+
const code = req.query["code"];
|
|
1015
|
+
const state = req.query["state"];
|
|
1016
|
+
if (!code || !state) {
|
|
1017
|
+
logger.warn({ event: "oauth_callback_invalid", code: !!code, state: !!state });
|
|
1018
|
+
res.status(400).send("Missing code or state");
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
provider.handleCallback(code, state, res);
|
|
1022
|
+
});
|
|
1023
|
+
createServer(callbackApp).listen(CALLBACK_PORT, "localhost", () => {
|
|
1024
|
+
logger.info({ event: "server_start", role: "oauth_callback", url: CALLBACK_URL });
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
app.listen(HTTP_PORT, HTTP_HOST, () => {
|
|
1028
|
+
logger.info({
|
|
1029
|
+
event: "server_start",
|
|
1030
|
+
role: "mcp_http",
|
|
1031
|
+
url: serverUrl,
|
|
1032
|
+
mcp_endpoint: `${serverUrl}/mcp`,
|
|
1033
|
+
oauth_metadata: `${serverUrl}/.well-known/oauth-authorization-server`
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
export {
|
|
1038
|
+
startHttpServer
|
|
1039
|
+
};
|