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 +28 -1
- package/bin/ozon-seller-mcp.js +5 -1
- package/package.json +1 -1
- package/src/ads/http.js +30 -22
- package/src/http.js +23 -14
- package/src/privacy.js +13 -11
- package/src/ratelimit.js +12 -10
- package/src/server.js +10 -1
- package/src/shops.js +135 -0
- package/src/tools.js +64 -6
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` 即可,不用另装代理软件:
|
package/bin/ozon-seller-mcp.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
+
cachedTokens.clear();
|
|
32
|
+
pendingTokens.clear();
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
? `检查
|
|
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
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
if (!
|
|
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
|
-
|
|
113
|
+
cachedTokens.set(clientId, token);
|
|
106
114
|
return token.value;
|
|
107
115
|
})
|
|
108
116
|
.finally(() => {
|
|
109
|
-
if (
|
|
117
|
+
if (pendingTokens.get(clientId) === promise) pendingTokens.delete(clientId);
|
|
110
118
|
});
|
|
111
|
-
|
|
119
|
+
pendingTokens.set(clientId, promise);
|
|
112
120
|
}
|
|
113
|
-
return
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
if (!clientId || !apiKey)
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
297
|
-
if (!token) throw new OzonError(
|
|
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
|
|
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(
|
|
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()) ? '[已隐藏]' :
|
|
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
|
|
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
|
|
65
|
-
history.
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 };
|