portkey-admin-mcp 0.3.1 → 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 +72 -28
- package/build/server.js +114 -33
- 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;
|
|
@@ -2804,7 +2859,7 @@ function registerConfigsTools(server, service) {
|
|
|
2804
2859
|
);
|
|
2805
2860
|
server.tool(
|
|
2806
2861
|
"create_config",
|
|
2807
|
-
"Create a config that defines routing, cache, retry, and targets for requests. At least one
|
|
2862
|
+
"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.",
|
|
2808
2863
|
CONFIGS_TOOL_SCHEMAS.createConfig,
|
|
2809
2864
|
async (params) => {
|
|
2810
2865
|
const config = buildConfigPayload(params);
|
|
@@ -3018,7 +3073,7 @@ function registerGuardrailsTools(server, service) {
|
|
|
3018
3073
|
);
|
|
3019
3074
|
server.tool(
|
|
3020
3075
|
"get_guardrail",
|
|
3021
|
-
"Fetch one guardrail with its full checks and actions. Use
|
|
3076
|
+
"Fetch one guardrail by id or slug with its full checks and actions; use list_guardrails to discover ids first. Use before update_guardrail or delete_guardrail when you need the exact enforcement policy, and returns the full check and action configuration alongside status and ownership.",
|
|
3022
3077
|
GUARDRAILS_TOOL_SCHEMAS.getGuardrail,
|
|
3023
3078
|
async (params) => {
|
|
3024
3079
|
const guardrail = await service.guardrails.getGuardrail(
|
|
@@ -3084,7 +3139,7 @@ function registerGuardrailsTools(server, service) {
|
|
|
3084
3139
|
);
|
|
3085
3140
|
server.tool(
|
|
3086
3141
|
"update_guardrail",
|
|
3087
|
-
"Update a guardrail's name, checks, or actions. This creates a new version, so
|
|
3142
|
+
"Update a guardrail's name, checks, or actions, unlike create_guardrail which registers a new one or delete_guardrail which removes it. This creates a new version that takes effect immediately for dependent configs, so review list_guardrails first; returns the updated id, slug, and version_id.",
|
|
3088
3143
|
GUARDRAILS_TOOL_SCHEMAS.updateGuardrail,
|
|
3089
3144
|
async (params) => {
|
|
3090
3145
|
const updateData = {};
|
|
@@ -3610,7 +3665,7 @@ function registerIntegrationsTools(server, service) {
|
|
|
3610
3665
|
);
|
|
3611
3666
|
server.tool(
|
|
3612
3667
|
"update_integration_workspaces",
|
|
3613
|
-
"Control which workspaces can use an integration and set per-workspace limits.
|
|
3668
|
+
"Control which workspaces can use an integration and set per-workspace limits, unlike update_integration which edits the org-level connection. Call list_integration_workspaces first to review current state; access changes and new limits apply to downstream usage immediately, and the call returns success plus the number of workspaces updated.",
|
|
3614
3669
|
INTEGRATIONS_TOOL_SCHEMAS.updateIntegrationWorkspaces,
|
|
3615
3670
|
async (params) => {
|
|
3616
3671
|
const result = await service.integrations.updateIntegrationWorkspaces(
|
|
@@ -4084,7 +4139,7 @@ function registerKeysTools(server, service) {
|
|
|
4084
4139
|
);
|
|
4085
4140
|
server.tool(
|
|
4086
4141
|
"update_api_key",
|
|
4087
|
-
"Update an API key's name, description, scopes, defaults, or limits. Changes
|
|
4142
|
+
"Update an API key's name, description, scopes, defaults, or limits, unlike delete_api_key which revokes it or create_api_key which issues a new one. Changes take effect immediately for downstream callers, type and sub-type stay fixed after creation, and the call returns success without rotating the secret.",
|
|
4088
4143
|
KEYS_TOOL_SCHEMAS.updateApiKey,
|
|
4089
4144
|
async (params) => {
|
|
4090
4145
|
const result = await service.keys.updateApiKey(params.id, {
|
|
@@ -4288,7 +4343,7 @@ function registerLabelsTools(server, service) {
|
|
|
4288
4343
|
);
|
|
4289
4344
|
server.tool(
|
|
4290
4345
|
"update_prompt_label",
|
|
4291
|
-
"Update a prompt label's name, description, or color only. This
|
|
4346
|
+
"Update a prompt label's name, description, or color only, unlike update_prompt_version which changes which label a version carries. This takes effect immediately for all versions already tagged with the label, but does not reassign labels or touch history; use list_prompt_labels to find the label_id first.",
|
|
4292
4347
|
LABELS_TOOL_SCHEMAS.updatePromptLabel,
|
|
4293
4348
|
async (params) => {
|
|
4294
4349
|
const { label_id, ...updateData } = params;
|
|
@@ -4657,7 +4712,7 @@ function registerLimitsTools(server, service) {
|
|
|
4657
4712
|
);
|
|
4658
4713
|
server.tool(
|
|
4659
4714
|
"update_usage_limit",
|
|
4660
|
-
"Update a usage limit's name, credit_limit, alert_threshold, reset schedule, or reset target by id.
|
|
4715
|
+
"Update a usage limit's name, credit_limit, alert_threshold, reset schedule, or reset target by id, unlike update_rate_limit which tunes request throttling. New values apply immediately to tracked usage, conditions and group_by are immutable after creation, and the call returns the updated id without clearing accumulated usage (use reset_usage_limit_entity for that).",
|
|
4661
4716
|
LIMITS_TOOL_SCHEMAS.updateUsageLimit,
|
|
4662
4717
|
async (params) => {
|
|
4663
4718
|
const result = await service.limits.updateUsageLimit(params.id, {
|
|
@@ -5027,7 +5082,7 @@ function registerLoggingTools(server, service) {
|
|
|
5027
5082
|
);
|
|
5028
5083
|
server.tool(
|
|
5029
5084
|
"cancel_log_export",
|
|
5030
|
-
"Cancel a pending or running log export job. This permanently stops that export,
|
|
5085
|
+
"Cancel a pending or running log export job, unlike start_log_export which queues one or delete_integration which removes the source. This permanently stops that export, takes effect immediately, and does not roll back already-processed rows; call create_log_export and start_log_export again to retry.",
|
|
5031
5086
|
LOGGING_TOOL_SCHEMAS.cancelLogExport,
|
|
5032
5087
|
async (params) => {
|
|
5033
5088
|
const result = await service.logging.cancelLogExport(params.export_id);
|
|
@@ -5880,7 +5935,7 @@ function registerPartialsTools(server, service) {
|
|
|
5880
5935
|
);
|
|
5881
5936
|
server.tool(
|
|
5882
5937
|
"list_prompt_partials",
|
|
5883
|
-
"List partials across collections, with optional collection filtering. Returns ids, slugs, names, collections, and status so you can choose a prompt_partial_id before
|
|
5938
|
+
"List partials across collections, with optional collection filtering. Returns ids, slugs, names, collections, and status so you can choose a prompt_partial_id before get_prompt_partial, update_prompt_partial, delete_prompt_partial, or publish_partial.",
|
|
5884
5939
|
PARTIALS_TOOL_SCHEMAS.listPromptPartials,
|
|
5885
5940
|
async (params) => {
|
|
5886
5941
|
const partials = await service.partials.listPromptPartials(params);
|
|
@@ -6030,7 +6085,7 @@ function registerPartialsTools(server, service) {
|
|
|
6030
6085
|
);
|
|
6031
6086
|
server.tool(
|
|
6032
6087
|
"publish_partial",
|
|
6033
|
-
"Publish a specific partial version as the default
|
|
6088
|
+
"Publish a specific partial version as the default, unlike update_prompt_partial which creates a new draft without activating it. Use after list_partial_versions to pick a version_id; this immediately changes what {{> partial_name}} resolves to for all prompts and replaces the previously active version without a rollback path.",
|
|
6034
6089
|
PARTIALS_TOOL_SCHEMAS.publishPartial,
|
|
6035
6090
|
async (params) => {
|
|
6036
6091
|
await service.partials.publishPartial(params.prompt_partial_id, {
|
|
@@ -6612,7 +6667,7 @@ function registerPromptsTools(server, service) {
|
|
|
6612
6667
|
);
|
|
6613
6668
|
server.tool(
|
|
6614
6669
|
"publish_prompt",
|
|
6615
|
-
"Publish a specific version of a prompt as the active default.
|
|
6670
|
+
"Publish a specific version of a prompt as the active default, unlike promote_prompt which copies across environments or update_prompt which creates a new draft. This immediately routes all callers using the slug to that version and there is no rollback, so use list_prompt_versions to pick the version and update_prompt first if you need to create new content before promoting it.",
|
|
6616
6671
|
PROMPTS_TOOL_SCHEMAS.publishPrompt,
|
|
6617
6672
|
async (params) => {
|
|
6618
6673
|
await service.prompts.publishPrompt(params.prompt_id, {
|
|
@@ -7569,7 +7624,7 @@ function registerUsersTools(server, service) {
|
|
|
7569
7624
|
);
|
|
7570
7625
|
server.tool(
|
|
7571
7626
|
"resend_user_invite",
|
|
7572
|
-
"Resend the email for a pending invite that has not been accepted.
|
|
7627
|
+
"Resend the email for a pending invite that has not been accepted, unlike invite_user which creates a new invite. This sends a fresh email without modifying the invite record, expiry, or role; use get_user_invite first if you are unsure whether the invite still exists and list_user_invites to discover invite_ids.",
|
|
7573
7628
|
USERS_TOOL_SCHEMAS.resendUserInvite,
|
|
7574
7629
|
async (params) => {
|
|
7575
7630
|
await service.users.resendUserInvite(params.invite_id);
|
|
@@ -7771,7 +7826,7 @@ function registerWorkspacesTools(server, service) {
|
|
|
7771
7826
|
);
|
|
7772
7827
|
server.tool(
|
|
7773
7828
|
"update_workspace",
|
|
7774
|
-
"Update a workspace's name, slug, description, default flag, or metadata by id. Only provided fields change; changing the slug can break URLs and other
|
|
7829
|
+
"Update a workspace's name, slug, description, default flag, or metadata by id, unlike update_workspace_member which changes role assignments within a workspace. Only provided fields change and updates take effect immediately; changing the slug can break URLs, API key references, and other external links, so confirm no dependencies first.",
|
|
7775
7830
|
WORKSPACES_TOOL_SCHEMAS.updateWorkspace,
|
|
7776
7831
|
async (params) => {
|
|
7777
7832
|
const { workspace_id, is_default, metadata, ...rest } = params;
|
|
@@ -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;
|
|
@@ -2819,7 +2874,7 @@ function registerConfigsTools(server, service) {
|
|
|
2819
2874
|
);
|
|
2820
2875
|
server.tool(
|
|
2821
2876
|
"create_config",
|
|
2822
|
-
"Create a config that defines routing, cache, retry, and targets for requests. At least one
|
|
2877
|
+
"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.",
|
|
2823
2878
|
CONFIGS_TOOL_SCHEMAS.createConfig,
|
|
2824
2879
|
async (params) => {
|
|
2825
2880
|
const config = buildConfigPayload(params);
|
|
@@ -3033,7 +3088,7 @@ function registerGuardrailsTools(server, service) {
|
|
|
3033
3088
|
);
|
|
3034
3089
|
server.tool(
|
|
3035
3090
|
"get_guardrail",
|
|
3036
|
-
"Fetch one guardrail with its full checks and actions. Use
|
|
3091
|
+
"Fetch one guardrail by id or slug with its full checks and actions; use list_guardrails to discover ids first. Use before update_guardrail or delete_guardrail when you need the exact enforcement policy, and returns the full check and action configuration alongside status and ownership.",
|
|
3037
3092
|
GUARDRAILS_TOOL_SCHEMAS.getGuardrail,
|
|
3038
3093
|
async (params) => {
|
|
3039
3094
|
const guardrail = await service.guardrails.getGuardrail(
|
|
@@ -3099,7 +3154,7 @@ function registerGuardrailsTools(server, service) {
|
|
|
3099
3154
|
);
|
|
3100
3155
|
server.tool(
|
|
3101
3156
|
"update_guardrail",
|
|
3102
|
-
"Update a guardrail's name, checks, or actions. This creates a new version, so
|
|
3157
|
+
"Update a guardrail's name, checks, or actions, unlike create_guardrail which registers a new one or delete_guardrail which removes it. This creates a new version that takes effect immediately for dependent configs, so review list_guardrails first; returns the updated id, slug, and version_id.",
|
|
3103
3158
|
GUARDRAILS_TOOL_SCHEMAS.updateGuardrail,
|
|
3104
3159
|
async (params) => {
|
|
3105
3160
|
const updateData = {};
|
|
@@ -3625,7 +3680,7 @@ function registerIntegrationsTools(server, service) {
|
|
|
3625
3680
|
);
|
|
3626
3681
|
server.tool(
|
|
3627
3682
|
"update_integration_workspaces",
|
|
3628
|
-
"Control which workspaces can use an integration and set per-workspace limits.
|
|
3683
|
+
"Control which workspaces can use an integration and set per-workspace limits, unlike update_integration which edits the org-level connection. Call list_integration_workspaces first to review current state; access changes and new limits apply to downstream usage immediately, and the call returns success plus the number of workspaces updated.",
|
|
3629
3684
|
INTEGRATIONS_TOOL_SCHEMAS.updateIntegrationWorkspaces,
|
|
3630
3685
|
async (params) => {
|
|
3631
3686
|
const result = await service.integrations.updateIntegrationWorkspaces(
|
|
@@ -4099,7 +4154,7 @@ function registerKeysTools(server, service) {
|
|
|
4099
4154
|
);
|
|
4100
4155
|
server.tool(
|
|
4101
4156
|
"update_api_key",
|
|
4102
|
-
"Update an API key's name, description, scopes, defaults, or limits. Changes
|
|
4157
|
+
"Update an API key's name, description, scopes, defaults, or limits, unlike delete_api_key which revokes it or create_api_key which issues a new one. Changes take effect immediately for downstream callers, type and sub-type stay fixed after creation, and the call returns success without rotating the secret.",
|
|
4103
4158
|
KEYS_TOOL_SCHEMAS.updateApiKey,
|
|
4104
4159
|
async (params) => {
|
|
4105
4160
|
const result = await service.keys.updateApiKey(params.id, {
|
|
@@ -4303,7 +4358,7 @@ function registerLabelsTools(server, service) {
|
|
|
4303
4358
|
);
|
|
4304
4359
|
server.tool(
|
|
4305
4360
|
"update_prompt_label",
|
|
4306
|
-
"Update a prompt label's name, description, or color only. This
|
|
4361
|
+
"Update a prompt label's name, description, or color only, unlike update_prompt_version which changes which label a version carries. This takes effect immediately for all versions already tagged with the label, but does not reassign labels or touch history; use list_prompt_labels to find the label_id first.",
|
|
4307
4362
|
LABELS_TOOL_SCHEMAS.updatePromptLabel,
|
|
4308
4363
|
async (params) => {
|
|
4309
4364
|
const { label_id, ...updateData } = params;
|
|
@@ -4672,7 +4727,7 @@ function registerLimitsTools(server, service) {
|
|
|
4672
4727
|
);
|
|
4673
4728
|
server.tool(
|
|
4674
4729
|
"update_usage_limit",
|
|
4675
|
-
"Update a usage limit's name, credit_limit, alert_threshold, reset schedule, or reset target by id.
|
|
4730
|
+
"Update a usage limit's name, credit_limit, alert_threshold, reset schedule, or reset target by id, unlike update_rate_limit which tunes request throttling. New values apply immediately to tracked usage, conditions and group_by are immutable after creation, and the call returns the updated id without clearing accumulated usage (use reset_usage_limit_entity for that).",
|
|
4676
4731
|
LIMITS_TOOL_SCHEMAS.updateUsageLimit,
|
|
4677
4732
|
async (params) => {
|
|
4678
4733
|
const result = await service.limits.updateUsageLimit(params.id, {
|
|
@@ -5042,7 +5097,7 @@ function registerLoggingTools(server, service) {
|
|
|
5042
5097
|
);
|
|
5043
5098
|
server.tool(
|
|
5044
5099
|
"cancel_log_export",
|
|
5045
|
-
"Cancel a pending or running log export job. This permanently stops that export,
|
|
5100
|
+
"Cancel a pending or running log export job, unlike start_log_export which queues one or delete_integration which removes the source. This permanently stops that export, takes effect immediately, and does not roll back already-processed rows; call create_log_export and start_log_export again to retry.",
|
|
5046
5101
|
LOGGING_TOOL_SCHEMAS.cancelLogExport,
|
|
5047
5102
|
async (params) => {
|
|
5048
5103
|
const result = await service.logging.cancelLogExport(params.export_id);
|
|
@@ -5895,7 +5950,7 @@ function registerPartialsTools(server, service) {
|
|
|
5895
5950
|
);
|
|
5896
5951
|
server.tool(
|
|
5897
5952
|
"list_prompt_partials",
|
|
5898
|
-
"List partials across collections, with optional collection filtering. Returns ids, slugs, names, collections, and status so you can choose a prompt_partial_id before
|
|
5953
|
+
"List partials across collections, with optional collection filtering. Returns ids, slugs, names, collections, and status so you can choose a prompt_partial_id before get_prompt_partial, update_prompt_partial, delete_prompt_partial, or publish_partial.",
|
|
5899
5954
|
PARTIALS_TOOL_SCHEMAS.listPromptPartials,
|
|
5900
5955
|
async (params) => {
|
|
5901
5956
|
const partials = await service.partials.listPromptPartials(params);
|
|
@@ -6045,7 +6100,7 @@ function registerPartialsTools(server, service) {
|
|
|
6045
6100
|
);
|
|
6046
6101
|
server.tool(
|
|
6047
6102
|
"publish_partial",
|
|
6048
|
-
"Publish a specific partial version as the default
|
|
6103
|
+
"Publish a specific partial version as the default, unlike update_prompt_partial which creates a new draft without activating it. Use after list_partial_versions to pick a version_id; this immediately changes what {{> partial_name}} resolves to for all prompts and replaces the previously active version without a rollback path.",
|
|
6049
6104
|
PARTIALS_TOOL_SCHEMAS.publishPartial,
|
|
6050
6105
|
async (params) => {
|
|
6051
6106
|
await service.partials.publishPartial(params.prompt_partial_id, {
|
|
@@ -6627,7 +6682,7 @@ function registerPromptsTools(server, service) {
|
|
|
6627
6682
|
);
|
|
6628
6683
|
server.tool(
|
|
6629
6684
|
"publish_prompt",
|
|
6630
|
-
"Publish a specific version of a prompt as the active default.
|
|
6685
|
+
"Publish a specific version of a prompt as the active default, unlike promote_prompt which copies across environments or update_prompt which creates a new draft. This immediately routes all callers using the slug to that version and there is no rollback, so use list_prompt_versions to pick the version and update_prompt first if you need to create new content before promoting it.",
|
|
6631
6686
|
PROMPTS_TOOL_SCHEMAS.publishPrompt,
|
|
6632
6687
|
async (params) => {
|
|
6633
6688
|
await service.prompts.publishPrompt(params.prompt_id, {
|
|
@@ -7584,7 +7639,7 @@ function registerUsersTools(server, service) {
|
|
|
7584
7639
|
);
|
|
7585
7640
|
server.tool(
|
|
7586
7641
|
"resend_user_invite",
|
|
7587
|
-
"Resend the email for a pending invite that has not been accepted.
|
|
7642
|
+
"Resend the email for a pending invite that has not been accepted, unlike invite_user which creates a new invite. This sends a fresh email without modifying the invite record, expiry, or role; use get_user_invite first if you are unsure whether the invite still exists and list_user_invites to discover invite_ids.",
|
|
7588
7643
|
USERS_TOOL_SCHEMAS.resendUserInvite,
|
|
7589
7644
|
async (params) => {
|
|
7590
7645
|
await service.users.resendUserInvite(params.invite_id);
|
|
@@ -7786,7 +7841,7 @@ function registerWorkspacesTools(server, service) {
|
|
|
7786
7841
|
);
|
|
7787
7842
|
server.tool(
|
|
7788
7843
|
"update_workspace",
|
|
7789
|
-
"Update a workspace's name, slug, description, default flag, or metadata by id. Only provided fields change; changing the slug can break URLs and other
|
|
7844
|
+
"Update a workspace's name, slug, description, default flag, or metadata by id, unlike update_workspace_member which changes role assignments within a workspace. Only provided fields change and updates take effect immediately; changing the slug can break URLs, API key references, and other external links, so confirm no dependencies first.",
|
|
7790
7845
|
WORKSPACES_TOOL_SCHEMAS.updateWorkspace,
|
|
7791
7846
|
async (params) => {
|
|
7792
7847
|
const { workspace_id, is_default, metadata, ...rest } = params;
|
|
@@ -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",
|