substack-saved-mcp 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,267 @@
1
+ """Sync engine for retrieving Substack saved posts into the local SQLite cache."""
2
+
3
+ import logging
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from substack_saved_mcp.database import (
9
+ finish_sync_run,
10
+ get_post,
11
+ init_db,
12
+ reconcile_unsaved_posts,
13
+ start_sync_run,
14
+ upsert_post,
15
+ )
16
+ from substack_saved_mcp.models import SavedPost, SyncRun
17
+ from substack_saved_mcp.substack_client import (
18
+ AuthRequiredError,
19
+ SubstackSavedPostsClient,
20
+ )
21
+ from substack_saved_mcp.url_utils import canonicalize_url
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Rough average adult reading speed used to derive reading time from word count.
26
+ WORDS_PER_MINUTE = 200
27
+
28
+
29
+ def _first_positive_int(source: dict[str, Any], *keys: str) -> int | None:
30
+ """Return the first of ``keys`` whose value coerces to a positive int, else None."""
31
+ for key in keys:
32
+ val = source.get(key)
33
+ if val is None:
34
+ continue
35
+ try:
36
+ n = int(val)
37
+ except (TypeError, ValueError):
38
+ continue
39
+ if n > 0:
40
+ return n
41
+ return None
42
+
43
+
44
+ def parse_remote_post(raw_data: dict[str, Any]) -> SavedPost:
45
+ """Parse raw Substack API JSON payload or DOM dict into a typed SavedPost object."""
46
+ # Check if raw_data is a normalized dictionary from DOM extraction. Use an explicit
47
+ # marker: reader-API post objects also carry a "canonical_url" key and must instead
48
+ # go through the full API mapping below (canonicalization, post_date, id, etc.).
49
+ if raw_data.get("_dom"):
50
+ return SavedPost(
51
+ url=raw_data["canonical_url"],
52
+ title=raw_data.get("title") or "Untitled Substack Post",
53
+ publication_name=raw_data.get("publication_name") or "Substack",
54
+ publication_url=raw_data.get("publication_url"),
55
+ author_name=raw_data.get("author_name"),
56
+ excerpt=raw_data.get("excerpt"),
57
+ published_at=raw_data.get("published_at"),
58
+ saved_at=raw_data.get("saved_at"),
59
+ audience=raw_data.get("audience"),
60
+ is_saved=1,
61
+ )
62
+
63
+ # Substack API json mapping
64
+ post_obj = raw_data.get("post") or raw_data.get("item") or raw_data
65
+ pub_obj = raw_data.get("publication") or post_obj.get("publication") or {}
66
+
67
+ substack_id = str(post_obj.get("id")) if post_obj.get("id") else None
68
+ raw_url = post_obj.get("canonical_url") or post_obj.get("url") or ""
69
+ clean_url = canonicalize_url(raw_url)
70
+
71
+ title = post_obj.get("title") or "Untitled Substack Post"
72
+ pub_name = pub_obj.get("name") or "Substack"
73
+ pub_url = (
74
+ pub_obj.get("custom_domain")
75
+ or f"https://{pub_obj.get('subdomain')}.substack.com"
76
+ if pub_obj.get("subdomain")
77
+ else None
78
+ )
79
+ author = (
80
+ post_obj.get("author")
81
+ or post_obj.get("author_name")
82
+ or (pub_obj.get("author_name") if pub_obj else None)
83
+ )
84
+
85
+ # Dates. Leave saved_at unknown (None) rather than stamping the sync moment.
86
+ published_at = post_obj.get("post_date") or post_obj.get("published_at")
87
+ saved_at = raw_data.get("created_at") or raw_data.get("saved_at")
88
+
89
+ excerpt = (
90
+ post_obj.get("description")
91
+ or post_obj.get("subtitle")
92
+ or post_obj.get("excerpt")
93
+ )
94
+ content_text = post_obj.get("body_html") or post_obj.get("content_text")
95
+ image_url = post_obj.get("cover_image") or post_obj.get("image_url")
96
+ audience = post_obj.get("audience")
97
+ is_paywalled = 1 if audience == "only_paid" or post_obj.get("is_paywalled") else 0
98
+
99
+ # Word count: Substack's exact reader-API field name isn't confirmed, so map the
100
+ # most likely candidates defensively (verify with `inspect-network` if these stay
101
+ # empty). Reading time is *derived* from word count rather than read from a field,
102
+ # since a wrong guess about that field's unit (seconds vs minutes) would store a
103
+ # badly wrong value — deriving at ~WPM is unambiguous and is how Substack itself
104
+ # presents it.
105
+ word_count = _first_positive_int(post_obj, "wordcount", "word_count", "words")
106
+ reading_time_minutes = (
107
+ -(-word_count // WORDS_PER_MINUTE)
108
+ if word_count
109
+ else None # ceil division, min 1
110
+ )
111
+
112
+ return SavedPost(
113
+ substack_post_id=substack_id,
114
+ url=clean_url,
115
+ title=title,
116
+ publication_name=pub_name,
117
+ publication_url=pub_url,
118
+ author_name=author,
119
+ published_at=published_at,
120
+ saved_at=saved_at,
121
+ is_saved=1,
122
+ excerpt=excerpt,
123
+ content_text=content_text,
124
+ image_url=image_url,
125
+ audience=audience,
126
+ is_paywalled=is_paywalled,
127
+ word_count=word_count,
128
+ reading_time_minutes=reading_time_minutes,
129
+ )
130
+
131
+
132
+ def sync_saved_posts(
133
+ force: bool = False,
134
+ db_path: Path | None = None,
135
+ client: SubstackSavedPostsClient | None = None,
136
+ ) -> SyncRun:
137
+ """Execute an incremental or full sync of Substack saved posts into SQLite cache."""
138
+ init_db(db_path)
139
+ sync_mode = "full" if force else "incremental"
140
+ sync_id = start_sync_run(sync_mode=sync_mode, db_path=db_path)
141
+
142
+ active_client = client or SubstackSavedPostsClient()
143
+ active_client.reset_cache()
144
+ total_fetched = 0
145
+ total_upserted = 0
146
+ consecutive_matches = 0
147
+ MAX_CONSECUTIVE_MATCHES = 3
148
+ # Only populated meaningfully for a force/full sync, which enumerates every
149
+ # currently-saved remote post; an incremental sync may stop early and its
150
+ # partial list must never be used to infer removals.
151
+ remote_urls: list[str] = []
152
+
153
+ page_size = 50
154
+ offset = 0
155
+
156
+ try:
157
+ while True:
158
+ remote_items = active_client.fetch_saved_posts_page(
159
+ limit=page_size, offset=offset
160
+ )
161
+ if not remote_items:
162
+ break
163
+
164
+ total_fetched += len(remote_items)
165
+
166
+ for item in remote_items:
167
+ parsed_post = parse_remote_post(item)
168
+ if not parsed_post.url:
169
+ continue
170
+ remote_urls.append(parsed_post.url)
171
+
172
+ # Incremental sync check
173
+ if not force:
174
+ existing = get_post(parsed_post.url, db_path=db_path)
175
+ if (
176
+ existing
177
+ and existing.is_saved == 1
178
+ and existing.saved_at == parsed_post.saved_at
179
+ ):
180
+ consecutive_matches += 1
181
+ if consecutive_matches >= MAX_CONSECUTIVE_MATCHES:
182
+ logger.info(
183
+ f"Incremental sync: encountered {consecutive_matches} existing matches. Stopping early."
184
+ )
185
+ break
186
+ else:
187
+ consecutive_matches = 0
188
+
189
+ upsert_post(parsed_post, db_path=db_path)
190
+ total_upserted += 1
191
+
192
+ if not force and consecutive_matches >= MAX_CONSECUTIVE_MATCHES:
193
+ break
194
+
195
+ if len(remote_items) < page_size:
196
+ break
197
+
198
+ offset += page_size
199
+
200
+ reconciled_count = 0
201
+ if force:
202
+ reconciled_count = reconcile_unsaved_posts(remote_urls, db_path=db_path)
203
+ if reconciled_count:
204
+ logger.info(
205
+ f"Reconciliation: soft-deleted {reconciled_count} post(s) no longer in the remote saved list."
206
+ )
207
+
208
+ finish_sync_run(
209
+ sync_id=sync_id,
210
+ status="success",
211
+ fetched_count=total_fetched,
212
+ upserted_count=total_upserted,
213
+ reconciled_count=reconciled_count,
214
+ db_path=db_path,
215
+ )
216
+ return SyncRun(
217
+ id=sync_id,
218
+ started_at=datetime.now(UTC).isoformat(),
219
+ completed_at=datetime.now(UTC).isoformat(),
220
+ status="success",
221
+ sync_mode=sync_mode,
222
+ fetched_count=total_fetched,
223
+ upserted_count=total_upserted,
224
+ reconciled_count=reconciled_count,
225
+ )
226
+
227
+ except AuthRequiredError as e:
228
+ msg = str(e)
229
+ finish_sync_run(
230
+ sync_id=sync_id,
231
+ status="auth_required",
232
+ fetched_count=total_fetched,
233
+ upserted_count=total_upserted,
234
+ error_message=msg,
235
+ db_path=db_path,
236
+ )
237
+ return SyncRun(
238
+ id=sync_id,
239
+ started_at=datetime.now(UTC).isoformat(),
240
+ completed_at=datetime.now(UTC).isoformat(),
241
+ status="auth_required",
242
+ sync_mode=sync_mode,
243
+ fetched_count=total_fetched,
244
+ upserted_count=total_upserted,
245
+ error_message=msg,
246
+ )
247
+ except Exception as e:
248
+ msg = f"Sync failed: {e!s}"
249
+ logger.exception(msg)
250
+ finish_sync_run(
251
+ sync_id=sync_id,
252
+ status="failed",
253
+ fetched_count=total_fetched,
254
+ upserted_count=total_upserted,
255
+ error_message=msg,
256
+ db_path=db_path,
257
+ )
258
+ return SyncRun(
259
+ id=sync_id,
260
+ started_at=datetime.now(UTC).isoformat(),
261
+ completed_at=datetime.now(UTC).isoformat(),
262
+ status="failed",
263
+ sync_mode=sync_mode,
264
+ fetched_count=total_fetched,
265
+ upserted_count=total_upserted,
266
+ error_message=msg,
267
+ )
@@ -0,0 +1,55 @@
1
+ """URL normalization and tracking parameter stripping utilities."""
2
+
3
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
4
+
5
+ # Common tracking parameters present in Substack links
6
+ TRACKING_PARAMS = {
7
+ "utm_source",
8
+ "utm_medium",
9
+ "utm_campaign",
10
+ "utm_term",
11
+ "utm_content",
12
+ "r",
13
+ "s",
14
+ "ref",
15
+ "post_id",
16
+ "isFreemail",
17
+ "triedSigningIn",
18
+ "action",
19
+ "showWelcome",
20
+ }
21
+
22
+
23
+ def canonicalize_url(url: str) -> str:
24
+ """Normalize post URL by stripping tracking query params and trailing slash.
25
+
26
+ Example:
27
+ Input: https://example.substack.com/p/my-post-slug?utm_source=substack&r=1a2b3#comments
28
+ Output: https://example.substack.com/p/my-post-slug
29
+ """
30
+ if not url:
31
+ return ""
32
+
33
+ parsed = urlparse(url.strip())
34
+ # Keep query parameters that are NOT in TRACKING_PARAMS
35
+ query_params = parse_qsl(parsed.query, keep_blank_values=False)
36
+ filtered_params = [(k, v) for k, v in query_params if k not in TRACKING_PARAMS]
37
+ clean_query = urlencode(filtered_params)
38
+
39
+ # Normalize path (remove trailing slash except for root path)
40
+ path = parsed.path
41
+ if len(path) > 1 and path.endswith("/"):
42
+ path = path[:-1]
43
+
44
+ # Reconstruct clean canonical URL (strip fragment/anchor as well)
45
+ clean_url = urlunparse(
46
+ (
47
+ parsed.scheme.lower(),
48
+ parsed.netloc.lower(),
49
+ path,
50
+ parsed.params,
51
+ clean_query,
52
+ "", # fragment stripped
53
+ )
54
+ )
55
+ return clean_url
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: substack-saved-mcp
3
+ Version: 0.1.0
4
+ Summary: Local stdio-based MCP server and sync engine for Substack saved posts
5
+ Author-email: Toni Hermoso Pulido <toniher@cau.cat>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: click>=8.1.0
10
+ Requires-Dist: fastmcp>=0.1.0
11
+ Requires-Dist: playwright>=1.40.0
12
+ Requires-Dist: pydantic>=2.0.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
16
+ Requires-Dist: ruff==0.16.1; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Substack Saved Posts MCP & CLI
20
+
21
+ A local, stdio-based Model Context Protocol (MCP) server and sync engine for your saved/bookmarked Substack posts.
22
+
23
+ ## Features
24
+
25
+ - **Read & Search**: Full-text search (SQLite FTS5) across saved post titles, excerpts, authors, and publications. Filter by publication, audience tier (e.g. `everyone`, `only_paid`), and date ranges (`published_at` vs `saved_at`). Search also covers a post's **full body text**, but only for posts whose content has already been fetched once via `get-content` / the `get_post_content` tool — a normal `sync` stores metadata and excerpts, not full bodies, so posts you haven't opened yet are matched on their title/excerpt/metadata only, not their full text.
26
+ - **Full Content for LLMs**: Fetch a saved post's full content and get it back cleaned and formatted (headings, lists, links) for feeding directly to an LLM, with the result cached locally for next time.
27
+ - **Save & Unsave**: Bookmark new Substack posts or unbookmark existing ones via authenticated browser sessions.
28
+ - **Offline First**: Fast, offline queries directly from local SQLite cache.
29
+ - **Privacy & Security**: Keeps session credentials local, redacting tokens from logs.
30
+ - **FastMCP Protocol**: Stdio MCP interface with rich tool suite and resources.
31
+
32
+ ---
33
+
34
+ ## Installation with `uv`
35
+
36
+ [`uv`](https://github.com/astral-sh/uv) is the recommended fast Python package manager for installing and running `substack-saved-mcp`.
37
+
38
+ ### Option A: Install System-wide as a Tool (`uv tool install`)
39
+
40
+ Install directly from your local repository folder:
41
+
42
+ ```bash
43
+ # Navigate to the repository
44
+ cd /path/to/substack-saved-mcp
45
+
46
+ # Install system-wide into an isolated uv environment
47
+ uv tool install .
48
+
49
+ # Or install directly from a remote Git repository:
50
+ # uv tool install git+https://github.com/your-username/substack-saved-mcp.git
51
+ ```
52
+
53
+ After installation, `substack-saved-mcp` is immediately available in your PATH:
54
+
55
+ ```bash
56
+ # Verify installation
57
+ substack-saved-mcp --help
58
+ ```
59
+
60
+ To update or uninstall:
61
+ ```bash
62
+ # Upgrade installed tool
63
+ uv tool upgrade substack-saved-mcp
64
+
65
+ # Uninstall tool
66
+ uv tool uninstall substack-saved-mcp
67
+ ```
68
+
69
+ ---
70
+
71
+ ### Option B: Local Development / Development Environment (`uv sync`)
72
+
73
+ If you are developing or modifying the codebase:
74
+
75
+ ```bash
76
+ # Clone and enter directory
77
+ cd substack-saved-mcp
78
+
79
+ # Install dependencies and dev tools (pytest)
80
+ uv sync --extra dev
81
+
82
+ # Run CLI commands using uv run
83
+ uv run substack-saved-mcp --help
84
+
85
+ # Run tests
86
+ uv run pytest
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Quick Start
92
+
93
+ ```bash
94
+ # 1. Initialize local database
95
+ substack-saved-mcp init
96
+
97
+ # 2. Authenticate with Substack (opens interactive browser window once)
98
+ substack-saved-mcp login
99
+
100
+ # 3. Sync saved posts into local cache
101
+ substack-saved-mcp sync
102
+
103
+ # 4. Search saved posts via CLI
104
+ substack-saved-mcp search "artificial intelligence"
105
+
106
+ # 4b. Filter by publication or audience tier (see which tiers are cached with `audiences`)
107
+ substack-saved-mcp audiences
108
+ substack-saved-mcp list --audience only_paid
109
+ substack-saved-mcp search "artificial intelligence" --audience everyone
110
+
111
+ # 5. Save or unsave a post
112
+ substack-saved-mcp save "https://example.substack.com/p/post-slug"
113
+ substack-saved-mcp unsave "https://example.substack.com/p/post-slug"
114
+
115
+ # 6. Get a saved post's full content, cleaned up and ready for an LLM
116
+ substack-saved-mcp get-content "https://example.substack.com/p/post-slug"
117
+
118
+ # 7. Launch stdio MCP server
119
+ substack-saved-mcp serve
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Configuring MCP Clients (Claude Desktop, Goose, Cursor, etc.)
125
+
126
+ Add `substack-saved-mcp` to your MCP client's configuration file (e.g. `claude_desktop_config.json`).
127
+
128
+ ### Using System-Wide Installed Tool (`uv tool` or global binary)
129
+
130
+ ```json
131
+ {
132
+ "mcpServers": {
133
+ "substack-saved": {
134
+ "command": "substack-saved-mcp",
135
+ "args": ["serve"]
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### Using `uv` directly from the Repository Path
142
+
143
+ If you prefer running directly from your repository path without installing system-wide:
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "substack-saved": {
149
+ "command": "uv",
150
+ "args": [
151
+ "--directory",
152
+ "/path/to/substack-saved-mcp",
153
+ "run",
154
+ "substack-saved-mcp",
155
+ "serve"
156
+ ]
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Frequently Asked Questions (FAQ)
165
+
166
+ ### Where is the database saved?
167
+
168
+ By default, the SQLite database is saved in your OS application data directory:
169
+
170
+ - **Linux / macOS**: `~/.local/share/substack-saved-mcp/saved_posts.sqlite`
171
+ *(or `$XDG_DATA_HOME/substack-saved-mcp/saved_posts.sqlite` if `XDG_DATA_HOME` is set)*
172
+
173
+ You can specify a custom database path or directory using environment variables:
174
+ ```bash
175
+ export SUBSTACK_SAVED_DB_PATH="/path/to/my/custom_database.sqlite"
176
+ # or
177
+ export SUBSTACK_SAVED_DATA_DIR="/path/to/my/data_dir"
178
+ ```
179
+
180
+ ### Will a browser window pop up when running as an MCP server?
181
+
182
+ **No, a visible browser window will not open during normal MCP operations.**
183
+
184
+ - **Read & Search Tools** (`search_saved_posts`, `list_saved_posts`, `get_saved_post`, `list_publications`, `saved_posts_status`):
185
+ Operate 100% offline using the local SQLite database. Zero browser activity.
186
+ - **Sync & Write Tools** (`sync_saved_posts`, `save_post`, `unsave_post`, `get_post_content`):
187
+ Run in **headless background mode** using the pre-authenticated session stored in `storage_state.json`.
188
+ - **Interactive Login**:
189
+ A visible browser window opens **only** when you manually run `substack-saved-mcp login` from your terminal. If your session expires while using an MCP client, the tool will return a clear error message instructing you to re-authenticate via `substack-saved-mcp login` instead of popping open a browser window unexpectedly.
190
+
191
+ ### What if I get a Playwright "Executable doesn't exist" error?
192
+
193
+ If you encounter an error like `BrowserType.launch: Executable doesn't exist` when running commands (especially `login`), it means Playwright hasn't installed its required browsers in the isolated environment.
194
+
195
+ To fix this, you need to run the `playwright install` command *inside* the environment where the tool is installed.
196
+
197
+ For a system-wide tool installation (via `uv tool install`), run:
198
+ ```bash
199
+ ~/.local/share/uv/tools/substack-saved-mcp/bin/playwright install
200
+ ```
201
+
202
+ If you are using a local development environment (via `uv sync`), run:
203
+ ```bash
204
+ uv run playwright install
205
+ ```
206
+
207
+ ### I edited the source code, but the installed `substack-saved-mcp` command still behaves like the old version. Why?
208
+
209
+ `uv tool install` copies the package into its own isolated environment at install time — it does **not** track your working tree. If you edited files under `src/` (or pulled new commits) after installing the tool system-wide, the globally installed copy is stale and keeps running the old code, even though `uv run substack-saved-mcp ...` from the repo would use the latest source.
210
+
211
+ Reinstall from your current working tree to pick up the changes:
212
+ ```bash
213
+ uv tool install . --no-cache --force
214
+ ```
215
+ - `--force` replaces the existing installed version instead of skipping the install because a version is already present.
216
+ - `--no-cache` ensures a fresh build rather than reusing a cached wheel/build artifact from before your edits.
217
+
218
+ Do this any time after modifying the codebase and before relying on the globally installed `substack-saved-mcp` binary (as opposed to `uv run substack-saved-mcp`, which always reflects the working tree).
@@ -0,0 +1,15 @@
1
+ substack_saved_mcp/__init__.py,sha256=Eqt8L6DOMTXLExCNmSqhBIkZJqvg32gDf_dF2y65mYA,74
2
+ substack_saved_mcp/cli.py,sha256=GbabUl9dGO48-2d6lQLc7CCHWgMguAAs-7Uz-QHF6Ms,13755
3
+ substack_saved_mcp/config.py,sha256=oDZveru_tPZigMvEyYkvKsJbNhupj51btgWI_zk4sqI,1757
4
+ substack_saved_mcp/content_utils.py,sha256=pFADyAmXhtAO-fMdxMWO_ynf30Ez-1UKpu-g-O2gEqQ,4231
5
+ substack_saved_mcp/database.py,sha256=gmW0kQ-ehZn3z7BhF3DiLYy0AnN3dLMfF42N1Bak1k4,21562
6
+ substack_saved_mcp/mcp_server.py,sha256=rDs793FT1yIwvjg_CDkeYvKBtL1NWVS-5sEK3R7VGpQ,10413
7
+ substack_saved_mcp/models.py,sha256=g01TWK8N0EBuzuZEhJ2WrSe9I8QkeQk8t_yGjBaw8Ro,2738
8
+ substack_saved_mcp/substack_client.py,sha256=Gsh_XJ9zCCrA3ilZdUE0jbMGLxghFh5tEuQ_jhLmRfU,28873
9
+ substack_saved_mcp/sync.py,sha256=04TVwBS8vP0DWoIgoZ4ERLGnVEsuJLu505nyYmHS3qw,9433
10
+ substack_saved_mcp/url_utils.py,sha256=WIswnksq_VLKvcHl-K7cAaE71L4M0LASpkBRxHunL6Y,1530
11
+ substack_saved_mcp-0.1.0.dist-info/METADATA,sha256=38RMyKCkehej-VMzM6zUfVLNjIumVoNnwtDAThBEWFM,7937
12
+ substack_saved_mcp-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
13
+ substack_saved_mcp-0.1.0.dist-info/entry_points.txt,sha256=3gZ2dPi4HRivOBMPaO3TcaW2NUR978nydg7LtcY3O0s,66
14
+ substack_saved_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=ZTJDgcyOdyNUW1neSxNwjSrNz0mZ7EYm3tXr_FbXdTU,1076
15
+ substack_saved_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ substack-saved-mcp = substack_saved_mcp.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toni Hermoso Pulido
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.