listingbureau-mcp 0.1.5 → 0.1.7

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
@@ -164,7 +164,7 @@ Create an account at [listingbureau.com](https://listingbureau.com/mcp?utm_sourc
164
164
 
165
165
  | Tool | Description |
166
166
  |------|-------------|
167
- | `lb_feedback_submit` | Submit feedback (10-5000 characters) |
167
+ | `lb_feedback_submit` | Submit feedback, feature requests, or suggestions (10-5000 characters) |
168
168
 
169
169
  ## šŸ› ļø Development
170
170
 
@@ -30,6 +30,6 @@ export declare class LBClient {
30
30
  * Make an authenticated API request.
31
31
  * Retries once on 401 (token expired mid-request).
32
32
  */
33
- request<T>(method: string, path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<ApiSuccessResponse<T>>;
33
+ request<T>(method: string, path: string, body?: Record<string, unknown>, query?: Record<string, string>, toolName?: string): Promise<ApiSuccessResponse<T>>;
34
34
  private doRequest;
35
35
  }
@@ -153,14 +153,14 @@ export class LBClient {
153
153
  * Make an authenticated API request.
154
154
  * Retries once on 401 (token expired mid-request).
155
155
  */
156
- async request(method, path, body, query) {
156
+ async request(method, path, body, query, toolName) {
157
157
  await this.ensureAuth();
158
- const response = await this.doRequest(method, path, body, query);
158
+ const response = await this.doRequest(method, path, body, query, toolName);
159
159
  // Single retry on 401
160
160
  if (response.status === "error" && response._statusCode === 401) {
161
161
  this.jwt = null;
162
162
  await this.ensureAuth();
163
- const retry = await this.doRequest(method, path, body, query);
163
+ const retry = await this.doRequest(method, path, body, query, toolName);
164
164
  if (retry.status === "error") {
165
165
  throw new LBApiError(retry._statusCode ?? 500, retry.error.code, retry.error.message);
166
166
  }
@@ -171,7 +171,7 @@ export class LBClient {
171
171
  }
172
172
  return response;
173
173
  }
174
- async doRequest(method, path, body, query) {
174
+ async doRequest(method, path, body, query, toolName) {
175
175
  let url = `${this.baseUrl}${path}`;
176
176
  if (query) {
177
177
  const params = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ""));
@@ -181,6 +181,8 @@ export class LBClient {
181
181
  }
182
182
  const headers = {
183
183
  Authorization: `Bearer ${this.jwt.access_token}`,
184
+ "X-LB-Source": "mcp",
185
+ ...(toolName && { "X-LB-Tool": toolName }),
184
186
  };
185
187
  const options = { method, headers };
186
188
  if (body && method !== "GET") {
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerOrdersTools } from "./tools/orders.tools.js";
10
10
  import { registerFeedbackTools } from "./tools/feedback.tools.js";
11
11
  import { registerCostTools } from "./tools/cost.tools.js";
12
12
  import { validateBaseUrl } from "./utils/validate-url.js";
13
+ import { checkForUpdate } from "./utils/update-check.js";
13
14
  import { createRequire } from "node:module";
14
15
  const require = createRequire(import.meta.url);
15
16
  const pkg = require("../package.json");
@@ -32,6 +33,22 @@ const client = new LBClient(apiKey, baseUrl);
32
33
  const server = new McpServer({
33
34
  name: "listingbureau",
34
35
  version: pkg.version,
36
+ }, {
37
+ instructions: [
38
+ "Listing Bureau provides Amazon organic ranking services: SFB (Search Find Buy), ATC (Add to Cart), and PGV (page views).",
39
+ "SFB drives purchases that directly boost organic ranking. ATC and PGV support the funnel but alone rarely move ranking unless the keyword is very uncompetitive.",
40
+ "When a user schedules only ATC/PGV with no SFB, note that these are typically paired with SFB to maintain a healthy conversion ratio.",
41
+ "",
42
+ "When advising on service volumes, aim for a natural Amazon funnel with realistic conversion rates (10-15%). These are starting points — adjust based on the user's product, price point, and keyword competitiveness:",
43
+ "Default ratio per SFB: ~2 ATC, ~8-10 PGV.",
44
+ "Cheaper/high-intent keywords (e.g. branded): ~1-1.5 ATC, ~5-7 PGV per SFB.",
45
+ "Expensive/competitive keywords: ~2-3 ATC, ~10-15 PGV per SFB to reflect more browsing.",
46
+ "Avoid SFB-only schedules with near-100% conversion. Keep ratios consistent per keyword so traffic looks like natural shopper behavior.",
47
+ "",
48
+ "When the user asks about something outside current capabilities (other marketplaces, unsupported features), offer to submit their input as feedback via lb_feedback_submit.",
49
+ "",
50
+ "SFB units are real Amazon purchases of the user's product. The retail price in the SFB cost is not a net expense — the seller receives it back as normal Amazon sale proceeds. The actual out-of-pocket cost per SFB is the service fee plus the 11% tax/transfer overhead on the retail price.",
51
+ ].join("\n"),
35
52
  });
36
53
  registerAccountTools(server, client);
37
54
  registerWalletTools(server, client);
@@ -42,3 +59,4 @@ registerFeedbackTools(server, client);
42
59
  registerCostTools(server, client);
43
60
  const transport = new StdioServerTransport();
44
61
  await server.connect(transport);
62
+ checkForUpdate(pkg.version).catch(() => { });
@@ -4,8 +4,8 @@ export function registerAccountTools(server, client) {
4
4
  server.tool("lb_account_get", "Get Listing Bureau account info (email, name, account status, wallet balance)", {}, { readOnlyHint: true }, async () => {
5
5
  try {
6
6
  const [accountRes, walletRes] = await Promise.all([
7
- client.request("GET", "/api/v1/account"),
8
- client.request("GET", "/api/v1/wallet"),
7
+ client.request("GET", "/api/v1/account", undefined, undefined, "lb_account_get"),
8
+ client.request("GET", "/api/v1/wallet", undefined, undefined, "lb_account_get"),
9
9
  ]);
10
10
  const account = accountRes.data;
11
11
  const wallet = walletRes.data;
@@ -55,7 +55,7 @@ export function registerAccountTools(server, client) {
55
55
  if (Object.keys(body).length === 0) {
56
56
  return formatErrorResult(new Error("At least one field (first_name, last_name, company) must be provided"));
57
57
  }
58
- const res = await client.request("PATCH", "/api/v1/account/profile", body);
58
+ const res = await client.request("PATCH", "/api/v1/account/profile", body, undefined, "lb_account_update_profile");
59
59
  return formatResult(res.data);
60
60
  }
61
61
  catch (e) {
@@ -64,7 +64,7 @@ export function registerAccountTools(server, client) {
64
64
  });
65
65
  server.tool("lb_account_get_service_rates", "Get current Listing Bureau service pricing rates. Returns empty object if no plan is active.", {}, { readOnlyHint: true }, async () => {
66
66
  try {
67
- const res = await client.request("GET", "/api/v1/account/service-rates");
67
+ const res = await client.request("GET", "/api/v1/account/service-rates", undefined, undefined, "lb_account_get_service_rates");
68
68
  return formatResult(res.data);
69
69
  }
70
70
  catch (e) {
@@ -73,7 +73,7 @@ export function registerAccountTools(server, client) {
73
73
  });
74
74
  server.tool("lb_account_get_subscription", "Get Listing Bureau subscription info (plan label, fee, discount, wallet usage)", {}, { readOnlyHint: true }, async () => {
75
75
  try {
76
- const res = await client.request("GET", "/api/v1/account/subscription");
76
+ const res = await client.request("GET", "/api/v1/account/subscription", undefined, undefined, "lb_account_get_subscription");
77
77
  return formatResult(res.data);
78
78
  }
79
79
  catch (e) {
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { sfbUnitCost, estimateCost, round2 } from "../utils/cost.js";
3
3
  import { formatResult, formatErrorResult } from "../utils/response.js";
4
+ import { ACCEPTED_REGIONS, normalizeRegion, assertSfbAllowed } from "../utils/regions.js";
4
5
  const scheduleItemSchema = z.object({
5
6
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD format").describe("Date in YYYY-MM-DD format"),
6
7
  atc: z.number().int().min(0).optional().describe("Add-to-cart volume (default 0)"),
@@ -8,9 +9,13 @@ const scheduleItemSchema = z.object({
8
9
  pgv: z.number().int().min(0).optional().describe("Page view volume (default 0)"),
9
10
  });
10
11
  const estimateCostShape = {
11
- atc: z.number().int().min(0).optional().describe("Uniform daily add-to-cart volume"),
12
- sfb: z.number().int().min(0).optional().describe("Uniform daily Search Find Buy (SFB) volume"),
13
- pgv: z.number().int().min(0).optional().describe("Uniform daily page view volume"),
12
+ region: z
13
+ .enum(ACCEPTED_REGIONS)
14
+ .optional()
15
+ .describe("Amazon region code — if provided with SFB volumes, validates SFB is allowed (US only). GB accepted as alias for UK."),
16
+ atc: z.number().int().min(0).optional().describe("Uniform daily add-to-cart volume (all regions; lower execution rate outside US)"),
17
+ sfb: z.number().int().min(0).optional().describe("Uniform daily Search Find Buy (SFB) volume (US-region projects only)"),
18
+ pgv: z.number().int().min(0).optional().describe("Uniform daily page view volume (all regions; lower execution rate outside US)"),
14
19
  num_days: z.number().int().min(1).max(365).optional().describe("Number of days for uniform volumes"),
15
20
  schedule: z
16
21
  .array(scheduleItemSchema)
@@ -25,7 +30,7 @@ const estimateCostShape = {
25
30
  .describe("Product retail price in USD — needed for accurate SFB cost including product price and fees"),
26
31
  };
27
32
  export function registerCostTools(server, client) {
28
- server.tool("lb_estimate_cost", "Estimate campaign cost before committing. Fetches current rates and wallet balance, then computes total cost, daily averages, and wallet sustainability. Provide either uniform daily volumes (atc/sfb/pgv + num_days) or a per-day schedule array. Include retail_price for accurate SFB costs.", estimateCostShape, { readOnlyHint: true }, async (params) => {
33
+ server.tool("lb_estimate_cost", "Estimate campaign cost before committing. Fetches current rates and wallet balance, then computes total cost, daily averages, and wallet sustainability. Provide either uniform daily volumes (atc/sfb/pgv + num_days) or a per-day schedule array. Include retail_price for accurate SFB costs. SFB is US-region only; ATC/PGV work in all regions (lower execution rate outside US). Pass region to validate SFB eligibility.", estimateCostShape, { readOnlyHint: true }, async (params) => {
29
34
  try {
30
35
  // Validate: either schedule array, or (volumes + num_days)
31
36
  const hasSchedule = params.schedule && params.schedule.length > 0;
@@ -33,9 +38,19 @@ export function registerCostTools(server, client) {
33
38
  if (!hasSchedule && !(hasVolume && params.num_days != null)) {
34
39
  return formatErrorResult(new Error("Provide either a 'schedule' array, OR at least one volume (atc/sfb/pgv > 0) with 'num_days'"));
35
40
  }
41
+ // Normalize region once for all downstream use
42
+ const normalizedRegion = params.region ? normalizeRegion(params.region) : undefined;
43
+ // SFB region validation
44
+ const hasSfbInput = params.schedule
45
+ ? params.schedule.some((d) => (d.sfb ?? 0) > 0)
46
+ : (params.sfb ?? 0) > 0;
47
+ if (normalizedRegion) {
48
+ // Hard reject: region provided + non-US + has SFB
49
+ assertSfbAllowed(normalizedRegion, hasSfbInput);
50
+ }
36
51
  const [ratesRes, walletRes] = await Promise.all([
37
- client.request("GET", "/api/v1/account/service-rates"),
38
- client.request("GET", "/api/v1/wallet"),
52
+ client.request("GET", "/api/v1/account/service-rates", undefined, undefined, "lb_estimate_cost"),
53
+ client.request("GET", "/api/v1/wallet", undefined, undefined, "lb_estimate_cost"),
39
54
  ]);
40
55
  const rates = ratesRes.data;
41
56
  const wallet = walletRes.data;
@@ -72,8 +87,12 @@ export function registerCostTools(server, client) {
72
87
  if (hasSchedule && (hasVolume || params.num_days != null)) {
73
88
  warnings.push("schedule array provided — num_days and uniform volumes (atc/sfb/pgv) were ignored.");
74
89
  }
75
- // SFB without retail_price warning (0 is effectively the same as omitting)
90
+ // SFB without region warning
76
91
  const hasSfb = schedule.some((d) => d.sfb > 0);
92
+ if (hasSfb && !params.region) {
93
+ warnings.push("SFB volumes provided without region — estimate assumes US pricing. Pass region to validate SFB eligibility.");
94
+ }
95
+ // SFB without retail_price warning (0 is effectively the same as omitting)
77
96
  if (hasSfb && (params.retail_price == null || params.retail_price === 0)) {
78
97
  warnings.push("SFB volumes provided without retail_price — estimate uses service fee only ($" +
79
98
  rates.sfb_service_fee.toFixed(2) +
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult, formatErrorResult } from "../utils/response.js";
3
3
  export function registerFeedbackTools(server, client) {
4
- server.tool("lb_feedback_submit", "Submit feedback about the Listing Bureau service (10-5000 characters).", {
4
+ server.tool("lb_feedback_submit", "Submit feedback, feature requests, or suggestions (10-5000 characters)", {
5
5
  feedback: z
6
6
  .string()
7
7
  .min(10)
@@ -9,7 +9,7 @@ export function registerFeedbackTools(server, client) {
9
9
  .describe("Feedback text (10-5000 characters)"),
10
10
  }, { idempotentHint: false }, async (params) => {
11
11
  try {
12
- const res = await client.request("POST", "/api/v1/feedback", { feedback: params.feedback });
12
+ const res = await client.request("POST", "/api/v1/feedback", { feedback: params.feedback }, undefined, "lb_feedback_submit");
13
13
  return formatResult(res.data);
14
14
  }
15
15
  catch (e) {
@@ -17,7 +17,7 @@ export function registerOrdersTools(server, client) {
17
17
  query.page = String(params.page);
18
18
  if (params.per_page !== undefined)
19
19
  query.per_page = String(params.per_page);
20
- const res = await client.request("GET", "/api/v1/orders", undefined, query);
20
+ const res = await client.request("GET", "/api/v1/orders", undefined, query, "lb_orders_list");
21
21
  if (res.meta) {
22
22
  return formatPaginatedResult(res.data, res.meta);
23
23
  }
@@ -31,7 +31,7 @@ export function registerOrdersTools(server, client) {
31
31
  order_id: z.string().describe("Order identifier"),
32
32
  }, { readOnlyHint: true }, async (params) => {
33
33
  try {
34
- const res = await client.request("GET", `/api/v1/orders/${encodeURIComponent(params.order_id)}`);
34
+ const res = await client.request("GET", `/api/v1/orders/${encodeURIComponent(params.order_id)}`, undefined, undefined, "lb_orders_get");
35
35
  return formatResult(res.data);
36
36
  }
37
37
  catch (e) {
@@ -47,7 +47,7 @@ export function registerOrdersTools(server, client) {
47
47
  .describe("Issue description (1-1000 characters)"),
48
48
  }, { idempotentHint: false }, async (params) => {
49
49
  try {
50
- const res = await client.request("POST", `/api/v1/orders/${encodeURIComponent(params.order_id)}/report-issue`, { description: params.description });
50
+ const res = await client.request("POST", `/api/v1/orders/${encodeURIComponent(params.order_id)}/report-issue`, { description: params.description }, undefined, "lb_orders_report_issue");
51
51
  return formatResult(res.data);
52
52
  }
53
53
  catch (e) {
@@ -1,28 +1,21 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult, formatErrorResult } from "../utils/response.js";
3
- // Valid Amazon region codes (validated against CONSTANTS['MARKETPLACES'] in backend)
4
- const VALID_REGIONS = [
5
- "US", "CA", "MX", // Americas
6
- "UK", "DE", "FR", "IT", // Europe
7
- "ES", "NL", "SE", "TR", // Europe cont.
8
- "JP", "AU", "IN", // Asia-Pacific
9
- "AE", "BR", "SG", "SA", // Rest of world
10
- ];
3
+ import { ACCEPTED_REGIONS, normalizeRegion } from "../utils/regions.js";
11
4
  export function registerProjectsTools(server, client) {
12
5
  server.tool("lb_projects_list", "List Listing Bureau Amazon projects with optional filters. Returns ASIN, keyword, active status, and current service volumes.", {
13
6
  region: z
14
- .enum(VALID_REGIONS)
7
+ .enum(ACCEPTED_REGIONS)
15
8
  .optional()
16
- .describe("Filter by Amazon region"),
9
+ .describe("Filter by Amazon region code (GB accepted as alias for UK)"),
17
10
  active: z.boolean().optional().describe("Filter by active status"),
18
11
  }, { readOnlyHint: true }, async (params) => {
19
12
  try {
20
13
  const query = { marketplace: "amazon" };
21
14
  if (params.region !== undefined)
22
- query.region = params.region;
15
+ query.region = normalizeRegion(params.region);
23
16
  if (params.active !== undefined)
24
17
  query.active = String(params.active);
25
- const res = await client.request("GET", "/api/v1/projects", undefined, query);
18
+ const res = await client.request("GET", "/api/v1/projects", undefined, query, "lb_projects_list");
26
19
  return formatResult(res.data);
27
20
  }
28
21
  catch (e) {
@@ -31,8 +24,8 @@ export function registerProjectsTools(server, client) {
31
24
  });
32
25
  server.tool("lb_projects_create", "Create a new Listing Bureau Amazon project. If a project with the same ASIN+keyword+region was previously archived, it will be reactivated instead (returns 201). ASIN must be a valid Amazon ASIN. Keyword must be 3-200 characters.", {
33
26
  region: z
34
- .enum(VALID_REGIONS)
35
- .describe("Amazon region code"),
27
+ .enum(ACCEPTED_REGIONS)
28
+ .describe("Amazon region code (GB accepted as alias for UK)"),
36
29
  asin: z
37
30
  .string()
38
31
  .describe("Amazon ASIN (e.g. 'B01MTJK06C')"),
@@ -50,14 +43,14 @@ export function registerProjectsTools(server, client) {
50
43
  try {
51
44
  const body = {
52
45
  marketplace: "amazon",
53
- region: params.region,
46
+ region: normalizeRegion(params.region),
54
47
  asin: params.asin,
55
48
  keyword: params.keyword,
56
49
  };
57
50
  if (params.expected_retail_price !== undefined) {
58
51
  body.expected_retail_price = params.expected_retail_price;
59
52
  }
60
- const res = await client.request("POST", "/api/v1/projects", body);
53
+ const res = await client.request("POST", "/api/v1/projects", body, undefined, "lb_projects_create");
61
54
  return formatResult(res.data);
62
55
  }
63
56
  catch (e) {
@@ -68,7 +61,7 @@ export function registerProjectsTools(server, client) {
68
61
  ui_id: z.string().describe("Project unique identifier"),
69
62
  }, { readOnlyHint: true }, async (params) => {
70
63
  try {
71
- const res = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`);
64
+ const res = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`, undefined, undefined, "lb_projects_get");
72
65
  return formatResult(res.data);
73
66
  }
74
67
  catch (e) {
@@ -86,7 +79,7 @@ export function registerProjectsTools(server, client) {
86
79
  if (Object.keys(body).length === 0) {
87
80
  return formatErrorResult(new Error("At least one field (active) must be provided"));
88
81
  }
89
- const res = await client.request("PATCH", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`, body);
82
+ const res = await client.request("PATCH", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`, body, undefined, "lb_projects_update");
90
83
  return formatResult(res.data);
91
84
  }
92
85
  catch (e) {
@@ -97,7 +90,7 @@ export function registerProjectsTools(server, client) {
97
90
  ui_id: z.string().describe("Project unique identifier"),
98
91
  }, { destructiveHint: true }, async (params) => {
99
92
  try {
100
- const res = await client.request("DELETE", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`);
93
+ const res = await client.request("DELETE", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`, undefined, undefined, "lb_projects_archive");
101
94
  return formatResult(res.data);
102
95
  }
103
96
  catch (e) {
@@ -118,7 +111,7 @@ export function registerProjectsTools(server, client) {
118
111
  const query = {};
119
112
  if (params.days !== undefined)
120
113
  query.days = String(params.days);
121
- const res = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/stats`, undefined, query);
114
+ const res = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/stats`, undefined, query, "lb_projects_get_stats");
122
115
  return formatResult(res.data);
123
116
  }
124
117
  catch (e) {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult, formatErrorResult } from "../utils/response.js";
3
+ import { assertSfbAllowed, SfbRegionError } from "../utils/regions.js";
3
4
  import { sfbUnitCost, estimateCost, mapScheduleEntries, round2 } from "../utils/cost.js";
4
5
  // Write schema: only YYYY-MM-DD dates (backend manages 'ongoing' entries internally)
5
6
  const scheduleEntrySchema = z.object({
@@ -7,9 +8,9 @@ const scheduleEntrySchema = z.object({
7
8
  .string()
8
9
  .regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD format")
9
10
  .describe("Date in YYYY-MM-DD format"),
10
- atc: z.number().int().min(0).describe("Add-to-cart volume"),
11
- sfb: z.number().int().min(0).describe("Search Find Buy (SFB) volume"),
12
- pgv: z.number().int().min(0).describe("Page view volume"),
11
+ atc: z.number().int().min(0).describe("Add-to-cart volume (all regions; lower execution rate outside US)"),
12
+ sfb: z.number().int().min(0).describe("Search Find Buy (SFB) volume (US-region projects only)"),
13
+ pgv: z.number().int().min(0).describe("Page view volume (all regions; lower execution rate outside US)"),
13
14
  });
14
15
  /**
15
16
  * Best-effort: fetch rates and compute cost summary for a schedule response.
@@ -18,13 +19,15 @@ const scheduleEntrySchema = z.object({
18
19
  async function appendCostSummary(data, client) {
19
20
  const result = { ...data };
20
21
  try {
22
+ // Internal enrichment fetch — no tool attribution in audit log
21
23
  const ratesRes = await client.request("GET", "/api/v1/account/service-rates");
22
24
  const rates = ratesRes.data;
23
25
  const { dated, ongoing } = mapScheduleEntries(data.scheduling);
24
26
  if (dated.length === 0 && !ongoing)
25
27
  return result;
26
28
  const sfbNote = "SFB costs use service fee only ($" + rates.sfb_service_fee.toFixed(2) +
27
- "), matching backend balance check behavior. Use lb_estimate_cost with retail_price for full SFB cost.";
29
+ "), matching backend balance check behavior. Use lb_estimate_cost with retail_price for full SFB cost." +
30
+ " Note: the retail price portion is not a net expense — it returns as Amazon sale proceeds.";
28
31
  if (dated.length > 0 && ongoing) {
29
32
  // Mixed: dated entries + ongoing
30
33
  const est = estimateCost(dated, rates);
@@ -98,11 +101,11 @@ async function appendCostSummary(data, client) {
98
101
  return result;
99
102
  }
100
103
  export function registerScheduleTools(server, client) {
101
- server.tool("lb_schedule_get", "Get the current schedule for a Listing Bureau project. Shows per-day service volumes (atc, sfb, pgv).", {
104
+ server.tool("lb_schedule_get", "Get the current schedule for a Listing Bureau project. Shows per-day service volumes (atc, sfb, pgv). Note: SFB is only available for US-region projects.", {
102
105
  ui_id: z.string().describe("Project unique identifier"),
103
106
  }, { readOnlyHint: true }, async (params) => {
104
107
  try {
105
- const res = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/schedule`);
108
+ const res = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/schedule`, undefined, undefined, "lb_schedule_get");
106
109
  const augmented = await appendCostSummary(res.data, client);
107
110
  return formatResult(augmented);
108
111
  }
@@ -110,7 +113,7 @@ export function registerScheduleTools(server, client) {
110
113
  return formatErrorResult(e);
111
114
  }
112
115
  });
113
- server.tool("lb_schedule_set", "Set the full per-day schedule for a Listing Bureau project. Replaces any existing schedule. Each entry represents one day with volumes for atc, sfb, and pgv. Max 365 entries.", {
116
+ server.tool("lb_schedule_set", "Set the full per-day schedule for a Listing Bureau project. Replaces any existing schedule. Each entry represents one day with volumes for atc, sfb, and pgv. Max 365 entries. SFB is US-region only; ATC/PGV work in all regions (lower execution rate outside US).", {
114
117
  ui_id: z.string().describe("Project unique identifier"),
115
118
  schedule: z
116
119
  .array(scheduleEntrySchema)
@@ -119,28 +122,84 @@ export function registerScheduleTools(server, client) {
119
122
  .describe("Array of daily schedule entries"),
120
123
  }, { destructiveHint: true, idempotentHint: true }, async (params) => {
121
124
  try {
122
- const res = await client.request("PUT", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/schedule`, { schedule: params.schedule });
125
+ // SFB region gate: only fetch project when schedule contains SFB > 0
126
+ let regionWarning;
127
+ if (params.schedule.some((e) => e.sfb > 0)) {
128
+ try {
129
+ const projRes = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`, undefined, undefined, "lb_schedule_set");
130
+ assertSfbAllowed(projRes.data.region, true);
131
+ }
132
+ catch (fetchErr) {
133
+ // If assertSfbAllowed threw, re-throw (it's a validation error, not a fetch failure)
134
+ if (fetchErr instanceof SfbRegionError) {
135
+ throw fetchErr;
136
+ }
137
+ // Fetch failure: warn and proceed — backend enforces the real restriction
138
+ regionWarning = "Could not verify project region for SFB eligibility. The backend will enforce region restrictions.";
139
+ }
140
+ }
141
+ const res = await client.request("PUT", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/schedule`, { schedule: params.schedule }, undefined, "lb_schedule_set");
123
142
  const augmented = await appendCostSummary(res.data, client);
143
+ if (regionWarning) {
144
+ augmented.region_warning = regionWarning;
145
+ }
146
+ // Note when schedule has ATC/PGV but no SFB
147
+ const hasAtcOrPgv = params.schedule.some((e) => e.atc > 0 || e.pgv > 0);
148
+ const hasSfb = params.schedule.some((e) => e.sfb > 0);
149
+ if (hasAtcOrPgv && !hasSfb) {
150
+ augmented.service_note =
151
+ "This schedule has ATC/PGV but no SFB. " +
152
+ "These services alone rarely move ranking unless the keyword is very uncompetitive. " +
153
+ "They're typically paired with SFB to maintain a healthy organic conversion ratio. " +
154
+ "You can update the schedule to add SFB if needed.";
155
+ }
124
156
  return formatResult(augmented);
125
157
  }
126
158
  catch (e) {
127
159
  return formatErrorResult(e);
128
160
  }
129
161
  });
130
- server.tool("lb_schedule_quick_set", "Quick-set uniform daily volumes for a Listing Bureau project. WARNING: This clears any existing per-day schedule and replaces it with uniform values. All omitted fields default to 0.", {
162
+ server.tool("lb_schedule_quick_set", "Quick-set uniform daily volumes for a Listing Bureau project. WARNING: This clears any existing per-day schedule and replaces it with uniform values. All omitted fields default to 0. SFB is US-region only; ATC/PGV work in all regions (lower execution rate outside US).", {
131
163
  ui_id: z.string().describe("Project unique identifier"),
132
- atc: z.number().int().min(0).optional().describe("Add-to-cart volume per day (default 0)"),
133
- sfb: z.number().int().min(0).optional().describe("Search Find Buy (SFB) volume per day (default 0)"),
134
- pgv: z.number().int().min(0).optional().describe("Page view volume per day (default 0)"),
164
+ atc: z.number().int().min(0).optional().describe("Add-to-cart volume per day (default 0) (all regions; lower execution rate outside US)"),
165
+ sfb: z.number().int().min(0).optional().describe("Search Find Buy (SFB) volume per day (default 0) (US-region projects only)"),
166
+ pgv: z.number().int().min(0).optional().describe("Page view volume per day (default 0) (all regions; lower execution rate outside US)"),
135
167
  }, { destructiveHint: true, idempotentHint: true }, async (params) => {
136
168
  try {
169
+ // SFB region gate: only fetch project when SFB > 0
170
+ let regionWarning;
171
+ if ((params.sfb ?? 0) > 0) {
172
+ try {
173
+ const projRes = await client.request("GET", `/api/v1/projects/${encodeURIComponent(params.ui_id)}`, undefined, undefined, "lb_schedule_quick_set");
174
+ assertSfbAllowed(projRes.data.region, true);
175
+ }
176
+ catch (fetchErr) {
177
+ if (fetchErr instanceof SfbRegionError) {
178
+ throw fetchErr;
179
+ }
180
+ regionWarning = "Could not verify project region for SFB eligibility. The backend will enforce region restrictions.";
181
+ }
182
+ }
137
183
  const body = {
138
184
  atc: params.atc ?? 0,
139
185
  sfb: params.sfb ?? 0,
140
186
  pgv: params.pgv ?? 0,
141
187
  };
142
- const res = await client.request("POST", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/schedule/quick`, body);
188
+ const res = await client.request("POST", `/api/v1/projects/${encodeURIComponent(params.ui_id)}/schedule/quick`, body, undefined, "lb_schedule_quick_set");
143
189
  const augmented = await appendCostSummary(res.data, client);
190
+ if (regionWarning) {
191
+ augmented.region_warning = regionWarning;
192
+ }
193
+ // Note when schedule has ATC/PGV but no SFB
194
+ const hasAtcOrPgv = (params.atc ?? 0) > 0 || (params.pgv ?? 0) > 0;
195
+ const hasSfb = (params.sfb ?? 0) > 0;
196
+ if (hasAtcOrPgv && !hasSfb) {
197
+ augmented.service_note =
198
+ "This schedule has ATC/PGV but no SFB. " +
199
+ "These services alone rarely move ranking unless the keyword is very uncompetitive. " +
200
+ "They're typically paired with SFB to maintain a healthy organic conversion ratio. " +
201
+ "You can update the schedule to add SFB if needed.";
202
+ }
144
203
  return formatResult(augmented);
145
204
  }
146
205
  catch (e) {
@@ -3,7 +3,7 @@ import { formatResult, formatPaginatedResult, formatErrorResult, } from "../util
3
3
  export function registerWalletTools(server, client) {
4
4
  server.tool("lb_wallet_get_balance", "Get Listing Bureau wallet balance (credits and USD). May include a warning if data is temporarily unavailable.", {}, { readOnlyHint: true }, async () => {
5
5
  try {
6
- const res = await client.request("GET", "/api/v1/wallet");
6
+ const res = await client.request("GET", "/api/v1/wallet", undefined, undefined, "lb_wallet_get_balance");
7
7
  return formatResult(res.data);
8
8
  }
9
9
  catch (e) {
@@ -26,7 +26,7 @@ export function registerWalletTools(server, client) {
26
26
  query.page = String(params.page);
27
27
  if (params.per_page !== undefined)
28
28
  query.per_page = String(params.per_page);
29
- const res = await client.request("GET", "/api/v1/wallet/transactions", undefined, query);
29
+ const res = await client.request("GET", "/api/v1/wallet/transactions", undefined, query, "lb_wallet_get_transactions");
30
30
  if (res.meta) {
31
31
  return formatPaginatedResult(res.data, res.meta);
32
32
  }
@@ -44,7 +44,7 @@ export function registerWalletTools(server, client) {
44
44
  .describe("Top-up amount in USD (minimum $5, maximum $5,000)"),
45
45
  }, { destructiveHint: true }, async (params) => {
46
46
  try {
47
- const res = await client.request("POST", "/api/v1/wallet/topup", { amount: params.amount });
47
+ const res = await client.request("POST", "/api/v1/wallet/topup", { amount: params.amount }, undefined, "lb_wallet_topup");
48
48
  return formatResult(res.data);
49
49
  }
50
50
  catch (e) {
@@ -0,0 +1,24 @@
1
+ /** Canonical 18 backend region codes (matches CONSTANTS['MARKETPLACES'] in Flask). */
2
+ export declare const VALID_REGIONS: readonly ["US", "CA", "MX", "UK", "DE", "FR", "IT", "ES", "NL", "SE", "TR", "JP", "AU", "IN", "AE", "BR", "SG", "SA"];
3
+ /**
4
+ * All accepted region codes for MCP tool input (19-element tuple).
5
+ * Includes GB as an alias for UK. Declared as a flat literal tuple
6
+ * because z.enum() requires a literal non-empty tuple type.
7
+ */
8
+ export declare const ACCEPTED_REGIONS: readonly ["US", "CA", "MX", "UK", "DE", "FR", "IT", "ES", "NL", "SE", "TR", "JP", "AU", "IN", "AE", "BR", "SG", "SA", "GB"];
9
+ /** Regions where SFB (Search Find Buy) is available. */
10
+ export declare const SFB_REGIONS: Set<"US" | "CA" | "MX" | "UK" | "DE" | "FR" | "IT" | "ES" | "NL" | "SE" | "TR" | "JP" | "AU" | "IN" | "AE" | "BR" | "SG" | "SA">;
11
+ /** Normalize user-supplied region code: GB -> UK, all others passthrough. */
12
+ export declare function normalizeRegion(region: string): string;
13
+ /** Typed error thrown by assertSfbAllowed for non-US SFB requests. */
14
+ export declare class SfbRegionError extends Error {
15
+ constructor(region: string);
16
+ }
17
+ /**
18
+ * Assert that SFB is allowed for the given region.
19
+ * Throws SfbRegionError if the region is not in SFB_REGIONS.
20
+ *
21
+ * @param region - Canonical region code (already normalized)
22
+ * @param hasSfb - Whether the request includes SFB volumes > 0
23
+ */
24
+ export declare function assertSfbAllowed(region: string, hasSfb: boolean): void;
@@ -0,0 +1,47 @@
1
+ // Centralized region constants and validation for Listing Bureau MCP tools.
2
+ /** Canonical 18 backend region codes (matches CONSTANTS['MARKETPLACES'] in Flask). */
3
+ export const VALID_REGIONS = [
4
+ "US", "CA", "MX", // Americas
5
+ "UK", "DE", "FR", "IT", // Europe
6
+ "ES", "NL", "SE", "TR", // Europe cont.
7
+ "JP", "AU", "IN", // Asia-Pacific
8
+ "AE", "BR", "SG", "SA", // Rest of world
9
+ ];
10
+ /**
11
+ * All accepted region codes for MCP tool input (19-element tuple).
12
+ * Includes GB as an alias for UK. Declared as a flat literal tuple
13
+ * because z.enum() requires a literal non-empty tuple type.
14
+ */
15
+ export const ACCEPTED_REGIONS = [
16
+ "US", "CA", "MX",
17
+ "UK", "DE", "FR", "IT",
18
+ "ES", "NL", "SE", "TR",
19
+ "JP", "AU", "IN",
20
+ "AE", "BR", "SG", "SA",
21
+ "GB",
22
+ ];
23
+ /** Regions where SFB (Search Find Buy) is available. */
24
+ export const SFB_REGIONS = new Set(["US"]);
25
+ /** Normalize user-supplied region code: GB -> UK, all others passthrough. */
26
+ export function normalizeRegion(region) {
27
+ return region === "GB" ? "UK" : region;
28
+ }
29
+ /** Typed error thrown by assertSfbAllowed for non-US SFB requests. */
30
+ export class SfbRegionError extends Error {
31
+ constructor(region) {
32
+ super(`SFB (Search Find Buy) is only available for US-region projects. This project's region is ${region}. Remove SFB volumes or use a US-region project.`);
33
+ this.name = "SfbRegionError";
34
+ }
35
+ }
36
+ /**
37
+ * Assert that SFB is allowed for the given region.
38
+ * Throws SfbRegionError if the region is not in SFB_REGIONS.
39
+ *
40
+ * @param region - Canonical region code (already normalized)
41
+ * @param hasSfb - Whether the request includes SFB volumes > 0
42
+ */
43
+ export function assertSfbAllowed(region, hasSfb) {
44
+ if (hasSfb && !SFB_REGIONS.has(region)) {
45
+ throw new SfbRegionError(region);
46
+ }
47
+ }
@@ -1,4 +1,5 @@
1
1
  import { formatError } from "./errors.js";
2
+ import { getUpdateNotice } from "./update-check.js";
2
3
  /**
3
4
  * Format a successful API response as an MCP tool result.
4
5
  * Handles both entity responses and message-only responses.
@@ -36,6 +37,10 @@ export function formatResult(data) {
36
37
  for (const w of warnings) {
37
38
  text += `\n\nāš ļø Warning: ${w}`;
38
39
  }
40
+ const notice = getUpdateNotice();
41
+ if (notice) {
42
+ text += `\n\n${notice}`;
43
+ }
39
44
  return {
40
45
  content: [{ type: "text", text }],
41
46
  };
@@ -49,8 +54,13 @@ export function formatPaginatedResult(data, meta) {
49
54
  data,
50
55
  pagination: meta,
51
56
  };
57
+ let text = JSON.stringify(result, null, 2);
58
+ const notice = getUpdateNotice();
59
+ if (notice) {
60
+ text += `\n\n${notice}`;
61
+ }
52
62
  return {
53
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
63
+ content: [{ type: "text", text }],
54
64
  };
55
65
  }
56
66
  /** Format an error as an MCP tool error result. */
@@ -0,0 +1,8 @@
1
+ /** Compare semver strings. Returns true only if latest > current. */
2
+ export declare function isNewerVersion(latest: string, current: string): boolean;
3
+ /** Fire-and-forget npm registry check. Stores notice for later retrieval. */
4
+ export declare function checkForUpdate(currentVersion: string): Promise<void>;
5
+ /** Returns the update notice once, then null for all subsequent calls. */
6
+ export declare function getUpdateNotice(): string | null;
7
+ /** Reset state for testing. */
8
+ export declare function resetForTesting(): void;
@@ -0,0 +1,61 @@
1
+ // Module-level state — reset via resetForTesting() in test files.
2
+ // Once noticeConsumed is true, subsequent checkForUpdate calls still overwrite
3
+ // updateNotice but getUpdateNotice will return null. This is intentional:
4
+ // the notice is designed to fire once per process lifetime.
5
+ let updateNotice = null;
6
+ let noticeConsumed = false;
7
+ /** Compare semver strings. Returns true only if latest > current. */
8
+ export function isNewerVersion(latest, current) {
9
+ const latestParts = latest.split(".").map(Number);
10
+ const currentParts = current.split(".").map(Number);
11
+ if (latestParts.length !== 3 || currentParts.length !== 3)
12
+ return false;
13
+ if (latestParts.some((n) => !Number.isFinite(n)) || currentParts.some((n) => !Number.isFinite(n)))
14
+ return false;
15
+ for (let i = 0; i < 3; i++) {
16
+ if (latestParts[i] > currentParts[i])
17
+ return true;
18
+ if (latestParts[i] < currentParts[i])
19
+ return false;
20
+ }
21
+ return false;
22
+ }
23
+ /** Fire-and-forget npm registry check. Stores notice for later retrieval. */
24
+ export async function checkForUpdate(currentVersion) {
25
+ try {
26
+ const res = await fetch("https://registry.npmjs.org/listingbureau-mcp/latest", {
27
+ signal: AbortSignal.timeout(5000),
28
+ });
29
+ if (!res.ok)
30
+ return;
31
+ const json = (await res.json());
32
+ const latest = json.version;
33
+ if (typeof latest !== "string" || !/^\d+\.\d+\.\d+$/.test(latest))
34
+ return;
35
+ if (isNewerVersion(latest, currentVersion)) {
36
+ updateNotice =
37
+ `šŸ“¦ Update available: v${currentVersion} → v${latest}\n` +
38
+ `To update:\n` +
39
+ ` npm: npx listingbureau-mcp@latest (or npm update -g listingbureau-mcp)\n` +
40
+ ` Desktop: Download from https://github.com/listingbureau/listingbureau-mcp/releases/latest`;
41
+ }
42
+ }
43
+ catch {
44
+ // Silent failure — network errors, TimeoutError, AbortError, bad JSON all swallowed
45
+ }
46
+ }
47
+ /** Returns the update notice once, then null for all subsequent calls. */
48
+ export function getUpdateNotice() {
49
+ if (noticeConsumed)
50
+ return null;
51
+ if (updateNotice) {
52
+ noticeConsumed = true;
53
+ return updateNotice;
54
+ }
55
+ return null;
56
+ }
57
+ /** Reset state for testing. */
58
+ export function resetForTesting() {
59
+ updateNotice = null;
60
+ noticeConsumed = false;
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "listingbureau-mcp",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Amazon organic ranking MCP server. Run ranking campaigns, track keyword positions, and monitor rank movement from any AI assistant.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",