* feat(studio): expose opt-in MCP control plane * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden Studio MCP tools: byte-safe auth, page clamping, forward export/checkpoint fields Follow-up hardening on the opt-in MCP control plane. All changes are additive and backwards compatible. - BearerTokenMiddleware now compares the Authorization header on raw bytes. A non-ASCII bearer value previously reached str-based hmac.compare_digest, which raises TypeError and surfaced as a 500 instead of a clean 401. The constructor also rejects an empty or whitespace-only token so an empty token can never match an empty "Bearer " header. - MCP tools call the route functions directly, which skips FastAPI Query validation. list_training_runs and get_recipe_job_dataset now clamp limit and offset to the same bounds the HTTP routes enforce (a negative SQLite LIMIT otherwise means "no limit"). - export_gguf forwards hf_token (the backend rejects a Hub upload without it), accepts a list of quantization methods, and exposes imatrix / imatrix_path so the IQ low-bit quants are reachable. - load_checkpoint forwards hf_token and approved_remote_code_fingerprint so gated checkpoints and the remote-code approval retry work. Its docstring is corrected: the export backend coexists with training and inference rather than freeing GPU work. - start_training passes via_api_key=False explicitly instead of relying on the unfilled Depends default. Tests: add coverage for non-ASCII and empty-token auth, the correct-token pass through, non-http scope pass through, pagination clamping, and the forwarded export/checkpoint fields. * Harden Studio MCP: cap /mcp request bodies, reject unusable tokens, fix docs Follow-up hardening from a full review pass. All changes are additive and backwards compatible. - Add "/mcp" to _BODY_PROTECTED_PREFIXES so MaxBodyMiddleware enforces the same request-body cap it already applies to every other write endpoint (/api/train, /api/export, /api/data-recipe, ...). The MCP endpoint accepts authenticated POST tool-call bodies; without this an authenticated client could send an unbounded body. The middleware only buffers the request body (not the SSE response), so streaming is unaffected, and the 500MB default cap never affects a real JSON-RPC tool call (verified live). - Reject a non-ASCII UNSLOTH_STUDIO_MCP_TOKEN at construction. HTTP header values are ASCII, so a non-ASCII token cannot be sent by a standard client and would silently lock out the endpoint; fail fast instead. - MCP.md: document the canonical /mcp/ endpoint and note that /mcp redirects to it, so clients that do not follow redirected POSTs still connect. Tests: add non-ASCII token rejection coverage. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten MCP server comments --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com>
290 lines
8.1 KiB
Python
290 lines
8.1 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import asyncio
|
|
import sys
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from mcp_server import BearerTokenMiddleware, _clamp, _dump, create_studio_mcp
|
|
|
|
|
|
def _get_tool(name):
|
|
tools = asyncio.run(create_studio_mcp().list_tools())
|
|
return {tool.name: tool for tool in tools}[name]
|
|
|
|
|
|
def test_studio_mcp_registers_control_plane_tools():
|
|
tools = asyncio.run(create_studio_mcp().list_tools())
|
|
|
|
assert {tool.name for tool in tools} == {
|
|
"studio_status",
|
|
"list_local_models",
|
|
"get_training_status",
|
|
"start_training",
|
|
"stop_training",
|
|
"list_training_runs",
|
|
"validate_recipe",
|
|
"get_recipe_job_status",
|
|
"get_recipe_job_dataset",
|
|
"load_checkpoint",
|
|
"export_gguf",
|
|
}
|
|
|
|
|
|
def test_dump_serializes_pydantic_values():
|
|
class Response:
|
|
def model_dump(self, *, mode):
|
|
assert mode == "json"
|
|
return {"ok": True}
|
|
|
|
assert _dump(Response()) == {"ok": True}
|
|
assert _dump({"already": "json"}) == {"already": "json"}
|
|
|
|
|
|
def test_bearer_token_middleware_rejects_wrong_token():
|
|
events = []
|
|
|
|
async def app(scope, receive, send):
|
|
events.append("app")
|
|
|
|
async def send(message):
|
|
events.append(message)
|
|
|
|
middleware = BearerTokenMiddleware(app, "secret")
|
|
asyncio.run(
|
|
middleware(
|
|
{"type": "http", "headers": [(b"authorization", b"Bearer wrong")]},
|
|
None,
|
|
send,
|
|
)
|
|
)
|
|
|
|
assert events[0]["status"] == 401
|
|
assert "app" not in events
|
|
|
|
|
|
def test_bearer_token_middleware_closes_unauthorized_websocket():
|
|
events = []
|
|
|
|
async def app(scope, receive, send):
|
|
events.append("app")
|
|
|
|
async def send(message):
|
|
events.append(message)
|
|
|
|
middleware = BearerTokenMiddleware(app, "secret")
|
|
asyncio.run(
|
|
middleware(
|
|
{"type": "websocket", "headers": []},
|
|
None,
|
|
send,
|
|
)
|
|
)
|
|
|
|
assert events == [{"type": "websocket.close", "code": 4401}]
|
|
|
|
|
|
def test_bearer_token_middleware_rejects_non_ascii_authorization():
|
|
# A non-ASCII bearer value must produce a clean 401, not a 500. Comparing on
|
|
# bytes avoids the str hmac.compare_digest TypeError on non-ASCII input.
|
|
events = []
|
|
|
|
async def app(scope, receive, send):
|
|
events.append("app")
|
|
|
|
async def send(message):
|
|
events.append(message)
|
|
|
|
middleware = BearerTokenMiddleware(app, "secret")
|
|
asyncio.run(
|
|
middleware(
|
|
{"type": "http", "headers": [(b"authorization", b"Bearer \xff\xff")]},
|
|
None,
|
|
send,
|
|
)
|
|
)
|
|
|
|
assert events[0]["status"] == 401
|
|
assert "app" not in events
|
|
|
|
|
|
def test_bearer_token_middleware_accepts_correct_token():
|
|
events = []
|
|
|
|
async def app(scope, receive, send):
|
|
events.append("app")
|
|
|
|
async def send(message):
|
|
events.append(message)
|
|
|
|
middleware = BearerTokenMiddleware(app, "secret")
|
|
asyncio.run(
|
|
middleware(
|
|
{"type": "http", "headers": [(b"authorization", b"Bearer secret")]},
|
|
None,
|
|
send,
|
|
)
|
|
)
|
|
|
|
assert events == ["app"]
|
|
|
|
|
|
def test_bearer_token_middleware_requires_non_empty_token():
|
|
async def app(scope, receive, send):
|
|
pass
|
|
|
|
for bad in ("", " "):
|
|
with pytest.raises(ValueError):
|
|
BearerTokenMiddleware(app, bad)
|
|
|
|
|
|
def test_bearer_token_middleware_rejects_non_ascii_token():
|
|
async def app(scope, receive, send):
|
|
pass
|
|
|
|
# non-ASCII tokens cannot be transmitted in an HTTP header by a standard
|
|
# client, so they are rejected at construction instead of locking out.
|
|
for bad in ("töken", "\U0001f600"):
|
|
with pytest.raises(ValueError):
|
|
BearerTokenMiddleware(app, bad)
|
|
|
|
|
|
def test_bearer_token_middleware_passes_through_non_http_scopes():
|
|
events = []
|
|
|
|
async def app(scope, receive, send):
|
|
events.append("app")
|
|
|
|
async def send(message):
|
|
events.append(message)
|
|
|
|
middleware = BearerTokenMiddleware(app, "secret")
|
|
asyncio.run(middleware({"type": "lifespan"}, None, send))
|
|
|
|
assert events == ["app"]
|
|
|
|
|
|
def test_clamp_restricts_to_inclusive_bounds():
|
|
assert _clamp(5, 1, 200) == 5
|
|
assert _clamp(-10, 1, 200) == 1
|
|
assert _clamp(10_000, 1, 200) == 200
|
|
assert _clamp(0, 1, 500) == 1
|
|
assert _clamp(1_000, 1, 500) == 500
|
|
|
|
|
|
def test_export_and_checkpoint_tools_expose_forwarded_fields():
|
|
export_props = set(_get_tool("export_gguf").parameters["properties"])
|
|
assert {"hf_token", "imatrix", "imatrix_path"} <= export_props
|
|
|
|
checkpoint_props = set(_get_tool("load_checkpoint").parameters["properties"])
|
|
assert {"hf_token", "approved_remote_code_fingerprint"} <= checkpoint_props
|
|
|
|
|
|
def _stub_module(monkeypatch, name, **attrs):
|
|
module = types.ModuleType(name)
|
|
for key, value in attrs.items():
|
|
setattr(module, key, value)
|
|
if "." in name:
|
|
module.__path__ = [] # mark package-like so submodule imports resolve
|
|
monkeypatch.setitem(sys.modules, name, module)
|
|
return module
|
|
|
|
|
|
def test_export_gguf_forwards_hf_token_and_imatrix(monkeypatch):
|
|
captured = {}
|
|
|
|
class FakeExportGGUFRequest:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
async def fake_export(request, current_subject):
|
|
return {"current_subject": current_subject}
|
|
|
|
_stub_module(monkeypatch, "models", ExportGGUFRequest = FakeExportGGUFRequest)
|
|
_stub_module(monkeypatch, "routes")
|
|
_stub_module(monkeypatch, "routes.export", export_gguf = fake_export)
|
|
|
|
tool = _get_tool("export_gguf")
|
|
result = asyncio.run(
|
|
tool.fn(
|
|
save_directory = "/tmp/out",
|
|
quantization_method = ["Q4_K_M", "Q8_0"],
|
|
push_to_hub = True,
|
|
repo_id = "me/model",
|
|
hf_token = "hf_secret",
|
|
imatrix = True,
|
|
imatrix_path = "/tmp/imatrix.dat",
|
|
)
|
|
)
|
|
|
|
assert captured["hf_token"] == "hf_secret"
|
|
assert captured["imatrix"] is True
|
|
assert captured["imatrix_path"] == "/tmp/imatrix.dat"
|
|
assert captured["quantization_method"] == ["Q4_K_M", "Q8_0"]
|
|
assert result["current_subject"] == "mcp"
|
|
|
|
|
|
def test_load_checkpoint_forwards_token_and_fingerprint(monkeypatch):
|
|
captured = {}
|
|
|
|
class FakeLoadCheckpointRequest:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
async def fake_load(request, current_subject):
|
|
return {"current_subject": current_subject}
|
|
|
|
_stub_module(monkeypatch, "models", LoadCheckpointRequest = FakeLoadCheckpointRequest)
|
|
_stub_module(monkeypatch, "routes")
|
|
_stub_module(monkeypatch, "routes.export", load_checkpoint = fake_load)
|
|
|
|
tool = _get_tool("load_checkpoint")
|
|
asyncio.run(
|
|
tool.fn(
|
|
checkpoint_path = "/tmp/ckpt",
|
|
approved_remote_code_fingerprint = "sha256:abc",
|
|
hf_token = "hf_secret",
|
|
)
|
|
)
|
|
|
|
assert captured["hf_token"] == "hf_secret"
|
|
assert captured["approved_remote_code_fingerprint"] == "sha256:abc"
|
|
|
|
|
|
def test_list_training_runs_clamps_pagination(monkeypatch):
|
|
captured = {}
|
|
|
|
async def fake_list_runs(limit, offset, current_subject):
|
|
captured["limit"] = limit
|
|
captured["offset"] = offset
|
|
return {"ok": True}
|
|
|
|
_stub_module(monkeypatch, "routes")
|
|
_stub_module(monkeypatch, "routes.training_history", list_training_runs = fake_list_runs)
|
|
|
|
tool = _get_tool("list_training_runs")
|
|
asyncio.run(tool.fn(limit = 10_000, offset = -5))
|
|
|
|
assert captured["limit"] == 200
|
|
assert captured["offset"] == 0
|
|
|
|
|
|
def test_get_recipe_job_dataset_clamps_pagination(monkeypatch):
|
|
captured = {}
|
|
|
|
def fake_job_dataset(job_id, limit, offset):
|
|
captured["limit"] = limit
|
|
captured["offset"] = offset
|
|
return {"ok": True}
|
|
|
|
_stub_module(monkeypatch, "routes")
|
|
_stub_module(monkeypatch, "routes.data_recipe")
|
|
_stub_module(monkeypatch, "routes.data_recipe.jobs", job_dataset = fake_job_dataset)
|
|
|
|
tool = _get_tool("get_recipe_job_dataset") # this tool is synchronous
|
|
tool.fn(job_id = "job-1", limit = -1, offset = -9)
|
|
|
|
assert captured["limit"] == 1
|
|
assert captured["offset"] == 0
|