From 569535fe775293f00723a4bd4a5f00f42a58e4e4 Mon Sep 17 00:00:00 2001 From: cowork-bot Date: Sat, 11 Jul 2026 05:50:18 -0400 Subject: [PATCH 1/3] cowork-bot: fix multi-valued Click params (multiple / nargs) in MCP adapter The adapter advertised multiple=True options, variadic (nargs=-1) arguments, and fixed-tuple (nargs>1) options as a single scalar string, and the handler stringified their list/tuple value into one garbage CLI argument (e.g. --tag "['a', 'b']"), so any LLM calling such a tool passed broken args. - Map multi-valued params to JSON Schema arrays whose items carry the element type; pin length (minItems/maxItems) for fixed nargs>1 tuples. - Expand list/tuple values in the handler: repeat the flag per value for multiple=True, emit one flag + N values for nargs>1, and one arg per element for variadic positionals. Scalar behavior is unchanged. - Add tests/test_multi_valued_params.py (10 cases) covering schema shape and invocation for all three multi-valued forms plus scalar back-compat. ruff check + format clean; existing adapter/list-tools/lint tests still pass. --- click_to_mcp/adapter.py | 106 +++++++++++++++++++++++++----- tests/test_multi_valued_params.py | 94 ++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 tests/test_multi_valued_params.py diff --git a/click_to_mcp/adapter.py b/click_to_mcp/adapter.py index b252937..5c1ab26 100644 --- a/click_to_mcp/adapter.py +++ b/click_to_mcp/adapter.py @@ -27,26 +27,65 @@ def _param_type_name(t: Any) -> str: return type(t).__name__ -def _click_type_to_json_schema(param: click.Parameter) -> dict[str, Any]: - """Map a Click parameter type to a JSON Schema property definition.""" - base: dict[str, Any] = {} +def _element_json_schema(param: click.Parameter) -> dict[str, Any]: + """Map a Click parameter's *element* type to a scalar JSON Schema fragment. + This is the per-value type, independent of whether the parameter accepts a + single value or many (see :func:`_is_multi_valued`). + """ t = param.type t_name = _param_type_name(t) + element: dict[str, Any] = {} if isinstance(t, click.Choice): - base["type"] = "string" - base["enum"] = t.choices + element["type"] = "string" + element["enum"] = t.choices elif t_name in ("IntParamType", "INT"): - base["type"] = "integer" + element["type"] = "integer" elif t_name in ("FloatParamType", "FLOAT"): - base["type"] = "number" + element["type"] = "number" elif t_name in ("BoolParamType", "BOOLEAN"): - base["type"] = "boolean" + element["type"] = "boolean" + else: + element["type"] = "string" + + return element + + +def _is_multi_valued(param: click.Parameter) -> bool: + """True if the parameter accepts more than one value. + + Covers ``multiple=True`` options (repeatable flags) as well as variadic + (``nargs=-1``) and fixed-tuple (``nargs>1``) options/arguments. + """ + if getattr(param, "multiple", False): + return True + nargs = getattr(param, "nargs", 1) + return nargs == -1 or (isinstance(nargs, int) and nargs > 1) + + +def _click_type_to_json_schema(param: click.Parameter) -> dict[str, Any]: + """Map a Click parameter type to a JSON Schema property definition. + + Multi-valued parameters (``multiple=True``, or ``nargs=-1`` / ``nargs>1``) + are mapped to an ``array`` whose ``items`` carry the element type, instead + of being silently flattened to a single scalar string. Fixed-length tuple + parameters (``nargs>1``) additionally constrain the array length. + """ + element = _element_json_schema(param) + + if _is_multi_valued(param): + base: dict[str, Any] = {"type": "array", "items": element} + nargs = getattr(param, "nargs", 1) + # Fixed-length tuple option/argument (e.g. nargs=2): pin the length. + if not getattr(param, "multiple", False) and isinstance(nargs, int) and nargs > 1: + base["minItems"] = nargs + base["maxItems"] = nargs else: - base["type"] = "string" + base = element base["description"] = getattr(param, "help", None) or "" + if not param.required: default = param.default if param.default is not None and param.default != () else None # Check for Click Sentinel (UNSET) values @@ -55,11 +94,23 @@ def _click_type_to_json_schema(param: click.Parameter) -> dict[str, Any]: if default_str == "Sentinel.UNSET" or "Sentinel" in default_str: default = None if default is not None and default is not inspect.Parameter.empty: + # Click represents multiple/nargs defaults as tuples; JSON wants a list. + if isinstance(default, tuple): + default = list(default) base["default"] = default return base +def _append_option(args: list[str], opt: str, val: Any) -> None: + """Append a single scalar option value to the CLI arg list.""" + if isinstance(val, bool): + if val: + args.append(opt) + else: + args.extend([opt, str(val)]) + + def _build_click_tool_def(cmd: click.Command, prefix: str = "") -> CliToolDef | None: """Convert a single Click Command into a CliToolDef. @@ -73,6 +124,8 @@ def _build_click_tool_def(cmd: click.Command, prefix: str = "") -> CliToolDef | properties: dict[str, Any] = {} required: list[str] = [] positional_args: list[str] = [] # track positional arg order + multiple_opts: set[str] = set() # options with multiple=True (repeat the flag) + nargs_opts: set[str] = set() # options with nargs>1 (one flag, many values) for param in cmd.params: if isinstance(param, click.Option): @@ -81,6 +134,12 @@ def _build_click_tool_def(cmd: click.Command, prefix: str = "") -> CliToolDef | key = names[0].lstrip("-").replace("-", "_") if names else (param.name or "") if not key or key == "ctx": continue + if getattr(param, "multiple", False): + multiple_opts.add(key) + else: + nargs = getattr(param, "nargs", 1) + if isinstance(nargs, int) and nargs > 1: + nargs_opts.add(key) elif isinstance(param, click.Argument): key = param.name or "" if not key: @@ -100,11 +159,16 @@ def handler(**kwargs: Any) -> str: runner = CliRunner() args: list[str] = [] - # Positional args first (in order of definition) + # Positional args first (in order of definition). Variadic positionals + # (nargs=-1 / nargs>1) arrive as a list/tuple and expand to one arg each. for pos_key in positional_args: if pos_key in kwargs: val = kwargs.pop(pos_key) - if val is not None: + if val is None: + continue + if isinstance(val, (list, tuple)): + args.extend(str(v) for v in val) + else: args.append(str(val)) # Then options @@ -112,11 +176,23 @@ def handler(**kwargs: Any) -> str: if val is None: continue opt = f"--{key.replace('_', '-')}" - if isinstance(val, bool): - if val: - args.append(opt) + if key in multiple_opts: + # Repeatable option: emit the flag once per value. + values = val if isinstance(val, (list, tuple)) else [val] + for v in values: + if isinstance(v, (list, tuple)): + # multiple=True combined with nargs>1: flag + N values. + args.append(opt) + args.extend(str(x) for x in v) + else: + _append_option(args, opt, v) + elif key in nargs_opts: + # Fixed-tuple option: one flag followed by all its values. + values = val if isinstance(val, (list, tuple)) else [val] + args.append(opt) + args.extend(str(v) for v in values) else: - args.extend([opt, str(val)]) + _append_option(args, opt, val) result = runner.invoke(cmd, args, catch_exceptions=False) if result.exit_code != 0: diff --git a/tests/test_multi_valued_params.py b/tests/test_multi_valued_params.py new file mode 100644 index 0000000..b5f6dd5 --- /dev/null +++ b/tests/test_multi_valued_params.py @@ -0,0 +1,94 @@ +"""Tests for multi-valued parameters: multiple=True options, variadic (nargs=-1) +arguments, and fixed-tuple (nargs>1) options. + +Regression coverage for the bug where such parameters were advertised as a +single scalar string in the JSON Schema and had their list/tuple value +stringified into one garbage CLI argument (e.g. ``--tag "['a', 'b']"``). +""" + +from __future__ import annotations + +import click + +from click_to_mcp.adapter import cli_to_mcp_tools + + +def _build_cli() -> click.Group: + @click.group() + def cli() -> None: # pragma: no cover - group entry + pass + + @cli.command() + @click.option("--tag", multiple=True, help="repeatable tag") + @click.option("--coord", nargs=2, type=int, help="an x/y pair") + @click.option("--name", default="anon") + @click.option("--verbose", is_flag=True) + @click.argument("files", nargs=-1) + def build(tag, coord, name, verbose, files) -> None: + click.echo(f"tags={list(tag)} coord={coord} name={name} verbose={verbose} files={list(files)}") + + return cli + + +def _tool(name: str): + return next(t for t in cli_to_mcp_tools(_build_cli()) if t.name == name) + + +class TestMultiValuedSchema: + """The JSON Schema should model multi-valued params as arrays.""" + + def test_multiple_option_is_array_of_element_type(self) -> None: + props = _tool("build").input_schema["properties"] + assert props["tag"]["type"] == "array" + assert props["tag"]["items"] == {"type": "string"} + + def test_variadic_argument_is_array(self) -> None: + props = _tool("build").input_schema["properties"] + assert props["files"]["type"] == "array" + assert props["files"]["items"] == {"type": "string"} + + def test_fixed_tuple_option_is_length_constrained_array(self) -> None: + props = _tool("build").input_schema["properties"] + coord = props["coord"] + assert coord["type"] == "array" + assert coord["items"] == {"type": "integer"} + assert coord["minItems"] == 2 + assert coord["maxItems"] == 2 + + def test_scalar_params_are_unchanged(self) -> None: + props = _tool("build").input_schema["properties"] + assert props["name"]["type"] == "string" + assert props["verbose"]["type"] == "boolean" + + +class TestMultiValuedInvocation: + """The handler should expand list/tuple values into real CLI arguments.""" + + def test_multiple_option_repeats_flag(self) -> None: + out = _tool("build").handler(tag=["a", "b"]) + assert "tags=['a', 'b']" in out + + def test_variadic_argument_expands(self) -> None: + out = _tool("build").handler(files=["x.py", "y.py"]) + assert "files=['x.py', 'y.py']" in out + + def test_fixed_tuple_option_passes_all_values(self) -> None: + out = _tool("build").handler(coord=[3, 4]) + assert "coord=(3, 4)" in out + + def test_combined_multi_valued_call(self) -> None: + out = _tool("build").handler(tag=["a", "b"], coord=[1, 2], files=["m", "n"], verbose=True) + assert "tags=['a', 'b']" in out + assert "coord=(1, 2)" in out + assert "files=['m', 'n']" in out + assert "verbose=True" in out + + def test_scalar_option_still_works(self) -> None: + out = _tool("build").handler(name="alice") + assert "name=alice" in out + + def test_single_value_for_multiple_option_is_accepted(self) -> None: + # Defensive: a caller passing a bare scalar to a repeatable option + # should still produce a valid single-value invocation. + out = _tool("build").handler(tag="solo") + assert "tags=['solo']" in out From b9dfa9eca4cd0ef81cb1760dbc48006f442c1951 Mon Sep 17 00:00:00 2001 From: cowork-bot Date: Mon, 13 Jul 2026 15:40:07 -0400 Subject: [PATCH 2/3] cowork-bot: map click.IntRange/FloatRange to numeric JSON Schema with bounds Previously IntRange/FloatRange options were introspected as a generic "string" JSON Schema type, dropping both the integer/number type and the min/max bounds. MCP clients were therefore told a bounded numeric option was a free-form string. - Add _apply_range_bounds() to emit minimum/maximum (or exclusiveMinimum/exclusiveMaximum for open bounds) from click's IntRange/FloatRange. - Map IntRange -> integer and FloatRange -> number in _element_json_schema(). - Add tests/test_numeric_range_params.py (9 cases: closed bounds, open-ended, exclusive upper bound, multi-valued composition, invocation + out-of-range rejection). - 203 tests pass; ruff check + format clean. --- CHANGELOG.md | 1 + click_to_mcp/adapter.py | 27 ++++++++ tests/test_numeric_range_params.py | 99 ++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 tests/test_numeric_range_params.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a3f39f..9b4a7cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ All notable changes to click-to-mcp will be documented in this file. - 7 ruff lint errors resolved - UTF-8 encoding (mojibake) in source files - `npm badge` reference fixed in README +- JSON Schema maps `click.IntRange`/`click.FloatRange` options to `integer`/`number` with `minimum`/`maximum` (and `exclusiveMinimum`/`exclusiveMaximum` for open bounds) instead of a generic `string` (#37) ## [0.5.0] — 2026-05-18 diff --git a/click_to_mcp/adapter.py b/click_to_mcp/adapter.py index 5c1ab26..ab0730c 100644 --- a/click_to_mcp/adapter.py +++ b/click_to_mcp/adapter.py @@ -27,6 +27,27 @@ def _param_type_name(t: Any) -> str: return type(t).__name__ +def _apply_range_bounds(element: dict[str, Any], rng: Any) -> None: + """Add JSON Schema numeric bounds from a click IntRange/FloatRange. + + Honors open vs closed bounds via ``minimum``/``maximum`` vs + ``exclusiveMinimum``/``exclusiveMaximum``. No-op when a bound is + unset (e.g. an open-ended range). + """ + lo = getattr(rng, "min", None) + hi = getattr(rng, "max", None) + if lo is not None: + if getattr(rng, "min_open", False): + element["exclusiveMinimum"] = lo + else: + element["minimum"] = lo + if hi is not None: + if getattr(rng, "max_open", False): + element["exclusiveMaximum"] = hi + else: + element["maximum"] = hi + + def _element_json_schema(param: click.Parameter) -> dict[str, Any]: """Map a Click parameter's *element* type to a scalar JSON Schema fragment. @@ -46,6 +67,12 @@ def _element_json_schema(param: click.Parameter) -> dict[str, Any]: element["type"] = "number" elif t_name in ("BoolParamType", "BOOLEAN"): element["type"] = "boolean" + elif t_name in ("IntRange",): + element["type"] = "integer" + _apply_range_bounds(element, t) + elif t_name in ("FloatRange",): + element["type"] = "number" + _apply_range_bounds(element, t) else: element["type"] = "string" diff --git a/tests/test_numeric_range_params.py b/tests/test_numeric_range_params.py new file mode 100644 index 0000000..65cecd6 --- /dev/null +++ b/tests/test_numeric_range_params.py @@ -0,0 +1,99 @@ +"""Tests for numeric range parameters: click.IntRange / click.FloatRange options. + +Regression coverage for the bug where such parameters were advertised as a +generic ``"string"`` in the JSON Schema — dropping both the integer/number +type and the ``min``/``max`` bounds. An MCP client was therefore told a +bounded numeric option was a free-form string. +""" + +from __future__ import annotations + +import click +import pytest + +from click_to_mcp.adapter import cli_to_mcp_tools + + +def _build_cli() -> click.Group: + @click.group() + def cli() -> None: # pragma: no cover - group entry + pass + + @cli.command() + @click.option("--score", type=click.IntRange(1, 100), help="closed int range") + @click.option("--ratio", type=click.FloatRange(0.0, 1.0), help="closed float range") + @click.option("--level", type=click.IntRange(min=5), help="open lower bound only") + @click.option("--cap", type=click.IntRange(1, 100, max_open=True), help="open upper bound") + @click.option("--tags", multiple=True, type=click.IntRange(0, 3), help="repeatable bounded int") + @click.option("--count", type=int, help="plain int") + @click.option("--name", default="anon", help="plain string") + def cmd(score, ratio, level, cap, tags, count, name) -> None: + click.echo(f"score={score} ratio={ratio} level={level} cap={cap} tags={list(tags)} count={count} name={name}") + + return cli + + +def _tool(name: str): + return next(t for t in cli_to_mcp_tools(_build_cli()) if t.name == name) + + +class TestNumericRangeSchema: + """The JSON Schema should model IntRange/FloatRange as numeric with bounds.""" + + def test_int_range_is_integer_with_closed_bounds(self) -> None: + props = _tool("cmd").input_schema["properties"] + assert props["score"]["type"] == "integer" + assert props["score"]["minimum"] == 1 + assert props["score"]["maximum"] == 100 + + def test_float_range_is_number_with_closed_bounds(self) -> None: + props = _tool("cmd").input_schema["properties"] + assert props["ratio"]["type"] == "number" + assert props["ratio"]["minimum"] == 0.0 + assert props["ratio"]["maximum"] == 1.0 + + def test_open_ended_lower_bound_only(self) -> None: + props = _tool("cmd").input_schema["properties"] + assert props["level"]["type"] == "integer" + assert props["level"]["minimum"] == 5 + assert "maximum" not in props["level"] + assert "exclusiveMaximum" not in props["level"] + + def test_open_upper_bound_uses_exclusive_maximum(self) -> None: + props = _tool("cmd").input_schema["properties"] + assert props["cap"]["type"] == "integer" + assert props["cap"]["minimum"] == 1 + assert props["cap"]["exclusiveMaximum"] == 100 + assert "maximum" not in props["cap"] + + def test_multi_valued_range_composes_with_array(self) -> None: + props = _tool("cmd").input_schema["properties"] + tags = props["tags"] + assert tags["type"] == "array" + assert tags["items"] == {"type": "integer", "minimum": 0, "maximum": 3} + + def test_plain_types_unaffected(self) -> None: + props = _tool("cmd").input_schema["properties"] + assert props["count"]["type"] == "integer" + assert "minimum" not in props["count"] + assert props["name"]["type"] == "string" + + +class TestNumericRangeInvocation: + """The handler should still pass values through and enforce ranges.""" + + def test_int_range_handler_passes_value(self) -> None: + out = _tool("cmd").handler(score=42) + assert "score=42" in out + + def test_float_range_handler_passes_value(self) -> None: + out = _tool("cmd").handler(ratio=0.5) + assert "ratio=0.5" in out + + def test_out_of_range_value_is_rejected(self) -> None: + # End-to-end: a value outside the IntRange must raise (click enforces + # the bound; the server maps that to an MCP tool error, not a silent + # success). The adapter surfaces this as RuntimeError (handler-wrapped + # non-zero exit) or, if click re-raises, BadParameter. + with pytest.raises((RuntimeError, click.exceptions.BadParameter)): + _tool("cmd").handler(score=200) From 7d145a14c42629454973136d89539f012fae6976 Mon Sep 17 00:00:00 2001 From: marketing-growth-agent Date: Tue, 14 Jul 2026 23:14:16 -0400 Subject: [PATCH 3/3] fix(marketing): click-to-mcp not on public PyPI (live 404) - use git+ install; honest PyPI badge --- README.md | 14 +++++++------- skill/click-to-mcp/SKILL.md | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3928a6d..2628271 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/Coding-Dev-Tools/click-to-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Coding-Dev-Tools/click-to-mcp/actions/workflows/ci.yml) -[![PyPI](https://img.shields.io/pypi/v/click-to-mcp)](https://pypi.org/project/click-to-mcp/) +[![PyPI](https://img.shields.io/badge/PyPI-not%20published-red)](https://github.com/Coding-Dev-Tools/click-to-mcp) [![GitHub stars](https://img.shields.io/github/stars/Coding-Dev-Tools/click-to-mcp?style=social)](https://github.com/Coding-Dev-Tools/click-to-mcp/stargazers) [![Awesome MCP Server](https://img.shields.io/badge/Awesome_MCP_Server-Listed-brightgreen?logo=github)](https://github.com/abordage/awesome-mcp) [![Awesome Codex CLI](https://img.shields.io/badge/Awesome_Codex_CLI-Listed-brightgreen?logo=github)](https://github.com/milisp/awesome-codex-cli) @@ -28,7 +28,7 @@ Part of the [DevForge](https://coding-dev-tools.github.io/devforge/) developer t **The click-to-mcp way:** ```bash -pip install click-to-mcp +pip install git+https://github.com/Coding-Dev-Tools/click-to-mcp.git click-to-mcp serve your-cli ``` @@ -40,16 +40,16 @@ Works with [DevForge CLI tools](https://coding-dev-tools.github.io/devforge/) ou ## Quick Start -Install from PyPI (recommended): +Install from GitHub (recommended — not yet on public PyPI): ```bash -pip install click-to-mcp +pip install git+https://github.com/Coding-Dev-Tools/click-to-mcp.git ``` For HTTP+SSE transport (web-based MCP clients), install with the `http` extra: ```bash -pip install "click-to-mcp[http]" +pip install "click-to-mcp[http] @ git+https://github.com/Coding-Dev-Tools/click-to-mcp.git" ``` Install directly from GitHub (latest development version): @@ -253,7 +253,7 @@ click-to-mcp discover # Serve a specific CLI as an MCP server over stdio click-to-mcp serve -# Serve over HTTP+SSE (requires pip install "click-to-mcp[http]") +# Serve over HTTP+SSE (requires pip install "click-to-mcp[http] @ git+https://github.com/Coding-Dev-Tools/click-to-mcp.git") click-to-mcp serve-http --port 8000 # Serve the built-in demo @@ -326,7 +326,7 @@ Best for web-based MCP clients, remote access, and multi-user setups. Requires t ```bash # Install HTTP dependencies -pip install "click-to-mcp[http]" +pip install "click-to-mcp[http] @ git+https://github.com/Coding-Dev-Tools/click-to-mcp.git" # Start an HTTP+SSE server click-to-mcp serve-http --host 127.0.0.1 --port 8000 diff --git a/skill/click-to-mcp/SKILL.md b/skill/click-to-mcp/SKILL.md index 3e88532..4e97fef 100644 --- a/skill/click-to-mcp/SKILL.md +++ b/skill/click-to-mcp/SKILL.md @@ -29,7 +29,7 @@ This works because Click and typer CLIs already define structured interfaces (co ### 1. Install ```bash -pip install click-to-mcp +pip install git+https://github.com/Coding-Dev-Tools/click-to-mcp.git # or: uvx click-to-mcp # or: npm install -g click-to-mcp ```