portkey-admin-mcp 0.8.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/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;
@@ -2701,6 +2706,14 @@ function buildConfigPayload(params) {
2701
2706
  function hasConfigSettings(config) {
2702
2707
  return config.cache !== void 0 || config.retry !== void 0 || config.strategy !== void 0 || config.targets !== void 0;
2703
2708
  }
2709
+ var createConfigSchema = z5.object(CONFIGS_TOOL_SCHEMAS.createConfig).superRefine((value, ctx) => {
2710
+ if (!hasConfigSettings(buildConfigPayload(value))) {
2711
+ ctx.addIssue({
2712
+ code: z5.ZodIssueCode.custom,
2713
+ message: "At least one config setting (cache_mode, retry_attempts, strategy_mode, or targets) must be provided"
2714
+ });
2715
+ }
2716
+ });
2704
2717
  function registerConfigsTools(server, service) {
2705
2718
  server.tool(
2706
2719
  "list_configs",
@@ -2766,25 +2779,15 @@ function registerConfigsTools(server, service) {
2766
2779
  "Create a config that defines routing, cache, retry, and targets for requests; use update_config to modify an existing one and list_config_versions for history. At least one setting is required, new configs become active immediately once referenced by a key or prompt, and the call returns the new id and version_id.",
2767
2780
  CONFIGS_TOOL_SCHEMAS.createConfig,
2768
2781
  async (params) => {
2769
- const config = buildConfigPayload(params);
2770
- if (!hasConfigSettings(config)) {
2771
- return {
2772
- content: [
2773
- {
2774
- type: "text",
2775
- text: "Error creating configuration: At least one config setting (cache_mode, retry_attempts, strategy_mode, or targets) must be provided"
2776
- }
2777
- ],
2778
- isError: true
2779
- };
2780
- }
2782
+ const validated = createConfigSchema.parse(params);
2783
+ const config = buildConfigPayload(validated);
2781
2784
  const result = await service.configs.createConfig({
2782
- name: params.name,
2785
+ name: validated.name,
2783
2786
  config,
2784
- workspace_id: params.workspace_id
2787
+ workspace_id: validated.workspace_id
2785
2788
  });
2786
2789
  return jsonResult({
2787
- message: `Successfully created configuration "${params.name}"`,
2790
+ message: `Successfully created configuration "${validated.name}"`,
2788
2791
  id: result.id,
2789
2792
  version_id: result.version_id
2790
2793
  });
@@ -3413,6 +3416,16 @@ var INTEGRATIONS_TOOL_SCHEMAS = {
3413
3416
  default_provider_slug: z8.string().optional().describe("Default slug for providers auto-created in workspaces")
3414
3417
  }
3415
3418
  };
3419
+ var updateIntegrationWorkspacesSchema = z8.object(INTEGRATIONS_TOOL_SCHEMAS.updateIntegrationWorkspaces).superRefine((value, ctx) => {
3420
+ const hasGlobalLimits = value.global_credit_limit !== void 0 || value.global_alert_threshold !== void 0 || value.global_rate_limit_rpm !== void 0;
3421
+ if (hasGlobalLimits && value.global_workspace_access_enabled === void 0) {
3422
+ ctx.addIssue({
3423
+ code: z8.ZodIssueCode.custom,
3424
+ path: ["global_workspace_access_enabled"],
3425
+ message: "global_workspace_access_enabled is required when setting global limits."
3426
+ });
3427
+ }
3428
+ });
3416
3429
  function buildIntegrationConfigurations(params) {
3417
3430
  const configurations = {};
3418
3431
  if (params.api_version !== void 0)
@@ -3690,19 +3703,8 @@ function registerIntegrationsTools(server, service) {
3690
3703
  idempotentHint: true,
3691
3704
  openWorldHint: true
3692
3705
  },
3693
- async (params) => {
3694
- const hasGlobalLimits = params.global_credit_limit !== void 0 || params.global_alert_threshold !== void 0 || params.global_rate_limit_rpm !== void 0;
3695
- if (hasGlobalLimits && params.global_workspace_access_enabled === void 0) {
3696
- return {
3697
- content: [
3698
- {
3699
- type: "text",
3700
- text: "global_workspace_access_enabled is required when setting global limits."
3701
- }
3702
- ],
3703
- isError: true
3704
- };
3705
- }
3706
+ async (rawParams) => {
3707
+ const params = updateIntegrationWorkspacesSchema.parse(rawParams);
3706
3708
  const globalUsageLimits = params.global_credit_limit !== void 0 || params.global_alert_threshold !== void 0 ? [
3707
3709
  {
3708
3710
  type: "cost",
@@ -4180,23 +4182,21 @@ var LABELS_TOOL_SCHEMAS = {
4180
4182
  label_id: z10.string().describe("Label ID to delete")
4181
4183
  }
4182
4184
  };
4185
+ var createPromptLabelSchema = z10.object(LABELS_TOOL_SCHEMAS.createPromptLabel).superRefine((value, ctx) => {
4186
+ if (!value.organisation_id && !value.workspace_id) {
4187
+ ctx.addIssue({
4188
+ code: z10.ZodIssueCode.custom,
4189
+ message: "Either organisation_id or workspace_id is required"
4190
+ });
4191
+ }
4192
+ });
4183
4193
  function registerLabelsTools(server, service) {
4184
4194
  server.tool(
4185
4195
  "create_prompt_label",
4186
4196
  "Create a prompt label for tagging prompt versions such as production, staging, or experiment. Requires either organisation_id or workspace_id to set scope, returns the new label id, and does not assign it to any versions yet.",
4187
4197
  LABELS_TOOL_SCHEMAS.createPromptLabel,
4188
- async (params) => {
4189
- if (!params.organisation_id && !params.workspace_id) {
4190
- return {
4191
- content: [
4192
- {
4193
- type: "text",
4194
- text: "Error: Either organisation_id or workspace_id is required"
4195
- }
4196
- ],
4197
- isError: true
4198
- };
4199
- }
4198
+ async (rawParams) => {
4199
+ const params = createPromptLabelSchema.parse(rawParams);
4200
4200
  const result = await service.labels.createLabel({
4201
4201
  name: params.name,
4202
4202
  organisation_id: params.organisation_id,
@@ -4606,12 +4606,13 @@ var logExportFieldSchema = z12.enum([
4606
4606
  "prompt_slug",
4607
4607
  "metadata"
4608
4608
  ]);
4609
- var getLogInputSchema = z12.object({
4609
+ var getLogShape = {
4610
4610
  log_id: z12.string().describe("Gateway log ID to retrieve"),
4611
4611
  path_format: z12.enum(["v1", "v2"]).optional().describe("Log storage path format; defaults to v1"),
4612
4612
  created_at: z12.iso.datetime({ offset: true }).optional().describe("Log creation timestamp in ISO 8601 format; required for v2"),
4613
4613
  type: z12.enum(["hooks"]).optional().describe("Set to hooks when retrieving a hook execution log")
4614
- }).superRefine((input, context) => {
4614
+ };
4615
+ var getLogSchema = z12.object(getLogShape).superRefine((input, context) => {
4615
4616
  if (input.path_format === "v2" && input.created_at === void 0) {
4616
4617
  context.addIssue({
4617
4618
  code: "custom",
@@ -4692,7 +4693,7 @@ function registerLoggingTools(server, service) {
4692
4693
  "get_log",
4693
4694
  {
4694
4695
  description: "Get one gateway request log by ID, including its request, response, usage, cost, and metadata payload when available. For path_format v2, also provide the log's created_at timestamp.",
4695
- inputSchema: getLogInputSchema,
4696
+ inputSchema: getLogShape,
4696
4697
  annotations: {
4697
4698
  title: "Get Gateway Log",
4698
4699
  readOnlyHint: true,
@@ -4701,7 +4702,8 @@ function registerLoggingTools(server, service) {
4701
4702
  openWorldHint: true
4702
4703
  }
4703
4704
  },
4704
- async (params) => {
4705
+ async (rawParams) => {
4706
+ const params = getLogSchema.parse(rawParams);
4705
4707
  const result = await service.logging.getLog(params.log_id, {
4706
4708
  path_format: params.path_format,
4707
4709
  created_at: params.created_at,
@@ -4993,6 +4995,17 @@ var MCP_INTEGRATIONS_TOOL_SCHEMAS = {
4993
4995
  ).min(1).describe("Array of workspace access updates")
4994
4996
  }
4995
4997
  };
4998
+ var createMcpIntegrationSchema = z13.object(MCP_INTEGRATIONS_TOOL_SCHEMAS.createMcpIntegration).superRefine((value, ctx) => {
4999
+ if (value.auth_type === "headers" && (!value.custom_headers || Object.keys(value.custom_headers).length === 0) && !value.secret_mappings?.some(
5000
+ (mapping) => mapping.target_field === "configurations.custom_headers"
5001
+ )) {
5002
+ ctx.addIssue({
5003
+ code: z13.ZodIssueCode.custom,
5004
+ path: ["custom_headers"],
5005
+ message: "custom_headers must be provided when auth_type is 'headers'"
5006
+ });
5007
+ }
5008
+ });
4996
5009
  function isRecord2(value) {
4997
5010
  return typeof value === "object" && value !== null;
4998
5011
  }
@@ -5071,20 +5084,8 @@ function registerMcpIntegrationsTools(server, service) {
5071
5084
  idempotentHint: false,
5072
5085
  openWorldHint: true
5073
5086
  },
5074
- async (params) => {
5075
- if (params.auth_type === "headers" && (!params.custom_headers || Object.keys(params.custom_headers).length === 0) && !params.secret_mappings?.some(
5076
- (mapping) => mapping.target_field === "configurations.custom_headers"
5077
- )) {
5078
- return {
5079
- content: [
5080
- {
5081
- type: "text",
5082
- text: "Error: custom_headers must be provided when auth_type is 'headers'"
5083
- }
5084
- ],
5085
- isError: true
5086
- };
5087
- }
5087
+ async (rawParams) => {
5088
+ const params = createMcpIntegrationSchema.parse(rawParams);
5088
5089
  const { custom_headers, ...rest } = params;
5089
5090
  const result = await service.mcpIntegrations.createMcpIntegration({
5090
5091
  ...rest,
@@ -5921,6 +5922,28 @@ var PROMPTS_TOOL_SCHEMAS = {
5921
5922
  )
5922
5923
  }
5923
5924
  };
5925
+ var createPromptSchema = z17.object(PROMPTS_TOOL_SCHEMAS.createPrompt).superRefine((value, ctx) => {
5926
+ if (!value.model && !value.ai_model_id && !value.finetune_id) {
5927
+ ctx.addIssue({
5928
+ code: z17.ZodIssueCode.custom,
5929
+ message: "At least one of model, ai_model_id, or finetune_id must be provided"
5930
+ });
5931
+ }
5932
+ if (value.string === void 0 && value.messages === void 0) {
5933
+ ctx.addIssue({
5934
+ code: z17.ZodIssueCode.custom,
5935
+ message: "Provide either string or messages"
5936
+ });
5937
+ }
5938
+ });
5939
+ var migratePromptSchema = z17.object(PROMPTS_TOOL_SCHEMAS.migratePrompt).superRefine((value, ctx) => {
5940
+ if (value.string === void 0 && value.messages === void 0) {
5941
+ ctx.addIssue({
5942
+ code: z17.ZodIssueCode.custom,
5943
+ message: "Provide either string or messages"
5944
+ });
5945
+ }
5946
+ });
5924
5947
  function normalizePromptTemplateString(params) {
5925
5948
  if (params.string !== void 0) {
5926
5949
  return params.string;
@@ -5995,30 +6018,9 @@ function registerPromptsTools(server, service) {
5995
6018
  "create_prompt",
5996
6019
  "Create a new prompt template and initial version. Use this for first-time setup; use migrate_prompt for idempotent CI/CD flows. Accepts plain text or structured chat messages, creates a new version immediately, and returns the prompt id, slug, and version id. For multi-message chat prompts pass messages (preferred) or a JSON-encoded array as string.",
5997
6020
  PROMPTS_TOOL_SCHEMAS.createPrompt,
5998
- async (params) => {
5999
- if (!params.model && !params.ai_model_id && !params.finetune_id) {
6000
- return {
6001
- content: [
6002
- {
6003
- type: "text",
6004
- text: "Error creating prompt: At least one of model, ai_model_id, or finetune_id must be provided"
6005
- }
6006
- ],
6007
- isError: true
6008
- };
6009
- }
6021
+ async (rawParams) => {
6022
+ const params = createPromptSchema.parse(rawParams);
6010
6023
  const templateString = normalizePromptTemplateString(params);
6011
- if (templateString === void 0) {
6012
- return {
6013
- content: [
6014
- {
6015
- type: "text",
6016
- text: "Error creating prompt: Provide either string or messages"
6017
- }
6018
- ],
6019
- isError: true
6020
- };
6021
- }
6022
6024
  if (params.dry_run) {
6023
6025
  return jsonResult({
6024
6026
  dry_run: true,
@@ -6128,14 +6130,18 @@ function registerPromptsTools(server, service) {
6128
6130
  });
6129
6131
  if (dry_run) {
6130
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
+ }
6131
6139
  return jsonResult({
6132
6140
  dry_run: true,
6133
6141
  action: "update",
6134
6142
  message: `Would update prompt "${current.name}"`,
6135
6143
  current_version: current.current_version?.version_number ?? null,
6136
- changes: Object.keys(updateData).filter(
6137
- (k) => updateData[k] !== void 0
6138
- )
6144
+ changes
6139
6145
  });
6140
6146
  }
6141
6147
  const result = await service.prompts.updatePrompt(prompt_id, {
@@ -6273,19 +6279,9 @@ function registerPromptsTools(server, service) {
6273
6279
  idempotentHint: true,
6274
6280
  openWorldHint: false
6275
6281
  },
6276
- async (params) => {
6282
+ async (rawParams) => {
6283
+ const params = migratePromptSchema.parse(rawParams);
6277
6284
  const templateString = normalizePromptTemplateString(params);
6278
- if (templateString === void 0) {
6279
- return {
6280
- content: [
6281
- {
6282
- type: "text",
6283
- text: "Error migrating prompt: Provide either string or messages"
6284
- }
6285
- ],
6286
- isError: true
6287
- };
6288
- }
6289
6285
  const result = await service.prompts.migratePrompt({
6290
6286
  name: params.name,
6291
6287
  app: params.app,
@@ -7251,7 +7247,7 @@ var scimWorkspaceMappingBaseShape = {
7251
7247
  workspace_id: z22.string().describe("Portkey workspace ID that the SCIM group should access"),
7252
7248
  role: z22.enum(["admin", "member", "manager"]).describe("Workspace role automatically granted to group members")
7253
7249
  };
7254
- var createScimWorkspaceMappingInputSchema = z22.object({
7250
+ var createScimWorkspaceMappingShape = {
7255
7251
  ...scimWorkspaceMappingBaseShape,
7256
7252
  scim_group_id: z22.string().min(1).optional().describe(
7257
7253
  "Existing identity-provider SCIM group ID; provide this or scim_group_name, but not both"
@@ -7259,7 +7255,8 @@ var createScimWorkspaceMappingInputSchema = z22.object({
7259
7255
  scim_group_name: z22.string().min(1).optional().describe(
7260
7256
  "Existing SCIM group display name; provide this or scim_group_id, but not both"
7261
7257
  )
7262
- }).superRefine((input, context) => {
7258
+ };
7259
+ var createScimWorkspaceMappingSchema = z22.object(createScimWorkspaceMappingShape).superRefine((input, context) => {
7263
7260
  if (Boolean(input.scim_group_id) === Boolean(input.scim_group_name)) {
7264
7261
  context.addIssue({
7265
7262
  code: "custom",
@@ -7414,7 +7411,7 @@ function registerWorkspacesTools(server, service) {
7414
7411
  "create_scim_workspace_mapping",
7415
7412
  {
7416
7413
  description: "Map one identity-provider SCIM group to a Portkey workspace role so current and future group members receive access automatically. Provide exactly one of scim_group_id or scim_group_name; a name can pre-create the Portkey SCIM group before the IdP provisions it. Use list_scim_groups to discover existing groups and list_workspaces for the workspace ID. This changes access provisioning and is distinct from add_workspace_member, which grants one user directly.",
7417
- inputSchema: createScimWorkspaceMappingInputSchema,
7414
+ inputSchema: createScimWorkspaceMappingShape,
7418
7415
  annotations: {
7419
7416
  title: "Create SCIM Workspace Mapping",
7420
7417
  readOnlyHint: false,
@@ -7423,7 +7420,8 @@ function registerWorkspacesTools(server, service) {
7423
7420
  openWorldHint: true
7424
7421
  }
7425
7422
  },
7426
- async (params) => {
7423
+ async (rawParams) => {
7424
+ const params = createScimWorkspaceMappingSchema.parse(rawParams);
7427
7425
  let request;
7428
7426
  if (params.scim_group_id !== void 0) {
7429
7427
  request = {
@@ -7784,7 +7782,25 @@ function augmentToolDescription(toolName, description) {
7784
7782
  }
7785
7783
  return `${description} ${ENTERPRISE_GATED_DESCRIPTION_NOTE}`;
7786
7784
  }
7785
+ function formatZodIssues(error) {
7786
+ return error.issues.map((issue) => {
7787
+ const path = issue.path.join(".");
7788
+ if (!path) {
7789
+ return issue.message;
7790
+ }
7791
+ const namesPath = new RegExp(`\\b${escapeRegExp(path)}\\b`).test(
7792
+ issue.message
7793
+ );
7794
+ return namesPath ? issue.message : `${path}: ${issue.message}`;
7795
+ }).join("; ");
7796
+ }
7797
+ function escapeRegExp(value) {
7798
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7799
+ }
7787
7800
  function getToolErrorMessage(error) {
7801
+ if (error instanceof z23.ZodError) {
7802
+ return formatZodIssues(error);
7803
+ }
7788
7804
  if (error instanceof Error && error.message) {
7789
7805
  return error.message;
7790
7806
  }
@@ -7931,14 +7947,14 @@ function wrapToolCallback(toolName, callback) {
7931
7947
  return normalizeToolResult(await callback(...args));
7932
7948
  } catch (error) {
7933
7949
  const message = getToolErrorMessage(error);
7950
+ const isValidationError = error instanceof z23.ZodError;
7951
+ const text = isValidationError ? `Invalid arguments for "${toolName}": ${message}` : `Tool "${toolName}" failed: ${message}`;
7934
7952
  Logger.error("Tool callback failed", {
7935
7953
  error: message,
7936
7954
  metadata: { toolName }
7937
7955
  });
7938
7956
  return normalizeToolResult({
7939
- content: [
7940
- { type: "text", text: `Tool "${toolName}" failed: ${message}` }
7941
- ],
7957
+ content: [{ type: "text", text }],
7942
7958
  isError: true
7943
7959
  });
7944
7960
  }
@@ -8079,6 +8095,21 @@ function registerAllTools(server, service, options = {}) {
8079
8095
  // src/lib/auth.ts
8080
8096
  import crypto2 from "node:crypto";
8081
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
8082
8113
  function getPrincipalOwnerKey(principal) {
8083
8114
  return crypto2.createHash("sha256").update(principal.id, "utf8").digest("hex");
8084
8115
  }
@@ -8299,7 +8330,7 @@ async function verifyClerkToken(token, config) {
8299
8330
  return authorizeClerkClaims(payload, config);
8300
8331
  }
8301
8332
  async function mcpAuthMiddleware(req, res, next) {
8302
- if (!req.path.startsWith("/mcp")) {
8333
+ if (!isMcpRequestPath(req.path)) {
8303
8334
  next();
8304
8335
  return;
8305
8336
  }
@@ -8428,6 +8459,15 @@ function getServerConfig() {
8428
8459
  `Invalid MCP_EVENT_TTL_SECONDS value: ${eventStoreTtlStr}. Must be a positive integer`
8429
8460
  );
8430
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
+ }
8431
8471
  const redisUrl = process.env.MCP_REDIS_URL?.trim() || process.env.REDIS_URL?.trim();
8432
8472
  if (eventStoreMode === "redis" && !redisUrl) {
8433
8473
  throw new Error(
@@ -8487,7 +8527,8 @@ function getServerConfig() {
8487
8527
  ttlSeconds: eventStoreTtlSeconds,
8488
8528
  redisUrl,
8489
8529
  redisKeyPrefix,
8490
- encryptionKey: eventEncryptionKey
8530
+ encryptionKey: eventEncryptionKey,
8531
+ commandTimeoutMs: eventStoreCommandTimeoutMs
8491
8532
  },
8492
8533
  protocol,
8493
8534
  port,
@@ -8719,13 +8760,15 @@ var RedisEventStore = class _RedisEventStore {
8719
8760
  keyPrefix;
8720
8761
  ownerKey;
8721
8762
  encryptionKey;
8763
+ commandTimeoutMs;
8722
8764
  connection;
8723
- constructor(redisUrl, keyPrefix, ttlSeconds, ownerKey, encryptionKey, connection = {}) {
8765
+ constructor(redisUrl, keyPrefix, ttlSeconds, ownerKey, encryptionKey, commandTimeoutMs, connection = {}) {
8724
8766
  this.redisUrl = redisUrl;
8725
8767
  this.ttlSeconds = ttlSeconds;
8726
8768
  this.keyPrefix = keyPrefix;
8727
8769
  this.ownerKey = ownerKey;
8728
8770
  this.encryptionKey = encryptionKey;
8771
+ this.commandTimeoutMs = commandTimeoutMs;
8729
8772
  this.connection = connection;
8730
8773
  }
8731
8774
  get client() {
@@ -8741,6 +8784,7 @@ var RedisEventStore = class _RedisEventStore {
8741
8784
  this.ttlSeconds,
8742
8785
  ownerKey,
8743
8786
  this.encryptionKey,
8787
+ this.commandTimeoutMs,
8744
8788
  this.connection
8745
8789
  );
8746
8790
  }
@@ -8762,11 +8806,19 @@ var RedisEventStore = class _RedisEventStore {
8762
8806
  this.connection.client = createClient({
8763
8807
  url: this.redisUrl,
8764
8808
  // node-redis v6 defaults to RESP3, a 5-second command timeout, and a
8765
- // 30-second keepalive delay. Keep the event store's v5 behavior stable
8766
- // 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.
8767
8817
  RESP: 2,
8768
8818
  socket: { keepAliveInitialDelay: 5e3 },
8769
- commandOptions: { timeout: void 0 }
8819
+ commandOptions: {
8820
+ timeout: this.commandTimeoutMs === 0 ? void 0 : this.commandTimeoutMs
8821
+ }
8770
8822
  });
8771
8823
  this.connection.client.on("error", (error) => {
8772
8824
  Logger.error("Redis event store error", {
@@ -8946,7 +8998,8 @@ function createManagedEventStore(config) {
8946
8998
  config.eventStore.redisKeyPrefix,
8947
8999
  config.eventStore.ttlSeconds,
8948
9000
  "internal",
8949
- encryptionKey
9001
+ encryptionKey,
9002
+ config.eventStore.commandTimeoutMs
8950
9003
  );
8951
9004
  return {
8952
9005
  mode: "redis",
@@ -9222,7 +9275,7 @@ function isAllowedHost(host) {
9222
9275
  });
9223
9276
  }
9224
9277
  function originValidationMiddleware(req, res, next) {
9225
- if (req.path === "/health" || req.path === "/ready") {
9278
+ if (isHealthOrReadyPath(req.path)) {
9226
9279
  next();
9227
9280
  return;
9228
9281
  }
@@ -9239,7 +9292,7 @@ function originValidationMiddleware(req, res, next) {
9239
9292
  next();
9240
9293
  }
9241
9294
  function hostValidationMiddleware(req, res, next) {
9242
- if (req.path === "/health" || req.path === "/ready") {
9295
+ if (isHealthOrReadyPath(req.path)) {
9243
9296
  next();
9244
9297
  return;
9245
9298
  }
@@ -9371,23 +9424,26 @@ function resolveRateLimitRedisUrl(store) {
9371
9424
  }
9372
9425
  return redisUrl;
9373
9426
  }
9374
- var rateLimitEnabled = process.env.RATE_LIMIT_ENABLED?.trim().toLowerCase() !== "false";
9375
- var rateLimitStore = resolveRateLimitStore();
9376
- if (rateLimitEnabled && rateLimitStore === "memory" && process.env.NODE_ENV?.trim().toLowerCase() === "production" && !isExplicitlyEnabled2(process.env.RATE_LIMIT_SINGLE_PROCESS)) {
9377
- throw new Error(
9378
- "Production in-memory rate limiting requires RATE_LIMIT_SINGLE_PROCESS=true. Use RATE_LIMIT_STORE=redis for multi-instance deployments."
9379
- );
9380
- }
9381
- var RATE_LIMIT_CONFIG = {
9382
- enabled: rateLimitEnabled,
9383
- store: rateLimitStore,
9384
- maxTokens: parsePositiveIntegerEnv("RATE_LIMIT_MAX", 60),
9385
- windowMs: parsePositiveIntegerEnv("RATE_LIMIT_WINDOW_MS", 6e4),
9386
- refillRate: parsePositiveIntegerEnv("RATE_LIMIT_REFILL", 60),
9387
- maxBuckets: parsePositiveIntegerEnv("RATE_LIMIT_MAX_BUCKETS", 1e4),
9388
- redisUrl: resolveRateLimitRedisUrl(rateLimitStore),
9389
- redisKeyPrefix: process.env.RATE_LIMIT_REDIS_KEY_PREFIX?.trim() || "mcp:rate-limit"
9390
- };
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();
9391
9447
  function getRateLimitConfig() {
9392
9448
  return RATE_LIMIT_CONFIG;
9393
9449
  }
@@ -9520,11 +9576,11 @@ function applyRateLimit(scope, req, res, next) {
9520
9576
  next();
9521
9577
  return;
9522
9578
  }
9523
- if (req.path === "/health" || req.path === "/ready") {
9579
+ if (isHealthOrReadyPath(req.path)) {
9524
9580
  next();
9525
9581
  return;
9526
9582
  }
9527
- if (scope === "authentication" && req.path !== "/mcp") {
9583
+ if (scope === "authentication" && !isMcpRequestPath(req.path)) {
9528
9584
  next();
9529
9585
  return;
9530
9586
  }
@@ -10378,14 +10434,16 @@ function createMcpPostHandler(deps) {
10378
10434
  try {
10379
10435
  await transport.handleRequest(req, res, req.body);
10380
10436
  } catch (error) {
10381
- if (hasReservedSessionSlot) {
10382
- sessionStore.releaseReservation();
10383
- }
10384
10437
  respondJsonRpcInternalError(
10385
10438
  res,
10386
10439
  sessionId ? "POST /mcp stateful session" : "POST /mcp stateful initialize",
10387
10440
  error
10388
10441
  );
10442
+ } finally {
10443
+ if (hasReservedSessionSlot) {
10444
+ sessionStore.releaseReservation();
10445
+ hasReservedSessionSlot = false;
10446
+ }
10389
10447
  }
10390
10448
  };
10391
10449
  }
@@ -10421,143 +10479,150 @@ function createMcpGetHandler(deps) {
10421
10479
  );
10422
10480
  return;
10423
10481
  }
10424
- const replayEventStore = managedEventStore.eventStoreForOwner(ownerKey);
10425
- if (!replayEventStore || !managedEventStore.acquireReplayLease) {
10426
- res.setHeader("Allow", "POST");
10427
- respondJsonRpcClientError(
10428
- res,
10429
- 405,
10430
- "Stateless GET /mcp replay requires MCP_EVENT_STORE=memory or redis",
10431
- -32e3
10432
- );
10433
- return;
10434
- }
10435
- const lastEventId = req.headers["last-event-id"];
10436
- if (typeof lastEventId !== "string" || lastEventId.length === 0) {
10437
- res.setHeader("Allow", "POST");
10438
- respondJsonRpcClientError(
10439
- res,
10440
- 405,
10441
- "Stateless GET /mcp only supports replay with Last-Event-ID",
10442
- -32e3
10443
- );
10444
- return;
10445
- }
10446
- if (!req.headers.accept?.includes("text/event-stream")) {
10447
- respondJsonRpcClientError(
10448
- res,
10449
- 406,
10450
- "Not Acceptable: Client must accept text/event-stream",
10451
- -32e3
10452
- );
10453
- return;
10454
- }
10455
- const protocolError = validateRequiredProtocolVersion(
10456
- getMcpProtocolVersion(req)
10457
- );
10458
- if (protocolError) {
10459
- respondJsonRpcClientError(res, 400, protocolError, -32e3);
10460
- return;
10461
- }
10482
+ let releaseReplaySlot;
10483
+ const replaySlot = new Promise((resolve) => {
10484
+ releaseReplaySlot = resolve;
10485
+ });
10486
+ activeReplayOperations.add(replaySlot);
10462
10487
  try {
10463
- const lease = await managedEventStore.acquireReplayLease(
10464
- lastEventId,
10465
- 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)
10466
10521
  );
10467
- if (lease.status !== "acquired") {
10468
- 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
+ }
10469
10541
  respondJsonRpcClientError(
10470
10542
  res,
10471
- 400,
10472
- "Invalid event ID format",
10543
+ 409,
10544
+ "Conflict: Stream already has an active replay connection",
10473
10545
  -32e3
10474
10546
  );
10475
10547
  return;
10476
10548
  }
10477
- respondJsonRpcClientError(
10478
- res,
10479
- 409,
10480
- "Conflict: Stream already has an active replay connection",
10481
- -32e3
10482
- );
10483
- return;
10484
- }
10485
- const replayOperation = (async () => {
10486
- try {
10487
- res.status(200);
10488
- res.setHeader("Content-Type", "text/event-stream");
10489
- res.setHeader("Cache-Control", "no-cache, no-transform");
10490
- res.flushHeaders();
10491
- let cursor = lastEventId;
10492
- let finalResponseSeen = false;
10493
- const deadline = Date.now() + 25e3;
10494
- while (getIsReady() && !res.destroyed && !finalResponseSeen) {
10495
- await replayEventStore.replayEventsAfter(cursor, {
10496
- send: async (eventId, message) => {
10497
- cursor = eventId;
10498
- finalResponseSeen = "id" in message && ("result" in message || "error" in message);
10499
- if (!res.write(
10500
- `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
10501
10565
  id: ${eventId}
10502
10566
  data: ${JSON.stringify(message)}
10503
10567
 
10504
10568
  `
10505
- )) {
10506
- await waitForResponseWritable(res);
10569
+ )) {
10570
+ await waitForResponseWritable(res);
10571
+ }
10507
10572
  }
10573
+ });
10574
+ if (finalResponseSeen || res.destroyed) {
10575
+ break;
10508
10576
  }
10509
- });
10510
- if (finalResponseSeen || res.destroyed) {
10511
- break;
10512
- }
10513
- if (Date.now() >= deadline) {
10514
- res.write(`id: ${cursor}
10577
+ if (Date.now() >= deadline) {
10578
+ res.write(`id: ${cursor}
10515
10579
  data:
10516
10580
 
10517
10581
  `);
10518
- break;
10519
- }
10520
- await new Promise((resolvePoll) => setTimeout(resolvePoll, 100));
10521
- }
10522
- if (!res.writableEnded && !res.destroyed) {
10523
- await new Promise((resolveResponse) => {
10524
- const done = () => resolveResponse();
10525
- res.once("finish", done);
10526
- res.once("close", done);
10527
- res.end();
10528
- });
10529
- }
10530
- } finally {
10531
- try {
10532
- await lease.release();
10533
- } catch (error) {
10534
- Logger.error("Failed to release stateless replay lease", {
10535
- metadata: {
10536
- error: error instanceof Error ? error.message : String(error)
10582
+ break;
10537
10583
  }
10538
- });
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
+ }
10539
10606
  }
10540
- }
10541
- })();
10542
- activeReplayOperations.add(replayOperation);
10543
- try {
10607
+ })();
10544
10608
  await replayOperation;
10545
- } finally {
10546
- activeReplayOperations.delete(replayOperation);
10547
- }
10548
- } catch (error) {
10549
- if (res.headersSent) {
10550
- Logger.error("GET /mcp stateless replay failed", {
10551
- metadata: {
10552
- 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();
10553
10618
  }
10554
- });
10555
- if (!res.writableEnded) {
10556
- res.end();
10619
+ } else {
10620
+ respondJsonRpcInternalError(res, "GET /mcp stateless", error);
10557
10621
  }
10558
- } else {
10559
- respondJsonRpcInternalError(res, "GET /mcp stateless", error);
10560
10622
  }
10623
+ } finally {
10624
+ activeReplayOperations.delete(replaySlot);
10625
+ releaseReplaySlot();
10561
10626
  }
10562
10627
  return;
10563
10628
  }
@@ -10594,6 +10659,45 @@ data:
10594
10659
  return;
10595
10660
  }
10596
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
+ }
10597
10701
  await transport.handleRequest(req, res);
10598
10702
  } else {
10599
10703
  res.status(404).json({
@@ -10667,9 +10771,9 @@ function createHttpAppRuntime() {
10667
10771
  const readyCheckMode = getReadyCheckMode();
10668
10772
  const requestBodyLimit = process.env.MCP_MAX_REQUEST_SIZE?.trim() || "1mb";
10669
10773
  const allowedOrigins = getAllowedOrigins();
10670
- const corsOriginConfig = allowedOrigins.includes(
10671
- "*"
10672
- ) ? true : allowedOrigins;
10774
+ const corsOriginConfig = (origin, callback) => {
10775
+ callback(null, validateOrigin(origin));
10776
+ };
10673
10777
  if (allowedOrigins.includes("*") && authConfig.mode === "none") {
10674
10778
  Logger.warn(
10675
10779
  "CORS wildcard (ALLOWED_ORIGINS=*) combined with MCP_AUTH_MODE=none: all origins are accepted with no authentication. Do not expose this server publicly."
@@ -10708,7 +10812,6 @@ function createHttpAppRuntime() {
10708
10812
  } : false
10709
10813
  })
10710
10814
  );
10711
- app.use(express.json({ limit: requestBodyLimit }));
10712
10815
  if (authConfig.mode === "none") {
10713
10816
  app.use(hostValidationMiddleware);
10714
10817
  }
@@ -10721,13 +10824,14 @@ function createHttpAppRuntime() {
10721
10824
  standardHeaders: false,
10722
10825
  legacyHeaders: false,
10723
10826
  validate: false,
10724
- skip: (req) => !rateLimitConfig.enabled || req.path !== "/mcp",
10827
+ skip: (req) => !rateLimitConfig.enabled || !isMcpRequestPath(req.path),
10725
10828
  message: { error: "Too Many Requests" }
10726
10829
  })
10727
10830
  );
10728
10831
  app.use(rateLimitMiddleware);
10729
10832
  app.use(mcpAuthMiddleware);
10730
10833
  app.use(principalRateLimitMiddleware);
10834
+ app.use(express.json({ limit: requestBodyLimit }));
10731
10835
  app.use(
10732
10836
  (err, _req, res, next) => {
10733
10837
  const errorType = err && typeof err === "object" && "type" in err ? err.type : void 0;