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 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;
@@ -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 of those settings is required; returns the new id and version_id.",
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 this before updating rules or when you need the exact enforcement policy.",
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 configs keep pointing at the latest policy after the change.",
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. Access changes and new limits apply to downstream usage immediately. Returns success and the number of workspaces updated.",
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 affect what downstream callers can access; type and sub-type stay fixed after creation. Returns success after the update is applied.",
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 changes the label definition, not existing prompt-version assignments or history.",
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. Conditions and group_by are immutable after creation.",
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, so create a new log export if you need the same data again.",
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 get/update/delete.",
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 version. This changes which content {{> partial_name}} resolves to and replaces the previously active version.",
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. Use list_prompt_versions to choose the version and update_prompt when you need to create new content before promoting it.",
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. The invite must still exist; use get_user_invite first if you are unsure.",
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 references.",
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
- 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;
@@ -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 of those settings is required; returns the new id and version_id.",
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 this before updating rules or when you need the exact enforcement policy.",
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 configs keep pointing at the latest policy after the change.",
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. Access changes and new limits apply to downstream usage immediately. Returns success and the number of workspaces updated.",
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 affect what downstream callers can access; type and sub-type stay fixed after creation. Returns success after the update is applied.",
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 changes the label definition, not existing prompt-version assignments or history.",
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. Conditions and group_by are immutable after creation.",
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, so create a new log export if you need the same data again.",
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 get/update/delete.",
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 version. This changes which content {{> partial_name}} resolves to and replaces the previously active version.",
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. Use list_prompt_versions to choose the version and update_prompt when you need to create new content before promoting it.",
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. The invite must still exist; use get_user_invite first if you are unsure.",
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 references.",
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(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.1",
45
+ "version": "0.3.3",
46
46
  "main": "build/index.js",
47
47
  "keywords": [
48
48
  "mcp",