Skip to main content

linc-cli-kit

Turn a FastMCP tool registry into a Click CLI with one agent-grade output contract: JSON on stdout, a structured error envelope on stderr, exit codes 0/1/2, and a schema --json dump of the whole command tree. An importable conformance suite proves the CLI still matches the MCP tools. An agent that has learned one CLI built this way has learned them all.

Install

pip install linc-cli-kit        # or: uv add linc-cli-kit

Python 3.12+. Runtime dependencies: click and mcp.

Example

Save as hello_cli.py:

import click
from mcp.server.fastmcp import FastMCP

from linc_cli_kit import LocalTransport, install_globals, mount_tools, strict_tools

server = FastMCP("hello")


@server.tool()
def hello_greet(name: str, times: int = 1) -> dict:
    """Greet someone. Example: hello greet --name Ada --times 2"""
    return {"greeting": " ".join([f"Hello, {name}!"] * times)}


strict_tools(server)


@click.group()
def main() -> None:
    """hello: JSON on stdout by default, --human for text."""


install_globals(main)
mount_tools(main, server, prefix="hello_", transport=LocalTransport())

if __name__ == "__main__":
    main()
$ python hello_cli.py greet --name Ada --times 2
{"greeting": "Hello, Ada! Hello, Ada!"}

$ python hello_cli.py greet; echo "exit=$?"
{"error": {"code": "USAGE_ERROR", "message": "Missing option '--name'.", "hint": "", "category": "validation", "retryable": false, "details": {}}}
exit=2

$ python hello_cli.py schema --json     # the whole command tree, for an agent to read once

The error envelope goes to stderr and stdout stays empty, so python hello_cli.py greet | jq never parses half a document.

Wiring a real tool

# src/<tool>/cli/main.py: a real tool with an optional local daemon
import sys
import click
from linc_cli_kit import (
    HttpDaemonTransport, LocalTransport, configure_structlog_stderr,
    install_globals, mount_tools, strict_tools,
)

configure_structlog_stderr()   # MUST be above your package imports — see below

from <tool>.mcp._core import server            # noqa: E402
from <tool>.core.controller import Controller  # noqa: E402

strict_tools(server)           # reject unknown MCP tool arguments (matches Click's CLI check)


@click.group()
def main() -> None:
    """<tool> — JSON on stdout by default; --human for text, --stream for JSON Lines."""


install_globals(main)          # adds the global flags; mount_tools refuses to run without it
main.add_command(my_hand_written_command)      # hand-written commands win on name collision

mount_tools(
    main, server, prefix="<tool>_",
    transport=HttpDaemonTransport(
        "<tool>", "http://127.0.0.1:8111",
        fallback=LocalTransport(before=ensure_initialized),
        daemon_only=frozenset({"<tool>_diff"}),   # tools that need the daemon's in-memory state
    ),
    skip={"<tool>_internal": "why this tool has no CLI surface"},
    renderers={"state": render_state},            # --human text per command
)

Why configure_structlog_stderr() goes above the imports: modules bind their logger at import time, so a later call leaves already-bound loggers writing to stdout — which corrupts the JSON payload and breaks <tool> <cmd> | jq. Same reason install_globals calls configure_stderr_logging() for stdlib logging.

Transports. LocalTransport(fn_for=..., before=...) calls the tool function in-process (before runs one-time init; fn_for swaps in a different callable per spec). HttpDaemonTransport(tool, default_url, fallback=..., daemon_only=..., timeout=...) posts to {base}/tools/{name}. It resolves the base from LINC_<TOOL>_DAEMON, then $LINC_HOME/<tool>/daemon.port (LINC_HOME defaults to ~/.linc), then default_url, and falls back to fallback when the daemon is down. It never falls back on a timeout: the daemon is still executing the tool, so a retry would run it twice. nerve is the worked example.

Conformance test

from linc_cli_kit import assert_cli_parity
from <tool>.cli.main import main, _registry_server


def test_cli_parity() -> None:
    report = assert_cli_parity(
        main, _registry_server(), prefix="<tool>_",
        skip={"<tool>_internal": "why this tool has no CLI surface"},
        samples={"<tool>_screenshot": {"device": "emulator-5554"}},
    )
    assert report.tools >= 40

Rules the suite enforces, so read them before adding a command:

  • Every FastMCP server must call strict_tools(server). FastMCP ignores unknown arguments by default while Click rejects unknown options; the suite verifies that MCP rejects an unknown argument (extra="forbid") and advertises additionalProperties: false on every mounted tool.
  • Every hand-written override needs a samples[<tool_name>] entry. Without one the suite fails closed rather than silently skipping the override's JSON contract.
  • Overrides must route through the group transport (ctx.find_root().command.linc_transport), not call the tool function directly, so the suite's stubbed error payloads reach them and the exit-code contract is actually exercised. An override that never calls the transport (it hard-codes or locally computes its output) fails, with or without a sample.
  • Generated commands are checked for option parity, --help, the payload handed to the transport, and execution consistency against a direct LocalTransport call.
  • A sampled generated command runs once through the real transport with the process stdout captured at the file-descriptor level. A tool that prints, or a structlog logger bound to stdout at import, or a child process inheriting fd 1, fails the suite: in a real CLI process those bytes would land in the JSON payload.
  • Reserved option names — json, human, stream, output, timeout, log_level, help, yes — are remapped to --arg-<name> on generated commands.

Output contract

Default bare JSON on stdout, one document, no ANSI — strict JSON (no NaN/Infinity)
Encoding dataclass → object · pydantic → model_dump(mode="json") · set → sorted list · bytes → base64 string · datetime/date/time → ISO 8601 · other → str(). A non-finite float is a NON_JSON_VALUE error (exit 1), never invalid JSON. linc_cli_kit.to_json / json_default expose the same encoder
Modes --json (default), --human, --stream (JSON Lines), or LINC_OUTPUT=json|human|jsonl
Failure empty stdout, one {"error": {code, message, hint, category, retryable, details}} line on stderr — always the last line (see below)
Exit codes 0 success · 1 tool/internal error · 2 usage/validation error
Discovery <tool> schema --json dumps the whole command tree — no --help crawl
Artifacts -o/--output PATH writes binary content and returns a {type, path, bytes, sha256} reference
Other globals --timeout SECONDS, --log-level LEVEL; logs and heartbeats are stderr-only
Signals SIGTERM → exit 143, SIGINT → exit 130 with an INTERRUPTED envelope; both unwind the stack (finally / context managers run). SIGTERM is not optional: a watchdog hard-exits after LINC_SIGNAL_GRACE seconds (default 10; inf = never) if the unwind stalls, and a second SIGTERM (more than 0.5s after the first) hard-exits at once. SIGINT keeps Python's semantics — every Ctrl-C raises KeyboardInterrupt

Parsing stderr. stderr may carry more than the envelope. A long call writes one heartbeat line every 10s ({"event": "heartbeat", "label": ..., "elapsed_s": ...}, or [label] Ns elapsed under --human), and stdlib/structlog logging goes to stderr too. So do not json.loads the whole stream. The envelope is the last line: read stderr.strip().splitlines()[-1], or call linc_cli_kit.parse_error_envelope(stderr). It returns the last line that is an {"error": {...}} object, or None.

A hand-written command or group callback that raises is wrapped the same way as a generated command: envelope, exit 1 (or the ToolError's own exit_code), and no traceback.

How this repo works

This is a release mirror of a private LINC repository. The code is built by LINC's AI software factory under human review, and each release lands here as one commit. Issues are welcome; see CONTRIBUTING.md. To report a security problem, see SECURITY.md.

License

Apache-2.0. See LICENSE and NOTICE.


Built by LINC Innovations. We diagnose flaky device test suites.

Metadata

Release files for linc-cli-kit 0.1.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for linc-cli-kit 0.1.4
File Size Uploaded Provenance
linc_cli_kit-0.1.4.tar.gz 51.4 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for linc-cli-kit 0.1.4
File Interpreter ABI Platform
linc_cli_kit-0.1.4-py3-none-any.whl Python 3 none any Details

Total release size: 87.2 kB

Release files / linc_cli_kit-0.1.4.tar.gz

Download URL linc_cli_kit-0.1.4.tar.gz
Size 51.4 kB
Tags Source
SHA-256 checksum
How to use checksums
14ee6b38d19dc5548a3c6a9e21946ba34bc6b05eac5ee1094e833a170deaf79d
BLAKE2b-256 checksum
How to use checksums
fd62cc78262f20fe606341d93d7d0096876cc0c7752a1dab64d452775c1e4390
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Release files / linc_cli_kit-0.1.4-py3-none-any.whl

Download URL linc_cli_kit-0.1.4-py3-none-any.whl
Size 35.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a120b3ed2c4703c7e08aedf7f12d924162d0a3b5e33c7f424392bfa08543d784
BLAKE2b-256 checksum
How to use checksums
00649b647c09a7e27559b9ac75dd8f159c2d4b00b707911c10522ee11419d611
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Security for linc-cli-kit 0.1.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for linc_cli_kit-0.1.4.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page