From 39fff1e44ac51a92423b26e235cf691d2fdcc7ef Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Tue, 31 Mar 2026 23:02:47 -0400 Subject: [PATCH] Add Memory Bank, Puppeteer, Sequential Thinking, and Docker MCP servers New MCP servers added to the gateway stack: - memory-bank-mcp (port 8700): Persistent key-value memory storage with tags, categories, and search - puppeteer-mcp (port 8800): Headless browser automation via Pyppeteer (navigate, screenshot, click, JS eval, PDF gen) - sequential-thinking-mcp (port 8900): Structured step-by-step reasoning with branching hypotheses and synthesis - docker-mcp (port 9000): Docker container/image/network/volume management via Docker socket All servers follow the existing Python/FastMCP pattern with streamable-http transport. docker-compose.yml updated with service definitions and gateway backend routes. --- docker-compose.yml | 107 ++++ docker-mcp/Dockerfile | 18 + docker-mcp/docker_mcp.py | 532 ++++++++++++++++++ docker-mcp/entrypoint.py | 12 + docker-mcp/requirements.txt | 6 + memory-bank-mcp/Dockerfile | 18 + memory-bank-mcp/entrypoint.py | 12 + memory-bank-mcp/memory_bank_mcp.py | 353 ++++++++++++ memory-bank-mcp/requirements.txt | 6 + puppeteer-mcp/Dockerfile | 37 ++ puppeteer-mcp/entrypoint.py | 12 + puppeteer-mcp/puppeteer_mcp.py | 335 +++++++++++ puppeteer-mcp/requirements.txt | 6 + sequential-thinking-mcp/Dockerfile | 18 + sequential-thinking-mcp/entrypoint.py | 12 + sequential-thinking-mcp/requirements.txt | 4 + .../sequential_thinking_mcp.py | 376 +++++++++++++ 17 files changed, 1864 insertions(+) create mode 100755 docker-mcp/Dockerfile create mode 100755 docker-mcp/docker_mcp.py create mode 100755 docker-mcp/entrypoint.py create mode 100755 docker-mcp/requirements.txt create mode 100755 memory-bank-mcp/Dockerfile create mode 100755 memory-bank-mcp/entrypoint.py create mode 100755 memory-bank-mcp/memory_bank_mcp.py create mode 100755 memory-bank-mcp/requirements.txt create mode 100755 puppeteer-mcp/Dockerfile create mode 100755 puppeteer-mcp/entrypoint.py create mode 100755 puppeteer-mcp/puppeteer_mcp.py create mode 100755 puppeteer-mcp/requirements.txt create mode 100755 sequential-thinking-mcp/Dockerfile create mode 100755 sequential-thinking-mcp/entrypoint.py create mode 100755 sequential-thinking-mcp/requirements.txt create mode 100755 sequential-thinking-mcp/sequential_thinking_mcp.py diff --git a/docker-compose.yml b/docker-compose.yml index 8d1781d..bdcde1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,11 @@ services: - MCP_BACKEND_WAVE=http://mcp-wave:8300/mcp - MCP_BACKEND_LINKEDIN=http://mcp-linkedin:8500/mcp - MCP_BACKEND_FORGEJO=http://mcp-forgejo:8400/mcp + - MCP_BACKEND_SSH=http://mcp-ssh:8600/mcp + - MCP_BACKEND_MEMORY_BANK=http://mcp-memory-bank:8700/mcp + - MCP_BACKEND_PUPPETEER=http://mcp-puppeteer:8800/mcp + - MCP_BACKEND_SEQUENTIAL_THINKING=http://mcp-sequential-thinking:8900/mcp + - MCP_BACKEND_DOCKER=http://mcp-docker:9000/mcp - GATEWAY_STATIC_API_KEY=${GATEWAY_STATIC_API_KEY} depends_on: - erpnext-mcp @@ -34,6 +39,11 @@ services: - wave-mcp - linkedin-mcp - forgejo-mcp + - ssh-mcp + - memory-bank-mcp + - puppeteer-mcp + - sequential-thinking-mcp + - docker-mcp networks: [mcpnet] healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4444/health', timeout=5)"] @@ -156,6 +166,103 @@ services: start_period: 15s retries: 3 + ssh-mcp: + build: + context: ./ssh-mcp + dockerfile: Dockerfile + container_name: mcp-ssh + restart: unless-stopped + environment: + - PORT=8600 + - SSH_HOST=${SSH_HOST} + - SSH_PORT=${SSH_PORT:-22} + - SSH_USER=${SSH_USER:-root} + - SSH_PASSWORD=${SSH_PASSWORD:-} + - SSH_KEY_PATH=${SSH_KEY_PATH:-} + - SSH_PASSPHRASE=${SSH_PASSPHRASE:-} + networks: [mcpnet] + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8600/mcp', timeout=5)"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + + memory-bank-mcp: + build: + context: ./memory-bank-mcp + dockerfile: Dockerfile + container_name: mcp-memory-bank + restart: unless-stopped + environment: + - PORT=8700 + - MEMORY_DIR=/data/memories + - MAX_MEMORIES=${MAX_MEMORIES:-10000} + volumes: + - memory-bank-data:/data + networks: [mcpnet] + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8700/mcp', timeout=5)"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + + puppeteer-mcp: + build: + context: ./puppeteer-mcp + dockerfile: Dockerfile + container_name: mcp-puppeteer + restart: unless-stopped + environment: + - PORT=8800 + - VIEWPORT_WIDTH=${PUPPETEER_VIEWPORT_WIDTH:-1280} + - VIEWPORT_HEIGHT=${PUPPETEER_VIEWPORT_HEIGHT:-720} + - PAGE_TIMEOUT=${PUPPETEER_PAGE_TIMEOUT:-30000} + networks: [mcpnet] + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8800/mcp', timeout=5)"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + + sequential-thinking-mcp: + build: + context: ./sequential-thinking-mcp + dockerfile: Dockerfile + container_name: mcp-sequential-thinking + restart: unless-stopped + environment: + - PORT=8900 + networks: [mcpnet] + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8900/mcp', timeout=5)"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + + docker-mcp: + build: + context: ./docker-mcp + dockerfile: Dockerfile + container_name: mcp-docker + restart: unless-stopped + environment: + - PORT=9000 + - DOCKER_HOST=${DOCKER_HOST:-unix:///var/run/docker.sock} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: [mcpnet] + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/mcp', timeout=5)"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + volumes: gateway-data: linkedin-data: + memory-bank-data: diff --git a/docker-mcp/Dockerfile b/docker-mcp/Dockerfile new file mode 100755 index 0000000..90ac381 --- /dev/null +++ b/docker-mcp/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY docker_mcp.py . +COPY entrypoint.py . + +ENV PORT=9000 + +EXPOSE 9000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:9000/mcp', timeout=5)" + +CMD ["python3", "entrypoint.py"] diff --git a/docker-mcp/docker_mcp.py b/docker-mcp/docker_mcp.py new file mode 100755 index 0000000..52f463f --- /dev/null +++ b/docker-mcp/docker_mcp.py @@ -0,0 +1,532 @@ +""" +Docker MCP Server +================= +MCP server providing Docker container and image management capabilities. +Supports listing, inspecting, starting, stopping, removing containers, +managing images, viewing logs, executing commands, and managing networks/volumes. +Connects to the Docker daemon via socket or TCP. +""" + +import json +import os +from typing import Optional, List, Dict, Any + +import docker +from docker.errors import DockerException, NotFound, APIError +from mcp.server.fastmcp import FastMCP + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DOCKER_HOST = os.environ.get("DOCKER_HOST", "unix:///var/run/docker.sock") + +# --------------------------------------------------------------------------- +# MCP Server +# --------------------------------------------------------------------------- + +mcp = FastMCP("docker_mcp") + +# --------------------------------------------------------------------------- +# Docker client +# --------------------------------------------------------------------------- + + +def _client() -> docker.DockerClient: + """Get a Docker client instance.""" + return docker.DockerClient(base_url=DOCKER_HOST, timeout=30) + + +def _safe(func): + """Wrapper for safe Docker API calls.""" + try: + return func() + except NotFound as e: + return {"error": f"Not found: {str(e)}"} + except APIError as e: + return {"error": f"Docker API error: {str(e)}"} + except DockerException as e: + return {"error": f"Docker error: {str(e)}"} + + +# --------------------------------------------------------------------------- +# Container tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def list_containers( + all: bool = True, + filters: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + List Docker containers. + + Args: + all: Show all containers (including stopped). Default True. + filters: Optional filter dict (e.g., {"status": "running", "name": "my-app"}) + """ + client = _client() + containers = client.containers.list(all=all, filters=filters or {}) + + result = [] + for c in containers: + result.append({ + "id": c.short_id, + "name": c.name, + "image": str(c.image.tags[0]) if c.image.tags else str(c.image.id[:12]), + "status": c.status, + "state": c.attrs.get("State", {}).get("Status"), + "created": c.attrs.get("Created"), + "ports": c.ports, + }) + + return {"containers": result, "total": len(result)} + + +@mcp.tool() +async def inspect_container(container_id: str) -> Dict[str, Any]: + """ + Get detailed information about a container. + + Args: + container_id: Container ID or name + """ + client = _client() + try: + c = client.containers.get(container_id) + attrs = c.attrs + return { + "id": c.short_id, + "name": c.name, + "image": str(c.image.tags[0]) if c.image.tags else str(c.image.id[:12]), + "status": c.status, + "state": attrs.get("State", {}), + "config": { + "env": attrs.get("Config", {}).get("Env", []), + "cmd": attrs.get("Config", {}).get("Cmd"), + "entrypoint": attrs.get("Config", {}).get("Entrypoint"), + "working_dir": attrs.get("Config", {}).get("WorkingDir"), + }, + "network": attrs.get("NetworkSettings", {}).get("Networks", {}), + "mounts": attrs.get("Mounts", []), + "ports": c.ports, + "created": attrs.get("Created"), + "restart_count": attrs.get("RestartCount", 0), + } + except NotFound: + return {"error": f"Container '{container_id}' not found"} + + +@mcp.tool() +async def container_logs( + container_id: str, + tail: int = 100, + since: Optional[str] = None, + timestamps: bool = False, +) -> Dict[str, Any]: + """ + Get logs from a container. + + Args: + container_id: Container ID or name + tail: Number of lines from the end (default 100) + since: Show logs since timestamp (e.g., '2024-01-01T00:00:00') + timestamps: Include timestamps in log output + """ + client = _client() + try: + c = client.containers.get(container_id) + kwargs: Dict[str, Any] = { + "tail": tail, + "timestamps": timestamps, + "stdout": True, + "stderr": True, + } + if since: + kwargs["since"] = since + + logs = c.logs(**kwargs) + log_text = logs.decode("utf-8", errors="replace") + + return { + "container": container_id, + "lines": tail, + "logs": log_text, + } + except NotFound: + return {"error": f"Container '{container_id}' not found"} + + +@mcp.tool() +async def start_container(container_id: str) -> Dict[str, Any]: + """ + Start a stopped container. + + Args: + container_id: Container ID or name + """ + client = _client() + try: + c = client.containers.get(container_id) + c.start() + c.reload() + return {"status": "started", "container": c.name, "state": c.status} + except NotFound: + return {"error": f"Container '{container_id}' not found"} + + +@mcp.tool() +async def stop_container( + container_id: str, + timeout: int = 10, +) -> Dict[str, Any]: + """ + Stop a running container. + + Args: + container_id: Container ID or name + timeout: Seconds to wait before killing (default 10) + """ + client = _client() + try: + c = client.containers.get(container_id) + c.stop(timeout=timeout) + c.reload() + return {"status": "stopped", "container": c.name, "state": c.status} + except NotFound: + return {"error": f"Container '{container_id}' not found"} + + +@mcp.tool() +async def restart_container( + container_id: str, + timeout: int = 10, +) -> Dict[str, Any]: + """ + Restart a container. + + Args: + container_id: Container ID or name + timeout: Seconds to wait before killing (default 10) + """ + client = _client() + try: + c = client.containers.get(container_id) + c.restart(timeout=timeout) + c.reload() + return {"status": "restarted", "container": c.name, "state": c.status} + except NotFound: + return {"error": f"Container '{container_id}' not found"} + + +@mcp.tool() +async def remove_container( + container_id: str, + force: bool = False, + v: bool = False, +) -> Dict[str, Any]: + """ + Remove a container. + + Args: + container_id: Container ID or name + force: Force remove even if running + v: Remove associated volumes + """ + client = _client() + try: + c = client.containers.get(container_id) + name = c.name + c.remove(force=force, v=v) + return {"status": "removed", "container": name} + except NotFound: + return {"error": f"Container '{container_id}' not found"} + + +@mcp.tool() +async def exec_in_container( + container_id: str, + command: str, + workdir: Optional[str] = None, +) -> Dict[str, Any]: + """ + Execute a command inside a running container. + + Args: + container_id: Container ID or name + command: Command to execute (shell string) + workdir: Optional working directory inside the container + """ + client = _client() + try: + c = client.containers.get(container_id) + kwargs: Dict[str, Any] = {"cmd": command, "stdout": True, "stderr": True} + if workdir: + kwargs["workdir"] = workdir + + exit_code, output = c.exec_run(**kwargs) + return { + "container": container_id, + "command": command, + "exit_code": exit_code, + "output": output.decode("utf-8", errors="replace"), + } + except NotFound: + return {"error": f"Container '{container_id}' not found"} + + +@mcp.tool() +async def container_stats(container_id: str) -> Dict[str, Any]: + """ + Get resource usage stats for a container. + + Args: + container_id: Container ID or name + """ + client = _client() + try: + c = client.containers.get(container_id) + stats = c.stats(stream=False) + + # Parse CPU + cpu_delta = stats["cpu_stats"]["cpu_usage"]["total_usage"] - \ + stats["precpu_stats"]["cpu_usage"]["total_usage"] + system_delta = stats["cpu_stats"]["system_cpu_usage"] - \ + stats["precpu_stats"]["system_cpu_usage"] + num_cpus = len(stats["cpu_stats"]["cpu_usage"].get("percpu_usage", [1])) + cpu_percent = (cpu_delta / system_delta) * num_cpus * 100.0 if system_delta > 0 else 0.0 + + # Parse Memory + mem_usage = stats["memory_stats"].get("usage", 0) + mem_limit = stats["memory_stats"].get("limit", 1) + mem_percent = (mem_usage / mem_limit) * 100.0 + + return { + "container": container_id, + "cpu_percent": round(cpu_percent, 2), + "memory_usage_mb": round(mem_usage / (1024 * 1024), 2), + "memory_limit_mb": round(mem_limit / (1024 * 1024), 2), + "memory_percent": round(mem_percent, 2), + "network_rx_bytes": sum( + v.get("rx_bytes", 0) for v in stats.get("networks", {}).values() + ), + "network_tx_bytes": sum( + v.get("tx_bytes", 0) for v in stats.get("networks", {}).values() + ), + "pids": stats.get("pids_stats", {}).get("current", 0), + } + except NotFound: + return {"error": f"Container '{container_id}' not found"} + except (KeyError, ZeroDivisionError) as e: + return {"error": f"Failed to parse stats: {str(e)}"} + + +# --------------------------------------------------------------------------- +# Image tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def list_images( + name: Optional[str] = None, + all: bool = False, +) -> Dict[str, Any]: + """ + List Docker images. + + Args: + name: Optional filter by image name + all: Show all images including intermediate layers + """ + client = _client() + images = client.images.list(name=name, all=all) + + result = [] + for img in images: + result.append({ + "id": img.short_id, + "tags": img.tags, + "size_mb": round(img.attrs.get("Size", 0) / (1024 * 1024), 2), + "created": img.attrs.get("Created"), + }) + + return {"images": result, "total": len(result)} + + +@mcp.tool() +async def pull_image( + image: str, + tag: str = "latest", +) -> Dict[str, Any]: + """ + Pull a Docker image from a registry. + + Args: + image: Image name (e.g., 'nginx', 'python') + tag: Image tag (default: 'latest') + """ + client = _client() + try: + img = client.images.pull(image, tag=tag) + return { + "status": "pulled", + "image": f"{image}:{tag}", + "id": img.short_id, + "size_mb": round(img.attrs.get("Size", 0) / (1024 * 1024), 2), + } + except APIError as e: + return {"error": f"Failed to pull {image}:{tag}: {str(e)}"} + + +@mcp.tool() +async def remove_image( + image: str, + force: bool = False, +) -> Dict[str, Any]: + """ + Remove a Docker image. + + Args: + image: Image ID or name:tag + force: Force removal + """ + client = _client() + try: + client.images.remove(image, force=force) + return {"status": "removed", "image": image} + except NotFound: + return {"error": f"Image '{image}' not found"} + + +# --------------------------------------------------------------------------- +# System tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def docker_system_info() -> Dict[str, Any]: + """Get Docker system-wide information.""" + client = _client() + info = client.info() + return { + "docker_version": info.get("ServerVersion"), + "os": info.get("OperatingSystem"), + "arch": info.get("Architecture"), + "cpus": info.get("NCPU"), + "memory_gb": round(info.get("MemTotal", 0) / (1024**3), 2), + "containers_running": info.get("ContainersRunning"), + "containers_stopped": info.get("ContainersStopped"), + "containers_paused": info.get("ContainersPaused"), + "images": info.get("Images"), + "storage_driver": info.get("Driver"), + } + + +@mcp.tool() +async def docker_disk_usage() -> Dict[str, Any]: + """Get Docker disk usage summary.""" + client = _client() + df = client.df() + + containers_size = sum(c.get("SizeRw", 0) for c in df.get("Containers", [])) + images_size = sum(i.get("Size", 0) for i in df.get("Images", [])) + volumes_size = sum(v.get("UsageData", {}).get("Size", 0) for v in df.get("Volumes", [])) + + return { + "containers_size_mb": round(containers_size / (1024 * 1024), 2), + "images_size_mb": round(images_size / (1024 * 1024), 2), + "volumes_size_mb": round(volumes_size / (1024 * 1024), 2), + "total_mb": round((containers_size + images_size + volumes_size) / (1024 * 1024), 2), + "images_count": len(df.get("Images", [])), + "containers_count": len(df.get("Containers", [])), + "volumes_count": len(df.get("Volumes", [])), + } + + +# --------------------------------------------------------------------------- +# Network tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def list_networks() -> Dict[str, Any]: + """List Docker networks.""" + client = _client() + networks = client.networks.list() + + result = [] + for n in networks: + result.append({ + "id": n.short_id, + "name": n.name, + "driver": n.attrs.get("Driver"), + "scope": n.attrs.get("Scope"), + "containers": len(n.attrs.get("Containers", {})), + }) + + return {"networks": result, "total": len(result)} + + +# --------------------------------------------------------------------------- +# Volume tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def list_volumes() -> Dict[str, Any]: + """List Docker volumes.""" + client = _client() + volumes = client.volumes.list() + + result = [] + for v in volumes: + result.append({ + "name": v.name, + "driver": v.attrs.get("Driver"), + "mountpoint": v.attrs.get("Mountpoint"), + "created": v.attrs.get("CreatedAt"), + }) + + return {"volumes": result, "total": len(result)} + + +@mcp.tool() +async def prune_system( + containers: bool = True, + images: bool = True, + volumes: bool = False, + networks: bool = True, +) -> Dict[str, Any]: + """ + Prune unused Docker resources. + + Args: + containers: Prune stopped containers + images: Prune dangling images + volumes: Prune unused volumes (CAUTION: data loss) + networks: Prune unused networks + """ + client = _client() + results = {} + + if containers: + r = client.containers.prune() + results["containers_deleted"] = len(r.get("ContainersDeleted", []) or []) + results["containers_space_mb"] = round(r.get("SpaceReclaimed", 0) / (1024 * 1024), 2) + + if images: + r = client.images.prune() + results["images_deleted"] = len(r.get("ImagesDeleted", []) or []) + results["images_space_mb"] = round(r.get("SpaceReclaimed", 0) / (1024 * 1024), 2) + + if volumes: + r = client.volumes.prune() + results["volumes_deleted"] = len(r.get("VolumesDeleted", []) or []) + results["volumes_space_mb"] = round(r.get("SpaceReclaimed", 0) / (1024 * 1024), 2) + + if networks: + r = client.networks.prune() + results["networks_deleted"] = len(r.get("NetworksDeleted", []) or []) + + return results diff --git a/docker-mcp/entrypoint.py b/docker-mcp/entrypoint.py new file mode 100755 index 0000000..050c693 --- /dev/null +++ b/docker-mcp/entrypoint.py @@ -0,0 +1,12 @@ +import os +from docker_mcp import mcp +from mcp.server.fastmcp.server import TransportSecuritySettings + +mcp.settings.host = "0.0.0.0" +mcp.settings.port = int(os.environ.get("PORT", "9000")) +mcp.settings.transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=False, +) + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/docker-mcp/requirements.txt b/docker-mcp/requirements.txt new file mode 100755 index 0000000..a62204e --- /dev/null +++ b/docker-mcp/requirements.txt @@ -0,0 +1,6 @@ +mcp[cli]>=1.0.0 +httpx>=0.27.0 +pydantic>=2.0.0 +uvicorn>=0.30.0 +starlette>=0.38.0 +docker>=7.0.0 diff --git a/memory-bank-mcp/Dockerfile b/memory-bank-mcp/Dockerfile new file mode 100755 index 0000000..9a28775 --- /dev/null +++ b/memory-bank-mcp/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY memory_bank_mcp.py . +COPY entrypoint.py . + +ENV PORT=8700 + +EXPOSE 8700 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8700/mcp', timeout=5)" + +CMD ["python3", "entrypoint.py"] diff --git a/memory-bank-mcp/entrypoint.py b/memory-bank-mcp/entrypoint.py new file mode 100755 index 0000000..1d6f8af --- /dev/null +++ b/memory-bank-mcp/entrypoint.py @@ -0,0 +1,12 @@ +import os +from memory_bank_mcp import mcp +from mcp.server.fastmcp.server import TransportSecuritySettings + +mcp.settings.host = "0.0.0.0" +mcp.settings.port = int(os.environ.get("PORT", "8700")) +mcp.settings.transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=False, +) + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/memory-bank-mcp/memory_bank_mcp.py b/memory-bank-mcp/memory_bank_mcp.py new file mode 100755 index 0000000..1979a03 --- /dev/null +++ b/memory-bank-mcp/memory_bank_mcp.py @@ -0,0 +1,353 @@ +""" +Memory Bank MCP Server +====================== +MCP server providing persistent memory storage for LLM conversations. +Stores and retrieves key-value memories with metadata, tags, and +semantic search capabilities. Backed by a local JSON file store. +""" + +import json +import os +import time +import hashlib +from pathlib import Path +from typing import Optional, List, Any, Dict +from datetime import datetime, timezone + +from mcp.server.fastmcp import FastMCP + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +MEMORY_DIR = os.environ.get("MEMORY_DIR", "/data/memories") +MAX_MEMORIES = int(os.environ.get("MAX_MEMORIES", "10000")) + +# --------------------------------------------------------------------------- +# MCP Server +# --------------------------------------------------------------------------- + +mcp = FastMCP("memory_bank_mcp") + +# --------------------------------------------------------------------------- +# Storage helpers +# --------------------------------------------------------------------------- + + +def _ensure_dir(): + """Ensure the memory directory exists.""" + Path(MEMORY_DIR).mkdir(parents=True, exist_ok=True) + + +def _index_path() -> str: + return os.path.join(MEMORY_DIR, "_index.json") + + +def _load_index() -> Dict[str, Any]: + """Load the memory index.""" + _ensure_dir() + idx_path = _index_path() + if os.path.exists(idx_path): + with open(idx_path, "r") as f: + return json.load(f) + return {"memories": {}, "tags": {}} + + +def _save_index(index: Dict[str, Any]): + """Save the memory index.""" + _ensure_dir() + with open(_index_path(), "w") as f: + json.dump(index, f, indent=2) + + +def _memory_path(memory_id: str) -> str: + return os.path.join(MEMORY_DIR, f"{memory_id}.json") + + +def _generate_id(content: str) -> str: + """Generate a deterministic ID from content.""" + return hashlib.sha256(content.encode()).hexdigest()[:16] + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def store_memory( + key: str, + content: str, + tags: Optional[List[str]] = None, + category: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Store a memory with a key, content, optional tags, category, and metadata. + If a memory with the same key exists, it will be updated. + + Args: + key: Unique key/name for this memory + content: The content to remember + tags: Optional list of tags for categorization + category: Optional category (e.g., 'user_preference', 'fact', 'context') + metadata: Optional additional metadata dict + """ + index = _load_index() + now = datetime.now(timezone.utc).isoformat() + + memory = { + "key": key, + "content": content, + "tags": tags or [], + "category": category or "general", + "metadata": metadata or {}, + "created_at": now, + "updated_at": now, + "access_count": 0, + } + + # Update if exists + if key in index["memories"]: + existing = _load_memory_file(key) + if existing: + memory["created_at"] = existing.get("created_at", now) + memory["access_count"] = existing.get("access_count", 0) + + # Save memory file + _ensure_dir() + with open(_memory_path(key), "w") as f: + json.dump(memory, f, indent=2) + + # Update index + index["memories"][key] = { + "category": memory["category"], + "tags": memory["tags"], + "updated_at": now, + } + + # Update tag index + for tag in memory["tags"]: + if tag not in index["tags"]: + index["tags"][tag] = [] + if key not in index["tags"][tag]: + index["tags"][tag].append(key) + + _save_index(index) + + return {"status": "stored", "key": key, "updated_at": now} + + +def _load_memory_file(key: str) -> Optional[Dict[str, Any]]: + """Load a memory file by key.""" + path = _memory_path(key) + if os.path.exists(path): + with open(path, "r") as f: + return json.load(f) + return None + + +@mcp.tool() +async def recall_memory(key: str) -> Dict[str, Any]: + """ + Retrieve a specific memory by its key. + + Args: + key: The key of the memory to retrieve + """ + memory = _load_memory_file(key) + if not memory: + return {"error": f"Memory '{key}' not found"} + + # Update access count + memory["access_count"] = memory.get("access_count", 0) + 1 + memory["last_accessed"] = datetime.now(timezone.utc).isoformat() + with open(_memory_path(key), "w") as f: + json.dump(memory, f, indent=2) + + return memory + + +@mcp.tool() +async def search_memories( + query: Optional[str] = None, + tags: Optional[List[str]] = None, + category: Optional[str] = None, + limit: int = 20, +) -> Dict[str, Any]: + """ + Search memories by text query, tags, or category. + + Args: + query: Optional text to search for in memory content and keys + tags: Optional list of tags to filter by (memories must have ALL tags) + category: Optional category to filter by + limit: Maximum results to return (default 20) + """ + index = _load_index() + results = [] + + # Get candidate keys + candidate_keys = set(index["memories"].keys()) + + # Filter by tags + if tags: + for tag in tags: + tag_keys = set(index["tags"].get(tag, [])) + candidate_keys &= tag_keys + + # Filter by category + if category: + candidate_keys = { + k for k in candidate_keys + if index["memories"].get(k, {}).get("category") == category + } + + # Load and search + for key in candidate_keys: + memory = _load_memory_file(key) + if not memory: + continue + + if query: + query_lower = query.lower() + if ( + query_lower not in memory.get("content", "").lower() + and query_lower not in memory.get("key", "").lower() + and not any(query_lower in t.lower() for t in memory.get("tags", [])) + ): + continue + + results.append({ + "key": memory["key"], + "content": memory["content"][:200] + ("..." if len(memory.get("content", "")) > 200 else ""), + "category": memory.get("category"), + "tags": memory.get("tags", []), + "updated_at": memory.get("updated_at"), + "access_count": memory.get("access_count", 0), + }) + + # Sort by most recently updated + results.sort(key=lambda x: x.get("updated_at", ""), reverse=True) + + return { + "total": len(results), + "results": results[:limit], + } + + +@mcp.tool() +async def delete_memory(key: str) -> Dict[str, Any]: + """ + Delete a specific memory by key. + + Args: + key: The key of the memory to delete + """ + index = _load_index() + + if key not in index["memories"]: + return {"error": f"Memory '{key}' not found"} + + # Remove from tag index + mem_info = index["memories"][key] + for tag in mem_info.get("tags", []): + if tag in index["tags"] and key in index["tags"][tag]: + index["tags"][tag].remove(key) + if not index["tags"][tag]: + del index["tags"][tag] + + # Remove from index + del index["memories"][key] + _save_index(index) + + # Remove file + path = _memory_path(key) + if os.path.exists(path): + os.remove(path) + + return {"status": "deleted", "key": key} + + +@mcp.tool() +async def list_memories( + category: Optional[str] = None, + limit: int = 50, + offset: int = 0, +) -> Dict[str, Any]: + """ + List all stored memories with optional category filter. + + Args: + category: Optional category to filter by + limit: Maximum results (default 50) + offset: Skip first N results (default 0) + """ + index = _load_index() + memories = [] + + for key, info in index["memories"].items(): + if category and info.get("category") != category: + continue + memories.append({ + "key": key, + "category": info.get("category"), + "tags": info.get("tags", []), + "updated_at": info.get("updated_at"), + }) + + memories.sort(key=lambda x: x.get("updated_at", ""), reverse=True) + total = len(memories) + + return { + "total": total, + "offset": offset, + "limit": limit, + "memories": memories[offset:offset + limit], + } + + +@mcp.tool() +async def get_memory_stats() -> Dict[str, Any]: + """Get statistics about the memory bank.""" + index = _load_index() + + categories = {} + for info in index["memories"].values(): + cat = info.get("category", "general") + categories[cat] = categories.get(cat, 0) + 1 + + return { + "total_memories": len(index["memories"]), + "total_tags": len(index["tags"]), + "categories": categories, + "max_memories": MAX_MEMORIES, + } + + +@mcp.tool() +async def bulk_store_memories( + memories: List[Dict[str, Any]], +) -> Dict[str, Any]: + """ + Store multiple memories at once. + + Args: + memories: List of memory objects, each with 'key', 'content', and optional 'tags', 'category', 'metadata' + """ + results = [] + for mem in memories: + result = await store_memory( + key=mem["key"], + content=mem["content"], + tags=mem.get("tags"), + category=mem.get("category"), + metadata=mem.get("metadata"), + ) + results.append(result) + + return { + "status": "bulk_stored", + "count": len(results), + "results": results, + } diff --git a/memory-bank-mcp/requirements.txt b/memory-bank-mcp/requirements.txt new file mode 100755 index 0000000..02077d4 --- /dev/null +++ b/memory-bank-mcp/requirements.txt @@ -0,0 +1,6 @@ +mcp[cli]>=1.0.0 +httpx>=0.27.0 +pydantic>=2.0.0 +uvicorn>=0.30.0 +starlette>=0.38.0 +aiofiles>=24.1.0 diff --git a/puppeteer-mcp/Dockerfile b/puppeteer-mcp/Dockerfile new file mode 100755 index 0000000..0b424b1 --- /dev/null +++ b/puppeteer-mcp/Dockerfile @@ -0,0 +1,37 @@ +FROM python:3.12-slim-bookworm + +# Install Chromium and dependencies for headless browser +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + chromium-sandbox \ + fonts-liberation \ + libnss3 \ + libatk-bridge2.0-0 \ + libdrm2 \ + libxkbcommon0 \ + libgbm1 \ + libasound2 \ + libatspi2.0-0 \ + libgtk-3-0 \ + && rm -rf /var/lib/apt/lists/* + +# Point pyppeteer to system Chromium +ENV PYPPETEER_CHROMIUM_REVISION=0 +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY puppeteer_mcp.py . +COPY entrypoint.py . + +ENV PORT=8800 + +EXPOSE 8800 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8800/mcp', timeout=5)" + +CMD ["python3", "entrypoint.py"] diff --git a/puppeteer-mcp/entrypoint.py b/puppeteer-mcp/entrypoint.py new file mode 100755 index 0000000..60ca29b --- /dev/null +++ b/puppeteer-mcp/entrypoint.py @@ -0,0 +1,12 @@ +import os +from puppeteer_mcp import mcp +from mcp.server.fastmcp.server import TransportSecuritySettings + +mcp.settings.host = "0.0.0.0" +mcp.settings.port = int(os.environ.get("PORT", "8800")) +mcp.settings.transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=False, +) + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/puppeteer-mcp/puppeteer_mcp.py b/puppeteer-mcp/puppeteer_mcp.py new file mode 100755 index 0000000..e1b07b7 --- /dev/null +++ b/puppeteer-mcp/puppeteer_mcp.py @@ -0,0 +1,335 @@ +""" +Puppeteer MCP Server +==================== +MCP server providing headless browser automation via Pyppeteer. +Supports navigation, screenshots, page content extraction, form filling, +clicking elements, JavaScript evaluation, and PDF generation. +""" + +import asyncio +import base64 +import json +import os +from typing import Optional, List, Dict, Any + +from mcp.server.fastmcp import FastMCP + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +CHROMIUM_ARGS = os.environ.get("CHROMIUM_ARGS", "--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu").split(",") +DEFAULT_VIEWPORT_WIDTH = int(os.environ.get("VIEWPORT_WIDTH", "1280")) +DEFAULT_VIEWPORT_HEIGHT = int(os.environ.get("VIEWPORT_HEIGHT", "720")) +DEFAULT_TIMEOUT = int(os.environ.get("PAGE_TIMEOUT", "30000")) + +# --------------------------------------------------------------------------- +# MCP Server +# --------------------------------------------------------------------------- + +mcp = FastMCP("puppeteer_mcp") + +# --------------------------------------------------------------------------- +# Browser management +# --------------------------------------------------------------------------- + +_browser = None +_pages: Dict[str, Any] = {} + + +async def _get_browser(): + """Get or create the browser instance.""" + global _browser + if _browser is None or not _browser.process: + from pyppeteer import launch + _browser = await launch( + headless=True, + args=CHROMIUM_ARGS, + handleSIGINT=False, + handleSIGTERM=False, + handleSIGHUP=False, + ) + return _browser + + +async def _get_page(page_id: str = "default") -> Any: + """Get or create a page by ID.""" + global _pages + browser = await _get_browser() + + if page_id not in _pages or _pages[page_id].isClosed(): + page = await browser.newPage() + await page.setViewport({ + "width": DEFAULT_VIEWPORT_WIDTH, + "height": DEFAULT_VIEWPORT_HEIGHT, + }) + _pages[page_id] = page + + return _pages[page_id] + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def navigate( + url: str, + page_id: str = "default", + wait_until: str = "networkidle2", +) -> Dict[str, Any]: + """ + Navigate to a URL in the browser. + + Args: + url: The URL to navigate to + page_id: Identifier for the browser tab/page (default: "default") + wait_until: When to consider navigation complete: 'load', 'domcontentloaded', 'networkidle0', 'networkidle2' + """ + page = await _get_page(page_id) + response = await page.goto(url, waitUntil=wait_until, timeout=DEFAULT_TIMEOUT) + + return { + "status": "navigated", + "url": page.url, + "status_code": response.status if response else None, + "title": await page.title(), + } + + +@mcp.tool() +async def screenshot( + page_id: str = "default", + full_page: bool = False, + selector: Optional[str] = None, +) -> Dict[str, Any]: + """ + Take a screenshot of the current page or a specific element. + + Args: + page_id: Page identifier (default: "default") + full_page: Whether to capture the full scrollable page + selector: Optional CSS selector to screenshot a specific element + """ + page = await _get_page(page_id) + + options: Dict[str, Any] = {"encoding": "binary"} + + if selector: + element = await page.querySelector(selector) + if not element: + return {"error": f"Element not found: {selector}"} + screenshot_bytes = await element.screenshot() + else: + options["fullPage"] = full_page + screenshot_bytes = await page.screenshot(options) + + b64 = base64.b64encode(screenshot_bytes).decode("utf-8") + + return { + "status": "screenshot_taken", + "format": "png", + "base64": b64, + "url": page.url, + } + + +@mcp.tool() +async def get_page_content( + page_id: str = "default", + content_type: str = "text", +) -> Dict[str, Any]: + """ + Get the content of the current page. + + Args: + page_id: Page identifier (default: "default") + content_type: 'text' for visible text, 'html' for full HTML source + """ + page = await _get_page(page_id) + + if content_type == "html": + content = await page.content() + else: + content = await page.evaluate("() => document.body.innerText") + + return { + "url": page.url, + "title": await page.title(), + "content_type": content_type, + "content": content[:50000], # Limit to 50k chars + "truncated": len(content) > 50000, + } + + +@mcp.tool() +async def click( + selector: str, + page_id: str = "default", +) -> Dict[str, Any]: + """ + Click an element on the page. + + Args: + selector: CSS selector for the element to click + page_id: Page identifier (default: "default") + """ + page = await _get_page(page_id) + + try: + await page.waitForSelector(selector, timeout=5000) + await page.click(selector) + await asyncio.sleep(0.5) # Brief wait for any navigation/updates + return { + "status": "clicked", + "selector": selector, + "url": page.url, + } + except Exception as e: + return {"error": f"Failed to click '{selector}': {str(e)}"} + + +@mcp.tool() +async def type_text( + selector: str, + text: str, + page_id: str = "default", + delay: int = 50, +) -> Dict[str, Any]: + """ + Type text into an input field. + + Args: + selector: CSS selector for the input element + text: Text to type + page_id: Page identifier (default: "default") + delay: Delay between key presses in ms (default: 50) + """ + page = await _get_page(page_id) + + try: + await page.waitForSelector(selector, timeout=5000) + await page.type(selector, text, delay=delay) + return { + "status": "typed", + "selector": selector, + "text_length": len(text), + } + except Exception as e: + return {"error": f"Failed to type into '{selector}': {str(e)}"} + + +@mcp.tool() +async def evaluate_javascript( + script: str, + page_id: str = "default", +) -> Dict[str, Any]: + """ + Execute JavaScript code in the browser context. + + Args: + script: JavaScript code to evaluate + page_id: Page identifier (default: "default") + """ + page = await _get_page(page_id) + + try: + result = await page.evaluate(script) + return { + "status": "evaluated", + "result": result, + } + except Exception as e: + return {"error": f"JS evaluation failed: {str(e)}"} + + +@mcp.tool() +async def generate_pdf( + page_id: str = "default", + format: str = "A4", + landscape: bool = False, + print_background: bool = True, +) -> Dict[str, Any]: + """ + Generate a PDF of the current page. + + Args: + page_id: Page identifier (default: "default") + format: Paper format: 'A4', 'Letter', 'Legal', etc. + landscape: Whether to use landscape orientation + print_background: Whether to print background graphics + """ + page = await _get_page(page_id) + + try: + pdf_bytes = await page.pdf({ + "format": format, + "landscape": landscape, + "printBackground": print_background, + }) + b64 = base64.b64encode(pdf_bytes).decode("utf-8") + return { + "status": "pdf_generated", + "format": format, + "base64": b64, + "url": page.url, + } + except Exception as e: + return {"error": f"PDF generation failed: {str(e)}"} + + +@mcp.tool() +async def wait_for_selector( + selector: str, + page_id: str = "default", + timeout: int = 10000, + visible: bool = True, +) -> Dict[str, Any]: + """ + Wait for an element to appear on the page. + + Args: + selector: CSS selector to wait for + page_id: Page identifier (default: "default") + timeout: Maximum wait time in ms (default: 10000) + visible: Whether element must be visible (default: True) + """ + page = await _get_page(page_id) + + try: + await page.waitForSelector( + selector, timeout=timeout, visible=visible + ) + return {"status": "found", "selector": selector} + except Exception as e: + return {"error": f"Timeout waiting for '{selector}': {str(e)}"} + + +@mcp.tool() +async def list_pages() -> Dict[str, Any]: + """List all open browser pages/tabs.""" + result = {} + for pid, page in _pages.items(): + if not page.isClosed(): + result[pid] = { + "url": page.url, + "title": await page.title(), + } + return {"pages": result, "count": len(result)} + + +@mcp.tool() +async def close_page(page_id: str = "default") -> Dict[str, Any]: + """ + Close a browser page/tab. + + Args: + page_id: Page identifier to close (default: "default") + """ + if page_id in _pages: + if not _pages[page_id].isClosed(): + await _pages[page_id].close() + del _pages[page_id] + return {"status": "closed", "page_id": page_id} + return {"error": f"Page '{page_id}' not found"} diff --git a/puppeteer-mcp/requirements.txt b/puppeteer-mcp/requirements.txt new file mode 100755 index 0000000..ffc8c1e --- /dev/null +++ b/puppeteer-mcp/requirements.txt @@ -0,0 +1,6 @@ +mcp[cli]>=1.0.0 +httpx>=0.27.0 +pydantic>=2.0.0 +uvicorn>=0.30.0 +starlette>=0.38.0 +pyppeteer>=2.0.0 diff --git a/sequential-thinking-mcp/Dockerfile b/sequential-thinking-mcp/Dockerfile new file mode 100755 index 0000000..2cb21de --- /dev/null +++ b/sequential-thinking-mcp/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY sequential_thinking_mcp.py . +COPY entrypoint.py . + +ENV PORT=8900 + +EXPOSE 8900 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8900/mcp', timeout=5)" + +CMD ["python3", "entrypoint.py"] diff --git a/sequential-thinking-mcp/entrypoint.py b/sequential-thinking-mcp/entrypoint.py new file mode 100755 index 0000000..14d673b --- /dev/null +++ b/sequential-thinking-mcp/entrypoint.py @@ -0,0 +1,12 @@ +import os +from sequential_thinking_mcp import mcp +from mcp.server.fastmcp.server import TransportSecuritySettings + +mcp.settings.host = "0.0.0.0" +mcp.settings.port = int(os.environ.get("PORT", "8900")) +mcp.settings.transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=False, +) + +if __name__ == "__main__": + mcp.run(transport="streamable-http") diff --git a/sequential-thinking-mcp/requirements.txt b/sequential-thinking-mcp/requirements.txt new file mode 100755 index 0000000..977d3df --- /dev/null +++ b/sequential-thinking-mcp/requirements.txt @@ -0,0 +1,4 @@ +mcp[cli]>=1.0.0 +pydantic>=2.0.0 +uvicorn>=0.30.0 +starlette>=0.38.0 diff --git a/sequential-thinking-mcp/sequential_thinking_mcp.py b/sequential-thinking-mcp/sequential_thinking_mcp.py new file mode 100755 index 0000000..e7f782f --- /dev/null +++ b/sequential-thinking-mcp/sequential_thinking_mcp.py @@ -0,0 +1,376 @@ +""" +Sequential Thinking MCP Server +=============================== +MCP server that provides structured, step-by-step thinking tools for +complex problem solving. Supports creating thinking chains, branching +hypotheses, revising earlier steps, and synthesizing conclusions. +Helps LLMs reason through multi-step problems methodically. +""" + +import json +import os +import time +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone +from enum import Enum + +from mcp.server.fastmcp import FastMCP + +# --------------------------------------------------------------------------- +# MCP Server +# --------------------------------------------------------------------------- + +mcp = FastMCP("sequential_thinking_mcp") + +# --------------------------------------------------------------------------- +# In-memory session storage +# --------------------------------------------------------------------------- + +_sessions: Dict[str, Dict[str, Any]] = {} + + +class ThoughtType(str, Enum): + OBSERVATION = "observation" + HYPOTHESIS = "hypothesis" + ANALYSIS = "analysis" + CONCLUSION = "conclusion" + REVISION = "revision" + QUESTION = "question" + EVIDENCE = "evidence" + COUNTER_ARGUMENT = "counter_argument" + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def start_thinking_session( + topic: str, + context: Optional[str] = None, + session_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Start a new sequential thinking session for a topic. + + Args: + topic: The main topic or problem to think through + context: Optional background context or constraints + session_id: Optional custom session ID (auto-generated if not provided) + """ + sid = session_id or f"think_{int(time.time() * 1000)}" + now = datetime.now(timezone.utc).isoformat() + + _sessions[sid] = { + "session_id": sid, + "topic": topic, + "context": context, + "created_at": now, + "updated_at": now, + "thoughts": [], + "branches": {}, + "status": "active", + "conclusion": None, + } + + return { + "session_id": sid, + "topic": topic, + "status": "active", + "message": "Thinking session started. Add thoughts with add_thought.", + } + + +@mcp.tool() +async def add_thought( + session_id: str, + thought: str, + thought_type: str = "analysis", + confidence: Optional[float] = None, + references_steps: Optional[List[int]] = None, + branch: Optional[str] = None, +) -> Dict[str, Any]: + """ + Add a thought/reasoning step to a thinking session. + + Args: + session_id: The thinking session ID + thought: The thought or reasoning content + thought_type: Type of thought: 'observation', 'hypothesis', 'analysis', 'conclusion', 'revision', 'question', 'evidence', 'counter_argument' + confidence: Optional confidence level 0.0-1.0 + references_steps: Optional list of step numbers this thought builds on + branch: Optional branch name for alternative reasoning paths + """ + if session_id not in _sessions: + return {"error": f"Session '{session_id}' not found"} + + session = _sessions[session_id] + now = datetime.now(timezone.utc).isoformat() + + # Determine step number + if branch: + if branch not in session["branches"]: + session["branches"][branch] = [] + step_num = len(session["branches"][branch]) + 1 + target_list = session["branches"][branch] + else: + step_num = len(session["thoughts"]) + 1 + target_list = session["thoughts"] + + thought_entry = { + "step": step_num, + "thought": thought, + "type": thought_type, + "confidence": confidence, + "references": references_steps or [], + "branch": branch, + "timestamp": now, + } + + target_list.append(thought_entry) + session["updated_at"] = now + + return { + "session_id": session_id, + "step": step_num, + "branch": branch, + "type": thought_type, + "total_steps": len(session["thoughts"]), + "total_branches": len(session["branches"]), + } + + +@mcp.tool() +async def revise_thought( + session_id: str, + step_number: int, + revised_thought: str, + reason: str, + branch: Optional[str] = None, +) -> Dict[str, Any]: + """ + Revise an earlier thought step with new reasoning. + + Args: + session_id: The thinking session ID + step_number: The step number to revise + revised_thought: The new thought content + reason: Reason for the revision + branch: Optional branch name if revising a branched thought + """ + if session_id not in _sessions: + return {"error": f"Session '{session_id}' not found"} + + session = _sessions[session_id] + + if branch: + target_list = session["branches"].get(branch, []) + else: + target_list = session["thoughts"] + + # Find the step + for entry in target_list: + if entry["step"] == step_number: + entry["original_thought"] = entry["thought"] + entry["thought"] = revised_thought + entry["revision_reason"] = reason + entry["revised_at"] = datetime.now(timezone.utc).isoformat() + + # Also add a revision note to the main chain + await add_thought( + session_id=session_id, + thought=f"[Revision of step {step_number}] {reason}: {revised_thought}", + thought_type="revision", + references_steps=[step_number], + branch=branch, + ) + + return { + "status": "revised", + "step": step_number, + "reason": reason, + } + + return {"error": f"Step {step_number} not found"} + + +@mcp.tool() +async def get_thinking_chain( + session_id: str, + branch: Optional[str] = None, + include_revisions: bool = True, +) -> Dict[str, Any]: + """ + Get the full chain of thoughts for a session. + + Args: + session_id: The thinking session ID + branch: Optional branch name to get (None for main chain) + include_revisions: Whether to include revision history + """ + if session_id not in _sessions: + return {"error": f"Session '{session_id}' not found"} + + session = _sessions[session_id] + + if branch: + thoughts = session["branches"].get(branch, []) + else: + thoughts = session["thoughts"] + + if not include_revisions: + thoughts = [ + {k: v for k, v in t.items() if k not in ("original_thought", "revision_reason", "revised_at")} + for t in thoughts + ] + + return { + "session_id": session_id, + "topic": session["topic"], + "context": session["context"], + "branch": branch, + "thoughts": thoughts, + "total_steps": len(thoughts), + "branches_available": list(session["branches"].keys()), + "status": session["status"], + "conclusion": session["conclusion"], + } + + +@mcp.tool() +async def synthesize_conclusion( + session_id: str, + conclusion: str, + confidence: Optional[float] = None, + key_insights: Optional[List[str]] = None, +) -> Dict[str, Any]: + """ + Synthesize a conclusion from the thinking chain. + + Args: + session_id: The thinking session ID + conclusion: The synthesized conclusion + confidence: Overall confidence level 0.0-1.0 + key_insights: Optional list of key insights from the thinking process + """ + if session_id not in _sessions: + return {"error": f"Session '{session_id}' not found"} + + session = _sessions[session_id] + now = datetime.now(timezone.utc).isoformat() + + session["conclusion"] = { + "text": conclusion, + "confidence": confidence, + "key_insights": key_insights or [], + "concluded_at": now, + "based_on_steps": len(session["thoughts"]), + "branches_considered": list(session["branches"].keys()), + } + session["status"] = "concluded" + session["updated_at"] = now + + # Add conclusion as final thought + await add_thought( + session_id=session_id, + thought=conclusion, + thought_type="conclusion", + confidence=confidence, + ) + + return { + "session_id": session_id, + "status": "concluded", + "conclusion": session["conclusion"], + } + + +@mcp.tool() +async def compare_branches( + session_id: str, + branch_names: Optional[List[str]] = None, +) -> Dict[str, Any]: + """ + Compare different reasoning branches in a session. + + Args: + session_id: The thinking session ID + branch_names: Optional list of branches to compare (all if not specified) + """ + if session_id not in _sessions: + return {"error": f"Session '{session_id}' not found"} + + session = _sessions[session_id] + branches = branch_names or list(session["branches"].keys()) + + comparison = { + "main_chain": { + "steps": len(session["thoughts"]), + "types": _count_types(session["thoughts"]), + "avg_confidence": _avg_confidence(session["thoughts"]), + } + } + + for branch in branches: + if branch in session["branches"]: + thoughts = session["branches"][branch] + comparison[branch] = { + "steps": len(thoughts), + "types": _count_types(thoughts), + "avg_confidence": _avg_confidence(thoughts), + } + + return { + "session_id": session_id, + "comparison": comparison, + } + + +def _count_types(thoughts: List[Dict]) -> Dict[str, int]: + counts: Dict[str, int] = {} + for t in thoughts: + tt = t.get("type", "unknown") + counts[tt] = counts.get(tt, 0) + 1 + return counts + + +def _avg_confidence(thoughts: List[Dict]) -> Optional[float]: + confs = [t["confidence"] for t in thoughts if t.get("confidence") is not None] + if not confs: + return None + return round(sum(confs) / len(confs), 3) + + +@mcp.tool() +async def list_sessions( + status: Optional[str] = None, + limit: int = 20, +) -> Dict[str, Any]: + """ + List all thinking sessions. + + Args: + status: Optional filter by status: 'active' or 'concluded' + limit: Maximum sessions to return + """ + sessions = [] + for sid, session in _sessions.items(): + if status and session["status"] != status: + continue + sessions.append({ + "session_id": sid, + "topic": session["topic"], + "status": session["status"], + "total_steps": len(session["thoughts"]), + "branches": len(session["branches"]), + "created_at": session["created_at"], + "updated_at": session["updated_at"], + }) + + sessions.sort(key=lambda x: x["updated_at"], reverse=True) + + return { + "total": len(sessions), + "sessions": sessions[:limit], + }