Vellum
# Vellum
**An MCP server that gives exact answers about text · By Shuja Jamal**
A language model reads tokens, not characters. Ask one how many letters are in a paragraph and it estimates, often badly. The same is true of diffing two files by eye, or predicting what a regular expression will match without running it.
These are not hard problems. They are simply not the kind of problem a model is built to solve. So it should ask instead.
| | |
| :--- | :--- |
| **Live** | [vellum-mcp.vercel.app](https://vellum-mcp.vercel.app) |
| **Smithery** | [sjshujaj/vellum](https://smithery.ai/server/sjshujaj/vellum) |
| **Write-up** | [EXPERIENCE.md](EXPERIENCE.md), on using existing MCPs before building this |
---
## The five tools
| Tool | What it answers | Instead of |
| :--- | :--- | :--- |
| `count_text` | Characters, words, lines, sentences, bytes | an estimate from token count |
| `score_readability` | Flesch reading ease, grade level, longest sentence | an impression |
| `diff_texts` | Unified diff, lines added and removed, similarity | reading both and describing |
| `test_regex` | Every match, its position and capture groups | reasoning about the pattern |
| `hash_text` | md5, sha1, sha256, sha512 | nothing, it cannot |
`count_text` reports **visible characters separately from code points**, because `café` is four characters to a person, five to `len()`, and six bytes. Which of those you want depends on why you asked, so it returns all three.
---
## Using it
### Claude Code
```bash
claude mcp add --transport http vellum https://vellum-mcp.vercel.app/mcp
```
Or locally, over stdio:
```bash
claude mcp add vellum -- python -m vellum.server
```
**One thing that cost me time when installing somebody else's server:** a server added mid-session shows as connected but its tools are not callable until the session restarts. A working install and a usable install are different states.
### Claude Desktop
In `claude_desktop_config.json`:
```json
{
"mcpServers": {
"vellum": {
"command": "python",
"args": ["-m", "vellum.server"],
"cwd": "/path/to/vellum-mcp"
}
}
}
```
---
## Running it
```bash
pip install -r requirements.txt
```
```bash
python -m vellum.server
```
That is stdio, which is what a local client speaks. For the HTTP transport and the landing page:
```bash
python -m vellum.server --http --port 8000
```
Then `http://localhost:8000` for the page and `http://localhost:8000/mcp` for the endpoint.
---
## Testing
```bash
python tests/test_vellum.py
```
46 checks. The first half calls the analysis functions directly. The second half is the part that matters: it **launches the server as a subprocess and speaks MCP to it**, doing the real handshake, listing the tools, calling them and reading the results back. A server whose functions are correct can still fail to speak the protocol, and only the second half would catch that.
```bash
python tests/test_deploy.py
```
Checks the deployed shape before deploying it: that a rewritten request reaches the MCP app at the path it expects, that `?action=demo` reaches the demo instead, that the static server card describes tools that actually exist, and that there is exactly one function because that is what the runtime builds.
---
## Deploying
### Vercel, for the hosting
```bash
vercel
```
No environment variables and no secrets: every tool is a pure function of its arguments.
The routing is deliberate and worth explaining, because the obvious version does not work.
**A Vercel rewrite does not hand the function the path the browser asked for.** Rewriting `/(.*)` to a single function means every request arrives as `/api/index`, so an app that routes on path answers every URL with its own 404 while looking, from outside, completely dead. I lost an afternoon to exactly that on an earlier project.
**And the Python runtime builds one function for the whole project, not one per file.** I had `api/demo.py` sitting next to `api/index.py`, declared in `vercel.json` and bundled into the deployment, and `/api/demo` still returned Vercel's own 404. `vercel inspect` showed the reason plainly: a single python lambda. Filesystem routing across several Python entrypoints does not happen.
So the deployment is one function plus static files:
```
/ static public/index.html
/.well-known/mcp/server-card.json static generated by build_card.py
/mcp -> /api/index function
/api/demo -> /api/index function, told apart by ?action=demo
```
[`api/index.py`](api/index.py) dispatches on the **query string**, because that is the part a rewrite preserves while it replaces the path, and it puts the path back before the MCP app routes on it. There are tests for both, because neither is something you want to discover from a live URL.
The two things that worked first time were the landing page and the server card, and they are exactly the two that never touch a function.
### Smithery, for the listing
Smithery's current model is **bring your own hosting**: you give it a public HTTPS URL to a streamable HTTP server and its gateway proxies to it. There is no container to build.
1. Deploy to Vercel first and note the URL
2. Go to [smithery.ai/new](https://smithery.ai/new)
3. Enter `https://your-deployment.vercel.app/mcp`
4. Complete the publishing flow
Smithery then reads the server's tools for the listing page. The documented path is a live scan, with a static card at `/.well-known/mcp/server-card.json` as the fallback when a scan cannot complete.
**In practice the fallback was the primary path.** The publish log reads:
```
Server metadata discovered (server card: 5 tools).
Using .well-known/mcp/server-card.json: (5 tools)
```
So the card was not insurance against a failure, it was the mechanism. [`build_card.py`](build_card.py) generates it **from the server's own tool definitions**, which is why it could not have listed tools that do not exist.
The log also warns that no config schema was provided. That is correct and intended here: every tool is a pure function of its arguments, so there is nothing to prompt a user for. The warning matters for a server that wraps an API and needs a key.
[`smithery.yaml`](smithery.yaml) records the details the publishing flow asks for. Note that it does not drive a build: the older container-based deployment path is no longer how this works.
Both tiers are free. Vercel's hobby tier hosts the function and the static files; Smithery's registry listing costs nothing.
---
## How it is put together
```
vellum/
analysis.py the actual work, with no MCP anywhere in it
server.py the five tools, their descriptions, and the transports
api/
index.py the one Vercel function: MCP, and the demo behind a query flag
public/
index.html the landing page
.well-known/mcp/server-card.json generated
tests/
test_vellum.py analysis, then a real MCP handshake over stdio
test_deploy.py the routing and the card
```
`analysis.py` imports nothing from MCP. That is what lets the tests call it directly, and it is why the landing page's demo can call the same functions the tools call rather than being a second implementation that drifts.
---
## Notes on writing tools for a model
**The description is the interface.** It is not documentation for a developer; it is the only thing the model reads when deciding whether a tool is relevant. A vague one means the tool is never chosen, a wrong one means it is chosen at the wrong moment. So `count_text` does not say "returns a dictionary of counts". It says to use this when the answer needs to be a precise number, because reading tokens is not the same as counting characters.
**Failure should be readable.** A broken regex comes back as `{"valid": false, "error": ...}` rather than an exception, and an unknown hash algorithm names the ones that exist. A model can act on that; it cannot act on a stack trace.
**Pick something the model genuinely cannot do.** The temptation is to wrap an API and call it a tool, but a "summarise this text" tool adds a network round trip to something the model already does better itself. Counting, hashing and running a regex are real gaps. Filling a real gap is what makes a tool get used rather than politely ignored.
---
*By Shuja Jamal, August 2026.*
TDQS
Scored across 5 tools
Each tool performs a unique, clearly distinguishable operation: counting metrics, readability assessment, diffing, regex testing, and hashing. There is no overlap or ambiguity between them, so an agent can reliably pick the right tool for a given task.
All tool names follow a consistent verb_noun pattern (count_text, score_readability, diff_texts, test_regex, hash_text) using snake_case throughout. The naming is predictable and immediately conveys the action and object.
With exactly five tools, the server is well-scoped for a text utility purpose. Each tool covers a distinct need without bloat, and the count is within the ideal range for clarity and usability.
The set covers core text analysis and manipulation operations (counting, readability, diffing, regex, hashing). Minor gaps like string transformation or encoding conversion exist, but these are not obvious dead-ends for the primary use cases, so the surface is reasonably complete.