freshrss-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python
2
+
3
+ import importlib
4
+ import inspect
5
+ import warnings
6
+
7
+ warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
8
+
9
+ __all__: list[str] = []
10
+
11
+ CORE_MODULES = [
12
+ "freshrss_agent.api",
13
+ ]
14
+
15
+ OPTIONAL_MODULES = {
16
+ "freshrss_agent.agent_server": "agent",
17
+ "freshrss_agent.mcp_server": "mcp",
18
+ }
19
+
20
+
21
+ def _import_module_safely(module_name: str):
22
+ """Try to import a module and return it, or None if not available."""
23
+ try:
24
+ return importlib.import_module(module_name)
25
+ except ImportError:
26
+ return None
27
+
28
+
29
+ def _expose_members(module):
30
+ """Expose public classes and functions from a module into globals and __all__."""
31
+ for name, obj in inspect.getmembers(module):
32
+ if (inspect.isclass(obj) or inspect.isfunction(obj)) and not name.startswith(
33
+ "_"
34
+ ):
35
+ globals()[name] = obj
36
+ __all__.append(name)
37
+
38
+
39
+ for module_name in CORE_MODULES:
40
+ try:
41
+ module = importlib.import_module(module_name)
42
+ _expose_members(module)
43
+ except ImportError:
44
+ pass
45
+
46
+ for module_name, extra_name in OPTIONAL_MODULES.items():
47
+ module = _import_module_safely(module_name)
48
+ if module is not None:
49
+ _expose_members(module)
50
+ globals()[f"_{extra_name.upper()}_AVAILABLE"] = True
51
+ else:
52
+ globals()[f"_{extra_name.upper()}_AVAILABLE"] = False
53
+
54
+ __all__.extend(["_MCP_AVAILABLE", "_AGENT_AVAILABLE"])
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/python
2
+ from freshrss_agent.agent_server import agent_server
3
+
4
+ if __name__ == "__main__":
5
+ agent_server()
@@ -0,0 +1,15 @@
1
+ # IDENTITY.md - FreshRSS Agent Identity
2
+
3
+ ## [default]
4
+ * **Name:** FreshRSS Agent
5
+ * **Role:** FreshRSS API + MCP Server + A2A Server
6
+ * **Emoji:** 🤖
7
+
8
+ ### System Prompt
9
+ You are the FreshRSS Agent.
10
+ Use the `mcp-client` universal skill and check the reference documentation for
11
+ `freshrss-agent.md` to discover the exact tags and tools available for your capabilities.
12
+
13
+ ### Capabilities
14
+ - **MCP Operations**: Leverage the `mcp-client` skill to interact with the target MCP server.
15
+ - **Custom Agent**: Handle custom tasks or general tasks.
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/python
2
+ import logging
3
+ import os
4
+ import sys
5
+ import warnings
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ logging.basicConfig(
10
+ level=logging.INFO,
11
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
12
+ handlers=[logging.StreamHandler()],
13
+ )
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def agent_server():
18
+ from agent_utilities import (
19
+ build_system_prompt_from_workspace,
20
+ create_agent_parser,
21
+ create_agent_server,
22
+ initialize_workspace,
23
+ load_identity,
24
+ )
25
+
26
+ warnings.filterwarnings("ignore", message=".*urllib3.*or chardet.*")
27
+ warnings.filterwarnings("ignore", category=DeprecationWarning, module="fastmcp")
28
+
29
+ initialize_workspace()
30
+ meta = load_identity()
31
+ agent_name = os.getenv("DEFAULT_AGENT_NAME", meta.get("name", "FreshRSS"))
32
+
33
+ print(f"{agent_name} v{__version__}", file=sys.stderr)
34
+ parser = create_agent_parser()
35
+ args = parser.parse_args()
36
+
37
+ if args.debug:
38
+ logging.getLogger().setLevel(logging.DEBUG)
39
+ logger.debug("Debug mode enabled")
40
+
41
+ create_agent_server(
42
+ mcp_url=args.mcp_url,
43
+ mcp_config=args.mcp_config or "mcp_config.json",
44
+ host=args.host,
45
+ port=args.port,
46
+ provider=args.provider,
47
+ model_id=args.model_id,
48
+ router_model=args.model_id,
49
+ agent_model=args.model_id,
50
+ base_url=args.base_url,
51
+ api_key=args.api_key,
52
+ agent_description=os.getenv(
53
+ "AGENT_DESCRIPTION",
54
+ meta.get("description", "FreshRSS API + MCP Server + A2A Server"),
55
+ ),
56
+ system_prompt=os.getenv(
57
+ "AGENT_SYSTEM_PROMPT",
58
+ meta.get("content") or build_system_prompt_from_workspace(),
59
+ ),
60
+ custom_skills_directory=args.custom_skills_directory,
61
+ enable_web_ui=args.web,
62
+ enable_otel=args.otel,
63
+ otel_endpoint=args.otel_endpoint,
64
+ otel_headers=args.otel_headers,
65
+ otel_public_key=args.otel_public_key,
66
+ otel_secret_key=args.otel_secret_key,
67
+ otel_protocol=args.otel_protocol,
68
+ debug=args.debug,
69
+ )
70
+
71
+
72
+ if __name__ == "__main__":
73
+ agent_server()
@@ -0,0 +1,19 @@
1
+ from .api_client_base import FreshRSSClientBase
2
+ from .api_client_reader import ReaderMixin
3
+ from .api_client_subscriptions import SubscriptionsMixin
4
+
5
+
6
+ class FreshRSSApi(ReaderMixin, SubscriptionsMixin, FreshRSSClientBase):
7
+ """FreshRSS GReader API client (reader + subscriptions)."""
8
+
9
+
10
+ # Backward-compat alias for the scaffold (auth.py imports ApiClientSystem).
11
+ ApiClientSystem = FreshRSSApi
12
+
13
+ __all__ = [
14
+ "FreshRSSApi",
15
+ "FreshRSSClientBase",
16
+ "ReaderMixin",
17
+ "SubscriptionsMixin",
18
+ "ApiClientSystem",
19
+ ]
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/python
2
+ """GReader (Google Reader compatible) HTTP base client for FreshRSS."""
3
+
4
+ import logging
5
+ from typing import Any
6
+
7
+ import requests
8
+ import urllib3
9
+ from agent_utilities.base_utilities import get_logger
10
+ from agent_utilities.core.exceptions import AuthError, UnauthorizedError
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class FreshRSSClientBase:
16
+ """Base client for the FreshRSS Google Reader compatible API (GReader).
17
+
18
+ Implements the GReader auth flow:
19
+ 1. ``ClientLogin`` exchanges ``Email``/``Passwd`` for an ``Auth`` token.
20
+ 2. Authenticated requests send ``Authorization: GoogleLogin auth=<token>``.
21
+ 3. Write actions require a short-lived POST token (``T`` form field) fetched
22
+ from the ``token`` endpoint.
23
+ 4. On HTTP 401 the client re-runs ClientLogin once and retries.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ base_url: str | None,
29
+ username: str | None,
30
+ api_password: str | None,
31
+ verify: bool = True,
32
+ ):
33
+ self.base_url = (base_url or "").rstrip("/")
34
+ self.username = username or ""
35
+ self.api_password = api_password or ""
36
+ self.verify = verify
37
+ self.session = requests.Session()
38
+ self._auth_token: str | None = None
39
+
40
+ if self.verify is False:
41
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
42
+
43
+ # -- internals -----------------------------------------------------------
44
+
45
+ def _greader_url(self, path: str) -> str:
46
+ return f"{self.base_url}/api/greader.php/{path.lstrip('/')}"
47
+
48
+ def _login(self) -> str:
49
+ """Run ClientLogin and cache the ``Auth`` token."""
50
+ url = self._greader_url("accounts/ClientLogin")
51
+ response = self.session.post(
52
+ url,
53
+ data={"Email": self.username, "Passwd": self.api_password},
54
+ verify=self.verify,
55
+ timeout=30,
56
+ )
57
+ if response.status_code in (401, 403):
58
+ logger.error("FreshRSS ClientLogin failed: %s", response.status_code)
59
+ raise AuthError if response.status_code == 401 else UnauthorizedError
60
+ response.raise_for_status()
61
+ token: str | None = None
62
+ for line in response.text.splitlines():
63
+ if line.startswith("Auth="):
64
+ token = line[len("Auth=") :].strip()
65
+ break
66
+ if not token:
67
+ raise AuthError
68
+ self._auth_token = token
69
+ return token
70
+
71
+ def _auth_headers(self) -> dict[str, str]:
72
+ if not self._auth_token:
73
+ self._login()
74
+ return {"Authorization": f"GoogleLogin auth={self._auth_token}"}
75
+
76
+ def _get_write_token(self) -> str:
77
+ """Fetch a short-lived POST token required by write actions."""
78
+ response = self.session.get(
79
+ self._greader_url("reader/api/0/token"),
80
+ headers=self._auth_headers(),
81
+ verify=self.verify,
82
+ timeout=30,
83
+ )
84
+ if response.status_code == 401:
85
+ self._login()
86
+ response = self.session.get(
87
+ self._greader_url("reader/api/0/token"),
88
+ headers=self._auth_headers(),
89
+ verify=self.verify,
90
+ timeout=30,
91
+ )
92
+ response.raise_for_status()
93
+ return response.text.strip()
94
+
95
+ def request(
96
+ self,
97
+ method: str,
98
+ path: str,
99
+ params: dict[str, Any] | None = None,
100
+ data: Any | None = None,
101
+ ) -> Any:
102
+ """Issue an authenticated GReader request, retrying once on 401.
103
+
104
+ ``output=json`` is always injected so JSON responses are returned. The
105
+ JSON body is decoded when possible; otherwise the raw text is returned.
106
+ """
107
+ params = dict(params or {})
108
+ params.setdefault("output", "json")
109
+ url = self._greader_url(path)
110
+
111
+ def _send() -> requests.Response:
112
+ return self.session.request(
113
+ method,
114
+ url,
115
+ params=params,
116
+ data=data,
117
+ headers=self._auth_headers(),
118
+ verify=self.verify,
119
+ timeout=60,
120
+ )
121
+
122
+ response = _send()
123
+ if response.status_code == 401:
124
+ # Token expired — re-login once and retry.
125
+ self._auth_token = None
126
+ response = _send()
127
+ if response.status_code == 401:
128
+ logger.error("FreshRSS request unauthorized after re-login: %s", url)
129
+ raise AuthError
130
+ response.raise_for_status()
131
+ try:
132
+ return response.json()
133
+ except ValueError:
134
+ return {"status": response.status_code, "text": response.text}
135
+
136
+ def set_debug(self, debug: bool = False) -> None:
137
+ logger.setLevel(logging.DEBUG if debug else logging.ERROR)
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/python
2
+ """Reader operations for the FreshRSS GReader API (stream contents, item bodies)."""
3
+
4
+ from typing import Any
5
+
6
+
7
+ class ReaderMixin:
8
+ """Read-side GReader operations: stream contents, item bodies, unread counts."""
9
+
10
+ request: Any
11
+
12
+ def stream_contents(
13
+ self,
14
+ stream_id: str = "user/-/state/com.google/reading-list",
15
+ count: int = 100,
16
+ order: str = "o",
17
+ newer_than: int | None = None,
18
+ continuation: str | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Fetch items for a stream.
21
+
22
+ ``stream_id`` is a GReader stream id (e.g.
23
+ ``user/-/state/com.google/reading-list`` for all items, ``feed/<feedUrl>``
24
+ for one feed, or ``user/-/label/<category>``). ``order`` is ``o`` (oldest
25
+ first) or ``n`` (newest first). ``newer_than`` is a unix-seconds watermark
26
+ mapped to the GReader ``ot`` parameter (exclude items older than this).
27
+ ``continuation`` resumes a previous page. Returns the raw
28
+ ``{"items": [...], "continuation": "..."}`` payload.
29
+ """
30
+ params: dict[str, Any] = {"n": count, "r": order}
31
+ if newer_than is not None:
32
+ params["ot"] = int(newer_than)
33
+ if continuation:
34
+ params["c"] = continuation
35
+ result = self.request(
36
+ "GET",
37
+ f"reader/api/0/stream/contents/{stream_id}",
38
+ params=params,
39
+ )
40
+ # Normalize each item to flat, transport-safe top-level fields so a KG
41
+ # connector reads ``text`` directly (a nested ``summary.content`` field-map
42
+ # does not survive the MCP structured-output round-trip reliably). The raw
43
+ # GReader fields (origin/categories/canonical) are preserved alongside.
44
+ if isinstance(result, dict) and isinstance(result.get("items"), list):
45
+ for item in result["items"]:
46
+ if not isinstance(item, dict):
47
+ continue
48
+ body = (item.get("summary") or {}).get("content") or (
49
+ item.get("content") or {}
50
+ ).get("content") or ""
51
+ item["text"] = body
52
+ canonical = item.get("canonical") or []
53
+ if isinstance(canonical, list) and canonical:
54
+ item["url"] = canonical[0].get("href", "")
55
+ return result
56
+
57
+ def item_contents(self, item_ids: list[str] | str) -> dict[str, Any]:
58
+ """Fetch full contents for specific item ids (GReader ``i`` parameters)."""
59
+ if isinstance(item_ids, str):
60
+ item_ids = [item_ids]
61
+ data = [("i", item_id) for item_id in item_ids]
62
+ return self.request(
63
+ "POST",
64
+ "reader/api/0/stream/items/contents",
65
+ data=data,
66
+ )
67
+
68
+ def unread_count(self) -> dict[str, Any]:
69
+ """Return unread counts per stream."""
70
+ return self.request("GET", "reader/api/0/unread-count")
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/python
2
+ """Subscription, category and tagging operations for the FreshRSS GReader API."""
3
+
4
+ from typing import Any
5
+
6
+
7
+ class SubscriptionsMixin:
8
+ """Manage feeds, categories and item tags (read/star) via the GReader API."""
9
+
10
+ request: Any
11
+ _get_write_token: Any
12
+
13
+ def subscription_list(self) -> dict[str, Any]:
14
+ """List all feed subscriptions."""
15
+ return self.request("GET", "reader/api/0/subscription/list")
16
+
17
+ def categories(self) -> dict[str, Any]:
18
+ """List categories / tags (``tag/list``)."""
19
+ return self.request("GET", "reader/api/0/tag/list")
20
+
21
+ def _stream_for_feed(self, feed_url: str) -> str:
22
+ return feed_url if feed_url.startswith("feed/") else f"feed/{feed_url}"
23
+
24
+ def subscribe(
25
+ self,
26
+ feed_url: str,
27
+ title: str | None = None,
28
+ category: str | None = None,
29
+ ) -> dict[str, Any]:
30
+ """Subscribe to a feed, optionally setting its title and category."""
31
+ data: list[tuple[str, str]] = [
32
+ ("ac", "subscribe"),
33
+ ("s", self._stream_for_feed(feed_url)),
34
+ ("T", self._get_write_token()),
35
+ ]
36
+ if title:
37
+ data.append(("t", title))
38
+ if category:
39
+ data.append(("a", f"user/-/label/{category}"))
40
+ return self.request("POST", "reader/api/0/subscription/edit", data=data)
41
+
42
+ def unsubscribe(self, feed_url: str) -> dict[str, Any]:
43
+ """Unsubscribe from a feed."""
44
+ data = [
45
+ ("ac", "unsubscribe"),
46
+ ("s", self._stream_for_feed(feed_url)),
47
+ ("T", self._get_write_token()),
48
+ ]
49
+ return self.request("POST", "reader/api/0/subscription/edit", data=data)
50
+
51
+ def label(self, feed_url: str, category: str) -> dict[str, Any]:
52
+ """Add a category label to an existing feed subscription."""
53
+ data = [
54
+ ("ac", "edit"),
55
+ ("s", self._stream_for_feed(feed_url)),
56
+ ("a", f"user/-/label/{category}"),
57
+ ("T", self._get_write_token()),
58
+ ]
59
+ return self.request("POST", "reader/api/0/subscription/edit", data=data)
60
+
61
+ def mark_read(self, item_ids: list[str] | str) -> dict[str, Any]:
62
+ """Mark one or more items as read."""
63
+ if isinstance(item_ids, str):
64
+ item_ids = [item_ids]
65
+ data: list[tuple[str, str]] = [("T", self._get_write_token())]
66
+ data.append(("a", "user/-/state/com.google/read"))
67
+ data.extend(("i", item_id) for item_id in item_ids)
68
+ return self.request("POST", "reader/api/0/edit-tag", data=data)
69
+
70
+ def star(self, item_id: str, starred: bool = True) -> dict[str, Any]:
71
+ """Star or unstar an item."""
72
+ key = "a" if starred else "r"
73
+ data = [
74
+ ("T", self._get_write_token()),
75
+ (key, "user/-/state/com.google/starred"),
76
+ ("i", item_id),
77
+ ]
78
+ return self.request("POST", "reader/api/0/edit-tag", data=data)
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/python
2
+ """Facade re-export of the modular api/ sub-package (backward compatibility)."""
3
+
4
+ from .api import * # noqa: F401,F403
5
+ from .api import __all__ as _api_all
6
+
7
+ __all__ = list(_api_all)
freshrss_agent/auth.py ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/python
2
+
3
+ """Authentication.
4
+
5
+ Priority:
6
+ 1. **OIDC Delegation** (RFC 8693 Token Exchange) — when ``ENABLE_DELEGATION`` is
7
+ active, exchanges the IdP-issued user token for a downstream access token via the
8
+ shared ``agent_utilities.mcp.delegated_auth`` helper.
9
+ 2. **Fixed credentials** — falls back to the ``FRESHRSS_API_PASSWORD`` env var.
10
+
11
+ For a multi-tenant service, add an ``instances.py`` that resolves a configured
12
+ instance NAME (from ``<service>_instances`` in ``~/.config/agent-utilities/config.json``)
13
+ to ``(url, token, verify)`` and call it here before the delegation/fixed paths — see
14
+ ``gitlab_api.instances`` (CONCEPT:KG-2.9g) for the golden pattern.
15
+ """
16
+
17
+ import os
18
+
19
+ from agent_utilities.base_utilities import get_logger, to_boolean
20
+ from agent_utilities.core.exceptions import AuthError, UnauthorizedError
21
+
22
+ from .api import ApiClientSystem
23
+
24
+ logger = get_logger(__name__)
25
+ _client = None
26
+
27
+
28
+ def get_client(
29
+ url: str | None = None,
30
+ token: str | None = None,
31
+ verify: bool | None = None,
32
+ config: dict | None = None,
33
+ ) -> ApiClientSystem:
34
+ """Get or create a singleton API client (OIDC delegation or fixed credentials)."""
35
+ global _client
36
+ if _client is not None:
37
+ return _client
38
+
39
+ base_url = url or os.getenv("FRESHRSS_URL", "http://localhost:8080")
40
+ api_password = token or os.getenv("FRESHRSS_API_PASSWORD", "")
41
+ username = os.getenv("FRESHRSS_USER", "")
42
+ if verify is None:
43
+ verify = to_boolean(string=os.getenv("FRESHRSS_SSL_VERIFY", "True"))
44
+
45
+ from agent_utilities.mcp.delegated_auth import (
46
+ get_delegated_token,
47
+ get_user_identity,
48
+ is_delegation_enabled,
49
+ )
50
+
51
+ # --- Path 1: OIDC Delegation (RFC 8693 Token Exchange) ---
52
+ if is_delegation_enabled(config):
53
+ try:
54
+ delegated_token = get_delegated_token(
55
+ config=config,
56
+ audience=(config or {}).get("audience", base_url),
57
+ scopes=(config or {}).get("delegated_scopes", "api"),
58
+ verify=verify,
59
+ )
60
+ identity = get_user_identity()
61
+ logger.info(
62
+ "Using OIDC delegated token",
63
+ extra={"user_email": identity.get("email"), "url": base_url},
64
+ )
65
+ _client = ApiClientSystem(
66
+ base_url=base_url,
67
+ username=username,
68
+ api_password=delegated_token,
69
+ verify=verify,
70
+ )
71
+ return _client
72
+ except Exception as e:
73
+ logger.error(
74
+ "OIDC delegation failed",
75
+ extra={"error_type": type(e).__name__, "error_message": str(e)},
76
+ )
77
+ raise RuntimeError(f"Token exchange failed: {str(e)}") from e
78
+
79
+ # --- Path 2: Fixed Credentials (FRESHRSS_API_PASSWORD) ---
80
+ logger.info("Using fixed credentials")
81
+ try:
82
+ _client = ApiClientSystem(
83
+ base_url=base_url,
84
+ username=username,
85
+ api_password=api_password,
86
+ verify=verify,
87
+ )
88
+ except (AuthError, UnauthorizedError) as e:
89
+ raise RuntimeError(
90
+ f"AUTHENTICATION ERROR: The credentials provided are not valid for '{base_url}'. "
91
+ f"Please check your FRESHRSS_API_PASSWORD and FRESHRSS_URL environment variables. "
92
+ f"Error details: {str(e)}"
93
+ ) from e
94
+ except Exception as e:
95
+ raise RuntimeError(
96
+ f"AUTHENTICATION ERROR: Failed to instantiate client. "
97
+ f"Error details: {str(e)}"
98
+ ) from e
99
+
100
+ return _client
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/python
2
+ """Pydantic input models for FreshRSS GReader API request parameters."""
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class StreamContentsParams(BaseModel):
8
+ """Parameters for fetching stream contents."""
9
+
10
+ stream_id: str = Field(
11
+ default="user/-/state/com.google/reading-list",
12
+ description="GReader stream id (reading-list, feed/<url>, user/-/label/<cat>).",
13
+ )
14
+ count: int = Field(
15
+ default=100, description="Maximum number of items (GReader 'n')."
16
+ )
17
+ order: str = Field(
18
+ default="o", description="'o' oldest-first or 'n' newest-first (GReader 'r')."
19
+ )
20
+ newer_than: int | None = Field(
21
+ default=None,
22
+ description="Unix-seconds watermark; exclude items older than this (GReader 'ot').",
23
+ )
24
+ continuation: str | None = Field(
25
+ default=None, description="Continuation token for pagination (GReader 'c')."
26
+ )
27
+
28
+
29
+ class ItemContentsParams(BaseModel):
30
+ """Parameters for fetching specific item bodies."""
31
+
32
+ item_ids: list[str] | str = Field(
33
+ description="One or more GReader long-form item ids."
34
+ )
35
+
36
+
37
+ class SubscribeParams(BaseModel):
38
+ """Parameters for subscribing to a feed."""
39
+
40
+ feed_url: str = Field(description="Feed URL (or 'feed/<url>' stream id).")
41
+ title: str | None = Field(default=None, description="Optional feed title.")
42
+ category: str | None = Field(
43
+ default=None, description="Optional category label to assign."
44
+ )
45
+
46
+
47
+ class UnsubscribeParams(BaseModel):
48
+ """Parameters for unsubscribing from a feed."""
49
+
50
+ feed_url: str = Field(description="Feed URL (or 'feed/<url>' stream id).")
51
+
52
+
53
+ class LabelParams(BaseModel):
54
+ """Parameters for adding a category label to a feed."""
55
+
56
+ feed_url: str = Field(description="Feed URL (or 'feed/<url>' stream id).")
57
+ category: str = Field(description="Category label to add.")
58
+
59
+
60
+ class MarkReadParams(BaseModel):
61
+ """Parameters for marking items as read."""
62
+
63
+ item_ids: list[str] | str = Field(description="One or more item ids.")
64
+
65
+
66
+ class StarParams(BaseModel):
67
+ """Parameters for starring/unstarring an item."""
68
+
69
+ item_id: str = Field(description="The item id to star or unstar.")
70
+ starred: bool = Field(default=True, description="True to star, False to unstar.")