The MCP server: connecting Segmentic to an agent
A secure connection from AI agents to Segmentic data and tools.
The MCP server lets Claude Code, Claude Desktop, Codex or Cursor read your Segmentic account: which events you actually send, what a filter would match, how a campaign performed. If the key carries write permissions it can also create and edit audiences, draft campaigns, write events and queue exports. If it carries send, it can send a transactional message or put a campaign on the wire.
The quickest route is the one we run. Point your client at https://mcp.segmentic.net/mcp with your own sk_seg_ key in an Authorization header and you install nothing at all; the whole client snippet is under hosted over HTTP. If you would rather the process ran on your own machine or inside your own network, you build it from this repository, and both transports are written out below.
What it is
Two Go files in backend/cmd/segmentic-mcp, reading in main.go and writing in write_tools.go, about a thousand lines together, next to 666 lines of tests. It declares itself to the client as segmentic, version 1.0.0, and registers at most twenty-one tools.
It speaks the Model Context Protocol over two transports and the choice is yours. Stdio is for running an agent on your own machine: one process per person, key from the environment. HTTP is for standing it up once and handing everybody else a URL, where each caller's key arrives in their own Authorization header.
Every tool is an HTTP call to the management host, https://api.segmentic.net, carrying the same sk_seg_ key you would put in a curl command.
Why it is a client of the API and not a database connection
The design comment at the top of the file is the whole argument, and it is worth reading before you decide whether to give an agent a key:
It is a client of the public REST API, not a second way into the database.
That is the whole security design: every permission gate, every tenant scope,
every budget already lives at the HTTP edge, and a tool here cannot reach
past them because it has no other door. An MCP server holding a database
handle would be a second admission path, and the second one is always the one
that forgets a check.
The practical consequence: the MCP server grants an agent nothing that the key does not already grant a curl loop. If you want to know what an agent can do, read what the key carries, not this page. Revoking the key stops the agent, immediately, because the tools have no other credential.
The reverse consequence is the one people miss. Because a tool is an ordinary API call, an agent holding the key is not confined to the tools. It can call any endpoint the key permits with curl, including endpoints the MCP server exposes no tool for. The tool list is a convenience, not a sandbox.
Getting the binary
Nothing is published. There is no GitHub release, no image of its own, no npm package, no Homebrew formula, no installer, and no download link in the panel. There is also no pre-built macOS or Windows binary anywhere.
Building it yourself
You need a copy of the backend repository and Go 1.26.4 or newer.
cd backend
go build -o segmentic-mcp ./cmd/segmentic-mcp
On Windows, name the output segmentic-mcp.exe. To cross-compile for a Mac from a Linux or Windows machine:
cd backend
GOOS=darwin GOARCH=arm64 go build -o segmentic-mcp-darwin-arm64 ./cmd/segmentic-mcp
Dependencies are vendored, so the build needs no network for modules. It does need the Go toolchain itself, and proxy.golang.org answers 403 to toolchain downloads from Iranian addresses, which is why the repository's own Dockerfile sets GOPROXY=off GOFLAGS=-mod=vendor. Install the toolchain from a mirror before you try.
There is no Makefile target for this binary. make build compiles it and discards the output, which is why the command above passes -o explicitly.
Running it out of the backend image
The backend image builds every command directory, so /usr/local/bin/segmentic-mcp exists inside ghcr.io/segmentic1/segmentic-backend:latest. It got there by a wildcard in the Dockerfile rather than by intent, and it is a static Linux amd64 build, so it will not run natively on macOS or Windows.
It works as an MCP command because the image declares no default command and stdio passes through with -i:
docker run -i --rm \
-e SEGMENTIC_API_URL=https://api.segmentic.net \
-e SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
ghcr.io/segmentic1/segmentic-backend:latest segmentic-mcp
The image is private. You need a docker login ghcr.io with a token that can read it.
The key
The server needs a management key, sk_seg_.... Mint one in the panel at Settings, Connections and integrations, API keys, which is https://app.segmentic.net/en/settings/keys. Creating one needs the apikey.write permission on your own account.
The form has three fields: a name, a role, and how long it stays valid (30 days, 90 days or one year, defaulting to one year). A key with no expiry cannot be created through this route.
The plaintext is shown once, in the response to the create call and in a box on the screen. After that only a SHA-256 hash and a 14 character display prefix exist. Lose it and you mint another one.
{
"id": 12,
"name": "agent-readonly",
"prefix": "sk_seg_ABCDEF",
"role": "viewer",
"role_label": "Viewer",
"created_by": "Maryam",
"created_at": "2026-08-07T18:00:00Z",
"expires_at": "2027-08-07T18:00:00Z",
"key": "sk_seg_yqk7..."
}
The role you choose decides which tools exist, because tools are registered from the key's permissions. Owner is refused outright with owner_key_forbidden: no key may ever be an owner, because deleting the account is not something an integration should be able to do at three in the morning. You also cannot mint a key for a role above your own.
| Role you pick | Tools the agent sees | Can it write | Can it send |
|---|---|---|---|
viewer | 11 | no | no |
approver | 11 | no | no |
analyst | 13 | no, but it can export | no |
marketer | 21 | yes | yes |
admin | 21 | yes | yes |
finance | 2, segmentic_whoami and segmentic_capabilities | no | no |
owner | cannot be minted | n/a | n/a |
These counts are asserted in cmd/segmentic-mcp/registration_test.go, so adding or moving a tool turns this table red in the build rather than in a customer's hands.
analyst differs from viewer by the two export tools, and those two are the only things here that carry email addresses and phone numbers out of the building. If the agent is only meant to read and report, mint a viewer.
Do not use a write key. A wk_seg_ key ships inside your JavaScript bundle and your app, so it is public by construction, and it can only write events. The server refuses to start on one and says so before making a single request.
The key decides, not a flag
The tool list is built from the key's permissions. A viewer key gets eleven reading tools and nothing else. A key with segment.write also gets the tools that create and edit audiences. A key with campaign.send gets the two that reach real people.
There is no --allow-write flag and no --allow-send flag, deliberately: the decision belongs to whoever holds the account, not to a command line an operator can mistype or leave switched on.
This section used to say "read only by default". That was true of the tool list and was never true of the credential, and the difference is the thing this page has to be blunt about:
campaign.send is carried only by marketer and admin, and both also carry segment.write, campaign.write, journey.publish, template.write and profile.write. So an unnarrowed send-capable key is a fully write-capable credential, whether or not the MCP server offers tools for it: all of it is reachable with curl. Withholding the tools never withheld the capability. It only moved the work to a path with no argument validation, no confirmation on the irreversible calls, and no record of what was attempted.
Scoped keys, which can now actually be minted
The fix is to narrow the key, not to hide the tools. The scopes column has been on api_keys since migration 016, and the read path has always intersected it into the effective permission set, but nothing ever wrote it. Now it does:
{
"name": "agent-authoring",
"role": "marketer",
"scopes": ["segment.read", "segment.write", "event.read"]
}
That key creates and edits audiences and can never send, through the MCP or through curl. Its effective permissions are the role intersected with this list, so a scope can only ever take away: a permission the role does not hold cannot be scoped in, and the server refuses it with scope_exceeds_role.
| What you send | What happens |
|---|---|
no scopes field | the key carries its whole role, which is the behaviour every existing key has |
| a list of permissions inside the role | the key carries exactly those |
an empty list [] | refused with scopes_empty. A key that can do nothing is indistinguishable from a mistake |
| a permission that does not exist | refused with unknown_permission, and the offending string is named |
| a permission outside the role | refused with scope_exceeds_role |
segmentic_whoami reports scoped and the effective permissions, so the agent knows what it can do before it tries.
The panel's key form has no permission picker yet, so a scoped key is minted from the API for now. Until that form ships, a curl with your own session is the shortest path.
Configuration
The binary reads two environment variables and takes three flags. A flag wins over the environment variable, because the environment value is only the flag's default.
| Flag | Environment variable | Default | What it is |
|---|---|---|---|
-api | SEGMENTIC_API_URL | http://localhost:8082 | Base URL of the management API. Trailing slashes are stripped. |
-key | SEGMENTIC_API_KEY | none | The sk_seg_ key. Absent is fatal. |
-timeout | none | 30s | Per-request HTTP timeout. There is no environment variable for this one. |
Prefer the environment variable over -key. A flag value sits in the process table where every other process on the machine can read it.
Set -timeout to 60s if you run reports, and know that it is only half the fix. The report handlers allow themselves 45 seconds, but the HTTP server's own WRITE_TIMEOUT defaults to 30 seconds and the client defaults to 30 as well, so a heavy retention query is cut off from two directions while it keeps running server-side and the 25 budget units are already spent. Raising the client timeout helps only if whoever operates your deployment also raises WRITE_TIMEOUT.
Claude Code
Project file .mcp.json at the root of your repository:
{
"mcpServers": {
"segmentic": {
"command": "/absolute/path/to/segmentic-mcp",
"args": ["-timeout", "60s"],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
The key under mcpServers becomes the namespace, so the tools appear as mcp__segmentic__segmentic_whoami and so on. Keep it segmentic, matching the name the server declares.
The same thing from the command line:
claude mcp add segmentic --scope project \
--env SEGMENTIC_API_URL=https://api.segmentic.net \
--env SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
-- /absolute/path/to/segmentic-mcp -timeout 60s
Everything after -- is the command and its own arguments, so -timeout reaches the binary rather than the CLI.
To run it from the image instead of a local binary:
{
"mcpServers": {
"segmentic": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "SEGMENTIC_API_URL",
"-e", "SEGMENTIC_API_KEY",
"ghcr.io/segmentic1/segmentic-backend:latest",
"segmentic-mcp"
],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
Claude Desktop
~/Library/Application Support/Claude/claude_desktop_config.json on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows.
{
"mcpServers": {
"segmentic": {
"command": "/absolute/path/to/segmentic-mcp",
"args": ["-timeout", "60s"],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
On Windows the path is absolute and the backslashes are doubled:
{
"mcpServers": {
"segmentic": {
"command": "C:\\Program Files\\Segmentic\\segmentic-mcp.exe",
"args": [],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
Claude Desktop does not inherit your shell PATH, so command must be an absolute path.
Codex
~/.codex/config.toml:
[mcp_servers.segmentic]
command = "/absolute/path/to/segmentic-mcp"
args = ["-timeout", "60s"]
[mcp_servers.segmentic.env]
SEGMENTIC_API_URL = "https://api.segmentic.net"
SEGMENTIC_API_KEY = "sk_seg_REPLACE_ME"
Cursor
.cursor/mcp.json in the project, or ~/.cursor/mcp.json globally.
{
"mcpServers": {
"segmentic": {
"command": "/absolute/path/to/segmentic-mcp",
"args": ["-timeout", "60s"],
"env": {
"SEGMENTIC_API_URL": "https://api.segmentic.net",
"SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
}
}
}
}
Hosted over HTTP
Stand it up once and nobody else installs anything:
segmentic-mcp -http :8090 -api https://api.segmentic.net
-http and -key are mutually exclusive and passing both is refused. That is design rather than fussiness: one process serves many accounts, so the credential has to arrive per request and the tool list has to be built from THAT key's permissions. A -key on a hosted server is one account's credential answering for everybody.
On the client side, a URL and a key instead of a command:
{
"mcpServers": {
"segmentic": {
"url": "https://mcp.segmentic.net/mcp",
"headers": { "Authorization": "Bearer sk_seg_..." }
}
}
}
Four things make this safe to host, and each is worth saying:
- The process holds no credential of its own. There is nothing in it to steal.
- The key is read from the header only, never from the query string. A key in a URL lands in every access log and every referrer header.
- SDK write keys are refused. A
wk_key ships inside every customer's JavaScript bundle, so accepting one would mean an agent could be pointed at a credential lifted from a page source. - Permissions are enforced by the API on every call. A key's identity is cached for about two minutes, and that cache only decides which tools are LISTED; a revoked key fails on its very next call with the API's own 401.
There is a GET /healthz, deliberately not behind the key: a probe that needs a credential is a probe somebody turns off.
Local development
The default base URL is http://localhost:8082, which is the port the public API listens on, so locally you can omit it:
{
"mcpServers": {
"segmentic-local": {
"command": "/absolute/path/to/segmentic-mcp",
"env": { "SEGMENTIC_API_KEY": "sk_seg_LOCAL_KEY" }
}
}
}
PUBLIC_API_ADDR is empty by default, and an empty value means the public API is not served at all. An install that has not decided to expose one should not be exposing one. If you start the API without setting it, nothing listens on 8082 and the MCP server exits with a connection refused message. The dashboard's own port, 8081, is not a substitute: it is a different mux and it does not carry /v1/whoami in this shape.
Verifying an installation without an agent
The server speaks JSON-RPC over stdio, so you can prove the key and see the tool count with no client at all. Run it in a terminal:
SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
SEGMENTIC_API_URL=https://api.segmentic.net \
./segmentic-mcp
It calls GET /v1/whoami before registering anything, then prints one line to standard error and waits for a client on standard input:
2026/08/07 21:04:11 segmentic-mcp 1.0.0, tenant 7, role viewer, 8 permissions
Failing at startup rather than on the first tool call is deliberate: an agent that discovers its credential is wrong halfway through a conversation reports it as "the tool is broken", and the human never sees the real reason.
A bad key exits 1 with one of these on standard error:
segmentic-mcp: no API key: set SEGMENTIC_API_KEY or pass -key
segmentic-mcp: that is an SDK write key (wk_…), which cannot read anything.
This needs a management key (sk_seg_…) from Settings → API keys.
segmentic-mcp: could not reach the Segmentic API: unauthenticated: a valid API key is required
The write-key guard fires on the prefix wk_, before any request goes out. The comment explains why it is a separate message rather than a 401: a write key ships inside every customer's JavaScript bundle, so accepting one here would mean an agent could be pointed at a credential lifted from a page source, and failing now with a sentence that explains the difference beats failing later with a 401.
The tools
The exact set depends on the key's permissions. Each subsection gives the permission that gates a tool, the arguments, the endpoint it calls, the budget it spends, and what comes back.
Results are always one text block containing indented JSON. There is no structured output schema, so a client that expects structuredContent gets none. Failures come back as tool content with isError: true, not as a protocol error, so the model reads the reason and can act on it.
| Tool | Permission | Endpoint | Budget |
|---|---|---|---|
segmentic_whoami | none | none, cached at startup | 0 |
segmentic_capabilities | none | GET /v1/capabilities | 1 |
segmentic_describe_data | event.read | GET /v1/schema/events and GET /v1/schema/traits | 10 |
segmentic_ingest_quality | event.read | GET /v1/ingest/quality | 5 |
segmentic_get_audience | segment.read | GET /v1/segments/{id} | 1 |
segmentic_create_audience | segment.write | POST /v1/segments | 1 |
segmentic_update_audience | segment.write | PUT /v1/segments/{id} | 1 |
segmentic_delete_audience | segment.delete | GET then DELETE /v1/segments/{id} | 2 |
segmentic_create_campaign | campaign.write | POST /v1/campaigns | 1 |
segmentic_submit_campaign_for_approval | campaign.write | POST /v1/campaigns/{id}/submit | 1 |
segmentic_set_campaign_recurrence | campaign.send | GET then PUT /v1/campaigns/{id}/recurrence | 6 |
segmentic_clear_campaign_recurrence | campaign.send | DELETE /v1/campaigns/{id}/recurrence | 1 |
segmentic_send_campaign | campaign.send | GET then POST /v1/campaigns/{id}/send | 6 |
segmentic_ingest_events | profile.write | POST /v1/events | 10 |
segmentic_queue_export | data.export | POST /v1/exports | 25 |
segmentic_list_exports | data.export | GET /v1/exports | 1 |
segmentic_list_audiences | segment.read | GET /v1/segments | 1 |
segmentic_describe_audience | segment.read | POST /v1/audiences/validate | 1 |
segmentic_count_audience | segment.read | POST /v1/audiences/count | 25 |
segmentic_list_campaigns | campaign.read | GET /v1/campaigns | 1 |
segmentic_campaign_report | campaign.read | GET /v1/campaigns/{id} | 5 |
segmentic_funnel_report | analytics.read | POST /v1/reports/funnel | 25 |
segmentic_retention_report | analytics.read | POST /v1/reports/retention | 25 |
segmentic_send_transactional_message | campaign.send | POST /v1/messages | 1 |
segmentic_whoami
Registered always, even for a key that carries nothing.
No arguments. It makes no HTTP call: the answer was fetched once at startup and is returned from memory, so it costs nothing and cannot fail. It also cannot notice that the key was revoked five minutes ago.
{
"tenant_id": 7,
"role": "analyst",
"permissions": [
"analytics.read", "audit.read", "campaign.read", "data.export",
"event.read", "journey.read", "member.read", "profile.read",
"segment.read", "settings.read", "template.read"
],
"scoped": false
}
permissions is the effective set, sorted. It is eleven long and not four, because the analyst role reads everything; the set is in backend/internal/auth/role.go. The tool list you see is shorter than that, because the MCP server has only written tools for some of those permissions. scoped is meant to say the key was narrowed below its role, but it is false on every key this product can mint: the scopes column exists on api_keys and no route writes it. No email address, no person's name, no tenant slug, and no key id: the API returns api_key_id and this tool drops it.
It does not report remaining budget, deployment capabilities, or a server mode. Those do not exist in the response.
segmentic_describe_data
Gate event.read. No arguments. Two sequential GETs, so one call spends 10 budget units; if the first fails the second is never made and the whole tool errors.
This is the tool that exists because of the single most expensive mistake an agent makes here. From the file:
It invents identifiers. describe_data exists so a segment id or an event
name can be looked up rather than guessed; a guessed event name compiles
cleanly and returns an empty audience, which reads as "nobody matches"
rather than as "that event does not exist".
The two server payloads are nested one level deeper than their own bodies:
{
"events": {
"events": [
{
"name": "order_completed",
"volume": 184203,
"prop_keys": ["revenue", "order_id", "currency"],
"last_seen": "2026-08-06"
}
]
},
"traits": {
"traits": ["city", "order_count", "plan"],
"schema": [
{"name": "city", "kind": "string", "users": 91204},
{"name": "order_count", "kind": "number", "users": 58110}
]
}
}
last_seen is the column worth reading. An event with a large volume and a last seen date three weeks ago is an integration that broke, and no other figure says so, because volume alone looks healthy for a month afterwards: the window is ninety days.
prop_keys and last_seen are omitted when empty. kind is string or number; a trait sent as both appears once, as number. users is how many profiles carry the trait. The tool's own description says "with volumes" for traits, which is loose: trait rows carry a profile count, not an event volume.
An account that has sent nothing gets {"events":[]} and {"traits":[]}.
segmentic_list_audiences
Gate segment.read. Cost 1.
Despite the name, this returns saved segments, the objects the panel calls سگمنت. It calls GET /v1/segments, which is registered only when the deployment serves segments.
It takes no arguments.
What comes back is not every segment. The store query is ORDER BY updated_at DESC LIMIT 200, so it is the 200 most recently updated and nothing else. The cap is silent: no count, no has_more, no warning, and an account holding 250 segments has no route on any surface to the other 50. The tool's own description now tells the model exactly this, and draws the conclusion: on a large account, treat a name you cannot find here as unknown rather than as absent. Two hundred whole filter trees can still be large; if the payload exceeds the client's 4 MiB response cap the tool reports unreadable response (200) with the first 200 characters.
Until recently this tool took limit and cursor, and neither did anything: the handler never reads the query string, and no response ever carries a next_cursor. An argument that does nothing is worse than no argument, because the model believes it and asks for page two. Both were removed.
{
"segments": [
{
"id": 41,
"name": "Recent purchasers",
"kind": "dynamic",
"definition": {
"version": 1,
"root": {
"kind": "event",
"event": "order_completed",
"window": {"kind": "last", "amount": 30, "unit": "day"}
}
},
"description_fa": "کاربرانی که شهرشان تهران است",
"last_size": 18422,
"last_computed_at": "2026-08-06T21:00:00Z",
"updated_at": "2026-08-01T11:12:00Z"
}
]
}
kind is dynamic, static or realtime. definition is the whole filter tree, not a summary. description_fa is Persian, always, on every response from this API: the public routes carry no locale middleware, so Accept-Language changes nothing and the MCP server sends no such header anyway.
segmentic_describe_audience
Gate segment.read. Cost 1. Calls POST /v1/audiences/validate.
One required argument, filter, and it must be a whole definition, {"version": 1, "root": {...}}, not a bare condition. The tool description sells it as the cheap rehearsal before an expensive count: it runs no query, returns nothing about people, and tells you in a sentence what the filter says.
{
"filter": {
"version": 1,
"root": {"kind": "trait", "trait": "city", "operator": "eq",
"value": {"type": "string", "str": "تهران"}}
}
}
{
"valid": true,
"description_fa": "کاربرانی که شهرشان تهران است"
}
Two fields, and that is all. There is no English description, no machine-readable problem list, and no report of how far back the filter reaches.
An invalid filter is 422, not a 200 with valid: false. The dashboard answers 200 with valid:false, which is right for a live-typing form and wrong for an integration whose error handling branches on status.
{"error": {"code": "filter_invalid", "message": "segment: unknown node kind"}}
That one the model sees in full, as filter_invalid: segment: unknown node kind.
segmentic_count_audience
Gate segment.read. Cost 25, the heaviest weight there is. Calls POST /v1/audiences/count with the same filter argument as above.
{
"count": 18422,
"approximate": false,
"description": "کاربرانی که شهرشان تهران است",
"took_ms": 812
}
A number, a Persian sentence and a duration. Nothing else, and the tool description says so in capitals so a model does not go looking for a list.
At the default budget of 600 units per key per minute this is 24 counts in a minute before the API answers budget_exhausted. That ceiling is the only brake. There is no refusal for an event condition with no time window, and no per-account concurrency limit on counting.
segmentic_list_campaigns
Gate campaign.read. Cost 1. Calls GET /v1/campaigns.
It takes no arguments. Exactly as with segmentic_list_audiences, the store query carries a fixed LIMIT 200: the 200 most recently updated campaigns, with no next_cursor and nothing in the response saying a 201st exists. The tool's description now states that cap to the model outright. It used to claim "Paginated; the page size is capped by the server" and to take two arguments that did nothing; the second half of that sentence was true and the first was not.
{
"campaigns": [
{
"id": 812,
"name": "July win-back",
"channel": "sms",
"status": "finished",
"estimated": 60000,
"processed": 60000,
"sent": 41230,
"scheduled_at": "2026-07-14T06:30:00Z",
"updated_at": "2026-07-14T09:11:00Z"
}
]
}
No recipient identifiers of any kind.
segmentic_campaign_report
Gate campaign.read. Cost 5. Calls GET /v1/campaigns/{id} with one required argument, campaign_id, whose schema description tells the model to take it from segmentic_list_campaigns rather than invent it.
The payload has five sections: the campaign itself including its variants, progress, a reach breakdown, a delivery breakdown, engagement per channel, and the uplift against the control group.
{
"engagement": [
{
"channel": "sms",
"channel_fa": "پیامک",
"issued": 41230,
"withheld": 180,
"measurable_open": 0,
"measurable_click": 41230,
"opened": 0,
"clicked": 5120,
"opened_unmeasurable": 41230,
"clicked_unmeasurable": 0,
"why_open": "پیامک رسید خواندن ندارد"
}
],
"uplift": {
"verdict": "positive",
"goal": "order_completed",
"treated_users": 41230,
"treated_conversions": 2110,
"control_users": 4581,
"control_conversions": 190,
"lift": 0.117,
"lift_low": 0.041,
"lift_high": 0.194,
"extra": 1420,
"extra_low": 812,
"extra_high": 2030,
"money_known": true,
"computed_at": "2026-07-21T09:30:00Z"
}
}
Every rate arrives as a numerator with a named denominator. opened is meaningless without measurable_open, and for SMS measurable_open is zero, because an SMS has no read receipt. A campaign whose message carried no link is not a campaign with a zero click rate. The tool description instructs the model to quote both numbers and never to collapse them into one percentage, for exactly this reason.
lift on its own is not a measurement, it is the middle of a range. lift_low and lift_high are the range, and a report that quotes the point estimate alone has thrown away the only thing that says whether the campaign worked.
A finished campaign whose seven-day measurement window has not closed returns an uplift section with verdict: "too_early" and little else filled in. It is synthesised on purpose, because otherwise the section simply vanishes for a week after every send, which reads as a missing feature rather than as "we are still counting".
The description calls one of these "the A/B verdict". That is loose. uplift.verdict is the holdout verdict, treated against control. Variants are present inside campaign.variants, but there is no per-variant conversion comparison in this payload and there is no separate experiment tool.
An unknown id and another account's id both give 404, which the model sees as bare http 404.
segmentic_funnel_report
Gate analytics.read. Cost 25. Behind the billing lock, so an account past its allowance is refused here with account_locked as well as in the panel.
Its arguments are steps (an array of step objects), from and to (RFC3339, both required), window (required) and strict (optional). The server wants between 2 and 12 steps and a range of at most 730 days.
window is how long somebody has to finish the whole funnel, as a duration string: 7d, 24h, 30m. The server has no default for it and refuses a request without one, for the plain reason that a funnel measured over thirty days and the same funnel over one hour are different questions. It must not be longer than the range itself.
strict requires the steps with no other event in between. It is off by default, which is what "who viewed a product and bought it" usually means.
Until recently this tool could not succeed at all: its argument struct had no window field, so the body never carried one and the server rejected every request. The model was told only http 400, with no message, after the 25 units were already spent. If your compiled copy is old, rebuild it from the repository; the other route is POST /v1/reports/funnel directly, documented in Reports.
segmentic_retention_report
Gate analytics.read. Cost 25. Behind the same billing lock. Calls POST /v1/reports/retention.
Its arguments are from and to (required) and four optional ones: start, return, granularity and periods.
start and return are separate because "came back" rarely means "did the same thing again". A shop wants to know who signed up and then bought; asking whether they opened the app flatters the number and answers nothing. Leaving either empty means "any activity".
granularity is one of day, week or month. Weeks start on Saturday and months are Jalali. It defaults to day.
Until recently this tool answered a different question from the one it was asked, and did not error: it sent an event field, the server's retention request has start and return and ignores an unknown field rather than refusing it. So whatever event the model named was silently dropped and every call measured "any activity, then any activity again". It never sent granularity or periods either. A plausible but wrong answer is worse than an error. If your compiled copy is old, rebuild it from the repository.
segmentic_send_transactional_message
Gate campaign.send. Cost 1 against the request budget. Calls POST /v1/messages, which is registered only when the deployment has a sending path configured.
This is the only tool that reaches a person. Its description leads with that, in capitals, and then names the alternative in the same breath:
Send ONE message to ONE named person, immediately, an order update, a delivery
notice, a login code. THIS REACHES A REAL PERSON'S PHONE OR INBOX AND CANNOT BE
RECALLED. Marketing is refused: use a campaign, which a human schedules. The
idempotency_key must identify the real-world event (for example
'order-8821-shipped'), so that retrying is safe; never invent a random one,
because a fresh key on a retry sends a second message.
| Argument | Required | Notes |
|---|---|---|
user_id | yes | The person, by the id their app reports |
channel | yes | See the warning below |
template_id | yes | A stored template. Message text cannot be passed inline |
idempotency_key | yes | Matches ^[A-Za-z0-9._:-]{8,200}$ |
category | no | transactional (the default) or critical. marketing is refused |
vars | no | At most 40 keys, all string values |
{
"user_id": "u_9137",
"channel": "sms",
"template_id": 42,
"vars": {"code": "8391"},
"idempotency_key": "order-8821-shipped"
}
{
"message_id": "t7.order-8821-shipped",
"status": "sent",
"sent_at": "2026-08-07T18:22:31.114Z"
}
The channel list in the tool's schema is wrong in one place. It offers web, and this endpoint does not accept web. The accepted values are exactly push, sms, email, webpush, inapp, bale, eitaa and rubika, compared literally with no alias resolution. A model that follows the description and sends web gets 400 transactional: unknown channel, which it sees as bare http 400.
A 200 does not mean a message went out. When status is suppressed and reason is set, the message was deliberately not sent: an unsubscribe, a channel opt-out, a suppression, or no address on file. The tool's description does not say this, so an agent will report a success. Check status and reason before telling anybody the notice was delivered.
{
"message_id": "t7.order-8821-shipped",
"status": "suppressed",
"reason": "channel_opt_out",
"reason_fa": "این کانال را خاموش کرده است",
"sent_at": "2026-08-07T18:22:31.114Z"
}
replayed: true marks a response served from the idempotency ledger rather than a fresh send. It is how you tell "we already did this" apart from "we just did this", which matters when the first attempt timed out and you do not know which happened.
The same key in flight gives 409 with Retry-After: 1. A message that was sent but whose ledger row failed to write still returns 200, because a 5xx would make the caller retry and send a second one, which is worse than the lost row being reported.
What guards this call, exactly: the permission on the key, the four required arguments the protocol enforces before the handler runs, an explicit refusal of an empty idempotency key, and the server's own idempotency ledger and marketing refusal. There is no two-phase confirmation, no confirm token, no per-session send cap, and no per-key recipient budget. The model authors the idempotency key itself.
The authoring tools
Every one of these calls a public API route. The recurrence tools and their routes were added together, while the other tools expose routes that were already present.
Three rules hold across this half. One, the body is built from the server's own request type rather than a hand written map, so renaming a field in the backend stops this file compiling. The class of bug that broke two of the reporting tools is not available here. Two, validation runs twice on purpose, once locally so the model gets a sentence it can act on and once at the server, which is where the decision actually is. Three, the two irreversible acts ask for the object's own name back.
segmentic_create_audience and segmentic_update_audience
segment.write. Creating one sends nothing: an audience is a saved question, and campaigns and journeys point at it later.
The filter must be a whole definition, {"version": 1, "root": {...}}. Pass a bare condition and the tool refuses before it reaches the server, and tells you how to wrap it. That is the commonest mistake, and the server's own answer to it (unknown node kind: "") tells nobody what to do.
An update replaces, it does not merge. The whole definition becomes what you send, so read it first with segmentic_get_audience. Every campaign and journey pointing at that audience uses the new filter from that moment.
segmentic_delete_audience
segment.delete. Reads the audience first, compares confirm_name against its real name, and does nothing if they differ. A journey or campaign that points at it is not deleted with it.
segmentic_create_campaign
campaign.write. Always creates a draft, whatever else you pass. Creating and sending are two calls because one of them is reversible.
The audience is either segment_id or filter, and passing both or neither is refused here: two audiences on one campaign is not something the server can resolve, and guessing which was meant is how the wrong people get messaged. template_id is required, because message text cannot be passed inline.
segmentic_submit_campaign_for_approval
campaign.write. On accounts that require approval, puts a draft into the review queue. Nothing is sent. The approval is fingerprinted against the campaign as it stands, so editing it afterwards means submitting again.
segmentic_set_campaign_recurrence
campaign.send. Starts or replaces a campaign's repeat schedule. It reads the campaign first and requires its exact current name in confirm_name. A schedule can create future campaigns for the whole audience, so the same protection used for an immediate send applies here.
The recurrence object uses daily, weekly or monthly cadence. hour and hours are Tehran hours. Saturday is weekday 0, and monthly dates are Jalali. The tool validates all of this locally before calling the API. The read costs 5 units and the write costs 1, for a total of 6.
segmentic_clear_campaign_recurrence
campaign.send. Stops future automatic repeats. It does not delete or change campaigns already created by the schedule. This call costs 1 unit and needs only a campaign_id from segmentic_list_campaigns.
segmentic_send_campaign
campaign.send. This reaches everyone in the campaign's audience and cannot be recalled.
It reads the campaign first and compares its name to confirm_name. If they differ, nothing is sent. If the name cannot be read at all, nothing is sent either: the failure direction is deliberately closed, because the campaign will still be there in five minutes and the messages will not come back.
Before this call, get the recipient count with segmentic_count_audience and tell the person who asked. The difference between four hundred people and four hundred thousand is one wrong filter.
segmentic_ingest_events
profile.write. The same door a customer's own backend posts to, which makes it the way to get facts in without a browser SDK.
A 200 means the request was accepted, not that every event was. The rejected array names each refused item by its index in your batch and why. Give every event a stable message_id derived from the real world fact, or a retry counts it twice.
segmentic_queue_export and segmentic_list_exports
data.export. The only tools whose output leads to personal data, meaning email addresses and phone numbers. The file is collected from the panel rather than from here, so queueing one produces something a person has to come and fetch. Both are behind the soft lock.
Why a missing permission hides a tool instead of refusing it
Tools are registered from what the key can do, not from what the API offers:
Registered conditionally on what the KEY can do, not on what the API
offers. A tool an agent can see is a tool it will try, and a refusal it
cannot fix reads to the model as a fault worth retrying, so the honest
move is not to offer it.
Three things follow, and all three surprise somebody eventually.
- The tool list is fixed when the process starts. It is built from the
whoamianswer fetched before the server was created. Revoking a permission, or the whole key, does not change the advertised list until you restart the process; the calls simply start failing at the API. - Absence, not refusal. A permission the key lacks means the tool never appears in
tools/list, so the model cannot see it, cannot try it, and cannot mistake a 403 for something worth retrying. - The gate is the key, not the deployment. The server never asks
GET /v1/capabilities. A key withcampaign.readon an install that does not serve campaigns still getssegmentic_list_campaigns, and the tool answersunknown_endpoint: no such endpoint: GET /v1/campaigns, see GET /v1/capabilities.
segmentic_whoami sits outside every condition, so a key scoped to nothing still exposes exactly one tool.
What the tools never return
No tool returns an email address or a phone number. Not one.
| Tool | Personal data in its response |
|---|---|
segmentic_whoami | none. No email, no name, no key id, no account slug |
segmentic_describe_data | aggregates: event names, volumes, property keys, trait names and counts. Never a property value |
segmentic_ingest_quality | counts of what ingest refused or corrected, by day, code, app and client library. Never a value, an identifier or an error string |
segmentic_list_audiences | segment names, descriptions, filter trees and sizes. A filter can embed a literal a marketer typed, a city or a plan name, but no person is named |
segmentic_describe_audience | one boolean and one Persian sentence. It runs no query |
segmentic_count_audience | a number, a sentence, a duration |
segmentic_list_campaigns | campaign metadata and totals |
segmentic_campaign_report | aggregates only: every row is a count grouped by a reason or a channel |
segmentic_funnel_report | level counts |
segmentic_retention_report | a grid of counts |
segmentic_send_transactional_message | echoes the user_id you supplied. No address, no rendered body |
The stronger guarantee is not a filter, it is absence. There is no tool for a profile, no timeline tool, no segment preview tool, no export tool and no search. The one internal handler that returns names, email addresses, phone numbers and cities for real people is registered on the dashboard's own listener and is not reachable from this API at all. The MCP server has no enumeration primitive of any kind, because the endpoints that would give it one are simply never called.
One thing is not guaranteed. Text your own team wrote reaches the model unfenced: segment names, campaign names and the Persian descriptions arrive as plain JSON strings with no untrusted-content wrapper. If somebody names a segment with an instruction aimed at a model, the model reads it as ordinary text. Treat the panel's free-text fields as an input channel to your agent.
What an agent gets wrong, and what the descriptions do about it
The file names three failure modes. Two of the countermeasures are real and one is not, and it is worth knowing which is which.
It invents identifiers. Countered by wording, only. segmentic_describe_data says ALWAYS call this first and explains the consequence. segmentic_list_audiences says find an id rather than guessing one. segmentic_funnel_report says step names must come from segmentic_describe_data. The campaign_id and filter argument descriptions repeat it. Nothing enforces any of it: a filter naming an event that does not exist compiles cleanly and returns zero, which looks exactly like a real audience of zero. There is no session state, no precondition, and no did-you-mean suggestion.
It reads more than it needs. The stated countermeasure is server-side paging with a ceiling the model cannot raise. Half of that is in the code. The ceiling is real and the model genuinely cannot raise it, because it is a fixed LIMIT 200 in SQL, but the paging is not, and there is no route to the 201st row. What was done about it was to stop the tools pretending otherwise: the arguments that did nothing were removed, and both tool descriptions now state the ceiling. The countermeasure that is real is the second half of the sentence, that no tool returns contact data, and it is real because those tools were never written.
It loops. This one holds. The budget is weighted and enforced server-side, per key and per minute, so a model retrying a heavy report is refused by the API rather than by good manners in the client. The budget is per key on purpose: a customer issues one narrow key to an agent and keeps their integration key separate, and a runaway agent must not be able to exhaust the budget their order pipeline depends on. The descriptions push the same way, telling the model to count once rather than count variations in a loop, to spend one call per question rather than one per hypothesis, and to read a whole retention grid rather than ask cohort by cohort. segmentic_describe_audience is advertised as free precisely so the model rehearses before it spends.
Error text is passed through rather than paraphrased, for the same reason:
The API's own code and message, passed through rather than
paraphrased. "budget_exhausted" tells a model to wait; a rewritten
"something went wrong" tells it to retry immediately, which is the
opposite of what the server just asked for.
Budget
Every call the server makes spends from the same per-key request budget as any other API client: 600 units per key per minute by default, in a fixed window. Startup spends 1 unit on whoami before any tool runs.
Exhaustion is 429 with Retry-After: 60 and the code budget_exhausted, which reaches the model intact. If the budget meter itself is unreachable the request is refused, not allowed: this one fails closed.
The transactional send endpoint has a second, separate limiter, counted per account rather than per key, and that one fails open when its cache is down, because the endpoint carries order receipts and login codes and refusing them all because a cache is down turns our outage into your checkout failing. It is off by default.
Per-key daily recipient limits and personal-data row limits do not exist. The columns are in a migration and nothing reads or writes them.
What the model sees when a call fails
Failures come back as tool content with isError: true, so the model reads the text and can act on it. What the text says depends on which part of the server produced the error, and this is the roughest edge in the whole binary.
The client understands one error shape, the one the public edge writes: {"error": {"code": ..., "message": ...}}. Several handlers behind that edge write a different shape, where error is a plain string. The client cannot read those, so it reports the status number and nothing else.
Survives intact, with code and message:
| Code | Status |
|---|---|
unauthenticated | 401 |
write_key_rejected | 401 |
key_expired | 401 |
forbidden, naming the permission in need | 403 |
account_locked | 403 |
filter_invalid | 422 |
budget_exhausted | 429 |
budget_unavailable | 503 |
unknown_endpoint | 404 |
Degrades to a bare status number:
| What actually failed | What the model is told |
|---|---|
| A filter that would not compile, on count | http 400 |
| Every funnel and retention validation error | http 400 |
| Every caller error on a send, including the wrong channel | http 400 |
| The send rate limiter | http 429 |
| A campaign id that does not exist | http 404 |
| The schema, segment list, campaign list or count query being unavailable | http 503 |
So the error the design most wants the model to read correctly, budget_exhausted, arrives whole. The errors a model most needs in order to fix its own input do not. When an agent reports http 400 and cannot say why, make the same call with curl and read the body.
The response body is read up to 4 MiB. Anything larger is truncated, then fails to parse, and the tool reports unreadable response with the first 200 characters.
What this server deliberately does not do, and what is simply missing
Deliberate:
- No tool that returns a person. No profile lookup, no timeline, no segment preview, no search. The only way personal data leaves through this server is an export, which is itself behind
data.exportand the soft lock. - No flag to change what a key can do. Neither
--allow-writenor--allow-sendexists. - No database handle. Every gate stays at the HTTP edge where it is already tested.
- No irreversible act without a name to confirm. Sending a campaign and deleting an audience both read the object first, and do nothing if they cannot read its name.
Missing, and you will want at least one of these:
- Journey and template tools. Neither has a route on the public API, so the MCP cannot create or edit a scenario or a template. Note that
GET /v1/capabilitiesreportsjourneys, which is true of the installation and not of this API surface. It has a card of its own. - Any published build. No release, no image of its own, no macOS or Windows binary. You compile it.
- A permission picker in the panel. Scoped keys can be minted now, but from the API; the dropdown is not on the keys screen yet.
- Paging on the two list endpoints. Each is cut at the 200 most recently updated rows and nothing in the response says it was cut. The tools at least no longer offer paging arguments and state the ceiling in their descriptions, but the 201st row is still out of reach.
- Readable validation errors, per the table above.
- Per-tool logging. The binary writes one line at startup and nothing after it. The account audit log is a separate mechanism and does not record reads, so there is no per-call trail of what an agent looked at.
- Server-level MCP instructions. All steering lives inside individual tool descriptions, which a client may or may not show the model in full.
- Test coverage beyond request shape. The tests today hand the body each tool builds to the server's own request type and run the same compile function the HTTP handler calls, which is what caught the two broken tools. End-to-end behaviour against a real API is still untested.
If you need something on the second list today, use the management API directly. Everything the MCP server does is one HTTP call, and there is nothing in it you cannot do with curl.