Skip to main content
Glama
Reppesgaard-Ninja

marketing-stack

README.md
# marketing-stack

**An orchestration layer built on Claude Code and MCP.** One entry point, three
namespaces, nine tools — over data that previously lived in scattered JSON files,
logs and weekly report emails. Anyone using the server needs no knowledge of the
directory layout. No SSH, no reading JSON by hand.

*[Deutsche Fassung](README.de.md)*

Three bodies of data are connected:

- **Terminology** — lookup and prose checking against an approved DE-EN glossary
- **SEO / GEO** — search performance, ranking decay, share of answer in AI
  responses, opportunities, competitors, change history
- **Foundation files** — binding company facts and the blocklist of
  impermissible claims, exposed as MCP resources and therefore in effect in
  every session

The governing rule across the whole server: **no figure without its date, and no
absence that could be mistaken for a measurement.**

## At a glance

| | |
|---|---|
| Protocol | MCP over stdio, JSON-RPC 2.0 |
| Implementation | `@modelcontextprotocol/sdk`, Node.js |
| Scope | 3 namespaces, 9 tools, 2 resources |
| Client | Claude Code |
| Upstream data source | a custom-built SEO/GEO bot (Node.js, process management via PM2, seven independent detectors) integrating Google Search Console, the Webflow CMS API and four LLM APIs |
| Write path | none outside the project directory |
| Network at runtime | none |
| Open ports | none |
| Secret access | none — the server never reads `.env` |

The API integrations live in the upstream bot, not in this server. At runtime the
server calls nothing; it reads local files and formats the result. That
separation is deliberate and part of the security model.

## Context

This repository is an extract from a system running in production, not a sample
project. The server runs under Claude Code on an Ubuntu VPS and reads the output
of a custom-built SEO/GEO bot that analyses a company website, generates
suggestions, and writes them once approved.

What is published is the transferable layer: server logic, tool boundaries,
security model, limits. Not published: operational data, installation-specific
paths, and the blocklist containing reference customer names — that file is
loaded at runtime from outside the repository (see Limits, item 11).

## The problem it solves

The upstream bot produces data continuously. Seven detectors write independently
into their own files and each sends its own report. No layer brings their results
together.

So the question "how is this one page doing?" used to cost: log in, locate the
most recent file across several directories, read JSON by hand, search the change
log for the last write, assemble the values mentally. Five files for one question
— manual work nobody repeats across dozens of pages.

`seo_page_visibility` consolidates nine signals from five sources, each with its
source and data date, and explicitly names what is **not** available for that
page. The difference is not convenience but scalability: manual work becomes a
question.

What the server explicitly does **not** replace: the dashboard and the periodic
reports. Those answer questions defined weeks ago. This server answers questions
that come up now.

## Foundation files as MCP resources

The fact base and the blocklists previously took effect only **inside** the bot:
when generating meta descriptions and during the fact check before every write. A
hand-written post, an ad, a slide deck — none of them were covered.

Exposed as MCP resources (`facts://company`, `facts://guardrails`) they apply in
every Claude session without anyone pasting them in. The rule set that prevents a
silver partner status from quietly becoming gold, or a certification number from
being invented, now takes effect where the text is actually written — not only in
the one system that knows about it.

The mechanism is a one-way export, not a reimplementation: the bot's source
modules are exported daily as **data** (JSON, text extract), never loaded via
`require`. When the source changes, the resource changes on the next run. If the
export fails, the previous file stays in place **and the run reports an error** —
rather than silently serving an empty list.

Hence the project's hardest rule: **the server must never become a second source
of truth.** Where logic exists in the production system it is mirrored here, not
reinvented. Where mirroring is impossible, the gap is named in the output itself
(see Limits, item 6).

## The architectural decision: mirror, not direct access

The server does **not** read from the production directories but from a mirrored
copy.

```
  ┌──────────────────────────────────┐
  │  production SEO/GEO bot          │   runs as root
  │  data/  changes.log  lib/        │   seven detectors, writing continuously
  └────────────────┬─────────────────┘
                   │
                   │  mirror cron, daily 09:00, runs as root
                   │  rsync (copies) + exports (JS modules → JSON)
                   ▼
  ┌──────────────────────────────────┐
  │  mirror                          │   owned by the unprivileged user
  │  data/  changes.log              │
  │  companyFacts.json  guardrails   │
  └────────────────┬─────────────────┘
                   │  read-only
                   ▼
  ┌──────────────────────────────────┐      ┌──────────────────────────┐
  │  marketing-stack (this server)   │◀─────┤  terminology glossary    │
  │  server.js + lib/                │ read │  (JSON)                  │
  └────────────────┬─────────────────┘      └──────────────────────────┘
                   │  stdio / JSON-RPC 2.0
                   ▼
             Claude Code
```

The dependency runs strictly one way: **the MCP server depends on the bot; the
bot does not depend on the MCP server.** There is not one line in the bot that
refers to this server.

**The rejected alternative** was direct access to the production directories —
always current, no mirror needed. The price would have been an MCP process
running with root privileges alongside a production system, and Claude Code
having to operate as root. A bug in the server could then have reached production
data — even with read-only intent.

The mirror was chosen instead. The production system is therefore structurally
untouchable: not because the server code behaves well, but because the process
cannot reach those files with its privileges.

**The price is data age** — up to 24 hours. So that this never goes unnoticed,
every signal states its data date, derived from the filename and falling back to
`mtime` only when there is no alternative. Past a threshold, every tool response
is prefixed with a staleness banner. A second effect of the same decision: a
detector writing in the middle of a query cannot hand over a half-written file.

**Failure behaviour:** if the mirror cron stops, data ages and the banners
appear. If the bot stops entirely, the server keeps answering from the last
mirror, with a banner. If the server fails, the bot does not notice. If the
server returns a wrong number, the error stays on the read path — it can cause a
bad decision, but not data damage.

## Findings

A query layer over scattered data inevitably exposes divergences between
documentation and reality. Two of them on the day it was finished:

### 535 self-contradictions in an approved glossary

A curated corpus of 4,838 terms was considered complete after seven review
rounds. This server's checking function found 535 terms held **simultaneously**
as a rejected variant and as an approved equivalent.

The cause is structural and transferable: the automated QA pass checks the
glossary against itself in blocks of 30 terms. Across 4,838 entries that is
roughly 160 blocks. Two contradictory entries sitting in different blocks are
never compared — the probability that two given entries land in the same block is
below one percent. **A block-wise model call cannot form a corpus-wide set
intersection.** The human rounds could not find it either, because all of them
worked on subsets: each reviewer saw one correct entry and decided correctly. The
contradiction exists only in the aggregate view.

This is not an argument against the quality of the curation but for a missing
layer of verification. The server introduced it without that being its purpose —
and deliberately does **not** resolve the finding: the corpus is a record of
review, and a script that quietly rewrites approved entries makes it impossible
afterwards to distinguish what a human verified from what an automation asserted.
The server classifies, counts and documents it as a property of the data source —
surfaced as the class `WIDERSPRUCH` (contradiction).

### A module path that had survived three revisions of the documentation

The fact base was consistently documented under a path that does not exist; the
file sits in a different directory. It surfaced because a mirror command pointed
at the documented path and **failed silently** — precisely the class of error the
whole server is built against. A second module, the only consumer of that fact
base, appeared in no module overview.

A third finding concerned a locale asymmetry: two visibility signals for the same
page pointed in opposite directions. In an aggregate figure both would have been
invisible — and that is exactly how they had been reported. Both signals are now
separated by language.

## Namespaces and tools

### Terminology

**`term_lookup(term, richtung)`** — Looks up a term. `richtung`: `de-en`, `en-de`
or `auto` (default; searches both directions, because the glossary holds both DE
and EN source entries). Returns the binding translation, rejected variants,
rationale, and do-not-translate status.

**`term_check_text(text, modus)`** — Checks DE/EN prose against the glossary.
Every hit is classified; the order of checks is binding, because each class
implies a different action:

| Class | Meaning | What the author can do |
|---|---|---|
| `WIDERSPRUCH` | rejected **and** approved at once | nothing — this needs a terminology review |
| `VERWORFEN` | held only as a rejected variant | replace with the stated equivalent |
| `MEHRDEUTIG` | several distinct approved targets | decide by context |
| `EINDEUTIG` | exactly one target, used correctly | nothing (shown only with `modus: alle`) |

`modus`: `probleme` (default — problem classes only, plus a count of suppressed
hits) or `alle`.

Deliberately **not** built in: a minimum-length or stopword filter. It would
silently swallow real findings such as `approval` or `documents`. Noise is
reduced through precision only — word boundaries, longest match per position,
consolidation of identical targets — never through suppression. A checking tool
that stays quiet is worse than one that says too much.

**`term_do_not_translate()`** — All protected names that must remain unchanged.

### SEO / GEO

**`seo_page_visibility(seite)`** — The centrepiece. Consolidates nine signals for
one page (slug or substring) from five sources: search performance, impressions
without clicks, decay, current rankings, anomalies, AI impressions (pilot pages
only, split DE/EN), mentions per AI engine with prompt language, meta status and
provenance (bot or manual), and the last write. Every line names its source and
data date; where a signal is missing it reads "no data in \<source\>".

**`geo_status(wochen)`** — Share of answer per AI engine across the last N runs,
separated by language (DE / EN / overall), trend against the previous run, prompts
won and lost.

**`seo_opportunities(typ)`** — Opportunities by type (`striking_distance`,
`impressions_no_clicks`, `decay`), sorted by an explicitly named field, top 15
with the total count stated.

**`geo_competitors(name)`** — Competitors appearing in AI answers, with trend.
Without `name`: all competitors in the latest run by frequency. With `name`: a
time series across all runs.

**`seo_change_history(seite)`** — What the bot wrote, when, and on which page.
Without `seite`: the last 20 changes.

### Foundation files

**`facts_for_url(url)`** — Maps a URL to a business unit (substring match on
product abbreviations, first match wins; no match → `null`, and then **no**
facts). Returns the matched abbreviation as `matchedProduct` so that a false
positive becomes visible instead of invisible.

**Resources:** `facts://company` (unit facts and usage rules) and
`facts://guardrails` (prohibited claims).

## Security model

| Guardrail | Implementation |
|---|---|
| Strictly read-only | No write outside the project directory. Source files are opened for reading only. |
| No guessed paths or field names | Every file was read before code processed it. The basis is a preceding schema survey, not an assumption about structure. |
| No secrets | No access to `.env`. A scan across all source files returned nothing. |
| No network calls at runtime | The server reads local files. No outbound calls. |
| No execution of bot code | Fact base and blocklists are exported as **data**, never loaded via `require`. |
| Privilege separation | What root owns stays with root. The server process cannot reach the production files. |

### Registration in Claude Code

1. **Server entry** in the project `.mcp.json` (start command plus environment
   variables). Template: `.mcp.json.example`.
2. **Approval** via `enabledMcpjsonServers` in the user settings — otherwise a
   project server stays permanently "pending approval".
3. **Binding to the start command** via `allowedMcpServers` using
   **`serverCommand`, not `serverName`.**

Item 3 matters most: `serverName` is a freely chosen label and **not a security
control**. Anyone who knows the name can assign it. Only binding to the concrete
`serverCommand` guarantees that the vetted start command is what actually runs.

Approval in the user settings remains self-imposed: whoever set it can loosen it.
Real enforcement only exists once the rule sits somewhere the executing user
cannot reach — a managed policy with `allowManagedMcpServersOnly`. Allowlists
from user, project and local settings are then ignored, while denylists continue
to apply from all sources. For single-user operation this is not operationally
necessary; it turns "I abide by my own allowlist" into "policy and execution are
separate".

## Limits

This is the most important section of the documentation. One principle runs
through it: **anywhere an absence could look like a measurement, it is labelled
explicitly.** Not tracked rather than 0. No anomaly rather than data missing. No
claim permitted rather than unknown. Partial list rather than complete list.
Blocklist unavailable rather than empty blocklist.

1. **Data age.** The mirror is refreshed daily. Every signal states its data date
   (from the filename, otherwise `mtime`). Once the newest data date passes the
   threshold, every tool response is prefixed with a staleness banner.
2. **Contradictory glossary.** A substantial share of terms is held
   simultaneously as a rejected variant and as an approved equivalent (reported
   as a concrete number at startup). This is a property of the data source, **not
   resolved** by the server but surfaced as the class `WIDERSPRUCH` — never
   quietly "corrected".
3. **DE/EN duplication.** Many terms exist twice, as a DE and as an EN source
   entry. `term_check_text` consolidates per position: same position plus same
   target yields one message; several synonyms mapping to the same approved form
   yield `EINDEUTIG`; only genuinely different targets remain `MEHRDEUTIG`.
4. **`term_check_text` finds only what is in the glossary.** A wrong term not
   held there as a rejected variant will not be detected. The tool supplements
   review; it does not replace it.
5. **`facts_for_url` matches by substring.** Short product abbreviations can hit
   URLs unrelated to the product. Every match therefore returns the matched
   abbreviation as `matchedProduct`.
6. **`EXCLUDE_URLS` is only a partial list.** Further exclusions (locale
   variants, utility pages, strategic exceptions) live outside the mirror and are
   unavailable to this server. Only what is in the file is reported.
7. **`null` for `supportFact` / `volumeFact` means "no claim permitted"** — not
   "unknown" and not "look it up elsewhere". The server never fills such fields.
8. **AI impressions exist for the pilot pages only** (split DE/EN). For any other
   page the answer is "not tracked" — **not 0**.
9. **Empty `ranking_anomalies` means "no anomaly"**, not "data missing".
10. **Local files only.** No network calls, no writes outside the project
    directory.
11. **The reference customer blocklist is not part of this repository.** It is
    loaded at runtime from a separate file (path via environment variable,
    pattern in `.mcp.json.example`). If the file is absent, `facts://guardrails`
    does **not** quietly return an empty list but states the gap explicitly — the
    rule "name no customers" remains in force, plus a warning on stderr.
12. **The server must never become a second source of truth.** Where logic exists
    in the production system it is mirrored here, not reinvented. Where mirroring
    is impossible, the gap is named in the output.

## Operation

### Environment variables

| Variable | Purpose |
|---|---|
| `MARKETING_STACK_DICT` | Path to the glossary file. If unset: the newest `woerterbuch_komplett_*.json` in the terminology output directory. |
| `MARKETING_STACK_MIRROR` | Root of the SEO mirror (contains `data/`, `changes.log`, `companyFacts.json`). |
| `MARKETING_STACK_FACTS` | Optional direct path to `companyFacts.json` (otherwise derived from `…_MIRROR`). |

All source paths come from environment variables — no path is compiled in.
Concrete values for a given installation are not part of this repository;
`.mcp.json.example` serves as a template with generic paths.

### Start

```
node server.js
```

On startup the server reports on stderr, among other things, the age of the
mirror and the number of glossary contradictions. The glossary is loaded once at
startup; the SEO/GEO files are read per call.

### Health check

```
claude mcp list
```

Run as a privileged user or from another directory, this command systematically
returns nothing — and that looks exactly like a broken server. Always run it as
the user owning the mirror, and from the project directory.

### Tests

```
node test/driver.js       # stdio session across all tools
node test/diagnose.js     # diagnostics for term_check_text match logic (read-only)
```

## Data sources → tools

| Source | feeds |
|---|---|
| terminology glossary (`woerterbuch_komplett_*.json`) | `term_lookup`, `term_check_text`, `term_do_not_translate` |
| `data/striking_distance_*` | `seo_opportunities`, `seo_page_visibility` |
| `data/impressions_no_clicks_*` | `seo_opportunities`, `seo_page_visibility` |
| `data/decay_*` | `seo_opportunities`, `seo_page_visibility` |
| `data/ranking_snapshots/snapshot_*` | `seo_page_visibility` |
| `data/ranking_anomalies_*` | `seo_page_visibility` (as a signal, no separate tool) |
| `data/geo_monitor_*` | `geo_status`, `geo_competitors`, `seo_page_visibility` |
| `data/gsc_ai_baseline.json` | `seo_page_visibility` (AI impressions, pilot pages) |
| `data/site_inventory_latest.json` + `data/seo-origins.json` | `seo_page_visibility` (meta status, provenance) |
| `changes.log` | `seo_change_history`, `seo_page_visibility` |
| `companyFacts.json` | `facts://company`, `facts_for_url` |
| `companyFacts_source.txt` | `facts://company` (rule texts) |
| `factcheck_guardrails.txt` | `facts://guardrails` |

No tool was built on a guessed data shape.