ozon-seller-mcp 2.2.0 → 2.3.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.
package/README.md CHANGED
@@ -37,7 +37,8 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
37
37
 
38
38
  | 变量 | 默认 | 说明 |
39
39
  |---|---|---|
40
- | `OZON_CLIENT_ID` / `OZON_API_KEY` | — | 店铺凭证,必填 |
40
+ | `OZON_CLIENT_ID` / `OZON_API_KEY` | — | 单店凭证;只有一家店时用 |
41
+ | `OZON_SHOPS` | — | 多店铺:JSON 数组,见下方「多店铺」;填了它就不再读单店的 4 个凭证变量 |
41
42
  | `OZON_READ_ONLY` | `true` | `false` 才允许写操作;其他值启动报错,拼写错误不会开放写入 |
42
43
  | `OZON_TOOLSET` | `core` | `core` 常用工具;`minimal` 只要组合工具和通用工具;`all` 全部接口;可追加接口路径或工具名,逗号分隔,如 `core,/v1/returns/list` |
43
44
  | `OZON_COMPACT` | `true` | 结果省略 null、空字符串、空数组、空对象 |
@@ -50,6 +51,32 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
50
51
 
51
52
  修改后需要重启 MCP。
52
53
 
54
+ ## 多店铺
55
+
56
+ 一个 MCP 连多家店:`OZON_SHOPS` 填一个 JSON 数组,每家店一项。
57
+
58
+ ```json
59
+ "env": {
60
+ "OZON_SHOPS": "[{\"name\":\"shop-a\",\"client_id\":\"…\",\"api_key\":\"…\",\"perf_client_id\":\"…@advertising.performance.ozon.ru\",\"perf_client_secret\":\"…\",\"market\":true,\"default\":true},{\"name\":\"shop-b\",\"client_id\":\"…\",\"api_key\":\"…\",\"perf_client_id\":\"…\",\"perf_client_secret\":\"…\"}]",
61
+ "OZON_READ_ONLY": "true"
62
+ }
63
+ ```
64
+
65
+ | 字段 | 必填 | 说明 |
66
+ |---|---|---|
67
+ | `name` | 是 | 店铺名,1~32 个字母、数字、汉字、`_`、`-`,不能重复;调用时用它选店 |
68
+ | `client_id` / `api_key` | 二选一 | 该店的 Seller API 凭证,两项要么都填要么都不填 |
69
+ | `perf_client_id` / `perf_client_secret` | 二选一 | 该店的广告凭证,两项要么都填要么都不填;每家店至少填一组凭证(只做广告的店可以不填 Seller API) |
70
+ | `access_token` | 否 | 该店的 Ozon Delivery OAuth 令牌 |
71
+ | `default` | 否 | `true` = 调用不指定店铺时用它;不标就用第一项 |
72
+ | `market` | 否 | `true` = 市场数据店,最多一家;没有店标它时,市场数据接口就用调用时选的那家店 |
73
+
74
+ - 访问 Ozon 的工具(含组合工具、广告工具)都多一个可选参数 `shop`(店铺名),不填用默认店。结果里的 `shop` 字段写明实际用哪家店的凭证查的。只在本地运行的工具(`ozon_status`、目录、说明、校验、报告)没有这个参数。
75
+ - **市场数据固定走 `market` 店**:类目大盘 `/v1/analytics/category/comparison`、全站热搜词 `/v1/search-queries/top`、`/v1/search-queries/text` 要 Premium Pro 订阅,结果和查询的店铺无关,所以不论 `shop` 填哪家,都用市场数据店的凭证查。本店相关的数据(销量、商品搜索词、价格指数、广告)一律用各自店的凭证。
76
+ - 限流和 5 分钟读缓存按店铺(Client-Id)分开计;广告令牌每家店各一个。
77
+ - 配置有错(JSON 不合法、缺凭证、重名、未知字段、两家店填了同一个 `client_id` / `perf_client_id`、两家都标 `market`)启动时直接报错,报错只说第几项哪个字段,不回显密钥。所有店的密钥都会从结果和报错里隐藏。
78
+ - `ozon_status` 列出每家店的名称、Client-Id、是否默认 / 市场店、是否配了 Seller API / 广告凭证,不含密钥。
79
+
53
80
  ## 通过节点访问 Ozon
54
81
 
55
82
  国内网络直连 Ozon 经常不通。把节点链接填进 `OZON_PROXY` 即可,不用另装代理软件:
@@ -11,7 +11,11 @@ if (args.includes('--version') || args.includes('-v')) {
11
11
  用法:npx -y ozon-seller-mcp
12
12
 
13
13
  环境变量:
14
- OZON_CLIENT_ID, OZON_API_KEY 店铺凭证(必填)
14
+ OZON_CLIENT_ID, OZON_API_KEY 单店凭证(只有一家店时用)
15
+ OZON_SHOPS 多店铺:JSON 数组,每项 {"name","client_id","api_key",
16
+ "perf_client_id"?,"perf_client_secret"?,"access_token"?,"market"?,"default"?};
17
+ 填了它就不再读上面的单店变量。"market": true 的店负责
18
+ 类目大盘、全站热搜词(需 Premium Pro);工具多一个 shop 参数
15
19
  OZON_READ_ONLY true(默认,只读)/ false(允许写入)
16
20
  OZON_TOOLSET core(默认)/ all / minimal / 追加接口路径,逗号分隔
17
21
  OZON_COMPACT true(默认,省略空值字段)/ false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ozon-seller-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Ozon Seller API + Performance (ads) API MCP server: 482 seller endpoints, 48 advertising endpoints, one-call composite queries, read-only by default, strict schema validation.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/ads/http.js CHANGED
@@ -4,6 +4,7 @@ import { OzonError, ValidationError } from '../errors.js';
4
4
  import { currentFetch, describeNetworkError, getDispatcher, parseJson, readCapped, sleep } from '../http.js';
5
5
  import { redact } from '../privacy.js';
6
6
  import { enforceMode } from '../schema.js';
7
+ import { currentShop, hasAds, shops } from '../shops.js';
7
8
  import { VERSION } from '../version.js';
8
9
  import { parseCsv } from './csv.js';
9
10
  import { SAFE_PATH_VALUE, pathParamNames, validateAdsRequest } from './schema.js';
@@ -22,27 +23,33 @@ export const CREDENTIAL_HELP = '在卖家后台 设置 → API 密钥 → Perfor
22
23
  + '把 Client ID(以 @advertising.performance.ozon.ru 结尾)和 Client Secret 分别填到 MCP env 的 OZON_PERF_CLIENT_ID、OZON_PERF_CLIENT_SECRET,然后重启 MCP。'
23
24
  + '它和 OZON_CLIENT_ID / OZON_API_KEY(Seller API)不是同一组凭证。';
24
25
 
25
- let cachedToken = null;
26
- let pendingToken = null;
26
+ // One token per Performance client: shops alternating in one process must not evict each other's token.
27
+ const cachedTokens = new Map();
28
+ const pendingTokens = new Map();
27
29
 
28
30
  export function resetAdsToken() {
29
- cachedToken = null;
30
- pendingToken = null;
31
+ cachedTokens.clear();
32
+ pendingTokens.clear();
31
33
  }
32
34
 
33
- export const adsConfigured = () => Boolean((process.env.OZON_PERF_CLIENT_ID ?? '').trim() && (process.env.OZON_PERF_CLIENT_SECRET ?? '').trim());
35
+ // True when any shop has Performance API credentials (the ads tools are listed then).
36
+ export const adsConfigured = () => shops().some(hasAds);
34
37
 
35
38
  function adsCredentials() {
36
- const clientId = (process.env.OZON_PERF_CLIENT_ID ?? '').trim();
37
- const secret = (process.env.OZON_PERF_CLIENT_SECRET ?? '').trim();
38
- if (!clientId || !secret) throw new OzonError(`广告数据需要 Performance API 凭证,OZON_PERF_CLIENT_ID / OZON_PERF_CLIENT_SECRET 未配置。${CREDENTIAL_HELP}`);
39
- if (/[\r\n]/.test(clientId + secret)) throw new OzonError('Performance API 凭证包含无效换行,请检查 MCP 环境变量。');
40
- return { clientId, secret };
39
+ const shop = currentShop();
40
+ if (!hasAds(shop)) {
41
+ throw new OzonError(shop.legacy
42
+ ? `广告数据需要 Performance API 凭证,OZON_PERF_CLIENT_ID / OZON_PERF_CLIENT_SECRET 未配置。${CREDENTIAL_HELP}`
43
+ : `店铺「${shop.name}」没有配置广告凭证(perf_client_id / perf_client_secret)。`);
44
+ }
45
+ if (/[\r\n]/.test(shop.perfClientId + shop.perfSecret)) throw new OzonError('Performance API 凭证包含无效换行,请检查 MCP 环境变量。');
46
+ return { clientId: shop.perfClientId, secret: shop.perfSecret, shop };
41
47
  }
42
48
 
43
- // Bearer tokens are credentials too: hide the one a request used as well as the current one.
49
+ // Bearer tokens are credentials too: hide the one a request used as well as every cached one.
44
50
  function hideTokens(text, ...tokens) {
45
- return [...tokens, cachedToken?.value].filter(Boolean).reduce((out, token) => out.replaceAll(token, '[已隐藏令牌]'), String(text));
51
+ const cached = [...cachedTokens.values()].map((t) => t.value);
52
+ return [...tokens, ...cached].filter(Boolean).reduce((out, token) => out.replaceAll(token, '[已隐藏令牌]'), String(text));
46
53
  }
47
54
 
48
55
  function scrubTokens(data, token) {
@@ -70,7 +77,7 @@ async function rejectRedirect(response, path) {
70
77
 
71
78
  const errorText = async (response, token) => hideTokens(redact((await response.text().catch(() => '')).slice(0, 2000)), token);
72
79
 
73
- async function requestToken({ clientId, secret }, agent) {
80
+ async function requestToken({ clientId, secret, shop }, agent) {
74
81
  const response = await send(ADS_BASE_URL + TOKEN_PATH, {
75
82
  method: 'POST',
76
83
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': USER_AGENT },
@@ -78,8 +85,9 @@ async function requestToken({ clientId, secret }, agent) {
78
85
  }, agent);
79
86
  await rejectRedirect(response, TOKEN_PATH);
80
87
  if (!response.ok) {
88
+ const fields = shop.legacy ? 'OZON_PERF_CLIENT_ID 和 OZON_PERF_CLIENT_SECRET' : `店铺「${shop.name}」的 perf_client_id 和 perf_client_secret`;
81
89
  const hint = response.status < 500
82
- ? `检查 OZON_PERF_CLIENT_ID 和 OZON_PERF_CLIENT_SECRET 是否完整、密钥是否已被删除。${CREDENTIAL_HELP}`
90
+ ? `检查 ${fields} 是否完整、密钥是否已被删除。${shop.legacy ? CREDENTIAL_HELP : ''}`
83
91
  : 'Ozon 授权服务暂时不可用,稍后再试。';
84
92
  throw new OzonError(`Performance API 授权失败(HTTP ${response.status}):${hint} ${await errorText(response)}`);
85
93
  }
@@ -96,21 +104,21 @@ async function requestToken({ clientId, secret }, agent) {
96
104
 
97
105
  // `rejected` is the token that just got a 401: a newer cached token is reused, otherwise a new one is fetched.
98
106
  async function getToken(credentials, agent, rejected) {
99
- const usable = cachedToken && cachedToken.clientId === credentials.clientId
100
- && cachedToken.value !== rejected && Date.now() < cachedToken.expiresAt;
101
- if (usable) return cachedToken.value;
102
- if (!pendingToken || pendingToken.clientId !== credentials.clientId) {
107
+ const { clientId } = credentials;
108
+ const cached = cachedTokens.get(clientId);
109
+ if (cached && cached.value !== rejected && Date.now() < cached.expiresAt) return cached.value;
110
+ if (!pendingTokens.has(clientId)) {
103
111
  const promise = requestToken(credentials, agent)
104
112
  .then((token) => {
105
- cachedToken = token;
113
+ cachedTokens.set(clientId, token);
106
114
  return token.value;
107
115
  })
108
116
  .finally(() => {
109
- if (pendingToken?.promise === promise) pendingToken = null;
117
+ if (pendingTokens.get(clientId) === promise) pendingTokens.delete(clientId);
110
118
  });
111
- pendingToken = { clientId: credentials.clientId, promise };
119
+ pendingTokens.set(clientId, promise);
112
120
  }
113
- return pendingToken.promise;
121
+ return pendingTokens.get(clientId);
114
122
  }
115
123
 
116
124
  function buildUrl(op, params) {
package/src/http.js CHANGED
@@ -7,6 +7,7 @@ import { OzonError } from './errors.js';
7
7
  import { redact } from './privacy.js';
8
8
  import { afterSuccess, beforeRequest, rateLimitText, shareInflight } from './ratelimit.js';
9
9
  import { enforceMode, validate } from './schema.js';
10
+ import { multiShop, shopForPath } from './shops.js';
10
11
  import { findOperation, mediaSource } from './spec.js';
11
12
  import { VlessConfigError, describeVless, isVlessLink, parseVless, vlessConnector } from './vless.js';
12
13
 
@@ -142,12 +143,16 @@ export function setFetchForTests(fn) {
142
143
  }
143
144
  export const currentFetch = () => fetchImpl;
144
145
 
145
- function credentials() {
146
- const clientId = (process.env.OZON_CLIENT_ID ?? '').trim();
147
- const apiKey = (process.env.OZON_API_KEY ?? '').trim();
148
- if (!clientId || !apiKey) throw new OzonError('请在 MCP 配置 env 中填写 OZON_CLIENT_ID 和 OZON_API_KEY,然后重启 MCP 服务。');
149
- if (/[\r\n]/.test(clientId + apiKey)) throw new OzonError('凭证包含无效换行,请检查 MCP 环境变量。');
150
- return [clientId, apiKey];
146
+ // The shop this call runs as; market endpoints always run as the market shop.
147
+ function credentials(op) {
148
+ const shop = shopForPath(op.path);
149
+ if (!shop.clientId || !shop.apiKey) {
150
+ throw new OzonError(shop.legacy
151
+ ? '请在 MCP 配置 env 中填写 OZON_CLIENT_ID 和 OZON_API_KEY(或用 OZON_SHOPS 配置多个店铺),然后重启 MCP 服务。'
152
+ : `店铺「${shop.name}」缺少 client_id 或 api_key。`);
153
+ }
154
+ if (/[\r\n]/.test(shop.clientId + shop.apiKey)) throw new OzonError('凭证包含无效换行,请检查 MCP 环境变量。');
155
+ return shop;
151
156
  }
152
157
 
153
158
  const MIME = {
@@ -272,16 +277,17 @@ export async function request(endpoint, body = {}, method = 'POST') {
272
277
  if (body !== null && (typeof body !== 'object' || Array.isArray(body))) throw new OzonError('请求体必须为 JSON 对象。');
273
278
  body ??= {};
274
279
  validate(op.tool_name, body);
275
- const [clientId, apiKey] = credentials();
280
+ const shop = credentials(op);
276
281
  // A broken proxy setting fails here, before a local rate-limit slot is taken.
277
282
  const agent = getDispatcher();
278
- // Identical concurrent reads of a rate-limited endpoint share one request.
279
- return shareInflight(op, body, () => beforeRequest(op, body) ?? send(op, body, clientId, apiKey, agent));
283
+ // Identical concurrent reads of a rate-limited endpoint share one request; limits count per account.
284
+ const scope = shop.clientId;
285
+ return shareInflight(op, body, () => beforeRequest(op, body, Date.now(), scope) ?? send(op, body, shop, agent), scope);
280
286
  }
281
287
 
282
- async function send(op, body, clientId, apiKey, agent) {
288
+ async function send(op, body, shop, agent) {
283
289
  const endpoint = op.path;
284
- const headers = { 'Client-Id': clientId, 'Api-Key': apiKey, Accept: 'application/json', 'User-Agent': USER_AGENT };
290
+ const headers = { 'Client-Id': shop.clientId, 'Api-Key': shop.apiKey, Accept: 'application/json', 'User-Agent': USER_AGENT };
285
291
  let payload;
286
292
  const [mediaType] = mediaSource(op);
287
293
  if (mediaType === 'multipart/form-data') {
@@ -293,8 +299,10 @@ async function send(op, body, clientId, apiKey, agent) {
293
299
  }
294
300
  if (op.method === 'GET') payload = undefined;
295
301
  if (op.tags.some((t) => OAUTH_TAGS.has(t))) {
296
- const token = (process.env.OZON_ACCESS_TOKEN ?? '').trim();
297
- if (!token) throw new OzonError('Ozon Delivery 接口需要 OAuth;请在 MCP env 中配置 OZON_ACCESS_TOKEN。');
302
+ const token = shop.accessToken;
303
+ if (!token) throw new OzonError(shop.legacy
304
+ ? 'Ozon Delivery 接口需要 OAuth;请在 MCP env 中配置 OZON_ACCESS_TOKEN。'
305
+ : `Ozon Delivery 接口需要 OAuth;请给店铺「${shop.name}」配置 access_token。`);
298
306
  if (/[\r\n]/.test(token)) throw new OzonError('OZON_ACCESS_TOKEN 包含无效换行。');
299
307
  headers.Authorization = 'Bearer ' + token;
300
308
  }
@@ -347,13 +355,14 @@ async function send(op, body, clientId, apiKey, agent) {
347
355
  }
348
356
  const result = {
349
357
  endpoint,
358
+ ...(multiShop() ? { shop: shop.name } : {}),
350
359
  http_status: response.status,
351
360
  queried_at_utc: new Date().toISOString(),
352
361
  elapsed_ms: Math.round(performance.now() - start),
353
362
  rate_limit_remaining: response.headers.get('ratelimit-remaining'),
354
363
  data: redact(data),
355
364
  };
356
- afterSuccess(op, body, result);
365
+ afterSuccess(op, body, result, Date.now(), shop.clientId);
357
366
  return result;
358
367
  }
359
368
  throw new OzonError('查询未完成。');
package/src/privacy.js CHANGED
@@ -1,22 +1,24 @@
1
+ import { allSecrets } from './shops.js';
2
+
1
3
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
2
- const SECRET_KEYS = new Set(['api-key', 'api_key', 'authorization', 'client_secret', 'access_token']);
4
+ const SECRET_KEYS = new Set(['api-key', 'api_key', 'authorization', 'client_secret', 'perf_client_secret', 'access_token']);
3
5
 
4
- // Keep credentials out of both upstream error text and tool results.
6
+ // Keep credentials out of both upstream error text and tool results: every shop's secrets,
7
+ // raw and trimmed (the trimmed form is what actually goes on the wire). The secret list is
8
+ // built once per call, not once per string in a large result.
5
9
  export function redact(value) {
10
+ return scrub(value, allSecrets());
11
+ }
12
+
13
+ function scrub(value, secrets) {
6
14
  if (typeof value === 'string') {
7
- for (const name of ['OZON_API_KEY', 'OZON_ACCESS_TOKEN', 'OZON_PERF_CLIENT_SECRET']) {
8
- const raw = process.env[name] ?? '';
9
- // The trimmed form is what actually goes on the wire.
10
- for (const secret of new Set([raw, raw.trim()])) {
11
- if (secret) value = value.replaceAll(secret, '[已隐藏密钥]');
12
- }
13
- }
15
+ for (const secret of secrets) value = value.replaceAll(secret, '[已隐藏密钥]');
14
16
  return value;
15
17
  }
16
- if (Array.isArray(value)) return value.map(redact);
18
+ if (Array.isArray(value)) return value.map((v) => scrub(v, secrets));
17
19
  if (isObject(value)) {
18
20
  return Object.fromEntries(Object.entries(value)
19
- .map(([k, v]) => [k, SECRET_KEYS.has(String(k).toLowerCase()) ? '[已隐藏]' : redact(v)]));
21
+ .map(([k, v]) => [k, SECRET_KEYS.has(String(k).toLowerCase()) ? '[已隐藏]' : scrub(v, secrets)]));
20
22
  }
21
23
  return value;
22
24
  }
package/src/ratelimit.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Official per-endpoint limits, transcribed from the pinned spec descriptions.
2
- // Local counting is per MCP process; Ozon's server-side counter is authoritative.
2
+ // Local counting is per MCP process and per seller account (`scope` = Client-Id: Ozon counts each
3
+ // account separately); Ozon's server-side counter is authoritative.
3
4
  import { RateLimitError } from './errors.js';
4
5
 
5
6
  const MIN = 60_000;
@@ -49,20 +50,21 @@ function waitText(ms) {
49
50
  return `${Math.ceil(s / 3600)} 小时`;
50
51
  }
51
52
 
52
- const cacheKey = (op, body) => `${op.method} ${op.path} ${JSON.stringify(body ?? {})}`;
53
+ const cacheKey = (op, body, scope) => `${scope}|${op.method} ${op.path} ${JSON.stringify(body ?? {})}`;
53
54
 
54
55
  // Returns a cached result for an identical read, throws if the local window is exhausted.
55
- export function beforeRequest(op, body, now = Date.now()) {
56
+ export function beforeRequest(op, body, now = Date.now(), scope = '') {
56
57
  const rule = RATE_LIMITS[op.path];
57
58
  if (!rule?.windows) return null;
58
59
  if (op.read_only) {
59
- const hit = cache.get(cacheKey(op, body));
60
+ const hit = cache.get(cacheKey(op, body, scope));
60
61
  if (hit && now - hit.at < CACHE_TTL) {
61
62
  return { ...hit.result, from_cache: true, cached_at_utc: new Date(hit.at).toISOString() };
62
63
  }
63
64
  }
64
- const stamps = (history.get(op.path) ?? []).filter((t) => now - t < DAY);
65
- history.set(op.path, stamps);
65
+ const historyKey = `${scope}|${op.path}`;
66
+ const stamps = (history.get(historyKey) ?? []).filter((t) => now - t < DAY);
67
+ history.set(historyKey, stamps);
66
68
  for (const [window, max] of rule.windows) {
67
69
  const used = stamps.filter((t) => now - t < window);
68
70
  if (used.length >= max) {
@@ -75,17 +77,17 @@ export function beforeRequest(op, body, now = Date.now()) {
75
77
  return null;
76
78
  }
77
79
 
78
- export function afterSuccess(op, body, result, now = Date.now()) {
80
+ export function afterSuccess(op, body, result, now = Date.now(), scope = '') {
79
81
  if (!op.read_only || !RATE_LIMITS[op.path]?.windows) return;
80
82
  for (const [key, entry] of cache) if (now - entry.at >= CACHE_TTL) cache.delete(key);
81
- cache.set(cacheKey(op, body), { at: now, result });
83
+ cache.set(cacheKey(op, body, scope), { at: now, result });
82
84
  }
83
85
 
84
86
  const inflight = new Map();
85
87
 
86
- export function shareInflight(op, body, fn) {
88
+ export function shareInflight(op, body, fn, scope = '') {
87
89
  if (!op.read_only || !RATE_LIMITS[op.path]?.windows) return Promise.resolve().then(fn);
88
- const key = cacheKey(op, body);
90
+ const key = cacheKey(op, body, scope);
89
91
  if (!inflight.has(key)) {
90
92
  inflight.set(key, Promise.resolve().then(fn).finally(() => inflight.delete(key)));
91
93
  }
package/src/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Server } from '@modelcontextprotocol/server';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
3
+ import { MARKET_PATHS, defaultShop, marketShop, multiShop, shops } from './shops.js';
3
4
  import { callTool, checkConfiguration, listTools } from './tools.js';
4
5
  import { VERSION } from './version.js';
5
6
 
@@ -11,8 +12,16 @@ const INSTRUCTIONS = [
11
12
  '工具说明或结果中出现“限流”时,必须把官方限制告诉用户,不要重复重试。默认只读。',
12
13
  ].join('');
13
14
 
15
+ function instructions() {
16
+ if (!multiShop()) return INSTRUCTIONS;
17
+ const market = marketShop();
18
+ return INSTRUCTIONS + `多店铺:${shops().map((s) => s.name).join('、')}。访问店铺数据的工具都有 shop 参数,不填 = 默认店「${defaultShop().name}」;`
19
+ + '问到某家店时必须传 shop,比较多家店时逐店调用,结果里的 shop 字段写明是哪家店的数据。'
20
+ + (market ? `市场数据接口(${[...MARKET_PATHS].join('、')})固定用「${market.name}」的凭证,结果对所有店通用。` : '');
21
+ }
22
+
14
23
  export function createServer() {
15
- const server = new Server({ name: 'ozon-seller', version: VERSION }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
24
+ const server = new Server({ name: 'ozon-seller', version: VERSION }, { capabilities: { tools: {} }, instructions: instructions() });
16
25
  server.setRequestHandler('tools/list', async () => ({ tools: listTools() }));
17
26
  server.setRequestHandler('tools/call', async (request) => callTool(request.params.name, request.params.arguments));
18
27
  return server;
package/src/shops.js ADDED
@@ -0,0 +1,135 @@
1
+ // Shops: OZON_SHOPS (a JSON array, one entry per shop) or the single-shop env variables.
2
+ // The shop a tool call runs as travels in AsyncLocalStorage, so both transports pick the right
3
+ // credentials without every helper threading a shop argument through.
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
+
6
+ const NAME = /^[\p{L}\p{N}_-]{1,32}$/u;
7
+ const FIELDS = new Set(['name', 'client_id', 'api_key', 'perf_client_id', 'perf_client_secret', 'access_token', 'market', 'default']);
8
+
9
+ // Platform-wide market data: the answer does not depend on which shop asks, and Ozon serves it
10
+ // only under Premium Pro. These always run as the shop marked "market": true.
11
+ export const MARKET_PATHS = new Set([
12
+ '/v1/analytics/category/comparison',
13
+ '/v1/search-queries/top',
14
+ '/v1/search-queries/text',
15
+ ]);
16
+
17
+ export class ShopConfigError extends Error {}
18
+
19
+ const text = (value) => {
20
+ if (typeof value === 'number' && Number.isInteger(value)) return String(value);
21
+ return typeof value === 'string' ? value.trim() : '';
22
+ };
23
+
24
+ function legacyShop() {
25
+ const env = (key) => (process.env[key] ?? '').trim();
26
+ return [{
27
+ name: 'default', legacy: true, isDefault: true, market: false,
28
+ clientId: env('OZON_CLIENT_ID'), apiKey: env('OZON_API_KEY'),
29
+ perfClientId: env('OZON_PERF_CLIENT_ID'), perfSecret: env('OZON_PERF_CLIENT_SECRET'),
30
+ accessToken: env('OZON_ACCESS_TOKEN'),
31
+ }];
32
+ }
33
+
34
+ // Messages name the entry and the field, never a value: a value may be a secret.
35
+ function parseShops(raw) {
36
+ let list;
37
+ try {
38
+ list = JSON.parse(raw);
39
+ } catch {
40
+ throw new ShopConfigError('OZON_SHOPS 不是合法 JSON;应为数组,例如 [{"name":"shop-a","client_id":"…","api_key":"…","market":true}]。');
41
+ }
42
+ if (!Array.isArray(list) || !list.length) throw new ShopConfigError('OZON_SHOPS 必须是至少含一个店铺的 JSON 数组。');
43
+ const seen = new Set();
44
+ const shops = list.map((item, i) => {
45
+ const at = `OZON_SHOPS 第 ${i + 1} 项`;
46
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) throw new ShopConfigError(`${at} 必须是对象。`);
47
+ const unknown = Object.keys(item).filter((k) => !FIELDS.has(k));
48
+ if (unknown.length) throw new ShopConfigError(`${at} 有未知字段:${unknown.join('、')}(可用 ${[...FIELDS].join('、')})。`);
49
+ const name = text(item.name);
50
+ if (!NAME.test(name)) throw new ShopConfigError(`${at} 的 name 必须是 1~32 个字母、数字、汉字、_ 或 -。`);
51
+ if (seen.has(name)) throw new ShopConfigError(`${at} 的 name「${name}」重复。`);
52
+ seen.add(name);
53
+ const shop = {
54
+ name, legacy: false,
55
+ clientId: text(item.client_id), apiKey: text(item.api_key),
56
+ perfClientId: text(item.perf_client_id), perfSecret: text(item.perf_client_secret),
57
+ accessToken: text(item.access_token),
58
+ market: item.market ?? false, isDefault: item.default ?? false,
59
+ };
60
+ if (Boolean(shop.clientId) !== Boolean(shop.apiKey)) {
61
+ throw new ShopConfigError(`店铺「${name}」的 client_id 和 api_key 要么都填,要么都不填。`);
62
+ }
63
+ if (Boolean(shop.perfClientId) !== Boolean(shop.perfSecret)) {
64
+ throw new ShopConfigError(`店铺「${name}」的 perf_client_id 和 perf_client_secret 要么都填,要么都不填。`);
65
+ }
66
+ if (!hasSeller(shop) && !hasAds(shop)) {
67
+ throw new ShopConfigError(`店铺「${name}」至少要填一组凭证:client_id + api_key(Seller API),或 perf_client_id + perf_client_secret(广告)。`);
68
+ }
69
+ for (const flag of ['market', 'isDefault']) {
70
+ if (typeof shop[flag] !== 'boolean') throw new ShopConfigError(`店铺「${name}」的 ${flag === 'market' ? 'market' : 'default'} 只能是 true 或 false。`);
71
+ }
72
+ for (const [field, value] of Object.entries(shop)) {
73
+ if (typeof value === 'string' && /[\r\n]/.test(value)) throw new ShopConfigError(`店铺「${name}」的 ${field} 含有换行。`);
74
+ }
75
+ return shop;
76
+ });
77
+ // Rate limits, the read cache and ad tokens are keyed by these ids: two entries must not share one.
78
+ for (const [field, key] of [['client_id', 'clientId'], ['perf_client_id', 'perfClientId']]) {
79
+ const ids = shops.map((s) => s[key]).filter(Boolean);
80
+ const twice = ids.find((id, i) => ids.indexOf(id) !== i);
81
+ if (twice) throw new ShopConfigError(`OZON_SHOPS 里有两个店铺的 ${field} 相同(${shops.filter((s) => s[key] === twice).map((s) => s.name).join('、')})。`);
82
+ }
83
+ if (shops.filter((s) => s.market).length > 1) throw new ShopConfigError('OZON_SHOPS 里只能有一个店铺标 "market": true。');
84
+ if (shops.filter((s) => s.isDefault).length > 1) throw new ShopConfigError('OZON_SHOPS 里只能有一个店铺标 "default": true。');
85
+ return shops;
86
+ }
87
+
88
+ let cached;
89
+ let cachedFrom;
90
+ const ENV_KEYS = ['OZON_SHOPS', 'OZON_CLIENT_ID', 'OZON_API_KEY', 'OZON_PERF_CLIENT_ID', 'OZON_PERF_CLIENT_SECRET', 'OZON_ACCESS_TOKEN'];
91
+
92
+ export function shops() {
93
+ const from = ENV_KEYS.map((k) => process.env[k] ?? '').join('\u0000');
94
+ if (cached && cachedFrom === from) return cached;
95
+ const raw = (process.env.OZON_SHOPS ?? '').trim();
96
+ cached = raw ? parseShops(raw) : legacyShop();
97
+ cachedFrom = from;
98
+ return cached;
99
+ }
100
+
101
+ export const hasSeller = (shop) => Boolean(shop.clientId && shop.apiKey);
102
+ export const hasAds = (shop) => Boolean(shop.perfClientId && shop.perfSecret);
103
+
104
+ export const multiShop = () => shops().length > 1;
105
+ export const defaultShop = () => shops().find((s) => s.isDefault) ?? shops()[0];
106
+ // Only an explicit "market": true takes market endpoints away from the shop the caller chose.
107
+ export const marketShop = () => shops().find((s) => s.market) ?? null;
108
+
109
+ export function findShop(name) {
110
+ const shop = shops().find((s) => s.name === name);
111
+ if (!shop) throw new ShopConfigError(`没有名为「${name}」的店铺;可用:${shops().map((s) => s.name).join('、')}。`);
112
+ return shop;
113
+ }
114
+
115
+ const storage = new AsyncLocalStorage();
116
+ export const runAsShop = (shop, fn) => storage.run(shop, fn);
117
+ export const currentShop = () => storage.getStore() ?? defaultShop();
118
+
119
+ // Market endpoints run as the market shop (when one is marked) whichever shop the call was made for.
120
+ export const shopForPath = (path) => (MARKET_PATHS.has(path) && marketShop()) || currentShop();
121
+
122
+ // Every configured secret, for redaction. Never throws: a broken config must not break error reporting.
123
+ export function allSecrets() {
124
+ const out = [];
125
+ for (const key of ['OZON_API_KEY', 'OZON_ACCESS_TOKEN', 'OZON_PERF_CLIENT_SECRET']) {
126
+ const raw = process.env[key] ?? '';
127
+ out.push(raw, raw.trim());
128
+ }
129
+ try {
130
+ for (const s of shops()) out.push(s.apiKey, s.perfSecret, s.accessToken);
131
+ } catch {
132
+ // config errors are reported by checkConfiguration
133
+ }
134
+ return [...new Set(out.filter(Boolean))];
135
+ }
package/src/tools.js CHANGED
@@ -9,6 +9,7 @@ import { hidePersonal, redact } from './privacy.js';
9
9
  import { rateLimitText } from './ratelimit.js';
10
10
  import { renderReport } from './reports.js';
11
11
  import { compileSchema, describe, enforceMode, formatErrors, inputSchema, validate } from './schema.js';
12
+ import { MARKET_PATHS, defaultShop, findShop, hasAds, hasSeller, marketShop, multiShop, runAsShop, shops } from './shops.js';
12
13
  import { CATALOG, OPERATIONS, findOperation, operationSource } from './spec.js';
13
14
  import { VERSION } from './version.js';
14
15
 
@@ -100,12 +101,34 @@ function makeTool(op) {
100
101
 
101
102
  const strip = ({ handler, ...tool }) => tool;
102
103
 
104
+ // A tool reaches Ozon unless its annotations say otherwise (openWorldHint: false = local only).
105
+ const reachesOzon = (tool) => tool.annotations?.openWorldHint !== false;
106
+
107
+ function shopProperty() {
108
+ const market = marketShop();
109
+ return {
110
+ type: 'string', enum: shops().map((s) => s.name),
111
+ description: `店铺;不填 = 默认店「${defaultShop().name}」。`
112
+ + (market ? `市场数据接口(${[...MARKET_PATHS].join('、')})不论填哪家都用「${market.name}」的凭证查询。` : ''),
113
+ };
114
+ }
115
+
116
+ // With several shops configured, every tool that reaches Ozon gains an optional `shop` argument.
117
+ function withShop(tool, property) {
118
+ if (!property || !reachesOzon(tool)) return tool;
119
+ const base = tool.inputSchema ?? { type: 'object', properties: {} };
120
+ return { ...tool, inputSchema: { ...base, properties: { ...(base.properties ?? {}), shop: property } } };
121
+ }
122
+
103
123
  export function listTools() {
104
- return [...metaTools(), ...COMPOSITE_TOOLS.map(strip), ...listAdsTools(), ...selectedOperations().map(makeTool)];
124
+ const property = multiShop() ? shopProperty() : null;
125
+ return [...metaTools(), ...COMPOSITE_TOOLS.map(strip), ...listAdsTools(), ...selectedOperations().map(makeTool)]
126
+ .map((tool) => withShop(tool, property));
105
127
  }
106
128
 
107
129
  // Called once at startup so configuration typos fail loudly instead of mid-conversation.
108
130
  export function checkConfiguration() {
131
+ shops();
109
132
  readOnlyMode();
110
133
  compactEnabled();
111
134
  maxResultBytes();
@@ -156,16 +179,26 @@ async function execute(name, args) {
156
179
  switch (name) {
157
180
  case 'ozon_status': {
158
181
  const readOnly = readOnlyMode();
182
+ const main = defaultShop();
159
183
  return {
160
184
  version: VERSION, read_only: readOnly, toolset: toolsetSpec(), listed_tools: listTools().length,
161
- api_key_configured: Boolean(process.env.OZON_API_KEY), client_id: process.env.OZON_CLIENT_ID || null,
185
+ api_key_configured: hasSeller(main), client_id: main.clientId || null,
186
+ ...(main.legacy ? {} : {
187
+ default_shop: main.name, market_shop: marketShop()?.name ?? null, market_endpoints: [...MARKET_PATHS],
188
+ shops: shops().map((s) => ({
189
+ name: s.name, client_id: s.clientId || null, default: s === main, market: s.market,
190
+ seller_api_configured: hasSeller(s), ads_configured: hasAds(s), ads_client_id: s.perfClientId || null,
191
+ delivery_token_configured: Boolean(s.accessToken),
192
+ })),
193
+ }),
162
194
  official_operations: OPERATIONS.size,
163
195
  available_api_operations: [...OPERATIONS.values()].filter((o) => !readOnly || o.read_only).length,
164
196
  compact_results: compactEnabled(), max_result_kb: Math.round(maxResultBytes() / 1024),
165
197
  source_date: CATALOG.source_date, source_version: CATALOG.source_version,
166
198
  node: process.version, platform: process.platform, proxy: proxySummary(),
167
199
  ads: {
168
- credentials_configured: adsConfigured(), client_id: process.env.OZON_PERF_CLIENT_ID?.trim() || null,
200
+ // With several shops the per-shop list above says which ones have ads credentials.
201
+ credentials_configured: adsConfigured(), client_id: main.legacy ? main.perfClientId || null : undefined,
169
202
  official_operations: ADS_OPERATIONS.length, source_date: ADS_SOURCE.date, source_version: ADS_SOURCE.version,
170
203
  setup_hint: adsConfigured() ? undefined : `广告工具(ozon_ads_stats 等)未启用。${CREDENTIAL_HELP}`,
171
204
  },
@@ -198,17 +231,42 @@ async function execute(name, args) {
198
231
  }
199
232
 
200
233
  function checkToolArguments(name, args) {
201
- const tool = metaTools().find((t) => t.name === name) ?? COMPOSITE.get(name) ?? ADS_TOOL_MAP.get(name);
234
+ const tool = toolDefinition(name);
202
235
  if (!tool) return;
203
236
  const check = compileSchema('tool:' + name, tool.inputSchema);
204
237
  if (!check(args)) throw new Error(`工具参数校验失败;未执行操作。${formatErrors(check.errors, 3).join(';')}`);
205
238
  }
206
239
 
240
+ function toolDefinition(name) {
241
+ return metaTools().find((t) => t.name === name) ?? COMPOSITE.get(name) ?? ADS_TOOL_MAP.get(name);
242
+ }
243
+
244
+ // Generated endpoint tools always reach Ozon; the hand-written ones say so in their annotations.
245
+ const isLocalTool = (name) => !OPERATIONS.has(name) && toolDefinition(name)?.annotations?.openWorldHint === false;
246
+
247
+ // `shop` is ours, not part of any Ozon request body: take it off before the tool's own validation.
248
+ function pickShop(name, args) {
249
+ const { shop, ...input } = args ?? {};
250
+ if (shop === undefined) return [defaultShop(), input];
251
+ if (isLocalTool(name)) throw new Error(`${name} 不访问店铺数据,不需要 shop 参数。`);
252
+ if (typeof shop !== 'string') throw new Error('shop 必须是店铺名(字符串)。');
253
+ return [findShop(shop), input];
254
+ }
255
+
256
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
257
+
258
+ // With several shops, every result that came from Ozon says whose credentials answered it. Endpoint
259
+ // results set it themselves (a market endpoint answers as the market shop); the rest ran as `shop`.
260
+ function labelShop(name, shop, result) {
261
+ if (!multiShop() || isLocalTool(name) || !isPlainObject(result) || 'shop' in result) return result;
262
+ return { shop: shop.name, ...result };
263
+ }
264
+
207
265
  export async function callTool(name, args) {
208
266
  try {
209
- const input = args ?? {};
267
+ const [shop, input] = pickShop(name, args);
210
268
  checkToolArguments(name, input);
211
- const result = redact(await execute(name, input));
269
+ const result = redact(labelShop(name, shop, await runAsShop(shop, () => execute(name, input))));
212
270
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
213
271
  } catch (error) {
214
272
  return { content: [{ type: 'text', text: redact(String(error?.message ?? error)) }], isError: true };