phase 2-3

This commit is contained in:
2026-05-09 22:59:43 -05:00
parent b4ac3b9968
commit fe3b555202
11 changed files with 1075 additions and 3 deletions
+11
View File
@@ -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.
+4
View File
@@ -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
+8 -2
View File
@@ -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
+5 -1
View File
@@ -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"]
+4
View File
@@ -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",
+167
View File
@@ -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;
+218
View File
@@ -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<ResolvedContainer> {
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;
+165
View File
@@ -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;
+157
View File
@@ -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<string, string> {
return config.token ? { Authorization: `Bearer ${config.token}` } : {};
}
async function rm<T>(
path: string,
init: { method?: string; body?: unknown; params?: Record<string, string | number | undefined> } = {}
): Promise<T> {
return httpRequest<T>(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<unknown>("/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;
+146
View File
@@ -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<string, string> {
return config.token ? { Authorization: `Bearer ${config.token}` } : {};
}
async function sv<T>(
path: string,
init: { method?: string; body?: unknown; params?: Record<string, string | number | undefined> } = {}
): Promise<T> {
return httpRequest<T>(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<unknown>("/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<unknown>(`/jobs/${args.jobId}`, { method: "DELETE" });
return { jobId: args.jobId, cancelled: true };
},
},
],
};
export default plugin;
+190
View File
@@ -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<string, string> {
return config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {};
}
async function unifi<T>(path: string, init: { method?: string; body?: unknown; params?: Record<string, string | number | boolean | undefined> } = {}): Promise<T> {
return httpRequest<T>(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;