217 lines
7.1 KiB
TypeScript
217 lines
7.1 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
},
|
|
},
|
|
];
|