Skip to content

StackCLI

Scope

This page documents the programmatic CLI class — handy if you embed the CLI or generate help programmatically. For end-user command help, see the CLI section of the docs.

StackCLI

Rich TUI CLI for composing, building, and running MCPStack pipelines.

The CLI wraps :class:MCPStackCore for quick, scriptable workflows: listing presets/tools, composing pipelines, building configs, and running the MCP server.

Completion & help

Use --help on the root and each subcommand for detailed usage and options. Flags shown in help mirror the method parameters below.

Attributes:

Name Type Description
app Typer

Root Typer application.

tools_app Typer

Sub-application mounted under tools for tool-specific subcommands, if provided by a tool.

tool_clis dict[str, Typer]

Loaded tool CLI apps by tool name.

Examples:

mcpstack --help
mcpstack list-presets
mcpstack build --presets example_preset --config-type fastmcp
mcpstack run --presets example_preset
mcpstack tools my_tool --help
mcpstack pipeline my_tool --new-pipeline my_pipeline.json
mcpstack pipeline my_tool_2 --to-pipeline my_pipeline.json
mcpstack search one_tool_named_hello_world --type tools
mcpstack search example --type presets
Source code in src/MCPStack/cli.py
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
@beartype
class StackCLI:
    """Rich TUI CLI for composing, building, and running MCPStack pipelines.

    The CLI wraps :class:`MCPStackCore` for quick, scriptable workflows:
    listing presets/tools, composing pipelines, building configs, and running
    the MCP server.

    !!! tip "Completion & help"
        Use `--help` on the root and each subcommand for detailed usage and
        options. Flags shown in help mirror the method parameters below.

    Attributes:
        app (typer.Typer): Root Typer application.
        tools_app (typer.Typer): Sub-application mounted under `tools` for
            tool-specific subcommands, if provided by a tool.
        tool_clis (dict[str, typer.Typer]): Loaded tool CLI apps by tool name.

    Examples:
        ```bash
        mcpstack --help
        mcpstack list-presets
        mcpstack build --presets example_preset --config-type fastmcp
        mcpstack run --presets example_preset
        mcpstack tools my_tool --help
        mcpstack pipeline my_tool --new-pipeline my_pipeline.json
        mcpstack pipeline my_tool_2 --to-pipeline my_pipeline.json
        mcpstack search one_tool_named_hello_world --type tools
        mcpstack search example --type presets
        ```
    """

    def __init__(self) -> None:
        self._display_banner()
        self.app: typer.Typer = typer.Typer(
            help="MCPStack CLI",
            add_completion=False,
            pretty_exceptions_show_locals=False,
            rich_markup_mode="markdown",
        )
        self.app.callback()(self.main_callback)
        self.app.command(help="List available presets.")(self.list_presets)
        self.app.command(help="List available tools.")(self.list_tools)
        self.app.command(help="Run MCPStack: build + run MCP server.")(self.run)
        self.app.command(help="Build an MCP host configuration without running.")(
            self.build
        )
        self.app.command(help="Compose or extend a pipeline with a tool.")(
            self.pipeline
        )
        self.app.command(help="Search presets/tools.")(self.search)

        # Tool-specific subcommands (loaded if a tool provides a CLI module)
        self.tools_app: typer.Typer = typer.Typer(help="Tool-specific commands.")
        self.app.add_typer(
            self.tools_app, name="tools", help="Tool-specific subcommands."
        )
        self.tool_clis = self._load_tool_clis()

    def __call__(self) -> None:
        self.app()

    @staticmethod
    def version_callback(value: Optional[bool]) -> Optional[bool]:
        """Handle `--version` eagerly and exit if provided.

        Args:
            value: Flag value parsed by Typer.

        Returns:
            Optional[bool]: `value` so Typer can continue parsing when False.

        Behavior:
            When `True`, prints the MCPStack CLI version and exits the process.
        """
        if value:
            from MCPStack import __version__

            console.print(
                f"[bold green]💬 MCPStack CLI Version: {__version__}[/bold green]"
            )
            raise typer.Exit()
        return value

    def main_callback(
        self,
        version: Annotated[
            Optional[bool],
            typer.Option(
                "--version",
                "-v",
                # is_flag=True,  # make it a flag
                is_eager=True,  # run early
                callback=version_callback.__func__,  # staticmethod
                help="Show CLI version and exit.",
            ),
        ] = False,
        verbose: Annotated[
            bool, typer.Option("--verbose", "-V", help="Enable DEBUG level logging.")
        ] = False,
    ) -> None:
        level = "DEBUG" if verbose else "INFO"
        setup_logging(level=level)
        if verbose:
            logger.debug("Verbose mode enabled.")

    def list_presets(self) -> None:
        """List available presets from the registry.

        Output:
            Prints a Rich table of preset names, or a placeholder if none.

        !!! tip "Where do presets come from?"
            Presets are discovered from :mod:`MCPStack.core.preset.registry`.
        """
        console.print("[bold green]💬 Available Presets[/bold green]")
        table = Table(title="")
        table.add_column("Preset", style="cyan")
        for preset in ALL_PRESETS.keys():
            table.add_row(preset)
        if not ALL_PRESETS:
            table.add_row("[dim]— none registered —[/dim]")
        console.print(table)

    def list_tools(self) -> None:
        """List discovered tools (built-in and entry-point based).

        Output:
            Prints a Rich table of tool names, or a placeholder if none.

        !!! note "Discovery"
            Tools are sourced from :mod:`MCPStack.tools.registry`.
        """
        console.print("[bold green]💬 Available Tools[/bold green]")
        table = Table(title="")
        table.add_column("Tool", style="cyan")
        for tool in ALL_TOOLS.keys():
            table.add_row(tool)
        if not ALL_TOOLS:
            table.add_row("[dim]— none registered —[/dim]")
        console.print(table)

    def run(
        self,
        pipeline: Annotated[
            Optional[str],
            typer.Option("--pipeline", help="Pipeline JSON path (!= Presets)."),
        ] = None,
        presets: Annotated[
            Optional[str],
            typer.Option("--presets", help="Comma-separated pipeline presets."),
        ] = "example_preset",
        config_type: Annotated[
            str, typer.Option("--config-type", help="MCP host configuration type.")
        ] = "fastmcp",
        config_path: Annotated[
            Optional[str],
            typer.Option("--config-path", "-c", help="Where to save pipeline JSON."),
        ] = None,
        show_status: Annotated[
            bool, typer.Option("--show-status", help="Display tool status post-build.")
        ] = True,
        command: Annotated[
            Optional[str], typer.Option("--command", help="Command for MCP host.")
        ] = None,
        args: Annotated[
            Optional[str],
            typer.Option("--args", help="Comma-separated args for MCP host."),
        ] = None,
        cwd: Annotated[
            Optional[str], typer.Option("--cwd", help="Working directory for MCP host.")
        ] = None,
        module_name: Annotated[
            Optional[str],
            typer.Option("--module-name", help="Module name for default args."),
        ] = None,
    ) -> None:
        """Build the (possibly preset-based) pipeline and run the MCP server.

        Args:
            pipeline: Path to an existing pipeline JSON to load and run.
            presets: Comma-separated list of preset names to compose (ignored
                if `pipeline` is provided).
            config_type: Config generator key (e.g. `"fastmcp"`).
            config_path: Target path to write the pipeline JSON (default:
                `mcpstack_pipeline.json`), ignored when `pipeline` points to an
                existing file.
            show_status: Whether to display tool statuses after build.
            command: Optional command used by certain config generators.
            args: Optional comma-separated args to pass with `command`.
            cwd: Working directory used by some host backends.
            module_name: Module path used by module-based hosts.

        Behavior:
            * Loads a pipeline from `--pipeline` **or** composes from `--presets`.
            * Builds the stack, saves the pipeline JSON, and starts the MCP server.
            * Displays errors via Rich and exits non-zero on failure.

        !!! warning "Mutually exclusive"
            `--pipeline` and `--config-path` cannot be used together.
        """
        console.print("[bold green]💬 Starting MCPStack run...[/bold green]")
        try:
            if pipeline and config_path:
                raise ValueError("Cannot specify both --pipeline and --config-path.")
            config = StackConfig(env_vars=os.environ.copy())
            _config_path = config_path or pipeline or "mcpstack_pipeline.json"
            _config_path = os.path.abspath(_config_path)
            if pipeline:
                console.print(
                    f"[bold green]💬 Loaded pipeline: {pipeline}[/bold green]"
                )
                stack = MCPStackCore.load(pipeline)
            else:
                stack = MCPStackCore(config=config)
                preset_list = [p.strip() for p in presets.split(",")] if presets else []
                for preset in preset_list:
                    if preset not in ALL_PRESETS:
                        available_presets = list(ALL_PRESETS.keys())
                        best_match, score = process.extractOne(
                            preset, available_presets
                        ) or (None, 0)
                        suggestion_text = (
                            f" Did you mean '{best_match}'?" if score >= 80 else ""
                        )
                        raise MCPStackPresetError(
                            f"Unknown preset: {preset}.{suggestion_text}"
                        )
                    console.print(
                        f"[bold green]💬 Applying preset '{preset}'...[/bold green]"
                    )
                    stack = stack.with_preset(preset)
            console.print(
                f"[bold green]💬 Building with config type '{config_type}'...[/bold green]"
            )
            args_list = args.split(",") if args else None
            stack.build(
                type=config_type,
                command=command,
                args=args_list,
                cwd=cwd,
                module_name=module_name,
                pipeline_config_path=_config_path,
                save_path=None,
            )
            stack.save(_config_path)
            console.print(
                f"[bold green]💬 ✅ Saved pipeline config to {_config_path}.[/bold green]"
            )
            console.print("[bold green]💬 Starting MCP server...[/bold green]")
            stack.run()
        except Exception as e:
            logger.error(f"Run failed: {e}", exc_info=True)
            console.print(f"[red]❌ Error: {e}[/red]")
            raise typer.Exit(code=1) from e

    def build(
        self,
        pipeline: Annotated[
            Optional[str],
            typer.Option("--pipeline", help="Pipeline JSON path (!= Presets)."),
        ] = None,
        presets: Annotated[
            Optional[str],
            typer.Option("--presets", help="Comma-separated pipeline presets."),
        ] = "example_preset",
        config_type: Annotated[
            str, typer.Option("--config-type", help="Configuration type for MCP host.")
        ] = "fastmcp",
        config_path: Annotated[
            Optional[str],
            typer.Option("--config-path", "-c", help="Where to save pipeline JSON."),
        ] = None,
        output: Annotated[
            Optional[str],
            typer.Option(
                "--output", "-o", help="Output path for MCP host configuration."
            ),
        ] = None,
        show_status: Annotated[
            bool, typer.Option("--show-status", help="Display tool status post-build.")
        ] = True,
        command: Annotated[
            Optional[str], typer.Option("--command", help="Command for MCP host.")
        ] = None,
        args: Annotated[
            Optional[str],
            typer.Option("--args", help="Comma-separated args for MCP host."),
        ] = None,
        cwd: Annotated[
            Optional[str], typer.Option("--cwd", help="Working directory for MCP host.")
        ] = None,
        module_name: Annotated[
            Optional[str],
            typer.Option("--module-name", help="Module name for default args."),
        ] = None,
    ) -> None:
        """Generate an MCP host configuration file without running.

        Args:
            pipeline: Path to an existing pipeline JSON to load and build from.
            presets: Comma-separated list of preset names to compose (ignored
                if `pipeline` is provided).
            config_type: Config generator key.
            config_path: Path to save the composed pipeline JSON.
            output: Optional path where the generated host config should be
                written by the generator (if supported).
            show_status: Whether to display tool statuses after build.
            command: Optional command for process-based generators.
            args: Optional comma-separated args for `command`.
            cwd: Working directory for the host/generator.
            module_name: Module path for module-based generators.

        Behavior:
            * Composes a pipeline (or loads one), builds it, saves the pipeline
              JSON, and optionally writes a host config to `--output`.

        !!! warning "Mutually exclusive"
            `--pipeline` and `--config-path` cannot be used together.
        """
        console.print("[bold green]💬 Starting MCPStack build...[/bold green]")
        try:
            if pipeline and config_path:
                raise ValueError("Cannot specify both --pipeline and --config-path.")
            config = StackConfig(env_vars=os.environ.copy())
            _config_path = config_path or pipeline or "mcpstack_pipeline.json"
            _config_path = os.path.abspath(_config_path)
            if pipeline:
                console.print(
                    f"[bold green]💬 Loaded pipeline: {pipeline}[/bold green]"
                )
                stack = MCPStackCore.load(pipeline)
            else:
                stack = MCPStackCore(config=config)
                preset_list = [p.strip() for p in presets.split(",")] if presets else []
                for preset in preset_list:
                    if preset not in ALL_PRESETS:
                        available_presets = list(ALL_PRESETS.keys())
                        best_match, score = process.extractOne(
                            preset, available_presets
                        ) or (None, 0)
                        suggestion_text = (
                            f" Did you mean '{best_match}'?" if score >= 80 else ""
                        )
                        raise MCPStackPresetError(
                            f"Unknown preset: {preset}.{suggestion_text}"
                        )
                    console.print(
                        f"[bold green]💬 Applying preset '{preset}'...[/bold green]"
                    )
                    stack = stack.with_preset(preset)
            _save_path = os.path.abspath(output) if output else None
            console.print(
                f"[bold green]💬 Building with config type '{config_type}'...[/bold green]"
            )
            args_list = args.split(",") if args else None
            stack.build(
                type=config_type,
                command=command,
                args=args_list,
                cwd=cwd,
                module_name=module_name,
                pipeline_config_path=_config_path,
                save_path=_save_path,
            )
            stack.save(_config_path)
            console.print("[bold green]💬 ✅ Pipeline config saved.[/bold green]")
        except Exception as e:
            logger.error(f"Build failed: {e}", exc_info=True)
            console.print(f"[red]❌ Error: {e}[/red]")
            raise typer.Exit(code=1) from e

    def pipeline(
        self,
        tool_name: Annotated[
            str, typer.Argument(help="Tool to add (registered name).")
        ],
        to_pipeline: Annotated[
            Optional[str],
            typer.Option("--to-pipeline", help="Append to existing pipeline JSON."),
        ] = None,
        new_pipeline: Annotated[
            Optional[str],
            typer.Option("--new-pipeline", help="Create new pipeline JSON at path."),
        ] = "mcpstack_pipeline.json",
        tool_config: Annotated[
            Optional[str],
            typer.Option("--tool-config", help="Path to tool config JSON."),
        ] = None,
    ) -> None:
        """Append a tool to a pipeline (existing or new) and save it.

        Args:
            tool_name: Registered tool name (see `list-tools`).
            to_pipeline: Path to an existing pipeline JSON to append to.
            new_pipeline: Path to create if `to_pipeline` is not supplied.
            tool_config: Path to a JSON file with `env_vars` and `tool_params`
                for the tool being added.

        Behavior:
            * Loads or creates a pipeline, merges env vars, constructs the tool
              from params, builds and saves the updated pipeline.

        !!! failure "Unknown tool?"
            If `tool_name` is not in the registry, the command suggests the
            closest match and exits with an error.
        """
        console.print(
            f"[bold green]💬 Adding tool '{tool_name}' to pipeline...[/bold green]"
        )
        if tool_name not in ALL_TOOLS:
            available = list(ALL_TOOLS.keys())
            best_match, score = process.extractOne(tool_name, available) or (None, 0)
            suggestion_text = f" Did you mean '{best_match}'?" if score >= 80 else ""
            console.print(f"[red]❌ Unknown tool: {tool_name}.{suggestion_text}[/red]")
            raise typer.Exit(code=1)
        try:
            if tool_config:
                with open(tool_config) as f:
                    tool_dict = json.load(f)
            else:
                tool_dict = {"env_vars": {}, "tool_params": {}}
            pipeline_path = to_pipeline or new_pipeline
            if to_pipeline and Path(to_pipeline).exists():
                stack: MCPStackCore = MCPStackCore.load(to_pipeline)
                console.print(f"[bold green]💬 Appending to {to_pipeline}[/bold green]")
            else:
                stack = MCPStackCore()
                console.print(
                    f"[bold green]💬 Creating new pipeline at {pipeline_path}[/bold green]"
                )
            stack.config.merge_env(tool_dict.get("env_vars", {}))
            tool_cls = ALL_TOOLS[tool_name]
            tool = tool_cls.from_dict(tool_dict.get("tool_params", {}))  # type: ignore
            stack = stack.with_tool(tool)
            stack.build()
            stack.save(pipeline_path)
            console.print(
                f"[bold green]💬 ✅ Pipeline updated: {pipeline_path} (tools: {len(stack.tools)})[/bold green]"
            )
        except Exception as e:
            logger.error(f"Failed to add {tool_name}: {e}", exc_info=True)
            console.print(f"[red]❌ Failed to add {tool_name}: {e}[/red]")
            raise typer.Exit(1) from e

    def search(
        self,
        query: str,
        type_: Annotated[
            str, typer.Option("--type", help="presets, tools, or both.")
        ] = "both",
        limit: int = 5,
    ) -> None:
        """Fuzzy-search across preset and tool names.

        Args:
            query: Search string.
            type_: Domain to search: `"presets"`, `"tools"`, or `"both"`.
            limit: Maximum number of matches to show for each domain.

        Output:
            Prints category tables with matches and scores.

        !!! tip "Partial names welcome"
            Short fragments are fine; results are ranked by fuzzy score.
        """
        console.print(f"[bold green]💬 Searching for '{query}'...[/bold green]")
        if type_ not in ["presets", "tools", "both"]:
            console.print(
                "[red]❌ Invalid type. Use `presets`, `tools`, or `both`.[/red]"
            )
            raise typer.Exit(code=1)
        results = []
        if type_ in ["presets", "both"]:
            presets = list(ALL_PRESETS.keys())
            from thefuzz import process as _p

            preset_matches = _p.extract(query, presets, limit=limit)
            results.append(("Presets", preset_matches))
        if type_ in ["tools", "both"]:
            tools = list(ALL_TOOLS.keys())
            from thefuzz import process as _p

            tool_matches = _p.extract(query, tools, limit=limit)
            results.append(("Tools", tool_matches))
        for category, matches in results:
            table = Table(title=f"[bold green]💬 {category} matches[/bold green]")
            table.add_column("Match", style="cyan")
            table.add_column("Score", style="magenta")
            for match, score in matches:
                table.add_row(str(match), str(score))
            console.print(table)

    def _load_tool_clis(self) -> Dict[str, typer.Typer]:
        """Discover and mount tool CLIs under `mcpstack tools`."""
        tool_clis: Dict[str, typer.Typer] = {}

        for tool_name in ALL_TOOLS:
            if app := self._load_tool_cli(tool_name):
                tool_clis[tool_name] = app
                self.tools_app.add_typer(
                    app, name=tool_name, help=f"{tool_name} tool commands."
                )
            else:
                logger.debug("No CLI found for tool '%s'", tool_name)

        return tool_clis

    @staticmethod
    def _load_tool_cli(tool_name: str):
        """Return a Typer app for a tool CLI, either internal or external"""
        try:
            from importlib.metadata import entry_points

            eps = entry_points().select(group="mcpstack.tool_clis")
            for ep in eps:
                if ep.name.lower() != tool_name.lower():
                    continue
                obj = ep.load()
                app = _materialize_cli_app(obj)
                if app:
                    return app
        except Exception as e:
            logger.debug(
                "Entry point CLI load failed for '%s': %s", tool_name, e, exc_info=True
            )

        try:
            module = importlib.import_module(f"MCPStack.tools.{tool_name}.cli")
            # Prefer a BaseToolCLI subclass, else a top-level get_app(), else a Typer app
            for _, cls in inspect.getmembers(module, inspect.isclass):
                if issubclass(cls, BaseToolCLI) and cls is not BaseToolCLI:
                    app = cls.get_app()
                    if app:
                        return app
            get_app = getattr(module, "get_app", None)
            if callable(get_app):
                return get_app()
        except ModuleNotFoundError:
            return None
        except Exception as e:
            logger.debug(
                "Built-in CLI load failed for '%s': %s", tool_name, e, exc_info=True
            )

        return None

    @staticmethod
    def _get_tool_cli_class(tool_name: str):
        """Return the BaseToolCLI subclass for a tool (if provided as a class).

        Supports:
          * Entry point 'mcpstack.tool_clis' (object must be a BaseToolCLI subclass)
          * MCPStack.tools.<tool_name>.cli (first-party fallback)

        If the CLI is exposed only as a callable / app, this accessor will not apply.
        """
        try:
            from importlib.metadata import entry_points

            eps = entry_points().select(group="mcpstack.tool_clis")
            for ep in eps:
                if ep.name.lower() != tool_name.lower():
                    continue
                obj = ep.load()
                if (
                    inspect.isclass(obj)
                    and issubclass(obj, BaseToolCLI)
                    and obj is not BaseToolCLI
                ):
                    return obj
        except Exception:
            pass

        module = importlib.import_module(f"MCPStack.tools.{tool_name}.cli")
        tool_cli_classes = [
            obj
            for _, obj in inspect.getmembers(module)
            if inspect.isclass(obj)
            and issubclass(obj, BaseToolCLI)
            and obj is not BaseToolCLI
        ]
        if not tool_cli_classes:
            raise RuntimeError(f"No CLI class found for '{tool_name}'.")
        return tool_cli_classes[0]

    def _status(self, tool: Optional[str] = None, verbose: bool = False) -> None:
        """Render a status panel for a tool, showing env and configuration.

        Args:
            tool: Specific tool to inspect. If omitted, attempts all tools that
                expose a CLI.
            verbose: Whether to print extended diagnostics (tool-dependent).

        Output:
            Relies on each tool CLI's `status()` implementation to render.
        """
        console.print("[bold green]💬 Checking status...[/bold green]")
        tools_to_check = [tool] if tool else list(self._load_tool_clis().keys())
        for _tool in tools_to_check:
            try:
                tool_cli_class = self._get_tool_cli_class(_tool)
                tool_cli_class.status(verbose=verbose)
            except Exception as e:
                logger.debug(f"Status not available for '{_tool}': {e}")

    @staticmethod
    def _display_banner() -> None:
        """Render the banner header for the CLI when `--help` is detected.

        Behavior:
            Prints a stylized Rich panel with project name and version, using a
            multi-color figlet header when help is requested.

        !!! tip "Quiet mode"
            The banner is only displayed on help screens to avoid noisy output
            during normal command execution.
        """
        from MCPStack import __version__

        if any(arg in sys.argv for arg in ["--help", "-h"]):
            rich_fig = RichFiglet(
                "MCPStack",
                font="ansi_shadow",
                colors=["#0ea5e9", "#0ea5e9", "#0ea5e9", "#FFFFFF", "#FFFFFF"],
                horizontal=True,
                remove_blank_lines=True,
            )
            entries = [
                ("🏗️", " Project", "MCPStack — Modular MCP Pipelines"),
                ("🏎️", " Version", __version__),
            ]
            max_label_len = max(
                cell_len(emoji + " " + key + ":") for emoji, key, value in entries
            )
            group_items = [
                Text(""),
                Text(""),
                rich_fig,
                Text(""),
                Text("Composable MCP pipelines."),
                Text(""),
            ]
            for i, (emoji, key, value) in enumerate(entries):
                label_plain = emoji + " " + key + ":"
                label_len = cell_len(label_plain)
                spaces = " " * (max_label_len - label_len + 2)
                line = f"[turquoise4]{label_plain}[/turquoise4]{spaces}{value}"
                group_items.append(Text.from_markup(line))
                if i == 0:
                    group_items.append(Text(""))
            group_items += [Text(""), Text("")]
            console.print(
                Panel(
                    Group(*group_items),
                    title="MCPStack CLI",
                    width=80,
                    title_align="left",
                    expand=False,
                    box=box.ROUNDED,
                    padding=(1, 5),
                )
            )

build(pipeline=None, presets='example_preset', config_type='fastmcp', config_path=None, output=None, show_status=True, command=None, args=None, cwd=None, module_name=None)

Generate an MCP host configuration file without running.

Parameters:

Name Type Description Default
pipeline Annotated[Optional[str], Option(--pipeline, help='Pipeline JSON path (!= Presets).')]

Path to an existing pipeline JSON to load and build from.

None
presets Annotated[Optional[str], Option(--presets, help='Comma-separated pipeline presets.')]

Comma-separated list of preset names to compose (ignored if pipeline is provided).

'example_preset'
config_type Annotated[str, Option(--config - type, help='Configuration type for MCP host.')]

Config generator key.

'fastmcp'
config_path Annotated[Optional[str], Option(--config - path, -c, help='Where to save pipeline JSON.')]

Path to save the composed pipeline JSON.

None
output Annotated[Optional[str], Option(--output, -o, help='Output path for MCP host configuration.')]

Optional path where the generated host config should be written by the generator (if supported).

None
show_status Annotated[bool, Option(--show - status, help='Display tool status post-build.')]

Whether to display tool statuses after build.

True
command Annotated[Optional[str], Option(--command, help='Command for MCP host.')]

Optional command for process-based generators.

None
args Annotated[Optional[str], Option(--args, help='Comma-separated args for MCP host.')]

Optional comma-separated args for command.

None
cwd Annotated[Optional[str], Option(--cwd, help='Working directory for MCP host.')]

Working directory for the host/generator.

None
module_name Annotated[Optional[str], Option(--module - name, help='Module name for default args.')]

Module path for module-based generators.

None
Behavior
  • Composes a pipeline (or loads one), builds it, saves the pipeline JSON, and optionally writes a host config to --output.

Mutually exclusive

--pipeline and --config-path cannot be used together.

Source code in src/MCPStack/cli.py
def build(
    self,
    pipeline: Annotated[
        Optional[str],
        typer.Option("--pipeline", help="Pipeline JSON path (!= Presets)."),
    ] = None,
    presets: Annotated[
        Optional[str],
        typer.Option("--presets", help="Comma-separated pipeline presets."),
    ] = "example_preset",
    config_type: Annotated[
        str, typer.Option("--config-type", help="Configuration type for MCP host.")
    ] = "fastmcp",
    config_path: Annotated[
        Optional[str],
        typer.Option("--config-path", "-c", help="Where to save pipeline JSON."),
    ] = None,
    output: Annotated[
        Optional[str],
        typer.Option(
            "--output", "-o", help="Output path for MCP host configuration."
        ),
    ] = None,
    show_status: Annotated[
        bool, typer.Option("--show-status", help="Display tool status post-build.")
    ] = True,
    command: Annotated[
        Optional[str], typer.Option("--command", help="Command for MCP host.")
    ] = None,
    args: Annotated[
        Optional[str],
        typer.Option("--args", help="Comma-separated args for MCP host."),
    ] = None,
    cwd: Annotated[
        Optional[str], typer.Option("--cwd", help="Working directory for MCP host.")
    ] = None,
    module_name: Annotated[
        Optional[str],
        typer.Option("--module-name", help="Module name for default args."),
    ] = None,
) -> None:
    """Generate an MCP host configuration file without running.

    Args:
        pipeline: Path to an existing pipeline JSON to load and build from.
        presets: Comma-separated list of preset names to compose (ignored
            if `pipeline` is provided).
        config_type: Config generator key.
        config_path: Path to save the composed pipeline JSON.
        output: Optional path where the generated host config should be
            written by the generator (if supported).
        show_status: Whether to display tool statuses after build.
        command: Optional command for process-based generators.
        args: Optional comma-separated args for `command`.
        cwd: Working directory for the host/generator.
        module_name: Module path for module-based generators.

    Behavior:
        * Composes a pipeline (or loads one), builds it, saves the pipeline
          JSON, and optionally writes a host config to `--output`.

    !!! warning "Mutually exclusive"
        `--pipeline` and `--config-path` cannot be used together.
    """
    console.print("[bold green]💬 Starting MCPStack build...[/bold green]")
    try:
        if pipeline and config_path:
            raise ValueError("Cannot specify both --pipeline and --config-path.")
        config = StackConfig(env_vars=os.environ.copy())
        _config_path = config_path or pipeline or "mcpstack_pipeline.json"
        _config_path = os.path.abspath(_config_path)
        if pipeline:
            console.print(
                f"[bold green]💬 Loaded pipeline: {pipeline}[/bold green]"
            )
            stack = MCPStackCore.load(pipeline)
        else:
            stack = MCPStackCore(config=config)
            preset_list = [p.strip() for p in presets.split(",")] if presets else []
            for preset in preset_list:
                if preset not in ALL_PRESETS:
                    available_presets = list(ALL_PRESETS.keys())
                    best_match, score = process.extractOne(
                        preset, available_presets
                    ) or (None, 0)
                    suggestion_text = (
                        f" Did you mean '{best_match}'?" if score >= 80 else ""
                    )
                    raise MCPStackPresetError(
                        f"Unknown preset: {preset}.{suggestion_text}"
                    )
                console.print(
                    f"[bold green]💬 Applying preset '{preset}'...[/bold green]"
                )
                stack = stack.with_preset(preset)
        _save_path = os.path.abspath(output) if output else None
        console.print(
            f"[bold green]💬 Building with config type '{config_type}'...[/bold green]"
        )
        args_list = args.split(",") if args else None
        stack.build(
            type=config_type,
            command=command,
            args=args_list,
            cwd=cwd,
            module_name=module_name,
            pipeline_config_path=_config_path,
            save_path=_save_path,
        )
        stack.save(_config_path)
        console.print("[bold green]💬 ✅ Pipeline config saved.[/bold green]")
    except Exception as e:
        logger.error(f"Build failed: {e}", exc_info=True)
        console.print(f"[red]❌ Error: {e}[/red]")
        raise typer.Exit(code=1) from e

list_presets()

List available presets from the registry.

Output

Prints a Rich table of preset names, or a placeholder if none.

Where do presets come from?

Presets are discovered from :mod:MCPStack.core.preset.registry.

Source code in src/MCPStack/cli.py
def list_presets(self) -> None:
    """List available presets from the registry.

    Output:
        Prints a Rich table of preset names, or a placeholder if none.

    !!! tip "Where do presets come from?"
        Presets are discovered from :mod:`MCPStack.core.preset.registry`.
    """
    console.print("[bold green]💬 Available Presets[/bold green]")
    table = Table(title="")
    table.add_column("Preset", style="cyan")
    for preset in ALL_PRESETS.keys():
        table.add_row(preset)
    if not ALL_PRESETS:
        table.add_row("[dim]— none registered —[/dim]")
    console.print(table)

list_tools()

List discovered tools (built-in and entry-point based).

Output

Prints a Rich table of tool names, or a placeholder if none.

Discovery

Tools are sourced from :mod:MCPStack.tools.registry.

Source code in src/MCPStack/cli.py
def list_tools(self) -> None:
    """List discovered tools (built-in and entry-point based).

    Output:
        Prints a Rich table of tool names, or a placeholder if none.

    !!! note "Discovery"
        Tools are sourced from :mod:`MCPStack.tools.registry`.
    """
    console.print("[bold green]💬 Available Tools[/bold green]")
    table = Table(title="")
    table.add_column("Tool", style="cyan")
    for tool in ALL_TOOLS.keys():
        table.add_row(tool)
    if not ALL_TOOLS:
        table.add_row("[dim]— none registered —[/dim]")
    console.print(table)

pipeline(tool_name, to_pipeline=None, new_pipeline='mcpstack_pipeline.json', tool_config=None)

Append a tool to a pipeline (existing or new) and save it.

Parameters:

Name Type Description Default
tool_name Annotated[str, Argument(help='Tool to add (registered name).')]

Registered tool name (see list-tools).

required
to_pipeline Annotated[Optional[str], Option(--to - pipeline, help='Append to existing pipeline JSON.')]

Path to an existing pipeline JSON to append to.

None
new_pipeline Annotated[Optional[str], Option(--new - pipeline, help='Create new pipeline JSON at path.')]

Path to create if to_pipeline is not supplied.

'mcpstack_pipeline.json'
tool_config Annotated[Optional[str], Option(--tool - config, help='Path to tool config JSON.')]

Path to a JSON file with env_vars and tool_params for the tool being added.

None
Behavior
  • Loads or creates a pipeline, merges env vars, constructs the tool from params, builds and saves the updated pipeline.

Unknown tool?

If tool_name is not in the registry, the command suggests the closest match and exits with an error.

Source code in src/MCPStack/cli.py
def pipeline(
    self,
    tool_name: Annotated[
        str, typer.Argument(help="Tool to add (registered name).")
    ],
    to_pipeline: Annotated[
        Optional[str],
        typer.Option("--to-pipeline", help="Append to existing pipeline JSON."),
    ] = None,
    new_pipeline: Annotated[
        Optional[str],
        typer.Option("--new-pipeline", help="Create new pipeline JSON at path."),
    ] = "mcpstack_pipeline.json",
    tool_config: Annotated[
        Optional[str],
        typer.Option("--tool-config", help="Path to tool config JSON."),
    ] = None,
) -> None:
    """Append a tool to a pipeline (existing or new) and save it.

    Args:
        tool_name: Registered tool name (see `list-tools`).
        to_pipeline: Path to an existing pipeline JSON to append to.
        new_pipeline: Path to create if `to_pipeline` is not supplied.
        tool_config: Path to a JSON file with `env_vars` and `tool_params`
            for the tool being added.

    Behavior:
        * Loads or creates a pipeline, merges env vars, constructs the tool
          from params, builds and saves the updated pipeline.

    !!! failure "Unknown tool?"
        If `tool_name` is not in the registry, the command suggests the
        closest match and exits with an error.
    """
    console.print(
        f"[bold green]💬 Adding tool '{tool_name}' to pipeline...[/bold green]"
    )
    if tool_name not in ALL_TOOLS:
        available = list(ALL_TOOLS.keys())
        best_match, score = process.extractOne(tool_name, available) or (None, 0)
        suggestion_text = f" Did you mean '{best_match}'?" if score >= 80 else ""
        console.print(f"[red]❌ Unknown tool: {tool_name}.{suggestion_text}[/red]")
        raise typer.Exit(code=1)
    try:
        if tool_config:
            with open(tool_config) as f:
                tool_dict = json.load(f)
        else:
            tool_dict = {"env_vars": {}, "tool_params": {}}
        pipeline_path = to_pipeline or new_pipeline
        if to_pipeline and Path(to_pipeline).exists():
            stack: MCPStackCore = MCPStackCore.load(to_pipeline)
            console.print(f"[bold green]💬 Appending to {to_pipeline}[/bold green]")
        else:
            stack = MCPStackCore()
            console.print(
                f"[bold green]💬 Creating new pipeline at {pipeline_path}[/bold green]"
            )
        stack.config.merge_env(tool_dict.get("env_vars", {}))
        tool_cls = ALL_TOOLS[tool_name]
        tool = tool_cls.from_dict(tool_dict.get("tool_params", {}))  # type: ignore
        stack = stack.with_tool(tool)
        stack.build()
        stack.save(pipeline_path)
        console.print(
            f"[bold green]💬 ✅ Pipeline updated: {pipeline_path} (tools: {len(stack.tools)})[/bold green]"
        )
    except Exception as e:
        logger.error(f"Failed to add {tool_name}: {e}", exc_info=True)
        console.print(f"[red]❌ Failed to add {tool_name}: {e}[/red]")
        raise typer.Exit(1) from e

run(pipeline=None, presets='example_preset', config_type='fastmcp', config_path=None, show_status=True, command=None, args=None, cwd=None, module_name=None)

Build the (possibly preset-based) pipeline and run the MCP server.

Parameters:

Name Type Description Default
pipeline Annotated[Optional[str], Option(--pipeline, help='Pipeline JSON path (!= Presets).')]

Path to an existing pipeline JSON to load and run.

None
presets Annotated[Optional[str], Option(--presets, help='Comma-separated pipeline presets.')]

Comma-separated list of preset names to compose (ignored if pipeline is provided).

'example_preset'
config_type Annotated[str, Option(--config - type, help='MCP host configuration type.')]

Config generator key (e.g. "fastmcp").

'fastmcp'
config_path Annotated[Optional[str], Option(--config - path, -c, help='Where to save pipeline JSON.')]

Target path to write the pipeline JSON (default: mcpstack_pipeline.json), ignored when pipeline points to an existing file.

None
show_status Annotated[bool, Option(--show - status, help='Display tool status post-build.')]

Whether to display tool statuses after build.

True
command Annotated[Optional[str], Option(--command, help='Command for MCP host.')]

Optional command used by certain config generators.

None
args Annotated[Optional[str], Option(--args, help='Comma-separated args for MCP host.')]

Optional comma-separated args to pass with command.

None
cwd Annotated[Optional[str], Option(--cwd, help='Working directory for MCP host.')]

Working directory used by some host backends.

None
module_name Annotated[Optional[str], Option(--module - name, help='Module name for default args.')]

Module path used by module-based hosts.

None
Behavior
  • Loads a pipeline from --pipeline or composes from --presets.
  • Builds the stack, saves the pipeline JSON, and starts the MCP server.
  • Displays errors via Rich and exits non-zero on failure.

Mutually exclusive

--pipeline and --config-path cannot be used together.

Source code in src/MCPStack/cli.py
def run(
    self,
    pipeline: Annotated[
        Optional[str],
        typer.Option("--pipeline", help="Pipeline JSON path (!= Presets)."),
    ] = None,
    presets: Annotated[
        Optional[str],
        typer.Option("--presets", help="Comma-separated pipeline presets."),
    ] = "example_preset",
    config_type: Annotated[
        str, typer.Option("--config-type", help="MCP host configuration type.")
    ] = "fastmcp",
    config_path: Annotated[
        Optional[str],
        typer.Option("--config-path", "-c", help="Where to save pipeline JSON."),
    ] = None,
    show_status: Annotated[
        bool, typer.Option("--show-status", help="Display tool status post-build.")
    ] = True,
    command: Annotated[
        Optional[str], typer.Option("--command", help="Command for MCP host.")
    ] = None,
    args: Annotated[
        Optional[str],
        typer.Option("--args", help="Comma-separated args for MCP host."),
    ] = None,
    cwd: Annotated[
        Optional[str], typer.Option("--cwd", help="Working directory for MCP host.")
    ] = None,
    module_name: Annotated[
        Optional[str],
        typer.Option("--module-name", help="Module name for default args."),
    ] = None,
) -> None:
    """Build the (possibly preset-based) pipeline and run the MCP server.

    Args:
        pipeline: Path to an existing pipeline JSON to load and run.
        presets: Comma-separated list of preset names to compose (ignored
            if `pipeline` is provided).
        config_type: Config generator key (e.g. `"fastmcp"`).
        config_path: Target path to write the pipeline JSON (default:
            `mcpstack_pipeline.json`), ignored when `pipeline` points to an
            existing file.
        show_status: Whether to display tool statuses after build.
        command: Optional command used by certain config generators.
        args: Optional comma-separated args to pass with `command`.
        cwd: Working directory used by some host backends.
        module_name: Module path used by module-based hosts.

    Behavior:
        * Loads a pipeline from `--pipeline` **or** composes from `--presets`.
        * Builds the stack, saves the pipeline JSON, and starts the MCP server.
        * Displays errors via Rich and exits non-zero on failure.

    !!! warning "Mutually exclusive"
        `--pipeline` and `--config-path` cannot be used together.
    """
    console.print("[bold green]💬 Starting MCPStack run...[/bold green]")
    try:
        if pipeline and config_path:
            raise ValueError("Cannot specify both --pipeline and --config-path.")
        config = StackConfig(env_vars=os.environ.copy())
        _config_path = config_path or pipeline or "mcpstack_pipeline.json"
        _config_path = os.path.abspath(_config_path)
        if pipeline:
            console.print(
                f"[bold green]💬 Loaded pipeline: {pipeline}[/bold green]"
            )
            stack = MCPStackCore.load(pipeline)
        else:
            stack = MCPStackCore(config=config)
            preset_list = [p.strip() for p in presets.split(",")] if presets else []
            for preset in preset_list:
                if preset not in ALL_PRESETS:
                    available_presets = list(ALL_PRESETS.keys())
                    best_match, score = process.extractOne(
                        preset, available_presets
                    ) or (None, 0)
                    suggestion_text = (
                        f" Did you mean '{best_match}'?" if score >= 80 else ""
                    )
                    raise MCPStackPresetError(
                        f"Unknown preset: {preset}.{suggestion_text}"
                    )
                console.print(
                    f"[bold green]💬 Applying preset '{preset}'...[/bold green]"
                )
                stack = stack.with_preset(preset)
        console.print(
            f"[bold green]💬 Building with config type '{config_type}'...[/bold green]"
        )
        args_list = args.split(",") if args else None
        stack.build(
            type=config_type,
            command=command,
            args=args_list,
            cwd=cwd,
            module_name=module_name,
            pipeline_config_path=_config_path,
            save_path=None,
        )
        stack.save(_config_path)
        console.print(
            f"[bold green]💬 ✅ Saved pipeline config to {_config_path}.[/bold green]"
        )
        console.print("[bold green]💬 Starting MCP server...[/bold green]")
        stack.run()
    except Exception as e:
        logger.error(f"Run failed: {e}", exc_info=True)
        console.print(f"[red]❌ Error: {e}[/red]")
        raise typer.Exit(code=1) from e

search(query, type_='both', limit=5)

Fuzzy-search across preset and tool names.

Parameters:

Name Type Description Default
query str

Search string.

required
type_ Annotated[str, Option(--type, help='presets, tools, or both.')]

Domain to search: "presets", "tools", or "both".

'both'
limit int

Maximum number of matches to show for each domain.

5
Output

Prints category tables with matches and scores.

Partial names welcome

Short fragments are fine; results are ranked by fuzzy score.

Source code in src/MCPStack/cli.py
def search(
    self,
    query: str,
    type_: Annotated[
        str, typer.Option("--type", help="presets, tools, or both.")
    ] = "both",
    limit: int = 5,
) -> None:
    """Fuzzy-search across preset and tool names.

    Args:
        query: Search string.
        type_: Domain to search: `"presets"`, `"tools"`, or `"both"`.
        limit: Maximum number of matches to show for each domain.

    Output:
        Prints category tables with matches and scores.

    !!! tip "Partial names welcome"
        Short fragments are fine; results are ranked by fuzzy score.
    """
    console.print(f"[bold green]💬 Searching for '{query}'...[/bold green]")
    if type_ not in ["presets", "tools", "both"]:
        console.print(
            "[red]❌ Invalid type. Use `presets`, `tools`, or `both`.[/red]"
        )
        raise typer.Exit(code=1)
    results = []
    if type_ in ["presets", "both"]:
        presets = list(ALL_PRESETS.keys())
        from thefuzz import process as _p

        preset_matches = _p.extract(query, presets, limit=limit)
        results.append(("Presets", preset_matches))
    if type_ in ["tools", "both"]:
        tools = list(ALL_TOOLS.keys())
        from thefuzz import process as _p

        tool_matches = _p.extract(query, tools, limit=limit)
        results.append(("Tools", tool_matches))
    for category, matches in results:
        table = Table(title=f"[bold green]💬 {category} matches[/bold green]")
        table.add_column("Match", style="cyan")
        table.add_column("Score", style="magenta")
        for match, score in matches:
            table.add_row(str(match), str(score))
        console.print(table)

version_callback(value) staticmethod

Handle --version eagerly and exit if provided.

Parameters:

Name Type Description Default
value Optional[bool]

Flag value parsed by Typer.

required

Returns:

Type Description
Optional[bool]

Optional[bool]: value so Typer can continue parsing when False.

Behavior

When True, prints the MCPStack CLI version and exits the process.

Source code in src/MCPStack/cli.py
@staticmethod
def version_callback(value: Optional[bool]) -> Optional[bool]:
    """Handle `--version` eagerly and exit if provided.

    Args:
        value: Flag value parsed by Typer.

    Returns:
        Optional[bool]: `value` so Typer can continue parsing when False.

    Behavior:
        When `True`, prints the MCPStack CLI version and exits the process.
    """
    if value:
        from MCPStack import __version__

        console.print(
            f"[bold green]💬 MCPStack CLI Version: {__version__}[/bold green]"
        )
        raise typer.Exit()
    return value