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:
|
||||
- a root `AGENTS.md` entrypoint,
|
||||
- a central skill index,
|
||||
- category hubs for routing,
|
||||
- specialized skill files for common software, docs, UX, marketing, and ideation tasks.
|
||||
- **Port:** `8811`
|
||||
- **Static IP:** `10.2.0.35` (Unraid `br0`)
|
||||
- **Registry:** `git.alwisp.com/jason/totalmcp`
|
||||
- **Spec:** see [`PLAN.md`](PLAN.md) for the full architecture and phased roadmap
|
||||
- **Service inventory:** see [`SERVICES.md`](SERVICES.md) for the catalog this gateway plugs into
|
||||
|
||||
## Structure
|
||||
## Quick Start (local dev)
|
||||
|
||||
- `AGENTS.md` - base instructions and routing rules
|
||||
- `DEPLOYMENT-PROFILE.md` - agent-readable prefilled deployment defaults
|
||||
- `INSTALL.md` - copy and customization guide for other repositories
|
||||
- `PROJECT-PROFILE-WORKBOOK.md` - one-time questionnaire for staging defaults
|
||||
- `SKILLS.md` - canonical skill index
|
||||
- `ROUTING-EXAMPLES.md` - representative prompt-to-skill routing examples
|
||||
- `hubs/` - category-level routing guides
|
||||
- `skills/` - specialized reusable skill files
|
||||
```bash
|
||||
cp .env.example .env # then fill in tokens
|
||||
npm install
|
||||
npm run prisma:generate
|
||||
npm run dev # starts tsx watch on src/server.ts
|
||||
```
|
||||
|
||||
## Design Goals
|
||||
Verify:
|
||||
|
||||
- Plain markdown only
|
||||
- Cross-agent portability
|
||||
- Implementation-first defaults
|
||||
- On-demand skill loading instead of loading everything every session
|
||||
- Context-efficient routing for large skill libraries
|
||||
- Prefilled deployment defaults without per-install questioning
|
||||
- Repo-local instructions take precedence over this bundle
|
||||
```bash
|
||||
curl http://localhost:8811/health
|
||||
# → { "status": "ok", "version": "0.1.0", "plugins": 0, "enabled": [] }
|
||||
```
|
||||
|
||||
## Intended Workflow
|
||||
## Build & run
|
||||
|
||||
1. The agent reads `AGENTS.md`.
|
||||
2. The agent reads `DEPLOYMENT-PROFILE.md` when it is filled in.
|
||||
3. The agent checks `SKILLS.md`.
|
||||
4. The agent opens only the relevant hub and skill files for the task.
|
||||
5. The agent combines multiple skills when the task spans several domains.
|
||||
```bash
|
||||
npm run build # tsc → dist/
|
||||
npm start # node dist/server.js
|
||||
```
|
||||
|
||||
## Core Categories
|
||||
## Docker
|
||||
|
||||
- Software development
|
||||
- Debugging
|
||||
- Documentation
|
||||
- UI/UX
|
||||
- Marketing
|
||||
- Brainstorming
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker compose logs -f totalmcp
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|-------------|----------|--------------------------------------|
|
||||
| GET | `/health` | none | Liveness — used by Unraid HEALTHCHECK |
|
||||
| GET | `/plugins` | bearer | Loaded plugin list + tool counts |
|
||||
| POST | `/mcp` | bearer | Streamable HTTP — primary MCP transport |
|
||||
| GET | `/mcp` | bearer | Streamable HTTP server-sent stream |
|
||||
| DELETE | `/mcp` | bearer | Streamable HTTP session close |
|
||||
| GET | `/sse` | bearer | Legacy SSE (Antigravity) |
|
||||
| POST | `/message` | bearer | Legacy SSE message channel |
|
||||
|
||||
## Authoring a plugin
|
||||
|
||||
Drop a directory under `src/plugins/<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