mcp-claimcheck 0.1.0a1__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.
- mcp_claimcheck-0.1.0a1/.gitignore +27 -0
- mcp_claimcheck-0.1.0a1/LICENSE +21 -0
- mcp_claimcheck-0.1.0a1/PKG-INFO +62 -0
- mcp_claimcheck-0.1.0a1/README.md +36 -0
- mcp_claimcheck-0.1.0a1/pyproject.toml +114 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/__init__.py +3 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/__main__.py +6 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/cli.py +151 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/config.py +165 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/__init__.py +17 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/expressions.py +279 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/loader.py +911 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/model.py +103 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/problems.py +83 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/log.py +137 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/payload.py +157 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/__init__.py +1 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/_windows.py +142 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/framing.py +49 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/interceptor.py +418 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/process.py +240 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/session.py +299 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/stdio.py +493 -0
- mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/py.typed +0 -0
- mcp_claimcheck-0.1.0a1/tests/conftest.py +45 -0
- mcp_claimcheck-0.1.0a1/tests/fixtures/fake_server.py +194 -0
- mcp_claimcheck-0.1.0a1/tests/fixtures/sdk_server.py +39 -0
- mcp_claimcheck-0.1.0a1/tests/proxy_harness.py +162 -0
- mcp_claimcheck-0.1.0a1/tests/real_servers.py +231 -0
- mcp_claimcheck-0.1.0a1/tests/stdout_guard.py +270 -0
- mcp_claimcheck-0.1.0a1/tests/test_capture.py +319 -0
- mcp_claimcheck-0.1.0a1/tests/test_cli.py +190 -0
- mcp_claimcheck-0.1.0a1/tests/test_config.py +137 -0
- mcp_claimcheck-0.1.0a1/tests/test_contracts.py +839 -0
- mcp_claimcheck-0.1.0a1/tests/test_expressions.py +282 -0
- mcp_claimcheck-0.1.0a1/tests/test_fail_open.py +182 -0
- mcp_claimcheck-0.1.0a1/tests/test_framing.py +89 -0
- mcp_claimcheck-0.1.0a1/tests/test_interceptor.py +502 -0
- mcp_claimcheck-0.1.0a1/tests/test_log.py +133 -0
- mcp_claimcheck-0.1.0a1/tests/test_payload.py +197 -0
- mcp_claimcheck-0.1.0a1/tests/test_process.py +253 -0
- mcp_claimcheck-0.1.0a1/tests/test_proxy.py +436 -0
- mcp_claimcheck-0.1.0a1/tests/test_readme.py +67 -0
- mcp_claimcheck-0.1.0a1/tests/test_real_servers.py +506 -0
- mcp_claimcheck-0.1.0a1/tests/test_session.py +283 -0
- mcp_claimcheck-0.1.0a1/tests/test_stdout_guard.py +280 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
|
|
8
|
+
# Environments
|
|
9
|
+
.venv/
|
|
10
|
+
venv/
|
|
11
|
+
|
|
12
|
+
# Tool caches
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.ruff_cache/
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
htmlcov/
|
|
18
|
+
|
|
19
|
+
# Editors / OS
|
|
20
|
+
.vscode/
|
|
21
|
+
.idea/
|
|
22
|
+
.DS_Store
|
|
23
|
+
Thumbs.db
|
|
24
|
+
|
|
25
|
+
# Runtime output (later phases write traces and baselines; never commit real data)
|
|
26
|
+
*.jsonl
|
|
27
|
+
.mcp-claimcheck/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roydon Sequeira
|
|
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,62 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-claimcheck
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Pre-alpha: runtime semantic verification for MCP tool calls. It will check whether a tool actually satisfied the operation it reported as successful.
|
|
5
|
+
Author: Roydon Sequeira
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ai-agents,contracts,mcp,model-context-protocol,verification
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: click>=8.1
|
|
23
|
+
Requires-Dist: jsonschema>=4.21
|
|
24
|
+
Requires-Dist: pyyaml>=6.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# mcp-claimcheck
|
|
28
|
+
|
|
29
|
+
> **Status: pre-alpha, under construction.** Phases 1 to 3 of 15 are done:
|
|
30
|
+
>
|
|
31
|
+
> - a transparent passthrough proxy (`mcp-claimcheck run -- <server command>`) that
|
|
32
|
+
> forwards MCP traffic unchanged;
|
|
33
|
+
> - which reads each tool result and logs what it saw (`--log-level info`);
|
|
34
|
+
> - and contracts, which can now be written and validated (`mcp-claimcheck check`).
|
|
35
|
+
> The format is described in `docs/CONTRACTS.md` in the source repository, which is
|
|
36
|
+
> not public yet.
|
|
37
|
+
>
|
|
38
|
+
> **Nothing is verified against a contract yet**: contracts are read and checked for
|
|
39
|
+
> being well formed, and no tool call is judged against one. The rest of this README
|
|
40
|
+
> describes the goal, not current behaviour.
|
|
41
|
+
|
|
42
|
+
Your agent called the tool.
|
|
43
|
+
The tool returned success.
|
|
44
|
+
Did the operation actually succeed?
|
|
45
|
+
|
|
46
|
+
`mcp-claimcheck` is a runtime semantic verification layer for MCP tool calls. It
|
|
47
|
+
will sit between an MCP client and an upstream MCP server, check each `tools/call`
|
|
48
|
+
result against declared contracts and built-in heuristics, and report when a
|
|
49
|
+
result does not satisfy the operation it claims to have completed.
|
|
50
|
+
|
|
51
|
+
## What it will not do
|
|
52
|
+
|
|
53
|
+
- **It cannot block execution.** It inspects responses, so by the time it sees a
|
|
54
|
+
result the upstream tool has already run. What it can do is stop the agent
|
|
55
|
+
from reporting a success that did not happen.
|
|
56
|
+
- **It cannot see inside MCP servers.** It only sees MCP JSON-RPC traffic. If a
|
|
57
|
+
server hides a backend failure and returns an empty result, that empty result
|
|
58
|
+
is reported as unverifiable, not as a failure.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT. The full text ships with the package as `LICENSE`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# mcp-claimcheck
|
|
2
|
+
|
|
3
|
+
> **Status: pre-alpha, under construction.** Phases 1 to 3 of 15 are done:
|
|
4
|
+
>
|
|
5
|
+
> - a transparent passthrough proxy (`mcp-claimcheck run -- <server command>`) that
|
|
6
|
+
> forwards MCP traffic unchanged;
|
|
7
|
+
> - which reads each tool result and logs what it saw (`--log-level info`);
|
|
8
|
+
> - and contracts, which can now be written and validated (`mcp-claimcheck check`).
|
|
9
|
+
> The format is described in `docs/CONTRACTS.md` in the source repository, which is
|
|
10
|
+
> not public yet.
|
|
11
|
+
>
|
|
12
|
+
> **Nothing is verified against a contract yet**: contracts are read and checked for
|
|
13
|
+
> being well formed, and no tool call is judged against one. The rest of this README
|
|
14
|
+
> describes the goal, not current behaviour.
|
|
15
|
+
|
|
16
|
+
Your agent called the tool.
|
|
17
|
+
The tool returned success.
|
|
18
|
+
Did the operation actually succeed?
|
|
19
|
+
|
|
20
|
+
`mcp-claimcheck` is a runtime semantic verification layer for MCP tool calls. It
|
|
21
|
+
will sit between an MCP client and an upstream MCP server, check each `tools/call`
|
|
22
|
+
result against declared contracts and built-in heuristics, and report when a
|
|
23
|
+
result does not satisfy the operation it claims to have completed.
|
|
24
|
+
|
|
25
|
+
## What it will not do
|
|
26
|
+
|
|
27
|
+
- **It cannot block execution.** It inspects responses, so by the time it sees a
|
|
28
|
+
result the upstream tool has already run. What it can do is stop the agent
|
|
29
|
+
from reporting a success that did not happen.
|
|
30
|
+
- **It cannot see inside MCP servers.** It only sees MCP JSON-RPC traffic. If a
|
|
31
|
+
server hides a backend failure and returns an empty result, that empty result
|
|
32
|
+
is reported as unverifiable, not as a failure.
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT. The full text ships with the package as `LICENSE`.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# 1.27 is the first release whose default core metadata is 2.4, the version that
|
|
3
|
+
# carries PEP 639 License-Expression and License-File (hatchling changelog, 1.27.0).
|
|
4
|
+
requires = ["hatchling>=1.27"]
|
|
5
|
+
build-backend = "hatchling.build"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "mcp-claimcheck"
|
|
9
|
+
dynamic = ["version"]
|
|
10
|
+
description = "Pre-alpha: runtime semantic verification for MCP tool calls. It will check whether a tool actually satisfied the operation it reported as successful."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
requires-python = ">=3.11"
|
|
15
|
+
authors = [{ name = "Roydon Sequeira" }]
|
|
16
|
+
keywords = ["mcp", "model-context-protocol", "verification", "contracts", "ai-agents"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
# Every dependency is install friction. Add one only in the phase that needs it.
|
|
32
|
+
dependencies = [
|
|
33
|
+
"click>=8.1", # Phase 0: the command line
|
|
34
|
+
# Phase 3: contracts are YAML (DECISIONS D-013). The standard library has no YAML
|
|
35
|
+
# parser, and `compose` gives the line and column numbers that contract errors must
|
|
36
|
+
# name. Only `safe_load`/`compose` are ever used: never `load`, which can construct
|
|
37
|
+
# arbitrary Python objects.
|
|
38
|
+
"pyyaml>=6.0",
|
|
39
|
+
# Phase 3: meta-validating a contract's JSON Schema (`check_schema`). Phase 4
|
|
40
|
+
# validates payloads against those schemas with the same library, so the dialect
|
|
41
|
+
# a contract is checked against is the dialect it is later enforced with.
|
|
42
|
+
"jsonschema>=4.21",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
mcp-claimcheck = "mcp_claimcheck.cli:main"
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=8.3",
|
|
51
|
+
# A hung test must fail, not stall CI: hangs are the bug class a proxy is prone to.
|
|
52
|
+
"pytest-timeout>=2.3",
|
|
53
|
+
"ruff>=0.13",
|
|
54
|
+
"mypy>=1.18",
|
|
55
|
+
"mcp==2.2.0",
|
|
56
|
+
# Type stubs only, so they stay out of the installed package. Phase 3.
|
|
57
|
+
"types-PyYAML>=6.0",
|
|
58
|
+
"types-jsonschema>=4.21",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.version]
|
|
62
|
+
path = "src/mcp_claimcheck/__init__.py"
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.wheel]
|
|
65
|
+
packages = ["src/mcp_claimcheck"]
|
|
66
|
+
|
|
67
|
+
[tool.hatch.build.targets.sdist]
|
|
68
|
+
include = ["src", "tests", "LICENSE", "README.md", "pyproject.toml"]
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------- ruff
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
target-version = "py311"
|
|
73
|
+
line-length = 100
|
|
74
|
+
src = ["src", "tests"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = [
|
|
78
|
+
"E", "W", # pycodestyle
|
|
79
|
+
"F", # pyflakes
|
|
80
|
+
"I", # isort
|
|
81
|
+
"B", # bugbear
|
|
82
|
+
"UP", # pyupgrade
|
|
83
|
+
"SIM", # simplify
|
|
84
|
+
"RUF", # ruff-specific
|
|
85
|
+
"T10", # debugger calls (pdb writes to stdout)
|
|
86
|
+
"T20", # Rule 5: print / pprint
|
|
87
|
+
"S102", # Rule 3: exec
|
|
88
|
+
"S307", # Rule 3: eval
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint.isort]
|
|
92
|
+
# Test helper imported from the tests directory, not an installed package.
|
|
93
|
+
known-local-folder = ["stdout_guard"]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.lint.per-file-ignores]
|
|
96
|
+
# Tests may print freely; they never run on the proxy's stdio transport.
|
|
97
|
+
# The adversarial snippets in the stdout guard test are strings, not calls.
|
|
98
|
+
"tests/**" = ["T20"]
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------- mypy
|
|
101
|
+
[tool.mypy]
|
|
102
|
+
python_version = "3.11"
|
|
103
|
+
strict = true
|
|
104
|
+
warn_unreachable = true
|
|
105
|
+
files = ["src", "tests"]
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------- pytest
|
|
108
|
+
[tool.pytest.ini_options]
|
|
109
|
+
minversion = "8.3"
|
|
110
|
+
testpaths = ["tests"]
|
|
111
|
+
addopts = ["-ra", "--strict-markers", "--strict-config"]
|
|
112
|
+
xfail_strict = true
|
|
113
|
+
filterwarnings = ["error"]
|
|
114
|
+
timeout = 120
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Command-line entrypoint.
|
|
2
|
+
|
|
3
|
+
Rule 5: this module must never write to stdout itself. ``--version`` and ``--help``
|
|
4
|
+
are written to stdout by click's built-in options. Both are one-shot informational
|
|
5
|
+
commands that exit immediately, so they can never run on the proxy's stdio transport.
|
|
6
|
+
Usage errors go to stderr (click's default).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from mcp_claimcheck import __version__
|
|
17
|
+
from mcp_claimcheck import log as logsetup
|
|
18
|
+
from mcp_claimcheck.config import Config, ConfigError, from_command_line, load
|
|
19
|
+
|
|
20
|
+
LOG_LEVELS = ["debug", "info", "warning", "error"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
24
|
+
@click.version_option(__version__, "-V", "--version", prog_name="mcp-claimcheck")
|
|
25
|
+
def main() -> None:
|
|
26
|
+
"""Runtime semantic verification for MCP tool calls."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@main.command(
|
|
30
|
+
context_settings={"allow_interspersed_args": False},
|
|
31
|
+
short_help="Run an MCP server behind the proxy.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--config",
|
|
35
|
+
"config_path",
|
|
36
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
37
|
+
help="TOML config file naming the upstream server (instead of a command).",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--shutdown-timeout",
|
|
41
|
+
type=click.FloatRange(min=0.0, max=600.0),
|
|
42
|
+
default=5.0,
|
|
43
|
+
show_default=True,
|
|
44
|
+
help="Seconds to wait for the upstream to exit before forcing it.",
|
|
45
|
+
)
|
|
46
|
+
@click.option(
|
|
47
|
+
"--log-level",
|
|
48
|
+
type=click.Choice(LOG_LEVELS, case_sensitive=False),
|
|
49
|
+
default="warning",
|
|
50
|
+
show_default=True,
|
|
51
|
+
help="How much to log to stderr.",
|
|
52
|
+
)
|
|
53
|
+
@click.argument("command", nargs=-1, type=click.UNPROCESSED)
|
|
54
|
+
def run(
|
|
55
|
+
config_path: Path | None,
|
|
56
|
+
shutdown_timeout: float,
|
|
57
|
+
log_level: str,
|
|
58
|
+
command: tuple[str, ...],
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Start an MCP server and relay traffic between it and the MCP client.
|
|
61
|
+
|
|
62
|
+
Give the server either as a command after `--`:
|
|
63
|
+
|
|
64
|
+
mcp-claimcheck run -- npx -y @modelcontextprotocol/server-everything
|
|
65
|
+
|
|
66
|
+
or in a config file:
|
|
67
|
+
|
|
68
|
+
mcp-claimcheck run --config claimcheck.toml
|
|
69
|
+
|
|
70
|
+
The MCP client launches mcp-claimcheck instead of the server itself. stdout
|
|
71
|
+
carries only MCP messages; diagnostics go to stderr.
|
|
72
|
+
"""
|
|
73
|
+
config = _resolve_config(config_path, command)
|
|
74
|
+
handler = logsetup.configure(log_level)
|
|
75
|
+
# Imported here so `--version` and `--help` stay fast and import nothing else.
|
|
76
|
+
from mcp_claimcheck.proxy.process import UpstreamSpawnError
|
|
77
|
+
from mcp_claimcheck.proxy.stdio import StdioProxy
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
exit_code = StdioProxy(config.upstream, shutdown_timeout=shutdown_timeout).run()
|
|
81
|
+
except UpstreamSpawnError as exc:
|
|
82
|
+
handler.shutdown()
|
|
83
|
+
raise click.ClickException(str(exc)) from exc
|
|
84
|
+
handler.shutdown()
|
|
85
|
+
sys.exit(exit_code)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@main.command(short_help="Validate contract files.")
|
|
89
|
+
@click.option(
|
|
90
|
+
"--contracts",
|
|
91
|
+
"contracts_dir",
|
|
92
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
93
|
+
default=Path("contracts"),
|
|
94
|
+
show_default=True,
|
|
95
|
+
help="Directory of contract files to validate.",
|
|
96
|
+
)
|
|
97
|
+
def check(contracts_dir: Path) -> None:
|
|
98
|
+
"""Validate every contract in a directory and report what is wrong.
|
|
99
|
+
|
|
100
|
+
Exit codes are meant for CI:
|
|
101
|
+
|
|
102
|
+
\b
|
|
103
|
+
0 every contract is valid (or the directory holds none yet)
|
|
104
|
+
1 at least one contract has a problem
|
|
105
|
+
2 the command itself was used wrongly
|
|
106
|
+
|
|
107
|
+
The report goes to stderr, because Rule 5 reserves stdout for MCP traffic and the
|
|
108
|
+
same binary serves both purposes. Redirect with `2>` to capture it.
|
|
109
|
+
"""
|
|
110
|
+
# Imported here so `--version` and `--help` do not pay for yaml and jsonschema.
|
|
111
|
+
from mcp_claimcheck.contracts.loader import find_files, read_directory
|
|
112
|
+
|
|
113
|
+
contracts, problems = read_directory(contracts_dir)
|
|
114
|
+
for problem in sorted(problems):
|
|
115
|
+
click.echo(str(problem), err=True)
|
|
116
|
+
|
|
117
|
+
if problems:
|
|
118
|
+
counted = _count(len(problems), "problem")
|
|
119
|
+
click.echo(f"\n{counted} found in {contracts_dir}", err=True)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
if not contracts:
|
|
123
|
+
# Not a failure. A project that has not written contracts yet is a legitimate
|
|
124
|
+
# state, and failing CI for it would punish adopting the tool gradually. The
|
|
125
|
+
# message is explicit so a mistyped directory is not mistaken for success.
|
|
126
|
+
found = len(find_files(contracts_dir)) if contracts_dir.is_dir() else 0
|
|
127
|
+
detail = "no contract files" if not found else f"{_count(found, 'file')} but no contracts"
|
|
128
|
+
click.echo(f"{detail} in {contracts_dir}", err=True)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
click.echo(f"{_count(len(contracts), 'contract')} in {contracts_dir}: all valid", err=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _count(number: int, noun: str) -> str:
|
|
135
|
+
return f"{number} {noun}" if number == 1 else f"{number} {noun}s"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_config(config_path: Path | None, command: tuple[str, ...]) -> Config:
|
|
139
|
+
if config_path is not None and command:
|
|
140
|
+
raise click.UsageError("give either --config or a server command after '--', not both")
|
|
141
|
+
try:
|
|
142
|
+
if config_path is not None:
|
|
143
|
+
return load(config_path)
|
|
144
|
+
if not command:
|
|
145
|
+
raise click.UsageError(
|
|
146
|
+
"no upstream server given: use 'mcp-claimcheck run -- <command> [args...]' "
|
|
147
|
+
"or 'mcp-claimcheck run --config <file>'"
|
|
148
|
+
)
|
|
149
|
+
return from_command_line(command)
|
|
150
|
+
except ConfigError as exc:
|
|
151
|
+
raise click.UsageError(str(exc)) from exc
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Configuration: which upstream MCP server to launch, and how.
|
|
2
|
+
|
|
3
|
+
Two ways to say it, and exactly one must be used:
|
|
4
|
+
|
|
5
|
+
* On the command line: ``mcp-claimcheck run -- <command> [args...]``.
|
|
6
|
+
* In a TOML file: ``mcp-claimcheck run --config claimcheck.toml``::
|
|
7
|
+
|
|
8
|
+
[upstream]
|
|
9
|
+
command = "npx" # required
|
|
10
|
+
args = ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
|
|
11
|
+
cwd = "." # optional; relative to this file
|
|
12
|
+
[upstream.env] # optional; merged over our own env
|
|
13
|
+
API_TOKEN = "..."
|
|
14
|
+
|
|
15
|
+
``args`` are passed to the server exactly as written; only ``cwd`` is resolved
|
|
16
|
+
relative to the config file.
|
|
17
|
+
|
|
18
|
+
Validation is strict. Unknown keys are errors, not silently ignored, because a
|
|
19
|
+
misspelt key that is ignored becomes a setting the user believes is active. Every
|
|
20
|
+
error names the file and the key.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import tomllib
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(ValueError):
|
|
33
|
+
"""The configuration is invalid. The message is written for a human."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class UpstreamConfig:
|
|
38
|
+
command: str
|
|
39
|
+
args: tuple[str, ...] = ()
|
|
40
|
+
env: Mapping[str, str] = field(default_factory=dict)
|
|
41
|
+
cwd: Path | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def argv(self) -> list[str]:
|
|
45
|
+
return [self.command, *self.args]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Config:
|
|
50
|
+
upstream: UpstreamConfig
|
|
51
|
+
source: Path | None = None # None when built from the command line
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_TOP_LEVEL_KEYS = frozenset({"upstream"})
|
|
55
|
+
_UPSTREAM_KEYS = frozenset({"command", "args", "env", "cwd"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def from_command_line(argv: Sequence[str]) -> Config:
|
|
59
|
+
"""Build a config from ``<command> [args...]`` given after ``--``."""
|
|
60
|
+
if not argv:
|
|
61
|
+
raise ConfigError("no upstream server command given")
|
|
62
|
+
command, *args = argv
|
|
63
|
+
if not command.strip():
|
|
64
|
+
raise ConfigError("the upstream server command is empty")
|
|
65
|
+
return Config(UpstreamConfig(command=command, args=tuple(args)))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load(path: Path) -> Config:
|
|
69
|
+
"""Read and validate a TOML config file."""
|
|
70
|
+
try:
|
|
71
|
+
raw = path.read_bytes()
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise ConfigError(f"{path}: cannot read config file: {exc.strerror or exc}") from exc
|
|
74
|
+
try:
|
|
75
|
+
data = tomllib.loads(raw.decode("utf-8"))
|
|
76
|
+
except UnicodeDecodeError as exc:
|
|
77
|
+
raise ConfigError(f"{path}: config file is not valid UTF-8 ({exc.reason})") from exc
|
|
78
|
+
except tomllib.TOMLDecodeError as exc:
|
|
79
|
+
raise ConfigError(f"{path}: invalid TOML: {exc}") from exc
|
|
80
|
+
return _parse(data, path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse(data: dict[str, Any], path: Path) -> Config:
|
|
84
|
+
_reject_unknown(data, _TOP_LEVEL_KEYS, path, prefix="")
|
|
85
|
+
if "upstream" not in data:
|
|
86
|
+
raise ConfigError(f"{path}: missing required table [upstream]")
|
|
87
|
+
upstream = data["upstream"]
|
|
88
|
+
if not isinstance(upstream, dict):
|
|
89
|
+
raise ConfigError(f"{path}: 'upstream' must be a table ([upstream])")
|
|
90
|
+
_reject_unknown(upstream, _UPSTREAM_KEYS, path, prefix="upstream.")
|
|
91
|
+
|
|
92
|
+
command = upstream.get("command")
|
|
93
|
+
if not isinstance(command, str):
|
|
94
|
+
if command is None:
|
|
95
|
+
raise ConfigError(f"{path}: missing required key upstream.command")
|
|
96
|
+
raise ConfigError(f"{path}: upstream.command must be a string, got {_type(command)}")
|
|
97
|
+
if not command.strip():
|
|
98
|
+
raise ConfigError(f"{path}: upstream.command is empty")
|
|
99
|
+
|
|
100
|
+
args = upstream.get("args", [])
|
|
101
|
+
if not isinstance(args, list):
|
|
102
|
+
raise ConfigError(f"{path}: upstream.args must be an array of strings, got {_type(args)}")
|
|
103
|
+
for index, arg in enumerate(args):
|
|
104
|
+
if not isinstance(arg, str):
|
|
105
|
+
raise ConfigError(
|
|
106
|
+
f"{path}: upstream.args[{index}] must be a string, got {_type(arg)}"
|
|
107
|
+
f"{_quote_hint(arg)}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
env = upstream.get("env", {})
|
|
111
|
+
if not isinstance(env, dict):
|
|
112
|
+
raise ConfigError(f"{path}: upstream.env must be a table, got {_type(env)}")
|
|
113
|
+
for key, value in env.items():
|
|
114
|
+
if not isinstance(value, str):
|
|
115
|
+
raise ConfigError(
|
|
116
|
+
f"{path}: upstream.env.{key} must be a string, got {_type(value)};"
|
|
117
|
+
f" environment values are text{_quote_hint(value, key)}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
cwd_value = upstream.get("cwd")
|
|
121
|
+
cwd: Path | None = None
|
|
122
|
+
if cwd_value is not None:
|
|
123
|
+
if not isinstance(cwd_value, str) or not cwd_value.strip():
|
|
124
|
+
raise ConfigError(f"{path}: upstream.cwd must be a non-empty string")
|
|
125
|
+
cwd = Path(cwd_value)
|
|
126
|
+
if not cwd.is_absolute():
|
|
127
|
+
cwd = (path.parent / cwd).resolve()
|
|
128
|
+
if not cwd.is_dir():
|
|
129
|
+
raise ConfigError(f"{path}: upstream.cwd is not a directory: {cwd}")
|
|
130
|
+
|
|
131
|
+
return Config(
|
|
132
|
+
UpstreamConfig(command=command, args=tuple(args), env=dict(env), cwd=cwd),
|
|
133
|
+
source=path,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _reject_unknown(
|
|
138
|
+
table: dict[str, Any], allowed: frozenset[str], path: Path, prefix: str
|
|
139
|
+
) -> None:
|
|
140
|
+
unknown = sorted(set(table) - allowed)
|
|
141
|
+
if unknown:
|
|
142
|
+
known = ", ".join(sorted(allowed))
|
|
143
|
+
names = ", ".join(f"{prefix}{key}" for key in unknown)
|
|
144
|
+
raise ConfigError(f"{path}: unknown key(s): {names} (allowed here: {known})")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_TYPE_NAMES: dict[type, str] = {
|
|
148
|
+
bool: "a boolean",
|
|
149
|
+
int: "an integer",
|
|
150
|
+
float: "a number",
|
|
151
|
+
list: "an array",
|
|
152
|
+
dict: "a table",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _type(value: object) -> str:
|
|
157
|
+
return _TYPE_NAMES.get(type(value), type(value).__name__)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _quote_hint(value: object, key: str | None = None) -> str:
|
|
161
|
+
"""Suggest quoting, but only for scalars, where quoting is the likely fix."""
|
|
162
|
+
if not isinstance(value, bool | int | float):
|
|
163
|
+
return ""
|
|
164
|
+
text = str(value).lower() if isinstance(value, bool) else str(value)
|
|
165
|
+
return f' (quote it: {key} = "{text}")' if key else f' (quote it: "{text}")'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Contracts as data: the model, the loader, and the expression syntax gate.
|
|
2
|
+
|
|
3
|
+
Phase 3 only reads and validates contracts. Nothing here verifies a tool call: schema
|
|
4
|
+
validation is Phase 4 and expression evaluation is Phase 5.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from mcp_claimcheck.contracts.model import Contract, ContractSet, Mode
|
|
8
|
+
from mcp_claimcheck.contracts.problems import ContractError, Position, Problem
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Contract",
|
|
12
|
+
"ContractError",
|
|
13
|
+
"ContractSet",
|
|
14
|
+
"Mode",
|
|
15
|
+
"Position",
|
|
16
|
+
"Problem",
|
|
17
|
+
]
|