portkey-admin-mcp 0.9.0 โ†’ 0.10.0

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/README.md CHANGED
@@ -15,6 +15,8 @@
15
15
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
16
16
  <br>
17
17
  <a href="https://github.com/CodesWhat/portkey-admin-mcp/actions/workflows/ci.yml"><img src="https://github.com/CodesWhat/portkey-admin-mcp/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
18
+ <a href="https://securityscorecards.dev/viewer/?uri=github.com/CodesWhat/portkey-admin-mcp"><img src="https://img.shields.io/ossf-scorecard/github.com/CodesWhat/portkey-admin-mcp?label=openssf+scorecard&style=flat" alt="OpenSSF Scorecard"></a>
19
+ <a href="https://www.bestpractices.dev/projects/14031"><img src="https://www.bestpractices.dev/projects/14031/badge" alt="OpenSSF Best Practices"></a>
18
20
  <a href="https://github.com/punkpeye/awesome-mcp-servers"><img src="https://awesome.re/mentioned-badge.svg" alt="Mentioned in Awesome MCP Servers"></a>
19
21
  <a href="https://lobehub.com/mcp/codeswhat-portkey-admin-mcp"><img src="https://lobehub.com/badge/mcp/codeswhat-portkey-admin-mcp" alt="LobeHub MCP"></a>
20
22
  </p>
@@ -34,8 +36,13 @@
34
36
  - [๐Ÿงฐ What You Can Do](#what-you-can-do)
35
37
  - [๐Ÿ”‘ API Key Scopes](#api-key-scopes)
36
38
  - [๐ŸŒ HTTP Server (Experimental)](#http-server)
39
+ - [๐Ÿ—๏ธ Architecture](./docs/ARCHITECTURE.md)
37
40
  - [๐Ÿ”„ Prisma AIRS interoperability](./docs/PRISMA_AIRS_INTEROPERABILITY.md)
41
+ - [๐Ÿ” Verify a release](./docs/VERIFY_RELEASE.md)
38
42
  - [๐Ÿ› ๏ธ Development](#development)
43
+ - [๐Ÿค Contributing](./CONTRIBUTING.md)
44
+ - [๐Ÿ›๏ธ Governance](./GOVERNANCE.md)
45
+ - [๐Ÿ›ก๏ธ Security assurance](./SECURITY-ASSURANCE.md)
39
46
  - [๐Ÿงพ Full tool list โ€” ENDPOINTS.md](./ENDPOINTS.md)
40
47
 
41
48
  <hr>
@@ -219,6 +226,7 @@ For local-only HTTP use, leave `MCP_HOST` at its default `127.0.0.1`. Set `MCP_H
219
226
  | `MCP_MAX_SESSIONS` | `100` | Maximum concurrent stateful sessions or active stateless request handlers |
220
227
  | `MCP_EVENT_STORE` | `off` | `off`, `memory`, or `redis`; stateless `GET /mcp` replay requires `memory` or `redis` |
221
228
  | `MCP_EVENT_TTL_SECONDS` | `300` | Replay retention in seconds |
229
+ | `MCP_EVENT_STORE_COMMAND_TIMEOUT_MS` | `5000` | Redis command timeout for the event store, in milliseconds; `0` disables the timeout (restores unbounded pre-v6 behavior) |
222
230
  | `MCP_REDIS_URL` | โ€” | Redis URL for shared event store; production requires `rediss://` and ACL-scoped credentials |
223
231
  | `MCP_EVENT_ENCRYPTION_KEY` | โ€” | Required 32-byte base64 AES key for Redis replay payloads; generate with `openssl rand -base64 32` |
224
232
  | `MCP_REDIS_KEY_PREFIX` | `mcp:event-store` | Dedicated Redis namespace for replay data |
@@ -283,13 +291,24 @@ docker run \
283
291
  npm run dev # stdio with hot reload
284
292
  npm run dev:http # HTTP with hot reload
285
293
  npm test # unit + contract tests
294
+ npm run test:coverage # unit + contract tests with the enforced 80% line floor
286
295
  npm run test:e2e # MCP protocol tests
287
296
  npm run test:http # HTTP endpoint smoke test
288
- npm run ci # full pipeline (lint + typecheck + test + build + e2e + verify)
297
+ npm run ci # full pipeline (lint + typecheck + coverage + build + e2e + verify)
289
298
  ```
290
299
 
300
+ The required CI and release gates measure every TypeScript source file and fail
301
+ below 80% line coverage. The current full report is 99.36% lines, 92.61%
302
+ branches, and 99.01% functions.
303
+
291
304
  `npm run dev:http` now requires `MCP_AUTH_MODE=bearer` or `MCP_AUTH_MODE=clerk` by default. For deliberate local-only unauthenticated testing, set `MCP_ALLOW_UNAUTHENTICATED_HTTP=true`.
292
305
 
306
+ Contributions use pull requests and the checks documented in
307
+ [`CONTRIBUTING.md`](CONTRIBUTING.md). Project decisions and maintainer roles are
308
+ documented in [`GOVERNANCE.md`](GOVERNANCE.md). Report vulnerabilities through
309
+ [`SECURITY.md`](SECURITY.md), and see [`SECURITY-ASSURANCE.md`](SECURITY-ASSURANCE.md)
310
+ for the public threat model and assurance case.
311
+
293
312
  ---
294
313
 
295
314
  <div align="center">
package/build/index.js CHANGED
@@ -3,6 +3,59 @@
3
3
  // src/index.ts
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
+ // src/lib/logger.ts
7
+ var LOG_LEVELS = {
8
+ debug: 0,
9
+ info: 1,
10
+ warn: 2,
11
+ error: 3
12
+ };
13
+ function getConfiguredLogLevel() {
14
+ const envLevel = process.env.LOG_LEVEL?.toLowerCase();
15
+ if (envLevel && envLevel in LOG_LEVELS) {
16
+ return envLevel;
17
+ }
18
+ return "info";
19
+ }
20
+ var configuredLogLevel = getConfiguredLogLevel();
21
+ function shouldLog(level) {
22
+ return LOG_LEVELS[level] >= LOG_LEVELS[configuredLogLevel];
23
+ }
24
+ function formatEntry(level, message, extra) {
25
+ return {
26
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
27
+ level,
28
+ message,
29
+ ...extra
30
+ };
31
+ }
32
+ function writeLog(entry) {
33
+ process.stderr.write(`${JSON.stringify(entry)}
34
+ `);
35
+ }
36
+ var Logger = {
37
+ debug(message, extra) {
38
+ if (shouldLog("debug")) {
39
+ writeLog(formatEntry("debug", message, extra));
40
+ }
41
+ },
42
+ info(message, extra) {
43
+ if (shouldLog("info")) {
44
+ writeLog(formatEntry("info", message, extra));
45
+ }
46
+ },
47
+ warn(message, extra) {
48
+ if (shouldLog("warn")) {
49
+ writeLog(formatEntry("warn", message, extra));
50
+ }
51
+ },
52
+ error(message, extra) {
53
+ if (shouldLog("error")) {
54
+ writeLog(formatEntry("error", message, extra));
55
+ }
56
+ }
57
+ };
58
+
6
59
  // src/lib/mcp-server.ts
7
60
  import { readFileSync } from "node:fs";
8
61
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -64,62 +117,10 @@ async function parseErrorResponse(response) {
64
117
  }
65
118
  }
66
119
 
67
- // src/lib/logger.ts
68
- var LOG_LEVELS = {
69
- debug: 0,
70
- info: 1,
71
- warn: 2,
72
- error: 3
73
- };
74
- function getConfiguredLogLevel() {
75
- const envLevel = process.env.LOG_LEVEL?.toLowerCase();
76
- if (envLevel && envLevel in LOG_LEVELS) {
77
- return envLevel;
78
- }
79
- return "info";
80
- }
81
- var configuredLogLevel = getConfiguredLogLevel();
82
- function shouldLog(level) {
83
- return LOG_LEVELS[level] >= LOG_LEVELS[configuredLogLevel];
84
- }
85
- function formatEntry(level, message, extra) {
86
- return {
87
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
88
- level,
89
- message,
90
- ...extra
91
- };
92
- }
93
- function writeLog(entry) {
94
- process.stderr.write(`${JSON.stringify(entry)}
95
- `);
96
- }
97
- var Logger = {
98
- debug(message, extra) {
99
- if (shouldLog("debug")) {
100
- writeLog(formatEntry("debug", message, extra));
101
- }
102
- },
103
- info(message, extra) {
104
- if (shouldLog("info")) {
105
- writeLog(formatEntry("info", message, extra));
106
- }
107
- },
108
- warn(message, extra) {
109
- if (shouldLog("warn")) {
110
- writeLog(formatEntry("warn", message, extra));
111
- }
112
- },
113
- error(message, extra) {
114
- if (shouldLog("error")) {
115
- writeLog(formatEntry("error", message, extra));
116
- }
117
- }
118
- };
119
-
120
120
  // src/services/base.service.ts
121
121
  var DEFAULT_BASE_URL = "https://api.portkey.ai/v1";
122
122
  var DEFAULT_PUBLIC_BASE_URL = "https://api.portkey.ai";
123
+ var MISSING_API_KEY_PLACEHOLDER = "__PORTKEY_API_KEY_NOT_CONFIGURED__";
123
124
  var PRIVATE_BASE_URL_OVERRIDE_HINT = "Set PORTKEY_ALLOW_PRIVATE_BASE_URL=true to allow self-hosted gateways on loopback or private networks.";
124
125
  var INSECURE_HTTP_OVERRIDE_HINT = "Set PORTKEY_ALLOW_INSECURE_HTTP=true only for a trusted self-hosted gateway when TLS is unavailable.";
125
126
  function isPrivateOrLocalHost(hostname) {
@@ -233,6 +234,11 @@ var BaseService = class {
233
234
  return body ? JSON.stringify(body) : void 0;
234
235
  }
235
236
  async executeRequest(method, path, options = {}) {
237
+ if ((options.authenticate ?? true) && this.apiKey === MISSING_API_KEY_PLACEHOLDER) {
238
+ throw new Error(
239
+ "PORTKEY_API_KEY is not configured. Set the PORTKEY_API_KEY environment variable to a valid Portkey Admin API key."
240
+ );
241
+ }
236
242
  const requestId = crypto.randomUUID();
237
243
  const url = this.buildUrl(path, options.params, options.baseUrl);
238
244
  const startTime = Date.now();
@@ -1795,7 +1801,6 @@ var WorkspacesService = class extends BaseService {
1795
1801
 
1796
1802
  // src/services/index.ts
1797
1803
  import { createHmac, randomBytes } from "node:crypto";
1798
- var MISSING_API_KEY_PLACEHOLDER = "__PORTKEY_API_KEY_NOT_CONFIGURED__";
1799
1804
  var SERVICE_CACHE_HMAC_KEY = randomBytes(32);
1800
1805
  function resolvePortkeyApiKey(apiKey) {
1801
1806
  const resolvedApiKey = apiKey ?? process.env.PORTKEY_API_KEY;
@@ -6108,14 +6113,18 @@ function registerPromptsTools(server, service) {
6108
6113
  });
6109
6114
  if (dry_run) {
6110
6115
  const current = await service.prompts.getPrompt(prompt_id);
6116
+ const changes = Object.keys(updateData).filter(
6117
+ (k) => updateData[k] !== void 0
6118
+ );
6119
+ if (messages !== void 0 && !changes.includes("string")) {
6120
+ changes.push("string");
6121
+ }
6111
6122
  return jsonResult({
6112
6123
  dry_run: true,
6113
6124
  action: "update",
6114
6125
  message: `Would update prompt "${current.name}"`,
6115
6126
  current_version: current.current_version?.version_number ?? null,
6116
- changes: Object.keys(updateData).filter(
6117
- (k) => updateData[k] !== void 0
6118
- )
6127
+ changes
6119
6128
  });
6120
6129
  }
6121
6130
  const result = await service.prompts.updatePrompt(prompt_id, {
@@ -8240,6 +8249,11 @@ function createMcpServer(options = {}) {
8240
8249
  // src/index.ts
8241
8250
  async function main() {
8242
8251
  try {
8252
+ if (!process.env.PORTKEY_API_KEY) {
8253
+ Logger.warn(
8254
+ "PORTKEY_API_KEY is not set; tool calls will fail until it is configured."
8255
+ );
8256
+ }
8243
8257
  const { server } = createMcpServer();
8244
8258
  const transport = new StdioServerTransport();
8245
8259
  await server.connect(transport);
package/build/server.js CHANGED
@@ -137,6 +137,7 @@ var Logger = {
137
137
  // src/services/base.service.ts
138
138
  var DEFAULT_BASE_URL = "https://api.portkey.ai/v1";
139
139
  var DEFAULT_PUBLIC_BASE_URL = "https://api.portkey.ai";
140
+ var MISSING_API_KEY_PLACEHOLDER = "__PORTKEY_API_KEY_NOT_CONFIGURED__";
140
141
  var PRIVATE_BASE_URL_OVERRIDE_HINT = "Set PORTKEY_ALLOW_PRIVATE_BASE_URL=true to allow self-hosted gateways on loopback or private networks.";
141
142
  var INSECURE_HTTP_OVERRIDE_HINT = "Set PORTKEY_ALLOW_INSECURE_HTTP=true only for a trusted self-hosted gateway when TLS is unavailable.";
142
143
  function isPrivateOrLocalHost(hostname) {
@@ -250,6 +251,11 @@ var BaseService = class {
250
251
  return body ? JSON.stringify(body) : void 0;
251
252
  }
252
253
  async executeRequest(method, path, options = {}) {
254
+ if ((options.authenticate ?? true) && this.apiKey === MISSING_API_KEY_PLACEHOLDER) {
255
+ throw new Error(
256
+ "PORTKEY_API_KEY is not configured. Set the PORTKEY_API_KEY environment variable to a valid Portkey Admin API key."
257
+ );
258
+ }
253
259
  const requestId = crypto.randomUUID();
254
260
  const url = this.buildUrl(path, options.params, options.baseUrl);
255
261
  const startTime = Date.now();
@@ -1812,7 +1818,6 @@ var WorkspacesService = class extends BaseService {
1812
1818
 
1813
1819
  // src/services/index.ts
1814
1820
  import { createHmac, randomBytes } from "node:crypto";
1815
- var MISSING_API_KEY_PLACEHOLDER = "__PORTKEY_API_KEY_NOT_CONFIGURED__";
1816
1821
  var SERVICE_CACHE_HMAC_KEY = randomBytes(32);
1817
1822
  function resolvePortkeyApiKey(apiKey) {
1818
1823
  const resolvedApiKey = apiKey ?? process.env.PORTKEY_API_KEY;
@@ -6125,14 +6130,18 @@ function registerPromptsTools(server, service) {
6125
6130
  });
6126
6131
  if (dry_run) {
6127
6132
  const current = await service.prompts.getPrompt(prompt_id);
6133
+ const changes = Object.keys(updateData).filter(
6134
+ (k) => updateData[k] !== void 0
6135
+ );
6136
+ if (messages !== void 0 && !changes.includes("string")) {
6137
+ changes.push("string");
6138
+ }
6128
6139
  return jsonResult({
6129
6140
  dry_run: true,
6130
6141
  action: "update",
6131
6142
  message: `Would update prompt "${current.name}"`,
6132
6143
  current_version: current.current_version?.version_number ?? null,
6133
- changes: Object.keys(updateData).filter(
6134
- (k) => updateData[k] !== void 0
6135
- )
6144
+ changes
6136
6145
  });
6137
6146
  }
6138
6147
  const result = await service.prompts.updatePrompt(prompt_id, {
@@ -8086,6 +8095,21 @@ function registerAllTools(server, service, options = {}) {
8086
8095
  // src/lib/auth.ts
8087
8096
  import crypto2 from "node:crypto";
8088
8097
  import { createRemoteJWKSet, jwtVerify } from "jose";
8098
+
8099
+ // src/lib/request-path.ts
8100
+ function canonicalizeRequestPath(path) {
8101
+ const normalized = path.toLowerCase();
8102
+ return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
8103
+ }
8104
+ function isMcpRequestPath(path) {
8105
+ return canonicalizeRequestPath(path) === "/mcp";
8106
+ }
8107
+ function isHealthOrReadyPath(path) {
8108
+ const canonical = canonicalizeRequestPath(path);
8109
+ return canonical === "/health" || canonical === "/ready";
8110
+ }
8111
+
8112
+ // src/lib/auth.ts
8089
8113
  function getPrincipalOwnerKey(principal) {
8090
8114
  return crypto2.createHash("sha256").update(principal.id, "utf8").digest("hex");
8091
8115
  }
@@ -8306,7 +8330,7 @@ async function verifyClerkToken(token, config) {
8306
8330
  return authorizeClerkClaims(payload, config);
8307
8331
  }
8308
8332
  async function mcpAuthMiddleware(req, res, next) {
8309
- if (!req.path.startsWith("/mcp")) {
8333
+ if (!isMcpRequestPath(req.path)) {
8310
8334
  next();
8311
8335
  return;
8312
8336
  }
@@ -8435,6 +8459,15 @@ function getServerConfig() {
8435
8459
  `Invalid MCP_EVENT_TTL_SECONDS value: ${eventStoreTtlStr}. Must be a positive integer`
8436
8460
  );
8437
8461
  }
8462
+ const eventStoreCommandTimeoutStr = (process.env.MCP_EVENT_STORE_COMMAND_TIMEOUT_MS || "5000").trim();
8463
+ const eventStoreCommandTimeoutMs = parseStrictInteger(
8464
+ eventStoreCommandTimeoutStr
8465
+ );
8466
+ if (Number.isNaN(eventStoreCommandTimeoutMs) || eventStoreCommandTimeoutMs < 0) {
8467
+ throw new Error(
8468
+ `Invalid MCP_EVENT_STORE_COMMAND_TIMEOUT_MS value: ${eventStoreCommandTimeoutStr}. Must be a non-negative integer (0 disables the timeout)`
8469
+ );
8470
+ }
8438
8471
  const redisUrl = process.env.MCP_REDIS_URL?.trim() || process.env.REDIS_URL?.trim();
8439
8472
  if (eventStoreMode === "redis" && !redisUrl) {
8440
8473
  throw new Error(
@@ -8494,7 +8527,8 @@ function getServerConfig() {
8494
8527
  ttlSeconds: eventStoreTtlSeconds,
8495
8528
  redisUrl,
8496
8529
  redisKeyPrefix,
8497
- encryptionKey: eventEncryptionKey
8530
+ encryptionKey: eventEncryptionKey,
8531
+ commandTimeoutMs: eventStoreCommandTimeoutMs
8498
8532
  },
8499
8533
  protocol,
8500
8534
  port,
@@ -8726,13 +8760,15 @@ var RedisEventStore = class _RedisEventStore {
8726
8760
  keyPrefix;
8727
8761
  ownerKey;
8728
8762
  encryptionKey;
8763
+ commandTimeoutMs;
8729
8764
  connection;
8730
- constructor(redisUrl, keyPrefix, ttlSeconds, ownerKey, encryptionKey, connection = {}) {
8765
+ constructor(redisUrl, keyPrefix, ttlSeconds, ownerKey, encryptionKey, commandTimeoutMs, connection = {}) {
8731
8766
  this.redisUrl = redisUrl;
8732
8767
  this.ttlSeconds = ttlSeconds;
8733
8768
  this.keyPrefix = keyPrefix;
8734
8769
  this.ownerKey = ownerKey;
8735
8770
  this.encryptionKey = encryptionKey;
8771
+ this.commandTimeoutMs = commandTimeoutMs;
8736
8772
  this.connection = connection;
8737
8773
  }
8738
8774
  get client() {
@@ -8748,6 +8784,7 @@ var RedisEventStore = class _RedisEventStore {
8748
8784
  this.ttlSeconds,
8749
8785
  ownerKey,
8750
8786
  this.encryptionKey,
8787
+ this.commandTimeoutMs,
8751
8788
  this.connection
8752
8789
  );
8753
8790
  }
@@ -8769,11 +8806,19 @@ var RedisEventStore = class _RedisEventStore {
8769
8806
  this.connection.client = createClient({
8770
8807
  url: this.redisUrl,
8771
8808
  // node-redis v6 defaults to RESP3, a 5-second command timeout, and a
8772
- // 30-second keepalive delay. Keep the event store's v5 behavior stable
8773
- // while taking the v6 fixes and supported runtime baseline.
8809
+ // 30-second keepalive delay. storeEvent/replayEventsAfter sit on the
8810
+ // MCP request-critical path (SSE sends, GET replay), so an unbounded
8811
+ // command wait here would hang requests indefinitely if Redis accepts
8812
+ // TCP but stalls on replies. Keep RESP2 for v5-compatible replies, but
8813
+ // use a bounded, configurable command timeout (default 5s, matching
8814
+ // node-redis's own default) instead of disabling it. Set
8815
+ // MCP_EVENT_STORE_COMMAND_TIMEOUT_MS=0 to restore the old unbounded
8816
+ // (v5-compatible) behavior.
8774
8817
  RESP: 2,
8775
8818
  socket: { keepAliveInitialDelay: 5e3 },
8776
- commandOptions: { timeout: void 0 }
8819
+ commandOptions: {
8820
+ timeout: this.commandTimeoutMs === 0 ? void 0 : this.commandTimeoutMs
8821
+ }
8777
8822
  });
8778
8823
  this.connection.client.on("error", (error) => {
8779
8824
  Logger.error("Redis event store error", {
@@ -8953,7 +8998,8 @@ function createManagedEventStore(config) {
8953
8998
  config.eventStore.redisKeyPrefix,
8954
8999
  config.eventStore.ttlSeconds,
8955
9000
  "internal",
8956
- encryptionKey
9001
+ encryptionKey,
9002
+ config.eventStore.commandTimeoutMs
8957
9003
  );
8958
9004
  return {
8959
9005
  mode: "redis",
@@ -9229,7 +9275,7 @@ function isAllowedHost(host) {
9229
9275
  });
9230
9276
  }
9231
9277
  function originValidationMiddleware(req, res, next) {
9232
- if (req.path === "/health" || req.path === "/ready") {
9278
+ if (isHealthOrReadyPath(req.path)) {
9233
9279
  next();
9234
9280
  return;
9235
9281
  }
@@ -9246,7 +9292,7 @@ function originValidationMiddleware(req, res, next) {
9246
9292
  next();
9247
9293
  }
9248
9294
  function hostValidationMiddleware(req, res, next) {
9249
- if (req.path === "/health" || req.path === "/ready") {
9295
+ if (isHealthOrReadyPath(req.path)) {
9250
9296
  next();
9251
9297
  return;
9252
9298
  }
@@ -9378,23 +9424,26 @@ function resolveRateLimitRedisUrl(store) {
9378
9424
  }
9379
9425
  return redisUrl;
9380
9426
  }
9381
- var rateLimitEnabled = process.env.RATE_LIMIT_ENABLED?.trim().toLowerCase() !== "false";
9382
- var rateLimitStore = resolveRateLimitStore();
9383
- if (rateLimitEnabled && rateLimitStore === "memory" && process.env.NODE_ENV?.trim().toLowerCase() === "production" && !isExplicitlyEnabled2(process.env.RATE_LIMIT_SINGLE_PROCESS)) {
9384
- throw new Error(
9385
- "Production in-memory rate limiting requires RATE_LIMIT_SINGLE_PROCESS=true. Use RATE_LIMIT_STORE=redis for multi-instance deployments."
9386
- );
9387
- }
9388
- var RATE_LIMIT_CONFIG = {
9389
- enabled: rateLimitEnabled,
9390
- store: rateLimitStore,
9391
- maxTokens: parsePositiveIntegerEnv("RATE_LIMIT_MAX", 60),
9392
- windowMs: parsePositiveIntegerEnv("RATE_LIMIT_WINDOW_MS", 6e4),
9393
- refillRate: parsePositiveIntegerEnv("RATE_LIMIT_REFILL", 60),
9394
- maxBuckets: parsePositiveIntegerEnv("RATE_LIMIT_MAX_BUCKETS", 1e4),
9395
- redisUrl: resolveRateLimitRedisUrl(rateLimitStore),
9396
- redisKeyPrefix: process.env.RATE_LIMIT_REDIS_KEY_PREFIX?.trim() || "mcp:rate-limit"
9397
- };
9427
+ function resolveRateLimitConfig() {
9428
+ const enabled = process.env.RATE_LIMIT_ENABLED?.trim().toLowerCase() !== "false";
9429
+ const store = resolveRateLimitStore();
9430
+ if (enabled && store === "memory" && process.env.NODE_ENV?.trim().toLowerCase() === "production" && !isExplicitlyEnabled2(process.env.RATE_LIMIT_SINGLE_PROCESS)) {
9431
+ throw new Error(
9432
+ "Production in-memory rate limiting requires RATE_LIMIT_SINGLE_PROCESS=true. Use RATE_LIMIT_STORE=redis for multi-instance deployments."
9433
+ );
9434
+ }
9435
+ return {
9436
+ enabled,
9437
+ store,
9438
+ maxTokens: parsePositiveIntegerEnv("RATE_LIMIT_MAX", 60),
9439
+ windowMs: parsePositiveIntegerEnv("RATE_LIMIT_WINDOW_MS", 6e4),
9440
+ refillRate: parsePositiveIntegerEnv("RATE_LIMIT_REFILL", 60),
9441
+ maxBuckets: parsePositiveIntegerEnv("RATE_LIMIT_MAX_BUCKETS", 1e4),
9442
+ redisUrl: resolveRateLimitRedisUrl(store),
9443
+ redisKeyPrefix: process.env.RATE_LIMIT_REDIS_KEY_PREFIX?.trim() || "mcp:rate-limit"
9444
+ };
9445
+ }
9446
+ var RATE_LIMIT_CONFIG = resolveRateLimitConfig();
9398
9447
  function getRateLimitConfig() {
9399
9448
  return RATE_LIMIT_CONFIG;
9400
9449
  }
@@ -9527,11 +9576,11 @@ function applyRateLimit(scope, req, res, next) {
9527
9576
  next();
9528
9577
  return;
9529
9578
  }
9530
- if (req.path === "/health" || req.path === "/ready") {
9579
+ if (isHealthOrReadyPath(req.path)) {
9531
9580
  next();
9532
9581
  return;
9533
9582
  }
9534
- if (scope === "authentication" && req.path !== "/mcp") {
9583
+ if (scope === "authentication" && !isMcpRequestPath(req.path)) {
9535
9584
  next();
9536
9585
  return;
9537
9586
  }
@@ -10385,14 +10434,16 @@ function createMcpPostHandler(deps) {
10385
10434
  try {
10386
10435
  await transport.handleRequest(req, res, req.body);
10387
10436
  } catch (error) {
10388
- if (hasReservedSessionSlot) {
10389
- sessionStore.releaseReservation();
10390
- }
10391
10437
  respondJsonRpcInternalError(
10392
10438
  res,
10393
10439
  sessionId ? "POST /mcp stateful session" : "POST /mcp stateful initialize",
10394
10440
  error
10395
10441
  );
10442
+ } finally {
10443
+ if (hasReservedSessionSlot) {
10444
+ sessionStore.releaseReservation();
10445
+ hasReservedSessionSlot = false;
10446
+ }
10396
10447
  }
10397
10448
  };
10398
10449
  }
@@ -10428,143 +10479,150 @@ function createMcpGetHandler(deps) {
10428
10479
  );
10429
10480
  return;
10430
10481
  }
10431
- const replayEventStore = managedEventStore.eventStoreForOwner(ownerKey);
10432
- if (!replayEventStore || !managedEventStore.acquireReplayLease) {
10433
- res.setHeader("Allow", "POST");
10434
- respondJsonRpcClientError(
10435
- res,
10436
- 405,
10437
- "Stateless GET /mcp replay requires MCP_EVENT_STORE=memory or redis",
10438
- -32e3
10439
- );
10440
- return;
10441
- }
10442
- const lastEventId = req.headers["last-event-id"];
10443
- if (typeof lastEventId !== "string" || lastEventId.length === 0) {
10444
- res.setHeader("Allow", "POST");
10445
- respondJsonRpcClientError(
10446
- res,
10447
- 405,
10448
- "Stateless GET /mcp only supports replay with Last-Event-ID",
10449
- -32e3
10450
- );
10451
- return;
10452
- }
10453
- if (!req.headers.accept?.includes("text/event-stream")) {
10454
- respondJsonRpcClientError(
10455
- res,
10456
- 406,
10457
- "Not Acceptable: Client must accept text/event-stream",
10458
- -32e3
10459
- );
10460
- return;
10461
- }
10462
- const protocolError = validateRequiredProtocolVersion(
10463
- getMcpProtocolVersion(req)
10464
- );
10465
- if (protocolError) {
10466
- respondJsonRpcClientError(res, 400, protocolError, -32e3);
10467
- return;
10468
- }
10482
+ let releaseReplaySlot;
10483
+ const replaySlot = new Promise((resolve) => {
10484
+ releaseReplaySlot = resolve;
10485
+ });
10486
+ activeReplayOperations.add(replaySlot);
10469
10487
  try {
10470
- const lease = await managedEventStore.acquireReplayLease(
10471
- lastEventId,
10472
- ownerKey
10488
+ const replayEventStore = managedEventStore.eventStoreForOwner(ownerKey);
10489
+ if (!replayEventStore || !managedEventStore.acquireReplayLease) {
10490
+ res.setHeader("Allow", "POST");
10491
+ respondJsonRpcClientError(
10492
+ res,
10493
+ 405,
10494
+ "Stateless GET /mcp replay requires MCP_EVENT_STORE=memory or redis",
10495
+ -32e3
10496
+ );
10497
+ return;
10498
+ }
10499
+ const lastEventId = req.headers["last-event-id"];
10500
+ if (typeof lastEventId !== "string" || lastEventId.length === 0) {
10501
+ res.setHeader("Allow", "POST");
10502
+ respondJsonRpcClientError(
10503
+ res,
10504
+ 405,
10505
+ "Stateless GET /mcp only supports replay with Last-Event-ID",
10506
+ -32e3
10507
+ );
10508
+ return;
10509
+ }
10510
+ if (!req.headers.accept?.includes("text/event-stream")) {
10511
+ respondJsonRpcClientError(
10512
+ res,
10513
+ 406,
10514
+ "Not Acceptable: Client must accept text/event-stream",
10515
+ -32e3
10516
+ );
10517
+ return;
10518
+ }
10519
+ const protocolError = validateRequiredProtocolVersion(
10520
+ getMcpProtocolVersion(req)
10473
10521
  );
10474
- if (lease.status !== "acquired") {
10475
- if (lease.status === "missing") {
10522
+ if (protocolError) {
10523
+ respondJsonRpcClientError(res, 400, protocolError, -32e3);
10524
+ return;
10525
+ }
10526
+ try {
10527
+ const lease = await managedEventStore.acquireReplayLease(
10528
+ lastEventId,
10529
+ ownerKey
10530
+ );
10531
+ if (lease.status !== "acquired") {
10532
+ if (lease.status === "missing") {
10533
+ respondJsonRpcClientError(
10534
+ res,
10535
+ 400,
10536
+ "Invalid event ID format",
10537
+ -32e3
10538
+ );
10539
+ return;
10540
+ }
10476
10541
  respondJsonRpcClientError(
10477
10542
  res,
10478
- 400,
10479
- "Invalid event ID format",
10543
+ 409,
10544
+ "Conflict: Stream already has an active replay connection",
10480
10545
  -32e3
10481
10546
  );
10482
10547
  return;
10483
10548
  }
10484
- respondJsonRpcClientError(
10485
- res,
10486
- 409,
10487
- "Conflict: Stream already has an active replay connection",
10488
- -32e3
10489
- );
10490
- return;
10491
- }
10492
- const replayOperation = (async () => {
10493
- try {
10494
- res.status(200);
10495
- res.setHeader("Content-Type", "text/event-stream");
10496
- res.setHeader("Cache-Control", "no-cache, no-transform");
10497
- res.flushHeaders();
10498
- let cursor = lastEventId;
10499
- let finalResponseSeen = false;
10500
- const deadline = Date.now() + 25e3;
10501
- while (getIsReady() && !res.destroyed && !finalResponseSeen) {
10502
- await replayEventStore.replayEventsAfter(cursor, {
10503
- send: async (eventId, message) => {
10504
- cursor = eventId;
10505
- finalResponseSeen = "id" in message && ("result" in message || "error" in message);
10506
- if (!res.write(
10507
- `event: message
10549
+ const replayOperation = (async () => {
10550
+ try {
10551
+ res.status(200);
10552
+ res.setHeader("Content-Type", "text/event-stream");
10553
+ res.setHeader("Cache-Control", "no-cache, no-transform");
10554
+ res.flushHeaders();
10555
+ let cursor = lastEventId;
10556
+ let finalResponseSeen = false;
10557
+ const deadline = Date.now() + 25e3;
10558
+ while (getIsReady() && !res.destroyed && !finalResponseSeen) {
10559
+ await replayEventStore.replayEventsAfter(cursor, {
10560
+ send: async (eventId, message) => {
10561
+ cursor = eventId;
10562
+ finalResponseSeen = "id" in message && ("result" in message || "error" in message);
10563
+ if (!res.write(
10564
+ `event: message
10508
10565
  id: ${eventId}
10509
10566
  data: ${JSON.stringify(message)}
10510
10567
 
10511
10568
  `
10512
- )) {
10513
- await waitForResponseWritable(res);
10569
+ )) {
10570
+ await waitForResponseWritable(res);
10571
+ }
10514
10572
  }
10573
+ });
10574
+ if (finalResponseSeen || res.destroyed) {
10575
+ break;
10515
10576
  }
10516
- });
10517
- if (finalResponseSeen || res.destroyed) {
10518
- break;
10519
- }
10520
- if (Date.now() >= deadline) {
10521
- res.write(`id: ${cursor}
10577
+ if (Date.now() >= deadline) {
10578
+ res.write(`id: ${cursor}
10522
10579
  data:
10523
10580
 
10524
10581
  `);
10525
- break;
10526
- }
10527
- await new Promise((resolvePoll) => setTimeout(resolvePoll, 100));
10528
- }
10529
- if (!res.writableEnded && !res.destroyed) {
10530
- await new Promise((resolveResponse) => {
10531
- const done = () => resolveResponse();
10532
- res.once("finish", done);
10533
- res.once("close", done);
10534
- res.end();
10535
- });
10536
- }
10537
- } finally {
10538
- try {
10539
- await lease.release();
10540
- } catch (error) {
10541
- Logger.error("Failed to release stateless replay lease", {
10542
- metadata: {
10543
- error: error instanceof Error ? error.message : String(error)
10582
+ break;
10544
10583
  }
10545
- });
10584
+ await new Promise(
10585
+ (resolvePoll) => setTimeout(resolvePoll, 100)
10586
+ );
10587
+ }
10588
+ if (!res.writableEnded && !res.destroyed) {
10589
+ await new Promise((resolveResponse) => {
10590
+ const done = () => resolveResponse();
10591
+ res.once("finish", done);
10592
+ res.once("close", done);
10593
+ res.end();
10594
+ });
10595
+ }
10596
+ } finally {
10597
+ try {
10598
+ await lease.release();
10599
+ } catch (error) {
10600
+ Logger.error("Failed to release stateless replay lease", {
10601
+ metadata: {
10602
+ error: error instanceof Error ? error.message : String(error)
10603
+ }
10604
+ });
10605
+ }
10546
10606
  }
10547
- }
10548
- })();
10549
- activeReplayOperations.add(replayOperation);
10550
- try {
10607
+ })();
10551
10608
  await replayOperation;
10552
- } finally {
10553
- activeReplayOperations.delete(replayOperation);
10554
- }
10555
- } catch (error) {
10556
- if (res.headersSent) {
10557
- Logger.error("GET /mcp stateless replay failed", {
10558
- metadata: {
10559
- error: error instanceof Error ? error.message : String(error)
10609
+ } catch (error) {
10610
+ if (res.headersSent) {
10611
+ Logger.error("GET /mcp stateless replay failed", {
10612
+ metadata: {
10613
+ error: error instanceof Error ? error.message : String(error)
10614
+ }
10615
+ });
10616
+ if (!res.writableEnded) {
10617
+ res.end();
10560
10618
  }
10561
- });
10562
- if (!res.writableEnded) {
10563
- res.end();
10619
+ } else {
10620
+ respondJsonRpcInternalError(res, "GET /mcp stateless", error);
10564
10621
  }
10565
- } else {
10566
- respondJsonRpcInternalError(res, "GET /mcp stateless", error);
10567
10622
  }
10623
+ } finally {
10624
+ activeReplayOperations.delete(replaySlot);
10625
+ releaseReplaySlot();
10568
10626
  }
10569
10627
  return;
10570
10628
  }
@@ -10601,6 +10659,45 @@ data:
10601
10659
  return;
10602
10660
  }
10603
10661
  sessionStore.touch(sessionId);
10662
+ const lastEventId = req.headers["last-event-id"];
10663
+ if (typeof lastEventId === "string" && lastEventId.length > 0 && managedEventStore.acquireReplayLease) {
10664
+ const lease = await managedEventStore.acquireReplayLease(
10665
+ lastEventId,
10666
+ ownerKey
10667
+ );
10668
+ if (lease.status !== "acquired") {
10669
+ if (lease.status === "missing") {
10670
+ respondJsonRpcClientError(
10671
+ res,
10672
+ 400,
10673
+ "Invalid event ID format",
10674
+ -32e3
10675
+ );
10676
+ return;
10677
+ }
10678
+ respondJsonRpcClientError(
10679
+ res,
10680
+ 409,
10681
+ "Conflict: Stream already has an active replay connection",
10682
+ -32e3
10683
+ );
10684
+ return;
10685
+ }
10686
+ try {
10687
+ await transport.handleRequest(req, res);
10688
+ } finally {
10689
+ try {
10690
+ await lease.release();
10691
+ } catch (error) {
10692
+ Logger.error("Failed to release stateful replay lease", {
10693
+ metadata: {
10694
+ error: error instanceof Error ? error.message : String(error)
10695
+ }
10696
+ });
10697
+ }
10698
+ }
10699
+ return;
10700
+ }
10604
10701
  await transport.handleRequest(req, res);
10605
10702
  } else {
10606
10703
  res.status(404).json({
@@ -10674,9 +10771,9 @@ function createHttpAppRuntime() {
10674
10771
  const readyCheckMode = getReadyCheckMode();
10675
10772
  const requestBodyLimit = process.env.MCP_MAX_REQUEST_SIZE?.trim() || "1mb";
10676
10773
  const allowedOrigins = getAllowedOrigins();
10677
- const corsOriginConfig = allowedOrigins.includes(
10678
- "*"
10679
- ) ? true : allowedOrigins;
10774
+ const corsOriginConfig = (origin, callback) => {
10775
+ callback(null, validateOrigin(origin));
10776
+ };
10680
10777
  if (allowedOrigins.includes("*") && authConfig.mode === "none") {
10681
10778
  Logger.warn(
10682
10779
  "CORS wildcard (ALLOWED_ORIGINS=*) combined with MCP_AUTH_MODE=none: all origins are accepted with no authentication. Do not expose this server publicly."
@@ -10715,7 +10812,6 @@ function createHttpAppRuntime() {
10715
10812
  } : false
10716
10813
  })
10717
10814
  );
10718
- app.use(express.json({ limit: requestBodyLimit }));
10719
10815
  if (authConfig.mode === "none") {
10720
10816
  app.use(hostValidationMiddleware);
10721
10817
  }
@@ -10728,13 +10824,14 @@ function createHttpAppRuntime() {
10728
10824
  standardHeaders: false,
10729
10825
  legacyHeaders: false,
10730
10826
  validate: false,
10731
- skip: (req) => !rateLimitConfig.enabled || req.path !== "/mcp",
10827
+ skip: (req) => !rateLimitConfig.enabled || !isMcpRequestPath(req.path),
10732
10828
  message: { error: "Too Many Requests" }
10733
10829
  })
10734
10830
  );
10735
10831
  app.use(rateLimitMiddleware);
10736
10832
  app.use(mcpAuthMiddleware);
10737
10833
  app.use(principalRateLimitMiddleware);
10834
+ app.use(express.json({ limit: requestBodyLimit }));
10738
10835
  app.use(
10739
10836
  (err, _req, res, next) => {
10740
10837
  const errorType = err && typeof err === "object" && "type" in err ? err.type : void 0;
package/package.json CHANGED
@@ -11,7 +11,9 @@
11
11
  "lint": "biome lint .",
12
12
  "format": "biome format --write .",
13
13
  "check": "biome check --write .",
14
- "test": "tsx --test tests/config.test.ts tests/security.test.ts tests/contract.test.ts tests/contract-extended.test.ts tests/unit.test.ts tests/http-server.test.ts tests/tools-observability.test.ts tests/tools-catalog.test.ts tests/tools-platform.test.ts tests/tools-keys.test.ts tests/tools-users.test.ts tests/tools-analytics.test.ts tests/current-api.test.ts tests/release-readiness.test.ts tests/auth-clerk.test.ts",
14
+ "test": "tsx --test tests/config.test.ts tests/security-worker-layout.test.ts tests/security-edge-runtime.test.ts tests/contract.test.ts tests/contract-extended.test.ts tests/unit.test.ts tests/services.test.ts tests/base-services.test.ts tests/http-server.test.ts tests/http-edge-runtime.test.ts tests/http-edge-runtime-final.test.ts tests/tools-observability.test.ts tests/tools-catalog.test.ts tests/tools-prompts-configs.test.ts tests/tools-remaining.test.ts tests/tools-edge-workflows.test.ts tests/tools-platform.test.ts tests/tools-keys.test.ts tests/tools-users.test.ts tests/tools-analytics.test.ts tests/current-api.test.ts tests/release-readiness.test.ts tests/auth-clerk.test.ts tests/lib-event-store.test.ts tests/lib-session-mcp.test.ts",
15
+ "test:coverage": "tsx --test --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-coverage-lines=80 $TEST_COVERAGE_REPORTERS tests/config.test.ts tests/security-worker-layout.test.ts tests/security-edge-runtime.test.ts tests/contract.test.ts tests/contract-extended.test.ts tests/unit.test.ts tests/services.test.ts tests/base-services.test.ts tests/http-server.test.ts tests/http-edge-runtime.test.ts tests/http-edge-runtime-final.test.ts tests/tools-observability.test.ts tests/tools-catalog.test.ts tests/tools-prompts-configs.test.ts tests/tools-remaining.test.ts tests/tools-edge-workflows.test.ts tests/tools-platform.test.ts tests/tools-keys.test.ts tests/tools-users.test.ts tests/tools-analytics.test.ts tests/current-api.test.ts tests/release-readiness.test.ts tests/auth-clerk.test.ts tests/lib-event-store.test.ts tests/lib-session-mcp.test.ts",
16
+ "test:coverage:qlty": "mkdir -p coverage && TEST_COVERAGE_REPORTERS=\"--test-reporter=lcov --test-reporter-destination=$PWD/coverage/lcov.info --test-reporter=spec --test-reporter-destination=stdout\" npm run test:coverage",
15
17
  "dev": "tsx --watch src/index.ts",
16
18
  "dev:http": "tsx --watch src/server.ts",
17
19
  "smoke": "tsx tests/smoke.ts",
@@ -23,7 +25,7 @@
23
25
  "verify:tool-quality": "node scripts/check-tool-definition-quality.mjs",
24
26
  "verify:readme-tools": "node scripts/verify-readme-tools.mjs",
25
27
  "knip": "knip",
26
- "ci": "npm run lint && npm run knip && npm run typecheck && npm run typecheck:tests && npm run test && npm run build && npm run verify:tool-quality && npm run test:e2e && npm run verify:readme-tools",
28
+ "ci": "npm run lint && npm run knip && npm run typecheck && npm run typecheck:tests && npm run test:coverage && npm run build && npm run verify:tool-quality && npm run test:e2e && npm run verify:readme-tools",
27
29
  "start:http": "node build/server.js",
28
30
  "generate:lobehub-tools": "npm run build && node scripts/generate-lobehub-tools.mjs && biome format --write lhm.plugin.json",
29
31
  "update:lobehub": "npm run generate:lobehub-tools && npx -y @lobehub/market-cli plugin update --dir \"$PWD\"",
@@ -53,7 +55,7 @@
53
55
  },
54
56
  "name": "portkey-admin-mcp",
55
57
  "mcpName": "io.github.CodesWhat/portkey-admin-mcp",
56
- "version": "0.9.0",
58
+ "version": "0.10.0",
57
59
  "main": "build/index.js",
58
60
  "keywords": [
59
61
  "mcp",