portkey-admin-mcp 0.3.2 → 0.3.4

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 @@ MCP server for the [Portkey](https://portkey.ai/) Admin API. Manage prompts, con
15
15
  <a href="https://github.com/s-b-e-n-s-o-n/portkey-admin-mcp/actions/workflows/ci.yml"><img src="https://github.com/s-b-e-n-s-o-n/portkey-admin-mcp/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
16
16
  <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="Node.js"></a>
17
17
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
18
+ <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
+ <a href="https://lobehub.com/mcp/s-b-e-n-s-o-n-portkey-admin-mcp"><img src="https://lobehub.com/badge/mcp/s-b-e-n-s-o-n-portkey-admin-mcp?style=flat" alt="LobeHub MCP"></a>
18
20
 
19
21
  <a href="https://glama.ai/mcp/servers/s-b-e-n-s-o-n/portkey-admin-mcp"><img src="https://glama.ai/mcp/servers/s-b-e-n-s-o-n/portkey-admin-mcp/badges/card.svg" alt="portkey-admin-mcp MCP server"></a>
20
22
 
@@ -22,6 +24,9 @@ MCP server for the [Portkey](https://portkey.ai/) Admin API. Manage prompts, con
22
24
 
23
25
  ---
24
26
 
27
+ > [!IMPORTANT]
28
+ > **Maintenance mode.** Portkey was acquired by **Palo Alto Networks** (completed 2026‑05‑29) and is being folded into the Prisma AIRS platform. The Portkey Admin API this server targets is **live and unchanged as of June 2026**, and this project still works end‑to‑end — but it is now in **maintenance mode**: security and dependency patches only, no new features, pending Palo Alto's post‑acquisition API roadmap. If the hosted Admin API is ever deprecated, point `PORTKEY_BASE_URL` at a self‑hosted [Portkey gateway](https://github.com/Portkey-AI/gateway). See [docs/audit-2026-06.md](./docs/audit-2026-06.md) for the full assessment.
29
+
25
30
  ## Quick Start
26
31
 
27
32
  You need a **Portkey API key** with appropriate scopes. Get one from your [Portkey dashboard](https://app.portkey.ai/) under API Keys.
@@ -175,6 +180,8 @@ For local-only HTTP use, leave `MCP_HOST` at its default `127.0.0.1`. Set `MCP_H
175
180
  | Variable | Default | Description |
176
181
  |----------|---------|-------------|
177
182
  | `PORTKEY_API_KEY` | (required) | Your Portkey API key |
183
+ | `PORTKEY_BASE_URL` | `https://api.portkey.ai/v1` | Portkey Admin API base URL. Point at a self-hosted Portkey gateway if needed. Loopback/private-network hosts are rejected unless `PORTKEY_ALLOW_PRIVATE_BASE_URL=true` |
184
+ | `PORTKEY_ALLOW_PRIVATE_BASE_URL` | — | Set to `true` to allow a `PORTKEY_BASE_URL` on loopback or a private network (e.g. a self-hosted gateway at `http://localhost:8787`) |
178
185
  | `PORTKEY_TOOL_DOMAINS` | — | Optional comma-separated stdio/HTTP default tool subset, e.g. `prompts,analytics` |
179
186
  | `MCP_HOST` | `127.0.0.1` | Bind address |
180
187
  | `MCP_PORT` | `3000` | Port |
@@ -188,7 +195,7 @@ For local-only HTTP use, leave `MCP_HOST` at its default `127.0.0.1`. Set `MCP_H
188
195
  | `MCP_REDIS_URL` | — | Redis URL for shared event store |
189
196
  | `MCP_TLS_KEY_PATH` | — | TLS key for native HTTPS |
190
197
  | `MCP_TLS_CERT_PATH` | — | TLS cert for native HTTPS |
191
- | `ALLOWED_ORIGINS` | — | CORS allow-list |
198
+ | `ALLOWED_ORIGINS` | — | CORS allow-list; also used to validate the `Host` header (DNS-rebinding protection) when `MCP_AUTH_MODE=none` |
192
199
  | `MCP_TRUST_PROXY` | `false` | Trust proxy headers (for reverse proxies) |
193
200
  | `RATE_LIMIT_MAX_BUCKETS` | `10000` | Maximum distinct in-memory rate-limit buckets before new clients share an overflow bucket |
194
201
 
package/build/index.js CHANGED
@@ -118,18 +118,73 @@ var Logger = {
118
118
 
119
119
  // src/services/base.service.ts
120
120
  var DEFAULT_BASE_URL = "https://api.portkey.ai/v1";
121
+ var PRIVATE_BASE_URL_OVERRIDE_HINT = "Set PORTKEY_ALLOW_PRIVATE_BASE_URL=true to allow self-hosted gateways on loopback or private networks.";
122
+ function isPrivateOrLocalHost(hostname) {
123
+ const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
124
+ if (host === "localhost" || host.endsWith(".localhost")) {
125
+ return true;
126
+ }
127
+ let ipv4 = host;
128
+ if (host.includes(":")) {
129
+ if (host === "::1") {
130
+ return true;
131
+ }
132
+ if (host.startsWith("fe80:")) {
133
+ return true;
134
+ }
135
+ if (host.startsWith("fc") || host.startsWith("fd")) {
136
+ return true;
137
+ }
138
+ if (host.startsWith("::ffff:")) {
139
+ ipv4 = host.slice("::ffff:".length);
140
+ } else {
141
+ return false;
142
+ }
143
+ }
144
+ const match = ipv4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
145
+ if (!match) {
146
+ return false;
147
+ }
148
+ const a = Number(match[1]);
149
+ const b = Number(match[2]);
150
+ if (a === 0 || a === 10 || a === 127) {
151
+ return true;
152
+ }
153
+ if (a === 169 && b === 254) {
154
+ return true;
155
+ }
156
+ if (a === 172 && b >= 16 && b <= 31) {
157
+ return true;
158
+ }
159
+ if (a === 192 && b === 168) {
160
+ return true;
161
+ }
162
+ if (a === 100 && b >= 64 && b <= 127) {
163
+ return true;
164
+ }
165
+ return false;
166
+ }
121
167
  function validateUrl(url) {
168
+ let parsed;
122
169
  try {
123
- const parsed = new URL(url);
124
- if (!["http:", "https:"].includes(parsed.protocol)) {
125
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
126
- }
170
+ parsed = new URL(url);
127
171
  } catch (error) {
128
172
  if (error instanceof TypeError) {
129
173
  throw new Error(`Invalid base URL: ${url}`);
130
174
  }
131
175
  throw error;
132
176
  }
177
+ if (!["http:", "https:"].includes(parsed.protocol)) {
178
+ throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
179
+ }
180
+ const allowPrivate = /^(1|true|yes)$/i.test(
181
+ process.env.PORTKEY_ALLOW_PRIVATE_BASE_URL?.trim() ?? ""
182
+ );
183
+ if (!allowPrivate && isPrivateOrLocalHost(parsed.hostname)) {
184
+ throw new Error(
185
+ `Refusing to use a loopback or private-network PORTKEY_BASE_URL host: ${parsed.hostname}. ${PRIVATE_BASE_URL_OVERRIDE_HINT}`
186
+ );
187
+ }
133
188
  }
134
189
  var BaseService = class {
135
190
  apiKey;
@@ -172,7 +227,9 @@ var BaseService = class {
172
227
  requestId,
173
228
  method,
174
229
  path,
175
- metadata: { url }
230
+ metadata: {
231
+ paramKeys: options.params ? Object.keys(options.params) : []
232
+ }
176
233
  });
177
234
  try {
178
235
  const response = await fetchWithTimeout(url, {
@@ -8000,7 +8057,6 @@ var READ_ONLY_IDEMPOTENT_TOOL_PREFIXES = [
8000
8057
  "render_",
8001
8058
  "download_"
8002
8059
  ];
8003
- var READ_ONLY_NON_IDEMPOTENT_TOOL_PREFIXES = ["run_", "test_"];
8004
8060
  var DESTRUCTIVE_TOOL_PREFIXES = [
8005
8061
  "delete_",
8006
8062
  "remove_",
@@ -8061,16 +8117,6 @@ function inferToolAnnotations(toolName) {
8061
8117
  openWorldHint: true
8062
8118
  };
8063
8119
  }
8064
- if (READ_ONLY_NON_IDEMPOTENT_TOOL_PREFIXES.some(
8065
- (prefix) => toolName.startsWith(prefix)
8066
- )) {
8067
- return {
8068
- readOnlyHint: true,
8069
- destructiveHint: false,
8070
- idempotentHint: false,
8071
- openWorldHint: true
8072
- };
8073
- }
8074
8120
  if (DESTRUCTIVE_TOOL_PREFIXES.some((prefix) => toolName.startsWith(prefix))) {
8075
8121
  return {
8076
8122
  readOnlyHint: false,
package/build/server.js CHANGED
@@ -133,18 +133,73 @@ var Logger = {
133
133
 
134
134
  // src/services/base.service.ts
135
135
  var DEFAULT_BASE_URL = "https://api.portkey.ai/v1";
136
+ var PRIVATE_BASE_URL_OVERRIDE_HINT = "Set PORTKEY_ALLOW_PRIVATE_BASE_URL=true to allow self-hosted gateways on loopback or private networks.";
137
+ function isPrivateOrLocalHost(hostname) {
138
+ const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
139
+ if (host === "localhost" || host.endsWith(".localhost")) {
140
+ return true;
141
+ }
142
+ let ipv4 = host;
143
+ if (host.includes(":")) {
144
+ if (host === "::1") {
145
+ return true;
146
+ }
147
+ if (host.startsWith("fe80:")) {
148
+ return true;
149
+ }
150
+ if (host.startsWith("fc") || host.startsWith("fd")) {
151
+ return true;
152
+ }
153
+ if (host.startsWith("::ffff:")) {
154
+ ipv4 = host.slice("::ffff:".length);
155
+ } else {
156
+ return false;
157
+ }
158
+ }
159
+ const match = ipv4.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
160
+ if (!match) {
161
+ return false;
162
+ }
163
+ const a = Number(match[1]);
164
+ const b = Number(match[2]);
165
+ if (a === 0 || a === 10 || a === 127) {
166
+ return true;
167
+ }
168
+ if (a === 169 && b === 254) {
169
+ return true;
170
+ }
171
+ if (a === 172 && b >= 16 && b <= 31) {
172
+ return true;
173
+ }
174
+ if (a === 192 && b === 168) {
175
+ return true;
176
+ }
177
+ if (a === 100 && b >= 64 && b <= 127) {
178
+ return true;
179
+ }
180
+ return false;
181
+ }
136
182
  function validateUrl(url) {
183
+ let parsed;
137
184
  try {
138
- const parsed = new URL(url);
139
- if (!["http:", "https:"].includes(parsed.protocol)) {
140
- throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
141
- }
185
+ parsed = new URL(url);
142
186
  } catch (error) {
143
187
  if (error instanceof TypeError) {
144
188
  throw new Error(`Invalid base URL: ${url}`);
145
189
  }
146
190
  throw error;
147
191
  }
192
+ if (!["http:", "https:"].includes(parsed.protocol)) {
193
+ throw new Error(`Invalid URL protocol: ${parsed.protocol}`);
194
+ }
195
+ const allowPrivate = /^(1|true|yes)$/i.test(
196
+ process.env.PORTKEY_ALLOW_PRIVATE_BASE_URL?.trim() ?? ""
197
+ );
198
+ if (!allowPrivate && isPrivateOrLocalHost(parsed.hostname)) {
199
+ throw new Error(
200
+ `Refusing to use a loopback or private-network PORTKEY_BASE_URL host: ${parsed.hostname}. ${PRIVATE_BASE_URL_OVERRIDE_HINT}`
201
+ );
202
+ }
148
203
  }
149
204
  var BaseService = class {
150
205
  apiKey;
@@ -187,7 +242,9 @@ var BaseService = class {
187
242
  requestId,
188
243
  method,
189
244
  path,
190
- metadata: { url }
245
+ metadata: {
246
+ paramKeys: options.params ? Object.keys(options.params) : []
247
+ }
191
248
  });
192
249
  try {
193
250
  const response = await fetchWithTimeout(url, {
@@ -8015,7 +8072,6 @@ var READ_ONLY_IDEMPOTENT_TOOL_PREFIXES = [
8015
8072
  "render_",
8016
8073
  "download_"
8017
8074
  ];
8018
- var READ_ONLY_NON_IDEMPOTENT_TOOL_PREFIXES = ["run_", "test_"];
8019
8075
  var DESTRUCTIVE_TOOL_PREFIXES = [
8020
8076
  "delete_",
8021
8077
  "remove_",
@@ -8076,16 +8132,6 @@ function inferToolAnnotations(toolName) {
8076
8132
  openWorldHint: true
8077
8133
  };
8078
8134
  }
8079
- if (READ_ONLY_NON_IDEMPOTENT_TOOL_PREFIXES.some(
8080
- (prefix) => toolName.startsWith(prefix)
8081
- )) {
8082
- return {
8083
- readOnlyHint: true,
8084
- destructiveHint: false,
8085
- idempotentHint: false,
8086
- openWorldHint: true
8087
- };
8088
- }
8089
8135
  if (DESTRUCTIVE_TOOL_PREFIXES.some((prefix) => toolName.startsWith(prefix))) {
8090
8136
  return {
8091
8137
  readOnlyHint: false,
@@ -8593,6 +8639,15 @@ function getServerConfig() {
8593
8639
  // src/lib/event-store.ts
8594
8640
  import { createClient } from "redis";
8595
8641
  var EVENT_STORE_CLEANUP_INTERVAL_MS = 3e4;
8642
+ var SAFE_EVENT_ID_PATTERN = /^[\w-]{1,128}$/;
8643
+ function assertSafeRedisId(id, kind) {
8644
+ if (!SAFE_EVENT_ID_PATTERN.test(id)) {
8645
+ throw new Error(
8646
+ `Invalid ${kind} for event store key: ${JSON.stringify(id)}`
8647
+ );
8648
+ }
8649
+ return id;
8650
+ }
8596
8651
  var InMemoryEventStore = class {
8597
8652
  sequence = 0;
8598
8653
  ttlMs;
@@ -8719,10 +8774,10 @@ var RedisEventStore = class {
8719
8774
  return `${this.keyPrefix}:counter`;
8720
8775
  }
8721
8776
  eventKey(eventId) {
8722
- return `${this.keyPrefix}:event:${eventId}`;
8777
+ return `${this.keyPrefix}:event:${assertSafeRedisId(eventId, "eventId")}`;
8723
8778
  }
8724
8779
  streamEventsKey(streamId) {
8725
- return `${this.keyPrefix}:stream:${streamId}:events`;
8780
+ return `${this.keyPrefix}:stream:${assertSafeRedisId(streamId, "streamId")}:events`;
8726
8781
  }
8727
8782
  async ensureConnected() {
8728
8783
  if (this.client.isOpen) {
@@ -8756,6 +8811,9 @@ var RedisEventStore = class {
8756
8811
  return eventId;
8757
8812
  }
8758
8813
  async getStreamIdForEventId(eventId) {
8814
+ if (!SAFE_EVENT_ID_PATTERN.test(eventId)) {
8815
+ return void 0;
8816
+ }
8759
8817
  await this.ensureConnected();
8760
8818
  const streamId = await this.client.hGet(this.eventKey(eventId), "streamId");
8761
8819
  return streamId || void 0;
@@ -8920,6 +8978,9 @@ function parseOriginParts(value) {
8920
8978
  return null;
8921
8979
  }
8922
8980
  }
8981
+ function normalizeHostWithoutPort(value) {
8982
+ return value.trim().toLowerCase().split(":")[0];
8983
+ }
8923
8984
  function isOriginMatch(origin, allowedOrigin) {
8924
8985
  const originParts = parseOriginParts(origin);
8925
8986
  const allowedParts = parseOriginParts(allowedOrigin);
@@ -8958,6 +9019,20 @@ function validateOrigin(origin) {
8958
9019
  }
8959
9020
  return allowedOrigins.some((allowed) => isOriginMatch(origin, allowed));
8960
9021
  }
9022
+ function isAllowedHost(host) {
9023
+ const allowedOrigins = getAllowedOrigins();
9024
+ if (allowedOrigins.includes("*")) {
9025
+ return true;
9026
+ }
9027
+ const hostWithoutPort = normalizeHostWithoutPort(host);
9028
+ return allowedOrigins.some((allowed) => {
9029
+ const allowedParts = parseOriginParts(allowed);
9030
+ if (allowedParts) {
9031
+ return allowedParts.hostname === hostWithoutPort;
9032
+ }
9033
+ return normalizeHostWithoutPort(allowed) === hostWithoutPort;
9034
+ });
9035
+ }
8961
9036
  function originValidationMiddleware(req, res, next) {
8962
9037
  if (req.path === "/health" || req.path === "/ready") {
8963
9038
  next();
@@ -8975,6 +9050,23 @@ function originValidationMiddleware(req, res, next) {
8975
9050
  }
8976
9051
  next();
8977
9052
  }
9053
+ function hostValidationMiddleware(req, res, next) {
9054
+ if (req.path === "/health" || req.path === "/ready") {
9055
+ next();
9056
+ return;
9057
+ }
9058
+ const host = req.headers.host;
9059
+ if (host && !isAllowedHost(host)) {
9060
+ Logger.warn("Host validation failed", {
9061
+ path: req.path,
9062
+ method: req.method,
9063
+ metadata: { host, ip: req.ip }
9064
+ });
9065
+ res.status(403).json({ error: "Forbidden: Host not allowed" });
9066
+ return;
9067
+ }
9068
+ next();
9069
+ }
8978
9070
  function parsePositiveIntegerEnv(name, fallback) {
8979
9071
  const raw = process.env[name];
8980
9072
  if (raw === void 0) {
@@ -9500,6 +9592,9 @@ function createHttpAppRuntime() {
9500
9592
  })
9501
9593
  );
9502
9594
  app.use(express.json({ limit: requestBodyLimit }));
9595
+ if (authConfig.mode === "none") {
9596
+ app.use(hostValidationMiddleware);
9597
+ }
9503
9598
  app.use(originValidationMiddleware);
9504
9599
  app.use(rateLimitMiddleware);
9505
9600
  app.use(mcpAuthMiddleware);
@@ -9784,7 +9879,7 @@ function createHttpAppRuntime() {
9784
9879
  return;
9785
9880
  }
9786
9881
  } else if (!transport) {
9787
- res.status(400).json({
9882
+ res.status(sessionId ? 404 : 400).json({
9788
9883
  jsonrpc: "2.0",
9789
9884
  error: {
9790
9885
  code: -32e3,
@@ -9865,11 +9960,11 @@ function createHttpAppRuntime() {
9865
9960
  sessionStore.touch(sessionId);
9866
9961
  await transport.handleRequest(req, res);
9867
9962
  } else {
9868
- res.status(400).json({
9963
+ res.status(404).json({
9869
9964
  jsonrpc: "2.0",
9870
9965
  error: {
9871
9966
  code: -32e3,
9872
- message: "Invalid session ID"
9967
+ message: "Session not found"
9873
9968
  },
9874
9969
  id: null
9875
9970
  });
@@ -9913,11 +10008,11 @@ function createHttpAppRuntime() {
9913
10008
  }
9914
10009
  await transport.handleRequest(req, res);
9915
10010
  } else {
9916
- res.status(400).json({
10011
+ res.status(404).json({
9917
10012
  jsonrpc: "2.0",
9918
10013
  error: {
9919
10014
  code: -32e3,
9920
- message: "Invalid session ID"
10015
+ message: "Session not found"
9921
10016
  },
9922
10017
  id: null
9923
10018
  });
package/package.json CHANGED
@@ -22,7 +22,7 @@
22
22
  "knip": "knip",
23
23
  "ci": "npm run lint && npm run knip && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run verify:readme-tools",
24
24
  "start:http": "node build/server.js",
25
- "prepare": "lefthook install",
25
+ "prepare": "[ -n \"$CI\" ] || [ -f /.dockerenv ] || [ ! -d .git ] || lefthook install",
26
26
  "prepublishOnly": "npm run ci"
27
27
  },
28
28
  "engines": {
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "name": "portkey-admin-mcp",
44
44
  "mcpName": "io.github.s-b-e-n-s-o-n/portkey-admin-mcp",
45
- "version": "0.3.2",
45
+ "version": "0.3.4",
46
46
  "main": "build/index.js",
47
47
  "keywords": [
48
48
  "mcp",