verifind-mcp
# verifind-mcp
  
An MCP server with one tool: **find real things matching whatever shape you ask for, and never invent one.** Products, job postings, event tickets, opportunities, apartments — you give it a plain-language query and a JSON Schema for what one result should look like. It runs a real web search, has a model restructure *only* what that search actually returned into your shape, then checks every result's source URL in code against the literal search results. If a claim can't be traced back to a real fetched page, that item is dropped — not guessed at, not "corrected," dropped.
**Search → restructure into your schema → verify the source in code → return only what survives.**
Built entirely on free-tier APIs — [Tavily](https://tavily.com) for search, any OpenAI-compatible endpoint ([Groq](https://console.groq.com/keys) by default) for extraction. No credit card required for either.
---
## The problem, in one exchange
Ask a raw LLM to "find" something and it will, whether or not that thing actually exists:
> **You:** Find me 3 real wireless earbuds under $50 with good bass.
> **Raw LLM (illustrative — not a real call):** 1. **AudioMax Pro X200** — $42.99, deep bass, 30hr battery. 2. **SoundWave Elite S3** — $38.50...
>
> Confident, specific, and — unless you happen to already know this market — completely unverifiable. Neither product may exist.
Here's `verified_search` on the same question, actual output from `examples/smoke.ts` against live Tavily + Groq, unedited:
```json
{
"items": [
{ "name": "Treblab WX8", "highlight": "Listed under “Best Bass Wireless Earbuds under $50”", "sourceUrl": "https://headphonesaddict.com/best-bluetooth-earbuds-under-50" },
{ "name": "TOZO NC2", "price": "$40", "highlight": "Described as having heavy bass and priced at $40", "sourceUrl": "https://www.soundguys.com/the-best-wireless-earbuds-under-50-156056" }
],
"itemsExtracted": 4,
"itemsDropped": 0
}
```
Every `sourceUrl` above is a real page this tool actually fetched — click one, it's there. That's the entire point of the project.
## Why this exists
This started as one piece of a different project (a CRM that finds paid photography gigs): a pattern for asking an LLM to "find real things" without it confidently hallucinating half of them. That pattern turned out to have nothing to do with photography — it generalizes to anything an agent might need to look up on the live web with a straight-faced answer instead of a plausible-sounding guess.
- **The core problem: an LLM asked to "find X" will happily invent X.** The fix is splitting retrieval from extraction: a real search API fetches real pages, and the model is only ever allowed to restructure *those specific results* — never asked to know things directly. See [`src/verifiedSearch.ts`](./src/verifiedSearch.ts).
- **The guarantee is enforced in code, not by asking nicely.** Every result schema gets a `sourceUrl` field injected automatically (even if the caller's schema didn't include one), and after the model responds, that field is checked against the literal set of URLs the search actually returned — in a `Set`, not by trusting the model's word. A result citing an unlisted or fabricated URL is dropped, unconditionally, no exceptions.
- **Fully generic, not a fixed set of "modes."** The result shape is a JSON Schema the *caller* supplies per call — there's no hardcoded `Product` or `JobPosting` type anywhere in this codebase. The same tool that found the earbuds above can find event tickets or apartment listings, because the shape of "a result" is an input, not a design decision baked into the server.
- **Honest about where the guarantee is strong vs. weak.** A `sourceUrl` is either genuinely one of the fetched pages or it isn't — that's a hard, checkable fact, enforced by a `Set.has()` call, not a vibe. A `price` or `name` field is only as good as the model's transcription of the source text; nothing stops it from misreading a number. This README says that outright instead of implying every field carries the same guarantee.
## Architecture
```mermaid
flowchart LR
Q["query + JSON Schema"] --> S["Tavily search"]
S -->|"real search results"| E["LLM extraction"]
E -->|"structured JSON"| V{"Validate"}
S -->|"actual URLs fetched"| V
V -->|"schema-valid AND source verified"| KEEP["Kept items"]
V -->|"fails either check"| DROP["Dropped, reason recorded"]
```
The LLM never searches the web itself — it only ever sees the exact search results `verifiedSearch` already fetched, wrapped as clearly-labeled untrusted data (see [`src/security.ts`](./src/security.ts), this project's prompt-injection defense against hostile page content trying to redirect the model's behavior).
## Tech stack
| Piece | Choice |
|---|---|
| Language | TypeScript |
| Protocol | `@modelcontextprotocol/sdk` (stdio transport) |
| Search | [Tavily](https://tavily.com) — free tier, no card |
| Extraction | Any OpenAI-compatible chat completions endpoint — defaults to [Groq](https://console.groq.com/keys), free tier, no card |
| Schema validation | `ajv` — validates the model's output against the *caller's own* JSON Schema, not a fixed one |
| Testing | Vitest — unit tests mock Tavily/the LLM; `examples/` scripts hit the real APIs |
## Repository layout
```
src/
index.ts MCP server entrypoint — registers the verified_search tool
verifiedSearch.ts The whole pipeline: search -> extract -> validate -> verify
tavily.ts Real web search client
security.ts Wraps untrusted web content for safe LLM inclusion
json.ts Tolerant JSON parsing for model output
env.ts Env validation
logger.ts stderr-only logger (stdout is the MCP protocol channel — never log there)
examples/
smoke.ts Live end-to-end call against real APIs, no MCP involved
mcp-smoke.ts Live call through the actual MCP client/server stdio protocol
```
## Quick start
```bash
git clone https://github.com/<you>/verifind-mcp.git
cd verifind-mcp
npm install
cp .env.example .env # fill in TAVILY_API_KEY and LLM_API_KEY (both free, no card)
npm run build
npx dotenv -e .env -- npx tsx examples/smoke.ts
```
That last command hits real APIs and prints real, sourced results — no MCP client required to see it work.
## Prerequisites
- Node.js 20+
- A free [Tavily](https://tavily.com) API key
- A free [Groq](https://console.groq.com/keys) API key (or any other OpenAI-compatible endpoint you'd rather point at)
## Using it as an MCP server
Register it with any MCP-compatible client — e.g. Claude Desktop's `claude_desktop_config.json`:
```json
{
"mcpServers": {
"verifind": {
"command": "node",
"args": ["/path/to/verifind-mcp/dist/index.js"],
"env": {
"TAVILY_API_KEY": "...",
"LLM_API_KEY": "..."
}
}
}
}
```
Then ask the client something like: *"Use verified_search to find me three real budget mechanical keyboards under $60, with name, price, and sourceUrl."*
To try it without any MCP client:
```bash
npx dotenv -e .env -- npx tsx examples/smoke.ts # calls verifiedSearch() directly
npx dotenv -e .env -- npx tsx examples/mcp-smoke.ts # calls it through the real stdio protocol
```
## Testing
```bash
npm test
```
Unit tests mock Tavily and the LLM — no network calls, no API keys needed. The `examples/` scripts are the real, live check: they hit actual Tavily and Groq endpoints and print real results, the same way the earbuds output above was generated.
## Limitations, honestly
- Free-tier rate limits (Tavily's request quota, Groq's per-minute token budget) apply — this isn't built for high-volume production traffic without upgrading those.
- The `sourceUrl` guarantee is real and code-enforced. Every *other* field is the model's best-effort transcription of the source text — good, but not independently verified the way the URL is.
- Search quality depends entirely on Tavily's index for the query given; a query with no good public results returns few or no items rather than a forced answer, which is the intended behavior, not a bug.
## Contributing
This is deliberately domain-agnostic — if you use it for something (job boards, real estate, event tickets, anything), an example schema under `examples/` showing that use case is a genuinely useful PR. Issues and schema-design questions welcome too.
## License
MIT — see [LICENSE](./LICENSE).
TDQS
Scored across 1 tool
With only one tool, there is no possibility of confusing it with another. The tool's purpose is clear from its name and detailed description.
A single tool means there is no inconsistent naming convention to worry about. verified_search is clear, readable snake_case and matches the server's purpose.
One tool feels thin, especially since the description claims coverage of many domains like products, jobs, events, and tickets. However, the tool is flexible enough that a single entry point may be reasonable for a narrowly focused server.
The tool covers the core need of verified search with hallucination control and customizable result schemas. It lacks obvious pagination or refinement options, but those are not major dead ends for the stated purpose.