Skip to content
MrJev

jev-seo

Rust CLI and MCP server for SEO and GEO work, scraping DuckDuckGo instead of paying for a search API.

View on GitHub →

Hands-on review

A Rust SEO toolkit with ten commands and an MCP server. Everything we reported was fixed within a day — but not yet released.

Good for

  • Markdown and HTML audits, schema validation and sitemap checks in one binary
  • Careful key handling: read from the environment, never written to disk or logged
  • Ten commands from one static Rust binary, no runtime to install

Watch out for

  • `cargo install jev-seo` still gets the Sept 18 build: 6 commands, and none of the fixes
  • The MCP handshake and the `geo` path filter landed in `f8cb7c5`; take that or later
  • `keywords` calls an autocomplete endpoint that DuckDuckGo's robots.txt disallows

Tested Sep 21, 2026 at c9e3491 · re · Rust in Docker, offline; the re-check drove the real MCP server over stdio and the CLI with no network at all

How we reviewed this: we read the source at c9e3491 and ran it in Docker offline against a local stand-in — its endpoint is a hard-coded constant with no override, so we patched that one line, then reverted and diffed. We re-ran everything at 5120aac after the fixes landed, driving the real MCP server over stdio and the CLI with --network none. We made no Jev calls.

What it does

A single Rust binary with ten subcommands covering the usual SEO surface: audit a directory of Markdown or HTML, validate Schema.org blocks, check robots.txt for AI crawler rules, inspect a sitemap, track a keyword’s DuckDuckGo rank over time in a local SQLite file, and generate a content brief. Five of them send text to Jev for judgement — keywords, query, audit, geo and brief; the rest are local parsing. It also ships an MCP server so an agent can call eight of the same tools.

For a repository that is two days old, the breadth is real, and the local half works. The SEO checks are ordinary but sound, and a single binary with no runtime is a genuinely nice way to ship this.

The MCP server couldn’t complete a handshake

src/mcp.rs:53 dispatches on the method name and handles exactly two:

match req.method.as_str() {
    "tools/list" => …
    "tools/call" => …
    _ => … Some(json!({ "code": -32601, "message": "Method not found" })),
}

initialize is not there. Every MCP client sends initialize first and waits for the result before it sends anything else, so the first thing any compliant client gets back is -32601 and the connection is over. The eight tools behind it are unreachable — not degraded, unreachable — and the README’s MCP section is the headline feature.

This was the cheapest fix in this review: one match arm returning a protocolVersion, capabilities and serverInfo.

Fixed in f8cb7c5, hours after we reported it. We drove the built binary over stdio with a compliant opening sequence, offline:

initialize -> result | protocolVersion: 2024-11-05 | serverInfo: {"name":"jev-seo","version":"0.1.0"}
tools/list -> 8 tools: seo_keywords, seo_serp_inspect, seo_audit, seo_geo,
                       seo_schema, seo_robots, seo_brief, seo_sitemap

notifications/initialized is accepted as well, so the whole client-side opening goes through and all eight tools are reachable.

Naming a path bypasses the filter

The directory audit is careful about what it opens. src/audit.rs:702:

if file_name.starts_with('.') || file_name == "node_modules" || file_name == "target" || file_name == "dist" || file_name == "build" {
    continue;
}

and then it takes only .md, .mdx, .markdown, .html and .htm.

geo runs none of that. src/main.rs:270:

let content = std::fs::read_to_string(&target).unwrap_or_else(|_| target.clone());

No extension check, no dot-file rule, no root. Whatever path you name is read whole and posted as "content". src/mcp.rs:210 is the same line again for seo_geo, which means an agent holding this MCP server can name the path.

This is the eighth tool in this directory where the exclusion list protects the directory walk and naming a file explicitly walks around it — the same shape as JevLint, perch, jegrep and jevscan-evm. It keeps recurring because the walk is where authors think about safety, and a named path never passes through it.

The unwrap_or_else had a second effect worth knowing. Give geo a URL and read_to_string fails, so the fallback sent the URL string itself as the page content. You got a score, and it was computed on about forty characters. Nothing in the output said the page was never fetched.

Also fixed in f8cb7c5, and fixed in the right place: paths::read_user_file is one guard shared by the CLI and the MCP tool, rather than a check bolted onto each caller. Same four targets through jev-seo geo and through tools/call, in a container with no network:

target CLI seo_geo over MCP
.env refusing dot-file isError: true, refusing dot-file
secrets.json refusing non-content file isError: true, refusing non-content file
https://example.com/pricing pass a local file path or an inline snippet, not a URL same
page.md passes the guard, then asks for the key same

Rejecting a URL outright is better than what we suggested: scoring forty characters of URL as if it were a page is gone rather than papered over.

A blocked search was recorded as a bad rank

rank scrapes DuckDuckGo and writes the position to SQLite. src/serp.rs:48 starts with an empty vector and fills it by regex from whatever HTML came back. There is no check that the response is a results page, and no check that anything matched.

If DuckDuckGo serves its anomaly page — which returns HTTP 200, so ureq does not error — no block matches, items is empty, position is None, and track_keyword writes NULL. The output reads:

  Current:  Not in top 30

and the exit code is 0. “We searched and you weren’t there” and “the search never happened” were stored identically and printed identically. For a tool whose whole point is a trend line you check over weeks, that is the failure that matters: a week of rate-limiting looks like a week of falling off the results.

Fixed in the same commit. serp.rs:49 now bails when the page carries a challenge marker and :95 bails when nothing parses, and because the rank command propagates the error, track_keyword is never reached — so no NULL row is written. This is the one fix we could not drive ourselves: scrape_serp builds its own request to a hardcoded URL, so exercising the blocked branch means asking DuckDuckGo for its anomaly page on purpose, which we won’t do. We’ve offered a pull request that lifts the parsing into a pure function so the branch can be tested hermetically.

A related catch of the author’s own, in the same range: rank matching now compares hosts exactly, so a lookalike domain can no longer take credit for your ranking.

One correction to something we nearly published: the SERP scrape itself is fine by robots. It posts to html.duckduckgo.com/html/, and that host’s robots.txt says Allow: /. The autocomplete call behind keywords is the one that isn’t — it hits duckduckgo.com/ac/?q=…, and duckduckgo.com/robots.txt carries Disallow: /*? with an Allow: /?* that only covers the root. Both requests go out under a spoofed Chrome user-agent string (src/serp.rs:5).

What’s on crates.io isn’t what’s on GitHub

cargo install jev-seo succeeds, and what you get is 0.1.0, published 2026-09-18 13:10 UTC. It was five commits behind when we reviewed it; it is much further behind now, and — this is the part that matters — it contains none of the fixes above. Someone installing today from the README still gets the broken handshake. We diffed the two at review time:

crates.io 0.1.0 GitHub c9e3491
commands 6 10
MCP tools 3 8

schema, robots, brief and sitemap do not exist in the published crate. The README documents all ten.

Verdict

Three days old, MIT, 27 test functions passing, and a workflow of its own. The key handling is the best part and deserves saying: TYPESAFE_API_KEY is read from the environment at src/engine.rs:22, never written to disk and never logged. Note that it is the only route — the endpoint at src/engine.rs:28 is a constant, so there is no OpenRouter or Vercel option and no way to point it at a gateway.

We’d said to wait. Within a day of the report the author had fixed all three — the handshake, the path guard and the blocked search — and kept going through five more rounds of work on top. That is the fastest turnaround we have seen in this directory, and the review above is now a record of what was rather than what is.

What still holds: install from the repository, not from crates.io, until a release catches up; and keywords reaches an autocomplete endpoint that DuckDuckGo’s own robots.txt disallows, under a spoofed user agent, which is a decision worth making deliberately.

For an SEO-adjacent tool that is further along, see jevcal.

See how it compares with other tools in Best Jev tools, tested hands-on.

Review updated Sep 21, 2026. Numbers quoted from the project are its author's own; we don't publish our own measurements of Jev.

More in Command-Line Tools

jevgrep

★ 866

dzhng/jevgrep

Finds code by what it does rather than what it matches: a CLI for coding agents that asks which files and which regions are relevant to a description.

TypeScript

JevRev

★ 434

Alex314618-create/JevRev

Puts an LLM and Jev in one workflow, with sift, loop and long modes, a TUI, and a CLI that can be pointed at any /v1/systemone host. English and Chinese.

TypeScript

semgrep (uehaj)

★ 145▲ 26

uehaj/jev-semgrep

Grep by meaning: Jev scores each line against a description in any language, with AND, OR, and NOT. A single dependency-free Node file.

JavaScriptReviewed

Get new Jev projects every week

New Jev releases, pricing changes, and the best new projects, once a week. No spam; unsubscribe anytime.

Powered by Buttondown. See our privacy policy.