From b4ac3b9968ee459e0f2892b25323698f43891b6c Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 9 May 2026 22:18:00 -0500 Subject: [PATCH] Scaffold and Phase 0-1 --- .dockerignore | 14 + .env.example | 115 +++++ .gitignore | 23 + AGENT-SUITE.md | 47 ++ CHANGELOG.md | 21 + Dockerfile | 22 + PLAN.md | 843 ++++++++++++++++++++++++++++++++++++ README.md | 138 ++++-- SERVICES.md | 669 ++++++++++++++++++++++++++++ docker-compose.yml | 17 + package.json | 34 ++ prisma/schema.prisma | 26 ++ src/auth/bearer.ts | 37 ++ src/config.ts | 30 ++ src/logger.ts | 24 + src/mcp/build.ts | 72 +++ src/plugins/.gitkeep | 0 src/plugins/gitea/index.ts | 302 +++++++++++++ src/plugins/unraid/index.ts | 269 ++++++++++++ src/registry.ts | 196 +++++++++ src/server.ts | 67 +++ src/transport/sse.ts | 38 ++ src/transport/streamable.ts | 58 +++ src/types/plugin.ts | 56 +++ src/util/http.ts | 78 ++++ tsconfig.json | 22 + 26 files changed, 3181 insertions(+), 37 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENT-SUITE.md create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 PLAN.md create mode 100644 SERVICES.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 prisma/schema.prisma create mode 100644 src/auth/bearer.ts create mode 100644 src/config.ts create mode 100644 src/logger.ts create mode 100644 src/mcp/build.ts create mode 100644 src/plugins/.gitkeep create mode 100644 src/plugins/gitea/index.ts create mode 100644 src/plugins/unraid/index.ts create mode 100644 src/registry.ts create mode 100644 src/server.ts create mode 100644 src/transport/sse.ts create mode 100644 src/transport/streamable.ts create mode 100644 src/types/plugin.ts create mode 100644 src/util/http.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2ac3ae0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +dist +.env +.env.local +.env.*.local +data +logs +.git +.gitea +.vscode +.idea +*.log +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dda3cf0 --- /dev/null +++ b/.env.example @@ -0,0 +1,115 @@ +# ─── Core ─────────────────────────────────────────────── +NODE_ENV=production +PORT=8811 +LOG_LEVEL=info +GATEWAY_VERSION=0.1.0 + +# Comma-separated list of active plugin names +# Phase 1–3 (core) +ENABLED_PLUGINS= +# Add Phase 7+ as plugins land: +# ,gitea,unraid,docker,openclaw,unifi,codex-mrp,streamvault,rackmapper +# ,npm,uisp,transmission,syncthing,plex,nyaa # Phase 7 +# ,home-assistant # Phase 8 +# ,invoiceninja,fabdash,cpas,wfh # Phase 9 +# ,breedr,codedump,ui-tracker,stepview,qrknit,memer,alwisp-web # Phase 10 + +# Where compiled plugins live (built output) +PLUGINS_DIR=./dist/plugins + +# ─── Auth ─────────────────────────────────────────────── +# Format: agentName:token,agentName2:token2 +AGENT_TOKENS=claude-code:CHANGE_ME_1,antigravity:CHANGE_ME_2,codex:CHANGE_ME_3 + +# ─── Phase 1: Gitea ───────────────────────────────────── +GITEA_HOST=https://git.alwisp.com +GITEA_TOKEN= + +# ─── Phase 1: Unraid ──────────────────────────────────── +UNRAID_HOST=http://10.2.0.2 +UNRAID_API_KEY= + +# ─── Phase 2: OpenClaw / NOVA ─────────────────────────── +OPENCLAW_HOST=http://10.2.0.26:18789 + +# ─── Phase 3: UniFi Access ────────────────────────────── +UNIFI_HOST= +UNIFI_API_KEY= +UNIFI_SITE_ID= + +# ─── Phase 3: CODEX MRP ───────────────────────────────── +CODEX_DB_PATH=/mnt/user/appdata/codex/db.sqlite + +# ─── Phase 3: StreamVault ─────────────────────────────── +STREAMVAULT_HOST=http://streamvault:3100 + +# ─── Phase 3: RackMapper ──────────────────────────────── +RACKMAPPER_HOST=http://10.2.0.23 + +# ─── Phase 6: Chronicle (DEFERRED) ────────────────────── +# CHRONICLE_HOST=http://chronicle:3003 +# CHRONICLE_TOKEN= + +# ─── Phase 6: Obsidian (DEFERRED) ─────────────────────── +# OBSIDIAN_REST_HOST=http://10.2.0.2:27123 +# OBSIDIAN_API_KEY= + +# ─── Phase 7: Infrastructure & Media ──────────────────── +NPM_HOST=http://10.2.0.3:81 +NPM_EMAIL= +NPM_PASSWORD= + +UISP_HOST=https://10.2.0.4:443 +UISP_TOKEN= + +TRANSMISSION_HOST=http://10.2.0.5:9091 +TRANSMISSION_USER= +TRANSMISSION_PASS= + +SYNCTHING_HOST=http://10.2.0.2:8384 +SYNCTHING_API_KEY= + +PLEX_HOST=http://10.2.0.2:32400 +PLEX_TOKEN= + +NYAA_HOST=http://10.2.0.21 +NYAA_API_KEY= + +# ─── Phase 8: Smart Home ──────────────────────────────── +HA_HOST=https://10.2.0.12:8123 +HA_TOKEN= + +# ─── Phase 9: Business Operations ─────────────────────── +INVOICENINJA_HOST=http://10.2.0.2:8000 +INVOICENINJA_TOKEN= + +FABDASH_HOST=http://10.2.0.13:8080 +FABDASH_TOKEN= + +CPAS_HOST=http://10.2.0.14:3001 +CPAS_TOKEN= + +WFH_HOST=http://10.2.0.18:3000 +WFH_TOKEN= + +# ─── Phase 10: Personal & Niche ───────────────────────── +BREEDR_HOST=http://10.2.0.17 +BREEDR_TOKEN= + +CODEDUMP_HOST=http://10.2.0.34 +CODEDUMP_TOKEN= + +UITRACKER_HOST=http://10.2.0.29 +UITRACKER_TOKEN= + +STEPVIEW_HOST=http://10.2.0.33:3000 +STEPVIEW_TOKEN= + +QRKNIT_HOST=http://10.2.0.9:5000 +QRKNIT_TOKEN= + +MEMER_HOST=http://10.2.0.30:3000 +MEMER_TOKEN= + +ALWISP_WEB_HOST=http://10.2.0.8:80 +ALWISP_WEB_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c06525 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules/ +dist/ +*.tsbuildinfo + +# Env +.env +.env.local +.env.*.local + +# Runtime data +data/ +logs/ + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ +*.swp + +# Prisma generated client lives in node_modules; migrations stay tracked diff --git a/AGENT-SUITE.md b/AGENT-SUITE.md new file mode 100644 index 0000000..2a16f86 --- /dev/null +++ b/AGENT-SUITE.md @@ -0,0 +1,47 @@ +# Drop-In Agent Instruction Suite + +This repository is a portable markdown instruction pack for coding agents. + +Copy these files into another repository to give the agent: +- a root `AGENTS.md` entrypoint, +- a central skill index, +- category hubs for routing, +- specialized skill files for common software, docs, UX, marketing, and ideation tasks. + +## Structure + +- `AGENTS.md` - base instructions and routing rules +- `DEPLOYMENT-PROFILE.md` - agent-readable prefilled deployment defaults +- `INSTALL.md` - copy and customization guide for other repositories +- `PROJECT-PROFILE-WORKBOOK.md` - one-time questionnaire for staging defaults +- `SKILLS.md` - canonical skill index +- `ROUTING-EXAMPLES.md` - representative prompt-to-skill routing examples +- `hubs/` - category-level routing guides +- `skills/` - specialized reusable skill files + +## Design Goals + +- Plain markdown only +- Cross-agent portability +- Implementation-first defaults +- On-demand skill loading instead of loading everything every session +- Context-efficient routing for large skill libraries +- Prefilled deployment defaults without per-install questioning +- Repo-local instructions take precedence over this bundle + +## Intended Workflow + +1. The agent reads `AGENTS.md`. +2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in. +3. The agent checks `SKILLS.md`. +4. The agent opens only the relevant hub and skill files for the task. +5. The agent combines multiple skills when the task spans several domains. + +## Core Categories + +- Software development +- Debugging +- Documentation +- UI/UX +- Marketing +- Brainstorming diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8f00062 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to totalmcp will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Phase 0 scaffold — Express + MCP SDK bootstrap, plugin registry with chokidar hot-reload, Streamable HTTP + legacy SSE transports, per-agent bearer auth, Zod-validated env config, structured JSON logger, Prisma event-log schema, Dockerfile + docker-compose for local dev. +- `MCPPlugin` interface contract with semver-based gateway compatibility check. +- Health and plugin-list endpoints. +- Phase 1 — `gitea` plugin (8 tools: list/get/create repos, list/create issues, list branches, get/commit files) against the Gitea v1 REST API. +- Phase 1 — `unraid` plugin (6 tools: host summary, list/get containers, list shares, list VMs, disk health) against the unraid-api GraphQL endpoint. +- Shared HTTP helper at `src/util/http.ts` — timeout, JSON serialization, structured `HttpError`. + +### Changed +- Tool naming: plugins now provide fully-qualified tool names (e.g., `gitea_list_repos`) directly. The registry no longer auto-prefixes with `pluginName__`. This matches PLAN.md's naming and handles plugins like `codex-mrp` whose tool prefix (`codex_`) differs from the directory name. + +[Unreleased]: https://git.alwisp.com/jason/totalmcp/compare/main...HEAD diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f38896f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json tsconfig.json ./ +RUN npm ci +COPY src/ ./src/ +COPY prisma/ ./prisma/ +RUN npx prisma generate +RUN npm run build + +FROM node:20-alpine +RUN addgroup -S mcp && adduser -S mcp -G mcp +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/prisma ./prisma +COPY package.json ./ +RUN mkdir -p /app/data && chown -R mcp:mcp /app/data +USER mcp +EXPOSE 8811 +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s \ + CMD wget -qO- http://localhost:8811/health || exit 1 +CMD ["node", "dist/server.js"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..fccf947 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,843 @@ +# Total MCP Gateway — PLAN.md + +> **Project:** totalmcp +> **Host:** Unraid ALPHA · 10.2.0.35 (new static IP, next available above codedump @ 10.2.0.34) +> **Port:** 8811 +> **Stack:** Node.js 20 + TypeScript + @modelcontextprotocol/sdk + Express 5 +> **Registry:** git.alwisp.com/jason/totalmcp +> **Last Updated:** 2026-05-09 + +--- + +## Overview + +A single Dockerized MCP server that acts as the unified control plane for every AI agent and tool in the Jason ecosystem. Instead of N×M point-to-point integrations, all three agent interfaces — **Claude Code**, **Codex**, and **Antigravity** — connect to one stable endpoint and get access to every backend service through hot-swappable plugin modules. + +**Two first-class design concerns:** +1. **Upgradability** — new connectors drop in without touching core infrastructure or restarting the container +2. **Utility** — every active service on ALPHA is reachable from any agent from day one + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Claude Code / Codex / Antigravity │ +└────────────────────┬────────────────────────────────────┘ + │ MCP (Streamable HTTP + SSE fallback) + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Total MCP Gateway :8811 │ +│ ┌──────────────┐ ┌───────────────┐ ┌─────────────┐ │ +│ │ Core Server │ │Plugin Registry│ │ Auth / Rate │ │ +│ │ (TypeScript) │ │ (hot-reload) │ │ Limiter │ │ +│ └──────────────┘ └───────────────┘ └─────────────┘ │ +│ │ +│ Active Plugins: │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────────┐ │ +│ │ gitea │ │ unraid │ │ docker │ │ openclaw │ │ +│ └────────┘ └────────┘ └────────┘ └──────────────────┘ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────────┐ │ +│ │ unifi │ │ codex │ │stream │ │ rackmapper │ │ +│ │ access │ │ mrp │ │ vault │ │ │ │ +│ └────────┘ └────────┘ └────────┘ └──────────────────┘ │ +│ │ +│ Deferred Plugins (install first): │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ chronicle│ │ obsidian │ │ +│ └──────────┘ └──────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ Unraid br0 · 10.2.0.x + ┌───────────────┼──────────────────────────┐ + ▼ ▼ ▼ +git.alwisp.com Unraid host NOVA (OpenClaw) + 10.2.0.15 10.2.0.2 10.2.0.26:18789 +``` + +### Transport + +Both transports are exposed on the same container at `:8811`: + +| Transport | Endpoint | Use For | +|-----------|----------|---------| +| Streamable HTTP (primary) | `POST/GET /mcp` | Claude Code, Codex | +| SSE (legacy fallback) | `GET /sse` + `POST /message` | Antigravity | +| Health check | `GET /health` | Unraid, monitoring | +| Plugin registry API | `GET /plugins` | Admin, debugging | +| Admin UI | `GET /admin` | Dashboard, plugin management | + +> **Note:** Use Streamable HTTP for Claude Code — there is a known SSE reconnection regression in Claude Code v2.1.83+. Use SSE for Antigravity until it supports Streamable HTTP natively. + +--- + +## Technology Stack + +| Layer | Choice | Reason | +|-------|--------|--------| +| Runtime | Node.js 20 LTS + TypeScript | Matches existing stack | +| MCP SDK | `@modelcontextprotocol/sdk` | Official Anthropic SDK | +| HTTP Framework | Express 5 | Familiar, lightweight | +| Plugin loading | Dynamic `import()` + chokidar | Hot-reload without restart | +| Config | `.env` + Zod config loader | Env-var-first, validated at boot | +| Persistence | SQLite via Prisma | Consistent with CODEX/Chronicle pattern | +| Auth | Bearer tokens (static, per-agent) | Simple, no OAuth needed for LAN | +| Containerization | Single Docker image | Unraid-native | +| Registry | git.alwisp.com Gitea Container Registry | Auto-build via Gitea Actions | +| Networking | Unraid br0 static IP | Consistent LAN addressing | + +--- + +## Directory Structure + +``` +totalmcp/ +├── src/ +│ ├── server.ts # Express + MCP SDK bootstrap +│ ├── registry.ts # Plugin loader + hot-reload watcher +│ ├── transport/ +│ │ ├── streamable.ts # Streamable HTTP transport +│ │ └── sse.ts # Legacy SSE transport +│ ├── auth/ +│ │ └── bearer.ts # Per-agent token validation +│ ├── types/ +│ │ └── plugin.ts # MCPPlugin interface contract +│ └── plugins/ +│ ├── gitea/ +│ │ └── index.ts # Phase 1 +│ ├── unraid/ +│ │ └── index.ts # Phase 1 +│ ├── docker/ +│ │ └── index.ts # Phase 2 +│ ├── openclaw/ +│ │ └── index.ts # Phase 2 +│ ├── unifi/ +│ │ └── index.ts # Phase 3 +│ ├── codex-mrp/ +│ │ └── index.ts # Phase 3 +│ ├── streamvault/ +│ │ └── index.ts # Phase 3 +│ ├── rackmapper/ +│ │ └── index.ts # Phase 3 +│ ├── chronicle/ # DEFERRED — install Chronicle first +│ │ └── index.ts +│ └── obsidian/ # DEFERRED — install Obsidian REST bridge first +│ └── index.ts +├── config/ +│ └── catalog.yaml # Enable/disable plugins without code changes +├── prisma/ +│ └── schema.prisma # Event log schema +├── admin/ # React + Vite admin dashboard +│ ├── src/ +│ └── dist/ # Built, served at /admin +├── Dockerfile +├── docker-compose.yml # Local dev +├── .env.example +├── AGENTS.md # Agent configuration reference +├── README.md +├── PLAN.md # This file +└── CHANGELOG.md +``` + +--- + +## Plugin Interface Contract + +Every connector implements this interface. The registry auto-discovers and loads any file matching `src/plugins/*/index.ts`. + +```typescript +// src/types/plugin.ts +export interface MCPPlugin { + name: string; // e.g., "gitea" — must be unique + version: string; // semver + description: string; + minGatewayVersion: string; // semver — registry skips incompatible plugins + tools: MCPTool[]; + resources?: MCPResource[]; + prompts?: MCPPrompt[]; + onLoad?: () => Promise; // init: connect, test auth + onUnload?: () => Promise; // cleanup: close connections, timers +} + +export interface MCPTool { + name: string; + description: string; + inputSchema: ZodSchema; + handler: (input: unknown) => Promise; +} +``` + +### Plugin Isolation Rules + +- Each plugin catches its own errors — never throws to core +- No plugin reads another plugin's state directly +- Credentials live in env vars, never in plugin source +- Inter-plugin coordination (if ever needed) goes through the SQLite event log + +### Adding a New Connector (5-Minute Workflow) + +1. `mkdir src/plugins/my-service && touch src/plugins/my-service/index.ts` +2. Implement `MCPPlugin` interface, export as `default` +3. Add required env vars to `/mnt/user/appdata/totalmcp/.env` +4. Add `my-service` to `ENABLED_PLUGINS` in `.env` +5. Hot-reload picks it up in ~2s (dev) OR push to Gitea → CI rebuilds → Unraid force-updates (prod) + +--- + +## Phased Roadmap + +### Phase 0 — Scaffold & Core Transport +**Goal:** Container boots, endpoints respond, empty plugin registry loads. +**Est:** 1–2 days + +- [ ] Init repo at `git.alwisp.com/jason/totalmcp` +- [ ] `npm init`, install core deps: `@modelcontextprotocol/sdk`, `express`, `zod`, `chokidar`, `dotenv`, `prisma` +- [ ] Implement `src/server.ts` — Express bootstrap, mount both transports +- [ ] Implement `src/transport/streamable.ts` — Streamable HTTP (primary) +- [ ] Implement `src/transport/sse.ts` — SSE legacy fallback +- [ ] Implement `src/registry.ts` — dynamic `import()`, chokidar file watcher, plugin lifecycle +- [ ] Implement `src/auth/bearer.ts` — per-agent static token validation +- [ ] Write `Dockerfile` — Node 20 Alpine, non-root user, `HEALTHCHECK` +- [ ] Write `docker-compose.yml` — local dev with volume mounts +- [ ] Write `.env.example` — all required vars documented +- [ ] Write `prisma/schema.prisma` — event log table +- [ ] Deploy to ALPHA via Unraid Docker GUI +- [ ] Verify: `curl http://10.2.0.35:8811/health` → `200 OK` +- [ ] Verify: `curl http://10.2.0.35:8811/plugins` → `{ "plugins": [] }` + +**Unraid Container Settings:** + +| Setting | Value | +|---------|-------| +| Container Name | `totalmcp` | +| Repository | `git.alwisp.com/jason/totalmcp:latest` | +| Network | `br0` | +| IP | `10.2.0.35` (next available static; `gitea-mcp` already owns `10.2.0.16:8081` and remains undisturbed during transition) | +| Port | `8811:8811` | +| Appdata | `/mnt/user/appdata/totalmcp → /app/data` | +| Env File | `/mnt/user/appdata/totalmcp/.env` | +| Docker Socket | `/var/run/docker.sock → /var/run/docker.sock` | +| Restart Policy | `unless-stopped` | +| Pids Limit | `2048` | + +--- + +### Phase 1 — Gitea + Unraid Connectors +**Goal:** Agents can query repos and Unraid system state. +**Est:** 1–2 days +**Prerequisite:** Phase 0 complete + +#### `plugins/gitea/index.ts` +- Auth: `GITEA_TOKEN` env var → `https://git.alwisp.com` REST API +- Tools: + - `gitea_list_repos` — list all repos + - `gitea_get_repo` — get repo details + - `gitea_create_repo` — create a new repo + - `gitea_list_issues` — list issues for a repo + - `gitea_create_issue` — open a new issue + - `gitea_list_branches` — list branches + - `gitea_get_file` — read a file's contents + - `gitea_commit_file` — create or update a file + +#### `plugins/unraid/index.ts` +- Auth: `UNRAID_API_KEY` + `UNRAID_HOST` +- Tools: + - `unraid_host_summary` — CPU, RAM, uptime, array status + - `unraid_list_containers` — all Docker containers + status + - `unraid_get_container` — single container details + - `unraid_list_shares` — user shares + usage + - `unraid_list_vms` — VM list + state + - `unraid_disk_health` — disk S.M.A.R.T. summary + +#### Agent Wiring (after Phase 1) + +**Claude Code:** +```bash +claude mcp add --scope user --transport http totalmcp http://10.2.0.35:8811/mcp \ + -H "Authorization: Bearer " +``` + +**Antigravity** (`mcp_config.json`): +```json +{ + "mcpServers": { + "totalmcp": { + "url": "http://10.2.0.35:8811/sse", + "transport": "sse", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +**Codex:** +```json +{ + "mcpServers": { + "totalmcp": { + "url": "http://10.2.0.35:8811/mcp", + "transport": "http", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +- [ ] Implement `plugins/gitea/index.ts` +- [ ] Implement `plugins/unraid/index.ts` +- [ ] Add both to `ENABLED_PLUGINS` +- [ ] Connect Claude Code, Codex, Antigravity +- [ ] Verify tool list shows gitea + unraid tools from each agent + +--- + +### Phase 2 — Docker + OpenClaw +**Goal:** Agents can manage containers and run local AI inference. +**Est:** 1–2 days +**Prerequisite:** Phase 1 complete + +#### `plugins/docker/index.ts` +- Auth: Docker socket mount (`/var/run/docker.sock`) +- Tools: + - `docker_list_containers` — running + stopped containers + - `docker_start_container` — start by name or ID + - `docker_stop_container` — stop by name or ID + - `docker_restart_container` — restart by name or ID + - `docker_get_logs` — tail N lines of logs + - `docker_get_stats` — CPU + memory stats + +#### `plugins/openclaw/index.ts` +- Auth: `OPENCLAW_HOST` env var → **NOVA** instance at `http://10.2.0.26:18789` +- Tools: + - `openclaw_chat` — chat completion via OpenClaw/Ollama + - `openclaw_list_models` — list available models + - `openclaw_get_model_info` — model details + context size + +> **Note:** The `OpenClaw`-named container in the Unraid Docker tab serves the `nova.alwisp.com` proxy entry — same image, "NOVA" is the persona name. The `donna.alwisp.com` instance at `10.2.0.28:18789` is a stopped second container set up for a friend; do not target it. + +- [ ] Implement `plugins/docker/index.ts` +- [ ] Implement `plugins/openclaw/index.ts` +- [ ] Add both to `ENABLED_PLUGINS` +- [ ] Verify container management works from Claude Code + +--- + +### Phase 3 — Service Connectors +**Goal:** Full parity with all active ALPHA services. +**Est:** 2–3 days +**Prerequisite:** Phase 2 complete + +#### `plugins/unifi/index.ts` +- Auth: `UNIFI_HOST`, `UNIFI_API_KEY`, `UNIFI_SITE_ID` +- Tools: + - `unifi_list_access_events` — recent badge/door events (V2 API) + - `unifi_list_users` — access users with names + - `unifi_get_door_status` — current lock state per door + - `unifi_list_sites` — managed UniFi sites + +#### `plugins/codex-mrp/index.ts` +- Auth: Internal Docker network — direct Prisma client access to CODEX SQLite +- Tools: + - `codex_list_work_orders` — active work orders + - `codex_get_work_order` — single WO detail + BOM + - `codex_create_work_order` — create new WO + - `codex_get_inventory` — inventory levels + - `codex_list_boms` — bill of materials list + +#### `plugins/streamvault/index.ts` +- Auth: `STREAMVAULT_HOST` (internal Docker network) +- Tools: + - `streamvault_list_jobs` — all download jobs + status + - `streamvault_add_download` — queue a new download URL + - `streamvault_get_job_status` — single job detail + - `streamvault_cancel_job` — cancel/remove a job + +#### `plugins/rackmapper/index.ts` +- Auth: `RACKMAPPER_HOST` (internal Docker network) +- Tools: + - `rackmapper_list_racks` — all racks + - `rackmapper_get_rack` — rack layout with devices + - `rackmapper_list_devices` — device inventory + - `rackmapper_map_service` — link a device to a service + +- [ ] Implement all four Phase 3 plugins +- [ ] Add all to `ENABLED_PLUGINS` +- [ ] Verify each tool is callable from agents + +--- + +### Phase 4 — Upgradability Infrastructure +**Goal:** Admin UI, CI/CD pipeline, zero-downtime plugin updates. +**Est:** 2–3 days +**Prerequisite:** Phase 3 complete + +- [ ] **Plugin Admin API** + - `GET /plugins` — list all plugins + tool counts, status, version + - `POST /plugins/:name/reload` — hot-reload a single plugin + - `POST /plugins/:name/enable` — enable a disabled plugin + - `POST /plugins/:name/disable` — disable without removing +- [ ] **`config/catalog.yaml`** — human-readable enable/disable, rate limits per plugin, per-agent tool allowlists +- [ ] **Admin Dashboard** (React + Vite, served at `/admin`) + - Plugin status cards (loaded / error / disabled) + - Tool call counts per plugin + - Last-used timestamps + - Reload button per plugin + - Live event log tail +- [ ] **Gitea Actions CI Pipeline** + - Trigger: push to `main` + - Steps: `npm ci` → `npm run build` → `docker build` → `docker push git.alwisp.com/jason/totalmcp:latest` + - Unraid force-updates container on next check or manual trigger +- [ ] **Semantic versioning for plugin API contracts** + - Plugins declare `minGatewayVersion` + - Registry checks compatibility at load time — skips with warning, never crashes +- [ ] **Health endpoint per plugin**: `GET /health/plugins/:name` + +--- + +### Phase 5 — Security & Observability +**Goal:** Production-hardened for daily autonomous agent use. +**Est:** 1–2 days +**Prerequisite:** Phase 4 complete + +- [ ] **Per-agent bearer tokens** — `AGENT_TOKENS=claude-code:TOKEN1,antigravity:TOKEN2,codex:TOKEN3` +- [ ] **Rate limiting** — per-agent, per-tool, configurable in `catalog.yaml` +- [ ] **Input schema validation** — Zod on every tool call; bad inputs return structured 400 errors, never reach handler +- [ ] **Structured JSON logging** → `/mnt/user/appdata/totalmcp/logs/` +- [ ] **SQLite event log** — every tool call: agent, tool name, input hash, duration, success/error, timestamp +- [ ] **Prometheus metrics endpoint** at `/metrics` (optional — wire to Grafana if desired) +- [ ] **Plugin error isolation test** — verify one crashing plugin cannot bring down the gateway or other plugins + +--- + +### Phase 6 — Deferred Connectors (Install Prerequisites First) +**Goal:** Add Chronicle and Obsidian after those services are deployed to ALPHA. +**Est:** 1 day per connector once services are live +**Prerequisite:** Chronicle and Obsidian deployed + accessible on br0 + +#### Chronicle — Prerequisites +- [ ] Deploy Chronicle container to ALPHA (see Chronicle build docs) +- [ ] Verify `http://chronicle:3003/health` responds from within the Docker network +- [ ] Obtain Chronicle bearer token + +#### `plugins/chronicle/index.ts` +- Auth: `CHRONICLE_HOST`, `CHRONICLE_TOKEN` +- Tools: + - `memory_store` — persist a memory entry + - `memory_recall` — retrieve by key or semantic query + - `memory_search` — full-text + semantic search across entries + - `memory_list_projects` — browse memories by project scope + - `memory_delete` — remove a specific entry + +#### Obsidian — Prerequisites +- [ ] Install Obsidian Local REST API plugin in the Obsidian container on ALPHA +- [ ] Generate API key from Obsidian plugin settings +- [ ] Verify `http://obsidian:27123` is reachable from Docker network + +#### `plugins/obsidian/index.ts` +- Auth: `OBSIDIAN_REST_HOST`, `OBSIDIAN_API_KEY` +- Tools: + - `obsidian_note_create` — create a new note at a given path + - `obsidian_note_read` — read note contents by path + - `obsidian_note_update` — overwrite note contents + - `obsidian_note_append` — append text to a note + - `obsidian_note_search` — search notes by query + - `obsidian_list_vault` — list notes in a folder + +**To activate either deferred connector once ready:** +1. Prerequisites above are complete +2. `touch src/plugins/chronicle/index.ts` (or `obsidian`) and implement +3. Add env vars to `/mnt/user/appdata/totalmcp/.env` +4. Add to `ENABLED_PLUGINS` +5. Push to Gitea → CI rebuilds → Unraid force-updates + +--- + +### Phase 7 — Infrastructure & Media Connectors +**Goal:** Cover the remaining third-party services on ALPHA so agents can manage networking, file sync, downloads, and media. +**Est:** 2–3 days +**Prerequisite:** Phase 5 complete (security hardening landed before broadening surface area) + +#### `plugins/npm/index.ts` — Nginx Proxy Manager +- Auth: `NPM_HOST=http://10.2.0.3:81`, `NPM_EMAIL`, `NPM_PASSWORD` (NPM token flow) +- Tools: + - `npm_list_proxy_hosts` — all configured proxy entries + - `npm_get_proxy_host` — single entry detail + - `npm_create_proxy_host` — new hostname → backend mapping + - `npm_update_proxy_host` — change backend / SSL settings + - `npm_delete_proxy_host` — remove entry + - `npm_renew_cert` — force Let's Encrypt renewal + - `npm_list_certs` — cert status across all hosts + +#### `plugins/uisp/index.ts` — Ubiquiti UISP +- Auth: `UISP_HOST=https://10.2.0.4:443`, `UISP_TOKEN` +- Tools: + - `uisp_list_devices` — all UISP-line devices + - `uisp_get_device_status` — per-device state, CPU, RAM + - `uisp_list_sites` — managed sites + - `uisp_get_link_quality` — radio link RSSI / capacity / errors + - `uisp_list_outages` — current device outages + +#### `plugins/transmission/index.ts` — NEBULA +- Auth: `TRANSMISSION_HOST=http://10.2.0.5:9091`, `TRANSMISSION_USER`, `TRANSMISSION_PASS` +- Tools: + - `transmission_list_torrents` — all torrents + status + - `transmission_add_torrent` — magnet or URL + - `transmission_pause_torrent` / `transmission_resume_torrent` + - `transmission_remove_torrent` — with optional data deletion + - `transmission_get_stats` — global up/down rates, totals + +#### `plugins/syncthing/index.ts` +- Auth: `SYNCTHING_HOST=http://10.2.0.2:8384`, `SYNCTHING_API_KEY` +- Tools: + - `syncthing_list_folders` — all configured folders + sync % + - `syncthing_folder_status` — single folder detail + - `syncthing_list_devices` — paired devices + connection state + - `syncthing_pause_folder` / `syncthing_resume_folder` + - `syncthing_rescan` — force a folder rescan + +#### `plugins/plex/index.ts` +- Auth: `PLEX_HOST=http://10.2.0.2:32400`, `PLEX_TOKEN` +- Tools: + - `plex_list_libraries` — all libraries (movies, TV, music) + - `plex_search_library` — search across libraries + - `plex_recently_added` — N most recently added items + - `plex_now_playing` — active sessions + - `plex_server_status` — version, transcoder activity, plugins + +#### `plugins/nyaa/index.ts` +- Auth: `NYAA_HOST=http://10.2.0.21:`, `NYAA_API_KEY` (if applicable) +- Tools: + - `nyaa_list_watches` — active search/watch rules + - `nyaa_add_watch` — new auto-download rule + - `nyaa_remove_watch` — delete a rule + - `nyaa_get_recent_matches` — last N triggered downloads + - `nyaa_force_check` — run watch loop immediately + +- [ ] Implement all six Phase 7 plugins +- [ ] Add to `ENABLED_PLUGINS` +- [ ] Confirm each tool works from Claude Code + +--- + +### Phase 8 — Smart Home +**Goal:** Bring Home Assistant under MCP control so agents can read sensor state and trigger automations. +**Est:** 1–2 days +**Prerequisite:** Phase 7 complete + +#### `plugins/home-assistant/index.ts` +- Auth: `HA_HOST=https://10.2.0.12:8123`, `HA_TOKEN` (long-lived access token from HA UI) +- Tools: + - `ha_list_entities` — all entities + current state + - `ha_get_state` — single entity state + attributes + - `ha_call_service` — invoke any HA service (`light.turn_on`, `automation.trigger`, etc.) + - `ha_list_automations` — all configured automations + - `ha_trigger_automation` — fire an automation by entity ID + - `ha_get_history` — historical state changes for an entity + +> **Note:** `matter-server` (raw Matter protocol bridge on ALPHA) is intentionally **not** wrapped as a separate plugin — Home Assistant already abstracts Matter, Zigbee, Z-Wave, and other ecosystems behind one API. Add a dedicated `matter` plugin only if a use case emerges that HA cannot serve. + +- [ ] Implement `plugins/home-assistant/index.ts` +- [ ] Add to `ENABLED_PLUGINS` +- [ ] Verify entity state + service calls work from Claude Code + +--- + +### Phase 9 — Business Operations +**Goal:** Connect remaining business apps so agents can support shop floor, HR, and finance workflows. +**Est:** 2–3 days +**Prerequisite:** Phase 8 complete + +#### `plugins/invoiceninja/index.ts` +- Auth: `INVOICENINJA_HOST=http://10.2.0.2:8000`, `INVOICENINJA_TOKEN` +- Tools: + - `invoiceninja_list_invoices` — filter by status, client, date range + - `invoiceninja_get_invoice` — single invoice detail + - `invoiceninja_create_invoice` — new invoice from line items + - `invoiceninja_send_invoice` — email an invoice + - `invoiceninja_list_clients` — client directory + - `invoiceninja_get_payment_status` — payment state for invoice + +#### `plugins/fabdash/index.ts` +- Auth: `FABDASH_HOST=http://10.2.0.13:8080`, `FABDASH_TOKEN` +- Tools: + - `fabdash_get_today_schedule` — today's shop schedule + - `fabdash_list_active_jobs` — jobs in flight + - `fabdash_machine_status` — utilization per machine + - `fabdash_create_job` — new shop job + - `fabdash_update_job` — change status / assignment + +#### `plugins/cpas/index.ts` +- Auth: `CPAS_HOST=http://10.2.0.14:3001`, `CPAS_TOKEN` +- Tools: + - `cpas_list_violations` — recent infractions + - `cpas_get_employee_score` — running point total + - `cpas_log_violation` — record new infraction + - `cpas_list_at_risk_employees` — employees at/near escalation thresholds + +#### `plugins/wfh/index.ts` +- Auth: `WFH_HOST=http://10.2.0.18:3000`, `WFH_TOKEN` +- Tools: + - `wfh_list_tasks` — task log per employee or team + - `wfh_get_employee_log` — single employee daily log + - `wfh_submit_form` — file required HR/timesheet form + - `wfh_list_pending_submissions` — overdue forms + +- [ ] Implement all four Phase 9 plugins +- [ ] Add to `ENABLED_PLUGINS` + +--- + +### Phase 10 — Personal & Niche Apps +**Goal:** Round out the catalog with personal and lower-priority custom apps. +**Est:** 2–3 days +**Prerequisite:** Phase 9 complete + +#### `plugins/breedr/index.ts` +- Auth: `BREEDR_HOST=http://10.2.0.17:`, `BREEDR_TOKEN` +- Tools: `breedr_list_dogs`, `breedr_get_pedigree`, `breedr_list_upcoming_litters`, `breedr_log_whelp_event` + +#### `plugins/codedump/index.ts` +- Auth: `CODEDUMP_HOST=http://10.2.0.34:`, `CODEDUMP_TOKEN` +- Tools: `codedump_list_projects`, `codedump_get_project`, `codedump_update_completion`, `codedump_add_project` + +#### `plugins/ui-tracker/index.ts` +- Auth: `UITRACKER_HOST=http://10.2.0.29:`, `UITRACKER_TOKEN` +- Tools: `uitracker_list_watched`, `uitracker_add_watch`, `uitracker_remove_watch`, `uitracker_get_alert_history` + +#### `plugins/stepview/index.ts` +- Auth: `STEPVIEW_HOST=http://10.2.0.33:3000`, `STEPVIEW_TOKEN` +- Tools: `stepview_list_models`, `stepview_upload_model`, `stepview_create_share_link`, `stepview_revoke_share` + +#### `plugins/qrknit/index.ts` +- Auth: `QRKNIT_HOST=http://10.2.0.9:5000`, `QRKNIT_TOKEN` +- Tools: `qrknit_create_link`, `qrknit_get_analytics`, `qrknit_list_links`, `qrknit_generate_qr` + +#### `plugins/memer/index.ts` +- Auth: `MEMER_HOST=http://10.2.0.30:3000`, `MEMER_TOKEN` +- Tools: `memer_search`, `memer_upload`, `memer_get_random`, `memer_list_tags` + +#### `plugins/alwisp-web/index.ts` +- Auth: `ALWISP_WEB_HOST=http://10.2.0.8:80`, `ALWISP_WEB_TOKEN` +- Tools: `alwisp_publish_page`, `alwisp_update_page`, `alwisp_list_pages` + +- [ ] Implement plugins for the apps that have stable APIs +- [ ] Defer any app that lacks a usable API until one is added +- [ ] Add to `ENABLED_PLUGINS` selectively — not all of these need to be on by default + +--- + +### Phase 11 — Future / Conditional +**Goal:** Track candidates that should not be built yet. + +| Plugin | Condition to start | +|---|---| +| `mrp-qrcode` | Once it stabilizes and its scope clearly differs from `codex-mrp` in production use | +| `inven` | Once development converges (currently the third in-flight MRP design) | +| `matter` (raw) | Only if a use case appears that HA cannot serve | +| `email-sigs` | Only if an API is added (currently UI-only) | +| `n8n` | Only if usage materially picks up (currently stopped) | + +--- + +### Skipped (per service triage) + +- `adminer` — UI only, no programmatic API needed +- `MariaDB`, `postgresql16`, `Redis` — services own their own DBs; no direct DB plugin +- `Gitea-Runner` — managed via the Gitea plugin +- `bx (client UISP)` — third-party trust boundary; do not target + +--- + +## Environment Variables + +Full reference for `/mnt/user/appdata/totalmcp/.env`: + +```dotenv +# ─── Core ─────────────────────────────────────────────── +NODE_ENV=production +PORT=8811 +LOG_LEVEL=info +GATEWAY_VERSION=1.0.0 + +# Comma-separated list of active plugin names +# Phase 1–3 (core) +ENABLED_PLUGINS=gitea,unraid,docker,openclaw,unifi,codex-mrp,streamvault,rackmapper +# Add Phase 7+ as plugins land: +# ,npm,uisp,transmission,syncthing,plex,nyaa # Phase 7 (infra/media) +# ,home-assistant # Phase 8 (smart home) +# ,invoiceninja,fabdash,cpas,wfh # Phase 9 (business ops) +# ,breedr,codedump,ui-tracker,stepview,qrknit,memer,alwisp-web # Phase 10 (personal/niche) + +# ─── Auth ─────────────────────────────────────────────── +# Format: agentName:token,agentName2:token2 +AGENT_TOKENS=claude-code:TOKEN1,antigravity:TOKEN2,codex:TOKEN3 + +# ─── Gitea ────────────────────────────────────────────── +GITEA_HOST=https://git.alwisp.com +GITEA_TOKEN= + +# ─── Unraid ───────────────────────────────────────────── +UNRAID_HOST=http://10.2.0.2 +UNRAID_API_KEY= + +# ─── OpenClaw / NOVA ──────────────────────────────────── +OPENCLAW_HOST=http://10.2.0.26:18789 + +# ─── UniFi Access ─────────────────────────────────────── +UNIFI_HOST=https:// +UNIFI_API_KEY= +UNIFI_SITE_ID= + +# ─── CODEX MRP ────────────────────────────────────────── +CODEX_DB_PATH=/mnt/user/appdata/codex/db.sqlite + +# ─── StreamVault ──────────────────────────────────────── +STREAMVAULT_HOST=http://streamvault:3100 + +# ─── RackMapper ───────────────────────────────────────── +RACKMAPPER_HOST=http://rackmapper:3200 + +# ─── Chronicle (DEFERRED) ─────────────────────────────── +# CHRONICLE_HOST=http://chronicle:3003 +# CHRONICLE_TOKEN= + +# ─── Obsidian (DEFERRED) ──────────────────────────────── +# OBSIDIAN_REST_HOST=http://obsidian:27123 +# OBSIDIAN_API_KEY= + +# ─── Phase 7: Infrastructure & Media ──────────────────── +NPM_HOST=http://10.2.0.3:81 +NPM_EMAIL= +NPM_PASSWORD= + +UISP_HOST=https://10.2.0.4:443 +UISP_TOKEN= + +TRANSMISSION_HOST=http://10.2.0.5:9091 +TRANSMISSION_USER= +TRANSMISSION_PASS= + +SYNCTHING_HOST=http://10.2.0.2:8384 +SYNCTHING_API_KEY= + +PLEX_HOST=http://10.2.0.2:32400 +PLEX_TOKEN= + +NYAA_HOST=http://10.2.0.21 +NYAA_API_KEY= + +# ─── Phase 8: Smart Home ──────────────────────────────── +HA_HOST=https://10.2.0.12:8123 +HA_TOKEN= + +# ─── Phase 9: Business Operations ─────────────────────── +INVOICENINJA_HOST=http://10.2.0.2:8000 +INVOICENINJA_TOKEN= + +FABDASH_HOST=http://10.2.0.13:8080 +FABDASH_TOKEN= + +CPAS_HOST=http://10.2.0.14:3001 +CPAS_TOKEN= + +WFH_HOST=http://10.2.0.18:3000 +WFH_TOKEN= + +# ─── Phase 10: Personal & Niche ───────────────────────── +BREEDR_HOST=http://10.2.0.17 +BREEDR_TOKEN= + +CODEDUMP_HOST=http://10.2.0.34 +CODEDUMP_TOKEN= + +UITRACKER_HOST=http://10.2.0.29 +UITRACKER_TOKEN= + +STEPVIEW_HOST=http://10.2.0.33:3000 +STEPVIEW_TOKEN= + +QRKNIT_HOST=http://10.2.0.9:5000 +QRKNIT_TOKEN= + +MEMER_HOST=http://10.2.0.30:3000 +MEMER_TOKEN= + +ALWISP_WEB_HOST=http://10.2.0.8:80 +ALWISP_WEB_TOKEN= +``` + +--- + +## Dockerfile + +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json tsconfig.json ./ +RUN npm ci +COPY src/ ./src/ +COPY prisma/ ./prisma/ +RUN npx prisma generate +RUN npm run build + +FROM node:20-alpine +RUN addgroup -S mcp && adduser -S mcp -G mcp +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/prisma ./prisma +COPY package.json ./ +USER mcp +EXPOSE 8811 +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s \ + CMD wget -qO- http://localhost:8811/health || exit 1 +CMD ["node", "dist/server.js"] +``` + +--- + +## Risk Register + +| Risk | Mitigation | +|------|-----------| +| Docker socket exposure | Plugin scoped; consider read-only mount once stable | +| Plugin crash brings down gateway | All plugin calls wrapped in try/catch; registry isolates failures | +| Claude Code SSE reconnect bug (v2.1.83+) | Use Streamable HTTP for Claude Code; SSE only for Antigravity | +| Gitea CI build fails silently | Add Gitea issue notification step to Actions workflow | +| Hot-reload duplicate tool registration | Registry calls `onUnload()` and removes old entry before re-import | +| br0 Unraid host routing quirk | All clients connect from workstations, not from the Unraid host itself | +| Deferred plugins partially configured | Chronicle and Obsidian env vars commented out until services are live | + +--- + +## Files to Generate Next + +| File | Purpose | Priority | +|------|---------|----------| +| `AGENTS.md` | Multi-agent config reference, full tool catalog, env var docs | High | +| `README.md` | Overview, quick-start, Unraid deployment steps | High | +| `src/server.ts` | Core server bootstrap | Phase 0 | +| `src/registry.ts` | Plugin loader + hot-reload | Phase 0 | +| `src/types/plugin.ts` | MCPPlugin interface | Phase 0 | +| `src/transport/streamable.ts` | Streamable HTTP transport | Phase 0 | +| `src/transport/sse.ts` | SSE legacy transport | Phase 0 | +| `src/auth/bearer.ts` | Per-agent token validation | Phase 0 | +| `Dockerfile` | Production image | Phase 0 | +| `docker-compose.yml` | Local dev stack | Phase 0 | +| `src/plugins/gitea/index.ts` | Gitea connector | Phase 1 | +| `src/plugins/unraid/index.ts` | Unraid API connector | Phase 1 | +| `src/plugins/docker/index.ts` | Docker socket connector | Phase 2 | +| `src/plugins/openclaw/index.ts` | OpenClaw/Ollama connector | Phase 2 | +| `config/catalog.yaml` | Plugin registry config | Phase 4 | +| `.gitea/workflows/build.yml` | Gitea Actions CI pipeline | Phase 4 | + +--- + +## Upgrade Policy + +- **Patch** (1.0.x): Bug fixes, no plugin API changes — always safe to update +- **Minor** (1.x.0): New gateway capabilities, backward-compatible plugin API additions +- **Major** (x.0.0): Breaking plugin API changes — dual-support period, migration guide in `CHANGELOG.md` +- Plugins declare `minGatewayVersion` — incompatible plugins are skipped with a warning log, never crash the server +- `CHANGELOG.md` is updated with every release + diff --git a/README.md b/README.md index 2a16f86..ab6858f 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,111 @@ -# Drop-In Agent Instruction Suite +# totalmcp -This repository is a portable markdown instruction pack for coding agents. +Unified MCP gateway for Jason's ALPHA stack. One Dockerized server, hot-reloadable plugin architecture, three agent clients (Claude Code, Codex, Antigravity) all connecting to one stable endpoint. -Copy these files into another repository to give the agent: -- a root `AGENTS.md` entrypoint, -- a central skill index, -- category hubs for routing, -- specialized skill files for common software, docs, UX, marketing, and ideation tasks. +- **Port:** `8811` +- **Static IP:** `10.2.0.35` (Unraid `br0`) +- **Registry:** `git.alwisp.com/jason/totalmcp` +- **Spec:** see [`PLAN.md`](PLAN.md) for the full architecture and phased roadmap +- **Service inventory:** see [`SERVICES.md`](SERVICES.md) for the catalog this gateway plugs into -## Structure +## Quick Start (local dev) -- `AGENTS.md` - base instructions and routing rules -- `DEPLOYMENT-PROFILE.md` - agent-readable prefilled deployment defaults -- `INSTALL.md` - copy and customization guide for other repositories -- `PROJECT-PROFILE-WORKBOOK.md` - one-time questionnaire for staging defaults -- `SKILLS.md` - canonical skill index -- `ROUTING-EXAMPLES.md` - representative prompt-to-skill routing examples -- `hubs/` - category-level routing guides -- `skills/` - specialized reusable skill files +```bash +cp .env.example .env # then fill in tokens +npm install +npm run prisma:generate +npm run dev # starts tsx watch on src/server.ts +``` -## Design Goals +Verify: -- Plain markdown only -- Cross-agent portability -- Implementation-first defaults -- On-demand skill loading instead of loading everything every session -- Context-efficient routing for large skill libraries -- Prefilled deployment defaults without per-install questioning -- Repo-local instructions take precedence over this bundle +```bash +curl http://localhost:8811/health +# → { "status": "ok", "version": "0.1.0", "plugins": 0, "enabled": [] } +``` -## Intended Workflow +## Build & run -1. The agent reads `AGENTS.md`. -2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in. -3. The agent checks `SKILLS.md`. -4. The agent opens only the relevant hub and skill files for the task. -5. The agent combines multiple skills when the task spans several domains. +```bash +npm run build # tsc → dist/ +npm start # node dist/server.js +``` -## Core Categories +## Docker -- Software development -- Debugging -- Documentation -- UI/UX -- Marketing -- Brainstorming +```bash +docker compose up --build -d +docker compose logs -f totalmcp +``` + +## Endpoints + +| Method | Path | Auth | Purpose | +|--------|-------------|----------|--------------------------------------| +| GET | `/health` | none | Liveness — used by Unraid HEALTHCHECK | +| GET | `/plugins` | bearer | Loaded plugin list + tool counts | +| POST | `/mcp` | bearer | Streamable HTTP — primary MCP transport | +| GET | `/mcp` | bearer | Streamable HTTP server-sent stream | +| DELETE | `/mcp` | bearer | Streamable HTTP session close | +| GET | `/sse` | bearer | Legacy SSE (Antigravity) | +| POST | `/message` | bearer | Legacy SSE message channel | + +## Authoring a plugin + +Drop a directory under `src/plugins//index.ts` and `export default` an `MCPPlugin`. Tool names must be fully-qualified (`_`) and unique across all loaded plugins. + +```typescript +import { z } from "zod"; +import type { MCPPlugin } from "../../types/plugin.js"; + +const plugin: MCPPlugin = { + name: "example", + version: "0.1.0", + description: "Example plugin", + minGatewayVersion: "0.1.0", + tools: [ + { + name: "example_ping", + description: "Returns pong", + inputSchema: z.object({}), + handler: async () => ({ result: "pong" }), + }, + ], +}; + +export default plugin; +``` + +Then add `example` to `ENABLED_PLUGINS` in `.env`. In dev (`npm run dev`), chokidar picks up the change in ~2s. In prod, push to Gitea and let CI rebuild the image. + +Reference implementations live at [`src/plugins/gitea/`](src/plugins/gitea/index.ts) and [`src/plugins/unraid/`](src/plugins/unraid/index.ts). + +## Repo layout + +``` +totalmcp/ +├── src/ +│ ├── server.ts # Express bootstrap +│ ├── registry.ts # Plugin loader + hot-reload +│ ├── config.ts # Zod env validation +│ ├── logger.ts # Structured JSON logger +│ ├── types/plugin.ts # MCPPlugin interface +│ ├── auth/bearer.ts # Per-agent token middleware +│ ├── mcp/build.ts # MCP server wiring +│ ├── transport/ +│ │ ├── streamable.ts # Streamable HTTP transport +│ │ └── sse.ts # Legacy SSE transport +│ ├── util/http.ts # Shared HTTP helper (timeouts, JSON, errors) +│ └── plugins/ +│ ├── gitea/index.ts # Phase 1 — Gitea REST API +│ └── unraid/index.ts # Phase 1 — Unraid GraphQL API +├── prisma/schema.prisma # Event log schema +├── Dockerfile +├── docker-compose.yml +├── .env.example +└── package.json +``` + +## Roadmap + +See [`PLAN.md`](PLAN.md) for the full phased plan. Phase 0 (this scaffold) is intentionally minimal — empty registry, both transports respond, Docker image builds, container boots clean. diff --git a/SERVICES.md b/SERVICES.md new file mode 100644 index 0000000..8ef0ba8 --- /dev/null +++ b/SERVICES.md @@ -0,0 +1,669 @@ +# SERVICES.md — ALPHA Service Catalog + +> **Primary host:** Unraid ALPHA +> **Primary network:** `br0` (10.2.0.0/24, static IPs) +> **Secondary network:** Docker `bridge` (172.17.0.0/16, port-mapped to host 10.2.0.2) +> **Reverse proxy:** Nginx Proxy Manager at `10.2.0.3` (admin UI on `:81`) +> **Last Updated:** 2026-05-09 + +This is the canonical reference for every service running on the ALPHA Unraid server **and the off-host services that integrate with it** (Home Assistant, off-site client UISP). Reuse this file across projects (MCP gateways, dashboards, monitoring, automation, documentation) — it is the single source of truth for service identity, purpose, networking, public hostnames, and integration potential. + +Each entry includes: +- **Purpose** — what it does and why it exists +- **Image source** — Docker image origin (where applicable) +- **Network** — `br0` (static LAN IP) / `bridge` (Docker NAT) / `host` / off-host +- **Address** — reachable IP/port for LAN clients +- **Public hostname(s)** — domain(s) bound via Nginx Proxy Manager +- **Category** — functional grouping +- **Owner** — `personal`, `business`, `commercial`, `infra`, or `third-party` +- **MCP Plugin Status** — current placement in the Total MCP Gateway plan + +--- + +## Brand Domains + +| Domain | Brand | Purpose | Used For | +|---|---|---|---| +| `alwisp.com` | Personal | Jason's personal-brand domain | Most services (default) | +| `mpm.to` | Business / work | Work-only short domain | Work-routed aliases for work services (`cpas.mpm.to`, `wfh.mpm.to`) | +| `qrknit.com` | Commercial product | QR.knit SaaS public domain | QR.knit app only | + +> **Rule of thumb:** if a service has both an `*.alwisp.com` and `*.mpm.to` hostname, the `mpm.to` alias is the work-facing entry point. Personal/dual-use services live only under `alwisp.com`. + +--- + +## Quick Index + +| Service | Public Hostname | LAN Address | Category | MCP Status | +|---|---|---|---|---| +| [adminer](#adminer) | — (LAN-only) | `10.2.0.2:7070` | DB Admin | Skip | +| [alwisp_db](#alwisp_db) | — | `10.2.0.7` | Database | Indirect | +| [alwisp_web](#alwisp_web) | `alwisp.com` | `10.2.0.8:80` | Personal Site | Candidate | +| [breedr](#breedr) | — | `10.2.0.17` | Custom App (Personal) | Candidate | +| [bx (client UISP)](#bx-offsite-client-uisp) | `bx.alwisp.com` | `71.45.182.201:1443` *(off-host)* | Network Mgmt (client) | Skip | +| [codedump](#codedump) | — | `10.2.0.34` | Custom App (PM) | Candidate | +| [cpas](#cpas) | `cpas.alwisp.com`, `cpas.mpm.to` | `10.2.0.14:3001` | HR / Compliance | Candidate | +| [DONNA](#donna) | `donna.alwisp.com` | `10.2.0.28:18789` *(stopped)* | AI / OpenClaw (friend) | Skip | +| [email-sigs](#email-sigs) | `sig.alwisp.com` | `10.2.0.10:3000` | HR / IT | Skip (no API) | +| [fabdash](#fabdash) | `fabdash.alwisp.com` | `10.2.0.13:8080` | Production | Candidate | +| [Gitea](#gitea) | `git.alwisp.com`, `registry.alwisp.com` | `10.2.0.15:3000` | Source Control + Registry | Phase 1 | +| [gitea-mcp](#gitea-mcp) | `mcp.alwisp.com` | `10.2.0.16:8081` | MCP / Gitea Bridge | Existing → folded into gateway | +| [Gitea-Runner](#gitea-runner) | — | `172.17.0.7` | CI/CD | Indirect | +| [Home Assistant](#home-assistant) | `ha.alwisp.com` | `10.2.0.12:8123` *(off-host)* | Smart Home (controller) | Candidate | +| [totalmcp](#totalmcp) | *(planned)* | `10.2.0.35:8811` *(planned)* | MCP Gateway | **THIS PROJECT** | +| [inven](#inven) | — | `10.2.0.25` | Custom MRP (in dev) | Future | +| [invoiceninja-v5](#invoiceninja-v5) | `inv.alwisp.com` | `10.2.0.2:8000` (HTTP), `:8444` (HTTPS) | Finance | Candidate | +| [MariaDB-Official](#mariadb-official) | — | `10.2.0.2:3306` | Database (shared) | Skip | +| [matter-server](#matter-server) | — | `10.2.0.2` (host net) | Smart Home (protocol) | Candidate | +| [memer](#memer) | `meme.alwisp.com` | `10.2.0.30:3000` | Personal / Media | Candidate | +| [mrp (CODEX)](#mrp-codex) | `mrp.alwisp.com` | `10.2.0.19:3000` | MRP / ERP | Phase 3 (`codex-mrp`) | +| [mrp-qrcode](#mrp-qrcode) | `qrmrp.alwisp.com` | `10.2.0.32:3000` | MRP (specialized) | Future | +| [n8n](#n8n) | `n8n.alwisp.com` | `10.2.0.20:5678` *(stopped)* | Automation | Skip | +| [NEBULA (Transmission)](#nebula-transmission) | `neb.alwisp.com` | `10.2.0.5:9091` | Torrent | Candidate | +| [NGINX (NPM)](#nginx-npm) | `internal.alwisp.com` | `10.2.0.3:81` (admin), `:80`/`:443` (proxy) | Reverse Proxy | Candidate | +| [NOVA (OpenClaw)](#nova-openclaw) | `nova.alwisp.com` | `10.2.0.26:18789` | AI / Compute (primary) | Phase 2 | +| [nyaa](#nyaa) | — | `10.2.0.21` | Torrent Crawler | Candidate | +| [obsidian](#obsidian) | — | `10.2.0.2:3000` (HTTP), `:3001` (HTTPS) | Notes / PKM | Phase 6 (deferred) | +| [plex](#plex) | — | `10.2.0.2` (host net) | Media | Candidate | +| [postgresql16](#postgresql16) | — | `10.2.0.2:5432` | Database (shared) | Skip | +| [QR.knit](#qrknit) | `qrknit.com`, `www.qrknit.com` | `10.2.0.9:5000` | Commercial SaaS | Candidate | +| [rackmapper](#rackmapper) | — | `10.2.0.23` | Datacenter Mgmt | Phase 3 | +| [Redis](#redis) | — | `10.2.0.2:6379` | Cache (shared) | Skip | +| [stepview](#stepview) | `step.alwisp.com` | `10.2.0.33:3000` | 3D Model Viewer | Candidate | +| [syncthing](#syncthing) | — | `10.2.0.2:8384` (web), `:21027/UDP` | File Sync | Candidate | +| [ui-tracker](#ui-tracker) | — | `10.2.0.29` | Stock Watcher | Candidate | +| [UISP](#uisp) | `wisp.alwisp.com` | `10.2.0.4:443` | Network Mgmt | Candidate | +| [unifi-access-dashboard](#unifi-access-dashboard) | — | `10.2.0.11` | Access Control | Phase 3 (`unifi`) | +| [wfh](#wfh) | `wfh.alwisp.com`, `wfh.mpm.to` | `10.2.0.18:3000` | HR / Remote Work | Candidate | +| [Decommissioned: to.alwisp.com](#decommissioned--unused) | `to.alwisp.com` | `10.2.0.6:5000` *(unused)* | — | — | +| [Decommissioned: url.alwisp.com](#decommissioned--unused) | `url.alwisp.com` | `10.2.0.2:8080` *(unused)* | — | — | + +--- + +## Infrastructure & Networking + +### NGINX (NPM) +- **Purpose:** Nginx Proxy Manager — reverse proxy, SSL termination, and routing for every public-facing service. NPM admin UI lives at `internal.alwisp.com`. All `*.alwisp.com`, `*.mpm.to`, and `*.qrknit.com` traffic terminates here. +- **Image:** `jc21/nginx-proxy-manager` +- **Network:** `br0` +- **Address:** `10.2.0.3` (proxy on `:80` / `:443`, admin UI on `:81`) +- **Public hostname:** `internal.alwisp.com` → `10.2.0.3:81` (admin UI) +- **Category:** Infra / Networking +- **Owner:** infra (third-party) +- **MCP Plugin Status:** Candidate — `npm_list_proxy_hosts`, `npm_create_proxy_host`, `npm_renew_cert`, `npm_check_cert_status`. Useful for managing routes from agents. + +### UISP +- **Purpose:** Ubiquiti UISP / UNMS — manages Ubiquiti UISP-line gear (airMAX, EdgeSwitch, EdgeRouter, UFiber). Distinct from UniFi Access. Used for monitoring radio links, switch ports, and ISP-grade gear. +- **Image:** `nico640/docker-unms` +- **Network:** `br0` +- **Address:** `10.2.0.4:443` +- **Public hostname:** `wisp.alwisp.com` → `https://10.2.0.4:443` +- **Category:** Infra / Networking +- **Owner:** infra (third-party) +- **MCP Plugin Status:** Candidate — `uisp_list_devices`, `uisp_get_device_status`, `uisp_list_sites`, `uisp_get_link_quality`. + +### bx (offsite client UISP) +- **Purpose:** **Off-site UISP instance for a client.** Reachable via NPM proxy entry `bx.alwisp.com` for convenience access. Not running on ALPHA — points to a client's public IP. +- **Network:** off-host (external WAN) +- **Address:** `71.45.182.201:1443` (HTTPS — client's public IP) +- **Public hostname:** `bx.alwisp.com` → `https://71.45.182.201:1443` +- **Category:** Infra / Networking (client-facing) +- **Owner:** third-party (client environment, exposed by client to Jason) +- **MCP Plugin Status:** **Skip** — client-owned, separate trust boundary. + +### NEBULA (Transmission) +- **Purpose:** Transmission BitTorrent daemon (named "NEBULA" — not the Nebula VPN/mesh product). General-purpose torrent client; pairs with `nyaa` for niche-source automation. +- **Image:** `lscr.io/linuxserver/transmission` +- **Network:** `br0` +- **Address:** `10.2.0.5:9091` (Transmission RPC / web UI) +- **Public hostname:** `neb.alwisp.com` → `http://10.2.0.5:9091` +- **Category:** Infra / Media Acquisition +- **Owner:** personal (third-party image) +- **MCP Plugin Status:** Candidate — `transmission_add_torrent`, `transmission_list_torrents`, `transmission_pause`, `transmission_remove`, `transmission_get_stats`. Pairs with `nyaa`. + +### syncthing +- **Purpose:** Peer-to-peer encrypted file sync across devices/servers. Used for keeping appdata, project files, and personal directories in sync without cloud dependencies. +- **Image:** `lscr.io/linuxserver/syncthing` +- **Network:** `bridge` +- **Address:** `10.2.0.2:8384` (web UI), `10.2.0.2:21027/UDP` (discovery) +- **Public hostname:** — (LAN-only) +- **Category:** Infra / File Sync +- **Owner:** infra (third-party) +- **MCP Plugin Status:** Candidate — `syncthing_list_folders`, `syncthing_folder_status`, `syncthing_list_devices`, `syncthing_pause_folder`, `syncthing_rescan`. + +--- + +## Source Control & CI/CD + +### Gitea +- **Purpose:** Self-hosted Git server. Primary source-control hub for all custom applications. Also hosts the **Gitea Container Registry** at the same host/port (proxied separately as `registry.alwisp.com` for clarity). +- **Image:** `gitea/gitea` +- **Network:** `br0` +- **Address:** `10.2.0.15:3000` +- **Public hostnames:** + - `git.alwisp.com` → `http://10.2.0.15:3000` (Git web UI + API) + - `registry.alwisp.com` → `http://10.2.0.15:3000` (Gitea container registry — same backend) +- **Category:** Source Control + Container Registry +- **Owner:** infra (third-party) +- **MCP Plugin Status:** Phase 1 — `gitea_list_repos`, `gitea_get_file`, `gitea_commit_file`, `gitea_list_issues`, `gitea_create_issue`, plus future registry tools (`registry_list_images`, `registry_get_image_tags`). + +### gitea-mcp +- **Purpose:** Existing standalone MCP bridge for Gitea (current production MCP endpoint). Reachable at `mcp.alwisp.com`. Will be **superseded by the Total MCP Gateway's `gitea` plugin** once Phase 1 lands. +- **Image:** `docker.gitea.com/.../mcp-server` +- **Network:** `br0` +- **Address:** `10.2.0.16:8081` +- **Public hostname:** `mcp.alwisp.com` → `http://10.2.0.16:8081` +- **Category:** MCP / Source Control bridge +- **Owner:** infra (third-party) +- **MCP Plugin Status:** Existing MCP server — to be replaced by gateway's `gitea` plugin. The replacement (`totalmcp`) lives on its own static IP `10.2.0.35:8811`, so this container can keep running undisturbed during the transition. + +### totalmcp +- **Purpose:** **THIS PROJECT.** Unified MCP gateway exposing every backend service on ALPHA (and off-host integrations like Home Assistant) as a single MCP endpoint for Claude Code, Codex, and Antigravity. Hot-reloadable plugin architecture; one stable URL per agent. +- **Image:** `git.alwisp.com/jason/totalmcp:latest` (built via Gitea Actions) +- **Network:** `br0` +- **Address:** `10.2.0.35:8811` (next available static IP, iterated above codedump @ `.34`) +- **Public hostname:** *(planned — likely `mcp.alwisp.com` once `gitea-mcp` is decommissioned, or a new `gw.alwisp.com` / `agents.alwisp.com`)* +- **Category:** MCP Gateway / Agent Control Plane +- **Owner:** personal (custom build — see [PLAN.md](PLAN.md)) +- **MCP Plugin Status:** **THIS PROJECT.** Phase roadmap in PLAN.md covers: Phase 1 (gitea, unraid), Phase 2 (docker, openclaw→NOVA), Phase 3 (unifi, codex-mrp, streamvault, rackmapper), Phase 6 (chronicle, obsidian — deferred), Phase 7 (npm, uisp, transmission, syncthing, plex, nyaa), Phase 8 (home-assistant), Phase 9 (invoiceninja, fabdash, cpas, wfh), Phase 10 (breedr, codedump, ui-tracker, stepview, qrknit, memer, alwisp-web). + +### Gitea-Runner +- **Purpose:** Gitea Actions runner (CI/CD executor). Builds Docker images for custom apps and pushes them to `git.alwisp.com` registry on every commit. +- **Image:** `gitea/act_runner` +- **Network:** `bridge` +- **Address:** `172.17.0.7` (no host port mapping) +- **Public hostname:** — +- **Category:** CI/CD +- **Owner:** infra (third-party) +- **MCP Plugin Status:** Indirect — managed via Gitea API. + +--- + +## AI & Compute + +### NOVA (OpenClaw) +- **Purpose:** **Jason's primary OpenClaw inference instance.** Local LLM runtime (Ollama-style, custom-branded). The `OpenClaw`-named container in the Unraid Docker tab serves this `nova.alwisp.com` proxy entry. Provides chat completion endpoint for local model use without sending data to external APIs. +- **Image:** `ghcr.io/opencla.../enclaw` +- **Network:** `br0` +- **Address:** `10.2.0.26:18789` +- **Public hostname:** `nova.alwisp.com` → `http://10.2.0.26:18789` +- **Category:** AI / Compute +- **Owner:** personal (custom build) +- **MCP Plugin Status:** Phase 2 (`openclaw` plugin) — endpoint URL: `http://10.2.0.26:18789`. Tools: `openclaw_chat`, `openclaw_list_models`, `openclaw_get_model_info`. + +### DONNA (OpenClaw — friend's instance) +- **Purpose:** **Second OpenClaw instance set up for a friend, who never uses it.** Currently stopped. Same image as NOVA, separate container at a different IP. +- **Image:** `ghcr.io/opencla.../enclaw` +- **Network:** `br0` +- **Address:** `10.2.0.28:18789` *(container currently stopped)* +- **Public hostname:** `donna.alwisp.com` → `http://10.2.0.28:18789` +- **Status:** Stopped (unused) +- **Category:** AI / Compute (shared with another user) +- **Owner:** personal (hosted for a friend) +- **MCP Plugin Status:** **Skip** — unused; not worth a plugin slot. If reactivated, reuse the OpenClaw plugin with a different endpoint URL. + +--- + +## Smart Home (off-host) + +### Home Assistant +- **Purpose:** Home Assistant — the central smart-home controller. **Hosted on its own dedicated machine** (not on ALPHA). Talks to `matter-server` (on ALPHA) for Matter-protocol devices, plus Zigbee, Z-Wave, and other integrations directly from the HA host. +- **Image:** N/A (lives on a separate physical/VM host) +- **Network:** off-host (LAN) +- **Address:** `10.2.0.12:8123` (HTTPS) +- **Public hostname:** `ha.alwisp.com` → `https://10.2.0.12:8123` +- **Category:** Smart Home (controller) +- **Owner:** personal (third-party software, separate host) +- **MCP Plugin Status:** Candidate — `ha_list_entities`, `ha_get_state`, `ha_call_service`, `ha_list_automations`, `ha_trigger_automation`, `ha_get_history`. Auth: long-lived access token from HA. **Note:** this is the higher-leverage smart-home plugin — `matter-server` is just the protocol bridge underneath HA. + +### matter-server +- **Purpose:** Matter protocol server. Bridges Matter-compliant smart-home devices to a controller (Home Assistant above). Enables device commissioning and inter-vendor smart-home interoperability. +- **Image:** `ghcr.io/home-assistant-libs/python-matter-server` (likely) +- **Network:** `host` +- **Address:** `10.2.0.2` (host networking — required for Matter mDNS) +- **Public hostname:** — +- **Category:** Smart Home / IoT (protocol bridge) +- **Owner:** personal (third-party) +- **MCP Plugin Status:** Candidate (lower priority than HA plugin) — direct Matter access. In practice, prefer `ha.*` tools because Home Assistant abstracts Matter + Zigbee + Z-Wave behind one API. + +--- + +## Custom Business Apps + +### cpas +- **Purpose:** **Conduct Points & Action System** — work HR app for tracking employee handbook violations on a points system. Records infractions, assigns point values, tracks running totals per employee, triggers escalation thresholds. +- **Image:** `library/cpas` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.14:3001` +- **Public hostnames:** + - `cpas.alwisp.com` (personal-brand alias) + - `cpas.mpm.to` (work-brand canonical) +- **Category:** HR / Compliance +- **Owner:** business (custom build) +- **MCP Plugin Status:** Candidate — `cpas_list_violations`, `cpas_get_employee_score`, `cpas_log_violation`, `cpas_list_at_risk_employees`. + +### fabdash +- **Purpose:** Fabrication calendar/dashboard for the production metal shop. Tracks shop schedule, work orders in flight, machine assignments, operator capacity. +- **Image:** `library/fabdash` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.13:8080` +- **Public hostname:** `fabdash.alwisp.com` → `http://10.2.0.13:8080` +- **Category:** Production / Operations +- **Owner:** business (custom build) +- **MCP Plugin Status:** Candidate — `fabdash_get_today_schedule`, `fabdash_list_active_jobs`, `fabdash_machine_status`, `fabdash_create_job`. + +### mrp (CODEX) +- **Purpose:** Original custom MRP/ERP system (image `mrp-codex`). Hybrid manufacturing-resource-planning + enterprise-resource-planning. Manages work orders, BOMs, inventory, purchasing. +- **Image:** `library/mrp-codex` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.19:3000` +- **Public hostname:** `mrp.alwisp.com` → `http://10.2.0.19:3000` +- **Category:** ERP / MRP +- **Owner:** business (custom build) +- **MCP Plugin Status:** Phase 3 (`codex-mrp`) — work orders, BOMs, inventory, purchasing. + +### mrp-qrcode +- **Purpose:** **Second-generation MRP**, specialized in pure manufacturing resource planning (no ERP overlap). QR-code-driven workflow — operators scan parts/locations to advance work orders. Independent of the original CODEX MRP. +- **Image:** `registry.alwisp.com/.../mrp-qrcode` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.32:3000` +- **Public hostname:** `qrmrp.alwisp.com` → `http://10.2.0.32:3000` +- **Category:** MRP (specialized) +- **Owner:** business (custom build) +- **MCP Plugin Status:** Future — separate plugin once stable. Distinct from `codex-mrp`. + +### inven +- **Purpose:** **Third custom MRP, in active development.** Different design philosophy from `mrp` (CODEX) and `mrp-qrcode`. Not yet production. +- **Image:** `library/inven` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.25` (port not yet exposed via NPM) +- **Public hostname:** — +- **Category:** MRP (in development) +- **Owner:** business (custom build) +- **MCP Plugin Status:** Future — wait until design stabilizes. + +### rackmapper +- **Purpose:** Server rack and datacenter inventory mapper. Tracks rack layouts, U-position of devices, cable runs, links physical hardware to logical services. +- **Image:** `library/rackmapper` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.23` (no public proxy) +- **Public hostname:** — +- **Category:** Infra Documentation +- **Owner:** business (custom build) +- **MCP Plugin Status:** Phase 3 — `rackmapper_list_racks`, `rackmapper_get_rack`, `rackmapper_list_devices`, `rackmapper_map_service`. + +### wfh +- **Purpose:** **Work-From-Home task tracker and form-submission portal.** Lets remote employees log daily tasks, submit timesheets, file required forms. +- **Image:** `library/wfh` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.18:3000` +- **Public hostnames:** + - `wfh.alwisp.com` (personal-brand alias) + - `wfh.mpm.to` (work-brand canonical) +- **Category:** HR / Remote Work +- **Owner:** business (custom build) +- **MCP Plugin Status:** Candidate — `wfh_list_tasks`, `wfh_get_employee_log`, `wfh_submit_form`, `wfh_list_pending_submissions`. + +### email-sigs +- **Purpose:** Company-wide email signature generator. Renders consistent, branded signatures from a central template. Used by all employees. +- **Image:** `library/email-sigs` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.10:3000` +- **Public hostname:** `sig.alwisp.com` → `http://10.2.0.10:3000` +- **Category:** HR / IT +- **Owner:** business (custom build) +- **MCP Plugin Status:** **Skip** — no API exists currently. Reconsider if API added. + +### codedump +- **Purpose:** Personal project tracker — quick-view dashboard for ongoing projects, completion percentages, rough status. Lightweight self-reporting alternative to a full PM tool. +- **Image:** `registry.alwisp.com/.../codedump` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.34` (no public proxy currently) +- **Public hostname:** — +- **Category:** Project Management (personal) +- **Owner:** personal (custom build) +- **MCP Plugin Status:** Candidate — `codedump_list_projects`, `codedump_get_project`, `codedump_update_completion`, `codedump_add_project`. + +### ui-tracker +- **Purpose:** **UniFi store stock tracker.** Watches Ubiquiti's online store for out-of-stock items and sends a Telegram message when an item comes back in stock. +- **Image:** `library/ui-tracker` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.29` (no public proxy) +- **Public hostname:** — +- **Category:** Monitoring / Notification +- **Owner:** personal (custom build) +- **MCP Plugin Status:** Candidate — `uitracker_list_watched`, `uitracker_add_watch`, `uitracker_remove_watch`, `uitracker_get_alert_history`. + +--- + +## Personal Apps + +### alwisp_web +- **Purpose:** Public HTML server for the **alwisp** personal-brand website. The root of `alwisp.com`. +- **Image:** `library/alwisp_web` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.8:80` +- **Public hostname:** `alwisp.com` → `http://10.2.0.8:80` +- **Category:** Personal Site +- **Owner:** personal (custom build) +- **MCP Plugin Status:** Candidate (low priority) — `alwisp_publish_page`, `alwisp_update_page`, `alwisp_list_pages`. + +### alwisp_db +- **Purpose:** Dedicated MySQL database for `alwisp_web`. Holds CMS / dynamic content for the personal-brand site. +- **Image:** `library/mysql` +- **Network:** `br0` +- **Address:** `10.2.0.7` (port 3306 internal) +- **Public hostname:** — +- **Category:** Database (service-owned) +- **Owner:** personal (third-party image, custom data) +- **MCP Plugin Status:** Indirect — accessed only via `alwisp_web`. + +### breedr +- **Purpose:** **Golden Retriever breeding/whelping calendar and genealogy app** for Jason's kennel. Tracks breeding pairs, due dates, whelping logs, litter records, multi-generational pedigree. +- **Image:** `library/breedr` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.17` (no public proxy) +- **Public hostname:** — +- **Category:** Personal / Hobby +- **Owner:** personal (custom build) +- **MCP Plugin Status:** Candidate — `breedr_list_dogs`, `breedr_get_pedigree`, `breedr_list_upcoming_litters`, `breedr_log_whelp_event`. + +### memer +- **Purpose:** Personal meme sharing and organizing app. Tag-based library, upload UI, shareable links. +- **Image:** `git.alwisp.com/.../memer` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.30:3000` +- **Public hostname:** `meme.alwisp.com` → `http://10.2.0.30:3000` +- **Category:** Personal / Media +- **Owner:** personal (custom build) +- **MCP Plugin Status:** Candidate (low priority) — `memer_search`, `memer_upload`, `memer_get_random`, `memer_list_tags`. + +--- + +## Commercial / SaaS + +### QR.knit +- **Purpose:** **Commercial short-link and QR code generation app.** Multi-tenant SaaS — generates branded QR codes and shortlinks. Public product, sold/licensed externally. Owns its own brand domain (`qrknit.com`). +- **Image:** `library/qrknit` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.9:5000` +- **Public hostnames:** + - `qrknit.com` → `http://10.2.0.9:5000` + - `www.qrknit.com` → `http://10.2.0.9:5000` +- **Category:** Commercial SaaS +- **Owner:** commercial (custom build) +- **MCP Plugin Status:** Candidate — `qrknit_create_link`, `qrknit_get_analytics`, `qrknit_list_links`, `qrknit_generate_qr`. + +--- + +## Productivity / PKM + +### obsidian +- **Purpose:** Obsidian vault hosted via the LinuxServer.io Obsidian image (web-accessible vault). Personal/business knowledge management. +- **Image:** `lscr.io/linuxserver/obsidian` +- **Network:** `bridge` +- **Address:** `10.2.0.2:3000` (web UI), `10.2.0.2:3001` (HTTPS) +- **Public hostname:** — +- **Category:** Notes / PKM +- **Owner:** personal (third-party) +- **MCP Plugin Status:** Phase 6 (deferred) — requires Obsidian Local REST API plugin first. Tools: `obsidian_note_create/read/update/append/search`, `obsidian_list_vault`. + +--- + +## Media & Entertainment + +### plex +- **Purpose:** Plex Media Server — movies, TV, music streaming to clients across the home and remote. +- **Image:** `lscr.io/linuxserver/plex` +- **Network:** `host` +- **Address:** `10.2.0.2` (host networking — Plex default ports 32400, 32469, etc.) +- **Public hostname:** — (Plex handles its own remote access via plex.tv tunnel) +- **Category:** Media / Entertainment +- **Owner:** personal (third-party) +- **MCP Plugin Status:** Candidate — `plex_search_library`, `plex_recently_added`, `plex_now_playing`, `plex_server_status`, `plex_list_libraries`. + +### nyaa +- **Purpose:** Custom **nyaa.si torrent crawler and auto-downloader**. Watches nyaa.si for matching titles, queues them into the torrent client when matches appear. +- **Image:** `library/nyaa` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.21` (no public proxy) +- **Public hostname:** — +- **Category:** Media Acquisition (automation) +- **Owner:** personal (custom build) +- **MCP Plugin Status:** Candidate — `nyaa_list_watches`, `nyaa_add_watch`, `nyaa_get_recent_matches`, `nyaa_force_check`. Pairs with NEBULA. + +--- + +## Access Control + +### unifi-access-dashboard +- **Purpose:** UniFi Access dashboard — door entry, badge/credential management, access events. Distinct from UISP (which manages UISP-line ISP gear). +- **Image:** `library/unifi-access-dashboard` (custom or thin wrapper) +- **Network:** `br0` +- **Address:** `10.2.0.11` +- **Public hostname:** — +- **Category:** Physical Security / Access Control +- **Owner:** business (custom or wrapped) +- **MCP Plugin Status:** Phase 3 (`unifi`) — `unifi_list_access_events`, `unifi_list_users`, `unifi_get_door_status`, `unifi_list_sites`. + +--- + +## Finance + +### invoiceninja-v5 +- **Purpose:** Invoice Ninja v5 — open-source invoicing, billing, expense tracking, client management. +- **Image:** `maihai/invoiceninja_v5` +- **Network:** `bridge` +- **Address:** `10.2.0.2:8000` (HTTP), `10.2.0.2:8444` (HTTPS) +- **Public hostname:** `inv.alwisp.com` → `http://10.2.0.2:8000` +- **Category:** Finance / Billing +- **Owner:** business (third-party) +- **MCP Plugin Status:** Candidate — `invoiceninja_list_invoices`, `invoiceninja_create_invoice`, `invoiceninja_list_clients`, `invoiceninja_get_payment_status`, `invoiceninja_send_invoice`. + +--- + +## 3D / CAD + +### stepview +- **Purpose:** Custom **STEP/STP (.stp/.step) 3D model viewer**. Lets clients view CAD models in a browser without being able to download the source files. Used for client preview while protecting IP. +- **Image:** `registry.alwisp.com/.../stepview` (custom) +- **Network:** `br0` +- **Address:** `10.2.0.33:3000` +- **Public hostname:** `step.alwisp.com` → `http://10.2.0.33:3000` +- **Category:** Engineering / Client Preview +- **Owner:** business (custom build) +- **MCP Plugin Status:** Candidate — `stepview_list_models`, `stepview_upload_model`, `stepview_create_share_link`, `stepview_revoke_share`. + +--- + +## Automation + +### n8n +- **Purpose:** n8n workflow automation engine. Currently stopped, barely used. +- **Image:** `n8nio/n8n` +- **Network:** `br0` +- **Address:** `10.2.0.20:5678` *(container currently stopped)* +- **Public hostname:** `n8n.alwisp.com` → `http://10.2.0.20:5678` +- **Status:** Stopped (barely used) +- **Category:** Automation +- **Owner:** infra (third-party) +- **MCP Plugin Status:** **Skip** — barely used per user. Skip unless usage picks up. + +--- + +## Databases (Shared) + +> **Rule:** These databases are accessed directly by the services that own them. The MCP gateway does **not** expose direct DB plugins — services should expose their own domain-specific tools instead. + +### MariaDB-Official +- **Purpose:** Shared MariaDB instance for general-purpose use by multiple services. +- **Image:** `library/mariadb` +- **Network:** `bridge` +- **Address:** `10.2.0.2:3306` +- **Public hostname:** — +- **Category:** Database (shared) +- **Owner:** infra (third-party) +- **MCP Plugin Status:** **Skip.** + +### postgresql16 +- **Purpose:** Shared PostgreSQL 16 instance. +- **Image:** `library/postgres` +- **Network:** `bridge` +- **Address:** `10.2.0.2:5432` +- **Public hostname:** — +- **Category:** Database (shared) +- **Owner:** infra (third-party) +- **MCP Plugin Status:** **Skip.** + +### Redis +- **Purpose:** Shared Redis cache / pub-sub. +- **Image:** `library/redis` +- **Network:** `bridge` +- **Address:** `10.2.0.2:6379` +- **Public hostname:** — +- **Category:** Database / Cache (shared) +- **Owner:** infra (third-party) +- **MCP Plugin Status:** **Skip.** + +### adminer +- **Purpose:** Web UI for browsing/editing the shared databases (MariaDB, PostgreSQL, MySQL `alwisp_db`). +- **Image:** `library/adminer` +- **Network:** `bridge` +- **Address:** `10.2.0.2:7070` +- **Public hostname:** — +- **Category:** DB Admin Tool +- **Owner:** infra (third-party) +- **MCP Plugin Status:** **Skip** — UI-only, not API-driven. + +--- + +## Decommissioned / Unused + +> NPM still has live proxy entries for these, but the underlying services are abandoned, replaced, or never reactivated. **Do not target these in MCP plugins.** Candidates for cleanup. + +### `to.alwisp.com` → `http://10.2.0.6:5000` +- **Purpose (historical):** Earlier custom URL shortener (Jason's first attempt). Replaced by QR.knit's link-shortening features. +- **Status:** **Unused** — superseded by QR.knit. Container at `10.2.0.6:5000` no longer relied on. +- **Recommendation:** Remove proxy entry and decommission container at next cleanup. + +### `url.alwisp.com` → `https://10.2.0.2:8080` +- **Purpose (historical):** Earlier short-link / URL service. +- **Status:** **Unused.** +- **Recommendation:** Remove proxy entry; investigate what (if anything) is still listening on `10.2.0.2:8080` before reusing the port. + +### n8n (covered above) +- See [n8n](#n8n) — container stopped, barely used. + +### DONNA (covered above) +- See [DONNA](#donna-openclaw--friends-instance) — second OpenClaw instance for a friend, never used, container stopped. + +--- + +## Networking Conventions + +### `br0` static-IP services (`10.2.0.0/24`) +Services on `br0` get their own LAN IP and are reachable directly from any device on the network. Used for: anything that needs to expose multiple ports cleanly, or where a stable LAN-routable address simplifies inter-service communication. + +### `bridge` (Docker NAT) services +Services on the default Docker `bridge` network sit behind the host (`10.2.0.2`) with port mappings. Used for: third-party images that don't fight with port conflicts and don't need their own LAN identity. + +### `host` networking +Used only when a service requires direct host networking (mDNS, broadcast, multicast, or wide port ranges) — currently `plex` and `matter-server`. + +### Off-host services +Reachable on the LAN but not running on ALPHA: **Home Assistant** (`10.2.0.12`) and the **client UISP** behind `bx.alwisp.com`. The MCP gateway can target these the same way it targets ALPHA services — it's just an outbound HTTP call. + +--- + +## Reverse Proxy Map (NPM) + +Reference of every NPM proxy host → backend, grouped by domain. Source: NPM admin at `internal.alwisp.com`. + +### `*.alwisp.com` (personal brand) + +| Hostname | → Backend | Service | +|---|---|---| +| `alwisp.com` | `http://10.2.0.8:80` | alwisp_web | +| `cpas.alwisp.com` | `http://10.2.0.14:3001` | cpas | +| `donna.alwisp.com` | `http://10.2.0.28:18789` | DONNA *(stopped)* | +| `fabdash.alwisp.com` | `http://10.2.0.13:8080` | fabdash | +| `git.alwisp.com` | `http://10.2.0.15:3000` | Gitea | +| `ha.alwisp.com` | `https://10.2.0.12:8123` | Home Assistant *(off-host)* | +| `internal.alwisp.com` | `http://10.2.0.3:81` | NPM admin UI | +| `inv.alwisp.com` | `http://10.2.0.2:8000` | invoiceninja-v5 | +| `mcp.alwisp.com` | `http://10.2.0.16:8081` | gitea-mcp | +| `meme.alwisp.com` | `http://10.2.0.30:3000` | memer | +| `mrp.alwisp.com` | `http://10.2.0.19:3000` | mrp (CODEX) | +| `n8n.alwisp.com` | `http://10.2.0.20:5678` | n8n *(stopped)* | +| `neb.alwisp.com` | `http://10.2.0.5:9091` | NEBULA (Transmission) | +| `nova.alwisp.com` | `http://10.2.0.26:18789` | NOVA (OpenClaw) | +| `qrmrp.alwisp.com` | `http://10.2.0.32:3000` | mrp-qrcode | +| `registry.alwisp.com` | `http://10.2.0.15:3000` | Gitea container registry | +| `sig.alwisp.com` | `http://10.2.0.10:3000` | email-sigs | +| `step.alwisp.com` | `http://10.2.0.33:3000` | stepview | +| `to.alwisp.com` | `http://10.2.0.6:5000` | *(decommissioned)* | +| `url.alwisp.com` | `https://10.2.0.2:8080` | *(decommissioned)* | +| `wfh.alwisp.com` | `http://10.2.0.18:3000` | wfh | +| `wisp.alwisp.com` | `https://10.2.0.4:443` | UISP | +| `bx.alwisp.com` | `https://71.45.182.201:1443` | Client UISP *(off-host)* | + +### `*.mpm.to` (work brand) + +| Hostname | → Backend | Service | +|---|---|---| +| `cpas.mpm.to` | `http://10.2.0.14:3001` | cpas (work alias) | +| `wfh.mpm.to` | `http://10.2.0.18:3000` | wfh (work alias) | + +### `qrknit.com` (commercial product) + +| Hostname | → Backend | Service | +|---|---|---| +| `qrknit.com` | `http://10.2.0.9:5000` | QR.knit | +| `www.qrknit.com` | `http://10.2.0.9:5000` | QR.knit | + +--- + +## Categorization Summary + +| Category | Services | +|---|---| +| **Infra / Networking** | NGINX (NPM), UISP, NEBULA (Transmission), syncthing, bx (client UISP, off-host) | +| **Source / CI** | Gitea, gitea-mcp, Gitea-Runner | +| **MCP Gateway** | totalmcp *(this project — planned, 10.2.0.35:8811)* | +| **AI / Compute** | NOVA (OpenClaw, primary), DONNA (OpenClaw, friend's, stopped) | +| **HR / Compliance** | cpas, wfh, email-sigs | +| **Production / MRP** | mrp (CODEX), mrp-qrcode, inven, fabdash | +| **Datacenter / Infra Mgmt** | rackmapper, ui-tracker | +| **Personal Apps** | alwisp_web, alwisp_db, breedr, codedump, memer | +| **Commercial SaaS** | QR.knit | +| **Productivity / PKM** | obsidian | +| **Media** | plex, nyaa, NEBULA | +| **Smart Home** | Home Assistant (off-host), matter-server | +| **Physical Security** | unifi-access-dashboard | +| **Finance** | invoiceninja-v5 | +| **Engineering / Client Preview** | stepview | +| **Automation** | n8n (stopped) | +| **Databases (shared)** | MariaDB, postgresql16, Redis, adminer | +| **Decommissioned** | to.alwisp.com, url.alwisp.com | + +--- + +## Owner Legend + +- **personal** — Jason's personal projects, hobbies, or self-hosted utilities (typically under `alwisp.com`) +- **business** — Custom apps for the metal-fab business / company operations (work-routed under `mpm.to`) +- **commercial** — Apps sold or licensed to external customers (under their own brand domain, e.g., `qrknit.com`) +- **infra** — Infrastructure plumbing (proxies, runners, AI runtime) +- **third-party** — Off-the-shelf images run as-is + +--- + +## Reuse Notes + +This document is intentionally project-agnostic. To reuse it in a different project: + +1. Drop this file into the new project's repo (root or `docs/`). +2. Trim sections that aren't relevant to that project's scope. +3. Update the **MCP Plugin Status** column if the new project has a different integration plan. +4. Keep the **Purpose**, **Image**, **Network**, **Address**, **Public hostname(s)**, and **Owner** fields stable — they describe the service itself, not the consumer's intent. +5. The **Reverse Proxy Map** section is the fastest way to get oriented — it's a one-page view of every public route and what it points at. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da0377d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + totalmcp: + build: . + container_name: totalmcp-dev + ports: + - "8811:8811" + env_file: + - .env + volumes: + - ./data:/app/data + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8811/health || exit 1"] + interval: 30s + timeout: 10s + start_period: 15s diff --git a/package.json b/package.json new file mode 100644 index 0000000..a2cf567 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "totalmcp", + "version": "0.1.0", + "description": "Unified MCP gateway for Jason's ALPHA stack — hot-reloadable plugin architecture exposing every backend service to Claude Code, Codex, and Antigravity.", + "type": "module", + "main": "dist/server.js", + "scripts": { + "build": "tsc", + "start": "node dist/server.js", + "dev": "tsx watch src/server.ts", + "typecheck": "tsc --noEmit", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "@prisma/client": "^5.22.0", + "chokidar": "^4.0.1", + "dotenv": "^16.4.5", + "express": "^5.0.1", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.5" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.9.0", + "prisma": "^5.22.0", + "tsx": "^4.19.2", + "typescript": "^5.6.3" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..bcf5995 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,26 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:../data/events.db" +} + +// One row per tool invocation. Used for usage analytics, debugging, and +// rate-limit accounting. Phase 5 wires the writes; Phase 0 only ships the schema. +model EventLog { + id Int @id @default(autoincrement()) + ts DateTime @default(now()) + agent String + pluginName String + toolName String + inputHash String + durationMs Int + success Boolean + errorMsg String? + + @@index([ts]) + @@index([agent]) + @@index([pluginName, toolName]) +} diff --git a/src/auth/bearer.ts b/src/auth/bearer.ts new file mode 100644 index 0000000..5a70084 --- /dev/null +++ b/src/auth/bearer.ts @@ -0,0 +1,37 @@ +import type { Request, Response, NextFunction } from "express"; + +export type AgentTokenMap = Map; + +declare module "express-serve-static-core" { + interface Request { + agent?: string; + } +} + +export function parseAgentTokens(envValue: string): AgentTokenMap { + const map: AgentTokenMap = new Map(); + if (!envValue) return map; + for (const pair of envValue.split(",")) { + const [agent, token] = pair.split(":").map((s) => s.trim()); + if (agent && token) map.set(token, agent); + } + return map; +} + +export function bearerAuth(tokens: AgentTokenMap) { + return (req: Request, res: Response, next: NextFunction): void => { + const header = req.header("authorization") ?? ""; + const m = /^Bearer\s+(.+)$/i.exec(header); + if (!m) { + res.status(401).json({ error: "missing_bearer" }); + return; + } + const agent = tokens.get(m[1]); + if (!agent) { + res.status(401).json({ error: "invalid_token" }); + return; + } + req.agent = agent; + next(); + }; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d80e7b8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,30 @@ +import { config as loadEnv } from "dotenv"; +import { z } from "zod"; + +loadEnv(); + +const schema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("production"), + PORT: z.coerce.number().int().positive().default(8811), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), + GATEWAY_VERSION: z.string().default("0.1.0"), + ENABLED_PLUGINS: z.string().default(""), + AGENT_TOKENS: z.string().default(""), + PLUGINS_DIR: z.string().default("./dist/plugins"), +}); + +const parsed = schema.safeParse(process.env); +if (!parsed.success) { + // Boot-time validation failure: print and exit. Never let a misconfigured + // gateway start up partially. + console.error("Invalid environment configuration:", parsed.error.format()); + process.exit(1); +} + +export const env = parsed.data; + +export const enabledPlugins = env.ENABLED_PLUGINS + ? env.ENABLED_PLUGINS.split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..4f4965a --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,24 @@ +import { env } from "./config.js"; + +const levels = { debug: 10, info: 20, warn: 30, error: 40 } as const; +type Level = keyof typeof levels; + +const threshold = levels[env.LOG_LEVEL]; + +function emit(level: Level, msg: string, fields?: Record): void { + if (levels[level] < threshold) return; + const record = { + ts: new Date().toISOString(), + level, + msg, + ...fields, + }; + process.stdout.write(JSON.stringify(record) + "\n"); +} + +export const log = { + debug: (msg: string, f?: Record) => emit("debug", msg, f), + info: (msg: string, f?: Record) => emit("info", msg, f), + warn: (msg: string, f?: Record) => emit("warn", msg, f), + error: (msg: string, f?: Record) => emit("error", msg, f), +}; diff --git a/src/mcp/build.ts b/src/mcp/build.ts new file mode 100644 index 0000000..41178ca --- /dev/null +++ b/src/mcp/build.ts @@ -0,0 +1,72 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { log } from "../logger.js"; +import type { PluginRegistry } from "../registry.js"; + +// Plugins are responsible for providing fully-qualified tool names +// (e.g., "gitea_list_repos", "unraid_host_summary"). The registry does not +// auto-prefix because tool prefix and plugin directory name don't always match +// (see plugins/codex-mrp → tools start with "codex_"). +export function buildMcpServer(registry: PluginRegistry, gatewayVersion: string): Server { + const server = new Server( + { name: "totalmcp", version: gatewayVersion }, + { capabilities: { tools: {} } } + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = registry.allTools().map(({ tool }) => ({ + name: tool.name, + description: tool.description, + inputSchema: zodToJsonSchema(tool.inputSchema, { target: "openApi3" }) as Record< + string, + unknown + >, + })); + return { tools }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const found = registry.allTools().find(({ tool }) => tool.name === name); + if (!found) { + return errorResult(`tool not found: ${name}`); + } + const { tool } = found; + try { + const parsed = tool.inputSchema.safeParse(args ?? {}); + if (!parsed.success) { + return errorResult(`invalid arguments: ${parsed.error.message}`); + } + const result = await tool.handler(parsed.data); + return { + content: [ + { + type: "text", + text: typeof result === "string" ? result : JSON.stringify(result), + }, + ], + }; + } catch (err) { + log.error("tool_call_failed", { tool: name, err: errString(err) }); + return errorResult(`Error: ${errString(err)}`); + } + }); + + return server; +} + +function errorResult(text: string) { + return { + isError: true, + content: [{ type: "text" as const, text }], + }; +} + +function errString(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/src/plugins/.gitkeep b/src/plugins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/plugins/gitea/index.ts b/src/plugins/gitea/index.ts new file mode 100644 index 0000000..0a4b8be --- /dev/null +++ b/src/plugins/gitea/index.ts @@ -0,0 +1,302 @@ +import { z } from "zod"; +import { log } from "../../logger.js"; +import { httpRequest, errString } from "../../util/http.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +const config = { + host: process.env.GITEA_HOST?.trim() || "https://git.alwisp.com", + token: process.env.GITEA_TOKEN?.trim() ?? "", +}; + +function authHeaders(): Record { + return config.token ? { Authorization: `token ${config.token}` } : {}; +} + +async function gitea( + path: string, + init: { + method?: string; + body?: unknown; + params?: Record; + } = {} +): Promise { + return httpRequest(config.host, `/api/v1${path}`, { + method: init.method, + body: init.body, + params: init.params, + headers: authHeaders(), + }); +} + +// ---------- Schemas ---------- + +const RepoRefSchema = z.object({ + owner: z.string().min(1).describe("Repository owner (user or org)"), + repo: z.string().min(1).describe("Repository name"), +}); + +const ListReposSchema = z.object({ + query: z.string().optional().describe("Optional search query (matches name/description)"), + limit: z.number().int().min(1).max(50).default(20), +}); + +const CreateRepoSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + private: z.boolean().default(true), + autoInit: z.boolean().default(true), +}); + +const ListIssuesSchema = RepoRefSchema.extend({ + state: z.enum(["open", "closed", "all"]).default("open"), + limit: z.number().int().min(1).max(50).default(20), +}); + +const CreateIssueSchema = RepoRefSchema.extend({ + title: z.string().min(1), + body: z.string().optional(), +}); + +const GetFileSchema = RepoRefSchema.extend({ + path: z.string().min(1).describe("File path within the repo (no leading slash)"), + ref: z.string().optional().describe("Branch, tag, or commit SHA (defaults to default branch)"), +}); + +const CommitFileSchema = RepoRefSchema.extend({ + path: z.string().min(1), + content: z.string().describe("File content as UTF-8 text (will be base64-encoded for the API)"), + message: z.string().min(1).describe("Commit message"), + branch: z.string().default("main"), + sha: z + .string() + .optional() + .describe("Required when updating an existing file — call gitea_get_file first to obtain"), +}); + +// ---------- Response shapes ---------- + +interface GiteaRepo { + id: number; + full_name: string; + description: string; + private: boolean; + fork: boolean; + html_url: string; + ssh_url: string; + default_branch: string; + updated_at: string; +} + +interface GiteaIssue { + number: number; + title: string; + state: string; + user?: { login: string }; + created_at: string; + updated_at: string; + html_url: string; + body: string; +} + +interface GiteaBranch { + name: string; + commit?: { id: string }; + protected: boolean; +} + +interface GiteaFileContent { + path: string; + sha: string; + encoding?: string; + content?: string; +} + +function slimRepo(r: GiteaRepo) { + return { + fullName: r.full_name, + description: r.description, + private: r.private, + fork: r.fork, + htmlUrl: r.html_url, + sshUrl: r.ssh_url, + defaultBranch: r.default_branch, + updatedAt: r.updated_at, + }; +} + +function slimIssue(i: GiteaIssue) { + return { + number: i.number, + title: i.title, + state: i.state, + author: i.user?.login, + body: i.body, + htmlUrl: i.html_url, + createdAt: i.created_at, + updatedAt: i.updated_at, + }; +} + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "gitea", + version: "0.1.0", + description: "Self-hosted Gitea Git server (default: git.alwisp.com)", + minGatewayVersion: "0.1.0", + + async onLoad() { + if (!config.token) { + log.warn("gitea_no_token", { + hint: "GITEA_TOKEN env var is empty — calls will fail with 401", + }); + return; + } + try { + const me = await gitea<{ login: string }>("/user"); + log.info("gitea_connected", { user: me.login, host: config.host }); + } catch (err) { + log.error("gitea_connect_failed", { err: errString(err) }); + } + }, + + tools: [ + { + name: "gitea_list_repos", + description: "Search/list repositories. Returns repos accessible to the authenticated user.", + inputSchema: ListReposSchema, + handler: async (raw) => { + const args = ListReposSchema.parse(raw); + const data = await gitea<{ data: GiteaRepo[] }>("/repos/search", { + params: { q: args.query, limit: args.limit }, + }); + return { repos: data.data.map(slimRepo) }; + }, + }, + { + name: "gitea_get_repo", + description: "Fetch details for a single repository.", + inputSchema: RepoRefSchema, + handler: async (raw) => { + const args = RepoRefSchema.parse(raw); + const repo = await gitea(`/repos/${args.owner}/${args.repo}`); + return slimRepo(repo); + }, + }, + { + name: "gitea_create_repo", + description: "Create a new repository owned by the authenticated user.", + inputSchema: CreateRepoSchema, + handler: async (raw) => { + const args = CreateRepoSchema.parse(raw); + const repo = await gitea("/user/repos", { + method: "POST", + body: { + name: args.name, + description: args.description, + private: args.private, + auto_init: args.autoInit, + }, + }); + return slimRepo(repo); + }, + }, + { + name: "gitea_list_issues", + description: "List issues for a repository. State defaults to open.", + inputSchema: ListIssuesSchema, + handler: async (raw) => { + const args = ListIssuesSchema.parse(raw); + const issues = await gitea( + `/repos/${args.owner}/${args.repo}/issues`, + { params: { state: args.state, limit: args.limit, type: "issues" } } + ); + return { issues: issues.map(slimIssue) }; + }, + }, + { + name: "gitea_create_issue", + description: "Open a new issue on a repository.", + inputSchema: CreateIssueSchema, + handler: async (raw) => { + const args = CreateIssueSchema.parse(raw); + const issue = await gitea( + `/repos/${args.owner}/${args.repo}/issues`, + { method: "POST", body: { title: args.title, body: args.body ?? "" } } + ); + return slimIssue(issue); + }, + }, + { + name: "gitea_list_branches", + description: "List branches for a repository.", + inputSchema: RepoRefSchema, + handler: async (raw) => { + const args = RepoRefSchema.parse(raw); + const branches = await gitea( + `/repos/${args.owner}/${args.repo}/branches` + ); + return { + branches: branches.map((b) => ({ + name: b.name, + commit: b.commit?.id, + protected: b.protected, + })), + }; + }, + }, + { + name: "gitea_get_file", + description: + "Read a file from a repo at the given path (and optional ref). Returns decoded UTF-8 content plus the file SHA needed for updates.", + inputSchema: GetFileSchema, + handler: async (raw) => { + const args = GetFileSchema.parse(raw); + const data = await gitea( + `/repos/${args.owner}/${args.repo}/contents/${args.path}`, + { params: { ref: args.ref } } + ); + if (data.encoding === "base64" && data.content) { + const decoded = Buffer.from(data.content, "base64").toString("utf8"); + return { path: data.path, sha: data.sha, content: decoded, encoding: "utf8" }; + } + return { + path: data.path, + sha: data.sha, + content: data.content ?? "", + encoding: data.encoding ?? "raw", + }; + }, + }, + { + name: "gitea_commit_file", + description: + "Create or update a file. Omit `sha` to create; pass `sha` from gitea_get_file to update.", + inputSchema: CommitFileSchema, + handler: async (raw) => { + const args = CommitFileSchema.parse(raw); + const body = { + message: args.message, + content: Buffer.from(args.content, "utf8").toString("base64"), + branch: args.branch, + ...(args.sha ? { sha: args.sha } : {}), + }; + const result = await gitea<{ + commit: { sha: string; html_url: string }; + content: { sha: string; path: string }; + }>(`/repos/${args.owner}/${args.repo}/contents/${args.path}`, { + method: args.sha ? "PUT" : "POST", + body, + }); + return { + commitSha: result.commit.sha, + commitUrl: result.commit.html_url, + fileSha: result.content.sha, + path: result.content.path, + }; + }, + }, + ], +}; + +export default plugin; diff --git a/src/plugins/unraid/index.ts b/src/plugins/unraid/index.ts new file mode 100644 index 0000000..82a7d8e --- /dev/null +++ b/src/plugins/unraid/index.ts @@ -0,0 +1,269 @@ +import { z } from "zod"; +import { log } from "../../logger.js"; +import { httpRequest, errString } from "../../util/http.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +// Unraid exposes a GraphQL endpoint at {host}/graphql via the unraid-api +// package (https://github.com/unraid/api). Auth is the API key generated by +// `unraid-api start`. The exact schema evolves between Unraid versions — +// adjust the queries below if your endpoint returns different field shapes. +const config = { + host: process.env.UNRAID_HOST?.trim() || "http://10.2.0.2", + apiKey: process.env.UNRAID_API_KEY?.trim() ?? "", +}; + +interface GraphQLResponse { + data?: T; + errors?: Array<{ message: string }>; +} + +async function gql(query: string, variables: Record = {}): Promise { + const res = await httpRequest>(config.host, "/graphql", { + method: "POST", + headers: { "x-api-key": config.apiKey }, + body: { query, variables }, + }); + if (res.errors?.length) { + throw new Error(`Unraid GraphQL: ${res.errors.map((e) => e.message).join("; ")}`); + } + if (!res.data) { + throw new Error("Unraid GraphQL: empty response"); + } + return res.data; +} + +// ---------- Schemas ---------- + +const NoArgsSchema = z.object({}); + +const ContainerNameSchema = z.object({ + name: z.string().min(1).describe("Container name (without leading slash)"), +}); + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "unraid", + version: "0.1.0", + description: "Unraid host telemetry, Docker/VM/share/disk inventory via the unraid-api GraphQL endpoint", + minGatewayVersion: "0.1.0", + + async onLoad() { + if (!config.apiKey) { + log.warn("unraid_no_api_key", { + hint: "UNRAID_API_KEY env var is empty — calls will fail", + }); + return; + } + try { + const data = await gql<{ info: { os: { hostname: string } } }>( + `query { info { os { hostname } } }` + ); + log.info("unraid_connected", { host: config.host, hostname: data.info.os.hostname }); + } catch (err) { + log.error("unraid_connect_failed", { err: errString(err) }); + } + }, + + tools: [ + { + name: "unraid_host_summary", + description: + "Hostname, OS version, uptime, CPU, memory, and array state in one call. Use this for a quick system snapshot.", + inputSchema: NoArgsSchema, + handler: async () => { + const data = await gql<{ + info: { + os: { hostname: string; uptime: string; distro: string; release: string }; + cpu: { manufacturer: string; brand: string; cores: number; threads: number }; + memory: { total: string; free: string; used: string }; + }; + array: { + state: string; + capacity: { kilobytes: { total: string; used: string; free: string } }; + }; + }>(` + query HostSummary { + info { + os { hostname uptime distro release } + cpu { manufacturer brand cores threads } + memory { total free used } + } + array { + state + capacity { kilobytes { total used free } } + } + } + `); + return { + host: data.info.os.hostname, + os: `${data.info.os.distro} ${data.info.os.release}`, + uptime: data.info.os.uptime, + cpu: `${data.info.cpu.manufacturer} ${data.info.cpu.brand}`.trim(), + cores: data.info.cpu.cores, + threads: data.info.cpu.threads, + memory: data.info.memory, + array: { + state: data.array.state, + capacity: data.array.capacity.kilobytes, + }, + }; + }, + }, + { + name: "unraid_list_containers", + description: "List all Docker containers managed by Unraid with state, image, and auto-start flag.", + inputSchema: NoArgsSchema, + handler: async () => { + const data = await gql<{ + docker: { + containers: Array<{ + id: string; + names: string[]; + image: string; + state: string; + status: string; + autoStart: boolean; + }>; + }; + }>(` + query ListContainers { + docker { containers { id names image state status autoStart } } + } + `); + return { + containers: data.docker.containers.map((c) => ({ + id: c.id.slice(0, 12), + name: stripLeadingSlash(c.names[0] ?? ""), + image: c.image, + state: c.state, + status: c.status, + autoStart: c.autoStart, + })), + }; + }, + }, + { + name: "unraid_get_container", + description: "Detail for a single container by name — includes ports and labels.", + inputSchema: ContainerNameSchema, + handler: async (raw) => { + const args = ContainerNameSchema.parse(raw); + const data = await gql<{ + docker: { + containers: Array<{ + id: string; + names: string[]; + image: string; + state: string; + status: string; + autoStart: boolean; + ports: Array<{ + ip?: string; + privatePort: number; + publicPort?: number; + type: string; + }>; + labels?: Record; + }>; + }; + }>(` + query ListContainersDetailed { + docker { + containers { + id names image state status autoStart + ports { ip privatePort publicPort type } + labels + } + } + } + `); + const container = data.docker.containers.find((c) => + c.names.some((n) => stripLeadingSlash(n) === args.name) + ); + if (!container) { + throw new Error(`container not found: ${args.name}`); + } + return { + id: container.id, + name: stripLeadingSlash(container.names[0] ?? ""), + image: container.image, + state: container.state, + status: container.status, + autoStart: container.autoStart, + ports: container.ports, + labels: container.labels ?? {}, + }; + }, + }, + { + name: "unraid_list_shares", + description: "List user shares with allocator, cache strategy, and usage.", + inputSchema: NoArgsSchema, + handler: async () => { + const data = await gql<{ + shares: Array<{ + name: string; + comment: string; + size: string; + used: string; + free: string; + allocator: string; + cache: string; + include: string[]; + exclude: string[]; + }>; + }>(` + query ListShares { + shares { name comment size used free allocator cache include exclude } + } + `); + return { shares: data.shares }; + }, + }, + { + name: "unraid_list_vms", + description: "List virtual machines and their current state.", + inputSchema: NoArgsSchema, + handler: async () => { + const data = await gql<{ + vms: { domain: Array<{ uuid: string; name: string; state: string }> }; + }>(` + query ListVms { + vms { domain { uuid name state } } + } + `); + return { vms: data.vms.domain }; + }, + }, + { + name: "unraid_disk_health", + description: "Disk inventory with S.M.A.R.T. status and temperature.", + inputSchema: NoArgsSchema, + handler: async () => { + const data = await gql<{ + disks: Array<{ + id: string; + name: string; + type: string; + size: string; + smartStatus: string; + temperature: number; + serialNumber: string; + }>; + }>(` + query DiskHealth { + disks { id name type size smartStatus temperature serialNumber } + } + `); + return { disks: data.disks }; + }, + }, + ], +}; + +function stripLeadingSlash(s: string): string { + return s.startsWith("/") ? s.slice(1) : s; +} + +export default plugin; diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..810a9ad --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,196 @@ +import path from "node:path"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import chokidar, { type FSWatcher } from "chokidar"; +import { log } from "./logger.js"; +import { env, enabledPlugins } from "./config.js"; +import type { MCPPlugin, MCPTool } from "./types/plugin.js"; + +interface LoadedPlugin { + plugin: MCPPlugin; + filePath: string; +} + +export class PluginRegistry { + private plugins = new Map(); + private watcher?: FSWatcher; + private listeners = new Set<() => void>(); + + constructor(private pluginsDir: string, private gatewayVersion: string) {} + + async loadAll(): Promise { + const dir = path.resolve(this.pluginsDir); + if (!existsSync(dir)) { + log.warn("plugins_dir_missing", { dir }); + return; + } + const entries = readdirSync(dir); + for (const name of entries) { + const full = path.join(dir, name); + if (!statSync(full).isDirectory()) continue; + if (enabledPlugins.length > 0 && !enabledPlugins.includes(name)) { + log.debug("plugin_not_enabled", { name }); + continue; + } + await this.loadPlugin(name); + } + } + + async loadPlugin(name: string): Promise { + try { + const filePath = path.resolve(this.pluginsDir, name, "index.js"); + if (!existsSync(filePath)) { + log.warn("plugin_entry_missing", { name, filePath }); + return; + } + // Cache-bust on reload by appending a query string to the file URL. + const url = pathToFileURL(filePath).href + `?t=${Date.now()}`; + const mod = (await import(url)) as { default?: MCPPlugin; plugin?: MCPPlugin }; + const plugin = mod.default ?? mod.plugin; + if (!plugin || typeof plugin !== "object" || !plugin.name) { + log.error("plugin_invalid_export", { name }); + return; + } + if (!isCompatible(plugin.minGatewayVersion, this.gatewayVersion)) { + log.warn("plugin_incompatible", { + name: plugin.name, + required: plugin.minGatewayVersion, + gateway: this.gatewayVersion, + }); + return; + } + const existing = this.plugins.get(plugin.name); + if (existing) { + await safeUnload(existing.plugin); + } + try { + await plugin.onLoad?.(); + } catch (err) { + log.error("plugin_onLoad_failed", { + name: plugin.name, + err: errString(err), + }); + return; + } + this.plugins.set(plugin.name, { plugin, filePath }); + log.info("plugin_loaded", { + name: plugin.name, + version: plugin.version, + tools: plugin.tools.length, + }); + this.emitChange(); + } catch (err) { + log.error("plugin_load_failed", { name, err: errString(err) }); + } + } + + async unloadPlugin(name: string): Promise { + const entry = this.plugins.get(name); + if (!entry) return; + await safeUnload(entry.plugin); + this.plugins.delete(name); + log.info("plugin_unloaded", { name }); + this.emitChange(); + } + + list() { + return Array.from(this.plugins.values()).map(({ plugin }) => ({ + name: plugin.name, + version: plugin.version, + description: plugin.description, + toolCount: plugin.tools.length, + resourceCount: plugin.resources?.length ?? 0, + promptCount: plugin.prompts?.length ?? 0, + })); + } + + get(name: string): MCPPlugin | undefined { + return this.plugins.get(name)?.plugin; + } + + allTools(): Array<{ pluginName: string; tool: MCPTool }> { + const out: Array<{ pluginName: string; tool: MCPTool }> = []; + for (const { plugin } of this.plugins.values()) { + for (const tool of plugin.tools) { + out.push({ pluginName: plugin.name, tool }); + } + } + return out; + } + + watch(): void { + if (this.watcher) return; + if (env.NODE_ENV === "production") { + log.info("plugin_watch_disabled_production"); + return; + } + const dir = path.resolve(this.pluginsDir); + this.watcher = chokidar.watch(dir, { + ignored: (p) => p.includes("node_modules"), + persistent: true, + ignoreInitial: true, + depth: 3, + }); + this.watcher.on("all", (event, filePath) => { + const rel = path.relative(dir, filePath); + const pluginName = rel.split(path.sep)[0]; + if (!pluginName) return; + log.debug("plugin_change", { event, plugin: pluginName }); + void this.loadPlugin(pluginName); + }); + log.info("plugin_watch_started", { dir }); + } + + onChange(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emitChange(): void { + for (const fn of this.listeners) { + try { + fn(); + } catch (err) { + log.error("registry_listener_failed", { err: errString(err) }); + } + } + } + + async stop(): Promise { + if (this.watcher) { + await this.watcher.close(); + this.watcher = undefined; + } + for (const { plugin } of this.plugins.values()) { + await safeUnload(plugin); + } + this.plugins.clear(); + } +} + +async function safeUnload(plugin: MCPPlugin): Promise { + try { + await plugin.onUnload?.(); + } catch (err) { + log.error("plugin_onUnload_failed", { name: plugin.name, err: errString(err) }); + } +} + +function errString(err: unknown): string { + if (err instanceof Error) return err.stack ?? err.message; + return String(err); +} + +// Plugin compat check: gateway must be >= the plugin's minGatewayVersion. +// Simple major.minor.patch comparison; sufficient for our internal versioning. +function isCompatible(pluginMin: string, gateway: string): boolean { + const parse = (v: string): [number, number, number] => { + const [ma, mi, pa] = v.split(".").map((n) => parseInt(n, 10) || 0); + return [ma ?? 0, mi ?? 0, pa ?? 0]; + }; + const [pma, pmi, ppa] = parse(pluginMin); + const [gma, gmi, gpa] = parse(gateway); + if (gma !== pma) return gma > pma; + if (gmi !== pmi) return gmi > pmi; + return gpa >= ppa; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..d356770 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,67 @@ +import express from "express"; +import { env, enabledPlugins } from "./config.js"; +import { log } from "./logger.js"; +import { PluginRegistry } from "./registry.js"; +import { parseAgentTokens, bearerAuth } from "./auth/bearer.js"; +import { streamableRouter } from "./transport/streamable.js"; +import { sseRouter } from "./transport/sse.js"; + +async function main(): Promise { + const tokens = parseAgentTokens(env.AGENT_TOKENS); + if (tokens.size === 0) { + log.warn("no_agent_tokens_configured", { + hint: "AGENT_TOKENS env var is empty — every authenticated request will 401", + }); + } + const auth = bearerAuth(tokens); + + const registry = new PluginRegistry(env.PLUGINS_DIR, env.GATEWAY_VERSION); + await registry.loadAll(); + registry.watch(); + + const app = express(); + app.use(express.json({ limit: "4mb" })); + + // Health is intentionally unauthenticated — Unraid healthcheck + external + // monitors need to hit it without credentials. + app.get("/health", (_req, res) => { + res.json({ + status: "ok", + version: env.GATEWAY_VERSION, + plugins: registry.list().length, + enabled: enabledPlugins, + }); + }); + + app.get("/plugins", auth, (_req, res) => { + res.json({ plugins: registry.list() }); + }); + + app.use("/mcp", auth, streamableRouter(registry, env.GATEWAY_VERSION)); + app.use(auth, sseRouter(registry, env.GATEWAY_VERSION)); + + const server = app.listen(env.PORT, () => { + log.info("gateway_listening", { + port: env.PORT, + version: env.GATEWAY_VERSION, + env: env.NODE_ENV, + plugins: registry.list().length, + }); + }); + + const shutdown = async (signal: string): Promise => { + log.info("gateway_shutdown_starting", { signal }); + server.close(); + await registry.stop(); + process.exit(0); + }; + process.on("SIGTERM", () => void shutdown("SIGTERM")); + process.on("SIGINT", () => void shutdown("SIGINT")); +} + +main().catch((err) => { + log.error("gateway_boot_failed", { + err: err instanceof Error ? (err.stack ?? err.message) : String(err), + }); + process.exit(1); +}); diff --git a/src/transport/sse.ts b/src/transport/sse.ts new file mode 100644 index 0000000..09da995 --- /dev/null +++ b/src/transport/sse.ts @@ -0,0 +1,38 @@ +import express, { type Request, type Response, type Router } from "express"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { log } from "../logger.js"; +import { buildMcpServer } from "../mcp/build.js"; +import type { PluginRegistry } from "../registry.js"; + +export function sseRouter(registry: PluginRegistry, gatewayVersion: string): Router { + const router = express.Router(); + const transports = new Map(); + + router.get("/sse", async (_req: Request, res: Response) => { + const transport = new SSEServerTransport("/message", res); + transports.set(transport.sessionId, transport); + res.on("close", () => { + transports.delete(transport.sessionId); + log.info("sse_session_closed", { sessionId: transport.sessionId }); + }); + const server = buildMcpServer(registry, gatewayVersion); + await server.connect(transport); + log.info("sse_session_started", { sessionId: transport.sessionId }); + }); + + router.post("/message", async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string | undefined; + if (!sessionId) { + res.status(400).send("sessionId query param required"); + return; + } + const transport = transports.get(sessionId); + if (!transport) { + res.status(404).send("session not found"); + return; + } + await transport.handlePostMessage(req, res, req.body); + }); + + return router; +} diff --git a/src/transport/streamable.ts b/src/transport/streamable.ts new file mode 100644 index 0000000..c8bd2c9 --- /dev/null +++ b/src/transport/streamable.ts @@ -0,0 +1,58 @@ +import express, { type Request, type Response, type Router } from "express"; +import { randomUUID } from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { log } from "../logger.js"; +import { buildMcpServer } from "../mcp/build.js"; +import type { PluginRegistry } from "../registry.js"; + +export function streamableRouter(registry: PluginRegistry, gatewayVersion: string): Router { + const router = express.Router(); + const transports = new Map(); + + router.post("/", async (req: Request, res: Response) => { + const sessionId = req.header("mcp-session-id"); + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports.has(sessionId)) { + transport = transports.get(sessionId)!; + } else if (!sessionId) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + transports.set(id, transport); + log.info("mcp_session_started", { sessionId: id }); + }, + }); + transport.onclose = () => { + if (transport.sessionId) { + transports.delete(transport.sessionId); + log.info("mcp_session_closed", { sessionId: transport.sessionId }); + } + }; + const server = buildMcpServer(registry, gatewayVersion); + await server.connect(transport); + } else { + res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "session not found" }, + id: null, + }); + return; + } + await transport.handleRequest(req, res, req.body); + }); + + // GET handles server-initiated streams; DELETE closes the session. + for (const method of ["get", "delete"] as const) { + router[method]("/", async (req: Request, res: Response) => { + const sessionId = req.header("mcp-session-id"); + if (!sessionId || !transports.has(sessionId)) { + res.status(400).send("session required"); + return; + } + await transports.get(sessionId)!.handleRequest(req, res); + }); + } + + return router; +} diff --git a/src/types/plugin.ts b/src/types/plugin.ts new file mode 100644 index 0000000..96db268 --- /dev/null +++ b/src/types/plugin.ts @@ -0,0 +1,56 @@ +import type { ZodTypeAny } from "zod"; + +export interface MCPTool { + // Fully-qualified tool name. Convention: "_" (e.g., "gitea_list_repos", + // "unraid_host_summary"). Must be unique across all loaded plugins. + name: string; + description: string; + inputSchema: ZodTypeAny; + handler: (input: unknown) => Promise; +} + +export interface MCPResourceContent { + uri: string; + mimeType?: string; + text: string; +} + +export interface MCPResource { + uri: string; + name: string; + description?: string; + mimeType?: string; + handler: () => Promise<{ contents: MCPResourceContent[] }>; +} + +export interface MCPPromptArg { + name: string; + description: string; + required?: boolean; +} + +export interface MCPPromptResult { + messages: Array<{ + role: "user" | "assistant" | "system"; + content: { type: "text"; text: string }; + }>; +} + +export interface MCPPrompt { + name: string; + description: string; + arguments?: MCPPromptArg[]; + handler: (args: Record) => Promise; +} + +export interface MCPPlugin { + name: string; + version: string; + description: string; + minGatewayVersion: string; + tools: MCPTool[]; + resources?: MCPResource[]; + prompts?: MCPPrompt[]; + onLoad?: () => Promise; + onUnload?: () => Promise; +} diff --git a/src/util/http.ts b/src/util/http.ts new file mode 100644 index 0000000..bcd1de9 --- /dev/null +++ b/src/util/http.ts @@ -0,0 +1,78 @@ +// Minimal HTTP helper used by plugins. Wraps `fetch` with timeout, JSON +// serialization, and structured error reporting so plugin handlers don't have +// to repeat the same boilerplate. + +export interface HttpRequestOptions { + method?: string; + headers?: Record; + body?: unknown; + params?: Record; + timeoutMs?: number; +} + +export class HttpError extends Error { + constructor( + public readonly status: number, + public readonly body: string, + public readonly url: string + ) { + super(`HTTP ${status} from ${url}: ${body.slice(0, 200)}`); + this.name = "HttpError"; + } +} + +export async function httpRequest( + baseUrl: string, + pathOrUrl: string, + options: HttpRequestOptions = {} +): Promise { + const url = new URL(pathOrUrl, ensureTrailingSlash(baseUrl)); + if (options.params) { + for (const [k, v] of Object.entries(options.params)) { + if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); + } + } + const timeout = options.timeoutMs ?? 15_000; + const headers: Record = { + Accept: "application/json", + ...(options.body != null && typeof options.body !== "string" + ? { "Content-Type": "application/json" } + : {}), + ...options.headers, + }; + const init: RequestInit = { + method: options.method ?? "GET", + headers, + signal: AbortSignal.timeout(timeout), + }; + if (options.body != null) { + init.body = + typeof options.body === "string" ? options.body : JSON.stringify(options.body); + } + const res = await fetch(url, init); + const text = await res.text(); + if (!res.ok) { + throw new HttpError(res.status, text, url.toString()); + } + if (!text) return undefined as T; + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return JSON.parse(text) as T; + } + // Best-effort JSON parse for endpoints that don't set content-type correctly. + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } +} + +function ensureTrailingSlash(s: string): string { + return s.endsWith("/") ? s : s + "/"; +} + +export function errString(err: unknown): string { + if (err instanceof HttpError) return err.message; + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3a71086 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "removeComments": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}