diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index 8073643123..9b4db01f1e 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -1,7 +1,7 @@ # cuopt_mcp — MCP server for NVIDIA cuOpt -Exposes cuOpt LP and MILP solving to MCP clients (Claude Code, Cursor, Codex) -over the cuOpt gRPC backend. +Exposes cuOpt LP, MILP, and vehicle routing (VRP/PDP) solving to MCP clients +(Claude Code, Cursor, Codex) over the cuOpt gRPC backend. ```text MCP client ──stdio (JSON-RPC)──> cuopt-mcp ──gRPC──> cuopt_grpc_server (GPU) @@ -55,13 +55,15 @@ needed) whenever `gpu-host` isn't a trusted local network. | `cuopt_health` | Report the configured gRPC target and whether it answers | | `cuopt_solve_lp` | Submit an LP; returns a `job_id` immediately | | `cuopt_solve_milp` | Submit a MILP; returns a `job_id` immediately | -| `cuopt_status` | Poll job state | -| `cuopt_result` | Fetch the solution, shaped to stay readable | +| `cuopt_solve_vrp` | Submit a vehicle routing problem; returns a `job_id` immediately | +| `cuopt_status` | Poll job state (LP, MILP, or VRP) | +| `cuopt_result` | Fetch an LP/MILP solution, shaped to stay readable | +| `cuopt_vrp_result` | Fetch a VRP solution (route stops), shaped to stay readable | | `cuopt_incumbents` | Watch a MILP's objective improve (needs `track_incumbents=true` at submit) | | `cuopt_logs` | Solver log lines for a finished job (no live tail yet) | | `cuopt_cancel` | Stop a running job | | `cuopt_delete` | Release a job's server-side state once its result is no longer needed | -| `cuopt_list_settings` | Discover solver parameters | +| `cuopt_list_settings` | Discover LP/MILP solver parameters | Solves are asynchronous by design. A blocking call would exceed the MCP client timeout on any realistic MILP and would make cancellation impossible. @@ -149,8 +151,17 @@ limit on a tool result is the model's context window, not the transport. So `cuopt_result` returns a summary plus narrow accessors (`variables`, `nonzero_only`), writing the full vector to a file past `limit`. -**Settings catalogue is generated.** `_generated/cuopt_mcp_schema.json` is -emitted from `cpp/src/grpc/codegen/field_registry.yaml` by -`./build.sh codegen`, the same source of truth that drives the proto and the -C++ conversion code. A new solver parameter reaches this server with no -MCP-specific work. +**Settings catalogue is generated (LP/MILP only).** `_generated/ +cuopt_mcp_schema.json` is emitted from `cpp/src/grpc/codegen/ +field_registry.yaml` by `./build.sh codegen`, the same source of truth +that drives the proto and the C++ conversion code. A new LP/MILP solver +parameter reaches this server with no MCP-specific work. VRP settings +aren't in this registry (only `time_limit`/`verbose_mode` (or `verbose`)/ +`error_logging` reach the server; see `cuopt_solve_vrp`'s docstring) so there's no +equivalent `cuopt_list_settings` coverage for VRP. + +**VRP submission has no host-CUDA dependency at record time.** +`cuopt.routing.DataModel` records setter calls (numpy arrays) and never +builds a device model on this host -- it only serializes the recorded +calls onto the wire, the same way `cuopt_solve_lp`/`cuopt_solve_milp`'s +JSON path never runs a solve locally. diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index f29b4da7a3..17da6a4361 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from cuopt.grpc.linear_programming import Client + from cuopt.grpc.routing import RoutingClient DEFAULT_HOST = "localhost" # Matches cuopt_default_grpc_port (cpp/src/grpc/cuopt_default_grpc_port.h) -- @@ -36,6 +37,7 @@ def redact_paths(text: str) -> str: _lock = threading.Lock() _client = None +_routing_client = None def endpoint() -> tuple: @@ -120,6 +122,36 @@ def reset_client() -> None: _client = None +def get_routing_client() -> "RoutingClient": + """Return a process-wide VRP gRPC client, connecting on first use. + + Separate from :func:`get_client`: the LP/MIP and VRP services are + distinct proto services with distinct compiled client classes, even + though both point at the same ``cuopt_grpc_server`` target. + + Returns + ------- + The cached ``cuopt.grpc.routing.RoutingClient``, creating it + against the current ``CUOPT_REMOTE_HOST``/``CUOPT_REMOTE_PORT`` / + TLS environment on first call. + """ + global _routing_client + with _lock: + if _routing_client is None: + from cuopt.grpc.routing import RoutingClient + + host, port = endpoint() + _routing_client = RoutingClient(host, port, tls=_tls_config()) + return _routing_client + + +def reset_routing_client() -> None: + """Drop the cached VRP client. Used by tests and after a channel error.""" + global _routing_client + with _lock: + _routing_client = None + + class CuOptMCPError(RuntimeError): """Raised with text meant for the model, not a stack trace.""" diff --git a/python/cuopt_mcp/cuopt_mcp/routing.py b/python/cuopt_mcp/cuopt_mcp/routing.py new file mode 100644 index 0000000000..ec78092113 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/routing.py @@ -0,0 +1,408 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""VRP (vehicle routing) tools for the cuOpt MCP server. + +Separate from tools.py: VRP uses a different gRPC client +(``RoutingClient``), a plain-dict settings object instead of a generated +schema, and a route table instead of a variable vector, so little of the +LP/MIP machinery is shared. cuopt_status/cuopt_cancel/cuopt_delete are +shared, though -- job_id is server-assigned from one registry regardless +of problem category, so those tools already work for a VRP job_id. + +A submitted model never touches the GPU on this host: ``routing.DataModel`` +records setter calls (numpy/plain-Python arrays) into a serializable form +and only builds the device model when solved locally, which this process +never does -- it only serializes the recorded calls onto the wire. +""" + +import json + +from .client import ( + CuOptMCPError, + describe_connection_error, + get_routing_client, +) +from .tools import INLINE_SOLUTION_LIMIT, _solution_file_path + +# node_type_t (cpp/include/cuopt/routing/routing_structures.hpp): plain +# sequential enum, no explicit values. Names match +# cuopt.grpc.client.grpc_client._NODE_TYPE_NAMES exactly (that mapping is +# what add_initial_solutions' `types` argument is actually matched against). +_NODE_TYPES = {"Depot": 0, "Pickup": 1, "Delivery": 2, "Break": 3} +_NODE_TYPE_NAMES = {v: k for k, v in _NODE_TYPES.items()} + +# objective_t (same header): also a plain sequential enum. No name mapping +# exists on the client side (unlike node types) because nothing needs one +# for local solving -- built here specifically for this JSON surface. +_OBJECTIVES = { + "COST": 0, + "TRAVEL_TIME": 1, + "VARIANCE_ROUTE_SIZE": 2, + "VARIANCE_ROUTE_SERVICE_TIME": 3, + "PRIZE": 4, + "VEHICLE_FIXED_COST": 5, + "DISTANCE_BREAK_COST": 6, +} +_OBJECTIVE_NAMES = {v: k for k, v in _OBJECTIVES.items()} + +# assignment.SolutionStatus, mirrored here rather than imported: importing +# the real enum pulls the compiled routing wrapper (and cudf) at module +# scope, defeating the lazy-import discipline this package otherwise keeps. +_STATUS_NAMES = {0: "SUCCESS", 1: "FAIL", 2: "TIMEOUT", 3: "EMPTY"} + + +def _node_type_value(value, where: str) -> str: + name = str(value).capitalize() + if name not in _NODE_TYPES: + raise CuOptMCPError( + f"{where}: {value!r} is not a node type ({sorted(_NODE_TYPES)})" + ) + return name + + +def _objective_value(value) -> int: + name = str(value).upper() + if name not in _OBJECTIVES: + raise CuOptMCPError( + f"objective {value!r} is not one of {sorted(_OBJECTIVES)}" + ) + return _OBJECTIVES[name] + + +def _require(problem: dict, key: str): + if key not in problem: + raise CuOptMCPError(f"problem is missing required key {key!r}") + return problem[key] + + +def _i32(values): + import numpy as np + + return np.asarray(values, dtype=np.int32) + + +def _f32(values): + import numpy as np + + return np.asarray(values, dtype=np.float32) + + +def _u8(values): + import numpy as np + + return np.asarray(values, dtype=np.uint8) + + +def _bool_arr(values): + import numpy as np + + return np.asarray(values, dtype=bool) + + +def _opt_i32(values): + """Like _i32, but None passes through -- an unset "locations" argument + means "any location", not an empty array. + """ + return None if values is None else _i32(values) + + +def _build_routing_model_from_json(problem: dict): + """Build a routing.DataModel from plain JSON arrays. + + Every array-like value is converted to a numpy array before reaching a + setter: the Python-side validators several setters run eagerly + (validate_matrix, validate_time_windows) explicitly reject plain + Python lists, unlike the gRPC serialization layer underneath (which + accepts anything array-like). + + Raises + ------ + CuOptMCPError: A required key is missing, an array has the wrong + shape, or a mutually exclusive combination was given. + """ + try: + return _map_problem_to_model(problem) + except KeyError as exc: + raise CuOptMCPError( + f"problem is missing required key {exc.args[0]!r}" + ) from None + + +def _map_problem_to_model(problem: dict): + import numpy as np + + from cuopt.routing import DataModel + + if not isinstance(problem, dict): + raise CuOptMCPError("problem must be an object") + + n_locations = int(_require(problem, "n_locations")) + fleet_size = int(_require(problem, "fleet_size")) + n_orders = int(problem.get("n_orders", -1)) + dm = DataModel(n_locations, fleet_size, n_orders) + + cost_matrices = _require(problem, "cost_matrices") + if not cost_matrices: + raise CuOptMCPError("cost_matrices must have at least one entry") + for entry in cost_matrices: + dm.add_cost_matrix(_f32(entry["values"]), entry.get("vehicle_type", 0)) + for entry in problem.get("transit_time_matrices", []): + dm.add_transit_time_matrix( + _f32(entry["values"]), entry.get("vehicle_type", 0) + ) + + if "vehicle_types" in problem: + dm.set_vehicle_types(_u8(problem["vehicle_types"])) + if "vehicle_locations" in problem: + vl = problem["vehicle_locations"] + dm.set_vehicle_locations(_i32(vl["start"]), _i32(vl["end"])) + if "vehicle_time_windows" in problem: + vtw = problem["vehicle_time_windows"] + dm.set_vehicle_time_windows(_i32(vtw["earliest"]), _i32(vtw["latest"])) + if "drop_return_trips" in problem: + dm.set_drop_return_trips(_bool_arr(problem["drop_return_trips"])) + if "skip_first_trips" in problem: + dm.set_skip_first_trips(_bool_arr(problem["skip_first_trips"])) + if "vehicle_max_costs" in problem: + dm.set_vehicle_max_costs(_f32(problem["vehicle_max_costs"])) + if "vehicle_max_times" in problem: + dm.set_vehicle_max_times(_f32(problem["vehicle_max_times"])) + if "vehicle_fixed_costs" in problem: + dm.set_vehicle_fixed_costs(_f32(problem["vehicle_fixed_costs"])) + + if "order_locations" in problem: + dm.set_order_locations(_i32(problem["order_locations"])) + if "order_time_windows" in problem: + otw = problem["order_time_windows"] + dm.set_order_time_windows(_i32(otw["earliest"]), _i32(otw["latest"])) + if "order_prizes" in problem: + dm.set_order_prizes(_f32(problem["order_prizes"])) + for entry in problem.get("order_service_times", []): + dm.set_order_service_times( + _i32(entry["service_times"]), entry.get("vehicle_id", -1) + ) + + if "pickup_delivery_pairs" in problem: + pdp = problem["pickup_delivery_pairs"] + # pickup/delivery are order indices (positions into order_locations + # etc.), not location ids -- set_order_locations should be called + # first for these to mean anything, though nothing here enforces it + # (the underlying setter doesn't either; a mismatch surfaces at + # solve time, not here). + dm.set_pickup_delivery_pairs( + _i32(pdp["pickup"]), _i32(pdp["delivery"]) + ) + + for entry in problem.get("capacity_dimensions", []): + dm.add_capacity_dimension( + entry["name"], _i32(entry["demand"]), _i32(entry["capacity"]) + ) + + if "break_locations" in problem: + dm.set_break_locations(_i32(problem["break_locations"])) + uniform_breaks = problem.get("uniform_breaks", []) + vehicle_breaks = problem.get("vehicle_breaks", []) + vehicle_distance_breaks = problem.get("vehicle_distance_breaks", []) + if uniform_breaks and (vehicle_breaks or vehicle_distance_breaks): + raise CuOptMCPError( + "uniform_breaks and vehicle_breaks/vehicle_distance_breaks are " + "mutually exclusive -- fleet-wide breaks or per-vehicle breaks, " + "not both" + ) + for entry in uniform_breaks: + dm.add_break_dimension( + _i32(entry["earliest"]), + _i32(entry["latest"]), + _i32(entry["duration"]), + ) + for entry in vehicle_breaks: + dm.add_vehicle_break( + entry["vehicle_id"], + entry["earliest"], + entry["latest"], + entry["duration"], + _opt_i32(entry.get("locations")), + ) + for entry in vehicle_distance_breaks: + dm.add_vehicle_distance_break( + entry["vehicle_id"], + entry["distance_min"], + entry["distance_max"], + entry["duration"], + _opt_i32(entry.get("locations")), + ) + + for entry in problem.get("vehicle_order_match", []): + dm.add_vehicle_order_match(entry["vehicle_id"], _i32(entry["orders"])) + for entry in problem.get("order_vehicle_match", []): + dm.add_order_vehicle_match(entry["order_id"], _i32(entry["vehicles"])) + for entry in problem.get("order_precedence", []): + # add_order_precedence has no Python-side validation at all (not + # declared in vehicle_routing.py, only the compiled wrapper), so a + # bad order_id/preceding_orders value isn't caught until solve time. + dm.add_order_precedence( + entry["order_id"], _i32(entry["preceding_orders"]) + ) + + if "objective" in problem: + obj = problem["objective"] + objectives = _i32([_objective_value(o) for o in obj["objectives"]]) + dm.set_objective_function(objectives, _f32(obj["weights"])) + if "min_vehicles" in problem: + dm.set_min_vehicles(int(problem["min_vehicles"])) + if "initial_solutions" in problem: + init = problem["initial_solutions"] + types = np.asarray( + [ + _node_type_value(t, "initial_solutions.types") + for t in init["types"] + ] + ) + dm.add_initial_solutions( + _i32(init["vehicle_ids"]), + _i32(init["routes"]), + types, + _i32(init["sol_offsets"]), + ) + + return dm + + +def submit(problem: dict, settings: dict | None = None) -> dict: + """Build a VRP model from plain JSON arrays and submit it for an + asynchronous solve. + + Args: + problem: The model as plain JSON arrays. See + :func:`_build_routing_model_from_json` for the accepted keys -- + they mirror ``RoutingProblem`` in cuopt_routing.proto field for + field (e.g. ``cost_matrices``, ``order_locations``, + ``vehicle_time_windows``, ``capacity_dimensions``, + ``pickup_delivery_pairs``, ``vehicle_breaks``, ``objective``). + settings: Solver settings. Only ``time_limit``, ``verbose_mode`` + (or ``verbose``), and ``error_logging`` reach the server -- + ``dump_best_results``/``dump_config_file`` are local-solve-only + and have no effect here. + + Returns + ------- + A dict with ``job_id``, ``num_locations``, ``fleet_size``, and + ``num_orders``. + + Raises + ------ + CuOptMCPError: The problem is malformed, or the backend is + unreachable. + """ + model = _build_routing_model_from_json(problem) + try: + job_id = get_routing_client().submit(model, settings) + except Exception as exc: + raise describe_connection_error(exc) from exc + return { + "job_id": job_id, + "num_locations": model.get_num_locations(), + "fleet_size": model.get_fleet_size(), + "num_orders": model.get_num_orders(), + "next": ( + "Poll cuopt_status(job_id). When it reports COMPLETED, call " + "cuopt_vrp_result(job_id)." + ), + } + + +def result(job_id: str, limit: int = INLINE_SOLUTION_LIMIT) -> dict: + """Fetch a completed VRP solution, shaped to stay within a usable size. + + Args: + job_id: A job handle previously returned by :func:`submit`. + limit: Maximum route stops returned inline; must be between 0 and + :data:`tools.INLINE_SOLUTION_LIMIT`. Beyond this, the full + route table is written to a file and its path returned + instead. + + Returns + ------- + ``{"ready": False, ...}`` if the job hasn't finished yet. Otherwise + a dict with ``status`` ("SUCCESS"/"FAIL"/"TIMEOUT"/"EMPTY"), + ``total_objective_value``, ``objective_values`` (by name), + ``vehicle_count``, and either ``stops`` (a flat list of + ``{vehicle, location, type, arrival}``, one per visit across all + routes) or, past ``limit``, ``stops_truncated`` plus + ``solution_path``. A non-SUCCESS status carries + ``status_message``/``error_message`` and, on FAIL, + ``unserviced_orders``. + + Raises + ------ + CuOptMCPError: ``limit`` is out of range, or the backend is + unreachable. + """ + if ( + isinstance(limit, bool) + or not isinstance(limit, int) + or not 0 <= limit <= INLINE_SOLUTION_LIMIT + ): + raise CuOptMCPError( + f"limit must be an integer between 0 and {INLINE_SOLUTION_LIMIT}" + ) + try: + sol = get_routing_client().result(job_id) + except Exception as exc: + raise describe_connection_error(exc) from exc + if sol is None: + return { + "job_id": job_id, + "ready": False, + "hint": "Job has not finished. Poll cuopt_status(job_id).", + } + + summary = { + "job_id": job_id, + "ready": True, + "status": _STATUS_NAMES.get(sol["status"], str(sol["status"])), + "vehicle_count": int(sol["vehicle_count"]), + "total_objective_value": float(sol["total_objective_value"]), + "objective_values": { + _OBJECTIVE_NAMES.get(k, str(k)): float(v) + for k, v in sol["objective_values"].items() + }, + } + if sol["status_message"]: + summary["status_message"] = sol["status_message"] + if sol["error_message"]: + summary["error_message"] = sol["error_message"] + if sol["status"] == 1: # FAIL + summary["unserviced_orders"] = [ + int(v) for v in sol["unserviced_nodes"] + ] + + stops = [ + { + "vehicle": int(truck_id), + "location": int(loc), + "type": _NODE_TYPE_NAMES.get(int(ntype), str(int(ntype))), + "arrival": float(arrival), + } + for truck_id, loc, ntype, arrival in zip( + sol["truck_id"], + sol["locations"], + sol["node_types"], + sol["arrival_stamp"], + ) + ] + if len(stops) <= limit: + summary["stops"] = stops + else: + summary["stops_truncated"] = True + summary["stops_shown"] = limit + summary["stops"] = stops[:limit] + path = _solution_file_path(job_id).with_suffix(".vrp.json") + path.write_text(json.dumps(stops, indent=1)) + summary["solution_path"] = str(path) + summary["hint"] = ( + f"{len(stops)} stops exceed the inline limit of {limit}. The " + "full route table is at solution_path." + ) + return summary diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index d235391b0c..006f58de69 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -17,7 +17,7 @@ from mcp.server.mcpserver import MCPServer -from . import tools +from . import routing, tools from .client import CuOptMCPError, endpoint, redact_paths logging.basicConfig( @@ -29,11 +29,14 @@ server = MCPServer( name="cuopt", instructions=( - "Solve linear and mixed-integer programs with NVIDIA cuOpt on GPU. " - "Solves are asynchronous: cuopt_solve_lp / cuopt_solve_milp return a " - "job_id immediately, then poll cuopt_status and fetch cuopt_result. " - "Call cuopt_list_settings to discover solver parameters before " - "passing a settings object. " + "Solve linear programs, mixed-integer programs, and vehicle " + "routing problems with NVIDIA cuOpt on GPU. Solves are " + "asynchronous: cuopt_solve_lp / cuopt_solve_milp / cuopt_solve_vrp " + "return a job_id immediately, then poll cuopt_status and fetch " + "cuopt_result (LP/MILP) or cuopt_vrp_result (VRP) -- cuopt_status/" + "cuopt_cancel/cuopt_delete work for a job_id from any of the three. " + "Call cuopt_list_settings to discover LP/MILP solver parameters " + "before passing a settings object. " "This server is a client, not a solver: it needs a running " "cuopt_grpc_server and never starts one. Call cuopt_health first to " "see the configured host/port and whether it answers. If it does " @@ -188,13 +191,101 @@ def cuopt_solve_milp( ) +@server.tool(structured_output=True) +def cuopt_solve_vrp( + problem: dict, settings: dict | None = None +) -> dict[str, Any]: + """Submit a vehicle routing problem (VRP/PDP) to cuOpt and return a job + handle immediately. + + problem: the model as plain JSON arrays, mirroring RoutingProblem in + cuopt_routing.proto field for field. Required: + n_locations, fleet_size: sizes (locations include vehicle start/end + points; n_orders defaults to n_locations if omitted). + cost_matrices: [{"values": [[...]], "vehicle_type": 0}] -- one + n_locations x n_locations matrix per vehicle_type (heterogeneous + fleets use more than one). At least one required. + Optional, by area: + transit_time_matrices: same shape as cost_matrices, used for time- + window feasibility instead of cost_matrices when set. + vehicle_locations: {"start": [...], "end": [...]}, vehicle_types, + vehicle_time_windows: {"earliest": [...], "latest": [...]}, + drop_return_trips, skip_first_trips, vehicle_max_costs, + vehicle_max_times, vehicle_fixed_costs -- each length fleet_size. + order_locations, order_prizes -- each length n_orders. + order_time_windows: {"earliest": [...], "latest": [...]}, length + n_orders (per order, not per location). + order_service_times: [{"service_times": [...], "vehicle_id": -1}] -- + vehicle_id -1 (default) sets the fallback for all vehicles. + pickup_delivery_pairs: {"pickup": [...], "delivery": [...]} -- order + indices (positions into order_locations), not location ids. + capacity_dimensions: [{"name": ..., "demand": [...] (n_orders), + "capacity": [...] (fleet_size)}] -- one entry per dimension. + break_locations: allowed break locations (default: any). + uniform_breaks: [{"earliest": [...], "latest": [...], + "duration": [...]}] (each length fleet_size) -- fleet-wide + breaks. Mutually exclusive with vehicle_breaks/ + vehicle_distance_breaks. + vehicle_breaks: [{"vehicle_id", "earliest", "latest", "duration", + "locations": [...] (optional)}] -- one entry per break. + vehicle_distance_breaks: [{"vehicle_id", "distance_min", + "distance_max", "duration", "locations": [...] (optional)}]. + vehicle_order_match: [{"vehicle_id", "orders": [...]}] -- restricts + a vehicle to only the given orders. + order_vehicle_match: [{"order_id", "vehicles": [...]}] -- restricts + an order to only the given vehicles. + order_precedence: [{"order_id", "preceding_orders": [...]}]. + objective: {"objectives": [...names...], "weights": [...]}. Names: + COST, TRAVEL_TIME, VARIANCE_ROUTE_SIZE, + VARIANCE_ROUTE_SERVICE_TIME, PRIZE, VEHICLE_FIXED_COST, + DISTANCE_BREAK_COST. Default weight 1.0 for COST and for any + objective whose matching input (prizes, fixed costs, distance + breaks) is set; 0.0 otherwise. + min_vehicles: floor on fleet size used (solution may not be + optimal when set). + initial_solutions: {"vehicle_ids", "routes", "sol_offsets": [...], + "types": [...]} -- types are "Depot"/"Pickup"/"Delivery"/ + "Break". + + settings: optional, e.g. {"time_limit": 30}. Only time_limit, + verbose_mode (or verbose), and error_logging reach the server. + + Returns a job_id. Use cuopt_vrp_result once cuopt_status reports + COMPLETED. + """ + return _guard(routing.submit, problem=problem, settings=settings) + + +@server.tool(structured_output=True) +def cuopt_vrp_result( + job_id: str, limit: int = tools.INLINE_SOLUTION_LIMIT +) -> dict[str, Any]: + """Fetch the solution for a finished VRP job. + + job_id: a job handle previously returned by cuopt_solve_vrp. + limit: maximum route stops returned inline. Beyond this the full route + table is written to a file and its path returned instead. + + Returns status (SUCCESS/FAIL/TIMEOUT/EMPTY), total_objective_value, + objective_values (by name), vehicle_count, and stops -- a flat list of + {vehicle, location, type, arrival} across all routes, one per visit + (type is "Depot"/"Pickup"/"Delivery"/"Break"). On FAIL, also carries + unserviced_orders. On failure, or on an out-of-range limit, returns + ``{"error": }`` instead (see ``_guard``). + """ + return _guard(routing.result, job_id=job_id, limit=limit) + + @server.tool(structured_output=True) def cuopt_status(job_id: str) -> dict[str, Any]: """Report whether a cuOpt job is queued, running, or finished. - Cheap to call repeatedly. Returns terminal=true once the job has - reached COMPLETED, FAILED, CANCELLED, or NOT_FOUND. On failure, returns - ``{"error": }`` instead (see ``_guard``). + Works for a job_id from any of cuopt_solve_lp/cuopt_solve_milp/ + cuopt_solve_vrp -- job_id is server-issued from one registry + regardless of problem type. Cheap to call repeatedly. Returns + terminal=true once the job has reached COMPLETED, FAILED, CANCELLED, + or NOT_FOUND. On failure, returns ``{"error": }`` instead + (see ``_guard``). """ return _guard(tools.status, job_id=job_id) @@ -277,7 +368,8 @@ def cuopt_logs( @server.tool(structured_output=True) def cuopt_cancel(job_id: str) -> dict[str, Any]: - """Stop a running cuOpt job. Any incumbent found so far remains fetchable. + """Stop a running cuOpt job (LP, MILP, or VRP). Any incumbent found so + far remains fetchable. job_id: the job to cancel. Cancelling a job that has already reached COMPLETED or FAILED returns ``{"error": }`` (see ``_guard``) @@ -289,7 +381,8 @@ def cuopt_cancel(job_id: str) -> dict[str, Any]: @server.tool(structured_output=True) def cuopt_delete(job_id: str) -> dict[str, Any]: """Release a finished job's server-side state (solution, logs, - incumbents). Cancels first if it is still running. + incumbents). Works for LP, MILP, or VRP jobs. Cancels first if it is + still running. job_id: the job to delete. Call this once its result is no longer needed, so cuopt_grpc_server doesn't accumulate state indefinitely. diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index 0bafde9616..fed790a19e 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -724,9 +724,13 @@ def delete(job_id: str) -> dict: except Exception as exc: raise describe_connection_error(exc) from exc try: + # job_id is server-issued and category-agnostic (one job registry), + # so cuopt_delete doesn't know which of these sidecar files exist + # without tracking submit()'s category -- cheaper to just try all. path = _solution_file_path(job_id) path.unlink(missing_ok=True) path.with_suffix(".names.json").unlink(missing_ok=True) + path.with_suffix(".vrp.json").unlink(missing_ok=True) except CuOptMCPError: pass # nothing to clean up if the directory itself is unusable return {"job_id": job_id, "deleted": True} diff --git a/python/cuopt_mcp/tests/test_routing.py b/python/cuopt_mcp/tests/test_routing.py new file mode 100644 index 0000000000..b2757054b0 --- /dev/null +++ b/python/cuopt_mcp/tests/test_routing.py @@ -0,0 +1,304 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""routing.py: JSON -> DataModel mapping and tool behavior with a stubbed +gRPC client. + +These run without a GPU or a cuopt_grpc_server; the live path is covered by +test_end_to_end.py. build_model tests need cuopt.routing importable (the +cuopt_routing fixture), unlike the stubbed-client tests, which stub the +client entirely and never touch cuopt.routing. +""" + +import pytest + +from cuopt_mcp import client, routing + + +@pytest.fixture +def cuopt_routing(): + return pytest.importorskip( + "cuopt.routing", + reason="cuopt.routing (and its cuDF/CUDA chain) not importable", + ) + + +UUID1 = "11111111-1111-1111-1111-111111111111" + +MIN_PROBLEM = { + "n_locations": 2, + "fleet_size": 1, + "cost_matrices": [{"values": [[0, 1], [1, 0]]}], +} + + +def test_build_model_requires_n_locations(cuopt_routing): + with pytest.raises(client.CuOptMCPError, match="n_locations"): + routing._build_routing_model_from_json({"fleet_size": 1}) + + +def test_build_model_requires_cost_matrices(cuopt_routing): + with pytest.raises(client.CuOptMCPError, match="cost_matrices"): + routing._build_routing_model_from_json( + {"n_locations": 2, "fleet_size": 1} + ) + + +def test_build_model_rejects_empty_cost_matrices(cuopt_routing): + with pytest.raises(client.CuOptMCPError, match="cost_matrices"): + routing._build_routing_model_from_json( + {"n_locations": 2, "fleet_size": 1, "cost_matrices": []} + ) + + +def test_build_model_minimal(cuopt_routing): + dm = routing._build_routing_model_from_json(MIN_PROBLEM) + assert dm.get_num_locations() == 2 + assert dm.get_fleet_size() == 1 + assert dm.get_num_orders() == 2 # defaults to n_locations + + +def test_build_model_rejects_mutually_exclusive_breaks(cuopt_routing): + problem = { + **MIN_PROBLEM, + "uniform_breaks": [{"earliest": [0], "latest": [10], "duration": [1]}], + "vehicle_breaks": [ + {"vehicle_id": 0, "earliest": 0, "latest": 10, "duration": 1} + ], + } + with pytest.raises(client.CuOptMCPError, match="mutually exclusive"): + routing._build_routing_model_from_json(problem) + + +def test_build_model_rejects_bad_objective_name(cuopt_routing): + problem = { + **MIN_PROBLEM, + "objective": { + "objectives": ["NOT_A_REAL_OBJECTIVE"], + "weights": [1.0], + }, + } + with pytest.raises(client.CuOptMCPError, match="NOT_A_REAL_OBJECTIVE"): + routing._build_routing_model_from_json(problem) + + +def test_build_model_rejects_bad_node_type_name(cuopt_routing): + problem = { + **MIN_PROBLEM, + "initial_solutions": { + "vehicle_ids": [0], + "routes": [1], + "types": ["NotAType"], + "sol_offsets": [0], + }, + } + with pytest.raises(client.CuOptMCPError, match="NotAType"): + routing._build_routing_model_from_json(problem) + + +def test_build_model_full_feature_set(cuopt_routing): + """Exercises every JSON key routing.py maps, verified via + problem_summary (the same _populate path submit() uses) rather than a + live server -- catches a wrong setter/arg mapping without a GPU. + """ + from cuopt.grpc.routing.grpc_client import problem_summary + + problem = { + "n_locations": 5, + "fleet_size": 2, + "n_orders": 3, + "cost_matrices": [ + {"values": [[0, 1, 2, 3, 4]] * 5, "vehicle_type": 0}, + {"values": [[0, 2, 4, 6, 8]] * 5, "vehicle_type": 1}, + ], + "transit_time_matrices": [{"values": [[0, 1, 2, 3, 4]] * 5}], + "vehicle_types": [0, 1], + "vehicle_locations": {"start": [0, 0], "end": [0, 0]}, + "vehicle_time_windows": {"earliest": [0, 0], "latest": [100, 100]}, + "drop_return_trips": [False, True], + "skip_first_trips": [False, False], + "vehicle_max_costs": [50.0, 50.0], + "vehicle_max_times": [80.0, 80.0], + "vehicle_fixed_costs": [1.0, 2.0], + "order_locations": [1, 2, 3], + "order_time_windows": {"earliest": [0, 0, 0], "latest": [90, 90, 90]}, + "order_prizes": [1.0, 2.0, 3.0], + "order_service_times": [ + {"service_times": [1, 1, 1]}, + {"service_times": [2, 2, 2], "vehicle_id": 0}, + ], + "pickup_delivery_pairs": {"pickup": [0], "delivery": [1]}, + "capacity_dimensions": [ + {"name": "weight", "demand": [1, 1, 1], "capacity": [5, 5]} + ], + "break_locations": [0], + "vehicle_breaks": [ + {"vehicle_id": 0, "earliest": 10, "latest": 20, "duration": 5} + ], + "vehicle_distance_breaks": [ + { + "vehicle_id": 1, + "distance_min": 1.0, + "distance_max": 5.0, + "duration": 5, + } + ], + "vehicle_order_match": [{"vehicle_id": 0, "orders": [0, 1]}], + "order_vehicle_match": [{"order_id": 2, "vehicles": [0, 1]}], + "order_precedence": [{"order_id": 2, "preceding_orders": [0]}], + "objective": { + "objectives": ["cost", "travel_time"], + "weights": [1.0, 0.5], + }, + "min_vehicles": 1, + "initial_solutions": { + "vehicle_ids": [0], + "routes": [1], + "types": ["delivery"], + "sol_offsets": [0], + }, + } + dm = routing._build_routing_model_from_json(problem) + summary = problem_summary(dm) + assert summary["num_locations"] == 5 + assert summary["fleet_size"] == 2 + assert summary["num_orders"] == 3 + assert summary["cost_matrices"] == 2 + assert summary["transit_time_matrices"] == 1 + assert summary["vehicle_start_locations"] == 2 + assert summary["vehicle_tw_earliest"] == 2 + assert summary["order_locations"] == 3 + assert summary["order_tw_earliest"] == 3 + assert summary["order_prizes"] == 3 + assert summary["order_service_times"] == 2 + assert summary["pickup_indices"] == 1 + assert summary["capacity_dimensions"] == 1 + assert summary["break_locations"] == 1 + assert summary["vehicle_breaks"] == 1 + assert summary["vehicle_distance_breaks"] == 1 + assert summary["vehicle_order_match"] == 1 + assert summary["order_vehicle_match"] == 1 + assert summary["order_precedence"] == 1 + assert summary["objectives"] == 2 + assert summary["min_vehicles"] == 1 + assert summary["initial_solutions_routes"] == 1 + + +class FakeRoutingClient: + def __init__(self, solution=None): + self.solution = solution + self.submitted = [] + self.deleted = [] + + def submit(self, data_model, settings=None): + self.submitted.append((data_model, settings)) + return "job-new" + + def result(self, job_id): + return self.solution + + def delete(self, job_id): + self.deleted.append(job_id) + + +@pytest.fixture +def fake_routing(monkeypatch): + def _install(solution=None): + stub = FakeRoutingClient(solution) + monkeypatch.setattr(routing, "get_routing_client", lambda: stub) + return stub + + yield _install + client.reset_routing_client() + + +def test_submit_passes_settings_through(cuopt_routing, fake_routing): + stub = fake_routing() + out = routing.submit(MIN_PROBLEM, settings={"time_limit": 5.0}) + assert out["job_id"] == "job-new" + assert out["num_locations"] == 2 + assert out["fleet_size"] == 1 + assert stub.submitted[0][1] == {"time_limit": 5.0} + + +def test_result_reports_not_ready_without_raising(fake_routing): + fake_routing(None) + out = routing.result("job-1") + assert out["ready"] is False + assert "cuopt_status" in out["hint"] + + +def _fake_solution(**overrides): + sol = { + "status": 0, + "status_message": "cuOpt solver success.", + "error_message": "", + "vehicle_count": 1, + "total_objective_value": 7.0, + "objective_values": {0: 7.0}, + "truck_id": [0, 0, 0], + "locations": [0, 1, 0], + "node_types": [0, 2, 0], + "arrival_stamp": [0.0, 1.0, 2.0], + "unserviced_nodes": [], + } + sol.update(overrides) + return sol + + +def test_result_shapes_a_successful_solution(fake_routing): + fake_routing(_fake_solution()) + out = routing.result("job-1") + assert out["ready"] is True + assert out["status"] == "SUCCESS" + assert out["objective_values"] == {"COST": 7.0} + assert out["stops"] == [ + {"vehicle": 0, "location": 0, "type": "Depot", "arrival": 0.0}, + {"vehicle": 0, "location": 1, "type": "Delivery", "arrival": 1.0}, + {"vehicle": 0, "location": 0, "type": "Depot", "arrival": 2.0}, + ] + assert "unserviced_orders" not in out + + +def test_result_reports_unserviced_orders_on_fail(fake_routing): + fake_routing( + _fake_solution( + status=1, + status_message="", + error_message="infeasible", + unserviced_nodes=[1, 2], + ) + ) + out = routing.result("job-1") + assert out["status"] == "FAIL" + assert out["error_message"] == "infeasible" + assert out["unserviced_orders"] == [1, 2] + + +def test_result_truncates_large_solution_to_a_file( + fake_routing, tmp_path, monkeypatch +): + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) + n = 20 + fake_routing( + _fake_solution( + truck_id=[0] * n, + locations=list(range(n)), + node_types=[0] * n, + arrival_stamp=[float(i) for i in range(n)], + ) + ) + out = routing.result(UUID1, limit=5) + assert out["stops_truncated"] is True + assert len(out["stops"]) == 5 + written = tmp_path / f"{UUID1}.vrp.json" + assert written.is_file() + import json + + assert len(json.loads(written.read_text())) == n + + +def test_result_rejects_bad_limit(fake_routing): + fake_routing(_fake_solution()) + with pytest.raises(client.CuOptMCPError, match="limit"): + routing.result("job-1", limit=-1)