phase 2-3
This commit is contained in:
@@ -13,7 +13,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Health and plugin-list endpoints.
|
- 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 — `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 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`.
|
- 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
|
### 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.
|
- 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.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
FROM node:20-alpine AS builder
|
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
|
WORKDIR /app
|
||||||
COPY package*.json tsconfig.json ./
|
COPY package*.json tsconfig.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -8,6 +10,8 @@ RUN npx prisma generate
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
# Runtime libs for native modules
|
||||||
|
RUN apk add --no-cache libstdc++
|
||||||
RUN addgroup -S mcp && adduser -S mcp -G mcp
|
RUN addgroup -S mcp && adduser -S mcp -G mcp
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|||||||
@@ -97,8 +97,14 @@ totalmcp/
|
|||||||
│ │ └── sse.ts # Legacy SSE transport
|
│ │ └── sse.ts # Legacy SSE transport
|
||||||
│ ├── util/http.ts # Shared HTTP helper (timeouts, JSON, errors)
|
│ ├── util/http.ts # Shared HTTP helper (timeouts, JSON, errors)
|
||||||
│ └── plugins/
|
│ └── plugins/
|
||||||
│ ├── gitea/index.ts # Phase 1 — Gitea REST API
|
│ ├── gitea/index.ts # Phase 1 — Gitea REST API
|
||||||
│ └── unraid/index.ts # Phase 1 — Unraid GraphQL 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
|
├── prisma/schema.prisma # Event log schema
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
|||||||
+5
-1
@@ -8,7 +8,11 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8811/health || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8811/health || exit 1"]
|
||||||
|
|||||||
@@ -18,13 +18,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"better-sqlite3": "^11.5.0",
|
||||||
"chokidar": "^4.0.1",
|
"chokidar": "^4.0.1",
|
||||||
|
"dockerode": "^4.0.2",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^5.0.1",
|
"express": "^5.0.1",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.23.5"
|
"zod-to-json-schema": "^3.23.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/dockerode": "^3.3.31",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user