Spaces:
Runtime error
Runtime error
File size: 7,273 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | /**
* MCP Plugin Tools — 8 tools for plugin management.
*
* @module mcp-server/tools/pluginTools
*/
import { z } from "zod";
import { resolve, normalize, isAbsolute } from "path";
import { listPlugins, getPluginByName, updatePluginConfig } from "../../../src/lib/db/plugins";
import { pluginManager } from "../../../src/lib/plugins/manager";
import { validatePluginConfig, type ConfigField } from "../../../src/lib/plugins/manifest";
/**
* Validate a path is safe for plugin installation.
* Prevents directory traversal and null byte injection.
*/
function validatePluginPath(path: string): string {
// Reject null bytes
if (path.includes("\0")) {
throw new Error("Invalid path: contains null bytes");
}
// Must be absolute
if (!isAbsolute(path)) {
throw new Error("Path must be absolute");
}
// Normalize and resolve to prevent traversal
const normalized = normalize(resolve(path));
// Reject paths with traversal patterns
if (normalized.includes("..") || normalized.includes("~")) {
throw new Error("Invalid path: directory traversal detected");
}
return normalized;
}
export const pluginTools = [
{
name: "plugin_list",
description: "List all installed plugins with their status, hooks, and metadata.",
scopes: ["read:plugins"],
inputSchema: z.object({
status: z
.enum(["installed", "active", "inactive", "error"])
.optional()
.describe("Filter by plugin status"),
}),
handler: async (args: { status?: string }) => {
const plugins = listPlugins(args.status as any);
return {
plugins: plugins.map((p) => ({
name: p.name,
version: p.version,
description: p.description,
status: p.status,
enabled: p.enabled === 1,
hooks: JSON.parse(p.hooks || "[]"),
permissions: JSON.parse(p.permissions || "[]"),
installedAt: p.installedAt,
activatedAt: p.activatedAt,
})),
};
},
},
{
name: "plugin_install",
description: "Install a plugin from a local directory path.",
scopes: ["write:plugins"],
inputSchema: z.object({
path: z.string().describe("Absolute path to the plugin directory containing plugin.json"),
}),
handler: async (args: { path: string }) => {
const safePath = validatePluginPath(args.path);
const plugin = await pluginManager.install(safePath);
return {
success: true,
plugin: {
name: plugin.name,
version: plugin.version,
status: plugin.status,
},
};
},
},
{
name: "plugin_activate",
description: "Activate an installed plugin (loads hooks into the request pipeline).",
scopes: ["write:plugins"],
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
try {
await pluginManager.activate(args.name);
return { success: true, message: `Plugin '${args.name}' activated` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
{
name: "plugin_deactivate",
description: "Deactivate an active plugin (unloads hooks from the request pipeline).",
scopes: ["write:plugins"],
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
try {
await pluginManager.deactivate(args.name);
return { success: true, message: `Plugin '${args.name}' deactivated` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
{
name: "plugin_uninstall",
description: "Uninstall a plugin (deactivates, removes files, removes from DB).",
scopes: ["write:plugins"],
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
try {
await pluginManager.uninstall(args.name);
return { success: true, message: `Plugin '${args.name}' uninstalled` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
{
name: "plugin_configure",
description: "Get or update a plugin's configuration.",
scopes: ["write:plugins"],
inputSchema: z.object({
name: z.string().describe("Plugin name"),
config: z
.record(z.string(), z.unknown())
.optional()
.describe("New config values to merge (omit to just read current config)"),
}),
handler: async (args: { name: string; config?: Record<string, unknown> }) => {
const plugin = getPluginByName(args.name);
if (!plugin) return { success: false, error: `Plugin '${args.name}' not found` };
if (args.config) {
const current = JSON.parse(plugin.config || "{}");
const merged = { ...current, ...args.config };
// Validate merged config against configSchema if the plugin declares one
const rawSchema = JSON.parse(plugin.configSchema || "{}") as Record<string, ConfigField>;
if (Object.keys(rawSchema).length > 0) {
const validation = validatePluginConfig(merged, rawSchema);
if (!validation.valid) {
// Return a generic message — do NOT leak raw field-level detail externally
return { success: false, error: "Config validation failed: one or more values are invalid" };
}
}
updatePluginConfig(args.name, merged);
return { success: true, config: merged };
}
return {
config: JSON.parse(plugin.config || "{}"),
configSchema: JSON.parse(plugin.configSchema || "{}"),
};
},
},
{
name: "plugin_executions",
description: "View plugin execution metrics (from plugin_analytics table).",
scopes: ["read:plugins"],
inputSchema: z.object({
name: z.string().optional().describe("Filter by plugin name"),
limit: z.number().min(1).max(100).default(20).describe("Max results to return"),
}),
handler: async (args: { name?: string; limit?: number }) => {
const { getPluginAnalytics, getPluginAnalyticsSummary } = await import(
"../../../src/lib/db/plugins"
);
const limit = args.limit || 20;
if (args.name) {
const rows = getPluginAnalytics(args.name).slice(0, limit);
return { metrics: rows };
}
// No name filter: return all plugins' summaries
const allPlugins = listPlugins();
const metrics = allPlugins.slice(0, limit).map((p) => getPluginAnalyticsSummary(p.name));
return { metrics };
},
},
{
name: "plugin_scan",
description: "Scan the plugin directory for new plugins and sync with DB.",
scopes: ["write:plugins"],
inputSchema: z.object({}),
handler: async () => {
const result = await pluginManager.scan();
return {
discovered: result.discovered,
errors: result.errors,
};
},
},
];
|