io.github.seer22/shturman-direct-mcp
<!-- mcp-name: io.github.seer22/shturman-direct-mcp -->
# Shturman Direct MCP
[](https://github.com/seer22/shturman-direct-mcp/actions/workflows/ci.yml)
[](LICENSE)
[](pyproject.toml)
[](pyproject.toml)
**An MCP server for Yandex.Direct that knows the platform's rules — field limits,
the price of a call in API units, and the places where the API answers `success`
and does something else.**
> Русская версия — [README.ru.md](README.ru.md).
> Where the project stands — [docs/SOSTOYANIE.md](docs/SOSTOYANIE.md) (Russian).
---
## Why another one
There are already two dozen MCP servers for Yandex.Direct. Nearly all of them are
one-to-one wrappers: one tool per API method. This one is built around three things
those wrappers do not have.
**It knows the traps.** 36 recorded cases where Direct returns success and does
something else. Counted honestly from the file: 13 are laid out as fields —
looks like / danger / the correct way — and 8 of them carry verbatim quotes of
the official documentation or live measurements recorded as data; the rest are
kept as prose and marked `structured: false`, because laying them out from
memory would mean inventing detail. The limits are held to a stricter bar:
every one of the 54 limits carries a verbatim quote.
Example: updating a modern combinatorial ad through the legacy
`TextAd` field silently collapses it to a single headline. Six of seven headlines
disappear, and the API reports success.
**It counts before it spends.** An erroneous API call costs 20 units; a blind retry
loop burns the daily limit in minutes. The cost is known before the call, not read
from a header afterwards.
**It cannot start your ads.** Starting ad serving, resuming, unarchiving and
deleting campaigns are absent — not behind a flag, not behind an environment
variable, not at all. The one moderation capability that exists is honest and
narrow: `ads.moderate` sends a **draft ad** to moderation, because a draft has no
manual path whatsoever — `State: OFF` is a consequence of `Status: DRAFT`, the
interface has no start button for it, and this API method is its only exit
(decision R23, 2026-08-25). Starting the **campaign** remains a person's hand,
forever. The full list is [the red line](#the-red-line) below.
That last one is not caution for its own sake. Google's own official Ads MCP server
ships **no write operations whatsoever**; Meta's creates campaigns **paused** and puts
spending behind a separate tool.
---
## The red line
Eleven methods this server does not implement, and will not:
`campaigns.moderate` · `campaigns.resume` · `campaigns.unarchive` ·
`campaigns.delete` · `adgroups.resume` · `adgroups.unarchive` ·
`adgroups.delete` · `ads.resume` · `ads.unarchive` ·
`keywords.resume` · `keywords.unarchive`
Why absence rather than a flag, in one paragraph: the protocol's own blog says of
guards built from settings that "they aren't enforcement", and a client is required
to treat server annotations as untrusted. A setting can be flipped, a prompt can be
argued with; a capability that is not there cannot be invoked. Most of these
methods start money moving or make it able to move; the deletions are on the list
for a different reason — they destroy statistics an automatic strategy learned
from, and nothing can recreate them. The list itself ships as data
(`knowledge://red-line`), with the reasoning recorded next to each entry.
---
## What it does not do
- **It does not decide what to advertise.** Keywords, ad copy, budgets and strategy
belong to a separate agent that installs alongside and depends on this server.
- **It does not delete campaigns.** A wrong deletion in an account with history is
irreversible and erases the statistics an automated strategy learns from.
- **It does not send your token anywhere.** The server talks to Yandex and to nothing
else. Your OAuth token stays on your machine — this is a property of how it is
built, verifiable in the code, not a promise.
---
## Design
- **Zero dependencies.** Python standard library only. An OAuth token for an
advertising account passes through this process; every transitive package would be
a leak channel. The CI file has no install step at all — if one ever appears,
the claim is dead and it shows up in the diff.
- **Two-phase writes.** `plan` returns a description and an id. `apply` accepts
**only the id** and re-reads the plan from disk. The model cannot hand it a request
body, because there is no parameter for one.
- **Read-back after every write.** A successful response proves nothing here.
- **Reference data as MCP resources, not tools.** Tool-selection accuracy degrades
past 30–50 tools; neighbours in this niche ship 111 to 155.
- **Knowledge is verifiable without credentials.** The official documentation corpus
downloads anonymously, so anyone can prove the limits still match Yandex's docs
without an account or a key.
Full reasoning: [docs/RESHENIYA.md](docs/RESHENIYA.md) (Russian).
Architecture map: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) (Russian).
---
## What it offers
Twelve tools, deliberately few — selection accuracy in a model degrades past
30–50, and servers in this niche ship between 111 and 155:
| Tool | |
|---|---|
| `direct_status` | what is configured, what is known, what will not be done — call it first |
| `direct_read` | any reading method, with the cost in units checked before the call |
| `direct_plan` | describe a change, get an identifier. Nothing is sent |
| `direct_apply` | carry out a plan **by identifier only**, with a confirmation phrase |
| `direct_report` | statistics for a period; dates outside the three-year window are refused, not silently trimmed |
| `direct_audit` | the mechanical checklist of the official Direct audit, run locally — 14 read-only settings checks, each naming its source, plus what it deliberately does **not** check |
| `direct_media_attach` | put an image or a video on an existing ad — the whole chain, not one call |
| `direct_wordstat` | search demand as a number — the Wordstat API of Yandex Cloud. Spends **rubles, not units**; `tree` is free, start with it |
| `direct_metrika` | read Metrika: counters, goals, one statistics slice. `accuracy=full` is sewn in; the current day lags ~2 hours — end the window yesterday |
| `direct_metrika_goals` | create the missing goals of a counter, idempotently — goals must exist **before** the first ad click, retroactive credit does not exist |
| `direct_audience` | read Yandex Audience: the login's segments with owner and status, one segment's reach — `totals` lives in `/stat`, not in the segment object |
| `direct_audience_segments` | create a geo segment from circles — radius in **meters**, preview first; Direct sees it only after `processed` and under a **different** number |
What the Metrika half does and does not do: it **reads** — counters, goals,
one statistics slice — and it **creates missing goals**, idempotently; existing
goals are never changed and never deleted, there is no code path that could.
Which goals a launch needs and what value each carries is strategy, and
strategy deliberately lives outside this server.
Reference data is offered as resources rather than tools, so it costs neither a
slot nor a call — fourteen of them:
`knowledge://limits` · `knowledge://traps` · `knowledge://errors` ·
`knowledge://units` · `knowledge://red-line` · `knowledge://defaults` ·
`knowledge://transitions` · `knowledge://irreversibles` · `knowledge://money` ·
`knowledge://maintenance` · `knowledge://feeds` · `knowledge://wordstat` ·
`knowledge://metrika` · `knowledge://audience`
---
## Writing to a live account
🔴 **Every write is real.** Yandex discontinued the Direct sandbox in July 2026 —
the client service answered so in writing, and the entry for error 513 in
`knowledge://errors` records that answer. There is no test account to point this
server at: the first write goes to an account with real money.
That is why a write takes two phases and a spoken sentence:
1. `direct_plan` turns the intended calls into a stored plan and returns its
identifier. Nothing is sent. A plan that writes must also carry `verify` —
reading calls to run afterwards, because in this API a green response does
not prove the outcome.
2. `direct_apply` takes the identifier and the confirmation phrase, verbatim:
`carry out plan <id>`. To be honest about what holds what here: the real
human barrier is the client's own confirmation dialog, because the tool is
marked as writing (`readOnlyHint: false`) and the client asks the person
before the call. The phrase is a measure against the model's reflex, not a
gate: a model used to setting `true` without thinking has to produce a
sentence instead. The main weight is carried by the mandatory `verify` — a
plan that writes is not stored without read-back calls — and by the plan's
one-hour life.
A plan lives **one hour**. It describes a state of the account that was true when
it was built; an hour later it may not be, so a stale plan is refused and a new
one is made.
---
## Cassettes: what is written to disk
Exchanges with the API are recorded **by default**: JSONL, one line per call, one
file per day, under `var/kassety` in the working directory
(`SHTURMAN_KASSETY_DIR` overrides the location). An incident then already has its
evidence — the recorder makes material a by-product of work.
What a line holds: service, method, request params, the answer, the `Units`
header, timing, and the author label from `SHTURMAN_KASSETA_AVTOR`. What it can
never hold: HTTP headers are not recorded at all, and the credential exists only
in the `Authorization` header — the same rule the logs follow, where
`redaction.py` strips credential-shaped strings when a record is *built*, not
when it is printed. Long base64 fields (`*Data` — image and video uploads) are
replaced with a length marker. A recording failure never breaks the live call.
Note that the cassette is **account data at rest on your disk**: campaign
structures and report rows land in those files. They stay in the advertiser's
working directory and are never sent anywhere. To not record at all, set
`SHTURMAN_BEZ_KASSET=1`.
---
## Environment variables
All twelve, in one place. **The environment overrides the token file**: when a
token variable is set, the file is not read at all.
| Variable | Meaning |
|---|---|
| `SHTURMAN_DIRECT_TOKEN` | OAuth token for Direct. Takes precedence over the stored token file |
| `SHTURMAN_METRIKA_TOKEN` | OAuth token for Metrika. Same precedence |
| `SHTURMAN_AUDIENCE_TOKEN` | OAuth token for Yandex Audience. Same precedence |
| `SHTURMAN_SEARCHAPI_TOKEN` | Api-Key of a Yandex Cloud service account, for Wordstat. Same precedence |
| `SHTURMAN_SEARCHAPI_FOLDER_ID` | id of the Yandex Cloud folder every Wordstat call bills to — an address rather than a secret, but required |
| `SHTURMAN_DIRECT_CLIENT_ID` | client id of your own registered OAuth application for Direct — `login` needs it; registration steps in [docs/TOKENY.ru.md](docs/TOKENY.ru.md) |
| `SHTURMAN_METRIKA_CLIENT_ID` | the same for Metrika |
| `SHTURMAN_AUDIENCE_CLIENT_ID` | the same for Audience — its application needs only the two Audience checkboxes, and no access request exists for this API |
| `SHTURMAN_PLANS_DIR` | where write plans are stored (default: `var/plans` in the installation tree — set it for installed copies) |
| `SHTURMAN_KASSETY_DIR` | where cassettes are written (default: `var/kassety` under the working directory) |
| `SHTURMAN_KASSETA_AVTOR` | author label recorded in each cassette line — tells the agent's calls from the operator's |
| `SHTURMAN_BEZ_KASSET` | `1` turns cassette recording off |
Separately: the standard OS variables `XDG_CONFIG_HOME` (macOS, Linux) and
`LOCALAPPDATA` (Windows) move the directory where the token file lives — they are
not this server's variables, but they change where it looks.
**OAuth applications.** `CLIENT_IDS` in the source are empty **by design and
stay that way** (decision R30): the publisher registers no applications and
takes part in no access contour — the server is fully self-hosted. `login`
takes `SHTURMAN_DIRECT_CLIENT_ID` / `SHTURMAN_METRIKA_CLIENT_ID` /
`SHTURMAN_AUDIENCE_CLIENT_ID`, the client id of an application you registered
at `oauth.yandex.ru` yourself. A client id is public by construction; there is
no client secret anywhere — the PKCE flow needs none.
---
## Install
Nothing to build and nothing to install: the server is Python standard library
only, so a clone is a working copy.
```bash
git clone https://github.com/seer22/shturman-direct-mcp.git
cd shturman-direct-mcp
python3 -m unittest discover -s tests -t . # optional, a few seconds
```
**1. Get the documentation it checks itself against** — no account needed:
```bash
python3 -m shturman_direct docs --fetch
python3 -m shturman_direct docs
# 54 limits, 254 citations across 9 knowledge files, 92 pages — all present and consistent.
```
Hit a 429 or a network error — wait and re-run the same command; without the
downloaded corpus the citation checks are honestly marked as skipped, which is
not a breakage.
**2. Store a credential for your Direct account.** The browser login flow has
one prerequisite: the client id of your own OAuth application. Register it
first, then run the command.
**Registering your own OAuth application** — one form, once. This is the
product's model, not a stopgap: the server is fully self-hosted, and no
publisher application will ever ship (decision R30):
1. Open `oauth.yandex.ru` → *Create application*.
2. Type — **"for API access"**. The type cannot be changed later: a mistake
means registering again.
3. Permissions: for Direct — `direct:api` and `passport:business`; for
Metrika — a **separate** application with `metrika:write`; for Audience —
a third separate application with exactly the two Audience checkboxes.
Separate for a reason: changing an application's scopes revokes every
token it has ever issued, so no two services may share one.
4. Redirect URI — `https://oauth.yandex.ru/verification_code`: Yandex's own
page that shows the confirmation code, which is what makes a console
login possible.
5. The ClientID from the application page goes into `SHTURMAN_DIRECT_CLIENT_ID`
(for Metrika — `SHTURMAN_METRIKA_CLIENT_ID`). The application secret is
needed nowhere: the PKCE flow does without one.
Now the login itself:
```bash
export SHTURMAN_DIRECT_CLIENT_ID="<ClientID from the application page>"
python3 -m shturman_direct login direct
```
It prints a URL, you press *Allow*, Yandex shows a short code, you paste it back.
The token is stored under your user configuration directory, readable by you
only. It is never passed through a conversation and never leaves the machine.
Already holding a token — a debug one, or one issued for another tool? Then:
```bash
python3 -m shturman_direct login direct --paste
```
takes it directly: hidden input, nothing on screen and nothing in the shell
history — an equal path, not a workaround, and the one that needs no client id
at all. The same command with `metrika`, `audience` and `searchapi` stores the
other three contours (`searchapi` is always the paste path — an Api-Key has no
OAuth flow). Every variable in one place:
[Environment variables](#environment-variables).
**What is needed from you, per contour** — the full walkthrough, every rake
named, is [docs/TOKENY.ru.md](docs/TOKENY.ru.md) (Russian):
- **Direct** — your own OAuth application, and your own access request to the
Direct API, filed in the account's API settings for that application —
a ready-to-paste template lives in [docs/TOKENY.ru.md](docs/TOKENY.ru.md);
review takes 1–7 working days, and API error 58 means "not approved yet",
not "broken". Plus an active account with at least one campaign in it
(without one, Yandex does not even open the API settings page), and the API
user agreement accepted on that page, by hand.
- **Metrika** — your own **separate** application with `metrika:write` (no
access request exists for Metrika at all). Reading needs any view access to
the counter; creating goals needs a role of **editor or higher on the
counter itself**. The application's `metrika:write` scope and the counter
role are different things: a write token under a viewer role gets 403.
- **Audience** — a third separate application with exactly the **two Audience
checkboxes**, nothing else; no access request exists here either — the
token works at once. Issue it under the login that owns the Direct account:
segments bind to the login, and one made under another login never appears
in Direct at all.
- **Cloud (Wordstat)** — a separate Yandex ID, a billing account with a card
attached at creation, an Api-Key with the `yc.search-api.execute` scope, and
the folder id from the console address bar. These calls spend **rubles, not
units**; the checklist with the reason behind every step is in the same
document.
**3. Point your client at it.** The client starts from anywhere, not from the
clone, so the path to the clone goes straight into the connect command —
a `PYTHONPATH` buried lower in a config is the single most common way this
install fails.
Claude Code — one line, **run from the clone directory**:
```bash
claude mcp add shturman-direct -e PYTHONPATH="$PWD" -- python3 -m shturman_direct.mcp.server
```
or `.mcp.json` in your project (the path is absolute, to your clone):
```json
{
"mcpServers": {
"shturman-direct": {
"type": "stdio",
"command": "python3",
"args": ["-m", "shturman_direct.mcp.server"],
"env": {"PYTHONPATH": "/absolute/path/to/shturman-direct-mcp"}
}
}
}
```
Claude Desktop — `claude_desktop_config.json`:
```json
{
"mcpServers": {
"shturman-direct": {
"command": "python3",
"args": ["-m", "shturman_direct.mcp.server"],
"env": {"PYTHONPATH": "/absolute/path/to/shturman-direct-mcp"}
}
}
}
```
Codex — `~/.codex/config.toml` or `.codex/config.toml`:
```toml
[mcp_servers.shturman-direct]
command = "python3"
args = ["-m", "shturman_direct.mcp.server"]
env = { PYTHONPATH = "/absolute/path/to/shturman-direct-mcp" }
```
> 🔴 **The `env` block carries `PYTHONPATH` and never a token.** Putting a
> token in a client config is how credentials leak in this ecosystem — tens of
> thousands have been found in MCP configuration files on public GitHub. This
> server reads its own file.
**4. Check it answers**, without a client:
```bash
python3 -m shturman_direct.mcp.server --list
```
---
## Security
The server runs locally with your permissions and holds a credential for an account
that spends money. Please read [SECURITY.md](SECURITY.md) before installing.
## License
MIT — see [LICENSE](LICENSE).
TDQS
Scored across 11 tools
Each tool targets a distinct operation or API: status, Wordstat, Direct read/plan/apply/report, media attach, and separate Metrika and Audience read/write pairs. The two-phase plan/apply design and explicit credential separation make misselection unlikely.
All tools share the direct_ prefix and snake_case, which gives the set a recognizable family resemblance. However, the second part mixes nouns, verbs, and domain suffixes—read/plan/apply versus status/wordstat/metrika_goals—so it is not a strict verb_noun pattern.
11 tools is well within the appropriate range for a server spanning Direct, Wordstat, Metrika, and Audience. Each tool covers a broad surface—direct_read alone handles all reading methods—so none feels redundant or superfluous.
The set provides read, write, report, and media paths across several APIs, and the direct_plan/direct_apply pattern covers arbitrary Direct writes. However, starting ads and unarchiving are explicitly absent, and Audience/Metrika writes are limited to one goal type and one geo-segment path, leaving notable lifecycle gaps.