marketing-stack
An MCP server that provides a unified query layer over terminology, SEO/GEO analytics, and company facts, enabling Claude Code to access and reason over data previously scattered across JSON files and reports.
README
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.
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
- Server entry in the project
.mcp.json(start command plus environment variables). Template:.mcp.json.example. - Approval via
enabledMcpjsonServersin the user settings — otherwise a project server stays permanently "pending approval". - Binding to the start command via
allowedMcpServersusingserverCommand, notserverName.
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.
- 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. - 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". - DE/EN duplication. Many terms exist twice, as a DE and as an EN source
entry.
term_check_textconsolidates per position: same position plus same target yields one message; several synonyms mapping to the same approved form yieldEINDEUTIG; only genuinely different targets remainMEHRDEUTIG. term_check_textfinds 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.facts_for_urlmatches by substring. Short product abbreviations can hit URLs unrelated to the product. Every match therefore returns the matched abbreviation asmatchedProduct.EXCLUDE_URLSis 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.nullforsupportFact/volumeFactmeans "no claim permitted" — not "unknown" and not "look it up elsewhere". The server never fills such fields.- AI impressions exist for the pilot pages only (split DE/EN). For any other page the answer is "not tracked" — not 0.
- Empty
ranking_anomaliesmeans "no anomaly", not "data missing". - Local files only. No network calls, no writes outside the project directory.
- 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://guardrailsdoes not quietly return an empty list but states the gap explicitly — the rule "name no customers" remains in force, plus a warning on stderr. - 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.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。