Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ dependencies = [
"pyyaml>=6.0.2",
"rich>=13.9.4",
"scikit-learn>=1.6.0",
"typer>=0.15.1",
"typer>=0.15.3",
"websockets>=12.0",
]

Expand Down Expand Up @@ -78,6 +78,7 @@ cli = [
"httpx>=0.28.1",
"pyjwt>=2.10.1",
"typer[all]>=0.15.1",
"watchdog>=6.0.0"
]

[build-system]
Expand Down
3 changes: 3 additions & 0 deletions src/mcp_agent/cli/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ def build(

console.print(server_table)
console.print()
else:
console.print("[yellow]No MCP servers found in configuration[/yellow]")
console.print()

# Show warnings
if report["warnings"]:
Expand Down
92 changes: 59 additions & 33 deletions src/mcp_agent/cli/commands/dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@

from __future__ import annotations

import asyncio
import subprocess
import sys
from pathlib import Path
import shutil
import time

import typer
from rich.console import Console

from mcp_agent.cli.core.utils import load_user_app
from mcp_agent.config import get_settings


Expand All @@ -39,16 +38,16 @@ def _preflight_ok() -> bool:
ok = False
return ok

async def _run_once():
app_obj = load_user_app(script)
async with app_obj.run():
console.print(f"Running {script}")
# Sleep until cancelled
try:
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
pass
def _run_script() -> subprocess.Popen:
"""Run the script as a subprocess."""
console.print(f"Running {script}")
# Run the script with the same Python interpreter
return subprocess.Popen(
[sys.executable, str(script)],
stdout=None, # Inherit stdout
stderr=None, # Inherit stderr
stdin=None, # Inherit stdin
)

# Simple preflight
_ = _preflight_ok()
Expand All @@ -57,50 +56,77 @@ async def _run_once():
try:
from watchdog.observers import Observer # type: ignore
from watchdog.events import FileSystemEventHandler # type: ignore
import time

class _Handler(FileSystemEventHandler):
def __init__(self):
self.touched = False

def on_modified(self, event): # type: ignore
self.touched = True
if not event.is_directory:
self.touched = True

def on_created(self, event): # type: ignore
self.touched = True

loop = asyncio.get_event_loop()
task = loop.create_task(_run_once())
if not event.is_directory:
self.touched = True

handler = _Handler()
observer = Observer()
observer.schedule(handler, path=str(script.parent), recursive=True)
observer.start()
console.print("Live reload enabled (watchdog)")

# Start the script
process = _run_script()

try:
while True:
time.sleep(0.5)

# Check if process died
if process.poll() is not None:
console.print(
f"[red]Process exited with code {process.returncode}[/red]"
)
break

# Check for file changes
if handler.touched:
handler.touched = False
console.print("Change detected. Restarting...")
task.cancel()
process.terminate()
try:
loop.run_until_complete(task)
except Exception:
pass
task = loop.create_task(_run_once())
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
process = _run_script()

except KeyboardInterrupt:
pass
console.print("\n[yellow]Stopping...[/yellow]")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
finally:
observer.stop()
observer.join()
task.cancel()
try:
loop.run_until_complete(task)
except Exception:
pass
except Exception:
# Fallback: run once

except ImportError:
# Fallback: run once without watchdog
console.print(
"[yellow]Watchdog not installed. Running without live reload.[/yellow]"
)
process = _run_script()
try:
asyncio.run(_run_once())
process.wait()
except KeyboardInterrupt:
pass
console.print("\n[yellow]Stopping...[/yellow]")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
15 changes: 10 additions & 5 deletions src/mcp_agent/cli/commands/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@


app = typer.Typer(help="Invoke an agent or workflow programmatically")
console = Console()
console = Console(color_system=None)


@app.callback(invoke_without_command=True)
Expand All @@ -27,6 +27,9 @@ def invoke(
vars: Optional[str] = typer.Option(None, "--vars", help="JSON structured inputs"),
script: Optional[str] = typer.Option(None, "--script"),
model: Optional[str] = typer.Option(None, "--model"),
servers: Optional[str] = typer.Option(
None, "--servers", help="Comma-separated list of MCP server names"
),
) -> None:
"""Run either an agent (LLM) or a workflow from the user's app script."""
if not agent and not workflow:
Expand All @@ -50,16 +53,18 @@ async def _run():
async with app_obj.run():
if agent:
# Run via LLM
server_list = servers.split(",") if servers else []
server_list = [s.strip() for s in server_list if s.strip()]
llm = create_llm(
agent_name=agent,
server_names=[],
server_names=server_list,
provider=None,
model=model,
context=app_obj.context,
)
if message:
res = await llm.generate_str(message)
console.print(res)
console.print(res, end="\n\n\n")
return
if payload:
# If structured vars contain messages, prefer that key; else stringify
Expand All @@ -69,7 +74,7 @@ async def _run():
or json.dumps(payload)
)
res = await llm.generate_str(msg)
console.print(res)
console.print(res, end="\n\n\n")
return
typer.secho("No input provided", err=True, fg=typer.colors.YELLOW)
return
Expand Down Expand Up @@ -98,7 +103,7 @@ async def _run():
val = getattr(result, "value", result)
except Exception:
val = result
console.print(val)
console.print(val, end="\n\n\n")

from pathlib import Path

Expand Down
9 changes: 8 additions & 1 deletion src/mcp_agent/cli/commands/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ async def _run():
info_table.add_row("Address", f"http://{address}")
if transport == "sse":
info_table.add_row("SSE Endpoint", f"http://{address}/sse")
elif transport == "http":
info_table.add_row("HTTP Endpoint", f"http://{address}/mcp")

# Show registered components
if hasattr(app_obj, "workflows") and app_obj.workflows:
Expand Down Expand Up @@ -251,6 +253,7 @@ async def _run():
def signal_handler(sig, frame):
console.print("\n[yellow]Shutting down server...[/yellow]")
shutdown_event.set()
os._exit(0)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Don’t call os._exit(0) in signal handler. It bypasses cleanup and can corrupt logs/metrics.

Immediate exit prevents graceful shutdown, resource cleanup, and OTEL/exporter flushes. Use a cooperative shutdown signal instead.

-        def signal_handler(sig, frame):
+        # Keep a reference to the uvicorn server for cooperative shutdown
+        server_ref = {"srv": None}
+
+        def signal_handler(sig, frame):
             console.print("\n[yellow]Shutting down server...[/yellow]")
             shutdown_event.set()
-            os._exit(0)
+            # For HTTP/SSE, ask uvicorn to exit if running
+            srv = server_ref.get("srv")
+            if srv is not None:
+                try:
+                    srv.should_exit = True
+                except Exception:
+                    pass
@@
-                server = uvicorn.Server(uvicorn_config)
+                server = uvicorn.Server(uvicorn_config)
+                server_ref["srv"] = server
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
os._exit(0)
# Keep a reference to the uvicorn server for cooperative shutdown
server_ref = {"srv": None}
def signal_handler(sig, frame):
console.print("\n[yellow]Shutting down server...[/yellow]")
shutdown_event.set()
# For HTTP/SSE, ask uvicorn to exit if running
srv = server_ref.get("srv")
if srv is not None:
try:
srv.should_exit = True
except Exception:
pass
# allow the process to exit naturally after cooperative shutdown
Suggested change
os._exit(0)
server = uvicorn.Server(uvicorn_config)
server_ref["srv"] = server
🤖 Prompt for AI Agents
In src/mcp_agent/cli/commands/serve.py around line 256, replace the direct
os._exit(0) call in the signal handler with a cooperative shutdown mechanism:
set a shared shutdown flag or threading.Event (or call a provided shutdown
coroutine/function) so the main loop can detect the signal, perform graceful
teardown (close resources, flush logs/metrics/OTEL exporters) and then exit
normally; remove any use of os._exit in handlers and ensure the main thread
performs cleanup and returns an appropriate exit code.


signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
Expand Down Expand Up @@ -284,7 +287,7 @@ def signal_handler(sig, frame):

# Configure uvicorn
uvicorn_config = uvicorn.Config(
mcp.app,
mcp.streamable_http_app if transport == "http" else mcp.sse_app,
host=host,
port=port or 8000,
log_level="debug" if debug else "info",
Expand All @@ -307,6 +310,10 @@ def signal_handler(sig, frame):

if transport == "sse":
console.print(f"[bold]SSE:[/bold] http://{host}:{port or 8000}/sse")
elif transport == "http":
console.print(
f"[bold]HTTP:[/bold] http://{host}:{port or 8000}/mcp"
)

console.print("\n[dim]Press Ctrl+C to stop the server[/dim]\n")

Expand Down
13 changes: 9 additions & 4 deletions src/mcp_agent/cli/commands/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
from rich.table import Table
from rich.prompt import Confirm

from mcp_agent.config import Settings, MCPServerSettings, MCPSettings
from mcp_agent.config import Settings, MCPServerSettings, MCPSettings, get_settings
from mcp_agent.cli.utils.importers import import_servers_from_mcp_json
from mcp_agent.core.context import cleanup_context


app = typer.Typer(help="Local server helpers")
Expand Down Expand Up @@ -338,7 +339,7 @@ def list_servers(
),
) -> None:
"""List configured servers."""
settings = Settings()
settings = get_settings()
servers = (settings.mcp.servers if settings.mcp else {}) or {}

if not servers:
Expand Down Expand Up @@ -443,7 +444,7 @@ def add(
),
) -> None:
"""Add a server to configuration."""
settings = Settings()
settings = get_settings()
if settings.mcp is None:
settings.mcp = MCPSettings()
servers = settings.mcp.servers or {}
Expand Down Expand Up @@ -759,7 +760,8 @@ async def _probe():
pass # Resources might not be supported

console.print(
f"\n[green bold]✅ Server '{name}' is working correctly![/green bold]"
f"\n[green bold]✅ Server '{name}' is working correctly![/green bold]",
end="\n\n",
)

except asyncio.TimeoutError:
Expand All @@ -773,6 +775,9 @@ async def _probe():
console.print(f"[dim]{traceback.format_exc()}[/dim]")
raise typer.Exit(1)

# Force complete shutdown of logging infrastructure for CLI commands
await cleanup_context(shutdown_logger=True)

try:
asyncio.run(asyncio.wait_for(_probe(), timeout=timeout))
except asyncio.TimeoutError:
Expand Down
39 changes: 24 additions & 15 deletions src/mcp_agent/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
from mcp_agent.cli.cloud.commands import deploy_config, login, logout, whoami
from mcp_agent.cli.commands import (
check as check_cmd,
chat as chat_cmd,
dev as dev_cmd,
invoke as invoke_cmd,
serve as serve_cmd,
server as server_cmd,
build as build_cmd,
logs as logs_cmd,
doctor as doctor_cmd,
configure as configure_cmd,
)
from mcp_agent.cli.commands import (
config as config_cmd,
Expand All @@ -50,8 +59,8 @@

app = typer.Typer(
help="mcp-agent CLI",
add_completion=False,
no_args_is_help=False,
add_completion=True,
no_args_is_help=True,
context_settings={"help_option_names": ["-h", "--help"]},
cls=HelpfulTyperGroup,
)
Expand Down Expand Up @@ -124,19 +133,19 @@ def main(
app.add_typer(models_cmd.app, name="models", help="List and manage models")

# TODO: Uncomment after testing - Local Development and beyond
# app.add_typer(chat_cmd.app, name="chat", help="Ephemeral REPL for quick iteration")
# app.add_typer(dev_cmd.app, name="dev", help="Run app locally with live reload")
# app.add_typer(
# invoke_cmd.app, name="invoke", help="Invoke agent/workflow programmatically"
# )
# app.add_typer(serve_cmd.app, name="serve", help="Serve app as an MCP server")
# app.add_typer(server_cmd.app, name="server", help="Local server helpers")
# app.add_typer(
# build_cmd.app, name="build", help="Preflight and bundle prep for deployment"
# )
# app.add_typer(logs_cmd.app, name="logs", help="Tail local logs")
# app.add_typer(doctor_cmd.app, name="doctor", help="Comprehensive diagnostics")
# app.add_typer(configure_cmd.app, name="configure", help="Client integration helpers")
app.add_typer(chat_cmd.app, name="chat", help="Ephemeral REPL for quick iteration")
app.add_typer(dev_cmd.app, name="dev", help="Run app locally with live reload")
app.add_typer(
invoke_cmd.app, name="invoke", help="Invoke agent/workflow programmatically"
)
app.add_typer(serve_cmd.app, name="serve", help="Serve app as an MCP server")
app.add_typer(server_cmd.app, name="server", help="Local server helpers")
app.add_typer(
build_cmd.app, name="build", help="Preflight and bundle prep for deployment"
)
app.add_typer(logs_cmd.app, name="logs", help="Tail local logs")
app.add_typer(doctor_cmd.app, name="doctor", help="Comprehensive diagnostics")
app.add_typer(configure_cmd.app, name="configure", help="Client integration helpers")

# Mount cloud commands
app.add_typer(cloud_app, name="cloud", help="MCP Agent Cloud commands")
Expand Down
Loading
Loading