Scope: decouple cuopt_mcp from the cuopt package
Goal
Make cuopt_mcp a pure-Python, CUDA-free wheel so an MCP client can install it
on a laptop (uvx cuopt-mcp) and talk to a remote cuopt_grpc_server. This is
what server.py already claims — "the host needs no GPU" — and what today's
packaging contradicts, since installing it pulls cuopt-cu13 and the CUDA
runtime onto a machine whose only job is to open a socket.
Secondary payoff: the CUDA-suffixed wheel matrix, the conda recipe and the
per-platform CI jobs all collapse to one py3-none-any artifact. That matters
now because none of that wiring exists yet — this decides what gets built.
What already exists (do not rebuild)
| Asset |
Where |
Why it matters |
| Full wire contract |
cuopt_remote_service.proto, codegen/generated/cuopt_remote_data.proto |
"The protos and this document together fully specify the wire contract; any gRPC-capable language can implement a client. The cuOpt project does not consider this protocol private." — GRPC_INTERFACE.md |
| Codegen that already emits a Python-facing artifact |
generate_conversions.py:409 generate_mcp_schema() → cuopt_mcp_schema.json |
The "generate it, don't hand-maintain it" pattern is already implemented and shipped |
| Loader for that artifact |
cuopt_mcp/schema.py |
Already falls back between packaged _generated/ and the codegen output dir |
| Field ids for every array |
field_registry.yaml (array_id: 0,1,2…) |
These are exactly the ArrayChunk.field_id values on the wire |
schema.py's docstring already states the principle: "Nothing here
hand-maintains a second copy of the settings surface." This work extends that
from settings to problem and solution arrays.
The five couplings to remove
cuopt_mcp imports exactly five symbols from cuopt, all lazily:
| Symbol |
Site |
Replacement |
grpc…Client |
client.py:64 |
generated cuopt_remote_service_pb2_grpc stub over grpcio |
grpc…TlsConfig |
client.py:45 |
grpc.ssl_channel_credentials |
DataModel |
tools.py:169 |
populate ChunkedProblemHeader + array payloads directly |
SolverSettings |
tools.py:267 |
populate PDLPSolverSettings / MIPSolverSettings |
Read (MPS/LP/QPS) |
tools.py:61 |
open decision — see below |
DataModel is the one worth calling out: today the MCP converts JSON arrays →
DataModel → the C++ client takes it apart again into arrays on the wire. The
MCP's JSON input is already the wire shape. Removing the middle step removes a
CUDA-linked C++ type that exists only to be disassembled.
Design
Three layers, smallest first.
1. Transport. grpcio + stubs generated by grpcio-tools from the two
existing protos. Generated at build time and committed alongside the other
codegen output, so ci/verify_grpc_codegen.sh covers drift.
2. Mapping. JSON arrays ↔ proto messages. Emitted from field_registry.yaml
by a new generate_python_mapping() in generate_conversions.py, sitting
beside the existing generate_mcp_schema(). A field added to the registry then
appears on the C++ side, the proto, the MCP schema and the Python mapper — or
CI fails. There is no second interface to drift because there is no
hand-written interface.
3. Tools. tools.py / client.py swap their imports. The tool surface,
JSON contract and tests are unchanged — the existing 63 tests are the
regression suite for this refactor.
Phasing
Phase 1 — unary only. GRPC_INTERFACE.md explicitly offers this: "Stick to
unary SubmitJob/GetResult and raise max_message_bytes on both sides… a custom
client can send any problem up to ~2 GiB or so as a single round-trip and skip
the chunked machinery entirely." This removes the largest risk from the
critical path. Ships a working CUDA-free MCP.
Phase 2 — chunked path, only if needed. StartChunkedUpload /
SendArrayChunk / FinishChunkedUpload and the download mirror. Needed only
for problems past ~2 GiB, or for throughput (the doc measures ~580 MB/s chunked
vs ~280 MB/s unary on a 706 MB problem). Defer until a real workload asks.
Open decisions
- MPS/LP/QPS parsing (
Read). Options: (a) drop problem_path, JSON-only;
(b) add an RPC that ships file bytes and parses server-side; (c) pure-Python
parser. Recommend (b) — it keeps the feature, puts parsing where the C++
parser already lives, and lets a remote user submit an MPS file with no local
cuOpt. Costs a proto addition, so it wants Trevor's view.
- Package name. Dropping the dependency makes
cuopt-mcp (unsuffixed)
correct, via disable-cuda = true. Verified: that flag alone un-suffixes the
name and the dependency, which is only safe once the dependency is gone.
- Does
cuopt_mcp stay in this repo? In-repo keeps it next to
field_registry.yaml, which is what makes the generated mapper trustworthy.
Recommend staying.
Non-goals
- Replacing the Cython client. It stays the client for in-process/GPU users.
- Routing/VRP over MCP.
- Chunking in phase 1.
Risks
- Two implementations of the wire protocol. Mitigated by generating the
mapper from the registry and covering it with verify_grpc_codegen.sh; not
eliminated. The chunking layer in phase 2 would be genuinely hand-written and
is the real drift risk — another argument for deferring it.
- Endianness.
ArrayChunk.data is "raw native-endian element bytes". A
Python client must match; numpy.tobytes() is native-endian, so this is fine
in practice but needs an explicit test.
- Scope creep into a general Python client. The deliverable is an MCP
server, not a supported public Python gRPC client. Keep the generated mapper
private to the package until someone asks otherwise.
Rough effort
|
|
| Stub generation + build wiring |
small |
generate_python_mapping() emitter |
medium — the registry is already parsed; this is a new emitter beside generate_mcp_schema() |
tools.py / client.py swap |
small — 5 call sites, tests already exist |
| Server-side MPS RPC (if chosen) |
medium, and needs proto review |
| Phase 2 chunking |
medium-large, deferred |
Scope: decouple cuopt_mcp from the cuopt package
Goal
Make
cuopt_mcpa pure-Python, CUDA-free wheel so an MCP client can install iton a laptop (
uvx cuopt-mcp) and talk to a remotecuopt_grpc_server. This iswhat
server.pyalready claims — "the host needs no GPU" — and what today'spackaging contradicts, since installing it pulls
cuopt-cu13and the CUDAruntime onto a machine whose only job is to open a socket.
Secondary payoff: the CUDA-suffixed wheel matrix, the conda recipe and the
per-platform CI jobs all collapse to one
py3-none-anyartifact. That mattersnow because none of that wiring exists yet — this decides what gets built.
What already exists (do not rebuild)
cuopt_remote_service.proto,codegen/generated/cuopt_remote_data.protoGRPC_INTERFACE.mdgenerate_conversions.py:409generate_mcp_schema()→cuopt_mcp_schema.jsoncuopt_mcp/schema.py_generated/and the codegen output dirfield_registry.yaml(array_id: 0,1,2…)ArrayChunk.field_idvalues on the wireschema.py's docstring already states the principle: "Nothing herehand-maintains a second copy of the settings surface." This work extends that
from settings to problem and solution arrays.
The five couplings to remove
cuopt_mcpimports exactly five symbols fromcuopt, all lazily:grpc…Clientclient.py:64cuopt_remote_service_pb2_grpcstub overgrpciogrpc…TlsConfigclient.py:45grpc.ssl_channel_credentialsDataModeltools.py:169ChunkedProblemHeader+ array payloads directlySolverSettingstools.py:267PDLPSolverSettings/MIPSolverSettingsRead(MPS/LP/QPS)tools.py:61DataModelis the one worth calling out: today the MCP converts JSON arrays →DataModel→ the C++ client takes it apart again into arrays on the wire. TheMCP's JSON input is already the wire shape. Removing the middle step removes a
CUDA-linked C++ type that exists only to be disassembled.
Design
Three layers, smallest first.
1. Transport.
grpcio+ stubs generated bygrpcio-toolsfrom the twoexisting protos. Generated at build time and committed alongside the other
codegen output, so
ci/verify_grpc_codegen.shcovers drift.2. Mapping. JSON arrays ↔ proto messages. Emitted from
field_registry.yamlby a new
generate_python_mapping()ingenerate_conversions.py, sittingbeside the existing
generate_mcp_schema(). A field added to the registry thenappears on the C++ side, the proto, the MCP schema and the Python mapper — or
CI fails. There is no second interface to drift because there is no
hand-written interface.
3. Tools.
tools.py/client.pyswap their imports. The tool surface,JSON contract and tests are unchanged — the existing 63 tests are the
regression suite for this refactor.
Phasing
Phase 1 — unary only.
GRPC_INTERFACE.mdexplicitly offers this: "Stick tounary SubmitJob/GetResult and raise max_message_bytes on both sides… a custom
client can send any problem up to ~2 GiB or so as a single round-trip and skip
the chunked machinery entirely." This removes the largest risk from the
critical path. Ships a working CUDA-free MCP.
Phase 2 — chunked path, only if needed.
StartChunkedUpload/SendArrayChunk/FinishChunkedUploadand the download mirror. Needed onlyfor problems past ~2 GiB, or for throughput (the doc measures ~580 MB/s chunked
vs ~280 MB/s unary on a 706 MB problem). Defer until a real workload asks.
Open decisions
Read). Options: (a) dropproblem_path, JSON-only;(b) add an RPC that ships file bytes and parses server-side; (c) pure-Python
parser. Recommend (b) — it keeps the feature, puts parsing where the C++
parser already lives, and lets a remote user submit an MPS file with no local
cuOpt. Costs a proto addition, so it wants Trevor's view.
cuopt-mcp(unsuffixed)correct, via
disable-cuda = true. Verified: that flag alone un-suffixes thename and the dependency, which is only safe once the dependency is gone.
cuopt_mcpstay in this repo? In-repo keeps it next tofield_registry.yaml, which is what makes the generated mapper trustworthy.Recommend staying.
Non-goals
Risks
mapper from the registry and covering it with
verify_grpc_codegen.sh; noteliminated. The chunking layer in phase 2 would be genuinely hand-written and
is the real drift risk — another argument for deferring it.
ArrayChunk.datais "raw native-endian element bytes". APython client must match;
numpy.tobytes()is native-endian, so this is finein practice but needs an explicit test.
server, not a supported public Python gRPC client. Keep the generated mapper
private to the package until someone asks otherwise.
Rough effort
generate_python_mapping()emittergenerate_mcp_schema()tools.py/client.pyswap