@ardennguyen/zalo-agent-cli 2.2.2 → 2.5.1

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.
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Express server wrapping MCP StreamableHTTP transport with bearer token auth.
2
+ * Express server wrapping MCP StreamableHTTP transport with bearer token and OAuth 2.0 auth.
3
3
  * Uses stateless mode — each POST /mcp request creates a fresh server+transport.
4
4
  */
5
5
 
6
6
  import express from "express";
7
- import { timingSafeEqual } from "crypto";
7
+ import https from "https";
8
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
8
9
  import { readFileSync } from "fs";
9
10
  import { fileURLToPath } from "url";
10
11
  import { dirname, join } from "path";
@@ -14,34 +15,525 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8"));
16
17
 
18
+ const ACCESS_TOKEN_TTL_SEC = 30 * 24 * 3600; // 30 days
19
+ const REFRESH_TOKEN_TTL_SEC = 365 * 24 * 3600; // 365 days
20
+ const AUTH_CODE_TTL_SEC = 600; // 10 minutes
21
+ const DEFAULT_SCOPES = ["mcp:tools", "read", "write", "ACCESS_VIEW_MANAGE_MCP_CONTENT"];
22
+
23
+ /**
24
+ * Constant-time string comparison.
25
+ * @param {string} a
26
+ * @param {string} b
27
+ * @returns {boolean}
28
+ */
29
+ function safeEqual(a, b) {
30
+ if (typeof a !== "string" || typeof b !== "string") return false;
31
+ const bufA = Buffer.from(a, "utf8");
32
+ const bufB = Buffer.from(b, "utf8");
33
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
34
+ }
35
+
36
+ /**
37
+ * Sign a compact JSON payload with HMAC-SHA256 (`<prefix><payloadB64Url>.<sigB64Url>`).
38
+ * @param {string} prefix
39
+ * @param {object} payload
40
+ * @param {string} secret
41
+ * @returns {string}
42
+ */
43
+ function signOAuthToken(prefix, payload, secret) {
44
+ const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
45
+ const sig = createHmac("sha256", secret).update(`${prefix}${body}`).digest("base64url");
46
+ return `${prefix}${body}.${sig}`;
47
+ }
48
+
49
+ /**
50
+ * Verify a compact HMAC-SHA256 token and check its `exp` claim (epoch seconds).
51
+ * @param {string} token
52
+ * @param {string} expectedPrefix
53
+ * @param {string} secret
54
+ * @returns {object|null}
55
+ */
56
+ function verifyOAuthToken(token, expectedPrefix, secret) {
57
+ if (typeof token !== "string" || !token.startsWith(expectedPrefix)) return null;
58
+ const rest = token.slice(expectedPrefix.length);
59
+ const dot = rest.indexOf(".");
60
+ if (dot <= 0) return null;
61
+ const body = rest.slice(0, dot);
62
+ const sig = rest.slice(dot + 1);
63
+ const expectedSig = createHmac("sha256", secret).update(`${expectedPrefix}${body}`).digest("base64url");
64
+ if (!safeEqual(sig, expectedSig)) return null;
65
+ try {
66
+ const parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
67
+ const now = Math.floor(Date.now() / 1000);
68
+ if (typeof parsed.exp !== "number" || parsed.exp < now) return null;
69
+ return parsed;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract OAuth client credentials from `Authorization: Basic ...` (`client_secret_basic`)
77
+ * or request body (`client_secret_post`).
78
+ * @param {import("express").Request} req
79
+ * @returns {{ clientId: string, clientSecret: string }}
80
+ */
81
+ function extractClientCredentials(req) {
82
+ const authHeader = req.headers.authorization || "";
83
+ if (/^basic\s+/i.test(authHeader)) {
84
+ try {
85
+ const decoded = Buffer.from(authHeader.slice(6).trim(), "base64").toString("utf8");
86
+ const colon = decoded.indexOf(":");
87
+ if (colon >= 0) {
88
+ const rawId = decoded.slice(0, colon);
89
+ const rawSecret = decoded.slice(colon + 1);
90
+ try {
91
+ return {
92
+ clientId: decodeURIComponent(rawId),
93
+ clientSecret: decodeURIComponent(rawSecret),
94
+ };
95
+ } catch {
96
+ return { clientId: rawId, clientSecret: rawSecret };
97
+ }
98
+ }
99
+ } catch {
100
+ /* fall through to body */
101
+ }
102
+ }
103
+ return {
104
+ clientId: typeof req.body?.client_id === "string" ? req.body.client_id : "",
105
+ clientSecret: typeof req.body?.client_secret === "string" ? req.body.client_secret : "",
106
+ };
107
+ }
108
+
17
109
  /**
18
- * Create HTTP MCP server with optional bearer token auth.
110
+ * Verify PKCE `code_verifier` against stored `code_challenge` and `code_challenge_method`.
111
+ * @param {string} verifier
112
+ * @param {string} challenge
113
+ * @param {string} [method="S256"]
114
+ * @returns {boolean}
115
+ */
116
+ function verifyPkce(verifier, challenge, method = "S256") {
117
+ if (typeof verifier !== "string" || !verifier) return false;
118
+ if (method.toUpperCase() === "PLAIN") {
119
+ return safeEqual(verifier, challenge);
120
+ }
121
+ const computed = createHash("sha256").update(verifier, "ascii").digest("base64url");
122
+ return safeEqual(computed, challenge);
123
+ }
124
+
125
+ /**
126
+ * Resolve the external origin (`https://host:port`) for OAuth metadata & `WWW-Authenticate` headers.
127
+ * @param {import("express").Request} req
128
+ * @param {string|null} configuredIssuer
129
+ * @param {boolean} isTls
130
+ * @returns {string}
131
+ */
132
+ function resolveIssuerOrigin(req, configuredIssuer, isTls) {
133
+ if (configuredIssuer) return configuredIssuer.replace(/\/+$/, "");
134
+ const protoHeader = req.headers["x-forwarded-proto"];
135
+ const proto =
136
+ (typeof protoHeader === "string" && protoHeader.split(",")[0].trim()) ||
137
+ (isTls || req.secure ? "https" : req.protocol || "http");
138
+ const hostHeader = req.headers["x-forwarded-host"] || req.headers.host || "127.0.0.1";
139
+ const host = typeof hostHeader === "string" ? hostHeader.split(",")[0].trim() : "127.0.0.1";
140
+ return `${proto}://${host}`;
141
+ }
142
+
143
+ /**
144
+ * Create HTTP or HTTPS MCP server with optional bearer token and OAuth 2.0 auth.
19
145
  * @param {Function} registerToolsFn - Registers tools on a McpServer instance
20
146
  * @param {object} deps - { api, buffer, filter, config, nameCache, accountDir, stageLock, syncJobs }
21
147
  * @param {number} port
22
- * @param {string|null} authToken - Bearer token (null = no auth)
23
- * @returns {import("http").Server}
148
+ * @param {string|null} authToken - Bearer token (null = no auth unless oauth.clientSecret or oauth.clients is set)
149
+ * @param {string} [host="127.0.0.1"] - Bind address
150
+ * @param {{cert: Buffer|string, key: Buffer|string}|null} [tls=null] - TLS cert and private key for HTTPS
151
+ * @param {{clientId?: string|null, clientSecret?: string|null, issuerUrl?: string|null, clients?: Array<{clientId: string, clientSecret: string}>}|null} [oauth=null]
152
+ * @returns {import("http").Server | import("https").Server}
24
153
  */
25
- export function createHTTPServer(registerToolsFn, deps, port, authToken, host = "127.0.0.1") {
154
+ export function createHTTPServer(registerToolsFn, deps, port, authToken, host = "127.0.0.1", tls = null, oauth = null) {
26
155
  const app = express();
156
+ const isTls = Boolean(tls?.cert && tls?.key);
157
+ const configuredClientId = oauth?.clientId || "zalo-mcp";
158
+ const configuredIssuer = oauth?.issuerUrl || null;
159
+
160
+ // Static OAuth clients map: clientId -> clientSecret (supports primary + oauth.clients[] for multi-consumer CRM/AI)
161
+ const configuredClients = new Map();
162
+ if (oauth?.clientSecret || authToken) {
163
+ configuredClients.set(configuredClientId, oauth?.clientSecret || authToken);
164
+ }
165
+ if (Array.isArray(oauth?.clients)) {
166
+ for (const entry of oauth.clients) {
167
+ const cid = typeof entry?.clientId === "string" ? entry.clientId.trim() : "";
168
+ const csec = typeof entry?.clientSecret === "string" ? entry.clientSecret.trim() : "";
169
+ if (cid && csec) {
170
+ configuredClients.set(cid, csec);
171
+ }
172
+ }
173
+ }
174
+ const signingSecret =
175
+ oauth?.clientSecret ||
176
+ authToken ||
177
+ (configuredClients.size > 0 ? configuredClients.values().next().value : null);
178
+
179
+ // Single-use authorization code replay cache (jti -> expiry epoch seconds)
180
+ const redeemedCodes = new Map();
181
+ // Bounded dynamic client registrations (RFC 7591)
182
+ const dynamicClients = new Map();
183
+
184
+ // CORS headers for browser-based MCP clients and OAuth discovery
185
+ app.use((req, res, next) => {
186
+ const remoteIp = req.socket?.remoteAddress || "";
187
+ const isLoopbackWait =
188
+ req.path === "/events/wait" &&
189
+ (remoteIp === "127.0.0.1" || remoteIp === "::1" || remoteIp === "::ffff:127.0.0.1");
190
+ if (!isLoopbackWait) {
191
+ res.on("finish", () => {
192
+ const ua = String(req.headers["user-agent"] || "-").slice(0, 80);
193
+ const h = String(req.headers.host || "-");
194
+ console.error(
195
+ `[mcp-http] ${req.method} ${req.originalUrl} -> ${res.statusCode} (host=${h} ip=${remoteIp} ua="${ua}")`,
196
+ );
197
+ });
198
+ }
199
+ res.setHeader("Access-Control-Allow-Origin", "*");
200
+ res.setHeader(
201
+ "Access-Control-Allow-Headers",
202
+ "Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version",
203
+ );
204
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
205
+ res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate, Mcp-Session-Id");
206
+ if (req.method === "OPTIONS") {
207
+ return res.status(204).end();
208
+ }
209
+ next();
210
+ });
211
+
27
212
  app.use(express.json());
213
+ app.use(express.urlencoded({ extended: false }));
214
+
215
+ // ── OAuth 2.0 / 2.1 Discovery & Authorization Endpoints ────────────
216
+ if (signingSecret) {
217
+ // RFC 9728 Protected Resource Metadata
218
+ const handleProtectedResourceMetadata = (req, res) => {
219
+ const origin = resolveIssuerOrigin(req, configuredIssuer, isTls);
220
+ res.json({
221
+ resource: `${origin}/mcp`,
222
+ authorization_servers: [origin],
223
+ bearer_methods_supported: ["header"],
224
+ scopes_supported: DEFAULT_SCOPES,
225
+ resource_name: "Zalo Agent MCP",
226
+ });
227
+ };
228
+ app.get("/.well-known/oauth-protected-resource", handleProtectedResourceMetadata);
229
+ app.get("/.well-known/oauth-protected-resource/mcp", handleProtectedResourceMetadata);
230
+
231
+ // RFC 8414 OAuth Authorization Server Metadata (+ OIDC discovery alias)
232
+ const handleAuthServerMetadata = (req, res) => {
233
+ const origin = resolveIssuerOrigin(req, configuredIssuer, isTls);
234
+ res.json({
235
+ issuer: origin,
236
+ authorization_endpoint: `${origin}/authorize`,
237
+ token_endpoint: `${origin}/token`,
238
+ registration_endpoint: `${origin}/register`,
239
+ revocation_endpoint: `${origin}/revoke`,
240
+ response_types_supported: ["code"],
241
+ grant_types_supported: ["authorization_code", "refresh_token"],
242
+ token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic", "none"],
243
+ code_challenge_methods_supported: ["S256"],
244
+ scopes_supported: DEFAULT_SCOPES,
245
+ resource_indicators_supported: true,
246
+ });
247
+ };
248
+ app.get("/.well-known/oauth-authorization-server", handleAuthServerMetadata);
249
+ app.get("/.well-known/openid-configuration", handleAuthServerMetadata);
28
250
 
29
- // Auth middleware — skips /health
30
- if (authToken) {
251
+ // RFC 7591 Dynamic Client Registration
252
+ app.post("/register", (req, res) => {
253
+ res.setHeader("Cache-Control", "no-store");
254
+ const redirectUris = Array.isArray(req.body?.redirect_uris) ? req.body.redirect_uris : [];
255
+ const authMethod = req.body?.token_endpoint_auth_method || "client_secret_post";
256
+ const issuedAt = Math.floor(Date.now() / 1000);
257
+ const dynId = `dyn_${randomBytes(12).toString("hex")}`;
258
+ const dynSecret = authMethod === "none" ? undefined : signingSecret;
259
+ if (dynamicClients.size >= 200) {
260
+ const oldest = dynamicClients.keys().next().value;
261
+ if (oldest) dynamicClients.delete(oldest);
262
+ }
263
+ dynamicClients.set(dynId, {
264
+ clientId: dynId,
265
+ authMethod,
266
+ redirectUris,
267
+ });
268
+ res.status(201).json({
269
+ client_id: dynId,
270
+ ...(dynSecret ? { client_secret: dynSecret, client_secret_expires_at: 0 } : {}),
271
+ client_id_issued_at: issuedAt,
272
+ redirect_uris: redirectUris,
273
+ token_endpoint_auth_method: authMethod,
274
+ grant_types: req.body?.grant_types || ["authorization_code", "refresh_token"],
275
+ response_types: req.body?.response_types || ["code"],
276
+ });
277
+ });
278
+
279
+ // OAuth 2.0 Authorization Endpoint (GET & POST)
280
+ const handleAuthorize = (req, res) => {
281
+ res.setHeader("Cache-Control", "no-store");
282
+ const params = req.method === "POST" ? { ...req.query, ...req.body } : req.query;
283
+ const responseType = String(params.response_type || "code");
284
+ const clientId = String(params.client_id || "").trim();
285
+ const redirectUri = String(params.redirect_uri || "").trim();
286
+ const state = params.state !== undefined ? String(params.state) : undefined;
287
+ const scope = typeof params.scope === "string" && params.scope.trim() ? params.scope.trim() : "mcp:tools";
288
+ const codeChallenge =
289
+ typeof params.code_challenge === "string" && params.code_challenge.trim()
290
+ ? params.code_challenge.trim()
291
+ : null;
292
+ const codeChallengeMethod =
293
+ typeof params.code_challenge_method === "string" && params.code_challenge_method.trim()
294
+ ? params.code_challenge_method.trim()
295
+ : "S256";
296
+
297
+ if (!redirectUri || !URL.canParse(redirectUri)) {
298
+ return res.status(400).json({
299
+ error: "invalid_request",
300
+ error_description: "A valid redirect_uri is required",
301
+ });
302
+ }
303
+ if (!clientId || (!configuredClients.has(clientId) && !dynamicClients.has(clientId))) {
304
+ return res.status(400).json({
305
+ error: "invalid_client",
306
+ error_description: "Unknown client_id",
307
+ });
308
+ }
309
+ if (responseType !== "code") {
310
+ const errUrl = new URL(redirectUri);
311
+ errUrl.searchParams.set("error", "unsupported_response_type");
312
+ if (state !== undefined) errUrl.searchParams.set("state", state);
313
+ return res.redirect(302, errUrl.href);
314
+ }
315
+
316
+ const now = Math.floor(Date.now() / 1000);
317
+ const code = signOAuthToken(
318
+ "zmcp_code_",
319
+ {
320
+ typ: "code",
321
+ cid: clientId,
322
+ ruri: redirectUri,
323
+ cc: codeChallenge,
324
+ ccm: codeChallengeMethod,
325
+ scp: scope,
326
+ exp: now + AUTH_CODE_TTL_SEC,
327
+ jti: randomBytes(12).toString("hex"),
328
+ },
329
+ signingSecret,
330
+ );
331
+
332
+ const target = new URL(redirectUri);
333
+ target.searchParams.set("code", code);
334
+ if (state !== undefined) target.searchParams.set("state", state);
335
+ return res.redirect(302, target.href);
336
+ };
337
+ app.get("/authorize", handleAuthorize);
338
+ app.post("/authorize", handleAuthorize);
339
+
340
+ // OAuth 2.0 Token Endpoint
341
+ app.post("/token", (req, res) => {
342
+ res.setHeader("Cache-Control", "no-store");
343
+ const grantType = String(req.body?.grant_type || "").trim();
344
+ const { clientId, clientSecret } = extractClientCredentials(req);
345
+
346
+ const issueTokens = (cid, scope = "mcp:tools") => {
347
+ const now = Math.floor(Date.now() / 1000);
348
+ const accessToken = signOAuthToken(
349
+ "zmcp_at_",
350
+ {
351
+ typ: "at",
352
+ cid,
353
+ scp: scope,
354
+ exp: now + ACCESS_TOKEN_TTL_SEC,
355
+ jti: randomBytes(8).toString("hex"),
356
+ },
357
+ signingSecret,
358
+ );
359
+ const refreshToken = signOAuthToken(
360
+ "zmcp_rt_",
361
+ {
362
+ typ: "rt",
363
+ cid,
364
+ scp: scope,
365
+ exp: now + REFRESH_TOKEN_TTL_SEC,
366
+ jti: randomBytes(8).toString("hex"),
367
+ },
368
+ signingSecret,
369
+ );
370
+ return {
371
+ access_token: accessToken,
372
+ token_type: "Bearer",
373
+ expires_in: ACCESS_TOKEN_TTL_SEC,
374
+ refresh_token: refreshToken,
375
+ scope,
376
+ };
377
+ };
378
+
379
+ const isValidSecretFor = (cid) => {
380
+ if (!clientSecret) return false;
381
+ if (cid && configuredClients.has(cid)) {
382
+ const expected = configuredClients.get(cid);
383
+ return (
384
+ safeEqual(clientSecret, expected) || Boolean(authToken && safeEqual(clientSecret, authToken))
385
+ );
386
+ }
387
+ return (
388
+ safeEqual(clientSecret, signingSecret) || Boolean(authToken && safeEqual(clientSecret, authToken))
389
+ );
390
+ };
391
+
392
+ if (grantType === "authorization_code") {
393
+ const rawCode = String(req.body?.code || "").trim();
394
+ const codePayload = verifyOAuthToken(rawCode, "zmcp_code_", signingSecret);
395
+ if (!codePayload || codePayload.typ !== "code") {
396
+ return res.status(400).json({
397
+ error: "invalid_grant",
398
+ error_description: "Invalid or expired authorization code",
399
+ });
400
+ }
401
+
402
+ // Prune expired redeemed codes and enforce single use
403
+ const now = Math.floor(Date.now() / 1000);
404
+ for (const [k, exp] of redeemedCodes) {
405
+ if (exp < now) redeemedCodes.delete(k);
406
+ }
407
+ if (redeemedCodes.has(codePayload.jti)) {
408
+ return res.status(400).json({
409
+ error: "invalid_grant",
410
+ error_description: "Authorization code has already been used",
411
+ });
412
+ }
413
+
414
+ const effectiveClientId = clientId || codePayload.cid;
415
+ if (effectiveClientId !== codePayload.cid) {
416
+ return res.status(400).json({
417
+ error: "invalid_grant",
418
+ error_description: "client_id does not match authorization code",
419
+ });
420
+ }
421
+
422
+ const dynClient = dynamicClients.get(effectiveClientId);
423
+ const isPublicDynClient = dynClient?.authMethod === "none" && Boolean(codePayload.cc);
424
+ if (!isValidSecretFor(effectiveClientId) && !isPublicDynClient) {
425
+ return res.status(401).json({
426
+ error: "invalid_client",
427
+ error_description: "Invalid client_secret",
428
+ });
429
+ }
430
+
431
+ if (req.body?.redirect_uri && req.body.redirect_uri !== codePayload.ruri) {
432
+ return res.status(400).json({
433
+ error: "invalid_grant",
434
+ error_description: "redirect_uri mismatch",
435
+ });
436
+ }
437
+
438
+ if (codePayload.cc) {
439
+ const verifier = String(req.body?.code_verifier || "");
440
+ if (!verifyPkce(verifier, codePayload.cc, codePayload.ccm)) {
441
+ return res.status(400).json({
442
+ error: "invalid_grant",
443
+ error_description: "PKCE code_verifier verification failed",
444
+ });
445
+ }
446
+ }
447
+
448
+ redeemedCodes.set(codePayload.jti, codePayload.exp);
449
+ return res.status(200).json(issueTokens(effectiveClientId, codePayload.scp));
450
+ }
451
+
452
+ if (grantType === "refresh_token") {
453
+ const rawRefresh = String(req.body?.refresh_token || "").trim();
454
+ const rtPayload = verifyOAuthToken(rawRefresh, "zmcp_rt_", signingSecret);
455
+ if (!rtPayload || rtPayload.typ !== "rt") {
456
+ return res.status(400).json({
457
+ error: "invalid_grant",
458
+ error_description: "Invalid or expired refresh_token",
459
+ });
460
+ }
461
+ const effectiveClientId = clientId || rtPayload.cid;
462
+ const dynClient = dynamicClients.get(effectiveClientId);
463
+ if (!isValidSecretFor(effectiveClientId) && dynClient?.authMethod !== "none") {
464
+ return res.status(401).json({
465
+ error: "invalid_client",
466
+ error_description: "Invalid client_secret",
467
+ });
468
+ }
469
+ return res.status(200).json(issueTokens(rtPayload.cid, rtPayload.scp));
470
+ }
471
+
472
+ if (grantType === "client_credentials") {
473
+ if (clientId && !configuredClients.has(clientId) && !dynamicClients.has(clientId)) {
474
+ return res.status(401).json({
475
+ error: "invalid_client",
476
+ error_description: "Invalid client_id",
477
+ });
478
+ }
479
+ const targetClientId = clientId || configuredClientId;
480
+ if (!isValidSecretFor(targetClientId)) {
481
+ return res.status(401).json({
482
+ error: "invalid_client",
483
+ error_description: "Invalid client_secret",
484
+ });
485
+ }
486
+ const scope =
487
+ typeof req.body?.scope === "string" && req.body.scope.trim() ? req.body.scope.trim() : "mcp:tools";
488
+ return res.status(200).json(issueTokens(targetClientId, scope));
489
+ }
490
+
491
+ return res.status(400).json({
492
+ error: "unsupported_grant_type",
493
+ error_description: "Supported grant_types: authorization_code, refresh_token, client_credentials",
494
+ });
495
+ });
496
+
497
+ // RFC 7009 Token Revocation Endpoint
498
+ app.post("/revoke", (_req, res) => {
499
+ res.status(200).json({});
500
+ });
501
+ }
502
+
503
+ // ── Bearer / OAuth2 Auth Middleware — skips /health ─────────────────
504
+ if (signingSecret) {
31
505
  app.use((req, res, next) => {
32
506
  if (req.path === "/health") return next();
33
- const token = req.headers.authorization?.slice(7) || "";
34
- const expected = Buffer.from(authToken, "utf8");
35
- const received = Buffer.from(token, "utf8");
36
- if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
507
+ const authHeader = req.headers.authorization || "";
508
+ const token = /^bearer\s+/i.test(authHeader) ? authHeader.slice(7).trim() : "";
509
+
510
+ let matchesStaticToken =
511
+ Boolean(token) && ((authToken && safeEqual(token, authToken)) || safeEqual(token, signingSecret));
512
+ if (!matchesStaticToken && token) {
513
+ for (const sec of configuredClients.values()) {
514
+ if (safeEqual(token, sec)) {
515
+ matchesStaticToken = true;
516
+ break;
517
+ }
518
+ }
519
+ }
520
+ const oauthClaims =
521
+ !matchesStaticToken && token ? verifyOAuthToken(token, "zmcp_at_", signingSecret) : null;
522
+
523
+ if (!matchesStaticToken && !oauthClaims) {
524
+ const origin = resolveIssuerOrigin(req, configuredIssuer, isTls);
525
+ const wwwAuth = token
526
+ ? `Bearer error="invalid_token", error_description="Unauthorized", resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`
527
+ : `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`;
528
+ res.setHeader("WWW-Authenticate", wwwAuth);
37
529
  return res.status(401).json({ error: "Unauthorized" });
38
530
  }
39
531
  next();
40
532
  });
41
533
  }
42
534
 
43
- // MCP endpoint — stateless, fresh server+transport per request
44
- app.post("/mcp", async (req, res) => {
535
+ // MCP endpoint — stateless, fresh server+transport per request (POST, GET, DELETE per Streamable HTTP spec)
536
+ app.all("/mcp", async (req, res) => {
45
537
  try {
46
538
  const server = new McpServer({ name: "zalo-agent", version: pkg.version });
47
539
  registerToolsFn(
@@ -128,6 +620,29 @@ export function createHTTPServer(registerToolsFn, deps, port, authToken, host =
128
620
  });
129
621
  });
130
622
 
623
+ if (isTls) {
624
+ const primary = https.createServer({ cert: tls.cert, key: tls.key }, app).listen(port, host, () => {
625
+ console.error(`MCP HTTPS server listening on ${host}:${port}`);
626
+ });
627
+ if (port !== 0 && port !== 443) {
628
+ const tls443 = https.createServer({ cert: tls.cert, key: tls.key }, app);
629
+ tls443.on("error", () => {
630
+ /* port 443 already in use or restricted — ignore */
631
+ });
632
+ tls443.listen(443, host, () => {
633
+ console.error(`MCP HTTPS server also listening on ${host}:443`);
634
+ });
635
+ primary.on("close", () => {
636
+ try {
637
+ tls443.close();
638
+ } catch {
639
+ /* ignore */
640
+ }
641
+ });
642
+ }
643
+ return primary;
644
+ }
645
+
131
646
  return app.listen(port, host, () => {
132
647
  console.error(`MCP HTTP server listening on ${host}:${port}`);
133
648
  });