substack-api-mcp 1.0.2__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.
Files changed (78) hide show
  1. substack_api_mcp/__init__.py +28 -0
  2. substack_api_mcp/api.py +213 -0
  3. substack_api_mcp/app.py +38 -0
  4. substack_api_mcp/cli.py +198 -0
  5. substack_api_mcp/core/__init__.py +0 -0
  6. substack_api_mcp/core/account_guard.py +33 -0
  7. substack_api_mcp/core/context.py +175 -0
  8. substack_api_mcp/core/errors.py +78 -0
  9. substack_api_mcp/core/helpers.py +33 -0
  10. substack_api_mcp/core/tools.py +106 -0
  11. substack_api_mcp/mcp_context.py +126 -0
  12. substack_api_mcp/mcp_server.py +67 -0
  13. substack_api_mcp/runner.py +106 -0
  14. substack_api_mcp/scripting.py +73 -0
  15. substack_api_mcp/session.py +197 -0
  16. substack_api_mcp/substack/__init__.py +0 -0
  17. substack_api_mcp/substack/auth.py +26 -0
  18. substack_api_mcp/substack/client.py +172 -0
  19. substack_api_mcp/substack/feed.py +31 -0
  20. substack_api_mcp/substack/markdown.py +239 -0
  21. substack_api_mcp/substack/publishing.py +70 -0
  22. substack_api_mcp/substack/subscribers.py +94 -0
  23. substack_api_mcp/substack/tools/__init__.py +0 -0
  24. substack_api_mcp/substack/tools/add_subscriber.py +60 -0
  25. substack_api_mcp/substack/tools/cancel_scheduled_post.py +54 -0
  26. substack_api_mcp/substack/tools/create_draft.py +75 -0
  27. substack_api_mcp/substack/tools/create_post_tag.py +49 -0
  28. substack_api_mcp/substack/tools/create_subscriber_tag.py +46 -0
  29. substack_api_mcp/substack/tools/delete_comment.py +45 -0
  30. substack_api_mcp/substack/tools/delete_post.py +47 -0
  31. substack_api_mcp/substack/tools/duplicate_post.py +55 -0
  32. substack_api_mcp/substack/tools/export_subscribers.py +42 -0
  33. substack_api_mcp/substack/tools/follow_user.py +46 -0
  34. substack_api_mcp/substack/tools/get_audience_insights.py +93 -0
  35. substack_api_mcp/substack/tools/get_conversation.py +71 -0
  36. substack_api_mcp/substack/tools/get_daily_stats.py +77 -0
  37. substack_api_mcp/substack/tools/get_feed.py +83 -0
  38. substack_api_mcp/substack/tools/get_post.py +75 -0
  39. substack_api_mcp/substack/tools/get_post_engagement.py +68 -0
  40. substack_api_mcp/substack/tools/get_post_stats.py +73 -0
  41. substack_api_mcp/substack/tools/get_post_traffic.py +72 -0
  42. substack_api_mcp/substack/tools/get_publication_stats.py +74 -0
  43. substack_api_mcp/substack/tools/get_unread_counts.py +40 -0
  44. substack_api_mcp/substack/tools/like_comment.py +57 -0
  45. substack_api_mcp/substack/tools/like_post.py +45 -0
  46. substack_api_mcp/substack/tools/list_activity.py +111 -0
  47. substack_api_mcp/substack/tools/list_comments.py +79 -0
  48. substack_api_mcp/substack/tools/list_conversations.py +78 -0
  49. substack_api_mcp/substack/tools/list_note_replies.py +79 -0
  50. substack_api_mcp/substack/tools/list_notes.py +80 -0
  51. substack_api_mcp/substack/tools/list_post_tags.py +48 -0
  52. substack_api_mcp/substack/tools/list_posts.py +105 -0
  53. substack_api_mcp/substack/tools/list_recommendations.py +109 -0
  54. substack_api_mcp/substack/tools/list_sections.py +60 -0
  55. substack_api_mcp/substack/tools/list_subscriber_tags.py +36 -0
  56. substack_api_mcp/substack/tools/list_subscribers.py +82 -0
  57. substack_api_mcp/substack/tools/list_subscriptions.py +76 -0
  58. substack_api_mcp/substack/tools/list_traffic_sources.py +89 -0
  59. substack_api_mcp/substack/tools/pin_comment.py +46 -0
  60. substack_api_mcp/substack/tools/publish_note.py +83 -0
  61. substack_api_mcp/substack/tools/publish_post.py +72 -0
  62. substack_api_mcp/substack/tools/recommend_publication.py +66 -0
  63. substack_api_mcp/substack/tools/remove_comment.py +45 -0
  64. substack_api_mcp/substack/tools/remove_subscriber.py +57 -0
  65. substack_api_mcp/substack/tools/reply_to_comment.py +63 -0
  66. substack_api_mcp/substack/tools/reply_to_note.py +56 -0
  67. substack_api_mcp/substack/tools/schedule_post.py +78 -0
  68. substack_api_mcp/substack/tools/search_substack.py +84 -0
  69. substack_api_mcp/substack/tools/send_message.py +65 -0
  70. substack_api_mcp/substack/tools/tag_post.py +55 -0
  71. substack_api_mcp/substack/tools/tag_subscribers.py +75 -0
  72. substack_api_mcp/substack/tools/unpublish_post.py +50 -0
  73. substack_api_mcp/substack/tools/update_draft.py +93 -0
  74. substack_api_mcp-1.0.2.dist-info/METADATA +516 -0
  75. substack_api_mcp-1.0.2.dist-info/RECORD +78 -0
  76. substack_api_mcp-1.0.2.dist-info/WHEEL +4 -0
  77. substack_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
  78. substack_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
@@ -0,0 +1,175 @@
1
+ """What a tool function gets besides its input: a ready HTTP client with your session of the app.
2
+
3
+ Requests go out with curl_cffi, which copies Chrome's TLS and HTTP/2 fingerprint, so the app sees the same
4
+ kind of client as your browser. Only the app's hosts (`app.DOMAINS`) are reachable, redirects included.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json as jsonlib
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+ from urllib.parse import urljoin, urlsplit
14
+
15
+ from curl_cffi.requests import AsyncSession, BrowserType
16
+
17
+ from substack_api_mcp import app
18
+ from substack_api_mcp.core.account_guard import check_account_write
19
+ from substack_api_mcp.core.errors import AuthExpiredError, CaptchaRequiredError, ErrorCode, ToolError
20
+ from substack_api_mcp.session import Session
21
+
22
+ DOMAINS = app.DOMAINS
23
+ _REDIRECTS = {301, 302, 303, 307, 308}
24
+ _MAX_REDIRECTS = 10
25
+ _TARGETS = {member.value for member in BrowserType}
26
+ _CHALLENGE_MARKERS = ("cf-chl", "challenge-platform", "Just a moment...", "cf_chl_opt")
27
+ _CAPTCHA_MARKERS = ("g-recaptcha", "h-captcha", "cf-turnstile")
28
+ _LOGGED_OUT_MARKERS = app.LOGGED_OUT_MARKERS
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class HttpResponse:
33
+ status: int
34
+ url: str
35
+ headers: dict[str, str]
36
+ text: str
37
+
38
+ def json(self) -> Any:
39
+ try:
40
+ return jsonlib.loads(self.text)
41
+ except ValueError as exc:
42
+ raise ToolError(f"{app.NAME} answered with something that is not JSON.", ErrorCode.TARGET_ERROR) from exc
43
+
44
+
45
+ def allowed_host(url: str) -> bool:
46
+ host = (urlsplit(url).hostname or "").lower()
47
+ return any(host == domain or host.endswith(f".{domain}") for domain in DOMAINS)
48
+
49
+
50
+ def impersonate_target(chrome_major: int | None) -> str:
51
+ """The exact Chrome version when curl_cffi has it, otherwise its newest Chrome profile."""
52
+ target = f"chrome{chrome_major}"
53
+ return target if chrome_major and target in _TARGETS else "chrome"
54
+
55
+
56
+ def _raise_for(response: HttpResponse) -> None:
57
+ final_url = response.url.lower()
58
+ if response.status == 401 or any(marker in final_url for marker in _LOGGED_OUT_MARKERS):
59
+ raise AuthExpiredError(
60
+ f"{app.NAME} signed this session out. Sign in to {app.NAME} in Chrome, then run: {app.CLI} connect"
61
+ )
62
+ if response.status == 429:
63
+ raise ToolError(f"{app.NAME} is rate limiting this account. Try again later.", ErrorCode.RATE_LIMITED)
64
+ head = response.text[:20000]
65
+ if response.status in (401, 403) and any(marker in head for marker in app.RELOGIN_MARKERS):
66
+ raise AuthExpiredError(
67
+ f"{app.NAME} asks you to sign in again before this action. Sign out and in again at "
68
+ f"{app.LOGIN_URL} in Chrome, then run: {app.CLI} connect"
69
+ )
70
+ if response.status in (200, 403) and any(marker in head for marker in _CAPTCHA_MARKERS):
71
+ raise CaptchaRequiredError(
72
+ f"{app.NAME} asks for a CAPTCHA. Open {app.DOMAINS[0]} in Chrome, finish it, then try again."
73
+ )
74
+ if response.status in (403, 503) and (
75
+ response.headers.get("cf-mitigated") == "challenge" or any(marker in head for marker in _CHALLENGE_MARKERS)
76
+ ):
77
+ raise ToolError(
78
+ f"{app.NAME} showed a bot check. Open {app.DOMAINS[0]} in Chrome once, then try again.",
79
+ ErrorCode.TARGET_BLOCKED,
80
+ )
81
+ if response.status == 403:
82
+ raise ToolError(f"{app.NAME} refused the request (403).", ErrorCode.TARGET_BLOCKED)
83
+
84
+
85
+ @dataclass
86
+ class ToolContext:
87
+ session: Session
88
+ timeout_s: float = 30.0
89
+ _cache: dict[str, tuple[float, str]] = field(default_factory=dict, repr=False)
90
+ _http: AsyncSession | None = field(default=None, repr=False)
91
+
92
+ def _client(self) -> AsyncSession:
93
+ if self._http is None:
94
+ headers = {"accept-language": self.session.locale or "en-US,en;q=0.9"}
95
+ target = impersonate_target(self.session.chrome_major)
96
+ # The browser's own user agent only when the TLS fingerprint matches its version.
97
+ if self.session.user_agent and target != "chrome":
98
+ headers["user-agent"] = self.session.user_agent
99
+ self._http = AsyncSession(impersonate=target, proxy=self.session.proxy or None, headers=headers) # pyright: ignore[reportArgumentType] - target is a BrowserType value
100
+ for cookie in self.session.cookies:
101
+ self._http.cookies.set(
102
+ cookie["name"], cookie["value"], domain=cookie["domain"], path=cookie.get("path", "/")
103
+ )
104
+ return self._http
105
+
106
+ async def call(self, method: str, url: str, **kwargs: Any) -> HttpResponse:
107
+ """One request to the app with your cookies. A signed-out answer raises auth_expired.
108
+
109
+ Writes to your account settings in the app are refused before they leave (account_guard).
110
+ """
111
+ if not allowed_host(url):
112
+ raise ToolError(f"Request outside {app.DOMAINS[0]}.", ErrorCode.INTERNAL_ERROR)
113
+ check_account_write(method, url)
114
+ client = self._client()
115
+ method = method.upper()
116
+ params, json, data, headers = (
117
+ kwargs.get("params"),
118
+ kwargs.get("json"),
119
+ kwargs.get("data"),
120
+ kwargs.get("headers"),
121
+ )
122
+ for _ in range(_MAX_REDIRECTS + 1):
123
+ try:
124
+ raw = await client.request(
125
+ method, # pyright: ignore[reportArgumentType] - an HTTP method name
126
+ url,
127
+ params=params,
128
+ json=json,
129
+ data=data,
130
+ headers=headers,
131
+ timeout=self.timeout_s,
132
+ allow_redirects=False,
133
+ )
134
+ except Exception as exc:
135
+ raise ToolError(f"{app.NAME} did not answer: {type(exc).__name__}.", ErrorCode.TARGET_ERROR) from exc
136
+ location = raw.headers.get("location")
137
+ if raw.status_code not in _REDIRECTS or not location:
138
+ break
139
+ url = urljoin(str(raw.url), location)
140
+ if not allowed_host(url):
141
+ raise ToolError(f"{app.NAME} redirected outside {app.DOMAINS[0]}.", ErrorCode.TARGET_ERROR)
142
+ params = None
143
+ if raw.status_code == 303 or (raw.status_code in (301, 302) and method == "POST"):
144
+ method, json, data = "GET", None, None
145
+ else:
146
+ raise ToolError("Too many redirects.", ErrorCode.TARGET_ERROR)
147
+ response = HttpResponse(
148
+ status=raw.status_code,
149
+ url=str(raw.url),
150
+ headers={k.lower(): v or "" for k, v in raw.headers.items()},
151
+ text=raw.text,
152
+ )
153
+ _raise_for(response)
154
+ return response
155
+
156
+ async def get_json(self, url: str, **kwargs: Any) -> Any:
157
+ return (await self.call("GET", url, **kwargs)).json()
158
+
159
+ async def post_json(self, url: str, payload: Any, **kwargs: Any) -> Any:
160
+ return (await self.call("POST", url, json=payload, **kwargs)).json()
161
+
162
+ async def cache_get(self, key: str) -> str | None:
163
+ """Short-lived app data (a build id, a page token), kept in this process."""
164
+ entry = self._cache.get(key)
165
+ if entry is None or entry[0] < time.monotonic():
166
+ return None
167
+ return entry[1]
168
+
169
+ async def cache_set(self, key: str, value: str, ttl_s: int) -> None:
170
+ self._cache[key] = (time.monotonic() + ttl_s, value)
171
+
172
+ async def close(self) -> None:
173
+ if self._http is not None:
174
+ await self._http.close()
175
+ self._http = None
@@ -0,0 +1,78 @@
1
+ """Error codes a tool call can end with, and the exceptions tools raise to produce them.
2
+
3
+ The REST API answers with the code in `error` and the HTTP status from `HTTP_STATUS`; the MCP server
4
+ returns the same code and message as a tool error.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import StrEnum
10
+
11
+
12
+ class ErrorCode(StrEnum):
13
+ # No session for this server yet, or the app signed it out: run `<cli> connect` again.
14
+ AUTH_REQUIRED = "auth_required"
15
+ AUTH_EXPIRED = "auth_expired"
16
+ CAPTCHA_REQUIRED = "captcha_required"
17
+ MFA_REQUIRED = "mfa_required"
18
+ # The tool changes something and the server runs with --read-only.
19
+ SCOPE_DENIED = "scope_denied"
20
+ # The account in the app lacks the role (not a group admin, not the author).
21
+ APP_PERMISSION_DENIED = "app_permission_denied"
22
+ TARGET_BLOCKED = "target_blocked"
23
+ UNKNOWN_TOOL = "unknown_tool"
24
+ INVALID_INPUT = "invalid_input"
25
+ NOT_BUILT = "not_built"
26
+ TARGET_ERROR = "target_error"
27
+ RATE_LIMITED = "rate_limited"
28
+ TIMEOUT = "timeout"
29
+ INTERNAL_ERROR = "internal_error"
30
+
31
+
32
+ HTTP_STATUS: dict[ErrorCode, int] = {
33
+ ErrorCode.AUTH_REQUIRED: 401,
34
+ ErrorCode.AUTH_EXPIRED: 401,
35
+ ErrorCode.CAPTCHA_REQUIRED: 409,
36
+ ErrorCode.MFA_REQUIRED: 409,
37
+ ErrorCode.SCOPE_DENIED: 403,
38
+ ErrorCode.APP_PERMISSION_DENIED: 403,
39
+ ErrorCode.TARGET_BLOCKED: 403,
40
+ ErrorCode.UNKNOWN_TOOL: 404,
41
+ ErrorCode.INVALID_INPUT: 422,
42
+ ErrorCode.NOT_BUILT: 501,
43
+ ErrorCode.TARGET_ERROR: 502,
44
+ ErrorCode.RATE_LIMITED: 429,
45
+ ErrorCode.TIMEOUT: 504,
46
+ ErrorCode.INTERNAL_ERROR: 500,
47
+ }
48
+
49
+
50
+ class ToolError(Exception):
51
+ """Raised by a tool or the core; the REST API and the MCP server turn it into an error answer."""
52
+
53
+ code: ErrorCode = ErrorCode.TARGET_ERROR
54
+
55
+ def __init__(self, message: str, code: ErrorCode | None = None) -> None:
56
+ super().__init__(message)
57
+ if code is not None:
58
+ self.code = code
59
+
60
+
61
+ class AuthExpiredError(ToolError):
62
+ code = ErrorCode.AUTH_EXPIRED
63
+
64
+
65
+ class CaptchaRequiredError(ToolError):
66
+ code = ErrorCode.CAPTCHA_REQUIRED
67
+
68
+
69
+ class MfaRequiredError(ToolError):
70
+ code = ErrorCode.MFA_REQUIRED
71
+
72
+
73
+ class TargetBlockedError(ToolError):
74
+ code = ErrorCode.TARGET_BLOCKED
75
+
76
+
77
+ class NotBuiltError(ToolError):
78
+ code = ErrorCode.NOT_BUILT
@@ -0,0 +1,33 @@
1
+ """Input and answer helpers the tools share."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from substack_api_mcp.core.errors import ErrorCode, ToolError
9
+
10
+
11
+ def page_cursor(cursor: str | None) -> int:
12
+ """A page-number cursor: None is page 1, anything else must be a positive number we handed out."""
13
+ if not cursor:
14
+ return 1
15
+ if not cursor.isdigit() or int(cursor) < 1:
16
+ raise ToolError("cursor must be the next_cursor value from the previous call.", ErrorCode.INVALID_INPUT)
17
+ return int(cursor)
18
+
19
+
20
+ def limit(args: dict[str, Any], default: int, maximum: int) -> int:
21
+ """`limit` from the input, clamped to 1..maximum."""
22
+ value = int(args.get("limit") or default)
23
+ return max(1, min(value, maximum))
24
+
25
+
26
+ def json_field(value: Any) -> Any:
27
+ """Apps keep some metadata as JSON strings: the parsed value, None when it does not parse."""
28
+ if isinstance(value, str) and value[:1] in "{[":
29
+ try:
30
+ return json.loads(value)
31
+ except ValueError:
32
+ return None
33
+ return value
@@ -0,0 +1,106 @@
1
+ """A tool is one async function, declared with @tool next to its schemas.
2
+
3
+ The declaration is the only source of a tool's shape: the REST API, the MCP server and the CLI are
4
+ all built from it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Awaitable, Callable
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Any, Literal
13
+
14
+ if TYPE_CHECKING:
15
+ from substack_api_mcp.core.context import ToolContext
16
+
17
+ ToolHandler = Callable[["ToolContext", dict[str, Any]], Awaitable[Any]]
18
+
19
+ # The REST route under app.API_PREFIX, e.g. "POST /groups/{group}/posts".
20
+ ROUTE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE) (/(?:[a-z0-9-]+|\{[a-z][a-z0-9_]*\}))+$")
21
+
22
+ # The hosted service asks the owner to approve each call that reaches other people. Here your MCP client
23
+ # asks before each tool call instead, so those sentences are dropped from the published details.
24
+ _HOSTED_CONFIRM = re.compile(
25
+ r"(,? ?so)?[,;]? ?[Bb]y default you confirm each [a-z]+[;,] ?(and )?you can switch that off for this tool\."
26
+ )
27
+
28
+
29
+ def _local_details(details: str | None) -> str | None:
30
+ if details is None:
31
+ return None
32
+ text = _HOSTED_CONFIRM.sub(".", details).replace("..", ".").replace(" through Cotlet", "")
33
+ return re.sub(r"\s+", " ", text).strip()
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ToolSpec:
38
+ name: str
39
+ title: str
40
+ access: Literal["read", "write"]
41
+ description: str
42
+ input_schema: dict[str, Any]
43
+ handler: ToolHandler
44
+ route: str | None = None
45
+ details: str | None = None
46
+ # Reaches other people (messages, posts, emails): MCP clients should ask before each call.
47
+ confirm: bool = False
48
+ credits: int = 25
49
+ output_schema: dict[str, Any] | None = None
50
+ example_response: dict[str, Any] | None = None
51
+ needs_browser: bool = False
52
+ timeout_s: int | None = None
53
+ # Read-only check of the input against the app before a call that reaches people.
54
+ check: ToolHandler | None = None
55
+
56
+
57
+ def check_route(name: str, route: str, input_schema: dict[str, Any]) -> None:
58
+ """A route is a method and a path whose {placeholders} are required arguments of the tool."""
59
+ if not ROUTE.match(route):
60
+ raise ValueError(f"{name}: route must look like 'POST /groups/{{group}}/posts', got {route!r}")
61
+ required = set(input_schema.get("required", []))
62
+ for param in re.findall(r"\{(\w+)\}", route):
63
+ if param not in required:
64
+ raise ValueError(f"{name}: route parameter {{{param}}} must be a required argument")
65
+
66
+
67
+ def tool(
68
+ *,
69
+ name: str,
70
+ title: str,
71
+ access: Literal["read", "write"],
72
+ description: str,
73
+ input_schema: dict[str, Any],
74
+ route: str | None = None,
75
+ details: str | None = None,
76
+ confirm: bool = False,
77
+ credits: int = 25, # noqa: A002 - kept for parity with the hosted declarations
78
+ output_schema: dict[str, Any] | None = None,
79
+ example_response: dict[str, Any] | None = None,
80
+ needs_browser: bool = False,
81
+ timeout_s: int | None = None,
82
+ check: ToolHandler | None = None,
83
+ ) -> Callable[[ToolHandler], ToolSpec]:
84
+ if route is not None:
85
+ check_route(name, route, input_schema)
86
+
87
+ def wrap(handler: ToolHandler) -> ToolSpec:
88
+ return ToolSpec(
89
+ name=name,
90
+ title=title,
91
+ access=access,
92
+ description=description,
93
+ input_schema=input_schema,
94
+ handler=handler,
95
+ route=route,
96
+ details=_local_details(details),
97
+ confirm=confirm,
98
+ credits=credits,
99
+ output_schema=output_schema,
100
+ example_response=example_response,
101
+ needs_browser=needs_browser,
102
+ timeout_s=timeout_s,
103
+ check=check,
104
+ )
105
+
106
+ return wrap
@@ -0,0 +1,126 @@
1
+ """Prompts and resources of the MCP server: how to start a task, and the tool catalog as context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+
8
+ import mcp.types as types
9
+ from mcp.server.lowlevel import Server
10
+ from mcp.server.lowlevel.helper_types import ReadResourceContents
11
+ from pydantic import AnyUrl
12
+
13
+ from substack_api_mcp import app
14
+ from substack_api_mcp.runner import Runner, description, tools
15
+
16
+ GUIDE_URI = f"{app.SLUG}://guide"
17
+ TOOLS_URI = f"{app.SLUG}://tools"
18
+
19
+ SAFETY = (
20
+ "Tools marked write act under the user's name and can reach real people. "
21
+ "Before calling one, say exactly what it will send or change and wait for the user's OK."
22
+ )
23
+
24
+
25
+ def _guide(runner: Runner) -> str:
26
+ specs = tools(runner.read_only)
27
+ reads = sum(spec.access == "read" for spec in specs)
28
+ mode = "read only" if runner.read_only else "read and write"
29
+ return "\n\n".join(
30
+ [
31
+ f"# {app.NAME} tools",
32
+ f"These tools work in the user's own {app.NAME} account through the session of their Chrome "
33
+ f"({app.LOGIN_URL}). Mode: {mode}. {len(specs)} tools, {reads} read, {len(specs) - reads} write.",
34
+ f"Where to start: {app.AGENT_START}",
35
+ SAFETY,
36
+ "Lists page with next_cursor: pass it back to get the next page.",
37
+ f"The full catalog with each tool's access is in {TOOLS_URI}.",
38
+ ]
39
+ )
40
+
41
+
42
+ def _catalog(runner: Runner) -> str:
43
+ return json.dumps(
44
+ [
45
+ {"name": spec.name, "title": spec.title, "access": spec.access, "description": description(spec)}
46
+ for spec in tools(runner.read_only)
47
+ ],
48
+ ensure_ascii=False,
49
+ indent=2,
50
+ )
51
+
52
+
53
+ def _prompts() -> list[types.Prompt]:
54
+ return [
55
+ types.Prompt(
56
+ name="getting_started",
57
+ title=f"Get started with {app.NAME}",
58
+ description=f"Check the {app.NAME} session and show what the tools can do, without changing anything.",
59
+ ),
60
+ types.Prompt(
61
+ name="plan_task",
62
+ title=f"Plan a {app.NAME} task",
63
+ description=f"Turn a goal into {app.NAME} tool calls: reads first, every write shown before it runs.",
64
+ arguments=[
65
+ types.PromptArgument(name="task", description=f"What you want done in {app.NAME}", required=True)
66
+ ],
67
+ ),
68
+ ]
69
+
70
+
71
+ def _prompt_text(name: str, arguments: dict[str, str] | None) -> str:
72
+ if name == "getting_started":
73
+ return (
74
+ f"Use the {app.NAME} tools. {app.AGENT_START} Tell me which account and data you can see, "
75
+ f"then list five things I can do with these tools. Read only: change nothing."
76
+ )
77
+ if name == "plan_task":
78
+ task = (arguments or {}).get("task", "").strip()
79
+ if not task:
80
+ raise ValueError("plan_task needs a task")
81
+ return (
82
+ f"I want to do this in {app.NAME}: {task}\n\n"
83
+ f"Read {TOOLS_URI}, pick the tools, and run the read tools you need first. {SAFETY}"
84
+ )
85
+ raise ValueError(f"unknown prompt: {name}")
86
+
87
+
88
+ def register(server: Server, runner: Runner) -> None:
89
+ @server.list_prompts()
90
+ async def list_prompts() -> list[types.Prompt]:
91
+ return _prompts()
92
+
93
+ @server.get_prompt()
94
+ async def get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult:
95
+ text = _prompt_text(name, arguments)
96
+ return types.GetPromptResult(
97
+ description=next(prompt.description for prompt in _prompts() if prompt.name == name),
98
+ messages=[types.PromptMessage(role="user", content=types.TextContent(type="text", text=text))],
99
+ )
100
+
101
+ @server.list_resources()
102
+ async def list_resources() -> list[types.Resource]:
103
+ return [
104
+ types.Resource(
105
+ uri=AnyUrl(GUIDE_URI),
106
+ name="guide",
107
+ title=f"How to use the {app.NAME} tools",
108
+ description="Where to start, read and write rules, paging.",
109
+ mimeType="text/markdown",
110
+ ),
111
+ types.Resource(
112
+ uri=AnyUrl(TOOLS_URI),
113
+ name="tools",
114
+ title=f"{app.NAME} tool catalog",
115
+ description="Every tool with its access (read or write) and full description.",
116
+ mimeType="application/json",
117
+ ),
118
+ ]
119
+
120
+ @server.read_resource()
121
+ async def read_resource(uri: AnyUrl) -> Iterable[ReadResourceContents]:
122
+ if str(uri) == GUIDE_URI:
123
+ return [ReadResourceContents(content=_guide(runner), mime_type="text/markdown")]
124
+ if str(uri) == TOOLS_URI:
125
+ return [ReadResourceContents(content=_catalog(runner), mime_type="application/json")]
126
+ raise ValueError(f"unknown resource: {uri}")
@@ -0,0 +1,67 @@
1
+ """The MCP server: one MCP tool per tool of the app, over stdio or streamable HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import mcp.types as types
9
+ from mcp.server.lowlevel import Server
10
+
11
+ from substack_api_mcp import __version__, app, mcp_context
12
+ from substack_api_mcp.core.errors import ToolError
13
+ from substack_api_mcp.runner import Runner, description, tools
14
+
15
+ INSTRUCTIONS = (
16
+ f"{app.NAME} tools working in the user's own {app.NAME} account, through the session of their Chrome. "
17
+ f"{app.AGENT_START} "
18
+ "Tools that post, comment, message, invite or change settings act under the user's name and reach real "
19
+ "people: say what you will send and get the user's OK first. Lists page with next_cursor."
20
+ )
21
+
22
+
23
+ def _annotations(spec: Any) -> types.ToolAnnotations:
24
+ method = (spec.route or "GET").split(" ", 1)[0]
25
+ return types.ToolAnnotations(
26
+ title=spec.title,
27
+ readOnlyHint=spec.access == "read",
28
+ destructiveHint=spec.access == "write" and (method == "DELETE" or spec.confirm),
29
+ idempotentHint=method in ("GET", "PUT"),
30
+ openWorldHint=True,
31
+ )
32
+
33
+
34
+ def build(runner: Runner) -> Server:
35
+ server: Server = Server(app.SLUG, version=__version__, instructions=INSTRUCTIONS)
36
+
37
+ @server.list_tools()
38
+ async def list_tools() -> list[types.Tool]:
39
+ return [
40
+ types.Tool(
41
+ name=spec.name,
42
+ title=spec.title,
43
+ description=description(spec),
44
+ inputSchema=spec.input_schema,
45
+ annotations=_annotations(spec),
46
+ )
47
+ for spec in tools(runner.read_only)
48
+ ]
49
+
50
+ @server.call_tool(validate_input=False)
51
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
52
+ try:
53
+ result = await runner.run(name, arguments)
54
+ except ToolError as exc:
55
+ raise RuntimeError(f"{exc.code}: {exc}") from None
56
+ return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
57
+
58
+ mcp_context.register(server, runner)
59
+ return server
60
+
61
+
62
+ async def run_stdio(runner: Runner) -> None:
63
+ from mcp.server.stdio import stdio_server
64
+
65
+ server = build(runner)
66
+ async with stdio_server() as (read, write):
67
+ await server.run(read, write, server.create_initialization_options())