Skip to main content
Glama
ArcadeAI

utm-helper-mcp

by ArcadeAI
README.md
# UTM Helper MCP server

An [Arcade](https://arcade.dev) MCP server that helps teams produce **consistent
UTM-tagged links** by reading an authoritative, version-controlled UTM spec and
exposing it through MCP tools.

This repo is both the **spec source of truth** ([`GUIDE.md`](./GUIDE.md),
[`utm-spec.yaml`](./utm-spec.yaml)) and the **server** that serves it. See the
[PRD](https://github.com/ArcadeAI/utm-helper-mcp/issues/1) for the full design.

> **Status:** in progress. `get_guidelines()`, `validate_url()`, and
> `list_campaigns()` are implemented. `build_url` and `add_campaign` land in
> later issues.

## Tools

| Tool | Description |
|---|---|
| `get_guidelines()` | Returns the human-readable UTM guide (`GUIDE.md`) fetched from the configured Git spec source. |
| `validate_url(url)` | The mandatory last hop: normalizes a URL's UTM parameters against the spec and validates them, returning the normalized URL, a changelog of fixups, and any soft nudges. |
| `list_campaigns()` | Returns the known recurring campaigns, read **fresh** from the campaign registry Google Sheet (via a shared service credential). Validates the Sheet's column schema on every read. |

### `validate_url` — normalize + validate (the last hop)

`validate_url` reads the structured spec (`utm-spec.yaml`) from the configured
Git source and applies it **end-to-end** to a URL:

1. **Normalize** every UTM value deterministically (lowercase, hyphen-separated,
   no spaces; the structural `_` in `utm_campaign` is preserved). Each change is
   reported in a `changelog`.
2. **Validate**, with per-field behavior asymmetry straight from the spec:

   | Param | On unknown value |
   |---|---|
   | `utm_source` (open enum) | Emits the normalized link **+ a nudge** to add the source to the spec repo. |
   | `utm_medium` (closed enum) | **Hard refuse** (raises): shows the valid set + closest suggestion; **no link is emitted**. |
   | `utm_campaign` (sheet) | Must match the `YYYY-qN_kebab-slug` template; a malformed name **hard-errors**. Registry membership is checked once the campaign registry lands. |
   | `utm_content` / `utm_term` (free) | Always shape-normalized; never refused. |

A missing **required** parameter (`utm_source`, `utm_medium`, `utm_campaign`)
also hard-refuses. Non-UTM query params are passed through untouched.

On success it returns `{ "url", "changelog": [{param, from, to}, ...], "nudges": [...] }`.
Hard refusals and spec-source failures surface as loud tool errors — it never
falls back to a built-in spec.

## Setup (admin)

Requires Python ≥ 3.12 and [`uv`](https://docs.astral.sh/uv/).

```bash
uv sync --extra dev        # install runtime + dev deps into .venv
```

### Configuration — pointers only, via an Arcade secret

Configuration only says **where to look** for the spec; it never encodes
behavior. All behavior (enums, shape rules, casing) lives in the spec repo and
changes by reviewed PR.

The single pointer is provided as the Arcade **tool secret**
`UTM_SPEC_SOURCE_URL` — it is **not** hard-coded in the server. The tool reads it
from its injected `Context` at call time (`context.get_secret(...)`).

| Secret | Meaning |
|---|---|
| `UTM_SPEC_SOURCE_URL` | Raw base URL of the Git spec source. The server appends file names (e.g. `GUIDE.md`), so it must serve **raw** file contents. Works with GitHub raw, GitLab raw, or any self-hosted mirror. A trailing `/` is added if omitted. |
| `UTM_CAMPAIGN_SHEET_ID` | The campaign registry Google Sheet ID (the token in the Sheet URL between `/d/` and `/edit`). **Required** for `list_campaigns()`; no default registry exists, so a missing ID fails loud. |
| `UTM_CAMPAIGN_SHEET_RANGE` | A1 tab/range to read. Optional; defaults to `Campaigns!A:D`. |
| `UTM_CAMPAIGN_SA_JSON` | The full Google **service-account** key JSON. The shared server-side credential used to read the Sheet — so end users need no direct Sheet access. **Required** for `list_campaigns()`. |

### Campaign Sheet setup (admin)

The `utm_campaign` registry lives in a Google Sheet, read with a shared
service-account credential so end users need **no** direct Sheet/Google access:

1. Create a Google Cloud **service account** and download its key JSON → set as `UTM_CAMPAIGN_SA_JSON`.
2. Enable the **Google Sheets API** for that project.
3. Share the registry Sheet (**Viewer**) with the service account's email.
4. The Sheet's **first row must be exactly**: `campaign | description | added_by | added_at`.

The schema is validated on every read; a mismatched header or malformed row
fails loud with a row-numbered, actionable error (see Failure behavior).

Set it where it belongs for your environment:

- **Production:** configure it on the [Arcade dashboard](https://api.arcade.dev/dashboard),
  or with `arcade secret set UTM_SPEC_SOURCE_URL <url>`.
- **Local dev:** put it in `.env` (copy [`.env.example`](./.env.example)) — Arcade
  discovers `.env` and injects the secret into the tool `Context`.

**First run / no config:** if the secret is unset, the tool uses the opinionated
**default seed** shipped in this repo
(`https://raw.githubusercontent.com/ArcadeAI/utm-helper-mcp/main/`) and **logs a
warning**. This is the *only* case where a default is used — see Failure
behavior below.

### Run

```bash
# stdio (Claude Desktop, CLI clients)
uv run src/utm_server/server.py

# http (Cursor, VS Code)
uv run src/utm_server/server.py http   # serves on http://127.0.0.1:8000/mcp/
```

## Caching

Successful spec reads are cached **in memory for 5 minutes**
(`SPEC_CACHE_TTL_SECONDS` in [`sources.py`](./src/utm_server/sources.py)). The
spec is slow-changing (reviewed-PR cadence), so this trades a little staleness
for far fewer network round-trips. The cache is per-process and per-file; a
restart clears it. The TTL is a code constant, not configuration, to keep config
strictly pointers-only.

The **campaign Sheet is the opposite**: it churns constantly, so `list_campaigns()`
reads it **fresh on every call** (no result cache) — newly added campaigns appear
immediately. Only the `CampaignRegistry` object (HTTP client + minted token) is
reused across calls.

## Failure behavior — fail loud, never silently default

If the spec source is unreachable, returns a non-2xx status, or the file is
missing, the tool **raises a specific error** naming the URL and failure and
telling the user to contact the admin. It **never** falls back to a built-in or
stale guide — a misconfigured deploy must look broken, not quietly serve the
wrong content. The single announced exception is the first-run default seed
*source* above (which is still fetched over the network like any other source).

The campaign Sheet is human-editable, so `list_campaigns()` **defensively
validates its schema on every read**: the header row must be exactly
`campaign | description | added_by | added_at`, and each data row is checked. On
a mismatch (wrong headers, extra columns, a row missing its campaign name) it
raises a specific, **row-numbered** error ("…headers don't match… fix the header
row (row 1)…", "…row 7 is missing a campaign name…") and returns no campaigns —
it never silently guesses what a column means.

## Development

```bash
uv run pytest          # tests
uv run ruff check .    # lint
uv run mypy src        # type-check
```

TDQS

A4.2/5.0

Scored across 1 tool

Disambiguation5/5

With only one tool, there is no possibility of confusion between tools. The tool name is specific and unambiguous.

Naming Consistency5/5

Single tool ensures perfect naming consistency; the PascalCase prefix 'UtmServer_' is clear and consistent.

Tool Count1/5

A single tool that only returns guidelines is far too minimal for a UTM helper server. The domain typically requires at least URL generation and validation tools.

Completeness1/5

The tool surface is severely incomplete; it only provides guidelines but lacks any operational UTM tools such as creation, parsing, or validation.

Maintenance

ActivityInactive
ResponsivenessUnresponsive