Scaffold and Phase 0-1

This commit is contained in:
2026-05-09 22:18:00 -05:00
parent 604a756c80
commit b4ac3b9968
26 changed files with 3181 additions and 37 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
dist
.env
.env.local
.env.*.local
data
logs
.git
.gitea
.vscode
.idea
*.log
*.md
!README.md
+115
View File
@@ -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 13 (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
View File
@@ -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
+47
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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"]
+843
View File
@@ -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:** 12 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:** 12 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:** 12 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:** 23 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:** 23 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:** 12 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:** 23 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:** 12 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:** 23 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:** 23 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 13 (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
+101 -37
View File
@@ -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
View File
@@ -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.
+17
View File
@@ -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
+34
View File
@@ -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"
}
}
+26
View File
@@ -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])
}
+37
View File
@@ -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();
};
}
+30
View File
@@ -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)
: [];
+24
View File
@@ -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),
};
+72
View File
@@ -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);
}
View File
+302
View File
@@ -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;
+269
View File
@@ -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
View File
@@ -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;
}
+67
View File
@@ -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);
});
+38
View File
@@ -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;
}
+58
View File
@@ -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;
}
+56
View File
@@ -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>;
}
+78
View File
@@ -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);
}
+22
View File
@@ -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"]
}