subscribeflow 1.8.0__tar.gz → 1.8.2__tar.gz
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.
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/.gitignore +0 -1
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/PKG-INFO +5 -4
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/README.md +3 -2
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/pyproject.toml +7 -2
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/__init__.py +1 -1
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/client.py +9 -1
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/mcp/server.py +254 -62
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/models.py +7 -0
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/exceptions.py +0 -0
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/mcp/__init__.py +0 -0
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/mcp/tools.py +0 -0
- {subscribeflow-1.8.0 → subscribeflow-1.8.2}/src/subscribeflow/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: subscribeflow
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.2
|
|
4
4
|
Summary: Python SDK for SubscribeFlow API - Email subscription management
|
|
5
5
|
Project-URL: Homepage, https://subscribeflow.net
|
|
6
6
|
Project-URL: Documentation, https://docs.subscribeflow.net/guides/sdk-setup
|
|
@@ -30,7 +30,7 @@ Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
|
|
|
30
30
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
31
31
|
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
32
32
|
Provides-Extra: mcp
|
|
33
|
-
Requires-Dist: mcp>=
|
|
33
|
+
Requires-Dist: mcp>=2.2.0; extra == 'mcp'
|
|
34
34
|
Description-Content-Type: text/markdown
|
|
35
35
|
|
|
36
36
|
# subscribeflow
|
|
@@ -116,6 +116,7 @@ The client uses an async context manager to manage the HTTP connection. This ens
|
|
|
116
116
|
import asyncio
|
|
117
117
|
from subscribeflow import SubscribeFlowClient
|
|
118
118
|
|
|
119
|
+
|
|
119
120
|
async def main():
|
|
120
121
|
async with SubscribeFlowClient(api_key="sf_live_xxx") as client:
|
|
121
122
|
# Create a subscriber with tags and metadata
|
|
@@ -126,6 +127,7 @@ async def main():
|
|
|
126
127
|
)
|
|
127
128
|
print(f"Created subscriber: {subscriber.id}")
|
|
128
129
|
|
|
130
|
+
|
|
129
131
|
asyncio.run(main())
|
|
130
132
|
```
|
|
131
133
|
|
|
@@ -410,11 +412,9 @@ The client accepts configuration options when initialized. Only the `api_key` is
|
|
|
410
412
|
client = SubscribeFlowClient(
|
|
411
413
|
# Required: Your API key (starts with sf_live_ or sf_test_)
|
|
412
414
|
api_key="sf_live_xxx",
|
|
413
|
-
|
|
414
415
|
# Optional: API base URL (default: https://api.subscribeflow.net)
|
|
415
416
|
# The SDK appends /api/v1/... automatically
|
|
416
417
|
base_url="https://api.subscribeflow.net",
|
|
417
|
-
|
|
418
418
|
# Optional: Request timeout in seconds (default: 30)
|
|
419
419
|
timeout=30.0,
|
|
420
420
|
)
|
|
@@ -483,6 +483,7 @@ This SDK is fully typed and works with mypy, pyright, and other type checkers ou
|
|
|
483
483
|
```python
|
|
484
484
|
from subscribeflow import SubscribeFlowClient, Subscriber, Tag
|
|
485
485
|
|
|
486
|
+
|
|
486
487
|
async def get_active_subscribers(client: SubscribeFlowClient) -> list[Subscriber]:
|
|
487
488
|
result = await client.subscribers.list(status="active")
|
|
488
489
|
return result.items
|
|
@@ -81,6 +81,7 @@ The client uses an async context manager to manage the HTTP connection. This ens
|
|
|
81
81
|
import asyncio
|
|
82
82
|
from subscribeflow import SubscribeFlowClient
|
|
83
83
|
|
|
84
|
+
|
|
84
85
|
async def main():
|
|
85
86
|
async with SubscribeFlowClient(api_key="sf_live_xxx") as client:
|
|
86
87
|
# Create a subscriber with tags and metadata
|
|
@@ -91,6 +92,7 @@ async def main():
|
|
|
91
92
|
)
|
|
92
93
|
print(f"Created subscriber: {subscriber.id}")
|
|
93
94
|
|
|
95
|
+
|
|
94
96
|
asyncio.run(main())
|
|
95
97
|
```
|
|
96
98
|
|
|
@@ -375,11 +377,9 @@ The client accepts configuration options when initialized. Only the `api_key` is
|
|
|
375
377
|
client = SubscribeFlowClient(
|
|
376
378
|
# Required: Your API key (starts with sf_live_ or sf_test_)
|
|
377
379
|
api_key="sf_live_xxx",
|
|
378
|
-
|
|
379
380
|
# Optional: API base URL (default: https://api.subscribeflow.net)
|
|
380
381
|
# The SDK appends /api/v1/... automatically
|
|
381
382
|
base_url="https://api.subscribeflow.net",
|
|
382
|
-
|
|
383
383
|
# Optional: Request timeout in seconds (default: 30)
|
|
384
384
|
timeout=30.0,
|
|
385
385
|
)
|
|
@@ -448,6 +448,7 @@ This SDK is fully typed and works with mypy, pyright, and other type checkers ou
|
|
|
448
448
|
```python
|
|
449
449
|
from subscribeflow import SubscribeFlowClient, Subscriber, Tag
|
|
450
450
|
|
|
451
|
+
|
|
451
452
|
async def get_active_subscribers(client: SubscribeFlowClient) -> list[Subscriber]:
|
|
452
453
|
result = await client.subscribers.list(status="active")
|
|
453
454
|
return result.items
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "subscribeflow"
|
|
3
|
-
version = "1.8.
|
|
3
|
+
version = "1.8.2"
|
|
4
4
|
description = "Python SDK for SubscribeFlow API - Email subscription management"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -34,11 +34,16 @@ dev = [
|
|
|
34
34
|
"ruff>=0.6.0",
|
|
35
35
|
]
|
|
36
36
|
mcp = [
|
|
37
|
-
|
|
37
|
+
# 2.2.0 is the floor, not 1.x: the server uses the constructor-based
|
|
38
|
+
# `Server(name, on_list_tools=..., on_call_tool=...)` API introduced in
|
|
39
|
+
# mcp 2.x, which does not exist in 1.x — an install resolving below this
|
|
40
|
+
# crashes at import.
|
|
41
|
+
"mcp>=2.2.0",
|
|
38
42
|
]
|
|
39
43
|
|
|
40
44
|
[project.scripts]
|
|
41
45
|
subscribeflow-mcp = "subscribeflow.mcp.server:main"
|
|
46
|
+
subscribeflow-mcp-http = "subscribeflow.mcp.server:main_http"
|
|
42
47
|
|
|
43
48
|
[project.urls]
|
|
44
49
|
Homepage = "https://subscribeflow.net"
|
|
@@ -1881,6 +1881,11 @@ class SubscribeFlowClient:
|
|
|
1881
1881
|
Raises:
|
|
1882
1882
|
SubscribeFlowError: On API errors
|
|
1883
1883
|
"""
|
|
1884
|
+
# The `json` parameter above shadows the module-level `import json` for the
|
|
1885
|
+
# rest of this method's scope, same as in `_do_request`. This method never
|
|
1886
|
+
# references `json.<anything>` directly, so it's safe today — but keep it
|
|
1887
|
+
# that way: don't add error handling here that touches `json.JSONDecodeError`
|
|
1888
|
+
# without renaming the parameter first (see TF-774).
|
|
1884
1889
|
if self._client is None:
|
|
1885
1890
|
# Create a one-off client if not using context manager
|
|
1886
1891
|
async with httpx.AsyncClient(
|
|
@@ -1918,7 +1923,10 @@ class SubscribeFlowClient:
|
|
|
1918
1923
|
# Handle errors
|
|
1919
1924
|
try:
|
|
1920
1925
|
error_data = response.json()
|
|
1921
|
-
except
|
|
1926
|
+
except ValueError:
|
|
1927
|
+
# json.JSONDecodeError is a ValueError subclass; catching it by name here
|
|
1928
|
+
# would resolve to the `json` parameter (a dict|None) shadowing the module
|
|
1929
|
+
# import, not the module itself. See TF-774.
|
|
1922
1930
|
error_data = {"detail": response.text or "Unknown error"}
|
|
1923
1931
|
|
|
1924
1932
|
raise SubscribeFlowError.from_response(error_data, response.status_code)
|
|
@@ -13,11 +13,25 @@ from __future__ import annotations
|
|
|
13
13
|
import asyncio
|
|
14
14
|
import logging
|
|
15
15
|
import os
|
|
16
|
+
from contextvars import ContextVar
|
|
16
17
|
from typing import Any
|
|
17
18
|
|
|
19
|
+
import uvicorn
|
|
18
20
|
from mcp.server import Server
|
|
21
|
+
from mcp.server.context import ServerRequestContext
|
|
19
22
|
from mcp.server.stdio import stdio_server
|
|
20
|
-
from mcp.
|
|
23
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
24
|
+
from mcp.shared.exceptions import MCPError
|
|
25
|
+
from mcp.types import (
|
|
26
|
+
INVALID_REQUEST,
|
|
27
|
+
CallToolRequestParams,
|
|
28
|
+
CallToolResult,
|
|
29
|
+
ListToolsResult,
|
|
30
|
+
PaginatedRequestParams,
|
|
31
|
+
TextContent,
|
|
32
|
+
)
|
|
33
|
+
from starlette.responses import JSONResponse
|
|
34
|
+
from starlette.types import Receive, Scope, Send
|
|
21
35
|
|
|
22
36
|
from subscribeflow import SubscribeFlowClient
|
|
23
37
|
from subscribeflow.exceptions import (
|
|
@@ -34,8 +48,9 @@ from subscribeflow.mcp.tools import get_all_tools
|
|
|
34
48
|
|
|
35
49
|
logger = logging.getLogger(__name__)
|
|
36
50
|
|
|
37
|
-
# Server instance
|
|
38
|
-
|
|
51
|
+
# Server instance is constructed after _handle_list_tools/_handle_call_tool
|
|
52
|
+
# are defined (mcp 2.x registers handlers via constructor kwargs, not
|
|
53
|
+
# decorators). See the bottom of this section.
|
|
39
54
|
|
|
40
55
|
# Lazy-initialized client
|
|
41
56
|
_client: SubscribeFlowClient | None = None
|
|
@@ -44,6 +59,13 @@ _client: SubscribeFlowClient | None = None
|
|
|
44
59
|
_tier_verified: bool | None = None
|
|
45
60
|
_tier_lock: asyncio.Lock | None = None
|
|
46
61
|
|
|
62
|
+
# Per-request API key for the Streamable HTTP transport (see create_http_app()
|
|
63
|
+
# in this module). Unset (None) when running as the stdio entry point, where
|
|
64
|
+
# SUBSCRIBEFLOW_API_KEY is used instead. A ContextVar rather than a function
|
|
65
|
+
# parameter because _handle_call_tool's signature is dictated by mcp.server.Server
|
|
66
|
+
# (on_call_tool's fixed (ctx, params) shape) and cannot carry transport-level data.
|
|
67
|
+
_request_api_key: ContextVar[str | None] = ContextVar("request_api_key", default=None)
|
|
68
|
+
|
|
47
69
|
# Paid tiers that are allowed to use MCP
|
|
48
70
|
_PAID_TIERS = {"starter", "professional", "pro"}
|
|
49
71
|
|
|
@@ -62,13 +84,25 @@ _TIER_CHECK_FAILED_MESSAGE = (
|
|
|
62
84
|
def _get_client() -> SubscribeFlowClient:
|
|
63
85
|
"""Get or create the SubscribeFlow client.
|
|
64
86
|
|
|
87
|
+
In HTTP mode (``_request_api_key`` set), builds a fresh client scoped to
|
|
88
|
+
the current request's API key on every call — concurrent requests from
|
|
89
|
+
different users must never share a client. In stdio mode, falls back to
|
|
90
|
+
the existing process-wide singleton built from SUBSCRIBEFLOW_API_KEY,
|
|
91
|
+
where one process always serves exactly one user.
|
|
92
|
+
|
|
65
93
|
Returns:
|
|
66
94
|
Configured SubscribeFlowClient instance.
|
|
67
95
|
|
|
68
96
|
Raises:
|
|
69
|
-
ValueError: If SUBSCRIBEFLOW_API_KEY is not set.
|
|
97
|
+
ValueError: If SUBSCRIBEFLOW_API_KEY is required but not set (stdio mode only).
|
|
70
98
|
"""
|
|
71
99
|
global _client
|
|
100
|
+
|
|
101
|
+
request_key = _request_api_key.get()
|
|
102
|
+
if request_key is not None:
|
|
103
|
+
base_url = os.environ.get("SUBSCRIBEFLOW_BASE_URL", "https://api.subscribeflow.net")
|
|
104
|
+
return SubscribeFlowClient(api_key=request_key, base_url=base_url)
|
|
105
|
+
|
|
72
106
|
if _client is None:
|
|
73
107
|
api_key = os.environ.get("SUBSCRIBEFLOW_API_KEY")
|
|
74
108
|
if not api_key:
|
|
@@ -176,20 +210,47 @@ def _format_delivery_stats(stats: Any) -> str:
|
|
|
176
210
|
)
|
|
177
211
|
|
|
178
212
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
213
|
+
async def _handle_list_tools(
|
|
214
|
+
ctx: ServerRequestContext[None], params: PaginatedRequestParams | None
|
|
215
|
+
) -> ListToolsResult:
|
|
216
|
+
"""Return all available MCP tools.
|
|
217
|
+
|
|
218
|
+
In HTTP mode (``_request_api_key`` set), the key is validated here before
|
|
219
|
+
the catalog is returned. Unlike ``_handle_call_tool``, this handler never
|
|
220
|
+
calls ``_get_client()``'s underlying API otherwise — so, without this
|
|
221
|
+
check, ``_AuthenticatedMCPApp``'s fail-fast (rejecting only a *missing*
|
|
222
|
+
header, not an invalid one) would be the only gate, and anyone with a
|
|
223
|
+
non-empty but bogus Authorization header could enumerate every tool's
|
|
224
|
+
name, description and input schema for free. Deliberately not routed
|
|
225
|
+
through ``_check_tier``: tier gating blocks *calling* tools, but browsing
|
|
226
|
+
the catalog with a real key has always been available regardless of tier
|
|
227
|
+
(stdio mode included) and should stay that way.
|
|
228
|
+
"""
|
|
229
|
+
if _request_api_key.get() is not None:
|
|
230
|
+
try:
|
|
231
|
+
await _get_client().billing.get_subscription()
|
|
232
|
+
except AuthenticationError as e:
|
|
233
|
+
raise MCPError(code=INVALID_REQUEST, message="Authentication failed") from e
|
|
234
|
+
return ListToolsResult(tools=get_all_tools())
|
|
183
235
|
|
|
184
236
|
|
|
185
237
|
async def _check_tier(client: SubscribeFlowClient) -> str | None:
|
|
186
238
|
"""Check if the organization's tier allows MCP access.
|
|
187
239
|
|
|
240
|
+
In HTTP mode (``_request_api_key`` set), the result is never cached
|
|
241
|
+
across requests — a cache keyed by nothing would leak one organization's
|
|
242
|
+
tier status into the next request that happens to run on this process.
|
|
243
|
+
Stdio mode keeps its existing process-lifetime cache, safe because one
|
|
244
|
+
process always serves exactly one organization.
|
|
245
|
+
|
|
188
246
|
Returns:
|
|
189
247
|
Error message string if blocked, None if allowed.
|
|
190
248
|
"""
|
|
191
249
|
global _tier_verified, _tier_lock
|
|
192
250
|
|
|
251
|
+
if _request_api_key.get() is not None:
|
|
252
|
+
return await _check_tier_uncached(client)
|
|
253
|
+
|
|
193
254
|
# Fast path: already verified
|
|
194
255
|
if _tier_verified is True:
|
|
195
256
|
return None
|
|
@@ -205,67 +266,84 @@ async def _check_tier(client: SubscribeFlowClient) -> str | None:
|
|
|
205
266
|
if _tier_verified is not None:
|
|
206
267
|
return _TIER_ERROR_MESSAGE if _tier_verified is False else None
|
|
207
268
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
_tier_verified =
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
"MCP access blocked for tier '%s'",
|
|
215
|
-
tier,
|
|
216
|
-
)
|
|
217
|
-
return _TIER_ERROR_MESSAGE
|
|
218
|
-
return None
|
|
219
|
-
except Exception:
|
|
220
|
-
logger.warning(
|
|
221
|
-
"Tier verification failed — blocking MCP access",
|
|
222
|
-
exc_info=True,
|
|
223
|
-
)
|
|
224
|
-
# Fail closed: don't cache, so next call retries
|
|
225
|
-
return _TIER_CHECK_FAILED_MESSAGE
|
|
269
|
+
result = await _check_tier_uncached(client)
|
|
270
|
+
# Only cache successful checks or tier-blocked checks, not transient failures
|
|
271
|
+
if result is None or result == _TIER_ERROR_MESSAGE:
|
|
272
|
+
_tier_verified = result is None
|
|
273
|
+
return result
|
|
274
|
+
|
|
226
275
|
|
|
276
|
+
async def _check_tier_uncached(client: SubscribeFlowClient) -> str | None:
|
|
277
|
+
"""Run the tier check against the API with no caching.
|
|
227
278
|
|
|
228
|
-
|
|
229
|
-
|
|
279
|
+
Returns:
|
|
280
|
+
Error message string if blocked, None if allowed.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
sub = await client.billing.get_subscription()
|
|
284
|
+
tier = (sub.tier or "").lower()
|
|
285
|
+
if tier not in _PAID_TIERS:
|
|
286
|
+
logger.info("MCP access blocked for tier '%s'", tier)
|
|
287
|
+
return _TIER_ERROR_MESSAGE
|
|
288
|
+
return None
|
|
289
|
+
except Exception:
|
|
290
|
+
logger.warning(
|
|
291
|
+
"Tier verification failed — blocking MCP access",
|
|
292
|
+
exc_info=True,
|
|
293
|
+
)
|
|
294
|
+
# Fail closed: don't cache, so next call retries
|
|
295
|
+
return _TIER_CHECK_FAILED_MESSAGE
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def _handle_call_tool(
|
|
299
|
+
ctx: ServerRequestContext[None], params: CallToolRequestParams
|
|
300
|
+
) -> CallToolResult:
|
|
230
301
|
"""Handle tool calls from the LLM.
|
|
231
302
|
|
|
232
303
|
Args:
|
|
233
|
-
|
|
234
|
-
|
|
304
|
+
ctx: Server request context (unused).
|
|
305
|
+
params: Tool call parameters (name and arguments).
|
|
235
306
|
|
|
236
307
|
Returns:
|
|
237
|
-
|
|
308
|
+
CallToolResult wrapping the TextContent result.
|
|
238
309
|
|
|
239
310
|
Raises:
|
|
240
311
|
ValueError: If tool name is unknown.
|
|
241
312
|
"""
|
|
313
|
+
name = params.name
|
|
314
|
+
arguments = params.arguments or {}
|
|
242
315
|
client = _get_client()
|
|
243
316
|
|
|
244
317
|
# Lazy tier check with async lock to prevent race conditions
|
|
245
318
|
tier_result = await _check_tier(client)
|
|
246
319
|
if tier_result is not None:
|
|
247
|
-
return [TextContent(type="text", text=tier_result)]
|
|
320
|
+
return CallToolResult(content=[TextContent(type="text", text=tier_result)])
|
|
248
321
|
|
|
249
322
|
try:
|
|
250
323
|
result = await _execute_tool(client, name, arguments)
|
|
251
|
-
return [TextContent(type="text", text=result)]
|
|
324
|
+
return CallToolResult(content=[TextContent(type="text", text=result)])
|
|
252
325
|
except AuthenticationError:
|
|
253
|
-
return
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
326
|
+
return CallToolResult(
|
|
327
|
+
content=[
|
|
328
|
+
TextContent(
|
|
329
|
+
type="text",
|
|
330
|
+
text="Authentication failed: the configured API key is invalid "
|
|
331
|
+
"or expired. Update SUBSCRIBEFLOW_API_KEY and restart.",
|
|
332
|
+
)
|
|
333
|
+
]
|
|
334
|
+
)
|
|
260
335
|
except AuthorizationError as e:
|
|
261
|
-
return
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
336
|
+
return CallToolResult(
|
|
337
|
+
content=[
|
|
338
|
+
TextContent(
|
|
339
|
+
type="text",
|
|
340
|
+
text=f"Permission denied: {e}. Check that your API key has the required "
|
|
341
|
+
"scopes.",
|
|
342
|
+
)
|
|
343
|
+
]
|
|
344
|
+
)
|
|
267
345
|
except NotFoundError as e:
|
|
268
|
-
return [TextContent(type="text", text=f"Not found: {e}")]
|
|
346
|
+
return CallToolResult(content=[TextContent(type="text", text=f"Not found: {e}")])
|
|
269
347
|
except ValidationError as e:
|
|
270
348
|
detail = f"Validation error: {e}"
|
|
271
349
|
if e.errors:
|
|
@@ -273,27 +351,33 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
|
273
351
|
f"{err.get('field', 'unknown')}: {err.get('message', str(err))}" for err in e.errors
|
|
274
352
|
)
|
|
275
353
|
detail += f"\nDetails: {fields}"
|
|
276
|
-
return [TextContent(type="text", text=detail)]
|
|
354
|
+
return CallToolResult(content=[TextContent(type="text", text=detail)])
|
|
277
355
|
except ConflictError as e:
|
|
278
|
-
return [TextContent(type="text", text=f"Conflict: {e}")]
|
|
356
|
+
return CallToolResult(content=[TextContent(type="text", text=f"Conflict: {e}")])
|
|
279
357
|
except LimitExceededError as e:
|
|
280
|
-
return
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
358
|
+
return CallToolResult(
|
|
359
|
+
content=[
|
|
360
|
+
TextContent(
|
|
361
|
+
type="text",
|
|
362
|
+
text=(
|
|
363
|
+
f"Plan limit exceeded: {e.detail}\n"
|
|
364
|
+
f"Limit: {e.limit} ({e.current}/{e.max_value})\n"
|
|
365
|
+
f"Upgrade at: {e.upgrade_url}"
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
]
|
|
369
|
+
)
|
|
290
370
|
except RateLimitError as e:
|
|
291
|
-
return [TextContent(type="text", text=f"Rate limit exceeded: {e}")]
|
|
371
|
+
return CallToolResult(content=[TextContent(type="text", text=f"Rate limit exceeded: {e}")])
|
|
292
372
|
except SubscribeFlowError as e:
|
|
293
|
-
return
|
|
373
|
+
return CallToolResult(
|
|
374
|
+
content=[TextContent(type="text", text=f"API error ({e.status}): {e}")]
|
|
375
|
+
)
|
|
294
376
|
except Exception as e:
|
|
295
377
|
logger.error("Unexpected error in MCP tool %s", name, exc_info=True)
|
|
296
|
-
return
|
|
378
|
+
return CallToolResult(
|
|
379
|
+
content=[TextContent(type="text", text=f"Unexpected error: {type(e).__name__}: {e}")]
|
|
380
|
+
)
|
|
297
381
|
|
|
298
382
|
|
|
299
383
|
async def _execute_tool(client: SubscribeFlowClient, name: str, args: dict[str, Any]) -> str:
|
|
@@ -1021,6 +1105,9 @@ async def _execute_tool(client: SubscribeFlowClient, name: str, args: dict[str,
|
|
|
1021
1105
|
raise ValueError(f"Unknown tool: {name}")
|
|
1022
1106
|
|
|
1023
1107
|
|
|
1108
|
+
server = Server("subscribeflow", on_list_tools=_handle_list_tools, on_call_tool=_handle_call_tool)
|
|
1109
|
+
|
|
1110
|
+
|
|
1024
1111
|
def main() -> None:
|
|
1025
1112
|
"""Run the MCP server."""
|
|
1026
1113
|
|
|
@@ -1037,3 +1124,108 @@ def main() -> None:
|
|
|
1037
1124
|
|
|
1038
1125
|
if __name__ == "__main__":
|
|
1039
1126
|
main()
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _extract_api_key(headers: list[tuple[bytes, bytes]]) -> str | None:
|
|
1130
|
+
"""Read an API key from the Authorization or X-API-Key header.
|
|
1131
|
+
|
|
1132
|
+
Args:
|
|
1133
|
+
headers: Raw ASGI header pairs from the request scope.
|
|
1134
|
+
|
|
1135
|
+
Returns:
|
|
1136
|
+
The extracted key, or None if neither header is present/valid.
|
|
1137
|
+
"""
|
|
1138
|
+
decoded = {name.decode("latin-1").lower(): value.decode("latin-1") for name, value in headers}
|
|
1139
|
+
x_api_key = decoded.get("x-api-key")
|
|
1140
|
+
if x_api_key:
|
|
1141
|
+
return x_api_key
|
|
1142
|
+
authorization = decoded.get("authorization")
|
|
1143
|
+
if authorization and authorization.startswith("Bearer "):
|
|
1144
|
+
return authorization[len("Bearer ") :]
|
|
1145
|
+
return None
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
class _AuthenticatedMCPApp:
|
|
1149
|
+
"""Wraps the Streamable HTTP app (from Server.streamable_http_app()) with
|
|
1150
|
+
API-key extraction.
|
|
1151
|
+
|
|
1152
|
+
Sets `_request_api_key` before delegating, so `_get_client()` builds a
|
|
1153
|
+
client scoped to this request. Performs no validation of the key itself
|
|
1154
|
+
— that happens naturally the first time the request-scoped client calls
|
|
1155
|
+
the real API. A completely missing header is the one case rejected here,
|
|
1156
|
+
to avoid starting an MCP request for a call that can never succeed.
|
|
1157
|
+
"""
|
|
1158
|
+
|
|
1159
|
+
def __init__(self, app: Any) -> None:
|
|
1160
|
+
self._app = app
|
|
1161
|
+
|
|
1162
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
1163
|
+
if scope["type"] != "http":
|
|
1164
|
+
# Non-HTTP scopes (in particular "lifespan") must reach the inner
|
|
1165
|
+
# app unchanged — Server.streamable_http_app()'s own lifespan is
|
|
1166
|
+
# what starts/stops its StreamableHTTPSessionManager.
|
|
1167
|
+
await self._app(scope, receive, send)
|
|
1168
|
+
return
|
|
1169
|
+
|
|
1170
|
+
api_key = _extract_api_key(scope["headers"])
|
|
1171
|
+
if api_key is None:
|
|
1172
|
+
response = JSONResponse(
|
|
1173
|
+
{"error": "API key required. Use X-API-Key or Authorization: Bearer <api_key>"},
|
|
1174
|
+
status_code=401,
|
|
1175
|
+
)
|
|
1176
|
+
await response(scope, receive, send)
|
|
1177
|
+
return
|
|
1178
|
+
|
|
1179
|
+
token = _request_api_key.set(api_key)
|
|
1180
|
+
try:
|
|
1181
|
+
await self._app(scope, receive, send)
|
|
1182
|
+
finally:
|
|
1183
|
+
_request_api_key.reset(token)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def create_http_app() -> _AuthenticatedMCPApp:
|
|
1187
|
+
"""Build the Streamable HTTP ASGI app for the `mcp` Fly process group.
|
|
1188
|
+
|
|
1189
|
+
Stateless: subscribeflow-api's reverse proxy may reach this process
|
|
1190
|
+
group across restarts/deploys with no client-visible session to lose,
|
|
1191
|
+
and no in-memory state needs to survive a request (see the spec's
|
|
1192
|
+
"Transport" section).
|
|
1193
|
+
|
|
1194
|
+
DNS-rebinding protection is explicitly disabled: this server is never
|
|
1195
|
+
reachable except via subscribeflow-api's reverse proxy over Fly's
|
|
1196
|
+
internal 6PN network (never publicly routable — see fly.toml). The
|
|
1197
|
+
default protection defends browsers against a locally-bound dev server
|
|
1198
|
+
being reached through a rebound DNS name; that threat model doesn't
|
|
1199
|
+
apply to a private, internal server-to-server hop, and the default
|
|
1200
|
+
allowed_hosts (127.0.0.1/localhost only) would reject every real
|
|
1201
|
+
request's Host header, which is set by the reverse proxy's upstream
|
|
1202
|
+
URL, not localhost.
|
|
1203
|
+
"""
|
|
1204
|
+
mcp_app = server.streamable_http_app(
|
|
1205
|
+
stateless_http=True,
|
|
1206
|
+
streamable_http_path="/",
|
|
1207
|
+
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
|
1208
|
+
)
|
|
1209
|
+
return _AuthenticatedMCPApp(mcp_app)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def main_http() -> None:
|
|
1213
|
+
"""Run the MCP server over Streamable HTTP (the `mcp` Fly process group).
|
|
1214
|
+
|
|
1215
|
+
Binds `::` rather than `0.0.0.0`. Fly's private 6PN network is IPv6-only
|
|
1216
|
+
and `<group>.process.<app>.internal` resolves to AAAA records exclusively,
|
|
1217
|
+
so the only caller this server has — subscribeflow-api's /mcp reverse proxy
|
|
1218
|
+
— reaches it over IPv6. A `0.0.0.0` listener refuses that connection
|
|
1219
|
+
outright.
|
|
1220
|
+
|
|
1221
|
+
The resulting socket is IPv6-*only*, not dual-stack: this is uvicorn's
|
|
1222
|
+
single-process path, which hands host/port to `loop.create_server()`, and
|
|
1223
|
+
asyncio explicitly sets IPV6_V6ONLY=1 there regardless of the kernel's
|
|
1224
|
+
dual-stack default. That is correct for this process — it has no IPv4
|
|
1225
|
+
caller — but it is why `HOST` stays overridable: anything that does need to
|
|
1226
|
+
reach this server over IPv4 (a local run, a future sidecar) must set
|
|
1227
|
+
`HOST=0.0.0.0` rather than rely on `::` covering both.
|
|
1228
|
+
"""
|
|
1229
|
+
port = int(os.environ.get("PORT", "8001"))
|
|
1230
|
+
host = os.environ.get("HOST", "::")
|
|
1231
|
+
uvicorn.run(create_http_app(), host=host, port=port)
|
|
@@ -53,6 +53,13 @@ class Tag(BaseModel):
|
|
|
53
53
|
category: str | None = Field(default=None, description="Category for grouping tags")
|
|
54
54
|
external_topic_id: str | None = Field(default=None, description="External provider topic ID")
|
|
55
55
|
is_public: bool = Field(default=True, description="Whether visible in Preference Center")
|
|
56
|
+
approval_status: str = Field(
|
|
57
|
+
default="approved",
|
|
58
|
+
description="Moderation status: 'approved' or 'pending' (subscriber-proposed)",
|
|
59
|
+
)
|
|
60
|
+
created_by_subscriber_id: str | None = Field(
|
|
61
|
+
default=None, description="Set when the tag was proposed by a subscriber"
|
|
62
|
+
)
|
|
56
63
|
subscriber_count: int = Field(default=0, description="Number of active subscribers")
|
|
57
64
|
created_at: datetime = Field(description="Creation timestamp")
|
|
58
65
|
updated_at: datetime = Field(description="Last update timestamp")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|