fpl-mcp-server 0.1.3__py3-none-any.whl

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.
src/tools/transfers.py ADDED
@@ -0,0 +1,629 @@
1
+ """FPL Transfer Tools - MCP tools for transfer statistics and live trends."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from ..client import FPLClient
6
+ from ..constants import CHARACTER_LIMIT
7
+ from ..state import store
8
+ from ..utils import (
9
+ ResponseFormat,
10
+ check_and_truncate,
11
+ format_json_response,
12
+ format_player_price,
13
+ handle_api_error,
14
+ )
15
+
16
+ # Import shared mcp instance
17
+ from . import mcp
18
+
19
+ # =============================================================================
20
+ # Pydantic Input Models
21
+ # =============================================================================
22
+
23
+
24
+ class GetPlayerTransfersByGameweekInput(BaseModel):
25
+ """Input model for getting player transfer statistics."""
26
+
27
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
28
+
29
+ player_name: str = Field(
30
+ ...,
31
+ description="Player name (e.g., 'Haaland', 'Salah')",
32
+ min_length=2,
33
+ max_length=100,
34
+ )
35
+ gameweek: int = Field(..., description="Gameweek number (1-38)", ge=1, le=38)
36
+
37
+
38
+ class GetTopTransferredPlayersInput(BaseModel):
39
+ """Input model for getting top transferred players."""
40
+
41
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
42
+
43
+ limit: int = Field(default=10, description="Number of players to return (1-50)", ge=1, le=50)
44
+ response_format: ResponseFormat = Field(
45
+ default=ResponseFormat.MARKDOWN,
46
+ description="Output format: 'markdown' or 'json'",
47
+ )
48
+
49
+
50
+ class GetManagerTransfersByGameweekInput(BaseModel):
51
+ """Input model for getting manager transfers."""
52
+
53
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
54
+
55
+ team_id: int = Field(..., description="Manager's team ID (entry ID) from FPL", ge=1)
56
+ gameweek: int = Field(..., description="Gameweek number (1-38)", ge=1, le=38)
57
+
58
+
59
+ # =============================================================================
60
+ # Helper Functions
61
+ # =============================================================================
62
+
63
+
64
+ async def _create_client():
65
+ """Create an unauthenticated FPL client for public API access and ensure data is loaded."""
66
+ client = FPLClient(store=store)
67
+ await store.ensure_bootstrap_data(client)
68
+ await store.ensure_fixtures_data(client)
69
+ return client
70
+
71
+
72
+ # =============================================================================
73
+ # MCP Tools
74
+ # =============================================================================
75
+
76
+
77
+ @mcp.tool(
78
+ name="fpl_get_player_transfers_by_gameweek",
79
+ annotations={
80
+ "title": "Get FPL Player Transfer Statistics",
81
+ "readOnlyHint": True,
82
+ "destructiveHint": False,
83
+ "idempotentHint": True,
84
+ "openWorldHint": True,
85
+ },
86
+ )
87
+ async def fpl_get_player_transfers_by_gameweek(
88
+ params: GetPlayerTransfersByGameweekInput,
89
+ ) -> str:
90
+ """
91
+ Get transfer statistics for a specific player in a specific gameweek.
92
+
93
+ Shows transfers in, transfers out, net transfers, ownership data, and performance.
94
+ Useful for understanding how manager sentiment towards a player changed during a
95
+ specific gameweek and correlation with performance.
96
+
97
+ Args:
98
+ params (GetPlayerTransfersByGameweekInput): Validated input parameters containing:
99
+ - player_name (str): Player name (e.g., 'Haaland', 'Salah')
100
+ - gameweek (int): Gameweek number between 1-38
101
+
102
+ Returns:
103
+ str: Transfer statistics and performance for the gameweek
104
+
105
+ Examples:
106
+ - Check Haaland GW20: player_name="Haaland", gameweek=20
107
+ - Salah transfers: player_name="Salah", gameweek=15
108
+
109
+ Error Handling:
110
+ - Returns error if player not found
111
+ - Suggests using fpl_find_player if name ambiguous
112
+ - Returns error if no data for gameweek
113
+ """
114
+ try:
115
+ client = await _create_client()
116
+
117
+ # Find player by name
118
+ matches = store.find_players_by_name(params.player_name, fuzzy=True)
119
+ if not matches:
120
+ return f"No player found matching '{params.player_name}'. Use fpl_search_players to find the correct name."
121
+
122
+ if len(matches) > 1 and matches[0][1] < 0.95:
123
+ return f"Ambiguous player name. Use fpl_find_player to see all matches for '{params.player_name}'"
124
+
125
+ player = matches[0][0]
126
+ player_id = player.id
127
+
128
+ # Fetch detailed summary from API
129
+ summary_data = await client.get_element_summary(player_id)
130
+ history = summary_data.get("history", [])
131
+
132
+ # Find the specific gameweek in history
133
+ gw_data = next((gw for gw in history if gw.get("round") == params.gameweek), None)
134
+
135
+ if not gw_data:
136
+ return f"No transfer data found for {player.web_name} in gameweek {params.gameweek}. The gameweek may not have started yet or data is unavailable."
137
+
138
+ # Enrich with team name
139
+ enriched_history = store.enrich_gameweek_history([gw_data])
140
+ if enriched_history:
141
+ gw_data = enriched_history[0]
142
+
143
+ output = [
144
+ f"**{player.web_name}** ({player.first_name} {player.second_name})",
145
+ f"Team: {player.team_name} | Position: {player.position} | Price: {format_player_price(player.now_cost)}",
146
+ "",
147
+ f"**Gameweek {params.gameweek} Transfer Statistics:**",
148
+ "",
149
+ f"├─ Transfers In: {gw_data.get('transfers_in', 0):,}",
150
+ f"├─ Transfers Out: {gw_data.get('transfers_out', 0):,}",
151
+ f"├─ Net Transfers: {gw_data.get('transfers_balance', gw_data.get('transfers_in', 0) - gw_data.get('transfers_out', 0)):+,}",
152
+ f"├─ Ownership at GW: {gw_data.get('selected', 0):,} teams",
153
+ "",
154
+ f"**Performance in GW{params.gameweek}:**",
155
+ f"├─ Points: {gw_data.get('total_points', 0)}",
156
+ f"├─ Minutes: {gw_data.get('minutes', 0)}",
157
+ f"├─ xGoal: {gw_data.get('expected_goals', '0.00')} | Goals: {gw_data.get('goals_scored', 0)}",
158
+ f"├─ xAssist: {gw_data.get('expected_assists', '0.00')} | Assists: {gw_data.get('assists', 0)}",
159
+ f"├─ Clean Sheets: {gw_data.get('clean_sheets', 0)} | Bonus: {gw_data.get('bonus', 0)}",
160
+ ]
161
+
162
+ opponent_name = gw_data.get("opponent_team_short", "Unknown")
163
+ home_away = "H" if gw_data.get("was_home") else "A"
164
+ output.append(f"├─ Opponent: vs {opponent_name} ({home_away})")
165
+
166
+ result = "\n".join(output)
167
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
168
+ return truncated
169
+
170
+ except Exception as e:
171
+ return handle_api_error(e)
172
+
173
+
174
+ @mcp.tool(
175
+ name="fpl_get_top_transferred_players",
176
+ annotations={
177
+ "title": "Get Top Transferred FPL Players",
178
+ "readOnlyHint": True,
179
+ "destructiveHint": False,
180
+ "idempotentHint": True,
181
+ "openWorldHint": True,
182
+ },
183
+ )
184
+ async def fpl_get_top_transferred_players(params: GetTopTransferredPlayersInput) -> str:
185
+ """
186
+ Get the most transferred in and out players for the current gameweek.
187
+
188
+ Shows live transfer trends to identify popular moves happening right now. Uses
189
+ real-time data from bootstrap for instant response. Essential for understanding
190
+ the current template and finding differentials.
191
+
192
+ Args:
193
+ params (GetTopTransferredPlayersInput): Validated input parameters containing:
194
+ - limit (int): Number of players to return, 1-50 (default: 10)
195
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
196
+
197
+ Returns:
198
+ str: Top transferred in and out players with net transfers
199
+
200
+ Examples:
201
+ - Top 10: limit=10
202
+ - Top 20: limit=20
203
+ - Get as JSON: limit=15, response_format="json"
204
+
205
+ Error Handling:
206
+ - Returns error if no transfer data available
207
+ - Returns formatted error message if current gameweek unavailable
208
+ """
209
+ try:
210
+ await _create_client()
211
+ if not store.bootstrap_data:
212
+ return "Error: Player data not available. Please try again later."
213
+
214
+ # Get current gameweek
215
+ current_gw = store.get_current_gameweek()
216
+ if not current_gw:
217
+ return "Error: Could not determine current gameweek. Data may be unavailable."
218
+
219
+ gameweek = current_gw.id
220
+
221
+ # Use bootstrap data directly - instant, no API calls!
222
+ players_with_transfers = []
223
+
224
+ for player in store.bootstrap_data.elements:
225
+ transfers_in = getattr(player, "transfers_in_event", 0)
226
+ transfers_out = getattr(player, "transfers_out_event", 0)
227
+
228
+ # Only include players with transfer activity
229
+ if transfers_in > 0 or transfers_out > 0:
230
+ players_with_transfers.append(
231
+ {
232
+ "player": player,
233
+ "transfers_in": transfers_in,
234
+ "transfers_out": transfers_out,
235
+ "net_transfers": transfers_in - transfers_out,
236
+ "points": getattr(player, "event_points", 0),
237
+ }
238
+ )
239
+
240
+ if not players_with_transfers:
241
+ return f"No transfer data available for gameweek {gameweek}. The gameweek may not have started yet."
242
+
243
+ # Sort by transfers in and out
244
+ most_transferred_in = sorted(
245
+ players_with_transfers, key=lambda x: x["transfers_in"], reverse=True
246
+ )[: params.limit]
247
+ most_transferred_out = sorted(
248
+ players_with_transfers, key=lambda x: x["transfers_out"], reverse=True
249
+ )[: params.limit]
250
+
251
+ if params.response_format == ResponseFormat.JSON:
252
+ result = {
253
+ "gameweek": gameweek,
254
+ "transferred_in": [
255
+ {
256
+ "rank": i + 1,
257
+ "player_name": data["player"].web_name,
258
+ "team": data["player"].team_name,
259
+ "position": data["player"].position,
260
+ "price": format_player_price(data["player"].now_cost),
261
+ "transfers_in": data["transfers_in"],
262
+ "net_transfers": data["net_transfers"],
263
+ "points": data["points"],
264
+ }
265
+ for i, data in enumerate(most_transferred_in)
266
+ ],
267
+ "transferred_out": [
268
+ {
269
+ "rank": i + 1,
270
+ "player_name": data["player"].web_name,
271
+ "team": data["player"].team_name,
272
+ "position": data["player"].position,
273
+ "price": format_player_price(data["player"].now_cost),
274
+ "transfers_out": data["transfers_out"],
275
+ "net_transfers": data["net_transfers"],
276
+ "points": data["points"],
277
+ }
278
+ for i, data in enumerate(most_transferred_out)
279
+ ],
280
+ }
281
+ return format_json_response(result)
282
+ else:
283
+ output = [
284
+ f"**Gameweek {gameweek} - Live Transfer Trends** 🔥",
285
+ "",
286
+ f"**Most Transferred IN (Top {min(params.limit, len(most_transferred_in))}):**",
287
+ "",
288
+ ]
289
+
290
+ for i, data in enumerate(most_transferred_in, 1):
291
+ player = data["player"]
292
+ price = format_player_price(player.now_cost)
293
+ output.append(
294
+ f"{i:2d}. {player.web_name:20s} ({player.team_name:15s} {player.position}) | "
295
+ f"{price} | In: {data['transfers_in']:,} | "
296
+ f"Net: {data['net_transfers']:+,} | {data['points']}pts"
297
+ )
298
+
299
+ output.extend(
300
+ [
301
+ "",
302
+ f"**Most Transferred OUT (Top {min(params.limit, len(most_transferred_out))}):**",
303
+ "",
304
+ ]
305
+ )
306
+
307
+ for i, data in enumerate(most_transferred_out, 1):
308
+ player = data["player"]
309
+ price = format_player_price(player.now_cost)
310
+ output.append(
311
+ f"{i:2d}. {player.web_name:20s} ({player.team_name:15s} {player.position}) | "
312
+ f"{price} | Out: {data['transfers_out']:,} | "
313
+ f"Net: {data['net_transfers']:+,} | {data['points']}pts"
314
+ )
315
+
316
+ result = "\n".join(output)
317
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
318
+ return truncated
319
+
320
+ except Exception as e:
321
+ return handle_api_error(e)
322
+
323
+
324
+ @mcp.tool(
325
+ name="fpl_get_manager_transfers_by_gameweek",
326
+ annotations={
327
+ "title": "Get Manager's FPL Transfers",
328
+ "readOnlyHint": True,
329
+ "destructiveHint": False,
330
+ "idempotentHint": True,
331
+ "openWorldHint": True,
332
+ },
333
+ )
334
+ async def fpl_get_manager_transfers_by_gameweek(
335
+ params: GetManagerTransfersByGameweekInput,
336
+ ) -> str:
337
+ """
338
+ Get all transfers made by a specific manager in a specific gameweek.
339
+
340
+ Shows which players were transferred in and out, transfer costs, and timing.
341
+ Useful for analyzing manager strategy and understanding when/why they made moves.
342
+ Requires manager's team ID (entry ID) which can be found in the FPL URL.
343
+
344
+ Args:
345
+ params (GetManagerTransfersByGameweekInput): Validated input parameters containing:
346
+ - team_id (int): Manager's team ID (entry ID)
347
+ - gameweek (int): Gameweek number (1-38)
348
+
349
+ Returns:
350
+ str: Complete transfer history for the gameweek with costs
351
+
352
+ Examples:
353
+ - View transfers: team_id=123456, gameweek=20
354
+ - Check costs: team_id=789012, gameweek=15
355
+
356
+ Error Handling:
357
+ - Returns error if team ID invalid
358
+ - Returns message if no transfers in gameweek
359
+ - Returns formatted error message if API fails
360
+ """
361
+ try:
362
+ client = await _create_client()
363
+
364
+ # Fetch manager entry info to get team name
365
+ try:
366
+ manager_entry = await client.get_manager_entry(params.team_id)
367
+ manager_name = f"{manager_entry.get('player_first_name', '')} {manager_entry.get('player_last_name', '')}".strip()
368
+ team_name = manager_entry.get("name", f"Team {params.team_id}")
369
+ except Exception:
370
+ manager_name = f"Manager {params.team_id}"
371
+ team_name = f"Team {params.team_id}"
372
+
373
+ # Fetch all transfer history
374
+ transfers_data = await client.get_manager_transfers(params.team_id)
375
+
376
+ # Filter transfers for the specific gameweek
377
+ gw_transfers = [t for t in transfers_data if t.get("event") == params.gameweek]
378
+
379
+ if not gw_transfers:
380
+ return f"No transfers found for {manager_name} in gameweek {params.gameweek}. They may have used their free transfers or rolled them over."
381
+
382
+ output = [
383
+ f"**{team_name}** - {manager_name}",
384
+ f"Gameweek {params.gameweek} Transfers",
385
+ "",
386
+ ]
387
+
388
+ total_cost = 0
389
+ for i, transfer in enumerate(gw_transfers, 1):
390
+ # Get player details
391
+ player_in_id = transfer.get("element_in")
392
+ player_out_id = transfer.get("element_out")
393
+
394
+ player_in_name = store.get_player_name(player_in_id)
395
+ player_out_name = store.get_player_name(player_out_id)
396
+
397
+ # Get player info for prices
398
+ player_in_info = next(
399
+ (p for p in store.bootstrap_data.elements if p.id == player_in_id), None
400
+ )
401
+ player_out_info = next(
402
+ (p for p in store.bootstrap_data.elements if p.id == player_out_id),
403
+ None,
404
+ )
405
+
406
+ price_in = format_player_price(player_in_info.now_cost) if player_in_info else "£0.0m"
407
+ price_out = (
408
+ format_player_price(player_out_info.now_cost) if player_out_info else "£0.0m"
409
+ )
410
+
411
+ # Transfer details
412
+ transfer_time = transfer.get("time", "Unknown")
413
+ cost = transfer.get("event_cost", 0)
414
+ total_cost += cost
415
+
416
+ output.append(f"**Transfer {i}:**")
417
+ output.append(
418
+ f"OUT: {player_out_name} ({player_out_info.team_name if player_out_info else 'Unknown'} "
419
+ f"{player_out_info.position if player_out_info else 'UNK'}) - {price_out}"
420
+ )
421
+ output.append(
422
+ f"IN: {player_in_name} ({player_in_info.team_name if player_in_info else 'Unknown'} "
423
+ f"{player_in_info.position if player_in_info else 'UNK'}) - {price_in}"
424
+ )
425
+ if cost > 0:
426
+ output.append(f"Cost: -{cost} points")
427
+ output.append(
428
+ f"Time: {transfer_time[:19] if transfer_time != 'Unknown' else 'Unknown'}"
429
+ )
430
+ output.append("")
431
+
432
+ output.extend(
433
+ [
434
+ "**Summary:**",
435
+ f"├─ Total Transfers: {len(gw_transfers)}",
436
+ f"├─ Total Cost: -{total_cost} points"
437
+ if total_cost > 0
438
+ else "├─ Free Transfers Used",
439
+ ]
440
+ )
441
+
442
+ result = "\n".join(output)
443
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
444
+ return truncated
445
+
446
+ except Exception as e:
447
+ return handle_api_error(e)
448
+
449
+
450
+ class GetManagerChipsInput(BaseModel):
451
+ """Input model for getting manager chip usage."""
452
+
453
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
454
+
455
+ team_id: int = Field(..., description="Manager's team ID (entry ID) from FPL", ge=1)
456
+ response_format: ResponseFormat = Field(
457
+ default=ResponseFormat.MARKDOWN,
458
+ description="Output format: 'markdown' or 'json'",
459
+ )
460
+
461
+
462
+ @mcp.tool(
463
+ name="fpl_get_manager_chips",
464
+ annotations={
465
+ "title": "Get Manager's Chip Usage",
466
+ "readOnlyHint": True,
467
+ "destructiveHint": False,
468
+ "idempotentHint": True,
469
+ "openWorldHint": True,
470
+ },
471
+ )
472
+ async def fpl_get_manager_chips(params: GetManagerChipsInput) -> str:
473
+ """
474
+ Get a manager's chip usage showing which chips have been used and which are still available.
475
+
476
+ Since the 2025/2026 season, FPL provides 4 chips per half-season.
477
+
478
+ Shows used chips with gameweek and timing, plus remaining available chips.
479
+ Essential for strategic chip planning and recommendations.
480
+
481
+ Args:
482
+ params (GetManagerChipsInput): Validated input parameters containing:
483
+ - team_id (int): Manager's team ID (entry ID)
484
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
485
+
486
+ Returns:
487
+ str: Chip usage summary with used and available chips
488
+
489
+ Examples:
490
+ - Check chip status: team_id=123456
491
+ - JSON format: team_id=123456, response_format="json"
492
+
493
+ Error Handling:
494
+ - Returns error if team ID invalid
495
+ - Returns formatted error message if API fails
496
+ """
497
+ try:
498
+ client = await _create_client()
499
+
500
+ # Fetch manager history for used chips
501
+ history_data = await client.get_manager_history(params.team_id)
502
+ used_chips = history_data.get("chips", [])
503
+
504
+ # Get all available chips from bootstrap data
505
+ bootstrap_chips = store.bootstrap_data.chips if store.bootstrap_data else []
506
+
507
+ # Get current gameweek to determine which chips are available
508
+ current_gw_data = store.get_current_gameweek()
509
+ current_gw = current_gw_data.id if current_gw_data else 1
510
+
511
+ # Build chip availability map
512
+ # FPL chip names: "wildcard", "freehit", "bboost", "3xc"
513
+ chip_display_names = {
514
+ "wildcard": "Wildcard",
515
+ "freehit": "Free Hit",
516
+ "bboost": "Bench Boost",
517
+ "3xc": "Triple Captain",
518
+ }
519
+
520
+ # Build available chips list
521
+ # Match used chips to specific chip instances based on gameweek used
522
+ available_chips = []
523
+ for chip in bootstrap_chips:
524
+ chip_name = chip.get("name")
525
+ start_gw = chip.get("start_event", 1)
526
+ end_gw = chip.get("stop_event", 38)
527
+
528
+ # Check if this specific chip instance was used
529
+ # Match by checking if any used chip has this name and was used within this chip's GW range
530
+ chip_used = any(
531
+ used_chip["name"] == chip_name and start_gw <= used_chip["event"] <= end_gw
532
+ for used_chip in used_chips
533
+ )
534
+
535
+ # Chip is available if: within current gameweek range AND not used
536
+ if start_gw <= current_gw <= end_gw and not chip_used:
537
+ available_chips.append(
538
+ {
539
+ "name": chip_name,
540
+ "display_name": chip_display_names.get(chip_name, chip_name.title()),
541
+ "start_event": start_gw,
542
+ "stop_event": end_gw,
543
+ "half": "First Half" if end_gw <= 19 else "Second Half",
544
+ }
545
+ )
546
+
547
+ if params.response_format == ResponseFormat.JSON:
548
+ result = {
549
+ "team_id": params.team_id,
550
+ "current_gameweek": current_gw,
551
+ "used_chips": [
552
+ {
553
+ "name": chip["name"],
554
+ "display_name": chip_display_names.get(chip["name"], chip["name"].title()),
555
+ "gameweek": chip["event"],
556
+ "time": chip["time"],
557
+ }
558
+ for chip in used_chips
559
+ ],
560
+ "available_chips": available_chips,
561
+ }
562
+ return format_json_response(result)
563
+ else:
564
+ # Markdown output
565
+ output = [
566
+ f"**Chip Usage Summary** (Team ID: {params.team_id})",
567
+ f"Current Gameweek: {current_gw}",
568
+ "",
569
+ f"**Used Chips ({len(used_chips)}):**",
570
+ "",
571
+ ]
572
+
573
+ if used_chips:
574
+ # Group used chips by half
575
+ first_half_used = [c for c in used_chips if c["event"] <= 19]
576
+ second_half_used = [c for c in used_chips if c["event"] >= 20]
577
+
578
+ if first_half_used:
579
+ output.append(" **First Half (GW1-19):**")
580
+ for chip in first_half_used:
581
+ display_name = chip_display_names.get(chip["name"], chip["name"].title())
582
+ gw = chip["event"]
583
+ time = chip["time"][:10] if chip.get("time") else "Unknown"
584
+ output.append(f" ✓ {display_name} - GW{gw} | Used: {time}")
585
+ output.append("")
586
+
587
+ if second_half_used:
588
+ output.append(" **Second Half (GW20-38):**")
589
+ for chip in second_half_used:
590
+ display_name = chip_display_names.get(chip["name"], chip["name"].title())
591
+ gw = chip["event"]
592
+ time = chip["time"][:10] if chip.get("time") else "Unknown"
593
+ output.append(f" ✓ {display_name} - GW{gw} | Used: {time}")
594
+ output.append("")
595
+ else:
596
+ output.append("No chips used yet")
597
+
598
+ output.extend(
599
+ [
600
+ "",
601
+ f"**Available Chips ({len(available_chips)}):**",
602
+ "",
603
+ ]
604
+ )
605
+
606
+ if available_chips:
607
+ # Group by half
608
+ first_half_available = [c for c in available_chips if c["half"] == "First Half"]
609
+ second_half_available = [c for c in available_chips if c["half"] == "Second Half"]
610
+
611
+ if first_half_available:
612
+ output.append(" **First Half (GW1-19):**")
613
+ for chip in first_half_available:
614
+ output.append(f" • {chip['display_name']}")
615
+ output.append("")
616
+
617
+ if second_half_available:
618
+ output.append(" **Second Half (GW20-38):**")
619
+ for chip in second_half_available:
620
+ output.append(f" • {chip['display_name']}")
621
+ else:
622
+ output.append("All chips have been used")
623
+
624
+ result = "\n".join(output)
625
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
626
+ return truncated
627
+
628
+ except Exception as e:
629
+ return handle_api_error(e)