@aistupidlevel/mcp 0.0.0-stage → 0.1.1

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/server.js ADDED
@@ -0,0 +1,516 @@
1
+ /**
2
+ * The MCP server: tools over the ASL Data API (rankings, history, drift, incidents, providers,
3
+ * recommendations) and the ASL Smart Router (route preview, routed prompts, usage).
4
+ *
5
+ * Every Data API tool makes at most one request, and usually none: answers are cached, and model
6
+ * names are resolved from the cached leaderboard. Tools that need a key that is not configured
7
+ * answer with a tool error saying which key and where to get it.
8
+ */
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { z } from 'zod';
11
+ import { LINKS, VERSION, resolveConfig } from './config.js';
12
+ import { DataApi } from './data-api.js';
13
+ import { RouterApi, ROUTING_MODES } from './router-api.js';
14
+ import { ToolError } from './errors.js';
15
+ import { ago, footer, modelLabel, num, pct, truncate, usd, when } from './format.js';
16
+ const SUITE_LABEL = { hourly: 'coding', deep: 'reasoning', tooling: 'tool use', canary: 'canary' };
17
+ const suiteName = (s) => SUITE_LABEL[s] ?? s;
18
+ const PERIOD_LABEL = { '24h': '24-hour', '7d': '7-day', '1m': '30-day', latest: 'newest-run' };
19
+ const AXIS_LABEL = { codeQuality: 'code quality', spec: 'spec compliance' };
20
+ const axisName = (a) => AXIS_LABEL[a] ?? a.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
21
+ const PERIOD = z.enum(['24h', '7d', '1m', 'latest']);
22
+ const PERIOD_HELP = 'Score window: 24h, 7d or 1m (30-day) averages, or latest (the newest run). Omit it to use the key\'s default: ' +
23
+ '24h on a free Data API key, latest on a paid one. latest needs a paid key.';
24
+ const MODEL_HELP = 'The model: its numeric id, its name (for example claude-opus-5-5 or gpt-6-sol) or its display name. ' +
25
+ 'get_leaderboard lists the tracked models with their ids.';
26
+ const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
27
+ const INSTRUCTIONS = `AI Stupid Level (ASL, aistupidlevel.info) publishes benchmark scores for the major LLM APIs on coding, reasoning and tool use, with drift signals and incidents. These tools read its Data API and, with a router key, use its Smart Router.
28
+
29
+ - Start with get_leaderboard or get_recommendations; they also give the model ids the other tools take.
30
+ - Free Data API keys allow 10 requests a day and 1 a minute. Answers are cached for a while, so repeating a question is free, but avoid calling many different data tools in a row on a free key.
31
+ - preview_route shows which model the Smart Router would pick for a prompt and why, without calling it. ask_router sends the prompt through the router using the account's own provider keys (set sandbox: true for a mock answer that spends nothing).
32
+ - Cite aistupidlevel.info as the source when you repeat the data.`;
33
+ export function createServer(options = {}) {
34
+ const cfg = resolveConfig(options);
35
+ const data = new DataApi(cfg);
36
+ const router = new RouterApi(cfg);
37
+ const server = new McpServer({
38
+ name: 'aistupidlevel',
39
+ title: 'AI Stupid Level',
40
+ version: VERSION,
41
+ websiteUrl: LINKS.site,
42
+ description: 'LLM benchmark rankings, drift status and incidents from AI Stupid Level, plus its Smart Router.',
43
+ // No `sizes`: the 2025-11-25 spec makes it a string array, but clients on earlier MCP SDKs
44
+ // (mcp-proxy 5.x, which Glama's checks run through) validate it as a string and refuse to connect.
45
+ icons: [{ src: 'https://aistupidlevel.info/apple-touch-icon.png', mimeType: 'image/png' }],
46
+ }, { instructions: INSTRUCTIONS });
47
+ const run = (fn) => async (args) => {
48
+ try {
49
+ return { content: [{ type: 'text', text: await fn(args) }] };
50
+ }
51
+ catch (err) {
52
+ if (err instanceof ToolError)
53
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
54
+ cfg.log(`unexpected error: ${err?.stack ?? err}`);
55
+ return { isError: true, content: [{ type: 'text', text: `Unexpected error: ${err?.message ?? err}` }] };
56
+ }
57
+ };
58
+ const done = (body, r) => `${body}\n\n${footer(r, data.quota)}`;
59
+ /** The model's row in the newest leaderboard fetched so far (never makes a request). */
60
+ const cachedRow = (id) => {
61
+ const board = data.lastBoard;
62
+ const row = board && Array.isArray(board.data) ? board.data.find(m => Number(m.id) === id) : null;
63
+ return row ? { row, period: board.period } : null;
64
+ };
65
+ // ── Leaderboard ────────────────────────────────────────────────────────────
66
+ server.registerTool('get_leaderboard', {
67
+ title: 'LLM leaderboard',
68
+ description: 'Ranking of the LLM APIs AI Stupid Level tracks (OpenAI, Anthropic, Google, DeepSeek, Kimi, GLM models) by benchmark score, 0-100. ' +
69
+ 'Use it to find the best model overall or for coding, reasoning or tool use, to compare models, and to get the model ids the other tools accept. ' +
70
+ 'Each row has the score, trend, status, stability and when it was last updated.',
71
+ inputSchema: {
72
+ sort_by: z.enum(['combined', 'coding', 'reasoning', 'tooling', 'price']).optional()
73
+ .describe('Which ranking: combined (default, all suites), coding, reasoning, tooling (tool use / agentic tasks), or price (score per list-price dollar).'),
74
+ period: PERIOD.optional().describe(PERIOD_HELP),
75
+ vendor: z.string().optional().describe('Only show models from this vendor: openai, anthropic, google, deepseek, kimi or glm.'),
76
+ limit: z.number().int().min(1).max(50).optional().describe('How many models to show (default 10).'),
77
+ },
78
+ annotations: { title: 'LLM leaderboard', ...READ },
79
+ }, run(async ({ sort_by, period, vendor, limit }) => {
80
+ const sortBy = sort_by ?? 'combined';
81
+ const r = await data.get('/models', { period, sortBy: sortBy === 'combined' ? undefined : sortBy });
82
+ if (!Array.isArray(r.body.data))
83
+ throw new ToolError('The Data API returned a leaderboard in an unexpected format.');
84
+ const all = r.body.data;
85
+ const rows = all.map((m, i) => {
86
+ const s = typeof m.currentScore === 'number' ? m.currentScore : null;
87
+ // Equal scores share a rank; in the price view the order is the ranking.
88
+ const rank = sortBy === 'price' || s === null ? i + 1 : all.findIndex(x => x.currentScore === s) + 1;
89
+ return { m, rank, joint: sortBy !== 'price' && s !== null && all.filter(x => x.currentScore === s).length > 1 };
90
+ });
91
+ const wanted = vendor ? vendor.trim().toLowerCase() : null;
92
+ const picked = rows.filter(({ m }) => !wanted || String(m.vendor ?? m.provider).toLowerCase() === wanted).slice(0, limit ?? 10);
93
+ const p = r.body.period ?? period ?? '';
94
+ const head = `AI Stupid Level leaderboard: ${sortBy === 'price' ? 'score per list-price dollar' : `${sortBy} score`}, ${PERIOD_LABEL[p] ?? p} view. ` +
95
+ `${all.length} models tracked${wanted ? `; showing ${wanted}` : ''}. Scores are 0-100; equal scores share a rank.`;
96
+ if (!picked.length)
97
+ return done(`${head}\n\nNo models${wanted ? ` from "${vendor}"` : ''} in this view.`, r);
98
+ const lines = picked.map(({ m, rank, joint }) => {
99
+ const parts = [
100
+ `${joint ? '=' : ''}${rank}. ${modelLabel(m)}`,
101
+ String(m.vendor ?? m.provider ?? ''),
102
+ typeof m.currentScore === 'number' ? `score ${m.currentScore}` : `score ${m.currentScore ?? 'n/a'}`,
103
+ ];
104
+ if (m.trend)
105
+ parts.push(`trend ${m.trend}`);
106
+ if (m.status)
107
+ parts.push(`status ${m.status}`);
108
+ if (typeof m.stability === 'number')
109
+ parts.push(`stability ${m.stability}`);
110
+ if (typeof m.dataPoints === 'number')
111
+ parts.push(`${m.dataPoints} data points`);
112
+ if (Array.isArray(m.suites) && m.suites.length)
113
+ parts.push(`suites: ${m.suites.map(suiteName).join(', ')}`);
114
+ if (sortBy === 'price' && m.pricing && typeof m.pricing.input === 'number')
115
+ parts.push(`list price $${m.pricing.input}/$${m.pricing.output} per 1M input/output tokens`);
116
+ if (m.lastUpdated)
117
+ parts.push(`updated ${when(m.lastUpdated)}`);
118
+ if (m.isStale)
119
+ parts.push(`STALE${m.staleReason ? `: ${m.staleReason}` : ''}`);
120
+ if (m.rankable === false)
121
+ parts.push('not ranked (incomplete coverage)');
122
+ return parts.filter(Boolean).join(' · ');
123
+ });
124
+ return done(`${head}\n\n${lines.join('\n')}`, r);
125
+ }));
126
+ // ── One model ──────────────────────────────────────────────────────────────
127
+ server.registerTool('get_model', {
128
+ title: 'Model details',
129
+ description: 'Details AI Stupid Level holds on one tracked LLM: vendor, version, notes on how it is called and priced, tool-calling support, ' +
130
+ 'how long it has been tracked, and its leaderboard row when one has been fetched.',
131
+ inputSchema: { model: z.string().min(1).describe(MODEL_HELP) },
132
+ annotations: { title: 'Model details', ...READ },
133
+ }, run(async ({ model }) => {
134
+ const ref = await data.resolveModel(model);
135
+ const r = await data.get(`/models/${ref.id}`);
136
+ const m = r.body.data ?? {};
137
+ const lines = [modelLabel({ ...ref, ...m })];
138
+ const meta = [m.vendor && `vendor ${m.vendor}`, m.version && `version ${m.version}`, typeof m.daysTracked === 'number' && `tracked for ${m.daysTracked} days`]
139
+ .filter(Boolean).join(' · ');
140
+ if (meta)
141
+ lines.push(meta);
142
+ if (m.isCalibrating)
143
+ lines.push(`Still calibrating: scores settle after ${m.calibrationDays ?? 'its first'} days of runs.`);
144
+ if (m.isNew)
145
+ lines.push('Recently added.');
146
+ if (typeof m.supportsToolCalling === 'boolean') {
147
+ lines.push(`Tool calling: ${m.supportsToolCalling ? `supported${m.maxToolsPerCall ? ` (up to ${m.maxToolsPerCall} tools per call)` : ''}` : 'not supported'}`);
148
+ }
149
+ if (typeof m.usesReasoningEffort === 'boolean')
150
+ lines.push(`Benchmarked with a reasoning-effort setting: ${m.usesReasoningEffort ? 'yes' : 'no'}`);
151
+ if (m.showInRankings === false)
152
+ lines.push('Not shown in the public rankings.');
153
+ if (m.latestScoreLocked)
154
+ lines.push('Newest-run score: needs a paid Data API key (see get_leaderboard for the 24h/7d/30-day scores).');
155
+ else if (m.latestScore !== null && m.latestScore !== undefined) {
156
+ const ls = m.latestScore;
157
+ lines.push(`Newest-run score: ${typeof ls === 'object' ? `${num(ls.stupidScore ?? ls.score)}${ls.timestamp ? ` (${when(ls.timestamp)})` : ''}` : num(ls)}`);
158
+ }
159
+ if (m.notes)
160
+ lines.push(`Notes: ${m.notes}`);
161
+ const row = cachedRow(ref.id);
162
+ if (row) {
163
+ const x = row.row;
164
+ lines.push(`Leaderboard (${PERIOD_LABEL[row.period ?? ''] ?? row.period ?? ''} view): score ${num(x.currentScore)}, trend ${x.trend ?? 'n/a'}, status ${x.status ?? 'n/a'}, stability ${num(x.stability)}`);
165
+ }
166
+ return done(lines.join('\n'), r);
167
+ }));
168
+ server.registerTool('get_model_history', {
169
+ title: 'Model score history',
170
+ description: 'Score history of one LLM on AI Stupid Level: each benchmark run in the window with its score, plus the newest run\'s per-axis results ' +
171
+ '(correctness, code quality, efficiency, …). Use it to see whether a model got better or worse over time.',
172
+ inputSchema: {
173
+ model: z.string().min(1).describe(MODEL_HELP),
174
+ period: PERIOD.optional().describe(PERIOD_HELP),
175
+ max_points: z.number().int().min(1).max(200).optional().describe('How many runs to list, newest first (default 30). The summary always covers every run.'),
176
+ },
177
+ annotations: { title: 'Model score history', ...READ },
178
+ }, run(async ({ model, period, max_points }) => {
179
+ const ref = await data.resolveModel(model);
180
+ const r = await data.get(`/models/${ref.id}/history`, { period });
181
+ const pts = (Array.isArray(r.body.data) ? r.body.data : [])
182
+ .filter(x => x && typeof x.score === 'number')
183
+ .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
184
+ const p = r.body.period ?? period ?? '';
185
+ const name = modelLabel(data.knownModel(ref.id) ?? ref);
186
+ if (!pts.length)
187
+ return done(`No scored runs for ${name} in the ${PERIOD_LABEL[p] ?? p} window.`, r);
188
+ const scores = pts.map(x => x.score);
189
+ const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
190
+ const lines = [
191
+ `Score history for ${name}, ${PERIOD_LABEL[p] ?? p} window: ${pts.length} runs from ${when(pts[pts.length - 1].timestamp)} to ${when(pts[0].timestamp)}.`,
192
+ `Newest ${pts[0].score}, oldest ${pts[pts.length - 1].score}, mean ${mean.toFixed(1)}, range ${Math.min(...scores)}-${Math.max(...scores)}.`,
193
+ ];
194
+ const axes = pts[0].axes && typeof pts[0].axes === 'object' ? Object.entries(pts[0].axes) : [];
195
+ if (axes.length)
196
+ lines.push(`Newest run by axis: ${axes.map(([k, v]) => `${axisName(k)} ${pct(v)}`).join(', ')}.`);
197
+ lines.push('', 'Runs, newest first:');
198
+ for (const x of pts.slice(0, max_points ?? 30)) {
199
+ lines.push(`${when(x.timestamp)} ${x.score}${x.suite && x.suite !== 'combined' ? ` (${suiteName(x.suite)})` : ''}`);
200
+ }
201
+ if (pts.length > (max_points ?? 30))
202
+ lines.push(`… ${pts.length - (max_points ?? 30)} older runs not listed.`);
203
+ return done(lines.join('\n'), r);
204
+ }));
205
+ server.registerTool('get_drift_status', {
206
+ title: 'Model drift status',
207
+ description: 'Whether an LLM\'s measured performance has shifted: AI Stupid Level\'s drift signature for one model, with its regime (stable, ' +
208
+ 'degrading, …), current score against its baseline, the change-point detector per benchmark suite, the last significant change ' +
209
+ 'and which axes moved. Use it to answer "has model X got worse?" or "is X being nerfed?".',
210
+ inputSchema: { model: z.string().min(1).describe(MODEL_HELP) },
211
+ annotations: { title: 'Model drift status', ...READ },
212
+ }, run(async ({ model }) => {
213
+ const ref = await data.resolveModel(model);
214
+ const r = await data.get(`/drift/${ref.id}`);
215
+ const d = r.body.data ?? {};
216
+ const name = modelLabel({ ...(data.knownModel(ref.id) ?? ref), name: d.modelName ?? ref.name, id: d.modelId ?? ref.id });
217
+ const lines = [`Drift status for ${name}: ${d.driftStatus ?? 'unknown'}, regime ${d.regime ?? 'unknown'}${d.timestamp ? ` (computed ${when(d.timestamp)})` : ''}.`];
218
+ const ci = Array.isArray(d.confidenceInterval) && d.confidenceInterval.length === 2 ? ` (95% CI ${d.confidenceInterval[0]}-${d.confidenceInterval[1]})` : '';
219
+ lines.push(`Current score ${num(d.currentScore)}${ci}, baseline ${num(d.baselineScore)}${d.suite ? `, on the ${suiteName(d.suite)} suite` : ''}.`);
220
+ if (typeof d.variance24h === 'number')
221
+ lines.push(`Variance over 24 h: ${d.variance24h}.`);
222
+ if (d.lastSignificantChange)
223
+ lines.push(`Last significant change: ${when(d.lastSignificantChange)}${typeof d.hoursSinceChange === 'number' ? ` (${d.hoursSinceChange} h ago)` : ''}.`);
224
+ const ph = d.pageHinkleyBySuite && typeof d.pageHinkleyBySuite === 'object' ? Object.entries(d.pageHinkleyBySuite) : [];
225
+ if (ph.length) {
226
+ lines.push('', 'Change-point detector (Page-Hinkley) by suite:');
227
+ for (const [suite, s] of ph) {
228
+ const state = s.fired ? 'FIRED: a sustained shift was detected' : s.armed === false ? `not armed yet (cold start, ${s.days ?? 0} of ${s.coldStart ?? '?'} days)` : 'no shift detected';
229
+ lines.push(`- ${suiteName(suite)}: ${state}; statistic ${num(s.cusum, 3)} vs threshold ${num(s.threshold, 2)}, ${s.days ?? '?'} days of data`);
230
+ }
231
+ }
232
+ const axes = d.axes && typeof d.axes === 'object' ? Object.entries(d.axes) : [];
233
+ if (axes.length) {
234
+ lines.push('', 'Axes (newest value, trend, change in points vs earlier runs):');
235
+ for (const [k, a] of axes) {
236
+ lines.push(`- ${axisName(k)}: ${pct(a.value)}, ${a.trend ?? 'n/a'}${typeof a.changeMagnitude === 'number' ? ` (${a.changeMagnitude > 0 ? '+' : ''}${a.changeMagnitude})` : ''}${a.status && a.status !== 'STABLE' ? `, ${a.status}` : ''}${a.comparable === false ? ', not comparable (test changed)' : ''}`);
237
+ }
238
+ }
239
+ if (d.dataSource || d.axesSource)
240
+ lines.push('', `Data source: scores ${d.dataSource ?? 'n/a'}, axes ${d.axesSource ?? 'n/a'}${typeof d.windowHours === 'number' ? `; window ${d.windowHours} h` : ''}.`);
241
+ return done(lines.join('\n'), r);
242
+ }));
243
+ // ── Fleet-wide ─────────────────────────────────────────────────────────────
244
+ server.registerTool('get_incidents', {
245
+ title: 'Recent incidents',
246
+ description: 'Recent incidents AI Stupid Level recorded: provider-wide degradations and model-level problems, with severity, the models affected, ' +
247
+ 'when each was detected and whether and when it was resolved.',
248
+ inputSchema: {
249
+ limit: z.number().int().min(1).max(50).optional().describe('How many incidents to show, newest first (default 10).'),
250
+ open_only: z.boolean().optional().describe('Only incidents that are not resolved yet (default false).'),
251
+ provider: z.string().optional().describe('Only incidents for this provider: openai, anthropic, google, deepseek, kimi or glm.'),
252
+ },
253
+ annotations: { title: 'Recent incidents', ...READ },
254
+ }, run(async ({ limit, open_only, provider }) => {
255
+ // Always the same request, so every variant of this question shares one cached answer.
256
+ const r = await data.get('/incidents', { limit: 50 });
257
+ const want = provider?.trim().toLowerCase();
258
+ const list = (Array.isArray(r.body.data) ? r.body.data : [])
259
+ .filter(i => !open_only || !i.resolvedAt)
260
+ .filter(i => !want || String(i.provider).toLowerCase() === want)
261
+ .slice(0, limit ?? 10);
262
+ const scope = `${open_only ? 'open ' : ''}incidents${want ? ` for ${want}` : ''}`;
263
+ if (!list.length)
264
+ return done(`No ${scope} in AI Stupid Level's recent record.`, r);
265
+ const lines = [`Recent ${scope} (newest first):`];
266
+ for (const i of list) {
267
+ const resolved = i.resolvedAt
268
+ ? `resolved ${when(i.resolvedAt)} (after ${ago(Date.parse(i.resolvedAt) - Date.parse(i.detectedAt))})`
269
+ : 'OPEN';
270
+ lines.push('', `#${i.id} [${i.severity ?? 'n/a'}] ${i.title ?? i.incidentType ?? 'Incident'}`);
271
+ lines.push(` ${[i.provider, i.modelName && !String(i.incidentType).startsWith('provider') ? `model ${i.modelName}` : null, `detected ${when(i.detectedAt)}`, resolved].filter(Boolean).join(' · ')}`);
272
+ const affected = i.metadata?.affectedModels;
273
+ if (Array.isArray(affected) && affected.length)
274
+ lines.push(` Affected models: ${affected.join(', ')}`);
275
+ if (i.description)
276
+ lines.push(` ${truncate(String(i.description), 400)}`);
277
+ }
278
+ return done(lines.join('\n'), r);
279
+ }));
280
+ server.registerTool('get_provider_status', {
281
+ title: 'Provider status',
282
+ description: 'Current status of each LLM provider\'s API as AI Stupid Level\'s health probes see it (OpenAI, Anthropic, Google, DeepSeek, Kimi, GLM): ' +
283
+ 'operational or not, probe response time, the last error and when it was checked. For past outages use get_incidents.',
284
+ inputSchema: {},
285
+ annotations: { title: 'Provider status', ...READ },
286
+ }, run(async () => {
287
+ const r = await data.get('/providers');
288
+ const entries = r.body.data && typeof r.body.data === 'object' ? Object.entries(r.body.data) : [];
289
+ if (!entries.length)
290
+ return done('The Data API returned no provider status.', r);
291
+ const lines = ['Provider API status (AI Stupid Level health probes):'];
292
+ for (const [name, s] of entries) {
293
+ lines.push(`- ${name}: ${s?.status ?? 'unknown'}${typeof s?.responseTime === 'number' ? ` · probe answered in ${s.responseTime} ms` : ''} · checked ${when(s?.lastChecked)}${s?.error ? ` · last error: ${truncate(String(s.error), 200)}` : ''}`);
294
+ }
295
+ return done(lines.join('\n'), r);
296
+ }));
297
+ server.registerTool('get_recommendations', {
298
+ title: 'Model recommendations',
299
+ description: 'AI Stupid Level\'s current picks: the best model for coding, the most reliable (steadiest score), the fastest, the best value ' +
300
+ '(most points per measured dollar among near-top models), models that cost far more than an equally good alternative, and models to ' +
301
+ 'avoid right now. Each pick comes with the reason and the evidence it rests on. Use it for "which model should I use?".',
302
+ inputSchema: { period: PERIOD.optional().describe(PERIOD_HELP) },
303
+ annotations: { title: 'Model recommendations', ...READ },
304
+ }, run(async ({ period }) => {
305
+ const r = await data.get('/analytics/recommendations', { period });
306
+ const d = r.body.data && typeof r.body.data === 'object' ? r.body.data : {};
307
+ const LABEL = {
308
+ bestForCode: 'Best for coding', mostReliable: 'Most reliable', fastestResponse: 'Fastest response',
309
+ bestValue: 'Best value', poorValue: 'Poor value (a cheaper model scores as well or better)', avoidNow: 'Avoid right now',
310
+ };
311
+ const pick = (x) => {
312
+ const bits = [`${modelLabel(x)}${x.vendor ? ` · ${x.vendor}` : ''}${typeof x.displayScore === 'number' ? ` · leaderboard score ${x.displayScore}` : typeof x.score === 'number' ? ` · score ${x.score}` : ''}`];
313
+ if (x.reason)
314
+ bits.push(` Why: ${x.reason}`);
315
+ if (x.evidence)
316
+ bits.push(` Evidence: ${x.evidence}`);
317
+ return bits.join('\n');
318
+ };
319
+ const p = r.body.period ?? period ?? '';
320
+ const lines = [`AI Stupid Level recommendations, ${PERIOD_LABEL[p] ?? p} view:`];
321
+ for (const [key, v] of Object.entries(d)) {
322
+ const label = LABEL[key] ?? key.replace(/([a-z])([A-Z])/g, '$1 $2');
323
+ if (Array.isArray(v)) {
324
+ lines.push('', `${label}:${v.length ? '' : ' none'}`);
325
+ for (const x of v)
326
+ lines.push(`- ${pick(x)}`);
327
+ }
328
+ else {
329
+ lines.push('', `${label}: ${v ? pick(v) : 'none in this view'}`);
330
+ }
331
+ }
332
+ return done(lines.join('\n'), r);
333
+ }));
334
+ server.registerTool('get_global_index', {
335
+ title: 'Global AI index',
336
+ description: 'AI Stupid Level\'s global index: one 0-100 number for the tracked models together, now and over the last 24 hours, with its trend ' +
337
+ 'and how many models are performing well. Use it for "are AI models worse today?".',
338
+ inputSchema: {},
339
+ annotations: { title: 'Global AI index', ...READ },
340
+ }, run(async () => {
341
+ const r = await data.get('/index');
342
+ const d = r.body.data ?? {};
343
+ const lines = [`AI Stupid Level global index: ${num(d.current?.globalScore)} now${d.current?.modelsCount ? ` across ${d.current.modelsCount} models` : ''}, trend ${d.trend ?? 'n/a'}.`];
344
+ if (typeof d.performingWell === 'number' && typeof d.totalModels === 'number')
345
+ lines.push(`Models performing well: ${d.performingWell} of ${d.totalModels}.`);
346
+ if (Array.isArray(d.history) && d.history.length) {
347
+ lines.push(`History: ${d.history.map((h) => `${h.label ?? when(h.timestamp)} ${num(h.globalScore)}`).join(', ')}.`);
348
+ }
349
+ return done(lines.join('\n'), r);
350
+ }));
351
+ // ── Smart Router ───────────────────────────────────────────────────────────
352
+ const MODE = z.enum(ROUTING_MODES).optional().describe('Routing mode (default auto = the strategy saved in the account): auto-best (highest combined score), auto-coding, auto-reasoning, ' +
353
+ 'auto-tooling (top model on that benchmark), auto-task (reads the prompt and picks the benchmark), auto-value, auto-value-coding, ' +
354
+ 'auto-value-reasoning, auto-value-tooling (most points per measured dollar among models within 5 points of the best), ' +
355
+ 'auto-fastest-quality, auto-consistent (fastest or steadiest near-top model), auto-fastest, auto-cheapest (no quality bar).');
356
+ const PIN = z.string().optional().describe('Pin a specific model id (for example gpt-6-sol) instead of a routing mode.');
357
+ const SANDBOX = z.boolean().optional().describe('Sandbox: run the real routing as if the account had a key for every provider and return a mock answer. Nothing is called, spent or counted.');
358
+ const messagesFor = (prompt, system) => [
359
+ ...(system ? [{ role: 'system', content: system }] : []),
360
+ { role: 'user', content: prompt },
361
+ ];
362
+ server.registerTool('preview_route', {
363
+ title: 'Preview Smart Router choice',
364
+ description: 'Which model the AI Stupid Level Smart Router would send a prompt to, and why: the routing strategy, the chosen model and provider ' +
365
+ 'with its benchmark score and estimated cost, and the fallback chain. Nothing is sent to a model and nothing is counted. Needs a router key (ASL_ROUTER_KEY).',
366
+ inputSchema: {
367
+ prompt: z.string().min(1).describe('The prompt to route (the decision can depend on it, for example with auto-task).'),
368
+ system: z.string().optional().describe('An optional system message.'),
369
+ mode: MODE,
370
+ pin_model: PIN,
371
+ sandbox: SANDBOX,
372
+ },
373
+ annotations: { title: 'Preview Smart Router choice', ...READ },
374
+ }, run(async ({ prompt, system, mode, pin_model, sandbox }) => {
375
+ const model = pin_model?.trim() || mode || 'auto';
376
+ const { data: p } = await router.request('POST', '/route/preview', { model, messages: messagesFor(prompt, system) }, { sandbox });
377
+ const sel = (s) => s
378
+ ? `${s.model ?? 'unknown'}${s.provider ? ` (${s.provider})` : ''}${typeof s.score === 'number' ? `, score ${s.score}` : ''}${typeof s.estimated_cost_usd === 'number' ? `, estimated cost ${usd(s.estimated_cost_usd)}` : ''}${s.reasoning ? `. Why: ${s.reasoning}` : ''}`
379
+ : 'none';
380
+ const lines = [`Smart Router decision for ${p.requested_model ?? model}${p.strategy ? ` (strategy ${p.strategy})` : ''}${p.sandbox ? ', sandbox: as if the account had a key for every provider' : ''}:`];
381
+ lines.push(`Primary: ${sel(p.primary)}`);
382
+ const fb = Array.isArray(p.fallbacks) ? p.fallbacks : [];
383
+ if (fb.length) {
384
+ lines.push(`Fallbacks${p.fallback_enabled === false ? ' (fallback is switched off for this key)' : ''}:`);
385
+ fb.forEach((f, i) => lines.push(`${i + 1}. ${sel(f)}`));
386
+ }
387
+ else
388
+ lines.push('Fallbacks: none');
389
+ lines.push('', 'Nothing was sent to a model and nothing was counted.');
390
+ return lines.join('\n');
391
+ }));
392
+ server.registerTool('ask_router', {
393
+ title: 'Ask via Smart Router',
394
+ description: 'Send a prompt through the AI Stupid Level Smart Router and return the answer, together with which model answered, its provider, why ' +
395
+ 'the router chose it and the latency. The router calls the model with the provider keys saved in the user\'s ASL account, so a real call ' +
396
+ 'is billed by that provider and counts against the router plan; set sandbox: true for a free mock answer. Needs a router key (ASL_ROUTER_KEY).',
397
+ inputSchema: {
398
+ prompt: z.string().min(1).describe('The user message to send.'),
399
+ system: z.string().optional().describe('An optional system message.'),
400
+ mode: MODE,
401
+ pin_model: PIN,
402
+ max_tokens: z.number().int().min(1).max(128_000).optional().describe('Upper bound on the answer length in tokens.'),
403
+ reasoning_effort: z.enum(['low', 'medium', 'high']).optional().describe('Reasoning effort for models that support it.'),
404
+ sandbox: SANDBOX,
405
+ },
406
+ annotations: { title: 'Ask via Smart Router', readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
407
+ }, run(async ({ prompt, system, mode, pin_model, max_tokens, reasoning_effort, sandbox }) => {
408
+ const model = pin_model?.trim() || mode || 'auto';
409
+ const body = { model, messages: messagesFor(prompt, system) };
410
+ if (max_tokens)
411
+ body.max_completion_tokens = max_tokens;
412
+ if (reasoning_effort)
413
+ body.reasoning_effort = reasoning_effort;
414
+ const { data: c, routing } = await router.request('POST', '/chat/completions', body, { sandbox, idempotent: true });
415
+ const choice = c?.choices?.[0];
416
+ const answer = typeof choice?.message?.content === 'string' && choice.message.content
417
+ ? choice.message.content
418
+ : choice?.message?.refusal ? `(The model declined: ${choice.message.refusal})` : '(The model returned no text.)';
419
+ const info = [
420
+ `Answered by ${routing.model ?? c?.model ?? 'unknown model'}${routing.provider ? ` (${routing.provider})` : ''} via the AI Stupid Level Smart Router, mode ${model}.`,
421
+ ];
422
+ if (routing.reasoning)
423
+ info.push(`Why this model: ${routing.reasoning}`);
424
+ if (routing.fallback)
425
+ info.push('A fallback model answered because the first choice failed.');
426
+ const facts = [
427
+ routing.latencyMs !== null ? `latency ${routing.latencyMs} ms` : null,
428
+ c?.usage ? `tokens ${c.usage.prompt_tokens ?? '?'} in / ${c.usage.completion_tokens ?? '?'} out` : null,
429
+ choice?.finish_reason && choice.finish_reason !== 'stop' ? `finish reason ${choice.finish_reason}` : null,
430
+ routing.requestId ? `request id ${routing.requestId}` : null,
431
+ ].filter(Boolean);
432
+ if (facts.length)
433
+ info.push(`${facts.join(' · ')}.`);
434
+ if (routing.sandbox || sandbox || cfg.forceSandbox)
435
+ info.push('Sandbox: this is a mock answer from real routing; no provider was called and nothing was spent or counted.');
436
+ return `${answer}\n\n---\n${info.join('\n')}`;
437
+ }));
438
+ server.registerTool('get_usage', {
439
+ title: 'ASL usage and limits',
440
+ description: 'This month\'s Smart Router usage for the configured router key (requests used and left, prepaid credits, the key\'s budget), and the ' +
441
+ 'Data API requests left today for the configured data key. Use it when a tool reports a limit, or to check the keys work.',
442
+ inputSchema: {},
443
+ annotations: { title: 'ASL usage and limits', ...READ },
444
+ }, run(async () => {
445
+ if (!router.hasKey && !data.hasKey) {
446
+ throw new ToolError(`No AI Stupid Level key is configured. Set ASL_DATA_KEY (create one at ${LINKS.dataKeys}) and/or ASL_ROUTER_KEY (create one at ${LINKS.routerKeys}) in this MCP server's "env" configuration.`);
447
+ }
448
+ const lines = [];
449
+ if (router.hasKey) {
450
+ try {
451
+ const { data: u } = await router.request('GET', '/usage');
452
+ const req = u?.requests ?? {};
453
+ lines.push(`Smart Router (${u?.period ?? 'this month'}, plan ${u?.plan ?? 'unknown'}): ${req.unlimited ? `${req.used ?? 0} requests, unlimited` : `${req.used ?? 0} of ${req.included ?? '?'} requests used, ${req.remaining ?? '?'} left`}` +
454
+ `${u?.credits ? `; prepaid credits: ${u.credits.requests ?? 0} requests${u.credits.in_use ? ' (in use)' : ''}` : ''}; a routed request would ${u?.allowed === false ? 'NOT ' : ''}be accepted now.`);
455
+ const b = u?.key?.budget;
456
+ if (u?.key)
457
+ lines.push(`Key ${u.key.name ?? ''} (${u.key.prefix ?? ''}…)${b ? `: spent $${num(b.spent_this_month_usd, 2)} this month${typeof b.monthly_limit_usd === 'number' ? ` of a ${b.hard_limit ? 'hard' : 'soft'} $${b.monthly_limit_usd} budget` : ', no budget set'}` : ''}${u.key.expires_at ? `, expires ${when(u.key.expires_at)}` : ''}.`);
458
+ }
459
+ catch (err) {
460
+ if (!(err instanceof ToolError))
461
+ throw err;
462
+ lines.push(`Smart Router: ${err.message}`);
463
+ }
464
+ }
465
+ else
466
+ lines.push(`Smart Router: no router key configured (ASL_ROUTER_KEY; create one at ${LINKS.routerKeys}).`);
467
+ if (data.hasKey) {
468
+ let q = data.quota;
469
+ let tier = '';
470
+ if (!q) {
471
+ try {
472
+ const r = await data.get('/me');
473
+ q = data.quota;
474
+ const me = r.body;
475
+ if (me?.key?.tier)
476
+ tier = `, ${me.key.tier} tier`;
477
+ if (me?.limits)
478
+ tier += ` (${me.limits.requests_per_day} a day, ${me.limits.requests_per_minute} a minute)`;
479
+ }
480
+ catch (err) {
481
+ if (!(err instanceof ToolError))
482
+ throw err;
483
+ lines.push(`Data API: ${err.message}`);
484
+ }
485
+ }
486
+ if (q)
487
+ lines.push(`Data API${tier}: ${q.remaining} of ${q.limit} requests left today; resets ${q.resetAt ? when(q.resetAt) : 'at 00:00 UTC'}.`);
488
+ }
489
+ else
490
+ lines.push(`Data API: no data key configured (ASL_DATA_KEY; create one at ${LINKS.dataKeys}).`);
491
+ return lines.join('\n');
492
+ }));
493
+ // ── Prompt ─────────────────────────────────────────────────────────────────
494
+ server.registerPrompt('choose_model', {
495
+ title: 'Choose an LLM for a task',
496
+ description: 'Pick the LLM API to use for a task, from AI Stupid Level\'s rankings, recommendations and drift data.',
497
+ argsSchema: { task: z.string().describe('What the model will be used for, e.g. "refactoring a large TypeScript codebase".') },
498
+ }, ({ task }) => ({
499
+ messages: [{
500
+ role: 'user',
501
+ content: {
502
+ type: 'text',
503
+ text: `I need to choose an LLM API for this task: ${task}\n\n` +
504
+ 'Use the AI Stupid Level tools. Start with get_recommendations; if the task is mainly coding, reasoning or tool use, also call ' +
505
+ 'get_leaderboard with that sort_by. Check get_drift_status for the top candidate. Then recommend one model and a cheaper ' +
506
+ 'alternative, citing the scores and the evidence the tools returned, and cite aistupidlevel.info as the source. ' +
507
+ 'A free Data API key allows 1 request a minute, so make as few data calls as you need.',
508
+ },
509
+ }],
510
+ }));
511
+ if (cfg.warnings.data)
512
+ cfg.log(cfg.warnings.data);
513
+ if (cfg.warnings.router)
514
+ cfg.log(cfg.warnings.router);
515
+ return server;
516
+ }
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@aistupidlevel/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "MCP server for AI Stupid Level: LLM leaderboard, model history, drift status, incidents and provider status from the ASL Data API, plus route previews and prompts through the ASL Smart Router.",
5
+ "license": "MIT",
6
+ "author": "AI Stupid Level (https://aistupidlevel.info)",
7
+ "homepage": "https://aistupidlevel.info/router/docs",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/StudioPlatforms/aistupidlevel-sdk.git",
11
+ "directory": "packages/mcp"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/StudioPlatforms/aistupidlevel-sdk/issues"
15
+ },
16
+ "mcpName": "io.github.StudioPlatforms/aistupidlevel",
17
+ "keywords": [
18
+ "mcp",
19
+ "model-context-protocol",
20
+ "mcp-server",
21
+ "llm",
22
+ "benchmarks",
23
+ "leaderboard",
24
+ "drift",
25
+ "router",
26
+ "ai"
27
+ ],
28
+ "type": "module",
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "bin": {
32
+ "aistupidlevel-mcp": "dist/cli.js"
33
+ },
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "scripts": {
50
+ "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/cli.js",
51
+ "test": "node --test test/*.test.mjs",
52
+ "prepublishOnly": "npm run build && npm test"
53
+ },
54
+ "dependencies": {
55
+ "@modelcontextprotocol/sdk": "1.32.1",
56
+ "zod": "4.6.5"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "18.19.130",
60
+ "typescript": "5.9.3"
61
+ }
62
+ }