diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 718b79a052..b8b5c15794 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -38,21 +38,33 @@ # Attributes (all optional unless noted): # # Per-field: -# description – human-readable meaning of the field, emitted as a -# leading comment on the generated proto field so a -# third-party client reading only the .proto learns -# what it does (see GRPC_INTERFACE.md, "Custom -# Clients"). Currently consumed for settings messages. +# description – human-readable meaning of the field. Emitted into +# cuopt_mcp_schema.json as the JSON Schema +# `description`, which is what an MCP client shows a +# model deciding whether to set the field. Consumed +# for settings sections only. # default – what the field does when omitted, written as a string # describing the C++ member initializer (e.g. "1e-4", -# "-1 (automatic)"). Appended to the generated proto -# comment as "(default: ...)". Documentation only — +# "-1 (automatic)"). Appended to the emitted +# description as "Default: ...". Documentation only — # it does not affect the wire format. The generator # does not derive it from the C++ struct or detect # initializer drift; it only checks, for a # non-optional field, that this string textually # matches the proto3 zero value (see `optional` -# above). +# above). Take the value from the C++ struct, not +# from docs/: the two are known to disagree. +# param_name – the CUOPT_* string parameter this field corresponds +# to, when it differs from the field name (the field +# name is the *proto* name; MIP diverges heavily — +# relative_mip_gap vs mip_relative_gap, mir_cuts vs +# mip_mixed_integer_rounding_cuts). Emitted into the +# MCP schema as "x-parameter-name". Set it explicitly +# to `null` for a field with no CUOPT_* constant at +# all: it stays on the wire but is dropped from the +# MCP schema, since set_parameter would reject it. +# python/cuopt_mcp/tests/test_parameter_names.py +# asserts every advertised name exists in constants.h. # type – proto wire type (default: double for scalars, repeated double for arrays) # field_num – proto message field number (see "Field numbers" above) # array_id – enum value for ArrayFieldId or ResultFieldId @@ -545,7 +557,7 @@ pdlp_settings: Iteration limit after which the solver stops and returns the current solution. Checked periodically, so the solver may run a few extra iterations. When set together with time_limit, the first limit reached - wins. Encoded on the wire as -1 for "no limit". + wins. default: "no limit (INT_MAX)" field_num: 10 type: int64 @@ -562,6 +574,7 @@ pdlp_settings: type: bool optional: true - detect_infeasibility: + param_name: infeasibility_detection description: >- Whether PDLP attempts to detect infeasibility. Detection is not always accurate — some problems reported infeasible converge under a different @@ -603,6 +616,7 @@ pdlp_settings: field_num: 15 type: lp_method - presolver: + param_name: presolve description: >- Which presolver performs presolve reductions: 0 disables presolve, 1 selects Papilo, 2 selects PSLP. By default LP uses PSLP and MIP uses @@ -811,6 +825,7 @@ mip_settings: # Tolerances (nested: settings.tolerances.) - tolerances: - relative_mip_gap: + param_name: mip_relative_gap description: >- Relative gap at which the solve terminates: abs(best_objective - dual_bound) / abs(best_objective). The gap is @@ -820,6 +835,7 @@ mip_settings: field_num: 2 optional: true - absolute_mip_gap: + param_name: mip_absolute_gap description: >- Absolute gap at which the solve terminates: best_objective - dual_bound when minimizing, dual_bound - best_objective when @@ -828,12 +844,14 @@ mip_settings: field_num: 3 optional: true - integrality_tolerance: + param_name: mip_integrality_tolerance description: >- How close to an integer a variable must be to count as integral. default: "1e-5" field_num: 4 optional: true - absolute_tolerance: + param_name: mip_absolute_tolerance description: >- Absolute term in the constraint-feasibility check: a constraint is feasible when its bound violation is less than absolute_tolerance + @@ -844,6 +862,7 @@ mip_settings: field_num: 5 optional: true - relative_tolerance: + param_name: mip_relative_tolerance description: >- Relative term in the constraint-feasibility check; multiplies the constraint's bound magnitude. See absolute_tolerance. @@ -851,6 +870,9 @@ mip_settings: field_num: 6 optional: true - presolve_absolute_tolerance: + # Not settable through cuOpt's string parameter API (no CUOPT_* + # constant in constants.h), so it is omitted from the MCP schema. + param_name: null # No user-facing documentation exists for this field yet (it is absent # from docs/cuopt/source/mip-settings.rst); only the C++ default is # recorded here. Add a `description:` once the behavior is documented. @@ -868,6 +890,7 @@ mip_settings: type: bool optional: true - heuristics_only: + param_name: mip_heuristics_only description: >- When true only the GPU heuristics run, improving the primal bound alone. When false both GPU and CPU are used and the dual bound is @@ -891,6 +914,7 @@ mip_settings: type: int32 optional: true - presolver: + param_name: presolve description: >- Which presolver performs presolve reductions: 0 disables presolve, 1 selects Papilo, 2 selects PSLP. MIP uses Papilo by default. @@ -908,6 +932,7 @@ mip_settings: type: int32 optional: true - symmetry: + param_name: mip_symmetry # Symmetry-detection level (dejavu). Valid: -1 (default), 0 (off), # 1 (orbital fixing), 2 (orbital fixing + lexical reduction). The # mapper clamps out-of-range values to -1 to match the local-solve @@ -936,8 +961,7 @@ mip_settings: description: >- Maximum number of branch-and-bound nodes explored before the solver stops and returns the best feasible solution found, if any. When set - together with time_limit, the first limit reached wins. Encoded on the - wire as -1 for "no limit". + together with time_limit or work_limit, the first limit reached wins. default: "no limit (INT_MAX)" field_num: 15 type: int32 @@ -946,6 +970,7 @@ mip_settings: # Branching - reliability_branching: + param_name: mip_reliability_branching description: >- Reliability branching mode. -1 automatic (the solver picks whether to use it and with what factor), 0 disabled, k > 0 enables it and treats a @@ -972,6 +997,7 @@ mip_settings: field_num: 32 type: int32 - strong_branching_simplex_iteration_limit: + param_name: mip_strong_branching_simplex_iteration_limit description: >- Maximum simplex iterations per candidate during strong branching. Lowering it speeds up strong branching at the cost of less accurate @@ -987,6 +1013,7 @@ mip_settings: # strong_chvatal_gomory_cuts) share the same tri-state contract: # -1 automatic, 0 disabled, 1 enabled. - max_cut_passes: + param_name: mip_cut_passes description: >- Maximum number of cut passes to run. 0 disables cuts entirely; larger values perform more passes. @@ -995,6 +1022,7 @@ mip_settings: type: int32 optional: true - mir_cuts: + param_name: mip_mixed_integer_rounding_cuts description: >- Whether mixed-integer rounding cuts are used. -1 automatic (the solver decides from problem characteristics), 0 disabled, 1 enabled. @@ -1003,6 +1031,7 @@ mip_settings: type: int32 optional: true - mixed_integer_gomory_cuts: + param_name: mip_mixed_integer_gomory_cuts description: >- Whether mixed-integer Gomory cuts are used. -1 automatic, 0 disabled, 1 enabled. @@ -1011,6 +1040,7 @@ mip_settings: type: int32 optional: true - knapsack_cuts: + param_name: mip_knapsack_cuts description: >- Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. default: "-1 (automatic)" @@ -1018,6 +1048,7 @@ mip_settings: type: int32 optional: true - clique_cuts: + param_name: mip_clique_cuts description: >- Whether clique cuts are used. -1 automatic, 0 disabled, 1 enabled. default: "-1 (automatic)" @@ -1025,6 +1056,7 @@ mip_settings: type: int32 optional: true - zero_half_cuts: + param_name: mip_zero_half_cuts description: >- Whether zero-half cuts are used. -1 automatic, 0 disabled, 1 enabled. default: "-1 (automatic)" @@ -1032,6 +1064,7 @@ mip_settings: type: int32 optional: true - implied_bound_cuts: + param_name: mip_implied_bound_cuts description: >- Whether implied bound cuts are used. -1 automatic, 0 disabled, 1 enabled. @@ -1040,6 +1073,7 @@ mip_settings: type: int32 optional: true - strong_chvatal_gomory_cuts: + param_name: mip_strong_chvatal_gomory_cuts description: >- Whether strong Chvatal-Gomory cuts are used. -1 automatic, 0 disabled, 1 enabled. @@ -1048,6 +1082,7 @@ mip_settings: type: int32 optional: true - reduced_cost_strengthening: + param_name: mip_reduced_cost_strengthening description: >- Whether integer feasible solutions are used to strengthen integer variable bounds. -1 automatic, 0 disabled, 1 during the root cut @@ -1057,6 +1092,7 @@ mip_settings: type: int32 optional: true - cut_change_threshold: + param_name: mip_cut_change_threshold description: >- Required improvement in the dual bound per cut pass. Larger values demand significant improvement each pass; -1 lets cut passes continue @@ -1065,6 +1101,7 @@ mip_settings: field_num: 25 optional: true - cut_min_orthogonality: + param_name: mip_cut_min_orthogonality description: >- Minimum orthogonality a cut needs to be added to the LP relaxation. Values near 1 require cuts to be nearly orthogonal to each other; @@ -1075,6 +1112,7 @@ mip_settings: # Determinism and reproducibility - determinism_mode: + param_name: mip_determinism_mode description: >- 0 opportunistic — results may vary between runs due to parallelism. 1 deterministic — improves reproducibility across runs with the same @@ -1084,6 +1122,7 @@ mip_settings: field_num: 27 type: int32 - seed: + param_name: random_seed description: >- Random seed. A fixed seed gives reproducible results when running in deterministic mode. @@ -1094,6 +1133,7 @@ mip_settings: # Presolve sub-steps - probing: + param_name: mip_probing # C++ default is `true`; declared as proto3 `optional` so that a client # which omits this field preserves the solver default. Without # `optional`, the proto3 wire zero (`false`) would silently overwrite @@ -1114,6 +1154,7 @@ mip_settings: # cannot derive a finite UB. Local-solve binding restricts the range to # [1.0, +inf] with default 1e10; see solver_settings.cu. - semi_continuous_big_m: + param_name: mip_semi_continuous_big_m description: >- Big-M coefficient used when linearizing semi-continuous variable constraints (such a variable is either zero or lies in @@ -1136,12 +1177,14 @@ mip_settings: # heuristics_hyper_params.hpp rather than from docs/. - heuristic_params: - population_size: + param_name: mip_hyper_heuristic_population_size description: Maximum number of solutions held in the solution pool. default: "32" field_num: 35 type: int32 optional: true - num_cpufj_threads: + param_name: mip_hyper_heuristic_num_cpufj_threads description: Number of parallel CPU Feasibility Jump climbers. default: "8" field_num: 36 @@ -1151,32 +1194,38 @@ mip_settings: # presolve stopped taking a wall budget. Do not reuse: an older client # still sends them on those numbers. - root_lp_time_ratio: + param_name: mip_hyper_heuristic_root_lp_time_ratio description: Fraction of the total time budget given to the root LP. default: "0.1" field_num: 39 optional: true - root_lp_max_time: + param_name: mip_hyper_heuristic_root_lp_max_time description: Hard cap in seconds on the root LP solve. default: "15.0" field_num: 40 optional: true - rins_time_limit: + param_name: mip_hyper_heuristic_rins_time_limit description: Per-call time budget in seconds for the RINS sub-MIP. default: "3.0" field_num: 41 optional: true - rins_max_time_limit: + param_name: mip_hyper_heuristic_rins_max_time_limit description: >- Ceiling in seconds on the adaptive RINS time budget. default: "20.0" field_num: 42 optional: true - rins_fix_rate: + param_name: mip_hyper_heuristic_rins_fix_rate description: Fraction of variables RINS fixes before solving the sub-MIP. default: "0.5" field_num: 43 optional: true - stagnation_trigger: + param_name: mip_hyper_heuristic_stagnation_trigger description: >- Number of Feasibility Pump loops without improvement before recombination is triggered. @@ -1185,17 +1234,20 @@ mip_settings: type: int32 optional: true - max_iterations_without_improvement: + param_name: mip_hyper_heuristic_max_iterations_without_improvement description: Depth of the diversity step taken after stagnation. default: "8" field_num: 45 type: int32 optional: true - initial_infeasibility_weight: + param_name: mip_hyper_heuristic_initial_infeasibility_weight description: Seed value for the constraint violation penalty. default: "1000.0" field_num: 46 optional: true - n_of_minimums_for_exit: + param_name: mip_hyper_heuristic_n_of_minimums_for_exit description: >- Number of local minima after which the Feasibility Jump baseline exits. @@ -1204,6 +1256,7 @@ mip_settings: type: int32 optional: true - enabled_recombiners: + param_name: mip_hyper_heuristic_enabled_recombiners description: >- Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility pump, 4 line-segment recombination, 8 sub-MIP. (Bit 4 is the @@ -1214,6 +1267,7 @@ mip_settings: type: int32 optional: true - cycle_detection_length: + param_name: mip_hyper_heuristic_cycle_detection_length description: >- Size of the ring buffer used to detect Feasibility Pump assignment cycles. @@ -1222,18 +1276,21 @@ mip_settings: type: int32 optional: true - relaxed_lp_time_limit: + param_name: mip_hyper_heuristic_relaxed_lp_time_limit description: >- Base time cap in seconds for relaxed LP solves inside the heuristics. default: "1.0" field_num: 50 optional: true - related_vars_time_limit: + param_name: mip_hyper_heuristic_related_vars_time_limit description: >- Time in seconds allowed for building the related-variable structure. default: "30.0" field_num: 51 optional: true - presolve_max_rounds: + param_name: mip_hyper_heuristic_presolve_max_rounds description: >- Cap on Papilo presolve rounds. A value <= 0 removes the cap entirely. default: "-1 (no override)" @@ -1241,6 +1298,7 @@ mip_settings: type: int32 optional: true - papilo_probing_max_badgesize: + param_name: mip_hyper_heuristic_papilo_probing_max_badgesize description: Ceiling on Papilo's probing.minbadgesize. default: "-1 (no override)" field_num: 54 diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index 7fd19d2857..d26219b344 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -17,6 +17,7 @@ """ import argparse +import json import os import re import sys @@ -351,46 +352,140 @@ def _array_wire_type_comment(f): return f"raw bytes ({size} B/elem)" -_DOC_COMMENT_WIDTH = 78 - +_JSON_SCHEMA_TYPES = { + "double": "number", + "float": "number", + "int32": "integer", + "int64": "integer", + "uint32": "integer", + "uint64": "integer", + "bool": "boolean", + "string": "string", +} -def _field_doc_comment(f, indent=" "): - """Render a field's `description:` / `default:` registry attributes as - proto leading comment lines. - The generated proto is part of the public wire contract (see - GRPC_INTERFACE.md, "Custom Clients"), so a third-party client reading - only the .proto should learn what a settings field means and what it - does when omitted. Returns [] when the field carries neither - attribute, so undocumented fields emit exactly as before. +def _json_schema_property(registry, f): + """Render one settings field as a JSON Schema property. - `default:` is rendered verbatim from the registry — it is a string - describing the C++ member initializer, not a value the generator derives - from it. It is checked, not fully validated: for a non-optional settings - field, `_validate_registry_uniqueness` fails generation if this string - doesn't textually match the proto3 zero value. That check can't catch a - C++ default that changed with no matching update here. + Used by the MCP tool-input schema (see generate_mcp_schema). Enums + become string enums keyed by their proto value names so a model emits + `"Stable3"` rather than a magic integer. """ + ftype = f.get("type", "double") + prop = {} + edef = _lookup_enum(registry, ftype) + if edef is not None and "values" in edef: + # Proto value names, not C++ names, so the schema and the wire agree + # on the spelling a client sends. + prefix = edef.get("proto_prefix", "") + named = { + _proto_enum_value_name(cpp_name, prefix): num + for cpp_name, num, _attrs in parse_enum_values(edef["values"]) + } + prop["type"] = "string" + prop["enum"] = list(named) + # cuOpt's string parameter interface takes the integer for an enum + # setting, not its name. Callers show the name to a user and send + # the number; emitting the mapping keeps that translation derived + # from the registry instead of hand-written in each client. + prop["x-enum-values"] = named + else: + prop["type"] = _JSON_SCHEMA_TYPES.get(ftype, "string") + description = f.get("description") + if description: + prop["description"] = " ".join(str(description).split()) default = f.get("default") - words = str(description).split() if description is not None else [] if default is not None: - words += f"(default: {default})".split() - if not words: - return [] - prefix = f"{indent}// " - width = max(_DOC_COMMENT_WIDTH - len(prefix), 20) - lines, current = [], "" - for word in words: - candidate = f"{current} {word}" if current else word - if current and len(candidate) > width: - lines.append(f"{prefix}{current}") - current = word - else: - current = candidate - if current: - lines.append(f"{prefix}{current}") - return lines + # Rendered into the description rather than JSON Schema `default`: + # the registry stores prose ("-1 (automatic)", "no limit (INT_MAX)"), + # not a typed value, and a wrong-typed `default` misleads a model + # more than no `default` does. + note = f"Default: {default}." + prop["description"] = ( + f"{prop['description']} {note}" if description else note + ) + # The registry field name is the proto field name, which is not always + # the CUOPT_* string parameter a client passes to set_parameter (MIP + # diverges heavily: relative_mip_gap vs mip_relative_gap, mir_cuts vs + # mip_mixed_integer_rounding_cuts). Carry the real name so no client has + # to rediscover the mapping. + param_name = f.get("param_name") + if param_name: + prop["x-parameter-name"] = param_name + if f.get("sentinel"): + # The wire encoding (e.g. max() <=> -1) is an implementation detail. + # A model must express "no limit" by omitting the field, never by + # sending the reserved value. + prop["description"] = ( + prop.get("description", "") + " Omit for the default limit." + ).strip() + return prop + + +def generate_mcp_schema(registry: dict) -> str: + """Build the MCP tool-input JSON Schema for the solver settings. + + Emitted as a generated artifact so an MCP server never hand-maintains a + second copy of the settings surface: a field added to the registry + reaches the schema in the same commit as the proto, and + ci/verify_grpc_codegen.sh guards the pair. + + Only fields carrying a `field_num` are included — those are exactly the + settings that cross the gRPC wire, which is exactly what a remote MCP + server can set. + + Args: + registry: The parsed field_registry.yaml. + + Returns: + The schema as serialized JSON text (trailing newline), ready to + write to cuopt_mcp_schema.json. + + Raises: + AssertionError: A section declares the same settings field name + twice (a registry-authoring bug, not a runtime condition). + """ + schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": ( + "AUTO-GENERATED by src/grpc/codegen/generate_conversions.py " + "from field_registry.yaml. DO NOT EDIT — regenerate with " + "./build.sh codegen." + ), + "settings": {}, + } + for section, title in ( + ("pdlp_settings", "PDLPSolverSettings"), + ("mip_settings", "MIPSolverSettings"), + ): + properties = {} + for f in parse_settings_fields(registry[section].get("fields", [])): + if f.get("field_num") is None: + continue + name = f["name"] + # An explicit `param_name: null` marks a field with no CUOPT_* + # string parameter — settable over the wire but not through the + # parameter API an MCP client uses, so advertising it would + # produce calls that can only fail. + if "param_name" in f and f["param_name"] is None: + continue + assert name not in properties, ( + f"duplicate settings field {section}.{name} — JSON Schema " + "properties must be unique" + ) + properties[name] = _json_schema_property(registry, f) + schema["settings"][section] = { + "title": title, + "type": "object", + "description": ( + "Solver settings. Every field is optional; omit a field to " + "keep the cuOpt default." + ), + "properties": properties, + "additionalProperties": False, + } + return json.dumps(schema, indent=2, sort_keys=False) + "\n" # ============================================================================ @@ -1329,9 +1424,7 @@ def generate_settings_message_proto(registry, message_name, obj): continue ptype = _settings_field_proto_type(registry, f) prefix = "optional " if f.get("optional") else "" - decl = f" {prefix}{ptype} {f['name']} = {num};" - doc = _field_doc_comment(f) - lines.append((num, "\n".join(doc + [decl]))) + lines.append((num, f" {prefix}{ptype} {f['name']} = {num};")) lines.extend(_iter_embeds(obj)) lines.sort(key=lambda x: x[0]) return "\n".join(item[1] for item in lines) @@ -3964,6 +4057,12 @@ def main(): os.path.join(outdir, "generated_array_field_element_size.inc"), HEADER + generate_array_field_element_size_inc(registry) + "\n", ) + # JSON has no comment syntax, so the provenance banner every other + # artifact carries in HEADER lives in the schema's own $comment. + write_file( + os.path.join(outdir, "cuopt_mcp_schema.json"), + generate_mcp_schema(registry), + ) print(f"\nDone! Generated {len(os.listdir(outdir))} files in: {outdir}") diff --git a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json new file mode 100644 index 0000000000..ca36b5c8e7 --- /dev/null +++ b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json @@ -0,0 +1,432 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "AUTO-GENERATED by src/grpc/codegen/generate_conversions.py from field_registry.yaml. DO NOT EDIT \u2014 regenerate with ./build.sh codegen.", + "settings": { + "pdlp_settings": { + "title": "PDLPSolverSettings", + "type": "object", + "description": "Solver settings. Every field is optional; omit a field to keep the cuOpt default.", + "properties": { + "absolute_gap_tolerance": { + "type": "number", + "description": "Absolute term in PDLP's duality gap check: duality_gap < absolute_gap_tolerance + relative_gap_tolerance * (|primal_objective| + |dual_objective|). Default: 1e-4." + }, + "relative_gap_tolerance": { + "type": "number", + "description": "Relative term in PDLP's duality gap check; multiplies (|primal_objective| + |dual_objective|). Significant impact on accuracy and runtime. Default: 1e-4." + }, + "primal_infeasible_tolerance": { + "type": "number", + "description": "Tolerance used when PDLP declares the problem primal infeasible. Only consulted when detect_infeasibility is enabled. Default: 1e-10." + }, + "dual_infeasible_tolerance": { + "type": "number", + "description": "Tolerance used when PDLP declares the problem dual infeasible (unbounded). Only consulted when detect_infeasibility is enabled. Default: 1e-10." + }, + "absolute_dual_tolerance": { + "type": "number", + "description": "Absolute term in PDLP's dual feasibility check: dual_feasibility < absolute_dual_tolerance + relative_dual_tolerance * l2_norm(c). Default: 1e-4." + }, + "relative_dual_tolerance": { + "type": "number", + "description": "Relative term in PDLP's dual feasibility check; multiplies the objective vector L2 norm. Default: 1e-4." + }, + "absolute_primal_tolerance": { + "type": "number", + "description": "Absolute term in PDLP's primal feasibility check: primal_feasibility < absolute_primal_tolerance + relative_primal_tolerance * l2_norm(b). Default: 1e-4." + }, + "relative_primal_tolerance": { + "type": "number", + "description": "Relative term in PDLP's primal feasibility check; multiplies the right-hand-side vector L2 norm. Default: 1e-4." + }, + "time_limit": { + "type": "number", + "description": "Wall-clock limit in seconds after which the solver stops and returns the current solution. Checked periodically rather than continuously, so the solver may run slightly over. When set together with iteration_limit, the first limit reached wins. Default: no limit (infinity)." + }, + "iteration_limit": { + "type": "integer", + "description": "Iteration limit after which the solver stops and returns the current solution. Checked periodically, so the solver may run a few extra iterations. When set together with time_limit, the first limit reached wins. Default: no limit (INT_MAX). Omit for the default limit." + }, + "log_to_console": { + "type": "boolean", + "description": "Whether the solver writes log output to the console. Logs may still be written to a file when this is false. Default: true." + }, + "detect_infeasibility": { + "type": "boolean", + "description": "Whether PDLP attempts to detect infeasibility. Detection is not always accurate \u2014 some problems reported infeasible converge under a different tolerance \u2014 and costs roughly 3-7% runtime and 10-20% memory. Dual simplex always detects infeasibility regardless of this setting. Default: false.", + "x-parameter-name": "infeasibility_detection" + }, + "strict_infeasibility": { + "type": "boolean", + "description": "When true, PDLP stops if either the current or the average solution is detected infeasible. When false, both must be detected infeasible. Default: false." + }, + "pdlp_solver_mode": { + "type": "string", + "enum": [ + "Stable1", + "Stable2", + "Methodical1", + "Fast1", + "Stable3" + ], + "x-enum-values": { + "Stable1": 0, + "Stable2": 1, + "Methodical1": 2, + "Fast1": 3, + "Stable3": 4 + }, + "description": "Mode under which PDLP operates; changes how the problem is internally optimized and can drastically change solve time. Stable3 is the best overall mode from cuOpt experiments; Methodical1 takes slower but fewer steps at 1.3-1.7x the memory; Fast1 is fastest but converges less often. There is no way to know upfront which mode suits a given problem \u2014 testing is encouraged. Default: Stable3." + }, + "method": { + "type": "string", + "enum": [ + "Concurrent", + "PDLP", + "DualSimplex", + "Barrier" + ], + "x-enum-values": { + "Concurrent": 0, + "PDLP": 1, + "DualSimplex": 2, + "Barrier": 3 + }, + "description": "Method used to solve the LP. Concurrent runs PDLP, dual simplex and barrier in parallel. Default accuracy differs per method: PDLP 1e-4 relative, barrier 1e-8 relative, dual simplex 1e-6 absolute. Default: Concurrent." + }, + "presolver": { + "type": "integer", + "description": "Which presolver performs presolve reductions: 0 disables presolve, 1 selects Papilo, 2 selects PSLP. By default LP uses PSLP and MIP uses Papilo. Default: Default (PSLP for LP, Papilo for MIP).", + "x-parameter-name": "presolve" + }, + "dual_postsolve": { + "type": "boolean", + "description": "Whether dual postsolve runs when the Papilo presolver is used on an LP. Disabling it can improve solve time at the cost of losing access to the dual solution. Not relevant for MIP. Default: true." + }, + "crossover": { + "type": "boolean", + "description": "Whether PDLP or barrier crosses over to a basic solution after reaching optimality. PDLP and barrier solutions need not lie at a vertex; crossover yields a basic solution with n - m variables on their bounds. Significant impact on accuracy and runtime. Default: false." + }, + "num_gpus": { + "type": "integer", + "description": "Number of GPUs used for the solve. Relevant to LP in concurrent mode (up to 2 GPUs), where PDLP and barrier run on separate GPUs rather than sharing one. For distributed PDLP, -1 selects all visible devices. Default: 1." + }, + "per_constraint_residual": { + "type": "boolean", + "description": "Whether PDLP computes primal and dual residuals per constraint instead of globally. Default: false." + }, + "cudss_deterministic": { + "type": "boolean", + "description": "Whether cuDSS runs in deterministic mode. Deterministic mode makes results reproducible across runs but may be slower. Default: false." + }, + "folding": { + "type": "integer", + "description": "Barrier: whether to fold the LP, reducing problem size by exploiting symmetry. -1 automatic, 0 disabled, 1 forced. Default: -1 (automatic)." + }, + "augmented": { + "type": "integer", + "description": "Barrier: which linear system to solve at each iteration. -1 automatic, 0 the ADAT system (normal equations), 1 the augmented system. The augmented system can be more stable, ADAT is often faster. Default: -1 (automatic)." + }, + "dualize": { + "type": "integer", + "description": "Barrier: whether presolve dualizes the LP, which can help problems with inequality constraints that have more constraints than variables. -1 automatic, 0 disabled, 1 forced. Default: -1 (automatic)." + }, + "ordering": { + "type": "integer", + "description": "Barrier: ordering algorithm cuDSS uses for sparse factorizations, which can significantly affect runtime. -1 automatic, 0 cuDSS default ordering, 1 AMD (approximate minimum degree). Default: -1 (automatic)." + }, + "barrier_dual_initial_point": { + "type": "integer", + "description": "Barrier: how the dual initial point is computed, which affects the iteration count. -1 automatic, 0 the Lustig-Marsten-Shanno heuristic, 1 a least-squares problem minimizing the norms of the dual variables and reduced costs subject to the dual equality constraints, 2 the Sturm/SeDuMi-style mu-based primal+dual initial point. Default: -1 (automatic)." + }, + "eliminate_dense_columns": { + "type": "boolean", + "description": "Barrier: whether dense columns are eliminated from the constraint matrix before solving, reducing factorization fill-in at the cost of extra solves per iteration. Only has an effect when the ADAT (normal equation) system is solved. Default: true." + }, + "barrier_iterative_refinement": { + "type": "boolean", + "description": "Barrier: whether iterative refinement runs after each barrier solve to improve solution accuracy (see cpp/src/barrier/barrier.cu). Default: true." + }, + "barrier_adaptive_regularization": { + "type": "integer", + "description": "Whether the barrier method scales its KKT regularization up or down on later iterations: -1 automatic (uses the built-in heuristic), 0 disabled, 1 enabled. Default: -1 (automatic)." + }, + "barrier_step_scale": { + "type": "number", + "description": "Barrier: scaling factor applied to the primal/dual step size. Must be strictly less than 1 \u2014 0.9 is conservative, 0.999 aggressive. The local-solve binding restricts the range to [0.5, 0.9999]; this field is not clamped after decoding, for consistency with the other float scalars. Default: 0.9." + }, + "postsolve_info": { + "type": "integer", + "description": "Default: -1." + }, + "save_best_primal_so_far": { + "type": "boolean", + "description": "Whether PDLP keeps the best primal solution seen so far. When enabled, a primal feasible iterate always beats an infeasible one; among feasible iterates the best primal objective wins; among infeasible ones the lowest primal residual wins, breaking ties on objective. Default: false." + }, + "first_primal_feasible": { + "type": "boolean", + "description": "Whether the solver stops as soon as a primal feasible iterate is found, without waiting for optimality or dual feasibility. Composable with per_constraint_residual. Default: false." + }, + "pdlp_precision": { + "type": "integer", + "description": "Precision mode for the PDLP solver. DefaultPrecision uses the problem's native precision; SinglePrecision runs PDHG in FP32 (half the memory, roughly 2x faster iterations, possibly more of them); DoublePrecision forces FP64; MixedPrecision stores the constraint matrix in FP32 for faster SpMV while keeping vectors and compute in FP64 (convergence checks still use the FP64 matrix, so memory is not reduced). Default: DefaultPrecision." + } + }, + "additionalProperties": false + }, + "mip_settings": { + "title": "MIPSolverSettings", + "type": "object", + "description": "Solver settings. Every field is optional; omit a field to keep the cuOpt default.", + "properties": { + "time_limit": { + "type": "number", + "description": "Wall-clock limit in seconds after which the solver stops and returns the best solution found so far. When set together with node_limit or work_limit, the first limit reached wins. Default: no limit (infinity)." + }, + "relative_mip_gap": { + "type": "number", + "description": "Relative gap at which the solve terminates: abs(best_objective - dual_bound) / abs(best_objective). The gap is zero when both are zero, and infinite when only the best objective is zero. Default: 1e-4.", + "x-parameter-name": "mip_relative_gap" + }, + "absolute_mip_gap": { + "type": "number", + "description": "Absolute gap at which the solve terminates: best_objective - dual_bound when minimizing, dual_bound - best_objective when maximizing. Default: 1e-10.", + "x-parameter-name": "mip_absolute_gap" + }, + "integrality_tolerance": { + "type": "number", + "description": "How close to an integer a variable must be to count as integral. Default: 1e-5.", + "x-parameter-name": "mip_integrality_tolerance" + }, + "absolute_tolerance": { + "type": "number", + "description": "Absolute term in the constraint-feasibility check: a constraint is feasible when its bound violation is less than absolute_tolerance + relative_tolerance * bound_magnitude. Distinct from absolute_mip_gap, which controls when the solve terminates, not what counts as feasible. Default: 1e-6.", + "x-parameter-name": "mip_absolute_tolerance" + }, + "relative_tolerance": { + "type": "number", + "description": "Relative term in the constraint-feasibility check; multiplies the constraint's bound magnitude. See absolute_tolerance. Default: 1e-12.", + "x-parameter-name": "mip_relative_tolerance" + }, + "log_to_console": { + "type": "boolean", + "description": "Whether the solver writes log output to the console. Logs may still be written to a file when this is false. Default: true." + }, + "heuristics_only": { + "type": "boolean", + "description": "When true only the GPU heuristics run, improving the primal bound alone. When false both GPU and CPU are used and the dual bound is improved on the CPU. Default: false.", + "x-parameter-name": "mip_heuristics_only" + }, + "num_cpu_threads": { + "type": "integer", + "description": "Number of CPU threads used by the MIP solver. Lower values cap cuOpt's CPU footprint; higher values speed up the CPU-parallel parts. -1 derives the count from the number of CPU cores. Default: -1 (automatic)." + }, + "num_gpus": { + "type": "integer", + "description": "Number of GPUs used for the solve. Default: 1." + }, + "presolver": { + "type": "integer", + "description": "Which presolver performs presolve reductions: 0 disables presolve, 1 selects Papilo, 2 selects PSLP. MIP uses Papilo by default. Default: Default (Papilo for MIP).", + "x-parameter-name": "presolve" + }, + "mip_scaling": { + "type": "integer", + "description": "Whether scaling is applied to the MIP problem. 0 off, 1 on, 2 applied but not to the objective. Default: 2 (no objective scaling)." + }, + "symmetry": { + "type": "integer", + "description": "Symmetry detection and handling. -1 automatic, 0 disabled, 1 orbital fixing, 2 orbital fixing plus lexical reduction. Default: -1 (automatic).", + "x-parameter-name": "mip_symmetry" + }, + "work_limit": { + "type": "number", + "description": "Limit in work units \u2014 a machine-independent measure of solver effort \u2014 after which the solver stops and returns the current solution. When set together with time_limit or node_limit, the first limit reached wins. Default: no limit (infinity)." + }, + "node_limit": { + "type": "integer", + "description": "Maximum number of branch-and-bound nodes explored before the solver stops and returns the best feasible solution found, if any. When set together with time_limit or work_limit, the first limit reached wins. Default: no limit (INT_MAX). Omit for the default limit." + }, + "reliability_branching": { + "type": "integer", + "description": "Reliability branching mode. -1 automatic (the solver picks whether to use it and with what factor), 0 disabled, k > 0 enables it and treats a variable as reliable once it has been branched on k times. Default: -1 (automatic).", + "x-parameter-name": "mip_reliability_branching" + }, + "mip_batch_pdlp_strong_branching": { + "type": "integer", + "description": "How strong branching at the root evaluates candidates: 0 dual simplex only, 1 cooperative dual simplex + a single batched PDLP solve, 2 batched PDLP only. Batching can cut strong-branching time when dual simplex struggles. Default: 0 (dual simplex only)." + }, + "mip_batch_pdlp_reliability_branching": { + "type": "integer", + "description": "How reliability-branching candidates are evaluated: 0 dual simplex only, 1 cooperative dual simplex + a single batched PDLP solve, 2 batched PDLP only. Default: 0 (dual simplex only)." + }, + "strong_branching_simplex_iteration_limit": { + "type": "integer", + "description": "Maximum simplex iterations per candidate during strong branching. Lowering it speeds up strong branching at the cost of less accurate candidate evaluations. Default: -1 (automatic).", + "x-parameter-name": "mip_strong_branching_simplex_iteration_limit" + }, + "max_cut_passes": { + "type": "integer", + "description": "Maximum number of cut passes to run. 0 disables cuts entirely; larger values perform more passes. Default: 10.", + "x-parameter-name": "mip_cut_passes" + }, + "mir_cuts": { + "type": "integer", + "description": "Whether mixed-integer rounding cuts are used. -1 automatic (the solver decides from problem characteristics), 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_mixed_integer_rounding_cuts" + }, + "mixed_integer_gomory_cuts": { + "type": "integer", + "description": "Whether mixed-integer Gomory cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_mixed_integer_gomory_cuts" + }, + "knapsack_cuts": { + "type": "integer", + "description": "Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_knapsack_cuts" + }, + "clique_cuts": { + "type": "integer", + "description": "Whether clique cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_clique_cuts" + }, + "zero_half_cuts": { + "type": "integer", + "description": "Whether zero-half cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_zero_half_cuts" + }, + "implied_bound_cuts": { + "type": "integer", + "description": "Whether implied bound cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_implied_bound_cuts" + }, + "strong_chvatal_gomory_cuts": { + "type": "integer", + "description": "Whether strong Chvatal-Gomory cuts are used. -1 automatic, 0 disabled, 1 enabled. Default: -1 (automatic).", + "x-parameter-name": "mip_strong_chvatal_gomory_cuts" + }, + "reduced_cost_strengthening": { + "type": "integer", + "description": "Whether integer feasible solutions are used to strengthen integer variable bounds. -1 automatic, 0 disabled, 1 during the root cut passes, 2 during the root cut passes and after strong branching. Default: -1 (automatic).", + "x-parameter-name": "mip_reduced_cost_strengthening" + }, + "cut_change_threshold": { + "type": "number", + "description": "Required improvement in the dual bound per cut pass. Larger values demand significant improvement each pass; -1 lets cut passes continue even without improvement. Default: -1 (no threshold).", + "x-parameter-name": "mip_cut_change_threshold" + }, + "cut_min_orthogonality": { + "type": "number", + "description": "Minimum orthogonality a cut needs to be added to the LP relaxation. Values near 1 require cuts to be nearly orthogonal to each other; values near 0 admit more cuts. Default: 0.5.", + "x-parameter-name": "mip_cut_min_orthogonality" + }, + "determinism_mode": { + "type": "integer", + "description": "0 opportunistic \u2014 results may vary between runs due to parallelism. 1 deterministic \u2014 improves reproducibility across runs with the same thread count. Deterministic mode is experimental and does not yet guarantee fully deterministic results in every scenario. Default: 0 (opportunistic).", + "x-parameter-name": "mip_determinism_mode" + }, + "seed": { + "type": "integer", + "description": "Random seed. A fixed seed gives reproducible results when running in deterministic mode. Default: -1 (chosen automatically).", + "x-parameter-name": "random_seed" + }, + "probing": { + "type": "boolean", + "description": "Whether the probing-cache step of MIP presolve runs. Probing evaluates variable fixings to discover implications later used by branch-and-bound and the rounding heuristics. Only meaningful when presolve is otherwise enabled \u2014 presolver=0 disables the whole pipeline. Default: true.", + "x-parameter-name": "mip_probing" + }, + "semi_continuous_big_m": { + "type": "number", + "description": "Big-M coefficient used when linearizing semi-continuous variable constraints (such a variable is either zero or lies in [lower_bound, upper_bound]). Should be at least as large as the upper bound of any semi-continuous variable in the problem. Serves as the fallback upper bound when bounds-strengthening cannot derive a finite one. The local-solve binding restricts the range to [1.0, +inf]. Default: 1e10.", + "x-parameter-name": "mip_semi_continuous_big_m" + }, + "population_size": { + "type": "integer", + "description": "Maximum number of solutions held in the solution pool. Default: 32.", + "x-parameter-name": "mip_hyper_heuristic_population_size" + }, + "num_cpufj_threads": { + "type": "integer", + "description": "Number of parallel CPU Feasibility Jump climbers. Default: 8.", + "x-parameter-name": "mip_hyper_heuristic_num_cpufj_threads" + }, + "root_lp_time_ratio": { + "type": "number", + "description": "Fraction of the total time budget given to the root LP. Default: 0.1.", + "x-parameter-name": "mip_hyper_heuristic_root_lp_time_ratio" + }, + "root_lp_max_time": { + "type": "number", + "description": "Hard cap in seconds on the root LP solve. Default: 15.0.", + "x-parameter-name": "mip_hyper_heuristic_root_lp_max_time" + }, + "rins_time_limit": { + "type": "number", + "description": "Per-call time budget in seconds for the RINS sub-MIP. Default: 3.0.", + "x-parameter-name": "mip_hyper_heuristic_rins_time_limit" + }, + "rins_max_time_limit": { + "type": "number", + "description": "Ceiling in seconds on the adaptive RINS time budget. Default: 20.0.", + "x-parameter-name": "mip_hyper_heuristic_rins_max_time_limit" + }, + "rins_fix_rate": { + "type": "number", + "description": "Fraction of variables RINS fixes before solving the sub-MIP. Default: 0.5.", + "x-parameter-name": "mip_hyper_heuristic_rins_fix_rate" + }, + "stagnation_trigger": { + "type": "integer", + "description": "Number of Feasibility Pump loops without improvement before recombination is triggered. Default: 3.", + "x-parameter-name": "mip_hyper_heuristic_stagnation_trigger" + }, + "max_iterations_without_improvement": { + "type": "integer", + "description": "Depth of the diversity step taken after stagnation. Default: 8.", + "x-parameter-name": "mip_hyper_heuristic_max_iterations_without_improvement" + }, + "initial_infeasibility_weight": { + "type": "number", + "description": "Seed value for the constraint violation penalty. Default: 1000.0.", + "x-parameter-name": "mip_hyper_heuristic_initial_infeasibility_weight" + }, + "n_of_minimums_for_exit": { + "type": "integer", + "description": "Number of local minima after which the Feasibility Jump baseline exits. Default: 7000.", + "x-parameter-name": "mip_hyper_heuristic_n_of_minimums_for_exit" + }, + "enabled_recombiners": { + "type": "integer", + "description": "Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility pump, 4 line-segment recombination, 8 sub-MIP. (Bit 4 is the `LINE_SEGMENT` recombiner specifically, not the solver's general local search.) Default: 15 (all enabled).", + "x-parameter-name": "mip_hyper_heuristic_enabled_recombiners" + }, + "cycle_detection_length": { + "type": "integer", + "description": "Size of the ring buffer used to detect Feasibility Pump assignment cycles. Default: 30.", + "x-parameter-name": "mip_hyper_heuristic_cycle_detection_length" + }, + "relaxed_lp_time_limit": { + "type": "number", + "description": "Base time cap in seconds for relaxed LP solves inside the heuristics. Default: 1.0.", + "x-parameter-name": "mip_hyper_heuristic_relaxed_lp_time_limit" + }, + "related_vars_time_limit": { + "type": "number", + "description": "Time in seconds allowed for building the related-variable structure. Default: 30.0.", + "x-parameter-name": "mip_hyper_heuristic_related_vars_time_limit" + }, + "presolve_max_rounds": { + "type": "integer", + "description": "Cap on Papilo presolve rounds. A value <= 0 removes the cap entirely. Default: -1 (no override).", + "x-parameter-name": "mip_hyper_heuristic_presolve_max_rounds" + }, + "papilo_probing_max_badgesize": { + "type": "integer", + "description": "Ceiling on Papilo's probing.minbadgesize. Default: -1 (no override).", + "x-parameter-name": "mip_hyper_heuristic_papilo_probing_max_badgesize" + } + }, + "additionalProperties": false + } + } +} diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index cf00d8c760..34b774dd7d 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -164,340 +164,95 @@ message OptimizationProblem { } message PDLPSolverSettings { - // Absolute term in PDLP's duality gap check: duality_gap < - // absolute_gap_tolerance + relative_gap_tolerance * (|primal_objective| + - // |dual_objective|). (default: 1e-4) optional double absolute_gap_tolerance = 1; - // Relative term in PDLP's duality gap check; multiplies (|primal_objective| - // + |dual_objective|). Significant impact on accuracy and runtime. - // (default: 1e-4) optional double relative_gap_tolerance = 2; - // Tolerance used when PDLP declares the problem primal infeasible. Only - // consulted when detect_infeasibility is enabled. (default: 1e-10) optional double primal_infeasible_tolerance = 3; - // Tolerance used when PDLP declares the problem dual infeasible - // (unbounded). Only consulted when detect_infeasibility is enabled. - // (default: 1e-10) optional double dual_infeasible_tolerance = 4; - // Absolute term in PDLP's dual feasibility check: dual_feasibility < - // absolute_dual_tolerance + relative_dual_tolerance * l2_norm(c). (default: - // 1e-4) optional double absolute_dual_tolerance = 5; - // Relative term in PDLP's dual feasibility check; multiplies the objective - // vector L2 norm. (default: 1e-4) optional double relative_dual_tolerance = 6; - // Absolute term in PDLP's primal feasibility check: primal_feasibility < - // absolute_primal_tolerance + relative_primal_tolerance * l2_norm(b). - // (default: 1e-4) optional double absolute_primal_tolerance = 7; - // Relative term in PDLP's primal feasibility check; multiplies the - // right-hand-side vector L2 norm. (default: 1e-4) optional double relative_primal_tolerance = 8; - // Wall-clock limit in seconds after which the solver stops and returns the - // current solution. Checked periodically rather than continuously, so the - // solver may run slightly over. When set together with iteration_limit, the - // first limit reached wins. (default: no limit (infinity)) optional double time_limit = 9; - // Iteration limit after which the solver stops and returns the current - // solution. Checked periodically, so the solver may run a few extra - // iterations. When set together with time_limit, the first limit reached - // wins. Encoded on the wire as -1 for "no limit". (default: no limit - // (INT_MAX)) optional int64 iteration_limit = 10; - // Whether the solver writes log output to the console. Logs may still be - // written to a file when this is false. (default: true) optional bool log_to_console = 11; - // Whether PDLP attempts to detect infeasibility. Detection is not always - // accurate — some problems reported infeasible converge under a different - // tolerance — and costs roughly 3-7% runtime and 10-20% memory. Dual - // simplex always detects infeasibility regardless of this setting. - // (default: false) bool detect_infeasibility = 12; - // When true, PDLP stops if either the current or the average solution is - // detected infeasible. When false, both must be detected infeasible. - // (default: false) bool strict_infeasibility = 13; - // Mode under which PDLP operates; changes how the problem is internally - // optimized and can drastically change solve time. Stable3 is the best - // overall mode from cuOpt experiments; Methodical1 takes slower but fewer - // steps at 1.3-1.7x the memory; Fast1 is fastest but converges less often. - // There is no way to know upfront which mode suits a given problem — - // testing is encouraged. (default: Stable3) optional PDLPSolverMode pdlp_solver_mode = 14; - // Method used to solve the LP. Concurrent runs PDLP, dual simplex and - // barrier in parallel. Default accuracy differs per method: PDLP 1e-4 - // relative, barrier 1e-8 relative, dual simplex 1e-6 absolute. (default: - // Concurrent) LPMethod method = 15; - // Which presolver performs presolve reductions: 0 disables presolve, 1 - // selects Papilo, 2 selects PSLP. By default LP uses PSLP and MIP uses - // Papilo. (default: Default (PSLP for LP, Papilo for MIP)) optional int32 presolver = 16; - // Whether dual postsolve runs when the Papilo presolver is used on an LP. - // Disabling it can improve solve time at the cost of losing access to the - // dual solution. Not relevant for MIP. (default: true) optional bool dual_postsolve = 17; - // Whether PDLP or barrier crosses over to a basic solution after reaching - // optimality. PDLP and barrier solutions need not lie at a vertex; - // crossover yields a basic solution with n - m variables on their bounds. - // Significant impact on accuracy and runtime. (default: false) bool crossover = 18; - // Number of GPUs used for the solve. Relevant to LP in concurrent mode (up - // to 2 GPUs), where PDLP and barrier run on separate GPUs rather than - // sharing one. For distributed PDLP, -1 selects all visible devices. - // (default: 1) optional int32 num_gpus = 19; - // Whether PDLP computes primal and dual residuals per constraint instead of - // globally. (default: false) bool per_constraint_residual = 20; - // Whether cuDSS runs in deterministic mode. Deterministic mode makes - // results reproducible across runs but may be slower. (default: false) bool cudss_deterministic = 21; - // Barrier: whether to fold the LP, reducing problem size by exploiting - // symmetry. -1 automatic, 0 disabled, 1 forced. (default: -1 (automatic)) optional int32 folding = 22; - // Barrier: which linear system to solve at each iteration. -1 automatic, 0 - // the ADAT system (normal equations), 1 the augmented system. The augmented - // system can be more stable, ADAT is often faster. (default: -1 - // (automatic)) optional int32 augmented = 23; - // Barrier: whether presolve dualizes the LP, which can help problems with - // inequality constraints that have more constraints than variables. -1 - // automatic, 0 disabled, 1 forced. (default: -1 (automatic)) optional int32 dualize = 24; - // Barrier: ordering algorithm cuDSS uses for sparse factorizations, which - // can significantly affect runtime. -1 automatic, 0 cuDSS default ordering, - // 1 AMD (approximate minimum degree). (default: -1 (automatic)) optional int32 ordering = 25; - // Barrier: how the dual initial point is computed, which affects the - // iteration count. -1 automatic, 0 the Lustig-Marsten-Shanno heuristic, 1 a - // least-squares problem minimizing the norms of the dual variables and - // reduced costs subject to the dual equality constraints, 2 the - // Sturm/SeDuMi-style mu-based primal+dual initial point. (default: -1 - // (automatic)) optional int32 barrier_dual_initial_point = 26; - // Barrier: whether dense columns are eliminated from the constraint matrix - // before solving, reducing factorization fill-in at the cost of extra - // solves per iteration. Only has an effect when the ADAT (normal equation) - // system is solved. (default: true) optional bool eliminate_dense_columns = 27; - // Whether PDLP keeps the best primal solution seen so far. When enabled, a - // primal feasible iterate always beats an infeasible one; among feasible - // iterates the best primal objective wins; among infeasible ones the lowest - // primal residual wins, breaking ties on objective. (default: false) bool save_best_primal_so_far = 28; - // Whether the solver stops as soon as a primal feasible iterate is found, - // without waiting for optimality or dual feasibility. Composable with - // per_constraint_residual. (default: false) bool first_primal_feasible = 29; - // Precision mode for the PDLP solver. DefaultPrecision uses the problem's - // native precision; SinglePrecision runs PDHG in FP32 (half the memory, - // roughly 2x faster iterations, possibly more of them); DoublePrecision - // forces FP64; MixedPrecision stores the constraint matrix in FP32 for - // faster SpMV while keeping vectors and compute in FP64 (convergence checks - // still use the FP64 matrix, so memory is not reduced). (default: - // DefaultPrecision) optional int32 pdlp_precision = 30; - // Barrier: whether iterative refinement runs after each barrier solve to - // improve solution accuracy (see cpp/src/barrier/barrier.cu). (default: - // true) optional bool barrier_iterative_refinement = 31; - // Barrier: scaling factor applied to the primal/dual step size. Must be - // strictly less than 1 — 0.9 is conservative, 0.999 aggressive. The - // local-solve binding restricts the range to [0.5, 0.9999]; this field is - // not clamped after decoding, for consistency with the other float scalars. - // (default: 0.9) optional double barrier_step_scale = 32; - // (default: -1) optional int32 postsolve_info = 33; - // Whether the barrier method scales its KKT regularization up or down on - // later iterations: -1 automatic (uses the built-in heuristic), 0 disabled, - // 1 enabled. (default: -1 (automatic)) optional int32 barrier_adaptive_regularization = 34; PDLPWarmStartData warm_start_data = 50; } message MIPSolverSettings { - // Wall-clock limit in seconds after which the solver stops and returns the - // best solution found so far. When set together with node_limit or - // work_limit, the first limit reached wins. (default: no limit (infinity)) optional double time_limit = 1; - // Relative gap at which the solve terminates: abs(best_objective - - // dual_bound) / abs(best_objective). The gap is zero when both are zero, - // and infinite when only the best objective is zero. (default: 1e-4) optional double relative_mip_gap = 2; - // Absolute gap at which the solve terminates: best_objective - dual_bound - // when minimizing, dual_bound - best_objective when maximizing. (default: - // 1e-10) optional double absolute_mip_gap = 3; - // How close to an integer a variable must be to count as integral. - // (default: 1e-5) optional double integrality_tolerance = 4; - // Absolute term in the constraint-feasibility check: a constraint is - // feasible when its bound violation is less than absolute_tolerance + - // relative_tolerance * bound_magnitude. Distinct from absolute_mip_gap, - // which controls when the solve terminates, not what counts as feasible. - // (default: 1e-6) optional double absolute_tolerance = 5; - // Relative term in the constraint-feasibility check; multiplies the - // constraint's bound magnitude. See absolute_tolerance. (default: 1e-12) optional double relative_tolerance = 6; - // (default: 1e-6) optional double presolve_absolute_tolerance = 7; - // Whether the solver writes log output to the console. Logs may still be - // written to a file when this is false. (default: true) optional bool log_to_console = 8; - // When true only the GPU heuristics run, improving the primal bound alone. - // When false both GPU and CPU are used and the dual bound is improved on - // the CPU. (default: false) bool heuristics_only = 9; - // Number of CPU threads used by the MIP solver. Lower values cap cuOpt's - // CPU footprint; higher values speed up the CPU-parallel parts. -1 derives - // the count from the number of CPU cores. (default: -1 (automatic)) optional int32 num_cpu_threads = 10; - // Number of GPUs used for the solve. (default: 1) optional int32 num_gpus = 11; - // Which presolver performs presolve reductions: 0 disables presolve, 1 - // selects Papilo, 2 selects PSLP. MIP uses Papilo by default. (default: - // Default (Papilo for MIP)) optional int32 presolver = 12; - // Whether scaling is applied to the MIP problem. 0 off, 1 on, 2 applied but - // not to the objective. (default: 2 (no objective scaling)) optional int32 mip_scaling = 13; - // Limit in work units — a machine-independent measure of solver effort — - // after which the solver stops and returns the current solution. When set - // together with time_limit or node_limit, the first limit reached wins. - // (default: no limit (infinity)) optional double work_limit = 14; - // Maximum number of branch-and-bound nodes explored before the solver stops - // and returns the best feasible solution found, if any. When set together - // with time_limit, the first limit reached wins. Encoded on the wire as -1 - // for "no limit". (default: no limit (INT_MAX)) optional int32 node_limit = 15; - // Reliability branching mode. -1 automatic (the solver picks whether to use - // it and with what factor), 0 disabled, k > 0 enables it and treats a - // variable as reliable once it has been branched on k times. (default: -1 - // (automatic)) optional int32 reliability_branching = 16; - // How strong branching at the root evaluates candidates: 0 dual simplex - // only, 1 cooperative dual simplex + a single batched PDLP solve, 2 batched - // PDLP only. Batching can cut strong-branching time when dual simplex - // struggles. (default: 0 (dual simplex only)) int32 mip_batch_pdlp_strong_branching = 17; - // Maximum number of cut passes to run. 0 disables cuts entirely; larger - // values perform more passes. (default: 10) optional int32 max_cut_passes = 18; - // Whether mixed-integer rounding cuts are used. -1 automatic (the solver - // decides from problem characteristics), 0 disabled, 1 enabled. (default: - // -1 (automatic)) optional int32 mir_cuts = 19; - // Whether mixed-integer Gomory cuts are used. -1 automatic, 0 disabled, 1 - // enabled. (default: -1 (automatic)) optional int32 mixed_integer_gomory_cuts = 20; - // Whether knapsack cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 knapsack_cuts = 21; - // Whether clique cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 clique_cuts = 22; - // Whether strong Chvatal-Gomory cuts are used. -1 automatic, 0 disabled, 1 - // enabled. (default: -1 (automatic)) optional int32 strong_chvatal_gomory_cuts = 23; - // Whether integer feasible solutions are used to strengthen integer - // variable bounds. -1 automatic, 0 disabled, 1 during the root cut passes, - // 2 during the root cut passes and after strong branching. (default: -1 - // (automatic)) optional int32 reduced_cost_strengthening = 24; - // Required improvement in the dual bound per cut pass. Larger values demand - // significant improvement each pass; -1 lets cut passes continue even - // without improvement. (default: -1 (no threshold)) optional double cut_change_threshold = 25; - // Minimum orthogonality a cut needs to be added to the LP relaxation. - // Values near 1 require cuts to be nearly orthogonal to each other; values - // near 0 admit more cuts. (default: 0.5) optional double cut_min_orthogonality = 26; - // 0 opportunistic — results may vary between runs due to parallelism. 1 - // deterministic — improves reproducibility across runs with the same thread - // count. Deterministic mode is experimental and does not yet guarantee - // fully deterministic results in every scenario. (default: 0 - // (opportunistic)) int32 determinism_mode = 27; - // Random seed. A fixed seed gives reproducible results when running in - // deterministic mode. (default: -1 (chosen automatically)) optional int32 seed = 28; - // Whether the probing-cache step of MIP presolve runs. Probing evaluates - // variable fixings to discover implications later used by branch-and-bound - // and the rounding heuristics. Only meaningful when presolve is otherwise - // enabled — presolver=0 disables the whole pipeline. (default: true) optional bool probing = 29; - // Maximum simplex iterations per candidate during strong branching. - // Lowering it speeds up strong branching at the cost of less accurate - // candidate evaluations. (default: -1 (automatic)) optional int32 strong_branching_simplex_iteration_limit = 30; - // Whether implied bound cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 implied_bound_cuts = 31; - // How reliability-branching candidates are evaluated: 0 dual simplex only, - // 1 cooperative dual simplex + a single batched PDLP solve, 2 batched PDLP - // only. (default: 0 (dual simplex only)) int32 mip_batch_pdlp_reliability_branching = 32; - // Symmetry detection and handling. -1 automatic, 0 disabled, 1 orbital - // fixing, 2 orbital fixing plus lexical reduction. (default: -1 - // (automatic)) optional int32 symmetry = 33; - // Big-M coefficient used when linearizing semi-continuous variable - // constraints (such a variable is either zero or lies in [lower_bound, - // upper_bound]). Should be at least as large as the upper bound of any - // semi-continuous variable in the problem. Serves as the fallback upper - // bound when bounds-strengthening cannot derive a finite one. The - // local-solve binding restricts the range to [1.0, +inf]. (default: 1e10) optional double semi_continuous_big_m = 34; - // Maximum number of solutions held in the solution pool. (default: 32) optional int32 population_size = 35; - // Number of parallel CPU Feasibility Jump climbers. (default: 8) optional int32 num_cpufj_threads = 36; - // Fraction of the total time budget given to the root LP. (default: 0.1) optional double root_lp_time_ratio = 39; - // Hard cap in seconds on the root LP solve. (default: 15.0) optional double root_lp_max_time = 40; - // Per-call time budget in seconds for the RINS sub-MIP. (default: 3.0) optional double rins_time_limit = 41; - // Ceiling in seconds on the adaptive RINS time budget. (default: 20.0) optional double rins_max_time_limit = 42; - // Fraction of variables RINS fixes before solving the sub-MIP. (default: - // 0.5) optional double rins_fix_rate = 43; - // Number of Feasibility Pump loops without improvement before recombination - // is triggered. (default: 3) optional int32 stagnation_trigger = 44; - // Depth of the diversity step taken after stagnation. (default: 8) optional int32 max_iterations_without_improvement = 45; - // Seed value for the constraint violation penalty. (default: 1000.0) optional double initial_infeasibility_weight = 46; - // Number of local minima after which the Feasibility Jump baseline exits. - // (default: 7000) optional int32 n_of_minimums_for_exit = 47; - // Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility pump, - // 4 line-segment recombination, 8 sub-MIP. (Bit 4 is the `LINE_SEGMENT` - // recombiner specifically, not the solver's general local search.) - // (default: 15 (all enabled)) optional int32 enabled_recombiners = 48; - // Size of the ring buffer used to detect Feasibility Pump assignment - // cycles. (default: 30) optional int32 cycle_detection_length = 49; - // Base time cap in seconds for relaxed LP solves inside the heuristics. - // (default: 1.0) optional double relaxed_lp_time_limit = 50; - // Time in seconds allowed for building the related-variable structure. - // (default: 30.0) optional double related_vars_time_limit = 51; - // Whether zero-half cuts are used. -1 automatic, 0 disabled, 1 enabled. - // (default: -1 (automatic)) optional int32 zero_half_cuts = 52; - // Cap on Papilo presolve rounds. A value <= 0 removes the cap entirely. - // (default: -1 (no override)) optional int32 presolve_max_rounds = 53; - // Ceiling on Papilo's probing.minbadgesize. (default: -1 (no override)) optional int32 papilo_probing_max_badgesize = 54; } diff --git a/dependencies.yaml b/dependencies.yaml index 1995027c58..a0751007bc 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -243,6 +243,31 @@ files: key: mps includes: - mps_cuopt_sh_client + py_build_cuopt_mcp: + output: pyproject + pyproject_dir: python/cuopt_mcp/ + extras: + table: build-system + includes: + - build_wheels + py_run_cuopt_mcp: + output: pyproject + pyproject_dir: python/cuopt_mcp/ + matrix: + cuda_suffixed: ["false"] + extras: + table: project + includes: + - run_cuopt_mcp + - depends_on_cuopt + py_test_cuopt_mcp: + output: pyproject + pyproject_dir: python/cuopt_mcp/ + extras: + table: project.optional-dependencies + key: test + includes: + - test_python_common channels: - rapidsai-nightly - rapidsai @@ -410,6 +435,19 @@ dependencies: packages: - *msgpack + run_cuopt_mcp: + # cuopt itself comes from depends_on_cuopt (CUDA-suffixed on PyPI). + # cuopt_mcp imports it lazily so the MCP handshake stays fast, which + # means an undeclared dependency would only surface on the first tool + # call rather than at install time. + common: + - output_types: [conda, requirements, pyproject] + packages: + - mcp>=2.0 + - output_types: conda + packages: + - pip + run_cuopt_sh_client: common: - output_types: [conda, requirements, pyproject] diff --git a/python/cuopt_mcp/LICENSE b/python/cuopt_mcp/LICENSE new file mode 120000 index 0000000000..30cff7403d --- /dev/null +++ b/python/cuopt_mcp/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md new file mode 100644 index 0000000000..19534f8b83 --- /dev/null +++ b/python/cuopt_mcp/README.md @@ -0,0 +1,81 @@ +# 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. + +```text +MCP client ──stdio (JSON-RPC)──> cuopt-mcp ──gRPC──> cuopt_grpc_server (GPU) +``` + +The MCP server runs as a stdio subprocess on the user's machine and needs no +GPU: the solve happens wherever `cuopt_grpc_server` runs. No HTTP application +endpoint is exposed — the client speaks MCP over stdio, and this process +speaks gRPC to the backend. + +## Install + +```bash +pip install cuopt_mcp +``` + +## Configure + +Start the solver backend on a GPU host (5001 is `cuopt_grpc_server`'s own +default; shown explicitly here since the client points at it by name): + +```bash +cuopt_grpc_server --port 5001 +``` + +Then register the MCP server with your client: + +```json +{ + "mcpServers": { + "cuopt": { + "command": "cuopt-mcp", + "env": { "CUOPT_REMOTE_HOST": "gpu-host", "CUOPT_REMOTE_PORT": "5001" } + } + } +} +``` + +Configuration reuses the environment the cuOpt gRPC client already honours — +`CUOPT_REMOTE_HOST`, `CUOPT_REMOTE_PORT`, and `CUOPT_TLS_*`. The connection is +plain TCP unless `CUOPT_TLS_ENABLED=true` is set; set it (with +`CUOPT_TLS_ROOT_CERT`/`CUOPT_TLS_CLIENT_CERT`/`CUOPT_TLS_CLIENT_KEY` as +needed) whenever `gpu-host` isn't a trusted local network. + +## Tools + +| Tool | Purpose | +|------|---------| +| `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_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 | + +Solves are asynchronous by design. A blocking call would exceed the MCP +client timeout on any realistic MILP and would make cancellation impossible. + +## Design notes + +**No per-job state.** Column names needed to label a solution are supplied +per call via `names_from`, so any process can retrieve a named result for a +job it did not submit. The only state this process holds is the gRPC channel. + +**Result shaping.** Problems can have millions of variables; the binding +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. diff --git a/python/cuopt_mcp/cuopt_mcp/VERSION b/python/cuopt_mcp/cuopt_mcp/VERSION new file mode 100644 index 0000000000..6549ba6527 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/VERSION @@ -0,0 +1 @@ +26.10.00 diff --git a/python/cuopt_mcp/cuopt_mcp/__init__.py b/python/cuopt_mcp/cuopt_mcp/__init__.py new file mode 100644 index 0000000000..ab7f419a3d --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/__init__.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""MCP server for NVIDIA cuOpt.""" + +__all__ = ["main"] + + +def main() -> None: + """Console-script entry point (``cuopt-mcp``). + + Runs the MCP server over stdio until the client disconnects or the + process is killed; does not return until then. + """ + from .server import main as _main + + _main() diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py new file mode 100644 index 0000000000..d7c8e1b4b2 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""gRPC connection handling for the cuOpt MCP server. + +``cuopt`` is imported lazily inside the accessors rather than at module +scope: importing it pulls the compiled ``libcuopt`` extension, and under +stdio that cost would be paid on every session start, delaying the +``initialize`` / ``tools/list`` handshake. +""" + +import os +import re +import threading +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cuopt.grpc.linear_programming import Client + +DEFAULT_HOST = "localhost" +# Matches cuopt_default_grpc_port (cpp/src/grpc/cuopt_default_grpc_port.h) -- +# cuopt_grpc_server's own default, not the unrelated routing-client examples +# that use 50051. +DEFAULT_PORT = 5001 + +# Redacts absolute filesystem paths out of backend error text, since a +# server-internal error (e.g. "Failed to open log file: ") can't be +# told apart from a caller-facing one by type alone. +_PATH_RE = re.compile(r"(? str: + """Redact absolute filesystem paths from text bound for an MCP caller.""" + return _PATH_RE.sub("[redacted path]", text) + + +_lock = threading.Lock() +_client = None + + +def endpoint() -> tuple: + """Return the configured ``(host, port)``. + + Reuses the same environment the cuOpt gRPC client already honours, so + the MCP server introduces no new configuration surface. + + Raises + ------ + CuOptMCPError: ``CUOPT_REMOTE_PORT`` isn't an integer, or is outside + the valid TCP port range -- reported here so it's an actionable + configuration error instead of a delayed connection failure. + """ + host = os.environ.get("CUOPT_REMOTE_HOST", DEFAULT_HOST) + raw_port = os.environ.get("CUOPT_REMOTE_PORT") + if raw_port is None: + return host, DEFAULT_PORT + try: + port = int(raw_port) + except ValueError: + raise CuOptMCPError( + f"CUOPT_REMOTE_PORT={raw_port!r} is not an integer" + ) from None + if not 1 <= port <= 65535: + raise CuOptMCPError( + f"CUOPT_REMOTE_PORT={port} must be between 1 and 65535" + ) + return host, port + + +def _tls_config(): + if os.environ.get("CUOPT_TLS_ENABLED", "").lower() not in ( + "1", + "true", + "yes", + ): + return None + from cuopt.grpc.linear_programming import TlsConfig + + return TlsConfig( + root_certs=os.environ.get("CUOPT_TLS_ROOT_CERT"), + client_cert=os.environ.get("CUOPT_TLS_CLIENT_CERT"), + client_key=os.environ.get("CUOPT_TLS_CLIENT_KEY"), + ) + + +def get_client() -> "Client": + """Return a process-wide gRPC client, connecting on first use. + + The channel is the only state this process holds; jobs themselves live + in cuopt_grpc_server and are addressed by the ``job_id`` returned to the + caller, so a restart loses nothing but the connection. + + Returns + ------- + The cached ``cuopt.grpc.linear_programming.Client``, creating it + against the current ``CUOPT_REMOTE_HOST``/``CUOPT_REMOTE_PORT`` / + TLS environment on first call. + """ + global _client + with _lock: + if _client is None: + from cuopt.grpc.linear_programming import Client + + host, port = endpoint() + _client = Client(host, port, tls=_tls_config()) + return _client + + +def reset_client() -> None: + """Drop the cached client. Used by tests and after a fatal channel error.""" + global _client + with _lock: + _client = None + + +class CuOptMCPError(RuntimeError): + """Raised with text meant for the model, not a stack trace.""" + + +def describe_connection_error(exc: Exception) -> CuOptMCPError: + """Convert a backend exception into a model-facing :class:`CuOptMCPError`. + + Recognizes the unreachable-server case and names the host/port plus how + to fix it. Any other backend text is passed through :func:`redact_paths`: + the server's own error strings sometimes embed its internal paths (e.g. + a job's log file location), which an MCP caller has no use for. + + Args: + exc: The exception raised by the gRPC client call. + + Returns + ------- + A :class:`CuOptMCPError` whose message is safe to return to the + MCP caller. + """ + host, port = endpoint() + text = str(exc) + if "UNAVAILABLE" in text or "failed to connect" in text.lower(): + 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." + ) + return CuOptMCPError(redact_paths(text)) diff --git a/python/cuopt_mcp/cuopt_mcp/schema.py b/python/cuopt_mcp/cuopt_mcp/schema.py new file mode 100644 index 0000000000..59052ac306 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/schema.py @@ -0,0 +1,155 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Access to the generated solver-settings JSON Schema. + +The schema is emitted from ``cpp/src/grpc/codegen/field_registry.yaml`` by +``./build.sh codegen`` — the same single source of truth that drives the +proto and the C++ conversion code. Nothing here hand-maintains a second +copy of the settings surface. +""" + +import functools +import json +from pathlib import Path + +SCHEMA_FILENAME = "cuopt_mcp_schema.json" + +# In an installed wheel the schema is packaged alongside this module. In a +# source checkout it lives in the codegen output directory; fall back to that +# so the server runs from the repo without a build step. +_PACKAGED = Path(__file__).parent / "_generated" / SCHEMA_FILENAME +_IN_TREE = ( + Path(__file__).resolve().parents[3] + / "cpp" + / "src" + / "grpc" + / "codegen" + / "generated" + / SCHEMA_FILENAME +) + + +def schema_path() -> Path: + """Locate the generated ``cuopt_mcp_schema.json``. + + Returns + ------- + The packaged copy if this is an installed wheel, else the + in-tree codegen output directory. + + Raises + ------ + FileNotFoundError: Neither location has the file — usually means + ``./build.sh codegen`` hasn't been run in a source checkout. + """ + for candidate in (_PACKAGED, _IN_TREE): + if candidate.is_file(): + return candidate + raise FileNotFoundError( + f"{SCHEMA_FILENAME} not found. In a source checkout, run " + "`./build.sh codegen` to generate it." + ) + + +@functools.lru_cache(maxsize=1) +def load() -> dict: + """Return the full generated schema document, reading it once per + process. + + Returns + ------- + The parsed ``cuopt_mcp_schema.json`` (``$schema``, ``$comment``, + and a ``settings`` dict keyed by ``pdlp_settings``/``mip_settings``). + + Raises + ------ + FileNotFoundError: See :func:`schema_path`. + """ + return json.loads(schema_path().read_text()) + + +def settings_schema(kind: str) -> dict: + """Return the JSON Schema object for one settings kind. + + Args: + kind: "pdlp_settings" or "mip_settings". + + Returns + ------- + The ``{"title", "type", "properties", ...}`` schema for ``kind``. + + Raises + ------ + KeyError: ``kind`` isn't a key under ``settings`` in the schema. + """ + return load()["settings"][kind] + + +def known_parameters(kind: str) -> set: + """Return the set of settable parameter names for one settings kind. + + Args: + kind: "pdlp_settings" or "mip_settings". + + Returns + ------- + The property names from :func:`settings_schema`'s ``properties``. + """ + return set(settings_schema(kind)["properties"]) + + +def validate_settings(kind: str, settings: dict) -> None: + """Reject unknown or wrongly-typed settings before any gRPC traffic. + + The schema declares ``additionalProperties: false``, so a typo like + ``time_limt`` fails here with the near-miss named rather than being + silently dropped by the solver. + + Args: + kind: "pdlp_settings" or "mip_settings". + settings: The caller-supplied settings dict to check, or falsy to + skip validation entirely (nothing to check). + + Raises + ------ + ValueError: A key isn't a known parameter for ``kind``, or its + value doesn't match the parameter's declared type/enum. + """ + if not settings: + return + schema = settings_schema(kind) + unknown = set(settings) - set(schema["properties"]) + if unknown: + import difflib + + hints = [] + for name in sorted(unknown): + close = difflib.get_close_matches( + name, schema["properties"], n=1, cutoff=0.7 + ) + hints.append( + f"{name}" + (f" (did you mean {close[0]}?)" if close else "") + ) + raise ValueError( + f"unknown {kind} parameter(s): {', '.join(hints)}. " + f"Call cuopt_list_settings('{kind}') for the full list." + ) + for name, value in settings.items(): + prop = schema["properties"][name] + expected = prop.get("type") + if expected == "string" and "enum" in prop: + if value not in prop["enum"]: + raise ValueError( + f"{name} must be one of {prop['enum']}, got {value!r}" + ) + elif expected == "integer" and ( + isinstance(value, bool) or not isinstance(value, int) + ): + raise ValueError(f"{name} must be an integer, got {value!r}") + elif expected == "number" and ( + isinstance(value, bool) or not isinstance(value, (int, float)) + ): + raise ValueError(f"{name} must be a number, got {value!r}") + elif expected == "boolean" and not isinstance(value, bool): + raise ValueError(f"{name} must be a boolean, got {value!r}") diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py new file mode 100644 index 0000000000..5df66a1b7b --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -0,0 +1,254 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""MCP server exposing cuOpt LP/MILP solves over the gRPC backend. + +Runs as a stdio subprocess of an MCP client, holding a gRPC channel to +``cuopt_grpc_server``. No HTTP application endpoint is exposed, and the +host needs no GPU — the solve happens wherever the gRPC server runs. + +stdout carries the JSON-RPC stream, so every diagnostic goes to stderr; a +stray ``print()`` here corrupts the protocol. +""" + +import logging +import sys +from typing import Any + +from mcp.server.mcpserver import MCPServer + +from . import tools +from .client import CuOptMCPError, endpoint, redact_paths + +logging.basicConfig( + stream=sys.stderr, + level=logging.INFO, + format="%(asctime)s cuopt-mcp %(levelname)s %(message)s", +) + +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." + ), +) + + +def _guard(fn, /, **kwargs) -> dict[str, Any]: + """Call a tools.* function, turning a caller-facing error into a normal + return value instead of an MCP protocol exception. + + Every tool below returns this dict's shape on success; on failure it's + ``{"error": }`` instead of a traceback, + which the model can act on inline instead of the call simply failing. + Paths are redacted here so every raise site doesn't have to. + + An exception of any other type is logged (with traceback) to stderr + and reduced to a generic message instead of str(exc): unlike + CuOptMCPError/ValueError, that text was never vetted as safe to + return, and letting it through here would bypass redact_paths. + """ + try: + return fn(**kwargs) + except (CuOptMCPError, ValueError) as exc: + return {"error": redact_paths(str(exc))} + except Exception: + logging.exception("unexpected error in %s", fn.__qualname__) + return {"error": "internal error -- see server logs"} + + +@server.tool(structured_output=True) +def cuopt_solve_lp( + problem_path: str, settings: dict | None = None +) -> dict[str, Any]: + """Submit a linear program to cuOpt and return a job handle immediately. + + problem_path: path to an MPS, QPS, or LP file readable by this process. + 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 + keep the cuOpt default. + + Returns a job_id. The solve runs asynchronously — poll cuopt_status, + then call cuopt_result. + """ + return _guard( + tools.submit, + problem_path=problem_path, + kind="pdlp_settings", + settings=settings, + ) + + +@server.tool(structured_output=True) +def cuopt_solve_milp( + problem_path: str, + settings: dict | None = None, + track_incumbents: bool = False, +) -> dict[str, Any]: + """Submit a mixed-integer program to cuOpt and return a job handle. + + problem_path: path to an MPS or LP file declaring integer/binary + variables (LP: Generals/Binaries sections). + 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. + 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. + + Returns a job_id. Use cuopt_cancel to stop early once the result is + good enough. + """ + return _guard( + tools.submit, + problem_path=problem_path, + kind="mip_settings", + settings=settings, + track_incumbents=track_incumbents, + ) + + +@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``). + """ + return _guard(tools.status, job_id=job_id) + + +@server.tool(structured_output=True) +def cuopt_result( + job_id: str, + names_from: str | None = None, + variables: list | None = None, + nonzero_only: bool = False, + limit: int = tools.INLINE_SOLUTION_LIMIT, +) -> dict[str, Any]: + """Fetch the solution for a finished cuOpt job. + + Always returns the termination status, objective, and solve time. + Variable values are shaped to stay readable: + + 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. + limit: maximum values returned inline. Beyond this the selected values + (respecting nonzero_only) are written to a file and its path + returned instead. + + On failure, returns ``{"error": }`` instead (see ``_guard``). + """ + return _guard( + tools.result, + job_id=job_id, + names_from=names_from, + variables=variables, + nonzero_only=nonzero_only, + limit=limit, + ) + + +@server.tool(structured_output=True) +def cuopt_incumbents(job_id: str, from_index: int = 0) -> dict[str, Any]: + """Return improving MILP solutions found so far, oldest first. + + Requires cuopt_solve_milp's track_incumbents=True for this job, or + this always comes back empty. Use the returned next_index on the + following call to fetch only new incumbents. A flat objective across + several calls means the solver has plateaued and cuopt_cancel may be + worthwhile. On failure, returns ``{"error": }`` instead + (see ``_guard``). + """ + return _guard(tools.incumbents, job_id=job_id, from_index=from_index) + + +@server.tool(structured_output=True) +def cuopt_logs( + job_id: str, from_byte: int = 0, tail_lines: int = 100 +) -> dict[str, Any]: + """Return solver log lines for a finished job, for diagnosing a failure + or an unexpected result after the fact. + + Only works once the job has reached a terminal state (poll + cuopt_status first) — a live tail of a still-running job isn't + available through this tool yet. For a CANCELLED job, expect an empty + or missing log: the server deletes it as part of cancelling, unlike + COMPLETED/FAILED. + + job_id: the job to fetch logs for. + from_byte: resume from this byte offset — pass back the next_byte from + a prior call to fetch only what's new since then. + tail_lines: keep only the last this many lines of the fetched text; + must be between 1 and tools.MAX_TAIL_LINES. + + Returns lines, truncated (whether more preceded the kept lines), and + next_byte for the following call. On failure, or on an out-of-range + tail_lines, returns ``{"error": }`` instead (see ``_guard``). + """ + return _guard( + tools.logs, job_id=job_id, from_byte=from_byte, tail_lines=tail_lines + ) + + +@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. + + job_id: the job to cancel. Cancelling a job that has already reached + COMPLETED or FAILED returns ``{"error": }`` (see ``_guard``) + rather than succeeding silently. + """ + return _guard(tools.cancel, job_id=job_id) + + +@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. + + job_id: the job to delete. Call this once its result is no longer + needed, so cuopt_grpc_server doesn't accumulate state indefinitely. + On failure, returns ``{"error": }`` instead (see ``_guard``). + """ + return _guard(tools.delete, job_id=job_id) + + +@server.tool(structured_output=True) +def cuopt_list_settings(kind: str, name: str | None = None) -> dict[str, Any]: + """List cuOpt solver settings with descriptions, types, and defaults. + + kind: "pdlp_settings" for LP, "mip_settings" for MILP. + name: a single parameter to describe in full, instead of listing names. + + The catalogue is generated from cuOpt's field registry, so it always + matches the solver build being talked to. On an unknown kind or name, + returns ``{"error": }`` instead (see ``_guard``). + """ + return _guard(tools.list_settings, kind=kind, name=name) + + +def main() -> None: + """Run the MCP server over stdio. + + Blocks until the client disconnects or the process is killed. Logs the + configured gRPC target to stderr on startup; raises whatever + ``server.run`` raises on a fatal transport failure (stdout is reserved + for the JSON-RPC stream, so nothing here writes there). + """ + host, port = endpoint() + logging.info("cuopt-mcp starting; gRPC target %s:%s", host, port) + server.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py new file mode 100644 index 0000000000..022377c639 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -0,0 +1,550 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tool implementations for the cuOpt MCP server. + +Every solve is asynchronous: submitting returns a ``job_id`` and nothing +blocks. A blocking call inside a single ``tools/call`` would exceed the +client timeout on any realistic MILP, and would make cancellation +impossible. + +No per-job state is kept here. Column names needed to label a solution are +supplied per call via ``names_from``, so any process — a second editor +window, or this one after a restart — can retrieve a named result for a job +it did not submit. +""" + +import json +import os +import re +import stat +import tempfile +from pathlib import Path + +from .client import CuOptMCPError, describe_connection_error, get_client +from .schema import known_parameters, settings_schema, validate_settings + +# Above this many variables a solution is written to a file instead of +# returned inline -- the binding limit is the model's context window. +INLINE_SOLUTION_LIMIT = 200 + +# Upper bound on logs()'s tail_lines, same context-window reasoning. +MAX_TAIL_LINES = 2000 + + +def _check_non_negative_int(name: str, value) -> None: + """Reject a bool (an int subclass in Python) or a negative value.""" + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise CuOptMCPError(f"{name} must be a non-negative integer") + + +def _solution_dir() -> Path: + """Return the directory solution/name files are written to, private to + this user. + + Defaults under the shared system temp dir. ``mkdir``'s ``mode`` is + filtered by umask, so it alone can't guarantee 0700 -- ``chmod`` after + creating makes that unconditional. If the directory already exists + (e.g. from a prior run, or planted by another local user first), it's + only trusted when it's already private to this user; otherwise another + local user could read or tamper with solution files (CWE-377). The + check uses ``lstat`` and rejects a symlink outright: ``stat`` follows + the link and would report the *target's* ownership/mode, so a symlink + planted here pointing at some other 0700 directory this user happens to + own elsewhere would pass a ``stat``-based check (CWE-59) and redirect + solution writes there. Also rejects an existing non-directory (e.g. a + stray file at this path): silently accepting one here would surface + later as an unguarded ``NotADirectoryError`` from + :func:`_write_solution_file`. + """ + path = Path( + os.environ.get( + "CUOPT_MCP_SOLUTION_DIR", Path(tempfile.gettempdir()) / "cuopt-mcp" + ) + ) + try: + path.mkdir(parents=True, mode=0o700) + except FileExistsError: + st = path.lstat() + if ( + not stat.S_ISDIR(st.st_mode) + or st.st_uid != os.getuid() + or stat.S_IMODE(st.st_mode) & 0o077 + ): + raise CuOptMCPError( + f"{path} exists but is not a private directory owned by " + f"this user (mode {oct(stat.S_IMODE(st.st_mode))}, owner " + f"uid {st.st_uid}) -- refusing to write solution files " + "there. Remove it or set CUOPT_MCP_SOLUTION_DIR to a " + "private location." + ) from None + else: + os.chmod(path, 0o700) + return path + + +def _read_problem(path: str): + from cuopt.linear_programming import Read + + resolved = Path(path).expanduser() + if not resolved.is_file(): + raise CuOptMCPError(f"problem file not found: {resolved}") + try: + return Read(str(resolved)) + except Exception as exc: + raise CuOptMCPError(f"failed to parse {resolved}: {exc}") from exc + + +def _build_settings(kind: str, settings: dict | None): + if kind not in ("pdlp_settings", "mip_settings"): + raise CuOptMCPError( + "kind must be 'pdlp_settings' (LP) or 'mip_settings' (MILP)" + ) + try: + validate_settings(kind, settings or {}) + except ValueError as exc: + raise CuOptMCPError(str(exc)) from exc + properties = settings_schema(kind)["properties"] + + from cuopt.linear_programming import SolverSettings + + solver_settings = SolverSettings() + for name, value in (settings or {}).items(): + # Enums are exposed by name ("Barrier"); set_parameter takes the int. + prop = properties[name] + mapping = prop.get("x-enum-values") + if mapping is not None: + value = mapping[value] + # The proto field name is not always the CUOPT_* parameter name. + solver_settings.set_parameter( + prop.get("x-parameter-name", name), value + ) + return solver_settings + + +def _variable_names(names_from: str | None): + if not names_from: + return None + model = _read_problem(names_from) + names = model.get_variable_names() + return list(names) if names is not None else None + + +def submit( + problem_path: str, + kind: str, + settings: dict | None = None, + track_incumbents: bool = False, +) -> dict: + """Parse a problem file and submit it 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. + 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 + :func:`incumbents` has something to poll. Off by default: + collection downloads each incumbent's full variable vector + server-side even though only its objective is kept, which adds + up for a large model with many incumbents. + + Returns + ------- + A dict with ``job_id``, ``source`` (the resolved problem path, for + ``cuopt_result``'s ``names_from``), and the problem's size. + + Raises + ------ + CuOptMCPError: ``kind`` is invalid, the file doesn't exist, fails to + parse, carries an unknown setting, or the backend is + unreachable. + """ + model = _read_problem(problem_path) + solver_settings = _build_settings(kind, settings) + try: + job_id = get_client().submit( + model, + solver_settings, + enable_incumbents=(kind == "mip_settings" and track_incumbents), + ) + except Exception as exc: + raise describe_connection_error(exc) from exc + + # DataModel exposes no public size accessors; derive from CSR offsets. + offsets = model.get_constraint_matrix_offsets() + return { + "job_id": job_id, + "source": str(Path(problem_path).expanduser().resolve()), + "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." + ), + } + + +def status(job_id: str) -> dict: + """Report whether a submitted job is queued, running, or finished. + + Args: + job_id: A job handle previously returned by :func:`submit`. + + Returns + ------- + A dict with ``status`` (the raw state name) and ``terminal`` + (whether ``status`` is one that will never change again). + + Raises + ------ + CuOptMCPError: The backend is unreachable. + """ + try: + state = get_client().status(job_id) + except Exception as exc: + raise describe_connection_error(exc) from exc + return { + "job_id": job_id, + "status": state.name, + "terminal": state.name + in ("COMPLETED", "FAILED", "CANCELLED", "NOT_FOUND"), + } + + +_JOB_ID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" +) + + +def _solution_file_path(job_id: str) -> Path: + """Return the on-disk path for a job's solution file, after checking + ``job_id`` looks like a server-issued UUID. + + ``job_id`` is caller-supplied and gets joined onto + ``CUOPT_MCP_SOLUTION_DIR`` as a filename; without this check, a value + like ``"../../etc/cron.d/x"`` would let a caller write or unlink an + arbitrary ``.json``-suffixed path outside that directory. + """ + if not _JOB_ID_RE.match(job_id): + raise CuOptMCPError(f"{job_id!r} is not a valid job_id") + return _solution_dir() / f"{job_id}.json" + + +def _write_solution_file(job_id: str, vars_by_name: dict) -> str: + path = _solution_file_path(job_id) + path.write_text(json.dumps(vars_by_name, indent=1)) + return str(path) + + +def result( + job_id: str, + names_from: str | None = None, + variables: list | None = None, + nonzero_only: bool = False, + limit: int = INLINE_SOLUTION_LIMIT, +) -> dict: + """Fetch a completed solution, shaped to stay within a usable size. + + Args: + job_id: A job handle previously returned by :func:`submit`. + names_from: Path to the problem file, to key ``variables`` by name + instead of column index — pass back ``submit``'s ``source``. + 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``. + 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 + ``solution_path`` returned instead. + + Returns + ------- + ``{"ready": False, ...}`` if the job hasn't finished yet, otherwise + the termination status, objective, solve time, and the (possibly + truncated) variable values. + + 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: + solution = get_client().result(job_id, _variable_names(names_from)) + except Exception as exc: + raise describe_connection_error(exc) from exc + if solution is None: + return { + "job_id": job_id, + "ready": False, + "hint": "Job has not finished. Poll cuopt_status(job_id).", + } + + primal = solution.get_primal_solution() + # IntEnum: use .name, not str(). Its numbering differs from the wire + # enum pdlp_termination_status -- never map between them. + status_enum = solution.get_termination_status() + summary = { + "job_id": job_id, + "ready": True, + "termination_status": getattr(status_enum, "name", str(status_enum)), + "termination_status_code": int(status_enum), + "primal_objective": float(solution.get_primal_objective()), + "solve_time_s": float(solution.get_solve_time()), + "num_variables": int(len(primal)), + } + + vars_by_name = solution.get_vars() + if not vars_by_name: + vars_by_name = {str(i): float(v) for i, v in enumerate(primal)} + if not names_from: + summary["names"] = ( + "Values are keyed by column index. Pass names_from= to key them by variable name." + ) + + if variables is not None: + missing = [v for v in variables if v not in vars_by_name] + summary["variables"] = { + v: float(vars_by_name[v]) for v in variables if v in vars_by_name + } + if missing: + summary["missing_variables"] = missing + return summary + + selected = vars_by_name + if nonzero_only: + selected = {k: v for k, v in vars_by_name.items() if v != 0} + summary["num_nonzero"] = len(selected) + + if len(selected) <= limit: + summary["variables"] = {k: float(v) for k, v in selected.items()} + else: + summary["variables_truncated"] = True + summary["variables_shown"] = limit + summary["variables"] = { + k: float(v) for k, v in list(selected.items())[:limit] + } + summary["solution_path"] = _write_solution_file(job_id, selected) + summary["hint"] = ( + f"{len(selected)} values exceed the inline limit of {limit}. " + "The values shown above are at solution_path (all of them, " + "not just the nonzero ones, if nonzero_only wasn't set); use " + "variables=[...] or nonzero_only=true to narrow the result." + ) + return summary + + +def cancel(job_id: str) -> dict: + """Stop a running job. + + Args: + job_id: A job handle previously returned by :func:`submit`. + + Returns + ------- + ``{"job_id": ..., "cancelled": True}`` on success. + + Raises + ------ + CuOptMCPError: The job has already reached COMPLETED or FAILED (the + backend rejects cancelling a terminal job), or the backend is + unreachable. + """ + try: + get_client().cancel(job_id) + except Exception as exc: + raise describe_connection_error(exc) from exc + return {"job_id": job_id, "cancelled": True} + + +def delete(job_id: str) -> dict: + """Release a job's server-side state (solution, logs, incumbents), and + its local solution file if :func:`result` wrote one. + + cuopt_grpc_server keeps this until deleted, so a caller done with a + job's result should call this rather than letting it accumulate. + Cancels first if the job is still running. + + Args: + job_id: A job handle previously returned by :func:`submit`. + + Returns + ------- + ``{"job_id": ..., "deleted": True}`` on success. + + Raises + ------ + CuOptMCPError: ``job_id`` isn't a valid job id, or the backend is + unreachable. + """ + if not _JOB_ID_RE.match(job_id): + raise CuOptMCPError(f"{job_id!r} is not a valid job_id") + try: + get_client().delete(job_id) + except Exception as exc: + raise describe_connection_error(exc) from exc + try: + _solution_file_path(job_id).unlink(missing_ok=True) + except CuOptMCPError: + pass # nothing to clean up if the directory itself is unusable + return {"job_id": job_id, "deleted": True} + + +def incumbents(job_id: str, from_index: int = 0) -> dict: + """Return the MILP incumbent trajectory so far. + + Lets a caller watch the objective improve and stop a run that has + plateaued, rather than waiting out the full time limit. + + Args: + job_id: A job handle previously returned by :func:`submit`. + from_index: Skip incumbents before this index — pass back a prior + call's ``next_index`` to fetch only what's new. + + Returns + ------- + A dict with the incumbent objectives found since ``from_index`` and + ``next_index`` for the following call. + + Raises + ------ + CuOptMCPError: ``from_index`` is negative, or the backend is + unreachable. + """ + _check_non_negative_int("from_index", from_index) + try: + # Each entry also has "assignment" (full variable vector); omitted + # here to stay within a usable tool-result size. + found = get_client().incumbents(job_id, from_index) + except Exception as exc: + raise describe_connection_error(exc) from exc + objectives = [ + {"index": entry["index"], "objective": float(entry["objective"])} + for entry in found or [] + ] + next_index = objectives[-1]["index"] + 1 if objectives else from_index + return { + "job_id": job_id, + "count": len(objectives), + "next_index": next_index, + "incumbents": objectives, + } + + +def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: + """Fetch a job's log lines starting at ``from_byte``, tailed to the last + ``tail_lines`` lines. + + Solver output is captured to a log for every job, from submission — + there's nothing to opt into. This tool only returns it once the job + has finished though: it fetches a snapshot via :meth:`Client.logs`, + which raises while a job is still queued or running; poll + ``cuopt_status`` first. A live tail while running is possible on the + wire (``Client.start_log_stream``) but not exposed by this tool yet. + + For a CANCELLED job specifically, expect an empty or missing log: the + server deletes a job's log file as part of handling cancellation + (unlike COMPLETED/FAILED, which keep it until :func:`delete`). + + Args: + job_id: The job to fetch logs for. + from_byte: Byte offset to resume from (e.g. ``next_byte`` from a + prior call), so repeated polling doesn't re-fetch the whole log. + tail_lines: Keep only the last this many lines of the fetched text; + must be between 1 and :data:`MAX_TAIL_LINES`. Bounds the + response, not the fetch: Client.logs() has no server-side tail + operation, so a multi-GB log is still downloaded and held in + memory here before being sliced. Use ``from_byte`` to fetch + incrementally rather than relying on ``tail_lines`` alone for a + log that large. + + Returns + ------- + ``{"ready": False, ...}`` if the job hasn't finished yet, otherwise + a dict with ``lines`` (the tailed text), ``truncated`` (whether more + preceded them), and ``next_byte`` to pass on the next call. + + Raises + ------ + CuOptMCPError: ``from_byte`` is negative, ``tail_lines`` is out of + range, or the backend is unreachable. + """ + _check_non_negative_int("from_byte", from_byte) + if ( + isinstance(tail_lines, bool) + or not isinstance(tail_lines, int) + or not 1 <= tail_lines <= MAX_TAIL_LINES + ): + raise CuOptMCPError( + f"tail_lines must be an integer between 1 and {MAX_TAIL_LINES}" + ) + try: + lines = get_client().logs(job_id, from_byte) + except Exception as exc: + # Matched by name, not isinstance, to avoid an eager cuopt import. + if type(exc).__name__ == "JobNotReadyError": + return { + "job_id": job_id, + "ready": False, + "hint": "Job has not finished. Poll cuopt_status(job_id).", + } + raise describe_connection_error(exc) from exc + lines = lines or [] + truncated = len(lines) > tail_lines + # Client.logs() doesn't return the server's byte offset; approximate it + # from encoded line lengths plus the '\n' each one was split on. + next_byte = from_byte + sum( + len(line.encode("utf-8")) + 1 for line in lines + ) + return { + "job_id": job_id, + "ready": True, + "truncated": truncated, + "lines": lines[-tail_lines:], + "next_byte": next_byte, + } + + +def list_settings(kind: str, name: str | None = None) -> dict: + """Describe available solver settings, from the generated schema. + + Args: + kind: "pdlp_settings" for LP or "mip_settings" for MILP. + name: A single parameter to describe in full (type, description, + default). Omit to list all parameter names for ``kind``. + + Returns + ------- + ``{"kind": ..., "parameters": [...]}`` when listing, or + ``{"kind": ..., "name": ..., **the property schema}`` for a single + parameter. + + Raises + ------ + CuOptMCPError: ``kind`` isn't recognized, or ``name`` isn't a known + parameter for it. + """ + if kind not in ("pdlp_settings", "mip_settings"): + raise CuOptMCPError( + "kind must be 'pdlp_settings' (LP) or 'mip_settings' (MILP)" + ) + schema = settings_schema(kind) + if name: + if name not in schema["properties"]: + raise CuOptMCPError( + f"unknown {kind} parameter {name!r}. " + f"Known: {sorted(known_parameters(kind))}" + ) + return {"kind": kind, "name": name, **schema["properties"][name]} + return {"kind": kind, "parameters": sorted(schema["properties"])} diff --git a/python/cuopt_mcp/pyproject.toml b/python/cuopt_mcp/pyproject.toml new file mode 100644 index 0000000000..b6cec104b7 --- /dev/null +++ b/python/cuopt_mcp/pyproject.toml @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[build-system] +requires = [ + "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" +dynamic = ["version"] +description = "MCP server exposing NVIDIA cuOpt LP/MILP solving to AI agents" +readme = { file = "README.md", content-type = "text/markdown" } +authors = [ + { name = "NVIDIA Corporation" }, +] +license = "Apache-2.0" +license-files = ["LICENSE"] +requires-python = ">=3.11" +dependencies = [ + "cuopt==26.10.*,>=0.0.0a0", + "mcp>=2.0", +] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[project.optional-dependencies] +test = [ + "pytest-cov", + "pytest-rerunfailures", + "pytest-xdist", + "pytest<9.0", +] # This list was generated by `rapids-dependency-file-generator`. To make changes, edit ../../dependencies.yaml and run `rapids-dependency-file-generator`. + +[project.urls] +Homepage = "https://docs.nvidia.com/cuopt/introduction.html" +Source = "https://github.com/nvidia/cuopt" + +[project.scripts] +cuopt-mcp = "cuopt_mcp:main" + +[tool.setuptools] +zip-safe = false + +[tool.setuptools.dynamic] +version = {file = "cuopt_mcp/VERSION"} + +[tool.setuptools.packages.find] +include = ["cuopt_mcp*"] + +[tool.setuptools.package-data] +cuopt_mcp = ["VERSION", "_generated/*.json"] diff --git a/python/cuopt_mcp/setup.py b/python/cuopt_mcp/setup.py new file mode 100644 index 0000000000..ad8aafaec1 --- /dev/null +++ b/python/cuopt_mcp/setup.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Copy the generated settings schema into the package at build time. + +The schema is a codegen artifact owned by cpp/src/grpc/codegen/generated/. +Copying it here at build time rather than committing a second copy keeps +field_registry.yaml the single source of truth: there is no checked-in file +that can drift from it. + +A source checkout needs no copy — cuopt_mcp.schema falls back to the codegen +output directory directly. +""" + +import shutil +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + +SCHEMA = "cuopt_mcp_schema.json" +SOURCE = ( + Path(__file__).resolve().parents[2] + / "cpp" + / "src" + / "grpc" + / "codegen" + / "generated" + / SCHEMA +) + + +class BuildPyWithSchema(build_py): + def run(self): + if not SOURCE.is_file(): + raise SystemExit( + f"{SOURCE} is missing. Run `./build.sh codegen` before " + "building cuopt_mcp." + ) + target = Path(self.build_lib) / "cuopt_mcp" / "_generated" + target.mkdir(parents=True, exist_ok=True) + shutil.copyfile(SOURCE, target / SCHEMA) + super().run() + + +setup(cmdclass={"build_py": BuildPyWithSchema}) diff --git a/python/cuopt_mcp/tests/conftest.py b/python/cuopt_mcp/tests/conftest.py new file mode 100644 index 0000000000..643a9a7f05 --- /dev/null +++ b/python/cuopt_mcp/tests/conftest.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + + +@pytest.fixture +def anyio_backend(): + """Run @pytest.mark.anyio tests on asyncio only. + + anyio's plugin would otherwise parametrise across trio as well, which is + not a dependency of this package. + """ + return "asyncio" diff --git a/python/cuopt_mcp/tests/test_end_to_end.py b/python/cuopt_mcp/tests/test_end_to_end.py new file mode 100644 index 0000000000..c76ce886d3 --- /dev/null +++ b/python/cuopt_mcp/tests/test_end_to_end.py @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""End-to-end test: a real MCP client driving a real cuOpt solve. + +Spawns the MCP server as a stdio subprocess exactly as an MCP client would, +speaks the MCP protocol to it, and lets it forward the solve to a live +cuopt_grpc_server. Nothing is mocked. + +Set CUOPT_TEST_GRPC_PORT to point at a running server; the test skips if no +server is reachable. +""" + +import os +import textwrap + +import pytest + +mcp = pytest.importorskip("mcp") + +from mcp import ClientSession, StdioServerParameters, stdio_client # noqa: E402 + +PORT = os.environ.get("CUOPT_TEST_GRPC_PORT") + +pytestmark = pytest.mark.skipif( + not PORT, reason="CUOPT_TEST_GRPC_PORT not set; no live cuopt_grpc_server" +) + +# minimize x + y s.t. x + y >= 10, x <= 8, y <= 8 +# optimum: 10 with x + y == 10 +LP_MPS = textwrap.dedent( + """\ + NAME TESTLP + ROWS + N COST + G LIM1 + COLUMNS + X COST 1.0 LIM1 1.0 + Y COST 1.0 LIM1 1.0 + RHS + RHS LIM1 10.0 + BOUNDS + UP BND X 8.0 + UP BND Y 8.0 + ENDATA + """ +) + + +@pytest.fixture +def mps_file(tmp_path): + path = tmp_path / "testlp.mps" + path.write_text(LP_MPS) + return str(path) + + +@pytest.fixture +async def session(tmp_path): + params = StdioServerParameters( + command="cuopt-mcp", + env={ + **os.environ, + "CUOPT_REMOTE_HOST": "localhost", + "CUOPT_REMOTE_PORT": PORT, + "CUOPT_MCP_SOLUTION_DIR": str(tmp_path / "solutions"), + }, + ) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as client: + await client.initialize() + yield client + + +async def _call(session, tool, /, **args): + """Positional-only params so a tool argument named `name` cannot shadow them.""" + result = await session.call_tool(tool, args) + assert not result.is_error, result.content + return result.structured_content + + +@pytest.mark.anyio +async def test_tools_are_discoverable(session): + listed = await session.list_tools() + names = {t.name for t in listed.tools} + assert {"cuopt_solve_lp", "cuopt_status", "cuopt_result"} <= names + + +@pytest.mark.anyio +async def test_settings_catalogue_reaches_the_client(session): + detail = await _call( + session, + "cuopt_list_settings", + kind="pdlp_settings", + name="pdlp_solver_mode", + ) + assert "Stable3" in detail["enum"] + assert "Default: Stable3" in detail["description"] + + +@pytest.mark.anyio +async def test_solve_lp_end_to_end(session, mps_file): + """Submit, poll, and fetch a named solution through the MCP protocol.""" + submitted = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"time_limit": 30.0}, + ) + assert "error" not in submitted, submitted + job_id = submitted["job_id"] + assert submitted["num_variables"] == 2 + + try: + for _ in range(120): + state = await _call(session, "cuopt_status", job_id=job_id) + if state["terminal"]: + break + await __import__("asyncio").sleep(0.5) + assert state["status"] == "COMPLETED", state + + solved = await _call( + session, "cuopt_result", job_id=job_id, names_from=mps_file + ) + assert solved["ready"] is True + assert solved["primal_objective"] == pytest.approx(10.0, abs=1e-4) + total = sum(solved["variables"].values()) + assert total == pytest.approx(10.0, abs=1e-4) + for name in ("X", "Y"): + assert -1e-4 <= solved["variables"][name] <= 8.0 + 1e-4 + finally: + await _call(session, "cuopt_delete", job_id=job_id) + + +@pytest.mark.anyio +async def test_invalid_setting_is_rejected_before_submission( + session, mps_file +): + """A typo must come back as a named error, not a silently ignored field.""" + out = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"time_limt": 5.0}, + ) + assert "error" in out + assert "did you mean time_limit" in out["error"] + + +@pytest.mark.anyio +async def test_missing_file_reports_cleanly(session): + out = await _call(session, "cuopt_solve_lp", problem_path="/no/such.mps") + assert "problem file not found" in out["error"] + + +@pytest.mark.anyio +async def test_enum_setting_is_accepted_by_name(session, mps_file): + """Regression: settings={"method": "Barrier"} must reach the solver. + + cuOpt's set_parameter takes an integer for enum settings, so passing the + readable name straight through failed with "value Barrier is not an + integer". + """ + out = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"method": "Barrier", "time_limit": 30.0}, + ) + assert "error" not in out, out + assert out["job_id"] + await _call(session, "cuopt_delete", job_id=out["job_id"]) + + +@pytest.mark.anyio +async def test_termination_status_is_readable(session, mps_file): + """Regression: status must be a name, not the IntEnum's bare number.""" + sub = await _call( + session, + "cuopt_solve_lp", + problem_path=mps_file, + settings={"time_limit": 30.0}, + ) + try: + for _ in range(120): + state = await _call(session, "cuopt_status", job_id=sub["job_id"]) + if state["terminal"]: + break + await __import__("asyncio").sleep(0.3) + else: + pytest.fail(f"job did not terminate in time: {state}") + out = await _call(session, "cuopt_result", job_id=sub["job_id"]) + assert out["termination_status"] == "Optimal" + assert out["termination_status_code"] == 1 + finally: + await _call(session, "cuopt_delete", job_id=sub["job_id"]) diff --git a/python/cuopt_mcp/tests/test_parameter_names.py b/python/cuopt_mcp/tests/test_parameter_names.py new file mode 100644 index 0000000000..0eb249fdf2 --- /dev/null +++ b/python/cuopt_mcp/tests/test_parameter_names.py @@ -0,0 +1,82 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Every advertised setting must be one cuOpt actually accepts. + +The registry field name is the *proto* field name, which frequently differs +from the ``CUOPT_*`` string parameter (``relative_mip_gap`` vs +``mip_relative_gap``, ``mir_cuts`` vs ``mip_mixed_integer_rounding_cuts``). +Without this check a renamed or newly added field reaches an agent as a +setting that fails at solve time with "Invalid parameter". + +Parsed from the in-repo constants.h rather than the installed cuOpt, so the +check stays consistent with the registry it is validating even when the +environment has a different cuOpt version installed. +""" + +import re +from pathlib import Path + +import pytest + +from cuopt_mcp import schema + +CONSTANTS_H = ( + Path(__file__).resolve().parents[3] + / "cpp" + / "include" + / "cuopt" + / "mathematical_optimization" + / "constants.h" +) + +pytestmark = pytest.mark.skipif( + not CONSTANTS_H.is_file(), reason="not a source checkout" +) + + +def cuopt_parameter_names() -> set: + text = CONSTANTS_H.read_text() + # #define CUOPT_X \ "x" — join continuations before matching. + text = re.sub(r"\\\s*\n\s*", " ", text) + return { + m.group(2) + for m in re.finditer(r'#define\s+(CUOPT_\w+)\s+"(\w+)"', text) + } + + +@pytest.mark.parametrize("kind", ["pdlp_settings", "mip_settings"]) +def test_every_advertised_setting_is_a_real_cuopt_parameter(kind): + valid = cuopt_parameter_names() + assert valid, "failed to parse any parameter names from constants.h" + bad = {} + for name, prop in schema.settings_schema(kind)["properties"].items(): + resolved = prop.get("x-parameter-name", name) + if resolved not in valid: + bad[name] = resolved + assert not bad, ( + f"{kind} advertises settings cuOpt will reject: {bad}. Add or fix " + "`param_name:` in field_registry.yaml, then ./build.sh codegen." + ) + + +def test_known_divergent_names_are_mapped(): + """Spot-check the renames that motivated param_name.""" + mip = schema.settings_schema("mip_settings")["properties"] + assert mip["relative_mip_gap"]["x-parameter-name"] == "mip_relative_gap" + assert mip["mir_cuts"]["x-parameter-name"] == ( + "mip_mixed_integer_rounding_cuts" + ) + assert mip["seed"]["x-parameter-name"] == "random_seed" + pdlp = schema.settings_schema("pdlp_settings")["properties"] + assert pdlp["detect_infeasibility"]["x-parameter-name"] == ( + "infeasibility_detection" + ) + + +def test_unsettable_field_is_not_advertised(): + """presolve_absolute_tolerance has no CUOPT_* constant, so it is omitted.""" + assert ( + "presolve_absolute_tolerance" + not in schema.settings_schema("mip_settings")["properties"] + ) diff --git a/python/cuopt_mcp/tests/test_schema.py b/python/cuopt_mcp/tests/test_schema.py new file mode 100644 index 0000000000..e0fecd15db --- /dev/null +++ b/python/cuopt_mcp/tests/test_schema.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the generated settings schema and its validation.""" + +import re + +import pytest + +from cuopt_mcp import schema + + +def test_both_settings_sections_present(): + doc = schema.load() + assert set(doc["settings"]) == {"pdlp_settings", "mip_settings"} + + +@pytest.mark.parametrize("kind", ["pdlp_settings", "mip_settings"]) +def test_every_parameter_is_documented(kind): + """Every generated property carries a description. + + Guards the registry: a field added without `description:`/`default:` + would reach an agent as a bare name and type, which is unusable for + deciding whether to set it. + """ + props = schema.settings_schema(kind)["properties"] + assert props + undocumented = [n for n, p in props.items() if not p.get("description")] + assert undocumented == [] + + +@pytest.mark.parametrize("kind", ["pdlp_settings", "mip_settings"]) +def test_every_parameter_states_a_default(kind): + """Every property's description must state a concrete default value. + + `default` isn't a separate JSON Schema key -- _json_schema_property + renders it into the description text only, deliberately, since the + registry's default: is untyped prose (see its docstring). So the + description is the only place a dropped default would show up; check + for "Default: " followed by content, not just the bare label. + """ + props = schema.settings_schema(kind)["properties"] + missing = [ + n + for n, p in props.items() + if not re.search(r"Default: \S", p.get("description", "")) + ] + assert missing == [] + + +def test_enum_parameters_expose_their_values(): + mode = schema.settings_schema("pdlp_settings")["properties"][ + "pdlp_solver_mode" + ] + assert mode["type"] == "string" + assert "Stable3" in mode["enum"] + + +def test_sentinel_field_tells_client_to_omit_not_send_minus_one(): + """iteration_limit's -1 encoding must not leak into agent-facing text. + + The wire encodes "no limit" as -1, but a client should express that by + omitting the field; advertising -1 invites sending it as a literal + iteration count. + """ + prop = schema.settings_schema("pdlp_settings")["properties"][ + "iteration_limit" + ] + assert "Omit" in prop["description"] + assert "-1" not in prop["description"] + + +def test_settings_schema_is_closed(): + for kind in ("pdlp_settings", "mip_settings"): + assert schema.settings_schema(kind)["additionalProperties"] is False + + +def test_validate_accepts_known_settings(): + schema.validate_settings("pdlp_settings", {"time_limit": 5.0}) + schema.validate_settings("pdlp_settings", {"method": "Barrier"}) + + +def test_validate_rejects_unknown_setting_with_suggestion(): + with pytest.raises(ValueError, match="did you mean time_limit"): + schema.validate_settings("pdlp_settings", {"time_limt": 5.0}) + + +def test_validate_rejects_bad_enum_value(): + with pytest.raises(ValueError, match="must be one of"): + schema.validate_settings("pdlp_settings", {"method": "Simplex"}) + + +def test_validate_rejects_wrong_type(): + with pytest.raises(ValueError, match="must be a number"): + schema.validate_settings("pdlp_settings", {"time_limit": "fast"}) + + +def test_enum_parameters_carry_a_name_to_integer_mapping(): + """Enum settings must ship the integer cuOpt's parameter interface wants. + + Callers show a model the readable name ("Barrier") but set_parameter + rejects it with "value Barrier is not an integer", so the schema has to + carry the mapping rather than leave each client to hand-write it. + """ + method = schema.settings_schema("pdlp_settings")["properties"]["method"] + mapping = method["x-enum-values"] + assert set(mapping) == set(method["enum"]) + assert all(isinstance(v, int) for v in mapping.values()) + # Must agree with constants.h (CUOPT_METHOD_CONCURRENT 0, PDLP 1, ...) + assert mapping["Concurrent"] == 0 + assert mapping["PDLP"] == 1 + assert mapping["DualSimplex"] == 2 + assert mapping["Barrier"] == 3 + + +def test_non_enum_parameters_have_no_mapping(): + prop = schema.settings_schema("pdlp_settings")["properties"]["time_limit"] + assert "x-enum-values" not in prop diff --git a/python/cuopt_mcp/tests/test_server.py b/python/cuopt_mcp/tests/test_server.py new file mode 100644 index 0000000000..44ae5713bd --- /dev/null +++ b/python/cuopt_mcp/tests/test_server.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""_guard's error-shaping behavior, isolated from the MCP protocol.""" + +from cuopt_mcp.client import CuOptMCPError +from cuopt_mcp.server import _guard + + +def test_guard_redacts_paths_in_cuoptmcperror(): + def raiser(): + raise CuOptMCPError("/home/alice/.cuopt-mcp/solutions exists") + + out = _guard(raiser) + assert "/home/alice" not in out["error"] + assert "[redacted path]" in out["error"] + + +def test_guard_redacts_paths_in_value_error(): + def raiser(): + raise ValueError("bad value for /etc/cuopt/secret.conf") + + out = _guard(raiser) + assert "/etc/cuopt" not in out["error"] + + +def test_guard_passes_through_on_success(): + assert _guard(lambda: {"ok": True}) == {"ok": True} + + +def test_guard_reduces_unexpected_exceptions_to_a_generic_message(): + """Not CuOptMCPError/ValueError, so its raw text was never vetted as + safe -- found via a real ImportError escaping to an MCP caller during + live testing. + """ + + def raiser(): + raise ImportError("/some/internal/path: undefined symbol: secret_abi") + + out = _guard(raiser) + assert "/some/internal/path" not in out["error"] + assert "secret_abi" not in out["error"] + assert out["error"] == "internal error -- see server logs" diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py new file mode 100644 index 0000000000..1d0498836d --- /dev/null +++ b/python/cuopt_mcp/tests/test_tools.py @@ -0,0 +1,356 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tool behaviour 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. +""" + +import json + +import pytest + +from cuopt_mcp import client, tools + +# 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 +# solution-file fallback, delete()). +UUID1 = "11111111-1111-1111-1111-111111111111" +UUID2 = "22222222-2222-2222-2222-222222222222" + + +class FakeSolution: + def __init__(self, values, names=None): + self._values = values + self._names = names + + def get_primal_solution(self): + return self._values + + def get_vars(self): + return dict(zip(self._names, self._values)) if self._names else {} + + def get_primal_objective(self): + return 42.0 + + def get_solve_time(self): + return 1.5 + + def get_termination_status(self): + import enum + + class LPTerminationStatus(enum.IntEnum): + Optimal = 1 + + return LPTerminationStatus.Optimal + + def get_termination_reason(self): + return "Optimal solution found" + + +class FakeClient: + def __init__(self, solution=None, not_ready_logs=False): + self.solution = solution + self.cancelled = [] + self.deleted = [] + self.submitted = [] + self.not_ready_logs = not_ready_logs + + def submit(self, problem, settings, enable_incumbents=None): + self.submitted.append(enable_incumbents) + return "job-new" + + def result(self, job_id, variable_names=None): + return self.solution + + def cancel(self, job_id): + self.cancelled.append(job_id) + + def delete(self, job_id): + self.deleted.append(job_id) + + def incumbents(self, job_id, from_index=0): + # Real shape: list of {"index", "objective", "assignment"} dicts, + # not (objective, assignment) tuples -- see tools.incumbents. + entries = [ + {"index": 0, "objective": 10.0, "assignment": []}, + {"index": 1, "objective": 8.0, "assignment": []}, + ] + return [e for e in entries if e["index"] >= from_index] + + def logs(self, job_id, from_byte=0): + if self.not_ready_logs: + # tools.logs matches by class name, not isinstance -- see there. + class JobNotReadyError(Exception): + pass + + raise JobNotReadyError(f"job {job_id} is not complete (QUEUED)") + # Real shape: list[str], not a joined string -- see tools.logs. + return [f"line {i}" for i in range(10)] + + +@pytest.fixture +def fake(monkeypatch): + def _install(solution=None, not_ready_logs=False): + stub = FakeClient(solution, not_ready_logs=not_ready_logs) + monkeypatch.setattr(tools, "get_client", lambda: stub) + return stub + + yield _install + client.reset_client() + + +def test_result_reports_not_ready_without_raising(fake): + fake(None) + out = tools.result("job-1") + assert out["ready"] is False + assert "cuopt_status" in out["hint"] + + +def test_result_returns_summary_and_named_values(fake): + fake(FakeSolution([1.0, 0.0, 3.0], names=["x", "y", "z"])) + out = tools.result("job-1") + assert out["primal_objective"] == 42.0 + # IntEnum: str() would give "1", which tells a caller nothing. + assert out["termination_status"] == "Optimal" + assert out["termination_status_code"] == 1 + assert out["variables"] == {"x": 1.0, "y": 0.0, "z": 3.0} + + +def test_result_nonzero_only_filters(fake): + fake(FakeSolution([1.0, 0.0, 3.0], names=["x", "y", "z"])) + out = tools.result("job-1", nonzero_only=True) + assert out["variables"] == {"x": 1.0, "z": 3.0} + assert out["num_nonzero"] == 2 + + +def test_result_named_lookup_reports_missing(fake): + fake(FakeSolution([1.0, 2.0], names=["x", "y"])) + out = tools.result("job-1", variables=["x", "nope"]) + assert out["variables"] == {"x": 1.0} + assert out["missing_variables"] == ["nope"] + + +def test_result_empty_variables_list_returns_no_variables(fake): + """variables=[] must mean "return none", distinct from omitted.""" + fake(FakeSolution([1.0, 2.0], names=["x", "y"])) + out = tools.result("job-1", variables=[]) + assert out["variables"] == {} + assert "missing_variables" not in out + + +def test_large_solution_is_written_to_file_not_inlined( + fake, tmp_path, monkeypatch +): + """A big solution must not be returned inline.""" + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) + n = 5000 + fake( + FakeSolution( + [float(i) for i in range(n)], names=[f"x{i}" for i in range(n)] + ) + ) + out = tools.result(UUID1, limit=10) + assert out["variables_truncated"] is True + assert len(out["variables"]) == 10 + assert out["num_variables"] == n + written = tmp_path / f"{UUID1}.json" + assert written.is_file() + assert len(json.loads(written.read_text())) == n + + +def test_nonzero_only_solution_file_is_also_filtered( + fake, tmp_path, monkeypatch +): + """nonzero_only must narrow the on-disk file too, not just inline.""" + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) + n = 5000 + values = [0.0] * n + for i in range(0, n, 100): + values[i] = 1.0 + fake(FakeSolution(values, names=[f"x{i}" for i in range(n)])) + out = tools.result(UUID2, nonzero_only=True, limit=10) + assert out["num_nonzero"] == n // 100 + written = json.loads((tmp_path / f"{UUID2}.json").read_text()) + assert len(written) == n // 100 + assert all(v != 0 for v in written.values()) + + +def test_result_rejects_solution_dir_that_is_a_file( + fake, tmp_path, monkeypatch +): + """A pre-existing non-directory at CUOPT_MCP_SOLUTION_DIR is rejected.""" + stray_file = tmp_path / "not-a-dir" + stray_file.write_text("") + stray_file.chmod(0o600) + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(stray_file)) + fake( + FakeSolution( + [float(i) for i in range(5000)], + names=[f"x{i}" for i in range(5000)], + ) + ) + with pytest.raises(client.CuOptMCPError, match="not a private directory"): + tools.result(UUID1, limit=10) + + +def test_unnamed_solution_falls_back_to_indices_with_a_hint(fake): + fake(FakeSolution([1.0, 2.0])) + out = tools.result("job-1") + assert out["variables"] == {"0": 1.0, "1": 2.0} + assert "names_from" in out["names"] + + +def test_incumbents_paginate(fake): + fake() + out = tools.incumbents("job-1", from_index=1) + assert out["count"] == 1 + assert out["incumbents"][0]["index"] == 1 + assert out["next_index"] == 2 + + +@pytest.mark.parametrize("bad_index", [-1, True]) +def test_incumbents_rejects_bad_from_index(fake, bad_index): + fake() + with pytest.raises(client.CuOptMCPError, match="from_index"): + tools.incumbents("job-1", from_index=bad_index) + + +def test_logs_tail_is_bounded(fake): + fake() + out = tools.logs("job-1", tail_lines=3) + assert out["ready"] is True + assert out["lines"] == ["line 7", "line 8", "line 9"] + assert out["truncated"] is True + assert out["next_byte"] > 0 + + +@pytest.mark.parametrize("bad_byte", [-1, True]) +def test_logs_rejects_bad_from_byte(fake, bad_byte): + fake() + with pytest.raises(client.CuOptMCPError, match="from_byte"): + tools.logs("job-1", from_byte=bad_byte) + + +def test_logs_reports_not_ready_without_raising(fake): + fake(not_ready_logs=True) + out = tools.logs("job-1") + assert out["ready"] is False + assert "cuopt_status" in out["hint"] + + +def test_cancel(fake): + stub = fake() + assert tools.cancel("job-1")["cancelled"] is True + assert stub.cancelled == ["job-1"] + + +def test_delete(fake): + stub = fake() + assert tools.delete(UUID1)["deleted"] is True + assert stub.deleted == [UUID1] + + +def test_delete_rejects_a_non_uuid_job_id(fake): + """job_id becomes a filename in delete()/result(); must reject + anything that isn't a server-issued UUID before touching the + filesystem, or a value like "../../etc/x" could write/unlink outside + the solution directory. + """ + fake() + with pytest.raises(client.CuOptMCPError, match="not a valid job_id"): + tools.delete("../../etc/cron.d/x") + + +def test_delete_removes_local_solution_file(fake, tmp_path, monkeypatch): + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) + fake() + solution_file = tmp_path / f"{UUID1}.json" + solution_file.write_text("{}") + tools.delete(UUID1) + assert not solution_file.exists() + + +def test_delete_with_no_solution_file_still_succeeds( + fake, tmp_path, monkeypatch +): + monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) + fake() + assert tools.delete(UUID1)["deleted"] is True + + +class FakeModel: + def get_variable_lower_bounds(self): + return [0.0, 0.0] + + def get_constraint_matrix_offsets(self): + return [0, 1] + + +def test_submit_track_incumbents_is_mip_only_and_opt_in( + fake, monkeypatch, tmp_path +): + """track_incumbents only takes effect for MIP, and defaults off.""" + stub = fake() + monkeypatch.setattr(tools, "_read_problem", lambda path: FakeModel()) + monkeypatch.setattr( + tools, "_build_settings", lambda kind, settings: object() + ) + 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) + assert stub.submitted == [False, True, False] + + +def test_build_settings_rejects_bad_kind(): + """Bad kind must be CuOptMCPError, not a raw KeyError past _guard.""" + with pytest.raises(client.CuOptMCPError, match="mip_settings"): + tools._build_settings("nonsense", None) + + +def test_build_settings_wraps_validate_settings_value_error(): + """A direct caller relies on the documented CuOptMCPError contract.""" + with pytest.raises(client.CuOptMCPError, match="unknown"): + tools._build_settings("pdlp_settings", {"time_limt": 5.0}) + + +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") + + +def test_list_settings_names_and_detail(): + listing = tools.list_settings("pdlp_settings") + assert "time_limit" in listing["parameters"] + detail = tools.list_settings("pdlp_settings", name="pdlp_solver_mode") + assert "Stable3" in detail["enum"] + + +def test_list_settings_rejects_bad_kind(): + with pytest.raises(client.CuOptMCPError, match="mip_settings"): + tools.list_settings("nonsense") + + +def test_unreachable_server_message_names_the_endpoint(monkeypatch): + monkeypatch.setenv("CUOPT_REMOTE_HOST", "gpu-host") + monkeypatch.setenv("CUOPT_REMOTE_PORT", "50999") + err = client.describe_connection_error(RuntimeError("UNAVAILABLE")) + assert "gpu-host:50999" in str(err) + + +@pytest.mark.parametrize("bad_port", ["0", "-1", "65536", "999999"]) +def test_endpoint_rejects_out_of_range_port(monkeypatch, bad_port): + monkeypatch.setenv("CUOPT_REMOTE_PORT", bad_port) + with pytest.raises(client.CuOptMCPError, match="between 1 and 65535"): + client.endpoint() + + +def test_default_port_matches_cuopt_grpc_server(monkeypatch): + """Must match cuopt_default_grpc_port, not the unrelated 50051 used by + routing-client examples. + """ + monkeypatch.delenv("CUOPT_REMOTE_PORT", raising=False) + assert client.endpoint()[1] == 5001