From 1911ed1b938ad855f53fc765978b53b3da0b67b1 Mon Sep 17 00:00:00 2001 From: Alexius Wadell Date: Wed, 29 Jul 2026 19:34:30 -0600 Subject: [PATCH 1/5] Migrate to FastMCP v4 beta --- docs/reference/otel.md | 28 ++- pyproject.toml | 4 +- src/nomad/gateway/middleware/logging.py | 2 +- src/nomad/gateway/middleware/telemetry.py | 33 +--- src/nomad/gateway/sandbox.py | 2 +- src/nomad/gateway/server.py | 150 +++++++--------- src/nomad/otel.py | 14 +- src/nomad/tool_search.py | 2 +- test/gateway/test_gateway.py | 3 - test/gateway/test_telemetry.py | 84 +++------ test/gateway/test_workspace.py | 1 - uv.lock | 206 +++++++++++++++++----- 12 files changed, 282 insertions(+), 247 deletions(-) diff --git a/docs/reference/otel.md b/docs/reference/otel.md index 010bdbb..e0978e2 100644 --- a/docs/reference/otel.md +++ b/docs/reference/otel.md @@ -1,8 +1,12 @@ # OpenTelemetry -Nomad emits OpenTelemetry metrics and spans for model serving and the code-mode -gateway when telemetry export is enabled. Install the optional dependency extra -to include the OTel SDK and OTLP/gRPC exporter: +FastMCP owns MCP tracing and trace-context propagation. Nomad adds domain +metrics and child spans for work below that MCP boundary, such as code-mode +sandbox execution. Both use the same process-wide OpenTelemetry provider. + +FastMCP instrumentation is active by default and is a no-op until an OTel SDK +is configured. Install Nomad's optional dependency extra to let Nomad configure +that SDK with OTLP/gRPC trace and metric exporters: ```bash pip install "nomad-scifm[otel]" @@ -28,6 +32,16 @@ service name. Set `telemetry.otlp_endpoint` or `OTEL_EXPORTER_OTLP_ENDPOINT` to control the collector endpoint. Set `OTEL_SDK_DISABLED=true` to disable SDK setup. +Nomad configures a provider only when none exists. If the process was launched +with `opentelemetry-instrument` or an embedding application already installed a +provider, Nomad and FastMCP use that provider without replacing it. Nomad only +flushes providers it configured itself. + +Use `FASTMCP_TELEMETRY_MODE` to control FastMCP tracing: `native` (the default) +creates MCP spans and propagates context, `propagation_only` propagates context +without MCP spans, and `off` disables both. Nomad's custom spans use FastMCP's +tracer and therefore follow the same mode. + For OTel environment variable behavior and exporter configuration, see the [OpenTelemetry Python documentation](https://opentelemetry.io/docs/languages/python/) and @@ -35,9 +49,11 @@ and ## Spans -`nomad serve` creates spans around managed Torch tool requests and batch -execution. The code-mode gateway creates spans for MCP tool entrypoints, sandbox -runs, and upstream MCP tool calls. +FastMCP creates the server and client MCP spans, including `tools/call` spans +for SciFM tools, code-mode entrypoints, and upstream calls. Nomad does not wrap +those boundaries a second time. The code-mode gateway adds the +`nomad.gateway.sandbox.run` child span for sandbox execution details; Nomad +metrics continue to cover entrypoints, sandbox runs, and upstream calls. ## Model-serving metrics diff --git a/pyproject.toml b/pyproject.toml index 2f32e49..cf6c5bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,9 @@ description = "MCP-Server for SciFMs" readme = "README.md" requires-python = ">=3.12" dependencies = [ - "fastmcp>=3.0.0", + "fastmcp[tasks]>=4.0.0b1,<5.0", "filelock>=3.13.0", - "mcp>=1.20.0,<2.0", + "mcp>=2.0.0,<3.0", "pydantic>=2.12.0,<3.0", "pyyaml>=6.0.3", "tantivy>=0.25.0", diff --git a/src/nomad/gateway/middleware/logging.py b/src/nomad/gateway/middleware/logging.py index 91b7515..8457adf 100644 --- a/src/nomad/gateway/middleware/logging.py +++ b/src/nomad/gateway/middleware/logging.py @@ -39,7 +39,7 @@ async def after_tool( ) -> CallToolResult: start_ns = ctx.metadata.pop("start_ns", None) duration_ms = (time.perf_counter_ns() - start_ns) / 1e6 if start_ns else None - payload = getattr(result, "structuredContent", None) + payload = result.structured_content size = 0 if payload is not None: try: diff --git a/src/nomad/gateway/middleware/telemetry.py b/src/nomad/gateway/middleware/telemetry.py index 36e7fb6..16abe70 100644 --- a/src/nomad/gateway/middleware/telemetry.py +++ b/src/nomad/gateway/middleware/telemetry.py @@ -1,36 +1,19 @@ from __future__ import annotations import time -from typing import Any from mcp.types import CallToolResult from ... import metrics as nomad_metrics -from ...otel import get_tracer, set_span_error, set_span_ok from .base import Middleware, ToolCallContext -_SPAN_KEY = "otel_span" _START_KEY = "otel_start_time" class TelemetryMiddleware(Middleware): - """OpenTelemetry spans around upstream tool invocations.""" - - def __init__(self, tracer: Any | None = None): - self._tracer = tracer or get_tracer("nomad.gateway") + """Nomad metrics around FastMCP-instrumented upstream tool calls.""" async def before_tool(self, ctx: ToolCallContext) -> ToolCallContext: - argument_keys = sorted(str(key) for key in ctx.arguments) - span = self._tracer.start_span( - "nomad.gateway.upstream_tool", - attributes={ - "nomad.gateway.server": ctx.server, - "nomad.gateway.tool": ctx.tool, - "nomad.gateway.run_id": ctx.run_id, - "nomad.gateway.argument_keys": argument_keys, - }, - ) - ctx.metadata[_SPAN_KEY] = span ctx.metadata[_START_KEY] = time.monotonic() return ctx @@ -39,7 +22,6 @@ async def after_tool( ctx: ToolCallContext, result: CallToolResult, ) -> CallToolResult: - span = ctx.metadata.pop(_SPAN_KEY, None) start_time = ctx.metadata.pop(_START_KEY, None) if start_time is not None: nomad_metrics.record_gateway_upstream_tool_call( @@ -48,13 +30,6 @@ async def after_tool( time.monotonic() - start_time, status="ok", ) - if span is None: - return result - payload = getattr(result, "structuredContent", None) - span.set_attribute("nomad.gateway.status", "ok") - span.set_attribute("nomad.gateway.output_present", payload is not None) - set_span_ok(span) - span.end() return result async def on_tool_error( @@ -62,7 +37,6 @@ async def on_tool_error( ctx: ToolCallContext, exc: Exception, ) -> None: - span: Any = ctx.metadata.pop(_SPAN_KEY, None) start_time = ctx.metadata.pop(_START_KEY, None) if start_time is not None: nomad_metrics.record_gateway_upstream_tool_call( @@ -71,9 +45,4 @@ async def on_tool_error( time.monotonic() - start_time, status="error", ) - if span is not None: - span.set_attribute("nomad.gateway.status", "error") - span.set_attribute("error.type", exc.__class__.__name__) - set_span_error(span, exc) - span.end() raise exc diff --git a/src/nomad/gateway/sandbox.py b/src/nomad/gateway/sandbox.py index 448f20a..b39f12d 100644 --- a/src/nomad/gateway/sandbox.py +++ b/src/nomad/gateway/sandbox.py @@ -34,7 +34,7 @@ def _jsonable(value: Any) -> Any: model_dump = getattr(value, "model_dump", None) if callable(model_dump): try: - value = model_dump(mode="python") + value = model_dump(mode="python", by_alias=True) except TypeError: value = model_dump() try: diff --git a/src/nomad/gateway/server.py b/src/nomad/gateway/server.py index d87ffe3..3e1a20f 100644 --- a/src/nomad/gateway/server.py +++ b/src/nomad/gateway/server.py @@ -21,9 +21,6 @@ ) from ..otel import ( configure_otel, - get_tracer, - set_span_error, - set_span_ok, shutdown_otel, ) from .config import ( @@ -47,7 +44,6 @@ from .upstream import UpstreamProxy logger = logging.getLogger(__name__) -tracer = get_tracer("nomad.gateway") try: _PACKAGE_VERSION = version("nomad-scifm") @@ -198,49 +194,36 @@ async def execute_mcp_script( """ start_time = time.perf_counter() nomad_metrics.record_gateway_request("execute_mcp_script") - with tracer.start_as_current_span( - "nomad.gateway.execute_mcp_script", - attributes={ - "nomad.gateway.request_id": str(ctx.request_id), - "nomad.gateway.script_path": str(script_path), - }, - ) as span: - try: - await ctx.info( - "execute_mcp_script started", - extra={"request_id": ctx.request_id, "phase": "start"}, - ) - result = await self.run_script(script_path, env) - span.set_attribute( - "nomad.gateway.tool_call_count", len(result.tool_calls) - ) - set_span_ok(span) - await ctx.info( - "execute_mcp_script completed", - extra={ - "request_id": ctx.request_id, - "phase": "end", - }, - ) - except TimeoutError as exc: - set_span_error(span, exc) - nomad_metrics.record_gateway_request_duration( - "execute_mcp_script", - time.perf_counter() - start_time, - status="timeout", - ) - return self._timeout_payload( - exc, - duration_seconds=time.perf_counter() - start_time, - ) - except Exception as exc: - set_span_error(span, exc) - nomad_metrics.record_gateway_request_duration( - "execute_mcp_script", - time.perf_counter() - start_time, - status="error", - ) - raise + try: + await ctx.info( + "execute_mcp_script started", + extra={"request_id": ctx.request_id, "phase": "start"}, + ) + result = await self.run_script(script_path, env) + await ctx.info( + "execute_mcp_script completed", + extra={ + "request_id": ctx.request_id, + "phase": "end", + }, + ) + except TimeoutError as exc: + nomad_metrics.record_gateway_request_duration( + "execute_mcp_script", + time.perf_counter() - start_time, + status="timeout", + ) + return self._timeout_payload( + exc, + duration_seconds=time.perf_counter() - start_time, + ) + except Exception: + nomad_metrics.record_gateway_request_duration( + "execute_mcp_script", + time.perf_counter() - start_time, + status="error", + ) + raise nomad_metrics.record_gateway_request_duration( "execute_mcp_script", @@ -291,49 +274,36 @@ async def main(): start_time = time.perf_counter() nomad_metrics.record_gateway_request("execute_mcp_code") - with tracer.start_as_current_span( - "nomad.gateway.execute_mcp_code", - attributes={ - "nomad.gateway.request_id": str(ctx.request_id), - "nomad.gateway.code_size": len(code), - }, - ) as span: - try: - await ctx.info( - "execute_mcp_code started", - extra={"request_id": ctx.request_id, "phase": "start"}, - ) - result = await self.run_code(code, env) - span.set_attribute( - "nomad.gateway.tool_call_count", len(result.tool_calls) - ) - set_span_ok(span) - await ctx.info( - "execute_mcp_code completed", - extra={ - "request_id": ctx.request_id, - "phase": "end", - }, - ) - except TimeoutError as exc: - set_span_error(span, exc) - nomad_metrics.record_gateway_request_duration( - "execute_mcp_code", - time.perf_counter() - start_time, - status="timeout", - ) - return self._timeout_payload( - exc, - duration_seconds=time.perf_counter() - start_time, - ) - except Exception as exc: - set_span_error(span, exc) - nomad_metrics.record_gateway_request_duration( - "execute_mcp_code", - time.perf_counter() - start_time, - status="error", - ) - raise + try: + await ctx.info( + "execute_mcp_code started", + extra={"request_id": ctx.request_id, "phase": "start"}, + ) + result = await self.run_code(code, env) + await ctx.info( + "execute_mcp_code completed", + extra={ + "request_id": ctx.request_id, + "phase": "end", + }, + ) + except TimeoutError as exc: + nomad_metrics.record_gateway_request_duration( + "execute_mcp_code", + time.perf_counter() - start_time, + status="timeout", + ) + return self._timeout_payload( + exc, + duration_seconds=time.perf_counter() - start_time, + ) + except Exception: + nomad_metrics.record_gateway_request_duration( + "execute_mcp_code", + time.perf_counter() - start_time, + status="error", + ) + raise nomad_metrics.record_gateway_request_duration( "execute_mcp_code", time.perf_counter() - start_time, diff --git a/src/nomad/otel.py b/src/nomad/otel.py index dc2493f..09e0c1b 100644 --- a/src/nomad/otel.py +++ b/src/nomad/otel.py @@ -35,19 +35,13 @@ def start_as_current_span(self, *args: Any, **kwargs: Any): yield NoopSpan() -def _import_trace_api(): +def get_tracer(name: str): + """Return FastMCP's tracer so custom spans follow its telemetry mode.""" try: - from opentelemetry import trace + from fastmcp.telemetry import get_tracer as get_fastmcp_tracer except ImportError: - return None - return trace - - -def get_tracer(name: str): - trace = _import_trace_api() - if trace is None: return NoopTracer() - return trace.get_tracer(name) + return get_fastmcp_tracer() def _truthy(value: str | None) -> bool: diff --git a/src/nomad/tool_search.py b/src/nomad/tool_search.py index a88be0f..f8749da 100644 --- a/src/nomad/tool_search.py +++ b/src/nomad/tool_search.py @@ -169,7 +169,7 @@ def to_wrapper_entry(self) -> dict[str, Any]: def _tool_schema(tool: Any) -> dict[str, Any]: if hasattr(tool, "to_mcp_tool"): tool = tool.to_mcp_tool() - return tool.model_dump(mode="json") + return tool.model_dump(mode="json", by_alias=True) def build_tool_descriptors( diff --git a/test/gateway/test_gateway.py b/test/gateway/test_gateway.py index a30f140..7d99db0 100644 --- a/test/gateway/test_gateway.py +++ b/test/gateway/test_gateway.py @@ -128,7 +128,6 @@ async def gateway(tmp_path: Path): config = _build_config(tmp_path) async with CodeModeGateway(config) as gateway_obj: async with Client(gateway_obj.fastmcp) as client: - await client.initialize() yield GatewayHarness(client=client, gateway=gateway_obj) @@ -141,7 +140,6 @@ async def gateway_with_workspace_venv(tmp_path: Path): config = _build_config(tmp_path) async with CodeModeGateway(config) as gateway_obj: async with Client(gateway_obj.fastmcp) as client: - await client.initialize() yield GatewayHarness(client=client, gateway=gateway_obj) @@ -538,7 +536,6 @@ async def test_uses_workspace_virtualenv_for_runtime(tmp_path: Path): async with CodeModeGateway(config) as gateway_obj: async with Client(gateway_obj.fastmcp) as client: - await client.initialize() payload = await client.call_tool( "execute_mcp_code", { diff --git a/test/gateway/test_telemetry.py b/test/gateway/test_telemetry.py index 1354fa3..b5ef77e 100644 --- a/test/gateway/test_telemetry.py +++ b/test/gateway/test_telemetry.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass, field from typing import Any import pytest @@ -15,41 +14,6 @@ from nomad.gateway.server import CodeModeGateway -@dataclass -class FakeSpan: - name: str - attributes: dict[str, Any] - ended: bool = False - status: Any | None = None - exceptions: list[Exception] = field(default_factory=list) - - def set_attribute(self, key: str, value: Any) -> None: - self.attributes[key] = value - - def set_status(self, status: Any) -> None: - self.status = status - - def record_exception(self, exc: Exception) -> None: - self.exceptions.append(exc) - - def end(self) -> None: - self.ended = True - - -class FakeTracer: - def __init__(self): - self.spans: list[FakeSpan] = [] - - def start_span( - self, - name: str, - attributes: dict[str, Any] | None = None, - ) -> FakeSpan: - span = FakeSpan(name=name, attributes=attributes or {}) - self.spans.append(span) - return span - - class FakeGatewayContext: request_id = "req-1" @@ -92,32 +56,34 @@ def capture(kind, _seconds, *, status): @pytest.mark.asyncio -async def test_telemetry_middleware_records_successful_tool_span(): - tracer = FakeTracer() - middleware = TelemetryMiddleware(tracer=tracer) # type: ignore[arg-type] +async def test_telemetry_middleware_records_successful_upstream_metric(monkeypatch): + events: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + nomad_metrics, + "record_gateway_upstream_tool_call", + lambda server, tool, _seconds, *, status: events.append((server, tool, status)), + ) + middleware = TelemetryMiddleware() ctx = _context() await middleware.before_tool(ctx) await middleware.after_tool( ctx, - CallToolResult(content=[], structuredContent={"ok": True}), + CallToolResult(content=[], structured_content={"ok": True}), ) - span = tracer.spans[0] - assert span.name == "nomad.gateway.upstream_tool" - assert span.attributes["nomad.gateway.server"] == "dummy" - assert span.attributes["nomad.gateway.tool"] == "alpha" - assert span.attributes["nomad.gateway.argument_keys"] == ["value"] - assert span.attributes["nomad.gateway.status"] == "ok" - assert span.attributes["nomad.gateway.output_present"] is True - assert span.ended is True - assert span.status is not None + assert events == [("dummy", "alpha", "ok")] @pytest.mark.asyncio -async def test_telemetry_middleware_records_tool_errors(): - tracer = FakeTracer() - middleware = TelemetryMiddleware(tracer=tracer) # type: ignore[arg-type] +async def test_telemetry_middleware_records_upstream_error_metric(monkeypatch): + events: list[tuple[str, str, str]] = [] + monkeypatch.setattr( + nomad_metrics, + "record_gateway_upstream_tool_call", + lambda server, tool, _seconds, *, status: events.append((server, tool, status)), + ) + middleware = TelemetryMiddleware() ctx = _context() exc = RuntimeError("boom") @@ -125,12 +91,7 @@ async def test_telemetry_middleware_records_tool_errors(): with pytest.raises(RuntimeError, match="boom"): await middleware.on_tool_error(ctx, exc) - span = tracer.spans[0] - assert span.attributes["nomad.gateway.status"] == "error" - assert span.attributes["error.type"] == "RuntimeError" - assert span.exceptions == [exc] - assert span.ended is True - assert span.status is not None + assert events == [("dummy", "alpha", "error")] def test_gateway_config_accepts_telemetry_section(tmp_path): @@ -166,6 +127,13 @@ def test_otel_env_false_does_not_request_exporter(monkeypatch): assert otel._otel_requested(None) is False +def test_nomad_custom_spans_use_fastmcp_tracer(monkeypatch): + tracer = object() + monkeypatch.setattr("fastmcp.telemetry.get_tracer", lambda: tracer) + + assert otel.get_tracer("nomad.gateway") is tracer + + def test_shutdown_otel_flushes_without_shutdown(monkeypatch): trace_provider = FakeProvider() meter_provider = FakeProvider() diff --git a/test/gateway/test_workspace.py b/test/gateway/test_workspace.py index 0eae24b..1aee740 100644 --- a/test/gateway/test_workspace.py +++ b/test/gateway/test_workspace.py @@ -58,7 +58,6 @@ async def ephemeral_gateway(tmp_path: Path): config = GatewayConfig(servers=servers, defaults=defaults, middleware=[]) async with CodeModeGateway(config) as gateway_obj: async with Client(gateway_obj.fastmcp) as client: - await client.initialize() yield GatewayHarness(client=client, gateway=gateway_obj) diff --git a/uv.lock b/uv.lock index d763512..1beac79 100644 --- a/uv.lock +++ b/uv.lock @@ -227,6 +227,22 @@ css = [ { name = "tinycss2" }, ] +[[package]] +name = "burner-redis" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/6f/ec3eeb9e3e9d7fedc51fcb56dd09da0f164495ab6fdf4caaa3754ceed659/burner_redis-0.1.6.tar.gz", hash = "sha256:362091d98c09953ef99be8bd026d75fad42599a0f153211e1a22d3e3029c7cfb", size = 843118, upload-time = "2026-04-27T17:11:41.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/cc/061897380b88c637e4bea1f6715ffba851d10b16d6610f2832ab61fa15b5/burner_redis-0.1.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5dc9c170b9994b8d57958041857f240d1b0b9ac1559d0d35473f03fb62386dea", size = 1275400, upload-time = "2026-04-27T17:11:27.07Z" }, + { url = "https://files.pythonhosted.org/packages/db/24/e4c6fb37d059b268c2a26b173d3f84b49547e837340983d3d018c08191c6/burner_redis-0.1.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f29caae7f80fea2e47350df24264a049aeaa934454210cddcadc2336ee8b423a", size = 1223570, upload-time = "2026-04-27T17:11:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/2e/be/718af7f42bbebbfbfd771ba43697526c922dff54d1b0d62654e21418b25e/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce82edea4ed1ec34448a8610c62665517b3d2030254f31af32828b070a92a8", size = 1325624, upload-time = "2026-04-27T17:11:30.648Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/4f72de7f967532d3739caa461625dc9122f0ca0d46faa883c153a10d0117/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e3dff7d691ab0035468c17f51632e452b075065a30e026278ed1b297441ce93", size = 1356531, upload-time = "2026-04-27T17:11:32.201Z" }, + { url = "https://files.pythonhosted.org/packages/bd/22/369338d6372abd12dee51965566428c06f3badd95f668ff1c11680b99b30/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3b43f983b6e8fbc208734f04b0bae8cf95323fc43105f977d20f0993fc28c1b3", size = 1526049, upload-time = "2026-04-27T17:11:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/b3/23/0651cf86bc5ed390fef09e30ff4a4664cc3c5b88ddf4c6b8d905acc0d60e/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b6ba097d910effff00a4610160a4a759503ad590e2c47a580bdcdac9a325823", size = 1579068, upload-time = "2026-04-27T17:11:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8c/302638fdad4476d4760d477b0f3c6b96c0f88d3f278e5f14d06eb048f788/burner_redis-0.1.6-cp310-abi3-win_amd64.whl", hash = "sha256:98c6b6fc397617cd5a6778ac020e4ed9985c393ad204f9f5cf524b68ae16070b", size = 1103735, upload-time = "2026-04-27T17:11:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/18668d92e18210150f7f93e2930264ad77a82e4d3e5f74ca1aecc002f78f/burner_redis-0.1.6-cp310-abi3-win_arm64.whl", hash = "sha256:c2583e98f9a3836ac2c6243ea0c8d56b40e7017b46617991e329bc807c544bad", size = 1029386, upload-time = "2026-04-27T17:11:40.266Z" }, +] + [[package]] name = "cachetools" version = "7.1.4" @@ -408,6 +424,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -492,6 +517,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] +[[package]] +name = "cronsim" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -547,7 +580,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, @@ -578,34 +611,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -752,21 +785,27 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.4.2" +version = "4.0.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/18/46beaec18c9f86a599ae3f9cdf6677dd6b50240cfd844d18233710b47f13/fastmcp-3.4.2.tar.gz", hash = "sha256:b468722946fc467c3796a6572f7a14d93d48c014cf8fea12910245220cbbe4e1", size = 28756849, upload-time = "2026-06-06T01:30:35.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fd/e513c524bb3e296203f65bd8e9e726e9236bd3b59315e7cbdeb095662c01/fastmcp-4.0.0b1.tar.gz", hash = "sha256:f98d69588a73e1672840558641d5d0f111e207baffe001f3465713a53ebb6b4c", size = 42065171, upload-time = "2026-07-28T21:18:15.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/4d/8b1ba42251160e11ca34686344572121432c23a082d56ef6bbdec5888fc1/fastmcp-3.4.2-py3-none-any.whl", hash = "sha256:c87a62b029f0c5400ada85f683629345d2466c39169f0cb853e487b2f7308c08", size = 8018, upload-time = "2026-06-06T01:30:38.118Z" }, + { url = "https://files.pythonhosted.org/packages/2c/66/41b503ef852eff83f3f0c04f46cd1fc738c5374fd509384fc6b96e45918f/fastmcp-4.0.0b1-py3-none-any.whl", hash = "sha256:d66eb7b0763ffff2ae0fc573778ea25604dcb7e59769e5afaf9851a806eb1129", size = 8064, upload-time = "2026-07-28T21:18:12.688Z" }, +] + +[package.optional-dependencies] +tasks = [ + { name = "fastmcp-tasks" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.2" +version = "4.0.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mcp-types" }, { name = "platformdirs" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -774,16 +813,16 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/33/f207166aac88c6d8be1b2a82c54fecd1e04b075ef12d027aa17b3134fc0f/fastmcp_slim-4.0.0b1.tar.gz", hash = "sha256:158efb25720e0cb301711146b2d05d181de95cd91fe70e87c251ad14b123d665", size = 660081, upload-time = "2026-07-28T21:17:50.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/fbe192d2ab50bb31b284fd0eb6c72d4347df7a57d836bee4f1973ffd1d7d/fastmcp_slim-4.0.0b1-py3-none-any.whl", hash = "sha256:dd907a3db5a2f479ca958c30157e227b4f7b340a56d333eb91de95719254597a", size = 827975, upload-time = "2026-07-28T21:17:48.747Z" }, ] [package.optional-dependencies] client = [ { name = "authlib" }, { name = "exceptiongroup" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, @@ -794,7 +833,7 @@ server = [ { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "griffelib" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -813,6 +852,20 @@ server = [ { name = "websockets" }, ] +[[package]] +name = "fastmcp-tasks" +version = "4.0.0b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "burner-redis", marker = "sys_platform == 'win32'" }, + { name = "fastmcp-slim", extra = ["server"] }, + { name = "pydocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/69/0a5613519eddd08fe674635ecd88ddae3f435f643141b725bb32834d7945/fastmcp_tasks-4.0.0b1.tar.gz", hash = "sha256:44f3a70a4241f82ff81c9fce477969aeb8cc06430985d7c37cb393eba791360e", size = 50552, upload-time = "2026-07-28T21:18:08.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/d3/48a891e14819d7b94304a0f360ceef5731e6d69477a28ded54fc43c3e286/fastmcp_tasks-4.0.0b1-py3-none-any.whl", hash = "sha256:221f1cca93213baeeea79a02fe70a69fea7c1902ed42e3f7a0f7f01d0c704869", size = 61363, upload-time = "2026-07-28T21:18:07.396Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -1111,6 +1164,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1127,12 +1193,19 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -1866,15 +1939,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -1884,9 +1957,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -2065,7 +2151,7 @@ name = "nomad-scifm" version = "0.2.0" source = { editable = "." } dependencies = [ - { name = "fastmcp" }, + { name = "fastmcp", extra = ["tasks"] }, { name = "filelock" }, { name = "h5py" }, { name = "huggingface-hub" }, @@ -2126,11 +2212,11 @@ test = [ [package.metadata] requires-dist = [ - { name = "fastmcp", specifier = ">=3.0.0" }, + { name = "fastmcp", extras = ["tasks"], specifier = ">=4.0.0b1,<5.0" }, { name = "filelock", specifier = ">=3.13.0" }, { name = "h5py", specifier = ">=3.15.1" }, { name = "huggingface-hub", specifier = ">=0.34.0,<2.0" }, - { name = "mcp", specifier = ">=1.20.0,<2.0" }, + { name = "mcp", specifier = ">=2.0.0,<3.0" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.40.0" }, { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'otel'", specifier = ">=1.40.0" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.40.0" }, @@ -2250,7 +2336,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -2289,7 +2375,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -2301,7 +2387,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -2331,9 +2417,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -2345,7 +2431,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -2544,7 +2630,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2768,6 +2854,9 @@ keyring = [ memory = [ { name = "cachetools" }, ] +redis = [ + { name = "redis" }, +] [[package]] name = "pycparser" @@ -2887,6 +2976,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] +[[package]] +name = "pydocket" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "burner-redis" }, + { name = "cloudpickle" }, + { name = "cronsim" }, + { name = "opentelemetry-api" }, + { name = "prometheus-client" }, + { name = "py-key-value-aio", extra = ["memory", "redis"] }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "uncalled-for" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/bf/7f1134e990855f373e5ee6ba316db8fe654a2d7dd852b41ab890fcfb91e3/pydocket-0.20.1.tar.gz", hash = "sha256:d72b3784e4b5069b39e5f49f599d54a891e1b6222c27a8bcfbd4dee0f57d4895", size = 361993, upload-time = "2026-05-06T14:06:25.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9d/1bd873a0ea480dec388c40ac1a7500c129efbb9d61e2fef6b97236703458/pydocket-0.20.1-py3-none-any.whl", hash = "sha256:c886ece90ac93018f069d1eef9443f888404081d7258955e16847752575c95ae", size = 102774, upload-time = "2026-05-06T14:06:24.548Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -3207,6 +3320,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, ] +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -3469,8 +3591,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ From 1ab49cc6589f9300bada60a1584a2c2fe7606eaf Mon Sep 17 00:00:00 2001 From: Alexius Wadell Date: Wed, 29 Jul 2026 19:34:37 -0600 Subject: [PATCH 2/5] Add bounded SciFM background tasks --- docs/reference/api-tool-manager.md | 20 +++++ docs/reference/config.md | 10 ++- src/nomad/_torch_module_compat.py | 17 +++- src/nomad/cli.py | 39 ++++++++- src/nomad/config.py | 13 ++- src/nomad/torch_tool_manager.py | 123 +++++++++++++++++++++++++---- test/test_cli_main.py | 66 ++++++++++++++++ test/test_config.py | 33 ++++++++ test/test_tool_manager.py | 89 ++++++++++++++++++++- 9 files changed, 385 insertions(+), 25 deletions(-) diff --git a/docs/reference/api-tool-manager.md b/docs/reference/api-tool-manager.md index 045c8d2..2852f01 100644 --- a/docs/reference/api-tool-manager.md +++ b/docs/reference/api-tool-manager.md @@ -9,6 +9,26 @@ GPU memory can be shared across a larger set of models. It also queues requests, batches calls for tools that support batching, and can register the managed tools with FastMCP. +FastMCP background-task workers feed requests into this queue; they do not +schedule accelerator work themselves. Nomad derives concurrency by adding the +total per-tool pending capacity to the +device pipeline capacity: `number_of_tools * max_pending_per_tool + devices * +max_batch_size * device_queue_depth`. It then applies +{py:attr}`~nomad.config.ToolManagerConfig.task_min_concurrency` as a floor. +`max_pending_per_tool` defaults to 65,536. Docket limits each SciFM task to 75% +of that capacity before invoking Nomad, leaving headroom for foreground calls; +each admitted background task also atomically reserves a real Nomad queue slot. +If no slot is available, Docket reschedules the task without removing it from +the queue. The Nomad limit remains a defensive boundary that returns a `Server +busy` tool error. The device queue depth and minimum default to two and ten, +respectively. This keeps enough work available for batching without moving GPU +scheduling out of the tool manager. + +Docket retains a completed or failed task's result payload for 15 minutes by +default. This lifetime starts when the task completes or fails, not when it is +submitted; for example, a task that runs for an hour still has its result +available for approximately 15 minutes after it finishes. + For managed Torch tools, the manager keeps one resident CPU instance when the tool is loaded from configuration and may create additional tool instances from the configured source when a busy tool is assigned to more than one device slot. diff --git a/docs/reference/config.md b/docs/reference/config.md index 7d2e2df..bf04083 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -20,7 +20,10 @@ tool_manager: idle_seconds: 120 gc_idle_seconds: 300 disk_idle_seconds: 600 - max_pending_per_tool: 50000 + max_pending_per_tool: 65536 + task_min_concurrency: 10 + device_queue_depth: 2 + task_backend_url: memory:// search_tool: expose: true telemetry: @@ -94,9 +97,8 @@ The `workspace_root` default controls where sandboxed scripts run. If that workspace contains `.venv/bin/python` or `.venv/Scripts/python.exe`, the gateway uses it for sandbox execution. -The gateway emits OpenTelemetry metrics and spans for MCP tool entrypoints, -sandbox runs, and upstream MCP tool calls. See {doc}`otel` for the full metric -reference. +FastMCP emits MCP boundary spans, while the gateway adds domain metrics and +sandbox execution spans. See {doc}`otel` for the full telemetry reference. Nomad configures OTLP/gRPC trace and metric exporters when `telemetry.enabled` is `true`, `NOMAD_OTEL_ENABLED=true`, or standard OTel environment variables diff --git a/src/nomad/_torch_module_compat.py b/src/nomad/_torch_module_compat.py index a528768..30901bf 100644 --- a/src/nomad/_torch_module_compat.py +++ b/src/nomad/_torch_module_compat.py @@ -4,8 +4,10 @@ from collections.abc import Callable from typing import Any +from docket import ConcurrencyLimit from fastmcp import FastMCP from fastmcp.tools import FunctionTool +from fastmcp.utilities.tasks import TaskConfig def _compatible_torch_module_tool_types() -> tuple[type[Any], ...]: @@ -30,10 +32,22 @@ def build_torch_module_fastmcp_tool( tool: Any, *, invoke: Callable[[Any], Any] | None = None, + task_concurrency_limit: int | None = None, + task_admission: Any = None, ) -> FunctionTool: """Build a FastMCP tool from a TorchModuleTool-like object.""" - async def fn(**input_data: Any) -> Any: + concurrency = ( + ConcurrencyLimit(max_concurrent=task_concurrency_limit) + if task_concurrency_limit is not None + else None + ) + + async def fn( + _task_concurrency: ConcurrencyLimit | None = concurrency, + _task_admission: Any = task_admission, + **input_data: Any, + ) -> Any: args = tool.args_schema(**input_data) result = invoke(args) if invoke is not None else tool(args) if inspect.isawaitable(result): @@ -46,6 +60,7 @@ async def fn(**input_data: Any) -> Any: description=tool.description, parameters=tool.args_schema.model_json_schema(), output_schema=tool.output_schema.model_json_schema(), + task_config=TaskConfig(mode="optional"), ) diff --git a/src/nomad/cli.py b/src/nomad/cli.py index 8c85bbf..6d042f1 100644 --- a/src/nomad/cli.py +++ b/src/nomad/cli.py @@ -10,6 +10,7 @@ import click import typer from fastmcp import FastMCP +from fastmcp_tasks import TasksExtension from typer.core import TyperCommand from ._torch_module_compat import add_torch_module_tool_to_fastmcp @@ -27,7 +28,6 @@ LogLevelName = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] ServeTransport = Literal["stdio", "http", "streamable-http", "streamable_http"] - app = typer.Typer( no_args_is_help=True, help=( @@ -77,6 +77,22 @@ def _format_visible_devices(manager: Any) -> str: return "none" +def _background_task_concurrency( + manager_config: Any, + manager: Any | None, + *, + number_of_tools: int, + max_batch_size: int, +) -> int: + minimum = getattr(manager_config, "task_min_concurrency", 10) + device_queue_depth = getattr(manager_config, "device_queue_depth", 2) + max_pending_per_tool = getattr(manager_config, "max_pending_per_tool", 2**16) + pending_capacity = number_of_tools * max_pending_per_tool + device_count = len(getattr(manager, "devices", ())) if manager is not None else 0 + pipeline_capacity = device_count * max(1, max_batch_size) * device_queue_depth + return max(minimum, pending_capacity + pipeline_capacity) + + def run_code_mode_script( config_path: Path, script_path: Path, @@ -244,6 +260,8 @@ def serve( manager = manager_cfg.instantiate() if use_manager else None LOGGER.info("Visible devices: %s", _format_visible_devices(manager)) + registered_fmod_count = 0 + max_batch_size = 1 for fm_config in config.fmod_models: fm_name = fm_config.tool_name or fm_config.name_or_path try: @@ -262,6 +280,10 @@ def serve( else: add_torch_module_tool_to_fastmcp(server, tool) + registered_fmod_count += 1 + tool_batch_size = getattr(tool, "batch_size", 1) or 1 + max_batch_size = max(max_batch_size, max(1, tool_batch_size)) + card_locator.register( tool.name or fm_config.name_or_path, source, @@ -274,6 +296,21 @@ def serve( if manager: manager.add_to_fastmcp(server) + if registered_fmod_count: + task_concurrency = _background_task_concurrency( + manager_cfg, + manager, + number_of_tools=registered_fmod_count, + max_batch_size=max_batch_size, + ) + LOGGER.info("FastMCP background task concurrency: %s", task_concurrency) + server.add_extension( + TasksExtension( + url=getattr(manager_cfg, "task_backend_url", "memory://"), + concurrency=task_concurrency, + ) + ) + if config.search_tool.expose: register_search_tool( server, diff --git a/src/nomad/config.py b/src/nomad/config.py index 1486a38..be08473 100644 --- a/src/nomad/config.py +++ b/src/nomad/config.py @@ -227,8 +227,17 @@ class ToolManagerConfig(BaseModel): idle_seconds: float | None = Field(default=300.0, ge=0) """Idle seconds before reducing a tool's device allocation by one slot. ``None`` disables device-slot idle eviction.""" - max_pending_per_tool: int | None = Field(default=None, ge=1) - """Maximum queued requests per tool. ``None`` disables the queue limit.""" + max_pending_per_tool: int = Field(default=2**16, ge=1) + """Maximum queued requests per tool.""" + + task_min_concurrency: int = Field(default=10, ge=1) + """Minimum derived FastMCP background-task concurrency.""" + + device_queue_depth: int = Field(default=2, ge=1) + """Maximum batches per device included in derived task concurrency.""" + + task_backend_url: str = "memory://" + """Docket backend URL used for FastMCP background tasks.""" max_devices_per_tool: int | None = Field(default=None, ge=1) """Maximum device slots one tool may occupy. ``None`` uses all managed slots.""" diff --git a/src/nomad/torch_tool_manager.py b/src/nomad/torch_tool_manager.py index 76ef2af..a648809 100644 --- a/src/nomad/torch_tool_manager.py +++ b/src/nomad/torch_tool_manager.py @@ -5,12 +5,15 @@ import time from collections import deque from collections.abc import Callable +from contextvars import ContextVar, Token from dataclasses import dataclass, field from pathlib import Path from typing import Any, TypeVar import torch +from docket.dependencies import AdmissionBlocked, Dependency, current_execution from fastmcp import FastMCP +from fastmcp.exceptions import ToolError from fastmcp.tools import FunctionTool as FastMCPTool from pydantic import BaseModel @@ -66,6 +69,54 @@ class ToolRequest: metrics_recorded: bool = False +@dataclass(slots=True) +class DocketQueueReservation: + manager: "TorchModelToolManager" + tool_name: str + reserved: bool = True + + +_docket_queue_reservation: ContextVar[DocketQueueReservation | None] = ContextVar( + "nomad_docket_queue_reservation", default=None +) + + +class DocketQueueAdmission(Dependency["DocketQueueAdmission"]): + """Reserve Nomad queue capacity before Docket starts a managed tool.""" + + def __init__(self, manager: "TorchModelToolManager", tool_name: str) -> None: + self.manager = manager + self.tool_name = tool_name + + async def __aenter__(self) -> "DocketQueueAdmission": + from docket.dependencies._functional import _Depends + + if not await self.manager._reserve_docket_queue_slot(self.tool_name): + raise AdmissionBlocked( + current_execution.get(), + reason=f"Nomad queue for {self.tool_name!r} is full", + ) + reservation = DocketQueueReservation(self.manager, self.tool_name) + context_token = _docket_queue_reservation.set(reservation) + _Depends.stack.get().push_async_callback( + self._cleanup_reservation, reservation, context_token + ) + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + pass + + @staticmethod + async def _cleanup_reservation( + reservation: DocketQueueReservation, + context_token: Token[DocketQueueReservation | None], + ) -> None: + if reservation.reserved: + await reservation.manager._release_docket_queue_slot(reservation.tool_name) + reservation.reserved = False + _docket_queue_reservation.reset(context_token) + + @dataclass(slots=True) class ToolState: """Queue and metadata for a registered tool.""" @@ -215,9 +266,8 @@ def __init__( self.gc_idle_seconds = gc_idle_seconds self.disk_idle_seconds = disk_idle_seconds max_pending_per_tool = config.max_pending_per_tool - if max_pending_per_tool is not None and max_pending_per_tool < 1: - raise ValueError("max_pending_per_tool must be >= 1 or None") self._max_pending_per_tool = max_pending_per_tool + self._docket_concurrency_limit = max(1, max_pending_per_tool * 3 // 4) threshold = float(idle_seconds) if idle_seconds is not None else None self._idle_threshold = None if threshold is None else max(threshold, 0.0) disk_threshold = ( @@ -272,6 +322,7 @@ def __init__( self._tools: dict[str, ToolState] = {} self._inflight_by_tool: dict[str, int] = {} + self._docket_queue_reservations: dict[str, int] = {} self._condition = asyncio.Condition() self._pending_tools: deque[str] = deque() @@ -344,6 +395,7 @@ def register_tool( batch_size=effective_batch, ) self._inflight_by_tool[name] = 0 + self._docket_queue_reservations[name] = 0 self._offload_tool(tool) def add_to_fastmcp(self, server: FastMCP) -> dict[str, FastMCPTool]: @@ -380,8 +432,12 @@ async def call_tool( request = ToolRequest(input=request_input, future=future) try: - await self._enqueue_request(name, request) - except RuntimeError: + await self._enqueue_request( + name, + request, + docket_reservation=_docket_queue_reservation.get(), + ) + except ToolError: nomad_metrics.record_tool_request_rejection(name, "queue_full") raise @@ -461,18 +517,54 @@ def _normalize_input( return args_schema(**kwargs) raise ValueError("No input provided for tool execution") - async def _enqueue_request(self, tool_name: str, request: ToolRequest) -> None: + async def _reserve_docket_queue_slot(self, tool_name: str) -> bool: async with self._condition: state = self._tools[tool_name] + active_pending = sum( + 1 for pending in state.queue if not pending.future.cancelled() + ) + reserved = self._docket_queue_reservations[tool_name] + if active_pending + reserved >= self._max_pending_per_tool: + return False + self._docket_queue_reservations[tool_name] = reserved + 1 + return True + + async def _release_docket_queue_slot(self, tool_name: str) -> None: + async with self._condition: + reserved = self._docket_queue_reservations[tool_name] + if reserved: + self._docket_queue_reservations[tool_name] = reserved - 1 + self._condition.notify_all() - if self._max_pending_per_tool is not None: - active_pending = sum( - 1 for pending in state.queue if not pending.future.cancelled() + async def _enqueue_request( + self, + tool_name: str, + request: ToolRequest, + *, + docket_reservation: DocketQueueReservation | None = None, + ) -> None: + async with self._condition: + state = self._tools[tool_name] + + active_pending = sum( + 1 for pending in state.queue if not pending.future.cancelled() + ) + if docket_reservation is not None: + if ( + docket_reservation.manager is not self + or docket_reservation.tool_name != tool_name + or not docket_reservation.reserved + ): + raise RuntimeError("Invalid Docket queue admission reservation") + self._docket_queue_reservations[tool_name] -= 1 + docket_reservation.reserved = False + elif ( + active_pending + self._docket_queue_reservations[tool_name] + >= self._max_pending_per_tool + ): + raise ToolError( + f"Server busy: too many requests for tool '{tool_name}'" ) - if active_pending >= self._max_pending_per_tool: - raise RuntimeError( - f"Too many pending requests for tool '{tool_name}'" - ) self._mark_server_active_locked() state.queue.append(request) @@ -743,10 +835,7 @@ async def _evict_disk_idle_tools(self, *, now: float | None = None) -> None: ) def _next_batch_size(self, state: ToolState) -> int: - batch_size = state.batch_size - if self._max_pending_per_tool is not None: - batch_size = min(batch_size, self._max_pending_per_tool) - return batch_size + return min(state.batch_size, self._max_pending_per_tool) def _next_available_requests( self, @@ -966,6 +1055,8 @@ def _build_fastmcp_tool( return build_torch_module_fastmcp_tool( state, invoke=lambda args: self.call_tool(name, args), + task_concurrency_limit=self._docket_concurrency_limit, + task_admission=DocketQueueAdmission(self, name), ) def _is_out_of_memory(self, exc: Exception) -> bool: diff --git a/test/test_cli_main.py b/test/test_cli_main.py index 890726e..32e68d1 100644 --- a/test/test_cli_main.py +++ b/test/test_cli_main.py @@ -1,14 +1,17 @@ from __future__ import annotations import asyncio +import inspect import json import logging import re import types +from datetime import timedelta from pathlib import Path from typing import Any import pytest +from docket import Docket from fastmcp import FastMCP from pydantic import BaseModel from typer.testing import CliRunner @@ -28,6 +31,50 @@ def strip_ansi(text: str) -> str: return ANSI_ESCAPE_RE.sub("", text) +def test_docket_retains_terminal_results_for_fifteen_minutes_by_default(): + execution_ttl = inspect.signature(Docket).parameters["execution_ttl"].default + + assert execution_ttl == timedelta(minutes=15) + + +def test_background_task_concurrency_tracks_gpu_pipeline_capacity(): + manager = types.SimpleNamespace(devices=["cuda:0", "cuda:1"]) + manager_config = types.SimpleNamespace( + task_min_concurrency=10, + max_pending_per_tool=5, + device_queue_depth=1, + ) + + assert ( + nomad_cli._background_task_concurrency( + manager_config, + manager, + number_of_tools=3, + max_batch_size=8, + ) + == 31 + ) + + +def test_background_task_concurrency_applies_minimum_and_device_queue_depth(): + manager = types.SimpleNamespace(devices=["cuda:0", "cuda:1"]) + manager_config = types.SimpleNamespace( + task_min_concurrency=20, + max_pending_per_tool=7, + device_queue_depth=2, + ) + + assert ( + nomad_cli._background_task_concurrency( + manager_config, + manager, + number_of_tools=2, + max_batch_size=8, + ) + == 46 + ) + + def test_nomad_cli_exposes_code_mode(monkeypatch, tmp_path: Path): called: dict[str, Any] = {} @@ -448,9 +495,16 @@ def register(self, tool_name, source): self.register_calls.append((tool_name, source)) class DummyServer: + last_instance = None + def __init__(self, *args, **kwargs): self.registered_tools: list[str] = [] + self.extensions: list[object] = [] self.transport: str | None = None + DummyServer.last_instance = self + + def add_extension(self, extension): + self.extensions.append(extension) def add_tool(self, tool): self.registered_tools.append(tool.name) @@ -494,6 +548,9 @@ def run(self, *, transport, **kwargs): ) assert result.exit_code == 0 + assert isinstance(DummyServer.last_instance.extensions[0], nomad_cli.TasksExtension) + assert DummyServer.last_instance.extensions[0].docket_settings.concurrency == 2**16 + assert DummyServer.last_instance.extensions[0].docket_settings.url == "memory://" assert dummy_fm.resolve_calls == [dummy_config.context_dir] assert dummy_locator.register_calls == [("dummy-tool", resolved_model_dir)] @@ -539,6 +596,9 @@ class DummyServer: def __init__(self, *args, **kwargs): pass + def add_extension(self, extension): + pass + def tool(self, **kwargs): def decorator(fn): return fn @@ -619,6 +679,9 @@ class DummyServer: def __init__(self, *args, **kwargs): pass + def add_extension(self, extension): + pass + def tool(self, **kwargs): def decorator(fn): return fn @@ -705,6 +768,9 @@ class DummyServer: def __init__(self, *args, **kwargs): pass + def add_extension(self, extension): + pass + def tool(self, **kwargs): def decorator(fn): return fn diff --git a/test/test_config.py b/test/test_config.py index 04c9ea2..1462a03 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -101,6 +101,9 @@ def parse(cls, spec: str, *, base_dir=None): "gc_idle_seconds": 30.0, "disk_idle_seconds": 60.0, "max_pending_per_tool": 5, + "task_min_concurrency": 12, + "device_queue_depth": 3, + "task_backend_url": "redis://localhost:6379/2", }, } @@ -108,6 +111,9 @@ def parse(cls, spec: str, *, base_dir=None): assert isinstance(config.tool_manager, ToolManagerConfig) assert config.tool_manager.enabled is True + assert config.tool_manager.task_min_concurrency == 12 + assert config.tool_manager.device_queue_depth == 3 + assert config.tool_manager.task_backend_url == "redis://localhost:6379/2" assert config.search_tool.expose is False assert config.search_tool.weights.prefix_name == 8.0 manager = config.tool_manager.instantiate() @@ -138,6 +144,7 @@ def add_tool(self, tool): fm_server = DummyFastMCPServer() registered_model_tool = fm_config.add_to_fastmcp(fm_server) assert registered_model_tool.name == "dummy_tool" + assert registered_model_tool.task_config.mode == "optional" assert fm_server.tools == [registered_model_tool] assert len(config.tools) == 2 @@ -196,6 +203,32 @@ def test_tool_manager_config_rejects_negative_seconds(field: str): assert "greater than or equal to 0" in str(exc_info.value) +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("task_min_concurrency", 0, "greater than or equal to 1"), + ("device_queue_depth", 0, "greater than or equal to 1"), + ], +) +def test_tool_manager_config_rejects_invalid_background_task_scaling( + field: str, value: int, message: str +): + with pytest.raises(ValidationError) as exc_info: + ToolManagerConfig.model_validate({field: value}) + + assert message in str(exc_info.value) + + +def test_tool_manager_config_defaults_to_finite_pending_limit(): + assert ToolManagerConfig().max_pending_per_tool == 2**16 + assert ToolManagerConfig().device_queue_depth == 2 + + +def test_tool_manager_config_rejects_unlimited_pending_requests(): + with pytest.raises(ValidationError): + ToolManagerConfig.model_validate({"max_pending_per_tool": None}) + + def test_torch_module_config_accepts_legacy_ursa_tool(monkeypatch): class LegacyTorchModuleTool(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) diff --git a/test/test_tool_manager.py b/test/test_tool_manager.py index 8bcac08..dfaad0e 100644 --- a/test/test_tool_manager.py +++ b/test/test_tool_manager.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import inspect import threading import time from collections.abc import Sequence @@ -7,13 +8,20 @@ import pytest import torch +from docket import ConcurrencyLimit from fastmcp import FastMCP +from fastmcp.exceptions import ToolError from pydantic import BaseModel from nomad import metrics as nomad_metrics from nomad.config import ToolManagerConfig from nomad.fm_base_tool import TorchModuleTool, default_device -from nomad.torch_tool_manager import ToolRequest, TorchModelToolManager +from nomad.torch_tool_manager import ( + DocketQueueAdmission, + DocketQueueReservation, + ToolRequest, + TorchModelToolManager, +) class DummyModule(torch.nn.Module): @@ -434,6 +442,84 @@ async def test_scheduler_reserves_capacity_for_other_queued_tools( await manager.aclose() +@pytest.mark.asyncio() +async def test_full_tool_queue_raises_server_busy(dummy_tool_factory): + manager = TorchModelToolManager( + ToolManagerConfig(max_pending_per_tool=1), + device_provider=lambda: [torch.device("cpu")], + ) + tool = dummy_tool_factory(label="busy") + manager.register_tool("busy", tool, source=DummyTool.clone_sources["busy"]) + + loop = asyncio.get_running_loop() + await manager._enqueue_request( + "busy", + ToolRequest(input=DummyInput(value=1), future=loop.create_future()), + ) + + with pytest.raises(ToolError, match="Server busy"): + await manager._enqueue_request( + "busy", + ToolRequest(input=DummyInput(value=2), future=loop.create_future()), + ) + + await manager.aclose() + + +@pytest.mark.asyncio() +async def test_docket_reservation_guarantees_nomad_queue_space(dummy_tool_factory): + manager = TorchModelToolManager( + ToolManagerConfig(max_pending_per_tool=2), + device_provider=lambda: [torch.device("cpu")], + ) + tool = dummy_tool_factory(label="reserved") + manager.register_tool("reserved", tool, source=DummyTool.clone_sources["reserved"]) + loop = asyncio.get_running_loop() + + reservation = DocketQueueReservation(manager, "reserved") + assert await manager._reserve_docket_queue_slot("reserved") is True + await manager._enqueue_request( + "reserved", + ToolRequest(input=DummyInput(value=1), future=loop.create_future()), + ) + await manager._enqueue_request( + "reserved", + ToolRequest(input=DummyInput(value=2), future=loop.create_future()), + docket_reservation=reservation, + ) + + assert reservation.reserved is False + assert await manager._reserve_docket_queue_slot("reserved") is False + with pytest.raises(ToolError, match="Server busy"): + await manager._enqueue_request( + "reserved", + ToolRequest(input=DummyInput(value=3), future=loop.create_future()), + ) + + await manager.aclose() + + +def test_managed_fastmcp_tool_limits_docket_to_queue_headroom(dummy_tool_factory): + manager = TorchModelToolManager( + ToolManagerConfig(max_pending_per_tool=100), + device_provider=lambda: [torch.device("cpu")], + ) + tool = dummy_tool_factory(label="docket-limit") + manager.register_tool( + "docket-limit", tool, source=DummyTool.clone_sources["docket-limit"] + ) + + fast_tool = manager._build_fastmcp_tool( + "docket-limit", manager._tools["docket-limit"] + ) + dependency = inspect.signature(fast_tool.fn).parameters["_task_concurrency"].default + admission = inspect.signature(fast_tool.fn).parameters["_task_admission"].default + + assert isinstance(dependency, ConcurrencyLimit) + assert dependency.max_concurrent == 75 + assert isinstance(admission, DocketQueueAdmission) + + @pytest.mark.asyncio() @pytest.mark.gpu async def test_tool_execution_does_not_block_event_loop(dummy_tool_factory): @@ -787,6 +873,7 @@ def fake_monotonic(): server = FastMCP() fast_tools = manager.add_to_fastmcp(server) assert "disk-idle" in fast_tools + assert fast_tools["disk-idle"].task_config.mode == "optional" clock["value"] += 0.1 await manager._evict_disk_idle_tools(now=clock["value"]) From ef77cbb5b0e10afc394d16f6e166fcb0e4c3656d Mon Sep 17 00:00:00 2001 From: Alexius Wadell Date: Wed, 29 Jul 2026 19:53:59 -0600 Subject: [PATCH 3/5] Document FastMCP v4 migration --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4585cd..0956c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## Changed +### Added + +- Added optional SciFM background tasks with configurable Docket storage and bounded Nomad queue admission ([#7]) + +### Breaking + +- Migrated to the FastMCP v4 beta and MCP 2, with FastMCP owning MCP telemetry and Nomad retaining domain metrics and child spans ([#7]) + +### Changed - Enabled [OTEL](https://opentelemetry.io/) logging within the [nomad demo image](ghcr.io/lanl/nomad). Added a docker-compose with a reference observability stack ([#9]). +- Changed `max_pending_per_tool` to a finite, non-nullable limit and added derived Docket worker concurrency controls ([#7]) ## [v0.2.0] - 2026-07-29 @@ -43,5 +52,6 @@ Initial Public Release of Nomad [#4]: https://github.com/lanl/nomad/pull/4 [#5]: https://github.com/lanl/nomad/pull/5 [#6]: https://github.com/lanl/nomad/pull/6 +[#7]: https://github.com/lanl/nomad/pull/7 [#8]: https://github.com/lanl/nomad/pull/8 [#9]: https://github.com/lanl/nomad/pull/9 From fa1fb4877b67fb6fb98fc8a94c3e4b9d6513a67c Mon Sep 17 00:00:00 2001 From: Alexius Wadell Date: Wed, 29 Jul 2026 20:18:30 -0600 Subject: [PATCH 4/5] Refresh demo lockfile for FastMCP beta --- container/demo/pyproject.toml | 1 + container/demo/uv.lock | 225 +++++++++++++++++++++++++++------- 2 files changed, 185 insertions(+), 41 deletions(-) diff --git a/container/demo/pyproject.toml b/container/demo/pyproject.toml index 0f1c3e0..7742555 100644 --- a/container/demo/pyproject.toml +++ b/container/demo/pyproject.toml @@ -27,6 +27,7 @@ where = ["src"] nomad-scifm = { path = "../..", editable = true } [tool.uv] +prerelease = "allow" override-dependencies = [ "nomad-scifm[otel]", ] diff --git a/container/demo/uv.lock b/container/demo/uv.lock index 86459c7..ec1e735 100644 --- a/container/demo/uv.lock +++ b/container/demo/uv.lock @@ -8,6 +8,9 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'win32'", ] +[options] +prerelease-mode = "allow" + [manifest] overrides = [{ name = "nomad-scifm", extras = ["otel"], editable = "../../" }] @@ -207,6 +210,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "burner-redis" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/6f/ec3eeb9e3e9d7fedc51fcb56dd09da0f164495ab6fdf4caaa3754ceed659/burner_redis-0.1.6.tar.gz", hash = "sha256:362091d98c09953ef99be8bd026d75fad42599a0f153211e1a22d3e3029c7cfb", size = 843118, upload-time = "2026-04-27T17:11:41.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/cc/061897380b88c637e4bea1f6715ffba851d10b16d6610f2832ab61fa15b5/burner_redis-0.1.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5dc9c170b9994b8d57958041857f240d1b0b9ac1559d0d35473f03fb62386dea", size = 1275400, upload-time = "2026-04-27T17:11:27.07Z" }, + { url = "https://files.pythonhosted.org/packages/db/24/e4c6fb37d059b268c2a26b173d3f84b49547e837340983d3d018c08191c6/burner_redis-0.1.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f29caae7f80fea2e47350df24264a049aeaa934454210cddcadc2336ee8b423a", size = 1223570, upload-time = "2026-04-27T17:11:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/2e/be/718af7f42bbebbfbfd771ba43697526c922dff54d1b0d62654e21418b25e/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ce82edea4ed1ec34448a8610c62665517b3d2030254f31af32828b070a92a8", size = 1325624, upload-time = "2026-04-27T17:11:30.648Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/4f72de7f967532d3739caa461625dc9122f0ca0d46faa883c153a10d0117/burner_redis-0.1.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e3dff7d691ab0035468c17f51632e452b075065a30e026278ed1b297441ce93", size = 1356531, upload-time = "2026-04-27T17:11:32.201Z" }, + { url = "https://files.pythonhosted.org/packages/bd/22/369338d6372abd12dee51965566428c06f3badd95f668ff1c11680b99b30/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3b43f983b6e8fbc208734f04b0bae8cf95323fc43105f977d20f0993fc28c1b3", size = 1526049, upload-time = "2026-04-27T17:11:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/b3/23/0651cf86bc5ed390fef09e30ff4a4664cc3c5b88ddf4c6b8d905acc0d60e/burner_redis-0.1.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b6ba097d910effff00a4610160a4a759503ad590e2c47a580bdcdac9a325823", size = 1579068, upload-time = "2026-04-27T17:11:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8c/302638fdad4476d4760d477b0f3c6b96c0f88d3f278e5f14d06eb048f788/burner_redis-0.1.6-cp310-abi3-win_amd64.whl", hash = "sha256:98c6b6fc397617cd5a6778ac020e4ed9985c393ad204f9f5cf524b68ae16070b", size = 1103735, upload-time = "2026-04-27T17:11:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ba/18668d92e18210150f7f93e2930264ad77a82e4d3e5f74ca1aecc002f78f/burner_redis-0.1.6-cp310-abi3-win_arm64.whl", hash = "sha256:c2583e98f9a3836ac2c6243ea0c8d56b40e7017b46617991e329bc807c544bad", size = 1029386, upload-time = "2026-04-27T17:11:40.266Z" }, +] + [[package]] name = "cachetools" version = "7.1.4" @@ -388,6 +407,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -397,6 +425,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cronsim" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -452,7 +488,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, @@ -483,34 +519,34 @@ wheels = [ [package.optional-dependencies] cudart = [ - { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime" }, ] cufft = [ - { name = "nvidia-cufft", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufft" }, ] cufile = [ - { name = "nvidia-cufile", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cufile" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti" }, ] curand = [ - { name = "nvidia-curand", marker = "sys_platform == 'linux'" }, + { name = "nvidia-curand" }, ] cusolver = [ - { name = "nvidia-cusolver", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusolver" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparse" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvtx" }, ] [[package]] @@ -607,21 +643,27 @@ wheels = [ [[package]] name = "fastmcp" -version = "3.4.2" +version = "4.0.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/18/46beaec18c9f86a599ae3f9cdf6677dd6b50240cfd844d18233710b47f13/fastmcp-3.4.2.tar.gz", hash = "sha256:b468722946fc467c3796a6572f7a14d93d48c014cf8fea12910245220cbbe4e1", size = 28756849, upload-time = "2026-06-06T01:30:35.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fd/e513c524bb3e296203f65bd8e9e726e9236bd3b59315e7cbdeb095662c01/fastmcp-4.0.0b1.tar.gz", hash = "sha256:f98d69588a73e1672840558641d5d0f111e207baffe001f3465713a53ebb6b4c", size = 42065171, upload-time = "2026-07-28T21:18:15.312Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/4d/8b1ba42251160e11ca34686344572121432c23a082d56ef6bbdec5888fc1/fastmcp-3.4.2-py3-none-any.whl", hash = "sha256:c87a62b029f0c5400ada85f683629345d2466c39169f0cb853e487b2f7308c08", size = 8018, upload-time = "2026-06-06T01:30:38.118Z" }, + { url = "https://files.pythonhosted.org/packages/2c/66/41b503ef852eff83f3f0c04f46cd1fc738c5374fd509384fc6b96e45918f/fastmcp-4.0.0b1-py3-none-any.whl", hash = "sha256:d66eb7b0763ffff2ae0fc573778ea25604dcb7e59769e5afaf9851a806eb1129", size = 8064, upload-time = "2026-07-28T21:18:12.688Z" }, +] + +[package.optional-dependencies] +tasks = [ + { name = "fastmcp-tasks" }, ] [[package]] name = "fastmcp-slim" -version = "3.4.2" +version = "4.0.0b1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mcp-types" }, { name = "platformdirs" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, @@ -629,16 +671,16 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/2e/d627b28b7403ecc526991ef732921b08bde010006e6148635f053fd29f4c/fastmcp_slim-3.4.2.tar.gz", hash = "sha256:290646e0955a516235a317151034559aa48336cb843d3f006131aedad8759bb4", size = 576291, upload-time = "2026-06-06T01:30:12.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/33/f207166aac88c6d8be1b2a82c54fecd1e04b075ef12d027aa17b3134fc0f/fastmcp_slim-4.0.0b1.tar.gz", hash = "sha256:158efb25720e0cb301711146b2d05d181de95cd91fe70e87c251ad14b123d665", size = 660081, upload-time = "2026-07-28T21:17:50.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/58/22afebf18df7260b09148199cbeb90cdcc4b3a4e1b5d7460e3591c3a7add/fastmcp_slim-3.4.2-py3-none-any.whl", hash = "sha256:bdc72492212681ca502755fa8acc0457f559295da1fc3dfc0599adc1c04b82f3", size = 749195, upload-time = "2026-06-06T01:30:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/fbe192d2ab50bb31b284fd0eb6c72d4347df7a57d836bee4f1973ffd1d7d/fastmcp_slim-4.0.0b1-py3-none-any.whl", hash = "sha256:dd907a3db5a2f479ca958c30157e227b4f7b340a56d333eb91de95719254597a", size = 827975, upload-time = "2026-07-28T21:17:48.747Z" }, ] [package.optional-dependencies] client = [ { name = "authlib" }, { name = "exceptiongroup" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, @@ -649,7 +691,7 @@ server = [ { name = "cyclopts" }, { name = "exceptiongroup" }, { name = "griffelib" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "joserfc" }, { name = "jsonref" }, { name = "jsonschema-path" }, @@ -668,6 +710,20 @@ server = [ { name = "websockets" }, ] +[[package]] +name = "fastmcp-tasks" +version = "4.0.0b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "burner-redis", marker = "sys_platform == 'win32'" }, + { name = "fastmcp-slim", extra = ["server"] }, + { name = "pydocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/69/0a5613519eddd08fe674635ecd88ddae3f435f643141b725bb32834d7945/fastmcp_tasks-4.0.0b1.tar.gz", hash = "sha256:44f3a70a4241f82ff81c9fce477969aeb8cc06430985d7c37cb393eba791360e", size = 50552, upload-time = "2026-07-28T21:18:08.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/d3/48a891e14819d7b94304a0f360ceef5731e6d69477a28ded54fc43c3e286/fastmcp_tasks-4.0.0b1-py3-none-any.whl", hash = "sha256:221f1cca93213baeeea79a02fe70a69fea7c1902ed42e3f7a0f7f01d0c704869", size = 61363, upload-time = "2026-07-28T21:18:07.396Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -939,6 +995,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -955,12 +1024,19 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -1211,15 +1287,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.28.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -1229,9 +1305,22 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/ee/94c6c50ffc5b5cf4737052275d11b57367f32d1a8516e31dcd60591b3916/mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2", size = 636040, upload-time = "2026-06-16T21:37:17.996Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -1431,7 +1520,7 @@ name = "nomad-scifm" version = "0.2.0" source = { editable = "../../" } dependencies = [ - { name = "fastmcp" }, + { name = "fastmcp", extra = ["tasks"] }, { name = "filelock" }, { name = "h5py" }, { name = "huggingface-hub" }, @@ -1456,11 +1545,11 @@ otel = [ [package.metadata] requires-dist = [ - { name = "fastmcp", specifier = ">=3.0.0" }, + { name = "fastmcp", extras = ["tasks"], specifier = ">=4.0.0b1,<5.0" }, { name = "filelock", specifier = ">=3.13.0" }, { name = "h5py", specifier = ">=3.15.1" }, { name = "huggingface-hub", specifier = ">=0.34.0,<2.0" }, - { name = "mcp", specifier = ">=1.20.0,<2.0" }, + { name = "mcp", specifier = ">=2.0.0,<3.0" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.40.0" }, { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'otel'", specifier = ">=1.40.0" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.40.0" }, @@ -1568,7 +1657,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1607,7 +1696,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1619,7 +1708,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1649,9 +1738,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1663,7 +1752,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1932,6 +2021,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + [[package]] name = "propcache" version = "0.5.2" @@ -2074,6 +2172,9 @@ keyring = [ memory = [ { name = "cachetools" }, ] +redis = [ + { name = "redis" }, +] [[package]] name = "pyarrow" @@ -2236,6 +2337,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] +[[package]] +name = "pydocket" +version = "0.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "burner-redis" }, + { name = "cloudpickle" }, + { name = "cronsim" }, + { name = "opentelemetry-api" }, + { name = "prometheus-client" }, + { name = "py-key-value-aio", extra = ["memory", "redis"] }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "rich" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "uncalled-for" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/bf/7f1134e990855f373e5ee6ba316db8fe654a2d7dd852b41ab890fcfb91e3/pydocket-0.20.1.tar.gz", hash = "sha256:d72b3784e4b5069b39e5f49f599d54a891e1b6222c27a8bcfbd4dee0f57d4895", size = 361993, upload-time = "2026-05-06T14:06:25.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9d/1bd873a0ea480dec388c40ac1a7500c129efbb9d61e2fef6b97236703458/pydocket-0.20.1-py3-none-any.whl", hash = "sha256:c886ece90ac93018f069d1eef9443f888404081d7258955e16847752575c95ae", size = 102774, upload-time = "2026-05-06T14:06:24.548Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -2289,6 +2414,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-json-logger" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, +] + [[package]] name = "python-multipart" version = "0.0.32" @@ -2435,6 +2569,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/a6/51fc1b0e61e3326e1c68a61cfd0c6b3c34c843681c4b1eefbf0596f59162/rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813", size = 855409, upload-time = "2026-04-07T11:16:15.787Z" }, ] +[[package]] +name = "redis" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -2807,8 +2950,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ From 2cd0781560e6811a7e80e4c28d71de96121a9bc8 Mon Sep 17 00:00:00 2001 From: Alexius Wadell Date: Wed, 29 Jul 2026 20:59:35 -0600 Subject: [PATCH 5/5] fix readme links --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c9788f3..c8d7ea5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Nomad icon + Nomad icon

# Nomad @@ -10,9 +10,9 @@ PyTorch-backed scientific tools over the [Model Context Protocol typed Python callables inside a code-execution sandbox. [Documentation site](https://lanl.github.io/nomad) | -[Getting started](docs/guides/getting-started.md) | -[Model builder guide](docs/guides/model-builder.md) | -[Reference](docs/reference/index.md) +[Getting started](https://lanl.github.io/nomad/guides/getting-started.html) | +[Model builder guide](https://lanl.github.io/nomad/guides/model-builder.html) | +[Reference](https://lanl.github.io/nomad/reference/index.html) ## What Nomad does @@ -52,20 +52,20 @@ npx @modelcontextprotocol/inspector -- \ ``` For the HTTP workflow and matching [URSA](https://github.com/lanl/ursa) configs, see -[Starting a Nomad server](docs/guides/getting-started.md#starting-a-nomad-server) +[Starting a Nomad server](https://lanl.github.io/nomad/guides/getting-started.html#starting-a-nomad-server) and -[Connect to a hosted Nomad server](docs/guides/getting-started.md#connect-to-a-hosted-nomad-server). +[Connect to a hosted Nomad server](https://lanl.github.io/nomad/guides/getting-started.html#connect-to-a-hosted-nomad-server). ## Start here by task | If you want to... | Start here | | --- | --- | -| Connect to a running Nomad server | [Getting started](docs/guides/getting-started.md) | -| Host a new SciFM | [Model builder guide](docs/guides/model-builder.md) | -| Use Nomad for Inference | [Nomad inference notebook](docs/guides/nomad_inference.ipynb) | -| Browse CLI, config, and API docs | [Reference](docs/reference/index.md) | -| Run the demo deployment | [Deployments](docs/deployments/index.md) | -| Work on Nomad itself | [Developer docs](docs/guides/developer.md) | +| Connect to a running Nomad server | [Getting started](https://lanl.github.io/nomad/guides/getting-started.html) | +| Host a new SciFM | [Model builder guide](https://lanl.github.io/nomad/guides/model-builder.html) | +| Use Nomad for Inference | [Nomad inference notebook](https://lanl.github.io/nomad/guides/nomad_inference.html) | +| Browse CLI, config, and API docs | [Reference](https://lanl.github.io/nomad/reference/index.html) | +| Run the demo deployment | [Deployments](https://lanl.github.io/nomad/deployments/index.html) | +| Work on Nomad itself | [Developer docs](https://lanl.github.io/nomad/guides/developer.html) | ## Development