pog-mcp 0.1.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/dist/server.js ADDED
@@ -0,0 +1,867 @@
1
+ /**
2
+ * server — the Proof of Goal MCP surface.
3
+ *
4
+ * Design rule: every tool should be callable by an agent that has read nothing
5
+ * but the tool descriptions. The API is already agent-friendly over plain HTTP
6
+ * (see AGENTS.md); what MCP adds is that the agent no longer has to parse a
7
+ * runbook, hold a Solana keypair, or guess payload shapes. So descriptions here
8
+ * carry the CONSTRAINTS, not just the parameter names — a squad rejected for
9
+ * "212 points" is a wasted round-trip the schema could have prevented.
10
+ */
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { z } from 'zod';
13
+ import { ApiError, LEADERBOARD_MAX_LIMIT, PogClient } from './client.js';
14
+ import { existsSync } from 'node:fs';
15
+ import { addressFromMnemonic, isValidMnemonic, loadOrCreateWallet, readWallet, walletFilePath, } from './wallet.js';
16
+ /** Positions the squad validator recognises. */
17
+ const POSITIONS = ['GK', 'DF', 'DMF', 'OMF', 'FW'];
18
+ /** Cup bracket stages, in the order they are played. */
19
+ const CUP_STAGES = ['qualifier', 'group', 'r32', 'r16', 'qf', 'sf', '3rd', 'final'];
20
+ /**
21
+ * Longest team name POST/PUT /api/teams accept. Declared here so the schema
22
+ * rejects an over-long name before the request, rather than the agent
23
+ * discovering it from a raw Fastify 400.
24
+ */
25
+ const TEAM_NAME_MAX = 30;
26
+ /**
27
+ * One squad player, in the FLAT shape POST /api/teams takes.
28
+ *
29
+ * Not the engine shape: the engine nests the SAME four names under `attrs`.
30
+ * (dori/shoo/defe are the Postgres column names only — team-store is the sole
31
+ * place that translation happens.) Both shapes exist here and mixing them is a
32
+ * documented trap, so this schema only ever describes the flat one.
33
+ */
34
+ const PlayerSchema = z.object({
35
+ /**
36
+ * Identity, not position. PUT /api/teams/:id resolves each entry to the
37
+ * player currently in that slot unless a playerId says otherwise, so moving
38
+ * players between slots WITHOUT this silently rewrites whoever was already
39
+ * there — and for minted players it discards the submitted stats too. Zod
40
+ * strips unknown keys, so omitting it from the schema was the same as
41
+ * deleting it from every request.
42
+ */
43
+ playerId: z
44
+ .string()
45
+ .uuid()
46
+ .optional()
47
+ .describe('Existing player to place in this slot, from get_squad. Required when ' +
48
+ 'rearranging an existing squad; omit for new players.'),
49
+ slotIndex: z
50
+ .number()
51
+ .int()
52
+ .min(0)
53
+ .max(10)
54
+ .describe('Squad slot 0-10. Each slot must appear exactly once across the 11 players.'),
55
+ name: z.string().min(1).max(10).describe('Display name, max 10 chars, unique within the squad'),
56
+ position: z.enum(POSITIONS).describe('Exactly one GK; at least one each of DF, DMF, OMF, FW'),
57
+ pass: z.number().int().min(1).max(10),
58
+ dribble: z.number().int().min(1).max(10),
59
+ shoot: z.number().int().min(1).max(10),
60
+ defense: z.number().int().min(1).max(10),
61
+ // Required by the route schema, not optional — an agent that omits them gets a
62
+ // raw Fastify validation error instead of a rule explanation.
63
+ // Defaulted, not required. Twenty-two booleans that are false on nine or ten
64
+ // of the eleven players is a lot of payload for an agent to emit correctly,
65
+ // and getting one wrong costs a whole rejected call — which is what happened
66
+ // the first time this surface was driven end to end.
67
+ isFkKicker: z
68
+ .boolean()
69
+ .default(false)
70
+ .describe('At least one player in the squad must be true. Omit for the rest.'),
71
+ isPkKicker: z
72
+ .boolean()
73
+ .default(false)
74
+ .describe('At least one player in the squad must be true. Omit for the rest.'),
75
+ });
76
+ /**
77
+ * The constraints POST /api/teams enforces, in the agent's own vocabulary.
78
+ *
79
+ * Every number here is asserted against packages/engine/src/validator.ts by
80
+ * `__tests__/gameFacts.test.ts` — an agent treats a tool description as fact and
81
+ * reasons on top of it, so a stale rule here does not merely cost a retry, it
82
+ * makes the agent rule out squads that are actually legal.
83
+ *
84
+ * The scarcity caps are TEAM-WIDE, not per player: a per-player cap would be
85
+ * dead code, since a player total of 29 already cannot hold four 10s.
86
+ */
87
+ const SQUAD_RULES = [
88
+ 'Exactly 11 players, with slotIndex 0-10 each used exactly once.',
89
+ 'The four attributes across the whole squad must sum to exactly 212.',
90
+ 'Each attribute is 1-10; each player total is 10-29.',
91
+ 'Across the whole squad: at most 3 attributes equal to 10, and at most 5 equal to 8 or 9.',
92
+ 'Exactly one GK, plus at least one each of DF, DMF, OMF, FW.',
93
+ 'At least one free-kick taker (isFkKicker) and at least one penalty taker (isPkKicker) — ' +
94
+ 'the same player may be both. Both default to false, so name the two you want and leave ' +
95
+ 'them off everyone else.',
96
+ 'Names are max 10 characters and unique within the squad.',
97
+ ].join(' ');
98
+ /**
99
+ * Matches `catch_up` looks through by default. A cup day alone can produce
100
+ * seven, and the endpoint caps at 200; 50 covers a normal absence without
101
+ * spending the context of a full career.
102
+ */
103
+ const DEFAULT_HISTORY_LIMIT = 50;
104
+ /** Leaderboard rows returned when the caller does not ask for a number. */
105
+ const DEFAULT_LEADERBOARD_ROWS = 20;
106
+ /**
107
+ * Tool results are model-facing, so they are billed as context on every call.
108
+ * Compact JSON rather than indented: indentation is roughly a third of the bytes
109
+ * on the wide, shallow shapes this API returns (a 174-nation list, a 100-row
110
+ * board), and a model reads compact JSON just as reliably. The trade is
111
+ * human readability while debugging — pipe through `jq` when that matters.
112
+ */
113
+ function ok(value) {
114
+ return { content: [{ type: 'text', text: JSON.stringify(value) }] };
115
+ }
116
+ function fail(err) {
117
+ // A rejection that says WHEN to come back is a schedule; one that does not is
118
+ // a reason to guess. The server names the moment on every 429 — as a header,
119
+ // and for the playoff cooldown as an exact timestamp — so pass it through
120
+ // instead of making the agent spend another call to rediscover it.
121
+ const timing = err instanceof ApiError
122
+ ? [
123
+ err.retryAt !== undefined ? `Try again at ${err.retryAt}.` : null,
124
+ err.retryAfterSeconds !== undefined && err.retryAt === undefined
125
+ ? `Try again in ${String(err.retryAfterSeconds)}s.`
126
+ : null,
127
+ err.remaining !== undefined ? `${String(err.remaining)} requests left this minute.` : null,
128
+ ]
129
+ .filter((p) => p !== null)
130
+ .join(' ')
131
+ : '';
132
+ const text = err instanceof ApiError
133
+ ? `API ${err.status} on ${err.path}: ${err.message}${timing ? ` ${timing}` : ''}`
134
+ : err instanceof Error
135
+ ? err.message
136
+ : String(err);
137
+ return { content: [{ type: 'text', text }], isError: true };
138
+ }
139
+ export function buildServer(opts = {}) {
140
+ const client = opts.client ?? new PogClient();
141
+ // Resolved lazily so constructing the server never writes a key to disk —
142
+ // tests and `--help` should not mint a wallet as a side effect.
143
+ let wallet = opts.wallet ?? null;
144
+ // Whether THIS process minted the key, kept rather than discarded.
145
+ //
146
+ // Creating a real Solana key is the single most consequential thing this
147
+ // server does, and it used to happen in silence: the operator got
148
+ // "Signed in." and no hint that a phrase now existed, where it was, or that
149
+ // losing it is terminal. A one-time notice is the only moment we get.
150
+ let mintedHere = false;
151
+ const getWallet = () => {
152
+ if (wallet === null) {
153
+ const loaded = loadOrCreateWallet();
154
+ wallet = loaded.wallet;
155
+ mintedHere = loaded.created;
156
+ }
157
+ return wallet;
158
+ };
159
+ const server = new McpServer({ name: 'proof-of-goal', version: '0.1.0' });
160
+ server.registerTool('login', {
161
+ title: 'Sign in to Proof of Goal',
162
+ description: 'Create or reuse this machine\'s Solana wallet and complete the Sign-In-With-Solana handshake. ' +
163
+ 'Call this once before any tool that writes. Returns the wallet address and session expiry. ' +
164
+ 'The recovery phrase is stored locally so the agent returns as the same manager after a restart.',
165
+ inputSchema: {},
166
+ // Not idempotent: each call burns a nonce, creates a new session row, and
167
+ // replaces the credential this client is holding. A client that retried a
168
+ // lost response would quietly invalidate the session it already had, and
169
+ // enough retries hit the auth route's per-minute limit.
170
+ annotations: { readOnlyHint: false, idempotentHint: false },
171
+ }, async () => {
172
+ try {
173
+ const w = getWallet();
174
+ const session = await client.login(w.mnemonic, w.address);
175
+ const created = mintedHere;
176
+ mintedHere = false; // say it once, not on every login of the process
177
+ return ok({
178
+ walletAddress: session.walletAddress,
179
+ expiresAt: session.expiresAt,
180
+ // Not a secret, and the operator cannot back up what they cannot find.
181
+ walletFile: walletFilePath(),
182
+ note: created
183
+ ? `Signed in. A NEW recovery phrase was just created at ${walletFilePath()} — it is ` +
184
+ 'the only copy, and it owns this squad and anything it earns. Tell whoever you ' +
185
+ 'work for to back it up now: the file holds 12 words that import into Phantom or ' +
186
+ 'Solflare. Delete it and the account is gone; there is no reset. ' +
187
+ 'Squad and match tools are now available.'
188
+ : 'Signed in. Squad and match tools are now available.',
189
+ });
190
+ }
191
+ catch (err) {
192
+ return fail(err);
193
+ }
194
+ });
195
+ server.registerTool('whoami', {
196
+ title: 'Show this agent’s wallet and session',
197
+ description: 'Report the wallet address this server plays as and whether a session is currently held. ' +
198
+ 'Never reveals the recovery phrase, and never creates a wallet — if none exists yet this ' +
199
+ 'says so, and `login` is what creates one.',
200
+ inputSchema: {},
201
+ annotations: { readOnlyHint: true },
202
+ }, async () => {
203
+ try {
204
+ // Deliberately NOT getWallet(): that generates a phrase and writes it to
205
+ // disk. A tool advertised as readOnlyHint may be auto-approved by the
206
+ // client, and a real Solana key appearing as a side effect of asking
207
+ // "who am I" is not something the user consented to.
208
+ //
209
+ // POG_MCP_MNEMONIC wins here exactly as it does in login, and its
210
+ // address is DERIVED rather than read back from disk. Reporting the old
211
+ // file's address — or "no wallet" — while login is about to use a
212
+ // different one would have an operator fund or whitelist the wrong
213
+ // Solana account off a tool that presents itself as authoritative.
214
+ // VALIDATE before deriving. Seed derivation does not check the BIP39
215
+ // checksum, so a typo'd phrase still yields a perfectly well-formed
216
+ // address — and login, which does validate, then refuses the same value.
217
+ // Reporting that address as fundable is how an operator sends assets to
218
+ // an account nobody can ever sign for.
219
+ const imported = process.env['POG_MCP_MNEMONIC']?.trim();
220
+ const importValid = imported !== undefined && imported !== '' && isValidMnemonic(imported);
221
+ // A valid import is not the same as a SELECTED one. login refuses to
222
+ // replace a wallet already on this machine, so when the file holds a
223
+ // different phrase the imported address is the one address login will
224
+ // NOT use — and reporting it is how an operator funds an account this
225
+ // server can never sign for. Which was the whole point of deriving it
226
+ // here instead of reading the file: report what login will do.
227
+ const stored = readWallet();
228
+ // existsSync, not `stored !== null`: readWallet also returns null for a
229
+ // file it cannot parse, and login refuses on EXISTENCE — so a corrupt or
230
+ // truncated wallet made this report the imported address as fundable
231
+ // while login would not start at all. The same distinction the import
232
+ // path itself had to make.
233
+ const importConflicts = importValid && (stored?.mnemonic !== imported || stored === null)
234
+ ? existsSync(walletFilePath())
235
+ : false;
236
+ const w = wallet ?? (importValid && !importConflicts
237
+ ? { mnemonic: imported, address: addressFromMnemonic(imported) }
238
+ : stored);
239
+ const badImport = imported !== undefined && imported !== '' && !importValid;
240
+ const s = client.currentSession();
241
+ return ok({
242
+ walletAddress: w?.address ?? null,
243
+ hasWallet: w !== null,
244
+ // Where the phrase lives. The path is not a secret, and an agent that
245
+ // cannot name it cannot tell its operator what to back up.
246
+ walletFile: walletFilePath(),
247
+ signedIn: s !== null,
248
+ sessionExpiresAt: s?.expiresAt ?? null,
249
+ ...(badImport
250
+ ? {
251
+ note: 'POG_MCP_MNEMONIC is set but is not a valid BIP39 phrase — login will refuse ' +
252
+ 'it. Any address shown here comes from the wallet file, not from it.',
253
+ }
254
+ : importConflicts
255
+ ? {
256
+ note: 'POG_MCP_MNEMONIC is a valid phrase but a DIFFERENT wallet is already stored ' +
257
+ 'on this machine, so login REFUSES to run rather than replace it — that file ' +
258
+ 'is probably the only copy of a key holding a squad and assets. The address ' +
259
+ 'above is the stored one, which is what login would use if you unset the ' +
260
+ 'variable. To use the imported phrase instead, point POG_MCP_WALLET_FILE at ' +
261
+ 'another path.',
262
+ }
263
+ : w === null
264
+ ? {
265
+ note: existsSync(walletFilePath())
266
+ ? // Something IS there — corrupt, truncated, a FIFO — and login
267
+ // refuses to replace it rather than destroy a phrase it cannot
268
+ // read. Saying "login creates one" sent the operator to run the
269
+ // one command that cannot work, and hid the file that needs
270
+ // looking at. It may still contain the recovery phrase.
271
+ `${walletFilePath()} exists but cannot be read as a wallet, so login ` +
272
+ 'refuses to start rather than replace it — it may still contain a ' +
273
+ 'recovery phrase in plain text, so open it before doing anything else. ' +
274
+ 'Move or delete it to start over, or point POG_MCP_WALLET_FILE elsewhere.'
275
+ : 'No wallet on this machine yet. login creates one.',
276
+ }
277
+ : {}),
278
+ });
279
+ }
280
+ catch (err) {
281
+ return fail(err);
282
+ }
283
+ });
284
+ server.registerTool('get_game_rules', {
285
+ title: 'Game rules and squad constraints',
286
+ description: 'The rules a squad must satisfy, plus how the daily cup works. Read this before building a squad.',
287
+ inputSchema: {},
288
+ annotations: { readOnlyHint: true },
289
+ }, async () => {
290
+ // Whether friendlies age your players depends on the deployment: growth is
291
+ // only recorded when the FA market is enabled. Stating it unconditionally
292
+ // would scare an agent off the sample sizes it needs on a deployment where
293
+ // friendlies really are free.
294
+ // Fail to UNKNOWN, never to "free". Equating a failed lookup with OFF told
295
+ // the agent friendlies cost nothing while the friendly endpoint stayed up
296
+ // and tracking was actually on — and career_matches does not come back.
297
+ let growthTracked = null;
298
+ try {
299
+ const market = (await client.marketStatus());
300
+ growthTracked = market.marketEnabled === true;
301
+ }
302
+ catch {
303
+ growthTracked = null;
304
+ }
305
+ return ok({
306
+ squad: SQUAD_RULES,
307
+ cup: 'The PoG Cup opens daily at 04:00 UTC with a 48-team field: 12 groups of four, then ' +
308
+ 'the top two of each group plus the eight best third-placed teams advance to a Round ' +
309
+ 'of 32, then R16, quarter-finals, semi-finals, third-place playoff, and the final. ' +
310
+ 'YOU DO NOT ENTER IT DIRECTLY. Entrants are auto-enrolled from DIVISION 1 of the ' +
311
+ 'weekly playoff ladder and the rest of the field is filled with AI teams. A new squad ' +
312
+ 'starts in the bottom division (4), and the top 3 of a division are promoted at the ' +
313
+ 'end of each ISO week — so reaching the cup takes at least three good weeks of ' +
314
+ 'play_playoff. Until then get_cup shows you other managers\u2019 matches, not yours; ' +
315
+ 'do not wait for fixtures that are not coming. ' +
316
+ 'A cup is created by the SCHEDULER, which is a separate process from this API. ' +
317
+ 'Against a deployment running without it — a bare local server, for instance — no cup ' +
318
+ 'is ever created and get_cup answers `cup: null` for every date forever. That is the ' +
319
+ 'deployment, not a wait: play friendlies instead of checking again.',
320
+ simulation: 'Matches are simulated off-chain by a deterministic engine seeded per match, so the same match always replays identically. You do not control players during a match — the squad you build decides the outcome.',
321
+ currency: 'POG',
322
+ growthTracking: growthTracked === true
323
+ ? 'ON for asset-backed squads. If your players are player-asset-backed, every ' +
324
+ 'match — friendlies included — permanently increments their tenure and ' +
325
+ 'career_matches, and a higher career count lowers their remaining growth ' +
326
+ 'ceiling for good; budget friendlies rather than bulk-running them. A legacy ' +
327
+ 'squad with no player assets records nothing and is unaffected.'
328
+ : growthTracked === false
329
+ ? 'OFF on this deployment. Friendlies cost nothing and can be run freely to ' +
330
+ 'measure a squad change.'
331
+ : 'UNKNOWN — the market status could not be read. Assume it is ON and treat ' +
332
+ 'friendlies as costly: being wrong the other way permanently ages your ' +
333
+ 'players, and that cannot be undone.',
334
+ });
335
+ });
336
+ server.registerTool('list_nations', {
337
+ title: 'List selectable nations',
338
+ description: 'Nation codes accepted by create_squad. Nation is identity only — it does not affect player ability. ' +
339
+ 'Returns code and name for every nation. Pass a nationCode to also get that nation’s ' +
340
+ 'given-name and surname pools, which is what the game draws plausible player names from.',
341
+ inputSchema: {
342
+ nationCode: z
343
+ .string()
344
+ .length(3)
345
+ .optional()
346
+ .describe('FIFA 3-letter code. Narrows to one nation and includes its name pools.'),
347
+ },
348
+ annotations: { readOnlyHint: true },
349
+ }, async ({ nationCode }) => {
350
+ try {
351
+ const { nations } = await client.nations();
352
+ if (nationCode) {
353
+ const wanted = nationCode.toUpperCase();
354
+ const hit = nations.find((n) => n.code.toUpperCase() === wanted);
355
+ if (!hit)
356
+ return fail(new Error(`Unknown nation code "${nationCode}". Call list_nations with no argument to see valid codes.`));
357
+ return ok(hit);
358
+ }
359
+ // Project away the name pools and emit a code→name map. The raw payload
360
+ // is ~55 kB / ~14k tokens, nearly all of it names the agent did not ask
361
+ // for; a lookup map of the two fields that matter is ~4 kB. An agent that
362
+ // wants names asks for one nation.
363
+ return ok({
364
+ count: nations.length,
365
+ nations: Object.fromEntries(nations.map((n) => [n.code, n.name])),
366
+ });
367
+ }
368
+ catch (err) {
369
+ return fail(err);
370
+ }
371
+ });
372
+ server.registerTool('create_squad', {
373
+ title: 'Create a squad',
374
+ description: `Register an 11-player squad for this agent's wallet. Requires login. ${SQUAD_RULES} ` +
375
+ 'On rejection the error names the specific rule that failed, so fix and retry rather than guessing. ' +
376
+ 'A wallet holds at most one squad — if you already have one, use update_squad instead.',
377
+ inputSchema: {
378
+ name: z
379
+ .string()
380
+ .min(1)
381
+ .max(TEAM_NAME_MAX)
382
+ .describe(`Team name — unique across all teams, at most ${String(TEAM_NAME_MAX)} characters`),
383
+ // Uppercased before it leaves here. list_nations accepts a lowercase code
384
+ // and says so, but the API compares this field case-sensitively — so
385
+ // "kor" passed every check on this side and came back "Unknown nation
386
+ // code". Two tools, one contract.
387
+ nationCode: z
388
+ .string()
389
+ .length(3)
390
+ .transform((code) => code.toUpperCase())
391
+ .describe('FIFA 3-letter code, e.g. KOR, BRA (see list_nations). Case-insensitive.'),
392
+ players: z.array(PlayerSchema).length(11),
393
+ },
394
+ annotations: { readOnlyHint: false, idempotentHint: false },
395
+ }, async ({ name, nationCode, players }) => {
396
+ try {
397
+ return ok(await client.createTeam({ name, nationCode, players }));
398
+ }
399
+ catch (err) {
400
+ // The one-per-wallet guard answers 409 `{error:"team_exists", teamId}`.
401
+ // Left raw that reads as a dead end; it is really a redirect, and the id
402
+ // the agent needs is sitting in the body.
403
+ // Two DIFFERENT conflicts share status 409. Collapsing them tells an
404
+ // agent whose only problem was a duplicate team name to go call
405
+ // update_squad with a teamId it does not have — a dead end, when the fix
406
+ // was simply to pick another name.
407
+ if (err instanceof ApiError && err.status === 409) {
408
+ if (err.teamId !== undefined || /team_exists/i.test(err.message)) {
409
+ const existing = err.teamId ?? '(see my_squads)';
410
+ return fail(new Error(`This wallet already owns a squad (${existing}). A wallet holds at most one. ` +
411
+ 'Call update_squad with that teamId to change the lineup.'));
412
+ }
413
+ if (/TEAM_NAME_TAKEN/i.test(err.message)) {
414
+ return fail(new Error(`The team name "${name}" is already used by another manager. Team names are unique ` +
415
+ 'across the whole game — retry create_squad with a different name. ' +
416
+ 'Your squad itself was fine.'));
417
+ }
418
+ }
419
+ return fail(err);
420
+ }
421
+ });
422
+ server.registerTool('update_squad', {
423
+ title: 'Replace a squad’s lineup',
424
+ description: 'Rewrite the lineup of a squad you own — this is how you iterate after a friendly. ' +
425
+ `Requires login. The same rules apply: ${SQUAD_RULES} ` +
426
+ 'The edit applies to the next not-yet-simulated match; completed matches are never rewritten. ' +
427
+ 'nationCode cannot change. ' +
428
+ 'MINTED players are partly immutable, and the two halves fail differently. ' +
429
+ 'Renaming one is REJECTED — the whole save fails with a 409, because the name is the ' +
430
+ 'on-chain NFT\u2019s. Submitted position and attribute changes are IGNORED instead: the ' +
431
+ 'persisted asset wins and the squad comes back looking like your edit applied when it ' +
432
+ 'did not. Move a minted player by giving its playerId a different slotIndex; only ' +
433
+ 'unminted players can be renamed or restatted.',
434
+ inputSchema: {
435
+ teamId: z.string().min(1).describe('A squad this agent owns — see my_squads'),
436
+ players: z.array(PlayerSchema).length(11),
437
+ name: z
438
+ .string()
439
+ .min(1)
440
+ .max(TEAM_NAME_MAX)
441
+ .optional()
442
+ .describe('Rename the team. Omit to keep the current name.'),
443
+ },
444
+ annotations: { readOnlyHint: false, idempotentHint: true },
445
+ }, async ({ teamId, players, name }) => {
446
+ try {
447
+ return ok(await client.updateTeam(teamId, { players, ...(name === undefined ? {} : { name }) }));
448
+ }
449
+ catch (err) {
450
+ return fail(err);
451
+ }
452
+ });
453
+ server.registerTool('my_squads', {
454
+ title: 'List this agent’s squads',
455
+ description: 'Squads owned by the signed-in wallet. Requires login.',
456
+ inputSchema: {},
457
+ annotations: { readOnlyHint: true },
458
+ }, async () => {
459
+ try {
460
+ return ok(await client.myTeams());
461
+ }
462
+ catch (err) {
463
+ return fail(err);
464
+ }
465
+ });
466
+ server.registerTool('catch_up', {
467
+ title: 'What happened since last time',
468
+ description: 'One call for everything about this manager: squads, league rank, playoff standing and ' +
469
+ 'cooldown, finished matches with their results and timestamps, next fixture, honours, and ' +
470
+ 'career record. Requires login. ' +
471
+ 'Call this FIRST whenever you return to the game after any gap — cups run daily and finish ' +
472
+ 'hours after they start, so results almost always land while you are away. ' +
473
+ 'sinceIso MARKS, it does not filter: every match in the window comes back, each with ' +
474
+ 'isNew relative to your cursor. Nothing is hidden because a cup or league match reports ' +
475
+ 'its scheduled kickoff rather than when it actually finished, so a late result can carry ' +
476
+ 'an old timestamp and arrive with isNew false. Track the matchIds you have processed and ' +
477
+ 'skip those; do not rely on the timestamp alone.',
478
+ inputSchema: {
479
+ sinceIso: z
480
+ .string()
481
+ .optional()
482
+ .describe('ISO-8601 timestamp, usually the finishedAt of the newest match you saw last ' +
483
+ 'session. Marks each match isNew relative to it; nothing is removed.'),
484
+ historyLimit: z
485
+ .number()
486
+ .int()
487
+ .min(1)
488
+ .max(200)
489
+ .optional()
490
+ .describe(`How many recent matches to look through. Defaults to ${String(DEFAULT_HISTORY_LIMIT)}, ` +
491
+ 'at most 200 — a hard ceiling, since the history endpoint has no cursor. Results ' +
492
+ 'older than the newest 200 cannot be reached through this tool at all.'),
493
+ },
494
+ annotations: { readOnlyHint: true },
495
+ }, async ({ sinceIso, historyLimit }) => {
496
+ try {
497
+ const snapshot = (await client.dashboard());
498
+ // The dashboard's own teamHistory is capped at five rows — fewer than a
499
+ // single cup day produces — so an agent returning after a cup would
500
+ // silently lose the older half of what it came back to read. Re-fetch
501
+ // the history from the endpoint that paginates.
502
+ const teamId = snapshot.teamHistory?.teamId ?? (snapshot.teams?.teams ?? [])[0]?.teamId ?? null;
503
+ // A null teamId means "no squad" ONLY if the dashboard could actually
504
+ // look. Its team read can time out, and treating that as absence told an
505
+ // agent its squad and history had vanished — and invited it to create
506
+ // another one.
507
+ const teamLookupFailed = typeof snapshot.teams?.error === 'string' ||
508
+ typeof snapshot.teamHistory?.error === 'string';
509
+ const squadUnknown = teamId === null && teamLookupFailed;
510
+ const limit = historyLimit ?? DEFAULT_HISTORY_LIMIT;
511
+ let fetched = null;
512
+ let historyError = null;
513
+ if (teamId !== null) {
514
+ try {
515
+ fetched = await client.teamHistory(teamId, limit);
516
+ }
517
+ catch (err) {
518
+ fetched = null;
519
+ historyError = err instanceof Error ? err.message : String(err);
520
+ }
521
+ }
522
+ // The dashboard's copy of history is a FALLBACK, and a thinner one: its
523
+ // rows carry neither resultBasis nor shootoutPossible. The note below
524
+ // tells agents to tell a definitive group draw from a possible shootout
525
+ // using exactly those fields, so serving these rows silently would have
526
+ // them answer that question from a row that never said.
527
+ const usedFallback = fetched === null && (snapshot.teamHistory?.matches?.length ?? 0) > 0;
528
+ const all = (fetched?.matches ?? snapshot.teamHistory?.matches ?? []).map(
529
+ // Standings are re-reported per row and dominate the payload; rank is
530
+ // already in `playoff`/`leaderboard` at the top level.
531
+ ({ teamLeagueStanding: _t, opponentLeagueStanding: _o, ...keep }) => keep);
532
+ const cutoff = sinceIso === undefined ? null : Date.parse(sinceIso);
533
+ if (cutoff !== null && Number.isNaN(cutoff)) {
534
+ return fail(new Error(`sinceIso is not a valid timestamp: "${sinceIso}"`));
535
+ }
536
+ // The cursor MARKS matches; it does not drop them.
537
+ //
538
+ // `finishedAt` is the completion time only for playoff matches — for cup
539
+ // and league ones the API reports the scheduled kickoff (playoff.ts:
540
+ // `m.finalizedAt ?? m.kickoffAt`), and no completion time is persisted
541
+ // for them at all. So a match finished late carries a timestamp that can
542
+ // sit behind any cursor, by any margin: a fixed lookback just moves the
543
+ // outage length at which results start vanishing.
544
+ //
545
+ // Since this side cannot know completion order, it does not pretend to.
546
+ // Every match in the window is returned with `isNew` relative to the
547
+ // cursor. Showing a result twice costs an agent nothing; never showing it
548
+ // loses the only reason it had to change the squad.
549
+ //
550
+ // Which is why the note tells agents to de-duplicate by matchId rather
551
+ // than to trust this flag: a late-finished match is marked isNew:false
552
+ // by exactly the timestamp problem described above, so an agent that
553
+ // filtered on it would throw away the results this window was widened to
554
+ // keep. The flag is a hint about the timestamp, not a claim about what
555
+ // the agent has seen.
556
+ const matches = all.map((m) => ({
557
+ ...m,
558
+ ...(cutoff === null ? {} : { isNew: Date.parse(m.finishedAt) > cutoff }),
559
+ }));
560
+ const newCount = cutoff === null ? all.length : matches.filter((m) => m.isNew).length;
561
+ return ok({
562
+ ...snapshot,
563
+ teamHistory: { teamId, matches },
564
+ history: {
565
+ ...(sinceIso === undefined
566
+ ? {}
567
+ : {
568
+ sinceIso,
569
+ newCount,
570
+ note: 'finishedAt is the scheduled kickoff for cup and league matches, not the ' +
571
+ 'completion time, so a late-finished match can carry a timestamp older than ' +
572
+ 'your cursor — and is then marked isNew:false even though you have never ' +
573
+ 'seen it. Nothing is filtered out, so decide what is unseen by MATCHID ' +
574
+ 'against the ones you have already processed; isNew is a timestamp hint, ' +
575
+ 'not the answer. Acting on it alone discards exactly the late results this ' +
576
+ 'unfiltered window exists to preserve. ' +
577
+ 'This window is the newest `limit` matches and there is no way to page ' +
578
+ 'past it, so after an absence of more than that many matches the older ' +
579
+ 'ones are gone — raise historyLimit (max 200) and visit more often. ' +
580
+ 'Each result comes from the recorded score (resultBasis), which already ' +
581
+ 'includes extra-time goals but NOT a shootout. Only a D on a row with ' +
582
+ 'shootoutPossible:true can be hiding one — knockout ties, and friendlies ' +
583
+ 'played without allowDraw. On shootoutPossible:false (cup GROUP matches, and ' +
584
+ 'friendlies that asked for draws) a D is a real draw: do not spend a ' +
585
+ 'get_match on it, and do not discount it as undecided.' +
586
+ (usedFallback
587
+ ? ' THESE ROWS ARE THE FALLBACK COPY and carry neither field, so every D ' +
588
+ 'here is unresolved: check get_match before drawing any conclusion from ' +
589
+ 'one, or call catch_up again once the history endpoint answers.'
590
+ : ''),
591
+ }),
592
+ returned: matches.length,
593
+ fetched: all.length,
594
+ limit,
595
+ // Three states, not two. A full page means older matches exist
596
+ // beyond this window; a FAILED fetch means there may be more we
597
+ // could not see, and saying false there is the one answer that
598
+ // stops an agent from retrying. But a manager with NO SQUAD has
599
+ // nothing to fetch — reporting that as truncated would have it
600
+ // retry forever against an empty career.
601
+ mayHaveMore: squadUnknown ? true : teamId === null ? false : fetched === null ? true : all.length >= limit,
602
+ source: squadUnknown
603
+ ? 'unknown — the dashboard could not read your teams'
604
+ : teamId === null
605
+ ? 'no squad yet'
606
+ : fetched === null
607
+ ? 'dashboard (capped at 5)'
608
+ : 'team history',
609
+ ...(squadUnknown
610
+ ? {
611
+ warning: 'The dashboard could not read your teams, so this cannot tell whether you ' +
612
+ 'have a squad. Do NOT create another one on the strength of this — call ' +
613
+ 'catch_up again, or my_squads.',
614
+ }
615
+ : historyError === null || teamId === null
616
+ ? {}
617
+ : {
618
+ warning: `Match history could not be read (${historyError}), so this shows only the ` +
619
+ 'five rows the dashboard carries. A cup day produces more than that — ' +
620
+ 'call catch_up again before concluding anything from it.',
621
+ }),
622
+ },
623
+ });
624
+ }
625
+ catch (err) {
626
+ return fail(err);
627
+ }
628
+ });
629
+ server.registerTool('get_squad', {
630
+ title: 'Read any squad',
631
+ description: 'Public squad details by id — use it to scout an opponent before a friendly.',
632
+ inputSchema: { teamId: z.string().min(1) },
633
+ annotations: { readOnlyHint: true },
634
+ }, async ({ teamId }) => {
635
+ try {
636
+ return ok(await client.team(teamId));
637
+ }
638
+ catch (err) {
639
+ return fail(err);
640
+ }
641
+ });
642
+ server.registerTool('play_friendly', {
643
+ title: 'Play a friendly match',
644
+ description: 'Simulate a friendly between two squads and return the result with its event stream. ' +
645
+ 'Instant, and moves no standing. Requires login, and homeTeamId must be a squad you own. ' +
646
+ 'MAY NOT BE FREE: where growth tracking applies, each friendly permanently increments ' +
647
+ 'tenure and career_matches for all eleven players on YOUR side (the away squad is ' +
648
+ 'untouched), and a higher career count lowers a player’s remaining growth ceiling for ' +
649
+ 'good. Check get_game_rules → growthTracking before bulk-running friendlies to chase ' +
650
+ 'statistical confidence; when it reports OFF they really are free. ' +
651
+ 'By DEFAULT a level score goes to penalties and someone wins — pass allowDraw=true if ' +
652
+ 'you want draws to stay draws, which is what you usually want when comparing squads.',
653
+ inputSchema: {
654
+ homeTeamId: z.string().min(1).describe('Your own squad — see my_squads'),
655
+ awayTeamId: z.string().min(1).describe('Any other squad — see get_leaderboard or get_cup'),
656
+ allowDraw: z
657
+ .boolean()
658
+ .optional()
659
+ .describe('Defaults to FALSE: a level score is decided by penalties unless you pass true.'),
660
+ },
661
+ annotations: { readOnlyHint: false, idempotentHint: false },
662
+ }, async ({ homeTeamId, awayTeamId, allowDraw }) => {
663
+ try {
664
+ return ok(await client.playFriendly({
665
+ homeTeamId,
666
+ awayTeamId,
667
+ ...(allowDraw === undefined ? {} : { allowDraw }),
668
+ }));
669
+ }
670
+ catch (err) {
671
+ return fail(err);
672
+ }
673
+ });
674
+ server.registerTool('play_playoff', {
675
+ title: 'Play a ranked playoff match',
676
+ description: 'Play one match on the weekly playoff ladder. Requires login and a squad. ' +
677
+ 'Unlike a friendly this COUNTS: it moves you up or down the ladder, and promotion or ' +
678
+ 'relegation between divisions is decided by where you finish the week. ' +
679
+ 'Your first call also enters you onto the ladder — until you make it you are not in the ' +
680
+ 'competitive season at all. The server picks the opponent. ' +
681
+ 'One match per 5 MINUTES per squad — that is the floor, and calling faster is answered ' +
682
+ '429, not queued. If you run on a schedule, once an hour is the recommended default ' +
683
+ '(24 ranked matches a day); every 5 minutes is the fastest the server allows. Take the ' +
684
+ 'next allowed moment from catch_up, which reports playoff.cooldown.nextMatchAt, rather ' +
685
+ 'than from a fixed timer.',
686
+ inputSchema: {},
687
+ annotations: { readOnlyHint: false, idempotentHint: false },
688
+ }, async () => {
689
+ try {
690
+ return ok(await client.playPlayoff());
691
+ }
692
+ catch (err) {
693
+ // Two different things answer 429 here: the ladder cooldown, and the
694
+ // generic per-wallet rate limiter that guards every route. Telling an
695
+ // agent to wait for playoff.cooldown.nextMatchAt when it actually just
696
+ // sent too many requests points it at a timestamp that will never
697
+ // explain the rejection.
698
+ if (err instanceof ApiError && err.status === 429) {
699
+ const isCooldown = /cooldown/i.test(err.message);
700
+ // Re-thrown as an ApiError, not a bare Error, so fail() can still
701
+ // append the timing the server sent. Wrapping it in `new Error` threw
702
+ // that away and left the advice "call catch_up and read nextMatchAt" —
703
+ // a whole extra round trip to learn what this rejection already said.
704
+ return fail(new ApiError(err.status, err.path, isCooldown
705
+ ? `${err.message} You are on cooldown between playoff matches. ` +
706
+ 'Friendlies are rate-limited separately and do not affect the ladder.'
707
+ : `${err.message} This is the request rate limit, not the ladder cooldown — ` +
708
+ 'slow down and retry shortly.', undefined, {
709
+ ...(err.retryAfterSeconds !== undefined
710
+ ? { retryAfterSeconds: err.retryAfterSeconds }
711
+ : {}),
712
+ ...(err.retryAt !== undefined ? { retryAt: err.retryAt } : {}),
713
+ ...(err.remaining !== undefined ? { remaining: err.remaining } : {}),
714
+ }));
715
+ }
716
+ return fail(err);
717
+ }
718
+ });
719
+ server.registerTool('get_match', {
720
+ title: 'Read a match',
721
+ description: 'Match result and ordered event stream by id. Replays are deterministic.',
722
+ inputSchema: { matchId: z.string().min(1) },
723
+ annotations: { readOnlyHint: true },
724
+ }, async ({ matchId }) => {
725
+ try {
726
+ return ok(await client.match(matchId));
727
+ }
728
+ catch (err) {
729
+ return fail(err);
730
+ }
731
+ });
732
+ server.registerTool('get_cup', {
733
+ title: 'Read a daily cup',
734
+ description: 'Result and fixtures for a cup date (YYYY-MM-DD, UTC). The cup opens at 04:00 UTC. ' +
735
+ 'Without a stage you get the champion, the final score, and how many matches each stage ' +
736
+ 'holds. Pass a stage to get those fixtures with their teamIds and scores — a full cup is ' +
737
+ '104 matches, so they are not all returned at once.',
738
+ inputSchema: {
739
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
740
+ stage: z
741
+ .enum(CUP_STAGES)
742
+ .optional()
743
+ .describe('qualifier (a mini-league that only runs when more than 48 teams enter), ' +
744
+ 'group, r32, r16, qf, sf, 3rd, or final.'),
745
+ },
746
+ annotations: { readOnlyHint: true },
747
+ }, async ({ date, stage }) => {
748
+ try {
749
+ // "There was no cup that day" is an ANSWER, not a failure. The summary
750
+ // endpoint 404s whenever the bracket is missing — a date that never ran,
751
+ // a day still ahead, or a deployment with no scheduler at all — and
752
+ // surfacing that as an MCP error had the agent read a settled fact as a
753
+ // transient outage and retry it forever. Non-404s still propagate: those
754
+ // really are outages.
755
+ let summary = null;
756
+ try {
757
+ summary = await client.cup(date);
758
+ }
759
+ catch (err) {
760
+ if (!(err instanceof ApiError) || err.status !== 404)
761
+ throw err;
762
+ }
763
+ if (summary === null) {
764
+ return ok({
765
+ date,
766
+ cup: null,
767
+ fixtures: null,
768
+ note: 'No cup on this date. Either it never ran (a date before this deployment, or one ' +
769
+ 'still ahead), or nothing creates cups here — they come from the SCHEDULER, a ' +
770
+ 'separate process from this API. Check another date before concluding anything; ' +
771
+ 'if every date answers this, the scheduler is not running and no amount of ' +
772
+ 'waiting will change it.',
773
+ });
774
+ }
775
+ // The cup endpoint has no fixtures in it at all, despite the tool being
776
+ // the obvious place an agent looks for opponents. Read the bracket too.
777
+ //
778
+ // Only a 404 means "this cup has no bracket" — a pending cup, a date
779
+ // that never ran. Anything else is an outage, and swallowing it would
780
+ // report a completed cup as having no inspectable fixtures, so the agent
781
+ // moves on instead of retrying.
782
+ let bracket = null;
783
+ try {
784
+ bracket = await client.cupBracket(date);
785
+ }
786
+ catch (err) {
787
+ if (!(err instanceof ApiError) || err.status !== 404)
788
+ throw err;
789
+ }
790
+ if (bracket === null) {
791
+ return ok({
792
+ ...summary,
793
+ fixtures: null,
794
+ note: 'No bracket for this cup — it has not been drawn yet.',
795
+ });
796
+ }
797
+ // `matchIds` repeats every matches[].matchId — pure duplication on a
798
+ // surface where the response is billed as context.
799
+ const { matchIds: _ignored, matches, ...rest } = bracket;
800
+ const stages = {};
801
+ for (const m of matches)
802
+ stages[m.matchType] = (stages[m.matchType] ?? 0) + 1;
803
+ if (stage === undefined) {
804
+ return ok({
805
+ ...summary,
806
+ ...rest,
807
+ stages,
808
+ note: 'Call again with a stage to get those fixtures.',
809
+ });
810
+ }
811
+ return ok({
812
+ ...summary,
813
+ stage,
814
+ stages,
815
+ matches: matches.filter((m) => m.matchType === stage),
816
+ });
817
+ }
818
+ catch (err) {
819
+ return fail(err);
820
+ }
821
+ });
822
+ server.registerTool('get_leaderboard', {
823
+ title: 'Read the leaderboard',
824
+ description: 'Current standings across managers, best first. A row\u2019s topTeamId is the squad id to ' +
825
+ 'pass as awayTeamId in play_friendly. Rows whose team has since been deleted carry ' +
826
+ 'topTeamId null and playable:false — skip those. ' +
827
+ 'Returns the top 20 by default. For your OWN rank use catch_up, which reports it directly ' +
828
+ 'however far down the board you are.',
829
+ inputSchema: {
830
+ limit: z
831
+ .number()
832
+ .int()
833
+ .min(1)
834
+ .max(LEADERBOARD_MAX_LIMIT)
835
+ .optional()
836
+ .describe(`How many rows from the top. Defaults to 20, at most ${String(LEADERBOARD_MAX_LIMIT)}.`),
837
+ },
838
+ annotations: { readOnlyHint: true },
839
+ }, async ({ limit }) => {
840
+ try {
841
+ // Ask for exactly what was requested. The API enriches every returned
842
+ // row through teamStore.getTeam, so always pulling the 500-row maximum
843
+ // would turn a routine top-20 question into 500 team lookups.
844
+ const want = limit ?? DEFAULT_LEADERBOARD_ROWS;
845
+ const fetched = await client.leaderboard(want);
846
+ // A deleted team leaves its manager on the board with topTeamId null.
847
+ // Marking those rows keeps the promise that topTeamId is a usable
848
+ // opponent — an agent that picks one otherwise gets rejected locally
849
+ // for a reason the board never mentioned.
850
+ const rows = fetched.map((r) => r.topTeamId === null ? { ...r, playable: false } : r);
851
+ // A full page means the board did not end here — it is a window, not a
852
+ // population. Reporting rows.length as a total would tell an agent the
853
+ // game has exactly 20 managers.
854
+ return ok({
855
+ rows,
856
+ returned: rows.length,
857
+ requested: want,
858
+ mayHaveMore: rows.length >= want,
859
+ });
860
+ }
861
+ catch (err) {
862
+ return fail(err);
863
+ }
864
+ });
865
+ return server;
866
+ }
867
+ //# sourceMappingURL=server.js.map