portkey-admin-mcp 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -8000,7 +8055,6 @@ var READ_ONLY_IDEMPOTENT_TOOL_PREFIXES = [
8000
8055
  "render_",
8001
8056
  "download_"
8002
8057
  ];
8003
- var READ_ONLY_NON_IDEMPOTENT_TOOL_PREFIXES = ["run_", "test_"];
8004
8058
  var DESTRUCTIVE_TOOL_PREFIXES = [
8005
8059
  "delete_",
8006
8060
  "remove_",
@@ -8061,16 +8115,6 @@ function inferToolAnnotations(toolName) {
8061
8115
  openWorldHint: true
8062
8116
  };
8063
8117
  }
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
8118
  if (DESTRUCTIVE_TOOL_PREFIXES.some((prefix) => toolName.startsWith(prefix))) {
8075
8119
  return {
8076
8120
  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;
@@ -8015,7 +8070,6 @@ var READ_ONLY_IDEMPOTENT_TOOL_PREFIXES = [
8015
8070
  "render_",
8016
8071
  "download_"
8017
8072
  ];
8018
- var READ_ONLY_NON_IDEMPOTENT_TOOL_PREFIXES = ["run_", "test_"];
8019
8073
  var DESTRUCTIVE_TOOL_PREFIXES = [
8020
8074
  "delete_",
8021
8075
  "remove_",
@@ -8076,16 +8130,6 @@ function inferToolAnnotations(toolName) {
8076
8130
  openWorldHint: true
8077
8131
  };
8078
8132
  }
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
8133
  if (DESTRUCTIVE_TOOL_PREFIXES.some((prefix) => toolName.startsWith(prefix))) {
8090
8134
  return {
8091
8135
  readOnlyHint: false,
@@ -8920,6 +8964,9 @@ function parseOriginParts(value) {
8920
8964
  return null;
8921
8965
  }
8922
8966
  }
8967
+ function normalizeHostWithoutPort(value) {
8968
+ return value.trim().toLowerCase().split(":")[0];
8969
+ }
8923
8970
  function isOriginMatch(origin, allowedOrigin) {
8924
8971
  const originParts = parseOriginParts(origin);
8925
8972
  const allowedParts = parseOriginParts(allowedOrigin);
@@ -8958,6 +9005,20 @@ function validateOrigin(origin) {
8958
9005
  }
8959
9006
  return allowedOrigins.some((allowed) => isOriginMatch(origin, allowed));
8960
9007
  }
9008
+ function isAllowedHost(host) {
9009
+ const allowedOrigins = getAllowedOrigins();
9010
+ if (allowedOrigins.includes("*")) {
9011
+ return true;
9012
+ }
9013
+ const hostWithoutPort = normalizeHostWithoutPort(host);
9014
+ return allowedOrigins.some((allowed) => {
9015
+ const allowedParts = parseOriginParts(allowed);
9016
+ if (allowedParts) {
9017
+ return allowedParts.hostname === hostWithoutPort;
9018
+ }
9019
+ return normalizeHostWithoutPort(allowed) === hostWithoutPort;
9020
+ });
9021
+ }
8961
9022
  function originValidationMiddleware(req, res, next) {
8962
9023
  if (req.path === "/health" || req.path === "/ready") {
8963
9024
  next();
@@ -8975,6 +9036,23 @@ function originValidationMiddleware(req, res, next) {
8975
9036
  }
8976
9037
  next();
8977
9038
  }
9039
+ function hostValidationMiddleware(req, res, next) {
9040
+ if (req.path === "/health" || req.path === "/ready") {
9041
+ next();
9042
+ return;
9043
+ }
9044
+ const host = req.headers.host;
9045
+ if (host && !isAllowedHost(host)) {
9046
+ Logger.warn("Host validation failed", {
9047
+ path: req.path,
9048
+ method: req.method,
9049
+ metadata: { host, ip: req.ip }
9050
+ });
9051
+ res.status(403).json({ error: "Forbidden: Host not allowed" });
9052
+ return;
9053
+ }
9054
+ next();
9055
+ }
8978
9056
  function parsePositiveIntegerEnv(name, fallback) {
8979
9057
  const raw = process.env[name];
8980
9058
  if (raw === void 0) {
@@ -9500,6 +9578,9 @@ function createHttpAppRuntime() {
9500
9578
  })
9501
9579
  );
9502
9580
  app.use(express.json({ limit: requestBodyLimit }));
9581
+ if (authConfig.mode === "none") {
9582
+ app.use(hostValidationMiddleware);
9583
+ }
9503
9584
  app.use(originValidationMiddleware);
9504
9585
  app.use(rateLimitMiddleware);
9505
9586
  app.use(mcpAuthMiddleware);
@@ -9784,7 +9865,7 @@ function createHttpAppRuntime() {
9784
9865
  return;
9785
9866
  }
9786
9867
  } else if (!transport) {
9787
- res.status(400).json({
9868
+ res.status(sessionId ? 404 : 400).json({
9788
9869
  jsonrpc: "2.0",
9789
9870
  error: {
9790
9871
  code: -32e3,
@@ -9865,11 +9946,11 @@ function createHttpAppRuntime() {
9865
9946
  sessionStore.touch(sessionId);
9866
9947
  await transport.handleRequest(req, res);
9867
9948
  } else {
9868
- res.status(400).json({
9949
+ res.status(404).json({
9869
9950
  jsonrpc: "2.0",
9870
9951
  error: {
9871
9952
  code: -32e3,
9872
- message: "Invalid session ID"
9953
+ message: "Session not found"
9873
9954
  },
9874
9955
  id: null
9875
9956
  });
@@ -9913,11 +9994,11 @@ function createHttpAppRuntime() {
9913
9994
  }
9914
9995
  await transport.handleRequest(req, res);
9915
9996
  } else {
9916
- res.status(400).json({
9997
+ res.status(404).json({
9917
9998
  jsonrpc: "2.0",
9918
9999
  error: {
9919
10000
  code: -32e3,
9920
- message: "Invalid session ID"
10001
+ message: "Session not found"
9921
10002
  },
9922
10003
  id: null
9923
10004
  });
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.3",
46
46
  "main": "build/index.js",
47
47
  "keywords": [
48
48
  "mcp",