cito-mcp 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +615 -609
- package/dist/client.js +107 -31
- package/dist/envelope.js +78 -0
- package/dist/instructions.js +81 -81
- package/dist/pacing.js +195 -0
- package/dist/tools/types.js +13 -4
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MAX_RATE_LIMIT_RETRIES, MAX_UPSTREAM_RETRIES, MAX_WAIT_MS, UPSTREAM_BACKOFF_MS, classify429, getClock, jitterMs, newCallBudget, pacerFor, } from './pacing.js';
|
|
1
2
|
import { PACKAGE_VERSION } from './version.js';
|
|
2
3
|
export const DEFAULT_API_BASE = 'https://api.citoapi.com/api/v1';
|
|
3
4
|
export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
|
|
@@ -16,8 +17,10 @@ export function authHeaders(apiKey, extra) {
|
|
|
16
17
|
export function parseRateLimitHeaders(headers) {
|
|
17
18
|
if (!headers)
|
|
18
19
|
return {};
|
|
19
|
-
const limitRaw = headers.get('x-ratelimit-limit') ?? headers.get('ratelimit-limit');
|
|
20
|
-
const remainingRaw = headers.get('x-ratelimit-remaining') ??
|
|
20
|
+
const limitRaw = headers.get('x-ratelimit-limit-minute') ?? headers.get('x-ratelimit-limit') ?? headers.get('ratelimit-limit');
|
|
21
|
+
const remainingRaw = headers.get('x-ratelimit-remaining-minute') ??
|
|
22
|
+
headers.get('x-ratelimit-remaining') ??
|
|
23
|
+
headers.get('ratelimit-remaining');
|
|
21
24
|
const resetRaw = headers.get('x-ratelimit-reset') ?? headers.get('ratelimit-reset');
|
|
22
25
|
const tier = headers.get('x-cito-tier') ?? headers.get('x-plan-tier');
|
|
23
26
|
return {
|
|
@@ -80,38 +83,111 @@ export async function fetchJson(ctx, path, opts) {
|
|
|
80
83
|
headers['content-type'] = 'application/json';
|
|
81
84
|
body = JSON.stringify(opts.body);
|
|
82
85
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
const budget = ctx.budget ?? newCallBudget();
|
|
87
|
+
const pacer = pacerFor(ctx.apiKey);
|
|
88
|
+
const clock = getClock();
|
|
89
|
+
let rateLimitRetries = 0;
|
|
90
|
+
let upstreamRetries = 0;
|
|
91
|
+
for (;;) {
|
|
92
|
+
const gate = await pacer.acquire(budget);
|
|
93
|
+
if (!gate.ok) {
|
|
94
|
+
const event = {
|
|
95
|
+
...(pacer.last429 ?? classify429(null)),
|
|
96
|
+
message: null,
|
|
97
|
+
retryAfterMs: gate.waitMs,
|
|
98
|
+
local: true,
|
|
99
|
+
};
|
|
100
|
+
budget.rateLimit = event;
|
|
101
|
+
const body = {
|
|
102
|
+
success: false,
|
|
103
|
+
error: {
|
|
104
|
+
code: event.apiCode,
|
|
105
|
+
status: 429,
|
|
106
|
+
message: `This API key is rate-limited by the Cito API. Retry in ${Math.ceil(gate.waitMs / 1000)} seconds.`,
|
|
107
|
+
retry_after_seconds: Math.ceil(gate.waitMs / 1000),
|
|
108
|
+
...(event.upgradeUrl ? { upgrade: event.upgradeUrl } : {}),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const text = JSON.stringify(body);
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
status: 429,
|
|
115
|
+
data: body,
|
|
116
|
+
headers: {},
|
|
117
|
+
text,
|
|
118
|
+
url,
|
|
119
|
+
rateLimit: event,
|
|
120
|
+
retries: rateLimitRetries + upstreamRetries,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let response;
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
response = await fetcher(url, { method, headers, body });
|
|
127
|
+
text = await response.text();
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
gate.release();
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 0,
|
|
134
|
+
data: { message: error.message },
|
|
135
|
+
headers: {},
|
|
136
|
+
text: error.message,
|
|
137
|
+
url,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
gate.release();
|
|
141
|
+
let data = null;
|
|
142
|
+
try {
|
|
143
|
+
data = text ? JSON.parse(text) : null;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
data = text;
|
|
147
|
+
}
|
|
148
|
+
const limited = response.status === 429 ? classify429(data, response.headers) : undefined;
|
|
149
|
+
pacer.observe(response.status, response.headers, limited);
|
|
150
|
+
if (limited) {
|
|
151
|
+
budget.rateLimit = limited;
|
|
152
|
+
const wait = limited.retryAfterMs ?? 2_000;
|
|
153
|
+
const retryable = limited.kind !== 'monthly' &&
|
|
154
|
+
rateLimitRetries < MAX_RATE_LIMIT_RETRIES &&
|
|
155
|
+
wait <= MAX_WAIT_MS &&
|
|
156
|
+
clock.now() + wait < budget.deadline;
|
|
157
|
+
if (retryable) {
|
|
158
|
+
rateLimitRetries += 1;
|
|
159
|
+
budget.retries += 1;
|
|
160
|
+
log(`429 ${limited.apiCode} on ${path}; retry ${rateLimitRetries}/${MAX_RATE_LIMIT_RETRIES} after ${wait} ms`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (response.status === 502 || response.status === 503) {
|
|
165
|
+
const wait = UPSTREAM_BACKOFF_MS + jitterMs();
|
|
166
|
+
if (upstreamRetries < MAX_UPSTREAM_RETRIES && clock.now() + wait < budget.deadline) {
|
|
167
|
+
upstreamRetries += 1;
|
|
168
|
+
budget.retries += 1;
|
|
169
|
+
budget.waitedMs += wait;
|
|
170
|
+
log(`${response.status} on ${path}; retry after ${wait} ms`);
|
|
171
|
+
await clock.sleep(wait);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!opts?.raw && response.ok && isTennisPath(path))
|
|
176
|
+
data = flattenTennisEnvelope(data);
|
|
177
|
+
const result = {
|
|
178
|
+
ok: response.ok,
|
|
179
|
+
status: response.status,
|
|
180
|
+
data,
|
|
181
|
+
headers: parseRateLimitHeaders(response.headers),
|
|
182
|
+
text,
|
|
94
183
|
url,
|
|
95
184
|
};
|
|
185
|
+
if (limited)
|
|
186
|
+
result.rateLimit = limited;
|
|
187
|
+
if (rateLimitRetries + upstreamRetries > 0)
|
|
188
|
+
result.retries = rateLimitRetries + upstreamRetries;
|
|
189
|
+
return result;
|
|
96
190
|
}
|
|
97
|
-
const text = await response.text();
|
|
98
|
-
let data = null;
|
|
99
|
-
try {
|
|
100
|
-
data = text ? JSON.parse(text) : null;
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
data = text;
|
|
104
|
-
}
|
|
105
|
-
if (!opts?.raw && response.ok && isTennisPath(path))
|
|
106
|
-
data = flattenTennisEnvelope(data);
|
|
107
|
-
return {
|
|
108
|
-
ok: response.ok,
|
|
109
|
-
status: response.status,
|
|
110
|
-
data,
|
|
111
|
-
headers: parseRateLimitHeaders(response.headers),
|
|
112
|
-
text,
|
|
113
|
-
url,
|
|
114
|
-
};
|
|
115
191
|
}
|
|
116
192
|
export async function parallelGet(ctx, paths) {
|
|
117
193
|
const results = await Promise.all(paths.map(async ({ key, path, query }) => {
|
package/dist/envelope.js
CHANGED
|
@@ -194,6 +194,84 @@ export function sealEnvelope(envelope) {
|
|
|
194
194
|
},
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
|
+
export function applyRateLimitContext(envelope, event, retries = 0) {
|
|
198
|
+
if (!event)
|
|
199
|
+
return envelope;
|
|
200
|
+
const seconds = event.retryAfterMs != null ? Math.max(1, Math.ceil(event.retryAfterMs / 1000)) : null;
|
|
201
|
+
const upgrade = event.upgradeUrl;
|
|
202
|
+
if (envelope.ok) {
|
|
203
|
+
if (!envelope.partial?.some((p) => p.code === 'RATE_LIMIT'))
|
|
204
|
+
return envelope;
|
|
205
|
+
const note = event.kind === 'monthly'
|
|
206
|
+
? `Some sections were not fetched: this key has used its monthly requests${upgrade ? ` (upgrade: ${upgrade})` : ''}.`
|
|
207
|
+
: `Some sections hit the Cito API rate limit after cito-mcp paced and retried them${seconds ? `; retry in ${seconds} s` : ''}${upgrade ? ` (higher limits: ${upgrade})` : ''}.`;
|
|
208
|
+
return { ...envelope, meta: { ...envelope.meta, warnings: [...(envelope.meta.warnings ?? []), note] } };
|
|
209
|
+
}
|
|
210
|
+
if (envelope.error.code !== 'RATE_LIMIT')
|
|
211
|
+
return envelope;
|
|
212
|
+
const details = {
|
|
213
|
+
...(envelope.error.details ?? {}),
|
|
214
|
+
apiCode: event.apiCode,
|
|
215
|
+
retriedByMcp: retries,
|
|
216
|
+
};
|
|
217
|
+
if (event.plan)
|
|
218
|
+
details.plan = event.plan;
|
|
219
|
+
if (event.limitPerMinute != null)
|
|
220
|
+
details.limitPerMinute = event.limitPerMinute;
|
|
221
|
+
if (event.burstPerMinute != null)
|
|
222
|
+
details.burstPerMinute = event.burstPerMinute;
|
|
223
|
+
if (upgrade)
|
|
224
|
+
details.upgrade = upgrade;
|
|
225
|
+
if (event.kind === 'monthly') {
|
|
226
|
+
return {
|
|
227
|
+
...envelope,
|
|
228
|
+
error: {
|
|
229
|
+
...envelope.error,
|
|
230
|
+
message: event.message ||
|
|
231
|
+
`This API key has used all of its requests for the month, so the Cito API refused this call (MONTHLY_LIMIT_REACHED).${upgrade ? ` Upgrade for more: ${upgrade}` : ''}`,
|
|
232
|
+
retryable: false,
|
|
233
|
+
retryAfterMs: event.retryAfterMs,
|
|
234
|
+
...(upgrade ? { hint: `Upgrade for more monthly requests: ${upgrade}` } : {}),
|
|
235
|
+
recover: [
|
|
236
|
+
'Do not retry: the monthly allowance is used up, and waiting seconds or minutes will not help',
|
|
237
|
+
...(upgrade ? [`Tell the user they can upgrade for more requests: ${upgrade}`] : []),
|
|
238
|
+
'Or wait for the monthly reset named in the message',
|
|
239
|
+
],
|
|
240
|
+
details,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const limitText = event.limitPerMinute != null
|
|
245
|
+
? ` (${event.plan ? `${event.plan} plan: ` : ''}${event.limitPerMinute} requests per minute on average${event.burstPerMinute ? `, bursts to ${event.burstPerMinute}` : ''})`
|
|
246
|
+
: '';
|
|
247
|
+
const what = event.kind === 'concurrency'
|
|
248
|
+
? 'The Cito API refused this call because this key had too many requests in flight at once'
|
|
249
|
+
: `The Cito API rate-limited this key${limitText}`;
|
|
250
|
+
const waited = retries > 0
|
|
251
|
+
? ` cito-mcp already waited and retried ${retries} time${retries === 1 ? '' : 's'}.`
|
|
252
|
+
: event.local
|
|
253
|
+
? ' cito-mcp held the request back because the API had already asked this key to wait.'
|
|
254
|
+
: '';
|
|
255
|
+
const message = `${what}.${waited}` +
|
|
256
|
+
(seconds ? ` Retry in ${seconds} second${seconds === 1 ? '' : 's'}.` : '') +
|
|
257
|
+
(upgrade && event.kind !== 'concurrency' ? ` For a higher per-minute limit, upgrade: ${upgrade}` : '');
|
|
258
|
+
return {
|
|
259
|
+
...envelope,
|
|
260
|
+
error: {
|
|
261
|
+
...envelope.error,
|
|
262
|
+
message,
|
|
263
|
+
retryable: true,
|
|
264
|
+
retryAfterMs: event.retryAfterMs,
|
|
265
|
+
...(upgrade && event.kind !== 'concurrency' ? { hint: `Higher per-minute limits: ${upgrade}` } : {}),
|
|
266
|
+
recover: [
|
|
267
|
+
seconds ? `Wait ${seconds} seconds, then retry this tool once` : 'Wait a minute, then retry this tool once',
|
|
268
|
+
'Call tools one at a time instead of in parallel, and prefer one composite tool over several thin ones',
|
|
269
|
+
...(upgrade && event.kind !== 'concurrency' ? [`Tell the user a paid plan raises the limit: ${upgrade}`] : []),
|
|
270
|
+
],
|
|
271
|
+
details,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
197
275
|
export const ENVELOPE_OUTPUT_SCHEMA = {
|
|
198
276
|
type: 'object',
|
|
199
277
|
additionalProperties: true,
|
package/dist/instructions.js
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
export const SERVER_INSTRUCTIONS = `# Cito MCP — agent operating manual
|
|
2
|
-
|
|
3
|
-
You are connected to Cito esports data (read-only). Prefer curated outcome tools over raw REST. Production apps must call Cito REST with the user's API key; use this MCP to design, prototype, and resolve IDs — not as a multi-tenant runtime bus.
|
|
4
|
-
|
|
5
|
-
## Hard rules
|
|
6
|
-
|
|
7
|
-
1. **Never invent IDs.** Do not guess matchId, gameId, playerId, team slug, eventId, or boutId. Always obtain IDs from a prior tool result (resolve, live, schedule, search, or list). If the user gives a name ("T1", "s1mple", "UFC 300"), call resolve_entity/search_entities first.
|
|
8
|
-
2. **Resolve before deep.** Name → ID/slug → summary/page → deep stats. Skip resolve only when the user already provided a Cito ID/slug.
|
|
9
|
-
3. **One screen, one composite.** Prefer a page/summary tool over stitching 4–6 thin GETs.
|
|
10
|
-
4. **Honor the envelope.** Parse ok, data, pagination, partial, error, meta.rateLimit, meta.entities. Partial section failures are not total failures — use successful sections.
|
|
11
|
-
5. **Read-only.** All curated tools are safe to retry except where error.retryable is false for bad args / entitlement.
|
|
12
|
-
|
|
13
|
-
## Game parameter
|
|
14
|
-
|
|
15
|
-
game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | tennis | all
|
|
16
|
-
|
|
17
|
-
- Default when the user named one title: that game. Default for "what's live?" / multi-title: omit game or all on tools that accept it.
|
|
18
|
-
- On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
|
|
19
|
-
- Fortnite and other titles may appear via call_api/resources; do not assume they are in the curated enum until list_capabilities says so.
|
|
20
|
-
- RAF (Real American Freestyle wrestling) and BKFC (Bare Knuckle Fighting Championship) are NOT on the game enum: use the combat_* tools with league raf|bkfc (combat_events, combat_card, combat_bout, combat_athlete, combat_h2h, combat_rankings), or call_api /raf/... and /bkfc/.... RAF scores per PERIOD (wrestling points, takedowns); BKFC per ROUND (punches landed/thrown, knockdowns). Never mix either into UFC answers.
|
|
21
|
-
- MMA archives: Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, plus /mma for a fighter's whole non-UFC career (recordByPromotion). combat_* tools with league bellator|pride|strikeforce|wec|dream|elitexc|affliction|mma, or call_api /{league}/.... Closed archives (1997-2017) with official per-ROUND stats in UFC's shape ("13 of 18", round 0 = whole fight); no live, rankings or upcoming cards (combat_card "last" = most recent card). Fighters share their UFC slug (ufcFighterSlug). Never mix these into UFC answers. Their stats predate the Free/Starter window: HISTORY_WINDOW_EXCEEDED means Pro, not a bad key.
|
|
22
|
-
- PFL (Professional Fighters League): combat_* with league pfl, or call_api /pfl/.... Every card since 2018, upcoming cards (combat_events when upcoming, combat_card "next") and live fight status (/pfl/live: bout status "live" with live.round / live.clock). Results only (winner, method, round, time): no strike or round stats exist for PFL. league mma includes PFL. Never mix into UFC answers.
|
|
23
|
-
- OKTAGON MMA: combat_* with league oktagon, or call_api /oktagon/.... Every card since Oktagon 1 (2016), upcoming cards and live fight status. Per-ROUND stats on most bouts, LANDED ONLY: significant/total strikes, head/body/leg and takedowns as plain counts (landed.*), plus submission attempts; attempts, accuracy, knockdowns and control time are null because the source does not log them (each bout's statsCoverage says which fields exist). Fighters share their UFC slug when proven. league mma includes OKTAGON. Never mix into UFC answers.
|
|
24
|
-
- UFC includes DWCS and Road to UFC (2022-2026, all 19 cards with official round stats; slugs like road-to-ufc-2-7, "Road to UFC 2.7").
|
|
25
|
-
|
|
26
|
-
## Preferred tool order
|
|
27
|
-
|
|
28
|
-
1. Unsure → list_capabilities (filter by game or job). Optional: api_health for key/tier/included games.
|
|
29
|
-
2. Name without ID → resolve_entity (best match) or search_entities (browse). Reuse returned id/slug.
|
|
30
|
-
3. Live / upcoming → live_matches; upcoming_schedule.
|
|
31
|
-
4. Match UI / recap → match_summary first. match_details only for timelines, full maps, demos, live state.
|
|
32
|
-
5. Team page → team_profile. Player form → player_profile. Tables/ranks → standings.
|
|
33
|
-
6. Pre-match → match_preview. Rivalry → head_to_head. Event / fight-night card → event_card.
|
|
34
|
-
7. Escape hatch → call_api (allowlisted path prefixes; prefer GET). Prefer curated tools.
|
|
35
|
-
|
|
36
|
-
Mnemonic: resolve → live/schedule → summary → deep.
|
|
37
|
-
|
|
38
|
-
## Parallel vs sequence
|
|
39
|
-
|
|
40
|
-
Parallel-safe: independent reads (team_profile A ∥ team_profile B; live_matches ∥ api_health). Cap ~3–5 concurrent agent tools; check meta.rateLimit.remaining.
|
|
41
|
-
|
|
42
|
-
Serial required: resolve → detail; pagination (cursor from page N only); live board → selected match deep-dive.
|
|
43
|
-
|
|
44
|
-
Prefer server-side fan-out inside composites (partial[] recovery) over agent N+1.
|
|
45
|
-
|
|
46
|
-
## Pagination
|
|
47
|
-
|
|
48
|
-
Lists use pagination: { limit, offset?, total?, hasMore, nextCursor, prevCursor }. Default limit 20, max 50. Loop with cursor: pagination.nextCursor and the same filters. Never invent cursors. Empty list is ok:true with items:[].
|
|
49
|
-
|
|
50
|
-
## Errors
|
|
51
|
-
|
|
52
|
-
ok:false → read error.code (VALIDATION, NOT_FOUND, UNSUPPORTED_GAME, RATE_LIMIT, UNAUTHORIZED, UPSTREAM, PATH_NOT_ALLOWED, NOT_IMPLEMENTED). Follow error.recover[]. Retry only when retryable.
|
|
53
|
-
|
|
54
|
-
ok:true with partial[] → use successful sections; do not treat partial as total fail.
|
|
55
|
-
ok:true with meta.warnings[] → filters or depth degraded; do not assume ignored filters applied.
|
|
56
|
-
resolve_entity ambiguity is soft: ok:true with data.needsDisambiguation and data.candidates — pick a candidate; do not wait for AMBIGUOUS_ENTITY.
|
|
57
|
-
|
|
58
|
-
## Recipes
|
|
59
|
-
|
|
60
|
-
Live board: api_health (optional) → live_matches → match_summary for selected matchId.
|
|
61
|
-
UFC empty live (count=0): read section.note / emptyReason / health (workerAlive, lag) / supervisor / nextCard — do not claim "API offline" without workerAlive/lag; never invent matchups from supervisor shells.
|
|
62
|
-
Team page: resolve_entity {type:team} → team_profile.
|
|
63
|
-
Player card: resolve_entity {type:player|fighter} → player_profile.
|
|
64
|
-
Fight night / event card: resolve_entity {type:event} → event_card {includeMatches:true} → match_preview for a featured bout.
|
|
65
|
-
Match preview (named sides): resolve_entity each side (optional) → match_preview {teamA, teamB}.
|
|
66
|
-
App scaffold: list_capabilities ∥ api_health ∥ live_matches, then one composite per screen.
|
|
67
|
-
|
|
68
|
-
## Resources
|
|
69
|
-
|
|
70
|
-
- cito://llms.txt — product context when available
|
|
71
|
-
- cito://capabilities — catalog summary
|
|
72
|
-
- cito://openapi.json — optional public OpenAPI fetch for typed clients
|
|
73
|
-
|
|
74
|
-
## What not to do
|
|
75
|
-
|
|
76
|
-
- Invent match/player/team IDs from memory.
|
|
77
|
-
- Parallel-paginate the same list with different cursors.
|
|
78
|
-
- N+1 match_details for every live row.
|
|
79
|
-
- Retry UNSUPPORTED_GAME or VALIDATION unchanged.
|
|
80
|
-
- Ship production traffic through MCP.
|
|
81
|
-
- Flood odds/timelines when a summary answers the question.
|
|
1
|
+
export const SERVER_INSTRUCTIONS = `# Cito MCP — agent operating manual
|
|
2
|
+
|
|
3
|
+
You are connected to Cito esports data (read-only). Prefer curated outcome tools over raw REST. Production apps must call Cito REST with the user's API key; use this MCP to design, prototype, and resolve IDs — not as a multi-tenant runtime bus.
|
|
4
|
+
|
|
5
|
+
## Hard rules
|
|
6
|
+
|
|
7
|
+
1. **Never invent IDs.** Do not guess matchId, gameId, playerId, team slug, eventId, or boutId. Always obtain IDs from a prior tool result (resolve, live, schedule, search, or list). If the user gives a name ("T1", "s1mple", "UFC 300"), call resolve_entity/search_entities first.
|
|
8
|
+
2. **Resolve before deep.** Name → ID/slug → summary/page → deep stats. Skip resolve only when the user already provided a Cito ID/slug.
|
|
9
|
+
3. **One screen, one composite.** Prefer a page/summary tool over stitching 4–6 thin GETs.
|
|
10
|
+
4. **Honor the envelope.** Parse ok, data, pagination, partial, error, meta.rateLimit, meta.entities. Partial section failures are not total failures — use successful sections.
|
|
11
|
+
5. **Read-only.** All curated tools are safe to retry except where error.retryable is false for bad args / entitlement.
|
|
12
|
+
|
|
13
|
+
## Game parameter
|
|
14
|
+
|
|
15
|
+
game enum (unless a tool documents otherwise): lol | cs2 | dota2 | ufc | cod | tennis | all
|
|
16
|
+
|
|
17
|
+
- Default when the user named one title: that game. Default for "what's live?" / multi-title: omit game or all on tools that accept it.
|
|
18
|
+
- On UNSUPPORTED_GAME / 403 for a title: stop retrying that game; call api_health; report plan gaps.
|
|
19
|
+
- Fortnite and other titles may appear via call_api/resources; do not assume they are in the curated enum until list_capabilities says so.
|
|
20
|
+
- RAF (Real American Freestyle wrestling) and BKFC (Bare Knuckle Fighting Championship) are NOT on the game enum: use the combat_* tools with league raf|bkfc (combat_events, combat_card, combat_bout, combat_athlete, combat_h2h, combat_rankings), or call_api /raf/... and /bkfc/.... RAF scores per PERIOD (wrestling points, takedowns); BKFC per ROUND (punches landed/thrown, knockdowns). Never mix either into UFC answers.
|
|
21
|
+
- MMA archives: Bellator, PRIDE, Strikeforce, WEC, DREAM, EliteXC, Affliction, plus /mma for a fighter's whole non-UFC career (recordByPromotion). combat_* tools with league bellator|pride|strikeforce|wec|dream|elitexc|affliction|mma, or call_api /{league}/.... Closed archives (1997-2017) with official per-ROUND stats in UFC's shape ("13 of 18", round 0 = whole fight); no live, rankings or upcoming cards (combat_card "last" = most recent card). Fighters share their UFC slug (ufcFighterSlug). Never mix these into UFC answers. Their stats predate the Free/Starter window: HISTORY_WINDOW_EXCEEDED means Pro, not a bad key.
|
|
22
|
+
- PFL (Professional Fighters League): combat_* with league pfl, or call_api /pfl/.... Every card since 2018, upcoming cards (combat_events when upcoming, combat_card "next") and live fight status (/pfl/live: bout status "live" with live.round / live.clock). Results only (winner, method, round, time): no strike or round stats exist for PFL. league mma includes PFL. Never mix into UFC answers.
|
|
23
|
+
- OKTAGON MMA: combat_* with league oktagon, or call_api /oktagon/.... Every card since Oktagon 1 (2016), upcoming cards and live fight status. Per-ROUND stats on most bouts, LANDED ONLY: significant/total strikes, head/body/leg and takedowns as plain counts (landed.*), plus submission attempts; attempts, accuracy, knockdowns and control time are null because the source does not log them (each bout's statsCoverage says which fields exist). Fighters share their UFC slug when proven. league mma includes OKTAGON. Never mix into UFC answers.
|
|
24
|
+
- UFC includes DWCS and Road to UFC (2022-2026, all 19 cards with official round stats; slugs like road-to-ufc-2-7, "Road to UFC 2.7").
|
|
25
|
+
|
|
26
|
+
## Preferred tool order
|
|
27
|
+
|
|
28
|
+
1. Unsure → list_capabilities (filter by game or job). Optional: api_health for key/tier/included games.
|
|
29
|
+
2. Name without ID → resolve_entity (best match) or search_entities (browse). Reuse returned id/slug.
|
|
30
|
+
3. Live / upcoming → live_matches; upcoming_schedule.
|
|
31
|
+
4. Match UI / recap → match_summary first. match_details only for timelines, full maps, demos, live state.
|
|
32
|
+
5. Team page → team_profile. Player form → player_profile. Tables/ranks → standings.
|
|
33
|
+
6. Pre-match → match_preview. Rivalry → head_to_head. Event / fight-night card → event_card.
|
|
34
|
+
7. Escape hatch → call_api (allowlisted path prefixes; prefer GET). Prefer curated tools.
|
|
35
|
+
|
|
36
|
+
Mnemonic: resolve → live/schedule → summary → deep.
|
|
37
|
+
|
|
38
|
+
## Parallel vs sequence
|
|
39
|
+
|
|
40
|
+
Parallel-safe: independent reads (team_profile A ∥ team_profile B; live_matches ∥ api_health). Cap ~3–5 concurrent agent tools; check meta.rateLimit.remaining.
|
|
41
|
+
|
|
42
|
+
Serial required: resolve → detail; pagination (cursor from page N only); live board → selected match deep-dive.
|
|
43
|
+
|
|
44
|
+
Prefer server-side fan-out inside composites (partial[] recovery) over agent N+1.
|
|
45
|
+
|
|
46
|
+
## Pagination
|
|
47
|
+
|
|
48
|
+
Lists use pagination: { limit, offset?, total?, hasMore, nextCursor, prevCursor }. Default limit 20, max 50. Loop with cursor: pagination.nextCursor and the same filters. Never invent cursors. Empty list is ok:true with items:[].
|
|
49
|
+
|
|
50
|
+
## Errors
|
|
51
|
+
|
|
52
|
+
ok:false → read error.code (VALIDATION, NOT_FOUND, UNSUPPORTED_GAME, RATE_LIMIT, UNAUTHORIZED, UPSTREAM, PATH_NOT_ALLOWED, NOT_IMPLEMENTED). Follow error.recover[]. Retry only when retryable. RATE_LIMIT means cito-mcp already paced and retried: wait error.retryAfterMs before one more try, call tools one at a time, and never retry when retryable is false (monthly allowance used up: relay the upgrade link).
|
|
53
|
+
|
|
54
|
+
ok:true with partial[] → use successful sections; do not treat partial as total fail.
|
|
55
|
+
ok:true with meta.warnings[] → filters or depth degraded; do not assume ignored filters applied.
|
|
56
|
+
resolve_entity ambiguity is soft: ok:true with data.needsDisambiguation and data.candidates — pick a candidate; do not wait for AMBIGUOUS_ENTITY.
|
|
57
|
+
|
|
58
|
+
## Recipes
|
|
59
|
+
|
|
60
|
+
Live board: api_health (optional) → live_matches → match_summary for selected matchId.
|
|
61
|
+
UFC empty live (count=0): read section.note / emptyReason / health (workerAlive, lag) / supervisor / nextCard — do not claim "API offline" without workerAlive/lag; never invent matchups from supervisor shells.
|
|
62
|
+
Team page: resolve_entity {type:team} → team_profile.
|
|
63
|
+
Player card: resolve_entity {type:player|fighter} → player_profile.
|
|
64
|
+
Fight night / event card: resolve_entity {type:event} → event_card {includeMatches:true} → match_preview for a featured bout.
|
|
65
|
+
Match preview (named sides): resolve_entity each side (optional) → match_preview {teamA, teamB}.
|
|
66
|
+
App scaffold: list_capabilities ∥ api_health ∥ live_matches, then one composite per screen.
|
|
67
|
+
|
|
68
|
+
## Resources
|
|
69
|
+
|
|
70
|
+
- cito://llms.txt — product context when available
|
|
71
|
+
- cito://capabilities — catalog summary
|
|
72
|
+
- cito://openapi.json — optional public OpenAPI fetch for typed clients
|
|
73
|
+
|
|
74
|
+
## What not to do
|
|
75
|
+
|
|
76
|
+
- Invent match/player/team IDs from memory.
|
|
77
|
+
- Parallel-paginate the same list with different cursors.
|
|
78
|
+
- N+1 match_details for every live row.
|
|
79
|
+
- Retry UNSUPPORTED_GAME or VALIDATION unchanged.
|
|
80
|
+
- Ship production traffic through MCP.
|
|
81
|
+
- Flood odds/timelines when a summary answers the question.
|
|
82
82
|
`;
|
package/dist/pacing.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export const MAX_CONCURRENT_PER_KEY = 4;
|
|
2
|
+
export const CONSTRAINED_CONCURRENT_PER_KEY = 2;
|
|
3
|
+
export const CONSTRAINED_WINDOW_MS = 60_000;
|
|
4
|
+
export const MAX_WAIT_MS = 20_000;
|
|
5
|
+
export const MAX_RATE_LIMIT_RETRIES = 2;
|
|
6
|
+
export const MAX_UPSTREAM_RETRIES = 1;
|
|
7
|
+
export const UPSTREAM_BACKOFF_MS = 750;
|
|
8
|
+
export const CALL_BUDGET_MS = 45_000;
|
|
9
|
+
const DEFAULT_RETRY_AFTER_MS = 2_000;
|
|
10
|
+
const WINDOW_MS = 60_000;
|
|
11
|
+
const IDLE_EVICT_MS = 10 * 60_000;
|
|
12
|
+
const MAX_TRACKED_KEYS = 2_000;
|
|
13
|
+
export const UPGRADE_URL = 'https://citoapi.com/dashboard/billing/plans';
|
|
14
|
+
const realClock = {
|
|
15
|
+
now: () => Date.now(),
|
|
16
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))),
|
|
17
|
+
};
|
|
18
|
+
let clock = realClock;
|
|
19
|
+
let jitter = () => Math.floor(Math.random() * 250);
|
|
20
|
+
export function getClock() {
|
|
21
|
+
return clock;
|
|
22
|
+
}
|
|
23
|
+
export function __setClockForTests(fake, jitterMs) {
|
|
24
|
+
clock = fake ?? realClock;
|
|
25
|
+
jitter = jitterMs ?? (fake ? () => 0 : () => Math.floor(Math.random() * 250));
|
|
26
|
+
}
|
|
27
|
+
export function jitterMs() {
|
|
28
|
+
return jitter();
|
|
29
|
+
}
|
|
30
|
+
export function newCallBudget(budgetMs = CALL_BUDGET_MS) {
|
|
31
|
+
return { deadline: clock.now() + budgetMs, retries: 0, waitedMs: 0 };
|
|
32
|
+
}
|
|
33
|
+
function rec(value) {
|
|
34
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
35
|
+
}
|
|
36
|
+
function num(value) {
|
|
37
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
|
|
38
|
+
return Number.isFinite(n) ? n : null;
|
|
39
|
+
}
|
|
40
|
+
export function parseRetryAfter(raw, nowMs = clock.now()) {
|
|
41
|
+
if (raw == null)
|
|
42
|
+
return null;
|
|
43
|
+
const value = String(raw).trim();
|
|
44
|
+
if (!value)
|
|
45
|
+
return null;
|
|
46
|
+
if (/^\d+(\.\d+)?$/.test(value))
|
|
47
|
+
return Math.round(Number(value) * 1000);
|
|
48
|
+
const at = Date.parse(value);
|
|
49
|
+
if (Number.isFinite(at))
|
|
50
|
+
return Math.max(0, at - nowMs);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
export function classify429(body, headers, nowMs = clock.now()) {
|
|
54
|
+
const root = rec(body);
|
|
55
|
+
const err = rec(root?.error);
|
|
56
|
+
const apiCode = String(err?.code ?? root?.errorCode ?? root?.code ?? 'RATE_LIMIT_EXCEEDED');
|
|
57
|
+
const kind = apiCode === 'MONTHLY_LIMIT_REACHED'
|
|
58
|
+
? 'monthly'
|
|
59
|
+
: apiCode === 'TOO_MANY_CONCURRENT_REQUESTS'
|
|
60
|
+
? 'concurrency'
|
|
61
|
+
: 'per_minute';
|
|
62
|
+
const fromHeader = parseRetryAfter(headers?.get('retry-after') ?? null, nowMs);
|
|
63
|
+
const bodySeconds = num(err?.retry_after_seconds) ?? num(rec(err?.limit)?.retryAfterSeconds) ?? num(root?.retry_after_seconds);
|
|
64
|
+
const retryAfterMs = fromHeader ?? (bodySeconds != null ? Math.round(bodySeconds * 1000) : null);
|
|
65
|
+
const upgradeRaw = err?.upgrade;
|
|
66
|
+
const upgradeUrl = (typeof upgradeRaw === 'string' && upgradeRaw) ||
|
|
67
|
+
(typeof rec(upgradeRaw)?.url === 'string' && rec(upgradeRaw).url) ||
|
|
68
|
+
(typeof err?.upgradeUrl === 'string' && err.upgradeUrl) ||
|
|
69
|
+
(kind === 'concurrency' ? null : UPGRADE_URL);
|
|
70
|
+
const message = (typeof err?.message === 'string' && err.message) ||
|
|
71
|
+
(typeof root?.error === 'string' && root.error) ||
|
|
72
|
+
(typeof root?.message === 'string' && root.message) ||
|
|
73
|
+
null;
|
|
74
|
+
return {
|
|
75
|
+
kind,
|
|
76
|
+
apiCode,
|
|
77
|
+
message,
|
|
78
|
+
retryAfterMs,
|
|
79
|
+
upgradeUrl,
|
|
80
|
+
plan: typeof err?.plan === 'string' ? err.plan : null,
|
|
81
|
+
limitPerMinute: num(err?.limit_per_minute),
|
|
82
|
+
burstPerMinute: num(err?.burst_per_minute),
|
|
83
|
+
local: false,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export class KeyPacer {
|
|
87
|
+
inFlight = 0;
|
|
88
|
+
waiters = [];
|
|
89
|
+
remaining = null;
|
|
90
|
+
windowEndsAt = 0;
|
|
91
|
+
blockedUntil = 0;
|
|
92
|
+
constrainedUntil = 0;
|
|
93
|
+
lastUsed = 0;
|
|
94
|
+
last429 = null;
|
|
95
|
+
maxConcurrent(now) {
|
|
96
|
+
return now < this.constrainedUntil ? CONSTRAINED_CONCURRENT_PER_KEY : MAX_CONCURRENT_PER_KEY;
|
|
97
|
+
}
|
|
98
|
+
async slot() {
|
|
99
|
+
for (;;) {
|
|
100
|
+
if (this.inFlight < this.maxConcurrent(clock.now())) {
|
|
101
|
+
this.inFlight += 1;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await new Promise((resolve) => this.waiters.push(resolve));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
releaseSlot() {
|
|
108
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
109
|
+
const next = this.waiters.shift();
|
|
110
|
+
if (next)
|
|
111
|
+
next();
|
|
112
|
+
}
|
|
113
|
+
async acquire(budget) {
|
|
114
|
+
await this.slot();
|
|
115
|
+
for (;;) {
|
|
116
|
+
const now = clock.now();
|
|
117
|
+
this.lastUsed = now;
|
|
118
|
+
if (this.remaining !== null && now >= this.windowEndsAt)
|
|
119
|
+
this.remaining = null;
|
|
120
|
+
let waitMs = 0;
|
|
121
|
+
let reason = 'blocked';
|
|
122
|
+
if (this.blockedUntil > now) {
|
|
123
|
+
waitMs = this.blockedUntil - now;
|
|
124
|
+
}
|
|
125
|
+
else if (this.remaining !== null && this.remaining <= 0) {
|
|
126
|
+
waitMs = this.windowEndsAt - now + 250;
|
|
127
|
+
reason = 'window';
|
|
128
|
+
}
|
|
129
|
+
if (waitMs <= 0)
|
|
130
|
+
break;
|
|
131
|
+
if (waitMs > MAX_WAIT_MS + 1_000 || now + waitMs >= budget.deadline) {
|
|
132
|
+
this.releaseSlot();
|
|
133
|
+
return { ok: false, waitMs, reason };
|
|
134
|
+
}
|
|
135
|
+
await clock.sleep(waitMs);
|
|
136
|
+
budget.waitedMs += waitMs;
|
|
137
|
+
}
|
|
138
|
+
if (this.remaining !== null)
|
|
139
|
+
this.remaining -= 1;
|
|
140
|
+
let released = false;
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
release: () => {
|
|
144
|
+
if (released)
|
|
145
|
+
return;
|
|
146
|
+
released = true;
|
|
147
|
+
this.releaseSlot();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
observe(status, headers, limited) {
|
|
152
|
+
const now = clock.now();
|
|
153
|
+
const remainingRaw = headers?.get('x-ratelimit-remaining-minute');
|
|
154
|
+
const remaining = num(remainingRaw);
|
|
155
|
+
if (remaining !== null) {
|
|
156
|
+
this.remaining = Math.max(0, remaining - this.inFlight);
|
|
157
|
+
this.windowEndsAt = (Math.floor(now / WINDOW_MS) + 1) * WINDOW_MS;
|
|
158
|
+
}
|
|
159
|
+
if (status === 429 && limited && limited.kind !== 'monthly') {
|
|
160
|
+
this.last429 = limited;
|
|
161
|
+
const wait = limited.retryAfterMs ?? DEFAULT_RETRY_AFTER_MS;
|
|
162
|
+
this.blockedUntil = Math.max(this.blockedUntil, now + wait + jitter());
|
|
163
|
+
this.constrainedUntil = now + CONSTRAINED_WINDOW_MS;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const pacers = new Map();
|
|
168
|
+
export function pacerFor(apiKey) {
|
|
169
|
+
let pacer = pacers.get(apiKey);
|
|
170
|
+
if (!pacer) {
|
|
171
|
+
if (pacers.size >= MAX_TRACKED_KEYS)
|
|
172
|
+
evictIdle();
|
|
173
|
+
pacer = new KeyPacer();
|
|
174
|
+
pacers.set(apiKey, pacer);
|
|
175
|
+
}
|
|
176
|
+
return pacer;
|
|
177
|
+
}
|
|
178
|
+
function evictIdle() {
|
|
179
|
+
const now = clock.now();
|
|
180
|
+
for (const [key, p] of pacers) {
|
|
181
|
+
if (p.inFlight === 0 && now - p.lastUsed > IDLE_EVICT_MS)
|
|
182
|
+
pacers.delete(key);
|
|
183
|
+
}
|
|
184
|
+
if (pacers.size >= MAX_TRACKED_KEYS) {
|
|
185
|
+
for (const [key, p] of pacers) {
|
|
186
|
+
if (pacers.size < MAX_TRACKED_KEYS)
|
|
187
|
+
break;
|
|
188
|
+
if (p.inFlight === 0)
|
|
189
|
+
pacers.delete(key);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export function __resetPacersForTests() {
|
|
194
|
+
pacers.clear();
|
|
195
|
+
}
|
package/dist/tools/types.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ENVELOPE_OUTPUT_SCHEMA, toMcpResult } from '../envelope.js';
|
|
1
|
+
import { ENVELOPE_OUTPUT_SCHEMA, applyRateLimitContext, toMcpResult } from '../envelope.js';
|
|
2
|
+
import { newCallBudget } from '../pacing.js';
|
|
2
3
|
export const READ_TOOL_ANNOTATIONS = {
|
|
3
4
|
readOnlyHint: true,
|
|
4
5
|
destructiveHint: false,
|
|
@@ -138,11 +139,19 @@ export async function runTool(def, args, ctx) {
|
|
|
138
139
|
],
|
|
139
140
|
}));
|
|
140
141
|
}
|
|
141
|
-
const
|
|
142
|
+
const budget = ctx.budget ?? newCallBudget();
|
|
143
|
+
const result = await def.handler(args ?? {}, { ...ctx, budget });
|
|
142
144
|
if (result && typeof result === 'object' && 'content' in result) {
|
|
143
|
-
|
|
145
|
+
const mcp = result;
|
|
146
|
+
const structured = mcp.structuredContent;
|
|
147
|
+
if (budget.rateLimit && structured && typeof structured === 'object' && 'ok' in structured) {
|
|
148
|
+
const enriched = applyRateLimitContext(structured, budget.rateLimit, budget.retries);
|
|
149
|
+
if (enriched !== structured)
|
|
150
|
+
return toMcpResult(enriched);
|
|
151
|
+
}
|
|
152
|
+
return mcp;
|
|
144
153
|
}
|
|
145
|
-
return toMcpResult(result);
|
|
154
|
+
return toMcpResult(applyRateLimitContext(result, budget.rateLimit, budget.retries));
|
|
146
155
|
}
|
|
147
156
|
catch (error) {
|
|
148
157
|
const { errorEnvelope, toMcpResult: toResult } = await import('../envelope.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cito-mcp",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Standalone MCP server for the Cito esports and sports API — curated outcome tools for agents (live scoreboards, round economy, opening duels, clutches, vetoes, rosters, tennis, mma).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://cito.gg",
|