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.
Files changed (46) hide show
  1. mcp_claimcheck-0.1.0a1/.gitignore +27 -0
  2. mcp_claimcheck-0.1.0a1/LICENSE +21 -0
  3. mcp_claimcheck-0.1.0a1/PKG-INFO +62 -0
  4. mcp_claimcheck-0.1.0a1/README.md +36 -0
  5. mcp_claimcheck-0.1.0a1/pyproject.toml +114 -0
  6. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/__init__.py +3 -0
  7. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/__main__.py +6 -0
  8. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/cli.py +151 -0
  9. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/config.py +165 -0
  10. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/__init__.py +17 -0
  11. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/expressions.py +279 -0
  12. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/loader.py +911 -0
  13. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/model.py +103 -0
  14. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/contracts/problems.py +83 -0
  15. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/log.py +137 -0
  16. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/payload.py +157 -0
  17. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/__init__.py +1 -0
  18. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/_windows.py +142 -0
  19. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/framing.py +49 -0
  20. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/interceptor.py +418 -0
  21. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/process.py +240 -0
  22. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/session.py +299 -0
  23. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/proxy/stdio.py +493 -0
  24. mcp_claimcheck-0.1.0a1/src/mcp_claimcheck/py.typed +0 -0
  25. mcp_claimcheck-0.1.0a1/tests/conftest.py +45 -0
  26. mcp_claimcheck-0.1.0a1/tests/fixtures/fake_server.py +194 -0
  27. mcp_claimcheck-0.1.0a1/tests/fixtures/sdk_server.py +39 -0
  28. mcp_claimcheck-0.1.0a1/tests/proxy_harness.py +162 -0
  29. mcp_claimcheck-0.1.0a1/tests/real_servers.py +231 -0
  30. mcp_claimcheck-0.1.0a1/tests/stdout_guard.py +270 -0
  31. mcp_claimcheck-0.1.0a1/tests/test_capture.py +319 -0
  32. mcp_claimcheck-0.1.0a1/tests/test_cli.py +190 -0
  33. mcp_claimcheck-0.1.0a1/tests/test_config.py +137 -0
  34. mcp_claimcheck-0.1.0a1/tests/test_contracts.py +839 -0
  35. mcp_claimcheck-0.1.0a1/tests/test_expressions.py +282 -0
  36. mcp_claimcheck-0.1.0a1/tests/test_fail_open.py +182 -0
  37. mcp_claimcheck-0.1.0a1/tests/test_framing.py +89 -0
  38. mcp_claimcheck-0.1.0a1/tests/test_interceptor.py +502 -0
  39. mcp_claimcheck-0.1.0a1/tests/test_log.py +133 -0
  40. mcp_claimcheck-0.1.0a1/tests/test_payload.py +197 -0
  41. mcp_claimcheck-0.1.0a1/tests/test_process.py +253 -0
  42. mcp_claimcheck-0.1.0a1/tests/test_proxy.py +436 -0
  43. mcp_claimcheck-0.1.0a1/tests/test_readme.py +67 -0
  44. mcp_claimcheck-0.1.0a1/tests/test_real_servers.py +506 -0
  45. mcp_claimcheck-0.1.0a1/tests/test_session.py +283 -0
  46. 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,3 @@
1
+ """mcp-claimcheck: runtime semantic verification for MCP tool calls."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m mcp_claimcheck``."""
2
+
3
+ from mcp_claimcheck.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ ]