mcp-modelmanager 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_modelmanager-0.1.0/LICENSE +21 -0
- mcp_modelmanager-0.1.0/PKG-INFO +544 -0
- mcp_modelmanager-0.1.0/README.md +519 -0
- mcp_modelmanager-0.1.0/pyproject.toml +65 -0
- mcp_modelmanager-0.1.0/setup.cfg +4 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/__init__.py +1 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/connection.py +501 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/estimation.py +161 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/jobs.py +170 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/operations.py +821 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/protocol.py +88 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/server.py +1984 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/services.py +206 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager/validation.py +768 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager.egg-info/PKG-INFO +544 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager.egg-info/SOURCES.txt +19 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager.egg-info/dependency_links.txt +1 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager.egg-info/entry_points.txt +2 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager.egg-info/requires.txt +2 -0
- mcp_modelmanager-0.1.0/src/mcp_modelmanager.egg-info/top_level.txt +1 -0
- mcp_modelmanager-0.1.0/tests/test_selftest.py +49 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Siegfried Emil Timothy Heerwagen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-modelmanager
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server to manage your own local Ollama/vLLM model machine over a secured two-channel SSH link, with a closed operation catalog.
|
|
5
|
+
Author: Siegfried Emil Timothy Heerwagen
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/setheerwagen/local-ai-mcp-servers
|
|
8
|
+
Project-URL: Repository, https://github.com/setheerwagen/local-ai-mcp-servers
|
|
9
|
+
Project-URL: Issues, https://github.com/setheerwagen/local-ai-mcp-servers/issues
|
|
10
|
+
Keywords: mcp,ollama,vllm,llm,self-hosted,local-ai
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Environment :: Console
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: mcp==1.29.0
|
|
23
|
+
Requires-Dist: httpx==0.28.1
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# mcp-modelmanager
|
|
27
|
+
|
|
28
|
+
**Manage your own local model machine through a language-model tool (Claude,
|
|
29
|
+
ChatGPT, etc.). The models and their data stay on your hardware; the server is
|
|
30
|
+
designed so the machine need not be opened to the internet by this software.**
|
|
31
|
+
|
|
32
|
+
This MCP server gives an AI session controlled access to **your own**
|
|
33
|
+
Ollama/model machine: read state, pull and remove models, switch the inference
|
|
34
|
+
service to a different model, build custom Ollama variants and run LoRA training.
|
|
35
|
+
The models run on your hardware. The server process itself talks only to your
|
|
36
|
+
own computer or local network, never to a public address, on its own. What it
|
|
37
|
+
does knowingly reach out to a public address for is a model: calling
|
|
38
|
+
`fetch_model` makes the model machine connect to Hugging Face at runtime to
|
|
39
|
+
download it. That is a deliberate, tool-triggered outbound connection, not
|
|
40
|
+
something the server does behind your back or only once at setup.
|
|
41
|
+
`switch_service` never reaches Hugging Face itself; if the model is not yet on
|
|
42
|
+
the machine, it refuses and tells you to run `fetch_model` first.
|
|
43
|
+
|
|
44
|
+
**What that does and does not promise.** The inference itself stays local: your
|
|
45
|
+
prompts to the *managed* models and their answers do not go to any cloud model
|
|
46
|
+
provider. But the language model you *drive this server with* (Claude, ChatGPT,
|
|
47
|
+
etc.) is itself a hosted service in the usual case. Your instructions to it and
|
|
48
|
+
the tool results it sees pass through that provider like any other MCP call.
|
|
49
|
+
"Local" here means the managed machine and its data; it is not a claim that
|
|
50
|
+
nothing you type ever reaches a third party. Choose the driving model
|
|
51
|
+
accordingly, or drive it with a local one.
|
|
52
|
+
|
|
53
|
+
**Operating systems:** The plain query path (reading status and models over the
|
|
54
|
+
tunnel) runs cross-platform. Management and setup (service and training control)
|
|
55
|
+
require a POSIX system with systemd (systemd user unit, `ssh -F /dev/null`);
|
|
56
|
+
Windows is not supported for management.
|
|
57
|
+
|
|
58
|
+
The server ships with **no** address of its own and **no** default values for a
|
|
59
|
+
machine. You enter where your machine is; without those details the server
|
|
60
|
+
deliberately does not start (see [Required fields](#required-fields) and
|
|
61
|
+
[SETUP.md](SETUP.md)).
|
|
62
|
+
|
|
63
|
+
## What the server can and cannot do
|
|
64
|
+
|
|
65
|
+
**It can:** query state (GPU memory, disk space, running service, containers),
|
|
66
|
+
list/pull/remove models, switch the vLLM inference service to a different model
|
|
67
|
+
(with an up-front estimate of the memory need), build custom Ollama variants
|
|
68
|
+
(fixed system prompt, parameters, later an adapter) and start/track/cancel LoRA
|
|
69
|
+
training.
|
|
70
|
+
|
|
71
|
+
**It cannot** and deliberately will not: run an arbitrary command on the
|
|
72
|
+
machine. There is **no** tool for free remote control. Every operation is a fixed
|
|
73
|
+
template; every parameter first passes an allowlist (see [Security
|
|
74
|
+
model](#security-model-fixed-operations)).
|
|
75
|
+
|
|
76
|
+
**It is not a query bridge.** For merely *asking* a model you use a separate,
|
|
77
|
+
read-only MCP server (e.g. a simple HTTP bridge to Ollama). This server *manages*
|
|
78
|
+
the machine. Two separate servers ensure that a query call never accidentally
|
|
79
|
+
holds management rights.
|
|
80
|
+
|
|
81
|
+
## Your machine: three possible cases
|
|
82
|
+
|
|
83
|
+
The model machine can be attached in different ways. All three cases are
|
|
84
|
+
supported; you only enter a different address.
|
|
85
|
+
|
|
86
|
+
| Case | Where Ollama/vLLM runs | What you enter as `MM_VM_HOST` |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| **a) same machine** | on the same computer as this server | `127.0.0.1` |
|
|
89
|
+
| **b) on the local network** | on another computer in the same LAN | its LAN address, e.g. `192.168.1.10` |
|
|
90
|
+
| **c) over tunnel/VPN** | on a remote computer, reachable via VPN/tunnel | the address at your own/VPN end, e.g. `127.0.0.1` for a local SSH tunnel, or the VPN address |
|
|
91
|
+
|
|
92
|
+
Safeguard: the server process's own MCP/SSH/HTTP traffic goes **only** to the own
|
|
93
|
+
computer or the local network. Addresses outside (public IPs, foreign hostnames)
|
|
94
|
+
are rejected as `MM_VM_HOST`, so the server does not reach out to a public
|
|
95
|
+
address on its own to talk to your model machine. For case c) this means: the
|
|
96
|
+
tunnel/VPN ends locally or in a private address range, and you enter **that**
|
|
97
|
+
local/private address, not the public address of the far side. This safeguard
|
|
98
|
+
does not cover model downloads: `fetch_model` makes the model machine itself
|
|
99
|
+
reach out to Hugging Face, a public address, whenever you ask it to fetch a
|
|
100
|
+
model (see the caveat above); `switch_service` refuses a model that is not
|
|
101
|
+
already on the machine instead of fetching it. `fetch_model(source="ollama")` sends
|
|
102
|
+
the machine to a second, equally deliberate public address: it makes the
|
|
103
|
+
machine's Ollama daemon pull the model via `/api/pull`, which reaches out to
|
|
104
|
+
`registry.ollama.ai` for plain model names; a prefixed name such as
|
|
105
|
+
`hf.co/user/repo` sends it to that other registry instead.
|
|
106
|
+
|
|
107
|
+
The plain **query** (state, version, loaded models) runs over HTTP to the service
|
|
108
|
+
port. **Management** (pulling models, switching the service, training) runs over
|
|
109
|
+
a secured SSH control channel. For management you therefore need SSH access to
|
|
110
|
+
the machine, also in case a) (then SSH to `127.0.0.1`).
|
|
111
|
+
|
|
112
|
+
## Installation
|
|
113
|
+
|
|
114
|
+
Requirement: Python 3.11 or newer.
|
|
115
|
+
|
|
116
|
+
**With uv (recommended):**
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
uv venv --seed .venv
|
|
120
|
+
.venv/bin/pip install -r requirements.txt
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`--seed` ensures `pip` is present in the venv.
|
|
124
|
+
|
|
125
|
+
**With Python built-ins (fallback):**
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
python3 -m venv .venv
|
|
129
|
+
.venv/bin/pip install -r requirements.txt
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Should `python3 -m venv` produce a venv without `pip` on your system (missing
|
|
133
|
+
`ensurepip`), use the uv path above.
|
|
134
|
+
|
|
135
|
+
There are only two **direct** dependencies, both pinned: the MCP SDK
|
|
136
|
+
(`mcp==1.29.0`) and an HTTP client (`httpx==0.28.1`). These two pins live in
|
|
137
|
+
`requirements.txt` and `pyproject.toml`; the full resolved dependency tree
|
|
138
|
+
(including the transitive packages `anyio`, `pydantic`, `starlette`,
|
|
139
|
+
`uvicorn`, `jsonschema`, `pyjwt`) is additionally pinned in `uv.lock`. CI
|
|
140
|
+
checks with `uv lock --check` that the lock still matches `pyproject.toml`,
|
|
141
|
+
then installs the exact locked tree for the tests, so a drifted lock fails CI.
|
|
142
|
+
A separate, advisory `pip-audit` job checks the locked tree for known
|
|
143
|
+
vulnerabilities on every push to main and pull request and again weekly; it
|
|
144
|
+
does not block a pull request. The SSH access uses the system's `ssh` program.
|
|
145
|
+
|
|
146
|
+
## Required fields
|
|
147
|
+
|
|
148
|
+
The server reads its configuration **exclusively** from environment variables.
|
|
149
|
+
These three you **must** set, otherwise it does not start:
|
|
150
|
+
|
|
151
|
+
| Field | Meaning |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `MM_VM_HOST` | Address of your model machine (`127.0.0.1`, LAN IP or tunnel/VPN address) |
|
|
154
|
+
| `MM_VM_USER` | SSH user name on the machine (only for management over the control channel) |
|
|
155
|
+
| `MM_CONTAINER_ROOT` | Root directory on your model machine under which all model, adapter and cache data live |
|
|
156
|
+
|
|
157
|
+
If one is missing, the start aborts with a clear message that says **which**
|
|
158
|
+
field is missing. Example:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
$ PYTHONPATH=src .venv/bin/python -m mcp_modelmanager.server
|
|
162
|
+
MM_CONTAINER_ROOT is not set. Enter the root directory on your model machine
|
|
163
|
+
under which all model, adapter and cache data live. Without this value the
|
|
164
|
+
server does not start. See SETUP.md.
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
All further fields have sensible defaults (Ollama and vLLM standard ports, key
|
|
168
|
+
storage in your own `~/.ssh`) and are only set when needed; see [Environment
|
|
169
|
+
variables](#environment-variables).
|
|
170
|
+
|
|
171
|
+
The **full** step-by-step setup from the empty state to the running server
|
|
172
|
+
(including the secured SSH channels) is in [SETUP.md](SETUP.md). Building the
|
|
173
|
+
GPU **model machine itself** (an Ollama and/or vLLM install reachable under
|
|
174
|
+
the `MM_*` variables below) is the machine-side task, and it ships **with this
|
|
175
|
+
repo**: the operator-side scripts (`setup/`, `vm_side/`), the network fence
|
|
176
|
+
templates (`security/`) and the machine-side guides ([MACHINE.md](MACHINE.md),
|
|
177
|
+
[SECURITY.md](SECURITY.md), [SETUP-MACHINE.md](SETUP-MACHINE.md)) sit next to
|
|
178
|
+
the server here. Start from [MACHINE.md](MACHINE.md) for the machine side.
|
|
179
|
+
|
|
180
|
+
## MCP registration (example with placeholders)
|
|
181
|
+
|
|
182
|
+
Register the server with your language-model tool. Replace the placeholders in
|
|
183
|
+
angle brackets with your values. Two ways to point the command at the server,
|
|
184
|
+
both supported:
|
|
185
|
+
|
|
186
|
+
**From a checkout** (no install step, `PYTHONPATH` points at `src/`):
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
{
|
|
190
|
+
"mcpServers": {
|
|
191
|
+
"modelmanager": {
|
|
192
|
+
"command": "/path/to/mcp-modelmanager/.venv/bin/python",
|
|
193
|
+
"args": ["-m", "mcp_modelmanager.server"],
|
|
194
|
+
"cwd": "/path/to/mcp-modelmanager",
|
|
195
|
+
"env": {
|
|
196
|
+
"PYTHONPATH": "src",
|
|
197
|
+
"MM_ACCESS": "direct",
|
|
198
|
+
"MM_VM_HOST": "<your-machine-or-127.0.0.1>",
|
|
199
|
+
"MM_VM_USER": "<your-ssh-user>",
|
|
200
|
+
"MM_CONTAINER_ROOT": "/srv/models"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**After `pip install .`** (the console entry point, no `cwd`/`PYTHONPATH` needed):
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"mcpServers": {
|
|
212
|
+
"modelmanager": {
|
|
213
|
+
"command": "/path/to/mcp-modelmanager/.venv/bin/mcp-modelmanager",
|
|
214
|
+
"env": {
|
|
215
|
+
"MM_ACCESS": "direct",
|
|
216
|
+
"MM_VM_HOST": "<your-machine-or-127.0.0.1>",
|
|
217
|
+
"MM_VM_USER": "<your-ssh-user>",
|
|
218
|
+
"MM_CONTAINER_ROOT": "/srv/models"
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Both blocks follow the plain `mcpServers` JSON shape most MCP clients read
|
|
226
|
+
(Claude Code, Claude Desktop and any other MCP client that reads an `mcpServers`
|
|
227
|
+
JSON block); consult your client's docs for where this block goes.
|
|
228
|
+
|
|
229
|
+
`MM_ACCESS` is `direct` (the server talks to the service ports under the given
|
|
230
|
+
address) or `tunnel` (it talks to `127.0.0.1` and builds an SSH tunnel on demand;
|
|
231
|
+
only sensible after the isolation, see SETUP.md). The default is `tunnel`; the
|
|
232
|
+
example above sets `direct` because that is what a case a)/b) setup without the
|
|
233
|
+
level-2 tunnel needs, so set `MM_ACCESS=direct` explicitly until that tunnel is
|
|
234
|
+
in place.
|
|
235
|
+
|
|
236
|
+
## Security model: fixed operations
|
|
237
|
+
|
|
238
|
+
There is **no** tool that runs an arbitrary command. Every operation is a fixed
|
|
239
|
+
template in `operations.py`. The caller can only fill named placeholders, and
|
|
240
|
+
every value must first pass an **allowlist** (`validation.py`): permitted is what
|
|
241
|
+
explicitly matches the agreed form. It is **not** filtered out, it is allowed in.
|
|
242
|
+
On substitution it is additionally quoted with `shlex.quote`; that is the net
|
|
243
|
+
below the net, not the safeguard.
|
|
244
|
+
|
|
245
|
+
Checked on **both** sides: here before sending and, independently again, by a
|
|
246
|
+
counterpart running on the model machine, which is bound to the
|
|
247
|
+
control-channel key. So even if that key is stolen, only the closed set of
|
|
248
|
+
operations is reachable.
|
|
249
|
+
|
|
250
|
+
Also: only targets on the own computer or the local network, names are resolved
|
|
251
|
+
and re-checked at each use (no address pinning like the query server; prefer
|
|
252
|
+
setting `MM_VM_HOST` to an IP if you use a hostname). No credentials in the
|
|
253
|
+
code, no `.env`.
|
|
254
|
+
The two SSH keys live **outside** the repo in your `~/.ssh`; the `.gitignore`
|
|
255
|
+
additionally excludes key patterns.
|
|
256
|
+
|
|
257
|
+
How the secured two-channel architecture (data channel for the tunnel, control
|
|
258
|
+
channel for management) is set up and verified is described step by step in
|
|
259
|
+
[SETUP.md](SETUP.md).
|
|
260
|
+
|
|
261
|
+
## Operation tiers and the operator gate
|
|
262
|
+
|
|
263
|
+
Every tool has a fixed classification, kept in one place in the code
|
|
264
|
+
(`TOOL_TIER`) so this table and the policy cannot drift apart:
|
|
265
|
+
|
|
266
|
+
| Tier | Meaning | Tools |
|
|
267
|
+
|---|---|---|
|
|
268
|
+
| **READ** | only observes, changes nothing | `status`, `models_present`, `job_status`, `list_jobs`, `list_variants`, `show_variant`, `list_adapters`, `training_status`, `check_training_env`, `check_isolation` |
|
|
269
|
+
| **CONTROL** | changes running state reversibly | `fetch_model`, `create_variant`, `switch_service`, `stop_service`, `start_service`, `start_training` |
|
|
270
|
+
| **DESTRUCTIVE** | removes or cancels | `remove_model`, `remove_variant`, `cancel_training` |
|
|
271
|
+
|
|
272
|
+
This classification is **not** an authorization system (see the note in
|
|
273
|
+
[SECURITY.md](SECURITY.md#authorization--scope)); it is a coarse operator switch
|
|
274
|
+
so you can narrow which operations this server will actually run, without editing
|
|
275
|
+
code. A disabled operation stays listed over MCP but is refused on call, before
|
|
276
|
+
any machine access:
|
|
277
|
+
|
|
278
|
+
| Variable | Default | Effect |
|
|
279
|
+
|---|---|---|
|
|
280
|
+
| `MM_MAX_TIER` | `read` | Highest tier the server will run. `read` exposes only READ tools; `control` exposes READ and CONTROL; `destructive` exposes all. |
|
|
281
|
+
| `MM_ENABLED_OPERATIONS` | empty (everything within `MM_MAX_TIER`) | Comma-separated **allowlist** of exact tool names, applied on top of `MM_MAX_TIER`: a tool runs only if it is both within `MM_MAX_TIER` and, when this list is set, named here. A typo in a name stops the server at startup rather than silently exposing more. |
|
|
282
|
+
|
|
283
|
+
**Default is read-only (deny-by-default).** Set nothing and only the READ tools
|
|
284
|
+
run; CONTROL and DESTRUCTIVE operations must be switched on explicitly. The gate
|
|
285
|
+
refuses a disabled operation **early and cleanly**, before any SSH or VM access,
|
|
286
|
+
with a message that names the tier and how to change it. To also allow the
|
|
287
|
+
management operations (`fetch_model`, `switch_service`, training, ...):
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
"env": { "MM_MAX_TIER": "control" }
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Or expose exactly two operations and nothing else, note that `MM_ENABLED_OPERATIONS`
|
|
294
|
+
narrows further on top of `MM_MAX_TIER`, so the tier must already admit both
|
|
295
|
+
named tools, here `switch_service` is CONTROL, so `MM_MAX_TIER` must be raised
|
|
296
|
+
to at least `control`:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
"env": { "MM_MAX_TIER": "control", "MM_ENABLED_OPERATIONS": "status,switch_service" }
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Concurrency guard
|
|
303
|
+
|
|
304
|
+
The machine has one GPU and one inference service, so two state changes at once
|
|
305
|
+
would leave it inconsistent. Every GPU-state-changing tool runs its critical
|
|
306
|
+
section serialized (`fetch_model` and `cancel_training` are excepted by design:
|
|
307
|
+
fetch is a background job, and cancel must always get through): while one runs,
|
|
308
|
+
a second is **refused immediately** (not queued), and the GPU-competing operations
|
|
309
|
+
(`switch_service`, `remove_model`, `create_variant`, `start_training`,
|
|
310
|
+
`start_service`) are refused while a training run this server started is still
|
|
311
|
+
active. The guard is in-process: it makes this server's own
|
|
312
|
+
calls consistent. A training run started from a *different* session is caught on
|
|
313
|
+
the machine side (a new `start_training` stops inference first), not fully
|
|
314
|
+
serialized here; that boundary is stated in [SECURITY.md](SECURITY.md).
|
|
315
|
+
|
|
316
|
+
## The tools
|
|
317
|
+
|
|
318
|
+
| Tool | Purpose |
|
|
319
|
+
|---|---|
|
|
320
|
+
| `status` | What is running: service, model, GPU memory, disk space, containers |
|
|
321
|
+
| `models_present` | Inventory on the machine, split by Ollama and Hugging Face |
|
|
322
|
+
| `fetch_model` | Fetch a model, returns immediately with an id |
|
|
323
|
+
| `job_status` | Progress of a background job |
|
|
324
|
+
| `list_jobs` | Known jobs here and on the machine |
|
|
325
|
+
| `remove_model` | Remove, only with a literal confirmation |
|
|
326
|
+
| `create_variant` | Build a custom Ollama variant: base model, system prompt, parameters |
|
|
327
|
+
| `list_variants` | Which variants exist, where they come from, what is set |
|
|
328
|
+
| `show_variant` | What a variant really contains, from both sources |
|
|
329
|
+
| `remove_variant` | Clear a variant away, only with a literal confirmation |
|
|
330
|
+
| `switch_service` | Switch vLLM to a different model, with estimate and cross-check |
|
|
331
|
+
| `stop_service`, `start_service` | Halt the service and bring it back up |
|
|
332
|
+
| `check_training_env` | Is the LoRA environment present and importable |
|
|
333
|
+
| `start_training` | LoRA run in the background, stops inference first and says so |
|
|
334
|
+
| `training_status` | Step, loss, estimated time remaining |
|
|
335
|
+
| `cancel_training` | End a run, with confirmation |
|
|
336
|
+
| `list_adapters` | Finished adapters with their base model |
|
|
337
|
+
| `check_isolation` | Tunnel works, LAN does not: both by the real behavior |
|
|
338
|
+
|
|
339
|
+
### Constraints of a machine with one GPU
|
|
340
|
+
|
|
341
|
+
- **Only one large model at a time.** `switch_service` computes beforehand
|
|
342
|
+
whether weights plus key-value cache plus base load fit into the allotted
|
|
343
|
+
memory, and rejects rather than trying. Computed with the real file sizes and
|
|
344
|
+
the model's `config.json`, not with a rule of thumb from the name; only if both
|
|
345
|
+
are missing is there a rough estimate, then explicitly marked as uncertain.
|
|
346
|
+
- **`switch_service` may only start a known container image and mount a known
|
|
347
|
+
path.** The container it (re)creates is checked against a closed image allowlist
|
|
348
|
+
(the configured vLLM image `MM_VLLM_IMAGE`, default `vllm/vllm-openai`, plus
|
|
349
|
+
`ollama/ollama`, optionally with a tag or digest; a mirrored vLLM image must be
|
|
350
|
+
set via `MM_VLLM_IMAGE` or it is rejected) and every bind mount's host side must
|
|
351
|
+
lie under one of the configured data roots; see [SECURITY.md](SECURITY.md) for
|
|
352
|
+
the full reasoning.
|
|
353
|
+
- **Inference and training do not share the card.** `start_training` rejects as
|
|
354
|
+
long as the confirmation to stop inference is not set, and names what would be
|
|
355
|
+
stopped. Its numeric hyper-parameters (epochs, rank, alpha, learning rate,
|
|
356
|
+
max length, batch size, accumulation) each have a fixed range that is checked
|
|
357
|
+
**on both sides**: here before the run is written, and independently again on
|
|
358
|
+
the machine before any value reaches the training library.
|
|
359
|
+
- **The machine does not always have the card.** If it is unreachable, the server
|
|
360
|
+
reports that as a state of its own with an explanation, not as an error. If a
|
|
361
|
+
second machine sharing the GPU is known via `MM_VM2_HOST`, the state is
|
|
362
|
+
`gpu_probably_on_second_machine`, otherwise simply `machine_unreachable`.
|
|
363
|
+
- **A switch that fails silently is the most dangerous failure mode.** After
|
|
364
|
+
every switch it is checked via `/health` and `/v1/models` whether the service
|
|
365
|
+
really answers and serves the desired model. The result is always in the report.
|
|
366
|
+
|
|
367
|
+
## Custom Ollama variants
|
|
368
|
+
|
|
369
|
+
Ollama lets you derive a model with a fixed system prompt, its own parameters and
|
|
370
|
+
later a trained adapter, via a **Modelfile**. The derivation then appears as its
|
|
371
|
+
own model name and is addressable like any other model. That is the path to
|
|
372
|
+
specialized agents and, at the same time, the path by which training results
|
|
373
|
+
enter operation.
|
|
374
|
+
|
|
375
|
+
```
|
|
376
|
+
create_variant(
|
|
377
|
+
name = "handyman:v1",
|
|
378
|
+
base_model = "qwen3:4b",
|
|
379
|
+
system_prompt = "You are a terse expert assistant ...",
|
|
380
|
+
parameters = {"num_ctx": 8192, "temperature": 0.2})
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### The delicate spot: free text that runs into a directive file
|
|
384
|
+
|
|
385
|
+
A system prompt is prose the user must be able to determine. But it lands in a
|
|
386
|
+
file Ollama reads as a **directive list**. Passing it through unchecked would
|
|
387
|
+
bypass the whole protection, not over the shell but a layer above it, in the
|
|
388
|
+
Modelfile itself. Four measures interlock:
|
|
389
|
+
|
|
390
|
+
1. **The Modelfile is assembled on the machine, not transferred.** Only parts go
|
|
391
|
+
over the control channel: base model, an object of parameter name and number,
|
|
392
|
+
an adapter path and the system prompt. There is no way to submit a finished
|
|
393
|
+
Modelfile.
|
|
394
|
+
2. **The permitted directives are in a closed list.** The template can produce
|
|
395
|
+
`FROM`, `PARAMETER`, `ADAPTER` and `SYSTEM` and nothing else. `FROM` takes an
|
|
396
|
+
Ollama model name and no path and is checked against the actual inventory.
|
|
397
|
+
3. **The free text travels as Base64.** No shell syntax and no newline can arise
|
|
398
|
+
in transit from the Base64 alphabet; it is unpacked only on the machine,
|
|
399
|
+
straight into the file.
|
|
400
|
+
4. **The character set of the free text contains no double quote.** A multi-line
|
|
401
|
+
Modelfile value ends with three of them; with the character missing from the
|
|
402
|
+
set, the text cannot end its own block and never become a directive. For
|
|
403
|
+
quotes the typographic quotation marks and the apostrophe are available.
|
|
404
|
+
|
|
405
|
+
Checked on **both** sides, since `validation.py` runs here as on the machine. A
|
|
406
|
+
crafted request placed straight into the control channel, past the check here, is
|
|
407
|
+
rejected by the wrapper on its own.
|
|
408
|
+
|
|
409
|
+
### The tunable parameters
|
|
410
|
+
|
|
411
|
+
Mapped are the ones actually worth using, not everything Ollama knows. Each has a
|
|
412
|
+
range that is checked before the value reaches the machine.
|
|
413
|
+
|
|
414
|
+
| Parameter | Type | Range | Effect |
|
|
415
|
+
|---|---|---|---|
|
|
416
|
+
| `num_ctx` | integer | 256 to 131072 | how much text the model keeps in view at once |
|
|
417
|
+
| `temperature` | number | 0.0 to 2.0 | randomness; 0 means the most repeatable answer |
|
|
418
|
+
| `top_p` | number | 0.0 to 1.0 | nucleus sampling by probability mass |
|
|
419
|
+
| `top_k` | integer | 1 to 200 | nucleus sampling by count |
|
|
420
|
+
| `min_p` | number | 0.0 to 1.0 | lower bound relative to the most probable token |
|
|
421
|
+
| `repeat_penalty` | number | 0.5 to 2.0 | repetition brake; 1.0 means off |
|
|
422
|
+
| `repeat_last_n` | integer | -1 to 4096 | lookback of the brake; -1 means num_ctx |
|
|
423
|
+
| `num_predict` | integer | -2 to 32768 | answer length; -1 unlimited, -2 until the context is full |
|
|
424
|
+
| `seed` | integer | 0 to 2147483647 | fixed seed for repeatable answers |
|
|
425
|
+
|
|
426
|
+
### Adapter: prepared but not live
|
|
427
|
+
|
|
428
|
+
The `adapter` parameter is built and checked, but the path has **never run**,
|
|
429
|
+
because there is no trained adapter in the reference state. What is present: the
|
|
430
|
+
allowlist for the adapter path, the conversion to the container's view, the
|
|
431
|
+
`ADAPTER` line in the template and the reading in the display tools. What has to
|
|
432
|
+
be settled with the first real adapter (Safetensors vs. GGUF format, the storage
|
|
433
|
+
location in the container, the fit to base model and quantization) is noted as
|
|
434
|
+
open in the code, in place. Until that is proven: leave `adapter` empty.
|
|
435
|
+
|
|
436
|
+
## Selftest
|
|
437
|
+
|
|
438
|
+
```
|
|
439
|
+
.venv/bin/python selftest.py # without the machine, HTTP is mocked
|
|
440
|
+
.venv/bin/python selftest.py --live # additionally against the real machine
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
Most groups run without a machine; only the optional `--live` group M needs
|
|
444
|
+
one. The operator-side setup scripts (`setup/`, `vm_side/`) that
|
|
445
|
+
some checks exercise ship **with this repo**, next to the server, so with them in
|
|
446
|
+
place the selftest runs additional checks on this machine, no model machine
|
|
447
|
+
required: on top of the server-code groups they cover the wrapper in actual
|
|
448
|
+
execution (group D), the setup script itself (group J), the isolation (group K)
|
|
449
|
+
and the operator policy, the concurrency guard and the isolation helpers (group
|
|
450
|
+
N). Allowlists against an attack battery, catalog and substitution, request
|
|
451
|
+
format, the service side against a stub server, memory estimation against
|
|
452
|
+
hand-checked values, connection layer, parsing of the machine outputs and the
|
|
453
|
+
variants all run and are checked. Should you run the server directory on its own,
|
|
454
|
+
with the `setup/` and `vm_side/` scripts removed from next to it, the selftest
|
|
455
|
+
still passes the server-code groups and skips groups D, J, K
|
|
456
|
+
and the operator-only parts of I and N cleanly with a note, not counted as
|
|
457
|
+
failed. Neither scenario needs a model machine. Only the `--live` group M does: it
|
|
458
|
+
runs against a reachable, set-up machine and is optional on top of either
|
|
459
|
+
scenario. (The exact check count is what the runner prints on the run in front
|
|
460
|
+
of you; it grows as checks are added, so it is not quoted here as a fixed
|
|
461
|
+
number and is not contractual.)
|
|
462
|
+
|
|
463
|
+
`--live` does NOT read `MM_VM_HOST`/`MM_VM_USER`/`MM_CONTAINER_ROOT`; it uses
|
|
464
|
+
its own, separate variables, defaulting to an address that is never reachable
|
|
465
|
+
(`TEST-NET`, RFC 5737) so a plain `--live` run without further setup is
|
|
466
|
+
silently skipped rather than a false pass:
|
|
467
|
+
|
|
468
|
+
| Name | Default | Meaning |
|
|
469
|
+
|---|---|---|
|
|
470
|
+
| `MM_LIVE_HOST` | `192.0.2.10` | Address of the machine group M probes |
|
|
471
|
+
| `MM_LIVE_PORT_VLLM` | `8000` | vLLM port on that machine |
|
|
472
|
+
| `MM_LIVE_PORT_OLLAMA` | `11434` | Ollama port on that machine |
|
|
473
|
+
|
|
474
|
+
Set all three to point `--live` at your actual, reachable machine; otherwise
|
|
475
|
+
you will see "VM not reachable, live group is skipped. Not an error." even
|
|
476
|
+
though `MM_VM_HOST` itself is configured correctly for normal server use.
|
|
477
|
+
|
|
478
|
+
## Environment variables
|
|
479
|
+
|
|
480
|
+
Required fields are marked **required**; they have no default value.
|
|
481
|
+
|
|
482
|
+
| Name | Default | Meaning |
|
|
483
|
+
|---|---|---|
|
|
484
|
+
| `MM_VM_HOST` | **required** | Address of the model machine (`127.0.0.1`, LAN IP or tunnel/VPN address) |
|
|
485
|
+
| `MM_VM_USER` | **required** | SSH user on the machine |
|
|
486
|
+
| `MM_ACCESS` | `tunnel` | `tunnel` (over the SSH tunnel to `127.0.0.1`) or `direct` (straight to the address) |
|
|
487
|
+
| `MM_MAX_TIER` | `read` | Highest operation tier the server exposes: `read`, `control` or `destructive` (see [Operation tiers](#operation-tiers-and-the-operator-gate)) |
|
|
488
|
+
| `MM_ENABLED_OPERATIONS` | empty | Optional comma-separated allowlist of exact tool names; when set, only those run. A typo aborts startup |
|
|
489
|
+
| `MM_VM2_HOST` | empty | Optional: second machine sharing the GPU, to interpret unreachability. Leave empty if there is only one machine |
|
|
490
|
+
| `MM_KEY_DIR` | `~/.ssh/modelmanager` | Storage of the two SSH keys (outside the repo) |
|
|
491
|
+
| `MM_KEY_DATA` | `${MM_KEY_DIR}/datachannel` | Private key of the data channel |
|
|
492
|
+
| `MM_KEY_CONTROL` | `${MM_KEY_DIR}/controlchannel` | Private key of the control channel |
|
|
493
|
+
| `MM_KNOWN_HOSTS` | `${MM_KEY_DIR}/known_hosts` | Pinned host key file of the model machine |
|
|
494
|
+
| `MM_FROM` | **required** (only while building the level-2 allowlist) | Permitted sender address(es) of the two keys, several comma-separated |
|
|
495
|
+
| `MM_FROM_NET_ALLOWED` | `no` | Set to `yes` to let `MM_FROM` allow a whole network, with a warning (only while building the level-2 allowlist) |
|
|
496
|
+
| `MM_REMOTE_PORT_VLLM` | `8000` | vLLM port on the machine (vLLM's standard port) |
|
|
497
|
+
| `MM_REMOTE_PORT_OLLAMA` | `11434` | Ollama port on the machine (Ollama's standard port) |
|
|
498
|
+
| `MM_LOCAL_PORT_VLLM` | `8000` | Local port under which the tunnel exposes vLLM |
|
|
499
|
+
| `MM_LOCAL_PORT_OLLAMA` | `11434` | Local port under which the tunnel exposes Ollama |
|
|
500
|
+
| `MM_CONTAINER_VLLM` | `vllm` | Name of the vLLM Docker container |
|
|
501
|
+
| `MM_CONTAINER_OLLAMA` | `ollama` | Name of the Ollama Docker container |
|
|
502
|
+
| `MM_VLLM_IMAGE` | `vllm/vllm-openai` | vLLM image the recreate allowlist accepts; set this to a mirrored/renamed image, otherwise it is rejected |
|
|
503
|
+
| `MM_MIN_DISK_GB` | `15` | Lower bound for an Ollama pull |
|
|
504
|
+
| `MM_CONTAINER_ROOT` | **required, no default** | Root directory on the machine under which all model, adapter and cache data live; the server does not start without it |
|
|
505
|
+
| `MM_VM_ROOT` | `${MM_CONTAINER_ROOT}/manager` | This tool's own working root on the machine (job folders live beneath it) |
|
|
506
|
+
| `MM_VM_DATA_ROOT` | `${MM_CONTAINER_ROOT}` | Root a training dataset path must lie under (`data_path_check`, `df` usage report) |
|
|
507
|
+
| `MM_VM_ADAPTER` | `${MM_CONTAINER_ROOT}/adapter` | Where a finished LoRA training run stores its adapter |
|
|
508
|
+
| `MM_LORA_PYTHON` | `${MM_CONTAINER_ROOT}/lora-venv/bin/python` | Interpreter of the LoRA training environment on the machine |
|
|
509
|
+
| `MM_VM_OLLAMA_STORE` | `${MM_CONTAINER_ROOT}/ollama` | Ollama's data folder on the machine (mounted into the container, see table below) |
|
|
510
|
+
| `MM_IN_CONTAINER_STORE` | `/root/.ollama` | The same folder's path AS SEEN INSIDE the Ollama container |
|
|
511
|
+
| `MM_VM_HF_CACHE` | `${MM_CONTAINER_ROOT}/hf` | Root of the Hugging Face weights cache on the machine |
|
|
512
|
+
| `MM_GPU_TOTAL_GB` | `12.0` | Assumed GPU memory in GB, used only when `nvidia-smi`'s real reading fails |
|
|
513
|
+
| `MM_AUDIT` (machine-side, not set here) | `${MM_VM_ROOT}/audit.jsonl` | Path of the audit log on the model machine itself; read there by the machine-side wrapper, defaulting to the `audit.jsonl` that `install.sh` creates. Not read by this server |
|
|
514
|
+
| `MM_ENFORCE_ROOT_OWNED` (machine-side, not set here) | unset | Set to `1` to force the machine-side wrapper's root-owned guard on `lib/` even in a checkout copy where `install.sh` has not flipped it on. Can only turn the guard ON, never off, so the environment cannot weaken an installed copy. Used by the self-test; not read by this server |
|
|
515
|
+
| `MM_STATE_DIR` | `$XDG_STATE_HOME/mcp-modelmanager` (or `~/.local/state/mcp-modelmanager`) | Where this server side stores the local job state (`fetch_model`/`start_training` job files) |
|
|
516
|
+
|
|
517
|
+
The local tunnel ports default to the same well-known values as the remote
|
|
518
|
+
ports; adjust them if those are already taken locally.
|
|
519
|
+
|
|
520
|
+
`MM_CONTAINER_ROOT` is required and has no built-in default (there is no
|
|
521
|
+
default machine layout this tool could assume); without it the server exits
|
|
522
|
+
at startup. The six `MM_VM_*`/`MM_LORA_PYTHON` variables above derive from it
|
|
523
|
+
by default and stay individually overridable, exactly like `MM_IN_CONTAINER_STORE`,
|
|
524
|
+
so a machine with a DIFFERENT directory layout can be adapted through
|
|
525
|
+
configuration alone, no code change needed. Every value is checked at startup
|
|
526
|
+
against a strict allowlist for VM paths (absolute, letters/digits/dot/hyphen/
|
|
527
|
+
underscore/slash only, no `..`, no spaces, no shell characters); a malformed
|
|
528
|
+
value stops the server immediately instead of being trusted. `MM_VM_DATA_ROOT`
|
|
529
|
+
and `MM_VM_OLLAMA_STORE` additionally become the allowlist roots for every path
|
|
530
|
+
a tool call may pass in later (a training dataset path, an adapter path), so
|
|
531
|
+
they stay consistent with what is actually configured.
|
|
532
|
+
|
|
533
|
+
Places on the machine that a variant needs, derived from the roots above (in
|
|
534
|
+
`operations.py`, not separately settable). The setup creates these paths:
|
|
535
|
+
|
|
536
|
+
| On the machine | In the container | For |
|
|
537
|
+
|---|---|---|
|
|
538
|
+
| `MM_VM_OLLAMA_STORE` (default `${MM_CONTAINER_ROOT}/ollama`) | `MM_IN_CONTAINER_STORE` (default `/root/.ollama`) | Ollama's data folder, mounted |
|
|
539
|
+
| `<MM_VM_OLLAMA_STORE>/variants/<name>/Modelfile` | `<MM_IN_CONTAINER_STORE>/variants/...` | the assembled Modelfile |
|
|
540
|
+
| `<MM_VM_OLLAMA_STORE>/adapter` | `<MM_IN_CONTAINER_STORE>/adapter` | storage for adapters |
|
|
541
|
+
|
|
542
|
+
## License
|
|
543
|
+
|
|
544
|
+
MIT, see [LICENSE](LICENSE).
|