cli: start MCP servers around chat and add /mcp slash command

This commit is contained in:
2026-09-08 12:18:35 +08:00
parent d603615d6a
commit 4916494e44
4 changed files with 200 additions and 1 deletions
+23 -1
View File
@@ -26,7 +26,7 @@ from plyngent.cli.state import ReplState
from plyngent.config.models import DatabaseConfig
from plyngent.memory import MemoryStore
from plyngent.prompting import NonInteractiveBackend, configure_prompting
from plyngent.runtime import ProviderNotSupportedError, create_client
from plyngent.runtime import McpManager, ProviderNotSupportedError, create_client
if TYPE_CHECKING:
from collections.abc import Mapping
@@ -134,6 +134,24 @@ def _setup_hooks(*, interactive: bool) -> None:
PtyManager.set_limit_continue_hook(None)
async def _start_mcp_manager(store: ConfigStore) -> McpManager | None:
"""Start configured MCP servers before the REPL state is built.
Returns ``None`` when nothing is enabled. Failures degrade to a warning
per server (the rest of the session, including LLM traffic, continues).
"""
manager = McpManager(store.mcp_config)
if not manager.enabled_servers():
return None
await manager.ensure_started()
for name, status, count in manager.statuses():
if status.startswith("error"):
click.secho(f"warning: mcp server {name!r} failed: {status}", fg="yellow", err=True)
else:
click.secho(f"mcp: {name} ({status}, tools={count})", fg="bright_black", err=True)
return manager
async def _bind_session(
state: ReplState,
*,
@@ -285,6 +303,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
except ProviderNotSupportedError as exc:
raise click.ClickException(str(exc)) from exc
mcp_manager = await _start_mcp_manager(store)
state = ReplState(
config=store,
memory=memory,
@@ -297,6 +316,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
stream_enabled=stream,
interactive_limits=interactive,
yolo=yolo,
mcp_manager=mcp_manager,
)
# Path denylist and policy confirm live on instance.workspace (no process bag).
state.instance_state.workspace.path_denylist = tuple(store.agent_config.path_denylist or ())
@@ -353,6 +373,8 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
set_fetch_policy_confirm_hook(None, instance=state_obj.instance_state)
clear_private_grants(instance=state_obj.instance_state)
clear_ssrf_assume_public_cidrs()
if state_obj.mcp_manager is not None:
await state_obj.mcp_manager.aclose()
await state_obj.instance_state.shutdown()
else:
# No ReplState: only PTY class cleanup (temps require instance allowlist).
+47
View File
@@ -1147,6 +1147,53 @@ def plugins_slash_cmd(state: ReplState, action: str | None, name: str | None) ->
raise click.UsageError(str(exc)) from exc
def _print_mcp_status(state: ReplState) -> None:
manager = state.mcp_manager
if manager is None:
click.echo("mcp: no servers configured (or none enabled)")
return
rows = manager.statuses()
if not rows:
click.echo("mcp: no servers configured (or none enabled)")
return
for name, status, count in rows:
click.echo(f" {name}\t{status}\ttools={count}")
connection = next(
(conn for conn in manager.connections() if conn.name == name),
None,
)
if connection is not None and connection.error is not None:
for line in connection.stderr_tail()[-3:]:
click.secho(f" {line}", fg="bright_black")
@slash.command("mcp")
@click.argument("action", required=False, type=click.Choice(["list", "reconnect"]))
@click.pass_obj
def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
"""List MCP server statuses, or reconnect after config edits.
``/mcp`` or ``/mcp list`` — per-server connection status and tool count.
``/mcp reconnect`` — re-read the config file, restart every enabled
server, and rebuild the tool registry (adopts newly added servers too).
"""
act = (action or "list").lower()
if act == "list":
_print_mcp_status(state)
return
from plyngent.runtime import McpManager
state.config.reload()
if state.mcp_manager is None:
state.mcp_manager = McpManager(state.config.mcp_config)
else:
_await(state.mcp_manager.restart(state.config.mcp_config))
_await(state.mcp_manager.ensure_started())
_slash_rebuild_tools_if_on(state)
click.echo(f"mcp reconnected; tools={'on' if state.tools_enabled else 'off'}")
_print_mcp_status(state)
@slash.command("yolo")
@click.argument("mode", required=False, type=YOLO_MODE, metavar="[on|off|once]")
@click.pass_obj
+8
View File
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
from plyngent.config.store import ConfigStore
from plyngent.memory import MemoryStore
from plyngent.memory.database.schema import Session as SessionRow
from plyngent.runtime.mcp_client import McpManager
type YoloMode = Literal["off", "on", "once"]
@@ -65,6 +66,8 @@ class ReplState:
todo_stack: TodoStack = field(default_factory=TodoStack)
instance_state: InstanceState = field(default_factory=InstanceState)
session_state: SessionState = field(default_factory=SessionState)
# MCP connections started by the host app before this state was built.
mcp_manager: McpManager | None = None
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
# Session ids for Tab complete (updated when listing/creating/resuming).
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
@@ -215,6 +218,11 @@ class ReplState:
plugins_cfg.enable,
disable=plugins_cfg.disable,
)
if self.mcp_manager is not None:
from plyngent.tools.mcp import register_mcp_tools
# Connections were started by the host; registration is sync-only.
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
# Local surface: builtins + allowlisted plugins (import registers into catalog).
tools = catalog.select(surface="local")
yolo = self.effective_yolo() != "off"