mcp-validation-server
A Model Context Protocol server that gives a language model a set of data-validation tools it cannot argue with.
uvx mcp-validation-server # or: pip install mcp-validation-server
MCP in one sentence: the Model Context Protocol is an open standard that lets an AI application call tools running in a separate process, so the tool does the work and returns a result the model has to accept.
Why this exists
A model asked "is this data clean?" will answer. It will answer confidently whether or not it checked anything. The fix is not a better prompt; it is to take the question away from the model and give it to code that computes an answer and hands it back.
This server exposes four deterministic checks — contract validation, arithmetic reconciliation, distinct entity counting, and duplicate detection. Each one returns a structured result with reasons and row/column locators. None of them need a model, a confidence score, or reference data. All of them can be wrong in ways you can see, which is the only kind of check worth trusting.
The design rule
A validation failure is a result, not an exception.
Every tool returns a JSON object with the same envelope:
| Field | Meaning |
|---|---|
ok |
true only when the data has nothing wrong with it. The one field to branch on. |
verdict |
clean, quarantined, flagged, does_not_close, review_required, inputs_rejected |
findings |
One entry per problem: a stable code, a human message, and a locator |
guidance |
Plain English: what to do next |
ok: false is a successful call reporting a failing dataset. The model reads findings, sees
exactly which row and column failed, and can say so instead of guessing.
Only a genuinely malformed tool argument raises — a contract that does not parse, a regex that does not compile, a key that is not a column name. There is nothing to validate until the call itself is valid, so the call is rejected with a message naming the problem.
The line this server draws:
- The contract is the call's definition → a broken contract is an argument error.
- The rows are the data → broken rows are a result, with a reason each.
The four tools
validate_rows
Applies a column contract — required, type, min, max, range, pattern, allowed,
not_blank, and per-column on_fail — and returns the passing rows and an explicit quarantine
list. Every quarantined row carries a locator (index, one-based row number, and the column that
failed), a list of human-readable reasons, and machine-readable findings.
accepted + quarantined always equals the number of rows you passed in. Nothing is ever silently
dropped. A row that is not even an object is quarantined with that as its reason.
reconcile
Given an opening balance, a list of movements, and a closing balance, reports whether
closing = opening + movements holds within a tolerance, showing both sides and the variance. This is
the check that does not depend on how plausible any single figure looked: clean extraction with quietly
wrong numbers is the failure that survives field-level validation, and arithmetic is what catches it.
When the variance exactly equals the size of one movement, the result says so — that is arithmetic, not
a guess, and it is usually the answer. When a movement cannot be read, closes becomes null rather
than false: a total computed over an incomplete set of movements must never be reported as a pass.
count_distinct
Counts distinct entities on a caller-supplied stable key and reports the difference between that and the row count, broken into its two causes: keys repeated across rows, and rows carrying no usable key. One customer can appear on fifty rows; "50" is a row count, not a customer count, and the difference is the error that survives review because the number looks plausible.
Rows with a blank key are excluded from the count and listed individually — never counted as one entity, never dropped. Nothing beyond whitespace and letter case is normalised: stripping punctuation or applying fuzzy matching changes which records count as the same entity, and that is a decision for a person.
duplicate_report
Finds suspected duplicate groups and returns them for human review. It never merges, never deletes,
and never picks a winner. Every group carries auto_merged: false, decision_required: true, the full
rows with locators, and a review_question phrased for a person to answer.
Three kinds of suspicion, each labelled:
same_key— the same normalised key on more than one row. Genuine duplicates, or legitimate repeated transactions? The tool cannot tell, so it reports rather than decides.same_signature— different keys whose values acrossmatch_fieldsare identical. The re-registration case.near_match— different keys with similar values. Only produced when you supplysimilarity_threshold, because "close enough" is a policy choice, not a fact.
Install
Fastest, with nothing to clone:
uvx mcp-validation-server
Or install it into an environment:
pip install mcp-validation-server
Or from source, for development:
git clone https://github.com/devops-gm88/mcp-validation-server.git
cd mcp-validation-server
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
Python 3.10 or newer. mcp is the only runtime dependency; everything else is the standard library.
Register it with an MCP client
The server speaks MCP over stdio, so an MCP host launches it as a subprocess and talks to it on stdin/stdout. This is the standard configuration block used by MCP hosts that launch local servers (Claude Desktop, Claude Code, and others):
{
"mcpServers": {
"data-validation": {
"command": "/absolute/path/to/mcp-validation-server/.venv/bin/python",
"args": ["-m", "mcp_validation_server"]
}
}
}
Use the absolute path to the interpreter — an MCP host does not inherit your shell's PATH or
virtualenv. If you installed with pipx or globally, the console script is equivalent:
{
"mcpServers": {
"data-validation": {
"command": "mcp-validation-server",
"args": []
}
}
}
Verify the server starts and lists its tools without involving a client at all:
.venv/bin/python examples/stdio_handshake.py
Or with the official MCP Inspector CLI, which speaks to it as a real MCP host does. Point command at
the same absolute interpreter path as the block above:
cat > /tmp/mcp.json <<JSON
{
"mcpServers": {
"data-validation": {
"command": "$PWD/.venv/bin/python",
"args": ["-m", "mcp_validation_server"]
}
}
}
JSON
npx @modelcontextprotocol/inspector --cli --config /tmp/mcp.json --server data-validation --method tools/list
To call a tool through the Inspector rather than just list it:
npx @modelcontextprotocol/inspector --cli --config /tmp/mcp.json --server data-validation \
--method tools/call --tool-name count_distinct \
--tool-arg 'rows=[{"id":"C-1"},{"id":"C-1"},{"id":""}]' 'key=id'
On the tool schemas
The Inspector reports 0 errors and 7 warnings for these tools, and the warnings are deliberate. Four
are on the items of a rows or columns array; three are on reconcile's opening_balance,
movements and closing_balance, which accept a number or a numeric string and report anything else as
a finding rather than refusing the call:
Schema carries no validation keyword at all, so it accepts any value.
That looseness is the design rule, applied at the protocol boundary. If rows were declared as
array of objects, a client sending a row that is a bare string would be stopped by the input schema and
the model would get an unrecoverable protocol error — when the useful answer is "row 3 of your data is a
string, not an object; here it is, held back with that reason". So row content is intentionally
unconstrained and every unusable row comes back as a quarantine finding with a locator. What is
constrained is the shape of the call: a missing key, a similarity_threshold outside 0–1, and a
column spec that will not parse are all rejected before anything runs.
The trade is stated plainly rather than papered over: it costs a schema warning, and it buys a model that can recover from bad data instead of just failing on it.
Worked example
Real output. The script that produced it is examples/worked_example.py, and the untrimmed result is
saved beside it as examples/worked_example_output.json.
A model calls validate_rows on four rows of deliberately broken data:
{
"rows": [
{"invoice": "INV-1001", "amount": "100.50", "date": "2026-04-01"},
{"invoice": "", "amount": "20", "date": "2026-04-02"},
{"invoice": "INV-1003", "amount": "-5", "date": "2026-04-03"},
{"invoice": "INV-1004", "amount": "abc", "date": "04/05/2026"}
],
"columns": [
{"name": "invoice", "type": "string", "required": true, "pattern": "INV-\\d+"},
{"name": "amount", "type": "number", "min": 0},
{"name": "date", "type": "date", "required": true}
],
"row_key": "invoice"
}
and gets back (is_error: false — the call succeeded; the data failed):
{
"tool": "validate_rows",
"ok": false,
"verdict": "quarantined",
"finding_count": 4,
"findings": "<<4 findings, one per quarantine reason; see worked_example_output.json>>",
"guidance": "3 of 4 rows were quarantined and are listed in `quarantine` with a locator and reasons. Failure codes: below_minimum (1), not_a_date (1), not_a_number (1), required_blank (1). Do not use the accepted rows as if they were the whole dataset: report both counts, and resolve or explicitly exclude the quarantined rows before aggregating.",
"contract": "<<the parsed contract echoed back; see worked_example_output.json>>",
"summary": {
"rows_in": 4,
"accepted": 1,
"quarantined": 3,
"flagged": 0,
"pass_rate": 0.25,
"accounted_for": 4
},
"accepted": [
{
"invoice": "INV-1001",
"amount": 100.5,
"date": "2026-04-01"
}
],
"quarantine": [
{
"locator": {
"row_index": 1,
"row_number": 2
},
"row": {
"invoice": "",
"amount": 20.0,
"date": "2026-04-02"
},
"reasons": [
"invoice: is required but blank"
],
"findings": [
{
"code": "required_blank",
"severity": "error",
"message": "is required but blank",
"locator": {
"row_index": 1,
"row_number": 2,
"column": "invoice"
},
"expected": "a non-blank value"
}
]
},
{
"locator": {
"row_index": 2,
"row_number": 3,
"row_key": "INV-1003"
},
"row": {
"invoice": "INV-1003",
"amount": -5.0,
"date": "2026-04-03"
},
"reasons": [
"amount: -5 is below the minimum of 0"
],
"findings": [
{
"code": "below_minimum",
"severity": "error",
"message": "-5 is below the minimum of 0",
"locator": {
"row_index": 2,
"row_number": 3,
"row_key": "INV-1003",
"column": "amount"
},
"value": -5.0
}
]
},
{
"locator": {
"row_index": 3,
"row_number": 4,
"row_key": "INV-1004"
},
"row": {
"invoice": "INV-1004",
"amount": "abc",
"date": "04/05/2026"
},
"reasons": [
"amount: 'abc' is not a number",
"date: '04/05/2026' is not an ISO-8601 date (expected YYYY-MM-DD)"
],
"findings": [
{
"code": "not_a_number",
"severity": "error",
"message": "'abc' is not a number",
"locator": {
"row_index": 3,
"row_number": 4,
"row_key": "INV-1004",
"column": "amount"
},
"value": "abc",
"expected": "a value of type number"
},
{
"code": "not_a_date",
"severity": "error",
"message": "'04/05/2026' is not an ISO-8601 date (expected YYYY-MM-DD)",
"locator": {
"row_index": 3,
"row_number": 4,
"row_key": "INV-1004",
"column": "date"
},
"value": "04/05/2026",
"expected": "a value of type date"
}
]
}
],
"flagged": [],
"errors_by_column": {
"amount": 2,
"date": 1,
"invoice": 1
},
"findings_by_code": {
"below_minimum": 1,
"not_a_date": 1,
"not_a_number": 1,
"required_blank": 1
},
"columns_seen": [],
"columns_required_but_absent": []
}
What a model can now do that it could not before: say "3 of your 4 rows are unusable, here is which
one and why", and be right. The one clean row came back with its string amount coerced to the number
100.5; the three broken rows came back with their original values intact and a reason each. Row 4
failed twice, on two different columns, and both reasons are listed.
Note the locator gives both row_index (0-based, for the machine) and row_number (1-based, for the
person reading the report), and quotes row_key when the row has one.
What this does not do
- It does not repair data. It reports which records are untrustworthy and why. It never fills, imputes, or corrects a value.
- It does not merge duplicates, ever.
duplicate_reportreturns suspicions with a question attached. Merging is a decision with consequences, and it belongs to a person. - It does not score data quality. There is no 0–100 number and no dashboard. There are findings with codes and locators.
- It does not prove the data is correct. Passing a contract means the rows satisfy the rules you
supplied and nothing else. A contract that does not check a column says nothing about that column.
reconcileconfirms internal consistency, not completeness: movements that are missing entirely will still balance if the closing balance was derived from the same wrong set. - It does not know your domain. Whether a repeated key is a defect depends on whether the table is a transaction log or an entity register. The tools report the number; you decide what it means.
- It does not normalise beyond whitespace and case. No punctuation stripping, no accent folding, no nickname tables, no fuzzy matching except where you explicitly ask for a similarity threshold.
- It has no persistence, network access, or file access. Every tool takes its data as an argument and returns a result. Nothing is written anywhere.
- It does not cap the size of the data you send it. A large
rowsarray comes back in full, in the result.duplicate_reporthas amax_groupscap that reports what it omitted;validate_rowsandcount_distinctdo not truncate, so pass a summary-sized batch rather than a whole table. - It does not check the caller. There is no authentication. It is a local stdio server for one user's machine.
Relationship to sheets-data-validation-kit
This project is a sibling, not a wrapper. It is self-contained: pip install -e . here installs
everything it needs, and nothing here imports the other package.
The ideas and the naming come from
sheets-data-validation-kit, a
standard-library-only Python library of the same primitives — Field, RowContract, the rule objects,
Min/Max/Range/Pattern, accept-or-quarantine, exact-match entity resolution, and never
auto-merging a duplicate. The kit is the library you import; this is the MCP server that puts those
checks behind a protocol so a model can call them.
Two differences are worth knowing:
sheets-data-validation-kit |
mcp-validation-server |
|
|---|---|---|
| Interface | Python objects you construct in code | JSON arguments over MCP |
| Contract shape | Field(...) with Rule instances |
Column spec objects; parsed, and unknown keys rejected |
| Locators | Reasons as strings | Findings with codes and row/column locators |
| Failure model | Returns a result object | Returns a result, plus a guidance string for the model |
The server's contract.py is a re-implementation for JSON-supplied contracts, not a copy: it adds type
coercion, locators, per-finding codes, and the parsing layer that turns a model's JSON into rules and
rejects a malformed contract before any data is touched.
Tests
pip install -e .
python3 -m unittest discover -s tests -v
139 tests, all passing on Python 3.14.7 — the count and result are from an actual run, not an estimate. Every tool has non-MCP unit tests that call the underlying functions directly; no test needs a client, a transport, or a subprocess.
Without installing first:
PYTHONPATH=src python3 -m unittest discover -s tests -v
The tests exercise the design rule directly: that accepted + quarantined equals the input count, that
a failing dataset never raises, that an unreadable movement withholds the reconciliation verdict instead
of producing a false pass, that nothing is ever merged, and that the four tools publish one shared
envelope.
Layout
mcp-validation-server/
├── examples/
│ ├── stdio_handshake.py # start the server and list its tools, no client needed
│ ├── worked_example.py # produces the README's worked example
│ └── worked_example_output.json # the untrimmed result, as produced
├── src/mcp_validation_server/
│ ├── __init__.py # public API
│ ├── __main__.py # python3 -m mcp_validation_server
│ ├── app.py # the MCPServer instance and its instructions
│ ├── tools.py # the four @mcp.tool() functions and their docstrings
│ ├── findings.py # Finding, Locator, the shared response envelope
│ ├── contract.py # column specs, rules, RowContract, quarantine
│ ├── reconcile.py # opening + movements = closing
│ ├── entities.py # distinct counting, duplicate grouping
│ └── server.py # entry point and transport selection
└── tests/ # stdlib unittest, no MCP client required
Notes on the SDK
Built against the official MCP Python SDK, mcp 2.2.0. Two things differ from most published examples,
which were written for mcp 1.x:
FastMCPwas renamed toMCPServer. Inmcp2.x the import isfrom mcp.server.mcpserver import MCPServer;mcp.server.fastmcpno longer exists and raises aModuleNotFoundErrorpointing at the migration guide.pyproject.tomltherefore requiresmcp>=2.0,<3.- Only
ToolErrorcarries a message to the caller. Any other exception raised inside a tool is wrapped by the SDK asUnexpectedToolError, whose text is the genericError executing tool <name>, with the original logged server-side only. Since a contract error the model cannot read is a contract error the model cannot fix, malformed arguments are raised asmcp.server.mcpserver.exceptions.ToolError. - Return annotations matter for structured output. Annotating a tool with
typing.Dict[str, Any]makes the SDK publish an output schema of{"result": {...}}and neststructuredContentone level down; the PEP 585dict[str, Any]annotation keeps the payload at the top level. These tools usedict[str, Any].
Licence
MIT. See LICENSE.
Metadata
Release files for mcp-validation-server 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_validation_server-0.1.0.tar.gz | 56.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_validation_server-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 100.8 kB
Release files / mcp_validation_server-0.1.0.tar.gz
| Download URL | mcp_validation_server-0.1.0.tar.gz |
|---|---|
| Size | 56.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
959d7bb58bb3835ffea735a4c68cac071ded0ddaa87a236ac5b7fb8839db31ea
|
|
BLAKE2b-256 checksum How to use checksums |
801abc2dfd7dad0a91c50b0dc057e0380315accdf78f9be52cf7c0ec62cb86d9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|
Release files / mcp_validation_server-0.1.0-py3-none-any.whl
| Download URL | mcp_validation_server-0.1.0-py3-none-any.whl |
|---|---|
| Size | 44.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b239196291683d2b97357906e0f26d460e6a3a6def3440c291c34fe0f6044ed5
|
|
BLAKE2b-256 checksum How to use checksums |
8129901cf2bc68800165446255dec7de226c6768b9f97a55e543d6ca72df4252
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|