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 +8 -1
- package/build/index.js +59 -15
- package/build/server.js +101 -20
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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(
|
|
9949
|
+
res.status(404).json({
|
|
9869
9950
|
jsonrpc: "2.0",
|
|
9870
9951
|
error: {
|
|
9871
9952
|
code: -32e3,
|
|
9872
|
-
message: "
|
|
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(
|
|
9997
|
+
res.status(404).json({
|
|
9917
9998
|
jsonrpc: "2.0",
|
|
9918
9999
|
error: {
|
|
9919
10000
|
code: -32e3,
|
|
9920
|
-
message: "
|
|
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.
|
|
45
|
+
"version": "0.3.3",
|
|
46
46
|
"main": "build/index.js",
|
|
47
47
|
"keywords": [
|
|
48
48
|
"mcp",
|