Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a1b138d
feat(downloadstation): module scaffolding + MODULE_INFO
cmeans-claude-dev[bot] May 13, 2026
95f9fb7
refactor(downloadstation): drop placeholder enabled_extras settings f…
cmeans-claude-dev[bot] May 13, 2026
e53d33b
feat(downloadstation): implement list_downloads
cmeans-claude-dev[bot] May 13, 2026
0b49748
refactor(downloadstation): apply code-quality review feedback
cmeans-claude-dev[bot] May 13, 2026
0f33d8c
feat(downloadstation): implement get_download_info
cmeans-claude-dev[bot] May 13, 2026
e34f0dd
refactor(downloadstation): polish get_download_info per review
cmeans-claude-dev[bot] May 13, 2026
b7cfc59
feat(downloadstation): implement get_download_stats
cmeans-claude-dev[bot] May 13, 2026
962fb7f
feat(downloadstation): implement get_download_config
cmeans-claude-dev[bot] May 13, 2026
da74a5a
feat(downloadstation): implement get_schedule + format_schedule_grid
cmeans-claude-dev[bot] May 13, 2026
67c5059
docs(downloadstation): list module in integration config example
cmeans-claude-dev[bot] May 13, 2026
db1a2a9
docs(changelog): add Phase 1 Download Station entry
cmeans-claude-dev[bot] May 13, 2026
7f0f7f9
refactor(downloadstation): hoist format_speed into shared helpers
cmeans-claude-dev[bot] May 13, 2026
186d797
docs(changelog): substitute real PR# 104 for placeholder
cmeans-claude-dev[bot] May 13, 2026
4aab0cf
test(downloadstation): lift coverage to 100% across all module files
cmeans-claude-dev[bot] May 13, 2026
cfe610a
docs(changelog): correct test count and coverage to actuals (60 tests…
cmeans-claude-dev[bot] May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

- **Download Station module — Phase 1 (READ tools)** (#104). New module `mcp_synology.modules.downloadstation` adds five read-only tools — `list_downloads`, `get_download_info`, `get_download_stats`, `get_download_config`, `get_schedule` — that expose Synology's Download Station package via DSM API v1 (`SYNO.DownloadStation.Task`, `Info`, `Statistic`, `Schedule`). Module is opt-in via `instances.[name].modules.downloadstation.enabled: true`; the API preflight skips registration entirely when the DS package is not installed on the NAS, so users without DS pay zero LLM-context cost (no tools registered, no handler imports, no MODULE_INFO consumed beyond the preflight). Module shape mirrors File Station: domain-split files (`tasks.py`, `stats.py`, `config.py`, `helpers.py`), lazy handler imports inside `register()`, `respx`-mocked unit tests. Reuses the existing shared DSM session — DSM does not gate API access by session name, so no auth-manager changes were needed; ADR-0001's deferral of per-client sessions applies to client-isolation, not per-package isolation. `list_downloads` does client-side status filtering (DSM v1 `Task.list` lacks server-side filter support); `get_download_info` requests all five `additional` groups (`detail,transfer,file,tracker,peer`) and renders them as distinct sections; `get_schedule` parses DSM's 168-character `schedule_plan` into a 7×24 ASCII grid. Tool input validation uses the project's standard `error_response(ErrorCode.INVALID_PARAMETER, ...)` structured envelope. Phase 2 (task CRUD writes + global config writes) and Phase 3 (BT search + RSS) to follow as separate PRs. 60 new unit tests; every new module file (`__init__.py`, `tasks.py`, `stats.py`, `config.py`, `helpers.py`) at 100% line coverage. Full design at `docs/superpowers/specs/2026-05-13-downloadstation-module-design.md`.

- **ADR 0001 — Per-Client DSM Sessions and the Streamable HTTP Roadmap** (#PR_PLACEHOLDER) — closes #47. The 2026-04-16 project-wide review flagged the largest spec-vs-code gap: `docs/specs/architecture.md` documented a `session_key` parameter on `AuthManager.get_session()` for per-MCP-client DSM sessions under Streamable HTTP, but the live signature was `get_session() -> str` with no key and no plumbing. Three options were considered — ship the multi-session path now, retract the spec, or formally defer with the helper restructured. This PR records the deferral (Option 3) and closes the spec drift. New `docs/adr/0001-per-client-dsm-sessions.md` captures the question, options-with-tradeoffs, decision, consequences, and five concrete revisit triggers (Streamable HTTP gaining a concrete plan, multi-tenant deployment use case, subagent isolation request, tool-surface roughly doubling, single-shared-session pain showing up under real load). The ADR also inventories what the next implementation step looks like when a trigger fires, so the deferred work has a clear bounded shape rather than an open-ended re-design. Spec changes: `docs/specs/architecture.md` "Auth Manager" example reverted to the live `get_session()` signature with a forward-pointer to the planned section + ADR; the dedicated section renamed "Future" → "Planned: Per-Client Sessions (Streamable HTTP)" with an explicit deferral-not-oversight statement and a back-reference to the ADR.

- **publish.yml: gate PyPI publish on `server.json` registry-schema validation** (#89) — closes #44. Adds a `validate-server-json` job that runs before `publish-pypi` on every release tag, mirroring the same check that runs on every PR via `ci.yml`. Without this gate, a malformed `server.json` (new required field in a future registry schema, type change, etc.) would PyPI-publish cleanly and then fail at the registry leg — leaving a discoverable PyPI release that isn't in the MCP registry, with no way to roll back PyPI short of yanking. v0.5.1 hit the registry-leg-fails-after-PyPI scenario for a *different* reason (mcp-publisher OIDC audience mismatch, fixed in #79), so the failure mode isn't theoretical. The new job uses the same `./.github/actions/install-mcp-publisher` composite that `publish-registry` uses, so the validating publisher version always matches the publishing one. Job runs in parallel with `build` (no dep on it), and `publish-pypi` now lists both as `needs:` so a validation failure stops the entire pipeline before any external side-effect. The optional `publish-manifest` artifact named in #44 is deferred — it's a tooling-version reconciliation aid that's only useful if a CI-PR / release-tag mismatch *does* fire, and is straightforward to add later when there's a concrete failure to debug.
Expand Down
184 changes: 184 additions & 0 deletions src/mcp_synology/modules/downloadstation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Download Station module: MODULE_INFO, register(), DownloadStationSettings.

Phase 1 (READ tools): list_downloads, get_download_info, get_download_stats,
get_download_config, get_schedule. Phase 2 adds task CRUD writes; Phase 3
adds BT search + RSS. See docs/superpowers/specs/2026-05-13-downloadstation-module-design.md.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from pydantic import BaseModel

from mcp_synology.modules import (
ApiRequirement,
ModuleInfo,
PermissionTier,
ToolInfo,
default_annotations,
)

if TYPE_CHECKING:
from mcp_synology.modules import RegisterContext


class DownloadStationSettings(BaseModel):
"""Download Station module settings.

Phase 1 has no settings; the schema is declared so server.py module-loading
machinery can pass an empty settings dict cleanly. Fields land in the task
that needs them.
"""


MODULE_INFO = ModuleInfo(
name="downloadstation",
description=("Manage Synology Download Station tasks, schedule, and configuration"),
required_apis=[
ApiRequirement(api_name="SYNO.DownloadStation.Task", min_version=1),
ApiRequirement(api_name="SYNO.DownloadStation.Info", min_version=1, optional=True),
ApiRequirement(api_name="SYNO.DownloadStation.Schedule", min_version=1, optional=True),
ApiRequirement(api_name="SYNO.DownloadStation.Statistic", min_version=1, optional=True),
ApiRequirement(api_name="SYNO.DownloadStation.RSS.Site", min_version=1, optional=True),
ApiRequirement(api_name="SYNO.DownloadStation.RSS.Feed", min_version=1, optional=True),
ApiRequirement(api_name="SYNO.DownloadStation.BTSearch", min_version=1, optional=True),
ApiRequirement(api_name="SYNO.DownloadStation2.Task", min_version=1, optional=True),
],
tools=[
ToolInfo(
name="list_downloads",
description=(
"List download tasks in the Download Station queue. Filter by status "
"(downloading/finished/paused/error/all). Returns a table with id, title, "
"type (bt/http/ftp/nzb), status, size, progress%, current speed, and ETA."
),
permission_tier=PermissionTier.READ,
),
ToolInfo(
name="get_download_info",
description=(
"Get detailed information for a specific download task: detail (status, "
"destination, URI), transfer (size downloaded/uploaded, speed, peers), "
"files (per-file selection for BT), trackers (BT only), peers (BT only). "
"Use list_downloads first to find the task_id."
),
permission_tier=PermissionTier.READ,
),
ToolInfo(
name="get_download_stats",
description=(
"Get current Download Station throughput statistics: total download and "
"upload speed across all tasks, plus per-service (BT, HTTP/FTP, eMule) "
"breakdowns when those services are enabled."
),
permission_tier=PermissionTier.READ,
),
ToolInfo(
name="get_download_config",
description=(
"Get Download Station global configuration: BT max upload/download speeds, "
"default destination, scheduled-throttling plan summary, eMule enable state, "
"and other DSM-level DS settings."
),
permission_tier=PermissionTier.READ,
),
ToolInfo(
name="get_schedule",
description=(
"Get the Download Station weekly schedule as a 7-day × 24-hour grid. Each "
"cell shows whether downloads are off, on, or throttled at that hour. "
"Useful for verifying off-peak bandwidth policies."
),
permission_tier=PermissionTier.READ,
),
],
settings_schema=DownloadStationSettings,
)


def register(ctx: RegisterContext) -> None:
"""Register Download Station tools with the MCP server."""
from mcp_synology.modules.downloadstation.config import (
get_download_config,
get_schedule,
)
from mcp_synology.modules.downloadstation.stats import get_download_stats
from mcp_synology.modules.downloadstation.tasks import (
get_download_info,
list_downloads,
)

server = ctx.server
manager = ctx.manager

_tool_annos = {
t.name: t.annotations or default_annotations(t.permission_tier) for t in MODULE_INFO.tools
}

def _desc(name: str) -> str:
return next(t.description for t in MODULE_INFO.tools if t.name == name)

if "list_downloads" in ctx.allowed_tools:

@server.tool(
name="list_downloads",
description=_desc("list_downloads"),
annotations=_tool_annos["list_downloads"],
)
async def tool_list_downloads(
status_filter: str = "all",
offset: int = 0,
limit: int = 100,
) -> str:
client = await manager.get_client()
return await list_downloads(
client,
status_filter=status_filter,
offset=offset,
limit=limit,
)

if "get_download_info" in ctx.allowed_tools:

@server.tool(
name="get_download_info",
description=_desc("get_download_info"),
annotations=_tool_annos["get_download_info"],
)
async def tool_get_download_info(task_id: str) -> str:
client = await manager.get_client()
return await get_download_info(client, task_id=task_id)

if "get_download_stats" in ctx.allowed_tools:

@server.tool(
name="get_download_stats",
description=_desc("get_download_stats"),
annotations=_tool_annos["get_download_stats"],
)
async def tool_get_download_stats() -> str:
client = await manager.get_client()
return await get_download_stats(client)

if "get_download_config" in ctx.allowed_tools:

@server.tool(
name="get_download_config",
description=_desc("get_download_config"),
annotations=_tool_annos["get_download_config"],
)
async def tool_get_download_config() -> str:
client = await manager.get_client()
return await get_download_config(client)

if "get_schedule" in ctx.allowed_tools:

@server.tool(
name="get_schedule",
description=_desc("get_schedule"),
annotations=_tool_annos["get_schedule"],
)
async def tool_get_schedule() -> str:
client = await manager.get_client()
return await get_schedule(client)
100 changes: 100 additions & 0 deletions src/mcp_synology/modules/downloadstation/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Download Station config tools: get_download_config, get_schedule."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from mcp_synology.core.errors import ErrorCode, SynologyError
from mcp_synology.core.formatting import (
error_response,
format_key_value,
synology_error_response,
)

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from mcp_synology.core.client import DsmClient


def _kbps_str(kbps: int) -> str:
"""Format a KB/s rate; 0 means unlimited per DSM convention."""
if kbps <= 0:
return "unlimited"
return f"{kbps} KB/s"


def _bool_str(value: bool | None) -> str:
if value is None:
return "—"
return "yes" if value else "no"


async def get_download_config(client: DsmClient) -> str:
"""Get Download Station global configuration."""
try:
data = await client.request(
"SYNO.DownloadStation.Info",
"getconfig",
version=1,
)
except SynologyError as e:
synology_error_response("Get download config", e)

pairs: list[tuple[str, str]] = [
("Default destination", str(data.get("default_destination", "—"))),
("BT max download", _kbps_str(int(data.get("bt_max_download", 0)))),
("BT max upload", _kbps_str(int(data.get("bt_max_upload", 0)))),
("HTTP max download", _kbps_str(int(data.get("http_max_download", 0)))),
("FTP max download", _kbps_str(int(data.get("ftp_max_download", 0)))),
("NZB max download", _kbps_str(int(data.get("nzb_max_download", 0)))),
("eMule enabled", _bool_str(data.get("emule_enabled"))),
("eMule max download", _kbps_str(int(data.get("emule_max_download", 0)))),
("eMule max upload", _kbps_str(int(data.get("emule_max_upload", 0)))),
("Auto-unzip enabled", _bool_str(data.get("unzip_service_enabled"))),
]

return format_key_value(pairs, title="Download Station configuration")


async def get_schedule(client: DsmClient) -> str:
"""Get the weekly DS schedule as a 7×24 grid plus enable flags."""
from mcp_synology.modules.downloadstation.helpers import format_schedule_grid

try:
data = await client.request(
"SYNO.DownloadStation.Schedule",
"getconfig",
version=1,
)
except SynologyError as e:
synology_error_response("Get download schedule", e)

pairs: list[tuple[str, str]] = [
("Enabled", _bool_str(data.get("enabled"))),
("eMule schedule enabled", _bool_str(data.get("emule_enabled"))),
]
flags_block = format_key_value(pairs, title="Download Station schedule")

plan = data.get("schedule_plan", "")
if not isinstance(plan, str):
error_response(
ErrorCode.INVALID_PARAMETER,
f"Get download schedule failed: schedule_plan is not a string ({type(plan).__name__}).",
retryable=False,
param="schedule_plan",
value=str(plan),
)
try:
grid = format_schedule_grid(plan)
except ValueError as e:
error_response(
ErrorCode.INVALID_PARAMETER,
f"Get download schedule failed: {e}",
retryable=False,
param="schedule_plan",
value=plan,
)

return f"{flags_block}\n\n{grid}"
Loading