kill-switch-mcp 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts DELETED
@@ -1,1088 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Kill Switch MCP Server
4
- *
5
- * This is the bridge between Claude Code and the Kill Switch tournament arena.
6
- * It runs locally on the player's machine and connects to the game server.
7
- *
8
- * Tools:
9
- * - login: Log in, set up, and connect to the game.
10
- * - execute_code: Send commands to your bot during gameplay.
11
- * - get_status: Check your bot's current state without executing code.
12
- * - join_game: Join a free game mode queue.
13
- * - wait_for_game_start: Wait for the game to begin.
14
- * - disconnect_bot: Disconnect from the game.
15
- * - setup_game_wallet: Set up a Tempo wallet access key for paid tournaments.
16
- * - check_wallet: Check game wallet address and USDC balance.
17
- * - tournament_schedule: View upcoming paid tournaments.
18
- * - join_tournament: Join a paid tournament (deposits buy-in from game wallet).
19
- */
20
-
21
- import { Server } from '@modelcontextprotocol/sdk/server/index.js';
22
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
23
- import {
24
- CallToolRequestSchema,
25
- ListToolsRequestSchema
26
- } from '@modelcontextprotocol/sdk/types.js';
27
- import { existsSync } from 'fs';
28
- import { readFile } from 'fs/promises';
29
- import { join } from 'path';
30
- import { botManager } from './api/index.js';
31
- import { formatWorldState } from './sdk/formatter.js';
32
- import {
33
- hasGameWallet,
34
- getWalletInfo,
35
- generateAccessKey,
36
- saveAccessKey,
37
- getTournamentSchedule,
38
- joinTournament,
39
- } from './wallet/index.js';
40
-
41
- // ── Configuration ──
42
- // Server URL from CLI args or env. Defaults to localhost for local dev.
43
- // In .mcp.json: { "args": ["run", "mcp/server.ts", "--server", "play.killswitch.gg"] }
44
- const SERVER_URL = (() => {
45
- const idx = process.argv.indexOf('--server');
46
- if (idx !== -1 && process.argv[idx + 1]) return process.argv[idx + 1];
47
- return process.env.KILL_SWITCH_SERVER || 'localhost';
48
- })();
49
-
50
- console.error(`[Kill Switch MCP] Server: ${SERVER_URL}`);
51
-
52
- // ── Game Knowledge ──
53
- // This description is what teaches Claude about the game. It's included in tool descriptions
54
- // so Claude knows the full context without needing to read docs.
55
- const GAME_DESCRIPTION = `Kill Switch is a Hunger Games-style battle royale where AI agents fight to the death.
56
-
57
- YOU are the brain of your player's bot. The human talks to you about strategy, and you
58
- execute it by sending code to the game via execute_code.
59
-
60
- ## How It Works
61
- 1. Call login to register and connect (lands you in the lobby)
62
- 2. Your bot spawns in Barbarian Village lobby with EMPTY inventory
63
- 3. Call join_game with mode "shorty" (or "longy" when available) to queue up
64
- 4. Chat with your human about strategy while waiting
65
- 5. Call wait_for_game_start — it blocks until the admin starts the match
66
- 6. When it returns, you're at the arena edge — run to the center to grab loot!
67
-
68
- ## Game Modes
69
- - **SHORTY**: Quick battle royale at Draynor Manor. 50 atk/str/def, 99 HP. Loot & fight.
70
- - **LONGY**: (Coming soon) Large survival map. Level 1 stats, skill up, craft, outlast.
71
-
72
- ## The Arena (Shorty)
73
- - You spawn on the PERIMETER of Draynor Manor with nothing
74
- - Items are scattered on the ground in a cornucopia pattern:
75
- - Outer ring: bronze/iron weapons, leather armor, bread/meat
76
- - Mid ring: mithril/adamant weapons, chainmail, trout/salmon
77
- - Center: rune weapons, rune/adamant armor, lobster/swordfish
78
- - Better items are closer to the center — but so is everyone else!
79
-
80
- ## PERMADEATH
81
- - If you die, your agent is DEAD FOREVER. No respawns, no second chances.
82
- - Only the last agent standing wins. Winners earn party hats!
83
-
84
- ## Key Actions
85
- - bot.pickupItem(/item name/i) — pick up ground items (CRITICAL — you start empty!)
86
- - bot.equipItem(/item name/i) — equip weapons and armor from inventory
87
- - bot.eatFood(/food name/i) — eat food to heal
88
- - bot.attackPlayer("name") or bot.attackPlayer(/pattern/i) — attack another player
89
- - sdk.getNearbyPlayers() — see nearby players (name, combatLevel, distance, x, z)
90
- - sdk.findGroundItem(/pattern/i) — find items on the ground near you
91
- - sdk.getState().player.hitpoints — your current HP
92
- - sdk.getInventory() — check what you've picked up
93
-
94
- ## Combat Loop Pattern
95
- Write reactive loops in execute_code that run for 15-30 seconds, then return state:
96
-
97
- // Phase 1: Grab loot
98
- const items = sdk.findGroundItem(/scimitar|sword|lobster|chainbody/i);
99
- if (items) await bot.pickupItem(items);
100
-
101
- // Equip best weapon found
102
- const weapon = sdk.findInventoryItem(/rune scimitar|adamant|mithril scimitar/i);
103
- if (weapon) await bot.equipItem(weapon);
104
-
105
- // Phase 2: Fight
106
- const endTime = Date.now() + 15_000;
107
- while (Date.now() < endTime) {
108
- const hp = sdk.getState().player.hitpoints;
109
- if (hp < 25) {
110
- const food = sdk.findInventoryItem(/swordfish|lobster|salmon|trout|bread|meat/i);
111
- if (food) await bot.eatFood(food);
112
- }
113
- const players = sdk.getNearbyPlayers();
114
- if (players.length > 0) {
115
- const nearest = players.sort((a, b) => a.distance - b.distance)[0];
116
- await bot.attackPlayer(nearest);
117
- }
118
- await sdk.waitForTicks(2);
119
- }
120
- return { hp: sdk.getState().player.hitpoints, inventory: sdk.getInventory(), nearbyPlayers: sdk.getNearbyPlayers() };
121
-
122
- ## Key Tips
123
- - You start with NOTHING — picking up items is your first priority!
124
- - Grab a weapon first, then food, then armor
125
- - Risk vs reward: center has the best loot but everyone converges there
126
- - Keep execute_code calls to 15-30 seconds, then check state and adapt
127
- - The human gives you strategy ("rush center", "play safe", "grab and run") — you turn it into code`;
128
-
129
- // ── Server Setup ──
130
- const server = new Server(
131
- { name: 'kill-switch', version: '3.0.0' },
132
- { capabilities: { tools: {} } }
133
- );
134
-
135
- // ── Tool Definitions ──
136
- server.setRequestHandler(ListToolsRequestSchema, async () => {
137
- return {
138
- tools: [
139
- {
140
- name: 'login',
141
- description: `Log in to Kill Switch. Creates your account if needed, connects to the game server, and opens the browser client.
142
-
143
- Call this when the user says anything like "let's play", "join the game", "log in", "connect", etc.
144
-
145
- If the user already has a bot, this will reconnect. If not, it creates a new one. Idempotent — safe to call multiple times.
146
-
147
- ${GAME_DESCRIPTION}`,
148
- inputSchema: {
149
- type: 'object',
150
- properties: {
151
- agent_name: {
152
- type: 'string',
153
- description: 'Name for the bot (max 12 chars, alphanumeric). If not provided, a random name is generated. If the user has played before and wants to reuse their name, use that.'
154
- }
155
- }
156
- }
157
- },
158
- {
159
- name: 'execute_code',
160
- description: `Execute TypeScript code on your connected bot. The code runs in an async context with three globals:
161
- - bot: High-level actions (attackPlayer, eatFood, walkTo, equipItem, pickupItem, etc.)
162
- - sdk: Low-level state access (getState, getNearbyPlayers, getInventory, findGroundItem, etc.)
163
- - actions: Pre-built strategy functions loaded from your bot's actions/ directory
164
-
165
- Use actions for common patterns instead of writing loops from scratch:
166
- await actions.fightLoop({ eatAt: 25, duration: 15000 })
167
- await actions.lootAndEquip({ maxItems: 5 })
168
- await actions.kite({ safeHp: 40 })
169
-
170
- Or write custom code using bot/sdk directly for full control.
171
-
172
- You MUST call login before using this tool.`,
173
- inputSchema: {
174
- type: 'object',
175
- properties: {
176
- code: {
177
- type: 'string',
178
- description: 'TypeScript code to execute. Has access to bot (BotActions) and sdk (BotSDK).'
179
- },
180
- timeout: {
181
- type: 'number',
182
- description: 'Execution timeout in minutes (default: 2, max: 60)'
183
- }
184
- },
185
- required: ['code']
186
- }
187
- },
188
- {
189
- name: 'get_status',
190
- description: 'Check your bot\'s current state without executing any code. Returns position, HP, inventory, nearby players. Use this to assess the situation before deciding what to do.',
191
- inputSchema: {
192
- type: 'object',
193
- properties: {}
194
- }
195
- },
196
- {
197
- name: 'wait_for_game_start',
198
- description: `Wait for the game to start. Call this after login and join_game while chatting with your human about strategy.
199
-
200
- This tool blocks until the game starts and your bot is teleported to the arena. When it returns, the fight is ON — immediately start your combat loop!
201
-
202
- Typical flow:
203
- 1. login → connect to game
204
- 2. join_game → pick a mode
205
- 3. Chat strategy with human
206
- 4. wait_for_game_start → blocks until game starts
207
- 5. execute_code → start fighting!`,
208
- inputSchema: {
209
- type: 'object',
210
- properties: {
211
- timeout: {
212
- type: 'number',
213
- description: 'Max seconds to wait (default: 300 = 5 minutes)'
214
- }
215
- }
216
- }
217
- },
218
- {
219
- name: 'join_game',
220
- description: `Join a specific game mode queue. Call this after login when your human says which mode to play.
221
-
222
- Available modes:
223
- - "shorty": Quick battle royale at Draynor Manor. 50 atk/str/def, 99 HP. Grab loot, fight, last one standing wins.
224
- - "longy": (Coming soon) Large survival map with skill progression.
225
-
226
- After joining, call wait_for_game_start to wait for the game to begin.`,
227
- inputSchema: {
228
- type: 'object',
229
- properties: {
230
- mode: {
231
- type: 'string',
232
- enum: ['shorty', 'longy'],
233
- description: 'Game mode to join'
234
- }
235
- },
236
- required: ['mode']
237
- }
238
- },
239
- {
240
- name: 'disconnect_bot',
241
- description: 'Disconnect from the game. Use when done playing.',
242
- inputSchema: {
243
- type: 'object',
244
- properties: {}
245
- }
246
- },
247
- {
248
- name: 'setup_game_wallet',
249
- description: `Set up a game wallet for paid tournaments. This creates a local access key that lets you join paid tournament matches.
250
-
251
- IMPORTANT: This tool handles real (or testnet) money. Explain to the user what's happening at each step.
252
-
253
- Prerequisites:
254
- - The user must have the Tempo CLI installed (curl -fsSL https://tempo.xyz/install | bash)
255
- - The user must have run "tempo wallet login" to create their Tempo account
256
-
257
- This tool generates a local access key. The user then needs to authorize it on their Tempo account (requires biometric/passkey confirmation). After that, the key is stored locally in ~/.killswitch/ and used for all tournament deposits.
258
-
259
- Learn more about Tempo access keys: https://docs.tempo.xyz/protocol/tips/tip-1011`,
260
- inputSchema: {
261
- type: 'object',
262
- properties: {
263
- tempo_account_address: {
264
- type: 'string',
265
- description: 'The user\'s Tempo account address (0x...). Get this by running "tempo wallet whoami".'
266
- }
267
- },
268
- required: ['tempo_account_address']
269
- }
270
- },
271
- {
272
- name: 'check_wallet',
273
- description: `Check your game wallet status and USDC balance. Shows your Tempo account address and how much USDC you have available for tournament buy-ins.
274
-
275
- If no game wallet is set up, this will tell you to run setup_game_wallet first.`,
276
- inputSchema: {
277
- type: 'object',
278
- properties: {}
279
- }
280
- },
281
- {
282
- name: 'tournament_schedule',
283
- description: `View upcoming paid tournaments. Shows start times, buy-in amounts, and how many players have signed up.
284
-
285
- This is a read-only tool — it doesn't cost anything to check the schedule.`,
286
- inputSchema: {
287
- type: 'object',
288
- properties: {}
289
- }
290
- },
291
- {
292
- name: 'join_tournament',
293
- description: `Join a paid tournament by depositing the buy-in from your game wallet.
294
-
295
- THIS TOOL SPENDS REAL MONEY (or testnet money). Before calling this tool:
296
- 1. Tell the user exactly which tournament they're joining (time, buy-in amount)
297
- 2. Show their current wallet balance
298
- 3. Ask them to confirm they want to proceed
299
- 4. Only then call this tool
300
-
301
- The deposit is sent on-chain to the tournament's escrow contract. The user's buy-in is held in escrow until:
302
- - They win → they receive 90% of the pot
303
- - The match is cancelled → they get a full refund
304
- - The server goes down → they can claim a refund after 1 hour
305
-
306
- The user must be logged in (call login first) so we know their in-game username.`,
307
- inputSchema: {
308
- type: 'object',
309
- properties: {
310
- tournament_address: {
311
- type: 'string',
312
- description: 'The tournament contract address to join (from tournament_schedule)'
313
- }
314
- },
315
- required: ['tournament_address']
316
- }
317
- }
318
- ]
319
- };
320
- });
321
-
322
- // ── Action Loading ──
323
-
324
- /** Convert kebab-case filename to camelCase function name */
325
- function toCamelCase(str: string): string {
326
- return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
327
- }
328
-
329
- /**
330
- * Load custom actions from bots/<name>/actions/*.ts
331
- *
332
- * Each action file must have a default export:
333
- * export default async function(bot, sdk, opts) { ... }
334
- *
335
- * The filename becomes the function name:
336
- * fight-loop.ts → actions.fightLoop(opts)
337
- */
338
- async function loadActions(botName: string, bot: any, sdk: any): Promise<{ actions: Record<string, Function>; descriptions: string[] }> {
339
- const { existsSync, readdirSync, cpSync, readFileSync } = await import('fs');
340
- const { join, basename } = await import('path');
341
- const { pathToFileURL } = await import('url');
342
-
343
- const botsDir = join(process.cwd(), 'bots');
344
- const actionsDir = join(botsDir, botName, 'actions');
345
- // Defaults ship with the npm package, resolve relative to this file
346
- const defaultsDir = join(new URL('.', import.meta.url).pathname, '..', 'defaults', 'actions');
347
-
348
- console.error(`[Kill Switch] Loading actions from ${actionsDir}`);
349
-
350
- // Copy defaults if no actions dir exists
351
- if (!existsSync(actionsDir) && existsSync(defaultsDir)) {
352
- cpSync(defaultsDir, actionsDir, { recursive: true });
353
- console.error(`[Kill Switch] Copied default actions to ${actionsDir}`);
354
- }
355
-
356
- const actions: Record<string, Function> = {};
357
- const descriptions: string[] = [];
358
-
359
- if (!existsSync(actionsDir)) {
360
- console.error(`[Kill Switch] No actions directory found at ${actionsDir}`);
361
- return { actions, descriptions };
362
- }
363
-
364
- const files = readdirSync(actionsDir).filter(f => f.endsWith('.ts'));
365
- console.error(`[Kill Switch] Found ${files.length} action files: ${files.join(', ')}`);
366
-
367
- for (const file of files) {
368
- const filePath = join(actionsDir, file);
369
- const name = toCamelCase(basename(file, '.ts'));
370
-
371
- try {
372
- // Cache-bust so re-login picks up edited action files
373
- const fileUrl = pathToFileURL(filePath).href + `?t=${Date.now()}`;
374
- const mod = await import(fileUrl);
375
-
376
- if (typeof mod.default !== 'function') {
377
- console.error(`[Kill Switch] Action ${file} has no default export, skipping`);
378
- continue;
379
- }
380
-
381
- // Wrap: actions.name(opts) → mod.default(bot, sdk, opts)
382
- actions[name] = (opts?: any) => mod.default(bot, sdk, opts);
383
-
384
- // Extract JSDoc description from file for discovery
385
- const source = readFileSync(filePath, 'utf-8');
386
- const jsdocMatch = source.match(/\/\*\*\s*\n\s*\*\s*(.+?)[\n*]/);
387
- const desc = jsdocMatch ? jsdocMatch[1].trim() : 'Custom action';
388
-
389
- descriptions.push(` - actions.${name}(opts) — ${desc}`);
390
- console.error(`[Kill Switch] Loaded action: ${name} (${file})`);
391
- } catch (e: any) {
392
- console.error(`[Kill Switch] Failed to load action ${file}: ${e.message}`);
393
- }
394
- }
395
-
396
- console.error(`[Kill Switch] Loaded ${Object.keys(actions).length} actions: ${Object.keys(actions).join(', ')}`);
397
- return { actions, descriptions };
398
- }
399
-
400
- // ── Helpers ──
401
- function generateRandomName(): string {
402
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
403
- let result = '';
404
- for (let i = 0; i < 9; i++) {
405
- result += chars.charAt(Math.floor(Math.random() * chars.length));
406
- }
407
- return result;
408
- }
409
-
410
- function generateRandomPassword(): string {
411
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
412
- let result = '';
413
- for (let i = 0; i < 12; i++) {
414
- result += chars.charAt(Math.floor(Math.random() * chars.length));
415
- }
416
- return result;
417
- }
418
-
419
- function parseEnv(content: string): Record<string, string> {
420
- const result: Record<string, string> = {};
421
- for (const line of content.split('\n')) {
422
- const trimmed = line.trim();
423
- if (!trimmed || trimmed.startsWith('#')) continue;
424
- const [key, ...valueParts] = trimmed.split('=');
425
- if (key && valueParts.length > 0) {
426
- result[key.trim()] = valueParts.join('=').trim();
427
- }
428
- }
429
- return result;
430
- }
431
-
432
- // Track the active bot for this session
433
- let activeBotName: string | null = null;
434
- let activeActions: Record<string, Function> = {};
435
- let activeActionDescriptions: string[] = [];
436
-
437
- function getActiveConnection() {
438
- if (!activeBotName) return null;
439
- return botManager.get(activeBotName) || null;
440
- }
441
-
442
- // ── Tool Handlers ──
443
- server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
444
- const { name, arguments: args } = request.params;
445
-
446
- try {
447
- switch (name) {
448
- case 'login': {
449
- let botName = (args?.agent_name as string) || '';
450
-
451
- // Check if we already have an active connection
452
- if (activeBotName) {
453
- const existing = botManager.get(activeBotName);
454
- if (existing?.connected) {
455
- const state = existing.sdk.getState();
456
- const parts = [`Already connected as "${activeBotName}".`];
457
- if (state) {
458
- parts.push('');
459
- parts.push('── Current State ──');
460
- parts.push(formatWorldState(state, existing.sdk.getStateAge()));
461
- }
462
- parts.push('');
463
- parts.push('Your bot is in the game. Talk strategy with your human while waiting for the tournament to start!');
464
- return { content: [{ type: 'text', text: parts.join('\n') }] };
465
- }
466
- }
467
-
468
- // Step 1: Find or create bot credentials
469
- const botsDir = join(process.cwd(), 'bots');
470
- let username: string;
471
- let password: string;
472
-
473
- if (botName) {
474
- // User specified a name — check if it already exists
475
- const envPath = join(botsDir, botName, 'bot.env');
476
- if (existsSync(envPath)) {
477
- console.error(`[Kill Switch] Found existing bot "${botName}"`);
478
- const env = parseEnv(await readFile(envPath, 'utf-8'));
479
- username = env.BOT_USERNAME || botName;
480
- password = env.PASSWORD || '';
481
- } else {
482
- // Create new bot
483
- console.error(`[Kill Switch] Creating new bot "${botName}"`);
484
- username = botName;
485
- password = generateRandomPassword();
486
-
487
- // Create bot directory and env file
488
- const { mkdirSync, writeFileSync } = await import('fs');
489
- const botDir = join(botsDir, botName);
490
- mkdirSync(botDir, { recursive: true });
491
- writeFileSync(join(botDir, 'bot.env'), [
492
- `BOT_USERNAME=${username}`,
493
- `PASSWORD=${password}`,
494
- `SERVER=${SERVER_URL}`,
495
- `SHOW_CHAT=false`
496
- ].join('\n'));
497
- }
498
- } else {
499
- // No name specified — check for any existing bot first
500
- const { readdirSync } = await import('fs');
501
- if (existsSync(botsDir)) {
502
- const dirs = readdirSync(botsDir).filter(d =>
503
- d !== '_template' && existsSync(join(botsDir, d, 'bot.env'))
504
- );
505
- if (dirs.length > 0) {
506
- // Reuse the first existing bot
507
- botName = dirs[0];
508
- console.error(`[Kill Switch] Reusing existing bot "${botName}"`);
509
- const env = parseEnv(await readFile(join(botsDir, botName, 'bot.env'), 'utf-8'));
510
- username = env.BOT_USERNAME || botName;
511
- password = env.PASSWORD || '';
512
- } else {
513
- // Generate a random name
514
- botName = generateRandomName();
515
- username = botName;
516
- password = generateRandomPassword();
517
-
518
- const { mkdirSync, writeFileSync } = await import('fs');
519
- const botDir = join(botsDir, botName);
520
- mkdirSync(botDir, { recursive: true });
521
- writeFileSync(join(botDir, 'bot.env'), [
522
- `BOT_USERNAME=${username}`,
523
- `PASSWORD=${password}`,
524
- `SERVER=${SERVER_URL}`,
525
- `SHOW_CHAT=false`
526
- ].join('\n'));
527
- console.error(`[Kill Switch] Created new bot "${botName}"`);
528
- }
529
- } else {
530
- botName = generateRandomName();
531
- username = botName;
532
- password = generateRandomPassword();
533
-
534
- const { mkdirSync, writeFileSync } = await import('fs');
535
- const botDir = join(botsDir, botName);
536
- mkdirSync(botDir, { recursive: true });
537
- writeFileSync(join(botDir, 'bot.env'), [
538
- `BOT_USERNAME=${username}`,
539
- `PASSWORD=${password}`,
540
- `SERVER=${SERVER_URL}`,
541
- `SHOW_CHAT=false`
542
- ].join('\n'));
543
- console.error(`[Kill Switch] Created new bot "${botName}"`);
544
- }
545
- }
546
-
547
- // Step 1.5: Check if agent is alive (permadeath check)
548
- try {
549
- const isLocal = SERVER_URL === 'localhost' || SERVER_URL === '127.0.0.1';
550
- const webBase = isLocal ? `http://localhost:8888` : `https://${SERVER_URL}`;
551
- const aliveRes = await fetch(`${webBase}/api/agent-alive?username=${encodeURIComponent(username!.toLowerCase())}`);
552
- const aliveData = await aliveRes.json() as any;
553
- if (aliveData.alive === false) {
554
- return errorResponse(
555
- `Agent "${username}" is PERMANENTLY DEAD (${aliveData.wins} wins, ${aliveData.kills} kills). ` +
556
- `This agent can never play again. Create a new agent with a different name to play.`
557
- );
558
- }
559
- } catch (e: any) {
560
- console.error(`[Kill Switch] Agent alive check failed (continuing): ${e.message}`);
561
- // Non-fatal — server might not be running yet
562
- }
563
-
564
- // Step 2: Connect to the game
565
- console.error(`[Kill Switch] Connecting "${botName}" to ${SERVER_URL}...`);
566
-
567
- // Suppress stdout logs during connection (MCP uses stdout for JSON-RPC)
568
- const originalLog = console.log;
569
- const originalWarn = console.warn;
570
- console.log = (...a) => console.error('[log]', ...a);
571
- console.warn = (...a) => console.error('[warn]', ...a);
572
-
573
- try {
574
- const connection = await botManager.connect(botName);
575
-
576
- // Wait for initial game state
577
- try {
578
- await connection.sdk.waitForCondition(() => connection.sdk.getState() !== null, 20000);
579
- } catch {
580
- console.error(`[Kill Switch] Warning: initial state not received within 20s`);
581
- }
582
-
583
- activeBotName = botName;
584
-
585
- // Load custom actions
586
- const { actions, descriptions } = await loadActions(botName, connection.bot, connection.sdk);
587
- activeActions = actions;
588
- activeActionDescriptions = descriptions;
589
-
590
- const state = connection.sdk.getState();
591
- const parts = [
592
- `Connected as "${username}"!`,
593
- '',
594
- 'Your bot is in the game. A browser window should have opened showing the game view.',
595
- '',
596
- 'You\'re in the tournament lobby (Barbarian Village) with:',
597
- ' - EMPTY inventory and no equipment',
598
- ' - Combat stats: 50 Attack, 50 Strength, 50 Defence, 99 Hitpoints',
599
- '',
600
- 'NEXT STEP: Call join_game with mode "shorty" (or "longy" when available) to queue up.',
601
- '',
602
- 'Game modes:',
603
- ' - SHORTY: Quick battle royale at Draynor Manor. Loot, fight, last one standing.',
604
- ' - LONGY: (Coming soon) Large survival map with skill progression.',
605
- '',
606
- 'After joining a game, call wait_for_game_start to wait for the game to begin.',
607
- ];
608
-
609
- if (activeActionDescriptions.length > 0) {
610
- parts.push('');
611
- parts.push('── Loaded Actions ──');
612
- parts.push('Use these in execute_code via the `actions` object:');
613
- parts.push(...activeActionDescriptions);
614
- }
615
-
616
- if (state) {
617
- parts.push('');
618
- parts.push('── Current State ──');
619
- parts.push(formatWorldState(state, connection.sdk.getStateAge()));
620
- }
621
-
622
- return { content: [{ type: 'text', text: parts.join('\n') }] };
623
- } finally {
624
- console.log = originalLog;
625
- console.warn = originalWarn;
626
- }
627
- }
628
-
629
- case 'get_status': {
630
- const connection = getActiveConnection();
631
- if (!connection) {
632
- return errorResponse('Not connected. Call login first.');
633
- }
634
-
635
- const state = connection.sdk.getState();
636
- if (!state) {
637
- return errorResponse('No game state available. The game client may not be loaded yet.');
638
- }
639
-
640
- return {
641
- content: [{ type: 'text', text: formatWorldState(state, connection.sdk.getStateAge()) }]
642
- };
643
- }
644
-
645
- case 'execute_code': {
646
- const code = args?.code as string;
647
- if (!code) {
648
- return errorResponse('code is required');
649
- }
650
-
651
- // Use active bot, or error
652
- const botName = activeBotName;
653
- if (!botName) {
654
- return errorResponse('Not connected. Call login first.');
655
- }
656
-
657
- const isLongCode = code.length > 2000;
658
-
659
- // Capture console output
660
- const logs: string[] = [];
661
- const originalLog = console.log;
662
- const originalWarn = console.warn;
663
-
664
- console.log = (...args) => logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' '));
665
- console.warn = (...args) => logs.push('[warn] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' '));
666
-
667
- let connection = botManager.get(botName);
668
- if (!connection) {
669
- console.error(`[Kill Switch] Bot "${botName}" not connected, reconnecting...`);
670
- connection = await botManager.connect(botName);
671
- try {
672
- await connection.sdk.waitForCondition(() => connection!.sdk.getState() !== null, 15000);
673
- } catch {
674
- console.error(`[Kill Switch] Warning: state not received within 15s`);
675
- }
676
- }
677
-
678
- try {
679
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
680
- const fn = new AsyncFunction('bot', 'sdk', 'actions', code);
681
-
682
- const timeoutMinutes = Math.min(Math.max((args?.timeout as number) || 2, 0.1), 60);
683
- const EXECUTION_TIMEOUT = timeoutMinutes * 60 * 1000;
684
- let timeoutId: ReturnType<typeof setTimeout>;
685
- const timeoutPromise = new Promise<never>((_, reject) => {
686
- timeoutId = setTimeout(() => reject(new Error(`Code execution timed out after ${timeoutMinutes} minute(s)`)), EXECUTION_TIMEOUT);
687
- });
688
-
689
- const abortController = new AbortController();
690
- const signal = abortController.signal;
691
-
692
- if (extra.signal) {
693
- if (extra.signal.aborted) {
694
- abortController.abort(extra.signal.reason);
695
- } else {
696
- extra.signal.addEventListener('abort', () => {
697
- console.error(`[Kill Switch] execute_code cancelled`);
698
- abortController.abort('Cancelled by client');
699
- }, { once: true });
700
- }
701
- }
702
-
703
- const cancelPromise = new Promise<never>((_, reject) => {
704
- signal.addEventListener('abort', () => {
705
- reject(new Error(typeof signal.reason === 'string' ? signal.reason : 'Code execution cancelled'));
706
- }, { once: true });
707
- });
708
-
709
- const cancellable = <T extends object>(target: T): T =>
710
- new Proxy(target, {
711
- get(obj, prop, receiver) {
712
- const value = Reflect.get(obj, prop, receiver);
713
- if (typeof value === 'function') {
714
- return (...args: any[]) => {
715
- if (signal.aborted) throw new Error('Execution cancelled');
716
- return value.apply(obj, args);
717
- };
718
- }
719
- return value;
720
- }
721
- });
722
-
723
- let result: any;
724
- try {
725
- result = await Promise.race([fn(cancellable(connection.bot), cancellable(connection.sdk), activeActions), timeoutPromise, cancelPromise]);
726
- } finally {
727
- clearTimeout(timeoutId!);
728
- if (!signal.aborted) abortController.abort('Execution finished');
729
- }
730
-
731
- // Build output
732
- const parts: string[] = [];
733
-
734
- if (logs.length > 0) {
735
- parts.push('── Console ──');
736
- parts.push(logs.join('\n'));
737
- }
738
-
739
- if (result !== undefined) {
740
- if (logs.length > 0) parts.push('');
741
- parts.push('── Result ──');
742
- parts.push(JSON.stringify(result, null, 2));
743
- }
744
-
745
- const state = connection.sdk.getState();
746
- if (state) {
747
- parts.push('');
748
- parts.push('── World State ──');
749
- parts.push(formatWorldState(state, connection.sdk.getStateAge()));
750
- }
751
-
752
- if (isLongCode) {
753
- parts.push('');
754
- parts.push('── Tip ──');
755
- parts.push(`Long script detected. Consider writing to a .ts file and running with: bun run bots/${botName}/script.ts`);
756
- }
757
-
758
- const output = parts.length > 0 ? parts.join('\n') : '(no output)';
759
- return { content: [{ type: 'text', text: output }] };
760
- } finally {
761
- console.log = originalLog;
762
- console.warn = originalWarn;
763
- }
764
- }
765
-
766
- case 'wait_for_game_start': {
767
- const connection = getActiveConnection();
768
- if (!connection) {
769
- return errorResponse('Not connected. Call login first.');
770
- }
771
-
772
- const timeoutSecs = Math.min(Math.max((args?.timeout as number) || 300, 10), 600);
773
- // Draynor Manor arena bounds
774
- const ARENA_MIN_X = 3086;
775
- const ARENA_MAX_X = 3126;
776
- const ARENA_MIN_Z = 3333;
777
- const ARENA_MAX_Z = 3382;
778
-
779
- console.error(`[Kill Switch] Waiting for tournament to start (timeout: ${timeoutSecs}s)...`);
780
-
781
- const startTime = Date.now();
782
- const deadline = startTime + timeoutSecs * 1000;
783
-
784
- // Poll every 2 seconds for position change to arena
785
- while (Date.now() < deadline) {
786
- const state = connection.sdk.getState();
787
- if (state) {
788
- // Check if player has been teleported INTO the fight zone (not just nearby)
789
- const x = state.player.worldX;
790
- const z = state.player.worldZ;
791
- if (x >= ARENA_MIN_X && x <= ARENA_MAX_X && z >= ARENA_MIN_Z && z <= ARENA_MAX_Z) {
792
- const waitTime = ((Date.now() - startTime) / 1000).toFixed(1);
793
- console.error(`[Kill Switch] Tournament started! (detected after ${waitTime}s)`);
794
-
795
- const players = connection.sdk.getNearbyPlayers?.() || [];
796
- const parts = [
797
- 'TOURNAMENT STARTED!',
798
- '',
799
- `You've been teleported to Draynor Manor. The fight is ON!`,
800
- `Position: ${state.player.worldX}, ${state.player.worldZ}`,
801
- `HP: ${state.player.hp}/${state.player.maxHp}`,
802
- '',
803
- `Nearby players: ${players.length > 0 ? players.map((p: any) => `${p.name} (CB ${p.combatLevel}, dist ${p.distance})`).join(', ') : 'scanning...'}`,
804
- '',
805
- 'REMEMBER: You die = your agent dies FOREVER. No second chances.',
806
- '',
807
- 'IMMEDIATELY:',
808
- '1. Pick up weapons and food from the ground',
809
- '2. Equip best weapon found, then start fighting',
810
- '3. Eat when HP gets low — you have 99 HP but no food yet!',
811
- ];
812
-
813
- return { content: [{ type: 'text', text: parts.join('\n') }] };
814
- }
815
- }
816
- await new Promise(resolve => setTimeout(resolve, 2000));
817
- }
818
-
819
- return errorResponse(`Tournament didn't start within ${timeoutSecs} seconds. Try calling wait_for_game_start again.`);
820
- }
821
-
822
- case 'join_game': {
823
- const connection = getActiveConnection();
824
- if (!connection) {
825
- return errorResponse('Not connected. Call login first.');
826
- }
827
-
828
- const mode = args?.mode as string;
829
- if (!mode || !['shorty', 'longy'].includes(mode)) {
830
- return errorResponse('mode must be "shorty" or "longy"');
831
- }
832
-
833
- // Determine the web server base URL
834
- const isLocal = SERVER_URL === 'localhost' || SERVER_URL === '127.0.0.1';
835
- const webBase = isLocal ? `http://localhost:8888` : `https://${SERVER_URL}`;
836
- const apiUrl = `${webBase}/api/join-game?username=${encodeURIComponent(activeBotName!)}&mode=${encodeURIComponent(mode)}`;
837
-
838
- try {
839
- const res = await fetch(apiUrl, { method: 'POST' });
840
- const data = await res.json() as any;
841
-
842
- if (data.error) {
843
- return errorResponse(data.error);
844
- }
845
-
846
- const parts = [
847
- `Joined ${mode.toUpperCase()} queue!`,
848
- '',
849
- `Queue position: ${data.queuePosition}`,
850
- `Players in queue: ${data.queuePlayers?.join(', ') || 'just you'}`,
851
- '',
852
- ];
853
-
854
- if (mode === 'shorty') {
855
- parts.push('SHORTY: Quick battle royale at Draynor Manor.');
856
- parts.push('Stats: 50 Attack, 50 Strength, 50 Defence, 99 Hitpoints');
857
- parts.push('You start with NOTHING — grab weapons and food from the ground!');
858
- } else {
859
- parts.push('LONGY: Large survival map (coming soon).');
860
- }
861
-
862
- parts.push('');
863
- parts.push('Now call wait_for_game_start to wait for the game to begin.');
864
- parts.push('Chat strategy with your human while you wait!');
865
-
866
- return { content: [{ type: 'text', text: parts.join('\n') }] };
867
- } catch (e: any) {
868
- return errorResponse(`Failed to join game: ${e.message}. Is the game server running?`);
869
- }
870
- }
871
-
872
- case 'disconnect_bot': {
873
- if (!activeBotName) {
874
- return successResponse({ message: 'Not connected.' });
875
- }
876
-
877
- await botManager.disconnect(activeBotName);
878
- const name = activeBotName;
879
- activeBotName = null;
880
- return successResponse({ message: `Disconnected "${name}" from the game.` });
881
- }
882
-
883
- case 'setup_game_wallet': {
884
- const tempoAddress = args?.tempo_account_address as string;
885
- if (!tempoAddress || !tempoAddress.startsWith('0x')) {
886
- return errorResponse('A valid Tempo account address (0x...) is required. Run "tempo wallet whoami" to get yours.');
887
- }
888
-
889
- // Check if wallet already exists
890
- if (hasGameWallet()) {
891
- try {
892
- const info = await getWalletInfo();
893
- return {
894
- content: [{
895
- type: 'text',
896
- text: [
897
- 'You already have a game wallet set up.',
898
- '',
899
- `Account: ${info.address}`,
900
- `Balance: $${info.balance} USDC`,
901
- '',
902
- 'To start fresh, delete ~/.killswitch/access-key.json and run this again.',
903
- ].join('\n')
904
- }]
905
- };
906
- } catch {
907
- // Wallet file exists but might be corrupt, continue with setup
908
- }
909
- }
910
-
911
- // Generate access key
912
- const { accessKeyAddress, privateKey, instructions } = generateAccessKey();
913
-
914
- // Save it (the player still needs to authorize it on-chain)
915
- saveAccessKey(privateKey, tempoAddress);
916
-
917
- return {
918
- content: [{
919
- type: 'text',
920
- text: [
921
- 'Game wallet setup started!',
922
- '',
923
- `Your Tempo account: ${tempoAddress}`,
924
- `Generated access key: ${accessKeyAddress}`,
925
- '',
926
- '── NEXT STEP ──',
927
- '',
928
- 'You need to authorize this access key on your Tempo account.',
929
- 'This requires your passkey (biometric confirmation).',
930
- '',
931
- instructions,
932
- '',
933
- '── AFTER AUTHORIZATION ──',
934
- '',
935
- 'Once authorized, your game wallet is ready. Check it with check_wallet.',
936
- 'Fund your Tempo account with USDC to start joining paid tournaments.',
937
- ].join('\n')
938
- }]
939
- };
940
- }
941
-
942
- case 'check_wallet': {
943
- if (!hasGameWallet()) {
944
- return errorResponse(
945
- 'No game wallet found. Run setup_game_wallet first.\n\n' +
946
- 'Prerequisites:\n' +
947
- '1. Install Tempo CLI: curl -fsSL https://tempo.xyz/install | bash\n' +
948
- '2. Create account: tempo wallet login\n' +
949
- '3. Get your address: tempo wallet whoami\n' +
950
- '4. Then call setup_game_wallet with your address'
951
- );
952
- }
953
-
954
- try {
955
- const info = await getWalletInfo();
956
- return {
957
- content: [{
958
- type: 'text',
959
- text: [
960
- '── Game Wallet ──',
961
- '',
962
- `Account: ${info.address}`,
963
- `USDC Balance: $${info.balance}`,
964
- '',
965
- info.balanceRaw === 0n
966
- ? 'Your wallet is empty. Fund it by sending USDC to your account address on the Tempo network.'
967
- : 'Your wallet is funded and ready for paid tournaments. Use tournament_schedule to see upcoming games.',
968
- ].join('\n')
969
- }]
970
- };
971
- } catch (e: any) {
972
- return errorResponse(`Failed to check wallet: ${e.message}`);
973
- }
974
- }
975
-
976
- case 'tournament_schedule': {
977
- try {
978
- const slots = await getTournamentSchedule();
979
-
980
- if (slots.length === 0) {
981
- return {
982
- content: [{
983
- type: 'text',
984
- text: 'No upcoming paid tournaments scheduled. Check back later, or paid tournaments may not be enabled on this server.'
985
- }]
986
- };
987
- }
988
-
989
- const lines = ['── Upcoming Paid Tournaments ──', ''];
990
-
991
- for (let i = 0; i < slots.length; i++) {
992
- const s = slots[i];
993
- const time = new Date(s.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
994
- const buyIn = (parseInt(s.buyIn) / 1_000_000).toFixed(2);
995
- lines.push(`[${i + 1}] ${time} — $${buyIn} buy-in — ${s.depositorCount}/${s.maxPlayers} players`);
996
- lines.push(` Contract: ${s.contractAddress}`);
997
- }
998
-
999
- lines.push('');
1000
- lines.push('To join a tournament, use join_tournament with the contract address.');
1001
- lines.push('Make sure you have enough USDC in your game wallet (check with check_wallet).');
1002
-
1003
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1004
- } catch (e: any) {
1005
- return errorResponse(`Failed to get tournament schedule: ${e.message}`);
1006
- }
1007
- }
1008
-
1009
- case 'join_tournament': {
1010
- const tournamentAddress = args?.tournament_address as string;
1011
- if (!tournamentAddress || !tournamentAddress.startsWith('0x')) {
1012
- return errorResponse('A valid tournament contract address (0x...) is required. Use tournament_schedule to find one.');
1013
- }
1014
-
1015
- if (!hasGameWallet()) {
1016
- return errorResponse('No game wallet found. Run setup_game_wallet first.');
1017
- }
1018
-
1019
- if (!activeBotName) {
1020
- return errorResponse('Not logged in. Call login first so we know your in-game username.');
1021
- }
1022
-
1023
- try {
1024
- const result = await joinTournament(tournamentAddress, activeBotName);
1025
-
1026
- return {
1027
- content: [{
1028
- type: 'text',
1029
- text: [
1030
- 'Tournament joined!',
1031
- '',
1032
- `Buy-in: $${result.amount} USDC`,
1033
- `Transaction: ${result.txHash}`,
1034
- `Username: ${activeBotName}`,
1035
- '',
1036
- 'Your deposit is held in the tournament escrow contract.',
1037
- 'Be online when the match starts or you\'ll be eliminated (buy-in stays in the pot).',
1038
- '',
1039
- 'If the match is cancelled (< 2 players), you\'ll get a full refund.',
1040
- 'If the server goes down, you can claim a refund after 1 hour.',
1041
- ].join('\n')
1042
- }]
1043
- };
1044
- } catch (e: any) {
1045
- return errorResponse(`Failed to join tournament: ${e.message}`);
1046
- }
1047
- }
1048
-
1049
- default:
1050
- throw new Error(`Unknown tool: ${name}`);
1051
- }
1052
- } catch (error: any) {
1053
- const errorMessage = `Error: ${error.message}\n\nStack trace:\n${error.stack}`;
1054
- return {
1055
- content: [{ type: 'text', text: errorMessage }],
1056
- isError: true
1057
- };
1058
- }
1059
- });
1060
-
1061
- function successResponse(data: any) {
1062
- return {
1063
- content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
1064
- };
1065
- }
1066
-
1067
- function errorResponse(message: string) {
1068
- return {
1069
- content: [{ type: 'text', text: `Error: ${message}` }],
1070
- isError: true
1071
- };
1072
- }
1073
-
1074
- // ── Start ──
1075
- async function main() {
1076
- console.error('[Kill Switch MCP] Starting Kill Switch MCP server v3.0...');
1077
- console.error(`[Kill Switch MCP] Game server: ${SERVER_URL}`);
1078
-
1079
- const transport = new StdioServerTransport();
1080
- await server.connect(transport);
1081
-
1082
- console.error('[Kill Switch MCP] Ready. Waiting for Claude...');
1083
- }
1084
-
1085
- main().catch((error) => {
1086
- console.error('[Kill Switch MCP] Fatal error:', error);
1087
- process.exit(1);
1088
- });