linc-cli-kit 0.1.3__tar.gz → 0.1.5__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 (36) hide show
  1. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/.github/workflows/test.yml +2 -0
  2. linc_cli_kit-0.1.5/CHANGELOG.md +45 -0
  3. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/PKG-INFO +11 -3
  4. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/README.md +10 -2
  5. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/pyproject.toml +17 -1
  6. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/__init__.py +2 -1
  7. linc_cli_kit-0.1.5/src/linc_cli_kit/registry.py +132 -0
  8. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/testing.py +51 -3
  9. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/conftest.py +3 -1
  10. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_json_encoding.py +3 -1
  11. linc_cli_kit-0.1.5/tests/test_lock_floors.py +69 -0
  12. linc_cli_kit-0.1.5/tests/test_registry.py +128 -0
  13. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_testing.py +38 -1
  14. linc_cli_kit-0.1.3/CHANGELOG.md +0 -26
  15. linc_cli_kit-0.1.3/src/linc_cli_kit/registry.py +0 -48
  16. linc_cli_kit-0.1.3/tests/test_registry.py +0 -28
  17. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/.github/workflows/release.yml +0 -0
  18. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/CONTRIBUTING.md +0 -0
  19. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/LICENSE +0 -0
  20. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/NOTICE +0 -0
  21. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/SECURITY.md +0 -0
  22. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/errors.py +0 -0
  23. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/logging.py +0 -0
  24. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/mount.py +0 -0
  25. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/output.py +0 -0
  26. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/schema.py +0 -0
  27. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/src/linc_cli_kit/transport.py +0 -0
  28. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/__init__.py +0 -0
  29. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_errors.py +0 -0
  30. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_logging.py +0 -0
  31. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_mount.py +0 -0
  32. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_output.py +0 -0
  33. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_public_docs.py +0 -0
  34. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_schema.py +0 -0
  35. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_startup.py +0 -0
  36. {linc_cli_kit-0.1.3 → linc_cli_kit-0.1.5}/tests/test_transport.py +0 -0
@@ -2,6 +2,8 @@ name: test
2
2
  on:
3
3
  push: {branches: [main]}
4
4
  pull_request:
5
+ permissions:
6
+ contents: read
5
7
  jobs:
6
8
  test:
7
9
  strategy:
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to linc-cli-kit are listed here.
4
+
5
+ ## [0.1.5]
6
+
7
+ - Security: the lock moves PyJWT to 2.15.1 and certifi to 2026.10.23, clearing the open
8
+ advisories against the earlier versions. `tests/test_lock_floors.py` fails if a re-lock
9
+ drops either one back below its patched version.
10
+ - CI: the public `test.yml` workflow runs with a read-only `GITHUB_TOKEN`
11
+ (`permissions: contents: read`). No library changes.
12
+
13
+ ## [0.1.4]
14
+
15
+ - `strict_tools(server, *, strict_types=False)`: rejects unknown MCP tool arguments. Every
16
+ registered tool's argument model gets `extra="forbid"` and its schema advertises
17
+ `additionalProperties: false`, including tools registered after the call. `strict_types=True`
18
+ also turns off lax type coercion. It raises `TypeError`, naming the change, if the MCP SDK
19
+ internals it relies on are missing. Exported as `linc_cli_kit.strict_tools`.
20
+ - `assert_cli_parity` checks that MCP rejects an unknown argument, matching the CLI's exit-2
21
+ rejection of an unknown option.
22
+ - Lint: ruff `S101` (no `assert` in library code) is on; tests are exempt.
23
+
24
+ ## [0.1.3]
25
+
26
+ - Tests: the JSON-mode heartbeat test also polls instead of a fixed sleep (the sibling of the
27
+ 0.1.2 fix; failed on macOS / Python 3.12 CI). No library changes.
28
+
29
+ ## [0.1.2]
30
+
31
+ - Tests: the heartbeat test polls instead of relying on a fixed sleep (it was timing-flaky on
32
+ macOS CI runners). No library changes.
33
+
34
+ ## [0.1.1]
35
+
36
+ First public release. (0.1.0 was an internal, never-published tag of an earlier commit.)
37
+
38
+ - `mount_tools` reflects a FastMCP tool registry into Click commands under one output contract:
39
+ JSON on stdout (`--human`, `--stream` alternatives), an error envelope as the last stderr line,
40
+ exit codes `0`/`1`/`2`, SIGTERM/SIGINT unwinding.
41
+ - `schema --json` dumps the whole command tree.
42
+ - Transports: in-process `LocalTransport`, and `HttpDaemonTransport` with local fallback (never on
43
+ a timeout). The daemon port file resolves under `LINC_HOME`.
44
+ - `assert_cli_parity` conformance suite for adopters.
45
+ - Licence: Apache-2.0.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: linc-cli-kit
3
- Version: 0.1.3
3
+ Version: 0.1.5
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"
3
+ version = "0.1.5"
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
- Nine checks, run per mounted tool (unless justified-skipped):
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
- _check_stubbed_output_contract(cli, cmd, spec, name, sample or _minimal_sample(spec))
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,69 @@
1
+ """Security floors for locked dependencies and least-privilege CI permissions."""
2
+
3
+ import re
4
+ import tomllib
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ REPO = Path(__file__).resolve().parents[1]
10
+ LOCK = REPO / "uv.lock"
11
+
12
+ # Patched versions for open Dependabot advisories.
13
+ FLOORS = {
14
+ "pyjwt": (2, 15, 0),
15
+ "certifi": (2026, 10, 18),
16
+ }
17
+
18
+
19
+ def _version_tuple(version: str) -> tuple[int, ...]:
20
+ parts = []
21
+ for piece in version.split("."):
22
+ match = re.match(r"\d+", piece)
23
+ if match is None:
24
+ break
25
+ parts.append(int(match.group()))
26
+ if match.group() != piece:
27
+ break
28
+ return tuple(parts)
29
+
30
+
31
+ def _workflows() -> Path:
32
+ # Private repo: templates under public/.github (the export maps them to .github).
33
+ public = REPO / "public" / ".github" / "workflows"
34
+ return public if public.is_dir() else REPO / ".github" / "workflows"
35
+
36
+
37
+ def _top_level_permissions(workflow: str) -> dict[str, str]:
38
+ lines = workflow.splitlines()
39
+ start = lines.index("permissions:")
40
+ perms = {}
41
+ for line in lines[start + 1 :]:
42
+ if not line.startswith(" "):
43
+ break
44
+ key, _, value = line.strip().partition(":")
45
+ perms[key] = value.strip()
46
+ return perms
47
+
48
+
49
+ def test_locked_packages_meet_security_floors() -> None:
50
+ if not LOCK.exists():
51
+ pytest.skip("uv.lock is not part of the public mirror export")
52
+ locked = {
53
+ pkg["name"].lower(): pkg["version"] for pkg in tomllib.loads(LOCK.read_text())["package"]
54
+ }
55
+ for name, floor in FLOORS.items():
56
+ assert name in locked, f"{name} missing from uv.lock"
57
+ assert _version_tuple(locked[name]) >= floor, (
58
+ f"{name} {locked[name]} is below the patched floor {floor}"
59
+ )
60
+
61
+
62
+ def test_test_workflow_token_is_read_only() -> None:
63
+ workflow = (_workflows() / "test.yml").read_text()
64
+ assert _top_level_permissions(workflow) == {"contents": "read"}
65
+
66
+
67
+ def test_version_tuple_parses_prerelease_suffix() -> None:
68
+ assert _version_tuple("2.15.0rc1") == (2, 15, 0)
69
+ assert _version_tuple("2026.7.22") < (2026, 10, 18)
@@ -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,26 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to linc-cli-kit are listed here.
4
-
5
- ## [0.1.3]
6
-
7
- - Tests: the JSON-mode heartbeat test also polls instead of a fixed sleep (the sibling of the
8
- 0.1.2 fix; failed on macOS / Python 3.12 CI). No library changes.
9
-
10
- ## [0.1.2]
11
-
12
- - Tests: the heartbeat test polls instead of relying on a fixed sleep (it was timing-flaky on
13
- macOS CI runners). No library changes.
14
-
15
- ## [0.1.1]
16
-
17
- First public release. (0.1.0 was an internal, never-published tag of an earlier commit.)
18
-
19
- - `mount_tools` reflects a FastMCP tool registry into Click commands under one output contract:
20
- JSON on stdout (`--human`, `--stream` alternatives), an error envelope as the last stderr line,
21
- exit codes `0`/`1`/`2`, SIGTERM/SIGINT unwinding.
22
- - `schema --json` dumps the whole command tree.
23
- - Transports: in-process `LocalTransport`, and `HttpDaemonTransport` with local fallback (never on
24
- a timeout). The daemon port file resolves under `LINC_HOME`.
25
- - `assert_cli_parity` conformance suite for adopters.
26
- - Licence: Apache-2.0.
@@ -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