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.
- segment_mcp/__init__.py +8 -0
- segment_mcp/client/__init__.py +0 -0
- segment_mcp/client/profile_api.py +269 -0
- segment_mcp/client/public_api.py +547 -0
- segment_mcp/client/regions.py +112 -0
- segment_mcp/modes.py +222 -0
- segment_mcp/py.typed +0 -0
- segment_mcp/server.py +343 -0
- segment_mcp/tools/__init__.py +0 -0
- segment_mcp/tools/_shared.py +238 -0
- segment_mcp/tools/governance.py +123 -0
- segment_mcp/tools/health.py +313 -0
- segment_mcp/tools/profiles.py +5 -0
- segment_mcp/tools/routing.py +393 -0
- segment_mcp-0.1.0.dist-info/METADATA +175 -0
- segment_mcp-0.1.0.dist-info/RECORD +19 -0
- segment_mcp-0.1.0.dist-info/WHEEL +4 -0
- segment_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- segment_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
segment_mcp/__init__.py
ADDED
|
@@ -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)
|