1212of several items called alike."""
1313
1414import datetime
15+ import logging
16+ import math
1517import time
1618
1719from gcm import (
1820 auth , bottlenecks , commands , craft_stats , inventory , item_info , patterns , planner , rates ,
1921 mctext , search , state , stock , store ,
2022)
2123
24+ log = logging .getLogger (__name__ )
25+
2226
2327class ToolError (Exception ):
2428 """A tool call that can't be answered: its message goes back to the
@@ -136,13 +140,14 @@ def _arg(args, name, kind, default=None, choices=None, minimum=None, maximum=Non
136140 if required :
137141 raise ToolError (f"{ name } is required" )
138142 return None
139- if kind == "integer" :
140- if isinstance (value , bool ) or not isinstance (value , (int , float )) or value != int (value ):
141- raise ToolError (f"{ name } must be a whole number" )
142- value = int (value )
143- elif kind == "number" :
144- if isinstance (value , bool ) or not isinstance (value , (int , float )):
143+ if kind in ("integer" , "number" ):
144+ # JSON allows 1e400, which Python reads as infinity.
145+ if isinstance (value , bool ) or not isinstance (value , (int , float )) or not math .isfinite (value ):
145146 raise ToolError (f"{ name } must be a number" )
147+ if kind == "integer" :
148+ if value != int (value ):
149+ raise ToolError (f"{ name } must be a whole number" )
150+ value = int (value )
146151 elif kind == "string" :
147152 if not isinstance (value , str ):
148153 raise ToolError (f"{ name } must be a string" )
@@ -177,7 +182,7 @@ def _range_arg(choices, default, what):
177182
178183
179184def _rank (item , query ):
180- name = (item .get ("name" ) or "" ).lower ()
185+ name = mctext . plain (item .get ("name" ) or "" ).lower ()
181186 q = query .strip ().lower ()
182187 if name == q :
183188 return 0
@@ -211,7 +216,7 @@ def search_items(user, args):
211216 item_info .mod_name (item .get ("mod" )), info .get ("formula" ),
212217 ):
213218 found .append (item )
214- found .sort (key = lambda it : (_rank (it , query ), len (it .get ("name" ) or "" ), - (it .get ("size" ) or 0 )))
219+ found .sort (key = lambda it : (_rank (it , query ), len (mctext . plain ( it .get ("name" ) or "" ) ), - (it .get ("size" ) or 0 )))
215220 return {
216221 "scanned_at" : _iso (scanned_at ),
217222 "matches" : len (found ),
@@ -283,11 +288,14 @@ def inventory_changes(user, args):
283288 sort = _arg (args , "sort" , "string" , "biggest" , choices = CHANGE_SORTS )
284289 limit = _arg (args , "limit" , "integer" , 25 , minimum = 1 , maximum = 100 )
285290 range_key , changes = inventory .changes (range_key )
286- sizes , _ , _ = inventory .stock_levels ()
291+ # One pass over the snapshot: looking each item up would go through
292+ # all of it again for every changed item.
293+ with state .network_lock :
294+ by_key = {store .items .key_of (it ): it for it in state .network ["items" ]}
287295 rows = []
288296 for key , change in changes .items ():
289- item = _network_item (key )
290- now = sizes .get (key , 0 )
297+ item = by_key . get (key )
298+ now = ( item .get ("size" ) or 0 ) if item else 0
291299 delta = now - change ["from" ]
292300 if (sort == "falling" and delta >= 0 ) or (sort == "rising" and delta <= 0 ):
293301 continue
@@ -688,6 +696,13 @@ def cancel_craft(user, args):
688696 except ValueError :
689697 raise ToolError ("job_id must be the job_id current_crafts gave for this CPU" ) from None
690698 shown = {"mod" : parts [0 ] or None , "internal" : parts [1 ], "damage" : damage }
699+ if shown is None :
700+ # Without it, a CPU that moved on to another job since the user
701+ # agreed would have that one cancelled instead.
702+ with state .crafts_lock :
703+ job = next ((j for j in state .crafts ["jobs" ] if j .get ("name" ) == cpu ), None )
704+ if job is not None and job .get ("busy" ) and _job_id (job ):
705+ raise ToolError ("Pass the job_id current_crafts gave for this CPU, so only that job is cancelled." )
691706 req_id , error = commands .request_cancel (user ["id" ], cpu , shown )
692707 if error :
693708 raise ToolError (error [0 ])
@@ -700,6 +715,10 @@ def cancel_craft(user, args):
700715def request_status (user , args ):
701716 req_id = _arg (args , "request_id" , "integer" , required = True )
702717 kind = _arg (args , "type" , "string" , "craft" , choices = ("craft" , "cancel" ))
718+ # A request the game never picked up is only marked failed when its
719+ # queue is next looked at (the game's poll, the page): look now.
720+ queue = commands .craft_requests if kind == "craft" else commands .cancel_requests
721+ queue .select (lambda r : False )
703722 found = store .requests .find (kind , req_id , user ["id" ])
704723 if found is None :
705724 raise ToolError (f"You have no { kind } request with that id." )
@@ -962,7 +981,10 @@ def request_status(user, args):
962981 ),
963982 "inputSchema" : _schema ({
964983 "cpu" : {"type" : "string" , "description" : "A CPU name from current_crafts." },
965- "job_id" : {"type" : "string" , "description" : "The job_id current_crafts gave for that CPU's job." },
984+ "job_id" : {"type" : "string" , "description" : (
985+ "The job_id current_crafts gave for that CPU's job. Required unless current_crafts "
986+ "gave none (the CPU has no Crafting Monitor)."
987+ )},
966988 }, ["cpu" ]),
967989 "annotations" : GAME_CHANGES ,
968990 "operator" : True ,
@@ -983,12 +1005,15 @@ def listing(user):
9831005 ]
9841006
9851007
1008+ def exists (name ):
1009+ return name in _BY_NAME
1010+
1011+
9861012def call (name , args , user ):
987- """Runs a tool for a user. Returns (result, is_error): result is the
988- tool's JSON-able answer, or an error message for the model."""
989- tool = _BY_NAME .get (name )
990- if tool is None :
991- raise KeyError (name )
1013+ """Runs a tool (exists(name)) for a user. Returns (result, is_error):
1014+ result is the tool's JSON-able answer, or an error message for the
1015+ model."""
1016+ tool = _BY_NAME [name ]
9921017 if args is None :
9931018 args = {}
9941019 if not isinstance (args , dict ):
@@ -1000,3 +1025,8 @@ def call(name, args, user):
10001025 return _compact_numbers (tool ["handler" ](user , args )), False
10011026 except ToolError as e :
10021027 return str (e ), True
1028+ except Exception :
1029+ # A bug, not the model's mistake: logged, and the model told so
1030+ # rather than the client getting a 500 it can't read.
1031+ log .exception ("MCP tool %s failed" , name )
1032+ return "The server hit an error running this tool. Its log has the details." , True
0 commit comments