zevian-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zevian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # Zevian for AI coding agents
2
+
3
+ `zevian-mcp` connects your AI coding agent (Claude Code, Cursor, VS Code) to [Zevian](https://zevian.tech).
4
+ Ask your agent to audit your site, check a page on **localhost** before you deploy it, and fix what it finds,
5
+ all without leaving your editor.
6
+
7
+ - **Audit** a connected site, or one page of it, and read the issues (errors, warnings and info).
8
+ - **Check a page before you deploy.** Your agent fetches localhost, staging or any unpublished page from
9
+ *your* machine and Zevian analyzes it. Zevian never fetches your localhost.
10
+ - **Fix it two ways.** Get exact instructions your agent applies in your own repository, or have Zevian's
11
+ GitHub App open a pull request for you.
12
+ - **See what the fixes achieved** in Search Console and Bing.
13
+
14
+ ## Set up in three minutes
15
+
16
+ You need Node.js 20 or newer and a Zevian account.
17
+
18
+ 1. **Create an API key.** In the Zevian dashboard, open **Connect to your AI agent** (or go to
19
+ `app.zevian.tech/settings/api`), name the key, and choose *Read* or *Read + write*. Copy it: it is shown
20
+ once. *Read + write* is only needed to let the agent open pull requests.
21
+ 2. **Add Zevian to your agent.** Replace `zv_live_YOUR_KEY` with your key. The dashboard shows each of these
22
+ with your key already filled in.
23
+
24
+ ### Claude Code
25
+
26
+ macOS, Linux and WSL:
27
+
28
+ ```bash
29
+ claude mcp add --env ZEVIAN_API_KEY=zv_live_YOUR_KEY --transport stdio zevian -- npx -y zevian-mcp
30
+ ```
31
+
32
+ Windows (PowerShell or cmd):
33
+
34
+ ```bash
35
+ claude mcp add --env ZEVIAN_API_KEY=zv_live_YOUR_KEY --transport stdio zevian -- cmd /c npx -y zevian-mcp
36
+ ```
37
+
38
+ Then start Claude Code and type /fix-seo, or ask it to audit your site with Zevian.
39
+
40
+ ### Cursor
41
+
42
+ Save this as `.cursor/mcp.json` in your project, or `~/.cursor/mcp.json` to use it everywhere:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "zevian": {
48
+ "command": "npx",
49
+ "args": [
50
+ "-y",
51
+ "zevian-mcp"
52
+ ],
53
+ "env": {
54
+ "ZEVIAN_API_KEY": "zv_live_YOUR_KEY"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ Add the file to `.gitignore` if it is in your project, so the key is not committed. Then make sure Zevian is
62
+ switched on under Customize in the sidebar.
63
+
64
+ ### VS Code
65
+
66
+ Save this as `.vscode/mcp.json` in your project, or run **MCP: Open User Configuration** to use it everywhere:
67
+
68
+ ```json
69
+ {
70
+ "servers": {
71
+ "zevian": {
72
+ "type": "stdio",
73
+ "command": "npx",
74
+ "args": [
75
+ "-y",
76
+ "zevian-mcp"
77
+ ],
78
+ "env": {
79
+ "ZEVIAN_API_KEY": "zv_live_YOUR_KEY"
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Add the file to `.gitignore` if it is in your project, so the key is not committed. Then start the server from
87
+ the Zevian entry in the file, trust it when VS Code asks, and ask in the Chat view with an agent, so it can
88
+ call Zevian's tools.
89
+
90
+ ## Try it
91
+
92
+ Three things to ask your agent:
93
+
94
+ 1. **"Audit my site with Zevian and fix the errors and warnings."** (Or type `/fix-seo`.) The agent finds
95
+ your site, runs an audit, shows you what is wrong, and applies each fix in your repository.
96
+ 2. **"Check http://localhost:3000/pricing with Zevian before I deploy."** The agent fetches the page from your
97
+ machine, Zevian lists the problems with their fixes, and the agent can apply them.
98
+ 3. **"Open a pull request that fixes the schema issues from that audit."** The agent shows you which issues it
99
+ will fix and waits for your yes, then Zevian's GitHub App opens one pull request.
100
+
101
+ ## Tools
102
+
103
+ Your agent picks these itself. Each one tells the agent when to use it.
104
+
105
+ | Tool | Use it to | Reads or changes |
106
+ |---|---|---|
107
+ | `list_sites` | List your connected sites. The agent calls this first, to get a `site_id`. | Reads |
108
+ | `run_audit` | Audit a site (`scope: "full"`) or one page of it (`scope: "page"`, with `url`). Waits about 45 seconds, then returns an `audit_id` to follow up with `get_audit`. | Starts a crawl; changes nothing on your site |
109
+ | `get_audit` | Read an audit: status, scores, and issues worst first, filtered by `severity` (error, warning, info) or `category` (seo, geo, aeo, schema). | Reads |
110
+ | `get_fix` | Get instructions for fixing one issue: the kind of change, which files to look in, exact target values and limits. Instructions, not a diff. | Reads |
111
+ | `open_fix_pr` | Open one pull request that fixes 1 to 20 issues. The agent asks you to confirm first. Waits about 45 seconds, then returns a `pr_id`. Needs a *Read + write* key. | **Changes your repository** |
112
+ | `get_fix_pr` | Check a pull request started by `open_fix_pr`: its status, its URL once open, and why it failed if it did. | Reads |
113
+ | `check_page` | Check one page, including localhost and staging. Returns the page's issues, each with its fix. | Reads |
114
+ | `get_impact` | Search Console and Bing performance, and the before and after of each merged Zevian pull request. Paid plans. | Reads |
115
+
116
+ And one prompt, **`fix-seo`** (a slash command in Claude Code and similar agents), which audits the site,
117
+ shows you the errors and warnings, applies each fix in the current repository, and summarizes what changed.
118
+ It takes an optional `domain`.
119
+
120
+ ## What `check_page` sends, and what it never does
121
+
122
+ `check_page` fetches the page **from your machine**, follows redirects, waits up to 15 seconds, and reads at
123
+ most 2 MB of HTML. Then it sends Zevian the page's URL, status, HTML and response headers, so Zevian can
124
+ analyze it. In that step:
125
+
126
+ - **No cookies, ever.** The request to your page carries no `Cookie` or `Authorization` header, and nothing is
127
+ stored between calls.
128
+ - **Credentials are removed before anything leaves your machine.** The response headers `Set-Cookie`,
129
+ `Cookie`, `Authorization` and `Proxy-Authorization` are dropped, and so is any header whose name contains
130
+ `token`, `secret`, `session` or `key`. Zevian only reads the content type, `X-Robots-Tag` and `Link`.
131
+ - **Zevian never fetches your page.** It reads only what the package sends. It does not follow links in your
132
+ HTML or load anything your HTML points to.
133
+ - The HTML itself is sent as served. Do not check a page that shows private data you do not want analyzed.
134
+ - Only the HTML as served is checked, so content a browser draws with JavaScript is not visible. The result
135
+ says so when a page is mostly empty before JavaScript runs.
136
+
137
+ Your API key goes only to Zevian, in the `Authorization` header, and is never printed or logged.
138
+
139
+ ## Plans and limits
140
+
141
+ | | Free | Pro and Team |
142
+ |---|---|---|
143
+ | `check_page` | 20 a day | 500 a day |
144
+ | `run_audit` | 3 a day | 50 a day |
145
+ | `get_fix` | 10 a day | 300 a day |
146
+ | `open_fix_pr` | 1 a month, shared with the dashboard | The plan's monthly pull request limit, shared with the dashboard |
147
+ | `get_impact` | Not included | Included |
148
+
149
+ Daily limits reset at 00:00 UTC. When you hit one, the message says when it resets and where to upgrade.
150
+
151
+ ## Configuration
152
+
153
+ | Variable | |
154
+ |---|---|
155
+ | `ZEVIAN_API_KEY` | **Required.** Your key, from the dashboard. Without it the server still starts, and every tool tells the agent how to create one. |
156
+ | `ZEVIAN_API_URL` | Optional. Defaults to `https://app.zevian.tech`. Use it to point at staging. It must be `https://`, except for `http://localhost`, so your key is never sent unencrypted. |
157
+
158
+ Every request carries a `User-Agent` of the form `zevian-mcp/<version>`, so Zevian can tell you when a newer
159
+ version is available.
160
+
161
+ ## Troubleshooting
162
+
163
+ The messages come from Zevian and say what to do. The common ones:
164
+
165
+ | Message | What to do |
166
+ |---|---|
167
+ | Zevian API key missing. Create one at zevian.tech/settings/api | Set `ZEVIAN_API_KEY` in your agent's config for Zevian. |
168
+ | This API key is invalid or revoked. Create a new one in the dashboard | Make a new key and replace the old one. |
169
+ | This key is read-only. Create a key with write access to open PRs | Make a *Read + write* key. |
170
+ | Zevian GitHub App isn't installed on this repo. Install it here: … | Open the link and install the app on the repository. |
171
+ | Couldn't reach this URL. If it's localhost, is your dev server running? | Start your dev server and try again. |
172
+ | Plan limit reached for today … | Wait for 00:00 UTC, or upgrade. |
173
+
174
+ If `claude mcp add` says `Invalid environment variable format`, the server name is directly after `--env`. Keep the
175
+ command as shown, with `--transport stdio` between them: `--env` takes several values and would read the name as one.
176
+
177
+ On native Windows, older versions of Claude Code could not start `npx` without `cmd /c` in front of it. Current
178
+ versions can, and the Windows command above works on both.
179
+
180
+ ## For developers
181
+
182
+ ```bash
183
+ npm install
184
+ npm test # builds, then runs the unit and stdio integration tests
185
+ npm run lint
186
+ ```
187
+
188
+ The server speaks MCP over stdio. It writes only protocol messages to stdout; logs go to stderr. Built with
189
+ the official MCP TypeScript SDK (`@modelcontextprotocol/server`).
package/dist/client.js ADDED
@@ -0,0 +1,93 @@
1
+ import { userAgent } from "./version.js";
2
+ export const MISSING_KEY_MESSAGE = "Zevian API key missing. Create one at zevian.tech/settings/api";
3
+ /**
4
+ * An error from Zevian, with the message the API wrote. Those messages are already written for agents (they
5
+ * say what to do next), so they are passed on unchanged.
6
+ */
7
+ export class ZevianApiError extends Error {
8
+ status;
9
+ code;
10
+ constructor(message, status, code) {
11
+ super(message);
12
+ this.status = status;
13
+ this.code = code;
14
+ this.name = "ZevianApiError";
15
+ }
16
+ }
17
+ /** A small client for the Zevian /v1 API. The key goes only to the API url, in the Authorization header. */
18
+ export class ZevianClient {
19
+ options;
20
+ fetchImpl;
21
+ timeoutMs;
22
+ agent;
23
+ constructor(options) {
24
+ this.options = options;
25
+ this.fetchImpl = options.fetchImpl ?? fetch;
26
+ this.timeoutMs = options.timeoutMs ?? 30_000;
27
+ this.agent = options.userAgent ?? userAgent();
28
+ }
29
+ /** Throws the missing-key error, for callers that want to fail before doing other work. */
30
+ requireKey() {
31
+ if (!this.options.apiKey)
32
+ throw new ZevianApiError(MISSING_KEY_MESSAGE, 401, "missing_key");
33
+ }
34
+ get(path, query) {
35
+ return this.request("GET", path, undefined, query);
36
+ }
37
+ post(path, body) {
38
+ return this.request("POST", path, body);
39
+ }
40
+ async request(method, path, body, query) {
41
+ // Checked here, without a request, so a missing key is the same clear message every time.
42
+ this.requireKey();
43
+ const url = new URL(`${this.options.apiUrl}${path}`);
44
+ for (const [name, value] of Object.entries(query ?? {}))
45
+ if (value !== undefined)
46
+ url.searchParams.set(name, String(value));
47
+ let response;
48
+ try {
49
+ response = await this.fetchImpl(url, {
50
+ method,
51
+ // A redirect would carry the key to wherever it points: refuse to follow one.
52
+ redirect: "manual",
53
+ signal: AbortSignal.timeout(this.timeoutMs),
54
+ headers: {
55
+ authorization: `Bearer ${this.options.apiKey}`,
56
+ accept: "application/json",
57
+ "user-agent": this.agent,
58
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
59
+ },
60
+ body: body === undefined ? undefined : JSON.stringify(body),
61
+ });
62
+ }
63
+ catch (error) {
64
+ if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
65
+ throw new ZevianApiError(`Zevian did not answer within ${Math.round(this.timeoutMs / 1000)} seconds. Try again in a minute.`, 0, "timeout");
66
+ }
67
+ throw new ZevianApiError(`Couldn't reach Zevian at ${url.host}. Check your connection, then try again.`, 0, "unreachable");
68
+ }
69
+ if (response.status >= 300 && response.status < 400) {
70
+ await response.body?.cancel().catch(() => undefined);
71
+ throw new ZevianApiError("ZEVIAN_API_URL redirected somewhere else, so the request was not sent on. Set it to the final address.", response.status, "redirect");
72
+ }
73
+ const text = await response.text().catch(() => "");
74
+ let parsed = null;
75
+ try {
76
+ parsed = text ? JSON.parse(text) : null;
77
+ }
78
+ catch {
79
+ // Not JSON: a proxy's error page, for instance. Handled below.
80
+ }
81
+ if (!response.ok) {
82
+ const error = parsed?.error;
83
+ if (typeof error?.message === "string" && error.message) {
84
+ throw new ZevianApiError(error.message, response.status, typeof error.code === "string" ? error.code : "error");
85
+ }
86
+ throw new ZevianApiError(`Zevian returned HTTP ${response.status}. Try again in a minute.`, response.status, "http_error");
87
+ }
88
+ if (parsed === null || typeof parsed !== "object") {
89
+ throw new ZevianApiError("Zevian sent an answer this version of zevian-mcp cannot read. Update the package and try again.", response.status, "bad_response");
90
+ }
91
+ return parsed;
92
+ }
93
+ }
package/dist/config.js ADDED
@@ -0,0 +1,29 @@
1
+ export const DEFAULT_API_URL = "https://app.zevian.tech";
2
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
3
+ /**
4
+ * Reads the environment. ZEVIAN_API_KEY is required to do anything, but a missing key is not a startup
5
+ * failure: the agent should see a clear tool error, not a server that will not start. ZEVIAN_API_URL is
6
+ * optional (staging, local development); it must be https, except for localhost, so the key is never sent in
7
+ * the clear.
8
+ */
9
+ export function loadConfig(env) {
10
+ const rawKey = env.ZEVIAN_API_KEY?.trim();
11
+ const rawUrl = env.ZEVIAN_API_URL?.trim();
12
+ let apiUrl = DEFAULT_API_URL;
13
+ if (rawUrl) {
14
+ let url;
15
+ try {
16
+ url = new URL(rawUrl);
17
+ }
18
+ catch {
19
+ return { ok: false, message: "ZEVIAN_API_URL is not a valid URL. Use something like https://app.zevian.tech." };
20
+ }
21
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && LOCAL_HOSTS.has(url.hostname))) {
22
+ return { ok: false, message: "ZEVIAN_API_URL must start with https:// (http:// is only allowed for localhost), so your API key is never sent unencrypted." };
23
+ }
24
+ if (url.username || url.password)
25
+ return { ok: false, message: "ZEVIAN_API_URL must not contain a username or password." };
26
+ apiUrl = url.origin + url.pathname.replace(/\/+$/, "");
27
+ }
28
+ return { ok: true, config: { apiKey: rawKey ? rawKey : null, apiUrl } };
29
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * What may leave the machine when a page is sent to Zevian for checking. A response's headers can hold
3
+ * session cookies and tokens; Zevian needs none of them (it reads only the content type, X-Robots-Tag and
4
+ * Link), so a header is dropped when its name is one of the credential headers, or contains a word that
5
+ * suggests a credential. The match is on the name, case-insensitively.
6
+ */
7
+ const ALWAYS_DROPPED = new Set(["set-cookie", "set-cookie2", "cookie", "authorization", "proxy-authorization"]);
8
+ const SUSPICIOUS_WORDS = ["token", "secret", "session", "key"];
9
+ export function isSensitiveHeader(name) {
10
+ const lower = name.trim().toLowerCase();
11
+ return ALWAYS_DROPPED.has(lower) || SUSPICIOUS_WORDS.some((word) => lower.includes(word));
12
+ }
13
+ /** Zevian takes at most 100 headers and 32 KB of them. */
14
+ export const MAX_HEADERS = 100;
15
+ const MAX_VALUE = 2_048;
16
+ const MAX_TOTAL = 30_000;
17
+ /** The response headers that are safe to send: sensitive ones removed, names lower-cased, sizes kept in bounds. */
18
+ export function safeHeaders(headers) {
19
+ const kept = {};
20
+ let total = 0;
21
+ let count = 0;
22
+ for (const [name, value] of headers) {
23
+ if (isSensitiveHeader(name))
24
+ continue;
25
+ const key = name.trim().toLowerCase();
26
+ if (!key || count >= MAX_HEADERS)
27
+ continue;
28
+ const text = value.length > MAX_VALUE ? value.slice(0, MAX_VALUE) : value;
29
+ if (total + key.length + text.length > MAX_TOTAL)
30
+ continue;
31
+ total += key.length + text.length;
32
+ count += 1;
33
+ kept[key] = text;
34
+ }
35
+ return kept;
36
+ }
package/dist/index.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
+ import { ZevianClient } from "./client.js";
4
+ import { loadConfig } from "./config.js";
5
+ import { createServer } from "./server.js";
6
+ import { VERSION } from "./version.js";
7
+ // stdout belongs to the MCP transport: anything else written there would corrupt the protocol. Logs go to
8
+ // stderr, and a stray console.log from any dependency is sent there too.
9
+ console.log = console.error;
10
+ console.info = console.error;
11
+ console.debug = console.error;
12
+ async function main() {
13
+ const loaded = loadConfig(process.env);
14
+ if (!loaded.ok) {
15
+ console.error(`[zevian-mcp] ${loaded.message}`);
16
+ process.exit(1);
17
+ }
18
+ const { apiKey, apiUrl } = loaded.config;
19
+ if (!apiKey) {
20
+ console.error("[zevian-mcp] ZEVIAN_API_KEY is not set. The server is running, but every tool will say how to create a key: zevian.tech/settings/api");
21
+ }
22
+ const client = new ZevianClient({ apiUrl, apiKey });
23
+ const server = createServer({ client, secrets: apiKey ? [apiKey] : [] });
24
+ await server.connect(new StdioServerTransport());
25
+ console.error(`[zevian-mcp] ${VERSION} ready (${apiUrl})`);
26
+ const stop = () => {
27
+ void server.close().finally(() => process.exit(0));
28
+ };
29
+ process.on("SIGINT", stop);
30
+ process.on("SIGTERM", stop);
31
+ }
32
+ main().catch((error) => {
33
+ console.error("[zevian-mcp] failed to start:", error instanceof Error ? error.message : String(error));
34
+ process.exit(1);
35
+ });
package/dist/page.js ADDED
@@ -0,0 +1,95 @@
1
+ import { safeHeaders } from "./headers.js";
2
+ import { userAgent } from "./version.js";
3
+ export const FETCH_TIMEOUT_MS = 15_000;
4
+ export const MAX_PAGE_BYTES = 2 * 1024 * 1024;
5
+ /** A problem fetching the page, with a message written for the agent. */
6
+ export class PageFetchError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "PageFetchError";
10
+ }
11
+ }
12
+ function charsetOf(contentType) {
13
+ const label = /charset\s*=\s*["']?([\w.:-]+)/i.exec(contentType ?? "")?.[1]?.toLowerCase();
14
+ if (!label)
15
+ return "utf-8";
16
+ try {
17
+ new TextDecoder(label);
18
+ return label;
19
+ }
20
+ catch {
21
+ return "utf-8";
22
+ }
23
+ }
24
+ /**
25
+ * Fetches a page from this machine, which is how localhost and staging pages get checked. It follows
26
+ * redirects, gives up after 15 seconds, and stops reading at 2 MB, counted in bytes as the body streams in.
27
+ * It never sends cookies or credentials: the request carries only a user agent and an Accept header, and the
28
+ * Zevian API key is not available here. Only http and https are fetched.
29
+ */
30
+ export async function fetchPage(rawUrl, options = {}) {
31
+ const fetchImpl = options.fetchImpl ?? fetch;
32
+ const timeoutMs = options.timeoutMs ?? FETCH_TIMEOUT_MS;
33
+ const maxBytes = options.maxBytes ?? MAX_PAGE_BYTES;
34
+ let url;
35
+ try {
36
+ url = new URL(rawUrl);
37
+ }
38
+ catch {
39
+ throw new PageFetchError("That is not a valid URL. Use a full address such as http://localhost:3000/pricing.");
40
+ }
41
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
42
+ throw new PageFetchError("check_page can only fetch http or https URLs, such as http://localhost:3000/pricing.");
43
+ }
44
+ if (url.username || url.password) {
45
+ throw new PageFetchError("Leave the username and password out of the URL. check_page does not send credentials.");
46
+ }
47
+ const signal = AbortSignal.timeout(timeoutMs);
48
+ try {
49
+ const response = await fetchImpl(url, {
50
+ method: "GET",
51
+ redirect: "follow",
52
+ signal,
53
+ // Nothing else: no Cookie, no Authorization. Node's fetch has no cookie jar to add one.
54
+ headers: { "user-agent": userAgent(), accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5" },
55
+ });
56
+ const chunks = [];
57
+ let total = 0;
58
+ const reader = response.body?.getReader();
59
+ if (reader) {
60
+ for (;;) {
61
+ const { done, value } = await reader.read();
62
+ if (done)
63
+ break;
64
+ total += value.byteLength;
65
+ if (total > maxBytes) {
66
+ await reader.cancel().catch(() => undefined);
67
+ throw new PageFetchError(`This page's HTML is larger than ${Math.round(maxBytes / 1024 / 1024)} MB, so it cannot be checked. Check a smaller page, or one that renders less on the server.`);
68
+ }
69
+ chunks.push(value);
70
+ }
71
+ }
72
+ const bytes = new Uint8Array(total);
73
+ let offset = 0;
74
+ for (const chunk of chunks) {
75
+ bytes.set(chunk, offset);
76
+ offset += chunk.byteLength;
77
+ }
78
+ const html = new TextDecoder(charsetOf(response.headers.get("content-type"))).decode(bytes);
79
+ return {
80
+ url: url.toString(),
81
+ finalUrl: response.url || url.toString(),
82
+ status: response.status,
83
+ headers: safeHeaders(response.headers),
84
+ html,
85
+ };
86
+ }
87
+ catch (error) {
88
+ if (error instanceof PageFetchError)
89
+ throw error;
90
+ if (signal.aborted || (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"))) {
91
+ throw new PageFetchError(`Timed out after ${Math.round(timeoutMs / 1000)} seconds reading this URL. If it's localhost, is your dev server responding?`);
92
+ }
93
+ throw new PageFetchError("Couldn't reach this URL. If it's localhost, is your dev server running?");
94
+ }
95
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ export const PROMPT_NAME = "fix-seo";
3
+ export const promptArgs = z.object({
4
+ domain: z.string().optional().describe("The domain to fix, such as example.com. Default: the site connected to this project."),
5
+ });
6
+ /** The instruction the fix-seo slash command gives the agent. It says "errors and warnings", the API's own severity names. */
7
+ export function fixSeoText(domain) {
8
+ const target = domain?.trim() ? domain.trim() : "the site that belongs to this project";
9
+ return [
10
+ `Audit ${target} with Zevian and fix what it finds.`,
11
+ "",
12
+ `1. Call list_sites to get the site_id${domain?.trim() ? ` of ${domain.trim()}` : ", and pick the site that matches this project"}.`,
13
+ '2. Call run_audit for it (scope "full"). If it is still running, call get_audit every minute or so until it is done.',
14
+ "3. Call get_audit and show me the errors and warnings, worst first: for each one, the page, what is wrong, and why it matters. Leave out info-level items unless I ask.",
15
+ "4. For each error and warning that has a fix, call get_fix and apply it in this repository. Search for the current value before you edit it, follow the constraints it gives, and write titles, descriptions and alt text from the page's own content: never invent claims.",
16
+ "5. Run this project's build or tests if it has any. Then summarize what you changed, file by file, and what you left alone and why.",
17
+ "",
18
+ "Do not open a pull request unless I ask. If I do, tell me which issues it will fix and wait for my yes before you call open_fix_pr.",
19
+ ].join("\n");
20
+ }
package/dist/server.js ADDED
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import { fixSeoText, promptArgs, PROMPT_NAME } from "./prompts.js";
3
+ import { runTool, TOOLS } from "./tools.js";
4
+ import { VERSION } from "./version.js";
5
+ const INSTRUCTIONS = "Zevian audits websites for SEO and AI-search readiness and gives fixes. Call list_sites first to get a site_id. Use check_page for localhost, staging or unpublished pages. Show the user errors and warnings, and ask before opening a pull request.";
6
+ /** The MCP server: the 8 tools and the fix-seo prompt, over the given Zevian client. */
7
+ export function createServer(deps) {
8
+ const server = new McpServer({ name: "zevian", version: VERSION }, { instructions: INSTRUCTIONS });
9
+ for (const tool of TOOLS) {
10
+ server.registerTool(tool.name, { title: tool.title, description: tool.description, inputSchema: tool.inputSchema, annotations: tool.annotations }, async (input) => runTool(tool, input, deps));
11
+ }
12
+ server.registerPrompt(PROMPT_NAME, { title: "Fix SEO with Zevian", description: "Audit the site with Zevian, show the errors and warnings, and fix them in this repository.", argsSchema: promptArgs }, ({ domain }) => ({ messages: [{ role: "user", content: { type: "text", text: fixSeoText(domain) } }] }));
13
+ return server;
14
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,179 @@
1
+ import { z } from "zod";
2
+ import { ZevianApiError } from "./client.js";
3
+ import { fetchPage, PageFetchError } from "./page.js";
4
+ import { DEFAULT_WAIT, pollUntil } from "./wait.js";
5
+ /** Keeps each tool's input typed from its own schema, then stores it with the others. */
6
+ function defineTool(tool) {
7
+ return tool;
8
+ }
9
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
10
+ const pick = (value) => (value && typeof value === "object" ? value : {});
11
+ /** Renames `id` to a name that says what it is, so an agent never has to guess which id this is. */
12
+ function named(value, idName) {
13
+ const { id, ...rest } = pick(value);
14
+ return id === undefined ? rest : { [idName]: id, ...rest };
15
+ }
16
+ const AUDIT_DONE = (audit) => audit.status === "done" || audit.status === "failed";
17
+ const PR_DONE = (pr) => ["open", "merged", "closed", "failed", "blocked"].includes(String(pr.status));
18
+ const siteId = z.string().min(1).describe("The site's id, from list_sites.");
19
+ const auditId = z.string().min(1).describe("The audit's id, from run_audit.");
20
+ export const TOOLS = [
21
+ defineTool({
22
+ name: "list_sites",
23
+ title: "List Zevian sites",
24
+ description: "Call this first, to get the site_id the other tools need. Lists the sites connected to this Zevian account with each site's SEO and AI scores, open issue count, connected GitHub repository, and whether the plan has paused it.",
25
+ inputSchema: z.object({}),
26
+ annotations: READ_ONLY,
27
+ run: async (_input, { client }) => client.get("/v1/sites"),
28
+ }),
29
+ defineTool({
30
+ name: "run_audit",
31
+ title: "Audit a site",
32
+ description: "Call this to audit a connected site, or one page of it, with Zevian's crawler. Needs a site_id from list_sites. For localhost, staging or any page that is not published on the site's domain, use check_page instead. Waits up to about 45 seconds for the result; if the audit is still running it returns an audit_id, and you should call get_audit with it in a minute. It crawls the site but changes nothing on it.",
33
+ inputSchema: z.object({
34
+ site_id: siteId,
35
+ scope: z.enum(["full", "page"]).optional().describe('"full" audits the whole site (default). "page" audits one page, which needs url.'),
36
+ url: z.string().optional().describe('The page to audit, on the site\'s own domain. Only with scope "page".'),
37
+ }),
38
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
39
+ run: async (input, { client, wait }) => {
40
+ const started = pick(await client.post("/v1/audits", { site_id: input.site_id, scope: input.scope ?? "full", ...(input.url ? { url: input.url } : {}) }));
41
+ const id = String(started.id);
42
+ const polled = await pollUntil(async () => pick(await client.get(`/v1/audits/${id}`)), AUDIT_DONE, wait ?? DEFAULT_WAIT);
43
+ const { note: _note, ...audit } = named(polled.value, "audit_id");
44
+ if (!polled.finished) {
45
+ return { audit_id: id, site_id: input.site_id, status: audit.status, next: "The audit is still running. Call get_audit with this audit_id in a minute." };
46
+ }
47
+ return {
48
+ ...audit,
49
+ next: audit.status === "done" ? "Call get_audit with this audit_id to list the issues, then get_fix for each one." : "The audit failed: tell the user why (see error), and do not retry until they have dealt with it.",
50
+ };
51
+ },
52
+ }),
53
+ defineTool({
54
+ name: "get_audit",
55
+ title: "Read an audit",
56
+ description: "Call this to read an audit started with run_audit: its status, scores and counts, and its issues worst first, each with an issue_id for get_fix. Filter by severity (error, warning, info) or category (seo, geo, aeo, schema). Lists up to 25 issues by default and 100 at most, with the total. If the audit is still running it says so: call it again in a minute.",
57
+ inputSchema: z.object({
58
+ audit_id: auditId,
59
+ severity: z.enum(["error", "warning", "info"]).optional().describe("Only issues of this severity."),
60
+ category: z.enum(["seo", "geo", "aeo", "schema"]).optional().describe("Only issues in this category."),
61
+ status: z.enum(["open", "fixed", "ignored", "all"]).optional().describe("Issue status. Default: open."),
62
+ limit: z.number().int().min(1).max(100).optional().describe("How many issues to return. Default 25."),
63
+ }),
64
+ annotations: READ_ONLY,
65
+ run: async (input, { client }) => {
66
+ const summary = pick(await client.get(`/v1/audits/${input.audit_id}`));
67
+ const { note: _note, top_issues: _top, ...audit } = named(summary, "audit_id");
68
+ if (summary.status !== "done") {
69
+ return { ...audit, next: summary.status === "failed" ? "The audit failed: tell the user why (see error)." : "The audit is still running. Call get_audit again in a minute." };
70
+ }
71
+ const list = pick(await client.get(`/v1/audits/${input.audit_id}/issues`, { severity: input.severity, category: input.category, status: input.status, limit: input.limit }));
72
+ return { ...audit, issues: { total: list.total, returned: list.returned, items: list.issues } };
73
+ },
74
+ }),
75
+ defineTool({
76
+ name: "get_fix",
77
+ title: "Get the fix for an issue",
78
+ description: "Call this to get instructions for fixing one issue (an issue_id from get_audit or check_page): the kind of change, which files to look in, the exact target values and limits, and why. Apply it in the user's own repository: search for the current value before you edit, and follow the constraints. It returns instructions, not a diff. If the user would rather have Zevian make the change, use open_fix_pr.",
79
+ inputSchema: z.object({ issue_id: z.string().min(1).describe("The issue's id, from get_audit.") }),
80
+ annotations: READ_ONLY,
81
+ run: async (input, { client }) => client.get(`/v1/issues/${input.issue_id}/fix`),
82
+ }),
83
+ defineTool({
84
+ name: "open_fix_pr",
85
+ title: "Open a pull request with Zevian",
86
+ description: "Opens one pull request on the user's connected GitHub repository that fixes 1 to 20 issues from the same site, using Zevian's GitHub App. This changes their repository: ask the user to confirm before you call it, and tell them which issues it will fix. It needs a key with write access, and counts against the monthly pull request allowance. Waits up to about 45 seconds; if the pull request is not open by then it returns a pr_id, so call get_fix_pr with it in a minute or two.",
87
+ inputSchema: z.object({
88
+ issue_ids: z.array(z.string().min(1)).min(1).max(20).describe("The issues to fix, from get_audit. All from the same site."),
89
+ title: z.string().max(120).optional().describe("The pull request title. Default: a short summary."),
90
+ }),
91
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
92
+ run: async (input, { client, wait }) => {
93
+ const started = pick(await client.post("/v1/fix-prs", { issue_ids: input.issue_ids, ...(input.title ? { title: input.title } : {}) }));
94
+ const id = String(started.id);
95
+ const polled = await pollUntil(async () => pick(await client.get(`/v1/fix-prs/${id}`)), PR_DONE, wait ?? DEFAULT_WAIT);
96
+ const { note: _note, ...pr } = named(polled.value, "pr_id");
97
+ if (!polled.finished) {
98
+ return { pr_id: id, status: pr.status, issue_ids: started.issue_ids, next: "Zevian is still writing the fix. Call get_fix_pr with this pr_id in a minute or two." };
99
+ }
100
+ return { ...pr, next: pr.status === "open" ? "Give the user the url. They review and merge it on GitHub." : "The pull request did not open: tell the user why (see error)." };
101
+ },
102
+ }),
103
+ defineTool({
104
+ name: "get_fix_pr",
105
+ title: "Check a pull request",
106
+ description: "Call this to check a pull request started with open_fix_pr, when open_fix_pr returned a pr_id instead of a url: its status (queued, running, open, failed), its url once open, and the reason if it failed.",
107
+ inputSchema: z.object({ pr_id: z.string().min(1).describe("The pr_id from open_fix_pr.") }),
108
+ annotations: READ_ONLY,
109
+ run: async (input, { client }) => {
110
+ const { note: _note, ...pr } = named(await client.get(`/v1/fix-prs/${input.pr_id}`), "pr_id");
111
+ const status = String(pr.status);
112
+ return { ...pr, next: status === "open" ? "Give the user the url." : status === "queued" || status === "running" ? "Still running. Call get_fix_pr again in a minute." : undefined };
113
+ },
114
+ }),
115
+ defineTool({
116
+ name: "check_page",
117
+ title: "Check a page before you deploy",
118
+ description: "Call this to check one page's SEO, including pages Zevian cannot reach: localhost, staging, or anything not yet published. The page is fetched from this machine (cookies and credentials are never sent, and sensitive response headers are removed before anything goes to Zevian), then analyzed. Returns the page's issues, each with its fix. It reads the HTML as served, so content a browser draws with JavaScript is not visible; the result says so when a page is mostly empty before JavaScript runs.",
119
+ inputSchema: z.object({
120
+ url: z.string().min(1).describe("The page's address, such as http://localhost:3000/pricing."),
121
+ include_fixes: z.boolean().optional().describe("Include the fix for each issue (default true). Set false for a shorter answer."),
122
+ }),
123
+ annotations: READ_ONLY,
124
+ run: async (input, { client, fetchPageImpl }) => {
125
+ // Fail on a missing key before fetching anything.
126
+ client.requireKey();
127
+ const page = await (fetchPageImpl ?? fetchPage)(input.url);
128
+ return client.post("/v1/analyze", {
129
+ url: page.url,
130
+ final_url: page.finalUrl,
131
+ status: page.status,
132
+ headers: page.headers,
133
+ html: page.html,
134
+ include_fixes: input.include_fixes ?? true,
135
+ });
136
+ },
137
+ }),
138
+ defineTool({
139
+ name: "get_impact",
140
+ title: "See what the fixes achieved",
141
+ description: "Call this to see how a site performs in Search Console and Bing over the last N days (7 to 180, default 28), and the before and after of each merged Zevian pull request: clicks, impressions and position, where a positive position_gain means the page moved up. Needs a site_id from list_sites. Paid plans only.",
142
+ inputSchema: z.object({
143
+ site_id: siteId,
144
+ days: z.number().int().min(7).max(180).optional().describe("The period in days. Default 28."),
145
+ }),
146
+ annotations: READ_ONLY,
147
+ run: async (input, { client }) => client.get(`/v1/sites/${input.site_id}/impact`, { days: input.days }),
148
+ }),
149
+ ];
150
+ export const TOOL_NAMES = TOOLS.map((tool) => tool.name);
151
+ /** Text for the agent: compact JSON, because agents pay for every token. */
152
+ export function resultOf(value) {
153
+ return { content: [{ type: "text", text: JSON.stringify(value) }] };
154
+ }
155
+ export function errorOf(message) {
156
+ return { content: [{ type: "text", text: message }], isError: true };
157
+ }
158
+ function scrub(message, secrets) {
159
+ return secrets.reduce((text, secret) => (secret ? text.split(secret).join("[key]") : text), message);
160
+ }
161
+ /**
162
+ * Runs a tool and turns the outcome into a tool result. An error from Zevian, or from fetching the page,
163
+ * carries a message written for the agent and is passed on unchanged (as a tool error, isError). Anything
164
+ * else is reported without detail that could matter, and never with the API key.
165
+ */
166
+ export async function runTool(tool, input, deps) {
167
+ const secrets = deps.secrets ?? [];
168
+ try {
169
+ return resultOf(await tool.run(tool.inputSchema.parse(input), deps));
170
+ }
171
+ catch (error) {
172
+ if (error instanceof ZevianApiError || error instanceof PageFetchError)
173
+ return errorOf(scrub(error.message, secrets));
174
+ if (error instanceof z.ZodError)
175
+ return errorOf(`Invalid input: ${error.issues[0]?.path.join(".") || "input"} ${error.issues[0]?.message ?? "is not valid"}.`);
176
+ console.error(`[zevian-mcp] ${tool.name} failed:`, scrub(error instanceof Error ? error.message : String(error), secrets));
177
+ return errorOf("zevian-mcp hit an unexpected error. Try again; if it keeps happening, update the package with npx zevian-mcp@latest.");
178
+ }
179
+ }
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from "node:fs";
2
+ /** The package version, read from package.json (one level up from both src/ and dist/). */
3
+ export const VERSION = (() => {
4
+ try {
5
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
6
+ const version = JSON.parse(raw).version;
7
+ return typeof version === "string" ? version : "0.0.0";
8
+ }
9
+ catch {
10
+ return "0.0.0";
11
+ }
12
+ })();
13
+ /** Sent with every request to Zevian, so the backend can tell versions apart (and later warn about old ones). */
14
+ export function userAgent(version = VERSION) {
15
+ return `zevian-mcp/${version} (node ${process.versions.node}; ${process.platform})`;
16
+ }
package/dist/wait.js ADDED
@@ -0,0 +1,19 @@
1
+ /** About 45 seconds: some MCP clients time out a tool call that runs longer. */
2
+ export const DEFAULT_WAIT = { timeoutMs: 45_000, intervalMs: 3_000 };
3
+ /**
4
+ * Polls until `done` says the value is final, or the time is up. It always polls once, never sleeps past the
5
+ * deadline, and returns the last value either way, so the caller can say where things stand.
6
+ */
7
+ export async function pollUntil(poll, done, waiter = DEFAULT_WAIT) {
8
+ const sleep = waiter.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
9
+ const now = waiter.now ?? Date.now;
10
+ const deadline = now() + waiter.timeoutMs;
11
+ for (;;) {
12
+ const value = await poll();
13
+ if (done(value))
14
+ return { value, finished: true };
15
+ if (now() + waiter.intervalMs > deadline)
16
+ return { value, finished: false };
17
+ await sleep(waiter.intervalMs);
18
+ }
19
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "zevian-mcp",
3
+ "version": "0.1.0",
4
+ "mcpName": "tech.zevian/mcp",
5
+ "description": "Zevian for AI coding agents: audit your site, check a page on localhost before you deploy, and fix SEO issues from your editor.",
6
+ "type": "module",
7
+ "bin": {
8
+ "zevian-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "model-context-protocol",
19
+ "seo",
20
+ "zevian"
21
+ ],
22
+ "license": "MIT",
23
+ "homepage": "https://zevian.tech/mcp",
24
+ "scripts": {
25
+ "build": "node scripts/build.mjs",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "tsc --noEmit --noUnusedLocals --noUnusedParameters",
28
+ "test": "vitest run",
29
+ "prepublishOnly": "npm run lint && npm test"
30
+ },
31
+ "dependencies": {
32
+ "@modelcontextprotocol/server": "2.2.0",
33
+ "zod": "4.6.5"
34
+ },
35
+ "devDependencies": {
36
+ "@modelcontextprotocol/client": "2.2.0",
37
+ "@types/node": "20.19.43",
38
+ "typescript": "7.0.2",
39
+ "vitest": "4.1.11"
40
+ }
41
+ }