diff --git a/src/tether/cli.py b/src/tether/cli.py index 91adfd4..e38979c 100644 --- a/src/tether/cli.py +++ b/src/tether/cli.py @@ -7744,6 +7744,5 @@ def data_revoke() -> None: set_contribute_data(False) console.print(f"Revoked: {removed} files deleted. Data contribution disabled.") - if __name__ == "__main__": app() diff --git a/src/tether/mcp/server.py b/src/tether/mcp/server.py index 63de7f4..d8e67a6 100644 --- a/src/tether/mcp/server.py +++ b/src/tether/mcp/server.py @@ -1,6 +1,6 @@ """FastMCP server factory bound to a live TetherServer. -Exposes 6 tools + 1 resource to MCP-compatible agents (Phase 1 + Phase 1.5): +Exposes 6 tools + 2 resources to MCP-compatible agents (Phase 1 + Phase 1.5): Phase 1 (consumer-side): - tool: `act(instruction, image_b64, state, episode_id?)` → action chunk + @@ -8,6 +8,7 @@ - tool: `health()` → {state, model_version, uptime_seconds, cuda_graphs_active} - tool: `models_list()` → [{id, hf_id, size_gb_fp16, hardware_fit}, ...] - tool: `validate_dataset(dataset_path)` → {summary, checks: [...]} +- resource: `version://current` → current package/runtime version - resource: `metrics://prometheus` → current Prometheus exposition text Phase 1.5 (producer-side, agents-can-plan-without-executing): @@ -57,6 +58,7 @@ - validate_dataset: pre-flight check a LeRobot v3.0 training dataset Available resources: +- version://current: fastcrest-tether package version (for client compatibility checks) - metrics://prometheus: current Prometheus metrics in text exposition format Safety note: tool `act` returns actions but does NOT actuate them. The caller is @@ -501,6 +503,21 @@ async def export_estimate( "notes": notes, } + @mcp.resource("version://current") + async def version_resource() -> str: + """Current fastcrest-tether package version. + + Clients can read this resource to check compatibility before issuing + tool calls. Returns a JSON string with ``version`` and ``package`` keys. + """ + import json + from tether import __version__ + return json.dumps({ + "version": __version__, + "package": "fastcrest-tether", + "service": "tether", + }) + @mcp.resource("metrics://prometheus") async def prometheus_metrics() -> str: """Current Prometheus metrics in text exposition format. diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 6fcac8f..5dfd44f 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -246,6 +246,52 @@ async def test_validate_dataset_tool_returns_error_on_missing_path(): assert ("error" in payload) or ("decision" in payload) or ("summary" in payload) +# --------------------------------------------------------------------------- +# Resource: version://current +# --------------------------------------------------------------------------- + + +def _resource_text(result) -> str: + if isinstance(result, list): + contents = result + elif hasattr(result, "contents"): + contents = result.contents + else: + return result.text if hasattr(result, "text") else str(result) + return "".join( + ( + item.text + if hasattr(item, "text") + else item.content + if hasattr(item, "content") + else str(item) + ) + for item in contents + ) + + +@pytest.mark.asyncio +async def test_version_resource_registered(): + mcp = create_mcp_server(_mock_tether_server()) + resources = await mcp.list_resources() + uris = {str(r.uri) for r in resources} + assert "version://current" in uris + + +@pytest.mark.asyncio +async def test_version_resource_returns_package_version(): + import json + from tether import __version__ + + mcp = create_mcp_server(_mock_tether_server()) + result = await mcp.read_resource("version://current") + + data = json.loads(_resource_text(result)) + assert data["version"] == __version__ + assert data["package"] == "fastcrest-tether" + assert data["service"] == "tether" + + # --------------------------------------------------------------------------- # Resource: metrics://prometheus # --------------------------------------------------------------------------- @@ -256,15 +302,7 @@ async def test_metrics_resource_returns_prometheus_text(): mcp = create_mcp_server(_mock_tether_server()) # Read the metrics resource result = await mcp.read_resource("metrics://prometheus") - # read_resource returns a list of ReadResourceContents or strings depending - # on FastMCP version — handle both - if isinstance(result, list): - payload = "".join( - (item.text if hasattr(item, "text") else str(item)) - for item in result - ) - else: - payload = result.text if hasattr(result, "text") else str(result) + payload = _resource_text(result) # Real Prometheus exposition starts with # HELP or # TYPE comments assert isinstance(payload, str) assert len(payload) > 0