@portproof/mcp 0.1.0 → 0.2.0

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.
Files changed (4) hide show
  1. package/README.md +12 -6
  2. package/dist/index.js +329 -65
  3. package/llms.txt +14 -12
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -123,21 +123,27 @@ is `http` or `socks5`. Which countries have devices online changes through the d
123
123
  ### Dedicated ports (coming soon, not on sale yet)
124
124
 
125
125
  `search_inventory`, `quote`, `buy_port`, `list_ports`, `get_port`, `rotate`, `set_rotation_schedule`,
126
- `get_passport` (port details), `get_receipt` (a rotation record, with its signature checked against the published
127
- keys) and `get_usage` work on dedicated ports for organisations that already hold one. The server tells the model
128
- not to offer them.
126
+ `renew_port` (one more term from your account balance), `set_auto_renew`, `get_passport` (port details),
127
+ `get_receipt` (a rotation record, with its signature checked against the published keys) and `get_usage` work on
128
+ dedicated ports for organisations that already hold one. The server tells the model not to offer them.
129
+
130
+ Every port has a `label` (country, carrier and port id) and a `network`: `own` (Own network) or `partner` (Partner
131
+ network). Partner network ports are bought in the web shop, one SKU per term: `quote` and `buy_port` answer such a
132
+ SKU with its price and `shop_url` and charge nothing. Their HTTP and SOCKS5 connections can differ in host, port,
133
+ login and password, so `get_port` builds `proxy_urls` from each set, and `rotation_url` is the link that rotates the
134
+ IP when fetched.
129
135
 
130
136
  ## Safety
131
137
 
132
- - `buy_traffic`, `buy_port`, `rotate` and `set_rotation_schedule` are refused, without any request to the API,
133
- unless the call carries `confirm: true` and an `idempotency_key`. `buy_traffic` also needs `accept_terms: true`,
138
+ - `buy_traffic`, `buy_port`, `renew_port`, `rotate`, `set_rotation_schedule` and `set_auto_renew` are refused,
139
+ without any request to the API, unless the call carries `confirm: true` and an `idempotency_key`. `buy_traffic` also needs `accept_terms: true`,
134
140
  which records that you accept the terms, the acceptable-use policy and the immediate start of the service. The
135
141
  server instructs the model to ask you before setting either.
136
142
  - Retrying with the same `idempotency_key` replays the first answer instead of buying twice; a different purchase
137
143
  needs a new key.
138
144
  - Responses are compact JSON. Errors are `{"error":{"code","status","title","detail"}}` with the API's problem
139
145
  codes (`insufficient_funds`, `trial_already_used`, `insufficient_scope`, `rate_limited`, ...) or a local code such
140
- as `confirmation_required` or `api_unreachable`.
146
+ as `confirmation_required`, `not_on_sale` or `api_unreachable`.
141
147
  - stdout carries only the MCP protocol; logs go to stderr.
142
148
 
143
149
  ## Licence
package/dist/index.js CHANGED
@@ -43,7 +43,11 @@ var ID_PREFIXES = [
43
43
  "aud",
44
44
  "upp",
45
45
  "led",
46
- "wl"
46
+ "wl",
47
+ "opc",
48
+ "opm",
49
+ "opt",
50
+ "opx"
47
51
  ];
48
52
  var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
49
53
  var prefixSet = /* @__PURE__ */ new Set(ID_PREFIXES);
@@ -91,6 +95,8 @@ var ORDER_STATUSES = [
91
95
  var TRAFFIC_TRIAL = { sku: "traffic-trial", gb: 0.5, price_cents: 290 };
92
96
  var TRAFFIC_MOBILE_COUNTRIES = ["US", "GB", "FR", "NL", "PL", "GE"];
93
97
  var TRAFFIC_PROTOCOLS = ["http", "socks5"];
98
+ var OPS_PRINCIPALS = ["admin-key", "claude", "codex"];
99
+ var OPS_ACTORS = [...OPS_PRINCIPALS, "system"];
94
100
 
95
101
  import { z } from "zod";
96
102
  var IsoDateTime = /* @__PURE__ */ z.string().datetime({ offset: true });
@@ -619,7 +625,8 @@ var ProblemDetails = /* @__PURE__ */ z12.object({
619
625
  instance: /* @__PURE__ */ z12.string().optional(),
620
626
  code: /* @__PURE__ */ z12.string().min(1),
621
627
  retry_after: /* @__PURE__ */ z12.number().int().min(0).optional(),
622
- errors: /* @__PURE__ */ z12.array(/* @__PURE__ */ z12.object({ path: /* @__PURE__ */ z12.string(), message: /* @__PURE__ */ z12.string() })).optional()
628
+ errors: /* @__PURE__ */ z12.array(/* @__PURE__ */ z12.object({ path: /* @__PURE__ */ z12.string(), message: /* @__PURE__ */ z12.string() })).optional(),
629
+ current_revision: /* @__PURE__ */ z12.number().int().min(1).optional()
623
630
  });
624
631
 
625
632
  import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
@@ -678,16 +685,29 @@ function verifyReceiptWithKeys(receipt, keys) {
678
685
  }
679
686
 
680
687
  var SERVER_NAME = "portproof";
681
- var SERVER_VERSION = "0.1.0";
688
+ var SERVER_VERSION = "0.2.0";
682
689
  var USER_AGENT = `portproof-mcp/${SERVER_VERSION}`;
683
690
  var DEFAULT_API_URL = "https://api.portproof.org";
684
691
  var DEFAULT_TIMEOUT_MS = 3e4;
685
692
  var ROTATE_TIMEOUT_MS = 2e5;
693
+ var ROTATION_POLL_INTERVAL_MS = 5e3;
694
+ var ROTATION_TOTAL_WAIT_MS = 18e4;
686
695
  function normaliseBaseUrl(raw) {
687
696
  const trimmed = (raw ?? "").trim().replace(/\/+$/, "");
688
697
  const origin = trimmed === "" ? DEFAULT_API_URL : trimmed;
689
698
  return origin.endsWith("/v1") ? origin : `${origin}/v1`;
690
699
  }
700
+ function shopOrigin(baseUrl) {
701
+ let url;
702
+ try {
703
+ url = new URL(baseUrl);
704
+ } catch {
705
+ return null;
706
+ }
707
+ if (!url.hostname.startsWith("api.")) return null;
708
+ const port = url.port === "" ? "" : `:${url.port}`;
709
+ return `${url.protocol}//${url.hostname.slice("api.".length)}${port}`;
710
+ }
691
711
  function loadConfig(env = process.env) {
692
712
  const key = env.PORTPROOF_API_KEY?.trim();
693
713
  return {
@@ -769,6 +789,7 @@ function apiOrigin(baseUrl) {
769
789
  var ApiClient = class {
770
790
  baseUrl;
771
791
  wellKnownKeysUrl;
792
+ shopOrigin;
772
793
  hasApiKey;
773
794
  apiKey;
774
795
  fetchImpl;
@@ -777,6 +798,7 @@ var ApiClient = class {
777
798
  constructor(opts) {
778
799
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
779
800
  this.wellKnownKeysUrl = apiOrigin(this.baseUrl) + WELL_KNOWN_KEYS_PATH;
801
+ this.shopOrigin = shopOrigin(this.baseUrl);
780
802
  this.apiKey = opts.apiKey;
781
803
  this.hasApiKey = Boolean(opts.apiKey);
782
804
  this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -871,13 +893,36 @@ var ApiClient = class {
871
893
  idempotencyKey,
872
894
  timeoutMs: ROTATE_TIMEOUT_MS
873
895
  });
896
+ return this.rotationOutcome(r, "POST /ports/{id}/rotate");
897
+ }
898
+ async getRotationStatus(portId, rotationId) {
899
+ const r = await this.request(
900
+ "GET",
901
+ `/ports/${encodeURIComponent(portId)}/rotation/${encodeURIComponent(rotationId)}`
902
+ );
903
+ return this.rotationOutcome(r, "GET /ports/{id}/rotation/{rotation_id}");
904
+ }
905
+ rotationOutcome(r, label) {
874
906
  if (r.status === 202) {
875
907
  return {
876
908
  kind: "pending",
877
- pending: this.lenient(RotatePendingResponse, r.data, "POST /ports/{id}/rotate 202")
909
+ pending: this.lenient(RotatePendingResponse, r.data, `${label} 202`)
878
910
  };
879
911
  }
880
- return { kind: "receipt", receipt: this.lenient(Receipt, r.data, "POST /ports/{id}/rotate") };
912
+ return { kind: "receipt", receipt: this.lenient(Receipt, r.data, label) };
913
+ }
914
+ async renewPort(portId, idempotencyKey) {
915
+ const r = await this.request("POST", `/ports/${encodeURIComponent(portId)}/renew`, {
916
+ idempotencyKey
917
+ });
918
+ return this.lenient(Subscription, r.data, "POST /ports/{id}/renew");
919
+ }
920
+ async setAutoRenew(portId, enabled, idempotencyKey) {
921
+ const r = await this.request("PUT", `/ports/${encodeURIComponent(portId)}/auto-renew`, {
922
+ body: { enabled },
923
+ idempotencyKey
924
+ });
925
+ return this.lenient(Subscription, r.data, "PUT /ports/{id}/auto-renew");
881
926
  }
882
927
  async setRotationSchedule(portId, intervalS, idempotencyKey) {
883
928
  const r = await this.request("PUT", `/ports/${encodeURIComponent(portId)}/rotation`, {
@@ -947,13 +992,14 @@ var SearchInventoryInput = z13.object({
947
992
  country: CountryCode.optional().describe('ISO-3166 alpha-2, e.g. "LT"'),
948
993
  carrier: z13.string().min(1).optional().describe("Carrier name filter, exactly as search_inventory returns it")
949
994
  });
995
+ var PortSku = ProductSku.describe('SKU from get_pricing (field skus), e.g. "lt-4g"');
950
996
  var QuoteInput = z13.object({
951
- sku: SkuSchema,
997
+ sku: PortSku,
952
998
  term: Term,
953
999
  quantity: z13.number().int().min(1).max(1e3)
954
1000
  });
955
1001
  var BuyPortInput = z13.object({
956
- sku: SkuSchema.describe('Price table SKU, e.g. "lt-4g" or "trial-24h"'),
1002
+ sku: PortSku,
957
1003
  term: Term,
958
1004
  carrier_id: idSchema("car").optional().describe("Pin a carrier (from search_inventory); omit for any"),
959
1005
  auto_renew: z13.boolean().optional(),
@@ -968,6 +1014,17 @@ var RotateInput = z13.object({
968
1014
  port_id: idSchema("prt"),
969
1015
  ...ConfirmFields
970
1016
  });
1017
+ var RenewPortInput = z13.object({
1018
+ port_id: idSchema("prt"),
1019
+ ...ConfirmFields
1020
+ });
1021
+ var SetAutoRenewInput = z13.object({
1022
+ port_id: idSchema("prt"),
1023
+ enabled: z13.boolean().describe(
1024
+ "true = renew from the account balance before the term ends; false = the port ends at the end of its term"
1025
+ ),
1026
+ ...ConfirmFields
1027
+ });
971
1028
  var SetRotationScheduleInput = z13.object({
972
1029
  port_id: idSchema("prt"),
973
1030
  interval_s: z13.number().int().refine((v) => v === 0 || v >= ROTATION_INTERVAL_MIN_S && v <= ROTATION_INTERVAL_MAX_S, {
@@ -1014,8 +1071,10 @@ var BuyTrafficInput = z13.object({
1014
1071
  });
1015
1072
  var PortSummary = z13.object({
1016
1073
  id: idSchema("prt"),
1074
+ label: z13.string(),
1075
+ network: PortNetwork,
1017
1076
  state: z13.string(),
1018
- site_code: z13.string(),
1077
+ site_code: z13.string().optional(),
1019
1078
  country: z13.string(),
1020
1079
  carrier: z13.string(),
1021
1080
  ip_stack: z13.string(),
@@ -1023,10 +1082,11 @@ var PortSummary = z13.object({
1023
1082
  egress_v6_prefix: z13.string().nullable(),
1024
1083
  rotation_interval_s: z13.number().int(),
1025
1084
  last_rotation_at: z13.string().nullable(),
1026
- usage_today_bytes: z13.number().int(),
1027
- daily_cap_bytes: z13.number().int(),
1085
+ usage_today_bytes: z13.number().int().nullable(),
1086
+ daily_cap_bytes: z13.number().int().nullable(),
1028
1087
  uptime_30d_pct: z13.number().nullable(),
1029
1088
  subscription_ends_at: z13.string().nullable(),
1089
+ auto_renew: z13.boolean().nullable(),
1030
1090
  operator: z13.enum(["owned", "partner"]),
1031
1091
  evidence: z13.enum(["signed", "observed"]),
1032
1092
  attestation: z13.enum(["signed", "observed"])
@@ -1056,6 +1116,8 @@ var TOOL_NAMES = [
1056
1116
  "get_port",
1057
1117
  "rotate",
1058
1118
  "set_rotation_schedule",
1119
+ "renew_port",
1120
+ "set_auto_renew",
1059
1121
  "get_passport",
1060
1122
  "get_receipt",
1061
1123
  "get_usage",
@@ -1067,6 +1129,122 @@ var TOOL_NAMES = [
1067
1129
  "test_connection"
1068
1130
  ];
1069
1131
 
1132
+ var regionNames = new Intl.DisplayNames(["en"], { type: "region" });
1133
+ function countryName(code) {
1134
+ const cc = code.toUpperCase();
1135
+ if (cc === "GE") return "Georgia (country)";
1136
+ try {
1137
+ const name = regionNames.of(cc);
1138
+ if (name === void 0 || name === cc || /^unknown/i.test(name)) return cc;
1139
+ return name;
1140
+ } catch {
1141
+ return cc;
1142
+ }
1143
+ }
1144
+ function networkOf(port) {
1145
+ if (port.network !== void 0) return port.network;
1146
+ if (port.evidence === "observed") return "partner";
1147
+ return "own";
1148
+ }
1149
+ function neutralPortLabel(port) {
1150
+ const parts = [countryName(port.country)];
1151
+ if (port.carrierName) parts.push(port.carrierName);
1152
+ parts.push(port.id);
1153
+ return parts.join(" \xB7 ");
1154
+ }
1155
+ function urlHost(host) {
1156
+ if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
1157
+ return host;
1158
+ }
1159
+ function proxyUrl(scheme, credential) {
1160
+ const auth = `${encodeURIComponent(credential.username)}:${encodeURIComponent(credential.password)}`;
1161
+ return `${scheme}://${auth}@${urlHost(credential.host)}:${credential.port}`;
1162
+ }
1163
+ function proxyUrls(port) {
1164
+ const credentials = port.credentials;
1165
+ if (!credentials) return null;
1166
+ const shared = { username: credentials.username, password: credentials.password };
1167
+ const http = credentials.http ?? {
1168
+ host: port.listeners.host,
1169
+ port: port.listeners.http_port,
1170
+ ...shared
1171
+ };
1172
+ const socks5 = credentials.socks5 ?? {
1173
+ host: port.listeners.host,
1174
+ port: port.listeners.socks_port,
1175
+ ...shared
1176
+ };
1177
+ return { http: proxyUrl("http", http), socks5: proxyUrl("socks5", socks5) };
1178
+ }
1179
+ function carrierView(carrier, network) {
1180
+ if (network === "own") return carrier;
1181
+ const { mcc_mnc: _placeholder, ...rest } = carrier;
1182
+ return rest;
1183
+ }
1184
+ function portView(port) {
1185
+ const { site_code: siteCode, usage_today: usageToday, carrier, ...rest } = port;
1186
+ const network = networkOf(port);
1187
+ const label = neutralPortLabel({ id: port.id, country: port.country, carrierName: carrier.name });
1188
+ const view = { label, ...rest, carrier: carrierView(carrier, network), network };
1189
+ if (network === "own") {
1190
+ view.site_code = siteCode;
1191
+ view.usage_today = usageToday;
1192
+ }
1193
+ return view;
1194
+ }
1195
+ function summarisePort(port) {
1196
+ const network = networkOf(port);
1197
+ const summary = {
1198
+ id: port.id,
1199
+ label: neutralPortLabel({ id: port.id, country: port.country, carrierName: port.carrier.name }),
1200
+ network,
1201
+ state: port.state,
1202
+ country: port.country,
1203
+ carrier: port.carrier.name,
1204
+ ip_stack: port.carrier.ip_stack,
1205
+ egress_v4: port.egress_v4,
1206
+ egress_v6_prefix: port.egress_v6_prefix,
1207
+ rotation_interval_s: port.rotation_interval_s,
1208
+ last_rotation_at: port.last_rotation_at,
1209
+ usage_today_bytes: network === "own" ? port.usage_today.bytes : null,
1210
+ daily_cap_bytes: network === "own" ? port.usage_today.cap_bytes : null,
1211
+ uptime_30d_pct: port.uptime_30d_pct,
1212
+ subscription_ends_at: port.subscription?.ends_at ?? null,
1213
+ auto_renew: port.subscription?.auto_renew ?? null,
1214
+ operator: port.operator,
1215
+ evidence: port.evidence,
1216
+ attestation: port.attestation
1217
+ };
1218
+ if (network === "own") summary.site_code = port.site_code;
1219
+ return summary;
1220
+ }
1221
+ function passportView(passport) {
1222
+ const {
1223
+ site_code: siteCode,
1224
+ usage: usage2,
1225
+ sessions,
1226
+ sim_contract_class: simClass,
1227
+ ip_shared_with_own_ports: sharedWithOwn,
1228
+ carrier,
1229
+ ...rest
1230
+ } = passport;
1231
+ const network = networkOf(passport);
1232
+ const label = neutralPortLabel({
1233
+ id: passport.port_id,
1234
+ country: passport.country,
1235
+ carrierName: carrier.name
1236
+ });
1237
+ const view = { label, ...rest, carrier: carrierView(carrier, network), network };
1238
+ if (network === "own") {
1239
+ view.site_code = siteCode;
1240
+ view.usage = usage2;
1241
+ view.sessions = sessions;
1242
+ view.sim_contract_class = simClass;
1243
+ view.ip_shared_with_own_ports = sharedWithOwn;
1244
+ }
1245
+ return view;
1246
+ }
1247
+
1070
1248
  function defineTool(def) {
1071
1249
  return def;
1072
1250
  }
@@ -1086,10 +1264,10 @@ var MUTATING = {
1086
1264
  var getPassport = defineTool({
1087
1265
  name: "get_passport",
1088
1266
  title: "Get port details",
1089
- description: "Port details (API object: passport): carrier, SIM contract class, egress IPs, geo consensus, reputation, rotation and uptime statistics, throughput, usage, concurrent sessions (proof the port is not shared) and exclusivity. Credentials are never included.",
1267
+ description: "Port details (API object: passport): label (country, carrier, port id), network (own = Own network, partner = Partner network), carrier, egress IPs, geo consensus, reputation, rotation and uptime statistics, throughput and exclusivity. Own network ports add the SIM contract class, usage against the daily cap and concurrent sessions (proof the port is not shared); a Partner network port has none of these, because nothing of ours runs on its device. Credentials are never included.",
1090
1268
  inputSchema: PortIdInput,
1091
1269
  annotations: READ_ONLY,
1092
- run: async (args, ctx) => jsonResult(await ctx.api.getPassport(args.port_id))
1270
+ run: async (args, ctx) => jsonResult(passportView(await ctx.api.getPassport(args.port_id)))
1093
1271
  });
1094
1272
  async function verifySignature(receipt, ctx) {
1095
1273
  try {
@@ -1143,35 +1321,42 @@ function checkConfirmation(args, action) {
1143
1321
  return null;
1144
1322
  }
1145
1323
 
1146
- function summarisePort(p) {
1147
- return {
1148
- id: p.id,
1149
- state: p.state,
1150
- site_code: p.site_code,
1151
- country: p.country,
1152
- carrier: p.carrier.name,
1153
- ip_stack: p.carrier.ip_stack,
1154
- egress_v4: p.egress_v4,
1155
- egress_v6_prefix: p.egress_v6_prefix,
1156
- rotation_interval_s: p.rotation_interval_s,
1157
- last_rotation_at: p.last_rotation_at,
1158
- usage_today_bytes: p.usage_today.bytes,
1159
- daily_cap_bytes: p.usage_today.cap_bytes,
1160
- uptime_30d_pct: p.uptime_30d_pct,
1161
- subscription_ends_at: p.subscription?.ends_at ?? null,
1162
- operator: p.operator,
1163
- evidence: p.evidence,
1164
- attestation: p.attestation
1165
- };
1324
+ var PRICE_TABLE_SKUS = new Set(SkuSchema.options);
1325
+ function isPriceTableSku(sku) {
1326
+ return PRICE_TABLE_SKUS.has(sku);
1327
+ }
1328
+ async function findCatalogueListing(sku, ctx) {
1329
+ const pricing = await ctx.api.getPricing();
1330
+ return pricing.skus.find((row) => row.sku === sku && row.buy_via === "cart") ?? null;
1166
1331
  }
1167
- function proxyUrls(p) {
1168
- if (!p.credentials) return null;
1169
- const auth = `${encodeURIComponent(p.credentials.username)}:${encodeURIComponent(p.credentials.password)}`;
1332
+ function shopAnswer(listing, requestedTerm, ctx) {
1333
+ const shopPath = `/shop/${encodeURIComponent(listing.sku)}`;
1334
+ const shopUrl = ctx.api.shopOrigin === null ? null : `${ctx.api.shopOrigin}${shopPath}`;
1335
+ const steps = [
1336
+ "This port is bought in the Portproof web shop, not through buy_port: open shop_url, add it to the cart and check out.",
1337
+ "The port is delivered after payment and company verification."
1338
+ ];
1339
+ if (listing.term !== requestedTerm) {
1340
+ steps.push(
1341
+ `This SKU is the ${listing.term} term; get_pricing lists the SKUs of the other terms.`
1342
+ );
1343
+ }
1170
1344
  return {
1171
- http: `http://${auth}@${p.listeners.host}:${p.listeners.http_port}`,
1172
- socks5: `socks5://${auth}@${p.listeners.host}:${p.listeners.socks_port}`
1345
+ buy_via: "cart",
1346
+ sku: listing.sku,
1347
+ term: listing.term,
1348
+ price_cents: listing.price_cents,
1349
+ currency: listing.currency,
1350
+ shop_path: shopPath,
1351
+ shop_url: shopUrl,
1352
+ next_step: steps.join(" ")
1173
1353
  };
1174
1354
  }
1355
+ var NOT_ON_SALE_DETAIL = "This SKU is not on sale for your organisation right now. get_pricing (field skus) lists what can be bought; dedicated ports are coming soon.";
1356
+ function notOnSale() {
1357
+ return localError("not_on_sale", "Not on sale", NOT_ON_SALE_DETAIL);
1358
+ }
1359
+
1175
1360
  async function costOf(port, input, ctx) {
1176
1361
  if (port.subscription) {
1177
1362
  return {
@@ -1187,27 +1372,33 @@ async function costOf(port, input, ctx) {
1187
1372
  return { cost_cents: null, currency: null, cost_source: "unknown" };
1188
1373
  }
1189
1374
  }
1375
+ async function answerCatalogueBuy(args, ctx) {
1376
+ const listing = await findCatalogueListing(args.sku, ctx);
1377
+ if (listing === null) return notOnSale();
1378
+ return jsonResult({ purchased: false, charged: false, ...shopAnswer(listing, args.term, ctx) });
1379
+ }
1190
1380
  var buyPort = defineTool({
1191
1381
  name: "buy_port",
1192
1382
  title: "Buy a port",
1193
- description: "Dedicated ports (coming soon, not on sale yet; do not offer them): buy one dedicated mobile port (one modem, one SIM, one customer) for a SKU and term; check search_inventory first. For proxies by the GB use buy_traffic instead. CHARGES MONEY: requires confirm: true and an idempotency_key; refused otherwise. Fails with kyb_required (402) until the org has passed KYB tier t1, and no_inventory (409) when the SKU is sold out. Returns the Port with credentials and cost_cents.",
1383
+ description: 'Dedicated ports (coming soon, not on sale yet; do not offer them): buy one dedicated mobile port (one modem, one SIM, one customer) for a SKU and term; check search_inventory first. For proxies by the GB use buy_traffic instead. CHARGES MONEY: requires confirm: true and an idempotency_key; refused otherwise. Fails with kyb_required (402) until the org has passed KYB tier t1, and no_inventory (409) when the SKU is sold out. Returns the Port with credentials and cost_cents. Partner network SKUs (buy_via "cart" in get_pricing) are bought in the web shop: for them nothing is charged and the answer is purchased: false with shop_url, or not_on_sale when they are not on sale for your organisation.',
1194
1384
  inputSchema: BuyPortInput,
1195
1385
  annotations: MUTATING,
1196
1386
  run: async (args, ctx) => {
1197
1387
  const refusal = checkConfirmation(args, "buy_port");
1198
1388
  if (refusal) return refusal;
1389
+ if (!isPriceTableSku(args.sku)) return answerCatalogueBuy(args, ctx);
1199
1390
  const body = { sku: args.sku, term: args.term, quantity: 1 };
1200
1391
  if (args.carrier_id !== void 0) body.carrier_id = args.carrier_id;
1201
1392
  if (args.auto_renew !== void 0) body.auto_renew = args.auto_renew;
1202
1393
  const { data: port } = await ctx.api.createPort(body, args.idempotency_key);
1203
1394
  const cost = await costOf(port, args, ctx);
1204
- return jsonResult({ ...cost, port, proxy_urls: proxyUrls(port) });
1395
+ return jsonResult({ ...cost, port: portView(port), proxy_urls: proxyUrls(port) });
1205
1396
  }
1206
1397
  });
1207
1398
  var listPorts = defineTool({
1208
1399
  name: "list_ports",
1209
1400
  title: "List ports",
1210
- description: "List the ports owned by this API key (compact summaries; use get_port for credentials and full detail). Paginated: pass next_cursor as cursor to continue.",
1401
+ description: "List the ports owned by this API key as compact summaries: label (country, carrier, port id), network (own = Own network, partner = Partner network), state, egress IPs, rotation, usage, subscription end and auto_renew. No credentials: use get_port for those. Paginated: pass next_cursor as cursor to continue.",
1211
1402
  inputSchema: ListPortsInput,
1212
1403
  annotations: READ_ONLY,
1213
1404
  run: async (args, ctx) => {
@@ -1221,47 +1412,65 @@ var listPorts = defineTool({
1221
1412
  var getPort = defineTool({
1222
1413
  name: "get_port",
1223
1414
  title: "Get port",
1224
- description: "Full detail for one port: state, carrier, listeners, credentials (only if you own it), allowlist, egress IPs, rotation settings, usage today, uptime and subscription. Also returns ready-to-use proxy_urls.",
1415
+ description: "Full detail for one port: label, network, state, carrier, listeners, credentials (only if you own it), allowlist, egress IPs, rotation settings, usage today, uptime and subscription. Partner network ports also carry credentials.http and credentials.socks5 (each with its own host, port, login and password), capabilities (controls the port supports), renewal (price_cents, ends_at, auto_renew, can_renew) and rotation_url: the Portproof link that rotates the IP when fetched (60 s cooldown). proxy_urls gives ready-to-use http and socks5 URLs, each built from its own credential set.",
1225
1416
  inputSchema: PortIdInput,
1226
1417
  annotations: READ_ONLY,
1227
1418
  run: async (args, ctx) => {
1228
1419
  const port = await ctx.api.getPort(args.port_id);
1229
- return jsonResult({ port, proxy_urls: proxyUrls(port) });
1420
+ return jsonResult({ port: portView(port), proxy_urls: proxyUrls(port) });
1230
1421
  }
1231
1422
  });
1423
+ function sleep(ms) {
1424
+ return new Promise((resolve) => setTimeout(resolve, ms));
1425
+ }
1426
+ async function awaitRotation(portId, rotationId, startedAt, ctx) {
1427
+ const intervalMs = ctx.rotationPoll?.intervalMs ?? ROTATION_POLL_INTERVAL_MS;
1428
+ const deadline = startedAt + (ctx.rotationPoll?.totalWaitMs ?? ROTATION_TOTAL_WAIT_MS);
1429
+ while (Date.now() + intervalMs <= deadline) {
1430
+ await sleep(intervalMs);
1431
+ const status = await ctx.api.getRotationStatus(portId, rotationId);
1432
+ if (status.kind === "receipt") return status.receipt;
1433
+ }
1434
+ return null;
1435
+ }
1436
+ function rotationDone(receipt) {
1437
+ return jsonResult({
1438
+ status: "done",
1439
+ rotated: receipt.rotated,
1440
+ reason: receipt.reason,
1441
+ old_ip4: receipt.old_ip4,
1442
+ new_ip4: receipt.new_ip4,
1443
+ elapsed_ms: receipt.elapsed_ms,
1444
+ receipt
1445
+ });
1446
+ }
1232
1447
  var rotate = defineTool({
1233
1448
  name: "rotate",
1234
1449
  title: "Rotate IP",
1235
- description: "Rotate IP: request a new egress IP for a port and wait (up to 180 s) for the rotation record, signed on the device for Own network ports (evidence: signed) or recorded by our monitoring for Partner network ports (evidence: observed). Changes live proxy state: requires confirm: true and an idempotency_key; refused otherwise. Carriers sometimes return the same IP (rotated: false, reason carrier_sticky); a rotation record is written either way. 429 port_cooldown (with retry_after seconds) inside the 60 s cooldown. Included in the subscription: no charge.",
1450
+ description: "Rotate IP: request a new egress IP for a port and wait (up to 180 s in all) for the rotation record, signed on the device for Own network ports (evidence: signed) or recorded by our monitoring for Partner network ports (evidence: observed). A rotation still running after the API's first answer is followed on its status link. Changes live proxy state: requires confirm: true and an idempotency_key; refused otherwise. Carriers sometimes return the same IP (rotated: false, reason carrier_sticky); a rotation record is written either way. 429 port_cooldown (with retry_after seconds) inside the 60 s cooldown. Included in the subscription: no charge.",
1236
1451
  inputSchema: RotateInput,
1237
1452
  annotations: MUTATING,
1238
1453
  run: async (args, ctx) => {
1239
1454
  const refusal = checkConfirmation(args, "rotate");
1240
1455
  if (refusal) return refusal;
1456
+ const startedAt = Date.now();
1241
1457
  const result = await ctx.api.rotatePort(args.port_id, args.idempotency_key);
1242
- if (result.kind === "pending") {
1243
- return jsonResult({
1244
- status: "pending",
1245
- rotation_id: result.pending.rotation_id,
1246
- port_id: args.port_id
1247
- });
1248
- }
1249
- const r = result.receipt;
1458
+ if (result.kind === "receipt") return rotationDone(result.receipt);
1459
+ const rotationId = result.pending.rotation_id;
1460
+ const receipt = await awaitRotation(args.port_id, rotationId, startedAt, ctx);
1461
+ if (receipt !== null) return rotationDone(receipt);
1250
1462
  return jsonResult({
1251
- status: "done",
1252
- rotated: r.rotated,
1253
- reason: r.reason,
1254
- old_ip4: r.old_ip4,
1255
- new_ip4: r.new_ip4,
1256
- elapsed_ms: r.elapsed_ms,
1257
- receipt: r
1463
+ status: "pending",
1464
+ rotation_id: rotationId,
1465
+ port_id: args.port_id,
1466
+ detail: "The rotation is still running. Do not rotate again: read get_receipt with this rotation_id in a minute, or get_port for the new egress IP."
1258
1467
  });
1259
1468
  }
1260
1469
  });
1261
1470
  var setRotationSchedule = defineTool({
1262
1471
  name: "set_rotation_schedule",
1263
1472
  title: "Set rotation schedule",
1264
- description: "Set the automatic rotation interval for a port: 0 = sticky (never rotate on a schedule), otherwise 60..86400 seconds. Schedules run on the device even if the API is unreachable. Changes live proxy state: requires confirm: true and an idempotency_key; refused otherwise. No charge.",
1473
+ description: "Set the automatic rotation interval for a port: 0 = sticky (never rotate on a schedule), otherwise 60..86400 seconds. The schedule keeps running between API calls. When get_port shows capabilities.rotation_interval: false the port has no schedule control. A change the network has not confirmed yet answers conflict (409); try again later. Changes live proxy state: requires confirm: true and an idempotency_key; refused otherwise. No charge.",
1265
1474
  inputSchema: SetRotationScheduleInput,
1266
1475
  annotations: MUTATING,
1267
1476
  run: async (args, ctx) => {
@@ -1281,11 +1490,54 @@ var setRotationSchedule = defineTool({
1281
1490
  });
1282
1491
  }
1283
1492
  });
1493
+ var renewPort = defineTool({
1494
+ name: "renew_port",
1495
+ title: "Renew a port",
1496
+ description: "Dedicated ports: renew one port for one more term now, paid from the account balance. Read get_port first: renewal.can_renew says whether it can be renewed now and renewal.price_cents what one more term costs. A Partner network port is renewed on the network first and ends_at is the end it confirmed; there is no grace period after a term ends. CHARGES MONEY: requires confirm: true and an idempotency_key; refused otherwise. Errors: insufficient_funds (402, nothing charged: top up the balance), conflict (409, for example while an earlier renewal is still being confirmed), supply_source_error (502, the renewal was refused and the charge refunded). Returns ends_at and cost_cents.",
1497
+ inputSchema: RenewPortInput,
1498
+ annotations: MUTATING,
1499
+ run: async (args, ctx) => {
1500
+ const refusal = checkConfirmation(args, "renew_port");
1501
+ if (refusal) return refusal;
1502
+ const subscription = await ctx.api.renewPort(args.port_id, args.idempotency_key);
1503
+ return jsonResult({
1504
+ port_id: args.port_id,
1505
+ renewed: true,
1506
+ ends_at: subscription.ends_at,
1507
+ auto_renew: subscription.auto_renew,
1508
+ cost_cents: subscription.price_cents,
1509
+ currency: subscription.currency,
1510
+ subscription
1511
+ });
1512
+ }
1513
+ });
1514
+ var setAutoRenew = defineTool({
1515
+ name: "set_auto_renew",
1516
+ title: "Set auto-renew",
1517
+ description: "Dedicated ports: switch auto-renew of one port on or off. On: the port is renewed from the account balance shortly before its term ends (a short balance means it ends). Off: the port ends at the end of its current term, with no refund. Changes the subscription: requires confirm: true and an idempotency_key; refused otherwise. No charge now.",
1518
+ inputSchema: SetAutoRenewInput,
1519
+ annotations: MUTATING,
1520
+ run: async (args, ctx) => {
1521
+ const refusal = checkConfirmation(args, "set_auto_renew");
1522
+ if (refusal) return refusal;
1523
+ const subscription = await ctx.api.setAutoRenew(
1524
+ args.port_id,
1525
+ args.enabled,
1526
+ args.idempotency_key
1527
+ );
1528
+ return jsonResult({
1529
+ port_id: args.port_id,
1530
+ auto_renew: subscription.auto_renew,
1531
+ ends_at: subscription.ends_at,
1532
+ subscription
1533
+ });
1534
+ }
1535
+ });
1284
1536
 
1285
1537
  var getPricing = defineTool({
1286
1538
  name: "get_pricing",
1287
1539
  title: "Get pricing",
1288
- description: 'Public price table. Field traffic: proxy traffic by the GB (tier table with the per-GB price by order size in EUR cents ex VAT, package sizes, the 0.5 GB trial, validity days, monthly GB without company verification); buy it with buy_traffic. Field skus: dedicated ports (coming soon, not on sale yet) with monthly price in cents (EUR, USD for us-4g), supply (evidence signed = Own network, observed = Partner network), volume ladder, weekly/daily term ratios and the 24 h trial. Each SKU says how it is bought (buy_via): "ports" SKUs work with quote and buy_port; "cart" SKUs are Partner network ports sold only through the web shop and cannot be quoted or bought here. No API key needed. Prices are quotes only; nothing is charged.',
1540
+ description: 'Public price table. Field traffic: proxy traffic by the GB (tier table with the per-GB price by order size in EUR cents ex VAT, package sizes, the 0.5 GB trial, validity days, monthly GB without company verification); buy it with buy_traffic. Field skus: dedicated ports (coming soon, not on sale yet) with monthly price in cents (EUR, USD for us-4g), supply (evidence signed = Own network, observed = Partner network), volume ladder, weekly/daily term ratios and the 24 h trial. Each SKU says how it is bought (buy_via): "ports" SKUs work with quote and buy_port; "cart" SKUs are Partner network ports, one SKU per term, sold only through the web shop (quote and buy_port answer them with the shop link). Partner network ports are listed only while they are on sale for your organisation, so the list can differ with and without an API key. No API key needed. Prices are quotes only; nothing is charged.',
1289
1541
  inputSchema: EmptyInput,
1290
1542
  annotations: READ_ONLY,
1291
1543
  run: async (_args, ctx) => jsonResult(await ctx.api.getPricing())
@@ -1293,7 +1545,7 @@ var getPricing = defineTool({
1293
1545
  var searchInventory = defineTool({
1294
1546
  name: "search_inventory",
1295
1547
  title: "Search inventory",
1296
- description: "Dedicated ports (coming soon, not on sale yet): available dedicated mobile ports per country / site / carrier, with the number available and whether new ports are wait-listed. Use the returned carrier_id to pin a carrier in buy_port. No API key needed.",
1548
+ description: "Dedicated ports (coming soon, not on sale yet): available Own network dedicated mobile ports per country / site / carrier, with the number available and whether new ports are wait-listed. Use the returned carrier_id to pin a carrier in buy_port. Partner network ports are not listed here: get_pricing lists the ones on sale for you. No API key needed.",
1297
1549
  inputSchema: SearchInventoryInput,
1298
1550
  annotations: READ_ONLY,
1299
1551
  run: async (args, ctx) => {
@@ -1306,10 +1558,19 @@ var searchInventory = defineTool({
1306
1558
  var quote = defineTool({
1307
1559
  name: "quote",
1308
1560
  title: "Quote a purchase",
1309
- description: "Price a purchase before buying: unit price, volume discount and total in cents for a SKU, term (monthly | weekly | daily) and quantity. Read-only, nothing is charged.",
1561
+ description: 'Price a purchase before buying: unit price, volume discount and total in cents for a SKU, term (monthly | weekly | daily) and quantity. For a Partner network SKU (buy_via "cart") the answer is its listed price per port and the web shop link, where the cart shows the total; not_on_sale when it is not on sale for your organisation. Read-only, nothing is charged.',
1310
1562
  inputSchema: QuoteInput,
1311
1563
  annotations: READ_ONLY,
1312
1564
  run: async (args, ctx) => {
1565
+ if (!isPriceTableSku(args.sku)) {
1566
+ const listing = await findCatalogueListing(args.sku, ctx);
1567
+ if (listing === null) return notOnSale();
1568
+ return jsonResult({
1569
+ ...shopAnswer(listing, args.term, ctx),
1570
+ quantity: args.quantity,
1571
+ charged: false
1572
+ });
1573
+ }
1313
1574
  const q = await ctx.api.postQuote({ sku: args.sku, term: args.term, quantity: args.quantity });
1314
1575
  return jsonResult({
1315
1576
  ...q,
@@ -1470,6 +1731,8 @@ var TOOLS = [
1470
1731
  getPort,
1471
1732
  rotate,
1472
1733
  setRotationSchedule,
1734
+ renewPort,
1735
+ setAutoRenew,
1473
1736
  getPassport,
1474
1737
  getReceipt,
1475
1738
  getUsage,
@@ -1490,10 +1753,11 @@ if (registered.size !== TOOL_NAMES.length)
1490
1753
  var INSTRUCTIONS = [
1491
1754
  `Portproof sells proxy traffic by the GB: one prepaid GB balance per organisation, usable on two shared pools, mobile 4G/5G (carrier modems in ${MOBILE_COUNTRIES_TEXT}) and residential (opt-in peer devices; the countries online now come from list_countries, so never state a country count from memory). Pool, country, rotation (new IP every 5 / 10 / 20 / 60 minutes, on every connection, or a sticky session) and the session name are chosen in the proxy username, so one credential serves everything. Be precise: the pools are shared, a sticky session keeps the same device and the carrier may still change its IP; never call this traffic dedicated, private, static or unlimited, and never list pool exit addresses. Validity is what get_traffic reports: limits.validity_days 0 (and expires_at null) means GB never expire; a positive number is the days of validity every purchase guarantees for the whole balance.`,
1492
1755
  "Traffic tools: get_traffic (balance, expiry, limits), list_countries (devices online now per pool), build_proxy_url (username, URL and code snippets; the password stays masked unless reveal_password is true), test_connection (one request through the gateway: exit IP and latency; exit_country is normally null because the exit country is not checked; never report the requested country as the exit country), buy_traffic (wallet purchase of whole GB or the one-off 0.5 GB trial; prices come from the tier table in get_pricing, field traffic).",
1493
- 'Dedicated ports are coming soon and not on sale yet: do not offer them. get_pricing (field skus), search_inventory, quote, buy_port, list_ports, get_port, rotate, set_rotation_schedule, get_passport, get_receipt and get_usage work on dedicated mobile ports (one modem, one SIM, one customer per port) for organisations that already have one. Every port states its supply: "signed" evidence means Own network (each rotation record is signed on the device), "observed" evidence means Partner network (recorded by our monitoring and signed by the control plane key); never describe a Partner network port as signed on the device.',
1756
+ 'Dedicated ports are coming soon and not on sale yet: do not offer them. get_pricing (field skus), search_inventory, quote, buy_port, list_ports, get_port, rotate, set_rotation_schedule, renew_port, set_auto_renew, get_passport, get_receipt and get_usage work on dedicated mobile ports (one modem, one SIM, one customer per port) for organisations that already have one. Every port states its supply: "signed" evidence means Own network (each rotation record is signed on the device), "observed" evidence means Partner network (recorded by our monitoring and signed by the control plane key); never describe a Partner network port as signed on the device. Name a port by its label (country, carrier, port id) and say only "Partner network" about where a Partner network port runs.',
1757
+ 'Partner network ports are bought in the web shop, one SKU per term (buy_via "cart" in get_pricing): quote and buy_port answer them with shop_url and never charge. Their HTTP and SOCKS5 connections may differ in host, port, login and password: give the user proxy_urls from get_port, never a URL assembled from one set. rotation_url is the Portproof link that rotates the IP when fetched. They renew from the account balance: renewal in get_port says what one more term costs and whether it can be renewed now; auto-renew off means the port ends at the end of its term.',
1494
1758
  "Read tools (get_pricing, search_inventory, quote, list_ports, get_port, get_passport, get_receipt, get_usage, get_status, get_traffic, list_countries, build_proxy_url) are safe to call freely; test_connection uses a few kilobytes of the balance and is limited to 6 per minute.",
1495
- "buy_traffic and buy_port charge money and rotate / set_rotation_schedule change live proxy state: they are refused unless called with confirm: true and an idempotency_key. buy_traffic also needs accept_terms: true, which records that the user accepts the terms, the acceptable-use policy and the immediate start of the service. Ask the user before setting confirm: true or accept_terms: true, and reuse the same idempotency_key only to retry the identical request.",
1496
- 'Every response is compact JSON. Successful purchases include cost_cents. Errors are {"error": {code, status, title, detail}} using the API problem codes (insufficient_funds, kyb_required_for_volume, trial_already_used, traffic_not_purchased, kyb_required, no_inventory, port_cooldown with retry_after, insufficient_scope, not_found, validation_failed, ...).',
1759
+ "buy_traffic, buy_port and renew_port charge money, rotate and set_rotation_schedule change live proxy state and set_auto_renew changes the subscription: they are refused unless called with confirm: true and an idempotency_key. buy_traffic also needs accept_terms: true, which records that the user accepts the terms, the acceptable-use policy and the immediate start of the service. Ask the user before setting confirm: true or accept_terms: true, and reuse the same idempotency_key only to retry the identical request.",
1760
+ 'Every response is compact JSON. Successful purchases include cost_cents. Errors are {"error": {code, status, title, detail}} using the API problem codes (insufficient_funds, kyb_required_for_volume, trial_already_used, traffic_not_purchased, kyb_required, no_inventory, use_cart, port_cooldown with retry_after, conflict, insufficient_scope, not_found, validation_failed, ...) plus local codes such as not_on_sale.',
1497
1761
  "get_pricing, search_inventory, quote, get_status and list_countries work without an API key; everything else needs PORTPROOF_API_KEY. Key scopes are enforced: get_traffic needs ports:read (connection details only with ports:write), and build_proxy_url, test_connection and buy_traffic need ports:write; a missing scope answers insufficient_scope."
1498
1762
  ].join(" ");
1499
1763
  function createServer(ctx) {
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Portproof MCP server
2
2
 
3
- > Proxy traffic by the GB: one prepaid GB balance per organisation, usable on two shared pools, mobile 4G/5G (carrier modems) and residential (opt-in peer devices). Pool, country, rotation and session are chosen in the proxy username, so one credential serves everything. The pools are shared: the traffic is never dedicated, private, static or unlimited, and a sticky session keeps the same device while the carrier may still change its IP. Countries online now come from list_countries; never state a country count from memory. Dedicated ports are coming soon and not on sale yet. Paid in cryptocurrency. This stdio MCP server (npm package @portproof/mcp) exposes the Portproof API (base `PORTPROOF_API_URL` + `/v1`, default https://api.portproof.org; key `PORTPROOF_API_KEY`) as seventeen tools.
3
+ > Proxy traffic by the GB: one prepaid GB balance per organisation, usable on two shared pools, mobile 4G/5G (carrier modems) and residential (opt-in peer devices). Pool, country, rotation and session are chosen in the proxy username, so one credential serves everything. The pools are shared: the traffic is never dedicated, private, static or unlimited, and a sticky session keeps the same device while the carrier may still change its IP. Countries online now come from list_countries; never state a country count from memory. Dedicated ports are coming soon and not on sale yet. Paid in cryptocurrency. This stdio MCP server (npm package @portproof/mcp) exposes the Portproof API (base `PORTPROOF_API_URL` + `/v1`, default https://api.portproof.org; key `PORTPROOF_API_KEY`) as nineteen tools.
4
4
 
5
5
  ## Traffic tools
6
6
 
@@ -12,24 +12,26 @@
12
12
 
13
13
  ## Port tools (dedicated ports: coming soon, not on sale yet)
14
14
 
15
- - get_pricing {} -> field traffic (per-GB tier table, packages, trial, validity) and field skus (port price table). Public.
16
- - search_inventory { country?, carrier? } -> available ports per site/carrier with carrier_id. Public.
17
- - quote { sku, term, quantity } -> unit_cents, discount_pct, total_cents, currency, charged:false. Public.
18
- - buy_port { sku, term, carrier_id?, auto_renew?, confirm:true, idempotency_key } -> port, proxy_urls, cost_cents, currency. Charges money.
19
- - list_ports { limit?, cursor? } -> compact port summaries (no credentials), next_cursor.
20
- - get_port { port_id } -> full Port with credentials (if owned) and proxy_urls.
21
- - rotate { port_id, confirm:true, idempotency_key } -> rotated, reason, old_ip4, new_ip4, elapsed_ms and the signed rotation record. Waits up to 180 s. No charge.
15
+ - get_pricing {} -> field traffic (per-GB tier table, packages, trial, validity) and field skus (port price table; buy_via ports or cart; Partner network SKUs only while on sale for the caller's organisation). Public.
16
+ - search_inventory { country?, carrier? } -> available Own network ports per site/carrier with carrier_id. Public.
17
+ - quote { sku, term, quantity } -> unit_cents, discount_pct, total_cents, currency, charged:false; for a Partner network SKU price_cents and shop_url instead, or not_on_sale. Public.
18
+ - buy_port { sku, term, carrier_id?, auto_renew?, confirm:true, idempotency_key } -> port, proxy_urls, cost_cents, currency. Charges money. A Partner network SKU (buy_via "cart" in get_pricing) is bought in the web shop: the answer is purchased:false, charged:false, price_cents and shop_url, or not_on_sale.
19
+ - list_ports { limit?, cursor? } -> compact port summaries (label, network, no credentials), next_cursor. A Partner network port has no site_code.
20
+ - get_port { port_id } -> full Port with label, network, credentials (if owned; Partner network ports carry credentials.http and credentials.socks5, which may differ in every field), capabilities, renewal, rotation_url (the Portproof link that rotates the IP) and proxy_urls built from each protocol's own set.
21
+ - rotate { port_id, confirm:true, idempotency_key } -> rotated, reason, old_ip4, new_ip4, elapsed_ms and the signed rotation record. Waits up to 180 s in all, following the status link when the first answer is 202; status pending with rotation_id when still running. No charge.
22
22
  - set_rotation_schedule { port_id, interval_s (0 = sticky, else 60..86400), confirm:true, idempotency_key } -> rotation_interval_s, sticky. No charge.
23
- - get_passport { port_id } -> port details (API object passport). Never credentials.
23
+ - renew_port { port_id, confirm:true, idempotency_key } -> renewed, ends_at, auto_renew, cost_cents, currency. One more term from the account balance. Charges money.
24
+ - set_auto_renew { port_id, enabled, confirm:true, idempotency_key } -> auto_renew, ends_at. Off: the port ends at the end of its term. No charge now.
25
+ - get_passport { port_id } -> port details (API object passport) with label and network. Never credentials.
24
26
  - get_receipt { receipt_id } -> rotation record plus signature { verified, key_id, reason, keys_url } checked against <API origin>/.well-known/portproof-keys.json.
25
27
  - get_usage { port_id, from?, to?, granularity? } -> buckets, totals, data[]. Default granularity hour.
26
28
  - get_status {} -> platform, sites, carriers, public incidents, support. Public.
27
29
 
28
30
  ## Rules
29
31
 
30
- - buy_traffic, buy_port, rotate and set_rotation_schedule are refused (error code confirmation_required / idempotency_key_required, no API call made) unless confirm is exactly true and idempotency_key is a non-empty printable string; buy_traffic is also refused (terms_acceptance_required) without accept_terms: true. Ask the user before confirming. A refused purchase stays stored under its key for 24 h, so buy with a new key after fixing the cause. Reuse an idempotency_key only to retry the identical request; the API answers idempotency_conflict (409) when a key is reused for a different purchase.
31
- - cost_cents is present whenever money moved (buy_traffic, buy_port). quote never charges.
32
- - Every response is compact JSON in one text block. Errors: {"error":{code,status,title,detail,retry_after?,errors?}} using API problem codes: insufficient_funds, kyb_required_for_volume, trial_already_used, traffic_not_purchased, kyb_required, no_inventory, port_cooldown (retry_after seconds), rescore_quota, insufficient_scope, unauthorized, not_found, validation_failed, idempotency_conflict, rate_limited; local codes: confirmation_required, idempotency_key_required, terms_acceptance_required, api_unreachable, timeout, http_error, config_missing (no PORTPROOF_API_KEY), unexpected_response.
32
+ - buy_traffic, buy_port, renew_port, rotate, set_rotation_schedule and set_auto_renew are refused (error code confirmation_required / idempotency_key_required, no API call made) unless confirm is exactly true and idempotency_key is a non-empty printable string; buy_traffic is also refused (terms_acceptance_required) without accept_terms: true. Ask the user before confirming. A refused purchase stays stored under its key for 24 h, so buy with a new key after fixing the cause. Reuse an idempotency_key only to retry the identical request; the API answers idempotency_conflict (409) when a key is reused for a different purchase.
33
+ - cost_cents is present whenever money moved (buy_traffic, buy_port, renew_port). quote never charges.
34
+ - Every response is compact JSON in one text block. Errors: {"error":{code,status,title,detail,retry_after?,errors?}} using API problem codes: insufficient_funds, kyb_required_for_volume, trial_already_used, traffic_not_purchased, kyb_required, no_inventory, use_cart, conflict, port_cooldown (retry_after seconds), rescore_quota, insufficient_scope, unauthorized, not_found, validation_failed, idempotency_conflict, rate_limited; local codes: confirmation_required, idempotency_key_required, terms_acceptance_required, not_on_sale, api_unreachable, timeout, http_error, config_missing (no PORTPROOF_API_KEY), unexpected_response.
33
35
  - Ids are prefixed ULIDs: prt_ ports, rot_ rotations (rotation records), car_ carriers, ord_ orders.
34
36
  - Money is integer cents in EUR (the us-4g port SKU is USD). Times are ISO 8601 UTC.
35
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portproof/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for Portproof: proxy traffic by the GB on shared mobile 4G/5G and residential pools, paid in crypto, as tools for AI agents",
6
6
  "keywords": [