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.
@@ -1,5 +1,4 @@
1
1
  # Dependencies
2
- uv.lock
3
2
 
4
3
  # Python
5
4
  __pycache__/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subscribeflow
3
- Version: 1.8.0
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>=1.0.0; extra == '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.0"
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
- "mcp>=1.0.0",
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"
@@ -47,7 +47,7 @@ from subscribeflow.models import (
47
47
  WebhookEndpoint,
48
48
  )
49
49
 
50
- __version__ = "1.7.0"
50
+ __version__ = "1.8.1"
51
51
 
52
52
  __all__ = [
53
53
  # Client
@@ -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 (json.JSONDecodeError, ValueError):
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.types import TextContent, Tool
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
- server = Server("subscribeflow")
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
- @server.list_tools()
180
- async def list_tools() -> list[Tool]:
181
- """Return all available MCP tools."""
182
- return get_all_tools()
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
- try:
209
- sub = await client.billing.get_subscription()
210
- tier = (sub.tier or "").lower()
211
- _tier_verified = tier in _PAID_TIERS
212
- if not _tier_verified:
213
- logger.info(
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
- @server.call_tool()
229
- async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
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
- name: Tool name to execute.
234
- arguments: Tool arguments.
304
+ ctx: Server request context (unused).
305
+ params: Tool call parameters (name and arguments).
235
306
 
236
307
  Returns:
237
- List of TextContent with the result.
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
- TextContent(
255
- type="text",
256
- text="Authentication failed: the configured API key is invalid "
257
- "or expired. Update SUBSCRIBEFLOW_API_KEY and restart.",
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
- TextContent(
263
- type="text",
264
- text=f"Permission denied: {e}. Check that your API key has the required scopes.",
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
- TextContent(
282
- type="text",
283
- text=(
284
- f"Plan limit exceeded: {e.detail}\n"
285
- f"Limit: {e.limit} ({e.current}/{e.max_value})\n"
286
- f"Upgrade at: {e.upgrade_url}"
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 [TextContent(type="text", text=f"API error ({e.status}): {e}")]
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 [TextContent(type="text", text=f"Unexpected error: {type(e).__name__}: {e}")]
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")