From d4f90d1de6112293552418c5aaa90de69d9fee3c Mon Sep 17 00:00:00 2001 From: enwaiax Date: Sun, 29 Mar 2026 15:37:20 +0800 Subject: [PATCH 1/2] feat(mcp): add prompts, resources, and LobeHub badge for MCP marketplace compliance - Add 3 MCP prompts leveraging runtime state: shuttle_overview, safe_command_check, node_context - Add 6 MCP resources exposing live data: nodes, node detail, security rules, sessions, pool status, command logs - Add LogRepo.list_by_node() for per-node command history queries - Add LobeHub MCP badge to README.md and README_CN.md - Shorten Docs badge text, reorder badges (MCP first) Made-with: Cursor --- README.md | 3 +- README_CN.md | 3 +- src/shuttle/db/repository.py | 14 ++ src/shuttle/mcp/prompts.py | 253 +++++++++++++++++++++++++++++++++++ src/shuttle/mcp/resources.py | 175 ++++++++++++++++++++++++ src/shuttle/mcp/server.py | 34 ++++- 6 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 src/shuttle/mcp/prompts.py create mode 100644 src/shuttle/mcp/resources.py diff --git a/README.md b/README.md index b919d9b..8d9b538 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ **Secure SSH gateway for AI assistants** +[![MCP](https://lobehub.com/badge/mcp/enwaiax-shuttle)](https://lobehub.com/mcp/enwaiax-shuttle) [![CI](https://img.shields.io/github/actions/workflow/status/enwaiax/shuttle/test.yml?style=flat-square&label=CI)](https://github.com/enwaiax/shuttle/actions/workflows/test.yml) [![codecov](https://img.shields.io/codecov/c/github/enwaiax/shuttle?style=flat-square&color=76B900)](https://codecov.io/gh/enwaiax/shuttle) [![PyPI](https://img.shields.io/pypi/v/shuttle-mcp?style=flat-square&color=76B900)](https://pypi.org/project/shuttle-mcp) [![Downloads](https://img.shields.io/pepy/dt/shuttle-mcp?style=flat-square&color=76B900&label=downloads)](https://pepy.tech/project/shuttle-mcp) [![Python](https://img.shields.io/badge/python-3.12+-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org) -[![Docs](https://img.shields.io/badge/docs-enwaiax.github.io%2Fshuttle-76B900?style=flat-square)](https://enwaiax.github.io/shuttle/) +[![Docs](https://img.shields.io/badge/docs-shuttle-76B900?style=flat-square)](https://enwaiax.github.io/shuttle/) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) Shuttle lets AI assistants (Claude Code, Cursor, etc.) securely execute commands on your remote SSH servers — with connection pooling, session isolation, command safety rules, and a web audit panel. diff --git a/README_CN.md b/README_CN.md index 20e5a51..9c90ab2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -4,10 +4,11 @@ **面向 AI 助手的安全 SSH 网关** +[![MCP](https://lobehub.com/badge/mcp/enwaiax-shuttle)](https://lobehub.com/mcp/enwaiax-shuttle) [![CI](https://img.shields.io/github/actions/workflow/status/enwaiax/shuttle/test.yml?style=flat-square&label=CI)](https://github.com/enwaiax/shuttle/actions/workflows/test.yml) [![PyPI](https://img.shields.io/pypi/v/shuttle-mcp?style=flat-square&color=76B900)](https://pypi.org/project/shuttle-mcp) [![Python](https://img.shields.io/badge/python-3.12+-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org) -[![Docs](https://img.shields.io/badge/docs-enwaiax.github.io%2Fshuttle-76B900?style=flat-square)](https://enwaiax.github.io/shuttle/) +[![Docs](https://img.shields.io/badge/docs-shuttle-76B900?style=flat-square)](https://enwaiax.github.io/shuttle/) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) Shuttle 让 AI 助手(Claude Code、Cursor 等)安全地在你的远程 SSH 服务器上执行命令 — 支持连接池、会话隔离、命令安全规则和 Web 审计面板。 diff --git a/src/shuttle/db/repository.py b/src/shuttle/db/repository.py index de76ea9..fa5605f 100644 --- a/src/shuttle/db/repository.py +++ b/src/shuttle/db/repository.py @@ -292,6 +292,20 @@ async def list_by_session( ) return list(result.scalars().all()) + async def list_by_node( + self, + node_id: str, + limit: int = 20, + ) -> list[CommandLog]: + """List recent command logs for a node, newest first.""" + result = await self._session.execute( + select(CommandLog) + .where(CommandLog.node_id == node_id) + .order_by(CommandLog.executed_at.desc()) + .limit(limit) + ) + return list(result.scalars().all()) + async def cleanup_old_data( session: AsyncSession, diff --git a/src/shuttle/mcp/prompts.py b/src/shuttle/mcp/prompts.py new file mode 100644 index 0000000..b4f04c6 --- /dev/null +++ b/src/shuttle/mcp/prompts.py @@ -0,0 +1,253 @@ +"""MCP prompt registrations for Shuttle. + +Provides ``register_prompts()`` which adds reusable prompt templates that +leverage Shuttle's runtime state to give AI assistants actionable context. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from typing import Any + +from shuttle.core.session import SessionManager + + +def register_prompts( + mcp: Any, + session_mgr: SessionManager, + pool: Any, + db_session_ctx: Callable[..., AsyncIterator], + node_repo_factory: Callable, +) -> None: + """Register all Shuttle MCP prompts on the given FastMCP instance.""" + + @mcp.prompt() + async def shuttle_overview() -> str: + """Get a complete overview of the current Shuttle environment. + + Returns live node status, active sessions, security rule summary, + and connection pool state — everything an AI assistant needs to + start working with this Shuttle instance. + """ + from shuttle.db.repository import RuleRepo + + # Nodes + async with db_session_ctx() as db_sess: + repo = node_repo_factory(db_sess) + nodes = await repo.list_all() + + node_lines = [] + for n in nodes: + icon = {"active": "●", "inactive": "○", "error": "✗"}.get(n.status, "?") + node_lines.append( + f" {icon} {n.name} — {n.host}:{n.port} (user={n.username}, " + f"auth={n.auth_type}, tags={n.tags or []})" + ) + nodes_section = "\n".join(node_lines) if node_lines else " (no nodes configured)" + + # Sessions + active = session_mgr.list_active() + session_lines = [] + for s in active: + session_lines.append( + f" • {s.node_id} — cwd={s.working_directory}, " + f"bypassed_rules={len(s.bypass_patterns)}" + ) + sessions_section = ( + "\n".join(session_lines) if session_lines else " (no active sessions)" + ) + + # Security rules + async with db_session_ctx() as db_sess: + rule_repo = RuleRepo(db_sess) + rules = await rule_repo.list_all() + + blocked = [r for r in rules if r.level == "BLOCK" and r.enabled] + confirm = [r for r in rules if r.level == "CONFIRM" and r.enabled] + warn = [r for r in rules if r.level == "WARN" and r.enabled] + + # Pool + pool_nodes = list(pool._registry.keys()) + total_idle = sum(len(q) for q in pool._idle.values()) + total_active = sum(pool._active.values()) + + return ( + "# Shuttle Environment Overview\n\n" + f"## Nodes ({len(nodes)})\n{nodes_section}\n\n" + f"## Active Sessions ({len(active)})\n{sessions_section}\n\n" + f"## Security Rules ({len(rules)} total)\n" + f" BLOCK: {len(blocked)} rules — commands matching these are rejected\n" + f" CONFIRM: {len(confirm)} rules — require explicit confirmation token\n" + f" WARN: {len(warn)} rules — allowed but logged with warning\n\n" + f"## Connection Pool\n" + f" Registered: {len(pool_nodes)} nodes\n" + f" Active connections: {total_active}\n" + f" Idle connections: {total_idle}\n\n" + "## Available Tools\n" + " • ssh_run(command, node) — execute command on a node\n" + " • ssh_list_nodes() — list configured nodes\n" + " • ssh_upload(node, local_path, remote_path) — SFTP upload\n" + " • ssh_download(node, remote_path, local_path) — SFTP download\n" + " • ssh_add_node(...) — add a new SSH node\n\n" + "Use `shuttle://security-rules` resource to see exact rule patterns " + "before running commands that might be blocked." + ) + + @mcp.prompt() + async def safe_command_check(command: str, node: str | None = None) -> str: + """Check if a command is safe to run before executing it. + + Evaluates the command against all active security rules and returns + a detailed assessment — which rules match, what security level applies, + and whether confirmation will be needed. + """ + from shuttle.db.repository import RuleRepo + + async with db_session_ctx() as db_sess: + rule_repo = RuleRepo(db_sess) + rules = await rule_repo.list_all(node_id=None) + + # If node specified, also get node-specific rules + node_rules = [] + if node: + async with db_session_ctx() as db_sess: + repo = node_repo_factory(db_sess) + node_obj = await repo.get_by_name(node) + if node_obj: + async with db_session_ctx() as db_sess: + rule_repo = RuleRepo(db_sess) + node_rules = await rule_repo.list_all(node_id=node_obj.id) + + all_rules = rules + node_rules + enabled_rules = [r for r in all_rules if r.enabled] + + import re + + matching = [] + for r in enabled_rules: + try: + if re.search(r.pattern, command): + matching.append(r) + except re.error: + pass + + if not matching: + return ( + f"## Command Safety Check\n\n" + f"**Command**: `{command}`\n" + f"**Node**: {node or '(auto-select)'}\n" + f"**Result**: ✅ ALLOW — no security rules matched.\n\n" + "You can proceed with `ssh_run(command=...)`." + ) + + lines = [] + highest_level = "ALLOW" + level_order = {"BLOCK": 3, "CONFIRM": 2, "WARN": 1, "ALLOW": 0} + for r in matching: + lines.append( + f" • [{r.level}] pattern=`{r.pattern}` — {r.description or 'no description'}" + ) + if level_order.get(r.level, 0) > level_order.get(highest_level, 0): + highest_level = r.level + + icon = {"BLOCK": "⛔", "CONFIRM": "⚠️", "WARN": "⚡"}.get(highest_level, "✅") + + advice = { + "BLOCK": "This command will be rejected. Rephrase or use an alternative approach.", + "CONFIRM": ( + "This command requires confirmation. Call ssh_run() first to get a " + "confirm_token, then call ssh_run() again with that token." + ), + "WARN": "This command is allowed but will be logged with a warning. Proceed if intended.", + }.get(highest_level, "Proceed normally.") + + return ( + f"## Command Safety Check\n\n" + f"**Command**: `{command}`\n" + f"**Node**: {node or '(auto-select)'}\n" + f"**Result**: {icon} {highest_level}\n\n" + f"### Matched Rules ({len(matching)})\n" + + "\n".join(lines) + + f"\n\n### Recommendation\n{advice}" + ) + + @mcp.prompt() + async def node_context(node: str) -> str: + """Get full operational context for a specific node. + + Returns the node's configuration, its active session state (including + current working directory), applicable security rules, and recent + command history — ready for the AI to operate on this node. + """ + from shuttle.db.repository import LogRepo, RuleRepo + + # Node info + async with db_session_ctx() as db_sess: + repo = node_repo_factory(db_sess) + node_obj = await repo.get_by_name(node) + + if not node_obj: + return f"Node **{node}** not found. Run `ssh_list_nodes()` to see available nodes." + + # Active session + active = session_mgr.list_active() + node_session = next((s for s in active if s.node_id == node), None) + + session_info = ( + f" Session ID: {node_session.session_id}\n" + f" Working directory: {node_session.working_directory}\n" + f" Bypassed rules: {list(node_session.bypass_patterns) or 'none'}" + if node_session + else " No active session (will be auto-created on first ssh_run)" + ) + + # Node-specific security rules + async with db_session_ctx() as db_sess: + rule_repo = RuleRepo(db_sess) + all_rules = await rule_repo.list_all() + node_rules = await rule_repo.list_all(node_id=node_obj.id) + + global_rules = [r for r in all_rules if r.node_id is None and r.enabled] + specific_rules = [r for r in node_rules if r.enabled] + + rule_lines = [] + for r in (specific_rules + global_rules)[:15]: + scope = "node-specific" if r.node_id else "global" + rule_lines.append(f" [{r.level}] `{r.pattern}` ({scope})") + + rules_section = "\n".join(rule_lines) if rule_lines else " (no rules)" + + # Recent command logs + async with db_session_ctx() as db_sess: + log_repo = LogRepo(db_sess) + logs = await log_repo.list_by_node( + node_id=node_obj.id, limit=10 + ) + + log_lines = [] + for log in logs: + icon = "✅" if log.exit_code == 0 else "❌" + cmd_short = log.command[:60] + ("..." if len(log.command) > 60 else "") + duration = f"{log.duration_ms}ms" if log.duration_ms else "?" + log_lines.append(f" {icon} `{cmd_short}` (exit={log.exit_code}, {duration})") + + logs_section = "\n".join(log_lines) if log_lines else " (no recent commands)" + + # Pool state + pool_idle = len(pool._idle.get(node, [])) + pool_active = pool._active.get(node, 0) + + return ( + f"# Node: {node}\n\n" + f"## Connection\n" + f" Host: {node_obj.host}:{node_obj.port}\n" + f" User: {node_obj.username}\n" + f" Auth: {node_obj.auth_type}\n" + f" Status: {node_obj.status}\n" + f" Tags: {node_obj.tags or []}\n" + f" Pool: {pool_active} active, {pool_idle} idle connections\n\n" + f"## Current Session\n{session_info}\n\n" + f"## Security Rules (top {len(rule_lines)})\n{rules_section}\n\n" + f"## Recent Commands (last {len(log_lines)})\n{logs_section}\n\n" + f"Ready to operate. Use `ssh_run(command=..., node='{node}')` to execute." + ) diff --git a/src/shuttle/mcp/resources.py b/src/shuttle/mcp/resources.py new file mode 100644 index 0000000..2740fcb --- /dev/null +++ b/src/shuttle/mcp/resources.py @@ -0,0 +1,175 @@ +"""MCP resource registrations for Shuttle. + +Provides ``register_resources()`` which exposes Shuttle's live runtime state +as MCP resources — nodes, security rules, sessions, command history, and +connection pool status. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator, Callable +from typing import Any + +from shuttle.core.session import SessionManager + + +def register_resources( + mcp: Any, + pool: Any, + session_mgr: SessionManager, + db_session_ctx: Callable[..., AsyncIterator], + node_repo_factory: Callable, +) -> None: + """Register all Shuttle MCP resources on the given FastMCP instance.""" + + @mcp.resource("shuttle://nodes") + async def list_nodes() -> str: + """All configured SSH nodes with connection details and status.""" + async with db_session_ctx() as db_sess: + repo = node_repo_factory(db_sess) + nodes = await repo.list_all() + + items = [] + for n in nodes: + items.append({ + "name": n.name, + "host": n.host, + "port": n.port, + "username": n.username, + "status": n.status, + "auth_type": n.auth_type, + "tags": n.tags or [], + "last_seen_at": n.last_seen_at.isoformat() if n.last_seen_at else None, + }) + return json.dumps({"nodes": items, "total": len(items)}) + + @mcp.resource("shuttle://nodes/{name}") + async def get_node_detail(name: str) -> str: + """Detailed information for a specific SSH node including pool state.""" + async with db_session_ctx() as db_sess: + repo = node_repo_factory(db_sess) + node = await repo.get_by_name(name) + + if not node: + return json.dumps({"error": f"Node '{name}' not found"}) + + return json.dumps({ + "name": node.name, + "host": node.host, + "port": node.port, + "username": node.username, + "status": node.status, + "auth_type": node.auth_type, + "tags": node.tags or [], + "pool": { + "active_connections": pool._active.get(name, 0), + "idle_connections": len(pool._idle.get(name, [])), + "registered": name in pool._registry, + }, + "last_seen_at": node.last_seen_at.isoformat() if node.last_seen_at else None, + "created_at": node.created_at.isoformat() if node.created_at else None, + "updated_at": node.updated_at.isoformat() if node.updated_at else None, + }) + + @mcp.resource("shuttle://security-rules") + async def list_security_rules() -> str: + """All security rules governing command execution, grouped by level.""" + from shuttle.db.repository import RuleRepo + + async with db_session_ctx() as db_sess: + rule_repo = RuleRepo(db_sess) + rules = await rule_repo.list_all() + + items = [] + for r in rules: + items.append({ + "id": r.id, + "pattern": r.pattern, + "level": r.level, + "description": r.description, + "priority": r.priority, + "enabled": r.enabled, + "node_id": r.node_id, + }) + + by_level = {} + for r in items: + by_level.setdefault(r["level"], []).append(r) + + return json.dumps({ + "rules": items, + "total": len(items), + "by_level": {k: len(v) for k, v in by_level.items()}, + }) + + @mcp.resource("shuttle://sessions") + async def list_active_sessions() -> str: + """Currently active SSH sessions with working directory and bypass state.""" + active = session_mgr.list_active() + + items = [] + for s in active: + items.append({ + "session_id": s.session_id, + "node_id": s.node_id, + "working_directory": s.working_directory, + "bypass_patterns": list(s.bypass_patterns), + "env_vars": s.env_vars, + }) + return json.dumps({"sessions": items, "total": len(items)}) + + @mcp.resource("shuttle://pool-status") + async def get_pool_status() -> str: + """Connection pool health — per-node active/idle counts and config.""" + per_node = {} + for node_id in pool._registry: + per_node[node_id] = { + "active": pool._active.get(node_id, 0), + "idle": len(pool._idle.get(node_id, [])), + } + + return json.dumps({ + "config": { + "max_per_node": pool._config.max_per_node, + "max_total": pool._config.max_total, + "idle_timeout_s": pool._config.idle_timeout, + "max_lifetime_s": pool._config.max_lifetime, + }, + "global_active": pool._global_active, + "registered_nodes": len(pool._registry), + "per_node": per_node, + }) + + @mcp.resource("shuttle://logs/{node_name}/recent") + async def get_recent_logs(node_name: str) -> str: + """Recent command execution history for a node (last 20 commands).""" + from shuttle.db.repository import LogRepo + + async with db_session_ctx() as db_sess: + repo = node_repo_factory(db_sess) + node_obj = await repo.get_by_name(node_name) + + if not node_obj: + return json.dumps({"error": f"Node '{node_name}' not found"}) + + async with db_session_ctx() as db_sess: + log_repo = LogRepo(db_sess) + logs = await log_repo.list_by_node(node_id=node_obj.id, limit=20) + + items = [] + for log in logs: + items.append({ + "command": log.command, + "exit_code": log.exit_code, + "security_level": log.security_level, + "bypassed": log.bypassed, + "duration_ms": log.duration_ms, + "executed_at": log.executed_at.isoformat() if log.executed_at else None, + }) + + return json.dumps({ + "node": node_name, + "logs": items, + "total": len(items), + }) diff --git a/src/shuttle/mcp/server.py b/src/shuttle/mcp/server.py index 16f61a7..64d1410 100644 --- a/src/shuttle/mcp/server.py +++ b/src/shuttle/mcp/server.py @@ -20,6 +20,8 @@ from shuttle.core.session import SessionManager from shuttle.db.engine import create_db_engine, create_session_factory, init_db from shuttle.db.repository import NodeRepo +from shuttle.mcp.prompts import register_prompts +from shuttle.mcp.resources import register_resources from shuttle.mcp.tools import register_tools @@ -185,7 +187,7 @@ async def db_session_ctx() -> AsyncIterator[AsyncSession]: # ── 9. FastMCP ────────────────────────────────────────────────── mcp = FastMCP(name="shuttle") - # ── 10-11. Register tools ─────────────────────────────────────── + # ── 10-11. Register tools, prompts, resources ─────────────────── register_tools( mcp=mcp, pool=pool, @@ -196,6 +198,20 @@ async def db_session_ctx() -> AsyncIterator[AsyncSession]: node_repo_factory=NodeRepo, cred_mgr=cred_mgr, ) + register_prompts( + mcp=mcp, + session_mgr=session_mgr, + pool=pool, + db_session_ctx=db_session_ctx, + node_repo_factory=NodeRepo, + ) + register_resources( + mcp=mcp, + pool=pool, + session_mgr=session_mgr, + db_session_ctx=db_session_ctx, + node_repo_factory=NodeRepo, + ) # ── 12. Return ────────────────────────────────────────────────── return mcp @@ -262,7 +278,7 @@ async def db_session_ctx() -> AsyncIterator[AsyncSession]: session_mgr = SessionManager(pool=pool, db_session_factory=db_session_ctx) - # ── FastMCP + tools ────────────────────────────────────────────── + # ── FastMCP + tools + prompts + resources ──────────────────────── mcp = FastMCP(name="shuttle") register_tools( mcp=mcp, @@ -274,6 +290,20 @@ async def db_session_ctx() -> AsyncIterator[AsyncSession]: node_repo_factory=NodeRepo, cred_mgr=cred_mgr, ) + register_prompts( + mcp=mcp, + session_mgr=session_mgr, + pool=pool, + db_session_ctx=db_session_ctx, + node_repo_factory=NodeRepo, + ) + register_resources( + mcp=mcp, + pool=pool, + session_mgr=session_mgr, + db_session_ctx=db_session_ctx, + node_repo_factory=NodeRepo, + ) mcp_http = mcp.http_app(path="/") From 5eebb800d5fd7a102310e01e957fb4e00d96a507 Mon Sep 17 00:00:00 2001 From: enwaiax Date: Sun, 29 Mar 2026 15:37:40 +0800 Subject: [PATCH 2/2] refactor(mcp): enhance prompts with runtime state, add pool-status and logs resources - Rewrite prompts to leverage live system state (nodes, sessions, security rules, connection pool) instead of static templates - Add safe_command_check prompt for pre-execution security assessment - Add shuttle://pool-status and shuttle://logs/{node}/recent resources - Include connection pool state in node detail resource - Bump version to 0.3.1 Made-with: Cursor --- src/shuttle/__init__.py | 2 +- src/shuttle/mcp/prompts.py | 12 +-- src/shuttle/mcp/resources.py | 168 ++++++++++++++++++++--------------- 3 files changed, 103 insertions(+), 79 deletions(-) diff --git a/src/shuttle/__init__.py b/src/shuttle/__init__.py index 08bc8bc..7d39f03 100644 --- a/src/shuttle/__init__.py +++ b/src/shuttle/__init__.py @@ -1,3 +1,3 @@ """Shuttle — Secure SSH gateway for AI assistants.""" -__version__ = "0.3.0" +__version__ = "0.3.1" diff --git a/src/shuttle/mcp/prompts.py b/src/shuttle/mcp/prompts.py index b4f04c6..5bc755a 100644 --- a/src/shuttle/mcp/prompts.py +++ b/src/shuttle/mcp/prompts.py @@ -43,7 +43,9 @@ async def shuttle_overview() -> str: f" {icon} {n.name} — {n.host}:{n.port} (user={n.username}, " f"auth={n.auth_type}, tags={n.tags or []})" ) - nodes_section = "\n".join(node_lines) if node_lines else " (no nodes configured)" + nodes_section = ( + "\n".join(node_lines) if node_lines else " (no nodes configured)" + ) # Sessions active = session_mgr.list_active() @@ -220,16 +222,16 @@ async def node_context(node: str) -> str: # Recent command logs async with db_session_ctx() as db_sess: log_repo = LogRepo(db_sess) - logs = await log_repo.list_by_node( - node_id=node_obj.id, limit=10 - ) + logs = await log_repo.list_by_node(node_id=node_obj.id, limit=10) log_lines = [] for log in logs: icon = "✅" if log.exit_code == 0 else "❌" cmd_short = log.command[:60] + ("..." if len(log.command) > 60 else "") duration = f"{log.duration_ms}ms" if log.duration_ms else "?" - log_lines.append(f" {icon} `{cmd_short}` (exit={log.exit_code}, {duration})") + log_lines.append( + f" {icon} `{cmd_short}` (exit={log.exit_code}, {duration})" + ) logs_section = "\n".join(log_lines) if log_lines else " (no recent commands)" diff --git a/src/shuttle/mcp/resources.py b/src/shuttle/mcp/resources.py index 2740fcb..140a326 100644 --- a/src/shuttle/mcp/resources.py +++ b/src/shuttle/mcp/resources.py @@ -32,16 +32,20 @@ async def list_nodes() -> str: items = [] for n in nodes: - items.append({ - "name": n.name, - "host": n.host, - "port": n.port, - "username": n.username, - "status": n.status, - "auth_type": n.auth_type, - "tags": n.tags or [], - "last_seen_at": n.last_seen_at.isoformat() if n.last_seen_at else None, - }) + items.append( + { + "name": n.name, + "host": n.host, + "port": n.port, + "username": n.username, + "status": n.status, + "auth_type": n.auth_type, + "tags": n.tags or [], + "last_seen_at": n.last_seen_at.isoformat() + if n.last_seen_at + else None, + } + ) return json.dumps({"nodes": items, "total": len(items)}) @mcp.resource("shuttle://nodes/{name}") @@ -54,23 +58,27 @@ async def get_node_detail(name: str) -> str: if not node: return json.dumps({"error": f"Node '{name}' not found"}) - return json.dumps({ - "name": node.name, - "host": node.host, - "port": node.port, - "username": node.username, - "status": node.status, - "auth_type": node.auth_type, - "tags": node.tags or [], - "pool": { - "active_connections": pool._active.get(name, 0), - "idle_connections": len(pool._idle.get(name, [])), - "registered": name in pool._registry, - }, - "last_seen_at": node.last_seen_at.isoformat() if node.last_seen_at else None, - "created_at": node.created_at.isoformat() if node.created_at else None, - "updated_at": node.updated_at.isoformat() if node.updated_at else None, - }) + return json.dumps( + { + "name": node.name, + "host": node.host, + "port": node.port, + "username": node.username, + "status": node.status, + "auth_type": node.auth_type, + "tags": node.tags or [], + "pool": { + "active_connections": pool._active.get(name, 0), + "idle_connections": len(pool._idle.get(name, [])), + "registered": name in pool._registry, + }, + "last_seen_at": node.last_seen_at.isoformat() + if node.last_seen_at + else None, + "created_at": node.created_at.isoformat() if node.created_at else None, + "updated_at": node.updated_at.isoformat() if node.updated_at else None, + } + ) @mcp.resource("shuttle://security-rules") async def list_security_rules() -> str: @@ -83,25 +91,29 @@ async def list_security_rules() -> str: items = [] for r in rules: - items.append({ - "id": r.id, - "pattern": r.pattern, - "level": r.level, - "description": r.description, - "priority": r.priority, - "enabled": r.enabled, - "node_id": r.node_id, - }) + items.append( + { + "id": r.id, + "pattern": r.pattern, + "level": r.level, + "description": r.description, + "priority": r.priority, + "enabled": r.enabled, + "node_id": r.node_id, + } + ) by_level = {} for r in items: by_level.setdefault(r["level"], []).append(r) - return json.dumps({ - "rules": items, - "total": len(items), - "by_level": {k: len(v) for k, v in by_level.items()}, - }) + return json.dumps( + { + "rules": items, + "total": len(items), + "by_level": {k: len(v) for k, v in by_level.items()}, + } + ) @mcp.resource("shuttle://sessions") async def list_active_sessions() -> str: @@ -110,13 +122,15 @@ async def list_active_sessions() -> str: items = [] for s in active: - items.append({ - "session_id": s.session_id, - "node_id": s.node_id, - "working_directory": s.working_directory, - "bypass_patterns": list(s.bypass_patterns), - "env_vars": s.env_vars, - }) + items.append( + { + "session_id": s.session_id, + "node_id": s.node_id, + "working_directory": s.working_directory, + "bypass_patterns": list(s.bypass_patterns), + "env_vars": s.env_vars, + } + ) return json.dumps({"sessions": items, "total": len(items)}) @mcp.resource("shuttle://pool-status") @@ -129,17 +143,19 @@ async def get_pool_status() -> str: "idle": len(pool._idle.get(node_id, [])), } - return json.dumps({ - "config": { - "max_per_node": pool._config.max_per_node, - "max_total": pool._config.max_total, - "idle_timeout_s": pool._config.idle_timeout, - "max_lifetime_s": pool._config.max_lifetime, - }, - "global_active": pool._global_active, - "registered_nodes": len(pool._registry), - "per_node": per_node, - }) + return json.dumps( + { + "config": { + "max_per_node": pool._config.max_per_node, + "max_total": pool._config.max_total, + "idle_timeout_s": pool._config.idle_timeout, + "max_lifetime_s": pool._config.max_lifetime, + }, + "global_active": pool._global_active, + "registered_nodes": len(pool._registry), + "per_node": per_node, + } + ) @mcp.resource("shuttle://logs/{node_name}/recent") async def get_recent_logs(node_name: str) -> str: @@ -159,17 +175,23 @@ async def get_recent_logs(node_name: str) -> str: items = [] for log in logs: - items.append({ - "command": log.command, - "exit_code": log.exit_code, - "security_level": log.security_level, - "bypassed": log.bypassed, - "duration_ms": log.duration_ms, - "executed_at": log.executed_at.isoformat() if log.executed_at else None, - }) - - return json.dumps({ - "node": node_name, - "logs": items, - "total": len(items), - }) + items.append( + { + "command": log.command, + "exit_code": log.exit_code, + "security_level": log.security_level, + "bypassed": log.bypassed, + "duration_ms": log.duration_ms, + "executed_at": log.executed_at.isoformat() + if log.executed_at + else None, + } + ) + + return json.dumps( + { + "node": node_name, + "logs": items, + "total": len(items), + } + )