From 3ec901a12281f1420a78d36451589763cb800614 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 12 Aug 2026 11:05:32 -0500 Subject: [PATCH 01/21] Add cuopt_mcp: an MCP server for LP/MILP solving over gRPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes cuOpt to MCP clients (Claude Code, Cursor, Codex) as a stdio subprocess holding a gRPC channel to cuopt_grpc_server. The agent host needs no GPU and no HTTP is involved. Solves are asynchronous — submitting returns a job_id and nothing blocks, because a blocking tools/call exceeds the client timeout on any realistic MILP and makes cancellation impossible. Results are shaped rather than streamed: 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 and writes large solutions to a file. The settings catalogue is generated. generate_conversions.py emits cuopt_mcp_schema.json from field_registry.yaml as a 29th artifact, so a new solver parameter reaches the server in the same commit as the proto with no MCP-specific work, guarded by ci/verify_grpc_codegen.sh. Two registry attributes were needed to make that schema usable: * param_name — the proto field name is frequently not the CUOPT_* string parameter (relative_mip_gap vs mip_relative_gap, mir_cuts vs mip_mixed_integer_rounding_cuts). 44 fields diverge; without the mapping every such setting failed at solve time with "Invalid parameter". presolve_absolute_tolerance has no constant at all and is marked null so it is not advertised. * x-enum-values — set_parameter takes the integer for an enum setting, but an agent needs the name, so the schema carries both. test_parameter_names.py asserts every advertised setting exists in constants.h, parsed from the in-repo header so the check matches the registry rather than whatever cuOpt happens to be installed. It already caught a missed mapping on the second presolver entry. Verified end to end against a live cuopt_grpc_server on a GPU: an LP and a knapsack MILP submitted, polled, and retrieved with named solutions through the real MCP protocol over stdio. Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 63 ++- cpp/src/grpc/codegen/generate_conversions.py | 138 ++++++ .../codegen/generated/cuopt_mcp_schema.json | 428 ++++++++++++++++++ .../codegen/generated/cuopt_remote_data.proto | 7 +- python/cuopt_mcp/LICENSE | 201 ++++++++ python/cuopt_mcp/README.md | 74 +++ python/cuopt_mcp/cuopt_mcp/VERSION | 1 + python/cuopt_mcp/cuopt_mcp/__init__.py | 13 + python/cuopt_mcp/cuopt_mcp/client.py | 86 ++++ python/cuopt_mcp/cuopt_mcp/schema.py | 97 ++++ python/cuopt_mcp/cuopt_mcp/server.py | 185 ++++++++ python/cuopt_mcp/cuopt_mcp/tools.py | 262 +++++++++++ python/cuopt_mcp/pyproject.toml | 56 +++ python/cuopt_mcp/setup.py | 46 ++ python/cuopt_mcp/tests/conftest.py | 14 + python/cuopt_mcp/tests/test_end_to_end.py | 183 ++++++++ .../cuopt_mcp/tests/test_parameter_names.py | 82 ++++ python/cuopt_mcp/tests/test_schema.py | 108 +++++ python/cuopt_mcp/tests/test_tools.py | 179 ++++++++ 19 files changed, 2216 insertions(+), 7 deletions(-) create mode 100644 cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json create mode 100644 python/cuopt_mcp/LICENSE create mode 100644 python/cuopt_mcp/README.md create mode 100644 python/cuopt_mcp/cuopt_mcp/VERSION create mode 100644 python/cuopt_mcp/cuopt_mcp/__init__.py create mode 100644 python/cuopt_mcp/cuopt_mcp/client.py create mode 100644 python/cuopt_mcp/cuopt_mcp/schema.py create mode 100644 python/cuopt_mcp/cuopt_mcp/server.py create mode 100644 python/cuopt_mcp/cuopt_mcp/tools.py create mode 100644 python/cuopt_mcp/pyproject.toml create mode 100644 python/cuopt_mcp/setup.py create mode 100644 python/cuopt_mcp/tests/conftest.py create mode 100644 python/cuopt_mcp/tests/test_end_to_end.py create mode 100644 python/cuopt_mcp/tests/test_parameter_names.py create mode 100644 python/cuopt_mcp/tests/test_schema.py create mode 100644 python/cuopt_mcp/tests/test_tools.py diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 718b79a052..926ffbd5f5 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -53,6 +53,17 @@ # non-optional field, that this string textually # matches the proto3 zero value (see `optional` # above). +# 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 +556,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 +573,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 +615,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 +824,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 +834,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 +843,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 +861,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 +869,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 +889,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 +913,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 +931,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 +960,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, the first limit reached wins. default: "no limit (INT_MAX)" field_num: 15 type: int32 @@ -946,6 +969,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 +996,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 +1012,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 +1021,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 +1030,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 +1039,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 +1047,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 +1055,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 +1063,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 +1072,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 +1081,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 +1091,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 +1100,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 +1111,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 +1121,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 +1132,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 +1153,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 +1176,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 +1193,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 +1233,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 +1255,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 +1266,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 +1275,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 +1297,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..ba14ca09bf 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 @@ -374,6 +375,12 @@ def _field_doc_comment(f, indent=" "): description = f.get("description") default = f.get("default") words = str(description).split() if description is not None else [] + if f.get("sentinel") == "max_as_negative_1": + # Rendered from `sentinel:` rather than written into `description:` + # so each consumer can phrase it correctly: the proto documents the + # wire encoding, while the MCP schema tells a client to omit the + # field instead of sending the reserved value. + words += "Wire encoding: -1 means no limit.".split() if default is not None: words += f"(default: {default})".split() if not words: @@ -393,6 +400,131 @@ def _field_doc_comment(f, indent=" "): return lines +_JSON_SCHEMA_TYPES = { + "double": "number", + "float": "number", + "int32": "integer", + "int64": "integer", + "uint32": "integer", + "uint64": "integer", + "bool": "boolean", + "string": "string", +} + + +def _json_schema_property(registry, f): + """Render one settings field as a JSON Schema property. + + 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 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") + if default is not None: + # 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): + """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. + """ + 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" + + # ============================================================================ # Enum helpers — convention-based derivation # ============================================================================ @@ -3964,6 +4096,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..7218ad9140 --- /dev/null +++ b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json @@ -0,0 +1,428 @@ +{ + "$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. 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_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": "MIP absolute tolerance. Default: 1e-6.", + "x-parameter-name": "mip_absolute_tolerance" + }, + "relative_tolerance": { + "type": "number", + "description": "MIP relative 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, 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": "Whether strong branching at the root evaluates candidates with a single batched PDLP solve instead of solving them in parallel with dual simplex, which can cut strong-branching time when dual simplex struggles. 0 disabled, 1 enabled. Default: 0 (disabled)." + }, + "mip_batch_pdlp_reliability_branching": { + "type": "integer", + "description": "Whether reliability-branching candidates are evaluated simultaneously with a single batched PDLP solve. 0 disabled, 1 enabled. Default: 0 (disabled)." + }, + "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. Probing is also skipped in deterministic mode and on LP-only solves. 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 local search, 8 sub-MIP. 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..50df3829b7 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -201,8 +201,7 @@ message PDLPSolverSettings { // 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)) + // wins. Wire encoding: -1 means 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) @@ -371,8 +370,8 @@ message MIPSolverSettings { 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)) + // with time_limit, the first limit reached wins. Wire encoding: -1 means 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 diff --git a/python/cuopt_mcp/LICENSE b/python/cuopt_mcp/LICENSE new file mode 100644 index 0000000000..6ed9218d99 --- /dev/null +++ b/python/cuopt_mcp/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 NVIDIA Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md new file mode 100644 index 0000000000..f8b42507bc --- /dev/null +++ b/python/cuopt_mcp/README.md @@ -0,0 +1,74 @@ +# 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. + +``` +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 is involved. + +## Install + +```bash +pip install cuopt_mcp +``` + +## Configure + +Start the solver backend on a GPU host: + +```bash +cuopt_grpc_server --port 50051 +``` + +Then register the MCP server with your client: + +```json +{ + "mcpServers": { + "cuopt": { + "command": "cuopt-mcp", + "env": { "CUOPT_REMOTE_HOST": "gpu-host", "CUOPT_REMOTE_PORT": "50051" } + } + } +} +``` + +Configuration reuses the environment the cuOpt gRPC client already honours — +`CUOPT_REMOTE_HOST`, `CUOPT_REMOTE_PORT`, and `CUOPT_TLS_*`. + +## 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 | +| `cuopt_logs` | Recent solver log lines | +| `cuopt_cancel` | Stop a running job | +| `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..cadf4ee8c6 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/__init__.py @@ -0,0 +1,13 @@ +# 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(): + """Console-script entry point (``cuopt-mcp``).""" + 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..cf9724e074 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -0,0 +1,86 @@ +# 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 threading + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 50051 + +_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. + """ + host = os.environ.get("CUOPT_REMOTE_HOST", DEFAULT_HOST) + port = int(os.environ.get("CUOPT_REMOTE_PORT", DEFAULT_PORT)) + 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(): + """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. + """ + 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: + 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(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..63867fa7db --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/schema.py @@ -0,0 +1,97 @@ +# 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: + 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.""" + return json.loads(schema_path().read_text()) + + +def settings_schema(kind: str) -> dict: + """Return the JSON Schema for ``pdlp_settings`` or ``mip_settings``.""" + return load()["settings"][kind] + + +def known_parameters(kind: str) -> set: + 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. + """ + 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 not isinstance(value, int): + raise ValueError(f"{name} must be an integer, got {value!r}") + elif expected == "number" and 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..9c51f5fcc7 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -0,0 +1,185 @@ +# 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 is involved 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 + +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]: + try: + return fn(**kwargs) + except CuOptMCPError as exc: + return {"error": str(exc)} + except ValueError as exc: + return {"error": str(exc)} + + +@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 +) -> dict[str, Any]: + """Submit a mixed-integer program to cuOpt and return a job handle. + + problem_path: path to an MPS file containing integer variables. + 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. + + Returns a job_id. Use cuopt_incumbents to watch the objective improve + and cuopt_cancel to stop early once it is good enough. + """ + return _guard( + tools.submit, + problem_path=problem_path, + kind="mip_settings", + settings=settings, + ) + + +@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. + """ + 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 full solution is + written to a file and its path returned instead. + """ + 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. + + 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. + """ + 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 recent solver log lines for a job, for diagnosing a slow solve.""" + 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.""" + return _guard(tools.cancel, 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. + """ + return _guard(tools.list_settings, kind=kind, name=name) + + +def main() -> None: + host, port = __import__( + "cuopt_mcp.client", fromlist=["endpoint"] + ).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..ef62569836 --- /dev/null +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -0,0 +1,262 @@ +# 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 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, not the +# transport: ~200 values is already a large tool result, and cuOpt problems +# routinely have millions. +INLINE_SOLUTION_LIMIT = 200 + + +def _solution_dir() -> Path: + path = Path( + os.environ.get( + "CUOPT_MCP_SOLUTION_DIR", Path(tempfile.gettempdir()) / "cuopt-mcp" + ) + ) + path.mkdir(parents=True, exist_ok=True) + 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): + from cuopt.linear_programming import SolverSettings + + validate_settings(kind, settings or {}) + properties = settings_schema(kind)["properties"] + solver_settings = SolverSettings() + for name, value in (settings or {}).items(): + # Enum settings are exposed to callers by name ("Barrier") because a + # bare integer is meaningless to an agent, but cuOpt's string + # parameter interface takes the integer. The mapping is generated + # from the field registry alongside the enum itself. + 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) -> dict: + """Parse a problem file and submit it; return the job handle.""" + model = _read_problem(problem_path) + solver_settings = _build_settings(kind, settings) + try: + job_id = get_client().submit(model, solver_settings) + except Exception as exc: + raise describe_connection_error(exc) from exc + + # DataModel exposes no public size accessors, so derive both from the + # arrays it does expose: one lower bound per column, and CSR row offsets + # numbering rows + 1. + offsets = model.get_constraint_matrix_offsets() + return { + "job_id": job_id, + "source": str(Path(problem_path).expanduser()), + "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: + 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"), + } + + +def _write_solution_file(job_id: str, vars_by_name: dict) -> str: + path = _solution_dir() / f"{job_id}.json" + 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.""" + 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() + # The status is an IntEnum, so str() would yield the bare number ("1"). + # get_termination_reason() is its .name, which is what a caller can act + # on. Note LPTerminationStatus numbers Optimal=1 while the wire enum + # pdlp_termination_status numbers it 2 — 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: + 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, vars_by_name) + summary["hint"] = ( + f"{len(selected)} values exceed the inline limit of {limit}. The " + "full solution is at solution_path; use variables=[...] or " + "nonzero_only=true to narrow the result." + ) + return summary + + +def cancel(job_id: str) -> dict: + 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 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. + """ + try: + found = get_client().incumbents(job_id, from_index) + except Exception as exc: + raise describe_connection_error(exc) from exc + objectives = [ + {"index": from_index + i, "objective": float(obj)} + for i, (obj, _) in enumerate(found or []) + ] + return { + "job_id": job_id, + "count": len(objectives), + "next_index": from_index + len(objectives), + "incumbents": objectives, + } + + +def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: + try: + text = get_client().logs(job_id, from_byte) + except Exception as exc: + raise describe_connection_error(exc) from exc + lines = (text or "").splitlines() + truncated = len(lines) > tail_lines + return { + "job_id": job_id, + "truncated": truncated, + "lines": lines[-tail_lines:], + "next_byte": from_byte + len(text or ""), + } + + +def list_settings(kind: str, name: str | None = None) -> dict: + """Describe available solver settings, from the generated schema.""" + 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..8189880164 --- /dev/null +++ b/python/cuopt_mcp/pyproject.toml @@ -0,0 +1,56 @@ +# 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", +] +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 = [ + "mcp>=2.0", +] +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<9.0", +] + +[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..e865506309 --- /dev/null +++ b/python/cuopt_mcp/tests/test_end_to_end.py @@ -0,0 +1,183 @@ +# 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 + + 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) + + +@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"] + + +@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}, + ) + while not (await _call(session, "cuopt_status", job_id=sub["job_id"]))[ + "terminal" + ]: + await __import__("asyncio").sleep(0.3) + out = await _call(session, "cuopt_result", job_id=sub["job_id"]) + assert out["termination_status"] == "Optimal" + assert out["termination_status_code"] == 1 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..0e53b02a82 --- /dev/null +++ b/python/cuopt_mcp/tests/test_schema.py @@ -0,0 +1,108 @@ +# 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 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): + props = schema.settings_schema(kind)["properties"] + missing = [ + n + for n, p in props.items() + if "Default:" not in 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_tools.py b/python/cuopt_mcp/tests/test_tools.py new file mode 100644 index 0000000000..3e0c722252 --- /dev/null +++ b/python/cuopt_mcp/tests/test_tools.py @@ -0,0 +1,179 @@ +# 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 pytest + +from cuopt_mcp import client, tools + + +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): + self.solution = solution + self.cancelled = [] + + def result(self, job_id, variable_names=None): + return self.solution + + def cancel(self, job_id): + self.cancelled.append(job_id) + + def incumbents(self, job_id, from_index=0): + return [(10.0, None), (8.0, None)][from_index:] + + def logs(self, job_id, from_byte=0): + return "\n".join(f"line {i}" for i in range(10)) + + +@pytest.fixture +def fake(monkeypatch): + def _install(solution=None): + stub = FakeClient(solution) + 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_large_solution_is_written_to_file_not_inlined( + fake, tmp_path, monkeypatch +): + """A big solution must not be returned inline. + + The binding limit is the model's context window, so past `limit` the + values go to a file and only a pointer comes back. + """ + 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("job-big", limit=10) + assert out["variables_truncated"] is True + assert len(out["variables"]) == 10 + assert out["num_variables"] == n + written = tmp_path / "job-big.json" + assert written.is_file() + import json + + assert len(json.loads(written.read_text())) == n + + +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 + + +def test_logs_tail_is_bounded(fake): + fake() + out = tools.logs("job-1", tail_lines=3) + assert out["lines"] == ["line 7", "line 8", "line 9"] + assert out["truncated"] is True + + +def test_cancel(fake): + stub = fake() + assert tools.cancel("job-1")["cancelled"] is True + assert stub.cancelled == ["job-1"] + + +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) From 012ab1667c41740d6d608bd1e852bbf3180aad6c Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 12 Aug 2026 12:21:34 -0500 Subject: [PATCH 02/21] Drop proto comments; the MCP schema is the real consumer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description/default attributes were rendered into cuopt_remote_data.proto because they needed some consumer to exist at all — an attribute nothing emits is not covered by verify_grpc_codegen.sh and can rot unnoticed. cuopt_mcp_schema.json is now that consumer, and a better one: it is read by every MCP client rather than by a hypothetical third-party proto reader, and it is equally covered by verify_grpc_codegen.sh. The proto rendering no longer earns its 234 lines of churn on every prose edit, so it goes, along with the now-unused comment-wrapping helper. cuopt_remote_data.proto is byte-identical to main again. Also symlink python/cuopt_mcp/LICENSE to the repo root rather than copying it, matching cuopt_self_hosted and cuopt_server. Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 17 +- cpp/src/grpc/codegen/generate_conversions.py | 52 +---- python/cuopt_mcp/LICENSE | 202 +------------------ 3 files changed, 11 insertions(+), 260 deletions(-) mode change 100644 => 120000 python/cuopt_mcp/LICENSE diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 926ffbd5f5..b19f172d21 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -38,21 +38,22 @@ # 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 — diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index ba14ca09bf..7713f240e1 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -352,54 +352,6 @@ def _array_wire_type_comment(f): return f"raw bytes ({size} B/elem)" -_DOC_COMMENT_WIDTH = 78 - - -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. - - `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. - """ - description = f.get("description") - default = f.get("default") - words = str(description).split() if description is not None else [] - if f.get("sentinel") == "max_as_negative_1": - # Rendered from `sentinel:` rather than written into `description:` - # so each consumer can phrase it correctly: the proto documents the - # wire encoding, while the MCP schema tells a client to omit the - # field instead of sending the reserved value. - words += "Wire encoding: -1 means no limit.".split() - 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 - - _JSON_SCHEMA_TYPES = { "double": "number", "float": "number", @@ -1461,9 +1413,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) diff --git a/python/cuopt_mcp/LICENSE b/python/cuopt_mcp/LICENSE deleted file mode 100644 index 6ed9218d99..0000000000 --- a/python/cuopt_mcp/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2025 NVIDIA Corporation - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. 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 From dbecc1c047bcab94139788ee0338bbd9895f9fe5 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 12 Aug 2026 14:44:17 -0500 Subject: [PATCH 03/21] Declare cuopt_mcp dependencies in dependencies.yaml cuopt_mcp imports cuopt lazily so the MCP handshake stays fast, which meant the dependency was undeclared and pip install succeeded while the first tool call failed. Adds run_cuopt_mcp plus build/run/test file entries so rapids-dependency-file-generator emits the CUDA-suffixed cuopt requirement into pyproject.toml. Signed-off-by: Ramakrishna Prabhu --- dependencies.yaml | 36 +++++++++++++++++++++++++++++++++ python/cuopt_mcp/pyproject.toml | 10 ++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/dependencies.yaml b/dependencies.yaml index 1995027c58..c366b0d9a1 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -243,6 +243,29 @@ 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/ + 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 +433,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/pyproject.toml b/python/cuopt_mcp/pyproject.toml index 8189880164..b6cec104b7 100644 --- a/python/cuopt_mcp/pyproject.toml +++ b/python/cuopt_mcp/pyproject.toml @@ -5,7 +5,7 @@ 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] @@ -20,8 +20,9 @@ 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", @@ -33,8 +34,11 @@ classifiers = [ [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" From 0623ee436e3bb0d6dcaefd636da081c447e84d05 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 16 Sep 2026 13:23:08 -0500 Subject: [PATCH 04/21] fix(mcp): fix enum unpack in MCP schema gen _json_schema_property still unpacked parse_enum_values() as 2-tuples while every other call site already expects the 3-tuple (name, num, attrs) parse_enum_entry returns. Fixed to match; ci/verify_grpc_codegen.sh now passes. Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/generate_conversions.py | 2 +- cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index 7713f240e1..e83a8383ca 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -380,7 +380,7 @@ def _json_schema_property(registry, f): prefix = edef.get("proto_prefix", "") named = { _proto_enum_value_name(cpp_name, prefix): num - for cpp_name, num in parse_enum_values(edef["values"]) + for cpp_name, num, _attrs in parse_enum_values(edef["values"]) } prop["type"] = "string" prop["enum"] = list(named) diff --git a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json index 7218ad9140..897a751b78 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json +++ b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json @@ -147,6 +147,9 @@ "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" + }, "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." @@ -196,12 +199,12 @@ }, "absolute_tolerance": { "type": "number", - "description": "MIP absolute tolerance. Default: 1e-6.", + "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": "MIP relative tolerance. Default: 1e-12.", + "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": { From 160332bc63411be53bed8b9e801d45179203707e Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 16 Sep 2026 13:15:06 -0500 Subject: [PATCH 05/21] fix(mcp): address security/correctness findings on client, tools, schema - client.py: redact filesystem paths from backend error text before returning it to an MCP caller. The gRPC client only ever surfaces a plain message string (no structured status code survives the C++ -> Cython -> here hop), so a caller-facing parse/solve error and a server-internal one (e.g. "Failed to open log file: ", a real, reachable message in grpc_service_impl.cpp) can't be told apart by type -- redacting paths keeps the rest of the message useful without leaking server-side directory layout. - tools.py: _solution_dir() now creates 0700 and rejects reusing a pre-existing directory that isn't already private to this user, closing a local-multiuser read/tamper path (CWE-377) and a pre-create race. result()'s limit and logs()'s new tail_lines bound both reject out-of-range/bool/non-int values before touching the backend, closing an inline-everything DoS path in each (CWE-400). - schema.py: validate_settings() now rejects bool for integer/number settings -- Python's bool is an int subclass, so True previously passed isinstance(value, int) and reached SolverSettings.set_parameter untyped. Also adds the type hints and docstrings (Args/Returns/Raises) these functions were missing, since the fixes above touch their signatures and contracts directly. Verified all three behaviors (redaction, insecure-dir rejection, bool rejection) and the two new bounds directly; full pre-commit and the non-GPU test subset pass. Signed-off-by: Ramakrishna Prabhu --- python/cuopt_mcp/cuopt_mcp/client.py | 39 ++++++- python/cuopt_mcp/cuopt_mcp/schema.py | 66 ++++++++++- python/cuopt_mcp/cuopt_mcp/tools.py | 168 ++++++++++++++++++++++++++- 3 files changed, 263 insertions(+), 10 deletions(-) diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index cf9724e074..c6abd8b690 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -10,11 +10,24 @@ """ import os +import re import threading +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cuopt.grpc.linear_programming import Client DEFAULT_HOST = "localhost" DEFAULT_PORT = 50051 +# Redacts absolute filesystem paths (2+ segments) out of backend error text. +# The gRPC client only ever gets a plain message string -- no structured +# status code survives the C++ -> Cython -> here hop -- so a caller-facing +# parse/solve error and a server-internal one (e.g. "Failed to open log +# file: ") can't be told apart by type. Redacting paths keeps the rest +# of the message useful without leaking server-side directory layout. +_PATH_RE = re.compile(r"(? "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: @@ -75,6 +94,22 @@ class CuOptMCPError(RuntimeError): 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 with filesystem + paths redacted (see ``_PATH_RE``): the server's own error strings + sometimes embed its internal paths (e.g. a job's log file location), + which a remote MCP caller has no use for and shouldn't see. + + 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(): @@ -83,4 +118,4 @@ def describe_connection_error(exc: Exception) -> CuOptMCPError: f"`cuopt_grpc_server --port {port}`, or set CUOPT_REMOTE_HOST / " "CUOPT_REMOTE_PORT to point at a running server." ) - return CuOptMCPError(text) + return CuOptMCPError(_PATH_RE.sub("[redacted path]", text)) diff --git a/python/cuopt_mcp/cuopt_mcp/schema.py b/python/cuopt_mcp/cuopt_mcp/schema.py index 63867fa7db..59052ac306 100644 --- a/python/cuopt_mcp/cuopt_mcp/schema.py +++ b/python/cuopt_mcp/cuopt_mcp/schema.py @@ -31,6 +31,18 @@ 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 @@ -42,16 +54,48 @@ def schema_path() -> Path: @functools.lru_cache(maxsize=1) def load() -> dict: - """Return the full generated schema document.""" + """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 for ``pdlp_settings`` or ``mip_settings``.""" + """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"]) @@ -61,6 +105,16 @@ def validate_settings(kind: str, settings: dict) -> None: 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 @@ -89,9 +143,13 @@ def validate_settings(kind: str, settings: dict) -> None: raise ValueError( f"{name} must be one of {prop['enum']}, got {value!r}" ) - elif expected == "integer" and not isinstance(value, int): + 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 not isinstance(value, (int, float)): + 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/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index ef62569836..557ccefb60 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -16,6 +16,7 @@ import json import os +import stat import tempfile from pathlib import Path @@ -28,14 +29,41 @@ # routinely have millions. INLINE_SOLUTION_LIMIT = 200 +# Upper bound on logs()'s tail_lines. Same context-window reasoning as +# INLINE_SOLUTION_LIMIT -- also caps how much of a job's log an unbounded or +# negative tail_lines could otherwise inline into a single tool result. +MAX_TAIL_LINES = 2000 + 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 a symlink another local user planted 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). + """ path = Path( os.environ.get( "CUOPT_MCP_SOLUTION_DIR", Path(tempfile.gettempdir()) / "cuopt-mcp" ) ) - path.mkdir(parents=True, exist_ok=True) + try: + path.mkdir(parents=True, mode=0o700) + except FileExistsError: + st = path.stat() + if st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) & 0o077: + raise CuOptMCPError( + f"{path} exists but is not private to this user (mode " + f"{oct(stat.S_IMODE(st.st_mode))}, owner 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 @@ -82,7 +110,25 @@ def _variable_names(names_from: str | None): def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: - """Parse a problem file and submit it; return the job handle.""" + """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. + + Returns + ------- + A dict with ``job_id``, ``source`` (the resolved problem path, for + ``cuopt_result``'s ``names_from``), and the problem's size. + + Raises + ------ + CuOptMCPError: 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: @@ -107,6 +153,20 @@ def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: 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: @@ -132,7 +192,38 @@ def result( nonzero_only: bool = False, limit: int = INLINE_SOLUTION_LIMIT, ) -> dict: - """Fetch a completed solution, shaped to stay within a usable size.""" + """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. + 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 full solution is + 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: @@ -201,6 +292,21 @@ def result( 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: @@ -213,6 +319,20 @@ def incumbents(job_id: str, from_index: int = 0) -> dict: 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: The backend is unreachable. """ try: found = get_client().incumbents(job_id, from_index) @@ -231,6 +351,29 @@ def incumbents(job_id: str, from_index: int = 0) -> dict: def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: + """Fetch a job's log text starting at ``from_byte``, tailed to the last + ``tail_lines`` lines. + + 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`. + + Returns + ------- + A dict with ``lines`` (the tailed text), ``truncated`` (whether more + preceded them), and ``next_byte`` to pass on the next call. + """ + 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: text = get_client().logs(job_id, from_byte) except Exception as exc: @@ -246,7 +389,24 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: def list_settings(kind: str, name: str | None = None) -> dict: - """Describe available solver settings, from the generated schema.""" + """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)" From 6874fe72cac721fff3e403195f48301f4c8e0b85 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 16 Sep 2026 13:23:28 -0500 Subject: [PATCH 06/21] docs(mcp): add missing type hints/docstrings, bound a test's polling loop - generate_mcp_schema: type hints + Args/Returns/Raises. - __init__.py, server.py: type hints/docstrings on main() and the remaining tool wrappers (cuopt_status, cuopt_incumbents, cuopt_logs, cuopt_cancel, cuopt_list_settings), including the _guard()-returned error-response shape each one can return. - test_end_to_end.py: test_termination_status_is_readable polled cuopt_status in an unbounded while loop; a stalled job would hang the test until the external CI timeout. Bounded it the same way the adjacent test already does. - test_schema.py: test_every_parameter_states_a_default only checked for the literal substring "Default:", which would still pass if the value after it were dropped. Tightened to require content after the label. (default isn't a separate JSON Schema key by design -- _json_schema_property renders it into description only -- so this substring is the only place a dropped value would show up.) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/generate_conversions.py | 13 +++++- python/cuopt_mcp/cuopt_mcp/__init__.py | 8 +++- python/cuopt_mcp/cuopt_mcp/server.py | 45 +++++++++++++++++--- python/cuopt_mcp/tests/test_end_to_end.py | 9 ++-- python/cuopt_mcp/tests/test_schema.py | 12 +++++- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index e83a8383ca..d26219b344 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -423,7 +423,7 @@ def _json_schema_property(registry, f): return prop -def generate_mcp_schema(registry): +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 @@ -434,6 +434,17 @@ def generate_mcp_schema(registry): 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", diff --git a/python/cuopt_mcp/cuopt_mcp/__init__.py b/python/cuopt_mcp/cuopt_mcp/__init__.py index cadf4ee8c6..ab7f419a3d 100644 --- a/python/cuopt_mcp/cuopt_mcp/__init__.py +++ b/python/cuopt_mcp/cuopt_mcp/__init__.py @@ -6,8 +6,12 @@ __all__ = ["main"] -def main(): - """Console-script entry point (``cuopt-mcp``).""" +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/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index 9c51f5fcc7..51a3348705 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -39,6 +39,13 @@ 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. + """ try: return fn(**kwargs) except CuOptMCPError as exc: @@ -97,7 +104,8 @@ 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. + reached COMPLETED, FAILED, CANCELLED, or NOT_FOUND. On failure, returns + ``{"error": }`` instead (see ``_guard``). """ return _guard(tools.status, job_id=job_id) @@ -122,6 +130,8 @@ def cuopt_result( what matters for a MILP. limit: maximum values returned inline. Beyond this the full solution is written to a file and its path returned instead. + + On failure, returns ``{"error": }`` instead (see ``_guard``). """ return _guard( tools.result, @@ -139,7 +149,8 @@ def cuopt_incumbents(job_id: str, from_index: int = 0) -> dict[str, Any]: 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. + 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) @@ -148,7 +159,18 @@ def cuopt_incumbents(job_id: str, from_index: int = 0) -> dict[str, Any]: def cuopt_logs( job_id: str, from_byte: int = 0, tail_lines: int = 100 ) -> dict[str, Any]: - """Return recent solver log lines for a job, for diagnosing a slow solve.""" + """Return recent solver log lines for a job, for diagnosing a slow solve. + + 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 ) @@ -156,7 +178,12 @@ def cuopt_logs( @server.tool(structured_output=True) def cuopt_cancel(job_id: str) -> dict[str, Any]: - """Stop a running cuOpt job. Any incumbent found so far remains fetchable.""" + """Stop a running cuOpt job. 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) @@ -168,12 +195,20 @@ def cuopt_list_settings(kind: str, name: str | None = None) -> dict[str, Any]: 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. + 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 = __import__( "cuopt_mcp.client", fromlist=["endpoint"] ).endpoint() diff --git a/python/cuopt_mcp/tests/test_end_to_end.py b/python/cuopt_mcp/tests/test_end_to_end.py index e865506309..eb2199d7a3 100644 --- a/python/cuopt_mcp/tests/test_end_to_end.py +++ b/python/cuopt_mcp/tests/test_end_to_end.py @@ -174,10 +174,13 @@ async def test_termination_status_is_readable(session, mps_file): problem_path=mps_file, settings={"time_limit": 30.0}, ) - while not (await _call(session, "cuopt_status", job_id=sub["job_id"]))[ - "terminal" - ]: + 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 diff --git a/python/cuopt_mcp/tests/test_schema.py b/python/cuopt_mcp/tests/test_schema.py index 0e53b02a82..e0fecd15db 100644 --- a/python/cuopt_mcp/tests/test_schema.py +++ b/python/cuopt_mcp/tests/test_schema.py @@ -3,6 +3,8 @@ """Tests for the generated settings schema and its validation.""" +import re + import pytest from cuopt_mcp import schema @@ -29,11 +31,19 @@ def test_every_parameter_is_documented(kind): @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 "Default:" not in p.get("description", "") + if not re.search(r"Default: \S", p.get("description", "")) ] assert missing == [] From 7afa7cf254df1089b1850b689793dbae4455e8e8 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 17 Sep 2026 10:17:22 -0500 Subject: [PATCH 07/21] fix(mcp): stage regenerated proto/schema dropped during rebase conflict The rebase conflict resolution for "Drop proto comments" kept the pre-drop generated cuopt_remote_data.proto (still had per-field proto comments) instead of output from ./build.sh codegen. verify_grpc_codegen.sh compares against the working tree, so it silently validated the correct on-disk regeneration without this ever being committed. --- .../codegen/generated/cuopt_mcp_schema.json | 13 +- .../codegen/generated/cuopt_remote_data.proto | 244 ------------------ 2 files changed, 7 insertions(+), 250 deletions(-) diff --git a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json index 897a751b78..f10a8bba04 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json +++ b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json @@ -137,7 +137,7 @@ }, "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. Default: -1 (automatic)." + "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", @@ -148,7 +148,8 @@ "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" + "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", @@ -253,11 +254,11 @@ }, "mip_batch_pdlp_strong_branching": { "type": "integer", - "description": "Whether strong branching at the root evaluates candidates with a single batched PDLP solve instead of solving them in parallel with dual simplex, which can cut strong-branching time when dual simplex struggles. 0 disabled, 1 enabled. Default: 0 (disabled)." + "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": "Whether reliability-branching candidates are evaluated simultaneously with a single batched PDLP solve. 0 disabled, 1 enabled. Default: 0 (disabled)." + "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", @@ -331,7 +332,7 @@ }, "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. Probing is also skipped in deterministic mode and on LP-only solves. Default: true.", + "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": { @@ -396,7 +397,7 @@ }, "enabled_recombiners": { "type": "integer", - "description": "Bitmask of enabled recombiners: 1 bound propagation, 2 feasibility pump, 4 local search, 8 sub-MIP. Default: 15 (all enabled).", + "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": { diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index 50df3829b7..34b774dd7d 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -164,339 +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. Wire encoding: -1 means 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. Wire encoding: -1 means 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; } From 7bccaf49896947ad7dc3d165f16df1a5db999195 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 17 Sep 2026 11:52:15 -0500 Subject: [PATCH 08/21] refactor(mcp): use a plain import for endpoint() in main() client.py only imports cuopt lazily inside function bodies, so importing endpoint at module scope carries none of the cost the dynamic __import__ was working around. --- python/cuopt_mcp/cuopt_mcp/server.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index 51a3348705..4bc720deea 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -18,7 +18,7 @@ from mcp.server.mcpserver import MCPServer from . import tools -from .client import CuOptMCPError +from .client import CuOptMCPError, endpoint logging.basicConfig( stream=sys.stderr, @@ -209,9 +209,7 @@ def main() -> None: ``server.run`` raises on a fatal transport failure (stdout is reserved for the JSON-RPC stream, so nothing here writes there). """ - host, port = __import__( - "cuopt_mcp.client", fromlist=["endpoint"] - ).endpoint() + host, port = endpoint() logging.info("cuopt-mcp starting; gRPC target %s:%s", host, port) server.run(transport="stdio") From c86517f9c63d46cb088aa43811710684146381a8 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 17 Sep 2026 12:13:04 -0500 Subject: [PATCH 09/21] fix(mcp): fix logs/incumbents shape bugs, enable incumbent collection, harden solution dir External review of #1916 found real bugs against the actual gRPC client: - cuopt_logs treated Client.logs() as a string; it returns list[str], so any non-empty log crashed with AttributeError (escaping _guard, which only catches CuOptMCPError/ValueError). - cuopt_logs also raised whenever a job was still queued/running instead of reporting not-ready, and its next_byte counted characters against a server that tracks bytes. - cuopt_incumbents unpacked each entry as (obj, _); Client.incumbents() returns {"index", "objective", "assignment"} dicts, so every call raised ValueError. - submit() never passed enable_incumbents, and this process keeps no local MIP callback object, so the server never collected incumbents for cuopt_incumbents to find even once the unpack bug was fixed. - _solution_dir used stat() for the ownership/mode check on an existing path; stat() follows symlinks, so a planted symlink to some other 0700 directory this user owns would pass and redirect solution writes. Switched to lstat() and reject symlinks outright. - CUOPT_REMOTE_PORT was int()'d with no validation; a bad value now raises a clear CuOptMCPError instead of a bare ValueError. - No cuopt_delete tool existed, so server-side job state (solution, log, incumbents) had no way to be released short of the server's own lifecycle -- added one, mirroring cuopt_cancel. test_tools.py's FakeClient previously returned tuples/joined-strings that don't match the real client, which is how these shipped green; fixed the stub shapes and added coverage for each case above. --- python/cuopt_mcp/README.md | 1 + python/cuopt_mcp/cuopt_mcp/client.py | 10 ++- python/cuopt_mcp/cuopt_mcp/server.py | 12 +++ python/cuopt_mcp/cuopt_mcp/tools.py | 106 ++++++++++++++++++++++----- python/cuopt_mcp/tests/test_tools.py | 76 +++++++++++++++++-- 5 files changed, 182 insertions(+), 23 deletions(-) diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index f8b42507bc..0c47c32d6c 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -51,6 +51,7 @@ Configuration reuses the environment the cuOpt gRPC client already honours — | `cuopt_incumbents` | Watch a MILP's objective improve | | `cuopt_logs` | Recent solver log lines | | `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 diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index c6abd8b690..5edc61bb5a 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -39,7 +39,15 @@ def endpoint() -> tuple: the MCP server introduces no new configuration surface. """ host = os.environ.get("CUOPT_REMOTE_HOST", DEFAULT_HOST) - port = int(os.environ.get("CUOPT_REMOTE_PORT", DEFAULT_PORT)) + 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 return host, port diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index 4bc720deea..fb19bf53e4 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -187,6 +187,18 @@ def cuopt_cancel(job_id: str) -> dict[str, Any]: 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. diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index 557ccefb60..6d116ceac0 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -42,9 +42,14 @@ def _solution_dir() -> Path: 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 a symlink another local user planted 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). + (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. """ path = Path( os.environ.get( @@ -54,13 +59,18 @@ def _solution_dir() -> Path: try: path.mkdir(parents=True, mode=0o700) except FileExistsError: - st = path.stat() - if st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) & 0o077: + st = path.lstat() + if ( + stat.S_ISLNK(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 private to this user (mode " - f"{oct(stat.S_IMODE(st.st_mode))}, owner uid {st.st_uid}) " - "-- refusing to write solution files there. Remove it or " - "set CUOPT_MCP_SOLUTION_DIR to a private location." + f"{oct(stat.S_IMODE(st.st_mode))}, owner uid {st.st_uid}, " + f"symlink {stat.S_ISLNK(st.st_mode)}) -- 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) @@ -132,7 +142,14 @@ def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: model = _read_problem(problem_path) solver_settings = _build_settings(kind, settings) try: - job_id = get_client().submit(model, solver_settings) + # Client.submit()'s default only enables incumbent collection when + # `settings` already carries a local MIP callback object (see local + # solve). This process keeps none -- cuopt_incumbents polls + # Client.incumbents() instead -- so it must ask explicitly, or the + # server never records incumbents for a MIP job to poll. + job_id = get_client().submit( + model, solver_settings, enable_incumbents=(kind == "mip_settings") + ) except Exception as exc: raise describe_connection_error(exc) from exc @@ -314,6 +331,31 @@ def cancel(job_id: str) -> dict: return {"job_id": job_id, "cancelled": True} +def delete(job_id: str) -> dict: + """Release a job's server-side state (solution, logs, incumbents). + + 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: The backend is unreachable. + """ + try: + get_client().delete(job_id) + except Exception as exc: + raise describe_connection_error(exc) from exc + return {"job_id": job_id, "deleted": True} + + def incumbents(job_id: str, from_index: int = 0) -> dict: """Return the MILP incumbent trajectory so far. @@ -335,25 +377,35 @@ def incumbents(job_id: str, from_index: int = 0) -> dict: CuOptMCPError: The backend is unreachable. """ try: + # Each entry is {"index", "objective", "assignment"}; assignment + # (the full variable vector) isn't returned here -- an agent tracking + # progress needs the objective trend, not the values, and returning + # it inline would blow past a usable tool-result size for any + # realistic MILP. found = get_client().incumbents(job_id, from_index) except Exception as exc: raise describe_connection_error(exc) from exc objectives = [ - {"index": from_index + i, "objective": float(obj)} - for i, (obj, _) in enumerate(found or []) + {"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": from_index + 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 text starting at ``from_byte``, tailed to the last + """Fetch a job's log lines starting at ``from_byte``, tailed to the last ``tail_lines`` lines. + Only available once the job has finished (COMPLETED, FAILED, or + CANCELLED) — the underlying client raises while a job is still queued + or running; poll ``cuopt_status`` first. + Args: job_id: The job to fetch logs for. from_byte: Byte offset to resume from (e.g. ``next_byte`` from a @@ -363,7 +415,8 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: Returns ------- - A dict with ``lines`` (the tailed text), ``truncated`` (whether more + ``{"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. """ if ( @@ -375,16 +428,35 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: f"tail_lines must be an integer between 1 and {MAX_TAIL_LINES}" ) try: - text = get_client().logs(job_id, from_byte) + lines = get_client().logs(job_id, from_byte) except Exception as exc: + # Matched by class name, not isinstance, to avoid importing + # cuopt.grpc.linear_programming (which pulls the compiled libcuopt + # extension) just to name this one exception type -- get_client() + # already imports it lazily on first real use. + 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 = (text or "").splitlines() + lines = lines or [] truncated = len(lines) > tail_lines + # Client.logs() reports lines, not the server-side byte offset each one + # ended at, so this re-derives it: every line came from a '\n'-delimited + # log file, so its encoded length plus one accounts for the separator. + # Off by the trailing byte only when the file's very last line has no + # newline yet -- self-correcting once one more byte is appended. + 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": from_byte + len(text or ""), + "next_byte": next_byte, } diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py index 3e0c722252..44677504b9 100644 --- a/python/cuopt_mcp/tests/test_tools.py +++ b/python/cuopt_mcp/tests/test_tools.py @@ -42,9 +42,16 @@ def get_termination_reason(self): class FakeClient: - def __init__(self, solution=None): + 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 @@ -52,17 +59,35 @@ def result(self, job_id, variable_names=None): 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): - return [(10.0, None), (8.0, None)][from_index:] + # 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): - return "\n".join(f"line {i}" for i in range(10)) + if self.not_ready_logs: + # tools.logs matches by class name, not isinstance, precisely + # so it needn't import the real cuopt.grpc.linear_programming + # exception type -- this stand-in exercises that. + 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): - stub = FakeClient(solution) + 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 @@ -145,8 +170,17 @@ def test_incumbents_paginate(fake): 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 + + +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): @@ -155,6 +189,38 @@ def test_cancel(fake): assert stub.cancelled == ["job-1"] +def test_delete(fake): + stub = fake() + assert tools.delete("job-1")["deleted"] is True + assert stub.deleted == ["job-1"] + + +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_enables_incumbents_for_mip_only(fake, monkeypatch, tmp_path): + """Client.submit() only enables incumbent collection by default when + settings already carries a local MIP callback -- this process keeps + none, so tools.submit must pass enable_incumbents explicitly, or + cuopt_incumbents always comes back empty for a MIP job (tools.py). + """ + 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), "pdlp_settings") + assert stub.submitted == [True, False] + + 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") From 99dab6d6c8dce9e610fe873859155a262a3f9d1e Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 17 Sep 2026 15:36:35 -0500 Subject: [PATCH 10/21] fix(mcp): address CodeRabbit review on #1916 - node_limit's description omitted work_limit, unlike time_limit's and work_limit's own descriptions, which both mention all three fields. - CUOPT_REMOTE_PORT accepted 0, negative, and >65535 values, reaching Client and failing later with an unclear connection error instead of an actionable config error at startup. - _solution_dir's existing-path check never verified the path was a directory: an owned 0600 regular file passed, then _write_solution_file raised an uncaught NotADirectoryError. Folded into the existing lstat-based check (a non-directory entry, symlinks included, now just fails not-S_ISDIR -- no separate S_ISLNK branch needed). - submit()'s returned "source" path was relative to this process's cwd, not the caller's; a later result(names_from=source) call from a different cwd could read the wrong file. Resolved to an absolute path. - result()'s `if variables:` treated variables=[] the same as omitted, falling through to the full/truncated shaping path instead of returning no variables as documented. - test_end_to_end.py submitted jobs without ever deleting them, leaking solution/log/incumbent state on the live server across test runs. Added cuopt_delete cleanup at all three submission sites now that the tool exists. Not changed: CodeRabbit's redaction finding for ValueError messages in _guard. Those messages only echo the caller's own supplied values or known schema field/enum names (see schema.py's validate_settings) -- unlike describe_connection_error's redaction, which targets server-side paths that can appear in backend gRPC error text, there's no internal detail here for a caller to learn that it didn't already send. --- cpp/src/grpc/codegen/field_registry.yaml | 2 +- .../codegen/generated/cuopt_mcp_schema.json | 2 +- python/cuopt_mcp/cuopt_mcp/client.py | 10 ++++ python/cuopt_mcp/cuopt_mcp/tools.py | 24 ++++---- python/cuopt_mcp/tests/test_end_to_end.py | 55 +++++++++++-------- python/cuopt_mcp/tests/test_tools.py | 40 ++++++++++++++ 6 files changed, 97 insertions(+), 36 deletions(-) diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index b19f172d21..b8b5c15794 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -961,7 +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. + together with time_limit or work_limit, the first limit reached wins. default: "no limit (INT_MAX)" field_num: 15 type: int32 diff --git a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json index f10a8bba04..ca36b5c8e7 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json +++ b/cpp/src/grpc/codegen/generated/cuopt_mcp_schema.json @@ -245,7 +245,7 @@ }, "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, the first limit reached wins. Default: no limit (INT_MAX). Omit for the default limit." + "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", diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index 5edc61bb5a..64fc1eb7b8 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -37,6 +37,12 @@ def endpoint() -> tuple: 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") @@ -48,6 +54,10 @@ def endpoint() -> tuple: 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 diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index 6d116ceac0..b31326238f 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -49,7 +49,10 @@ def _solution_dir() -> Path: 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. + 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( @@ -61,16 +64,16 @@ def _solution_dir() -> Path: except FileExistsError: st = path.lstat() if ( - stat.S_ISLNK(st.st_mode) + 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 private to this user (mode " - f"{oct(stat.S_IMODE(st.st_mode))}, owner uid {st.st_uid}, " - f"symlink {stat.S_ISLNK(st.st_mode)}) -- refusing to write " - "solution files there. Remove it or set " - "CUOPT_MCP_SOLUTION_DIR to a private location." + 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) @@ -159,7 +162,7 @@ def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: offsets = model.get_constraint_matrix_offsets() return { "job_id": job_id, - "source": str(Path(problem_path).expanduser()), + "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": ( @@ -216,7 +219,8 @@ def result( 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. + 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 full solution is @@ -277,7 +281,7 @@ def result( "path> to key them by variable name." ) - if variables: + 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 diff --git a/python/cuopt_mcp/tests/test_end_to_end.py b/python/cuopt_mcp/tests/test_end_to_end.py index eb2199d7a3..4a66bd356a 100644 --- a/python/cuopt_mcp/tests/test_end_to_end.py +++ b/python/cuopt_mcp/tests/test_end_to_end.py @@ -110,20 +110,23 @@ async def test_solve_lp_end_to_end(session, mps_file): job_id = submitted["job_id"] assert submitted["num_variables"] == 2 - 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) + 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) + finally: + await _call(session, "cuopt_delete", job_id=job_id) @pytest.mark.anyio @@ -163,6 +166,7 @@ async def test_enum_setting_is_accepted_by_name(session, mps_file): ) assert "error" not in out, out assert out["job_id"] + await _call(session, "cuopt_delete", job_id=out["job_id"]) @pytest.mark.anyio @@ -174,13 +178,16 @@ async def test_termination_status_is_readable(session, mps_file): problem_path=mps_file, settings={"time_limit": 30.0}, ) - 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 + 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_tools.py b/python/cuopt_mcp/tests/test_tools.py index 44677504b9..514b6f0d2f 100644 --- a/python/cuopt_mcp/tests/test_tools.py +++ b/python/cuopt_mcp/tests/test_tools.py @@ -126,6 +126,17 @@ def test_result_named_lookup_reports_missing(fake): assert out["missing_variables"] == ["nope"] +def test_result_empty_variables_list_returns_no_variables(fake): + """variables=[] must mean "return none", distinct from the omitted + default -- `if variables:` treated both the same and fell through to + the full/truncated shaping path instead. + """ + 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 ): @@ -152,6 +163,28 @@ def test_large_solution_is_written_to_file_not_inlined( assert len(json.loads(written.read_text())) == n +def test_result_rejects_solution_dir_that_is_a_file( + fake, tmp_path, monkeypatch +): + """A pre-existing non-directory at CUOPT_MCP_SOLUTION_DIR must be + rejected up front -- previously it passed the ownership/mode check + (nothing there tested S_ISDIR) and only failed later as an unguarded + NotADirectoryError out of _write_solution_file. + """ + 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("job-big", 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") @@ -243,3 +276,10 @@ def test_unreachable_server_message_names_the_endpoint(monkeypatch): 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() From d47fccfc7cacac1ca8e26808e92381a0c4fc13f0 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 17 Sep 2026 16:42:40 -0500 Subject: [PATCH 11/21] docs(mcp): document plaintext-by-default TLS, fix markdownlint fence - README's remote-connection example didn't say the channel is plain TCP unless CUOPT_TLS_ENABLED is set; documented it alongside the TLS env vars. - The architecture diagram's fenced code block had no language tag, which .coderabbit.yaml's markdownlint (MD040) flags. --- python/cuopt_mcp/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index 0c47c32d6c..e6a8b69ca6 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -3,7 +3,7 @@ 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) ``` @@ -38,7 +38,10 @@ Then register the MCP server with your client: ``` Configuration reuses the environment the cuOpt gRPC client already honours — -`CUOPT_REMOTE_HOST`, `CUOPT_REMOTE_PORT`, and `CUOPT_TLS_*`. +`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 From d4fe9f13983ab6291b7b3c1bcac0b37658f07673 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 17 Sep 2026 16:57:13 -0500 Subject: [PATCH 12/21] docs(mcp): document logs()'s Raises, fix HTTP transport wording, tighten e2e bounds check - logs() was missing a Raises section for the out-of-range tail_lines / unreachable-backend cases every other tool documents. - "No HTTP is involved" was imprecise -- gRPC is HTTP/2 over TCP. Reworded to what's actually true: no HTTP application endpoint is exposed. - test_solve_lp_end_to_end checked objective and sum but not that each variable actually respects its bound (X <= 8, Y <= 8); a solution violating a bound while keeping the same objective/sum would have passed. --- python/cuopt_mcp/README.md | 4 +++- python/cuopt_mcp/cuopt_mcp/tools.py | 5 +++++ python/cuopt_mcp/tests/test_end_to_end.py | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index e6a8b69ca6..776ba915a0 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -8,7 +8,9 @@ MCP client ──stdio (JSON-RPC)──> cuopt-mcp ──gRPC──> cuopt_grpc_ ``` 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 is involved. +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 diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index b31326238f..f8af87e9a4 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -422,6 +422,11 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: ``{"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: ``tail_lines`` is out of range, or the backend is + unreachable. """ if ( isinstance(tail_lines, bool) diff --git a/python/cuopt_mcp/tests/test_end_to_end.py b/python/cuopt_mcp/tests/test_end_to_end.py index 4a66bd356a..c76ce886d3 100644 --- a/python/cuopt_mcp/tests/test_end_to_end.py +++ b/python/cuopt_mcp/tests/test_end_to_end.py @@ -125,6 +125,8 @@ async def test_solve_lp_end_to_end(session, mps_file): 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) From 52a302f7cb4bdadd3a340c52a165b6b006111876 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 09:43:25 -0500 Subject: [PATCH 13/21] style(mcp): trim inline comments to 1-2 lines No behavior change -- shortens comments added across earlier review rounds down to the essential claim, per project comment-density convention. --- python/cuopt_mcp/cuopt_mcp/client.py | 9 ++--- python/cuopt_mcp/cuopt_mcp/tools.py | 49 ++++++++-------------------- python/cuopt_mcp/tests/test_tools.py | 27 +++------------ 3 files changed, 21 insertions(+), 64 deletions(-) diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index 64fc1eb7b8..49bf26cf15 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -20,12 +20,9 @@ DEFAULT_HOST = "localhost" DEFAULT_PORT = 50051 -# Redacts absolute filesystem paths (2+ segments) out of backend error text. -# The gRPC client only ever gets a plain message string -- no structured -# status code survives the C++ -> Cython -> here hop -- so a caller-facing -# parse/solve error and a server-internal one (e.g. "Failed to open log -# file: ") can't be told apart by type. Redacting paths keeps the rest -# of the message useful without leaking server-side directory layout. +# 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"(? dict: model = _read_problem(problem_path) solver_settings = _build_settings(kind, settings) try: - # Client.submit()'s default only enables incumbent collection when - # `settings` already carries a local MIP callback object (see local - # solve). This process keeps none -- cuopt_incumbents polls - # Client.incumbents() instead -- so it must ask explicitly, or the - # server never records incumbents for a MIP job to poll. + # This process keeps no local MIP callback, so ask explicitly or the + # server never collects incumbents for cuopt_incumbents to poll. job_id = get_client().submit( model, solver_settings, enable_incumbents=(kind == "mip_settings") ) except Exception as exc: raise describe_connection_error(exc) from exc - # DataModel exposes no public size accessors, so derive both from the - # arrays it does expose: one lower bound per column, and CSR row offsets - # numbering rows + 1. + # DataModel exposes no public size accessors; derive from CSR offsets. offsets = model.get_constraint_matrix_offsets() return { "job_id": job_id, @@ -257,10 +245,8 @@ def result( } primal = solution.get_primal_solution() - # The status is an IntEnum, so str() would yield the bare number ("1"). - # get_termination_reason() is its .name, which is what a caller can act - # on. Note LPTerminationStatus numbers Optimal=1 while the wire enum - # pdlp_termination_status numbers it 2 — never map between them. + # 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, @@ -381,11 +367,8 @@ def incumbents(job_id: str, from_index: int = 0) -> dict: CuOptMCPError: The backend is unreachable. """ try: - # Each entry is {"index", "objective", "assignment"}; assignment - # (the full variable vector) isn't returned here -- an agent tracking - # progress needs the objective trend, not the values, and returning - # it inline would blow past a usable tool-result size for any - # realistic MILP. + # 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 @@ -439,10 +422,7 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: try: lines = get_client().logs(job_id, from_byte) except Exception as exc: - # Matched by class name, not isinstance, to avoid importing - # cuopt.grpc.linear_programming (which pulls the compiled libcuopt - # extension) just to name this one exception type -- get_client() - # already imports it lazily on first real use. + # Matched by name, not isinstance, to avoid an eager cuopt import. if type(exc).__name__ == "JobNotReadyError": return { "job_id": job_id, @@ -452,11 +432,8 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: raise describe_connection_error(exc) from exc lines = lines or [] truncated = len(lines) > tail_lines - # Client.logs() reports lines, not the server-side byte offset each one - # ended at, so this re-derives it: every line came from a '\n'-delimited - # log file, so its encoded length plus one accounts for the separator. - # Off by the trailing byte only when the file's very last line has no - # newline yet -- self-correcting once one more byte is appended. + # 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 ) diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py index 514b6f0d2f..7f7258fb8c 100644 --- a/python/cuopt_mcp/tests/test_tools.py +++ b/python/cuopt_mcp/tests/test_tools.py @@ -73,9 +73,7 @@ def incumbents(self, job_id, from_index=0): def logs(self, job_id, from_byte=0): if self.not_ready_logs: - # tools.logs matches by class name, not isinstance, precisely - # so it needn't import the real cuopt.grpc.linear_programming - # exception type -- this stand-in exercises that. + # tools.logs matches by class name, not isinstance -- see there. class JobNotReadyError(Exception): pass @@ -127,10 +125,7 @@ def test_result_named_lookup_reports_missing(fake): def test_result_empty_variables_list_returns_no_variables(fake): - """variables=[] must mean "return none", distinct from the omitted - default -- `if variables:` treated both the same and fell through to - the full/truncated shaping path instead. - """ + """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"] == {} @@ -140,11 +135,7 @@ def test_result_empty_variables_list_returns_no_variables(fake): def test_large_solution_is_written_to_file_not_inlined( fake, tmp_path, monkeypatch ): - """A big solution must not be returned inline. - - The binding limit is the model's context window, so past `limit` the - values go to a file and only a pointer comes back. - """ + """A big solution must not be returned inline.""" monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) n = 5000 fake( @@ -166,11 +157,7 @@ def test_large_solution_is_written_to_file_not_inlined( def test_result_rejects_solution_dir_that_is_a_file( fake, tmp_path, monkeypatch ): - """A pre-existing non-directory at CUOPT_MCP_SOLUTION_DIR must be - rejected up front -- previously it passed the ownership/mode check - (nothing there tested S_ISDIR) and only failed later as an unguarded - NotADirectoryError out of _write_solution_file. - """ + """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) @@ -237,11 +224,7 @@ def get_constraint_matrix_offsets(self): def test_submit_enables_incumbents_for_mip_only(fake, monkeypatch, tmp_path): - """Client.submit() only enables incumbent collection by default when - settings already carries a local MIP callback -- this process keeps - none, so tools.submit must pass enable_incumbents explicitly, or - cuopt_incumbents always comes back empty for a MIP job (tools.py). - """ + """submit() must ask for incumbents explicitly for MIP, not LP.""" stub = fake() monkeypatch.setattr(tools, "_read_problem", lambda path: FakeModel()) monkeypatch.setattr( From 2a5df5b5ac069d76ef4e0aa36f73506e497e5d59 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 10:00:45 -0500 Subject: [PATCH 14/21] fix(mcp): validate kind before it reaches settings_schema() _build_settings() never checked kind, so an invalid one (e.g. from submit()) raised a raw KeyError out of settings_schema() -- _guard only catches CuOptMCPError/ValueError, so this escaped as a protocol exception instead of a clean {"error": ...}. list_settings() already had this same check; _build_settings() now does too, checked before the lazy SolverSettings import so a bad kind fails without paying for it. --- python/cuopt_mcp/cuopt_mcp/tools.py | 14 ++++++++++---- python/cuopt_mcp/tests/test_tools.py | 6 ++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index c08d8424d6..bbb0dee2f4 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -89,10 +89,15 @@ def _read_problem(path: str): def _build_settings(kind: str, settings: dict | None): - from cuopt.linear_programming import SolverSettings - + if kind not in ("pdlp_settings", "mip_settings"): + raise CuOptMCPError( + "kind must be 'pdlp_settings' (LP) or 'mip_settings' (MILP)" + ) validate_settings(kind, settings or {}) 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. @@ -132,8 +137,9 @@ def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: Raises ------ - CuOptMCPError: The file doesn't exist, fails to parse, carries an - unknown setting, or the backend is unreachable. + 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) diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py index 7f7258fb8c..b9e042b63b 100644 --- a/python/cuopt_mcp/tests/test_tools.py +++ b/python/cuopt_mcp/tests/test_tools.py @@ -237,6 +237,12 @@ def test_submit_enables_incumbents_for_mip_only(fake, monkeypatch, tmp_path): assert stub.submitted == [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_missing_problem_file_is_a_clear_error(): with pytest.raises(client.CuOptMCPError, match="problem file not found"): tools.submit("/nonexistent/model.mps", "pdlp_settings") From 5aa4c73c09ae84127001568280d1149b648cbe4e Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 10:14:42 -0500 Subject: [PATCH 15/21] fix(mcp): fix sparse-solution file writes, unlink on delete, opt-in incumbents, centralize error redaction - nonzero_only wrote the unfiltered solution to solution_path, defeating its purpose for a sparse million-variable MILP (write selected, not vars_by_name). - delete() released server-side state but never removed the local solution file result() may have written, so repeated large solves retained files indefinitely. - MIP incumbent collection was always enabled by submit(), downloading every incumbent's full assignment vector server-side even though only index/objective are kept. Made it opt-in via a new track_incumbents argument (submit(), cuopt_solve_milp), default off. - _guard redacted nothing for CuOptMCPError/ValueError text, and two direct raise sites (_read_problem, _solution_dir) can embed a resolved local path an MCP caller didn't supply (e.g. `~` expansion) or an owner uid. Moved path redaction to _guard itself so every error path gets it, not just describe_connection_error's. - _build_settings let validate_settings' ValueError propagate unwrapped, contradicting submit()'s documented "Raises: CuOptMCPError" for a direct (non-MCP-protocol) caller. Not changed: logs()'s tail_lines still bounds the response, not the fetch -- Client.logs() has no server-side tail operation, and the lower-level Client._stream_logs() this could use is designed for live tailing of a running job with no fast "not ready" check, so reusing it here risks reintroducing a blocking hang for the fail-fast case logs() currently guarantees. Documented the limitation instead. --- python/cuopt_mcp/cuopt_mcp/client.py | 15 +++++--- python/cuopt_mcp/cuopt_mcp/server.py | 36 +++++++++++------- python/cuopt_mcp/cuopt_mcp/tools.py | 51 ++++++++++++++++++------- python/cuopt_mcp/tests/test_server.py | 28 ++++++++++++++ python/cuopt_mcp/tests/test_tools.py | 55 ++++++++++++++++++++++++--- 5 files changed, 147 insertions(+), 38 deletions(-) create mode 100644 python/cuopt_mcp/tests/test_server.py diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index 49bf26cf15..36f1e6702b 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -25,6 +25,12 @@ # 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 @@ -112,10 +118,9 @@ 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 with filesystem - paths redacted (see ``_PATH_RE``): the server's own error strings - sometimes embed its internal paths (e.g. a job's log file location), - which a remote MCP caller has no use for and shouldn't see. + 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. @@ -133,4 +138,4 @@ def describe_connection_error(exc: Exception) -> CuOptMCPError: f"`cuopt_grpc_server --port {port}`, or set CUOPT_REMOTE_HOST / " "CUOPT_REMOTE_PORT to point at a running server." ) - return CuOptMCPError(_PATH_RE.sub("[redacted path]", text)) + return CuOptMCPError(redact_paths(text)) diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index fb19bf53e4..dff2881801 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -18,7 +18,7 @@ from mcp.server.mcpserver import MCPServer from . import tools -from .client import CuOptMCPError, endpoint +from .client import CuOptMCPError, endpoint, redact_paths logging.basicConfig( stream=sys.stderr, @@ -45,13 +45,12 @@ def _guard(fn, /, **kwargs) -> dict[str, Any]: 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. """ try: return fn(**kwargs) - except CuOptMCPError as exc: - return {"error": str(exc)} - except ValueError as exc: - return {"error": str(exc)} + except (CuOptMCPError, ValueError) as exc: + return {"error": redact_paths(str(exc))} @server.tool(structured_output=True) @@ -79,7 +78,9 @@ def cuopt_solve_lp( @server.tool(structured_output=True) def cuopt_solve_milp( - problem_path: str, settings: dict | None = None + 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. @@ -87,15 +88,19 @@ def cuopt_solve_milp( 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_incumbents to watch the objective improve - and cuopt_cancel to stop early once it is good enough. + 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, ) @@ -128,8 +133,9 @@ def cuopt_result( 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 full solution is - written to a file and its path returned instead. + 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``). """ @@ -147,10 +153,12 @@ def cuopt_result( def cuopt_incumbents(job_id: str, from_index: int = 0) -> dict[str, Any]: """Return improving MILP solutions found so far, oldest first. - 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``). + 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) diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index bbb0dee2f4..e7b17a32ce 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -93,7 +93,10 @@ def _build_settings(kind: str, settings: dict | None): raise CuOptMCPError( "kind must be 'pdlp_settings' (LP) or 'mip_settings' (MILP)" ) - validate_settings(kind, settings or {}) + 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 @@ -120,7 +123,12 @@ def _variable_names(names_from: str | None): return list(names) if names is not None else None -def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: +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: @@ -129,6 +137,11 @@ def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: 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 ------- @@ -144,10 +157,10 @@ def submit(problem_path: str, kind: str, settings: dict | None = None) -> dict: model = _read_problem(problem_path) solver_settings = _build_settings(kind, settings) try: - # This process keeps no local MIP callback, so ask explicitly or the - # server never collects incumbents for cuopt_incumbents to poll. job_id = get_client().submit( - model, solver_settings, enable_incumbents=(kind == "mip_settings") + model, + solver_settings, + enable_incumbents=(kind == "mip_settings" and track_incumbents), ) except Exception as exc: raise describe_connection_error(exc) from exc @@ -217,8 +230,9 @@ def result( (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 full solution is - written to a file and ``solution_path`` returned instead. + :data:`INLINE_SOLUTION_LIMIT`. Beyond this, the selected values + (post ``nonzero_only`` filtering) are written to a file and + ``solution_path`` returned instead. Returns ------- @@ -295,11 +309,12 @@ def result( summary["variables"] = { k: float(v) for k, v in list(selected.items())[:limit] } - summary["solution_path"] = _write_solution_file(job_id, vars_by_name) + summary["solution_path"] = _write_solution_file(job_id, selected) summary["hint"] = ( - f"{len(selected)} values exceed the inline limit of {limit}. The " - "full solution is at solution_path; use variables=[...] or " - "nonzero_only=true to narrow the result." + 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 @@ -328,7 +343,8 @@ def cancel(job_id: str) -> dict: def delete(job_id: str) -> dict: - """Release a job's server-side state (solution, logs, incumbents). + """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. @@ -349,6 +365,10 @@ def delete(job_id: str) -> dict: get_client().delete(job_id) except Exception as exc: raise describe_connection_error(exc) from exc + try: + (_solution_dir() / f"{job_id}.json").unlink(missing_ok=True) + except CuOptMCPError: + pass # nothing to clean up if the directory itself is unusable return {"job_id": job_id, "deleted": True} @@ -404,7 +424,12 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: 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`. + 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 ------- diff --git a/python/cuopt_mcp/tests/test_server.py b/python/cuopt_mcp/tests/test_server.py new file mode 100644 index 0000000000..5799365359 --- /dev/null +++ b/python/cuopt_mcp/tests/test_server.py @@ -0,0 +1,28 @@ +# 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} diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py index b9e042b63b..ae6caf068e 100644 --- a/python/cuopt_mcp/tests/test_tools.py +++ b/python/cuopt_mcp/tests/test_tools.py @@ -7,6 +7,8 @@ test_end_to_end.py. """ +import json + import pytest from cuopt_mcp import client, tools @@ -149,11 +151,26 @@ def test_large_solution_is_written_to_file_not_inlined( assert out["num_variables"] == n written = tmp_path / "job-big.json" assert written.is_file() - import json - 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("job-sparse", nonzero_only=True, limit=10) + assert out["num_nonzero"] == n // 100 + written = json.loads((tmp_path / "job-sparse.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 ): @@ -215,6 +232,23 @@ def test_delete(fake): assert stub.deleted == ["job-1"] +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 / "job-1.json" + solution_file.write_text("{}") + tools.delete("job-1") + 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("job-1")["deleted"] is True + + class FakeModel: def get_variable_lower_bounds(self): return [0.0, 0.0] @@ -223,8 +257,10 @@ def get_constraint_matrix_offsets(self): return [0, 1] -def test_submit_enables_incumbents_for_mip_only(fake, monkeypatch, tmp_path): - """submit() must ask for incumbents explicitly for MIP, not LP.""" +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( @@ -233,8 +269,9 @@ def test_submit_enables_incumbents_for_mip_only(fake, monkeypatch, tmp_path): problem = tmp_path / "p.mps" problem.write_text("") tools.submit(str(problem), "mip_settings") - tools.submit(str(problem), "pdlp_settings") - assert stub.submitted == [True, False] + 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(): @@ -243,6 +280,12 @@ def test_build_settings_rejects_bad_kind(): 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") From 7bcd7e2d7d5592958c2a0fc5b9ba7acbad9eb1b1 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 12:03:45 -0500 Subject: [PATCH 16/21] docs(mcp): cuopt_solve_milp accepts LP too, not just MPS The .lp reader (lp_parser.cpp) handles CPLEX-notation Generals/ Binaries sections, so an LP file can declare integer/binary variables same as MPS -- cuopt_solve_lp's docstring already said MPS/QPS/LP; cuopt_solve_milp's incorrectly narrowed to MPS only. --- python/cuopt_mcp/cuopt_mcp/server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index dff2881801..127b56e33f 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -84,7 +84,8 @@ def cuopt_solve_milp( ) -> dict[str, Any]: """Submit a mixed-integer program to cuOpt and return a job handle. - problem_path: path to an MPS file containing integer variables. + 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. From c68fca65fb5c1bb3a0378fa3dbf190f782da1e06 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 12:06:59 -0500 Subject: [PATCH 17/21] docs(mcp): fix stale HTTP wording in server.py, note incumbents opt-in in README - server.py's module docstring still said "No HTTP is involved" -- same imprecision already fixed in README.md's copy of this claim. - README's tools table didn't mention cuopt_incumbents now needs track_incumbents=true at submit time. --- python/cuopt_mcp/README.md | 2 +- python/cuopt_mcp/cuopt_mcp/server.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index 776ba915a0..d947e1397e 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -53,7 +53,7 @@ needed) whenever `gpu-host` isn't a trusted local network. | `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 | +| `cuopt_incumbents` | Watch a MILP's objective improve (needs `track_incumbents=true` at submit) | | `cuopt_logs` | Recent solver log lines | | `cuopt_cancel` | Stop a running job | | `cuopt_delete` | Release a job's server-side state once its result is no longer needed | diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index 127b56e33f..e59890191d 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -4,8 +4,8 @@ """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 is involved and the host needs no GPU — the -solve happens wherever the gRPC server runs. +``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. From 4fce32ccc25d22a75166cc11c779a5d17092d674 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 12:14:28 -0500 Subject: [PATCH 18/21] docs(mcp): clarify logs() availability -- capture is always on, only the fetch is post-completion Previous wording ("only available once finished... client raises while queued or running") was true of the specific unary fetch this tool uses, but read as if log capture itself needed enabling or wasn't happening during the run. It's on unconditionally from submission (create_job_file_log_file runs for every job); only the client-side snapshot fetch is gated on terminal state. A live tail is possible on the wire (Client.start_log_stream) but not wired into this tool yet -- tracked as a follow-up. Also dropped cuopt_logs's "for diagnosing a slow solve" framing, which implied it works mid-solve. --- python/cuopt_mcp/README.md | 2 +- python/cuopt_mcp/cuopt_mcp/server.py | 7 ++++++- python/cuopt_mcp/cuopt_mcp/tools.py | 10 +++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index d947e1397e..fb05075b45 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -54,7 +54,7 @@ needed) whenever `gpu-host` isn't a trusted local network. | `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` | Recent solver log lines | +| `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 | diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index e59890191d..0ced2ae5da 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -168,7 +168,12 @@ def cuopt_incumbents(job_id: str, from_index: int = 0) -> dict[str, Any]: def cuopt_logs( job_id: str, from_byte: int = 0, tail_lines: int = 100 ) -> dict[str, Any]: - """Return recent solver log lines for a job, for diagnosing a slow solve. + """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. job_id: the job to fetch logs for. from_byte: resume from this byte offset — pass back the next_byte from diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index e7b17a32ce..2ae744ea98 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -415,9 +415,13 @@ 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. - Only available once the job has finished (COMPLETED, FAILED, or - CANCELLED) — the underlying client raises while a job is still queued - or running; poll ``cuopt_status`` first. + 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 (COMPLETED, FAILED, or CANCELLED) 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. Args: job_id: The job to fetch logs for. From 2c96b4b963e779a729393710979edd248f45f0f0 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 12:36:07 -0500 Subject: [PATCH 19/21] fix(mcp): _guard must not let an unanticipated exception's raw text through Found by actually driving the server as a real MCP client would (spun up cuopt_grpc_server, installed cuopt_mcp, called cuopt_solve_lp): an ImportError from a broken local rmm install escaped _guard's (CuOptMCPError, ValueError) catch entirely and reached the caller as an unredacted traceback fragment, bypassing redact_paths -- the exact class of leak CodeRabbit's earlier _guard finding was about, just via a path that finding didn't anticipate. Now any other exception is logged (with traceback) to stderr and reduced to a generic message instead of str(exc): unlike the two explicitly-handled types, that text was never vetted as safe to return. --- python/cuopt_mcp/cuopt_mcp/server.py | 8 ++++++++ python/cuopt_mcp/tests/test_server.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index 0ced2ae5da..3b0c594331 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -46,11 +46,19 @@ def _guard(fn, /, **kwargs) -> dict[str, Any]: ``{"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) diff --git a/python/cuopt_mcp/tests/test_server.py b/python/cuopt_mcp/tests/test_server.py index 5799365359..44ae5713bd 100644 --- a/python/cuopt_mcp/tests/test_server.py +++ b/python/cuopt_mcp/tests/test_server.py @@ -26,3 +26,18 @@ def raiser(): 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" From 6553c177d9008bd0c70c5d40ef3dcc1ba9035759 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 13:29:04 -0500 Subject: [PATCH 20/21] fix(mcp): correct default port, sanitize job_id as filename, range-check offsets - DEFAULT_PORT was 50051, but cuopt_grpc_server's own default is 5001 (cuopt_default_grpc_port.h). A server started with no flags and a client started with no env couldn't reach each other. Fixed the default and the README examples. - job_id was joined onto CUOPT_MCP_SOLUTION_DIR as a filename with no validation, in both _write_solution_file and delete(). Since job_id is a caller-supplied MCP tool argument, a crafted value like "../../etc/cron.d/x" could write or unlink an arbitrary .json-suffixed path outside the solution directory. Server-issued ids are UUIDs; now enforced via a regex before any path is built. - from_byte (logs) and from_index (incumbents) had no range check, unlike limit/tail_lines -- a bool (an int subclass) or a negative value could slip through. - Documented that CANCELLED jobs' logs are unavailable: the server deletes a job's log file as part of handling cancellation (unlike COMPLETED/FAILED, which keep it until delete()), contradicting the prior docstring's claim that logs work for all three terminal states. Filed NVIDIA/cuopt#1942 for the LP/MILP integer-variable check suggestion (out of scope here; needs its own design/tests). --- python/cuopt_mcp/README.md | 7 ++-- python/cuopt_mcp/cuopt_mcp/client.py | 5 ++- python/cuopt_mcp/cuopt_mcp/server.py | 4 +- python/cuopt_mcp/cuopt_mcp/tools.py | 57 +++++++++++++++++++++------ python/cuopt_mcp/tests/test_tools.py | 59 +++++++++++++++++++++++----- 5 files changed, 106 insertions(+), 26 deletions(-) diff --git a/python/cuopt_mcp/README.md b/python/cuopt_mcp/README.md index fb05075b45..19534f8b83 100644 --- a/python/cuopt_mcp/README.md +++ b/python/cuopt_mcp/README.md @@ -20,10 +20,11 @@ pip install cuopt_mcp ## Configure -Start the solver backend on a GPU host: +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 50051 +cuopt_grpc_server --port 5001 ``` Then register the MCP server with your client: @@ -33,7 +34,7 @@ Then register the MCP server with your client: "mcpServers": { "cuopt": { "command": "cuopt-mcp", - "env": { "CUOPT_REMOTE_HOST": "gpu-host", "CUOPT_REMOTE_PORT": "50051" } + "env": { "CUOPT_REMOTE_HOST": "gpu-host", "CUOPT_REMOTE_PORT": "5001" } } } } diff --git a/python/cuopt_mcp/cuopt_mcp/client.py b/python/cuopt_mcp/cuopt_mcp/client.py index 36f1e6702b..d7c8e1b4b2 100644 --- a/python/cuopt_mcp/cuopt_mcp/client.py +++ b/python/cuopt_mcp/cuopt_mcp/client.py @@ -18,7 +18,10 @@ from cuopt.grpc.linear_programming import Client DEFAULT_HOST = "localhost" -DEFAULT_PORT = 50051 +# 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 diff --git a/python/cuopt_mcp/cuopt_mcp/server.py b/python/cuopt_mcp/cuopt_mcp/server.py index 3b0c594331..5df66a1b7b 100644 --- a/python/cuopt_mcp/cuopt_mcp/server.py +++ b/python/cuopt_mcp/cuopt_mcp/server.py @@ -181,7 +181,9 @@ def cuopt_logs( 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. + 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 diff --git a/python/cuopt_mcp/cuopt_mcp/tools.py b/python/cuopt_mcp/cuopt_mcp/tools.py index 2ae744ea98..022377c639 100644 --- a/python/cuopt_mcp/cuopt_mcp/tools.py +++ b/python/cuopt_mcp/cuopt_mcp/tools.py @@ -16,6 +16,7 @@ import json import os +import re import stat import tempfile from pathlib import Path @@ -31,6 +32,12 @@ 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. @@ -206,8 +213,27 @@ def status(job_id: str) -> dict: } +_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_dir() / f"{job_id}.json" + path = _solution_file_path(job_id) path.write_text(json.dumps(vars_by_name, indent=1)) return str(path) @@ -359,14 +385,17 @@ def delete(job_id: str) -> dict: Raises ------ - CuOptMCPError: The backend is unreachable. + 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_dir() / f"{job_id}.json").unlink(missing_ok=True) + _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} @@ -390,8 +419,10 @@ def incumbents(job_id: str, from_index: int = 0) -> dict: Raises ------ - CuOptMCPError: The backend is unreachable. + 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. @@ -417,11 +448,14 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: 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 (COMPLETED, FAILED, or CANCELLED) 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. + 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. @@ -443,9 +477,10 @@ def logs(job_id: str, from_byte: int = 0, tail_lines: int = 100) -> dict: Raises ------ - CuOptMCPError: ``tail_lines`` is out of range, or the backend is - unreachable. + 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) diff --git a/python/cuopt_mcp/tests/test_tools.py b/python/cuopt_mcp/tests/test_tools.py index ae6caf068e..1d0498836d 100644 --- a/python/cuopt_mcp/tests/test_tools.py +++ b/python/cuopt_mcp/tests/test_tools.py @@ -13,6 +13,12 @@ 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): @@ -145,11 +151,11 @@ def test_large_solution_is_written_to_file_not_inlined( [float(i) for i in range(n)], names=[f"x{i}" for i in range(n)] ) ) - out = tools.result("job-big", limit=10) + 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 / "job-big.json" + written = tmp_path / f"{UUID1}.json" assert written.is_file() assert len(json.loads(written.read_text())) == n @@ -164,9 +170,9 @@ def test_nonzero_only_solution_file_is_also_filtered( 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("job-sparse", nonzero_only=True, limit=10) + out = tools.result(UUID2, nonzero_only=True, limit=10) assert out["num_nonzero"] == n // 100 - written = json.loads((tmp_path / "job-sparse.json").read_text()) + written = json.loads((tmp_path / f"{UUID2}.json").read_text()) assert len(written) == n // 100 assert all(v != 0 for v in written.values()) @@ -186,7 +192,7 @@ def test_result_rejects_solution_dir_that_is_a_file( ) ) with pytest.raises(client.CuOptMCPError, match="not a private directory"): - tools.result("job-big", limit=10) + tools.result(UUID1, limit=10) def test_unnamed_solution_falls_back_to_indices_with_a_hint(fake): @@ -204,6 +210,13 @@ def test_incumbents_paginate(fake): 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) @@ -213,6 +226,13 @@ def test_logs_tail_is_bounded(fake): 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") @@ -228,16 +248,27 @@ def test_cancel(fake): def test_delete(fake): stub = fake() - assert tools.delete("job-1")["deleted"] is True - assert stub.deleted == ["job-1"] + 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 / "job-1.json" + solution_file = tmp_path / f"{UUID1}.json" solution_file.write_text("{}") - tools.delete("job-1") + tools.delete(UUID1) assert not solution_file.exists() @@ -246,7 +277,7 @@ def test_delete_with_no_solution_file_still_succeeds( ): monkeypatch.setenv("CUOPT_MCP_SOLUTION_DIR", str(tmp_path)) fake() - assert tools.delete("job-1")["deleted"] is True + assert tools.delete(UUID1)["deleted"] is True class FakeModel: @@ -315,3 +346,11 @@ 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 From 781c3e630aa12505fd925993b3996efb769c9b3d Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 18 Sep 2026 14:51:48 -0500 Subject: [PATCH 21/21] fix(deps): give py_run_cuopt_mcp a matrix so depends_on_cuopt resolves py_run_cuopt_mcp included depends_on_cuopt without ever setting cuda_suffixed, so rapids-dependency-file-generator couldn't match any of depends_on_cuopt's three matrix arms (all keyed on cuda_suffixed) and failed with "No matching matrix found ... for: {}" -- this is what was failing checks/check-style in CI. py_run_cuopt_server, which also deliberately depends on the unsuffixed cuopt package, already sets this; mirrored it here. No generated file changed -- the committed pyproject.toml already matched what the generator produces once the matrix resolves. --- dependencies.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dependencies.yaml b/dependencies.yaml index c366b0d9a1..a0751007bc 100644 --- a/dependencies.yaml +++ b/dependencies.yaml @@ -253,6 +253,8 @@ files: py_run_cuopt_mcp: output: pyproject pyproject_dir: python/cuopt_mcp/ + matrix: + cuda_suffixed: ["false"] extras: table: project includes: