feat(dual-mode): introduce mcp_common.dual_mode — single-source MCP tool + CLI command#101
feat(dual-mode): introduce mcp_common.dual_mode — single-source MCP tool + CLI command#101markballew wants to merge 8 commits into
Conversation
First slice of the dual-mode tool introspection framework (#86 — headline capability of mcp-common). A function decorated with @dual_mode_tool is registered with FastMCP exactly as if mcp.tool(...) had been called by hand, and its metadata is recorded in a per-FastMCP-instance registry (WeakKeyDictionary so GC tears it down with the server) for later CLI materialization. Escape hatches: - cli_only=True skips the mcp.tool registration (registry-only entry). - mcp_only=True still registers with FastMCP but later opts out of CLI. - name= / cli_name= / cli_group= / formatters= / summary= override defaults; **mcp_tool_kwargs are forwarded to mcp.tool(...). Default CLI name strips the FastMCP instance's namespace prefix and kebab-cases the rest: netbox_lookup_device on FastMCP("netbox") becomes lookup-device. The decorator returns the original function unchanged so direct Python callers see no indirection. Subsequent commits add the Typer parameter mapping, CliContext shim, and build_cli_from_mcp materializer. Co-authored-by: Cursor <cursoragent@cursor.com>
iter_typer_params(fn) walks a function's signature and synthesizes the parameter list a Typer command should advertise. Covers the canonical type vocabulary every vhspace MCP CLI needs: - str / int / float / bool / pathlib.Path — direct typer.Option mapping. - Optional[T] — required if no default, optional with None default when the function specifies '= None' (without the explicit default, the Optional annotation alone keeps the option required). - list[T] — multi-value option, defaults to [] so users don't have to pass an empty list explicitly. - Literal["a","b"] — Typer natively renders this as a Click choice. - pydantic.BaseModel — flattened into per-field --<param>-<field> options when the model has <= PYDANTIC_FLATTEN_THRESHOLD (6) fields, else falls back to a single --<param>-params '<json>' blob. Both paths re-bundle Typer kwargs back into a model instance via _PydanticFlatten.build_from_typer_kwargs. - fastmcp.Context — recognised but NOT exposed as a CLI option; the builder will inject a CliContext shim at call time. Existing Annotated[T, typer.Option/Argument(...)] annotations are respected and pass through unchanged so callers can opt into custom Typer behavior without giving up @dual_mode_tool. Annotations are evaluated with inspect.signature(eval_str=True) so PEP 563 'from __future__ import annotations' callers get the real runtime types. Co-authored-by: Cursor <cursoragent@cursor.com>
FastMCP tools may declare a 'ctx: Context' parameter for progress
reporting and structured logging. The CLI side has no MCP session, so
CliContext stands in for fastmcp.Context when the same function runs
from a Typer command.
The shim covers the methods every vhspace MCP actually uses:
- info / warning / error / debug / log → route to a stdlib logger at
the matching level, preserving ``logger_name`` and ``extra``.
- report_progress(progress, total=None, message=None) → emit a
'[NN%] message' line to stderr; falls back to 'progress/total' or
'progress' when the total is 0 or unknown.
All methods are async (matching fastmcp.Context's signatures) so the
builder can await them uniformly whether the tool itself is sync or
async. Other Context methods are not stubbed — calling them on a
CliContext raises AttributeError so missing coverage is discoverable
rather than silently no-op'd, and a module-import-time warning lists
the async Context methods the installed fastmcp exposes that this shim
doesn't cover.
Re-exported from mcp_common.dual_mode alongside dual_mode_tool.
Co-authored-by: Cursor <cursoragent@cursor.com>
The materializer ties the previous slices together. Walking the
per-FastMCP-instance registry populated by @dual_mode_tool, it
synthesizes one Typer command per tool whose body:
- Maps the wrapped function's signature via iter_typer_params(), adding
the shared --json / -j flag from mcp_common.cli.JsonOption.
- Drives async tools with asyncio.run; sync tools call through directly.
- Shims any fastmcp.Context parameter with a fresh CliContext instance.
- Re-bundles flattened Pydantic kwargs back into a model (or parses the
--<param>-params JSON blob for models above the flatten threshold).
- Routes the return value through mcp_common.cli.echo_result so --json
and human modes are uniform; per-tool 'formatters={type: fn}'
mappings drive the human-mode renderer.
The returned Typer app is built via create_cli_app(), so the standard
no_args_is_help + SuggestingTyperGroup + install_cli_exception_handler
wiring comes for free. Optional cli_group='devices' nests commands
under a Typer subgroup. mcp_only=True tools are filtered out of the
CLI surface but remain available to FastMCP.
Default CLI app name strips a trailing '-mcp' off the FastMCP instance
name: FastMCP('netbox-mcp') yields a 'netbox-cli' Typer app.
build_cli_from_mcp and CliContext are now re-exported from
mcp_common.dual_mode alongside dual_mode_tool.
Co-authored-by: Cursor <cursoragent@cursor.com>
The headline acceptance criterion from #86 is that a single @dual_mode_tool-decorated function is callable via both the FastMCP in-memory client AND typer.testing.CliRunner with consistent results in --json mode. tests/integration/test_dual_mode_e2e.py is the executable form of that contract: - A small FastMCP server with one sync tool, one async tool, and one async + Context tool. - TestParity invokes each tool over the in-memory FastMCP client (parsing the structured_content payload) AND via the CLI surface built by build_cli_from_mcp, then diffs the dicts. The Context-using tool is the load-bearing case because the MCP side has a real Context and the CLI side uses CliContext — return values must match regardless. - TestMcpToolRegistration independently verifies all dual-mode tools remain reachable via the FastMCP client (no regressions to the MCP-only surface). mcp-template adoption is tracked as a follow-up: the template lives in a separate repository (vhspace/mcp-template) and cannot ship in the same PR as the framework itself. Per-MCP migration issues will pick it up after this lands. Co-authored-by: Cursor <cursoragent@cursor.com>
Surface the framework on the top-level mcp_common package and document the canonical usage pattern. - Re-export dual_mode_tool, build_cli_from_mcp, and CliContext from mcp_common.__init__ (kept intentionally narrow per the reviewer feedback on #98). - Cover the new exports in tests/unit/test_init.py. - Add a 'Dual-mode tools' section to README with the canonical netbox-mcp example and the parameter / escape-hatch documentation. - CHANGELOG entry under Unreleased pointing to #86, listing the decorator + builder + CliContext surface, the parameter type coverage, and noting that per-MCP migrations + mcp-template adoption are follow-ups. Co-authored-by: Cursor <cursoragent@cursor.com>
Code review — feat(dual-mode): mcp_common.dual_modeSTATUS: CHANGES_REQUESTED The shape of the framework is sound and the per-FastMCP registry + builder + Context shim design is good. The integration test is exactly the right contract. But running the real code surfaced one blocker (any module with Verification
BlockersB1.
|
| Area | Status | Notes |
|---|---|---|
Per-tool override flexibility (formatters) |
✅ | Confirmed working with stateful closure formatter (test_dual_mode_builder.py::TestCustomFormatter + manual probe). MRO-based lookup means subclassed dicts route correctly. |
Custom CLI options (e.g. NetBox --limit 50) |
✅ | Annotated[int, typer.Option("--limit", "-l", help="…")] passes through (_has_typer_metadata branch); confirmed working. |
cli_only helpers (e.g. version, config show) |
✅ | Confirmed: @dual_mode_tool(mcp, cli_only=True) registers in the CLI without touching mcp.tool(); the command shows in --help and runs end-to-end. |
Branch-pin install path (git+https://…@feat/86-dual-mode-framework) |
✅ | pyproject.toml uses hatchling + standard PEP 517 — nothing in the build prevents pip / uv git+https resolution. No extra build scripts or env vars required. |
PEP 563 / from __future__ import annotations compat |
❌ | Blocker. See B1. Both gpu-diag-mcp (pilot) and netbox-mcp use future-annotations in source files — adoption will fail. |
| Nested Pydantic input models | ❌ | Important for NetBox create_device / update_device style endpoints with nested address / interfaces / tags models. See Bug 3. |
Pydantic Field(description=…) → CLI help |
❌ | Without this, downstream MCPs need to manually re-author per-field help in formatters or accept generic str option. text. See Bug 8. |
set[T] / non-Optional Union[T,U] rejected at decoration time |
❌ | Currently they crash the CLI app at first invocation. See Bug 4. |
Suggestions / nits
src/mcp_common/dual_mode/_typer_params.py:263–267—_typing_union()lazy-importstypingonly to compare againsttyping.Union. Sincetypingis already imported at module top (from typing import Annotated, Any, Literal, get_args, get_origin), this can be justUnion(withfrom typing import Union) at module top. Save the indirection.src/mcp_common/dual_mode/cli_context.py:138–141—print(line, file=self._stream, flush=True)is a slightly unusual choice for a structured shim —self._stream.write(line + "\n")+self._stream.flush()reads the same and avoids triggeringBrokenPipeErrordifferently. Minor.src/mcp_common/dual_mode/cli_context.py:222—_detect_context_drift()is called at module import. The resulting warning is now visible in every test run. Consider gating withif not os.environ.get("MCP_COMMON_SUPPRESS_CONTEXT_DRIFT_WARNING")or running once-per-process with a module-level_warned_onceflag.src/mcp_common/dual_mode/_typer_params.py:202–221—_make_optionalways builds anOptionInfowithdefault=...and overrides via_build_kw_only's default. Works, but is enough indirection to add a brief inline comment. The current docstring is fine — just consider whether the two-layer "default sentinel then real default" trick warrants an example.src/mcp_common/dual_mode/builder.py:158—as_json = bool(typer_kwargs.pop("json", False))— Bug 5 fix should land here as well, since the samejsonkey is what collides with a user parameter.src/mcp_common/dual_mode/_naming.py:36–48—strip_mcp_namespace's candidate set includesnormalized_mcp(which removes both-and_). ForFastMCP("netbox-mcp")+ toolnetbox_lookupthe prefixnetboxmcp_doesn't matchnetbox_lookup, so the tool keepsnetbox-lookuprather thanlookup. Not a bug, but a docstring example with this case would help downstream MCPs predict naming.tests/unit/test_dual_mode_builder.py::TestEmptyRegistry::test_no_tools_yields_help_only_app— only checks--helpsucceeds. Worth also assertingapp.registered_commands == []so future refactors don't accidentally inject scaffolding commands.- Worth adding a
test_dual_mode_decorator.pycase that calls@dual_mode_toolon adef my_tool(): ...taking zero parameters — currently the test suite only has zero-param tools embedded in largercli_onlyexamples.
Approval recommendation
CHANGES_REQUESTED. The headline capability needs to survive from __future__ import annotations before downstream migration can proceed (B1 — critical; the proof is that adding the import to test_dual_mode_e2e.py flips 3/5 tests RED). The other bugs (Literal[int], nested Pydantic, set[T], parameter-named-json, Field-description drop, cli_name validation) are all small, well-localized, and will be encountered by the first downstream migration. None of them require a redesign. Fix B1 + Bugs 2/3/4/5/8, add the PEP 563 regression test to tests/integration/, and tighten cli_name validation in the decorator. The DRY/KISS cleanups (_PydanticFlatten.for_param, CONTEXT_PARAM_SENTINEL, drift-warning gating) can come as a follow-up if it keeps this PR's blast radius reasonable.
When a tool is defined in a module with `from __future__ import
annotations`, FastMCP's `tool()` caches a stringified signature on
`fn.__signature__` at decoration time. `inspect.signature(fn,
eval_str=True)` returns that cached signature without re-evaluating its
strings, so PEP 563 stringified types come back as `ForwardRef('str')`
and Typer raises "Type not yet supported".
Resolve annotations via `typing.get_type_hints(fn, include_extras=True)`
instead, which always re-evaluates against `fn.__globals__` regardless
of any cached `__signature__`, and substitute the resolved types onto
the parameters returned by `inspect.signature(fn)`. `include_extras=True`
preserves any user-supplied `Annotated[T, typer.Option(...)]` metadata
so the existing passthrough path keeps working.
Add `tests/integration/test_dual_mode_e2e_future_annotations.py`
mirroring the e2e parity suite under `from __future__ import
annotations` so any regression triggers in CI.
Refs #101.
Co-authored-by: Cursor <cursoragent@cursor.com>
Bug2: Literal[int|float|bool] choices were rejecting every valid input because Click's Choice compares the raw user-string to the int/float choices and never coerces. Detect the homogeneous scalar literal, replace the annotation with the underlying scalar type so Click coerces the input, and validate membership via a callback on the OptionInfo. Bug3: Nested Pydantic / list[Pydantic] / dict fields crashed Typer at app-build time because flattening passed their annotation straight through. ``_PydanticFlatten.synthesize_params`` now detects complex field types (``_is_complex_field_type``) and falls back to a ``--<field>-json`` blob for that specific field while keeping sibling primitive flattening intact. Validation runs through Pydantic's ``model_validate`` / ``TypeAdapter``; JSON parse and validation errors surface as ``typer.BadParameter``. Bug4: ``set[T]`` / ``frozenset[T]`` and non-Optional ``Union[T, U]`` crashed at first invocation. Reject both at decoration time via a new ``validate_supported_annotation`` helper, with messages naming the offending parameter and pointing at the supported alternatives. Bug5: A wrapped function with a parameter named ``json`` raised ``ValueError: duplicate parameter name`` because the builder appends a synthetic ``json`` param. Reject the collision at decoration time with a clear message rather than letting it explode mid-build. Bug6: A sync tool that returned a coroutine or async generator was ``str()``-ified through ``echo_result``, producing ``"<coroutine object …>"`` JSON output. Detect the case in ``_invoke_tool`` and raise ``RuntimeError`` so the user sees the actual mistake, then close the coroutine to suppress the "coroutine never awaited" warning. Bug7: ``cli_name`` validation gaps (duplicates, whitespace, leading underscore) silently produced unreachable commands. Validate ``cli_name`` against ``[a-z0-9][a-z0-9-]*`` at decoration time, strip leading underscores in ``to_kebab_case`` so ``_private_thing`` derives ``private-thing`` rather than ``-private-thing``, and raise ``ValueError`` on per-instance ``cli_name`` collisions. Bug8: ``Field(description=...)`` was thrown away during Pydantic flattening. Read ``field_info.description`` and pass it through ``_to_typer_parameter(help_override=...)`` so the synthesized ``--help`` shows the documented field description instead of the generic ``"str option."`` placeholder. Cleanup1: Drop ``_PydanticFlatten.for_param`` (a pure passthrough to ``from_parameter`` whose docstring claimed caching it didn't do). Builder now calls ``from_parameter`` directly. Cleanup2: Drop ``CONTEXT_PARAM_SENTINEL`` (exported but never set in the registry; the rationale "kept as extension point" was just dead code). Removed from ``__all__``, the module, and the unused branch in ``builder._rehydrate_call_kwargs``. Cleanup3: Gate ``_detect_context_drift`` so it fires at most once per process via a module-level ``_drift_warned_once`` flag. Tests use a new ``force=True`` kwarg to re-run the detector deterministically. Docs: ``build_cli_from_mcp`` now notes that ``typer.testing.CliRunner`` bypasses the ``install_cli_exception_handler`` footer (errors come through ``result.exception`` / ``result.exit_code``). Tests: Bug2 (Literal int/float CLI parity), Bug3 (nested Pydantic + list[Pydantic] flattening), Bug4 (set/Union/frozenset rejection), Bug5 (json-name collision), Bug6 (sync coroutine + asyncgen leakage), Bug7 (duplicate / whitespace / underscore / leading-underscore name cases incl. mcp_only carve-out), Bug8 (Field description in help), Cleanup3 (once-only drift warning), and an empty-registry ``app.registered_commands == []`` assertion. Updated the false ``inspect.signature(eval_str=True)`` claim in the params test docstring. Refs #101. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Closes #86 — the headline capability of
mcp-common. A single function definition becomes BOTH a FastMCP tool AND a Typer CLI command, eliminating the parallel-implementation pattern that duplicated ~500–2000+ LOC across every vhspace MCP.Same function, two surfaces, output formatting routed through
echo_result(Pydantic-aware, sorted-key JSON, agent-friendly).What's in
mcp_common.dual_modepackage@dual_mode_tooldecorator withcli_name,cli_group,formatters,cli_only,mcp_only,summary, and**mcp_tool_kwargsescape hatchesbuild_cli_from_mcpmaterializer built on top ofcreate_cli_appstr,int,float,bool,pathlib.Path,Optional[T],list[T],Literal[...], Pydantic models (flatten up to 6 fields or--<param>-params '<json>'blob)asyncio.run)CliContextshim for tools that takeContext; coversinfo/warning/error/debug/log/report_progressnetbox_lookup_deviceonFastMCP("netbox")→lookup-device)tests/integration/test_dual_mode_e2e.py)CliContext, builder, integrationPer-MCP migrations (follow-up — one issue per MCP)
Tracked in the #86 migration checklist. Pilots after merge: gpu-diag-mcp, then netbox-mcp, then others.
mcp-templateadoption is a separate PR since it lives in a different repository.Test plan
uv run pytest— 667 passed (568 baseline + 99 new)uv run ruff check src/ tests/— cleanuv run ruff format --check src/ tests/— cleanuv run mypy src/mcp_common/dual_mode/— clean (pre-existing errors incredential_chain.py/testing/eval/are unrelated)--jsonoutput across sync, async, and Context-using toolsDesign notes
--<param>-<field>options; larger models fall back to a single--<param>-params '<json>'blob (so e.g. complex create requests still work without exposing a 20-flag command).asyncio.run.mcp_common.progress.poll_with_progressand the existing FastMCP layer are asyncio-based, so the CLI side follows suit. (anyiois used insidemcp_common.testingfor client tests but not as the framework convention for tool execution.)fastmcp3.0.2.CliContextshimsinfo/warning/error/debug/log/report_progress; remaining async Context methods raiseAttributeErroron use, and a module-import-time warning lists them so drift between framework + downstream is observable rather than silent.WeakKeyDictionaryso two FastMCP servers in the same process get independent registries and GC-on-shutdown tears down state without leaks.Risks
ContextAPI drift —CliContextmirrors what's installed; if FastMCP releases new Context methods that downstream tools want to call from the CLI, follow-up PRs will need to extend the shim.inspect.signature(..., eval_str=True)requires Python 3.10+ (project already pins 3.12+).