Skip to content

feat(dual-mode): introduce mcp_common.dual_mode — single-source MCP tool + CLI command#101

Open
markballew wants to merge 8 commits into
mainfrom
feat/86-dual-mode-framework
Open

feat(dual-mode): introduce mcp_common.dual_mode — single-source MCP tool + CLI command#101
markballew wants to merge 8 commits into
mainfrom
feat/86-dual-mode-framework

Conversation

@markballew
Copy link
Copy Markdown
Contributor

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.

from fastmcp import FastMCP
from mcp_common.cli import run_cli
from mcp_common.dual_mode import build_cli_from_mcp, dual_mode_tool

mcp = FastMCP("netbox-mcp")

@dual_mode_tool(mcp, cli_name="lookup-device")
def lookup_device(hostname: str, include_interfaces: bool = False) -> dict:
    """Resolve a hostname/IP to a NetBox device."""
    ...

app = build_cli_from_mcp(mcp, project_repo="vhspace/netbox-mcp")

if __name__ == "__main__":
    run_cli(app, log_name="netbox_cli")

Same function, two surfaces, output formatting routed through echo_result (Pydantic-aware, sorted-key JSON, agent-friendly).

What's in

  • mcp_common.dual_mode package
  • @dual_mode_tool decorator with cli_name, cli_group, formatters, cli_only, mcp_only, summary, and **mcp_tool_kwargs escape hatches
  • build_cli_from_mcp materializer built on top of create_cli_app
  • Parameter introspection: str, int, float, bool, pathlib.Path, Optional[T], list[T], Literal[...], Pydantic models (flatten up to 6 fields or --<param>-params '<json>' blob)
  • Sync + async tool support (asyncio.run)
  • CliContext shim for tools that take Context; covers info / warning / error / debug / log / report_progress
  • MCP-namespace prefix stripping on default CLI names (netbox_lookup_device on FastMCP("netbox")lookup-device)
  • Integration test proving the "two surfaces, consistent output" contract (tests/integration/test_dual_mode_e2e.py)
  • 99 new tests across decorator/registry/naming, param mapping, CliContext, builder, integration
  • README "Dual-mode tools" section and CHANGELOG entry

Per-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-template adoption 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/ — clean
  • uv run ruff format --check src/ tests/ — clean
  • uv run mypy src/mcp_common/dual_mode/ — clean (pre-existing errors in credential_chain.py / testing/eval/ are unrelated)
  • Integration test: same function callable via FastMCP in-memory client + CliRunner with consistent --json output across sync, async, and Context-using tools

Design notes

  • Pydantic flatten threshold: 6 fields. Models with ≤ 6 fields get individual --<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).
  • Async runner: asyncio.run. mcp_common.progress.poll_with_progress and the existing FastMCP layer are asyncio-based, so the CLI side follows suit. (anyio is used inside mcp_common.testing for client tests but not as the framework convention for tool execution.)
  • Context API match: targets the installed fastmcp 3.0.2. CliContext shims info / warning / error / debug / log / report_progress; remaining async Context methods raise AttributeError on use, and a module-import-time warning lists them so drift between framework + downstream is observable rather than silent.
  • Registry: per-FastMCP-instance via WeakKeyDictionary so two FastMCP servers in the same process get independent registries and GC-on-shutdown tears down state without leaks.

Risks

  • FastMCP Context API drift — CliContext mirrors 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.
  • Pydantic model flatten threshold (currently 6) is heuristic; revisable.
  • inspect.signature(..., eval_str=True) requires Python 3.10+ (project already pins 3.12+).

markballew and others added 6 commits May 28, 2026 03:24
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>
@markballew
Copy link
Copy Markdown
Contributor Author

Code review — feat(dual-mode): mcp_common.dual_mode

STATUS: 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 from __future__ import annotations breaks at app build time) and a handful of important bugs around type-mapping and cli_name hygiene that downstream migrations will hit on day one. None of these require a redesign — they're 5–40 LOC each.

Verification

  • uv run pytest: 667 passed, 3 warnings, 28.25s (568 baseline + 99 new — matches PR description).
  • uv run ruff check src/ tests/: clean.
  • uv run ruff format --check src/ tests/: clean (88 files).
  • gh pr checks 101: lint pass, test (3.12) pass, typecheck pass, test (3.13) pending (same job, will land green).
  • Diff: 2687 insertions, 1 deletion, 18 files (matches PR description).
  • Module-import emits UserWarning: CliContext does not shim these async fastmcp.Context methods: close_sse_stream, … — intentional per design notes.

Blockers

B1. from __future__ import annotations breaks every CLI command

Impact: Any tool defined in a module with from __future__ import annotations at top fails at runner.invoke(app) / app() time with RuntimeError: Type not yet supported: ForwardRef('str') (or ForwardRef('Context'), etc.).

Repro (clean module, no closures):

from __future__ import annotations

from fastmcp import Context, FastMCP
from typer.testing import CliRunner
from mcp_common.dual_mode import build_cli_from_mcp, dual_mode_tool

mcp = FastMCP("probe")

@dual_mode_tool(mcp)
async def with_ctx(ctx: Context, name: str) -> dict:
    return {"name": name}

app = build_cli_from_mcp(mcp, project_repo="vhspace/probe")
r = CliRunner(mix_stderr=False).invoke(app, ["with-ctx", "--name", "alice", "--json"])
# RuntimeError: Type not yet supported: ForwardRef('str')

Root cause: fastmcp.tool() overwrites fn.__signature__ with a new signature that bakes in string annotations when PEP 563 was active. After that mutation, inspect.signature(fn, eval_str=True) returns the cached __signature__ with strings unchanged — eval_str=True only evaluates a signature it's building from __annotations__, not one that's already cached. The relevant call site is src/mcp_common/dual_mode/_typer_params.py:85 (sig = inspect.signature(fn, eval_str=True)).

Evidence: temporarily adding from __future__ import annotations to tests/integration/test_dual_mode_e2e.py flips 3/5 tests RED (TestParity::test_sync_tool_parity, test_async_tool_parity, test_context_tool_parity) with the same RuntimeError: Type not yet supported: ForwardRef('str'). I restored the file; current tree is clean.

Why it matters: every source file under src/mcp_common/dual_mode/ uses from __future__ import annotations. The framework's own house style explicitly opts into PEP 563 — but applying the framework to any tool defined under that style crashes the CLI. tests/unit/test_dual_mode_params.py:3–9 even calls out the workaround:

"""Tests for the Python signatureTyper parameter mapping.

These tests deliberately do *not* use ``from __future__ import annotations``:
the framework relies on evaluating annotations at decoration time, and the
PEP 563 stringification makes test-local types unresolvable from the
function's ``__globals__``. Production callers see eager evaluation
because :func:`iter_typer_params` calls ``inspect.signature(eval_str=True)``;
this file mirrors the conditions under which real decorator usage runs.

The "production callers see eager evaluation" claim is incorrecteval_str=True doesn't re-evaluate an already-cached __signature__. The integration test tests/integration/test_dual_mode_e2e.py only passes because it happens to omit from __future__ import annotations.

Downstream impact: gpu-diag-mcp (the announced pilot) has from __future__ import annotations in 8+ source files including src/gpu_diag_mcp/cli.py. netbox-mcp has it in cli.py + netbox_client.py. Tool-definition migration of either MCP will fail until this is fixed — that's the whole point of this PR.

Suggested fix: in _typer_params.py:iter_typer_params, resolve annotations via typing.get_type_hints(fn, include_extras=True) (which always re-evaluates against fn.__globals__ regardless of cached signature state) and substitute the resolved types onto the parameters returned by inspect.signature(fn). The include_extras=True flag preserves Annotated[...] metadata for the typer-passthrough path.

Must add a regression test: a from __future__ import annotations-flavored copy of test_dual_mode_e2e.py::TestParity.

Bugs found

Bug2. Literal[int] rejects every valid choice

src/mcp_common/dual_mode/_typer_params.py:132–144Literal[1, 2, 3] builds a Typer choice annotation, but Click's IntChoice/Choice parses the user's input as a string and compares it to the raw int. Result: --level 2 fails with Invalid value for '--level': '2' is not one of 1, 2, 3..

Repro:

@dual_mode_tool(mcp)
def pick_int(level: Literal[1, 2, 3]) -> dict: return {"level": level}
# CLI: pick-int --level 2  →  exit 2, "'2' is not one of 1, 2, 3"

Literal[str] works (tests/unit/test_dual_mode_builder.py:165–188); Literal[int] doesn't and has no test coverage. The framework should either convert Literal[int] to a coerced choice (parse-as-int-then-validate) or document the restriction and reject at decoration time.

Bug3. Nested Pydantic models crash the entire CLI

src/mcp_common/dual_mode/_typer_params.py:_PydanticFlatten.synthesize_params flattens model fields but passes each field's annotation straight through to Typer. A nested Pydantic field's annotation is class Inner(BaseModel): ... — Typer can't handle that and raises RuntimeError: Type not yet supported: <class 'Inner'>. The error fires at app() / runner.invoke() time, poisoning the entire CLI (not just the offending command).

Repro:

class Inner(BaseModel): x: int
class Outer(BaseModel):
    name: str
    inner: Inner
@dual_mode_tool(mcp)
def t(payload: Outer) -> dict: ...
# RuntimeError: Type not yet supported: <class 'Inner'>

Real NetBox / MAAS "create device" payloads routinely have nested models. At minimum, the framework should detect a non-primitive field annotation during _PydanticFlatten.synthesize_params and fall back to the --<param>-params <json> blob for that single param (preserving flattening for siblings), or reject at decoration time with a clear error rather than crashing the whole app later.

Bug4. set[T] / Union[T, U] (non-Optional) crash the entire CLI

Same shape as Bug3. _to_typer_parameter's fallback path (_typer_params.py:189–194) passes the annotation through to Typer. Typer's get_click_type raises at app build time — the entire CLI is unusable, not just the offending command.

Repros:

  • values: set[str]RuntimeError: Type not yet supported: set[str]
  • x: int | strAssertionError: Typer Currently doesn't support Union types

Reject these at decoration time with a clear error citing the unsupported type, or document and document-test the restriction.

Bug5. Parameter named json crashes build_cli_from_mcp

src/mcp_common/dual_mode/builder.py:_build_command_signature (line 183–192) unconditionally appends inspect.Parameter(name="json", …). If the wrapped function already has a json parameter, this raises ValueError: duplicate parameter name: 'json' from inspect.Signature.__init__.

Repro:

@dual_mode_tool(mcp)
def f(json: bool = False) -> dict: ...
# build_cli_from_mcp -> ValueError: duplicate parameter name: 'json'

Either rename the synthetic flag (e.g. json_output) when a collision is detected, or raise a clear error at decoration time listing the reserved parameter names.

Bug6. Sync tool returning a coroutine / async generator → silently str()-ed

src/mcp_common/dual_mode/builder.py:_invoke_tool (line 234–238) does return fn(**call_kwargs) for sync tools. A sync function that returns inner() (the coroutine) hands an un-awaited coroutine straight to echo_result, which then str()-renders it: "<coroutine object ... at 0xffffa4a39640>". Same for async def generators decorated as dual-mode tools: output is "<async_generator object ...>".

These cases are programmer errors, but the silent str()-pass-through is the worst of all worlds — the JSON output looks structured (a quoted string!) and the coroutine is leaked. Detect with inspect.iscoroutine(result) / inspect.isasyncgen(result) and raise a clear error.

Bug7. cli_name validation gaps

All three slip past silently and produce an unreachable CLI command:

  1. Duplicate cli_name across two tools → silent overwrite, last one wins. tools/decorator.py does not check the existing registry for collisions.
    @dual_mode_tool(mcp, cli_name="dup")
    def a(x: int) -> dict: return {"src": "a"}
    @dual_mode_tool(mcp, cli_name="dup")
    def b(x: int) -> dict: return {"src": "b"}
    # 'dup' command exists; tool_a is unreachable
  2. cli_name with whitespace (cli_name="lookup device") → registered with a space; never invokable (shell would treat it as two args).
  3. Tool with leading-underscore name (def _private_thing(x): ...) → default cli_name becomes -private-thing; Typer sees a leading - and parses it as a flag → No such option: -p. The to_kebab_case doesn't strip leading underscores.

Suggested fix: in decorator.py, validate cli_name against re.fullmatch(r"[a-z0-9][a-z0-9-]*", cli_name) (Typer's natural shape) and check the registry for collisions, raising ValueError with the offending name.

Bug8. Pydantic Field(description=...) ignored in flattened help

_PydanticFlatten._synthesize_field_param (line 419–434) builds a new inspect.Parameter from the Pydantic field annotation but throws away field_info.description. Result: flattened options show "str option." / "int option." instead of the model's Field-level description. That's the primary reason MCPs add Field descriptions on input models — they're the source of truth for tool input docs.

Repro:

class M(BaseModel):
    name: str = Field(..., description="Device hostname.")
# CLI help shows: "--payload-name TEXT  str option. [required]"
# (instead of "Device hostname.")

Suggested fix: forward field_info.description to the _make_option(..., help=...) call inside _synthesize_field_param.

Bug9. _default_cli_name produces broken CLI names from edge inputs

builder.py:_default_cli_name does not actually do what its docstring claims ("non-alphanumeric runs collapsed to single dashes" — claimed at builder.py:71). It only kebab-cases and strips a trailing -mcp. Observed outputs:

  • _default_cli_name("NetBox MCP")"net-box mcp-cli" (space preserved → CLI name has a space).
  • _default_cli_name("_internal")"-internal-cli" (leading dash → invalid).
  • _default_cli_name("-mcp")"mcp-cli" (OK).
  • _default_cli_name("mcp")"mcp-cli" (OK).

Either fix to_kebab_case to collapse non-alphanumeric to single dashes, strip leading/trailing dashes, and lowercase — or update the docstring to be honest about the actual behavior.

Edge cases that need handling

  • src/mcp_common/dual_mode/_typer_params.py:189–194 — unsupported types poison the whole CLI app at app() time (Bugs 3/4). They should fail at registration with a clear message.
  • src/mcp_common/dual_mode/builder.py:183–192 — synthetic json parameter collides with a user-supplied json parameter (Bug 5).
  • src/mcp_common/dual_mode/decorator.py:97derive_cli_name is not validated; whitespace/leading-- not rejected (Bug 7).
  • src/mcp_common/dual_mode/decorator.py:107–120 — same cli_name re-registration silently appends a second entry that shadows the first (Bug 7).
  • src/mcp_common/dual_mode/_typer_params.py:135–144Literal[int] produces a Click Choice that rejects every value (Bug 2).
  • src/mcp_common/dual_mode/builder.py:234–238 — a sync tool that returns a coroutine / async generator gets str()-ed (Bug 6).
  • src/mcp_common/dual_mode/_typer_params.py:419–434 — Pydantic Field(description=...) is discarded (Bug 8).
  • tests/integration/test_dual_mode_e2e.py — does not exercise from __future__ import annotations, which is the dominant Python style and the framework's own house style.

Edge cases that do work and are worth highlighting:

  • T | None = None and Optional[T] = None behave identically.
  • T | None (no default) → required at CLI.
  • list[T] = [] doesn't trip the mutable-default trap (the framework reassigns default=[] only when no explicit default was given).
  • ✅ Async tool using anyio.create_task_group() inside an asyncio.run wrapper works (anyio is event-loop-agnostic).
  • ✅ 1000 rapid report_progress calls — no buffering/serialization issues.
  • ✅ Parameter literally named ctx with type str — correctly treated as a normal --ctx flag, not Context-shimmed (only the Context type matters).
  • cli_only=True helper commands (e.g. version) work and show in --help.
  • build_cli_from_mcp called twice on the same MCP → two independent apps, no shared state, no duplicate commands.
  • ✅ WeakKeyDictionary registry actually GCs when the FastMCP instance is dropped (confirmed by del mcp; gc.collect() → registry size 0).
  • ✅ Exception handler chain — sync RuntimeError propagates through asyncio.run, exits 1, prints the agent remediation footer with stable repo link / traceback (install_cli_exception_handler from mcp_common.cli is integrated correctly).
  • cli_group subgrouping (single tool, multiple tools both work) — covered by tests.
  • ✅ Decorator + mcp.tool() coexistence: re-registering a function with a second name produces a FastMCP deprecation warning but the CLI is unaffected.
  • Annotated[T, typer.Option(...)] passthrough — user-provided Option metadata is honored.
  • ✅ Pydantic return values serialize correctly via model_dump(mode="json") (the _json_default path in echo_result).

DRY findings

  • _PydanticFlatten.for_param literally calls from_parametersrc/mcp_common/dual_mode/_typer_params.py:352–360. The docstring says "Kept separate so the builder can call it on the same Parameter without re-running annotation analysis twice" but the implementation is one line: return cls.from_parameter(param). It IS re-running analysis. Inline the call in builder.py:_rehydrate_call_kwargs and delete for_param.
  • CONTEXT_PARAM_SENTINEL is exported but never set in the registry_typer_params.py:55–62. The comment says "currently unused — kept as an extension point". Either wire it (e.g. as an opt-in for tools without an explicit Context param) or delete it. As-is it's dead code in __all__.
  • _make_option + per-branch help= literals in _to_typer_parameter — six branches each pass their own help="str option." / help="int option." / help="Filesystem path." / help="Boolean flag." / help="Multi-value option…". These could collapse into a single _default_help(annotation) table and a one-pass dispatch. Not a blocker, just future-friendly.
  • Module-import-time side effect: _detect_context_drift() runs on every import of cli_context.py and emits a UserWarning. Any test run or downstream import that touches mcp_common (transitively → mcp_common.dual_mode) gets the warning. Move it behind if __debug__: plus a once-per-process guard, or invoke it lazily on first CliContext() construction.

Otherwise the framework leans on mcp_common.cli correctly: create_cli_app, echo_result, JsonOption, install_cli_exception_handler are all reused; no reimplementation.

KISS findings

  • _PydanticFlatten.for_param (above).
  • CONTEXT_PARAM_SENTINEL (above).
  • 8 decorator kwargs (name, cli_name, cli_group, formatters, cli_only, mcp_only, summary, **mcp_tool_kwargs) — all justifiable. summary is marginal (the docstring extraction is automatic and the only reason to override it would be when the docstring is wrong, which is fixable upstream), but it does map to the FastMCP description= kwarg cleanly so I'd keep it.
  • PYDANTIC_FLATTEN_THRESHOLD = 6 is module-level. The README and CHANGELOG mention it; nothing in the public surface exposes it. If a downstream wants to override it, they can shadow the constant — but the threshold gets baked into the from_parameter's flatten decision at decoration time. Acceptable for v1.
  • iter_typer_params returns (typer_params, original_params, context_params) — a small named tuple or dataclass would self-document, but the three-tuple is clear enough and only used internally.

Project-standards findings

  • __all__ in mcp_common/__init__.py adds CliContext, build_cli_from_mcp, dual_mode_tool — three names for the headline surface, consistent with how JsonOption / echo_result / run_cli were promoted to top-level in feat(cli): introduce mcp_common.cli module (foundation for dual-mode framework) #98. ✅
  • __all__ in mcp_common/dual_mode/__init__.py is tight (3 names). ✅
  • _metadata.py, _naming.py, _registry.py, _typer_params.py are leading-underscore modules — consistent with the project convention for internal surface (cf. mcp_common/cli/_bootstrap.py, _typer_group.py). ✅
  • Tests are split into tests/unit/test_dual_mode_*.py + tests/integration/test_dual_mode_e2e.py with pytest.mark.integration — matches existing layout (tests/test_http_auth_integration.py uses @pytest.mark.anyio, slightly different but accepted style). ✅
  • CHANGELOG entry is under ## Unreleased and lists every public-surface addition explicitly — good cadence with how feat(logging): add suppress_noisy_loggers helper #97 / feat(cli): introduce mcp_common.cli module (foundation for dual-mode framework) #98 were documented. ✅
  • README "Dual-mode tools" section is well-positioned (right after the CLI scaffolding section, before Testing). ✅
  • Module docstrings explain the why not just the what — consistent with progress.py / agent_remediation.py. ✅

Semver: this PR is purely additive — every import path under mcp_common.dual_mode.* and every newly-added symbol in mcp_common/__init__.py is net-new. No existing public surface is renamed or removed. The single -1 deletion in the diff is in uv.lock. ✅ (minor bump appropriate.)

Downstream-migration readiness

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-imports typing only to compare against typing.Union. Since typing is already imported at module top (from typing import Annotated, Any, Literal, get_args, get_origin), this can be just Union (with from typing import Union) at module top. Save the indirection.
  • src/mcp_common/dual_mode/cli_context.py:138–141print(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 triggering BrokenPipeError differently. 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 with if not os.environ.get("MCP_COMMON_SUPPRESS_CONTEXT_DRIFT_WARNING") or running once-per-process with a module-level _warned_once flag.
  • src/mcp_common/dual_mode/_typer_params.py:202–221_make_option always builds an OptionInfo with default=... 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:158as_json = bool(typer_kwargs.pop("json", False)) — Bug 5 fix should land here as well, since the same json key is what collides with a user parameter.
  • src/mcp_common/dual_mode/_naming.py:36–48strip_mcp_namespace's candidate set includes normalized_mcp (which removes both - and _). For FastMCP("netbox-mcp") + tool netbox_lookup the prefix netboxmcp_ doesn't match netbox_lookup, so the tool keeps netbox-lookup rather than lookup. 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 --help succeeds. Worth also asserting app.registered_commands == [] so future refactors don't accidentally inject scaffolding commands.
  • Worth adding a test_dual_mode_decorator.py case that calls @dual_mode_tool on a def my_tool(): ... taking zero parameters — currently the test suite only has zero-param tools embedded in larger cli_only examples.

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.

markballew and others added 2 commits May 28, 2026 04:13
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: dual-mode tool introspection — expose FastMCP tools as CLI commands from a single definition

1 participant