prompteye-mcp
Official# prompteye-mcp
An MCP server for [PromptEye](https://prompteye.com) — how visible a brand is inside the
answers AI assistants give, and the content that changes it.
A client picks a project, then works with the prompts it is tracked on: which questions are
being asked, how they are grouped and filed, and which ones PromptEye suggests adding next. For
the prompts where the brand is weak, it starts content generation, so the whole visibility loop
is reachable from here: track, generate content, measure.
The server needs the API URL of the deployment, and every call needs a PromptEye API key.
Both are at [app.prompteye.com/integrations](https://app.prompteye.com/integrations). Over
stdio the key comes from the environment, one key per process; over HTTP each request carries
its own key, so one hosted server serves many users (see [Hosted / HTTP mode](#hosted--http-mode)).
## Knowing what to do with it
"I connected it — now what?" is the first question a user asks, and a list of sixteen tools does
not answer it. Three things answer it instead:
- **Server instructions** (`src/instructions.ts`) reach the host at connection time, before any
call. They lay out the order the product works in — project, brand description, prompts,
measurement, content — and say plainly what cannot be done through the API, so nobody is
promised a button that is not there, and nobody is told PromptEye cannot generate content.
- **The help center** (`src/help/`, `src/tools/help.ts`) is PromptEye's knowledge base of guides
on how the product works. Its complete corpus is `https://app.prompteye.com/help/llms-full.txt`;
`read_full_help_knowledge_base` exposes it to hosts, while `list_help_articles` and
`read_help_article` locate and retrieve individual Markdown guides. Server instructions tell the
model to check relevant articles in the full corpus before answering, cite their titles and avoid
guessing. The host is fixed in `src/help/help.ts`, and `read_help_article` only accepts Markdown
paths under `/help/raw/` on it.
- **Prompts** (`src/prompts.ts`) are the workflows, surfaced by hosts as slash commands:
`visibility_review`, `what_to_track_next`, `own_the_narrative` and `onboard_brand`.
## Tools
Every one of these calls the PromptEye API.
| Tool | Endpoint |
|---|---|
| `get_account` | `GET /v1/me` |
| `list_workspaces` | `GET /v1/workspaces` |
| `list_projects` | `GET /v1/projects` |
| `create_project` | `POST /v1/projects` |
| `get_knowledge_base` | `GET /v1/projects/{projectId}/knowledge-base` |
| `list_categories` | `GET /v1/projects/{projectId}/categories` |
| `create_category` | `POST /v1/projects/{projectId}/categories` |
| `list_prompts` | `GET /v1/projects/{projectId}/prompts` |
| `get_prompt` | `GET /v1/projects/{projectId}/prompts/{promptId}` |
| `list_prompt_groups` | `GET /v1/projects/{projectId}/groups` |
| `list_prompt_suggestions` | `GET /v1/projects/{projectId}/prompt-suggestions` |
| `accept_prompt_suggestion` | `POST /v1/projects/{projectId}/prompt-suggestions/{suggestionId}/accept` |
| `get_prompt_suggestion_availability` | `GET /v1/projects/{projectId}/prompt-groups/{groupId}/suggestions/availability` |
| `generate_prompt_suggestions` | `POST /v1/projects/{projectId}/prompt-groups/{groupId}/suggestions/generate` |
| `add_prompts` | `POST /v1/projects/{projectId}/prompts` |
| `upsert_prompt_group` | `POST /v1/projects/{projectId}/groups`, or `PATCH …/groups/{groupId}` with a `groupId` |
| `delete_prompt_group` | `DELETE /v1/projects/{projectId}/groups/{groupId}` — empty groups only |
| `create_report` | `POST /v1/reports` — **public, no key**, identified by `agencyId` |
| `get_report_integration` | `GET /v1/me` — the agency id and endpoint to post a form to |
| `list_reports` | `GET /v1/reports` |
| `get_report` | `GET /v1/reports/{reportId}` |
| `create_content_brief` | `POST /v1/content/briefs` |
| `get_content_brief` | `GET /v1/content/briefs/{briefId}` |
| `create_article` | `POST /v1/content/briefs/{briefId}/article` |
| `save_article` | `PUT /v1/content/briefs/{briefId}/article` |
| `list_articles` | `GET /v1/content/articles` |
| `publish_article` | `POST /v1/content/articles/{articleId}/publication` |
| `get_article` | `GET /v1/content/articles/{articleId}` |
| `list_sources` | `GET /v1/projects/{projectId}/sources` |
| `get_prompt_answers` | `GET /v1/projects/{projectId}/answers` |
| `list_source_pages` | `GET /v1/projects/{projectId}/sources/pages` |
| `list_competitors` | `GET /v1/projects/{projectId}/competitors` |
| `get_integrations_status` | `GET /v1/projects/{projectId}/integrations/status` |
| `get_google_status` | `GET /v1/projects/{projectId}/traffic/google/status` |
| `get_search_performance` | `GET /v1/projects/{projectId}/traffic/google/search`, `…/search/queries`, `…/search/pages` |
| `get_ai_traffic` | `GET /v1/projects/{projectId}/traffic/google/analytics`, `…/analytics/sources`, `…/analytics/pages` |
| `list_bot_visits` | `GET /v1/projects/{projectId}/traffic/events` |
| `count_bot_visits` | `GET /v1/projects/{projectId}/traffic/events/count` |
| `get_crawl_health` | `GET /v1/projects/{projectId}/traffic/health` |
| `list_crawls` | `GET /v1/projects/{projectId}/traffic/crawls` |
| `get_sitemap` | `GET /v1/projects/{projectId}/traffic/sitemap` |
| `create_brand_analysis_run` | `POST /v1/projects/{projectId}/analysis/runs` |
| `get_brand_analysis_availability` | `GET /v1/projects/{projectId}/analysis/availability` |
| `get_brand_analysis_run` | `GET /v1/projects/{projectId}/analysis/runs/{runId}` |
| `create_audit` | `POST /v1/audits` |
| `get_audit_usage` | `GET /v1/audits/usage` |
| `get_audit` | `GET /v1/audits/{auditId}` |
| `create_topical_map` | `POST /v1/projects/{projectId}/maps` |
| `list_topical_maps` | `GET /v1/projects/{projectId}/maps` |
| `get_topical_map` | `GET /v1/projects/{projectId}/maps/{mapId}` |
| `regenerate_topical_map_cluster` | `POST /v1/projects/{projectId}/maps/{mapId}/regenerate` |
| `report_missing_capability` | `POST /v1/feedback` — only after the user agrees to send it |
Periods default to the last 30 days and are capped at 366 — except the bot traffic, which the API
reads a month at a time, so `list_bot_visits`, `count_bot_visits` and `get_crawl_health` cap theirs at 31 days.
### The bot traffic
One tool per endpoint: `list_bot_visits` is the evidence, `count_bot_visits` the totals the API
computed, `get_crawl_health` the API's scored verdict on them, `list_crawls` what each bot has ever
fetched, `get_sitemap` what the site offers for reading. Paths in the last two are in the same form, so comparing them is the caller's job — the
tools do not join anything.
Every request carries `verified`, which says whether the origin checked out as the bot it names. A
`User-Agent` is free text and the API has no filter for it, so `list_bot_visits` prints the flag on
every row and both descriptions say a count is an upper bound. No tool drops a row or adjusts a
figure on its own.
### Zeros from a missing integration
A project with nothing connected answers the Google and bot traffic endpoints with zeros and empty
lists, which reads exactly like a site nobody visits. `get_integrations_status` reports Search
Console, Google Analytics, the bot tracker and the sitemap in one call, and the descriptions of
`get_search_performance`, `get_ai_traffic`, `list_bot_visits`, `count_bot_visits` and `list_crawls`
tell the model to read it before reporting a zero as a finding.
### Google's own figures
`get_search_performance` and `get_ai_traffic` report the period's totals, and `by` ranks it
instead: `query` or `page` for Search Console, `source` or `page` for the sessions Analytics
attributes to AI assistants. One tool per pair of endpoints rather than one per endpoint, so the
tool list stays readable.
These count people who arrived, where visibility counts answers that named the brand — and they
undercount by design, since an assistant that names a brand without linking it sends nobody. Both
integrations are bound to the project in the PromptEye app, and a project with nothing bound
answers with zeros and empty lists, which reads exactly like a site nobody visits. So an empty
reading makes one extra call to `…/traffic/google/status` and says which of the two it was.
### Content generation
PromptEye generates content as well as measuring visibility, and the two make one loop: track the
prompts, generate an article for the ones where the brand is weak, then read whether that
prompt's visibility and citations move.
`create_content_brief` starts it for a project. It orders a brief — a title and an H2/H3
outline — for an article that targets one prompt; passing `promptId` links the brief to a tracked
prompt, which is what later measures the article. The brief comes back `processing`, and
`get_content_brief` reads it until it is `ready`: the outline, the fan-out phrases it covers and the
phrases that deserve an article of their own.
`create_article` writes the article from a `ready` brief. It comes back `processing` and takes a few
minutes; `get_article` reads the Markdown text until the status is no longer `processing`, and
`list_articles` lists a project's articles without their text. Once the user has published it,
`save_article` stores text written outside PromptEye (for example by the user's own assistant) as the
article of a `ready` brief, without generating anything. `publish_article` records the live URL and marks it `published`. Requesting indexing and following
citations are done in the PromptEye app under [Content](https://app.prompteye.com/content), and the
server instructions say so.
### Public reports
Next to tracking sits PromptEye's lead magnet, sold to agencies white-label: a prospect fills in
a form, gets a visibility report branded as the agency, and becomes a lead. `create_report`
generates one — it is the **only call that sends no API key**, because that endpoint is public
and books the report to the account named by `agencyId`, spending that account's quota. That id is
the id of the account the configured key belongs to, so nothing is asked for: `create_report`
reads it from the account itself. A report for the same domain within 30 days is re-sent rather
than rebuilt, and the tool says which happened. `list_reports` and `get_report` read them back
with the key, including every request to be contacted from the report page.
`get_report_integration` answers the other half of it — how an agency posts its own form straight
to the endpoint. It returns the agency id, `POST {base URL}/v1/reports`, a filled-in example body,
a cURL line and the request typed out, ready to hand to a developer. The snippet carries no API
key, which is what makes it safe in a browser.
`list_prompt_suggestions` is the way to add prompts: PromptEye generates them from real
demand and from how people actually put questions to assistants; `generate_prompt_suggestions`
asks for a new set for a group and `accept_prompt_suggestion` turns one into a tracked prompt.
`add_prompts` tracks hand-written prompts instead, skipping that, so it says as much in its own
description and requires `confirmBypassPromptIntelligence: true`.
## The branded pages
`list_prompts`, `list_competitors` and `list_sources` are MCP App tools: a host that
supports UI renders a PromptEye-branded page beside the text — the mark, the orange the
mark is drawn in, warm neutrals, and ranked bars with the project's own brand or domain
picked out. The prompts page adds headline tiles and a chip per prompt for its status,
business priority and categories.
One shell carries the brand and both handshakes, and each widget contributes only its
`render()`; `src/widgets.ts` composes them, so the brand lives in one file rather than
copied into each page.
| Host | How the page gets its data |
|---|---|
| Claude | The MCP Apps handshake over `postMessage`: `ui/initialize`, then `ui/notifications/tool-result` |
| ChatGPT | The Apps SDK: `window.openai.toolOutput`, refreshed by the `openai:set_globals` event |
Both paths call the same `render()`, so a widget is written once. Tools carry the resource
uri under `_meta.ui.resourceUri` for Claude and `_meta["openai/outputTemplate"]` for the
Apps SDK, and the resource is served as `text/html;profile=mcp-app`. ChatGPT also expects
its own `text/html+skybridge` mime, which would be a second registration of the same page —
worth adding only once the server is actually reachable as a ChatGPT connector.
## Running it
```bash
npm install
cp .env.example .env # put the API URL in it; the key too, for stdio
npm run build
npm start # stdio — Claude Desktop, Cursor; key from PROMPTEYE_API_KEY
npm run start:http # Streamable HTTP on http://localhost:3000/mcp; key per request
npm test
```
`npm run start:http` needs only `PROMPTEYE_API_BASE_URL` (and `PORT`, optionally). It never
reads `PROMPTEYE_API_KEY`.
During development, `npm run dev` and `npm run dev:http` watch and reload.
### Hosted / HTTP mode
The HTTP server holds no key of its own. Each request brings the caller's PromptEye API key in
one of three headers — `X-PromptEye-Key` or `X-API-Key` wins over `Authorization`, so a client
that also sends a bearer token of its own still reaches the key:
```http
Authorization: Bearer pe_live_…
X-PromptEye-Key: pe_live_…
X-API-Key: pe_live_…
```
What happens with it:
- **Verified on every request.** A request is answered only after `GET /v1/me` on the
PromptEye API has accepted the key; an accepted key is remembered for a minute, so requests
that follow closely do not call it again, and a `401` from the API to a tool call forgets it. A key PromptEye rejects gets `401`
with a `WWW-Authenticate: Bearer` challenge; a key PromptEye throttles gets `429` with its
`Retry-After`; a PromptEye API that cannot be reached gets `503` with `Retry-After`. A request
without a key gets `401` before anything else is looked at, and a request that is
not a `POST` gets `405` before the key is looked at.
- **No sessions.** The server is stateless: every `POST` gets its own `McpServer` and API
client built from the key it carries, and nothing is kept between requests. No
`Mcp-Session-Id` is handed out, one sent by a client is ignored, and a request does not need
an `initialize` before it. Nothing can expire mid-conversation, and nothing leaks between users.
- **Rate limits.** `MCP_RATE_LIMIT_PER_KEY` requests a minute per key and
`MCP_RATE_LIMIT_PER_IP` per client address, answered with `429` and `Retry-After` when
exceeded. `MCP_RATE_LIMIT_AUTH_FAILURES` counts the keys PromptEye rejected per client
address; past it, every request from that address gets `429` until the minute is up, a
valid key included, so a script guessing keys stops costing calls to PromptEye. `Retry-After`
from the PromptEye API itself is passed on to the model in the tool error text.
- **Logs** are one JSON line per request on stdout — method, path, status, duration, the
JSON-RPC method and the tool name for `tools/call`, and a SHA-256 fingerprint of the key.
Never the key, never headers, never arguments. `LOG_LEVEL=error` keeps only failures.
- **`GET /healthz`** (and `GET /`) answer `{ "status": "ok", "server": { "name", "version" } }`
and nothing about the deployment.
Pointing a client at it:
```json
// .cursor/mcp.json
{
"mcpServers": {
"prompteye": {
"url": "http://localhost:3000/mcp",
"headers": { "Authorization": "Bearer pe_live_…" }
}
}
}
```
```bash
claude mcp add --transport http prompteye http://localhost:3000/mcp \
--header "Authorization: Bearer pe_live_…"
```
#### Claude.ai and ChatGPT (OAuth)
Add a custom connector with the URL `https://mcp.prompteye.com/mcp`, sign in to PromptEye
when prompted and approve access. No key is pasted. The server publishes
`/.well-known/oauth-protected-resource` (RFC 9728) pointing at PromptEye's authorization
server and answers an unauthenticated request with `401` and a `WWW-Authenticate` header that
links to it. Enable it by setting both `MCP_PUBLIC_URL` and `MCP_AUTHORIZATION_SERVER`
(startup fails if only one is set).
The OAuth access token is forwarded to the PromptEye API like an API key; the API verifies
it. A refreshed token is simply the key of the next request; no session has to be started
again. `pe_live_…` keys keep working, which is the method for Claude Desktop (`.mcpb`), Cursor
and Claude Code. If the API answers `401` to a tool call (the token expired), the next request
is challenged with `401` so the client refreshes its token. With OAuth the per-key rate limit
follows the token, so it resets when the client refreshes (the per-IP limit still applies).
Set `MCP_PUBLIC_HOSTS` to the `Host` values the server is reachable under and
`MCP_ALLOWED_ORIGINS` to the browser origins allowed to call it; together they are the
DNS-rebinding protection. Requests without an `Origin` header (Cursor, Claude Code, curl) are
never affected by the origin list. Behind a reverse proxy, set `MCP_TRUST_PROXY_HOPS` to the
number of proxies in front of the server (Cloud Run: `1`) so `X-Forwarded-For` is read that
far and the per-IP limit counts clients rather than the proxy; at the default `0` the header
is ignored.
### Claude Desktop
`npm run bundle` packs the server into `build/prompteye-mcp.mcpb`. Install it by
double-clicking the file, dragging it onto the Claude Desktop window, or through Settings →
Extensions → Advanced settings → Install Extension. The install form asks for both settings:
- **PromptEye API key** — `pe_live_…`, with the `api_access` scope. Kept in the operating
system's keychain.
- **API base URL** — the API URL of the deployment that key belongs to.
Both are at [app.prompteye.com/integrations](https://app.prompteye.com/integrations).
After changing either, disable and re-enable the extension so the server restarts with them.
The server logs which deployment it talks to — never the key — to
`~/Library/Logs/Claude/mcp-server-PromptEye.log`.
Pushing a `v*` tag builds the bundle in CI and attaches it to the GitHub release
(`.github/workflows/bundle.yml`); the workflow also runs on demand.
Configured by hand instead of as a bundle:
```json
{
"mcpServers": {
"prompteye": {
"command": "node",
"args": ["/absolute/path/to/prompteye-mcp/dist/index.js"],
"env": {
"PROMPTEYE_API_BASE_URL": "https://…",
"PROMPTEYE_API_KEY": "pe_live_…"
}
}
}
}
```
## The API client
`src/api/` is a client for the PromptEye API that knows nothing about MCP and depends on
`zod` alone, so it can be published as its own package.
```ts
import { PromptEyeApi, PromptEyeApiError } from "./api/index.js";
const api = new PromptEyeApi({
baseUrl: process.env.PROMPTEYE_API_BASE_URL!,
token: process.env.PROMPTEYE_API_KEY!,
});
const account = await api.account.get();
const { data: projects } = await api.projects.list();
const project = await api.projects.get(projects[0].id);
const { data: prompts } = await api.prompts.list(project.id, { startDate: "2026-08-01" });
const { data: suggestions } = await api.promptSuggestions.list(project.id);
await api.prompts.create(project.id, [{ prompt: "best crm for agencies", groupName: "Comparisons" }]);
```
| Option | Default | |
|---|---|---|
| `token` | required | The API key, sent as `Authorization: Bearer …` |
| `baseUrl` | required | API root of the deployment the token belongs to |
| `timeoutMs` | `30000` | Request timeout |
| `fetch` | global `fetch` | Any compatible implementation |
| `headers` | `{}` | Sent with every request |
Every method also takes `{ signal }` as its last argument.
Responses are validated with `zod`: unknown fields are dropped and enumeration values added
later are accepted, while a field that changed shape throws a `ZodError`.
A non-2xx answer throws `PromptEyeApiError` with the `status` and the response `body`;
`code` and `details` are read from that body, and `message` comes from `API_ERROR_MESSAGES`,
which maps every documented error code to a human message.
## How a conversation goes
Every tool that works on a project, read or change, takes its `projectId`. The server keeps no
project between calls, so a conversation starts by finding the id and passes it every time:
```
list_projects → the projects, with their ids
list_prompt_suggestions(projectId: …)
list_prompts(projectId: …, by group or category)
```
A call that names no project is refused by its input schema. `create_project` answers the id of
what it created.
## Layout
```
src/
api/ PromptEye API client — no MCP in it, publishable on its own
index.ts stdio entry point — key and API URL from the environment
index-http.ts Streamable HTTP entry point — reads the settings, starts the server
http/
settings.ts every HTTP setting read from the environment, with its default
server.ts builds the verified-key cache, the budgets and the app from the settings, listens
app.ts the express app: wires the middleware chain — CORS, logging, JSON body, then the /mcp steps
steps.ts the /mcp steps as express handlers: IP limit, POST only, key required, key limit, error guard
mcp.ts serves one request: verifies the key, builds a server and a stateless transport for it
credentials.ts reads the key from X-PromptEye-Key / X-API-Key / Authorization, fingerprints it, verifies it
rejections.ts every refusal the app answers with — status, JSON-RPC code, message, headers
verified-keys.ts VerifiedKeys — keys the API accepted in the last minute
rate-limit.ts RequestBudget — requests per minute, per key and per client address
logging.ts one JSON line per event and per request, never a key or a header
server.ts builds one server from a ToolContext: its tools
config.ts environment, credentials, and the client factory
client/ PromptEyeClient interface, implemented over the API client
schemas/ the input shapes tools share, and the API schemas re-exported for them
tools/ one module per group of tools, plus the glossary they quote
widgets.ts composes and registers the branded pages tools render
public/
widget-shell.html the brand: mark, palette, and both host handshakes
widgets/*.js one render() per widget, dropped into that shell
manifest.json MCPB manifest — entry point, and the settings users fill in
scripts/bundle.mjs stages dist/, public/ and production deps, then packs the .mcpb
```
## Environment
| Variable | Default | Mode | Purpose |
|---|---|---|---|
| `PROMPTEYE_API_BASE_URL` | required | both | API root of the deployment |
| `PROMPTEYE_API_KEY` | required for stdio | stdio | The API key; HTTP takes it from each request instead |
| `MCP_SERVER_NAME` | `prompteye-mcp` | both | Name reported to clients |
| `MCP_SERVER_VERSION` | `version` from `package.json` | both | Version reported to clients |
| `PORT` | `3000` | HTTP | Port to listen on |
| `LOG_LEVEL` | `info` | HTTP | `info` logs every request, `error` only failures |
| `MCP_RATE_LIMIT_PER_KEY` | `120` | HTTP | Requests a minute per key |
| `MCP_RATE_LIMIT_PER_IP` | `600` | HTTP | Requests a minute per client address |
| `MCP_RATE_LIMIT_AUTH_FAILURES` | `10` | HTTP | Rejected keys a minute per client address before its initializes get `429`, valid key or not |
| `MCP_PUBLIC_HOSTS` | unset | HTTP | Comma-separated `Host` values to accept; unset accepts any |
| `MCP_ALLOWED_ORIGINS` | unset | HTTP | Comma-separated browser `Origin` values to accept; unset accepts any |
| `MCP_PUBLIC_URL` | unset | HTTP | Canonical MCP URL (e.g. `https://mcp.example.com/mcp`); with `MCP_AUTHORIZATION_SERVER` it enables OAuth discovery |
| `MCP_AUTHORIZATION_SERVER` | unset | HTTP | Issuer URL of the OAuth authorization server advertised to clients |
| `MCP_TRUST_PROXY_HOPS` | `0` | HTTP | Reverse proxies in front of the server whose `X-Forwarded-For` is trusted; `0` ignores it |
Both the API URL and the key are at
[app.prompteye.com/integrations](https://app.prompteye.com/integrations).
TDQS
Scored across 60 tools
Tools map to distinct resources and lifecycle stages, and the descriptions explicitly disambiguate overlapping measurements (search performance vs AI traffic vs sources vs answers) and artifacts (public report vs brand analysis). A few pairs still overlap in practice—get_integrations_status vs get_google_status, and the four bot-visit/crawl tools—so an agent may hesitate, but the boundaries are documented.
All tools are snake_case and verb-first, with predictable families (list_, get_, create_, update_, delete_, set_, generate_, create_/get_ for async artifacts). Minor verb variation such as read_help_article, count_bot_visits and upsert_prompt_group is semantic and consistent within each family.
60 tools is far above a manageable set for an agent, even for a broad platform. Many read/status tools could be consolidated, and the sheer size materially increases tool-selection burden.
Core workflows are well covered: project/prompt tracking, metrics, content lifecycle, audits, topical maps, public reports and help. Some lifecycle gaps remain—no project deletion/archive, no category update/delete, no article or topical-map deletion, and prompt deletion is replaced by pausing—but agents can work around them.