From fe3b555202328a4c862935e1a33a8a8e31b3338f Mon Sep 17 00:00:00 2001 From: jason Date: Sat, 9 May 2026 22:59:43 -0500 Subject: [PATCH] phase 2-3 --- CHANGELOG.md | 11 ++ Dockerfile | 4 + README.md | 10 +- docker-compose.yml | 6 +- package.json | 4 + src/plugins/codex-mrp/index.ts | 167 +++++++++++++++++++++++ src/plugins/docker/index.ts | 218 +++++++++++++++++++++++++++++++ src/plugins/openclaw/index.ts | 165 +++++++++++++++++++++++ src/plugins/rackmapper/index.ts | 157 ++++++++++++++++++++++ src/plugins/streamvault/index.ts | 146 +++++++++++++++++++++ src/plugins/unifi/index.ts | 190 +++++++++++++++++++++++++++ 11 files changed, 1075 insertions(+), 3 deletions(-) create mode 100644 src/plugins/codex-mrp/index.ts create mode 100644 src/plugins/docker/index.ts create mode 100644 src/plugins/openclaw/index.ts create mode 100644 src/plugins/rackmapper/index.ts create mode 100644 src/plugins/streamvault/index.ts create mode 100644 src/plugins/unifi/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f00062..71fbb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- Phase 2 — `docker` plugin (6 tools: list containers, start/stop/restart, get logs, get stats) via direct Docker socket using `dockerode`. Decodes the multiplexed stdout/stderr log stream automatically. +- Phase 2 — `openclaw` plugin (3 tools: list models, get model info, chat) against NOVA's Ollama-compatible HTTP API at `http://10.2.0.26:18789`. +- Phase 3 — `unifi` plugin (4 tools: list access events, list users, get door status, list sites) against the UniFi Access developer REST API. +- Phase 3 — `codex-mrp` plugin (5 tools: list/get/create work orders, get inventory, list BOMs) via direct SQLite using `better-sqlite3`. Queries use placeholder table/column names — adjust to match your CODEX schema before relying on these tools. +- Phase 3 — `streamvault` plugin (4 tools: list jobs, add download, get job status, cancel job) — REST stub; awaits StreamVault deployment. +- Phase 3 — `rackmapper` plugin (4 tools: list racks, get rack, list devices, map service) against RackMapper at 10.2.0.23. - Shared HTTP helper at `src/util/http.ts` — timeout, JSON serialization, structured `HttpError`. +- Dependencies: `dockerode`, `@types/dockerode`, `better-sqlite3`, `@types/better-sqlite3`. + +### Changed +- Dockerfile: install `python3`/`make`/`g++` in the builder stage and `libstdc++` in the runtime stage so `better-sqlite3` compiles cleanly on Alpine/musl. +- docker-compose.yml: Docker socket is now mounted read-write (Phase 2 needs it for start/stop). Added a commented-out mount line for the CODEX SQLite file. ### 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. diff --git a/Dockerfile b/Dockerfile index f38896f..d8277f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ FROM node:20-alpine AS builder +# Build deps for native modules (better-sqlite3 needs python3/make/g++ on musl) +RUN apk add --no-cache python3 make g++ WORKDIR /app COPY package*.json tsconfig.json ./ RUN npm ci @@ -8,6 +10,8 @@ RUN npx prisma generate RUN npm run build FROM node:20-alpine +# Runtime libs for native modules +RUN apk add --no-cache libstdc++ RUN addgroup -S mcp && adduser -S mcp -G mcp WORKDIR /app COPY --from=builder /app/dist ./dist diff --git a/README.md b/README.md index ab6858f..f454ce5 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,14 @@ totalmcp/ │ │ └── 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 +│ ├── gitea/index.ts # Phase 1 — Gitea REST API +│ ├── unraid/index.ts # Phase 1 — Unraid GraphQL API +│ ├── docker/index.ts # Phase 2 — Docker socket (dockerode) +│ ├── openclaw/index.ts # Phase 2 — OpenClaw/NOVA (Ollama-compatible) +│ ├── unifi/index.ts # Phase 3 — UniFi Access (developer REST) +│ ├── codex-mrp/index.ts # Phase 3 — CODEX MRP (direct SQLite) +│ ├── streamvault/index.ts # Phase 3 — StreamVault download manager +│ └── rackmapper/index.ts # Phase 3 — RackMapper datacenter inventory ├── prisma/schema.prisma # Event log schema ├── Dockerfile ├── docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml index da0377d..ef951be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,11 @@ services: - .env volumes: - ./data:/app/data - - /var/run/docker.sock:/var/run/docker.sock:ro + # Read-write socket needed for docker_start/stop/restart_container. + # Drop the trailing `:rw` (default) and add `:ro` if you only want list/logs/stats. + - /var/run/docker.sock:/var/run/docker.sock + # Phase 3: mount CODEX DB for the codex-mrp plugin (uncomment if running CODEX locally) + # - /mnt/user/appdata/codex/db.sqlite:/app/codex/db.sqlite restart: unless-stopped healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:8811/health || exit 1"] diff --git a/package.json b/package.json index a2cf567..06ec04d 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,17 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", "@prisma/client": "^5.22.0", + "better-sqlite3": "^11.5.0", "chokidar": "^4.0.1", + "dockerode": "^4.0.2", "dotenv": "^16.4.5", "express": "^5.0.1", "zod": "^3.23.8", "zod-to-json-schema": "^3.23.5" }, "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/dockerode": "^3.3.31", "@types/express": "^5.0.0", "@types/node": "^22.9.0", "prisma": "^5.22.0", diff --git a/src/plugins/codex-mrp/index.ts b/src/plugins/codex-mrp/index.ts new file mode 100644 index 0000000..d5fcaa8 --- /dev/null +++ b/src/plugins/codex-mrp/index.ts @@ -0,0 +1,167 @@ +import Database from "better-sqlite3"; +import { z } from "zod"; +import { log } from "../../logger.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +// ─── PLACEHOLDER ALERT ────────────────────────────────────────────────────── +// CODEX is Jason's custom MRP/ERP. The queries below assume table names +// `work_orders`, `bom_lines`, `inventory`, `boms` with reasonable column +// shapes. Adjust to match the real CODEX schema before relying on these +// tools. Run `sqlite3 /mnt/user/appdata/codex/db.sqlite ".schema"` and +// update the SQL below. +// +// DEPLOYMENT: the totalmcp container needs the CODEX SQLite file mounted in. +// Add to the Unraid container settings: +// /mnt/user/appdata/codex/db.sqlite → /app/codex/db.sqlite (read-write) +// Then set CODEX_DB_PATH=/app/codex/db.sqlite in .env. +// ──────────────────────────────────────────────────────────────────────────── + +const config = { + dbPath: process.env.CODEX_DB_PATH?.trim() || "/mnt/user/appdata/codex/db.sqlite", +}; + +let db: Database.Database | null = null; + +// ---------- Schemas ---------- + +const NoArgsSchema = z.object({}); + +const WorkOrderRefSchema = z.object({ + id: z.union([z.string(), z.number()]).describe("Work order primary key"), +}); + +const CreateWorkOrderSchema = z.object({ + title: z.string().min(1), + partNumber: z.string().optional(), + quantity: z.number().int().positive(), + dueDate: z.string().optional().describe("ISO 8601 date (YYYY-MM-DD)"), +}); + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "codex-mrp", + version: "0.1.0", + description: "CODEX MRP/ERP — direct SQLite access for work orders, BOMs, inventory", + minGatewayVersion: "0.1.0", + + async onLoad() { + try { + db = new Database(config.dbPath, { fileMustExist: true }); + // WAL mode lets us read concurrently with CODEX's own writes safely. + db.pragma("journal_mode = WAL"); + const row = db + .prepare("SELECT COUNT(*) as count FROM sqlite_master WHERE type='table'") + .get() as { count: number }; + log.info("codex_mrp_connected", { dbPath: config.dbPath, tableCount: row.count }); + } catch (err) { + log.error("codex_mrp_connect_failed", { + dbPath: config.dbPath, + err: err instanceof Error ? err.message : String(err), + }); + db = null; + } + }, + + async onUnload() { + if (db) { + db.close(); + db = null; + } + }, + + tools: [ + { + name: "codex_list_work_orders", + description: "List active (non-completed/cancelled) work orders, soonest due first.", + inputSchema: NoArgsSchema, + handler: async () => { + const conn = requireDb(); + const rows = conn + .prepare( + `SELECT id, title, part_number, quantity, status, due_date, created_at + FROM work_orders + WHERE status NOT IN ('completed', 'cancelled') + ORDER BY COALESCE(due_date, '9999-12-31') ASC + LIMIT 100` + ) + .all(); + return { workOrders: rows }; + }, + }, + { + name: "codex_get_work_order", + description: "Single work order detail including its BOM lines.", + inputSchema: WorkOrderRefSchema, + handler: async (raw) => { + const args = WorkOrderRefSchema.parse(raw); + const conn = requireDb(); + const wo = conn.prepare(`SELECT * FROM work_orders WHERE id = ?`).get(args.id); + if (!wo) throw new Error(`work order not found: ${args.id}`); + const lines = conn + .prepare(`SELECT * FROM bom_lines WHERE work_order_id = ?`) + .all(args.id); + return { workOrder: wo, bomLines: lines }; + }, + }, + { + name: "codex_create_work_order", + description: + "Create a new work order. Note: writes directly to the CODEX DB and bypasses any application-level validation in CODEX itself.", + inputSchema: CreateWorkOrderSchema, + handler: async (raw) => { + const args = CreateWorkOrderSchema.parse(raw); + const conn = requireDb(); + const result = conn + .prepare( + `INSERT INTO work_orders (title, part_number, quantity, due_date, status, created_at) + VALUES (?, ?, ?, ?, 'pending', datetime('now'))` + ) + .run(args.title, args.partNumber ?? null, args.quantity, args.dueDate ?? null); + return { id: result.lastInsertRowid, title: args.title }; + }, + }, + { + name: "codex_get_inventory", + description: "Current inventory levels (on hand, reserved, available) for every SKU.", + inputSchema: NoArgsSchema, + handler: async () => { + const conn = requireDb(); + const rows = conn + .prepare( + `SELECT sku, name, on_hand, reserved, available + FROM inventory + ORDER BY name ASC` + ) + .all(); + return { inventory: rows }; + }, + }, + { + name: "codex_list_boms", + description: "List bills of materials.", + inputSchema: NoArgsSchema, + handler: async () => { + const conn = requireDb(); + const rows = conn + .prepare( + `SELECT id, name, part_number, version, created_at + FROM boms + ORDER BY name ASC + LIMIT 200` + ) + .all(); + return { boms: rows }; + }, + }, + ], +}; + +function requireDb(): Database.Database { + if (!db) { + throw new Error("CODEX database not connected — check CODEX_DB_PATH and the volume mount"); + } + return db; +} + +export default plugin; diff --git a/src/plugins/docker/index.ts b/src/plugins/docker/index.ts new file mode 100644 index 0000000..f591fc0 --- /dev/null +++ b/src/plugins/docker/index.ts @@ -0,0 +1,218 @@ +import Docker from "dockerode"; +import type { ContainerInfo, ContainerStats } from "dockerode"; +import { z } from "zod"; +import { log } from "../../logger.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +// Docker socket is mounted into the container at /var/run/docker.sock +// (read-write — needed for start/stop/restart). When running outside Docker, +// dockerode talks to the local daemon directly. +const docker = new Docker({ socketPath: "/var/run/docker.sock" }); + +// ---------- Schemas ---------- + +const ListContainersSchema = z.object({ + all: z + .boolean() + .default(true) + .describe("Include stopped containers (default true)"), +}); + +const NameOrIdSchema = z.object({ + nameOrId: z.string().min(1).describe("Container name or full/short ID"), +}); + +const GetLogsSchema = NameOrIdSchema.extend({ + tail: z.number().int().min(1).max(2000).default(100).describe("Lines from the end"), +}); + +// ---------- Helpers ---------- + +interface ResolvedContainer { + info: ContainerInfo; + container: Docker.Container; + shortId: string; + name: string; +} + +async function resolveContainer(nameOrId: string): Promise { + const containers = await docker.listContainers({ all: true }); + const match = containers.find( + (c) => + c.Id === nameOrId || + c.Id.startsWith(nameOrId) || + c.Names.some((n) => n === nameOrId || n === `/${nameOrId}`) + ); + if (!match) { + throw new Error(`container not found: ${nameOrId}`); + } + return { + info: match, + container: docker.getContainer(match.Id), + shortId: match.Id.slice(0, 12), + name: stripLeadingSlash(match.Names[0] ?? ""), + }; +} + +function stripLeadingSlash(s: string): string { + return s.startsWith("/") ? s.slice(1) : s; +} + +// Docker multiplexes stdout/stderr in a header-prefixed stream when there is +// no TTY. Header layout: 8 bytes — [streamType, 0, 0, 0, size_be32]. +function decodeDockerLogs(buf: Buffer): string { + const out: string[] = []; + let offset = 0; + while (offset + 8 <= buf.length) { + const size = buf.readUInt32BE(offset + 4); + if (offset + 8 + size > buf.length) break; + out.push(buf.slice(offset + 8, offset + 8 + size).toString("utf8")); + offset += 8 + size; + } + if (out.length === 0) { + // Likely a TTY container — stream is plain text, not multiplexed. + return buf.toString("utf8"); + } + return out.join(""); +} + +function summarizeStats(s: ContainerStats, name: string) { + const cpuDelta = + (s.cpu_stats.cpu_usage.total_usage ?? 0) - + (s.precpu_stats.cpu_usage.total_usage ?? 0); + const systemDelta = + (s.cpu_stats.system_cpu_usage ?? 0) - (s.precpu_stats.system_cpu_usage ?? 0); + const onlineCpus = s.cpu_stats.online_cpus ?? 1; + const cpuPercent = + systemDelta > 0 && cpuDelta > 0 ? (cpuDelta / systemDelta) * onlineCpus * 100 : 0; + const memUsage = s.memory_stats.usage ?? 0; + const memLimit = s.memory_stats.limit ?? 0; + return { + name, + cpuPercent: Number(cpuPercent.toFixed(2)), + memoryUsedBytes: memUsage, + memoryLimitBytes: memLimit, + memoryPercent: memLimit > 0 ? Number(((memUsage / memLimit) * 100).toFixed(2)) : 0, + }; +} + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "docker", + version: "0.1.0", + description: "Direct Docker socket control — list/start/stop/restart containers, fetch logs and stats", + minGatewayVersion: "0.1.0", + + async onLoad() { + try { + const info = await docker.info(); + log.info("docker_connected", { + containers: info.Containers, + running: info.ContainersRunning, + version: info.ServerVersion, + }); + } catch (err) { + log.error("docker_connect_failed", { + err: err instanceof Error ? err.message : String(err), + hint: "Is /var/run/docker.sock mounted into the container?", + }); + } + }, + + tools: [ + { + name: "docker_list_containers", + description: "List Docker containers. Set all=true (default) to include stopped containers.", + inputSchema: ListContainersSchema, + handler: async (raw) => { + const args = ListContainersSchema.parse(raw); + const containers = await docker.listContainers({ all: args.all }); + return { + containers: containers.map((c) => ({ + id: c.Id.slice(0, 12), + name: stripLeadingSlash(c.Names[0] ?? ""), + image: c.Image, + state: c.State, + status: c.Status, + ports: c.Ports.map((p) => ({ + ip: p.IP, + privatePort: p.PrivatePort, + publicPort: p.PublicPort, + type: p.Type, + })), + })), + }; + }, + }, + { + name: "docker_start_container", + description: "Start a stopped container.", + inputSchema: NameOrIdSchema, + handler: async (raw) => { + const args = NameOrIdSchema.parse(raw); + const found = await resolveContainer(args.nameOrId); + await found.container.start(); + return { id: found.shortId, name: found.name, state: "started" }; + }, + }, + { + name: "docker_stop_container", + description: "Stop a running container (graceful, default 10s timeout).", + inputSchema: NameOrIdSchema, + handler: async (raw) => { + const args = NameOrIdSchema.parse(raw); + const found = await resolveContainer(args.nameOrId); + await found.container.stop(); + return { id: found.shortId, name: found.name, state: "stopped" }; + }, + }, + { + name: "docker_restart_container", + description: "Restart a container.", + inputSchema: NameOrIdSchema, + handler: async (raw) => { + const args = NameOrIdSchema.parse(raw); + const found = await resolveContainer(args.nameOrId); + await found.container.restart(); + return { id: found.shortId, name: found.name, state: "restarted" }; + }, + }, + { + name: "docker_get_logs", + description: "Tail the last N lines of stdout/stderr from a container.", + inputSchema: GetLogsSchema, + handler: async (raw) => { + const args = GetLogsSchema.parse(raw); + const found = await resolveContainer(args.nameOrId); + const buf = (await found.container.logs({ + stdout: true, + stderr: true, + tail: args.tail, + follow: false, + } as Docker.ContainerLogsOptions & { follow: false })) as unknown as Buffer; + return { + id: found.shortId, + name: found.name, + tail: args.tail, + logs: decodeDockerLogs(buf), + }; + }, + }, + { + name: "docker_get_stats", + description: "One-shot CPU and memory stats for a container.", + inputSchema: NameOrIdSchema, + handler: async (raw) => { + const args = NameOrIdSchema.parse(raw); + const found = await resolveContainer(args.nameOrId); + const stats = (await found.container.stats({ + stream: false, + })) as unknown as ContainerStats; + return summarizeStats(stats, found.name); + }, + }, + ], +}; + +export default plugin; diff --git a/src/plugins/openclaw/index.ts b/src/plugins/openclaw/index.ts new file mode 100644 index 0000000..07a5f47 --- /dev/null +++ b/src/plugins/openclaw/index.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { log } from "../../logger.js"; +import { httpRequest, errString } from "../../util/http.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +// OpenClaw exposes an Ollama-compatible HTTP API. Default endpoint is +// NOVA (Jason's primary instance) at http://10.2.0.26:18789. Override via +// OPENCLAW_HOST env var. +const config = { + host: process.env.OPENCLAW_HOST?.trim() || "http://10.2.0.26:18789", +}; + +// ---------- Schemas ---------- + +const ListModelsSchema = z.object({}); + +const GetModelInfoSchema = z.object({ + name: z.string().min(1).describe("Model name (e.g., 'llama3.1:8b')"), +}); + +const ChatMessageSchema = z.object({ + role: z.enum(["system", "user", "assistant"]), + content: z.string(), +}); + +const ChatSchema = z.object({ + model: z.string().min(1).describe("Model name (use openclaw_list_models to discover)"), + messages: z + .array(ChatMessageSchema) + .min(1) + .describe("Conversation history including the latest user message"), + temperature: z.number().min(0).max(2).optional(), + maxTokens: z + .number() + .int() + .min(1) + .max(8192) + .optional() + .describe("Maximum tokens to generate (Ollama num_predict)"), +}); + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "openclaw", + version: "0.1.0", + description: "Local LLM inference via OpenClaw (NOVA) — Ollama-compatible HTTP API", + minGatewayVersion: "0.1.0", + + async onLoad() { + try { + const data = await httpRequest<{ models?: unknown[] }>(config.host, "/api/tags", { + timeoutMs: 5_000, + }); + log.info("openclaw_connected", { + host: config.host, + modelCount: data.models?.length ?? 0, + }); + } catch (err) { + log.error("openclaw_connect_failed", { + host: config.host, + err: errString(err), + }); + } + }, + + tools: [ + { + name: "openclaw_list_models", + description: "List all models available on the OpenClaw/NOVA instance.", + inputSchema: ListModelsSchema, + handler: async () => { + const data = await httpRequest<{ + models: Array<{ + name: string; + size: number; + modified_at: string; + details?: { parameter_size?: string; quantization_level?: string; family?: string }; + }>; + }>(config.host, "/api/tags"); + return { + models: (data.models ?? []).map((m) => ({ + name: m.name, + sizeBytes: m.size, + modifiedAt: m.modified_at, + family: m.details?.family, + parameters: m.details?.parameter_size, + quantization: m.details?.quantization_level, + })), + }; + }, + }, + { + name: "openclaw_get_model_info", + description: "Get metadata for a specific model — family, parameter count, quantization, template.", + inputSchema: GetModelInfoSchema, + handler: async (raw) => { + const args = GetModelInfoSchema.parse(raw); + const data = await httpRequest<{ + modelfile?: string; + parameters?: string; + template?: string; + details?: { + parameter_size?: string; + quantization_level?: string; + family?: string; + format?: string; + }; + }>(config.host, "/api/show", { + method: "POST", + body: { name: args.name }, + timeoutMs: 30_000, + }); + return { + name: args.name, + family: data.details?.family, + format: data.details?.format, + parameters: data.details?.parameter_size, + quantization: data.details?.quantization_level, + template: data.template, + }; + }, + }, + { + name: "openclaw_chat", + description: + "Chat completion against a model. Returns a single (non-streaming) response with token counts and latency.", + inputSchema: ChatSchema, + handler: async (raw) => { + const args = ChatSchema.parse(raw); + const data = await httpRequest<{ + model: string; + message: { role: string; content: string }; + done: boolean; + total_duration?: number; + eval_count?: number; + prompt_eval_count?: number; + }>(config.host, "/api/chat", { + method: "POST", + body: { + model: args.model, + messages: args.messages, + stream: false, + options: { + ...(args.temperature !== undefined ? { temperature: args.temperature } : {}), + ...(args.maxTokens !== undefined ? { num_predict: args.maxTokens } : {}), + }, + }, + timeoutMs: 120_000, + }); + return { + model: data.model, + response: data.message.content, + tokensIn: data.prompt_eval_count, + tokensOut: data.eval_count, + totalDurationMs: data.total_duration + ? Math.round(data.total_duration / 1_000_000) + : undefined, + }; + }, + }, + ], +}; + +export default plugin; diff --git a/src/plugins/rackmapper/index.ts b/src/plugins/rackmapper/index.ts new file mode 100644 index 0000000..cb29373 --- /dev/null +++ b/src/plugins/rackmapper/index.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; +import { log } from "../../logger.js"; +import { httpRequest, errString } from "../../util/http.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +// RackMapper is Jason's custom datacenter inventory tool at 10.2.0.23. +// Endpoint shapes below are placeholders — adjust paths/fields once +// RackMapper's actual API is published. +const config = { + host: process.env.RACKMAPPER_HOST?.trim() || "http://10.2.0.23", + token: process.env.RACKMAPPER_TOKEN?.trim() ?? "", +}; + +function authHeaders(): Record { + return config.token ? { Authorization: `Bearer ${config.token}` } : {}; +} + +async function rm( + path: string, + init: { method?: string; body?: unknown; params?: Record } = {} +): Promise { + return httpRequest(config.host, path, { + method: init.method, + body: init.body, + params: init.params, + headers: authHeaders(), + }); +} + +// ---------- Schemas ---------- + +const NoArgsSchema = z.object({}); + +const RackRefSchema = z.object({ + rackId: z.union([z.string(), z.number()]).describe("Rack identifier"), +}); + +const ListDevicesSchema = z.object({ + rackId: z.union([z.string(), z.number()]).optional().describe("Filter by rack"), + type: z.string().optional().describe("Filter by device type (server, switch, pdu, etc.)"), + limit: z.number().int().min(1).max(500).default(100), +}); + +const MapServiceSchema = z.object({ + deviceId: z.union([z.string(), z.number()]), + serviceName: z.string().min(1).describe("Logical service name (e.g., 'gitea', 'plex')"), + notes: z.string().optional(), +}); + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "rackmapper", + version: "0.1.0", + description: "RackMapper — datacenter inventory: racks, devices, service mappings", + minGatewayVersion: "0.1.0", + + async onLoad() { + try { + await rm("/health"); + log.info("rackmapper_connected", { host: config.host }); + } catch (err) { + log.warn("rackmapper_connect_failed", { + host: config.host, + err: errString(err), + }); + } + }, + + tools: [ + { + name: "rackmapper_list_racks", + description: "List all racks with their location and device counts.", + inputSchema: NoArgsSchema, + handler: async () => { + const data = await rm<{ + racks: Array<{ + id: string | number; + name: string; + location?: string; + unitCount?: number; + deviceCount?: number; + }>; + }>("/racks"); + return { racks: data.racks ?? [] }; + }, + }, + { + name: "rackmapper_get_rack", + description: "Layout of a single rack with each device's U-position.", + inputSchema: RackRefSchema, + handler: async (raw) => { + const args = RackRefSchema.parse(raw); + const data = await rm<{ + id: string | number; + name: string; + location?: string; + unitCount?: number; + devices: Array<{ + id: string | number; + name: string; + type?: string; + startUnit?: number; + unitHeight?: number; + serial?: string; + }>; + }>(`/racks/${args.rackId}`); + return data; + }, + }, + { + name: "rackmapper_list_devices", + description: "Device inventory across all racks. Filter by rack and/or type.", + inputSchema: ListDevicesSchema, + handler: async (raw) => { + const args = ListDevicesSchema.parse(raw); + const data = await rm<{ + devices: Array<{ + id: string | number; + name: string; + type?: string; + rackId?: string | number; + startUnit?: number; + unitHeight?: number; + serial?: string; + services?: string[]; + }>; + }>("/devices", { + params: { rack: args.rackId, type: args.type, limit: args.limit }, + }); + return { devices: data.devices ?? [] }; + }, + }, + { + name: "rackmapper_map_service", + description: "Link a logical service to a physical device (e.g., the host running Gitea).", + inputSchema: MapServiceSchema, + handler: async (raw) => { + const args = MapServiceSchema.parse(raw); + const data = await rm<{ id: string | number; deviceId: string | number; serviceName: string }>( + "/mappings", + { + method: "POST", + body: { + deviceId: args.deviceId, + serviceName: args.serviceName, + notes: args.notes, + }, + } + ); + return data; + }, + }, + ], +}; + +export default plugin; diff --git a/src/plugins/streamvault/index.ts b/src/plugins/streamvault/index.ts new file mode 100644 index 0000000..a88a112 --- /dev/null +++ b/src/plugins/streamvault/index.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; +import { log } from "../../logger.js"; +import { httpRequest, errString } from "../../util/http.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +// StreamVault is a planned download manager service (not yet in SERVICES.md). +// PLAN.md lists this as Phase 3 — when StreamVault ships, this plugin will +// connect to it. Until then, onLoad will log a warning and tool calls return +// a connection error. +// +// The endpoint shape below assumes a REST API. Adjust to match StreamVault's +// actual API once it lands. +const config = { + host: process.env.STREAMVAULT_HOST?.trim() || "http://streamvault:3100", + token: process.env.STREAMVAULT_TOKEN?.trim() ?? "", +}; + +function authHeaders(): Record { + return config.token ? { Authorization: `Bearer ${config.token}` } : {}; +} + +async function sv( + path: string, + init: { method?: string; body?: unknown; params?: Record } = {} +): Promise { + return httpRequest(config.host, path, { + method: init.method, + body: init.body, + params: init.params, + headers: authHeaders(), + }); +} + +// ---------- Schemas ---------- + +const ListJobsSchema = z.object({ + status: z.enum(["queued", "running", "completed", "failed", "cancelled", "all"]).default("all"), + limit: z.number().int().min(1).max(200).default(50), +}); + +const JobRefSchema = z.object({ + jobId: z.union([z.string(), z.number()]).describe("StreamVault job identifier"), +}); + +const AddDownloadSchema = z.object({ + url: z.string().url(), + destination: z.string().optional().describe("Override default destination directory"), + priority: z.enum(["low", "normal", "high"]).default("normal"), +}); + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "streamvault", + version: "0.1.0", + description: "StreamVault download manager — queue, track, and cancel download jobs", + minGatewayVersion: "0.1.0", + + async onLoad() { + try { + await sv("/health", { params: undefined }); + log.info("streamvault_connected", { host: config.host }); + } catch (err) { + log.warn("streamvault_connect_failed", { + host: config.host, + err: errString(err), + hint: "StreamVault may not be deployed yet — see PLAN.md Phase 3", + }); + } + }, + + tools: [ + { + name: "streamvault_list_jobs", + description: "List download jobs filtered by status.", + inputSchema: ListJobsSchema, + handler: async (raw) => { + const args = ListJobsSchema.parse(raw); + const data = await sv<{ + jobs: Array<{ + id: string | number; + url: string; + status: string; + progress?: number; + destination?: string; + createdAt?: string; + completedAt?: string | null; + }>; + }>("/jobs", { + params: { status: args.status === "all" ? undefined : args.status, limit: args.limit }, + }); + return { jobs: data.jobs ?? [] }; + }, + }, + { + name: "streamvault_add_download", + description: "Queue a new download by URL.", + inputSchema: AddDownloadSchema, + handler: async (raw) => { + const args = AddDownloadSchema.parse(raw); + const data = await sv<{ id: string | number; status: string }>("/jobs", { + method: "POST", + body: { + url: args.url, + destination: args.destination, + priority: args.priority, + }, + }); + return { jobId: data.id, status: data.status }; + }, + }, + { + name: "streamvault_get_job_status", + description: "Detail for a single download job.", + inputSchema: JobRefSchema, + handler: async (raw) => { + const args = JobRefSchema.parse(raw); + const data = await sv<{ + id: string | number; + url: string; + status: string; + progress?: number; + destination?: string; + bytesTotal?: number; + bytesDownloaded?: number; + error?: string; + createdAt?: string; + completedAt?: string | null; + }>(`/jobs/${args.jobId}`); + return data; + }, + }, + { + name: "streamvault_cancel_job", + description: "Cancel or remove a download job. Running jobs are aborted; completed jobs are removed from history.", + inputSchema: JobRefSchema, + handler: async (raw) => { + const args = JobRefSchema.parse(raw); + await sv(`/jobs/${args.jobId}`, { method: "DELETE" }); + return { jobId: args.jobId, cancelled: true }; + }, + }, + ], +}; + +export default plugin; diff --git a/src/plugins/unifi/index.ts b/src/plugins/unifi/index.ts new file mode 100644 index 0000000..a2542c0 --- /dev/null +++ b/src/plugins/unifi/index.ts @@ -0,0 +1,190 @@ +import { z } from "zod"; +import { log } from "../../logger.js"; +import { httpRequest, errString } from "../../util/http.js"; +import type { MCPPlugin } from "../../types/plugin.js"; + +// UniFi Access exposes a developer REST API. Auth is a Bearer token generated +// from the UniFi Access UI: Settings → Security → API tokens. +// Reference: https://help.ui.com/hc/en-us/articles/24566236795031 +const config = { + host: process.env.UNIFI_HOST?.trim() || "", + apiKey: process.env.UNIFI_API_KEY?.trim() ?? "", + siteId: process.env.UNIFI_SITE_ID?.trim() ?? "", +}; + +function authHeaders(): Record { + return config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}; +} + +async function unifi(path: string, init: { method?: string; body?: unknown; params?: Record } = {}): Promise { + return httpRequest(config.host, path, { + method: init.method, + body: init.body, + params: init.params, + headers: authHeaders(), + }); +} + +// ---------- Schemas ---------- + +const ListEventsSchema = z.object({ + limit: z.number().int().min(1).max(500).default(50), + sinceMinutes: z.number().int().min(1).max(10_080).optional().describe("Only return events from the last N minutes"), +}); + +const ListUsersSchema = z.object({ + limit: z.number().int().min(1).max(500).default(100), +}); + +const DoorRefSchema = z.object({ + doorId: z.string().min(1).optional().describe("Specific door ID; omit to return all doors"), +}); + +const NoArgsSchema = z.object({}); + +// ---------- Plugin ---------- + +const plugin: MCPPlugin = { + name: "unifi", + version: "0.1.0", + description: "UniFi Access — door events, users, lock state, sites", + minGatewayVersion: "0.1.0", + + async onLoad() { + if (!config.host || !config.apiKey) { + log.warn("unifi_not_configured", { + hint: "UNIFI_HOST and UNIFI_API_KEY required", + }); + return; + } + try { + const data = await unifi<{ data?: unknown[] }>("/api/v1/developer/doors"); + log.info("unifi_connected", { host: config.host, doors: data.data?.length ?? 0 }); + } catch (err) { + log.error("unifi_connect_failed", { err: errString(err) }); + } + }, + + tools: [ + { + name: "unifi_list_access_events", + description: "Recent badge/door access events (door openings, denials, etc.).", + inputSchema: ListEventsSchema, + handler: async (raw) => { + const args = ListEventsSchema.parse(raw); + const sinceMs = args.sinceMinutes ? Date.now() - args.sinceMinutes * 60_000 : undefined; + const data = await unifi<{ + data: Array<{ + id: string; + actor?: { display_name?: string; alternate_id?: string }; + target?: Array<{ display_name?: string; type?: string }>; + event?: { type?: string; result?: string; published?: number }; + timestamp?: number; + }>; + }>("/api/v1/developer/system_log/door_openings", { + params: { page_size: args.limit, since: sinceMs }, + }); + return { + events: (data.data ?? []).map((e) => ({ + id: e.id, + actor: e.actor?.display_name ?? e.actor?.alternate_id ?? "unknown", + door: e.target?.find((t) => t.type === "door")?.display_name, + type: e.event?.type, + result: e.event?.result, + timestamp: e.timestamp ?? e.event?.published, + })), + }; + }, + }, + { + name: "unifi_list_users", + description: "Access users with their display names and credentials count.", + inputSchema: ListUsersSchema, + handler: async (raw) => { + const args = ListUsersSchema.parse(raw); + const data = await unifi<{ + data: Array<{ + id: string; + first_name?: string; + last_name?: string; + email?: string; + employee_number?: string; + status?: string; + nfc_cards?: unknown[]; + }>; + }>("/api/v1/developer/users", { params: { page_size: args.limit } }); + return { + users: (data.data ?? []).map((u) => ({ + id: u.id, + name: [u.first_name, u.last_name].filter(Boolean).join(" ") || u.email || u.id, + email: u.email, + employeeNumber: u.employee_number, + status: u.status, + credentialCount: u.nfc_cards?.length ?? 0, + })), + }; + }, + }, + { + name: "unifi_get_door_status", + description: + "Lock state per door. Pass doorId for a specific door, or omit to get every door.", + inputSchema: DoorRefSchema, + handler: async (raw) => { + const args = DoorRefSchema.parse(raw); + const path = args.doorId + ? `/api/v1/developer/doors/${args.doorId}` + : `/api/v1/developer/doors`; + const data = await unifi<{ + data: + | Array<{ + id: string; + name?: string; + full_name?: string; + door_lock_relay_status?: string; + door_position_status?: string; + is_bind_hub?: boolean; + }> + | { + id: string; + name?: string; + full_name?: string; + door_lock_relay_status?: string; + door_position_status?: string; + is_bind_hub?: boolean; + }; + }>(path); + const doors = Array.isArray(data.data) ? data.data : [data.data]; + return { + doors: doors.map((d) => ({ + id: d.id, + name: d.full_name ?? d.name, + lockState: d.door_lock_relay_status, + position: d.door_position_status, + online: d.is_bind_hub, + })), + }; + }, + }, + { + name: "unifi_list_sites", + description: "List managed UniFi Access sites/locations.", + inputSchema: NoArgsSchema, + handler: async () => { + // UniFi Access groups doors by location; expose those as "sites". + const data = await unifi<{ + data: Array<{ id: string; name?: string; full_name?: string; door_count?: number }>; + }>("/api/v1/developer/locations"); + return { + sites: (data.data ?? []).map((s) => ({ + id: s.id, + name: s.full_name ?? s.name, + doorCount: s.door_count, + })), + }; + }, + }, + ], +}; + +export default plugin;