segment-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,8 @@
1
+ """A read-first MCP server for Twilio Segment.
2
+
3
+ Answers which destinations get which events, which sources are dead, and
4
+ which are governed by nothing. Read-only by default; regulation/deletion
5
+ creation is not exposed in any mode. See BUILD-PLAN.md for the design.
6
+ """
7
+
8
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,269 @@
1
+ """Client for the Segment Profile API — a separate, higher trust tier.
2
+
3
+ **Auth is HTTP Basic with the access token as username and a BLANK
4
+ password** — different from every other Segment API (the Public API uses
5
+ `Authorization: Bearer`). This looks like a bug in this code. It isn't —
6
+ it's Segment's own documented mechanism for this API. See BUILD-PLAN.md §4.
7
+
8
+ Path shape: `/v1/spaces/{spaceId}/collections/{users|accounts}/profiles/{id_type:value}/{route}`,
9
+ `route` one of `traits`, `external_ids`, `events`, `metadata`, `links`.
10
+
11
+ This API returns PII on named individuals — the most privacy-sensitive
12
+ read in the whole surface. Accordingly:
13
+
14
+ - It is only ever constructed with a **separate credential**
15
+ (`SEGMENT_PROFILE_TOKEN`), never the main `SEGMENT_API_TOKEN`.
16
+ - Every lookup is logged (collection, normalized key, route, and the
17
+ caller-supplied `requested_by` label) via the `segment_mcp.profile_api`
18
+ logger, before the request is sent — so a lookup is on record even if
19
+ the call then fails.
20
+ - Whether this client is ever constructed at all is an explicit opt-in
21
+ decision made by whatever wires a profile tool into `server.py`: absent
22
+ `SEGMENT_PROFILE_TOKEN`, no such tool should be registered. See
23
+ README.md's Profile API section.
24
+
25
+ Lookups are case-sensitive; the wrong case returns an **empty result, not
26
+ an error** — `_normalize_id` lowercases every lookup value at this
27
+ client's boundary and logs a warning when it had to change something,
28
+ since that's a sign of caller input that would otherwise have silently
29
+ returned nothing.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import base64
35
+ import json
36
+ import logging
37
+ from typing import Any, Literal, cast
38
+
39
+ import httpx
40
+
41
+ from segment_mcp.client.regions import Region, endpoints_for
42
+
43
+ logger = logging.getLogger("segment_mcp.profile_api")
44
+
45
+ Collection = Literal["users", "accounts"]
46
+ ProfileRoute = Literal["traits", "external_ids", "events", "metadata", "links"]
47
+
48
+ # Per-route limits from BUILD-PLAN.md §4 / this prompt. `events` has no
49
+ # client-settable limit — the API enforces a fixed 14-day window
50
+ # server-side, not a row count. `links` is capped at 20 by the API and
51
+ # isn't client-configurable at all, so there's no constant for it here.
52
+ _TRAITS_DEFAULT_LIMIT = 10
53
+ _TRAITS_MAX_LIMIT = 200
54
+
55
+
56
+ # --------------------------------------------------------------------------
57
+ # Errors — deliberately a separate hierarchy from client/public_api.py's.
58
+ # Different auth mechanism, different rate-limit shape (flat 100 req/sec
59
+ # per Space vs. the Public API's per-endpoint header-driven limits), and
60
+ # conflating the two trust tiers' error types would blur exactly the
61
+ # boundary this module exists to keep sharp.
62
+ # --------------------------------------------------------------------------
63
+
64
+
65
+ class SegmentProfileAPIError(RuntimeError):
66
+ """Base class for all Segment Profile API errors."""
67
+
68
+ def __init__(self, message: str, *, status_code: int | None = None) -> None:
69
+ super().__init__(message)
70
+ self.status_code = status_code
71
+
72
+
73
+ class SegmentProfileAuthError(SegmentProfileAPIError):
74
+ """401 — the Profile token was not recognized. Check
75
+ `SEGMENT_PROFILE_TOKEN` specifically; it is not the same credential
76
+ as `SEGMENT_API_TOKEN`."""
77
+
78
+
79
+ class SegmentProfileNotFoundError(SegmentProfileAPIError):
80
+ """404 — no profile found for this key. Note this is also what a
81
+ case-mismatched lookup looks like *before* normalization — which is
82
+ exactly why `_normalize_id` exists."""
83
+
84
+
85
+ class SegmentProfileRateLimitError(SegmentProfileAPIError):
86
+ """429 — rate limited (100 req/sec per Space)."""
87
+
88
+ def __init__(self, message: str, *, retry_after: float | None) -> None:
89
+ super().__init__(message, status_code=429)
90
+ self.retry_after = retry_after
91
+
92
+
93
+ class SegmentProfileMalformedResponseError(SegmentProfileAPIError):
94
+ """The response body was not valid JSON, or wasn't a JSON object."""
95
+
96
+
97
+ def _as_dict(value: object) -> dict[str, Any]:
98
+ if isinstance(value, dict):
99
+ untyped = cast("dict[object, object]", value)
100
+ if all(isinstance(key, str) for key in untyped):
101
+ return cast("dict[str, Any]", value)
102
+ return {}
103
+
104
+
105
+ class ProfileAPIClient:
106
+ """Async client for the Segment Profile API, bound to one Space in
107
+ one region. See the module docstring for the trust-tier requirements
108
+ a caller constructing this client is responsible for."""
109
+
110
+ def __init__(
111
+ self,
112
+ token: str,
113
+ region: Region,
114
+ *,
115
+ space_id: str,
116
+ base_url: str | None = None,
117
+ timeout: float = 30.0,
118
+ transport: httpx.AsyncBaseTransport | None = None,
119
+ ) -> None:
120
+ self.region = region
121
+ self._space_id = space_id
122
+ credentials = base64.b64encode(f"{token}:".encode()).decode("ascii")
123
+ self._client = httpx.AsyncClient(
124
+ base_url=base_url or endpoints_for(region).profile_api,
125
+ timeout=timeout,
126
+ transport=transport,
127
+ headers={"Authorization": f"Basic {credentials}"},
128
+ )
129
+
130
+ async def __aenter__(self) -> ProfileAPIClient:
131
+ return self
132
+
133
+ async def __aexit__(self, *exc_info: object) -> None:
134
+ await self.aclose()
135
+
136
+ async def aclose(self) -> None:
137
+ await self._client.aclose()
138
+
139
+ @staticmethod
140
+ def _normalize_id(id_type: str, value: str) -> str:
141
+ """Lowercase `value`. Profile API lookups are case-sensitive and
142
+ the wrong case returns an empty result, not an error — silently
143
+ "working" while returning nothing is worse than raising, so this
144
+ at least logs when normalization changed something."""
145
+ lowered = value.lower()
146
+ if lowered != value:
147
+ logger.warning(
148
+ "Profile lookup id_type=%s value=%r was not lowercase; "
149
+ "normalized to %r before calling Segment (wrong case "
150
+ "returns an empty result, not an error).",
151
+ id_type,
152
+ value,
153
+ lowered,
154
+ )
155
+ return lowered
156
+
157
+ async def _get(
158
+ self,
159
+ collection: Collection,
160
+ id_type: str,
161
+ id_value: str,
162
+ route: ProfileRoute,
163
+ *,
164
+ params: dict[str, Any] | None = None,
165
+ requested_by: str = "unknown",
166
+ ) -> dict[str, Any]:
167
+ normalized_value = self._normalize_id(id_type, id_value)
168
+ lookup_key = f"{id_type}:{normalized_value}"
169
+ # Logged before the request is sent — a lookup is on record even
170
+ # if the call itself then fails or times out.
171
+ logger.info(
172
+ "Profile lookup: space=%s collection=%s key=%s route=%s requested_by=%s",
173
+ self._space_id,
174
+ collection,
175
+ lookup_key,
176
+ route,
177
+ requested_by,
178
+ )
179
+ path = f"/v1/spaces/{self._space_id}/collections/{collection}/profiles/{lookup_key}/{route}"
180
+ response = await self._client.get(path, params=params)
181
+
182
+ if response.status_code == 429:
183
+ retry_after_header = response.headers.get("Retry-After")
184
+ retry_after = float(retry_after_header) if retry_after_header else None
185
+ raise SegmentProfileRateLimitError(
186
+ "Profile API rate-limited this request (429, 100 req/sec per Space).",
187
+ retry_after=retry_after,
188
+ )
189
+ if response.status_code == 401:
190
+ raise SegmentProfileAuthError(
191
+ "Profile API rejected this token (401). Check "
192
+ "SEGMENT_PROFILE_TOKEN — it is a separate credential from "
193
+ "SEGMENT_API_TOKEN.",
194
+ status_code=401,
195
+ )
196
+ if response.status_code == 404:
197
+ raise SegmentProfileNotFoundError(
198
+ f"No profile found for {lookup_key!r} in collection {collection!r}. "
199
+ "If this key was recently case-mismatched, note that wrong "
200
+ "case also returns an empty/not-found result, not an error "
201
+ "— this client already lowercased it before asking.",
202
+ status_code=404,
203
+ )
204
+ response.raise_for_status()
205
+
206
+ try:
207
+ body: object = response.json()
208
+ except (json.JSONDecodeError, ValueError) as exc:
209
+ raise SegmentProfileMalformedResponseError(
210
+ f"Profile API returned a response that was not valid JSON "
211
+ f"(status {response.status_code}).",
212
+ status_code=response.status_code,
213
+ ) from exc
214
+ parsed = _as_dict(body)
215
+ if not parsed and body != {}:
216
+ raise SegmentProfileMalformedResponseError(
217
+ f"Profile API returned JSON that was not a string-keyed "
218
+ f"object (got {type(body).__name__}, status {response.status_code}).",
219
+ status_code=response.status_code,
220
+ )
221
+ return parsed
222
+
223
+ async def get_traits(
224
+ self,
225
+ collection: Collection,
226
+ id_type: str,
227
+ id_value: str,
228
+ *,
229
+ limit: int = _TRAITS_DEFAULT_LIMIT,
230
+ requested_by: str = "unknown",
231
+ ) -> dict[str, Any]:
232
+ """`GET .../traits`. Defaults to 10 traits; pass `limit` up to 200."""
233
+ if not 1 <= limit <= _TRAITS_MAX_LIMIT:
234
+ raise ValueError(f"limit must be between 1 and {_TRAITS_MAX_LIMIT}, got {limit}")
235
+ return await self._get(
236
+ collection,
237
+ id_type,
238
+ id_value,
239
+ "traits",
240
+ params={"limit": limit},
241
+ requested_by=requested_by,
242
+ )
243
+
244
+ async def get_external_ids(
245
+ self, collection: Collection, id_type: str, id_value: str, *, requested_by: str = "unknown"
246
+ ) -> dict[str, Any]:
247
+ """`GET .../external_ids`."""
248
+ return await self._get(
249
+ collection, id_type, id_value, "external_ids", requested_by=requested_by
250
+ )
251
+
252
+ async def get_events(
253
+ self, collection: Collection, id_type: str, id_value: str, *, requested_by: str = "unknown"
254
+ ) -> dict[str, Any]:
255
+ """`GET .../events`. Always a 14-day window — enforced by the API
256
+ itself, not a parameter this client can widen."""
257
+ return await self._get(collection, id_type, id_value, "events", requested_by=requested_by)
258
+
259
+ async def get_metadata(
260
+ self, collection: Collection, id_type: str, id_value: str, *, requested_by: str = "unknown"
261
+ ) -> dict[str, Any]:
262
+ """`GET .../metadata`."""
263
+ return await self._get(collection, id_type, id_value, "metadata", requested_by=requested_by)
264
+
265
+ async def get_links(
266
+ self, collection: Collection, id_type: str, id_value: str, *, requested_by: str = "unknown"
267
+ ) -> dict[str, Any]:
268
+ """`GET .../links`. Capped at 20 by the API — not configurable higher."""
269
+ return await self._get(collection, id_type, id_value, "links", requested_by=requested_by)