cito-mcp 0.1.0 → 0.2.2

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/dist/client.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Authenticated Cito REST client for curated tools.
3
+ * Auth: CITO_API_KEY → x-api-key. Logs never include the key.
4
+ */
5
+ export const DEFAULT_API_BASE = 'https://api.citoapi.com/api/v1';
6
+ export const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024;
7
+ /** stderr ONLY — stdout is the MCP stdio channel. */
8
+ export function log(message) {
9
+ console.error(`[cito-mcp] ${message}`);
10
+ }
11
+ export function authHeaders(apiKey, extra) {
12
+ return {
13
+ 'x-api-key': apiKey,
14
+ accept: 'application/json',
15
+ ...extra,
16
+ };
17
+ }
18
+ export function parseRateLimitHeaders(headers) {
19
+ if (!headers)
20
+ return {};
21
+ const limitRaw = headers.get('x-ratelimit-limit') ?? headers.get('ratelimit-limit');
22
+ const remainingRaw = headers.get('x-ratelimit-remaining') ?? headers.get('ratelimit-remaining');
23
+ const resetRaw = headers.get('x-ratelimit-reset') ?? headers.get('ratelimit-reset');
24
+ const tier = headers.get('x-cito-tier') ?? headers.get('x-plan-tier');
25
+ return {
26
+ tier: tier ?? null,
27
+ limit: limitRaw != null && limitRaw !== '' ? Number(limitRaw) : null,
28
+ remaining: remainingRaw != null && remainingRaw !== '' ? Number(remainingRaw) : null,
29
+ resetAt: resetRaw ?? null,
30
+ };
31
+ }
32
+ /**
33
+ * Build absolute URL under api base.
34
+ * path must start with `/`. Query values skip null/undefined.
35
+ * Arrays become repeated query keys.
36
+ */
37
+ export function buildUrl(baseUrl, path, query) {
38
+ if (!path.startsWith('/')) {
39
+ throw new Error(`path must start with /: ${path}`);
40
+ }
41
+ const base = baseUrl.replace(/\/+$/, '');
42
+ // LoL-style absolute API paths: if path already includes /api/v1, use origin only
43
+ let originBase = base;
44
+ if (path.startsWith('/api/v1/') && base.endsWith('/api/v1')) {
45
+ originBase = base.slice(0, -'/api/v1'.length) || base;
46
+ }
47
+ const qs = new URLSearchParams();
48
+ if (query) {
49
+ for (const [key, value] of Object.entries(query)) {
50
+ if (value === undefined || value === null)
51
+ continue;
52
+ if (Array.isArray(value)) {
53
+ for (const item of value)
54
+ qs.append(key, String(item));
55
+ }
56
+ else if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
57
+ qs.append(key, String(value));
58
+ }
59
+ else {
60
+ qs.append(key, JSON.stringify(value));
61
+ }
62
+ }
63
+ }
64
+ const q = qs.toString();
65
+ return `${originBase}${path}${q ? `?${q}` : ''}`;
66
+ }
67
+ /** Pretty-print JSON; truncate oversized bodies with an agent-actionable note. */
68
+ export function present(text, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
69
+ let out = text;
70
+ try {
71
+ out = JSON.stringify(JSON.parse(text), null, 2);
72
+ }
73
+ catch {
74
+ // Not JSON — return as-is.
75
+ }
76
+ if (out.length > maxBytes) {
77
+ return (`${out.slice(0, maxBytes)}\n\n` +
78
+ `[cito-mcp] Response truncated at ${maxBytes} bytes (full body was ${out.length}). ` +
79
+ `Narrow the result with query parameters (e.g. smaller limit, date range, or an id filter) and retry.`);
80
+ }
81
+ return out;
82
+ }
83
+ export async function fetchJson(ctx, path, opts) {
84
+ const fetcher = ctx.fetchImpl ?? fetch;
85
+ const method = (opts?.method ?? 'GET').toUpperCase();
86
+ const url = buildUrl(ctx.baseUrl, path, opts?.query);
87
+ const headers = authHeaders(ctx.apiKey);
88
+ let body;
89
+ if (opts?.body !== undefined && method !== 'GET' && method !== 'HEAD') {
90
+ headers['content-type'] = 'application/json';
91
+ body = JSON.stringify(opts.body);
92
+ }
93
+ let response;
94
+ try {
95
+ response = await fetcher(url, { method, headers, body });
96
+ }
97
+ catch (error) {
98
+ return {
99
+ ok: false,
100
+ status: 0,
101
+ data: { message: error.message },
102
+ headers: {},
103
+ text: error.message,
104
+ url,
105
+ };
106
+ }
107
+ const text = await response.text();
108
+ let data = null;
109
+ try {
110
+ data = text ? JSON.parse(text) : null;
111
+ }
112
+ catch {
113
+ data = text;
114
+ }
115
+ return {
116
+ ok: response.ok,
117
+ status: response.status,
118
+ data,
119
+ headers: parseRateLimitHeaders(response.headers),
120
+ text,
121
+ url,
122
+ };
123
+ }
124
+ export async function parallelGet(ctx, paths) {
125
+ const results = await Promise.all(paths.map(async ({ key, path, query }) => {
126
+ const result = await fetchJson(ctx, path, { query });
127
+ return [key, result];
128
+ }));
129
+ return Object.fromEntries(results);
130
+ }
131
+ /** Best-effort row extraction across Cito response shapes. */
132
+ export function extractRows(data) {
133
+ if (data == null)
134
+ return [];
135
+ if (Array.isArray(data))
136
+ return data;
137
+ if (typeof data !== 'object')
138
+ return [];
139
+ const obj = data;
140
+ // Order matters: UFC /ufc/live nests real bouts under liveBouts while also
141
+ // shipping supervisor `events` (no fighter names). Prefer bout-like keys first.
142
+ for (const key of [
143
+ 'data',
144
+ 'liveBouts',
145
+ 'bouts',
146
+ 'matches',
147
+ 'items',
148
+ 'results',
149
+ 'teams',
150
+ 'players',
151
+ 'fighters',
152
+ 'tournaments',
153
+ 'leagues',
154
+ 'orgs',
155
+ 'events',
156
+ 'rankings',
157
+ 'standings',
158
+ 'rows',
159
+ ]) {
160
+ if (Array.isArray(obj[key]))
161
+ return obj[key];
162
+ }
163
+ // Nested data.matches / data.items
164
+ if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) {
165
+ return extractRows(obj.data);
166
+ }
167
+ return [];
168
+ }
169
+ export function asRecord(value) {
170
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
171
+ return value;
172
+ }
173
+ return null;
174
+ }
175
+ export function pickString(...values) {
176
+ for (const v of values) {
177
+ if (typeof v === 'string' && v.length > 0)
178
+ return v;
179
+ if (typeof v === 'number' && Number.isFinite(v))
180
+ return String(v);
181
+ }
182
+ return undefined;
183
+ }
184
+ export function clampInt(value, fallback, min, max) {
185
+ const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
186
+ if (!Number.isFinite(n))
187
+ return fallback;
188
+ return Math.min(max, Math.max(min, Math.floor(n)));
189
+ }
190
+ export function encodeCursor(payload) {
191
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
192
+ }
193
+ export function decodeCursor(cursor) {
194
+ if (!cursor)
195
+ return null;
196
+ try {
197
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
198
+ const parsed = JSON.parse(json);
199
+ return parsed && typeof parsed === 'object' ? parsed : null;
200
+ }
201
+ catch {
202
+ return null;
203
+ }
204
+ }
205
+ export function gameNotIncludedHint(data) {
206
+ const text = typeof data === 'string' ? data : JSON.stringify(data ?? '');
207
+ return /GAME_NOT_INCLUDED|not included|plan does not|upgrade/i.test(text);
208
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Canonical JSON envelope for every curated cito-mcp tool response.
3
+ * Agents branch on `ok` / `error.code` / `partial[]` without scraping prose.
4
+ */
5
+ export function isOk(e) {
6
+ return e.ok === true;
7
+ }
8
+ export function isError(e) {
9
+ return e.ok === false;
10
+ }
11
+ export function hasPartial(e) {
12
+ return e.ok === true && Array.isArray(e.partial) && e.partial.length > 0;
13
+ }
14
+ export const DEFAULT_RECOVER = {
15
+ NOT_FOUND: [
16
+ 'Call resolve_entity or search_entities with a name/query from the user',
17
+ 'Retry this tool with an id/slug from the results',
18
+ ],
19
+ AMBIGUOUS_ENTITY: [
20
+ 'Disambiguate using an exact id or slug from the candidates list',
21
+ 'Retry with that single identifier',
22
+ ],
23
+ RATE_LIMIT: [
24
+ 'Wait until the rate limit resets (see meta.rateLimit / message)',
25
+ 'Retry once; reduce parallel tool calls and prefer composites',
26
+ ],
27
+ UNAUTHORIZED: [
28
+ 'Verify CITO_API_KEY is set and valid',
29
+ 'Call api_health to confirm tier and included games',
30
+ ],
31
+ UPSTREAM: [
32
+ 'Retry this tool once',
33
+ 'If it still fails, narrow params (limit, date range) or use a smaller tool',
34
+ ],
35
+ VALIDATION: [
36
+ 'Fix arguments using the error message and tool schema',
37
+ 'Re-check required params, enums, and exclusive id/slug groups',
38
+ ],
39
+ UNSUPPORTED_GAME: [
40
+ 'Use a supported game enum value for this tool (lol|cs2|dota2|cod|ufc)',
41
+ 'Call api_health to see which games your plan includes',
42
+ ],
43
+ PATH_NOT_ALLOWED: [
44
+ 'Use only allowlisted path prefixes: /health /lol /cs2 /dota2 /cod /ufc /fortnite',
45
+ 'Prefer a curated tool from list_capabilities when one covers the outcome',
46
+ ],
47
+ NOT_IMPLEMENTED: [
48
+ 'Call list_capabilities for an alternate tool that covers this job',
49
+ 'Use call_api only if you know a valid REST path for the gap',
50
+ ],
51
+ };
52
+ export const MAX_ENVELOPE_CHARS = 100_000;
53
+ export const DEFAULT_PAGE_LIMIT = 20;
54
+ export const MAX_PAGE_LIMIT = 50;
55
+ export function mapHttpToCode(status, hints) {
56
+ if (status === 404)
57
+ return 'NOT_FOUND';
58
+ if (status === 429)
59
+ return 'RATE_LIMIT';
60
+ if (status === 401)
61
+ return 'UNAUTHORIZED';
62
+ if (status === 403) {
63
+ return hints?.gameNotIncluded ? 'UNSUPPORTED_GAME' : 'UNAUTHORIZED';
64
+ }
65
+ if (status === 400 && hints?.ambiguous)
66
+ return 'AMBIGUOUS_ENTITY';
67
+ if (status === 400)
68
+ return 'VALIDATION';
69
+ if (status >= 500 || status === 0)
70
+ return 'UPSTREAM';
71
+ return 'UPSTREAM';
72
+ }
73
+ export function newRequestId() {
74
+ return `mcp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
75
+ }
76
+ export function successEnvelope(args) {
77
+ const meta = {
78
+ game: args.game,
79
+ source: args.source,
80
+ fetchedAt: args.fetchedAt ?? new Date().toISOString(),
81
+ };
82
+ if (args.requestId)
83
+ meta.requestId = args.requestId;
84
+ if (args.tookMs !== undefined)
85
+ meta.tookMs = args.tookMs;
86
+ if (args.upstreamCalls !== undefined)
87
+ meta.upstreamCalls = args.upstreamCalls;
88
+ if (args.rateLimit)
89
+ meta.rateLimit = args.rateLimit;
90
+ if (args.warnings?.length)
91
+ meta.warnings = args.warnings;
92
+ if (args.entities)
93
+ meta.entities = args.entities;
94
+ const out = {
95
+ ok: true,
96
+ data: args.data,
97
+ meta,
98
+ };
99
+ if (args.pagination)
100
+ out.pagination = args.pagination;
101
+ if (args.partial?.length)
102
+ out.partial = args.partial;
103
+ if (args.truncated)
104
+ out.truncated = args.truncated;
105
+ return out;
106
+ }
107
+ export function errorEnvelope(args) {
108
+ const retryable = args.retryable ??
109
+ (args.code === 'RATE_LIMIT' || args.code === 'UPSTREAM');
110
+ const meta = {
111
+ game: args.game,
112
+ source: args.source,
113
+ fetchedAt: args.fetchedAt ?? new Date().toISOString(),
114
+ };
115
+ if (args.requestId)
116
+ meta.requestId = args.requestId;
117
+ if (args.tookMs !== undefined)
118
+ meta.tookMs = args.tookMs;
119
+ if (args.upstreamCalls !== undefined)
120
+ meta.upstreamCalls = args.upstreamCalls;
121
+ if (args.rateLimit)
122
+ meta.rateLimit = args.rateLimit;
123
+ const error = {
124
+ code: args.code,
125
+ message: args.message,
126
+ recover: args.recover ?? DEFAULT_RECOVER[args.code],
127
+ retryable,
128
+ };
129
+ if (args.httpStatus !== undefined)
130
+ error.httpStatus = args.httpStatus;
131
+ if (args.retryAfterMs !== undefined)
132
+ error.retryAfterMs = args.retryAfterMs;
133
+ if (args.hint)
134
+ error.hint = args.hint;
135
+ if (args.details)
136
+ error.details = args.details;
137
+ return {
138
+ ok: false,
139
+ data: null,
140
+ meta,
141
+ error,
142
+ };
143
+ }
144
+ export function partialFromRejection(section, err) {
145
+ const code = err.code ?? 'UPSTREAM';
146
+ return {
147
+ section,
148
+ ok: false,
149
+ code,
150
+ message: err.message ?? `Section "${section}" failed`,
151
+ recover: err.recover ?? DEFAULT_RECOVER[code],
152
+ ...(err.httpStatus !== undefined ? { httpStatus: err.httpStatus } : {}),
153
+ };
154
+ }
155
+ /**
156
+ * Enforce MAX_ENVELOPE_CHARS with structural shrink (never mid-slice JSON).
157
+ * Prefers trimming data.items when present.
158
+ */
159
+ export function sealEnvelope(envelope) {
160
+ let text = JSON.stringify(envelope);
161
+ if (text.length <= MAX_ENVELOPE_CHARS)
162
+ return envelope;
163
+ if (!envelope.ok)
164
+ return envelope;
165
+ const data = envelope.data;
166
+ if (data && Array.isArray(data.items)) {
167
+ let items = data.items;
168
+ const from = items.length;
169
+ while (items.length > 1 && JSON.stringify({ ...envelope, data: { ...data, items } }).length > MAX_ENVELOPE_CHARS) {
170
+ items = items.slice(0, Math.max(1, Math.floor(items.length / 2)));
171
+ }
172
+ const sealed = {
173
+ ...envelope,
174
+ data: { ...data, items },
175
+ truncated: {
176
+ applied: true,
177
+ reason: 'max_envelope_chars',
178
+ maxChars: MAX_ENVELOPE_CHARS,
179
+ dropped: { itemsFrom: from, itemsTo: items.length },
180
+ recover: [
181
+ 'Pass a smaller limit or use pagination.cursor for the next page',
182
+ 'Request view=summary and avoid heavy include* flags',
183
+ ],
184
+ },
185
+ pagination: envelope.pagination
186
+ ? { ...envelope.pagination, hasMore: true }
187
+ : { limit: from, hasMore: true },
188
+ };
189
+ return sealed;
190
+ }
191
+ return {
192
+ ...envelope,
193
+ truncated: {
194
+ applied: true,
195
+ reason: 'max_envelope_chars',
196
+ maxChars: MAX_ENVELOPE_CHARS,
197
+ recover: [
198
+ 'Request view=summary and lower limits',
199
+ 'Drop optional include* flags (timeline, advanced, media)',
200
+ ],
201
+ },
202
+ };
203
+ }
204
+ export function toMcpResult(envelope) {
205
+ const sealed = sealEnvelope(envelope);
206
+ return {
207
+ content: [{ type: 'text', text: JSON.stringify(sealed, null, 2) }],
208
+ ...(sealed.ok ? {} : { isError: true }),
209
+ };
210
+ }