mcp-validation-server 0.1.0__tar.gz

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.
Files changed (25) hide show
  1. mcp_validation_server-0.1.0/LICENSE +21 -0
  2. mcp_validation_server-0.1.0/PKG-INFO +531 -0
  3. mcp_validation_server-0.1.0/README.md +502 -0
  4. mcp_validation_server-0.1.0/pyproject.toml +49 -0
  5. mcp_validation_server-0.1.0/setup.cfg +4 -0
  6. mcp_validation_server-0.1.0/src/mcp_validation_server/__init__.py +85 -0
  7. mcp_validation_server-0.1.0/src/mcp_validation_server/__main__.py +8 -0
  8. mcp_validation_server-0.1.0/src/mcp_validation_server/app.py +39 -0
  9. mcp_validation_server-0.1.0/src/mcp_validation_server/contract.py +800 -0
  10. mcp_validation_server-0.1.0/src/mcp_validation_server/entities.py +652 -0
  11. mcp_validation_server-0.1.0/src/mcp_validation_server/findings.py +204 -0
  12. mcp_validation_server-0.1.0/src/mcp_validation_server/py.typed +0 -0
  13. mcp_validation_server-0.1.0/src/mcp_validation_server/reconcile.py +498 -0
  14. mcp_validation_server-0.1.0/src/mcp_validation_server/server.py +71 -0
  15. mcp_validation_server-0.1.0/src/mcp_validation_server/tools.py +671 -0
  16. mcp_validation_server-0.1.0/src/mcp_validation_server.egg-info/PKG-INFO +531 -0
  17. mcp_validation_server-0.1.0/src/mcp_validation_server.egg-info/SOURCES.txt +23 -0
  18. mcp_validation_server-0.1.0/src/mcp_validation_server.egg-info/dependency_links.txt +1 -0
  19. mcp_validation_server-0.1.0/src/mcp_validation_server.egg-info/entry_points.txt +2 -0
  20. mcp_validation_server-0.1.0/src/mcp_validation_server.egg-info/requires.txt +7 -0
  21. mcp_validation_server-0.1.0/src/mcp_validation_server.egg-info/top_level.txt +1 -0
  22. mcp_validation_server-0.1.0/tests/test_entities.py +310 -0
  23. mcp_validation_server-0.1.0/tests/test_reconcile.py +254 -0
  24. mcp_validation_server-0.1.0/tests/test_tool_contract.py +190 -0
  25. mcp_validation_server-0.1.0/tests/test_validate_rows.py +317 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SK Saimon
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.
@@ -0,0 +1,531 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-validation-server
3
+ Version: 0.1.0
4
+ Summary: Model Context Protocol server exposing deterministic data-validation tools: contract validation with quarantine, reconciliation, distinct entity counts, and duplicate review
5
+ Author: devops-gm88
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/devops-gm88/mcp-validation-server
8
+ Keywords: mcp,model-context-protocol,data-validation,data-quality,reconciliation,llm-tools
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: mcp<3,>=2.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: build>=1.2; extra == "dev"
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Requires-Dist: ruff>=0.6; extra == "dev"
27
+ Requires-Dist: twine>=5.0; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # mcp-validation-server
31
+
32
+ [![CI](https://github.com/devops-gm88/mcp-validation-server/actions/workflows/ci.yml/badge.svg)](https://github.com/devops-gm88/mcp-validation-server/actions/workflows/ci.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/mcp-validation-server.svg)](https://pypi.org/project/mcp-validation-server/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/mcp-validation-server.svg)](https://pypi.org/project/mcp-validation-server/)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-black.svg)](LICENSE)
36
+
37
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that gives a language model a set of
38
+ data-validation tools it cannot argue with.
39
+
40
+ ```bash
41
+ uvx mcp-validation-server # or: pip install mcp-validation-server
42
+ ```
43
+
44
+ **MCP in one sentence:** the Model Context Protocol is an open standard that lets an AI application
45
+ call tools running in a separate process, so the tool does the work and returns a result the model has
46
+ to accept.
47
+
48
+ ## Why this exists
49
+
50
+ A model asked "is this data clean?" will answer. It will answer confidently whether or not it checked
51
+ anything. The fix is not a better prompt; it is to take the question away from the model and give it to
52
+ code that computes an answer and hands it back.
53
+
54
+ This server exposes four deterministic checks — contract validation, arithmetic reconciliation, distinct
55
+ entity counting, and duplicate detection. Each one returns a structured result with reasons and
56
+ row/column locators. None of them need a model, a confidence score, or reference data. All of them can
57
+ be wrong in ways you can see, which is the only kind of check worth trusting.
58
+
59
+ ## The design rule
60
+
61
+ > **A validation failure is a result, not an exception.**
62
+
63
+ Every tool returns a JSON object with the same envelope:
64
+
65
+ | Field | Meaning |
66
+ | --- | --- |
67
+ | `ok` | `true` only when the data has nothing wrong with it. The one field to branch on. |
68
+ | `verdict` | `clean`, `quarantined`, `flagged`, `does_not_close`, `review_required`, `inputs_rejected` |
69
+ | `findings` | One entry per problem: a stable `code`, a human `message`, and a `locator` |
70
+ | `guidance` | Plain English: what to do next |
71
+
72
+ `ok: false` is a **successful call reporting a failing dataset**. The model reads `findings`, sees
73
+ exactly which row and column failed, and can say so instead of guessing.
74
+
75
+ Only a genuinely malformed *tool argument* raises — a contract that does not parse, a regex that does
76
+ not compile, a key that is not a column name. There is nothing to validate until the call itself is
77
+ valid, so the call is rejected with a message naming the problem.
78
+
79
+ The line this server draws:
80
+
81
+ - **The contract is the call's definition** → a broken contract is an argument error.
82
+ - **The rows are the data** → broken rows are a result, with a reason each.
83
+
84
+ ## The four tools
85
+
86
+ ### `validate_rows`
87
+
88
+ Applies a column contract — `required`, `type`, `min`, `max`, `range`, `pattern`, `allowed`,
89
+ `not_blank`, and per-column `on_fail` — and returns **the passing rows and an explicit quarantine
90
+ list**. Every quarantined row carries a locator (index, one-based row number, and the column that
91
+ failed), a list of human-readable `reasons`, and machine-readable `findings`.
92
+
93
+ `accepted + quarantined` always equals the number of rows you passed in. Nothing is ever silently
94
+ dropped. A row that is not even an object is quarantined with that as its reason.
95
+
96
+ ### `reconcile`
97
+
98
+ Given an opening balance, a list of movements, and a closing balance, reports whether
99
+ `closing = opening + movements` holds within a tolerance, showing both sides and the variance. This is
100
+ the check that does not depend on how plausible any single figure looked: clean extraction with quietly
101
+ wrong numbers is the failure that survives field-level validation, and arithmetic is what catches it.
102
+
103
+ When the variance exactly equals the size of one movement, the result says so — that is arithmetic, not
104
+ a guess, and it is usually the answer. When a movement cannot be read, `closes` becomes `null` rather
105
+ than `false`: a total computed over an incomplete set of movements must never be reported as a pass.
106
+
107
+ ### `count_distinct`
108
+
109
+ Counts distinct entities on a caller-supplied stable key and reports **the difference between that and
110
+ the row count**, broken into its two causes: keys repeated across rows, and rows carrying no usable
111
+ key. One customer can appear on fifty rows; "50" is a row count, not a customer count, and the
112
+ difference is the error that survives review because the number looks plausible.
113
+
114
+ Rows with a blank key are excluded from the count and listed individually — never counted as one
115
+ entity, never dropped. Nothing beyond whitespace and letter case is normalised: stripping punctuation
116
+ or applying fuzzy matching changes which records count as the same entity, and that is a decision for
117
+ a person.
118
+
119
+ ### `duplicate_report`
120
+
121
+ Finds suspected duplicate groups and **returns them for human review**. It never merges, never deletes,
122
+ and never picks a winner. Every group carries `auto_merged: false`, `decision_required: true`, the full
123
+ rows with locators, and a `review_question` phrased for a person to answer.
124
+
125
+ Three kinds of suspicion, each labelled:
126
+
127
+ - `same_key` — the same normalised key on more than one row. Genuine duplicates, or legitimate repeated
128
+ transactions? The tool cannot tell, so it reports rather than decides.
129
+ - `same_signature` — different keys whose values across `match_fields` are identical. The
130
+ re-registration case.
131
+ - `near_match` — different keys with similar values. Only produced when you supply
132
+ `similarity_threshold`, because "close enough" is a policy choice, not a fact.
133
+
134
+ ## Install
135
+
136
+ Fastest, with nothing to clone:
137
+
138
+ ```bash
139
+ uvx mcp-validation-server
140
+ ```
141
+
142
+ Or install it into an environment:
143
+
144
+ ```bash
145
+ pip install mcp-validation-server
146
+ ```
147
+
148
+ Or from source, for development:
149
+
150
+ ```bash
151
+ git clone https://github.com/devops-gm88/mcp-validation-server.git
152
+ cd mcp-validation-server
153
+ python3 -m venv .venv
154
+ .venv/bin/pip install -e ".[dev]"
155
+ ```
156
+
157
+ Python 3.10 or newer. `mcp` is the only runtime dependency; everything else is the standard library.
158
+
159
+ ## Register it with an MCP client
160
+
161
+ The server speaks MCP over **stdio**, so an MCP host launches it as a subprocess and talks to it on
162
+ stdin/stdout. This is the standard configuration block used by MCP hosts that launch local servers
163
+ (Claude Desktop, Claude Code, and others):
164
+
165
+ ```json
166
+ {
167
+ "mcpServers": {
168
+ "data-validation": {
169
+ "command": "/absolute/path/to/mcp-validation-server/.venv/bin/python",
170
+ "args": ["-m", "mcp_validation_server"]
171
+ }
172
+ }
173
+ }
174
+ ```
175
+
176
+ Use the **absolute** path to the interpreter — an MCP host does not inherit your shell's `PATH` or
177
+ virtualenv. If you installed with `pipx` or globally, the console script is equivalent:
178
+
179
+ ```json
180
+ {
181
+ "mcpServers": {
182
+ "data-validation": {
183
+ "command": "mcp-validation-server",
184
+ "args": []
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ Verify the server starts and lists its tools without involving a client at all:
191
+
192
+ ```bash
193
+ .venv/bin/python examples/stdio_handshake.py
194
+ ```
195
+
196
+ Or with the official MCP Inspector CLI, which speaks to it as a real MCP host does. Point `command` at
197
+ the same absolute interpreter path as the block above:
198
+
199
+ ```bash
200
+ cat > /tmp/mcp.json <<JSON
201
+ {
202
+ "mcpServers": {
203
+ "data-validation": {
204
+ "command": "$PWD/.venv/bin/python",
205
+ "args": ["-m", "mcp_validation_server"]
206
+ }
207
+ }
208
+ }
209
+ JSON
210
+ npx @modelcontextprotocol/inspector --cli --config /tmp/mcp.json --server data-validation --method tools/list
211
+ ```
212
+
213
+ To call a tool through the Inspector rather than just list it:
214
+
215
+ ```bash
216
+ npx @modelcontextprotocol/inspector --cli --config /tmp/mcp.json --server data-validation \
217
+ --method tools/call --tool-name count_distinct \
218
+ --tool-arg 'rows=[{"id":"C-1"},{"id":"C-1"},{"id":""}]' 'key=id'
219
+ ```
220
+
221
+ ### On the tool schemas
222
+
223
+ The Inspector reports **0 errors and 7 warnings** for these tools, and the warnings are deliberate. Four
224
+ are on the `items` of a `rows` or `columns` array; three are on `reconcile`'s `opening_balance`,
225
+ `movements` and `closing_balance`, which accept a number or a numeric string and report anything else as
226
+ a finding rather than refusing the call:
227
+
228
+ > Schema carries no validation keyword at all, so it accepts any value.
229
+
230
+ That looseness is the design rule, applied at the protocol boundary. If `rows` were declared as
231
+ `array of objects`, a client sending a row that is a bare string would be stopped by the input schema and
232
+ the model would get an unrecoverable protocol error — when the useful answer is "row 3 of your data is a
233
+ string, not an object; here it is, held back with that reason". So row content is intentionally
234
+ unconstrained and every unusable row comes back as a quarantine finding with a locator. What *is*
235
+ constrained is the shape of the call: a missing `key`, a `similarity_threshold` outside 0–1, and a
236
+ column spec that will not parse are all rejected before anything runs.
237
+
238
+ The trade is stated plainly rather than papered over: it costs a schema warning, and it buys a model
239
+ that can recover from bad data instead of just failing on it.
240
+
241
+ ## Worked example
242
+
243
+ Real output. The script that produced it is `examples/worked_example.py`, and the untrimmed result is
244
+ saved beside it as `examples/worked_example_output.json`.
245
+
246
+ A model calls `validate_rows` on four rows of deliberately broken data:
247
+
248
+ ```json
249
+ {
250
+ "rows": [
251
+ {"invoice": "INV-1001", "amount": "100.50", "date": "2026-04-01"},
252
+ {"invoice": "", "amount": "20", "date": "2026-04-02"},
253
+ {"invoice": "INV-1003", "amount": "-5", "date": "2026-04-03"},
254
+ {"invoice": "INV-1004", "amount": "abc", "date": "04/05/2026"}
255
+ ],
256
+ "columns": [
257
+ {"name": "invoice", "type": "string", "required": true, "pattern": "INV-\\d+"},
258
+ {"name": "amount", "type": "number", "min": 0},
259
+ {"name": "date", "type": "date", "required": true}
260
+ ],
261
+ "row_key": "invoice"
262
+ }
263
+ ```
264
+
265
+ and gets back (`is_error: false` — the call succeeded; the data failed):
266
+
267
+ ```json
268
+ {
269
+ "tool": "validate_rows",
270
+ "ok": false,
271
+ "verdict": "quarantined",
272
+ "finding_count": 4,
273
+ "findings": "<<4 findings, one per quarantine reason; see worked_example_output.json>>",
274
+ "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.",
275
+ "contract": "<<the parsed contract echoed back; see worked_example_output.json>>",
276
+ "summary": {
277
+ "rows_in": 4,
278
+ "accepted": 1,
279
+ "quarantined": 3,
280
+ "flagged": 0,
281
+ "pass_rate": 0.25,
282
+ "accounted_for": 4
283
+ },
284
+ "accepted": [
285
+ {
286
+ "invoice": "INV-1001",
287
+ "amount": 100.5,
288
+ "date": "2026-04-01"
289
+ }
290
+ ],
291
+ "quarantine": [
292
+ {
293
+ "locator": {
294
+ "row_index": 1,
295
+ "row_number": 2
296
+ },
297
+ "row": {
298
+ "invoice": "",
299
+ "amount": 20.0,
300
+ "date": "2026-04-02"
301
+ },
302
+ "reasons": [
303
+ "invoice: is required but blank"
304
+ ],
305
+ "findings": [
306
+ {
307
+ "code": "required_blank",
308
+ "severity": "error",
309
+ "message": "is required but blank",
310
+ "locator": {
311
+ "row_index": 1,
312
+ "row_number": 2,
313
+ "column": "invoice"
314
+ },
315
+ "expected": "a non-blank value"
316
+ }
317
+ ]
318
+ },
319
+ {
320
+ "locator": {
321
+ "row_index": 2,
322
+ "row_number": 3,
323
+ "row_key": "INV-1003"
324
+ },
325
+ "row": {
326
+ "invoice": "INV-1003",
327
+ "amount": -5.0,
328
+ "date": "2026-04-03"
329
+ },
330
+ "reasons": [
331
+ "amount: -5 is below the minimum of 0"
332
+ ],
333
+ "findings": [
334
+ {
335
+ "code": "below_minimum",
336
+ "severity": "error",
337
+ "message": "-5 is below the minimum of 0",
338
+ "locator": {
339
+ "row_index": 2,
340
+ "row_number": 3,
341
+ "row_key": "INV-1003",
342
+ "column": "amount"
343
+ },
344
+ "value": -5.0
345
+ }
346
+ ]
347
+ },
348
+ {
349
+ "locator": {
350
+ "row_index": 3,
351
+ "row_number": 4,
352
+ "row_key": "INV-1004"
353
+ },
354
+ "row": {
355
+ "invoice": "INV-1004",
356
+ "amount": "abc",
357
+ "date": "04/05/2026"
358
+ },
359
+ "reasons": [
360
+ "amount: 'abc' is not a number",
361
+ "date: '04/05/2026' is not an ISO-8601 date (expected YYYY-MM-DD)"
362
+ ],
363
+ "findings": [
364
+ {
365
+ "code": "not_a_number",
366
+ "severity": "error",
367
+ "message": "'abc' is not a number",
368
+ "locator": {
369
+ "row_index": 3,
370
+ "row_number": 4,
371
+ "row_key": "INV-1004",
372
+ "column": "amount"
373
+ },
374
+ "value": "abc",
375
+ "expected": "a value of type number"
376
+ },
377
+ {
378
+ "code": "not_a_date",
379
+ "severity": "error",
380
+ "message": "'04/05/2026' is not an ISO-8601 date (expected YYYY-MM-DD)",
381
+ "locator": {
382
+ "row_index": 3,
383
+ "row_number": 4,
384
+ "row_key": "INV-1004",
385
+ "column": "date"
386
+ },
387
+ "value": "04/05/2026",
388
+ "expected": "a value of type date"
389
+ }
390
+ ]
391
+ }
392
+ ],
393
+ "flagged": [],
394
+ "errors_by_column": {
395
+ "amount": 2,
396
+ "date": 1,
397
+ "invoice": 1
398
+ },
399
+ "findings_by_code": {
400
+ "below_minimum": 1,
401
+ "not_a_date": 1,
402
+ "not_a_number": 1,
403
+ "required_blank": 1
404
+ },
405
+ "columns_seen": [],
406
+ "columns_required_but_absent": []
407
+ }
408
+ ```
409
+
410
+ What a model can now do that it could not before: say **"3 of your 4 rows are unusable, here is which
411
+ one and why"**, and be right. The one clean row came back with its string amount coerced to the number
412
+ `100.5`; the three broken rows came back with their original values intact and a reason each. Row 4
413
+ failed twice, on two different columns, and both reasons are listed.
414
+
415
+ Note the locator gives both `row_index` (0-based, for the machine) and `row_number` (1-based, for the
416
+ person reading the report), and quotes `row_key` when the row has one.
417
+
418
+ ## What this does not do
419
+
420
+ - **It does not repair data.** It reports which records are untrustworthy and why. It never fills,
421
+ imputes, or corrects a value.
422
+ - **It does not merge duplicates, ever.** `duplicate_report` returns suspicions with a question
423
+ attached. Merging is a decision with consequences, and it belongs to a person.
424
+ - **It does not score data quality.** There is no 0–100 number and no dashboard. There are findings
425
+ with codes and locators.
426
+ - **It does not prove the data is correct.** Passing a contract means the rows satisfy *the rules you
427
+ supplied and nothing else*. A contract that does not check a column says nothing about that column.
428
+ `reconcile` confirms internal consistency, not completeness: movements that are missing entirely will
429
+ still balance if the closing balance was derived from the same wrong set.
430
+ - **It does not know your domain.** Whether a repeated key is a defect depends on whether the table is a
431
+ transaction log or an entity register. The tools report the number; you decide what it means.
432
+ - **It does not normalise beyond whitespace and case.** No punctuation stripping, no accent folding, no
433
+ nickname tables, no fuzzy matching except where you explicitly ask for a similarity threshold.
434
+ - **It has no persistence, network access, or file access.** Every tool takes its data as an argument
435
+ and returns a result. Nothing is written anywhere.
436
+ - **It does not cap the size of the data you send it.** A large `rows` array comes back in full, in the
437
+ result. `duplicate_report` has a `max_groups` cap that reports what it omitted; `validate_rows` and
438
+ `count_distinct` do not truncate, so pass a summary-sized batch rather than a whole table.
439
+ - **It does not check the caller.** There is no authentication. It is a local stdio server for one
440
+ user's machine.
441
+
442
+ ## Relationship to `sheets-data-validation-kit`
443
+
444
+ This project is a **sibling**, not a wrapper. It is self-contained: `pip install -e .` here installs
445
+ everything it needs, and nothing here imports the other package.
446
+
447
+ The ideas and the naming come from
448
+ [`sheets-data-validation-kit`](https://github.com/devops-gm88/sheets-data-validation-kit), a
449
+ standard-library-only Python library of the same primitives — `Field`, `RowContract`, the rule objects,
450
+ `Min`/`Max`/`Range`/`Pattern`, accept-or-quarantine, exact-match entity resolution, and never
451
+ auto-merging a duplicate. The kit is the library you import; this is the MCP server that puts those
452
+ checks behind a protocol so a model can call them.
453
+
454
+ Two differences are worth knowing:
455
+
456
+ | | `sheets-data-validation-kit` | `mcp-validation-server` |
457
+ | --- | --- | --- |
458
+ | Interface | Python objects you construct in code | JSON arguments over MCP |
459
+ | Contract shape | `Field(...)` with `Rule` instances | Column spec objects; parsed, and unknown keys rejected |
460
+ | Locators | Reasons as strings | Findings with codes and row/column locators |
461
+ | Failure model | Returns a result object | Returns a result, plus a `guidance` string for the model |
462
+
463
+ The server's `contract.py` is a re-implementation for JSON-supplied contracts, not a copy: it adds type
464
+ coercion, locators, per-finding codes, and the parsing layer that turns a model's JSON into rules and
465
+ rejects a malformed contract before any data is touched.
466
+
467
+ ## Tests
468
+
469
+ ```bash
470
+ pip install -e .
471
+ python3 -m unittest discover -s tests -v
472
+ ```
473
+
474
+ 139 tests, all passing on Python 3.14.7 — the count and result are from an actual run, not an estimate.
475
+ Every tool has non-MCP unit tests that call the underlying functions directly; no test needs a client,
476
+ a transport, or a subprocess.
477
+
478
+ Without installing first:
479
+
480
+ ```bash
481
+ PYTHONPATH=src python3 -m unittest discover -s tests -v
482
+ ```
483
+
484
+ The tests exercise the design rule directly: that `accepted + quarantined` equals the input count, that
485
+ a failing dataset never raises, that an unreadable movement withholds the reconciliation verdict instead
486
+ of producing a false pass, that nothing is ever merged, and that the four tools publish one shared
487
+ envelope.
488
+
489
+ ## Layout
490
+
491
+ ```
492
+ mcp-validation-server/
493
+ ├── examples/
494
+ │ ├── stdio_handshake.py # start the server and list its tools, no client needed
495
+ │ ├── worked_example.py # produces the README's worked example
496
+ │ └── worked_example_output.json # the untrimmed result, as produced
497
+ ├── src/mcp_validation_server/
498
+ │ ├── __init__.py # public API
499
+ │ ├── __main__.py # python3 -m mcp_validation_server
500
+ │ ├── app.py # the MCPServer instance and its instructions
501
+ │ ├── tools.py # the four @mcp.tool() functions and their docstrings
502
+ │ ├── findings.py # Finding, Locator, the shared response envelope
503
+ │ ├── contract.py # column specs, rules, RowContract, quarantine
504
+ │ ├── reconcile.py # opening + movements = closing
505
+ │ ├── entities.py # distinct counting, duplicate grouping
506
+ │ └── server.py # entry point and transport selection
507
+ └── tests/ # stdlib unittest, no MCP client required
508
+ ```
509
+
510
+ ## Notes on the SDK
511
+
512
+ Built against the official MCP Python SDK, `mcp` 2.2.0. Two things differ from most published examples,
513
+ which were written for `mcp` 1.x:
514
+
515
+ - **`FastMCP` was renamed to `MCPServer`.** In `mcp` 2.x the import is
516
+ `from mcp.server.mcpserver import MCPServer`; `mcp.server.fastmcp` no longer exists and raises a
517
+ `ModuleNotFoundError` pointing at the migration guide. `pyproject.toml` therefore requires
518
+ `mcp>=2.0,<3`.
519
+ - **Only `ToolError` carries a message to the caller.** Any other exception raised inside a tool is
520
+ wrapped by the SDK as `UnexpectedToolError`, whose text is the generic
521
+ `Error executing tool <name>`, with the original logged server-side only. Since a contract error the
522
+ model cannot read is a contract error the model cannot fix, malformed arguments are raised as
523
+ `mcp.server.mcpserver.exceptions.ToolError`.
524
+ - **Return annotations matter for structured output.** Annotating a tool with `typing.Dict[str, Any]`
525
+ makes the SDK publish an output schema of `{"result": {...}}` and nest `structuredContent` one level
526
+ down; the PEP 585 `dict[str, Any]` annotation keeps the payload at the top level. These tools use
527
+ `dict[str, Any]`.
528
+
529
+ ## Licence
530
+
531
+ MIT. See [LICENSE](LICENSE).