feat: add get_info tool using SYNO.FileStation.List::getinfo

SYNO.FileStation.Stat is absent from this NAS's API registry.
SYNO.FileStation.List::getinfo returns identical data and is confirmed
working.

- tools/filestation.py: new get_info tool — accepts one or more
  comma-separated paths, calls getinfo with real_path/size/time/perm/
  owner/type additional fields, returns a 9-column table
- tests: 6 new tests covering single file, directory, multi-path,
  empty input, DSM error, and correct API method assertion
- SPEC.md: remove SYNO.FileStation.Stat from API table, rewrite get_info
  tool section to reference getinfo, update list_dir note
- CLAUDE.md: update Implemented Tools list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 09:28:04 +02:00
parent 8fc2f731ce
commit 014af1aefe
4 changed files with 280 additions and 9 deletions
@@ -211,3 +211,104 @@ def register_filestation(
lines.append(f"Use offset={end} to fetch the next page.")
return "\n".join(lines)
@mcp.tool()
async def get_info(path: str) -> str:
"""Get detailed metadata for one or more files or folders on the NAS.
Accepts a single path or a comma-separated list of paths.
Use share paths as returned by list_shares (e.g. "/dev/file.txt").
Args:
path: One or more share-relative paths, comma-separated
(e.g. "/dev/notes.txt" or "/dev/notes.txt,/data/photo.jpg").
Returns:
Formatted table with type, size, owner, permissions, and timestamps
for each requested path.
"""
from mcp_synology_filestation.client import SynologyError
paths = [p.strip() for p in path.split(",") if p.strip()]
if not paths:
return "Error: no path provided."
try:
data = await client.request(
"SYNO.FileStation.List",
"getinfo",
params={
"path": ",".join(paths),
"additional": json.dumps(
["real_path", "size", "time", "perm", "owner", "type"]
),
},
)
except SynologyError as e:
return f"Error: {e}"
files: list[dict] = data.get("files", [])
if not files:
return "No information returned for the given path(s)."
rows = []
for f in files:
item_path = f.get("path") or f.get("name", "")
is_dir = f.get("isdir", False)
ftype = "dir" if is_dir else "file"
add = f.get("additional", {})
size_str = "-" if is_dir else _fmt_size(add.get("size"))
mtime_str = _fmt_time((add.get("time") or {}).get("mtime"))
crtime_str = _fmt_time((add.get("time") or {}).get("crtime"))
owner_info = add.get("owner") or {}
owner = owner_info.get("user", "-")
group = owner_info.get("group", "-")
perm_info = add.get("perm") or {}
posix = perm_info.get("posix", 0)
perm_str = oct(posix)[2:] if posix else "-"
real_path = (add.get("real_path") or "").strip() or "-"
rows.append(
(
item_path,
ftype,
size_str,
owner,
group,
perm_str,
mtime_str,
crtime_str,
real_path,
)
)
headers = (
"Path",
"Type",
"Size",
"Owner",
"Group",
"Perm",
"Modified",
"Created",
"Real path",
)
col_widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
def _sep() -> str:
return "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
def _row(vals: tuple[str, ...]) -> str:
return "| " + " | ".join(f"{v:<{col_widths[i]}}" for i, v in enumerate(vals)) + " |"
lines = [_sep(), _row(headers), _sep()]
for r in rows:
lines.append(_row(r))
lines.append(_sep())
lines.append(f"\n{len(rows)} item(s).")
return "\n".join(lines)