diff --git a/build.sh b/build.sh index 2c6fb407a4..2c8ef73834 100755 --- a/build.sh +++ b/build.sh @@ -14,7 +14,7 @@ ARGS=$* REPODIR=$(cd "$(dirname "$0")"; pwd) LIBCUOPT_BUILD_DIR=${LIBCUOPT_BUILD_DIR:=${REPODIR}/cpp/build} -VALIDARGS="clean codegen libcuopt cuopt_grpc_server cuopt cuopt_server cuopt_sh_client java docs deb --run-java-tests -a -b -g -fsanitize -tsan -msan -v -l= --verbose-pdlp --build-lp-only --no-fetch-rapids --skip-c-python-adapters --skip-tests-build --skip-routing-build --skip-grpc-build --skip-fatbin-write --host-lineinfo --split-compile [--cmake-args=\\\"\\\"] [--cache-tool=] --install --allgpuarch --ci-only-arch --show_depr_warn -h --help" +VALIDARGS="clean codegen libcuopt cuopt_grpc_server cuopt cuopt_server cuopt_sh_client cuopt_mcp java docs deb --run-java-tests -a -b -g -fsanitize -tsan -msan -v -l= --verbose-pdlp --build-lp-only --no-fetch-rapids --skip-c-python-adapters --skip-tests-build --skip-routing-build --skip-grpc-build --skip-fatbin-write --host-lineinfo --split-compile [--cmake-args=\\\"\\\"] [--cache-tool=] --install --allgpuarch --ci-only-arch --show_depr_warn -h --help" HELP="$0 [ ...] [ ...] where is: clean - remove all existing build artifacts and configuration (start over) @@ -24,6 +24,7 @@ HELP="$0 [ ...] [ ...] cuopt - build the cuopt Python package cuopt_server - build the cuopt_server Python package cuopt_sh_client - build cuopt self host client + cuopt_mcp - build the cuopt_mcp Python package (MCP server) java - build the cuopt Java bindings (requires libcuopt; needs maven and a JDK) docs - build the docs deb - build deb package (requires libcuopt to be built first) @@ -56,7 +57,7 @@ HELP="$0 [ ...] [ ...] --show_depr_warn - show cmake deprecation warnings -h - print this text - default action (no args) is to build 'libcuopt', 'cuopt', 'cuopt_server', and 'cuopt_sh_client' targets without installing into the conda environment (pass --install to also install libcuopt into the active conda environment; pass 'docs' explicitly to build documentation) + default action (no args) is to build 'libcuopt', 'cuopt', 'cuopt_server', 'cuopt_sh_client', and 'cuopt_mcp' targets without installing into the conda environment (pass --install to also install libcuopt into the active conda environment; pass 'docs' explicitly to build documentation) libcuopt build dir is: ${LIBCUOPT_BUILD_DIR} @@ -477,6 +478,12 @@ if buildAll || hasArg cuopt_sh_client; then python "${PYTHON_ARGS_FOR_INSTALL[@]}" . fi +# Build and install the cuopt_mcp Python package +if buildAll || hasArg cuopt_mcp; then + cd "${REPODIR}"/python/cuopt_mcp/ + python "${PYTHON_ARGS_FOR_INSTALL[@]}" . +fi + # Build the Java bindings (opt-in; pass 'java' explicitly to build) if hasArg java; then if [ ! -f "${LIBCUOPT_BUILD_DIR}/libcuopt.so" ] && [ ! -f "${INSTALL_PREFIX}/lib/libcuopt.so" ]; then diff --git a/cpp/src/grpc/server/grpc_server_main.cpp b/cpp/src/grpc/server/grpc_server_main.cpp index d307f5ede0..a229088eba 100644 --- a/cpp/src/grpc/server/grpc_server_main.cpp +++ b/cpp/src/grpc/server/grpc_server_main.cpp @@ -22,6 +22,7 @@ #include #include +#include #include // Defined in grpc_service_impl.cpp @@ -331,6 +332,11 @@ int main(int argc, char** argv) auto service = create_cuopt_grpc_service(); ServerBuilder builder; + // gRPC enables SO_REUSEPORT by default, letting a second server silently + // bind an already-served port -- each process has its own job_tracker, + // so a request landing on the other process gets "Job ID not found". + // Always off; use --port for an independent second instance. + builder.AddChannelArgument(GRPC_ARG_ALLOW_REUSEPORT, 0); builder.AddListeningPort(server_address, creds); builder.RegisterService(service.get()); const int64_t max_bytes = server_max_message_bytes(); @@ -342,6 +348,10 @@ int main(int argc, char** argv) std::unique_ptr server(builder.BuildAndStart()); if (!server) { SERVER_LOG_ERROR("[Server] Failed to bind to %s", server_address); + SERVER_LOG_ERROR( + "[Server] A server may already be listening there; check with " + "`pgrep -af cuopt_grpc_server`. Use --port for an independent second " + "instance."); shutdown_all(); return 1; } diff --git a/dependencies.yaml b/dependencies.yaml index 3fd82474bc..7b6d6a11fe 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -273,6 +273,7 @@ files: extras: table: build-system includes: + - rapids_build_setuptools - build_wheels py_run_cuopt_mcp: output: pyproject diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index 19534f8b83..8073643123 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -18,6 +18,8 @@ speaks gRPC to the backend. pip install cuopt_mcp ``` +Not published yet — until then, build from source (see [Testing](#build-and-install-from-source)). + ## Configure Start the solver backend on a GPU host (5001 is `cuopt_grpc_server`'s own @@ -50,6 +52,7 @@ needed) whenever `gpu-host` isn't a trusted local network. | Tool | Purpose | |------|---------| +| `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 | @@ -63,6 +66,78 @@ needed) whenever `gpu-host` isn't a trusted local network. Solves are asynchronous by design. A blocking call would exceed the MCP client timeout on any realistic MILP and would make cancellation impossible. +## Testing + +### Build and install from source + +```bash +conda activate ./.cuopt_env # the repo-local env, see CONTRIBUTING.md +./build.sh cuopt_mcp # installs into the active env +``` + +This installs as plain `cuopt_mcp` (unsuffixed), matching its `cuopt` +dependency -- `./build.sh` installs every Python package against the +unsuffixed, CPU-only `cuopt`. + +### Smoke test + +```bash +cuopt_grpc_server --port 5001 & +python -c "from cuopt_mcp import tools; print(tools.health())" +``` + +`reachable: true` means the MCP server can see the backend. If it is false the +message names the endpoint and what to check — a wrong `CUOPT_REMOTE_PORT` and +a server that is not running look identical from the client side, so it does +not assume either. + +### Test suite + +```bash +# Unit tests: no GPU, no server, stubbed gRPC client +pytest python/cuopt_mcp/tests -q + +# Plus end-to-end against a live server, over real MCP stdio +CUOPT_TEST_GRPC_PORT=5001 pytest python/cuopt_mcp/tests -q +``` + +Without `CUOPT_TEST_GRPC_PORT` the end-to-end tests skip rather than fail. + +**The end-to-end fixture launches `cuopt-mcp` from `PATH`**, not from the +interpreter running pytest. If another environment shadows the one you built, +the suite silently exercises that install instead — which surfaces as +unrelated-looking failures such as `undefined symbol: _ZTIN3rmm...bad_allocE` +from an ABI mismatch. Check with `which cuopt-mcp` before believing a failure. + +### Driving it from an MCP client + +Point the client at the built entry point and confirm with `cuopt_health` +before submitting a model — every other tool reports a connection problem only +after a model has been built. + +```json +{ + "mcpServers": { + "cuopt": { + "command": "/path/to/.cuopt_env/bin/cuopt-mcp", + "env": { "CUOPT_REMOTE_HOST": "localhost", "CUOPT_REMOTE_PORT": "5001" } + } + } +} +``` + +A minimal end-to-end exercise: `cuopt_health`, then `cuopt_solve_lp` with a +small JSON model, then `cuopt_status` until terminal, then `cuopt_result`. + +### If the backend looks unreachable + +`cuopt-mcp` never starts or stops `cuopt_grpc_server`. Before starting one, +check whether one is already running on the configured port: + +```bash +pgrep -af cuopt_grpc_server +``` + ## Design notes **No per-job state.** Column names needed to label a solution are supplied diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index d7c8e1b4b2..f29b4da7a3 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -67,12 +67,19 @@ def endpoint() -> tuple: return host, port -def _tls_config(): - if os.environ.get("CUOPT_TLS_ENABLED", "").lower() not in ( +def tls_enabled() -> bool: + """True when ``CUOPT_TLS_ENABLED`` is "1"/"true"/"yes" (case-insensitive); + False for any other value, including unset. + """ + return os.environ.get("CUOPT_TLS_ENABLED", "").lower() in ( "1", "true", "yes", - ): + ) + + +def _tls_config(): + if not tls_enabled(): return None from cuopt.grpc.linear_programming import TlsConfig @@ -136,9 +143,13 @@ def describe_connection_error(exc: Exception) -> CuOptMCPError: host, port = endpoint() text = str(exc) if "UNAVAILABLE" in text or "failed to connect" in text.lower(): + # "Start one" without this caveat invites a second server alongside + # one already running on another port/misconfigured env. return CuOptMCPError( - f"cuOpt gRPC server unreachable at {host}:{port}. Start it with " - f"`cuopt_grpc_server --port {port}`, or set CUOPT_REMOTE_HOST / " - "CUOPT_REMOTE_PORT to point at a running server." + f"cuOpt gRPC server unreachable at {host}:{port}. Check whether " + "one is already running (`pgrep -af cuopt_grpc_server`) before " + "starting another, and confirm CUOPT_REMOTE_HOST / " + f"CUOPT_REMOTE_PORT point at it. Only if none is running, start " + f"one with `cuopt_grpc_server --port {port}`." ) return CuOptMCPError(redact_paths(text)) diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index 5df66a1b7b..d235391b0c 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -33,7 +33,12 @@ "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." + "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 " + "not, check whether a server is already running before starting " + "another — two servers can end up sharing a port." ), ) @@ -61,13 +66,51 @@ def _guard(fn, /, **kwargs) -> dict[str, Any]: return {"error": "internal error -- see server logs"} +@server.tool(structured_output=True) +def cuopt_health() -> dict[str, Any]: + """Report the configured gRPC target and whether it answers. + + Takes no arguments. Returns host, port, tls, and reachable — plus, when + unreachable, the error and what to check. Worth calling before building + a model, since every other tool fails only after the model exists. + + This MCP server never starts or stops cuopt_grpc_server. If the target + is unreachable, check for a server that is already running before + starting one. + """ + return _guard(tools.health) + + @server.tool(structured_output=True) def cuopt_solve_lp( - problem_path: str, settings: dict | None = None + problem_path: str | None = None, + settings: dict | None = None, + *, + problem: dict | None = None, ) -> dict[str, Any]: """Submit a linear program to cuOpt and return a job handle immediately. + Give the model exactly one of two ways: + problem_path: path to an MPS, QPS, or LP file readable by this process. + problem: the model as plain JSON arrays, with no file involved: + objective: cost per variable (its length defines the column count) + constraint_matrix: {"rows": [...], "cols": [...], "values": [...]} + COO triplets, or {"offsets", "indices", "values"} for CSR + constraint_lower_bounds / constraint_upper_bounds: one per row, + defaulting to -inf / +inf. Set both equal for an equality row. + Their length fixes the row count, so a trailing row with all + zero coefficients is kept rather than inferred away. + variable_lower_bounds / variable_upper_bounds: default 0 / +inf + variable_names: labels echoed back by cuopt_result + maximize: true to maximise (default false) + objective_offset: constant added to the objective + + Bounds: use null for an unbounded side, since JSON has no infinity + literal. A magnitude of 1e30 or more is also read as infinite — left + finite, such a bound can make the solver return a constraint-violating + point reported as Optimal. Repeated matrix cells are summed. + settings: optional PDLP solver settings, e.g. {"time_limit": 60, "method": "Barrier"}. Call cuopt_list_settings("pdlp_settings") for the full list with descriptions and defaults. Omit any setting to @@ -79,6 +122,7 @@ def cuopt_solve_lp( return _guard( tools.submit, problem_path=problem_path, + problem=problem, kind="pdlp_settings", settings=settings, ) @@ -86,17 +130,47 @@ def cuopt_solve_lp( @server.tool(structured_output=True) def cuopt_solve_milp( - problem_path: str, + problem_path: str | None = None, settings: dict | None = None, track_incumbents: bool = False, + *, + problem: dict | None = None, ) -> dict[str, Any]: """Submit a mixed-integer program to cuOpt and return a job handle. + Give the model exactly one of two ways: + problem_path: path to an MPS or LP file declaring integer/binary - variables (LP: Generals/Binaries sections). + variables (LP: Generals/Binaries sections; MPS: an integer column + with no explicit bound entry silently defaults to [0, 1], which + turns an ordinary model infeasible for no visible reason). + problem: the model as plain JSON arrays. Prefer this over MPS for + integer models: variable_types carries integrality without + touching bounds. Keys — the same set cuopt_solve_lp takes, plus + variable_types, repeated here because a caller may hold this tool + without that one: + objective: cost per variable (its length defines the column count) + constraint_matrix: {"rows": [...], "cols": [...], "values": [...]} + COO triplets, or {"offsets", "indices", "values"} for CSR + constraint_lower_bounds / constraint_upper_bounds: one per row, + defaulting to -inf / +inf. Set both equal for an equality row. + Their length fixes the row count, so a trailing row with all + zero coefficients is kept rather than inferred away. + variable_lower_bounds / variable_upper_bounds: default 0 / +inf + variable_types: per-variable "I" (integer) or "C" (continuous) + variable_names: labels echoed back by cuopt_result + maximize: true to maximise (default false) + objective_offset: constant added to the objective + + Bounds: use null for an unbounded side, since JSON has no infinity + literal. A magnitude of 1e30 or more is also read as infinite — left + finite, such a bound can make the solver return a constraint-violating + point reported as Optimal. Repeated matrix cells are summed. + settings: optional MIP solver settings, e.g. {"time_limit": 300, "relative_mip_gap": 0.01}. Call cuopt_list_settings("mip_settings") - for the full list. + for the full list — parameter names are easy to guess wrong + (relative_mip_gap, not mip_relative_gap). track_incumbents: set True to make cuopt_incumbents useful for this job. Off by default -- it costs extra server-side work and network transfer per incumbent found, worth paying only if you'll poll it. @@ -107,6 +181,7 @@ def cuopt_solve_milp( return _guard( tools.submit, problem_path=problem_path, + problem=problem, kind="mip_settings", settings=settings, track_incumbents=track_incumbents, @@ -140,8 +215,8 @@ def cuopt_result( names_from: path to the problem file, to key values by variable name rather than column index. Pass the "source" returned by the solve. variables: fetch only these named variables. - nonzero_only: return only variables with a non-zero value — usually - what matters for a MILP. + nonzero_only: drop values within solver tolerance of zero (not + necessarily exact zero) — usually what matters for a MILP. limit: maximum values returned inline. Beyond this the selected values (respecting nonzero_only) are written to a file and its path returned instead. diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index 022377c639..0bafde9616 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -21,7 +21,14 @@ import tempfile from pathlib import Path -from .client import CuOptMCPError, describe_connection_error, get_client +from .client import ( + CuOptMCPError, + describe_connection_error, + endpoint, + get_client, + reset_client, + tls_enabled, +) from .schema import known_parameters, settings_schema, validate_settings # Above this many variables a solution is written to a file instead of @@ -31,6 +38,20 @@ # Upper bound on logs()'s tail_lines, same context-window reasoning. MAX_TAIL_LINES = 2000 +# Magnitude below which a solution value is treated as zero by nonzero_only. +ZERO_TOL = 1e-9 + +# At or beyond this magnitude a caller-supplied bound means infinity. +INFINITY_SENTINEL = 1e30 + +# Largest row/column count _to_csr will allocate for -- n_constraints is a +# tiny-payload scalar that sizes an allocation directly. +MAX_PROBLEM_DIMENSION = 10_000_000 + +# No health RPC exists, so reachability is probed via a status lookup for +# a job id no server can have issued -- must come back NOT_FOUND. +PROBE_JOB_ID = "00000000-0000-0000-0000-000000000000" + def _check_non_negative_int(name: str, value) -> None: """Reject a bool (an int subclass in Python) or a negative value.""" @@ -38,6 +59,18 @@ def _check_non_negative_int(name: str, value) -> None: raise CuOptMCPError(f"{name} must be a non-negative integer") +def _check_dimension(name: str, value) -> None: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or not 0 <= value <= MAX_PROBLEM_DIMENSION + ): + raise CuOptMCPError( + f"{name} ({value!r}) must be an integer between 0 and " + f"{MAX_PROBLEM_DIMENSION}" + ) + + def _solution_dir() -> Path: """Return the directory solution/name files are written to, private to this user. @@ -95,6 +128,224 @@ def _read_problem(path: str): raise CuOptMCPError(f"failed to parse {resolved}: {exc}") from exc +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 _merge_duplicate_cells(rows, cols, values): + """Sum COO entries that name the same cell. + + Building a row incrementally naturally emits a cell twice (``2*x`` after + collecting ``x`` from two terms). Passing both through would leave the + row's meaning dependent on how the backend treats repeated indices, so + they are summed here where the intent is unambiguous. + """ + import numpy as np + + if len(rows) == 0: + return rows, cols, values + starts = np.empty(len(rows), dtype=bool) + starts[0] = True + starts[1:] = (rows[1:] != rows[:-1]) | (cols[1:] != cols[:-1]) + if starts.all(): + return rows, cols, values + group = np.cumsum(starts) - 1 + merged = np.zeros(int(group[-1]) + 1, dtype=np.float64) + np.add.at(merged, group, values) + return rows[starts], cols[starts], merged + + +def _check_coo_indices(name: str, raw, upper_bound: int | None = None): + """Validate raw COO row/col values and return them as int64. + + Casting straight to int64 would silently truncate a fractional value, + convert a bool to 0/1, and let a negative value through to build a + different (or invalid) model instead of rejecting it. + """ + import numpy as np + + arr = np.asarray(raw) + if arr.dtype.kind == "b" or ( + arr.dtype.kind == "f" and not np.array_equal(arr, np.floor(arr)) + ): + raise CuOptMCPError( + f"constraint_matrix {name} must be integers, got {raw!r}" + ) + if len(arr) and arr.min() < 0: + raise CuOptMCPError(f"constraint_matrix {name} must be non-negative") + if upper_bound is not None and len(arr) and arr.max() >= upper_bound: + raise CuOptMCPError( + f"constraint_matrix {name} must be less than {upper_bound}" + ) + return arr.astype(np.int64) + + +def _to_csr(matrix: dict, n_vars: int, n_cons: int | None = None): + """Accept either CSR or COO triplets and return CSR arrays. + + COO is what a caller naturally builds when emitting a model row by row, + so taking it directly removes the most error-prone step of the handoff. + + n_cons pins the row count. Without it the count is inferred from the + largest row index present, which silently loses a trailing row whose + coefficients are all zero. + """ + import numpy as np + + _check_dimension("n_vars", n_vars) + if n_cons is not None: + _check_dimension("n_constraints", n_cons) + + if "offsets" in matrix: + offsets = np.asarray(matrix["offsets"], dtype=np.int32) + indices = np.asarray(matrix["indices"], dtype=np.int32) + values = np.asarray(matrix["values"], dtype=np.float64) + if len(indices) != len(values): + raise CuOptMCPError( + f"constraint_matrix indices ({len(indices)}) and values " + f"({len(values)}) must have equal length" + ) + if n_cons is not None and len(offsets) - 1 != n_cons: + raise CuOptMCPError( + f"constraint_matrix has {len(offsets) - 1} rows but " + f"{n_cons} constraint bounds were given" + ) + return offsets, indices, values + + rows_raw = matrix.get("rows", []) + cols_raw = matrix.get("cols", []) + values = np.asarray(matrix.get("values", []), dtype=np.float64) + if not (len(rows_raw) == len(cols_raw) == len(values)): + raise CuOptMCPError( + f"constraint_matrix rows/cols/values must have equal length, got " + f"{len(rows_raw)}/{len(cols_raw)}/{len(values)}" + ) + rows = _check_coo_indices("rows", rows_raw) + cols = _check_coo_indices("cols", cols_raw, upper_bound=n_vars) + inferred = int(rows.max()) + 1 if len(rows) else 0 + if n_cons is None: + _check_dimension("constraint_matrix row count", inferred) + n_cons = inferred + elif inferred > n_cons: + raise CuOptMCPError( + f"constraint_matrix references row {inferred - 1} but only " + f"{n_cons} constraint bounds were given" + ) + order = np.lexsort((cols, rows)) + rows, cols, values = _merge_duplicate_cells( + rows[order], cols[order], values[order] + ) + counts = np.bincount(rows, minlength=n_cons).astype(np.int32) + offsets = np.zeros(n_cons + 1, dtype=np.int32) + np.cumsum(counts, out=offsets[1:]) + return offsets, cols.astype(np.int32), values + + +def _build_model_from_json(problem: dict): + """Build a DataModel from plain arrays, with no file in the loop. + + Integrality is declared as a type vector rather than MPS INTORG/INTEND + markers, so integer columns keep the bounds given here instead of + silently defaulting to [0, 1]. + """ + import numpy as np + + from cuopt.linear_programming import DataModel + + if not isinstance(problem, dict): + raise CuOptMCPError("problem must be an object") + + objective = np.asarray(_require(problem, "objective"), dtype=np.float64) + n_vars = len(objective) + + # Prefer a caller-stated row count: inferring it from the largest row + # index would silently drop a trailing all-zero row. + lengths = { + key: len(problem[key]) + for key in ("constraint_lower_bounds", "constraint_upper_bounds") + if problem.get(key) is not None + } + if len(set(lengths.values())) > 1: + raise CuOptMCPError( + "constraint_lower_bounds and constraint_upper_bounds must have " + f"the same length, got {lengths}" + ) + declared = problem.get("n_constraints") + if declared is None and lengths: + declared = next(iter(lengths.values())) + + offsets, indices, values = _to_csr( + _require(problem, "constraint_matrix"), n_vars, declared + ) + n_cons = max(len(offsets) - 1, 0) + + def vec(key, default, size, dtype=np.float64): + raw = problem.get(key) + if raw is None: + return np.full(size, default, dtype=dtype) + # JSON has no infinity literal, so null means "unbounded on this + # side" and is the only way a caller can express a one-sided row. + arr = np.asarray( + [default if x is None else x for x in raw], dtype=dtype + ) + # 1e30 (MPS-era convention) means infinity too; left finite, such a + # bound can make cuOpt report a constraint-violating point Optimal. + if dtype is np.float64: + arr = np.where(arr >= INFINITY_SENTINEL, np.inf, arr) + arr = np.where(arr <= -INFINITY_SENTINEL, -np.inf, arr) + if len(arr) != size: + raise CuOptMCPError( + f"{key} has length {len(arr)}, expected {size}" + ) + return arr + + model = DataModel() + model.set_csr_constraint_matrix(values, indices, offsets) + model.set_objective_coefficients(objective) + model.set_constraint_lower_bounds( + vec("constraint_lower_bounds", -np.inf, n_cons) + ) + model.set_constraint_upper_bounds( + vec("constraint_upper_bounds", np.inf, n_cons) + ) + model.set_variable_lower_bounds(vec("variable_lower_bounds", 0.0, n_vars)) + model.set_variable_upper_bounds( + vec("variable_upper_bounds", np.inf, n_vars) + ) + model.set_maximize(bool(problem.get("maximize", False))) + if problem.get("objective_offset"): + model.set_objective_offset(float(problem["objective_offset"])) + if problem.get("problem_name"): + model.set_problem_name(str(problem["problem_name"])) + + types = problem.get("variable_types") + if types is not None: + if len(types) != n_vars: + raise CuOptMCPError( + f"variable_types has length {len(types)}, expected {n_vars}" + ) + allowed = {"C", "I"} + bad = sorted({str(t).upper() for t in types} - allowed) + if bad: + raise CuOptMCPError( + f"variable_types entries must be 'C' or 'I', got {bad}" + ) + model.set_variable_types( + np.asarray([str(t).upper() for t in types], dtype=" str: + # job_id is the backend's response, not literal caller input -- nothing + # guarantees it's a well-formed UUID, so validate before it's a path. + path = _solution_file_path(job_id).with_suffix(".names.json") + path.write_text(json.dumps([str(v) for v in names])) + return str(path) + + +def health() -> dict: + """Report where this server is pointed and whether that target answers. + + Every other tool needs a model or a job_id, so without this there is no + way to check the connection except by submitting work and reading the + failure — by which point a caller has already built a model, and may + conclude from the error that no server is running anywhere. + + Returns + ------- + A dict with ``host``, ``port``, ``tls``, and ``reachable``. When + unreachable, also ``error`` (the same message :func:`submit` + raises for a connection failure). When reachable, also ``note`` + (this process never starts/stops the backend). + + Raises + ------ + CuOptMCPError: The configured endpoint is invalid (e.g. + ``CUOPT_REMOTE_PORT``), from :func:`endpoint`. + """ + host, port = endpoint() + info = {"host": host, "port": port, "tls": tls_enabled()} + try: + get_client().status(PROBE_JOB_ID) + except Exception as exc: + # A dead cached channel would keep failing every later call. + reset_client() + return { + **info, + "reachable": False, + "error": str(describe_connection_error(exc)), + } + return { + **info, + "reachable": True, + "note": "This server does not start or stop cuopt_grpc_server; it " + "only holds a channel to one.", + } + + def submit( - problem_path: str, kind: str, + problem_path: str | None = None, + problem: dict | None = None, settings: dict | None = None, track_incumbents: bool = False, ) -> dict: - """Parse a problem file and submit it for an asynchronous solve. + """Submit a model, given either as a file path or plain JSON arrays, for + an asynchronous solve. Args: - problem_path: Path to an MPS/QPS/LP file readable by this process. kind: "pdlp_settings" for LP or "mip_settings" for MILP; selects which settings schema ``settings`` is validated against. + problem_path: Path to an MPS/QPS/LP file readable by this process. + Exactly one of ``problem_path``/``problem`` must be given. + problem: The model as plain JSON arrays, with no file in the loop. settings: Solver settings by name, or ``None`` to use cuOpt defaults for all of them. track_incumbents: For a MIP job, collect incumbents server-side so @@ -157,11 +465,20 @@ def submit( Raises ------ - CuOptMCPError: ``kind`` is invalid, the file doesn't exist, fails to - parse, carries an unknown setting, or the backend is - unreachable. + CuOptMCPError: ``kind`` is invalid, neither or both of + ``problem_path``/``problem`` were given, the input doesn't + exist/parse/validate, or the backend is unreachable. """ - model = _read_problem(problem_path) + if (problem_path is None) == (problem is None): + raise CuOptMCPError( + "pass exactly one of problem_path (an MPS/QPS/LP file) or " + "problem (a JSON model object)" + ) + model = ( + _read_problem(problem_path) + if problem_path is not None + else _build_model_from_json(problem) + ) solver_settings = _build_settings(kind, settings) try: job_id = get_client().submit( @@ -174,14 +491,23 @@ def submit( # DataModel exposes no public size accessors; derive from CSR offsets. offsets = model.get_constraint_matrix_offsets() + if problem_path is not None: + source = str(Path(problem_path).expanduser().resolve()) + else: + names = problem.get("variable_names") + source = _write_names_file(job_id, names) if names else None return { "job_id": job_id, - "source": str(Path(problem_path).expanduser().resolve()), + "source": source, "num_variables": int(len(model.get_variable_lower_bounds())), "num_constraints": int(max(len(offsets) - 1, 0)), "next": ( "Poll cuopt_status(job_id). When it reports COMPLETED, call " "cuopt_result(job_id, names_from=source) for a named solution." + if source + else "Poll cuopt_status(job_id). When it reports COMPLETED, call " + "cuopt_result(job_id). Values will be keyed by column index; " + "pass variable_names in the problem to label them." ), } @@ -254,7 +580,9 @@ def result( variables: Return only these named/indexed variables, skipping the inline-size shaping below. An empty list returns no variables (distinct from omitting the argument). - nonzero_only: Drop exactly-zero values before applying ``limit``. + nonzero_only: Drop values with magnitude at most :data:`ZERO_TOL` + (1e-9) before applying ``limit`` -- a dropped value isn't + necessarily exact zero, just within solver tolerance of it. limit: Maximum variables returned inline; must be between 0 and :data:`INLINE_SOLUTION_LIMIT`. Beyond this, the selected values (post ``nonzero_only`` filtering) are written to a file and @@ -324,7 +652,8 @@ def result( selected = vars_by_name if nonzero_only: - selected = {k: v for k, v in vars_by_name.items() if v != 0} + # Exact != 0 would let PDLP's numerical dust (~1e-13) through as signal. + selected = {k: v for k, v in vars_by_name.items() if abs(v) > ZERO_TOL} summary["num_nonzero"] = len(selected) if len(selected) <= limit: @@ -395,7 +724,9 @@ def delete(job_id: str) -> dict: except Exception as exc: raise describe_connection_error(exc) from exc try: - _solution_file_path(job_id).unlink(missing_ok=True) + path = _solution_file_path(job_id) + path.unlink(missing_ok=True) + path.with_suffix(".names.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/pyproject.toml b/python/cuopt_mcp/pyproject.toml index b6cec104b7..dde47184b3 100644 --- a/python/cuopt_mcp/pyproject.toml +++ b/python/cuopt_mcp/pyproject.toml @@ -2,11 +2,12 @@ # SPDX-License-Identifier: Apache-2.0 [build-system] +build-backend = "rapids_build_backend.build" requires = [ + "rapids-build-backend>=0.4.0,<0.5.0", "setuptools>=77.0.0", "wheel", ] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. -build-backend = "setuptools.build_meta" [project] name = "cuopt_mcp" @@ -58,3 +59,13 @@ include = ["cuopt_mcp*"] [tool.setuptools.package-data] cuopt_mcp = ["VERSION", "_generated/*.json"] + +[tool.rapids-build-backend] +build-backend = "setuptools.build_meta" +dependencies-file = "../../dependencies.yaml" +# ./build.sh's shared install args pass cuda_suffixed=false on the command +# line, which overrides whatever matrix-entry says here -- so this must +# match py_run_cuopt_mcp's own matrix in dependencies.yaml (also false) or +# the two diverge (this package depends on plain "cuopt", so a mismatched +# "true" here would be internally inconsistent, not just unenforced). +matrix-entry = "cuda_suffixed=false" diff --git a/python/cuopt_mcp/tests/test_server.py b/python/cuopt_mcp/tests/test_server.py index 44ae5713bd..8e73c18332 100644 --- a/python/cuopt_mcp/tests/test_server.py +++ b/python/cuopt_mcp/tests/test_server.py @@ -3,8 +3,21 @@ """_guard's error-shaping behavior, isolated from the MCP protocol.""" +import inspect + from cuopt_mcp.client import CuOptMCPError -from cuopt_mcp.server import _guard +from cuopt_mcp.server import _guard, cuopt_solve_lp, cuopt_solve_milp + + +def test_problem_is_keyword_only_on_solve_tools(): + """``problem`` was inserted as an alternative to ``problem_path`` after + these tools' 2/3-positional-arg signatures had already shipped + (#1916) -- a positional (path, settings) call must still bind + settings to settings, not silently to problem. + """ + for fn in (cuopt_solve_lp, cuopt_solve_milp): + params = inspect.signature(fn).parameters + assert params["problem"].kind == inspect.Parameter.KEYWORD_ONLY def test_guard_redacts_paths_in_cuoptmcperror(): diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py index 1d0498836d..a2983f9653 100644 --- a/python/cuopt_mcp/tests/test_tools.py +++ b/python/cuopt_mcp/tests/test_tools.py @@ -12,6 +12,7 @@ import pytest from cuopt_mcp import client, tools +from cuopt_mcp.client import CuOptMCPError # job_id must look like a server-issued UUID (tools._JOB_ID_RE) wherever a # test exercises code that turns job_id into a filename (result()'s @@ -50,17 +51,30 @@ def get_termination_reason(self): class FakeClient: - def __init__(self, solution=None, not_ready_logs=False): + def __init__(self, solution=None, not_ready_logs=False, status_error=None): self.solution = solution self.cancelled = [] self.deleted = [] self.submitted = [] self.not_ready_logs = not_ready_logs + self.status_error = status_error + self.probed = [] def submit(self, problem, settings, enable_incumbents=None): self.submitted.append(enable_incumbents) return "job-new" + def status(self, job_id): + self.probed.append(job_id) + if self.status_error is not None: + raise self.status_error + import enum + + class JobStatus(enum.IntEnum): + NOT_FOUND = 5 + + return JobStatus.NOT_FOUND + def result(self, job_id, variable_names=None): return self.solution @@ -92,8 +106,12 @@ class JobNotReadyError(Exception): @pytest.fixture def fake(monkeypatch): - def _install(solution=None, not_ready_logs=False): - stub = FakeClient(solution, not_ready_logs=not_ready_logs) + def _install(solution=None, not_ready_logs=False, status_error=None): + stub = FakeClient( + solution, + not_ready_logs=not_ready_logs, + status_error=status_error, + ) monkeypatch.setattr(tools, "get_client", lambda: stub) return stub @@ -263,6 +281,15 @@ def test_delete_rejects_a_non_uuid_job_id(fake): tools.delete("../../etc/cron.d/x") +def test_write_names_file_rejects_a_non_uuid_job_id(): + """job_id here is the backend's response to submit(), not literal + caller input -- a compromised/buggy server returning a crafted value + like "../../etc/x" must not become a write target. + """ + with pytest.raises(client.CuOptMCPError, match="not a valid job_id"): + tools._write_names_file("../../etc/cron.d/x", ["a", "b"]) + + def test_delete_removes_local_solution_file(fake, tmp_path, monkeypatch): monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) fake() @@ -272,6 +299,15 @@ def test_delete_removes_local_solution_file(fake, tmp_path, monkeypatch): assert not solution_file.exists() +def test_delete_removes_local_names_file(fake, tmp_path, monkeypatch): + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) + fake() + names_file = tmp_path / f"{UUID1}.names.json" + names_file.write_text("[]") + tools.delete(UUID1) + assert not names_file.exists() + + def test_delete_with_no_solution_file_still_succeeds( fake, tmp_path, monkeypatch ): @@ -299,9 +335,13 @@ def test_submit_track_incumbents_is_mip_only_and_opt_in( ) problem = tmp_path / "p.mps" problem.write_text("") - tools.submit(str(problem), "mip_settings") - tools.submit(str(problem), "mip_settings", track_incumbents=True) - tools.submit(str(problem), "pdlp_settings", track_incumbents=True) + tools.submit("mip_settings", problem_path=str(problem)) + tools.submit( + "mip_settings", problem_path=str(problem), track_incumbents=True + ) + tools.submit( + "pdlp_settings", problem_path=str(problem), track_incumbents=True + ) assert stub.submitted == [False, True, False] @@ -319,7 +359,7 @@ def test_build_settings_wraps_validate_settings_value_error(): def test_missing_problem_file_is_a_clear_error(): with pytest.raises(client.CuOptMCPError, match="problem file not found"): - tools.submit("/nonexistent/model.mps", "pdlp_settings") + tools.submit("pdlp_settings", problem_path="/nonexistent/model.mps") def test_list_settings_names_and_detail(): @@ -354,3 +394,363 @@ def test_default_port_matches_cuopt_grpc_server(monkeypatch): """ monkeypatch.delenv("CUOPT_REMOTE_PORT", raising=False) assert client.endpoint()[1] == 5001 + + +def test_unreachable_message_says_to_look_before_starting_a_server(): + """The advice must not read as 'start one', full stop. + + Told only to start a server, a caller that already has one running + elsewhere starts a second. Two servers can share a listen port, after + which jobs and result lookups land in different processes. + """ + err = str(client.describe_connection_error(RuntimeError("UNAVAILABLE"))) + assert "pgrep" in err + assert err.index("already running") < err.index("start one with") + + +def test_health_names_the_endpoint_and_probes_it(fake, monkeypatch): + monkeypatch.setenv("CUOPT_REMOTE_HOST", "gpu-host") + monkeypatch.setenv("CUOPT_REMOTE_PORT", "50999") + stub = fake() + out = tools.health() + assert (out["host"], out["port"]) == ("gpu-host", 50999) + assert out["reachable"] is True + # A NOT_FOUND answer still proves a server answered. + assert stub.probed == [tools.PROBE_JOB_ID] + + +def test_health_reports_unreachable_without_raising(fake, monkeypatch): + monkeypatch.setenv("CUOPT_REMOTE_HOST", "gpu-host") + monkeypatch.setenv("CUOPT_REMOTE_PORT", "50999") + fake(status_error=RuntimeError("failed to connect to all addresses")) + out = tools.health() + assert out["reachable"] is False + assert "gpu-host:50999" in out["error"] + assert "pgrep" in out["error"] + + +def test_health_drops_a_dead_channel(fake, monkeypatch): + """A cached channel outlives the failure, so every later call would fail.""" + dropped = [] + monkeypatch.setattr(tools, "reset_client", lambda: dropped.append(True)) + fake(status_error=RuntimeError("UNAVAILABLE")) + tools.health() + assert dropped == [True] + + +# --- JSON model entry point ------------------------------------------- + + +def _base_problem(): + return { + "objective": [1.0, 1.0], + "constraint_matrix": { + "rows": [0, 0, 1], + "cols": [0, 1, 0], + "values": [1.0, 1.0, 1.0], + }, + "constraint_upper_bounds": [4.0, 3.0], + } + + +def test_json_coo_is_converted_to_csr(): + model = tools._build_model_from_json(_base_problem()) + assert list(model.get_constraint_matrix_offsets()) == [0, 2, 3] + assert list(model.get_constraint_matrix_indices()) == [0, 1, 0] + assert len(model.get_variable_lower_bounds()) == 2 + + +def test_json_accepts_csr_directly(): + problem = _base_problem() + problem["constraint_matrix"] = { + "offsets": [0, 2, 3], + "indices": [0, 1, 0], + "values": [1.0, 1.0, 1.0], + } + model = tools._build_model_from_json(problem) + assert list(model.get_constraint_matrix_offsets()) == [0, 2, 3] + + +def test_null_bound_becomes_infinite(): + problem = _base_problem() + problem["constraint_lower_bounds"] = [None, 2.0] + model = tools._build_model_from_json(problem) + lower = model.get_constraint_lower_bounds() + assert lower[0] == float("-inf") and lower[1] == 2.0 + + +def test_large_sentinel_is_normalised_to_infinity(): + # A finite 1e30 row bound can make cuOpt return a constraint-violating + # point reported as Optimal, so it must not survive as a finite value. + problem = _base_problem() + problem["constraint_lower_bounds"] = [-1e30, -1e30] + lower = tools._build_model_from_json(problem).get_constraint_lower_bounds() + assert all(v == float("-inf") for v in lower) + + +def test_integrality_does_not_disturb_bounds(): + # The MPS INTORG/INTEND trap: an integer column with no explicit bound + # silently becomes [0, 1]. variable_types must not do that. + problem = _base_problem() + problem["variable_types"] = ["I", "I"] + model = tools._build_model_from_json(problem) + assert list(model.get_variable_types()) == ["I", "I"] + assert all(v == float("inf") for v in model.get_variable_upper_bounds()) + + +def test_variable_names_round_trip(): + problem = _base_problem() + problem["variable_names"] = ["x", "y"] + assert list( + tools._build_model_from_json(problem).get_variable_names() + ) == [ + "x", + "y", + ] + + +@pytest.mark.parametrize( + "kwargs", + [ + {}, + {"problem_path": "a.mps", "problem": {"objective": [1.0]}}, + ], +) +def test_submit_requires_exactly_one_model_source(kwargs): + with pytest.raises(CuOptMCPError, match="exactly one"): + tools.submit(kind="mip_settings", **kwargs) + + +@pytest.mark.parametrize( + "problem,message", + [ + ( + {"constraint_matrix": {"rows": [], "cols": [], "values": []}}, + "missing required key 'objective'", + ), + ({"objective": [1.0]}, "missing required key 'constraint_matrix'"), + ( + { + "objective": [1.0, 1.0], + "constraint_matrix": { + "rows": [0], + "cols": [0], + "values": [1.0, 2.0], + }, + }, + "equal length", + ), + ( + { + "objective": [1.0], + "constraint_matrix": { + "rows": [0], + "cols": [5], + "values": [1.0], + }, + }, + "cols must be less than 1", + ), + ], +) +def test_json_model_errors_name_the_offending_key(problem, message): + with pytest.raises(CuOptMCPError, match=message): + tools._build_model_from_json(problem) + + +def test_variable_types_rejects_unknown_code(): + problem = _base_problem() + problem["variable_types"] = ["I", "B"] + with pytest.raises(CuOptMCPError, match="'C' or 'I'"): + tools._build_model_from_json(problem) + + +def test_nonzero_only_drops_numerical_dust(): + dust = {"a": 1.0, "b": 3.4e-13, "c": -1.5e-12} + assert {k: v for k, v in dust.items() if abs(v) > tools.ZERO_TOL} == { + "a": 1.0 + } + + +def test_duplicate_cells_are_summed(): + # Building a row incrementally emits a cell twice; the row must mean + # 2x <= 10, not depend on how the backend treats repeated indices. + model = tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": { + "rows": [0, 0], + "cols": [0, 0], + "values": [1.0, 1.0], + }, + "constraint_upper_bounds": [10.0], + } + ) + assert list(model.get_constraint_matrix_offsets()) == [0, 1] + assert list(model.get_constraint_matrix_indices()) == [0] + assert list(model.get_constraint_matrix_values()) == [2.0] + + +def test_duplicate_cells_summed_across_interleaved_rows(): + model = tools._build_model_from_json( + { + "objective": [1.0, 1.0], + "constraint_matrix": { + "rows": [1, 0, 1, 0, 1], + "cols": [0, 1, 0, 1, 1], + "values": [3.0, 1.0, 4.0, 2.0, 5.0], + }, + "constraint_upper_bounds": [1.0, 1.0], + } + ) + assert list(model.get_constraint_matrix_offsets()) == [0, 1, 3] + assert list(model.get_constraint_matrix_indices()) == [1, 0, 1] + assert list(model.get_constraint_matrix_values()) == [3.0, 7.0, 5.0] + + +def test_trailing_all_zero_row_is_preserved(): + # Row 1 has no entries. Inferring the count from the data would drop it + # and then blame the bounds array for the length mismatch. + model = tools._build_model_from_json( + { + "objective": [1.0, 1.0], + "constraint_matrix": { + "rows": [0], + "cols": [0], + "values": [1.0], + }, + "constraint_upper_bounds": [5.0, 7.0], + "constraint_lower_bounds": [None, None], + } + ) + assert list(model.get_constraint_matrix_offsets()) == [0, 1, 1] + assert len(model.get_constraint_upper_bounds()) == 2 + + +def test_row_index_beyond_declared_count_blames_the_matrix(): + with pytest.raises(CuOptMCPError, match="references row 2 but only 2"): + tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": { + "rows": [0, 2], + "cols": [0, 0], + "values": [1.0, 1.0], + }, + "constraint_upper_bounds": [5.0, 7.0], + } + ) + + +def test_mismatched_constraint_bound_lengths_are_reported(): + with pytest.raises(CuOptMCPError, match="same length"): + tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": { + "rows": [0], + "cols": [0], + "values": [1.0], + }, + "constraint_lower_bounds": [0.0], + "constraint_upper_bounds": [5.0, 7.0], + } + ) + + +def test_explicit_n_constraints_allows_a_fully_empty_row_block(): + model = tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": {"rows": [], "cols": [], "values": []}, + "n_constraints": 3, + } + ) + assert list(model.get_constraint_matrix_offsets()) == [0, 0, 0, 0] + + +def test_csr_row_count_must_match_declared_bounds(): + with pytest.raises(CuOptMCPError, match="has 1 rows but 2 constraint"): + tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": { + "offsets": [0, 1], + "indices": [0], + "values": [1.0], + }, + "constraint_upper_bounds": [5.0, 7.0], + } + ) + + +def test_huge_declared_n_constraints_is_rejected_before_allocating(): + """n_constraints is a caller-controlled scalar, cheap to express as a + huge number in a tiny JSON payload; must be capped before it sizes a + np.zeros/np.bincount allocation. + """ + with pytest.raises(CuOptMCPError, match="n_constraints"): + tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": {"rows": [], "cols": [], "values": []}, + "n_constraints": 10**12, + } + ) + + +def test_huge_inferred_row_count_is_rejected_before_allocating(): + """Same risk via a single huge row index instead of an explicit + n_constraints -- a length-1 array is just as cheap to send. + """ + with pytest.raises(CuOptMCPError, match="constraint_matrix row count"): + tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": { + "rows": [10**12], + "cols": [0], + "values": [1.0], + }, + } + ) + + +@pytest.mark.parametrize( + "rows,error", + [ + ([True], "must be integers"), + ([1.5], "must be integers"), + ([-1], "must be non-negative"), + ], +) +def test_coo_rows_are_validated_not_silently_cast(rows, error): + """int64 casting would otherwise silently turn a bool into 0/1, a + fractional value into a truncated int, or let a negative row reach + np.bincount as a bare (non-CuOptMCPError) ValueError. + """ + with pytest.raises(CuOptMCPError, match=error): + tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": { + "rows": rows, + "cols": [0], + "values": [1.0], + }, + } + ) + + +def test_coo_cols_reject_out_of_range_value(): + with pytest.raises(CuOptMCPError, match="cols.*less than 1"): + tools._build_model_from_json( + { + "objective": [1.0], + "constraint_matrix": { + "rows": [0], + "cols": [1], + "values": [1.0], + }, + } + )