Skip to content
59 changes: 59 additions & 0 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1525,6 +1525,51 @@ export async function handleChatCore({
};
let tokensCompressed: number | null = null;
body = injectSystemPrompt(body);
// ── Plugin onRequest hook ──
// Dynamic import cached by Node.js after first call — minimal overhead
try {
const { runOnRequest } = await import("@/lib/plugins/index");
const pluginCtx = {
requestId: traceId,
body,
model,
provider,
apiKeyInfo,
metadata: {},
};
const pluginResult = await runOnRequest(pluginCtx);
if (pluginResult?.blocked) {
log?.info?.("PLUGIN", `Request blocked by plugin`);
return {
success: false,
status: 403,
error: "Request blocked by plugin",
response: pluginResult.response
? new Response(JSON.stringify(pluginResult.response), {
status: 403,
headers: { "Content-Type": "application/json" },
})
: new Response(
JSON.stringify({
error: { message: "Request blocked by plugin", type: "plugin_block" },
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
),
};
}
if (pluginResult?.ctx && "body" in pluginResult.ctx) {
body = (pluginResult.ctx as unknown as Record<string, unknown>).body;
}
} catch (pluginErr) {
log?.debug?.(
"PLUGIN",
`onRequest hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}`
);
}

type EffectiveServiceTier = "standard" | CodexServiceTier;
let effectiveServiceTier: EffectiveServiceTier = "standard";
const resolveEffectiveServiceTier = (requestBody?: unknown): EffectiveServiceTier => {
Expand Down Expand Up @@ -3199,6 +3244,20 @@ export async function handleChatCore({
);
}
} catch (error) {
// ── Plugin onError hook ──
try {
const { runOnError } = await import("@/lib/plugins/index");
await runOnError(
{ requestId: traceId, body, model, provider, apiKeyInfo, metadata: {} },
error instanceof Error ? error : new Error(String(error))
);
} catch (pluginErr) {
log?.debug?.(
"PLUGIN",
`onError hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}`
);
}

const parsedStatus = Number(error?.statusCode);
const statusCode =
Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599
Expand Down
27 changes: 26 additions & 1 deletion open-sse/mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
} from "./tools/advancedTools.ts";
import { memoryTools } from "./tools/memoryTools.ts";
import { skillTools } from "./tools/skillTools.ts";
import { pluginTools } from "./tools/pluginTools.ts";
import { compressionTools } from "./tools/compressionTools.ts";
import { gamificationTools } from "./tools/gamificationTools.ts";
import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts";
Expand Down Expand Up @@ -102,7 +103,8 @@ const TOTAL_MCP_TOOL_COUNT =
MCP_TOOLS.length +
Object.keys(memoryTools).length +
Object.keys(skillTools).length +
gamificationTools.length;
gamificationTools.length +
pluginTools.length;

type JsonRecord = Record<string, unknown>;

Expand Down Expand Up @@ -1003,6 +1005,29 @@ export function createMcpServer(): McpServer {
);
});

// ── Plugin Tools ──────────────────────────────
pluginTools.forEach((toolDef) => {
server.registerTool(
toolDef.name,
{
description: toolDef.description,
// @ts-ignore: dynamic zod access
inputSchema: toolDef.inputSchema,
},
withScopeEnforcement(toolDef.name, async (args) => {
try {
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
// @ts-ignore: handler expected specific object
const result = await toolDef.handler(parsedArgs);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
})
);
});

// ── Compression Tools ─────────────────────────
Object.values(compressionTools).forEach((toolDef) => {
server.registerTool(
Expand Down
149 changes: 149 additions & 0 deletions open-sse/mcp-server/tools/pluginTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* MCP Plugin Tools — 8 tools for plugin management.
*
* @module mcp-server/tools/pluginTools
*/

import { z } from "zod";
import { listPlugins, getPluginByName, updatePluginConfig } from "../../../src/lib/db/plugins";
import { pluginManager } from "../../../src/lib/plugins/manager";

export const pluginTools = [
{
name: "plugin_list",
description: "List all installed plugins with their status, hooks, and metadata.",
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.",
inputSchema: z.object({
path: z.string().describe("Absolute path to the plugin directory containing plugin.json"),
}),
handler: async (args: { path: string }) => {
const plugin = await pluginManager.install(args.path);
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).",
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.activate(args.name);
return { success: true, message: `Plugin '${args.name}' activated` };
},
},

{
name: "plugin_deactivate",
description: "Deactivate an active plugin (unloads hooks from the request pipeline).",
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.deactivate(args.name);
return { success: true, message: `Plugin '${args.name}' deactivated` };
},
},

{
name: "plugin_uninstall",
description: "Uninstall a plugin (deactivates, removes files, removes from DB).",
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.uninstall(args.name);
return { success: true, message: `Plugin '${args.name}' uninstalled` };
},
},

{
name: "plugin_configure",
description: "Get or update a plugin's configuration.",
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) throw new Error(`Plugin '${args.name}' not found`);

if (args.config) {
const current = JSON.parse(plugin.config || "{}");
const merged = { ...current, ...args.config };
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 history (from skill_executions table).",
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 }) => {
// Plugin executions are tracked via the skills system
const { skillExecutor } = await import("../../../src/lib/skills/executor");
const executions = skillExecutor.listExecutions(undefined, args.limit || 20);
return { executions };
},
},

{
name: "plugin_scan",
description: "Scan the plugin directory for new plugins and sync with DB.",
inputSchema: z.object({}),
handler: async () => {
const result = await pluginManager.scan();
return {
discovered: result.discovered,
errors: result.errors,
};
},
},
];
30 changes: 30 additions & 0 deletions src/app/api/plugins/[name]/activate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

export async function OPTIONS() {
return handleCorsOptions();
}

/**
* POST /api/plugins/[name]/activate — Activate a plugin
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { name } = await params;

try {
await pluginManager.activate(name);
return NextResponse.json(
{ success: true, message: `Plugin '${name}' activated` },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
}
}
Loading