Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ ARGS=$*
REPODIR=$(cd "$(dirname "$0")"; pwd)
LIBCUOPT_BUILD_DIR=${LIBCUOPT_BUILD_DIR:=${REPODIR}/cpp/build}

VALIDARGS="clean codegen libcuopt cuopt_grpc_server cuopt cuopt_server cuopt_sh_client java docs deb --run-java-tests -a -b -g -fsanitize -tsan -msan -v -l= --verbose-pdlp --build-lp-only --no-fetch-rapids --skip-c-python-adapters --skip-tests-build --skip-routing-build --skip-grpc-build --skip-fatbin-write --host-lineinfo --split-compile [--cmake-args=\\\"<args>\\\"] [--cache-tool=<tool>] --install --allgpuarch --ci-only-arch --show_depr_warn -h --help"
VALIDARGS="clean codegen libcuopt cuopt_grpc_server cuopt cuopt_server cuopt_sh_client cuopt_mcp java docs deb --run-java-tests -a -b -g -fsanitize -tsan -msan -v -l= --verbose-pdlp --build-lp-only --no-fetch-rapids --skip-c-python-adapters --skip-tests-build --skip-routing-build --skip-grpc-build --skip-fatbin-write --host-lineinfo --split-compile [--cmake-args=\\\"<args>\\\"] [--cache-tool=<tool>] --install --allgpuarch --ci-only-arch --show_depr_warn -h --help"
HELP="$0 [<target> ...] [<flag> ...]
where <target> is:
clean - remove all existing build artifacts and configuration (start over)
Expand All @@ -24,6 +24,7 @@ HELP="$0 [<target> ...] [<flag> ...]
cuopt - build the cuopt Python package
cuopt_server - build the cuopt_server Python package
cuopt_sh_client - build cuopt self host client
cuopt_mcp - build the cuopt_mcp Python package (MCP server)
java - build the cuopt Java bindings (requires libcuopt; needs maven and a JDK)
docs - build the docs
deb - build deb package (requires libcuopt to be built first)
Expand Down Expand Up @@ -56,7 +57,7 @@ HELP="$0 [<target> ...] [<flag> ...]
--show_depr_warn - show cmake deprecation warnings
-h - print this text

default action (no args) is to build 'libcuopt', 'cuopt', 'cuopt_server', and 'cuopt_sh_client' targets without installing into the conda environment (pass --install to also install libcuopt into the active conda environment; pass 'docs' explicitly to build documentation)
default action (no args) is to build 'libcuopt', 'cuopt', 'cuopt_server', 'cuopt_sh_client', and 'cuopt_mcp' targets without installing into the conda environment (pass --install to also install libcuopt into the active conda environment; pass 'docs' explicitly to build documentation)

libcuopt build dir is: ${LIBCUOPT_BUILD_DIR}

Expand Down Expand Up @@ -477,6 +478,12 @@ if buildAll || hasArg cuopt_sh_client; then
python "${PYTHON_ARGS_FOR_INSTALL[@]}" .
fi

# Build and install the cuopt_mcp Python package
if buildAll || hasArg cuopt_mcp; then
cd "${REPODIR}"/python/cuopt_mcp/
python "${PYTHON_ARGS_FOR_INSTALL[@]}" .
Comment thread
coderabbitai[bot] marked this conversation as resolved.
fi

# Build the Java bindings (opt-in; pass 'java' explicitly to build)
if hasArg java; then
if [ ! -f "${LIBCUOPT_BUILD_DIR}/libcuopt.so" ] && [ ! -f "${INSTALL_PREFIX}/lib/libcuopt.so" ]; then
Expand Down
10 changes: 10 additions & 0 deletions cpp/src/grpc/server/grpc_server_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include <argparse/argparse.hpp>
#include <cuopt/version_config.hpp>

#include <grpc/impl/channel_arg_names.h>
#include <pthread.h>

// Defined in grpc_service_impl.cpp
Expand Down Expand Up @@ -331,6 +332,11 @@ int main(int argc, char** argv)
auto service = create_cuopt_grpc_service();

ServerBuilder builder;
// gRPC enables SO_REUSEPORT by default, letting a second server silently
// bind an already-served port -- each process has its own job_tracker,
// so a request landing on the other process gets "Job ID not found".
// Always off; use --port for an independent second instance.
builder.AddChannelArgument<int>(GRPC_ARG_ALLOW_REUSEPORT, 0);
builder.AddListeningPort(server_address, creds);
builder.RegisterService(service.get());
const int64_t max_bytes = server_max_message_bytes();
Expand All @@ -342,6 +348,10 @@ int main(int argc, char** argv)
std::unique_ptr<Server> server(builder.BuildAndStart());
if (!server) {
SERVER_LOG_ERROR("[Server] Failed to bind to %s", server_address);
SERVER_LOG_ERROR(
"[Server] A server may already be listening there; check with "
"`pgrep -af cuopt_grpc_server`. Use --port for an independent second "
"instance.");
shutdown_all();
return 1;
}
Expand Down
1 change: 1 addition & 0 deletions dependencies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ files:
extras:
table: build-system
includes:
- rapids_build_setuptools
- build_wheels
py_run_cuopt_mcp:
output: pyproject
Expand Down
75 changes: 75 additions & 0 deletions python/cuopt_mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ speaks gRPC to the backend.
pip install cuopt_mcp
```

Not published yet — until then, build from source (see [Testing](#build-and-install-from-source)).

## Configure

Start the solver backend on a GPU host (5001 is `cuopt_grpc_server`'s own
Expand Down Expand Up @@ -50,6 +52,7 @@ needed) whenever `gpu-host` isn't a trusted local network.

| Tool | Purpose |
|------|---------|
| `cuopt_health` | Report the configured gRPC target and whether it answers |
| `cuopt_solve_lp` | Submit an LP; returns a `job_id` immediately |
| `cuopt_solve_milp` | Submit a MILP; returns a `job_id` immediately |
| `cuopt_status` | Poll job state |
Expand All @@ -63,6 +66,78 @@ needed) whenever `gpu-host` isn't a trusted local network.
Solves are asynchronous by design. A blocking call would exceed the MCP
client timeout on any realistic MILP and would make cancellation impossible.

## Testing

### Build and install from source

```bash
conda activate ./.cuopt_env # the repo-local env, see CONTRIBUTING.md
./build.sh cuopt_mcp # installs into the active env
```

This installs as plain `cuopt_mcp` (unsuffixed), matching its `cuopt`
dependency -- `./build.sh` installs every Python package against the
unsuffixed, CPU-only `cuopt`.

### Smoke test

```bash
cuopt_grpc_server --port 5001 &
python -c "from cuopt_mcp import tools; print(tools.health())"
```

`reachable: true` means the MCP server can see the backend. If it is false the
message names the endpoint and what to check — a wrong `CUOPT_REMOTE_PORT` and
a server that is not running look identical from the client side, so it does
not assume either.

### Test suite

```bash
# Unit tests: no GPU, no server, stubbed gRPC client
pytest python/cuopt_mcp/tests -q

# Plus end-to-end against a live server, over real MCP stdio
CUOPT_TEST_GRPC_PORT=5001 pytest python/cuopt_mcp/tests -q
```

Without `CUOPT_TEST_GRPC_PORT` the end-to-end tests skip rather than fail.

**The end-to-end fixture launches `cuopt-mcp` from `PATH`**, not from the
interpreter running pytest. If another environment shadows the one you built,
the suite silently exercises that install instead — which surfaces as
unrelated-looking failures such as `undefined symbol: _ZTIN3rmm...bad_allocE`
from an ABI mismatch. Check with `which cuopt-mcp` before believing a failure.

### Driving it from an MCP client

Point the client at the built entry point and confirm with `cuopt_health`
before submitting a model — every other tool reports a connection problem only
after a model has been built.

```json
{
"mcpServers": {
"cuopt": {
"command": "/path/to/.cuopt_env/bin/cuopt-mcp",
"env": { "CUOPT_REMOTE_HOST": "localhost", "CUOPT_REMOTE_PORT": "5001" }
}
}
}
```

A minimal end-to-end exercise: `cuopt_health`, then `cuopt_solve_lp` with a
small JSON model, then `cuopt_status` until terminal, then `cuopt_result`.

### If the backend looks unreachable

`cuopt-mcp` never starts or stops `cuopt_grpc_server`. Before starting one,
check whether one is already running on the configured port:

```bash
pgrep -af cuopt_grpc_server
```

## Design notes

**No per-job state.** Column names needed to label a solution are supplied
Expand Down
23 changes: 17 additions & 6 deletions python/cuopt_mcp/cuopt_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,19 @@ def endpoint() -> tuple:
return host, port


def _tls_config():
if os.environ.get("CUOPT_TLS_ENABLED", "").lower() not in (
def tls_enabled() -> bool:
"""True when ``CUOPT_TLS_ENABLED`` is "1"/"true"/"yes" (case-insensitive);
False for any other value, including unset.
"""
return os.environ.get("CUOPT_TLS_ENABLED", "").lower() in (
"1",
"true",
"yes",
):
)


def _tls_config():
if not tls_enabled():
return None
from cuopt.grpc.linear_programming import TlsConfig

Expand Down Expand Up @@ -136,9 +143,13 @@ def describe_connection_error(exc: Exception) -> CuOptMCPError:
host, port = endpoint()
text = str(exc)
if "UNAVAILABLE" in text or "failed to connect" in text.lower():
# "Start one" without this caveat invites a second server alongside
# one already running on another port/misconfigured env.
return CuOptMCPError(
f"cuOpt gRPC server unreachable at {host}:{port}. Start it with "
f"`cuopt_grpc_server --port {port}`, or set CUOPT_REMOTE_HOST / "
"CUOPT_REMOTE_PORT to point at a running server."
f"cuOpt gRPC server unreachable at {host}:{port}. Check whether "
"one is already running (`pgrep -af cuopt_grpc_server`) before "
"starting another, and confirm CUOPT_REMOTE_HOST / "
f"CUOPT_REMOTE_PORT point at it. Only if none is running, start "
f"one with `cuopt_grpc_server --port {port}`."
)
return CuOptMCPError(redact_paths(text))
89 changes: 82 additions & 7 deletions python/cuopt_mcp/cuopt_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
"Solves are asynchronous: cuopt_solve_lp / cuopt_solve_milp return a "
"job_id immediately, then poll cuopt_status and fetch cuopt_result. "
"Call cuopt_list_settings to discover solver parameters before "
"passing a settings object."
"passing a settings object. "
"This server is a client, not a solver: it needs a running "
"cuopt_grpc_server and never starts one. Call cuopt_health first to "
"see the configured host/port and whether it answers. If it does "
"not, check whether a server is already running before starting "
"another — two servers can end up sharing a port."
),
)

Expand Down Expand Up @@ -61,13 +66,51 @@ def _guard(fn, /, **kwargs) -> dict[str, Any]:
return {"error": "internal error -- see server logs"}


@server.tool(structured_output=True)
def cuopt_health() -> dict[str, Any]:
"""Report the configured gRPC target and whether it answers.

Takes no arguments. Returns host, port, tls, and reachable — plus, when
unreachable, the error and what to check. Worth calling before building
a model, since every other tool fails only after the model exists.

This MCP server never starts or stops cuopt_grpc_server. If the target
is unreachable, check for a server that is already running before
starting one.
"""
return _guard(tools.health)


@server.tool(structured_output=True)
def cuopt_solve_lp(
problem_path: str, settings: dict | None = None
problem_path: str | None = None,
settings: dict | None = None,
*,
problem: dict | None = None,
) -> dict[str, Any]:
"""Submit a linear program to cuOpt and return a job handle immediately.

Give the model exactly one of two ways:

problem_path: path to an MPS, QPS, or LP file readable by this process.
problem: the model as plain JSON arrays, with no file involved:
objective: cost per variable (its length defines the column count)
constraint_matrix: {"rows": [...], "cols": [...], "values": [...]}
COO triplets, or {"offsets", "indices", "values"} for CSR
constraint_lower_bounds / constraint_upper_bounds: one per row,
defaulting to -inf / +inf. Set both equal for an equality row.
Their length fixes the row count, so a trailing row with all
zero coefficients is kept rather than inferred away.
variable_lower_bounds / variable_upper_bounds: default 0 / +inf
variable_names: labels echoed back by cuopt_result
maximize: true to maximise (default false)
objective_offset: constant added to the objective

Bounds: use null for an unbounded side, since JSON has no infinity
literal. A magnitude of 1e30 or more is also read as infinite — left
finite, such a bound can make the solver return a constraint-violating
point reported as Optimal. Repeated matrix cells are summed.

settings: optional PDLP solver settings, e.g. {"time_limit": 60,
"method": "Barrier"}. Call cuopt_list_settings("pdlp_settings") for
the full list with descriptions and defaults. Omit any setting to
Expand All @@ -79,24 +122,55 @@ def cuopt_solve_lp(
return _guard(
tools.submit,
problem_path=problem_path,
problem=problem,
kind="pdlp_settings",
settings=settings,
)


@server.tool(structured_output=True)
def cuopt_solve_milp(
problem_path: str,
problem_path: str | None = None,
settings: dict | None = None,
track_incumbents: bool = False,
*,
problem: dict | None = None,
) -> dict[str, Any]:
"""Submit a mixed-integer program to cuOpt and return a job handle.

Give the model exactly one of two ways:

problem_path: path to an MPS or LP file declaring integer/binary
variables (LP: Generals/Binaries sections).
variables (LP: Generals/Binaries sections; MPS: an integer column
with no explicit bound entry silently defaults to [0, 1], which
turns an ordinary model infeasible for no visible reason).
problem: the model as plain JSON arrays. Prefer this over MPS for
integer models: variable_types carries integrality without
touching bounds. Keys — the same set cuopt_solve_lp takes, plus
variable_types, repeated here because a caller may hold this tool
without that one:
objective: cost per variable (its length defines the column count)
constraint_matrix: {"rows": [...], "cols": [...], "values": [...]}
COO triplets, or {"offsets", "indices", "values"} for CSR
constraint_lower_bounds / constraint_upper_bounds: one per row,
defaulting to -inf / +inf. Set both equal for an equality row.
Their length fixes the row count, so a trailing row with all
zero coefficients is kept rather than inferred away.
variable_lower_bounds / variable_upper_bounds: default 0 / +inf
variable_types: per-variable "I" (integer) or "C" (continuous)
variable_names: labels echoed back by cuopt_result
maximize: true to maximise (default false)
objective_offset: constant added to the objective

Bounds: use null for an unbounded side, since JSON has no infinity
literal. A magnitude of 1e30 or more is also read as infinite — left
finite, such a bound can make the solver return a constraint-violating
point reported as Optimal. Repeated matrix cells are summed.

settings: optional MIP solver settings, e.g. {"time_limit": 300,
"relative_mip_gap": 0.01}. Call cuopt_list_settings("mip_settings")
for the full list.
for the full list — parameter names are easy to guess wrong
(relative_mip_gap, not mip_relative_gap).
track_incumbents: set True to make cuopt_incumbents useful for this
job. Off by default -- it costs extra server-side work and network
transfer per incumbent found, worth paying only if you'll poll it.
Expand All @@ -107,6 +181,7 @@ def cuopt_solve_milp(
return _guard(
tools.submit,
problem_path=problem_path,
problem=problem,
kind="mip_settings",
settings=settings,
track_incumbents=track_incumbents,
Expand Down Expand Up @@ -140,8 +215,8 @@ def cuopt_result(
names_from: path to the problem file, to key values by variable name
rather than column index. Pass the "source" returned by the solve.
variables: fetch only these named variables.
nonzero_only: return only variables with a non-zero value — usually
what matters for a MILP.
nonzero_only: drop values within solver tolerance of zero (not
necessarily exact zero) — usually what matters for a MILP.
limit: maximum values returned inline. Beyond this the selected values
(respecting nonzero_only) are written to a file and its path
returned instead.
Expand Down
Loading
Loading