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/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/client.d.ts +319 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +728 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +867 -0
- package/dist/server.js.map +1 -0
- package/dist/wallet.d.ts +88 -0
- package/dist/wallet.d.ts.map +1 -0
- package/dist/wallet.js +562 -0
- package/dist/wallet.js.map +1 -0
- package/package.json +63 -0
- package/skill/SKILL.md +375 -0
- package/skill/reference/measurements.md +192 -0
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
|