linc-cli-kit 0.1.3__tar.gz → 0.1.4__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.
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/CHANGELOG.md +11 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/PKG-INFO +11 -3
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/README.md +10 -2
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/pyproject.toml +17 -1
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/__init__.py +2 -1
- linc_cli_kit-0.1.4/src/linc_cli_kit/registry.py +132 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/testing.py +51 -3
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/conftest.py +3 -1
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_json_encoding.py +3 -1
- linc_cli_kit-0.1.4/tests/test_registry.py +128 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_testing.py +38 -1
- linc_cli_kit-0.1.3/src/linc_cli_kit/registry.py +0 -48
- linc_cli_kit-0.1.3/tests/test_registry.py +0 -28
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/.github/workflows/release.yml +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/.github/workflows/test.yml +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/CONTRIBUTING.md +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/LICENSE +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/NOTICE +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/SECURITY.md +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/errors.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/logging.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/mount.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/output.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/schema.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/src/linc_cli_kit/transport.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/__init__.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_errors.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_logging.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_mount.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_output.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_public_docs.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_schema.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_startup.py +0 -0
- {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.4}/tests/test_transport.py +0 -0
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to linc-cli-kit are listed here.
|
|
4
4
|
|
|
5
|
+
## [0.1.4]
|
|
6
|
+
|
|
7
|
+
- `strict_tools(server, *, strict_types=False)`: rejects unknown MCP tool arguments. Every
|
|
8
|
+
registered tool's argument model gets `extra="forbid"` and its schema advertises
|
|
9
|
+
`additionalProperties: false`, including tools registered after the call. `strict_types=True`
|
|
10
|
+
also turns off lax type coercion. It raises `TypeError`, naming the change, if the MCP SDK
|
|
11
|
+
internals it relies on are missing. Exported as `linc_cli_kit.strict_tools`.
|
|
12
|
+
- `assert_cli_parity` checks that MCP rejects an unknown argument, matching the CLI's exit-2
|
|
13
|
+
rejection of an unknown option.
|
|
14
|
+
- Lint: ruff `S101` (no `assert` in library code) is on; tests are exempt.
|
|
15
|
+
|
|
5
16
|
## [0.1.3]
|
|
6
17
|
|
|
7
18
|
- Tests: the JSON-mode heartbeat test also polls instead of a fixed sleep (the sibling of the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: linc-cli-kit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Reflect a FastMCP tool registry into Click commands under one agent-grade output contract
|
|
5
5
|
Project-URL: Homepage, https://github.com/LincForge/cli-kit
|
|
6
6
|
Project-URL: Issues, https://github.com/LincForge/cli-kit/issues
|
|
@@ -41,7 +41,7 @@ Save as `hello_cli.py`:
|
|
|
41
41
|
import click
|
|
42
42
|
from mcp.server.fastmcp import FastMCP
|
|
43
43
|
|
|
44
|
-
from linc_cli_kit import LocalTransport, install_globals, mount_tools
|
|
44
|
+
from linc_cli_kit import LocalTransport, install_globals, mount_tools, strict_tools
|
|
45
45
|
|
|
46
46
|
server = FastMCP("hello")
|
|
47
47
|
|
|
@@ -52,6 +52,9 @@ def hello_greet(name: str, times: int = 1) -> dict:
|
|
|
52
52
|
return {"greeting": " ".join([f"Hello, {name}!"] * times)}
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
strict_tools(server)
|
|
56
|
+
|
|
57
|
+
|
|
55
58
|
@click.group()
|
|
56
59
|
def main() -> None:
|
|
57
60
|
"""hello: JSON on stdout by default, --human for text."""
|
|
@@ -86,7 +89,7 @@ import sys
|
|
|
86
89
|
import click
|
|
87
90
|
from linc_cli_kit import (
|
|
88
91
|
HttpDaemonTransport, LocalTransport, configure_structlog_stderr,
|
|
89
|
-
install_globals, mount_tools,
|
|
92
|
+
install_globals, mount_tools, strict_tools,
|
|
90
93
|
)
|
|
91
94
|
|
|
92
95
|
configure_structlog_stderr() # MUST be above your package imports — see below
|
|
@@ -94,6 +97,8 @@ configure_structlog_stderr() # MUST be above your package imports — see belo
|
|
|
94
97
|
from <tool>.mcp._core import server # noqa: E402
|
|
95
98
|
from <tool>.core.controller import Controller # noqa: E402
|
|
96
99
|
|
|
100
|
+
strict_tools(server) # reject unknown MCP tool arguments (matches Click's CLI check)
|
|
101
|
+
|
|
97
102
|
|
|
98
103
|
@click.group()
|
|
99
104
|
def main() -> None:
|
|
@@ -147,6 +152,9 @@ def test_cli_parity() -> None:
|
|
|
147
152
|
|
|
148
153
|
Rules the suite enforces, so read them before adding a command:
|
|
149
154
|
|
|
155
|
+
- **Every FastMCP server must call `strict_tools(server)`.** FastMCP ignores unknown arguments by
|
|
156
|
+
default while Click rejects unknown options; the suite verifies that MCP rejects an unknown
|
|
157
|
+
argument (`extra="forbid"`) and advertises `additionalProperties: false` on every mounted tool.
|
|
150
158
|
- **Every hand-written override needs a `samples[<tool_name>]` entry.** Without one the suite
|
|
151
159
|
fails closed rather than silently skipping the override's JSON contract.
|
|
152
160
|
- **Overrides must route through the group transport** (`ctx.find_root().command.linc_transport`),
|
|
@@ -22,7 +22,7 @@ Save as `hello_cli.py`:
|
|
|
22
22
|
import click
|
|
23
23
|
from mcp.server.fastmcp import FastMCP
|
|
24
24
|
|
|
25
|
-
from linc_cli_kit import LocalTransport, install_globals, mount_tools
|
|
25
|
+
from linc_cli_kit import LocalTransport, install_globals, mount_tools, strict_tools
|
|
26
26
|
|
|
27
27
|
server = FastMCP("hello")
|
|
28
28
|
|
|
@@ -33,6 +33,9 @@ def hello_greet(name: str, times: int = 1) -> dict:
|
|
|
33
33
|
return {"greeting": " ".join([f"Hello, {name}!"] * times)}
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
strict_tools(server)
|
|
37
|
+
|
|
38
|
+
|
|
36
39
|
@click.group()
|
|
37
40
|
def main() -> None:
|
|
38
41
|
"""hello: JSON on stdout by default, --human for text."""
|
|
@@ -67,7 +70,7 @@ import sys
|
|
|
67
70
|
import click
|
|
68
71
|
from linc_cli_kit import (
|
|
69
72
|
HttpDaemonTransport, LocalTransport, configure_structlog_stderr,
|
|
70
|
-
install_globals, mount_tools,
|
|
73
|
+
install_globals, mount_tools, strict_tools,
|
|
71
74
|
)
|
|
72
75
|
|
|
73
76
|
configure_structlog_stderr() # MUST be above your package imports — see below
|
|
@@ -75,6 +78,8 @@ configure_structlog_stderr() # MUST be above your package imports — see belo
|
|
|
75
78
|
from <tool>.mcp._core import server # noqa: E402
|
|
76
79
|
from <tool>.core.controller import Controller # noqa: E402
|
|
77
80
|
|
|
81
|
+
strict_tools(server) # reject unknown MCP tool arguments (matches Click's CLI check)
|
|
82
|
+
|
|
78
83
|
|
|
79
84
|
@click.group()
|
|
80
85
|
def main() -> None:
|
|
@@ -128,6 +133,9 @@ def test_cli_parity() -> None:
|
|
|
128
133
|
|
|
129
134
|
Rules the suite enforces, so read them before adding a command:
|
|
130
135
|
|
|
136
|
+
- **Every FastMCP server must call `strict_tools(server)`.** FastMCP ignores unknown arguments by
|
|
137
|
+
default while Click rejects unknown options; the suite verifies that MCP rejects an unknown
|
|
138
|
+
argument (`extra="forbid"`) and advertises `additionalProperties: false` on every mounted tool.
|
|
131
139
|
- **Every hand-written override needs a `samples[<tool_name>]` entry.** Without one the suite
|
|
132
140
|
fails closed rather than silently skipping the override's JSON contract.
|
|
133
141
|
- **Overrides must route through the group transport** (`ctx.find_root().command.linc_transport`),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "linc-cli-kit"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
description = "Reflect a FastMCP tool registry into Click commands under one agent-grade output contract"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -50,6 +50,9 @@ select = [
|
|
|
50
50
|
"RUF",
|
|
51
51
|
"ANN",
|
|
52
52
|
]
|
|
53
|
+
extend-select = [
|
|
54
|
+
"S101",
|
|
55
|
+
]
|
|
53
56
|
ignore = [
|
|
54
57
|
"ANN401",
|
|
55
58
|
]
|
|
@@ -57,6 +60,19 @@ ignore = [
|
|
|
57
60
|
[tool.ruff.lint.per-file-ignores]
|
|
58
61
|
"tests/**" = [
|
|
59
62
|
"ANN",
|
|
63
|
+
"S101",
|
|
64
|
+
]
|
|
65
|
+
"**/test_*.py" = [
|
|
66
|
+
"S101",
|
|
67
|
+
]
|
|
68
|
+
"**/*_test.py" = [
|
|
69
|
+
"S101",
|
|
70
|
+
]
|
|
71
|
+
"**/conftest.py" = [
|
|
72
|
+
"S101",
|
|
73
|
+
]
|
|
74
|
+
"**/testing.py" = [
|
|
75
|
+
"S101",
|
|
60
76
|
]
|
|
61
77
|
|
|
62
78
|
[tool.pytest.ini_options]
|
|
@@ -17,7 +17,7 @@ from linc_cli_kit.mount import (
|
|
|
17
17
|
mount_tools,
|
|
18
18
|
)
|
|
19
19
|
from linc_cli_kit.output import emit, emit_exception, heartbeat, json_default, to_json
|
|
20
|
-
from linc_cli_kit.registry import ToolSpec, registry_of
|
|
20
|
+
from linc_cli_kit.registry import ToolSpec, registry_of, strict_tools
|
|
21
21
|
from linc_cli_kit.transport import HttpDaemonTransport, LocalTransport
|
|
22
22
|
|
|
23
23
|
if TYPE_CHECKING:
|
|
@@ -46,6 +46,7 @@ __all__ = [
|
|
|
46
46
|
"mount_tools",
|
|
47
47
|
"parse_error_envelope",
|
|
48
48
|
"registry_of",
|
|
49
|
+
"strict_tools",
|
|
49
50
|
"to_json",
|
|
50
51
|
]
|
|
51
52
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The only module allowed to touch FastMCP's private tool manager.
|
|
2
|
+
|
|
3
|
+
`_tool_manager` is private SDK API (renamed in the SDK's v2 line). Everything
|
|
4
|
+
else in the kit consumes ToolSpec, so an SDK change is fixed in one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import functools
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ToolSpec:
|
|
18
|
+
name: str
|
|
19
|
+
description: str
|
|
20
|
+
parameters: dict[str, Any]
|
|
21
|
+
fn: Callable[..., Any]
|
|
22
|
+
is_async: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _tool_manager(server: Any, caller: str) -> tuple[Any, Callable[[], list[Any]]]:
|
|
26
|
+
manager = getattr(server, "_tool_manager", None)
|
|
27
|
+
list_tools = getattr(manager, "list_tools", None)
|
|
28
|
+
if manager is None or not callable(list_tools):
|
|
29
|
+
raise TypeError(
|
|
30
|
+
f"{caller}() expects a FastMCP server with a FastMCP tool registry "
|
|
31
|
+
f"(_tool_manager.list_tools); got {type(server).__name__}"
|
|
32
|
+
)
|
|
33
|
+
return manager, list_tools
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _harden_tool(tool: Any, *, strict_types: bool) -> None:
|
|
37
|
+
parameters = getattr(tool, "parameters", None)
|
|
38
|
+
if not isinstance(parameters, dict):
|
|
39
|
+
raise TypeError(
|
|
40
|
+
"FastMCP Tool lacks 'parameters' — SDK shape changed; update registry.py"
|
|
41
|
+
)
|
|
42
|
+
fn_metadata = getattr(tool, "fn_metadata", None)
|
|
43
|
+
arg_model = getattr(fn_metadata, "arg_model", None)
|
|
44
|
+
model_config = getattr(arg_model, "model_config", None)
|
|
45
|
+
model_rebuild = getattr(arg_model, "model_rebuild", None)
|
|
46
|
+
if (
|
|
47
|
+
fn_metadata is None
|
|
48
|
+
or arg_model is None
|
|
49
|
+
or not isinstance(model_config, dict)
|
|
50
|
+
or not callable(model_rebuild)
|
|
51
|
+
):
|
|
52
|
+
raise TypeError(
|
|
53
|
+
"FastMCP Tool lacks 'fn_metadata.arg_model' — SDK shape changed; update registry.py"
|
|
54
|
+
)
|
|
55
|
+
model_config["extra"] = "forbid"
|
|
56
|
+
if strict_types:
|
|
57
|
+
model_config["strict"] = True
|
|
58
|
+
model_rebuild(force=True)
|
|
59
|
+
parameters["additionalProperties"] = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def strict_tools(server: Any, *, strict_types: bool = False) -> Any:
|
|
63
|
+
"""Reject unknown MCP tool arguments (`extra='forbid'` + `additionalProperties: false`).
|
|
64
|
+
|
|
65
|
+
FastMCP's default argument model silently drops unknown keys, while Click rejects unknown
|
|
66
|
+
CLI options with exit code 2. Calling `strict_tools(server)` brings the MCP surface into
|
|
67
|
+
parity with the CLI contract and advertises `additionalProperties: false` via `list_tools()`.
|
|
68
|
+
Pass `strict_types=True` to also set Pydantic `strict=True` (disabling lax string-to-number
|
|
69
|
+
coercion).
|
|
70
|
+
"""
|
|
71
|
+
manager, list_tools = _tool_manager(server, "strict_tools")
|
|
72
|
+
for tool in list_tools():
|
|
73
|
+
_harden_tool(tool, strict_types=strict_types)
|
|
74
|
+
|
|
75
|
+
add_tool = getattr(manager, "_linc_orig_add_tool", None) or getattr(manager, "add_tool", None)
|
|
76
|
+
if callable(add_tool):
|
|
77
|
+
manager._linc_orig_add_tool = add_tool
|
|
78
|
+
|
|
79
|
+
@functools.wraps(add_tool)
|
|
80
|
+
def _strict_add_tool(*args: Any, **kwargs: Any) -> Any:
|
|
81
|
+
tool = add_tool(*args, **kwargs)
|
|
82
|
+
_harden_tool(tool, strict_types=strict_types)
|
|
83
|
+
return tool
|
|
84
|
+
|
|
85
|
+
manager.add_tool = _strict_add_tool
|
|
86
|
+
return server
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@contextlib.contextmanager
|
|
90
|
+
def stubbed_tool_fn(server: Any, name: str) -> Iterator[None]:
|
|
91
|
+
"""Temporarily replace a registered tool's callable with a no-op for validation probes."""
|
|
92
|
+
_, list_tools = _tool_manager(server, "stubbed_tool_fn")
|
|
93
|
+
for tool in list_tools():
|
|
94
|
+
if getattr(tool, "name", None) == name:
|
|
95
|
+
orig = tool.fn
|
|
96
|
+
|
|
97
|
+
async def _async_noop(**_: Any) -> dict[str, bool]:
|
|
98
|
+
return {"ok": True}
|
|
99
|
+
|
|
100
|
+
def _sync_noop(**_: Any) -> dict[str, bool]:
|
|
101
|
+
return {"ok": True}
|
|
102
|
+
|
|
103
|
+
tool.fn = _async_noop if getattr(tool, "is_async", False) else _sync_noop
|
|
104
|
+
try:
|
|
105
|
+
yield
|
|
106
|
+
finally:
|
|
107
|
+
tool.fn = orig
|
|
108
|
+
return
|
|
109
|
+
yield
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def registry_of(server: Any) -> list[ToolSpec]:
|
|
113
|
+
"""Return every tool registered on a FastMCP server as a ToolSpec."""
|
|
114
|
+
_, list_tools = _tool_manager(server, "registry_of")
|
|
115
|
+
specs: list[ToolSpec] = []
|
|
116
|
+
for tool in list_tools():
|
|
117
|
+
for attr in ("name", "fn", "parameters", "is_async"):
|
|
118
|
+
if not hasattr(tool, attr):
|
|
119
|
+
raise TypeError(
|
|
120
|
+
f"FastMCP Tool lacks '{attr}' — SDK shape changed; update registry.py"
|
|
121
|
+
)
|
|
122
|
+
specs.append(
|
|
123
|
+
ToolSpec(
|
|
124
|
+
name=tool.name,
|
|
125
|
+
description=(tool.description or "").strip(),
|
|
126
|
+
parameters=dict(tool.parameters or {}),
|
|
127
|
+
fn=tool.fn,
|
|
128
|
+
is_async=bool(tool.is_async),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return specs
|
|
132
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Importable conformance suite (spec §5.5). Each repo calls assert_cli_parity in one test.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ten checks, run per mounted tool (unless justified-skipped):
|
|
4
4
|
1. coverage + justified skips
|
|
5
5
|
2. --help exits 0 and names every required option
|
|
6
6
|
3. stubbed success -> bare JSON on stdout, no ANSI, empty stderr, AND the payload the
|
|
@@ -22,6 +22,9 @@ Nine checks, run per mounted tool (unless justified-skipped):
|
|
|
22
22
|
CliRunner alone cannot see it
|
|
23
23
|
8. stubbed generic error -> exit 1, empty stdout, envelope on stderr
|
|
24
24
|
9. `schema --json` lists every mounted command
|
|
25
|
+
10. unknown-argument rejection parity: MCP rejects an unknown argument (`ToolError` naming
|
|
26
|
+
the key + `additionalProperties: false` in the schema, via `strict_tools(server)`) the
|
|
27
|
+
same way the CLI rejects an unknown option (exit 2 + error envelope on stderr)
|
|
25
28
|
|
|
26
29
|
Every JSON parse goes through a named assertion that reports the tool and the stream.
|
|
27
30
|
|
|
@@ -52,11 +55,13 @@ from dataclasses import dataclass, field
|
|
|
52
55
|
from typing import Any
|
|
53
56
|
from unittest.mock import patch
|
|
54
57
|
|
|
58
|
+
import anyio
|
|
55
59
|
import click
|
|
56
60
|
from click.testing import CliRunner
|
|
61
|
+
from mcp.server.fastmcp.exceptions import ToolError as FastMCPToolError
|
|
57
62
|
|
|
58
63
|
from linc_cli_kit.output import to_json
|
|
59
|
-
from linc_cli_kit.registry import ToolSpec, registry_of
|
|
64
|
+
from linc_cli_kit.registry import ToolSpec, registry_of, stubbed_tool_fn
|
|
60
65
|
from linc_cli_kit.schema import RESERVED, cli_name
|
|
61
66
|
from linc_cli_kit.transport import LocalTransport
|
|
62
67
|
|
|
@@ -262,6 +267,47 @@ def _check_option_parity(cmd: click.Command, spec: ToolSpec) -> None:
|
|
|
262
267
|
)
|
|
263
268
|
|
|
264
269
|
|
|
270
|
+
def _check_unknown_argument_rejected(
|
|
271
|
+
cli: click.Group,
|
|
272
|
+
cmd: click.Command,
|
|
273
|
+
server: Any,
|
|
274
|
+
spec: ToolSpec,
|
|
275
|
+
name: str,
|
|
276
|
+
sample: dict[str, Any],
|
|
277
|
+
) -> None:
|
|
278
|
+
# 10. unknown-argument rejection parity: Click rejects an unknown option with exit 2 and an
|
|
279
|
+
# error envelope on stderr; MCP must reject an unknown argument the same way (via
|
|
280
|
+
# strict_tools(server)) rather than silently dropping it.
|
|
281
|
+
unknown_key = "__linc_unknown_arg__"
|
|
282
|
+
unknown_flag = "--__linc_unknown_arg__"
|
|
283
|
+
r = _invoke(cli, [name, *_sample_args(cmd, sample), unknown_flag, "x"])
|
|
284
|
+
assert r.exit_code == 2, (
|
|
285
|
+
f"{spec.name}: expected exit 2 for unknown CLI option {unknown_flag}, "
|
|
286
|
+
f"got {r.exit_code}: {r.output}"
|
|
287
|
+
)
|
|
288
|
+
assert r.stdout == "", f"{spec.name}: stdout not empty on unknown CLI option"
|
|
289
|
+
doc = _loads(r.stderr, spec.name, "stderr")
|
|
290
|
+
assert isinstance(doc, dict) and doc.get("error"), (
|
|
291
|
+
f"{spec.name}: no envelope on stderr for unknown CLI option; got {r.stderr[:200]!r}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
with stubbed_tool_fn(server, spec.name):
|
|
295
|
+
try:
|
|
296
|
+
anyio.run(server.call_tool, spec.name, {**sample, unknown_key: "x"})
|
|
297
|
+
except FastMCPToolError as exc:
|
|
298
|
+
assert unknown_key in str(exc), (
|
|
299
|
+
f"{spec.name}: MCP error for unknown argument did not name {unknown_key!r}: {exc}"
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
raise AssertionError(
|
|
303
|
+
f"{spec.name}: MCP accepted unknown argument {unknown_key!r} while CLI "
|
|
304
|
+
"rejected it — call strict_tools(server)"
|
|
305
|
+
)
|
|
306
|
+
assert spec.parameters.get("additionalProperties") is False, (
|
|
307
|
+
f"{spec.name}: MCP schema missing additionalProperties: false — call strict_tools(server)"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
265
311
|
def _assert_error_stubs(
|
|
266
312
|
cli: click.Group, transport_holder: Any, name: str, args: list[str], label: str
|
|
267
313
|
) -> None:
|
|
@@ -440,7 +486,9 @@ def _run_parity(
|
|
|
440
486
|
|
|
441
487
|
_check_option_parity(cmd, spec)
|
|
442
488
|
sample = samples.get(spec.name)
|
|
443
|
-
|
|
489
|
+
sample_or_minimal = sample or _minimal_sample(spec)
|
|
490
|
+
_check_stubbed_output_contract(cli, cmd, spec, name, sample_or_minimal)
|
|
491
|
+
_check_unknown_argument_rejected(cli, cmd, server, spec, name, sample_or_minimal)
|
|
444
492
|
if sample is not None:
|
|
445
493
|
_check_execution_consistency(cli, cmd, spec, name, sample)
|
|
446
494
|
|
|
@@ -8,6 +8,8 @@ import click
|
|
|
8
8
|
import pytest
|
|
9
9
|
from mcp.server.fastmcp import FastMCP
|
|
10
10
|
|
|
11
|
+
from linc_cli_kit import strict_tools
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
def build_toy_server() -> FastMCP:
|
|
13
15
|
server = FastMCP("toy")
|
|
@@ -46,7 +48,7 @@ def build_toy_server() -> FastMCP:
|
|
|
46
48
|
"""Async tool."""
|
|
47
49
|
return {"device": device, "async": True}
|
|
48
50
|
|
|
49
|
-
return server
|
|
51
|
+
return strict_tools(server)
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
@pytest.fixture
|
|
@@ -141,7 +141,7 @@ def test_parity_execution_consistency_uses_the_same_encoder():
|
|
|
141
141
|
import click
|
|
142
142
|
from mcp.server.fastmcp import FastMCP
|
|
143
143
|
|
|
144
|
-
from linc_cli_kit import install_globals, mount_tools
|
|
144
|
+
from linc_cli_kit import install_globals, mount_tools, strict_tools
|
|
145
145
|
from linc_cli_kit.testing import assert_cli_parity
|
|
146
146
|
from linc_cli_kit.transport import LocalTransport
|
|
147
147
|
|
|
@@ -152,6 +152,8 @@ def test_parity_execution_consistency_uses_the_same_encoder():
|
|
|
152
152
|
"""When."""
|
|
153
153
|
return {"device": device, "at": datetime.datetime(2026, 1, 1, 0, 0), "tags": {"x"}}
|
|
154
154
|
|
|
155
|
+
strict_tools(server)
|
|
156
|
+
|
|
155
157
|
@click.group()
|
|
156
158
|
def cli() -> None:
|
|
157
159
|
"""dt"""
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import anyio
|
|
2
|
+
import pytest
|
|
3
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
4
|
+
from mcp.server.fastmcp.exceptions import ToolError as FastMCPToolError
|
|
5
|
+
|
|
6
|
+
from linc_cli_kit import strict_tools
|
|
7
|
+
from linc_cli_kit.registry import ToolSpec, registry_of
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_registry_exposes_fn_schema_and_async_flag(toy_server):
|
|
11
|
+
specs = {s.name: s for s in registry_of(toy_server)}
|
|
12
|
+
assert set(specs) == {
|
|
13
|
+
"toy_echo", "toy_extras", "toy_tags", "toy_reserved", "toy_fail", "toy_async"
|
|
14
|
+
}
|
|
15
|
+
echo = specs["toy_echo"]
|
|
16
|
+
assert isinstance(echo, ToolSpec)
|
|
17
|
+
assert callable(echo.fn)
|
|
18
|
+
assert echo.parameters["required"] == ["device"]
|
|
19
|
+
assert echo.parameters["properties"]["count"] == {
|
|
20
|
+
"default": 1, "title": "Count", "type": "integer"
|
|
21
|
+
}
|
|
22
|
+
assert echo.description.startswith("Echo the inputs back")
|
|
23
|
+
assert specs["toy_async"].is_async is True
|
|
24
|
+
assert echo.is_async is False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_registry_fails_loudly_on_unknown_server_shape():
|
|
28
|
+
class Bogus: # no _tool_manager
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
with pytest.raises(TypeError, match="FastMCP tool registry"):
|
|
32
|
+
registry_of(Bogus())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_contract_strict_tools_rejects_unknown_key_that_fastmcp_ignores_by_default():
|
|
36
|
+
"""[contract] #15: without strict_tools(server), FastMCP silently drops unknown keys;
|
|
37
|
+
with strict_tools(server), calling a tool with an unknown key raises ToolError naming it."""
|
|
38
|
+
server = FastMCP("strict_probe")
|
|
39
|
+
|
|
40
|
+
@server.tool()
|
|
41
|
+
def tap(x: int, y: int) -> dict:
|
|
42
|
+
return {"x": x, "y": y}
|
|
43
|
+
|
|
44
|
+
# Baseline without the hook: extra keys are silently ignored by FastMCP.
|
|
45
|
+
anyio.run(server.call_tool, "tap", {"x": 1, "y": 2, "hallucinated_flag": True})
|
|
46
|
+
|
|
47
|
+
returned = strict_tools(server)
|
|
48
|
+
assert returned is server
|
|
49
|
+
|
|
50
|
+
with pytest.raises(FastMCPToolError, match="hallucinated_flag"):
|
|
51
|
+
anyio.run(server.call_tool, "tap", {"x": 1, "y": 2, "hallucinated_flag": True})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_contract_strict_tools_preserves_valid_calls_and_context_injection():
|
|
55
|
+
"""[contract] #15: valid calls (including lax str->int coercion by default and FastMCP
|
|
56
|
+
Context injection) still succeed after strict_tools(server); strict_types=True is opt-in."""
|
|
57
|
+
server = FastMCP("ctx_probe")
|
|
58
|
+
|
|
59
|
+
@server.tool()
|
|
60
|
+
def inspect_device(device: str, count: int, ctx: Context) -> dict:
|
|
61
|
+
return {"device": device, "count": count, "has_ctx": isinstance(ctx, Context)}
|
|
62
|
+
|
|
63
|
+
strict_tools(server)
|
|
64
|
+
|
|
65
|
+
# Valid call + Context injection + default lax int coercion ("3" -> 3) still work.
|
|
66
|
+
blocks = anyio.run(server.call_tool, "inspect_device", {"device": "X", "count": "3"})
|
|
67
|
+
assert '"count": 3' in blocks[0].text
|
|
68
|
+
assert '"has_ctx": true' in blocks[0].text
|
|
69
|
+
|
|
70
|
+
# Opt-in strict_types=True rejects str->int coercion too.
|
|
71
|
+
strict_tools(server, strict_types=True)
|
|
72
|
+
with pytest.raises(FastMCPToolError, match="count"):
|
|
73
|
+
anyio.run(server.call_tool, "inspect_device", {"device": "X", "count": "3"})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_contract_strict_tools_sets_additional_properties_false_on_list_tools(toy_server):
|
|
77
|
+
"""[contract] #15: list_tools() reports additionalProperties: false for every tool."""
|
|
78
|
+
strict_tools(toy_server)
|
|
79
|
+
tools = anyio.run(toy_server.list_tools)
|
|
80
|
+
assert len(tools) == 6
|
|
81
|
+
for tool in tools:
|
|
82
|
+
assert tool.inputSchema.get("additionalProperties") is False, tool.name
|
|
83
|
+
for spec in registry_of(toy_server):
|
|
84
|
+
assert spec.parameters.get("additionalProperties") is False, spec.name
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_contract_strict_tools_fails_loudly_on_missing_sdk_internals():
|
|
88
|
+
"""[contract] #15: fails loudly (TypeError naming the SDK change) if _tool_manager or
|
|
89
|
+
fn_metadata.arg_model is missing."""
|
|
90
|
+
|
|
91
|
+
class NoManager:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
with pytest.raises(TypeError, match="FastMCP tool registry"):
|
|
95
|
+
strict_tools(NoManager())
|
|
96
|
+
|
|
97
|
+
class NoParamsTool:
|
|
98
|
+
def __init__(self):
|
|
99
|
+
self.name = "no_params"
|
|
100
|
+
self.parameters = None
|
|
101
|
+
self.fn_metadata = None
|
|
102
|
+
|
|
103
|
+
class NoParamsManager:
|
|
104
|
+
def list_tools(self):
|
|
105
|
+
return [NoParamsTool()]
|
|
106
|
+
|
|
107
|
+
class NoParamsServer:
|
|
108
|
+
_tool_manager = NoParamsManager()
|
|
109
|
+
|
|
110
|
+
with pytest.raises(TypeError, match=r"parameters.*SDK shape changed"):
|
|
111
|
+
strict_tools(NoParamsServer())
|
|
112
|
+
|
|
113
|
+
class BrokenTool:
|
|
114
|
+
def __init__(self):
|
|
115
|
+
self.name = "broken"
|
|
116
|
+
self.parameters = {"type": "object"}
|
|
117
|
+
self.fn_metadata = None
|
|
118
|
+
|
|
119
|
+
class BrokenManager:
|
|
120
|
+
def list_tools(self):
|
|
121
|
+
return [BrokenTool()]
|
|
122
|
+
|
|
123
|
+
class BrokenServer:
|
|
124
|
+
_tool_manager = BrokenManager()
|
|
125
|
+
|
|
126
|
+
with pytest.raises(TypeError, match=r"fn_metadata\.arg_model.*SDK shape changed"):
|
|
127
|
+
strict_tools(BrokenServer())
|
|
128
|
+
|
|
@@ -187,6 +187,8 @@ def _polluting_server(write):
|
|
|
187
187
|
"""A toy server whose only tool runs `write()` before returning its payload."""
|
|
188
188
|
from mcp.server.fastmcp import FastMCP
|
|
189
189
|
|
|
190
|
+
from linc_cli_kit import strict_tools
|
|
191
|
+
|
|
190
192
|
server = FastMCP("polluter")
|
|
191
193
|
|
|
192
194
|
@server.tool()
|
|
@@ -195,7 +197,7 @@ def _polluting_server(write):
|
|
|
195
197
|
write()
|
|
196
198
|
return {"device": device}
|
|
197
199
|
|
|
198
|
-
return server
|
|
200
|
+
return strict_tools(server)
|
|
199
201
|
|
|
200
202
|
|
|
201
203
|
def _parity_on(server):
|
|
@@ -302,3 +304,38 @@ def test_non_object_json_on_stderr_is_a_named_assertion(toy_server, toy_group):
|
|
|
302
304
|
cmd.callback = list_errors
|
|
303
305
|
with pytest.raises(AssertionError, match=r"toy_echo.*no envelope on stderr"):
|
|
304
306
|
assert_cli_parity(cli, toy_server, prefix="toy_")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_contract_assert_cli_parity_requires_mcp_to_reject_unknown_arguments():
|
|
310
|
+
"""[contract] #15: assert_cli_parity checks that MCP rejects an unknown argument the same
|
|
311
|
+
way the CLI rejects an unknown option, and never invokes the real tool fn when unstrict."""
|
|
312
|
+
from mcp.server.fastmcp import FastMCP
|
|
313
|
+
|
|
314
|
+
from linc_cli_kit import strict_tools
|
|
315
|
+
|
|
316
|
+
server = FastMCP("lax")
|
|
317
|
+
side_effects: list[str] = []
|
|
318
|
+
|
|
319
|
+
@server.tool()
|
|
320
|
+
def lax_ping(device: str) -> dict:
|
|
321
|
+
"""Ping a device."""
|
|
322
|
+
side_effects.append(device)
|
|
323
|
+
return {"device": device}
|
|
324
|
+
|
|
325
|
+
@click.group()
|
|
326
|
+
def cli() -> None:
|
|
327
|
+
"""lax"""
|
|
328
|
+
|
|
329
|
+
install_globals(cli)
|
|
330
|
+
mount_tools(cli, server, prefix="lax_", transport=LocalTransport())
|
|
331
|
+
|
|
332
|
+
# Without strict_tools(server), MCP accepts unknown args while the CLI rejects them.
|
|
333
|
+
with pytest.raises(AssertionError, match=r"lax_ping.*unknown argument.*strict_tools"):
|
|
334
|
+
assert_cli_parity(cli, server, prefix="lax_")
|
|
335
|
+
assert side_effects == [], "unstrict probe must not execute the real tool body"
|
|
336
|
+
|
|
337
|
+
# Once strict_tools(server) is applied, parity passes.
|
|
338
|
+
strict_tools(server)
|
|
339
|
+
report = assert_cli_parity(cli, server, prefix="lax_")
|
|
340
|
+
assert report.tools == 1
|
|
341
|
+
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
"""The only module allowed to touch FastMCP's private tool manager.
|
|
2
|
-
|
|
3
|
-
`_tool_manager` is private SDK API (renamed in the SDK's v2 line). Everything
|
|
4
|
-
else in the kit consumes ToolSpec, so an SDK change is fixed in one place.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from collections.abc import Callable
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
from typing import Any
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass(frozen=True)
|
|
15
|
-
class ToolSpec:
|
|
16
|
-
name: str
|
|
17
|
-
description: str
|
|
18
|
-
parameters: dict[str, Any]
|
|
19
|
-
fn: Callable[..., Any]
|
|
20
|
-
is_async: bool
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def registry_of(server: Any) -> list[ToolSpec]:
|
|
24
|
-
"""Return every tool registered on a FastMCP server as a ToolSpec."""
|
|
25
|
-
manager = getattr(server, "_tool_manager", None)
|
|
26
|
-
list_tools = getattr(manager, "list_tools", None)
|
|
27
|
-
if manager is None or list_tools is None:
|
|
28
|
-
raise TypeError(
|
|
29
|
-
"registry_of() expects a FastMCP server with a FastMCP tool registry "
|
|
30
|
-
f"(_tool_manager.list_tools); got {type(server).__name__}"
|
|
31
|
-
)
|
|
32
|
-
specs: list[ToolSpec] = []
|
|
33
|
-
for tool in list_tools():
|
|
34
|
-
for attr in ("name", "fn", "parameters", "is_async"):
|
|
35
|
-
if not hasattr(tool, attr):
|
|
36
|
-
raise TypeError(
|
|
37
|
-
f"FastMCP Tool lacks '{attr}' — SDK shape changed; update registry.py"
|
|
38
|
-
)
|
|
39
|
-
specs.append(
|
|
40
|
-
ToolSpec(
|
|
41
|
-
name=tool.name,
|
|
42
|
-
description=(tool.description or "").strip(),
|
|
43
|
-
parameters=dict(tool.parameters or {}),
|
|
44
|
-
fn=tool.fn,
|
|
45
|
-
is_async=bool(tool.is_async),
|
|
46
|
-
)
|
|
47
|
-
)
|
|
48
|
-
return specs
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
from linc_cli_kit.registry import ToolSpec, registry_of
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_registry_exposes_fn_schema_and_async_flag(toy_server):
|
|
5
|
-
specs = {s.name: s for s in registry_of(toy_server)}
|
|
6
|
-
assert set(specs) == {
|
|
7
|
-
"toy_echo", "toy_extras", "toy_tags", "toy_reserved", "toy_fail", "toy_async"
|
|
8
|
-
}
|
|
9
|
-
echo = specs["toy_echo"]
|
|
10
|
-
assert isinstance(echo, ToolSpec)
|
|
11
|
-
assert callable(echo.fn)
|
|
12
|
-
assert echo.parameters["required"] == ["device"]
|
|
13
|
-
assert echo.parameters["properties"]["count"] == {
|
|
14
|
-
"default": 1, "title": "Count", "type": "integer"
|
|
15
|
-
}
|
|
16
|
-
assert echo.description.startswith("Echo the inputs back")
|
|
17
|
-
assert specs["toy_async"].is_async is True
|
|
18
|
-
assert echo.is_async is False
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_registry_fails_loudly_on_unknown_server_shape():
|
|
22
|
-
class Bogus: # no _tool_manager
|
|
23
|
-
pass
|
|
24
|
-
|
|
25
|
-
import pytest
|
|
26
|
-
|
|
27
|
-
with pytest.raises(TypeError, match="FastMCP tool registry"):
|
|
28
|
-
registry_of(Bogus())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|