Scaffold and Phase 0-1
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
data
|
||||||
|
logs
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
+115
@@ -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=
|
||||||
+23
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+22
@@ -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"]
|
||||||
@@ -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<void>; // init: connect, test auth
|
||||||
|
onUnload?: () => Promise<void>; // cleanup: close connections, timers
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPTool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: ZodSchema;
|
||||||
|
handler: (input: unknown) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 <CLAUDE_CODE_TOKEN>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Antigravity** (`mcp_config.json`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"totalmcp": {
|
||||||
|
"url": "http://10.2.0.35:8811/sse",
|
||||||
|
"transport": "sse",
|
||||||
|
"headers": { "Authorization": "Bearer <ANTIGRAVITY_TOKEN>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Codex:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"totalmcp": {
|
||||||
|
"url": "http://10.2.0.35:8811/mcp",
|
||||||
|
"transport": "http",
|
||||||
|
"headers": { "Authorization": "Bearer <CODEX_TOKEN>" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] 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:<port>`, `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:<port>`, `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:<port>`, `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:<port>`, `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=<pat>
|
||||||
|
|
||||||
|
# ─── Unraid ─────────────────────────────────────────────
|
||||||
|
UNRAID_HOST=http://10.2.0.2
|
||||||
|
UNRAID_API_KEY=<key>
|
||||||
|
|
||||||
|
# ─── OpenClaw / NOVA ────────────────────────────────────
|
||||||
|
OPENCLAW_HOST=http://10.2.0.26:18789
|
||||||
|
|
||||||
|
# ─── UniFi Access ───────────────────────────────────────
|
||||||
|
UNIFI_HOST=https://<controller-ip>
|
||||||
|
UNIFI_API_KEY=<key>
|
||||||
|
UNIFI_SITE_ID=<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=<bearer>
|
||||||
|
|
||||||
|
# ─── Obsidian (DEFERRED) ────────────────────────────────
|
||||||
|
# OBSIDIAN_REST_HOST=http://obsidian:27123
|
||||||
|
# OBSIDIAN_API_KEY=<key>
|
||||||
|
|
||||||
|
# ─── Phase 7: Infrastructure & Media ────────────────────
|
||||||
|
NPM_HOST=http://10.2.0.3:81
|
||||||
|
NPM_EMAIL=<email>
|
||||||
|
NPM_PASSWORD=<password>
|
||||||
|
|
||||||
|
UISP_HOST=https://10.2.0.4:443
|
||||||
|
UISP_TOKEN=<token>
|
||||||
|
|
||||||
|
TRANSMISSION_HOST=http://10.2.0.5:9091
|
||||||
|
TRANSMISSION_USER=<user>
|
||||||
|
TRANSMISSION_PASS=<password>
|
||||||
|
|
||||||
|
SYNCTHING_HOST=http://10.2.0.2:8384
|
||||||
|
SYNCTHING_API_KEY=<key>
|
||||||
|
|
||||||
|
PLEX_HOST=http://10.2.0.2:32400
|
||||||
|
PLEX_TOKEN=<token>
|
||||||
|
|
||||||
|
NYAA_HOST=http://10.2.0.21
|
||||||
|
NYAA_API_KEY=<key>
|
||||||
|
|
||||||
|
# ─── Phase 8: Smart Home ────────────────────────────────
|
||||||
|
HA_HOST=https://10.2.0.12:8123
|
||||||
|
HA_TOKEN=<long-lived-access-token>
|
||||||
|
|
||||||
|
# ─── Phase 9: Business Operations ───────────────────────
|
||||||
|
INVOICENINJA_HOST=http://10.2.0.2:8000
|
||||||
|
INVOICENINJA_TOKEN=<token>
|
||||||
|
|
||||||
|
FABDASH_HOST=http://10.2.0.13:8080
|
||||||
|
FABDASH_TOKEN=<token>
|
||||||
|
|
||||||
|
CPAS_HOST=http://10.2.0.14:3001
|
||||||
|
CPAS_TOKEN=<token>
|
||||||
|
|
||||||
|
WFH_HOST=http://10.2.0.18:3000
|
||||||
|
WFH_TOKEN=<token>
|
||||||
|
|
||||||
|
# ─── Phase 10: Personal & Niche ─────────────────────────
|
||||||
|
BREEDR_HOST=http://10.2.0.17
|
||||||
|
BREEDR_TOKEN=<token>
|
||||||
|
|
||||||
|
CODEDUMP_HOST=http://10.2.0.34
|
||||||
|
CODEDUMP_TOKEN=<token>
|
||||||
|
|
||||||
|
UITRACKER_HOST=http://10.2.0.29
|
||||||
|
UITRACKER_TOKEN=<token>
|
||||||
|
|
||||||
|
STEPVIEW_HOST=http://10.2.0.33:3000
|
||||||
|
STEPVIEW_TOKEN=<token>
|
||||||
|
|
||||||
|
QRKNIT_HOST=http://10.2.0.9:5000
|
||||||
|
QRKNIT_TOKEN=<token>
|
||||||
|
|
||||||
|
MEMER_HOST=http://10.2.0.30:3000
|
||||||
|
MEMER_TOKEN=<token>
|
||||||
|
|
||||||
|
ALWISP_WEB_HOST=http://10.2.0.8:80
|
||||||
|
ALWISP_WEB_TOKEN=<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
|
||||||
|
|
||||||
@@ -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:
|
- **Port:** `8811`
|
||||||
- a root `AGENTS.md` entrypoint,
|
- **Static IP:** `10.2.0.35` (Unraid `br0`)
|
||||||
- a central skill index,
|
- **Registry:** `git.alwisp.com/jason/totalmcp`
|
||||||
- category hubs for routing,
|
- **Spec:** see [`PLAN.md`](PLAN.md) for the full architecture and phased roadmap
|
||||||
- specialized skill files for common software, docs, UX, marketing, and ideation tasks.
|
- **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
|
```bash
|
||||||
- `DEPLOYMENT-PROFILE.md` - agent-readable prefilled deployment defaults
|
cp .env.example .env # then fill in tokens
|
||||||
- `INSTALL.md` - copy and customization guide for other repositories
|
npm install
|
||||||
- `PROJECT-PROFILE-WORKBOOK.md` - one-time questionnaire for staging defaults
|
npm run prisma:generate
|
||||||
- `SKILLS.md` - canonical skill index
|
npm run dev # starts tsx watch on src/server.ts
|
||||||
- `ROUTING-EXAMPLES.md` - representative prompt-to-skill routing examples
|
```
|
||||||
- `hubs/` - category-level routing guides
|
|
||||||
- `skills/` - specialized reusable skill files
|
|
||||||
|
|
||||||
## Design Goals
|
Verify:
|
||||||
|
|
||||||
- Plain markdown only
|
```bash
|
||||||
- Cross-agent portability
|
curl http://localhost:8811/health
|
||||||
- Implementation-first defaults
|
# → { "status": "ok", "version": "0.1.0", "plugins": 0, "enabled": [] }
|
||||||
- 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
|
## Build & run
|
||||||
|
|
||||||
1. The agent reads `AGENTS.md`.
|
```bash
|
||||||
2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in.
|
npm run build # tsc → dist/
|
||||||
3. The agent checks `SKILLS.md`.
|
npm start # node dist/server.js
|
||||||
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
|
## Docker
|
||||||
|
|
||||||
- Software development
|
```bash
|
||||||
- Debugging
|
docker compose up --build -d
|
||||||
- Documentation
|
docker compose logs -f totalmcp
|
||||||
- UI/UX
|
```
|
||||||
- Marketing
|
|
||||||
- Brainstorming
|
## 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/<name>/index.ts` and `export default` an `MCPPlugin`. Tool names must be fully-qualified (`<service>_<action>`) 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.
|
||||||
|
|||||||
+669
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
export type AgentTokenMap = Map<string, string>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
: [];
|
||||||
@@ -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<string, unknown>): 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<string, unknown>) => emit("debug", msg, f),
|
||||||
|
info: (msg: string, f?: Record<string, unknown>) => emit("info", msg, f),
|
||||||
|
warn: (msg: string, f?: Record<string, unknown>) => emit("warn", msg, f),
|
||||||
|
error: (msg: string, f?: Record<string, unknown>) => emit("error", msg, f),
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<string, string> {
|
||||||
|
return config.token ? { Authorization: `token ${config.token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gitea<T>(
|
||||||
|
path: string,
|
||||||
|
init: {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
params?: Record<string, string | number | boolean | undefined>;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
return httpRequest<T>(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<GiteaRepo>(`/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<GiteaRepo>("/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<GiteaIssue[]>(
|
||||||
|
`/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<GiteaIssue>(
|
||||||
|
`/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<GiteaBranch[]>(
|
||||||
|
`/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<GiteaFileContent>(
|
||||||
|
`/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;
|
||||||
@@ -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<T> {
|
||||||
|
data?: T;
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gql<T>(query: string, variables: Record<string, unknown> = {}): Promise<T> {
|
||||||
|
const res = await httpRequest<GraphQLResponse<T>>(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<string, string>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>(`
|
||||||
|
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;
|
||||||
+196
@@ -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<string, LoadedPlugin>();
|
||||||
|
private watcher?: FSWatcher;
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(private pluginsDir: string, private gatewayVersion: string) {}
|
||||||
|
|
||||||
|
async loadAll(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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<string, SSEServerTransport>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, StreamableHTTPServerTransport>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ZodTypeAny } from "zod";
|
||||||
|
|
||||||
|
export interface MCPTool {
|
||||||
|
// Fully-qualified tool name. Convention: "<service>_<action>" (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<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string>) => Promise<MCPPromptResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
minGatewayVersion: string;
|
||||||
|
tools: MCPTool[];
|
||||||
|
resources?: MCPResource[];
|
||||||
|
prompts?: MCPPrompt[];
|
||||||
|
onLoad?: () => Promise<void>;
|
||||||
|
onUnload?: () => Promise<void>;
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
params?: Record<string, string | number | boolean | undefined | null>;
|
||||||
|
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<T = unknown>(
|
||||||
|
baseUrl: string,
|
||||||
|
pathOrUrl: string,
|
||||||
|
options: HttpRequestOptions = {}
|
||||||
|
): Promise<T> {
|
||||||
|
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<string, string> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user